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