import type { Bounds,DrawingDocument,DrawingView,Entity,Point,QualityIssue,Rect,ViewId } from './types'; import { CAD_STYLE } from './cad-style'; import { entityBox,occupiedBounds,overlaps,textWidth,union,viewCollisions } from './occupancy'; import { presentationEntities } from './presentation'; import { shortenedY } from './breaks'; import { frameQuality } from './frame-quality'; export const DRAWING_SCALES=[25,40,50,75,100,125,150,200,250,300,400,500,750,1000,1500,2000,3000,5000]; type Factory=(id:ViewId,scale:number)=>DrawingView; const physical=(b:Bounds,s:number):Bounds=>({minX:b.minX/s,minY:b.minY/s,maxX:b.maxX/s,maxY:b.maxY/s}); function normalise(view:DrawingView):void{ const table=['schedule','heights'].includes(view.id); view.entities=view.entities.map(e=>{ if(e.kind==='dimension')return{...e,height:CAD_STYLE.dimensionText*view.scale}; if(e.kind==='text')return{...e,height:Math.max(e.height,(table?CAD_STYLE.tableText:e.layer==='ELEVATION'?CAD_STYLE.elevationText:CAD_STYLE.text)*view.scale)}; return e; }); } /** Interval lanes: nested/overall dimensions are placed after the shorter detail intervals. */ function dimensionLanes(view:DrawingView):void{ const s=view.scale,b=view.geometryBounds,wide=view.id==='front'||view.id==='side'; const ordinary=presentationEntities({...view,entities:view.entities.filter(e=>e.kind!=='dimension')}).filter(p=>p.entity.layer!=='CENTER'); const base=union([physical({minX:b.minX,maxX:b.maxX,minY:shortenedY(b.minY,view.breaks),maxY:shortenedY(b.maxY,view.breaks)},s),...ordinary.map(p=>physical(entityBox(p.entity),s))]); const groups=new Map[]>(); for(const e of view.entities)if(e.kind==='dimension'){ const visualMid=(shortenedY(b.minY,view.breaks)+shortenedY(b.maxY,view.breaks))/2; const key=e.axis==='x'?(shortenedY(e.position,view.breaks)a.value-b.value); for(const e of dims){ const a=e.axis==='x'?e.a[0]/s:shortenedY(e.a[1],view.breaks)/s,c=e.axis==='x'?e.b[0]/s:shortenedY(e.b[1],view.breaks)/s; const lo=Math.min(a,c),hi=Math.max(a,c),width=textWidth(e.label??e.value.toLocaleString('en-US',{maximumFractionDigits:1}),CAD_STYLE.dimensionText),mid=(lo+hi)/2; const item={lo,hi,textLo:mid-width/2-.6,textHi:mid+width/2+.6}; let lane=0; while(lanes[lane]?.some(p=>Math.min(p.hi,item.hi)-Math.max(p.lo,item.lo)>.05||Math.min(p.textHi,item.textHi)-Math.max(p.textLo,item.textLo)>0))lane++; (lanes[lane]??=[]).push(item); const offset=lane*CAD_STYLE.laneGap; e.position=(side==='left'?base.minX-3-offset:side==='right'?base.maxX+6+offset:side==='bottom'?b.minY/s+(base.minY-shortenedY(b.minY,view.breaks)/s)-6-offset: b.maxY/s+(base.maxY-shortenedY(b.maxY,view.breaks)/s)+3+offset)*s; } } } function rawY(view:DrawingView,y:number):number{ let removed=0;for(const c of view.breaks??[]){const lo=c.from-removed,hi=lo+c.gap;if(y<=lo)return y+removed;if(y=>e.kind==='text'&&e.layer==='ELEVATION').sort((a,b)=>a.point[1]-b.point[1]); let previous=-Infinity; for(const e of labels){ const old=[...e.point],display=shortenedY(e.point[1],view.breaks),y=Math.max(display,previous+4.5*s);previous=y; e.point=[view.geometryBounds.maxX+4*s,rawY(view,y)]; const leader=view.entities.find(x=>x.kind==='line'&&x.layer==='ELEVATION'&&Math.abs(x.b[1]-(old[1]-.3*s))=>e.kind==='text'&&e.source.member!=='bearing-number'); const placed:Bounds[]=presentationEntities(view).filter(p=>p.entity.kind==='text'&&(p.entity.layer==='DIMENSION'||p.entity.source.member==='bearing-number')).map(p=>entityBox(p.entity)); for(const e of labels){ const old=[...e.point] as Point;let moves=0; while(placed.some(b=>overlaps(b,entityBox(e),-.2*view.scale))&&moves<32){e.point=[e.point[0],e.point[1]+4*view.scale];moves++;} placed.push(entityBox(e)); if(moves&&['h1','h2','h3','T'].includes(e.text)){ const leader=view.entities.find(l=>l.kind==='line'&&Math.abs(l.b[1]-old[1])<1e-6&&Math.abs(Math.abs(l.b[0]-old[0])-view.scale)<1e-6); if(leader?.kind==='line')leader.b=[leader.b[0],e.point[1]]; } } } if(view.id==='keyplan'){ const used:Bounds[]=[]; const priority=(e:Extract)=>e.text==='N'?0:e.source.member==='keyplan-label'?2:1; for(const e of view.entities.filter((e):e is Extract=>e.kind==='text').sort((a,b)=>priority(a)-priority(b)||a.point[0]-b.point[0])){ const old=[...e.point] as Point;let steps=0; while(used.some(b=>overlaps(b,entityBox(e),-.3*view.scale))&&steps<8){e.point=[e.point[0],e.point[1]+4*view.scale];steps++;} used.push(entityBox(e));if(steps)view.entities.push({kind:'line',a:[old[0]-1.8*view.scale,old[1]-2*view.scale],b:[e.point[0],e.point[1]-.5*view.scale],layer:'CENTER',source:{...e.source,member:'keyplan-leader'}}); } } if(['front','side','top','footing','section'].includes(view.id))dimensionLanes(view); const b=occupiedBounds(view);view.bounds={minX:b.minX-.8*view.scale,maxX:b.maxX+.8*view.scale,minY:rawY(view,b.minY-.8*view.scale),maxY:rawY(view,b.maxY+.8*view.scale)}; return view; } const collisionCache=new WeakMap(); const collisionCount=(v:DrawingView):number=>{let n=collisionCache.get(v);if(n===undefined){n=viewCollisions(v).length;collisionCache.set(v,n);}return n;}; const measureCache=new WeakMap(); const measure=(v:DrawingView):Bounds=>{let b=measureCache.get(v);if(!b){b=physical(occupiedBounds(v),v.scale);measureCache.set(v,b);}return b;}; function fit(view:DrawingView,area:Rect):void{ const b=measure(view),w=b.maxX-b.minX,h=b.maxY-b.minY; view.paperOrigin=[area.x+(area.width-w)/2-b.minX,area.y+(area.height-h)/2+b.maxY]; view.slot={x:view.paperOrigin[0]+b.minX,y:view.paperOrigin[1]-b.maxY,width:w,height:h}; } /** Preserve template zoning, solve view scales, rows and column widths from measured CAD content. */ export function optimiseLayout(doc:DrawingDocument,factory:Factory):void{ const geometrySignature=()=>JSON.stringify(doc.views.filter(v=>['front','side','top','footing','section'].includes(v.id)).map(v=>v.entities.filter(e=>['STRUCTURE','BEARING','HIDDEN'].includes(e.layer)&&e.kind!=='hatch'))); const originalGeometry=geometrySignature(); const before=doc.views.filter(v=>!['iso','keyplan'].includes(v.id)).reduce((n,v)=>n+viewCollisions(v).length,0); const cache=new Map(); const get=(id:ViewId,scale:number)=>{const key=`${id}:${scale}`;if(!cache.has(key))cache.set(key,finish(factory(id,scale)));return cache.get(key)!;}; const k=doc.paper.width/841,ky=doc.paper.height/594,major:Rect={x:54*k,y:55*ky,width:466*k,height:472*ky},gap=CAD_STYLE.viewGap; const choices=doc.settings.scale==='auto'?DRAWING_SCALES:[doc.settings.scale]; let best:{views:DrawingView[];bs:Bounds[];left:number;right:number;top:number;bottom:number;score:number}|undefined; for(const elevation of choices){ for(const plan of doc.settings.scale==='auto'?choices.filter(p=>p>=elevation*.7&&p<=elevation*1.8):choices){ const vs=[get('front',elevation),get('side',elevation),get('top',plan),get('footing',plan)],bs=vs.map(measure); const left=Math.max(bs[0].maxX-bs[0].minX,bs[2].maxX-bs[2].minX),right=Math.max(bs[1].maxX-bs[1].minX,bs[3].maxX-bs[3].minX); const top=Math.max(bs[0].maxY,bs[1].maxY)-Math.min(bs[0].minY,bs[1].minY),bottom=Math.max(bs[2].maxY-bs[2].minY,bs[3].maxY-bs[3].minY); const overflow=Math.max(0,left+right+gap-major.width)+Math.max(0,top+bottom+gap+12-major.height); const score=overflow*10000+vs.reduce((n,v)=>n+collisionCount(v),0)*100000+elevation+plan*.45+Math.abs(elevation-plan)*.1; if(!best||scorebest.views[0].scale)break; } if(!best)return; const extraW=Math.max(0,major.width-best.left-best.right-gap),leftW=best.left+extraW*.55,rightW=best.right+extraW*.45; const extraH=Math.max(0,major.height-best.top-best.bottom-gap-12),topY=major.y+5+extraH*.2,bottomY=topY+best.top+gap+6+extraH*.3; const maxTop=Math.max(best.bs[0].maxY,best.bs[1].maxY); for(let i=0;i<4;i++){ const v=best.views[i],b=best.bs[i],left=i%2===0,x=left?major.x:major.x+leftW+gap,w=left?leftW:rightW; const y=i<2?topY:bottomY; v.paperOrigin=[x+(w-(b.maxX-b.minX))/2-b.minX,y+(i<2?maxTop:b.maxY)]; v.slot={x:v.paperOrigin[0]+b.minX,y:v.paperOrigin[1]-b.maxY,width:b.maxX-b.minX,height:b.maxY-b.minY}; doc.views[doc.views.findIndex(p=>p.id===v.id)]=v; } // One aligned geometry centre per column; the top/bottom views retain independent scales. for(const column of [[best.views[0],best.views[2]],[best.views[1],best.views[3]]]){ const centre=column[0].paperOrigin![0]+(column[0].geometryBounds.minX+column[0].geometryBounds.maxX)/(2*column[0].scale); const v=column[1],b=measure(v);v.paperOrigin![0]=centre-(v.geometryBounds.minX+v.geometryBounds.maxX)/(2*v.scale);v.slot.x=v.paperOrigin![0]+b.minX; } // Coordinate callouts may be asymmetric: retain alignment only within the major drawing zone. for(const v of best.views){const b=measure(v),left=v.paperOrigin![0]+b.minX,right=v.paperOrigin![0]+b.maxX; const shift=right>major.x+major.width?major.x+major.width-right:leftv.id==='schedule')!),tb=measure(table),tableHeight=tb.maxY-tb.minY; fit(table,{x:538*k,y:527*ky-tableHeight,width:280*k,height:tableHeight}); const small:Rect={x:538*k,y:46*ky,width:126*k,height:Math.min(table.slot.y-9,365*ky)-46*ky}; let pair:{iso:DrawingView;detail:DrawingView;ih:number;dh:number;score:number}|undefined; for(const isoScale of DRAWING_SCALES)for(const detailScale of DRAWING_SCALES.filter(s=>s<=300)){ const iso=get('iso',isoScale),detail=get('detail',detailScale),a=measure(iso),b=measure(detail),iw=a.maxX-a.minX,dw=b.maxX-b.minX,ih=a.maxY-a.minY,dh=b.maxY-b.minY; const over=Math.max(0,Math.max(iw,dw)-small.width)+Math.max(0,ih+dh+14-small.height),score=over*10000+collisionCount(detail)*100000+isoScale+detailScale; if(!pair||scorep.id===v.id)]=v; } const keyOriginal=doc.views.find(v=>v.id==='keyplan')!;let keyView:DrawingView|undefined,keyScore=Infinity; for(const ratio of [.8,1,1.1,1.25,1.5,2,3]){const v=get('keyplan',Math.ceil(keyOriginal.scale*ratio/100)*100),b=measure(v),w=b.maxX-b.minX,h=b.maxY-b.minY,over=Math.max(0,w-122*k)+Math.max(0,h-58*ky);const score=over*10000+v.scale;if(scorev.id==='keyplan')]=keyView;} for(const id of ['heights','section'] as ViewId[]){const original=doc.views.find(v=>v.id===id)!;const v=finish(original);fit(v,original.slot);} const visible=doc.views.filter(v=>!['heights','section'].includes(v.id)),issues:QualityIssue[]=visible.flatMap(viewCollisions),occupied:Record={}; let minimumTextMm=Infinity; for(const v of visible){occupied[v.id]=v.slot;for(const {entity} of presentationEntities(v))if(entity.kind==='text')minimumTextMm=Math.min(minimumTextMm,entity.height/v.scale); if(v.slot.x<23*k||v.slot.y<20*ky||v.slot.x+v.slot.width>822*k||v.slot.y+v.slot.height>532*ky)issues.push({view:v.id,kind:'outside',first:-1,message:'도면 점유 영역이 출력 가능 영역을 넘습니다.'}); } for(let i=0;i({minX:v.slot.x,minY:v.slot.y-4.5,maxX:v.slot.x+v.slot.width,maxY:v.slot.y+v.slot.height});if(overlaps(rect(a),rect(b),.5))issues.push({view:a.id,kind:'view-overlap',first:-1,message:`${a.title} / ${b.title} 점유 영역 중첩`});} const reserved=[{x:680*k,y:82*ky,width:140*k,height:273*ky},{x:50*k,y:537*ky,width:770*k,height:57*ky}]; for(const v of visible)for(const r of reserved)if(overlaps({minX:v.slot.x,minY:v.slot.y-6,maxX:v.slot.x+v.slot.width,maxY:v.slot.y+v.slot.height},{minX:r.x,minY:r.y,maxX:r.x+r.width,maxY:r.y+r.height},.5))issues.push({view:v.id,kind:'view-overlap',first:-1,message:'일반사항/표제란 보호 영역과 중첩'}); issues.push(...frameQuality(doc)); doc.quality={before,after:issues.length,issues,occupied,minimumTextMm,layout:'reference-zones / measured CAD bounds / interval dimension lanes',geometryUnchanged:originalGeometry===geometrySignature()}; if(!doc.quality.geometryUnchanged)throw new Error('배치 중 원본 투영 형상이 바뀌었습니다. 출력을 중단합니다.'); if(issues.length)doc.warnings.push(`도면 품질 검사: 간섭/범위 확인 ${issues.length}건. 품질 검사 결과를 확인하세요.`); }