Files
Pier_v1/도면작성/workspace.ts
T
ypshimandClaude Haiku 5.5 fc520ec03d feat: 프로젝트 공통 지반모델·교대 지반 통합·재료표/수량 진입·옥성교 랜딩 히어로 (11-06~11-09 작업분)
공용 파일(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>
2026-11-09 18:01:02 +09:00

135 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()};
}