공용 파일(main.ts·index.html·style.css)에서 서로 얽혀 있어 한 커밋으로 묶음. - 지반: ① 형상 프로젝트 지반모델 관리창(생성·JSON 불러오기·교각별 비교·선택 적용), 프로젝트 JSON 내장 groundModel 추출, 교대 8행 통합 적용·공통지형 중복격자 제외 - 재료표·수량: 상단 ⑤ 메뉴·배근 바로가기, 결과 교각/탭 기억, 수량 워크북·정합 보정 - 랜딩: 옥성교 6교각 히어로 이미지·투영 강조점·좌→우 광선·지형 라운드/도로 연결 - 검증 스크립트(verify-project-ground/ground-abutments/okseong-user-files/quantity-*/landing-hero 등) 정본: DOC/04_실행/실행_*_2026-11-09_v0.1.00.md Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
135 lines
17 KiB
TypeScript
135 lines
17 KiB
TypeScript
import './workspace.css';
|
||
import { DEFAULT_SETTINGS, generateDrawing, VIEW_TITLES, formatMm } from './drawing';
|
||
import { drawingSvg } from './svg';
|
||
import { exportDxf } from './dxf';
|
||
import { LAYERS, type DrawingDocument, type DrawingSettings, type DrawingSnapshot, type Layer, type Rect, type ViewId } from './types';
|
||
import type { PierModel } from '../src/core/geometry';
|
||
import { defaultSheet, parseSheetConfig } from './template';
|
||
import { createSheetEditor } from './sheet-editor';
|
||
import { shortenedBounds } from './breaks';
|
||
import { createQualityPanel } from './quality-panel';
|
||
|
||
export function createDrawingWorkspace(host: HTMLElement,read:()=>DrawingSnapshot,select:(id:string)=>void,openMaterials?:()=>void): { update:()=>void } {
|
||
host.className='ga-workspace';
|
||
host.innerHTML=`<aside class="ga-settings"><div class="ga-sidebar-title">도면 설정 <span>GENERAL ARRANGEMENT</span></div>
|
||
<label>교각 선택<select id="ga-pier" aria-label="도면 교각 선택"></select></label>
|
||
<label>도면 종류<select id="ga-view" aria-label="표시 도면"><option value="sheet">일반도 · 전체</option>${Object.entries(VIEW_TITLES).map(([id,label])=>`<option value="${id}">${label}</option>`).join('')}</select></label>
|
||
<label>상세 A 받침 선택<select id="ga-bearing" aria-label="받침 상세 선택"></select></label>
|
||
<label>아이소메트릭 방향<select id="ga-iso"><option value="0">등각 1</option><option value="1">등각 2</option><option value="2">등각 3</option><option value="3">등각 4</option></select></label>
|
||
<button id="ga-sheet-editor" type="button">도곽 · 노트 편집 / 불러오기</button>
|
||
<div class="ga-setting-pair"><label>용지<select id="ga-paper"><option>A3</option><option>A2</option><option>A1</option></select></label>
|
||
<label>도면 축척<select id="ga-scale"><option value="auto">자동 · 뷰별</option>${[25,50,75,100,150,200,250,300,400,500,750,1000].map(n=>`<option value="${n}">1:${n}</option>`).join('')}</select></label></div>
|
||
<fieldset><legend>표시 설정</legend><label><input id="ga-hatches" type="checkbox" checked/> 콘크리트 해치</label><label><input id="ga-shorten" type="checkbox" checked/> 기둥·말뚝 생략 표현</label><label><input id="ga-dimensions" type="checkbox" checked/> 치수선</label><label><input id="ga-centers" type="checkbox" checked/> 중심선</label><label><input id="ga-elevations" type="checkbox" checked/> 표고 EL</label></fieldset>
|
||
<button id="ga-regenerate" type="button" class="ga-primary">도면 재생성</button>
|
||
<fieldset class="ga-layers"><legend>도면 레이어</legend>${Object.entries(LAYERS).map(([id,layer])=>`<label><input type="checkbox" data-layer="${id}" ${id==='HIDDEN'?'':'checked'}/><i style="background:${layer.color}"></i><span>${layer.title}</span><small>${id}</small></label>`).join('')}</fieldset>
|
||
<section class="ga-notices"><h3>데이터 확인</h3><p id="ga-data-note"></p><ul id="ga-warnings"></ul></section></aside>
|
||
<section class="ga-main"><header class="ga-toolbar"><div><span id="ga-project">프로젝트 미지정</span><strong id="ga-title">교각 일반도</strong></div><div class="ga-toolbar-actions"><button id="ga-generate" type="button">도면 생성</button><button id="ga-update" type="button">업데이트</button><button id="ga-fit" type="button">전체 맞춤</button><button id="ga-quality" type="button">품질 검사</button><button id="ga-dwg" class="ga-primary" type="button">DWG 내보내기</button><button id="ga-dxf" type="button">DXF 내보내기</button><button id="ga-preview" type="button">출력 미리보기</button></div></header>
|
||
<div class="ga-viewer"><div id="ga-empty">교각 형상을 생성하면 일반도가 표시됩니다.</div><div id="ga-canvas" class="ga-canvas" aria-label="교각 일반도 CAD 미리보기" tabindex="0"></div>
|
||
<div class="ga-zoom"><button id="ga-zoom-in" type="button" aria-label="도면 확대">+</button><button id="ga-zoom-out" type="button" aria-label="도면 축소">−</button><button id="ga-svg" type="button">SVG 저장</button></div></div>
|
||
<footer class="ga-status"><span id="ga-object-info">휠 확대·축소 / 드래그 이동 / 객체 클릭</span><span id="ga-count">모델 mm · EL m</span></footer></section>
|
||
<dialog class="ga-print-dialog" id="ga-print-dialog" aria-label="일반도 출력 미리보기"><div class="ga-print-toolbar"><strong>출력 미리보기</strong><span id="ga-print-paper"></span><button id="ga-print" class="ga-primary" type="button">인쇄 / PDF</button><button id="ga-print-close" type="button">닫기</button></div><div id="ga-print-sheet"></div></dialog>`;
|
||
const node=<T extends HTMLElement=HTMLElement>(id:string)=>host.querySelector<T>(`#${id}`)!;
|
||
if(openMaterials){
|
||
const button=window.document.createElement('button');button.type='button';button.id='ga-materials';button.textContent='재료표';button.addEventListener('click',openMaterials);
|
||
host.querySelector('.ga-toolbar-actions')!.prepend(button);
|
||
}
|
||
const pier=node<HTMLSelectElement>('ga-pier'),view=node<HTMLSelectElement>('ga-view'),paper=node<HTMLSelectElement>('ga-paper'),scale=node<HTMLSelectElement>('ga-scale');
|
||
const bearing=node<HTMLSelectElement>('ga-bearing');
|
||
const canvas=node('ga-canvas'),empty=node('ga-empty'),warnings=node('ga-warnings'),note=node('ga-data-note');
|
||
const visible=new Set<Layer>((Object.keys(LAYERS) as Layer[]).filter(l=>l!=='HIDDEN'));
|
||
let sheetConfig=defaultSheet(),sheetKey='',lastContextModels:(PierModel|null)[]=[];
|
||
let document:DrawingDocument|undefined,lastModel:PierModel|undefined,lastKey='',optionKey='',camera:Rect={x:0,y:0,width:420,height:297};
|
||
let press:{x:number;y:number;anchor:DOMPoint;entity?:string}|undefined;
|
||
const settings=():DrawingSettings=>({paper:paper.value as DrawingSettings['paper'],scale:scale.value==='auto'?'auto':Number(scale.value),
|
||
dimensions:node<HTMLInputElement>('ga-dimensions').checked,centers:node<HTMLInputElement>('ga-centers').checked,elevations:node<HTMLInputElement>('ga-elevations').checked,
|
||
shorten:node<HTMLInputElement>('ga-shorten').checked,detailBearing:bearing.value,hatches:node<HTMLInputElement>('ga-hatches').checked,isoDirection:Number(node<HTMLSelectElement>('ga-iso').value),sheet:sheetConfig});
|
||
const svg=()=>canvas.querySelector<SVGSVGElement>('svg');
|
||
const setCamera=()=>svg()?.setAttribute('viewBox',`${camera.x} ${camera.y} ${camera.width} ${camera.height}`);
|
||
const fit=()=>{
|
||
if(!document)return;
|
||
const target=document.views.find(v=>v.id===view.value);
|
||
if(target){const b=shortenedBounds(target.bounds,target.breaks),width=Math.max(target.slot.width,(b.maxX-b.minX)/target.scale),height=Math.max(target.slot.height,(b.maxY-b.minY)/target.scale);camera={x:target.slot.x+(target.slot.width-width)/2-5,y:target.slot.y+(target.slot.height-height)/2-5,width:width+10,height:height+12};}
|
||
else camera={x:0,y:0,width:document.paper.width,height:document.paper.height};
|
||
setCamera();
|
||
};
|
||
const redraw=(reset=false)=>{
|
||
if(!document){canvas.replaceChildren();empty.hidden=false;return;}
|
||
empty.hidden=true;canvas.innerHTML=drawingSvg(document,visible,true,view.value==='sheet'?undefined:view.value as ViewId);if(reset)fit();else setCamera();
|
||
node('ga-count').textContent=`${document.views.reduce((n,v)=>n+v.entities.filter(e=>visible.has(e.layer)).length,0)} 객체 · 간섭 ${document.quality?.after??'미검사'}건 · mm / EL m`;
|
||
};
|
||
const enable=(on:boolean)=>['ga-dxf','ga-dwg','ga-quality','ga-svg','ga-preview','ga-generate','ga-update','ga-regenerate'].forEach(id=>{node<HTMLButtonElement>(id).disabled=!on;});
|
||
const update=(force=false)=>{
|
||
host.style.height=`${Math.max(480,window.innerHeight-host.getBoundingClientRect().top-12)}px`;
|
||
const snapshot=read(),items=snapshot.piers;
|
||
const nextSheetKey=`pierzainer.ga.sheet.v1:${snapshot.project.name}|${snapshot.project.bridge}`;
|
||
if(sheetKey!==nextSheetKey){sheetKey=nextSheetKey;sheetConfig=defaultSheet();try{const saved=localStorage.getItem(sheetKey);if(saved)sheetConfig=parseSheetConfig(JSON.parse(saved));}catch{/* Keep original template if browser storage is unavailable or corrupt. */}}
|
||
const nextOptionKey=items.map(p=>`${p.input.name}:${p.input.type}:${!!p.model}`).join('|');
|
||
if(optionKey!==nextOptionKey){optionKey=nextOptionKey;pier.replaceChildren();
|
||
if(!items.length)pier.add(new Option('교각 없음',''));
|
||
for(const p of items)pier.add(new Option(`${p.input.name} · ${p.input.type}${p.model?'':' · 형상 없음'}`,p.input.name));}
|
||
pier.value=snapshot.selected??items[0]?.input.name??'';
|
||
const selected=items.find(p=>p.input.name===pier.value);
|
||
node('ga-project').textContent=[snapshot.project.name,snapshot.project.bridge].filter(Boolean).join(' · ')||'프로젝트 미지정';
|
||
node('ga-title').textContent=selected?`${selected.input.name} 교각 일반도`:'교각 일반도';
|
||
host.dataset.pier=selected?.input.name??'';
|
||
note.textContent=selected?.dirty?'현재 입력이 3D 형상에 아직 반영되지 않았습니다. 상단에서 형상을 다시 생성하면 도면도 갱신됩니다.':'현재 생성된 3D 형상 데이터를 사용합니다. 모델 변경 시 자동 갱신합니다.';
|
||
enable(!!selected?.model);warnings.replaceChildren();
|
||
if(!selected?.model){document=undefined;lastModel=undefined;empty.textContent=selected?'선택한 교각의 형상이 없습니다. 형상 생성 오류를 먼저 확인하세요.':'교각 형상을 생성하면 일반도가 표시됩니다.';redraw();return;}
|
||
if(lastModel!==selected.model){const previous=bearing.value;bearing.replaceChildren();for(const b of selected.model.blocks)bearing.add(new Option(`${b.row} ${b.no}`,`${b.row}:${b.no}`));if(Array.from(bearing.options).some(o=>o.value===previous))bearing.value=previous;}
|
||
const key=JSON.stringify([settings(),snapshot.project.name,snapshot.project.bridge]);
|
||
try{
|
||
const models=items.map(p=>p.model),contextChanged=models.length!==lastContextModels.length||models.some((m,i)=>m!==lastContextModels[i]);
|
||
if(force||lastModel!==selected.model||lastKey!==key||contextChanged){document=generateDrawing(selected.model,selected.input,snapshot.project,settings(),{models:models.filter((m):m is PierModel=>!!m)});lastModel=selected.model;lastContextModels=models;lastKey=key;redraw(true);}
|
||
for(const message of document?.warnings??[]){const li=window.document.createElement('li');li.textContent=message;warnings.append(li);}
|
||
}catch(error){document=undefined;lastModel=undefined;enable(false);empty.textContent=(error as Error).message;redraw();}
|
||
};
|
||
const download=(text:string,extension:string,mime:string)=>{
|
||
if(!document)return;
|
||
const url=URL.createObjectURL(new Blob([text],{type:mime})),link=window.document.createElement('a');
|
||
link.href=url;link.download=`${document.bridge||document.project||'교량'}_${document.pier}_일반도.${extension}`.replace(/[\\/:*?"<>|]/g,'_');link.click();window.setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||
};
|
||
pier.addEventListener('change',()=>select(pier.value));view.addEventListener('change',()=>redraw(true));
|
||
[paper,scale,bearing,node('ga-iso'),node('ga-hatches'),node('ga-shorten'),node('ga-dimensions'),node('ga-centers'),node('ga-elevations')].forEach(e=>e.addEventListener('change',()=>update(true)));
|
||
const editor=createSheetEditor(host,config=>{sheetConfig=config;try{localStorage.setItem(sheetKey,JSON.stringify(config));}catch{node('ga-object-info').textContent='브라우저 저장 공간이 부족합니다. 도곽/노트 JSON 저장을 사용하세요.';}update(true);});
|
||
node('ga-sheet-editor').addEventListener('click',()=>editor.open(sheetConfig));
|
||
['ga-generate','ga-update','ga-regenerate'].forEach(id=>node(id).addEventListener('click',()=>update(true)));
|
||
node('ga-fit').addEventListener('click',fit);
|
||
host.querySelectorAll<HTMLInputElement>('[data-layer]').forEach(checkbox=>checkbox.addEventListener('change',()=>{const layer=checkbox.dataset.layer as Layer;if(checkbox.checked)visible.add(layer);else visible.delete(layer);redraw();}));
|
||
const point=(event:{clientX:number;clientY:number}):DOMPoint=>new DOMPoint(event.clientX,event.clientY).matrixTransform(svg()!.getScreenCTM()!.inverse());
|
||
const zoom=(factor:number,anchor?:DOMPoint)=>{
|
||
if(!document||!svg())return;
|
||
const target=anchor??new DOMPoint(camera.x+camera.width/2,camera.y+camera.height/2),width=Math.min(document.paper.width*20,Math.max(document.paper.width*.025,camera.width*factor)),ratio=width/camera.width;
|
||
camera={x:target.x-(target.x-camera.x)*ratio,y:target.y-(target.y-camera.y)*ratio,width,height:camera.height*ratio};setCamera();
|
||
};
|
||
node('ga-zoom-in').addEventListener('click',()=>zoom(.8));node('ga-zoom-out').addEventListener('click',()=>zoom(1.25));
|
||
canvas.addEventListener('wheel',event=>{if(svg()){event.preventDefault();zoom(Math.exp(Math.max(-.4,Math.min(.4,event.deltaY*.0015))),point(event));}},{passive:false});
|
||
canvas.addEventListener('pointerdown',event=>{if(!svg()||event.button>1)return;event.preventDefault();press={x:event.clientX,y:event.clientY,anchor:point(event),entity:(event.target as Element).closest<SVGGElement>('[data-entity]')?.dataset.entity};canvas.setPointerCapture(event.pointerId);canvas.classList.add('is-panning');});
|
||
canvas.addEventListener('pointermove',event=>{if(!press||!svg())return;const current=point(event);camera.x+=press.anchor.x-current.x;camera.y+=press.anchor.y-current.y;setCamera();});
|
||
const release=(event:PointerEvent)=>{if(!press)return;const clicked=Math.hypot(event.clientX-press.x,event.clientY-press.y)<4;if(clicked&&press.entity&&document){const [viewId,index]=press.entity.split(':');const e=document.views.find(v=>v.id===viewId)?.entities[Number(index)];if(e)node('ga-object-info').textContent=`${e.source.pier} / ${e.source.member} ${e.source.index+1} · ${e.layer}${e.kind==='dimension'?` · ${formatMm(e.value)} mm`:''}`;}press=undefined;canvas.classList.remove('is-panning');};
|
||
canvas.addEventListener('pointerup',release);canvas.addEventListener('pointercancel',()=>{press=undefined;canvas.classList.remove('is-panning');});
|
||
node('ga-dxf').addEventListener('click',()=>{if(document)download(exportDxf(document,visible),'dxf','application/dxf;charset=utf-8');});
|
||
const qualityPanel=createQualityPanel(host,()=>document,id=>{view.value=id;redraw(true);});
|
||
node('ga-quality').addEventListener('click',qualityPanel);
|
||
node('ga-dwg').addEventListener('click',async()=>{
|
||
const snapshot=document;if(!snapshot)return;const button=node<HTMLButtonElement>('ga-dwg');button.disabled=true;button.textContent='DWG 검증 중…';
|
||
try{
|
||
const response=await fetch('/__pierzainer/general-arrangement/dwg',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dxf:exportDxf(snapshot,new Set(visible))})});
|
||
if(!response.ok){const error=await response.json().catch(()=>({error:'DWG 출력 서버를 찾을 수 없습니다. 개발 서버를 다시 실행하세요.'}));throw new Error(error.error);}
|
||
snapshot.dwgValidation=JSON.parse(decodeURIComponent(response.headers.get('X-Pierzainer-Validation')??'%7B%7D'));
|
||
const url=URL.createObjectURL(await response.blob()),a=window.document.createElement('a');a.href=url;a.download=`${snapshot.bridge||snapshot.project}_${snapshot.pier}_일반도.dwg`.replace(/[\\/:*?"<>|]/g,'_');a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||
node('ga-object-info').textContent='DWG 2000 변환 · 내부 객체/치수 비교 통과(CAD 직접 열기 미검증). 열리지 않으면 DXF 내보내기를 사용하고 CAD 오류 문구를 확인하세요.';
|
||
}catch(error){node('ga-object-info').textContent=`DWG 출력 실패: ${(error as Error).message}`;}finally{button.disabled=false;button.textContent='DWG 내보내기';}
|
||
});
|
||
node('ga-svg').addEventListener('click',()=>{if(document)download(drawingSvg(document,visible),'svg','image/svg+xml;charset=utf-8');});
|
||
const preview=node<HTMLDialogElement>('ga-print-dialog');
|
||
node('ga-preview').addEventListener('click',()=>{if(!document)return;node('ga-print-sheet').innerHTML=drawingSvg(document,visible);node('ga-print-paper').textContent=`${document.paper.name} 가로 · 각 뷰 축척 적용`;preview.showModal();});
|
||
node('ga-print-close').addEventListener('click',()=>preview.close());
|
||
node('ga-print').addEventListener('click',()=>{
|
||
if(!document)return;const frame=window.document.createElement('iframe');frame.style.cssText='position:fixed;width:1px;height:1px;left:-9999px;border:0';
|
||
frame.srcdoc=`<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>교각 일반도</title><style>@page{size:${document.paper.width}mm ${document.paper.height}mm;margin:0}html,body{margin:0}svg{display:block}</style></head><body>${drawingSvg(document,visible)}</body></html>`;
|
||
frame.onload=()=>{frame.contentWindow?.addEventListener('afterprint',()=>frame.remove(),{once:true});frame.contentWindow?.print();};host.append(frame);
|
||
});
|
||
window.addEventListener('resize',()=>{if(!host.hidden)host.style.height=`${Math.max(480,window.innerHeight-host.getBoundingClientRect().top-12)}px`;});
|
||
return{update:()=>update()};
|
||
}
|