현장타설말뚝철근.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>
184 lines
13 KiB
TypeScript
184 lines
13 KiB
TypeScript
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);
|
||
});
|
||
});
|