Improve drawing export and foundation quantity views

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
CopilotandCopilot committed 2026-11-06 15:35:56 +09:00
1 parent a0ec74b09b
commit d22c5008f0
72 files changed
+24346 -12

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# DWG 호환성 점검 (2026-11-06)
현재 번들 LibreDWG dxf2dwg는 R2000/R2004까지만 지원하고 최신 버전은 지원하지 않는다. 기존 구현은 R2004 객체 누락 때문에 R2000을 사용한다. 이번에 버전 표기만 변경하거나 검증되지 않은 최신 버전을 생성하지 않았다.
최근 출력 원본 source.dxf를 재변환했다. R2000 미지원 MATERIAL 및 ACAD_MATERIAL 사전을 변환용 중간 DXF에서 제외하고, 재질 참조를 초기화했다. 원본 DXF와 프로젝트 데이터는 변경하지 않는다. ezdxf 저장에는 기본 재질이 필요하므로 저장 후 변환 입력에만 적용한다. 색상은 기존 ACI 변환을 유지한다.
결과: output/compatibility-20261106/general.dwg. 모델 8911객체, 배치 9463객체, 치수 44개 및 객체 지문/레이어/문자스타일/출력설정 일치. 이전 잘못된 MATERIAL placeholder 복구 3건은 0건으로 줄었다. 최대 치수 오차 1.1e-11mm.
단, 동일 LibreDWG 재읽기 검증이며 실제 사용자 CAD 열기 검증이 아니다. decoder에는 중복 핸들 #144 및 ENDBLK 경고가 남는다. 따라서 이 수정이 열리지 않는 원인을 확정 해결했다고 판정하지 않는다. CAD 제품·버전·오류 메시지에 따라 독립 CAD 읽기 또는 신뢰할 수 있는 최신 DWG 변환기가 필요하다.
+10
View File
@@ -121,3 +121,13 @@ npx vite build --outDir 도면작성/.cache/build
- A-A는 첫 기둥의 일정한 원형 단면이다. 생략부에 함께 표시하고 개별 단면 보기로도 확인한다. 임의 절단 위치, 복잡한 부재 단면, 모든 원본 상세의 자동 인식은 미구현이다.
- 부재 간 완전한 가림 판정, CAD 실제 글꼴을 포함한 완전한 간섭 판정, 다주 교각의 모든 개별 높이 연쇄 치수, 모델 공간과 연동되는 DXF 뷰포트, AutoCAD 실검증은 후속 범위이다.
- 대호지교/감북교의 기존 현장타설말뚝 후속 화면 오류와 승천천교의 원본 방위각 누락 등 기존 샘플 제약은 그대로 유지한다. 도면작성 때문에 외부 기능을 수정하지 않았다.
### 아이소메트릭 높이·EL 정보
표고 EL 설정이 켜져 있으면 아이소메트릭 구조물의 실제 투영점에서 지반고, 말뚝/기초 하단, 교좌면(블럭 상면), 몰탈 상면으로 지시선을 연결한다. 기둥 높이 H는 실제 상·하단 사이 치수선(mm)으로 표시하고 EL은 m 단위다. 방향 변경 시 동일한 투영식을 사용하며 구조물 아래 정보 목록은 제거했다. 현재 각 부재 종류의 첫 부재를 지시하며 없는 부재는 지시선을 생성하지 않는다. 지반고는 입력 지반고가 첫 기둥 수직선과 만나는 높이를 가리키며 지표면 형상을 새로 생성하지 않는다. 치수/EL 표시 설정을 따르고 모델은 변경하지 않는다.
### 기초 좌표·말뚝 주석
기초 평면에는 현재 직사각형 기초의 실제 네 모서리 X(N)·Y(E)를 m 단위 소수 3자리로 지시한다. 로컬 모델점을 회전·위치변환한 좌표를 사용하며, 참고 도면의 모따기 형상·좌표를 복사하지 않는다. 횡단면도에는 기초 상단의 C-C 절단 위치 지시선이 있고 기초 평면 제목과 연결한다. 기존 A-A 기둥 단면 지시선도 유지한다. 말뚝 생략 경계의 간격을 좁혀 이중 꺾임으로 표시하며 모델 공간과 길이값은 그대로 둔다. 횡단면/측면에는 입력 말뚝 종류(없는 경우 미확인), 본수, 실제 외경, 확인 가능한 두께 및 모델 길이 범위를 지시선 주석으로 표시한다. '내부굴착복합말뚝' 등 미입력 공법명은 추정하지 않는다.
+1 -1
View File
@@ -12,7 +12,7 @@ export function modelBreaks(model:PierModel,scale:number): ViewBreak[] {
const keepBottom=Math.max(member==='column'?900:350,diameter*.65);
const keepTop=Math.max(member==='column'?1300:500,diameter*.9);
const from=bottom+keepBottom,to=top-keepTop;
const gap=member==='column'?diameter+6*scale:3*scale;
const gap=member==='column'?diameter+6*scale:1.5*scale;
if(to-from>Math.max(gap+500,scale*10))result.push({from,to,gap,member});
}
return result.sort((a,b)=>a.from-b.from).filter((b,i,all)=>i===0||b.from>=all[i-1].to);
+19
View File
@@ -1,3 +1,4 @@
import { foundationNotes } from './foundation-notes';
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
@@ -114,6 +115,17 @@ describe('실제 모델 → 일반도',()=>{
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).toBeNull();expect(rows[0].T1).toBeNull();
@@ -200,6 +212,13 @@ describe('실제 모델 → 일반도',()=>{
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);
+10 -6
View File
@@ -1,3 +1,4 @@
import { foundationNotes } from './foundation-notes';
import { sectionCut } from './reference-symbols';
import { Vector3 } from 'three';
import type { PierInput } from '../src/core/types';
@@ -6,7 +7,7 @@ import { entityBounds, localPoint, modelPrimitives, projectModel, projectPoint }
import { modelBreaks, shortenedBounds } from './breaks';
import { bearingDetail, scheduleEntities } from './bearing-detail';
import { templateSlots, defaultSheet, wrappedLines } from './template';
import { isometricEntities, keyplanEntities } from './project-views';
import { isometricEntities, isometricInfo, keyplanEntities } from './project-views';
import { hatchGeometry } from './hatches';
import { componentDimensions } from './component-dimensions';
import { optimiseLayout } from './quality-layout';
@@ -15,7 +16,7 @@ import type { Bounds, DrawingDocument, DrawingSettings, DrawingView, Entity, Poi
export const DEFAULT_SETTINGS: DrawingSettings = { paper:'A3',scale:'auto',dimensions:true,centers:true,elevations:true,shorten:true,hatches:true,isoDirection:0 };
const SCALES=[10,20,25,30,40,50,75,100,150,200,250,300,400,500,750,1000,1500,2000];
export const VIEW_TITLES: Record<ViewId,string>={front:'횡단면도',side:'단면도 · 측면',top:'단면도 · 두부보 평면',footing:'단면도 · 기초 평면',section:'A-A 기둥 단면',detail:'상세 A · 받침 및 교좌면',schedule:'받침 및 교좌면 상세 변화 치수표',heights:'받침 높이·표고표',iso:'ISOMETRIC VIEW',keyplan:'KEY PLAN'};
export const VIEW_TITLES: Record<ViewId,string>={front:'횡단면도',side:'단면도 · 측면',top:'두부보 평면도',footing:'단면도 C-C · 기초 평면',section:'A-A 기둥 단면',detail:'상세 A · 받침 및 교좌면',schedule:'받침 및 교좌면 상세 변화 치수표',heights:'받침 높이·표고표',iso:'ISOMETRIC VIEW',keyplan:'KEY PLAN'};
function slots(width: number,height: number): Record<ViewId,Rect> {
return templateSlots(width,height);
@@ -25,7 +26,7 @@ function recommend(bounds: Bounds,slot: Rect,id: ViewId): number {
const need=Math.max((bounds.maxX-bounds.minX)/Math.max(10,slot.width-(side?36:24)),(bounds.maxY-bounds.minY)/Math.max(10,slot.height-(id==='top'?47:31)));
return SCALES.find(s=>s>=need)??Math.ceil(need/500)*500;
}
function annotate(model: PierModel,id: ViewId,b: Bounds,scale: number,settings: DrawingSettings,geometry?:Entity[]): Entity[] {
function annotate(model: PierModel,id: ViewId,b: Bounds,scale: number,settings: DrawingSettings,input:PierInput,geometry?:Entity[]): Entity[] {
const out: Entity[]=[],source: Source={member:'annotation',index:0,pier:model.name},h=2.3*scale;
const line=(a:Point,c:Point,layer:Entity['layer']='CENTER')=>out.push({kind:'line',a,b:c,layer,source});
const text=(p:Point,value:string,layer:Entity['layer']='TEXT',align:'left'|'center'='left')=>out.push({kind:'text',point:p,text:value,height:h,layer,source,align});
@@ -110,6 +111,8 @@ function annotate(model: PierModel,id: ViewId,b: Bounds,scale: number,settings:
out.push({kind:'hatch',points:Array.from({length:16},(_,i)=>[p[0]+.5*scale*Math.cos(i*Math.PI/8),p[1]+.5*scale*Math.sin(i*Math.PI/8)] as Point),pattern:'SOLID',spacing:scale,angle:0,color:'#ff0000',layer:'SECTION',source});
text([end[0]+scale,end[1]+scale],`말뚝 Ø${formatMm(pile.diameter*1000)} · ${model.piles.length}본`);
}
out.push(...foundationNotes(model,input,id,b,scale));
if(id==='front'&&model.footings[0]){const y=model.footings[0].top*1000;line([b.minX-3*scale,y],[b.maxX+3*scale,y],'SECTION');out.push(...sectionCut([b.minX-3*scale,y],scale,'C',false,source).filter(e=>e.kind!=='text'),...sectionCut([b.maxX+3*scale,y],scale,'C',true,source).filter(e=>e.kind!=='text'));out.push({kind:'text',point:[b.minX-5*scale,y+2*scale],text:'C',height:2.5*scale,align:'right',layer:'SECTION',source});}
if(settings.dimensions)out.push(...componentDimensions(model,id,geometry??projectModel(model,id),scale));
return settings.hatches===false?out.filter(e=>e.kind!=='hatch'):out;
}
@@ -129,7 +132,7 @@ export function generateDrawing(model: PierModel,input: PierInput,project: {name
let scale=settings.scale==='auto'?recommend(geometryBounds,slot,id):settings.scale;
if(settings.scale==='auto'&&wide&&settings.shorten!==false)for(let i=0;i<3;i++)scale=recommend(shortenedBounds(geometryBounds,modelBreaks(model,scale)),slot,id);
const breaks=wide&&settings.shorten!==false?modelBreaks(model,scale):undefined;
const entities=[...geometry,...annotate(model,id,geometryBounds,scale,settings)];
const entities=[...geometry,...annotate(model,id,geometryBounds,scale,settings,input)];
const bounds={minX:geometryBounds.minX-(wide?17:12)*scale,maxX:geometryBounds.maxX+(wide&&settings.elevations?18:10)*scale,
minY:geometryBounds.minY-(id==='top'?27:20)*scale,maxY:geometryBounds.maxY+(id==='top'?19:10)*scale};
return{id,title:VIEW_TITLES[id],entities,geometryBounds,bounds,scale,slot,breaks};
@@ -137,7 +140,7 @@ export function generateDrawing(model: PierModel,input: PierInput,project: {name
if(settings.scale==='auto'){
const common=Math.max(views[0].scale,views[1].scale);
for(const v of views.slice(0,2)){
const b=v.geometryBounds;v.entities=[...v.entities.filter(e=>e.source.member!=='annotation'),...annotate(model,v.id,b,common,settings)];
const b=v.geometryBounds;v.entities=[...v.entities.filter(e=>e.source.member!=='annotation'),...annotate(model,v.id,b,common,settings,input)];
v.scale=common;v.breaks=settings.shorten===false?undefined:modelBreaks(model,common);
v.bounds={minX:b.minX-17*common,maxX:b.maxX+(settings.elevations?18:10)*common,minY:b.minY-20*common,maxY:b.maxY+10*common};
}
@@ -173,8 +176,9 @@ export function generateDrawing(model: PierModel,input: PierInput,project: {name
const originals=new Map(views.map(v=>[v.id,v])),geometry=new Map(views.slice(0,5).map(v=>[v.id,v.entities.filter(e=>e.kind!=='hatch'&&e.source.member!=='annotation'&&e.source.member!=='bearing-location')]));
optimiseLayout(doc,(id,scale)=>{
const original=originals.get(id)!;let entities=original.entities;
if(geometry.has(id))entities=[...geometry.get(id)!,...annotate(model,id,original.geometryBounds,scale,settings,geometry.get(id))];
if(geometry.has(id))entities=[...geometry.get(id)!,...annotate(model,id,original.geometryBounds,scale,settings,input,geometry.get(id))];
else if(id==='detail')entities=bearingDetail(model,scale,settings.detailBearing,settings.dimensions,settings.centers).entities;
else if(id==='iso')entities=[...original.entities,...(settings.elevations?isometricInfo(model,input,original.entities,scale,settings.isoDirection??0,settings.dimensions):[])];
else if(id==='keyplan')entities=keyplanEntities(models,model,scale,cfg.startLabel,cfg.endLabel);
const breaks=['front','side'].includes(id)&&settings.shorten!==false?modelBreaks(model,scale):undefined;
if(settings.hatches!==false&&id!=='iso')entities=[...hatchGeometry(entities,scale),...entities];
+27
View File
@@ -56,6 +56,13 @@ def convert(source,output):
original=ezdxf.readfile(source);audit=original.audit()
if audit.errors:raise ValueError('Input DXF audit failed')
doc=original
# R2000 has no MATERIAL objects. LibreDWG used to turn the default material
# dictionary into invalid ACDBPLACEHOLDER entries that its own reader repairs.
# Remove unsupported metadata before encoding, preserving indexed colours.
materials=[e for e in doc.objects if e.dxftype()=='MATERIAL']
material_dictionary=doc.rootdict.get('ACAD_MATERIAL')
unsupported_handles={e.dxf.handle for e in materials}
if material_dictionary:unsupported_handles.add(material_dictionary.dxf.handle)
# The bundled writer's R2004 path drops entities. R2000 is used after a full round-trip check.
# R2000 uses indexed colours; retain the intended greys with the nearest ACI colour.
colour_count=0
@@ -69,6 +76,25 @@ def convert(source,output):
expected={layout.name:Counter(fingerprint(e) for e in layout) for layout in doc.layouts}
expected_blocks={block.name:Counter(fingerprint(e) for e in block) for block in doc.blocks if not block.name.startswith('*')}
compatibility_angles(intermediate)
# Strip only converter-input metadata after ezdxf has finished serialization;
# ezdxf requires its default materials while saving even an R2000 document.
lines=intermediate.read_text(encoding='cp949').splitlines()
pairs=list(zip(lines[::2],lines[1::2]));groups=[];group=[]
for code,value in pairs:
if code.strip()=='0' and group:groups.append(group);group=[]
group.append((code,value))
if group:groups.append(group)
cleaned=[]
for group in groups:
handle=next((v for c,v in group if c.strip()=='5'),None)
if handle in unsupported_handles:continue
skip=False
for i,(code,value) in enumerate(group):
if skip:skip=False;continue
if code.strip()=='3' and value=='ACAD_MATERIAL':skip=True;continue
if code.strip()=='347':value='0'
cleaned.extend([code,value])
intermediate.write_text('\n'.join(cleaned)+'\n',encoding='cp949')
logs=[]
for args in [[str(TOOLS/'dxf2dwg.exe'),'--as','r2000','-o',str(output),str(intermediate)],
[str(TOOLS/'dwgread.exe'),'-O','DXF','-o',str(output.with_suffix('.roundtrip.dxf')),str(output)]]:
@@ -109,6 +135,7 @@ def convert(source,output):
object_fingerprints_match=True,source_links_match=True,dimension_measurements_match=True,max_dimension_error_mm=max_error,lineweights_match=True,dimstyles_match=True,paper_plot_settings_match=True,font_styles_match=True,audit_errors=len(audit.errors),
decoder_metadata_repairs=[str(e.message) for e in reader_audit.fixes],indexed_colour_conversions=colour_count,
native_autocad_open_tested=False)
report['unsupported_materials_removed']=len(materials)
output.with_suffix('.validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
output.with_suffix('.conversion.log').write_text('\n'.join(logs),encoding='utf-8')
return report
+10
View File
@@ -0,0 +1,10 @@
import {it,expect} from 'vitest';
import {readFileSync,writeFileSync} from 'node:fs';
import {buildPier} from '../src/core/geometry';
import {generateDrawing,DEFAULT_SETTINGS} from './drawing';
it('회전된 다주 기초의 좌표 주석은 겹치지 않는다',()=>{
const s=JSON.parse(readFileSync(new URL('../랜딩페이지/assets/samples/sample06.pzr.json',import.meta.url),'utf8'));
const p=s.piers[1],m=buildPier(p.input),d=generateDrawing(m,p.input,s.project,DEFAULT_SETTINGS);
if(d.quality?.after)writeFileSync(new URL('./.cache/foundation-debug.json',import.meta.url),JSON.stringify(d.quality.issues.map(q=>({q,a:d.views.find(v=>v.id===q.view)?.entities[q.first],b:d.views.find(v=>v.id===q.view)?.entities[q.second??-1]})),null,2));
expect(d.quality?.after).toBe(0);
});
+35
View File
@@ -0,0 +1,35 @@
import type { PierModel } from '../src/core/geometry';
import type { PierInput } from '../src/core/types';
import { localPoint,projectPoint } from './projection';
import type { Entity,ViewId,Bounds,Point } from './types';
/** Survey labels refer to actual model corners; X=Northing, Y=Easting. */
export function foundationNotes(model:PierModel,input:PierInput,id:ViewId,b:Bounds,scale:number):Entity[]{
const out:Entity[]=[],source={pier:model.name,member:'annotation',index:0};
const text=(point:Point,text:string,align:'left'|'right'='left')=>out.push({kind:'text',point,text,height:2.5*scale,align,layer:'TEXT',source});
const line=(a:Point,b:Point)=>out.push({kind:'line',a,b,layer:'TEXT',source});
if(id==='footing')model.footings.forEach((f,index)=>{
const corners=[[-1,-1],[-1,1],[1,-1],[1,1]].map(([s,n])=>{
const world=localPoint(model,f.k,f.s+s*f.length/2,f.top,n*f.width/2);
return {point:projectPoint(model,world,id),e:world.x+model.pivot.e,n:-world.z+model.pivot.n};
});
corners.sort((a,b)=>b.point[1]-a.point[1]);
const ordered=[...corners.slice(0,2).sort((a,b)=>a.point[0]-b.point[0]),...corners.slice(2).sort((a,b)=>a.point[0]-b.point[0])];
for(const [i,c] of ordered.entries()){const right=i%2===1,upper=i<2,sign=right?1:-1;
const x=(right?b.maxX:b.minX),y=(upper?b.maxY+(8+index*8)*scale:b.minY-(12+index*8)*scale);
line(c.point,[x-sign*scale,y]);line([x-sign*scale,y],[x,y]);
text([x,y+3.5*scale],`X(N) ${c.n.toFixed(3)}`,right?'right':'left');
text([x,y],`Y(E) ${c.e.toFixed(3)}`,right?'right':'left');
}
});
if((id==='front'||id==='side')&&model.piles.length){
const pile=model.piles[0],anchor=projectPoint(model,localPoint(model,pile.k,pile.s,pile.top-.2,pile.n),id);
const x=b.minX,y=b.minY-25*scale;
line(anchor,[x-scale,y]);line([x-scale,y],[x+12*scale,y]);
const lengths=model.piles.map(p=>p.top-p.bottom),lo=Math.min(...lengths),hi=Math.max(...lengths);
text([x,y+3.5*scale],`${input.pileKind??'종류 미확인'} 말뚝 · ${model.piles.length}본`);
text([x,y],`Ø${(pile.diameter*1000).toFixed(1)}${input.pileThicknessMm!=null?' × t'+input.pileThicknessMm:''}`);
text([x,y-3.5*scale],`L=${lo.toFixed(3)}${hi-lo>.0005?'~'+hi.toFixed(3):''} m`);
}
return out;
}
+36
View File
@@ -1,4 +1,6 @@
import { Vector3 } from 'three';
import type { PierInput } from '../src/core/types';
import { entityBounds } from './projection';
import type { PierModel } from '../src/core/geometry';
import { modelPrimitives, projectPoint, projectModel } from './projection';
import { modelBreaks, shortenedY } from './breaks';
@@ -61,3 +63,37 @@ export function keyplanEntities(models:readonly PierModel[],selected:PierModel,s
{kind:'polyline',points:[[tip[0]-north[0]*2*scale+north[1]*scale,tip[1]-north[1]*2*scale-north[0]*scale],tip,[tip[0]-north[0]*2*scale-north[1]*scale,tip[1]-north[1]*2*scale+north[0]*scale]],closed:false,layer:'TEXT',source});
return entities;
}
/** Actual projected model points anchor all isometric dimensions and EL leaders. */
export function isometricInfo(model:PierModel,input:PierInput,geometry:Entity[],scale:number,direction=0,dimensions=true):Entity[]{
const bounds=entityBounds(geometry),out:Entity[]=[],primitives=modelPrimitives(model);
const datum=Math.min(...model.columns.map(c=>c.bottom),...model.piles.map(c=>c.bottom),...model.footings.map(c=>c.bottom))*1000,theta=direction*Math.PI/2;
const project=(p:Vector3):Point=>{const [s,n]=projectPoint(model,p,'top'),a=s*Math.cos(theta)-n*Math.sin(theta),b=s*Math.sin(theta)+n*Math.cos(theta);return [(a-b)*Math.sqrt(3)/2,p.y*1000-datum+(a+b)*.5];};
const source={pier:model.name,member:'iso-info',index:0};
const text=(p:Point,value:string)=>out.push({kind:'text',point:p,text:value,height:2.5*scale,layer:'ELEVATION',source,align:'left'});
const line=(a:Point,b:Point)=>out.push({kind:'line',a,b,layer:'ELEVATION',source});
const rows:{point:Point;name:string;el:number}[]=[];
const column=primitives.cylinders.find(c=>c.source.member==='column');
if(column){
const bottom=project(column.center.clone().setY(column.bottom)),top=project(column.center.clone().setY(column.top));
if(dimensions)out.push({kind:'dimension',a:bottom,b:top,position:bounds.minX-4*scale,axis:'y',height:2.5*scale,value:(column.top-column.bottom)*1000,label:`H=${((column.top-column.bottom)*1000).toFixed(0)}`,source,layer:'DIMENSION'});
if(Number.isFinite(input.groundEl))rows.push({point:project(column.center.clone().setY(input.groundEl)),name:'지반고',el:input.groundEl});
}
const pile=primitives.cylinders.find(c=>c.source.member==='pile');
if(pile)rows.push({point:project(pile.center.clone().setY(pile.bottom)),name:'말뚝 하단',el:pile.bottom});
const footing=primitives.solids.find(s=>s.source.member==='footing');
if(footing){const points=footing.faces.flat(),el=Math.min(...points.map(p=>p.y)),p=points.find(p=>Math.abs(p.y-el)<1e-7)!;rows.push({point:project(p),name:'기초 하단',el});}
for(const [member,name,field] of [['block','교좌면','top'],['pad','몰탈 상면','padTop']] as const){
const solid=primitives.solids.find(s=>s.source.member===member);if(!solid)continue;
const block=model.blocks[solid.source.index];if(!block)continue;const el=block[field],points=solid.faces.flat(),p=points.reduce((a,b)=>Math.abs(a.y-el)<Math.abs(b.y-el)?a:b);
rows.push({point:project(p),name,el});
}
rows.sort((a,b)=>a.point[1]-b.point[1]);let last=-Infinity;
for(const row of rows){
const y=Math.max(row.point[1],last+8*scale);last=y;const x=bounds.maxX+3*scale;
line(row.point,[x-scale,y]);line([x-scale,y],[x+13*scale,y]);
out.push({kind:'polyline',points:[[row.point[0]+scale,row.point[1]+.6*scale],row.point,[row.point[0]+scale,row.point[1]-.6*scale]],closed:false,layer:'ELEVATION',source});
text([x,y+3.3*scale],row.name);text([x,y],`EL. ${row.el.toFixed(3)}`);
}
return out;
}
+6 -1
View File
@@ -102,7 +102,7 @@ export function optimiseLayout(doc:DrawingDocument,factory:Factory):void{
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+elevation+plan*.45+Math.abs(elevation-plan)*.1;
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.
@@ -124,6 +124,11 @@ export function optimiseLayout(doc:DrawingDocument,factory:Factory):void{
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};