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> = {}) => 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(' { 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); }); });