Files
Pier_v1/도면작성/quality-layout.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

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}건. 품질 검사 결과를 확인하세요.`);
}