Files
Pier_v1/도면작성/drawing.test.ts
T
ypshim f51865c57b Merge origin/master with current Pierzainer development
Preserve local abutment, cast-in-place rebar and compact header features alongside remote drawing, ground-link and quantity workspaces. Adapt drawing and takeoff consumers to generated solplate plans, retain current P1 regression coverage, and resolve bearing-detail annotation collisions.

Validation: 122 test files / 975 tests passed; production build passed; 10 browser integration checks passed.
2026-11-06 17:10:33 +09:00

249 lines
19 KiB
TypeScript

import { foundationNotes } from './foundation-notes';
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Vector3 } from 'three';
import { buildPier, solveIp4 } from '../src/core/geometry';
import { buildAxis, copingSpan, fromAxis, kinkArc } from '../src/core/axis';
import type { PierInput } from '../src/core/types';
import { generateDrawing, DEFAULT_SETTINGS } from './drawing';
import { localPoint, projectPoint, modelPrimitives, silhouette } from './projection';
import { drawingSvg } from './svg';
import { exportDxf } from './dxf';
import { bearingSchedule } from './bearing-detail';
import { shortenedBounds } from './breaks';
import { presentationEntities } from './presentation';
import { defaultSheet, REFERENCE_TEMPLATE, parseSheetConfig } from './template';
import { sheetFrame } from './sheet';
import { cutHatch } from './hatches';
import { viewCollisions,entityBox } from './occupancy';
import { LAYERS, type Layer } from './types';
const root=resolve(import.meta.dirname,'..');
const samples=readdirSync(resolve(root,'랜딩페이지/assets/samples')).filter(f=>/^sample\d+\.pzr\.json$/.test(f)).map(file=>({file,...JSON.parse(readFileSync(resolve(root,'랜딩페이지/assets/samples',file),'utf8'))}));
const input: PierInput=samples.find(s=>s.file==='sample02.pzr.json').piers[0].input;
const project={name:'검증 프로젝트',bridge:'운곡교'};
const layers=new Set(Object.keys(LAYERS) as Layer[]);
describe('실제 모델 → 일반도',()=>{
it('전체 샘플의 생성 가능한 모델을 투영하며 원본을 변경하지 않는다',()=>{
let count=0;let piWritten=false;const checks:unknown[]=[];
for(const sample of samples){const models=sample.piers.filter((p:{input?:PierInput})=>p.input).map((p:{input:PierInput})=>buildPier(p.input));for(const pier of sample.piers){
if(!pier.input)continue;
const model=buildPier(pier.input),before=JSON.stringify(model);
const doc=generateDrawing(model,pier.input,sample.project,DEFAULT_SETTINGS,{models});
expect(doc.quality?.geometryUnchanged).toBe(true);expect(doc.quality?.after,`${sample.file}/${model.name}: ${JSON.stringify({issues:doc.quality?.issues,table:doc.quality?.occupied.schedule})}`).toBe(0);
if(!piWritten&&pier.input.type==='2주형'){mkdirSync(resolve(import.meta.dirname,'.cache'),{recursive:true});writeFileSync(resolve(import.meta.dirname,'.cache/generated-pi.dxf'),exportDxf(doc,layers));writeFileSync(resolve(import.meta.dirname,'.cache/generated-pi.svg'),drawingSvg(doc,layers));piWritten=true;}
checks.push({sample:sample.file,pier:model.name,warnings:doc.warnings,quality:doc.quality,views:doc.views.map(v=>({id:v.id,scale:v.scale,breaks:v.breaks?.length??0,display:shortenedBounds(v.bounds,v.breaks),slot:v.slot}))});
expect(doc.views.map(v=>v.id)).toEqual(['front','side','top','footing','section','detail','schedule','heights','iso','keyplan']);
for(const view of doc.views){
expect(view.entities.length).toBeGreaterThan(0);
expect(Object.values(view.geometryBounds).every(Number.isFinite)).toBe(true);
for(const e of view.entities)if(e.kind==='dimension')expect(e.value).toBeCloseTo(Math.abs(e.axis==='x'?e.b[0]-e.a[0]:e.b[1]-e.a[1]),7);
}
expect(JSON.stringify(model)).toBe(before);count++;
}}
expect(count).toBeGreaterThanOrEqual(63);
mkdirSync(resolve(import.meta.dirname,'.cache'),{recursive:true});writeFileSync(resolve(import.meta.dirname,'.cache/sample-drawing-checks.json'),JSON.stringify(checks,null,2));
},120000);
it('EL과 실제 기둥 지름을 mm로 투영하고 로컬 축과 일치한다',()=>{
const model=buildPier(input),c=model.columns[0];
const p=projectPoint(model,localPoint(model,c.k,c.s,c.top,0),'front');
expect(p[1]).toBeCloseTo(c.top*1000,7);
const base=projectPoint(model,new Vector3(model.bp1.e-model.pivot.e,0,-(model.bp1.n-model.pivot.n)),'top');
expect(base[0]).toBeCloseTo(0,7);expect(base[1]).toBeCloseTo(0,7);
const doc=generateDrawing(model,input,project,DEFAULT_SETTINGS);
const section=doc.views.find(v=>v.id==='section')!;
expect(section.entities.some(e=>e.kind==='circle'&&e.radius===c.diameter*500)).toBe(true);
});
it('용지·축척 변경은 외형 좌표와 치수값을 바꾸지 않는다',()=>{
const model=buildPier(input);
const docs=[generateDrawing(model,input,project,DEFAULT_SETTINGS),generateDrawing(model,input,project,{...DEFAULT_SETTINGS,paper:'A1',scale:200})];
expect(docs[0].views.slice(0,5).map(v=>v.entities.filter(e=>e.kind!=='hatch'&&e.source.member!=='annotation'&&e.source.member!=='bearing-location'))).toEqual(docs[1].views.slice(0,5).map(v=>v.entities.filter(e=>e.kind!=='hatch'&&e.source.member!=='annotation'&&e.source.member!=='bearing-location')));
expect(docs[0].views.map(v=>v.entities.filter(e=>e.kind==='dimension').map(e=>e.value))).toEqual(docs[1].views.map(v=>v.entities.filter(e=>e.kind==='dimension').map(e=>e.value)));
});
it('꺾임 교각도 기존 mitre 평면의 허용 영역만 사용한다',()=>{
// Same explicit 60-degree test geometry as the existing bent-coping suite.
// Verification fixture only; never used as application/project data.
const ip1={e:0,n:0},ip3={e:9,n:-3.4641016};
const axis=buildAxis(ip1,ip3,solveIp4(ip1,{e:1,n:0},ip3,60));
const start=copingSpan(axis,6).sStart;
const bearings=(['전열','후열'] as const).flatMap(row=>[.6,1.9,3.2].map((g,i)=>{
const arc=start+g,k=arc>kinkArc(axis)!?1:0,p=fromAxis(axis,{k,s:arc,n:row==='전열'?-.7:.6});
return {no:i+1,row,x:p.e,y:p.n,z:input.copingTopEl+.15,azimuthDeg:0};
}));
const bent: PierInput={...input,type:'T형',columnCount:1,copingLengthMm:6000,copingWidthMm:3100,bearings,
ips:[{name:'IP1',x:0,y:0,angleDeg:0},{name:'IP2',x:1,y:0,angleDeg:0},{name:'IP3',x:9,y:-3.4641016,angleDeg:0},{name:'IP4',x:0,y:0,angleDeg:60}]};
const model=buildPier(bent),primitives=modelPrimitives(model);
expect(model.stretches.length).toBe(2);
for(const solid of primitives.solids.filter(s=>s.source.member==='coping'))for(const face of solid.faces)for(const p of face){
const east=p.x+model.pivot.e-model.kink!.e,north=-p.z+model.pivot.n-model.kink!.n;
const signed=east*model.mitre!.e+north*model.mitre!.n;
const k=model.copingParts[solid.source.index].k;
expect(k===0?signed:-signed).toBeLessThanOrEqual(1e-7);
}
expect(generateDrawing(model,bent,project,DEFAULT_SETTINGS).warnings.some(w=>w.includes('정투영'))).toBe(true);
for(const type of ['2주형','3주형'] as const){
const multi={...bent,type,columnCount:type==='2주형'?2:3,copingLengthMm:16000,columnEndOffsetMm:2000,columnSpacingMm:undefined};
const m=buildPier(multi),drawing=generateDrawing(m,multi,project,DEFAULT_SETTINGS);
expect(m.stretches).toHaveLength(2);
expect(drawing.views.find(v=>v.id==='top')!.entities.filter(e=>e.kind==='circle'&&e.source.member==='column')).toHaveLength(m.columns.length);
expect(drawingSvg(drawing,layers)).not.toMatch(/NaN|Infinity/);
}
},15000); // Three complete layout searches, including the larger valid multi-column fixtures.
it('프리즘의 내부 면 경계를 외곽선으로 잘못 표시하지 않는다',()=>{
const edges=silhouette([[[0,0],[10,0],[10,10],[0,10]],[[5,0],[15,0],[15,10],[5,10]]]);
expect(edges.length).toBeGreaterThan(3);
expect(edges.some(([a,b])=>a[0]===b[0]&&(a[0]===5||a[0]===10))).toBe(false);
});
it('생략 표현은 기둥·말뚝의 화면 길이만 줄이고 실제 좌표·치수·EL은 유지한다',()=>{
const model=buildPier(input),before=JSON.stringify(model);
const full=generateDrawing(model,input,project,{...DEFAULT_SETTINGS,shorten:false});
const short=generateDrawing(model,input,project,DEFAULT_SETTINGS);
const a=full.views[0],b=short.views[0],display=shortenedBounds(b.geometryBounds,b.breaks);
expect(b.breaks?.some(c=>c.member==='column')).toBe(true);
expect(b.breaks?.some(c=>c.member==='pile')).toBe(true);
expect(display.maxY-display.minY).toBeLessThan((a.geometryBounds.maxY-a.geometryBounds.minY)*.6);
expect(a.geometryBounds).toEqual(b.geometryBounds);
expect(a.entities.filter(e=>e.kind==='dimension').map(e=>e.value)).toEqual(b.entities.filter(e=>e.kind==='dimension').map(e=>e.value));
expect(presentationEntities(b).some(e=>e.entity.source.member==='column-section-inline')).toBe(true);
expect(JSON.stringify(model)).toBe(before);
});
it('기초 좌표와 말뚝 주석은 모델 실좌표·실제 길이를 사용한다',()=>{
const m=buildPier(input),f=m.footings[0],bounds={minX:-10000,maxX:10000,minY:0,maxY:5000};
const notes=foundationNotes(m,input,'footing',bounds,100);
const corner=localPoint(m,f.k,f.s-f.length/2,f.top,-f.width/2);
expect(notes.some(e=>e.kind==='text'&&e.text===`X(N) ${(-corner.z+m.pivot.n).toFixed(3)}`)).toBe(true);
expect(notes.some(e=>e.kind==='text'&&e.text===`Y(E) ${(corner.x+m.pivot.e).toFixed(3)}`)).toBe(true);
expect(notes.filter(e=>e.kind==='text')).toHaveLength(m.footings.length*8);
const pileNotes=foundationNotes(m,input,'front',bounds,100);
expect(pileNotes.some(e=>e.kind==='text'&&e.text.startsWith('L=')&&e.text.endsWith(' m'))).toBe(true);
});
it('참고 치수표 항목은 모델의 받침 제원·실제 높이를 사용하고 미확인 값을 만들지 않는다',()=>{
const m=buildPier(input),rows=bearingSchedule(m);
expect(rows).toHaveLength(m.blocks.length);expect(rows[0].A).toBe(m.soles[0].aMm);expect(rows[0].T1).toBe(m.blocks[0].plan.T1);
const b=m.blocks[0];expect(rows[0].h1).toBeCloseTo((b.top-b.bottom)*1000,7);expect(rows[0].elx).toBe(b.padTop);
// Explicit test parameters, not application defaults or source drawing dimensions.
const actual=buildPier({...input,sole:{h3Mm:190,marginMm:120,plates:Object.fromEntries(input.bearings.map(b=>[String(b.no),{aMm:720,bMm:650}])),tByRow:{전열:35,후열:35},slopePct:Object.fromEntries(input.bearings.map(b=>[String(b.no),-2.5]))}});
const r=bearingSchedule(actual)[0];
expect(r.L).toBeCloseTo(840,8);expect(r.W).toBeCloseTo(770,8);expect(r.T1).toBeCloseTo(46,8);expect(r.T2).toBeCloseTo(25,8);
expect(r.E).toBeCloseTo(r.h1+r.h2+190+35,8);
expect(r.C).not.toBeNull();expect(bearingSchedule(actual).find(x=>x.row==='후열')!.C).toBeNull();
const drawing=generateDrawing(actual,input,project,DEFAULT_SETTINGS),table=drawing.views.find(v=>v.id==='schedule')!;
expect(table.entities.filter(e=>e.kind==='text').some(e=>e.text==='46')).toBe(true);
expect(drawing.views.find(v=>v.id==='detail')!.entities.some(e=>e.source.member==='solplate')).toBe(true);
const cd=drawing.views.find(v=>v.id==='detail')!.entities.find(e=>e.kind==='dimension');
expect(cd?.kind==='dimension'?cd.value:null).toBeCloseTo(r.C!,6);
const detail=drawing.views.find(v=>v.id==='detail')!;
for(const key of ['A','B','C','T1','T2'] as const){
const dimension=detail.entities.find(e=>e.kind==='dimension'&&e.label===key);
expect(dimension?.kind==='dimension'?dimension.value:null).toBeCloseTo(r[key]!,6);
}
expect(detail.entities.some(e=>e.kind==='text'&&e.text==='S(%)')).toBe(true);
expect(detail.entities.some(e=>e.kind==='text'&&e.text==='h1')).toBe(true);
const symbolicDxf=exportDxf(drawing,layers);
expect(symbolicDxf).toContain('\r\n1\r\nT1\r\n');
expect(drawingSvg(drawing,layers,true)).toContain('T1');
const uneven={...input,bearings:input.bearings.filter(b=>!(b.row==='전열'&&b.no===5))},um=buildPier(uneven);
expect(bearingSchedule(um)).toHaveLength(uneven.bearings.length);
const ud=generateDrawing(um,uneven,project,{...DEFAULT_SETTINGS,detailBearing:'후열:10'});
expect(ud.views.find(v=>v.id==='detail')!.title).toContain('10(후)');
const ub=bearingSchedule(um).find(r=>r.no===10&&r.row==='후열')!;expect(ub.C).toBeNull();expect(ub.D).toBeNull();
});
it('원시 CAD 객체와 DXF 검증 산출물을 생성한다',()=>{
const doc=generateDrawing(buildPier(input),input,project,DEFAULT_SETTINGS);
const svg=drawingSvg(doc,layers,true),dxf=exportDxf(doc,layers);
expect(svg).not.toMatch(/NaN|Infinity/);expect(dxf).not.toMatch(/NaN|Infinity/);
expect(dxf).toContain('DIMENSION');expect(dxf).toContain('PZ_GA');
expect(drawingSvg(doc,new Set(['STRUCTURE']))).not.toContain('EL. ');
mkdirSync(resolve(import.meta.dirname,'.cache'),{recursive:true});
writeFileSync(resolve(import.meta.dirname,'.cache/generated-general.svg'),svg);
writeFileSync(resolve(import.meta.dirname,'.cache/generated-general.dxf'),dxf);
writeFileSync(resolve(import.meta.dirname,'.cache/generated-quality.json'),JSON.stringify(doc.quality,null,2));
writeFileSync(resolve(import.meta.dirname,'.cache/generated-issues.json'),JSON.stringify(doc.quality?.issues.map(q=>({...q,a:doc.views.find(v=>v.id===q.view)?.entities[q.first],b:doc.views.find(v=>v.id===q.view)?.entities[q.second??-1]})),null,2));
writeFileSync(resolve(import.meta.dirname,'.cache/expected-dimensions.json'),JSON.stringify(doc.views.flatMap(v=>v.entities.filter(e=>e.kind==='dimension').map(e=>e.value))));
});
it('첨부 DWG의 실제 도곽/노트와 편집 값을 출력에 공유한다',()=>{
expect(REFERENCE_TEMPLATE.sha256).toBe('156570dc5389650e20840e91c3d9a974a92c40687669f8d00aafb62073fb2b6b');
expect(REFERENCE_TEMPLATE.fields).toHaveLength(38);expect(REFERENCE_TEMPLATE.entities.length).toBeGreaterThan(200);
expect(REFERENCE_TEMPLATE.notes).toContain('콘크리트 기준 압축강도');
const sheet={...defaultSheet(),notes:'현 프로젝트 검토 노트',fields:{TD_DNAME_MAIN:'일반도 · {{pier}}',TD_CCOMP:'검증 시공자'}};
const doc=generateDrawing(buildPier(input),input,project,{...DEFAULT_SETTINGS,sheet}),frame=sheetFrame(doc);
expect(frame.some(e=>e.kind==='text'&&e.text==='일반도 · P1')).toBe(true);
expect(frame.some(e=>e.kind==='text'&&e.text==='현 프로젝트 검토 노트')).toBe(true);
expect(drawingSvg(doc,layers)).toContain('검증 시공자');expect(exportDxf(doc,layers)).toContain('검증 시공자');
expect(parseSheetConfig(JSON.parse(JSON.stringify({...sheet,template:REFERENCE_TEMPLATE})))).toMatchObject(sheet);
expect(()=>parseSheetConfig({...sheet,noteHeight:NaN})).toThrow();
expect(()=>parseSheetConfig({...sheet,template:{...REFERENCE_TEMPLATE,entities:[{kind:'line',a:[NaN,0],b:[0,1],layer:'FRAME',source:{}}]}})).toThrow();
});
it('키플랜은 현재 프로젝트의 실제 교각만 표시하고 등각도는 모델에서 투영한다',()=>{
const s=samples.find(s=>s.file==='sample02.pzr.json'),models=s.piers.map((p:{input:PierInput})=>buildPier(p.input));
const before=JSON.stringify(models),doc=generateDrawing(models[0],input,s.project,DEFAULT_SETTINGS,{models});
const key=doc.views.find(v=>v.id==='keyplan')!,iso=doc.views.find(v=>v.id==='iso')!;
expect(key.entities.filter(e=>e.source.member==='keyplan-label')).toHaveLength(models.length);
expect(new Set(key.entities.filter(e=>e.source.member==='keyplan-label').map(e=>e.source.pier))).toEqual(new Set(models.map((m:ReturnType<typeof buildPier>)=>m.name)));
expect(iso.entities.some(e=>e.kind==='hatch')).toBe(true);
const other=generateDrawing(models[0],input,s.project,{...DEFAULT_SETTINGS,isoDirection:1},{models}).views.find(v=>v.id==='iso')!;
expect(iso.entities).not.toEqual(other.entities);expect(JSON.stringify(models)).toBe(before);
});
it('콘크리트 해치는 생략부를 메우지 않고 실치수 모델은 유지한다',()=>{
const doc=generateDrawing(buildPier(input),input,project,DEFAULT_SETTINGS),front=doc.views[0];
expect(front.entities.some(e=>e.kind==='hatch')).toBe(true);
const cut=front.breaks!.find(c=>c.member==='column')!;
const column=front.entities.find(e=>e.kind==='hatch'&&e.source.member==='column');
expect(column?.kind).toBe('hatch');
if(column?.kind==='hatch')for(const p of cutHatch(column.points,[cut]))expect(p.every(q=>q[1]<=cut.from+1e-6)||p.every(q=>q[1]>=cut.to-1e-6)).toBe(true);
const none=generateDrawing(buildPier(input),input,project,{...DEFAULT_SETTINGS,hatches:false});
expect(none.views.flatMap(v=>v.entities).filter(e=>e.kind==='hatch')).toHaveLength(0);
expect(front.entities.filter(e=>e.kind==='dimension').map(e=>e.kind==='dimension'?e.value:0)).toEqual(none.views[0].entities.filter(e=>e.kind==='dimension').map(e=>e.kind==='dimension'?e.value:0));
});
it('전체 치수는 외측 레인에 배치하고 출력 문자 크기와 실제 점유 범위를 검증한다',()=>{
const doc=generateDrawing(buildPier(input),input,project,DEFAULT_SETTINGS),front=doc.views[0];
const info=doc.views.find(v=>v.id==='iso')!.entities.filter(e=>e.kind==='text'&&e.source.member==='iso-info');
expect(info.some(e=>e.kind==='text'&&e.text==='지반고')).toBe(true);
expect(info.some(e=>e.kind==='text'&&e.text===`EL. ${input.groundEl.toFixed(3)}`)).toBe(true);
expect(info.some(e=>e.kind==='text'&&e.text==='말뚝 하단')).toBe(true);
const iso=doc.views.find(v=>v.id==='iso')!;
expect(iso.entities.some(e=>e.kind==='dimension'&&e.label?.startsWith('H='))).toBe(true);
expect(iso.entities.some(e=>e.kind==='line'&&e.source.member==='iso-info')).toBe(true);
expect(doc.quality?.geometryUnchanged).toBe(true);expect(doc.quality?.after).toBe(0);expect(doc.quality!.minimumTextMm).toBeGreaterThanOrEqual(2.5);
const vertical=front.entities.filter((e):e is Extract<typeof e,{kind:'dimension'}>=>e.kind==='dimension'&&e.axis==='y');
const overall=vertical.reduce((a,b)=>a.value>b.value?a:b);
expect(overall.position).toBe(Math.min(...vertical.map(d=>d.position)));
expect(doc.views.slice(0,4).every(v=>v.paperOrigin&&v.slot.width>0&&v.slot.height>0)).toBe(true);
expect(doc.views[0].scale).toBe(doc.views[1].scale);
const table=doc.views.find(v=>v.id==='schedule')!;
expect(table.slot.y+table.slot.height).toBeLessThan(537*doc.paper.height/594);
});
it('형상에 따라 배치가 변하고 같은 입력의 배치는 반복 가능하다',()=>{
const model=buildPier(input),a=generateDrawing(model,input,project,DEFAULT_SETTINGS),b=generateDrawing(model,input,project,DEFAULT_SETTINGS);
expect(a.quality).toEqual(b.quality);
const otherInput=samples.find(s=>s.file==='sample01.pzr.json').piers.find((p:{input:PierInput})=>p.input.type==='2주형').input;
const c=generateDrawing(buildPier(otherInput),otherInput,project,DEFAULT_SETTINGS);
expect(c.views.slice(0,4).map(v=>v.slot)).not.toEqual(a.views.slice(0,4).map(v=>v.slot));
const crowded=generateDrawing(model,input,project,{...DEFAULT_SETTINGS,scale:25});
expect(crowded.views[0].scale).toBe(25);expect(crowded.quality!.issues.some(i=>i.kind==='outside'||i.kind==='view-overlap')).toBe(true);
});
it('문자 회전과 실제 간섭을 검사하며 검사를 무조건 통과시키지 않는다',()=>{
const doc=generateDrawing(buildPier(input),input,project,DEFAULT_SETTINGS),v=doc.views[0];
const text={kind:'text' as const,point:[0,0] as [number,number],text:'123456',height:3,rotation:90,layer:'TEXT' as const,source:{pier:'P1',member:'annotation',index:0}};
const bounds=entityBox(text);expect(bounds.maxY-bounds.minY).toBeGreaterThan(bounds.maxX-bounds.minX);
expect(viewCollisions({...v,scale:1,entities:[text,{...text}],breaks:[]})).toHaveLength(1);
});
});