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.
173 lines
14 KiB
TypeScript
173 lines
14 KiB
TypeScript
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<string,Extract<Entity,{kind:'dimension'}>[]>();
|
|
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)<visualMid?'bottom':'top'):(wide||e.position<(b.minX+b.maxX)/2?'left':'right');
|
|
if(!groups.has(key))groups.set(key,[]);groups.get(key)!.push(e);
|
|
}
|
|
for(const [side,dims] of groups){
|
|
const lanes:{lo:number;hi:number;textLo:number;textHi:number}[][]=[];
|
|
dims.sort((a,b)=>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<hi)return c.from+(y-lo)/c.gap*(c.to-c.from);removed+=c.to-c.from-c.gap;}return y+removed;
|
|
}
|
|
function tidyElevations(view:DrawingView):void{
|
|
const s=view.scale,labels=view.entities.filter((e):e is Extract<Entity,{kind:'text'}>=>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))<s*.15);
|
|
if(leader?.kind==='line')leader.b=[e.point[0]-.8*s,e.point[1]-.3*s];
|
|
}
|
|
}
|
|
function finish(view:DrawingView):DrawingView{
|
|
measureCache.delete(view);
|
|
normalise(view);tidyElevations(view);
|
|
if(view.id==='detail'){
|
|
// Shorter current bearings bring symbolic height leaders near dimension labels.
|
|
// Keep dimension endpoints and numbered circles fixed; move only annotation text.
|
|
const labels=view.entities.filter((e):e is Extract<Entity,{kind:'text'}>=>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<Entity,{kind:'text'}>)=>e.text==='N'?0:e.source.member==='keyplan-label'?2:1;
|
|
for(const e of view.entities.filter((e):e is Extract<Entity,{kind:'text'}>=>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<DrawingView,number>();
|
|
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<DrawingView,Bounds>();
|
|
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<string,DrawingView>();
|
|
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||score<best.score)best={views:vs,bs,left,right,top,bottom,score};
|
|
}
|
|
// The smallest feasible elevation scale is preferred; larger denominators cannot improve clarity.
|
|
if(best&&best.score<10000&&elevation>best.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:left<major.x?major.x-left:0;
|
|
v.paperOrigin![0]+=shift;v.slot.x+=shift;
|
|
}
|
|
const table=finish(doc.views.find(v=>v.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||score<pair.score)pair={iso,detail,ih,dh,score};
|
|
}
|
|
if(pair){
|
|
const remaining=Math.max(0,small.height-pair.ih-pair.dh-14),isoY=small.y+remaining*.2;
|
|
fit(pair.iso,{x:small.x,y:isoY,width:small.width,height:pair.ih});fit(pair.detail,{x:small.x,y:isoY+pair.ih+14+remaining*.3,width:small.width,height:pair.dh});
|
|
for(const v of [pair.iso,pair.detail])doc.views[doc.views.findIndex(p=>p.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(score<keyScore){keyScore=score;keyView=v;}}
|
|
if(keyView){const b=measure(keyView),height=b.maxY-b.minY;fit(keyView,{x:687*k,y:78*ky-height,width:122*k,height});doc.views[doc.views.findIndex(v=>v.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<string,Rect>={};
|
|
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<visible.length;i++)for(const b of visible.slice(i+1)){const a=visible[i],rect=(v:DrawingView):Bounds=>({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}건. 품질 검사 결과를 확인하세요.`);
|
|
}
|