Validate DXF geometry and splines and improve viewer maintenance

This commit is contained in:
eglee committed 2026-10-22 11:28:16 +09:00
1 parent 11fd3badf6
commit 9cee9bf791
6 files changed
+560 -77

No files matched your search

+8 -50
View File
@@ -6,68 +6,26 @@
<style>
:root{--bg:#101b28;--panel:#172635;--border:#2b4052;--fg:#edf3f6;--muted:#9caebb;--mint:#8de1cf;--gold:#edc28a}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}button,a,select{font:inherit}button,select{cursor:pointer}button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--mint);outline-offset:3px}button{color:inherit}a{color:var(--mint)}.wrap{max-width:1600px;margin:auto;padding:0 28px 30px}header{display:flex;justify-content:space-between;align-items:center;gap:16px;height:82px;border-bottom:1px solid var(--border)}.brand{display:flex;align-items:center;gap:12px}.symbol{width:39px;height:39px;border:1px solid #428379;color:var(--mint);border-radius:11px;display:grid;place-items:center;font-weight:800}.brand strong{display:block;font-size:15px}.brand small{color:var(--muted);font-size:11px}.actions{display:flex;gap:8px;align-items:center}.badge{font-size:10px;color:var(--mint);padding:5px 10px;border:1px solid #345f5b;border-radius:20px}.link,.btn{border:1px solid var(--border);background:var(--panel);padding:8px 12px;border-radius:7px;text-decoration:none;font-size:12px;color:var(--fg);white-space:nowrap}.btn:hover,.link:hover{background:#24394a}.btn.active{background:#204a49;border-color:#4a8b7f;color:#bcf4e6}h1{font-size:25px;font-weight:650;letter-spacing:-.04em;margin:4px 0 6px}.intro{display:flex;justify-content:space-between;align-items:end;margin:25px 0 19px;gap:20px}.eyebrow{color:var(--mint);font-size:10px;font-weight:700;letter-spacing:.15em}.subtitle{color:var(--muted);font-size:12px;margin:0}.intro-note{text-align:right;color:var(--muted);font-size:11px}.workspace{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:18px}.main{min-width:0}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px}.seg{display:flex;gap:5px;flex-wrap:wrap}.seg .btn{padding:6px 10px;font-size:11px}.stage{position:relative;isolation:isolate;height:clamp(420px,61vh,760px);border:1px solid #465662;border-radius:12px;overflow:hidden;background:radial-gradient(ellipse at 45% 38%,#f5f7f6,#dfe7e9)}#scene{display:block;position:absolute;inset:0;width:100%;height:100%;touch-action:none;cursor:grab}#scene:active{cursor:grabbing}.stage-label{position:absolute;left:17px;top:15px;pointer-events:none;font-size:10px;color:#526673;letter-spacing:.12em;z-index:2}.stage-label strong{display:block;font-size:12px;color:#283e49;letter-spacing:.03em;margin-top:3px}.stage-bottom{position:absolute;left:17px;bottom:14px;color:#526673;font-size:10px;pointer-events:none}.stage-status{position:absolute;right:14px;top:14px;border:1px solid #b7c9cc;background:#f9fcfaca;padding:5px 9px;border-radius:7px;color:#35635f;font-size:10px;pointer-events:none}.zoom{position:absolute;right:13px;bottom:13px;display:flex;gap:5px}.zoom button{background:#f5f8f8e8;border:1px solid #b5c6cd;border-radius:6px;color:#334b59;width:31px;height:30px;font-weight:650}.viewbar{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:10px}.help{font-size:10px;color:var(--muted)}.error{position:absolute;inset:55px 15px auto;z-index:5;padding:12px;color:#7b2f22;background:#ffefdf;border-radius:7px;font-size:12px}.error[hidden]{display:none}.cards{display:flex;flex-direction:column;gap:12px}.card{padding:16px;border:1px solid var(--border);border-radius:10px;background:var(--panel)}.card h2{font-size:12px;margin:0 0 13px;display:flex;justify-content:space-between;align-items:center}.small-tag{font-size:9px;font-weight:400;letter-spacing:.05em;color:var(--mint)}.dims{margin:0;display:grid;grid-template-columns:1fr auto;gap:8px;font-size:12px}.dims dt{color:var(--muted)}.dims dd{margin:0;font-variant-numeric:tabular-nums}.dims dd.main-value{color:var(--mint);font-weight:650}.rule{border:0;border-top:1px solid var(--border);margin:13px 0}.micro{font-size:11px;color:var(--muted);margin:0;line-height:1.7}.legend{display:flex;gap:8px;align-items:center;font-size:11px;margin-bottom:7px}.swatch{width:10px;height:10px;border-radius:3px;background:#bcc8c9}.swatch.bottom{background:#81979d}.swatch.inner{background:#6b8189}.card .btn{width:100%;margin-top:12px;text-align:center}.limits{color:var(--muted);font-size:10px;line-height:1.7;margin-top:10px}.drawing-card{border:1px solid var(--border);border-radius:10px;margin-top:22px;background:var(--panel);overflow:hidden}.drawing-card>summary{cursor:pointer;list-style:none;display:flex;justify-content:space-between;gap:12px;padding:16px;font-size:13px;font-weight:600}.drawing-card>summary::after{content:'+';color:var(--mint)}.drawing-card[open]>summary::after{content:'−'}.drawing-controls{display:flex;gap:7px;flex-wrap:wrap;align-items:center;padding:0 16px 12px}.drawing-controls select{background:var(--bg);border:1px solid var(--border);color:var(--fg);border-radius:6px;padding:6px 9px;font-size:11px}.drawing-viewport{height:440px;background:#f7fafbLine truncated
@media(max-width:1050px){.workspace{grid-template-columns:minmax(0,1fr) 270px}.wrap{padding:0 17px 25px}.intro-note{display:none}.viewbar{align-items:start;flex-direction:column}.stage{height:540px}}@media(max-width:740px){header{height:72px}.badge{display:none}.wrap{padding:0 12px 20px}.workspace{grid-template-columns:1fr}h1{font-size:21px}.intro{margin-top:20px}.stage{height:440px}.cards{display:grid;grid-template-columns:1fr 1fr}.card:last-child{grid-column:1/-1}.drawing-viewport{height:350px}.toolbar{align-items:start}.brand small{display:none}}@media(max-width:440px){.cards{grid-template-columns:1fr}.stage{height:390px}.toolbar{flex-direction:column}.actions .link{font-size:10px;padding:7px}.help{display:none}}
.upload-zone{border:1px dashed #456477;background:#152735;border-radius:10px;padding:16px;display:flex;gap:14px;align-items:center;justify-content:space-between;margin-bottom:18px}.upload-zone.dragover{border-color:var(--mint);background:#1b403d}.upload-zone strong{font-size:13px}.upload-zone p{margin:3px 0 0;font-size:11px;color:var(--muted)}.upload-controls{display:flex;gap:8px;flex-shrink:0}.upload-message{margin:0 0 12px;color:var(--mint);font-size:12px;overflow-wrap:anywhere}.upload-message.error{position:static;background:#492c29;color:#ffd5c8;padding:10px}.upload-message[hidden]{display:none}.specbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 12px;background:var(--panel);border:1px solid var(--border);border-radius:8px;margin-bottom:12px}.specbar label{font-size:12px;color:var(--muted)}.specbar select{background:#10212b;color:var(--fg);border:1px solid #496879;border-radius:6px;padding:6px 12px;min-width:160px}.specbar select:disabled{opacity:.6}.specnote{font-size:10px;color:var(--muted)}.spec-warning{font-size:10px;line-height:1.65;color:var(--gold);margin:9px 0 0}.spec-cell{border:0;color:var(--mint);background:transparent;padding:2px;cursor:pointer;font-size:11px;text-decoration:underline;text-underline-offset:3px}.btn:disabled{cursor:wait;opacity:.55}.actions{flex-wrap:wrap}
@media(max-width:740px){.upload-zone{align-items:flex-start;flex-direction:column}.upload-controls{width:100%}.upload-controls .btn{flex:1}.specbar select{min-width:120px}.specnote{flex-basis:100%}}
</style>
</head>
<body><div class="wrap">
<header><div class="brand"><div class="symbol">R2</div><div><strong>R2C 도면 스튜디오</strong><small>DXF → 단면 재구성 → 웹 3D</small></div></div><div class="actions"><span class="badge">DXF 엔티티 추출</span><a class="link" id="sourceLink" href="__DXF_HREF__" download>원본 DXF ↓</a><button class="btn" id="download">3D 모델 ↓</button></div></header>
<div class="intro"><div><div class="eyebrow">DRAWING / RECONSTRUCTION</div><h1>사각 입구에서 원형 관로까지</h1><p class="subtitle">R2C관의 전환부와 소켓을 원본 단면으로 확인하세요.</p></div><div class="intro-note">모든 치수는 mm 기준<br>원본 A-A / B-B 단면 및 변화치수표 반영</div></div>
<section class="upload-zone" id="dropZone" aria-label="DXF 첨부 영역"><div><strong>DXF 파일을 첨부하거나 여기에 놓으세요</strong><p>R2C 일반도의 단면·변화치수표를 읽어 바로 3D로 변환합니다. · 최대 10 MB</p><p>첨부 파일은 변환 후 서버에 저장하지 않습니다.</p></div><div class="upload-controls"><input type="file" id="dxfFile" accept=".dxf" hidden><button class="btn" id="uploadButton">DXF 파일 첨부</button><button class="btn" id="sampleReset">기본 도면으로</button></div></section><p class="upload-message" id="uploadMessage" role="status" aria-live="polite" hidden></p>
<div class="specbar"><label for="specification">규격 선택</label><select id="specification" aria-label="R2C 관로 내경 규격"></select><span class="small-tag" id="specBasis">원본 단면</span><span class="specnote" id="specNotice">선택한 규격의 변화치수를 적용합니다.</span></div>
<div class="workspace"><section class="main"><div class="toolbar"><div class="seg" aria-label="표시할 부재"><button class="btn active" data-model="r2c">R2C관</button><button class="btn" data-model="pipe">원심력 콘크리트관</button></div><div class="seg" aria-label="분해 표시"><button class="btn active" data-mode="closed">전체</button><button class="btn" data-mode="exploded">덮개 분리</button><button class="btn" data-mode="cutaway">내부 보기</button></div></div>
<div class="stage"><div class="stage-label">SECTION-BASED 3D<strong id="modelName">R2C · Ø1,000</strong></div><span class="stage-status" id="status">기본 시점</span><svg id="scene" viewBox="0 0 1000 700" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="DXF에서 재구성한 R2C관 3D 모델" tabindex="0">__INITIAL_SCENE__</svg><div class="error" id="renderError" hidden role="alert"></div><noscript><p>기본 3D 형상입니다. 회전과 분해 기능은 JavaScript를 허용하면 사용할 수 있습니다.</p></noscript><span class="stage-bottom">X 길이 / Y 폭 / Z 높이 · 단위 mm</span><div class="zoom"><button id="zoomOut" aria-label="축소">−</button><button id="zoomIn" aria-label="확대">+</button><button id="reset" aria-label="기본 시점">↺</button></div></div>
<div class="viewbar"><div class="seg" aria-label="시점"><button class="btn active" data-view="iso">등각</button><button class="btn" data-view="inlet">입구 A-A</button><button class="btn" data-view="outlet">소켓 B-B</button><button class="btn" data-view="side">측면</button><button class="btn" data-view="top">평면</button><button class="btn" id="wire" aria-pressed="false">윤곽선</button></div><span class="help">드래그 회전 · 휠 확대 · 방향키 회전</span></div></section>
<aside class="cards"><section class="card"><h2>도면에서 읽은 치수 <span class="small-tag">DXF VERIFIED</span></h2><dl class="dims" id="dimensions"></dl><hr class="rule"><p class="micro" id="shapeNote">사각 입구 → Ø1,000 원형 유로 → 단차 소켓</p></section><section class="card"><h2>부재와 내부 형상</h2><div class="legend"><i class="swatch"></i>상부 덮개</div><div class="legend"><i class="swatch bottom"></i>하부 본체</div><div class="legend"><i class="swatch inner"></i>전환부·관로·소켓</div><p class="micro">덮개 분리 또는 내부 보기를 선택하면 사각–원형 전환 면이 드러납니다.</p></section><section class="card"><h2>재구성 기준 <span class="small-tag">A-A / B-B</span></h2><p class="micro">외곽선·원호·치수표에서 단면과 소켓 위치를 추출합니다. 원심력관은 실제 종단 폴리라인을 회전시켜 만듭니다.</p><div class="limits" id="limits"></div><button class="btn" id="evidence">추출 근거 JSON ↓</button></section></aside></div>
<aside class="cards"><section class="card"><h2>도면에서 읽은 치수 <span class="small-tag">DXF VERIFIED</span></h2><dl class="dims" id="dimensions"></dl><hr class="rule"><p class="micro" id="shapeNote">사각 입구 → Ø1,000 원형 유로 → 단차 소켓</p></section><section class="card"><h2>부재와 내부 형상</h2><div class="legend"><i class="swatch"></i>상부 덮개</div><div class="legend"><i class="swatch bottom"></i>하부 본체</div><div class="legend"><i class="swatch inner"></i>전환부·관로·소켓</div><p class="micro">덮개 분리 또는 내부 보기를 선택하면 사각–원형 전환 면이 드러납니다.</p></section><section class="card"><h2>재구성 기준 <span class="small-tag">A-A / B-B</span></h2><p class="micro">외곽선·원호·치수표에서 단면과 소켓 위치를 추출합니다. 원심력관은 실제 종단 폴리라인을 회전시켜 만듭니다.</p><div class="limits" id="limits"></div><p class="spec-warning" id="specWarning"></p><button class="btn" id="evidence">추출 근거 JSON ↓</button></section></aside></div>
<details class="drawing-card"><summary>원본 DXF 벡터 도면과 비교</summary><div class="drawing-controls"><select id="drawingView" aria-label="도면 영역"><option value="all">전체 일반도</option><option value="r2c">R2C 평면·측면·단면</option><option value="sections">A-A / B-B 단면</option><option value="pipe">원심력관 종단·단면</option></select><button class="btn" id="drawingReset">영역 맞춤</button><span class="micro">드래그 이동 · 휠 확대</span></div><div class="drawing-viewport">__DRAWING_SVG__</div><div class="drawing-note">DXF의 선·원·원호·타원·스플라인·문자·치수 블록을 표시합니다. 해치 채움과 치수 화살표 SOLID는 생략했습니다.</div></details>
<details class="drawing-card"><summary>변화치수표 · 원본에서 추출한 7개 규격</summary><div class="table-scroll"><table><thead><tr><th>D</th><th>L</th><th>B</th><th>H</th><th>D1</th><th>D2</th><th>L1</th><th>L2</th><th>L3</th></tr></thead><tbody id="sizeTable"></tbody></table></div><div class="drawing-note">3D 모델은 실제 단면이 그려진 Ø1,000 규격입니다. 다른 규격은 치수표 비교용입니다.</div></details>
<details class="drawing-card"><summary>변화치수표 · 규격을 누르면 3D에 반영</summary><div class="table-scroll"><table><thead><tr><th>D</th><th>D1</th><th>D2</th><th>L</th><th>L1</th><th>L2</th><th>L3</th><th>B</th><th>b1</th><th>b2</th><th>b3</th><th>b4</th><th>H</th><th>h1</th><th>h2</th><th>h3</th><th>h4</th><th>h5</th><th>h6</th><th>h7</th></tr></thead><tbody id="sizeTable"></tbody></table></div><div class="drawing-note">내경 D를 누르면 해당 규격의 R2C 모델과 치수가 함께 변경됩니다. 원형 콘크리트관은 실제 종단이 있는 원본 규격으로 표시합니다.</div></details>
<p class="foot" id="sourceNote">Python 표준 라이브러리로 생성한 독립 실행형 웹 뷰어 · 외부 CDN 및 추가 패키지 불필요</p>
</div>
<script id="modelData" type="application/json">__MODEL_JSON__</script>
<script>
window.addEventListener('error',function(event){const box=document.getElementById('renderError');box.hidden=false;box.textContent='화면 실행 오류: '+event.message;});
(()=>{
'use strict';
const data=JSON.parse(document.getElementById('modelData').textContent),scene=document.getElementById('scene');
let model='r2c',mode='closed',yaw=.87,pitch=.40,zoom=1,wire=false,drag=null,pending=false;
const fmt=n=>Math.round(n).toLocaleString('ko-KR');
const presets={iso:[.87,.40],inlet:[Math.PI/2,0],outlet:[-Math.PI/2,0],side:[0,0],top:[0,Math.PI/2]};
function active(selector,key,value){document.querySelectorAll(selector).forEach(b=>{const on=b.dataset[key]===value;b.classList.toggle('active',on);b.setAttribute('aria-pressed',String(on));});}
function rotate(p){const x=p[0],y=p[1],z=p[2]-data.height/2,c=Math.cos(yaw),s=Math.sin(yaw),cp=Math.cos(pitch),sp=Math.sin(pitch),h=x*c-y*s,d=x*s+y*c;return [h,d*cp-z*sp,z*cp+d*sp];}
function polygons(){
const mesh=data.meshes[model],lift=mode==='exploded'&&model==='r2c'?data.height*.64:0,result=[];
mesh.faces.forEach((face,i)=>{const group=mesh.groups[i];if(mode==='cutaway'&&group.part==='upper')return;const pts=face.map(index=>{const p=mesh.vertices[index];return [p[0],p[1],p[2]+(group.part==='upper'?lift:0)];});result.push({pts,part:group.part,surface:group.surface});});
if(model==='r2c'&&mode!=='closed'){
for(let i=0;i<data.profiles.length-1;i++){const a=data.profiles[i],b=data.profiles[i+1];if(b.x-a.x<.001)continue;for(const sign of [-1,1]){
const ra=a.kind==='circle'?a.radius:a.halfWidth,rb=b.kind==='circle'?b.radius:b.halfWidth;
const pts=[[a.x-data.length/2,sign*data.width/2,data.axisHeight],[b.x-data.length/2,sign*data.width/2,data.axisHeight],[b.x-data.length/2,sign*rb,data.axisHeight],[a.x-data.length/2,sign*ra,data.axisHeight]];
result.push({pts,part:'lower',surface:'end'});if(lift)result.push({pts:pts.map(p=>[p[0],p[1],p[2]+lift]),part:'upper',surface:'end'});
}}
}
return result;
}
function shading(poly){const a=poly.pts[0],b=poly.pts[1],c=poly.pts[2],u=b.map((n,i)=>n-a[i]),v=c.map((n,i)=>n-a[i]);let normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]],len=Math.hypot(...normal)||1;normal=normal.map(n=>n/len);const light=Math.max(.48,.78+normal[0]*-.22+normal[1]*-.25+normal[2]*.28);let base=poly.part==='upper'?[194,204,203]:[150,171,176];if(poly.surface==='inner')base=poly.part==='upper'?[152,171,175]:[123,150,159];return 'rgb('+base.map(n=>Math.min(255,Math.round(n*light))).join(',')+')';}
function render(){
pending=false;const polys=polygons(),all=polys.flatMap(poly=>poly.pts.map(rotate)),xs=all.map(p=>p[0]),ys=all.map(p=>p[2]),minX=Math.min(...xs),maxX=Math.max(...xs),minY=Math.min(...ys),maxY=Math.max(...ys),scale=Math.min(780/(maxX-minX||1),490/(maxY-minY||1))*zoom;
const project=p=>{const r=rotate(p);return [500+(r[0]-(minX+maxX)/2)*scale,345-(r[2]-(minY+maxY)/2)*scale];};
const grid='<defs><pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M 40 0 L 0 0 0 40" fill="none" stroke="#98b1bb" stroke-opacity=".18" stroke-width="1"/></pattern></defs><rect width="1000" height="700" fill="url(#grid)"/>';
const sorted=polys.map(poly=>({poly,depth:poly.pts.reduce((sum,p)=>sum+rotate(p)[1],0)/poly.pts.length})).sort((a,b)=>b.depth-a.depth);
let markup=grid;
for(const {poly} of sorted){const pts=poly.pts.map(project).map(p=>p.map(n=>n.toFixed(2)).join(',')).join(' '),color=shading(poly);markup+='<polygon points="'+pts+'" fill="'+color+'" stroke="'+(wire?'#3b5965':color)+'" stroke-width="'+(wire?'.7':'.45')+'" stroke-linejoin="round"/>';}
// Subtle front and rear perimeter lines retain readable openings without a dense wire grid.
scene.innerHTML=markup;document.getElementById('status').textContent=(mode==='exploded'?'덮개 분리':mode==='cutaway'?'내부 보기':'전체 형상')+' · '+Math.round(zoom*100)+'%';
}
function schedule(){if(!pending){pending=true;requestAnimationFrame(render);}}
function info(){const d=data.dimensions,values=model==='r2c'?[['관로 내경 D','Ø'+fmt(data.diameter)],['부재 길이 L',fmt(data.length)],['외형 폭 B',fmt(data.width)],['외형 높이 H',fmt(data.height)],['관 중심 높이 h6',fmt(data.axisHeight)],['입구 폭 b3',fmt(d.b3)],['소켓 길이 L2',fmt(d.L2)]]:[['관로 내경','Ø'+fmt(data.diameter)],['유효 길이',fmt(data.pipeDimensions.nominalLength)],['전체 길이',fmt(Math.max(...data.pipeProfile.map(p=>p[0])))],['본체 외경','Ø'+fmt(data.pipeDimensions.outerDiameter)],['본체 두께',fmt(data.pipeDimensions.wallThickness)]];document.getElementById('dimensions').innerHTML=values.map((v,i)=>'<dt>'+v[0]+'</dt><dd class="'+(i===0?'main-value':'')+'">'+v[1]+' <small>mm</small></dd>').join('');document.getElementById('modelName').textContent=(model==='r2c'?'R2C관':'원심력 콘크리트관')+' · Ø'+fmt(data.diameter);document.getElementById('shapeNote').textContent=model==='r2c'?'사각 입구 → Ø1,000 원형 유로 → 단차 소켓':'종단 폴리라인 195를 관 중심축으로 회전한 실제 단면 형상';}
function reset(){[yaw,pitch]=presets.iso;zoom=1;active('[data-view]','view','iso');schedule();}
document.querySelectorAll('[data-model]').forEach(b=>b.addEventListener('click',()=>{model=b.dataset.model;mode='closed';active('[data-model]','model',model);active('[data-mode]','mode',mode);document.querySelectorAll('[data-mode]').forEach(x=>x.disabled=model==='pipe'&&x.dataset.mode==='exploded');info();reset();}));
document.querySelectorAll('[data-mode]').forEach(b=>b.addEventListener('click',()=>{mode=b.dataset.mode;active('[data-mode]','mode',mode);schedule();}));
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>{[yaw,pitch]=presets[b.dataset.view];active('[data-view]','view',b.dataset.view);schedule();}));
document.getElementById('wire').addEventListener('click',e=>{wire=!wire;e.currentTarget.classList.toggle('active',wire);e.currentTarget.setAttribute('aria-pressed',String(wire));schedule();});
document.getElementById('zoomIn').addEventListener('click',()=>{zoom=Math.min(2.5,zoom*1.2);schedule();});document.getElementById('zoomOut').addEventListener('click',()=>{zoom=Math.max(.5,zoom/1.2);schedule();});document.getElementById('reset').addEventListener('click',reset);
scene.addEventListener('pointerdown',e=>{drag={x:e.clientX,y:e.clientY,yaw,pitch};scene.setPointerCapture(e.pointerId);});scene.addEventListener('pointermove',e=>{if(!drag)return;yaw=drag.yaw+(e.clientX-drag.x)*.008;pitch=Math.max(-1.5,Math.min(1.5,drag.pitch+(e.clientY-drag.y)*.006));active('[data-view]','view','');schedule();});scene.addEventListener('pointerup',()=>drag=null);scene.addEventListener('pointercancel',()=>drag=null);scene.addEventListener('wheel',e=>{e.preventDefault();zoom=Math.max(.5,Math.min(2.5,zoom*Math.exp(-e.deltaY*.001)));schedule();},{passive:false});scene.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return;e.preventDefault();yaw+=e.key==='ArrowLeft'?-.1:e.key==='ArrowRight'?.1:0;pitch=Math.max(-1.5,Math.min(1.5,pitch+(e.key==='ArrowUp'?.1:e.key==='ArrowDown'?-.1:0)));schedule();});
function save(name,text,type){const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
document.getElementById('download').addEventListener('click',()=>{const mesh=data.meshes[model],lines=['# DXF reconstructed model; units: mm'];for(const p of mesh.vertices)lines.push('v '+p.join(' '));for(const f of mesh.faces)lines.push('f '+f.map(i=>i+1).join(' '));save(model+'.obj',lines.join('\n')+'\n','text/plain');});document.getElementById('evidence').addEventListener('click',()=>{const report={...data};delete report.meshes;save('dxf-evidence.json',JSON.stringify(report,null,2),'application/json');});
document.getElementById('sizeTable').innerHTML=data.table.map(row=>'<tr class="'+(row.D===data.diameter?'current':'')+'">'+['D','L','B','H','D1','D2','L1','L2','L3'].map(key=>'<td>'+fmt(row[key])+'</td>').join('')+'</tr>').join('');document.getElementById('limits').textContent=data.limitations.join(' ');document.getElementById('sourceNote').textContent=data.source+' · SHA-256 '+data.sha256.slice(0,16)+'… · 외부 라이브러리 없이 생성';
const drawing=document.querySelector('.drawing-viewport svg'),regions={all:[0,-5940,16820,5940],r2c:[8750,-5500,7900,5000],sections:[11350,-2800,4200,1950],pipe:[550,-4900,4800,1900]};let vb=regions.all.slice(),drawingDrag=null;
function applyView(){drawing.setAttribute('viewBox',vb.join(' '));}document.getElementById('drawingView').addEventListener('change',e=>{vb=regions[e.target.value].slice();applyView();});document.getElementById('drawingReset').addEventListener('click',()=>{vb=regions[document.getElementById('drawingView').value].slice();applyView();});drawing.addEventListener('pointerdown',e=>{drawingDrag={x:e.clientX,y:e.clientY,v:vb.slice()};drawing.setPointerCapture(e.pointerId);});drawing.addEventListener('pointermove',e=>{if(!drawingDrag)return;const r=drawing.getBoundingClientRect(),s=Math.max(drawingDrag.v[2]/r.width,drawingDrag.v[3]/r.height);vb=[drawingDrag.v[0]-(e.clientX-drawingDrag.x)*s,drawingDrag.v[1]-(e.clientY-drawingDrag.y)*s,drawingDrag.v[2],drawingDrag.v[3]];applyView();});drawing.addEventListener('pointerup',()=>drawingDrag=null);drawing.addEventListener('pointercancel',()=>drawingDrag=null);drawing.addEventListener('wheel',e=>{e.preventDefault();const f=Math.exp(Math.max(-.35,Math.min(.35,e.deltaY*.001))),width=Math.max(200,Math.min(40000,vb[2]*f)),ratio=width/vb[2];vb=[vb[0]+vb[2]*(1-ratio)/2,vb[1]+vb[3]*(1-ratio)/2,width,vb[3]*ratio];applyView();},{passive:false});
info();render();
})();
__VIEWER_JS__
</script>
</body></html>
+132
View File
@@ -0,0 +1,132 @@
window.addEventListener('error',function(event){const box=document.getElementById('renderError');box.hidden=false;box.textContent='화면 실행 오류: '+event.message;});
(()=>{
'use strict';
const initialDocument=JSON.parse(document.getElementById('modelData').textContent),scene=document.getElementById('scene');
let sourceDocument=initialDocument,data=initialDocument,selectedDiameter=initialDocument.diameter;
const initialDrawing=document.querySelector('.drawing-viewport').innerHTML,initialSource=document.getElementById('sourceLink').getAttribute('href');
let sourceUrl=null,uploadBusy=false;
const specification=document.getElementById('specification');
const tableKeys=['D','D1','D2','L','L1','L2','L3','B','b1','b2','b3','b4','H','h1','h2','h3','h4','h5','h6','h7'];
let model='r2c',mode='closed',yaw=.87,pitch=.40,zoom=1,wire=false,drag=null,pending=false;
const fmt=n=>Math.round(n).toLocaleString('ko-KR');
const presets={iso:[.87,.40],inlet:[Math.PI/2,0],outlet:[-Math.PI/2,0],side:[0,0],top:[0,Math.PI/2]};
function active(selector,key,value){document.querySelectorAll(selector).forEach(b=>{const on=b.dataset[key]===value;b.classList.toggle('active',on);b.setAttribute('aria-pressed',String(on));});}
function rotate(p){const x=p[0],y=p[1],z=p[2]-data.height/2,c=Math.cos(yaw),s=Math.sin(yaw),cp=Math.cos(pitch),sp=Math.sin(pitch),h=x*c-y*s,d=x*s+y*c;return [h,d*cp-z*sp,z*cp+d*sp];}
function polygons(){
const mesh=data.meshes[model],lift=mode==='exploded'&&model==='r2c'?data.height*.64:0,result=[];
mesh.faces.forEach((face,i)=>{const group=mesh.groups[i];if(mode==='cutaway'&&group.part==='upper')return;const pts=face.map(index=>{const p=mesh.vertices[index];return [p[0],p[1],p[2]+(group.part==='upper'?lift:0)];});result.push({pts,part:group.part,surface:group.surface});});
if(model==='r2c'&&mode!=='closed'){
for(let i=0;i<data.profiles.length-1;i++){const a=data.profiles[i],b=data.profiles[i+1];if(b.x-a.x<.001)continue;for(const sign of [-1,1]){
const ra=a.kind==='circle'?a.radius:a.halfWidth,rb=b.kind==='circle'?b.radius:b.halfWidth;
const pts=[[a.x-data.length/2,sign*data.width/2,data.axisHeight],[b.x-data.length/2,sign*data.width/2,data.axisHeight],[b.x-data.length/2,sign*rb,data.axisHeight],[a.x-data.length/2,sign*ra,data.axisHeight]];
result.push({pts,part:'lower',surface:'end'});if(lift)result.push({pts:pts.map(p=>[p[0],p[1],p[2]+lift]),part:'upper',surface:'end'});
}}
}
return result;
}
function shading(poly){const a=poly.pts[0],b=poly.pts[1],c=poly.pts[2],u=b.map((n,i)=>n-a[i]),v=c.map((n,i)=>n-a[i]);let normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]],len=Math.hypot(...normal)||1;normal=normal.map(n=>n/len);const light=Math.max(.48,.78+normal[0]*-.22+normal[1]*-.25+normal[2]*.28);let base=poly.part==='upper'?[194,204,203]:[150,171,176];if(poly.surface==='inner')base=poly.part==='upper'?[152,171,175]:[123,150,159];return 'rgb('+base.map(n=>Math.min(255,Math.round(n*light))).join(',')+')';}
function render(){
pending=false;const polys=polygons(),all=polys.flatMap(poly=>poly.pts.map(rotate)),xs=all.map(p=>p[0]),ys=all.map(p=>p[2]),minX=Math.min(...xs),maxX=Math.max(...xs),minY=Math.min(...ys),maxY=Math.max(...ys),scale=Math.min(780/(maxX-minX||1),490/(maxY-minY||1))*zoom;
const project=p=>{const r=rotate(p);return [500+(r[0]-(minX+maxX)/2)*scale,345-(r[2]-(minY+maxY)/2)*scale];};
const grid='<defs><pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M 40 0 L 0 0 0 40" fill="none" stroke="#98b1bb" stroke-opacity=".18" stroke-width="1"/></pattern></defs><rect width="1000" height="700" fill="url(#grid)"/>';
const sorted=polys.map(poly=>({poly,depth:poly.pts.reduce((sum,p)=>sum+rotate(p)[1],0)/poly.pts.length})).sort((a,b)=>b.depth-a.depth);
let markup=grid;
for(const {poly} of sorted){const pts=poly.pts.map(project).map(p=>p.map(n=>n.toFixed(2)).join(',')).join(' '),color=shading(poly);markup+='<polygon points="'+pts+'" fill="'+color+'" stroke="'+(wire?'#3b5965':color)+'" stroke-width="'+(wire?'.7':'.45')+'" stroke-linejoin="round"/>';}
// Subtle front and rear perimeter lines retain readable openings without a dense wire grid.
scene.innerHTML=markup;document.getElementById('status').textContent=(mode==='exploded'?'덮개 분리':mode==='cutaway'?'내부 보기':'전체 형상')+' · '+Math.round(zoom*100)+'%';
}
function schedule(){if(!pending){pending=true;requestAnimationFrame(render);}}
function info(){const d=data.dimensions,values=model==='r2c'?[['관로 내경 D','Ø'+fmt(data.diameter)],['부재 길이 L',fmt(data.length)],['외형 폭 B',fmt(data.width)],['외형 높이 H',fmt(data.height)],['관 중심 높이 h6',fmt(data.axisHeight)],['입구 폭 b3',fmt(d.b3)],['소켓 길이 L2',fmt(d.L2)]]:[['관로 내경','Ø'+fmt(data.diameter)],['유효 길이',fmt(data.pipeDimensions.nominalLength)],['전체 길이',fmt(Math.max(...data.pipeProfile.map(p=>p[0])))],['본체 외경','Ø'+fmt(data.pipeDimensions.outerDiameter)],['본체 두께',fmt(data.pipeDimensions.wallThickness)]];document.getElementById('dimensions').innerHTML=values.map((v,i)=>'<dt>'+v[0]+'</dt><dd class="'+(i===0?'main-value':'')+'">'+v[1]+' <small>mm</small></dd>').join('');document.getElementById('modelName').textContent=(model==='r2c'?'R2C관':'원심력 콘크리트관')+' · Ø'+fmt(data.diameter);document.getElementById('shapeNote').textContent=model==='r2c'?'사각 입구 → Ø'+fmt(data.diameter)+' 원형 유로 → 단차 소켓':'원본 DXF 종단 폴리라인을 회전한 실제 단면 형상';}
function reset(){[yaw,pitch]=presets.iso;zoom=1;active('[data-view]','view','iso');schedule();}
function updateTable(){document.getElementById('sizeTable').innerHTML=sourceDocument.table.map(row=>'<tr class="'+(row.D===selectedDiameter?'current':'')+'">'+tableKeys.map(key=>key==='D'?'<td><button class="spec-cell" data-spec="'+row.D+'" aria-label="내경 '+row.D+' 규격 보기">'+fmt(row.D)+'</button></td>':'<td>'+fmt(row[key])+'</td>').join('')+'</tr>').join('');}
function applySelection(){
const variant=sourceDocument.variants.find(v=>v.diameter===selectedDiameter);
if(!variant)throw Error('선택한 규격의 모델을 찾지 못했습니다.');
data=model==='pipe'?sourceDocument:{...sourceDocument,...variant};
specification.value=String(selectedDiameter);specification.disabled=model==='pipe';
document.querySelectorAll('[data-mode]').forEach(b=>b.disabled=model==='pipe'&&b.dataset.mode==='exploded');
document.getElementById('specBasis').textContent=model==='pipe'?'원본 종단':data.basis==='drawn'?'원본 단면':'치수표 재구성';
document.getElementById('specNotice').textContent=model==='pipe'?'원심력관은 원본 Ø'+fmt(sourceDocument.diameter)+' 규격만 제공합니다.':data.reconstructionNote;
document.getElementById('specWarning').textContent=model==='pipe'?'':(data.warnings||[]).join(' ');
document.getElementById('limits').textContent=sourceDocument.limitations.join(' ');
info();updateTable();schedule();
}
function selectSpecification(value){selectedDiameter=Number(value);model='r2c';active('[data-model]','model',model);applySelection();}
specification.addEventListener('change',e=>selectSpecification(e.target.value));
document.getElementById('sizeTable').addEventListener('click',e=>{const button=e.target.closest('[data-spec]');if(button)selectSpecification(button.dataset.spec);});
document.querySelectorAll('[data-model]').forEach(b=>b.addEventListener('click',()=>{model=b.dataset.model;mode='closed';active('[data-model]','model',model);active('[data-mode]','mode',mode);applySelection();reset();}));
document.querySelectorAll('[data-mode]').forEach(b=>b.addEventListener('click',()=>{mode=b.dataset.mode;active('[data-mode]','mode',mode);schedule();}));
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>{[yaw,pitch]=presets[b.dataset.view];active('[data-view]','view',b.dataset.view);schedule();}));
document.getElementById('wire').addEventListener('click',e=>{wire=!wire;e.currentTarget.classList.toggle('active',wire);e.currentTarget.setAttribute('aria-pressed',String(wire));schedule();});
document.getElementById('zoomIn').addEventListener('click',()=>{zoom=Math.min(2.5,zoom*1.2);schedule();});document.getElementById('zoomOut').addEventListener('click',()=>{zoom=Math.max(.5,zoom/1.2);schedule();});document.getElementById('reset').addEventListener('click',reset);
scene.addEventListener('pointerdown',e=>{drag={x:e.clientX,y:e.clientY,yaw,pitch};scene.setPointerCapture(e.pointerId);});scene.addEventListener('pointermove',e=>{if(!drag)return;yaw=drag.yaw+(e.clientX-drag.x)*.008;pitch=Math.max(-1.5,Math.min(1.5,drag.pitch+(e.clientY-drag.y)*.006));active('[data-view]','view','');schedule();});scene.addEventListener('pointerup',()=>drag=null);scene.addEventListener('pointercancel',()=>drag=null);scene.addEventListener('wheel',e=>{e.preventDefault();zoom=Math.max(.5,Math.min(2.5,zoom*Math.exp(-e.deltaY*.001)));schedule();},{passive:false});scene.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return;e.preventDefault();yaw+=e.key==='ArrowLeft'?-.1:e.key==='ArrowRight'?.1:0;pitch=Math.max(-1.5,Math.min(1.5,pitch+(e.key==='ArrowUp'?.1:e.key==='ArrowDown'?-.1:0)));schedule();});
function save(name,text,type){const url=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
document.getElementById('download').addEventListener('click',()=>{const mesh=data.meshes[model],lines=['# DXF reconstructed model; units: mm'];for(const p of mesh.vertices)lines.push('v '+p.join(' '));for(const f of mesh.faces)lines.push('f '+f.map(i=>i+1).join(' '));save(model+'-D'+data.diameter+'.obj',lines.join('\n')+'\n','text/plain');});document.getElementById('evidence').addEventListener('click',()=>{const report={...data};delete report.meshes;delete report.variants;save('dxf-evidence-D'+data.diameter+'.json',JSON.stringify(report,null,2),'application/json');});
let drawing=document.querySelector('.drawing-viewport svg');
let regions={},vb=[],drawingDrag=null;
function drawingRegions(){
// Measure rendered vectors, including block transforms and text.
const bounds=drawing.querySelector('g').getBBox();
const pad=100;
const all=[bounds.x-pad,bounds.y-pad,bounds.width+2*pad,bounds.height+2*pad];
const labels=Array.from(drawing.querySelectorAll('text'));
const find=text=>labels.find(label=>label.textContent.includes(text));
const around=(label,width,height)=>{
if(!label)return all.slice();
const box=label.getBBox();
return [box.x-200,box.y-100,width+400,height+500];
};
const aa=find('단면 A-A'),bb=find('단면 B-B');
let sections=all.slice();
if(aa&&bb){
const a=around(aa,sourceDocument.width,sourceDocument.height);
const b=around(bb,sourceDocument.width,sourceDocument.height);
const x=Math.min(a[0],b[0]),y=Math.min(a[1],b[1]);
sections=[x,y,Math.max(a[0]+a[2],b[0]+b[2])-x,Math.max(a[1]+a[3],b[1]+b[3])-y];
}
regions={all,r2c:around(find('측면도'),sourceDocument.length,sourceDocument.height),
sections,pipe:around(find('일반 B형'),sourceDocument.pipeDimensions.overallLength,sourceDocument.pipeDimensions.outerDiameter)};
vb=regions[document.getElementById('drawingView').value].slice();
applyView();
}
function applyView(){drawing.setAttribute('viewBox',vb.join(' '));}document.getElementById('drawingView').addEventListener('change',e=>{vb=regions[e.target.value].slice();applyView();});document.getElementById('drawingReset').addEventListener('click',()=>{vb=regions[document.getElementById('drawingView').value].slice();applyView();});function bindDrawing(){drawing.addEventListener('pointerdown',e=>{drawingDrag={x:e.clientX,y:e.clientY,v:vb.slice()};drawing.setPointerCapture(e.pointerId);});drawing.addEventListener('pointermove',e=>{if(!drawingDrag)return;const r=drawing.getBoundingClientRect(),s=Math.max(drawingDrag.v[2]/r.width,drawingDrag.v[3]/r.height);vb=[drawingDrag.v[0]-(e.clientX-drawingDrag.x)*s,drawingDrag.v[1]-(e.clientY-drawingDrag.y)*s,drawingDrag.v[2],drawingDrag.v[3]];applyView();});drawing.addEventListener('pointerup',()=>drawingDrag=null);drawing.addEventListener('pointercancel',()=>drawingDrag=null);drawing.addEventListener('wheel',e=>{e.preventDefault();const f=Math.exp(Math.max(-.35,Math.min(.35,e.deltaY*.001))),width=Math.max(200,Math.min(40000,vb[2]*f)),ratio=width/vb[2];vb=[vb[0]+vb[2]*(1-ratio)/2,vb[1]+vb[3]*(1-ratio)/2,width,vb[3]*ratio];applyView();},{passive:false});}
function documentInfo(){
specification.innerHTML=sourceDocument.variants.map(v=>'<option value="'+v.diameter+'">Ø '+fmt(v.diameter)+' mm</option>').join('');
document.getElementById('sourceNote').textContent=sourceDocument.source+' · SHA-256 '+sourceDocument.sha256.slice(0,16)+'… · 첨부 변환은 Python 서버에서 실행';
applySelection();
}
function uploadMessage(text,error=false){const el=document.getElementById('uploadMessage');el.textContent=text;el.hidden=!text;el.classList.toggle('error',error);}
function replaceDocument(documentData,svg,file){
sourceDocument=documentData;selectedDiameter=documentData.diameter;model='r2c';mode='closed';
if(sourceUrl){URL.revokeObjectURL(sourceUrl);sourceUrl=null;}
if(file){sourceUrl=URL.createObjectURL(file);document.getElementById('sourceLink').href=sourceUrl;document.getElementById('sourceLink').download=file.name;}else{document.getElementById('sourceLink').href=initialSource;document.getElementById('sourceLink').download=initialDocument.source;}
document.querySelector('.drawing-viewport').innerHTML=svg;drawing=document.querySelector('.drawing-viewport svg');drawingDrag=null;bindDrawing();
active('[data-model]','model',model);active('[data-mode]','mode',mode);documentInfo();if(drawing.closest('details').open)drawingRegions();reset();
}
async function upload(file){
if(!file||uploadBusy)return;
if(!/\.dxf$/i.test(file.name)){uploadMessage('.dxf 파일을 선택해 주세요.',true);return;}
if(file.size>10*1024*1024){uploadMessage('파일은 10 MB 이하로 첨부해 주세요.',true);return;}
if(location.protocol==='file:'){uploadMessage('파일 첨부 변환은 웹 서버에서 사용할 수 있습니다. python3 r2c.py serve 실행 후 http://localhost:8000 에 접속해 주세요.',true);return;}
uploadBusy=true;document.getElementById('uploadButton').disabled=true;document.getElementById('sampleReset').disabled=true;uploadMessage(file.name+' · 도면과 규격을 분석하고 있습니다…');
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000);
try{
const response=await fetch('/api/convert',{method:'POST',headers:{'Content-Type':'application/octet-stream','X-Filename':encodeURIComponent(file.name)},body:file,signal:controller.signal});
if(!(response.headers.get('Content-Type')||'').includes('application/json'))throw Error('첨부 변환 API가 없습니다. python3 r2c.py serve로 실행한 사이트에서 첨부해 주세요.');
const payload=await response.json();if(!response.ok)throw Error(payload.error||'도면 변환에 실패했습니다.');
if(!payload.model?.variants?.length||!payload.drawingSvg?.startsWith('<svg'))throw Error('변환 결과가 올바르지 않습니다.');
replaceDocument(payload.model,payload.drawingSvg,file);uploadMessage(file.name+' · '+payload.model.variants.length+'개 규격을 불러왔습니다.');
}catch(error){uploadMessage(error.name==='AbortError'?'변환 시간이 초과되었습니다. 파일 크기와 도면 형식을 확인해 주세요.':error.message,true);}
finally{clearTimeout(timer);uploadBusy=false;document.getElementById('uploadButton').disabled=false;document.getElementById('sampleReset').disabled=false;document.getElementById('dxfFile').value='';}
}
const fileInput=document.getElementById('dxfFile'),dropZone=document.getElementById('dropZone');
document.getElementById('uploadButton').addEventListener('click',()=>fileInput.click());fileInput.addEventListener('change',e=>upload(e.target.files[0]));
dropZone.addEventListener('dragover',e=>{e.preventDefault();dropZone.classList.add('dragover');});dropZone.addEventListener('dragleave',()=>dropZone.classList.remove('dragover'));dropZone.addEventListener('drop',e=>{e.preventDefault();dropZone.classList.remove('dragover');upload(e.dataTransfer.files[0]);});
document.getElementById('sampleReset').addEventListener('click',()=>{replaceDocument(initialDocument,initialDrawing,null);uploadMessage('기본 도면으로 돌아왔습니다.');});
bindDrawing();documentInfo();render();
// Hidden SVGs have no measurable bounds until their details panel opens.
document.querySelector('.drawing-card').addEventListener('toggle',event=>{
if(event.currentTarget.open)drawingRegions();
});
})();