Add src/main.js
This commit is contained in:
1 parent
0a9bd91407
commit
6de19266cf
1 file changed
+91
+91
@@ -0,0 +1,91 @@
|
||||
// UI state, model construction, canvas rendering, and viewer controls.
|
||||
const $=id=>document.getElementById(id);let entities=[],dimensionTexts=[],fileName='',modelData=null,yaw=-.72,pitch=.62,zoom=1,panX=0,panY=0,drag=null;
|
||||
const canvas=document.createElement('canvas');$('viewport').appendChild(canvas);const ctx=canvas.getContext('2d');
|
||||
const status=(msg,error=false)=>{$('status').textContent=msg;$('status').classList.toggle('error',error)};
|
||||
function resize(){const r=$('viewport').getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2);canvas.width=r.width*dpr;canvas.height=r.height*dpr;canvas.style.width=r.width+'px';canvas.style.height=r.height+'px';ctx.setTransform(dpr,0,0,dpr,0,0);draw()}window.addEventListener('resize',resize);
|
||||
function fill(sel,items,placeholder){sel.innerHTML='';const opt=document.createElement('option');opt.value='';opt.textContent=placeholder;sel.append(opt);for(const v of items){const o=document.createElement('option');o.value=v;o.textContent=v;sel.append(o)}}
|
||||
function loadFile(file){if(!file)return;fileName=file.name;$('filename').textContent=file.name;$('meta').textContent=file.name;const reader=new FileReader();reader.onerror=()=>status('파일을 읽을 수 없습니다.',true);reader.onload=()=>{try{const parsed=CatchpitDxf.parseDxf(String(reader.result));entities=parsed.entities;dimensionTexts=parsed.dimensionTexts;const layers=parsed.layers;fill($('outline'),layers,'평면 외곽선 레이어 선택');$('outline').disabled=false;$('build').disabled=false;$('export').disabled=true;$('parts').classList.add('hidden');$('empty').classList.remove('hidden');if(layers.includes('LN_out'))$('outline').value='LN_out';status(`${layers.length}개 레이어 · 선 도형 ${entities.length}개 · 치수 문자 ${dimensionTexts.length}개를 읽었습니다. 외곽선 레이어를 확인하세요.`);$('footstatus').textContent=file.name;modelData=null;draw()}catch(e){$('build').disabled=true;status('DXF 읽기 실패: '+e.message,true)}};reader.readAsText(file)}
|
||||
$('file').addEventListener('change',e=>loadFile(e.target.files[0]));$('drop').addEventListener('dragover',e=>{e.preventDefault();$('drop').classList.add('drag')});$('drop').addEventListener('dragleave',()=>$('drop').classList.remove('drag'));$('drop').addEventListener('drop',e=>{e.preventDefault();$('drop').classList.remove('drag');loadFile(e.dataTransfer.files[0])});
|
||||
// Build the slab and hollow wall from dimensioned outer and inner plan loops.
|
||||
function build(){
|
||||
try{
|
||||
const found=CatchpitDxf.closedLoops(entities,$('outline').value);
|
||||
if(!found.length)throw Error('외곽선을 찾지 못했습니다. LN_out 레이어를 확인하세요.');
|
||||
// Reuse the same DIM line set when scoring every closed outline.
|
||||
const dimLines=entities.filter(e=>e.layer==='DIM'&&e.type==='LINE'&&e.pts?.length===2);
|
||||
const candidates=found.map(poly=>({poly,dims:CatchpitDxf.loopDims(poly,dimLines,dimensionTexts),area:Math.abs(CatchpitDxf.area(poly))})).filter(c=>c.dims).sort((a,b)=>a.dims.score-b.dims.score||b.area-a.area);
|
||||
if(!candidates.length)throw Error('평면 외곽에 대응하는 가로·세로 DIM 치수를 찾지 못했습니다.');
|
||||
const chosen=candidates[0],outer=chosen.poly,minX=Math.min(...outer.map(p=>p.x)),minY=Math.min(...outer.map(p=>p.y));
|
||||
const insideLoops=found.filter(h=>h!==outer&&CatchpitDxf.inside(h[0],outer)).sort((a,b)=>Math.abs(CatchpitDxf.area(b))-Math.abs(CatchpitDxf.area(a)));
|
||||
if(!insideLoops.length)throw Error('벽체와 내부 공간을 분리하려면 평면도에 안쪽 경계선이 필요합니다.');
|
||||
const inner=insideLoops[0],scaleX=chosen.dims.scaleX*.001,scaleY=chosen.dims.scaleY*.001;
|
||||
let height=Number($('height').value)*.001,baseThickness=Number($('thickness').value)*.001,heightSource='manual';
|
||||
if($('autoHeight').checked){const used=chosen.dims.dyText;const vertical=dimensionTexts.filter(t=>Math.abs((((t.rotation%180)+180)%180)-90)<12&&t!==used).map(t=>({t,value:CatchpitDxf.dimValue(t.text)})).filter(v=>v.value&&v.value>=100).sort((a,b)=>b.value-a.value);if(vertical.length){height=vertical[0].value*.001;heightSource=`section DIM ${vertical[0].value} mm`;const slab=vertical.filter(v=>v.value<height*1000).sort((a,b)=>a.value-b.value)[0];if(slab)baseThickness=slab.value*.001;$('height').value=vertical[0].value;if(slab)$('thickness').value=slab.value}}
|
||||
if(!(height>0&&baseThickness>=0))throw Error('벽체 높이와 바닥 두께를 확인해 주세요.');
|
||||
const outerW=(Math.max(...outer.map(p=>p.x))-minX)*scaleX,outerH=(Math.max(...outer.map(p=>p.y))-minY)*scaleY,innerW=(Math.max(...inner.map(p=>p.x))-Math.min(...inner.map(p=>p.x)))*scaleX,innerH=(Math.max(...inner.map(p=>p.y))-Math.min(...inner.map(p=>p.y)))*scaleY;
|
||||
const wallThickness=((outerW-innerW)+(outerH-innerH))/4;
|
||||
const toModel=p=>({x:(p.x-minX)*scaleX,y:(p.y-minY)*scaleY});
|
||||
const parts=[{name:'바닥판',type:'base',footprint:outer.map(toModel),z0:-baseThickness,z1:0,color:'#3c9881',visible:true}];
|
||||
const edgePairs=[];
|
||||
const unused=new Set(inner.map((_,i)=>i));
|
||||
for(let i=0;i<outer.length;i++){
|
||||
const a=outer[i],b=outer[(i+1)%outer.length],dx=b.x-a.x,dy=b.y-a.y,len=Math.hypot(dx,dy)||1;
|
||||
let best=-1,bestScore=Infinity;
|
||||
for(const k of unused){const q=inner[k],r=inner[(k+1)%inner.length],ex=r.x-q.x,ey=r.y-q.y,el=Math.hypot(ex,ey)||1,parallel=1-Math.abs((dx*ex+dy*ey)/(len*el)),dist=Math.hypot((a.x+b.x-q.x-r.x)/2,(a.y+b.y-q.y-r.y)/2);const score=parallel+dist/(len*2);if(score<bestScore){bestScore=score;best=k}}
|
||||
if(best<0)throw Error('안쪽 경계에서 벽체별 대응 구간을 찾지 못했습니다.');
|
||||
unused.delete(best);const q=inner[best],r=inner[(best+1)%inner.length],qa=Math.hypot(q.x-a.x,q.y-a.y)<=Math.hypot(r.x-a.x,r.y-a.y)?q:r,qb=qa===q?r:q;
|
||||
const innerStart=qa===q?best:(best+1)%inner.length,innerEnd=(innerStart+1)%inner.length;
|
||||
edgePairs.push({outerStart:i,outerEnd:(i+1)%outer.length,innerStart,innerEnd});
|
||||
}
|
||||
if(edgePairs.length<3)throw Error('평면 내부 경계에서 벽체를 만들 수 없습니다.');
|
||||
parts.push({name:'벽체',type:'wall',footprint:outer.map(toModel),innerFootprint:inner.map(toModel),edgePairs,z0:0,z1:height,color:'#438dcc',visible:true});
|
||||
modelData={outer,inner,minX,minY,scaleX,scaleY,height,baseThickness,wallThickness,parts,dimX:chosen.dims.dx,dimY:chosen.dims.dy};
|
||||
$('partRows').innerHTML='';for(const part of parts){const label=document.createElement('label');label.className='partrow';const toggle=document.createElement('input');toggle.type='checkbox';toggle.checked=true;toggle.addEventListener('change',()=>{part.visible=toggle.checked;draw()});const swatch=document.createElement('i');swatch.className='swatch';swatch.style.background=part.color;const text=document.createElement('span');text.textContent=part.name;label.append(toggle,swatch,text);$('partRows').append(label)}$('parts').classList.remove('hidden');
|
||||
$('empty').classList.add('hidden');$('badge').classList.remove('hidden');$('legend').classList.remove('hidden');$('reset').disabled=false;$('export').disabled=false;
|
||||
$('meta').textContent=`${fileName} · 평면 ${chosen.dims.dx} × ${chosen.dims.dy} mm · 벽체 높이 ${(height*1000).toFixed(0)} mm`;
|
||||
status(`모델 생성 완료 · 바닥판 1 · 벽체 1 · 벽 두께 약 ${(wallThickness*1000).toFixed(0)} mm · ${heightSource}`);
|
||||
$('footstatus').textContent=`좌하단 기준점 (${minX.toFixed(3)}, ${minY.toFixed(3)}) → (0, 0, 0)`;fit();
|
||||
}catch(e){status(e.message,true)}
|
||||
}
|
||||
function project(x,y,z){const d=yaw,c=Math.cos(d),s=Math.sin(d),xx=x*c-y*s,yy=x*s+y*c,cp=Math.cos(pitch),sp=Math.sin(pitch);return{x:xx,y:yy*cp-z*sp,z:yy*sp+z*cp}}
|
||||
// Project model faces into the canvas and paint them from back to front.
|
||||
function draw(){
|
||||
if(!ctx)return;const w=$('viewport').clientWidth,h=$('viewport').clientHeight;ctx.clearRect(0,0,w,h);
|
||||
if(!modelData){drawGrid(w,h);return}
|
||||
const m=modelData,visible=m.parts.filter(p=>p.visible),all=[];
|
||||
for(const part of visible)for(const p of [...part.footprint,...(part.innerFootprint||[])])all.push(project(p.x,p.y,part.z0),project(p.x,p.y,part.z1));
|
||||
if(!all.length){drawGrid(w,h);return}
|
||||
const xs=all.map(p=>p.x),ys=all.map(p=>p.y),span=Math.max(Math.max(...xs)-Math.min(...xs),Math.max(...ys)-Math.min(...ys),.01),s=Math.min(w,h)*.68/span*zoom,cx=(Math.min(...xs)+Math.max(...xs))/2,cy=(Math.min(...ys)+Math.max(...ys))/2;
|
||||
const screen=p=>({x:w/2+panX+(p.x-cx)*s,y:h/2+panY-(p.y-cy)*s});
|
||||
const ringAt=(ring,z)=>ring.map(p=>screen(project(p.x,p.y,z)));
|
||||
const signed=pts=>{let a=0;for(let i=0;i<pts.length;i++){const q=pts[(i+1)%pts.length];a+=pts[i].x*q.y-q.x*pts[i].y}return a/2};
|
||||
const paint=(pts,fill,stroke='#b8f3dd')=>{if(!pts.length)return;ctx.beginPath();ctx.moveTo(pts[0].x,pts[0].y);for(const p of pts.slice(1))ctx.lineTo(p.x,p.y);ctx.closePath();ctx.fillStyle=fill;ctx.fill();if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=1;ctx.stroke()}};
|
||||
drawGrid(w,h);ctx.globalAlpha=.55;
|
||||
const base=visible.find(p=>p.type==='base');if(base){ctx.globalAlpha=.82;paint(ringAt(base.footprint,base.z1),base.color,'#b0f0dc');ctx.globalAlpha=.55}
|
||||
const faces=[];
|
||||
for(const part of visible){
|
||||
const outerOrientation=Math.sign(CatchpitDxf.area(part.footprint))||1;
|
||||
const rings=part.type==='wall'?[{ring:part.footprint,orientation:outerOrientation},{ring:part.innerFootprint,orientation:-outerOrientation}]:[{ring:part.footprint,orientation:outerOrientation}];
|
||||
for(const {ring,orientation} of rings)for(let i=0;i<ring.length;i++){
|
||||
const a=ring[i],b=ring[(i+1)%ring.length],pts=[screen(project(a.x,a.y,part.z0)),screen(project(b.x,b.y,part.z0)),screen(project(b.x,b.y,part.z1)),screen(project(a.x,a.y,part.z1))];
|
||||
if(part.type!=='base'&&signed(pts)*(-orientation)<=0)continue;
|
||||
const mid=project((a.x+b.x)/2,(a.y+b.y)/2,(part.z0+part.z1)/2);faces.push({pts,depth:mid.z,color:part.type==='base'?'#347866':part.color,opacity:part.type==='base'?.78:.55,stroke:part.type==='base'?'#9fe6d0':null});
|
||||
}
|
||||
}
|
||||
faces.sort((a,b)=>a.depth-b.depth);for(const face of faces){ctx.globalAlpha=face.opacity;paint(face.pts,face.color,face.stroke)}ctx.globalAlpha=.55;
|
||||
for(const part of visible.filter(p=>p.type==='wall')){
|
||||
for(const e of part.edgePairs){const oa=part.footprint[e.outerStart],ob=part.footprint[e.outerEnd],ia=part.innerFootprint[e.innerStart],ib=part.innerFootprint[e.innerEnd];paint([oa,ob,ib,ia].map(p=>screen(project(p.x,p.y,part.z1))),part.color,null)}
|
||||
ctx.strokeStyle='#c3ffe9';ctx.lineWidth=1;
|
||||
for(const ring of [part.footprint,part.innerFootprint]){
|
||||
for(const z of [part.z0,part.z1]){const pts=ringAt(ring,z);ctx.beginPath();ctx.moveTo(pts[0].x,pts[0].y);for(const p of pts.slice(1))ctx.lineTo(p.x,p.y);ctx.closePath();ctx.stroke()}
|
||||
for(const p of ring){const a=screen(project(p.x,p.y,part.z0)),b=screen(project(p.x,p.y,part.z1));ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke()}
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha=1;const origin=screen(project(0,0,0));ctx.fillStyle='#ffbb73';ctx.beginPath();ctx.arc(origin.x,origin.y,4,0,Math.PI*2);ctx.fill();ctx.fillStyle='#dce5eb';ctx.font='11px Segoe UI';ctx.fillText('0,0,0',origin.x+7,origin.y+4);
|
||||
}
|
||||
function drawGrid(w,h){ctx.strokeStyle='rgba(91,111,125,.13)';ctx.lineWidth=1;const step=36;for(let x=w/2%step;x<w;x+=step){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h);ctx.stroke()}for(let y=h/2%step;y<h;y+=step){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke()}}
|
||||
function fit(){if(!modelData)return;zoom=1;panX=0;panY=0;yaw=-.72;pitch=.62;draw()}$('build').addEventListener('click',build);$('reset').addEventListener('click',fit);
|
||||
canvas.addEventListener('pointerdown',e=>{canvas.setPointerCapture(e.pointerId);drag={x:e.clientX,y:e.clientY,button:e.button,shift:e.shiftKey}});canvas.addEventListener('pointermove',e=>{if(!drag)return;const dx=e.clientX-drag.x,dy=e.clientY-drag.y;if(drag.button===2||drag.shift){panX+=dx;panY+=dy}else{yaw+=dx*.009;pitch+=dy*.007}drag.x=e.clientX;drag.y=e.clientY;draw()});canvas.addEventListener('pointerup',()=>drag=null);canvas.addEventListener('contextmenu',e=>e.preventDefault());canvas.addEventListener('wheel',e=>{e.preventDefault();zoom=Math.max(.2,Math.min(8,zoom*(e.deltaY<0?1.1:.9)));draw()},{passive:false});
|
||||
$('export').addEventListener('click',()=>CatchpitObjExporter.download(modelData,fileName));
|
||||
resize();
|
||||
|
||||
Reference in new issue
Block a user