diff --git a/scripts/verify-multicol-coping.cjs b/scripts/verify-multicol-coping.cjs index 4d42160..733e4da 100644 --- a/scripts/verify-multicol-coping.cjs +++ b/scripts/verify-multicol-coping.cjs @@ -36,7 +36,8 @@ fs.mkdirSync(out, { recursive: true }); const buf = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); const base = parseWorkbook(XLSX.read(buf)).piers.find(p => p.name === 'P1'); if (!base) throw new Error('P1 sample missing'); - const m = buildPier({ ...base, type: ${JSON.stringify(type)}, columnCount: ${type === '2주형' ? 2 : 3} }); + // 3주형 needs a 20 m coping for the 오목형 spans (P1 length leaves no flat part, RB-M01). + const m = buildPier({ ...base, ...(${type === '3주형'} ? { copingLengthMm: 20000 } : {}), type: ${JSON.stringify(type)}, columnCount: ${type === '2주형' ? 2 : 3} }); const built = buildPierScene(m); const scene = new THREE.Scene(); scene.background = new THREE.Color(0xe9eef4); const group = new THREE.Group(); @@ -58,11 +59,11 @@ fs.mkdirSync(out, { recursive: true }); document.body.replaceChildren(renderer.domElement); document.body.style.margin = '0'; renderer.render(scene,camera); const profile = m.copingParts[0].profile; - const raised = profile.filter(([,z]) => Math.abs(z-m.levelBottom-0.3)<1e-8).length; + const raised = profile.filter(([,z]) => Math.abs(z-m.levelBottom-0.5)<1e-8).length; if (raised !== (m.columns.length-1)*2) throw new Error('Raised span count mismatch'); if (Math.abs(Math.min(...profile.map(p=>p[1]))-m.levelBottom)>1e-8) throw new Error('Underside below column Level'); if (!m.columns.every(c=>c.top===m.levelBottom)) throw new Error('Column connection changed'); - return { type: ${JSON.stringify(type)}, raisedVertices: raised, levelBottom:m.levelBottom, interiorBottom:m.levelBottom+0.3, columns:m.columns.length, profile, screenshot:renderer.domElement.toDataURL('image/png') }; + return { type: ${JSON.stringify(type)}, raisedVertices: raised, levelBottom:m.levelBottom, interiorBottom:m.levelBottom+0.5, columns:m.columns.length, profile, screenshot:renderer.domElement.toDataURL('image/png') }; })() ` }); if (res.exceptionDetails) throw new Error(res.exceptionDetails.exception?.description || res.exceptionDetails.text); diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 3f2045b..71d6113 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -10,14 +10,25 @@ import { SOLPLATE_RULE_DEFAULT, solplatePlan } from './solplate-rule'; import { type CopingGeom, P1_COPING_GEOM } from './coping-geom'; import { castShape } from './cast-in-place-shape'; +/** Default Level width (mm) when no input: T형 2100 (P1), 2·3주형 D + 200 (spec §3, 2주형 DWG 2026-11-06). */ +export function defaultLevelWidthMm(type: PierInput['type'], columnDiameterMm: number): number { + return type === 'T형' ? P1_COPING_GEOM.levelWidth : columnDiameterMm + DIM.multiLevelExtra * 1000; +} + /** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */ export const DIM = { columnDiameter: 2.0, copingH1: 1.0, copingH2: 1.5, levelWidth: 2.1, - /** Multi-column coping: underside between columns sits this far above Level, with 45° haunches of the same size (spec §4.4, Q3). */ - haunch: 0.3, + /** + * Multi-column coping, 오목형 underside (spec §4.4, 3주형형상배근.md §2.2, 2주형 DWG 2026-11-06): + * from each Level end it rises concaveRise over the horizontal haunch length concaveHaunch, stays flat, and comes back down. + */ + concaveRise: 0.5, + concaveHaunch: 1.5, + /** Multi-column Level width = D + this (spec §3, 2주형 DWG). T형 keeps levelWidth (= D + 100 for P1). */ + multiLevelExtra: 0.2, padThickness: 0.05, padSize: 1.0, blockSize: 1.2, @@ -251,9 +262,9 @@ export function buildPier(input: PierInput): PierModel { // Coping heights and Level width (m): inputs, with the P1 values when absent (1단계). const copingH1 = (input.copingH1Mm ?? P1_COPING_GEOM.h1) / 1000; const copingH2 = (input.copingH2Mm ?? P1_COPING_GEOM.h2) / 1000; - const levelW = (input.levelWidthMm ?? P1_COPING_GEOM.levelWidth) / 1000; // Column diameter (m): the input, with 2.0 m when absent (승인 2026-10-23). const colD = (input.columnDiameterMm ?? DIM.columnDiameter * 1000) / 1000; + const levelW = (input.levelWidthMm ?? defaultLevelWidthMm(input.type, colD * 1000)) / 1000; const planHalfB = B / 2; const ip1v: Vec2 = { e: ip1.x, n: ip1.y }; const ip2v: Vec2 = { e: ip2.x, n: ip2.y }; @@ -346,7 +357,8 @@ export function buildPier(input: PierInput): PierModel { const levelEnd = Math.max(...colG) + levelW / 2; if (levelStart < 0 || levelEnd > L) throw new Error('코핑 길이가 Level 구간보다 짧습니다.'); - // Underside from the right end to the left end. Between columns (2·3주형) it rises 300 above Level with 300 × 300 haunches (spec §4.4, Q3). + // Underside from the right end to the left end. Between columns (2·3주형) it is 오목형: from each Level end it rises + // h_r (500) over the horizontal haunch l_h (1500), stays flat, and comes back down (spec §4.4, 3주형형상배근.md §2.2). const underside: [number, number][] = [[L, endBottom]]; const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - levelW / 2, s + levelW / 2] as const); levelZones.forEach(([zoneStart, zoneEnd], k) => { @@ -354,9 +366,12 @@ export function buildPier(input: PierInput): PierModel { const next = levelZones[k + 1]; if (!next) return; const gap = zoneStart - next[1]; - if (gap <= 2 * DIM.haunch) throw new Error('기둥 사이 간격이 헌치(300 × 2)보다 좁아 코핑 하면을 만들 수 없습니다.'); - const raisedEl = levelBottom + DIM.haunch; - underside.push([zoneStart - DIM.haunch, raisedEl], [next[1] + DIM.haunch, raisedEl], [next[1], levelBottom]); + // RB-M01: no flat part left between the two haunches. + if (gap <= 2 * DIM.concaveHaunch + 1e-9) { + throw new Error(`기둥 사이 간격 ${Math.round(gap * 1000)} mm가 오목형 헌치(${DIM.concaveHaunch * 1000} × 2)보다 짧아 코핑 하면을 만들 수 없습니다 (RB-M01).`); + } + const raisedEl = levelBottom + DIM.concaveRise; + underside.push([zoneStart - DIM.concaveHaunch, raisedEl], [next[1] + DIM.concaveHaunch, raisedEl], [next[1], levelBottom]); }); // Elevation outline in coping coordinates for one row of tops, closed by the shared underside. diff --git a/src/main.ts b/src/main.ts index d36faa3..a6a16c6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -82,7 +82,7 @@ import { applyCopy, COPY_ITEMS, planCopy, type CopyItem } from './core/copy'; import { copyCandidates, copyItemsFor, copyItemsWithOverrides, hasCopyItems, selectedTargets, type CopyCandidate } from './core/copy-targets'; import { quantityTargetNames } from './core/quantity-scope'; import { aggregateQuantities, type QuantityTotal } from './core/quantity-total'; -import { pileGeometry } from './core/geometry'; +import { defaultLevelWidthMm, pileGeometry } from './core/geometry'; import { rebarSummary, type RebarRow } from './core/rebar/summary'; import { shearSegments } from './core/rebar/coping'; import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d'; @@ -388,7 +388,7 @@ function withCopingDefaults(input: PierInput): PierInput { pileThicknessMm: input.pileThicknessMm ?? 12, copingH1Mm: input.copingH1Mm ?? P1_COPING_GEOM.h1, copingH2Mm: input.copingH2Mm ?? P1_COPING_GEOM.h2, - levelWidthMm: input.levelWidthMm ?? P1_COPING_GEOM.levelWidth, + levelWidthMm: input.levelWidthMm ?? defaultLevelWidthMm(input.type, input.columnDiameterMm ?? 2000), }; } diff --git a/tests/cast-in-place.test.ts b/tests/cast-in-place.test.ts index bd2517c..bcb71cd 100644 --- a/tests/cast-in-place.test.ts +++ b/tests/cast-in-place.test.ts @@ -22,7 +22,8 @@ describe('cast-in-place independent rendering and incomplete quantities', () => for (const [type, columnCount] of [['T형', 1], ['2주형', 2], ['3주형', 3]] as const) { for (const foundationType of ['직접기초', '말뚝기초', '현장타설말뚝'] as const) { it(`${type} / ${foundationType}: concrete, rebar and summary remain usable`, () => { - const m = buildPier({ ...loadP1(), type, columnCount, foundationType }); + // 3주형 needs a coping long enough for the 오목형 spans (RB-M01 otherwise). + const m = buildPier({ ...loadP1(), ...(columnCount === 3 ? { copingLengthMm: 20000 } : {}), type, columnCount, foundationType }); const concrete = buildPierScene(m); expect(concrete.parts.coping.children.length).toBeGreaterThan(0); const rebar = buildRebar3D(m); diff --git a/tests/cast-layout.test.ts b/tests/cast-layout.test.ts index fa2abe0..50c3e6a 100644 --- a/tests/cast-layout.test.ts +++ b/tests/cast-layout.test.ts @@ -100,7 +100,7 @@ describe('dedicated cast-in-place physical reinforcement', () => { expect(result.bars.some(b=>b.name==='말뚝-P1')).toBe(true); }); it('repeats physical pieces per actual column without multiplying piece counts again', () => { - const one=layout(), three=layout({type:'3주형',columnCount:3}); + const one=layout(), three=layout({type:'3주형',columnCount:3,copingLengthMm:20000}); expect(three.bars.filter(b=>b.kind==='main')).toHaveLength(one.bars.filter(b=>b.kind==='main').length*3); expect(new Set(three.bars.map(b=>b.columnIndex)).size).toBe(3); }); diff --git a/tests/multicol-rebar.test.ts b/tests/multicol-rebar.test.ts index a287549..771d797 100644 --- a/tests/multicol-rebar.test.ts +++ b/tests/multicol-rebar.test.ts @@ -7,10 +7,13 @@ import { castRebarLayout } from '../src/core/rebar/cast-layout'; import { buildRebar3D } from '../src/view/rebar3d'; import { loadP1 } from './helpers'; +// 3주형 on the P1 sheet needs a longer coping: with L 12.166 m the columns are 3650 apart and leave no flat 오목 part (RB-M01). +const lengthFor = (columnCount: number) => (columnCount === 3 ? { copingLengthMm: 20000 } : {}); + 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 model = buildPier({ ...loadP1(), ...lengthFor(columnCount), 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); @@ -20,7 +23,7 @@ describe('multi-column drawing corrections', () => { 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 model = buildPier({ ...loadP1(), ...lengthFor(columnCount), type, columnCount, foundationType, groundEl: 30, pileLengthMm: 24000 }); const bars = multicolCopingBars(model); expect(bars.length).toBeGreaterThan(100); const polygon = model.copingParts[0].profile; @@ -54,7 +57,7 @@ describe('multi-column drawing corrections', () => { }, 15000); } it(`${type}: physical column pieces and local confinement height agree`, () => { - const model = buildPier({ ...loadP1(), type, columnCount }); + const model = buildPier({ ...loadP1(), ...lengthFor(columnCount), type, columnCount }); const rows = rebarSummary(model).rows; for (const name of ['P1', 'P2']) { const row = rows.find(r => r.name === name)!; @@ -83,7 +86,7 @@ describe('multi-column drawing corrections', () => { 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, + const model = buildPier({ ...loadP1(), copingLengthMm: 20000, type: '3주형', columnCount: 3, foundationType: '현장타설말뚝', groundEl: 40, pileLengthMm: 24000, castInPlaceRebar: { column: { endZoneLengthMm: 3000 } } }); const layout = castRebarLayout(model); for (let i = 0; i < 3; i++) { diff --git a/tests/rebar-multi.test.ts b/tests/rebar-multi.test.ts index 68c44da..4cc9739 100644 --- a/tests/rebar-multi.test.ts +++ b/tests/rebar-multi.test.ts @@ -4,9 +4,10 @@ import { shearBarsMulti } from '../src/core/rebar/coping'; import { rebarSummary } from '../src/core/rebar/summary'; import { loadP1 } from './helpers'; -// 2주형 and 3주형 on the P1 sheet with a 12 m coping (columns at L/5, 4L/5 and L/5, L/2, 4L/5). +// 2주형 on the P1 sheet with a 12 m coping (columns at L/5, 4L/5). 3주형 needs 20 m (columns 4 / 10 / 16): with 12 m the +// columns are 3600 apart and leave no flat 오목 part between the 1500 haunches (RB-M01). const two = buildPier({ ...loadP1(), type: '2주형', columnCount: 2, copingLengthMm: 12000 }); -const three = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLengthMm: 12000 }); +const three = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLengthMm: 20000 }); const count = (rows: ReturnType['rows'], name: string) => rows.find((r) => r.name === name)!.count; describe('다주형 철근 (2주형·3주형, 코핑철근.md §9)', () => { @@ -18,10 +19,11 @@ describe('다주형 철근 (2주형·3주형, 코핑철근.md §9)', () => { expect(count(rows, 'C9·C11 사이')).toBe(15 * 6); }); - it('3주형: C10 27 positions and two gaps of three, each with six physical pieces', () => { + it('3주형: C10 27 positions and two gaps of eleven, each with six physical pieces', () => { const rows = rebarSummary(three).rows; + // Per column 9 positions (±4 @300); between groups 8.8 − 5.2 = 3.6 m → floor(3600 / 300) = 12 bays → 11 positions. expect(count(rows, 'C10')).toBe(27 * 6); - expect(count(rows, 'C9·C11 사이')).toBe(6 * 6); + expect(count(rows, 'C9·C11 사이')).toBe(22 * 6); }); it('column bars repeat per column: P1 230 × 2 and 230 × 3', () => { diff --git a/tests/validate-and-foundation.test.ts b/tests/validate-and-foundation.test.ts index 63fcc71..4724b1a 100644 --- a/tests/validate-and-foundation.test.ts +++ b/tests/validate-and-foundation.test.ts @@ -82,27 +82,50 @@ describe('foundation types', () => { expect(m.piles[0].diameter).toBeCloseTo(0.6096, 6); }); - it('2주형: underside between columns rises 300 above Level; column tops and T형 stay at Level', () => { + it('2주형: underside between columns is 오목형 (500 above Level); column tops and T형 stay at Level', () => { const t = buildPier(loadP1()); - const raised = t.levelBottom + 0.3; + const raised = t.levelBottom + 0.5; expect(t.copingParts[0].profile.some(([, z]) => Math.abs(z - raised) < 1e-9)).toBe(false); + expect(t.copingGeom.levelWidth).toBe(2100); const two = buildPier({ ...loadP1(), type: '2주형', columnCount: 2 }); expect(two.copingParts[0].profile.some(([, z]) => Math.abs(z - raised) < 1e-9)).toBe(true); const outlineLow = Math.min(...two.copingParts[0].profile.map(([, z]) => z)); expect(outlineLow).toBeCloseTo(two.levelBottom, 9); expect(two.columns.every((c) => c.top === two.levelBottom)).toBe(true); + // Level width default for 2·3주형 = D + 200 (D 2000 when absent). + expect(two.copingGeom.levelWidth).toBe(2200); }); - it('3주형 builds with two gaps of haunches', () => { - const m = buildPier({ ...loadP1(), type: '3주형', columnCount: 3 }); - const raised = m.copingParts[0].profile.filter(([, z]) => Math.abs(z - (m.levelBottom + 0.3)) < 1e-9); + it('3주형 builds with two 오목형 spans', () => { + const m = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLengthMm: 20000 }); + const raised = m.copingParts[0].profile.filter(([, z]) => Math.abs(z - (m.levelBottom + 0.5)) < 1e-9); expect(raised).toHaveLength(4); }); + it('RB-M01: columns too close for two 1500 haunches → error', () => { + // P1 length 12.166 m as 3주형: columns L/5 · L/2 · 4L/5 are 3650 apart, minus Level 2200 = 1450 < 3000. + expect(() => buildPier({ ...loadP1(), type: '3주형', columnCount: 3 })).toThrow(/RB-M01/); + }); + + it('2주형 DWG dimensions (L 24200, D 3000): Level 3200, haunch 1500 rising 500 (3주형형상배근.md §2.2)', () => { + const m = buildPier({ ...loadP1(), type: '2주형', columnCount: 2, copingLengthMm: 24200, columnDiameterMm: 3000, copingH1Mm: 1500, copingH2Mm: 1500 }); + expect(m.copingGeom.levelWidth).toBe(3200); + const profile = m.copingParts[0].profile; + const has = (x: number, z: number) => profile.some(([s, el]) => Math.abs(s - x) < 1e-9 && Math.abs(el - z) < 1e-9); + const [c1, c2] = m.columns.map((c) => c.s); + // Level ends at column ± 1.6, the haunch reaches Level + 0.5 another 1.5 inward, then the flat 오목 part. + expect(has(c1 + 1.6, m.levelBottom)).toBe(true); + expect(has(c1 + 3.1, m.levelBottom + 0.5)).toBe(true); + expect(has(c2 - 3.1, m.levelBottom + 0.5)).toBe(true); + expect(has(c2 - 1.6, m.levelBottom)).toBe(true); + // Ends: taper from the end height (h1 1500 below the top) down to Level. + expect(has(0, m.levelBottom + 1.5)).toBe(true); + }); + for (const type of ['2주형', '3주형'] as const) { - for (const dims of [{}, { copingLengthMm: 15000, copingH1Mm: 1200, copingH2Mm: 1000, levelWidthMm: 2400 }]) { - it(`${type}: raised spans and 300 × 300 haunches follow every column, dimensions ${JSON.stringify(dims)}`, () => { + for (const dims of [type === '2주형' ? {} : { copingLengthMm: 20000 }, { copingLengthMm: 22000, copingH1Mm: 1200, copingH2Mm: 1000, levelWidthMm: 2400 }]) { + it(`${type}: 오목형 spans and 1500 haunches follow every column, dimensions ${JSON.stringify(dims)}`, () => { const m = buildPier({ ...loadP1(), ...dims, type, columnCount: type === '2주형' ? 2 : 3 }); const profile = m.copingParts[0].profile; const half = m.copingGeom.levelWidth / 2000; @@ -115,8 +138,8 @@ describe('foundation types', () => { for (let i = 0; i < m.columns.length - 1; i++) { const left = m.columns[i].s + half; const right = m.columns[i + 1].s - half; - expect(has(left + 0.3, m.levelBottom + 0.3)).toBe(true); - expect(has(right - 0.3, m.levelBottom + 0.3)).toBe(true); + expect(has(left + 1.5, m.levelBottom + 0.5)).toBe(true); + expect(has(right - 1.5, m.levelBottom + 0.5)).toBe(true); } expect(Math.min(...profile.map(([, z]) => z))).toBeCloseTo(m.levelBottom, 9); }); @@ -171,10 +194,10 @@ describe('일체기초 (spec §4.6)', () => { expect(m.derived.pileLayout).toBe('7@2314 × 3@2250 = 32본'); }); - it('3주형 L 12000: three columns 3600 apart share one footing', () => { - const m = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLengthMm: 12000, foundationType: '직접기초' }); + it('3주형 L 20000: three columns 6000 apart share one footing (outer distance + 10400)', () => { + const m = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLengthMm: 20000, foundationType: '직접기초' }); expect(m.footings).toHaveLength(1); - expect(m.footings[0].length).toBeCloseTo(17.6, 6); + expect(m.footings[0].length).toBeCloseTo(22.4, 6); }); it('2주형 with columns 18000 apart: separate footings of 10400', () => {