Files
Pier_v1/tests/multicol-rebar.test.ts
T
ypshimandClaude Opus 5.5 370bd3d7cd feat(shape): 다주형 코핑 하면을 오목형으로 (Level D+200 · 오목 500 · 헌치 1500)
spec §3·§4.4(2026-11-06 개정)와 3주형형상배근.md §2.2(2주형 DWG 실측)를 따른다.
- 기둥 사이 하면: Level 끝에서 헌치 수평 1500 동안 500 올라가 평탄, 다음 기둥 앞에서 내려옴
  (이전 Level + 300, 헌치 300 × 300 대체)
- 다주형 Level 폭 기본값 = D + 200 (입력값이 있으면 입력 우선). T형은 2100 유지
- 오목 평탄부가 없으면 RB-M01 오류
- 3주형 테스트는 오목형이 성립하는 20 m 코핑으로 바꿈 (P1 길이 12 m는 RB-M01)
- 2주형 DWG 치수(L 24200, D 3000) 하면 꺾임점 테스트 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:11:15 +09:00

110 lines
7.1 KiB
TypeScript

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';
// 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(), ...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);
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(), ...lengthFor(columnCount), 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(), ...lengthFor(columnCount), 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(), 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++) {
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('양수');
});
});