import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; import { columnInputFor, columnRebar } from '../src/core/rebar/column'; import { multicolCopingBars } from '../src/core/rebar/multicol-coping'; import { rebarSummary } from '../src/core/rebar/summary'; import { castRebarLayout } from '../src/core/rebar/cast-layout'; import { buildRebar3D } from '../src/view/rebar3d'; import { loadP1 } from './helpers'; describe('multi-column drawing corrections', () => { it('excludes C5/C6 from the multi-column coping bars (대안A: the 1106 전용 삽도 has no C5/C6)', () => { for (const [type, columnCount] of [['2주형', 2], ['3주형', 3]] as const) { const model = buildPier({ ...loadP1(), type, columnCount, foundationType: '말뚝기초', groundEl: 30, pileLengthMm: 24000 }); const names = new Set(multicolCopingBars(model).map(b => b.name)); expect(names.has('C5')).toBe(false); expect(names.has('C6')).toBe(false); for (const name of ['C1', 'C2', 'C3', 'C4', 'C7', 'C8']) expect(names.has(name), `${type} ${name}`).toBe(true); } }); for (const [type, columnCount] of [['2주형', 2], ['3주형', 3]] as const) { for (const foundationType of ['말뚝기초', '현장타설말뚝'] as const) { it(`${type} ${foundationType}: every coping path stays inside the concrete lower boundary`, () => { const model = buildPier({ ...loadP1(), type, columnCount, foundationType, groundEl: 30, pileLengthMm: 24000 }); const bars = multicolCopingBars(model); expect(bars.length).toBeGreaterThan(100); const polygon = model.copingParts[0].profile; // Independent intersection against the concrete polygon; include all haunch vertices. const lower = (x: number) => Math.min(...polygon.flatMap((a, i) => { const b = polygon[(i + 1) % polygon.length]; return Math.abs(a[0] - b[0]) > 1e-9 && x >= Math.min(a[0], b[0]) - 1e-9 && x <= Math.max(a[0], b[0]) + 1e-9 ? [a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0])] : []; })); for (const bar of bars.filter(b => !['C15', 'C16'].includes(b.name))) for (let i = 1; i < bar.pts.length; i++) { const a = bar.pts[i - 1], b = bar.pts[i]; const ts = [...Array.from({ length: 21 }, (_, j) => j / 20), ...polygon.map(p => (p[0] - a[0]) / (b[0] - a[0])).filter(t => Number.isFinite(t) && t >= 0 && t <= 1)]; for (const t of ts) { const x = a[0] + (b[0] - a[0]) * t, y = a[1] + (b[1] - a[1]) * t; expect(y - lower(x), `${bar.name} at ${x}`).toBeGreaterThanOrEqual(0.099); } } const summary = rebarSummary(model); for (const row of summary.rows.filter(r => r.member === 'coping')) { const pieces = bars.filter(b => b.name === row.name); expect(row.count).toBe(pieces.length); expect(row.pieces!.reduce((s, p) => s + p.count * p.length, 0)).toBeCloseTo(pieces.reduce((s, p) => s + p.length, 0), 6); } const scene = buildRebar3D(model); for (const name of ['C7', 'C8', 'C10', 'C12', 'C14']) { const expected = bars.filter(b => b.name === name); expect(scene.records.filter(r => r.name === name).every(r => r.dMm === expected[0].diameterMm)).toBe(true); expect(scene.records.some(r => r.name === name)).toBe(true); } scene.group.traverse(o => { const mesh = o as import('three').Mesh; mesh.geometry?.dispose(); const m = mesh.material; if (Array.isArray(m)) m.forEach(v => v.dispose()); else m?.dispose(); }); }, 15000); } it(`${type}: physical column pieces and local confinement height agree`, () => { const model = buildPier({ ...loadP1(), type, columnCount }); const rows = rebarSummary(model).rows; for (const name of ['P1', 'P2']) { const row = rows.find(r => r.name === name)!; expect(row.pieces!.reduce((s, p) => s + p.count, 0)).toBe(row.count); const key = name === 'P1' ? 'p1Pieces' : 'p2Pieces'; const total = model.columns.reduce((s, _, i) => { const input = columnInputFor(model, undefined, i); return s + input.nDiv * columnRebar(input)[key].reduce((a, b) => a + b, 0); }, 0); expect(row.pieces!.reduce((s, p) => s + p.count * p.length, 0)).toBeCloseTo(total, 6); } for (const [i, column] of model.columns.entries()) { const input = columnInputFor(model, undefined, i), detail = columnRebar(input); const segment = model.segments.find(s => column.s >= s.s0 && column.s <= s.s1)!; expect(column.bottom + detail.zTopT / 1000).toBeCloseTo((column.top + segment.top) / 2, 3); expect(detail.p5.filter(z => z > input.Hc - input.D).length).toBeGreaterThan(10); expect(Math.max(...detail.p5)).toBeLessThanOrEqual(detail.zTopT); expect(detail.p3.every(z => z > detail.lE && z < input.Hc - detail.lE)).toBe(true); } }); } it('short columns merge dense ends without duplicate or out-of-range rings', () => { const detail = columnRebar({ ...columnInputFor(buildPier({ ...loadP1(), type: '2주형', columnCount: 2 })), Hc: 1000 }); expect(detail.p3).toEqual([]); expect(new Set(detail.p5).size).toBe(detail.p5.length); expect(detail.p5.every(z => z >= 0 && z <= detail.zTopT)).toBe(true); }); it('cast upper dense zone includes the coping half-height and clips at ground', () => { const model = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, foundationType: '현장타설말뚝', groundEl: 40, pileLengthMm: 24000, castInPlaceRebar: { column: { endZoneLengthMm: 3000 } } }); const layout = castRebarLayout(model); for (let i = 0; i < 3; i++) { const ties = layout.bars.filter(b => b.columnIndex === i && b.name === '기둥-P5'); expect(ties.length).toBeGreaterThan(10); expect(ties.some(b => b.pts[0][1] > model.columns[i].top)).toBe(true); expect(ties.every(b => b.pts[0][1] >= 40)).toBe(true); const pileTies = layout.bars.filter(b => b.columnIndex === i && b.name === '말뚝-P5'); expect(pileTies.some(b => b.pts[0][1] >= model.columns[i].top - 3 && b.pts[0][1] < 40)).toBe(true); } }); it('drawing defaults yield to saved overrides and leave the T-shaped defaults unchanged', () => { const multi = buildPier({ ...loadP1(), type: '2주형', columnCount: 2 }); expect(multi.copingRebar).toMatchObject({ spacingS: 300, sideSpacing: 200, c14UpperSpacing: 600 }); const edited = buildPier({ ...loadP1(), type: '2주형', columnCount: 2, copingRebar: { spacingS: 275 } }); expect(edited.copingRebar.spacingS).toBe(275); expect(buildPier(loadP1()).copingRebar.spacingS).toBe(250); expect(() => multicolCopingBars({ ...multi, copingRebar: { ...multi.copingRebar, sideSpacing: 0 } })).toThrow('양수'); }); });