Files
Pier_v1/tests/cast-layout.test.ts
T
ypshimandClaude Opus 5.5 5fec2743cb feat(cast): 기둥–말뚝 겹침이음 · 말뚝 철근망 근입부 기준 (Q-PL3·Q-PL6)
현장타설말뚝철근.md §5–§6 (사용자 결정 2026-11-06: 11-06 겹침 결정이 11-05 커플러 회신을 대체).
- 희생강관 기본 적용, 상단 지반고 + 2500 (그림 값, Q-PL4 가정). 암반 근입부 지름 입력 추가(기본 D − 150, 강관 없으면 D)
- 말뚝 철근망 반지름을 근입부 기준으로 (D 3000: r 1300 / 1268.2)
- 두 철근 원이 다르면 자동 겹침이음: 기둥 주철근이 지반고 아래 l_s(1.3 l_d, S32 2387)까지 내려감,
  지반고 커플러 없음. 원이 같으면(근입부 없음) 지반고 커플러. 저장된 방식은 그대로 둠
- 겹침 구간의 말뚝 띠철근은 바깥 기둥 주철근을 감싸는 반지름
- 겹침 모드에서도 주기 커플러(기둥 지반고 + @3000, 말뚝 지반고 − @3000, 내측 + 150)
- RB-PL07: 두 원 간격 > min(l_s/5, 150)이면 경고
- 이전 '겹침부 미계산' 표시 제거. 기존 커플러 테스트는 희생강관 미적용 입력으로 유지

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

184 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import type { CastInPlaceShapeSettings } from '../src/core/cast-in-place-shape';
// No casing → no rock socket → both cages on one circle → coupler at ground (the pre-2026-11-06 default these tests cover).
const base = () => ({ ...loadP1(), foundationType: '현장타설말뚝' as const, groundEl: 30, pileLengthMm: 24000, castInPlaceShape: { casingOn: false } as CastInPlaceShapeSettings });
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('a lap saved without a length gets l_s and no ground coupler', () => {
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(false);
expect(Math.min(...result.bars.filter(b=>b.name==='기둥-P1').flatMap(b=>b.pts.map(p=>p[1])))).toBeCloseTo(30-2.387,6);
});
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,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);
});
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');
});
});
// 현장타설말뚝철근.md §5–§6 · §10 (Q-PL3·Q-PL6 결정 2026-11-06): D 3000, 지반고 30.000, 말뚝길이 20450, 점토층 하단 23.000.
describe('기둥–말뚝 겹침이음 (철근망 근입부 기준)', () => {
const example = { ...loadP1(), foundationType: '현장타설말뚝' as const, groundEl: 30, pileLengthMm: 20450, columnDiameterMm: 3000,
copingWidthMm: 3500, castInPlaceShape: { clayBottomEl: 23 } };
const model = buildPier(example);
const result = castRebarLayout(model);
const x = model.columns[0].s;
const radius = (b: { pts: number[][] }) => Math.hypot(b.pts[0][0] - x, b.pts[0][2]) * 1000;
it('pile cage on the rock socket 2850: r 1300 / 1268.2; column cage r 1375 / 1343.2', () => {
expect(radius(result.bars.find(b => b.name === '말뚝-P1')!)).toBeCloseTo(1300, 6);
expect(radius(result.bars.find(b => b.name === '말뚝-P2')!)).toBeCloseTo(1268.2, 6);
expect(radius(result.bars.find(b => b.name === '기둥-P1')!)).toBeCloseTo(1375, 6);
});
it('column mains run down to ground − 2387 (lap), the pile mains up to ground; no ground coupler', () => {
const colLow = Math.min(...result.bars.filter(b => b.name === '기둥-P1').flatMap(b => b.pts.map(p => p[1])));
const pileHigh = Math.max(...result.bars.filter(b => b.name === '말뚝-P1').flatMap(b => b.pts.map(p => p[1])));
expect(colLow).toBeCloseTo(27.613, 6);
expect(pileHigh).toBeCloseTo(30, 6);
expect(result.bars.some(b => b.name.startsWith('접합-'))).toBe(false);
expect(result.missing.some(m => m.includes('겹침') || m.includes('RB-PL07'))).toBe(false);
});
it('periodic couplers: column ground + 3 / + 6 m, pile ground − 3 m … , outside the lap (K2·D2 + 150)', () => {
const mid = (b: { pts: number[][] }) => Math.round((b.pts[0][1] + b.pts[1][1]) / 2 * 1000) / 1000;
// The P1 coping sits higher than the DWG example (coping underside 38.950), so the column may carry one more coupler.
const column = [...new Set(result.bars.filter(b => b.name === '기둥-P1 커플러').map(mid))].sort((a, b) => a - b);
expect(column.slice(0, 2)).toEqual([33, 36]);
expect(column.every(h => Math.abs(((h - 30) / 3) - Math.round((h - 30) / 3)) < 1e-9)).toBe(true);
const pile = [...new Set(result.bars.filter(b => b.name === '말뚝-P1 커플러').map(mid))].sort((a, b) => b - a);
expect(pile[0]).toBe(27);
expect(pile.every(h => h < 27.613)).toBe(true);
expect([...new Set(result.bars.filter(b => b.name === '말뚝-P2 커플러').map(mid))]).toContain(27.15);
});
it('8 pile hoops inside the lap wrap the column bars (r 1400.45), the others sit on the pile cage (r 1325.45)', () => {
const hoops = result.bars.filter(b => b.name === '말뚝-P3');
const wrap = hoops.filter(b => Math.abs(radius(b) - 1400.45) < 1e-6);
expect(wrap).toHaveLength(8);
expect(wrap.every(b => b.pts[0][1] >= 27.613 - 1e-9 && b.pts[0][1] <= 30)).toBe(true);
expect(hoops.filter(b => b.pts[0][1] < 27.613 - 1e-9).every(b => Math.abs(radius(b) - 1325.45) < 1e-6)).toBe(true);
});
it('RB-PL07: a socket 400 smaller puts the circles 200 apart (> 150) → warning', () => {
const narrow = castRebarLayout(buildPier({ ...example, castInPlaceShape: { clayBottomEl: 23, socketDiameterMm: 2600 } }));
expect(narrow.missing.some(m => m.includes('RB-PL07'))).toBe(true);
});
});