Files
TDC_R2C_Drawing/web/viewer.js
T

134 lines
17 KiB
JavaScript

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');
document.getElementById('localFileNotice').hidden=location.protocol!=='file:';
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();
});
})();