feat(rebar): 현장타설 말뚝 · 다주형 코핑 형상과 철근 계산

- 현장타설 기둥·말뚝 형상(cast-in-place-shape)과 주철근·횡철근·커플러 계산(rebar/cast-in-place, cast-layout)
- 다주형 코핑 철근(multicol-coping), 기둥 높이 · 양단부 연장
- 철근표 요약 · 재료 수량 · 3D 뷰 연결과 삽도(cast-rebar-svg)
- 검사 기준 갱신과 회귀 테스트, 검증 스크립트 보관

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-06 13:15:01 +09:00
1 parent 3922b330d5
commit 2e0bfb6fc8
29 files changed
+1750 -71

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { castShape, castShapeIssues, castLowerZone } from '../src/core/cast-in-place-shape';
import { castRebarDefaults } from '../src/core/rebar/cast-in-place';
import { buildPier } from '../src/core/geometry';
import { changedKeys } from '../src/core/changes';
import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project';
import { loadP1 } from './helpers';
describe('2026-11-05 cast-in-place drawing and user decisions', () => {
const input = { ...loadP1(), foundationType: '현장타설말뚝' as const, groundEl: 30, footingBottomEl: 10, pileLengthMm: 24000 };
it('uses ground level for the pile head and ground minus length for its tip', () => {
const model = buildPier(input);
expect(model.piles[0].top).toBe(30);
expect(model.piles[0].bottom).toBe(6);
expect(model.columns[0].bottom).toBe(30);
expect(model.footings).toEqual([]);
expect(model.derived.soilCover).toBe(0);
});
it('uses the clarified positive rock offsets, 9000 mm beta and casing defaults', () => {
expect(castShape(input)).toMatchObject({ pileBottomEl: 6, betaLengthMm: 9000, betaEl: 21, weatheredRockEl: 11, bedrockEl: 7, casingOn: false, casingTopEl: 33, casingBottomEl: 7, clayBottomEl: undefined });
});
it('rejects a pile head above the coping instead of generating an inverted column', () => {
expect(() => buildPier({ ...input, groundEl: 100 })).toThrow('지반고');
});
it('rejects zero or negative cast-in-place pile length', () => {
for (const pileLengthMm of [0, -1]) expect(() => buildPier({ ...input, pileLengthMm })).toThrow('말뚝 길이');
});
it('keeps automatic values linked to geometry changes and manual overrides fixed', () => {
const auto = castShape({ ...input, groundEl: 40 });
expect(auto.bedrockEl).toBe(17);
expect(auto.casingBottomEl).toBe(17);
expect(auto.casingTopEl).toBe(43);
const manual = castShape({ ...input, groundEl: 40, castInPlaceShape: { bedrockEl: -2, casingTopEl: 35, weatheredRockEl: 3 } });
expect(manual.casingBottomEl).toBe(-2);
expect(manual.casingTopEl).toBe(35);
expect(manual.weatheredRockEl).toBe(3);
});
it('requires independent clay EL and reports reversed lower zone instead of silently swapping it', () => {
expect(castLowerZone(input)).toBeNull();
expect(castLowerZone({ ...input, castInPlaceShape: { clayBottomEl: 16 } })).toEqual({ bottomEl: 15, topEl: 18, valid: true });
expect(castLowerZone({ ...input, castInPlaceShape: { clayBottomEl: 4 } })?.valid).toBe(false);
});
it('inherits both round-column main layers, hoop spacing, cover convention and couplers', () => {
const s = castRebarDefaults(input);
expect(s.column).toMatchObject({ mainDiameterMm: 31.8, innerMainDiameterMm: 31.8, mainCount: 46, innerMainCount: 46, coverMm: 125, coverReference: 'main-center', tieSpacingMm: 300, endZoneSpacingMm: 120, tieKind: 'hoop' });
expect(s.pile?.endCoverMm).toBe(250);
expect(s.connection).toEqual({ mode: 'coupler', couplerLengthMm: 150 });
});
it('preserves old explicit clear cover and independent edits', () => {
const s = castRebarDefaults({ ...input, columnRebar: { nDiv: 48, c: 140 }, castInPlaceRebar: { column: { coverMm: 100, mainCount: 44 }, pile: { endCoverMm: 300 } } });
expect(s.column?.coverReference).toBe('clear');
expect(s.column?.coverMm).toBe(100);
expect(s.column?.mainCount).toBe(44);
expect(s.pile?.coverMm).toBe(140);
expect(s.pile?.innerMainCount).toBe(48);
expect(s.pile?.endCoverMm).toBe(300);
});
it('persists explicit shape values without turning auto defaults into fixed values', () => {
const next = { ...input, castInPlaceShape: { casingOn: true, clayBottomEl: 16, betaLengthMm: 10000 } };
const p: ProjectFile = { schemaVersion: SCHEMA_VERSION, project: { name: '', bridge: '', client: '', author: '', crs: '', memo: '' }, source: { fileName: '', sheets: [] }, history: [], piers: [{ id: 'P1', input: next, defaults: input, draft: next }] };
expect(parseProject(serializeProject(p))).toEqual(p);
expect(changedKeys(input, next)).toEqual(['castInPlaceShape']);
const invalid = JSON.parse(serializeProject(p));
invalid.piers[0].draft.castInPlaceShape.betaLengthMm = 0;
expect(() => parseProject(JSON.stringify(invalid))).toThrow('현장타설');
});
it('permits negative elevations but rejects nonfinite elevations and invalid switches', () => {
expect(castShapeIssues({ bedrockEl: -20, casingBottomEl: -30 })).toEqual([]);
expect(castShapeIssues({ casingOn: 1 })).toHaveLength(1);
expect(castShapeIssues({ clayBottomEl: Infinity })).toHaveLength(1);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { buildPierScene } from '../src/view/scene';
import { buildRebar3D } from '../src/view/rebar3d';
import { rebarSummary } from '../src/core/rebar/summary';
import { buildQuantity, quantityRows } from '../src/core/rebar/quantity';
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
import { castSettingsIssues, type CastInPlaceRebar } from '../src/core/rebar/cast-in-place';
import { castRebarLayout } from '../src/core/rebar/cast-layout';
import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project';
import { changedKeys } from '../src/core/changes';
import { validateInput } from '../src/core/validate';
import type { Issue, PierInput } from '../src/core/types';
import { loadP1 } from './helpers';
const settings: CastInPlaceRebar = { column: { mainDiameterMm: 32, mainCount: 46, coverMm: 100, mainGradeMpa: 500 }, pile: { mainDiameterMm: 32, mainCount: 46, cageLengthMm: 12000, tieKind: 'spiral' }, connection: { mode: 'continuous' } };
function project(input: PierInput): ProjectFile {
return { schemaVersion: SCHEMA_VERSION, project: { name: '', bridge: '', client: '', author: '', crs: '', memo: '' }, source: { fileName: '', sheets: [] }, history: [], piers: [{ id: 'one', input, defaults: { ...input, castInPlaceRebar: undefined }, draft: { ...input, castInPlaceRebar: settings } }] };
}
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 });
const concrete = buildPierScene(m);
expect(concrete.parts.coping.children.length).toBeGreaterThan(0);
const rebar = buildRebar3D(m);
const result = rebarSummary(m);
expect(rebar.byName.get('C1')!.length).toBeGreaterThan(0);
const q = buildQuantity(result.rows, DEFAULT_MATERIAL, result.incomplete);
if (foundationType === '현장타설말뚝') {
expect(m.footings).toHaveLength(0);
expect(m.piles).toHaveLength(columnCount);
m.columns.forEach((c, i) => expect(c.bottom).toBe(m.piles[i].top));
expect(rebar.byName.get('기둥-P1')!.length).toBeGreaterThan(0);
expect(rebar.byName.get('말뚝-P1')!.length).toBeGreaterThan(0);
expect(rebar.byName.get('기둥-P3')!.length).toBeGreaterThan(0);
expect(rebar.byName.get('말뚝-P3')!.length).toBeGreaterThan(0);
expect(rebar.byName.has('B1')).toBe(false);
expect(result.rows.some(r => r.member === 'column')).toBe(true);
expect(result.rows.some(r => r.member === 'pile')).toBe(true);
expect(result.rows.some(r => r.member === 'footing')).toBe(false);
expect(q.missing).toEqual(result.incomplete);
expect(q.missing).toEqual(expect.arrayContaining(castRebarLayout(m).missing));
expect(q.missing).toContain('말뚝 저부 P6 검토안 미적용');
expect(quantityRows(q).filter(r => r[0] === '미계산 · 합계 미완료')).toHaveLength(q.missing.length);
expect(q.kg).toBeGreaterThan(0);
} else {
expect(rebar.byName.get('P1')!.length).toBeGreaterThan(0);
if (columnCount === 1) expect(result.incomplete).toEqual([]);
else expect(result.incomplete).toContain('다주형 철근 번호 대응·cycle별 이음 위치·수평철근 갈고리 상세 확인 필요');
expect(q.missing).toEqual(result.incomplete);
}
rebar.group.traverse(o => { const mesh = o as import('three').Mesh; mesh.geometry?.dispose(); });
});
}
}
});
describe('cast-in-place storage and validation', () => {
it('round-trips dedicated settings in generated/default/draft inputs without merging legacy settings', () => {
const input = { ...loadP1(), foundationType: '현장타설말뚝' as const, castInPlaceRebar: settings };
const p = project(input);
expect(parseProject(serializeProject(p))).toEqual(p);
expect(changedKeys({ ...input, castInPlaceRebar: undefined }, input)).toContain('castInPlaceRebar');
});
it('legacy projects keep absent settings absent rather than inventing defaults', () => {
const p = project(loadP1());
delete p.piers[0].draft;
expect(parseProject(serializeProject(p)).piers[0].input.castInPlaceRebar).toBeUndefined();
});
for (const bad of [null, [], { pile: [] }, { column: { mainCount: 2.5 } }, { pile: { coverMm: -1 } }, { connection: { mode: ['continuous'] } }, { column: { tieKind: 'unknown' } }, { column: { mainCount: '46' } }, { pile: { extra: 1 } }]) {
it(`rejects malformed settings ${JSON.stringify(bad)} in saved draft`, () => {
const p = project(loadP1());
p.piers[0].draft!.castInPlaceRebar = bad as CastInPlaceRebar;
expect(() => parseProject(serializeProject(p))).toThrow('현장타설');
});
}
it('geometry length changes produce a warning without destroying concrete or preventing reopening', () => {
const input = { ...loadP1(), foundationType: '현장타설말뚝' as const, pileLengthMm: 10000, castInPlaceRebar: settings };
const issues: Issue[] = [];
expect(validateInput(input, issues)).toBe(true);
expect(issues.some(i => i.code === 'CIP01')).toBe(true);
expect(() => parseProject(serializeProject(project(input)))).not.toThrow();
});
it('has no numeric defaults and checks cage length and cover geometry', () => {
expect(castSettingsIssues({})).toEqual([]);
expect(castSettingsIssues({ pile: { cageLengthMm: 15000 } }, 10000)).toHaveLength(1);
expect(castSettingsIssues({ column: { coverMm: 1000 } }, 10000, 2000)).toHaveLength(1);
expect(castSettingsIssues({ pile: { mainCount: Infinity } })).toHaveLength(1);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { castRebarLayout } from '../src/core/rebar/cast-layout';
import { rebarSummary } from '../src/core/rebar/summary';
import { buildQuantity, unitWeight } from '../src/core/rebar/quantity';
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
import { castRebarSvg } from '../src/view/cast-rebar-svg';
import { parseProject, serializeProject, SCHEMA_VERSION, type ProjectFile } from '../src/core/project';
import { loadP1 } from './helpers';
const base = () => ({ ...loadP1(), foundationType: '현장타설말뚝' as const, groundEl: 30, pileLengthMm: 24000 });
const layout = (changes: Partial<ReturnType<typeof base>> = {}) => castRebarLayout(buildPier({ ...base(), ...changes }));
describe('dedicated cast-in-place physical reinforcement', () => {
it('generates both main layers on both sides of ground and one interface coupler per line', () => {
const result = layout();
for (const part of ['기둥', '말뚝']) for (const mark of ['P1', 'P2', 'P3']) expect(result.bars.some(b=>b.name===`${part}-${mark}`)).toBe(true);
const joints = result.bars.filter(b=>b.name.startsWith('접합-'));
expect(joints).toHaveLength(92);
expect(joints.filter(b=>b.name==='접합-P1 커플러').every(b=>Math.abs((b.pts[0][1]+b.pts[1][1])/2-30)<1e-8)).toBe(true);
expect(joints.filter(b=>b.name==='접합-P2 커플러').every(b=>Math.abs((b.pts[0][1]+b.pts[1][1])/2-30.15)<1e-8)).toBe(true);
const bottoms = result.bars.filter(b=>b.name==='말뚝-P1').flatMap(b=>b.pts.map(p=>p[1]));
expect(Math.min(...bottoms)).toBeCloseTo(6 + (250 + 31.8/2)/1000);
expect(result.bars.some(b=>b.name.includes('P4')||b.name==='말뚝-P6')).toBe(false);
});
it('gives the same centre-line endpoints to both main pieces at every joint', () => {
const result=layout();
for(const joint of result.bars.filter(b=>b.kind==='coupler' && b.name.startsWith('접합-'))) {
const centre=joint.pts[0].map((v,i)=>(v+joint.pts[1][i])/2);
const endpoints=result.bars.filter(b=>b.kind==='main').flatMap(b=>[b.pts[0],b.pts.at(-1)!]);
expect(endpoints.filter(p=>p.every((v,i)=>Math.abs(v-centre[i])<1e-8))).toHaveLength(2);
}
});
it('keeps every generated ordinary hoop surface inside the section and above the tip', () => {
const centre = buildPier(base()).columns[0].s;
for (const bar of layout().bars.filter(b=>b.kind!=='coupler')) for(const point of bar.pts) {
if(bar.kind==='main' || bar.kind==='tie') expect(Math.hypot(point[0]-centre,point[2])+bar.diameterMm/2000).toBeLessThanOrEqual(1+1e-7);
expect(point[1]-bar.diameterMm/2000).toBeGreaterThanOrEqual(6.25-1e-7);
}
});
it('uses each section diameter, grade and count without silently equating mismatched joints', () => {
const result=layout({castInPlaceRebar:{column:{mainCount:44,mainDiameterMm:35.8,mainGradeMpa:400},pile:{mainCount:48,mainGradeMpa:500}}});
expect(result.bars.some(b=>b.name.startsWith('접합-'))).toBe(false);
expect(result.missing.some(m=>m.includes('불일치'))).toBe(true);
expect(result.bars.filter(b=>b.name==='기둥-P1').every(b=>b.diameterMm===35.8 && b.gradeMpa===400)).toBe(true);
});
it('converts clear cover to the main centre correctly and preserves two different cover conventions', () => {
const centre=layout({castInPlaceRebar:{column:{coverMm:125,coverReference:'main-center'}}});
const clear=layout({castInPlaceRebar:{column:{coverMm:125,coverReference:'clear'}}});
const x=buildPier(base()).columns[0].s;
expect(centre.bars.find(b=>b.name==='기둥-P1')!.pts[0][0]-x).toBeCloseTo(.875);
expect(clear.bars.find(b=>b.name==='기둥-P1')!.pts[0][0]-x).toBeCloseTo((1000-125-19.1-15.9)/1000);
});
it('places dense hoops in the manual upper column and beta/clay lower pile zones', () => {
const result=layout({castInPlaceShape:{clayBottomEl:16},castInPlaceRebar:{column:{endZoneLengthMm:2000}}});
for (const part of ['기둥','말뚝']) expect(result.bars.some(b=>b.name===`${part}-P5`)).toBe(true);
for(const b of result.bars.filter(b=>b.name==='말뚝-P5')) expect(b.pts[0][1]).toBeGreaterThan(15);
for(const b of result.bars.filter(b=>b.name==='말뚝-P5')) expect(b.pts[0][1]).toBeLessThan(18);
expect(result.bars.filter(b=>b.name==='말뚝-P5 띠철근 커플러')).toHaveLength(result.bars.filter(b=>b.name==='말뚝-P5').length);
});
it('reports missing/reversed/out-of-range end zones while keeping ordinary rebar visible', () => {
for(const settings of [{},{castInPlaceShape:{clayBottomEl:4}},{castInPlaceRebar:{column:{endZoneLengthMm:99999}}}]) {
const result=layout(settings);
expect(result.bars.some(b=>b.name==='기둥-P1')).toBe(true);
expect(result.missing.some(m=>m.includes('경계')||m.includes('단부구간'))).toBe(true);
}
});
it('uses a real helix with rise and analytical length when spiral is selected', () => {
const result=layout({castInPlaceRebar:{pile:{tieKind:'spiral',tieSpacingMm:250}}});
const helix=result.bars.find(b=>b.name==='말뚝-P3 나선')!;
expect(helix.pts.at(-1)![1]).toBeGreaterThan(helix.pts[0][1]);
expect(helix.lengthMm).toBeGreaterThan((helix.pts.at(-1)![1]-helix.pts[0][1])*1000);
expect(result.missing.some(m=>m.includes('나선철근'))).toBe(true);
});
it('merges continuous mains into one physical bar across the joint', () => {
const result=layout({castInPlaceRebar:{connection:{mode:'continuous'}}});
expect(result.bars.filter(b=>b.name==='기둥·말뚝-P1')).toHaveLength(46);
expect(result.bars.filter(b=>b.name==='기둥·말뚝-P2')).toHaveLength(46);
expect(result.bars.some(b=>b.kind==='coupler')).toBe(false);
expect(result.bars.find(b=>b.name==='기둥·말뚝-P1')!.pts[0][1]).toBeLessThan(30);
});
it('does not claim a missing lap or mismatched joint is connected', () => {
const result=layout({castInPlaceRebar:{connection:{mode:'lap'}}});
expect(result.bars.some(b=>b.name.startsWith('접합-'))).toBe(false);
expect(result.missing.some(m=>m.includes('겹침'))).toBe(true);
});
it('excludes an invalid pile cage rather than silently clipping it', () => {
const result=layout({castInPlaceRebar:{pile:{cageLengthMm:24000}}});
expect(result.bars.some(b=>b.member==='pile')).toBe(false);
expect(result.missing.some(m=>m.includes('선단 피복'))).toBe(true);
});
it('rejects coupler lengths that exceed the joint space without crashing the drawing', () => {
const result=layout({castInPlaceRebar:{connection:{couplerLengthMm:5000}}});
expect(result.bars.some(b=>b.name.startsWith('접합-'))).toBe(false);
expect(result.missing.some(m=>m.includes('커플러 길이'))).toBe(true);
});
it('does not draw a multi-turn hoop when the lap exceeds the circumference', () => {
const result=layout({castInPlaceRebar:{pile:{concreteStrengthMpa:0.001}}});
expect(result.missing.some(m=>m.includes('겹침길이가 원주 이상'))).toBe(true);
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});
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);
});
it('uses the same physical pieces, diameters and grades for table length and mass', () => {
const input={...base(),castInPlaceRebar:{column:{mainGradeMpa:400,mainDiameterMm:35.8}}};
const model=buildPier(input), result=castRebarLayout(model), summary=rebarSummary(model);
const q=buildQuantity(summary.rows.filter(r=>r.diameterMm!==undefined),DEFAULT_MATERIAL,summary.incomplete);
const steel=result.bars.filter(b=>b.kind!=='coupler');
expect(q.groups.flatMap(g=>g.diameters.flatMap(d=>d.lines)).reduce((s,l)=>s+l.pieceCount,0)).toBe(steel.length);
const mass=steel.reduce((s,b)=>s+b.lengthMm/1000*unitWeight(b.diameterMm),0);
expect(q.kg).toBeCloseTo(mass,8);
expect(q.groups.find(g=>g.grade===400)!.diameters.some(d=>d.dMm===35.8)).toBe(true);
expect(q.couplers.reduce((s,c)=>s+c.count,0)).toBe(result.bars.filter(b=>b.kind==='coupler').length);
});
it('groups identical hoops as one length and merges nominal/equivalent diameter subtotals', () => {
const summary=rebarSummary(buildPier(base()));
const hoop=summary.rows.find(r=>r.name==='말뚝-P3')!;
expect(hoop.pieces).toHaveLength(1);
expect(hoop.pieces![0].count).toBe(hoop.count);
expect(hoop.length).toBeGreaterThan(0);
const q=buildQuantity(summary.rows,DEFAULT_MATERIAL,summary.incomplete);
expect(q.groups.find(g=>g.grade===400)!.diameters.filter(d=>Math.abs(d.dMm-19.1)<1e-8)).toHaveLength(1);
});
it('restores generated bar paths and quantities from the real project schema', () => {
const input={...base(),castInPlaceRebar:{column:{mainCount:48},pile:{mainCount:48},connection:{mode:'coupler' as const,couplerLengthMm:180}}};
const file:ProjectFile={schemaVersion:SCHEMA_VERSION,project:{name:'검증',bridge:'',client:'',author:'',crs:'',memo:''},source:{fileName:'',sheets:[]},history:[],piers:[{id:'P1',input,defaults:input,draft:input}]};
const restored=parseProject(serializeProject(file)).piers[0].input;
expect(castRebarLayout(buildPier(restored))).toEqual(castRebarLayout(buildPier(input)));
expect(castRebarSvg(buildPier(restored),castRebarLayout(buildPier(restored)))).toContain('<polyline');
});
});
+97
View File
@@ -0,0 +1,97 @@
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', () => {
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('양수');
});
});
+8 -8
View File
@@ -10,18 +10,18 @@ const three = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLe
const count = (rows: ReturnType<typeof rebarSummary>['rows'], name: string) => rows.find((r) => r.name === name)!.count;
describe('다주형 철근 (2주형·3주형, 코핑철근.md §9)', () => {
it('2주형: C10 2 × 9 = 18; C9 and C11 3 each outside; 18 between (추천안, Q-K2)', () => {
it('2주형: H22 @300, six physical pieces per position', () => {
const rows = rebarSummary(two).rows;
expect(count(rows, 'C10')).toBe(18);
expect(count(rows, 'C9')).toBe(3);
expect(count(rows, 'C11')).toBe(3);
expect(count(rows, 'C9·C11 사이')).toBe(18);
expect(count(rows, 'C10')).toBe(18 * 6);
expect(count(rows, 'C9')).toBe(3 * 6);
expect(count(rows, 'C11')).toBe(3 * 6);
expect(count(rows, 'C9·C11 사이')).toBe(15 * 6);
});
it('3주형: C10 3 × 9 = 27; two gaps of 3 between', () => {
it('3주형: C10 27 positions and two gaps of three, each with six physical pieces', () => {
const rows = rebarSummary(three).rows;
expect(count(rows, 'C10')).toBe(27);
expect(count(rows, 'C9·C11 사이')).toBe(6);
expect(count(rows, 'C10')).toBe(27 * 6);
expect(count(rows, 'C9·C11 사이')).toBe(6 * 6);
});
it('column bars repeat per column: P1 230 × 2 and 230 × 3', () => {
+5 -3
View File
@@ -33,9 +33,11 @@ describe('조각 길이 (승인 2026-10-23): 여러 길이를 가진 행', () =>
expect(total(row('C12'))).toBeGreaterThan(0);
});
it('P1 and P2 pieces: each splice piece counts all the bars (230)', () => {
expect(row('P1').pieces!.every((p) => p.count === 230)).toBe(true);
expect(row('P2').pieces!.every((p) => p.count === 230)).toBe(true);
it('P1 and P2 pieces: each level has 46 bars, giving 230 physical pieces in all', () => {
expect(row('P1').pieces!.every((p) => p.count === 46)).toBe(true);
expect(row('P2').pieces!.every((p) => p.count === 46)).toBe(true);
expect(bars(row('P1'))).toBe(row('P1').count);
expect(bars(row('P2'))).toBe(row('P2').count);
expect(row('P1').pieces!.length).toBeGreaterThan(1);
});
+37 -10
View File
@@ -82,29 +82,56 @@ describe('foundation types', () => {
expect(m.piles[0].diameter).toBeCloseTo(0.6096, 6);
});
it('2주형: underside between columns drops 300 below Level, T형 has no such point (Q3 추천안)', () => {
it('2주형: underside between columns rises 300 above Level; column tops and T형 stay at Level', () => {
const t = buildPier(loadP1());
const dip = t.levelBottom - 0.3;
expect(t.copingParts[0].profile.some(([, z]) => Math.abs(z - dip) < 1e-9)).toBe(false);
const raised = t.levelBottom + 0.3;
expect(t.copingParts[0].profile.some(([, z]) => Math.abs(z - raised) < 1e-9)).toBe(false);
const two = buildPier({ ...loadP1(), type: '2주형', columnCount: 2 });
expect(two.copingParts[0].profile.some(([, z]) => Math.abs(z - dip) < 1e-9)).toBe(true);
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(dip, 9);
expect(outlineLow).toBeCloseTo(two.levelBottom, 9);
expect(two.columns.every((c) => c.top === two.levelBottom)).toBe(true);
});
it('3주형 builds with two gaps of haunches', () => {
const m = buildPier({ ...loadP1(), type: '3주형', columnCount: 3 });
const dips = m.copingParts[0].profile.filter(([, z]) => Math.abs(z - (m.levelBottom - 0.3)) < 1e-9);
expect(dips).toHaveLength(4);
const raised = m.copingParts[0].profile.filter(([, z]) => Math.abs(z - (m.levelBottom + 0.3)) < 1e-9);
expect(raised).toHaveLength(4);
});
it('현장타설말뚝: no footing, one pile of column diameter per column, tip = bottom EL − length', () => {
const m = buildPier({ ...loadP1(), foundationType: '현장타설말뚝' });
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)}`, () => {
const m = buildPier({ ...loadP1(), ...dims, type, columnCount: type === '2주형' ? 2 : 3 });
const profile = m.copingParts[0].profile;
const half = m.copingGeom.levelWidth / 2000;
const has = (x: number, z: number) => profile.some(([s, el]) => Math.abs(s - x) < 1e-9 && Math.abs(el - z) < 1e-9);
for (const c of m.columns) {
expect(has(c.s - half, m.levelBottom)).toBe(true);
expect(has(c.s + half, m.levelBottom)).toBe(true);
expect(c.top).toBe(m.levelBottom);
}
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(Math.min(...profile.map(([, z]) => z))).toBeCloseTo(m.levelBottom, 9);
});
}
}
it('현장타설말뚝: no footing, one pile of column diameter per column, tip = ground EL − length', () => {
const input = { ...loadP1(), foundationType: '현장타설말뚝' as const };
const m = buildPier(input);
expect(m.footings).toHaveLength(0);
expect(m.piles).toHaveLength(1);
expect(m.piles[0].diameter).toBeCloseTo(2.0, 6);
expect(m.piles[0].bottom).toBeCloseTo(26.208 - 24, 6);
expect(m.piles[0].top).toBe(input.groundEl);
expect(m.columns[0].bottom).toBe(input.groundEl);
expect(m.piles[0].bottom).toBeCloseTo(input.groundEl - input.pileLengthMm / 1000, 6);
});
});