const FURN={"Bedside table":[0.45,0.50,0.40],"Study table":[1.20,0.75,0.60],"Bunk bed":[2.00,1.60,1.00],"Rotating bookshelf":[0.45,1.20,0.45],"Storage cabinet":[0.80,1.00,0.40],"Custom…":[0.80,0.60,0.50]};const COLORS=["#3b7dd8","#e08a2b","#2e9e5b","#d8453b","#8e5cd9","#1aa3a3","#c2476f","#6b8e23","#b5651d","#3a3a8e"];const EXTEND_LIMIT_M=0.25;const DEFAULT_PXM=90;const S={videoFrame:null,layoutImg:null,backdrop:null,backdropType:null,calib:{video:null,layout:null},scaleLines:{video:[],layout:[]},markColor:'#000',calibrating:!1,calibPts:[],measureDepth:null,cropFor:null,cropRect:null,view3d:!1,items:[],drag:null,selected:null,rotating:null,resizing:null,dragScale:null,cloneOn:!1,brush:40,hasEdit:!1,cloneStroke:null,cloneSrc:null,cloneOff:null,cursor:null,canUndo:!1};const cv=document.getElementById('cv'),ctx=cv.getContext('2d');const banner=document.getElementById('banner');const edited=document.createElement('canvas');edited.width=cv.width;edited.height=cv.height;const ectx=edited.getContext('2d');const bdc=document.createElement('canvas');bdc.width=cv.width;bdc.height=cv.height;const bdx=bdc.getContext('2d');const undoCanvas=document.createElement('canvas');undoCanvas.width=cv.width;undoCanvas.height=cv.height;const uctx=undoCanvas.getContext('2d');function resetEdited(){ectx.clearRect(0,0,cv.width,cv.height);const br=backdropRect();if(br&&S.backdrop)ectx.drawImage(S.backdrop,br.x,br.y,br.w,br.h);S.hasEdit=!1;S.cloneSrc=null;S.cloneOff=null;S.canUndo=!1} function cloneStampAt(p){const rad=S.brush/2,sx=p.x-S.cloneOff.dx,sy=p.y-S.cloneOff.dy,br=backdropRect();ectx.save();if(br){ectx.beginPath();ectx.rect(br.x,br.y,br.w,br.h);ectx.clip()} ectx.beginPath();ectx.arc(p.x,p.y,rad,0,7);ectx.clip();ectx.drawImage(bdc,sx-rad-2,sy-rad-2,rad*2+4,rad*2+4,p.x-rad-2,p.y-rad-2,rad*2+4,rad*2+4);ectx.restore();S.hasEdit=!0} function cloneLine(a,b){const d=Math.hypot(b.x-a.x,b.y-a.y),step=Math.max(2,S.brush/4),n=Math.max(1,Math.round(d/step));for(let i=1;i<=n;i++)cloneStampAt({x:a.x+(b.x-a.x)*i/n,y:a.y+(b.y-a.y)*i/n})} function pxm(){return S.calib[S.backdropType]||null} function drawPxm(){return pxm()||DEFAULT_PXM} function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y)} function pointInPolygon(p,poly){let inside=!1;for(let i=0,j=poly.length-1;i
p.y)!==(b.y>p.y))&&(p.x<(b.x-a.x)*(p.y-a.y)/(b.y-a.y)+a.x))inside=!inside} return inside} function pointSegDist(p,a,b){const dx=b.x-a.x,dy=b.y-a.y,l2=dx*dx+dy*dy;if(l2===0)return dist(p,a);let t=((p.x-a.x)*dx+(p.y-a.y)*dy)/l2;t=Math.max(0,Math.min(1,t));return dist(p,{x:a.x+t*dx,y:a.y+t*dy})} function pointPolyDist(p,poly){let m=Infinity;for(let i=0,j=poly.length-1;i
{const dx=v.x-c.x,dy=v.y-c.y,L=Math.hypot(dx,dy)||1;return{x:v.x+dx/L*dpx,y:v.y+dy/L*dpx}})} function itemCorners(it,ppm){const hw=it.w*ppm/2,hd=it.d*ppm/2,c=Math.cos(it.rot),s=Math.sin(it.rot);return[[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([x,y])=>({x:it.x+x*c-y*s,y:it.y+x*s+y*c}))} function footprintCorners(it,ppm){const hw=it.w*ppm/2,hd=(it.depth||it.d)*ppm/2,c=Math.cos(it.rot),s=Math.sin(it.rot);return[[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([x,y])=>({x:it.x+x*c-y*s,y:it.y+x*s+y*c}))} function itemContains(it,p,ppm){const c=Math.cos(-it.rot),s=Math.sin(-it.rot),dx=p.x-it.x,dy=p.y-it.y;const lx=dx*c-dy*s,ly=dx*s+dy*c;return Math.abs(lx)<=it.w*ppm/2&&Math.abs(ly)<=it.d*ppm/2} function backdropRect(){if(!S.backdrop)return null;const iw=S.backdrop.width,ih=S.backdrop.height;const k=Math.min(cv.width/iw,cv.height/ih);const w=iw*k,h=ih*k;return{x:(cv.width-w)/2,y:(cv.height-h)/2,w,h}} function roomPoly(){if(S.backdropType!=='layout')return null;const br=backdropRect();if(!br)return null;return[{x:br.x,y:br.y},{x:br.x+br.w,y:br.y},{x:br.x+br.w,y:br.y+br.h},{x:br.x,y:br.y+br.h}]} function evaluate(){if(!S.items.length){setBanner('grey','Add furniture to begin.');return} const room=roomPoly(),ppm=pxm();if(!(room&&ppm)){const br=backdropRect();let off=!1;if(br)for(const it of S.items)for(const c of itemCorners(it,drawPxm())) if(c.x
br.x+br.w||c.y
br.y+br.h)off=!0;let msg=ppm?'Feel mode — drag pieces around.':'Feel mode — set scale for true-to-size pieces.';if(S.backdropType!=='layout'&&S.layoutImg)msg+=' Switch backdrop to the 2D layout for fit alerts.';setBanner(off?'amber':'grey',off?'Space extended a little so it still fits the feel.':msg,off?'The neutral margin around your photo stands in for a touch more room.':'');S.extendPx=null;return} let worst='green',lines=[],extend=null;S.items.forEach((it)=>{const outside=footprintCorners(it,ppm).filter(c=>!pointInPolygon(c,room));if(!outside.length){lines.push(`${it.name}: fits ✓`);return} const outPx=Math.max(...outside.map(c=>pointPolyDist(c,room)));const outMm=Math.round(outPx/ppm*1000);if(outPx<=EXTEND_LIMIT_M*ppm){worst=worst==='red'?'red':'amber';extend=Math.max(extend||0,outPx);lines.push(`${it.name}: tight — extended ${outMm} mm to fit`)}else{worst='red';lines.push(`${it.name}: too small by ~${outMm} mm`)}});S.extendPx=extend;const head=worst==='green'?'Everything fits 👍':worst==='amber'?'Fits if we extend the space a little':'Too small to fit';setBanner(worst,head,lines.join(' · '))} function setBanner(level,msg,sub){banner.className=(level==='grey'?'':level);banner.innerHTML=msg+(sub?`
${sub}
`:'')} const V3x=.5*Math.cos(Math.PI/6),V3y=.5*Math.sin(Math.PI/6);function draw(){ctx.clearRect(0,0,cv.width,cv.height);const br=backdropRect();if(br){ctx.fillStyle='#efeae0';ctx.fillRect(0,0,cv.width,cv.height);if(S.hasEdit)ctx.drawImage(edited,0,0);else ctx.drawImage(S.backdrop,br.x,br.y,br.w,br.h)}else{ctx.fillStyle='#efeae0';ctx.fillRect(0,0,cv.width,cv.height);ctx.strokeStyle='#e2dccf';ctx.lineWidth=1;for(let x=0;x
i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();ctx.stroke();if(S.extendPx){ctx.save();ctx.setLineDash([8,6]);ctx.strokeStyle='#9a7415';ctx.lineWidth=2;const ep=expandPoly(room,S.extendPx);ctx.beginPath();ep.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();ctx.stroke();ctx.restore()}} const ppm=drawPxm();S.items.forEach((it,i)=>{const w=it.w*ppm,h=it.d*ppm,sel=i===S.selected;if(S.view3d){const Dpx=(it.depth||it.d)*ppm,ox=Dpx*V3x,oy=-Dpx*V3y,C=itemCorners(it,ppm);ctx.strokeStyle=COLORS[i];ctx.lineWidth=1.5;ctx.fillStyle=COLORS[i]+'33';ctx.beginPath();C.forEach((c,k)=>k?ctx.lineTo(c.x+ox,c.y+oy):ctx.moveTo(c.x+ox,c.y+oy));ctx.closePath();ctx.fill();ctx.stroke();C.forEach(c=>{ctx.beginPath();ctx.moveTo(c.x,c.y);ctx.lineTo(c.x+ox,c.y+oy);ctx.stroke()})} ctx.save();ctx.translate(it.x,it.y);ctx.rotate(it.rot);if(it.cutout&&it.cutout.complete&&it.cutout.naturalWidth){ctx.globalAlpha=.95;ctx.drawImage(it.cutout,-w/2,-h/2,w,h);ctx.globalAlpha=1}else{ctx.fillStyle=COLORS[i]+'cc';ctx.fillRect(-w/2,-h/2,w,h)} ctx.strokeStyle=COLORS[i];ctx.lineWidth=sel?3:2;ctx.strokeRect(-w/2,-h/2,w,h);ctx.restore();if(sel){const r=h/2+30,hx=it.x+Math.sin(it.rot)*r,hy=it.y-Math.cos(it.rot)*r;it._handle={x:hx,y:hy};ctx.strokeStyle=COLORS[i];ctx.setLineDash([3,3]);ctx.beginPath();ctx.moveTo(it.x,it.y);ctx.lineTo(hx,hy);ctx.stroke();ctx.setLineDash([]);ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(hx,hy,10,0,7);ctx.fill();ctx.stroke();ctx.fillStyle=COLORS[i];ctx.beginPath();ctx.arc(hx,hy,3.5,0,7);ctx.fill();const cs=Math.cos(it.rot),sn=Math.sin(it.rot);const rx=it.x+(w/2)*cs-(h/2)*sn,ry=it.y+(w/2)*sn+(h/2)*cs;it._resize={x:rx,y:ry};ctx.fillStyle='#fff';ctx.strokeStyle=COLORS[i];ctx.lineWidth=2;ctx.fillRect(rx-7,ry-7,14,14);ctx.strokeRect(rx-7,ry-7,14,14);ctx.fillStyle=COLORS[i];ctx.fillRect(rx-3,ry-3,6,6);const dlx=it.x-(w/2)*cs+(h/2)*sn,dly=it.y-(w/2)*sn-(h/2)*cs;it._del={x:dlx,y:dly};ctx.beginPath();ctx.arc(dlx,dly,9,0,7);ctx.fillStyle='#fff';ctx.fill();ctx.lineWidth=2;ctx.strokeStyle='#000';ctx.stroke();ctx.beginPath();ctx.moveTo(dlx-4,dly-4);ctx.lineTo(dlx+4,dly+4);ctx.moveTo(dlx+4,dly-4);ctx.lineTo(dlx-4,dly+4);ctx.stroke()}else{it._handle=null;it._resize=null;it._del=null} {const Wmm=Math.round(it.w*1000),Hmm=Math.round(it.d*1000),Dmm=Math.round((it.depth||it.d)*1000);const hw=w/2,hh=h/2,gap=14,tk=5;ctx.save();ctx.translate(it.x,it.y);ctx.rotate(it.rot);ctx.strokeStyle=COLORS[i];ctx.lineWidth=1.5;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(-hw,hh);ctx.lineTo(-hw,hh+gap);ctx.moveTo(hw,hh);ctx.lineTo(hw,hh+gap);ctx.moveTo(-hw,hh+gap);ctx.lineTo(hw,hh+gap);ctx.moveTo(-hw,hh+gap-tk);ctx.lineTo(-hw,hh+gap+tk);ctx.moveTo(hw,hh+gap-tk);ctx.lineTo(hw,hh+gap+tk);ctx.moveTo(-hw,-hh);ctx.lineTo(-hw-gap,-hh);ctx.moveTo(-hw,hh);ctx.lineTo(-hw-gap,hh);ctx.moveTo(-hw-gap,-hh);ctx.lineTo(-hw-gap,hh);ctx.moveTo(-hw-gap-tk,-hh);ctx.lineTo(-hw-gap+tk,-hh);ctx.moveTo(-hw-gap-tk,hh);ctx.lineTo(-hw-gap+tk,hh);ctx.stroke();ctx.font='bold 12px system-ui';const tag=(txt,x,y,al,ba)=>{ctx.textAlign=al;ctx.textBaseline=ba;const tw=ctx.measureText(txt).width,ph=16;const bx=al==='center'?x-tw/2:al==='right'?x-tw:x;const by=ba==='bottom'?y-ph:ba==='top'?y:y-ph/2;ctx.fillStyle='rgba(255,255,255,.85)';ctx.fillRect(bx-3,by-1,tw+6,ph+2);ctx.fillStyle='#1c1c1c';ctx.fillText(txt,x,y)};tag(Wmm+'L',0,hh+gap+3,'center','top');tag(Hmm+'H',-hw-gap-3,0,'right','middle');tag(`${it.name} ${Dmm}D`,0,-hh-gap-3,'center','bottom');ctx.textAlign='left';ctx.textBaseline='alphabetic';ctx.restore()}});const cal=S.calib[S.backdropType];(S.scaleLines[S.backdropType]||[]).forEach(sl=>{const a=sl.a,b=sl.b,dx=b.x-a.x,dy=b.y-a.y,L=Math.hypot(dx,dy)||1,ux=dx/L,uy=dy/L,px=-uy,py=ux;ctx.strokeStyle=S.markColor;ctx.fillStyle=S.markColor;ctx.lineWidth=3;ctx.setLineDash([]);ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.moveTo(a.x-px*7,a.y-py*7);ctx.lineTo(a.x+px*7,a.y+py*7);ctx.moveTo(b.x-px*7,b.y-py*7);ctx.lineTo(b.x+px*7,b.y+py*7);ctx.stroke();if(!S.exporting)[a,b].forEach(pt=>{ctx.beginPath();ctx.arc(pt.x,pt.y,5,0,7);ctx.fillStyle='#fff';ctx.fill();ctx.lineWidth=2;ctx.strokeStyle=S.markColor;ctx.stroke()});const mm=cal?Math.round(L/(cal/1000)):0;const mmx=(a.x+b.x)/2-px*14,mmy=(a.y+b.y)/2-py*14;sl._mm={x:mmx,y:mmy};ctx.fillStyle=S.markColor;ctx.font='bold 13px system-ui';ctx.textAlign='center';ctx.fillText(mm+' mm',mmx,mmy);const xb={x:b.x+ux*20,y:b.y+uy*20};sl._x=xb;if(!S.exporting){ctx.beginPath();ctx.arc(xb.x,xb.y,9,0,7);ctx.fillStyle='#fff';ctx.fill();ctx.lineWidth=2;ctx.strokeStyle='#000';ctx.stroke();ctx.beginPath();ctx.moveTo(xb.x-4,xb.y-4);ctx.lineTo(xb.x+4,xb.y+4);ctx.moveTo(xb.x+4,xb.y-4);ctx.lineTo(xb.x-4,xb.y+4);ctx.stroke()}});if((S.calibrating||S.measureDepth!=null)&&S.calibPts.length){ctx.strokeStyle=S.markColor;ctx.lineWidth=2;ctx.setLineDash([4,4]);ctx.beginPath();S.calibPts.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.stroke();ctx.setLineDash([]);S.calibPts.forEach(p=>{ctx.beginPath();ctx.arc(p.x,p.y,6,0,7);ctx.fillStyle=S.markColor;ctx.fill();ctx.lineWidth=2;ctx.strokeStyle='#fff';ctx.stroke()})} if(S.cropFor!=null&&S.cropRect&&!S.exporting){const r=S.cropRect;ctx.save();ctx.strokeStyle='#d8453b';ctx.lineWidth=1.5;ctx.setLineDash([5,3]);ctx.strokeRect(Math.min(r.x0,r.x1),Math.min(r.y0,r.y1),Math.abs(r.x1-r.x0),Math.abs(r.y1-r.y0));ctx.restore()} if(S.cloneOn&&!S.exporting){ctx.save();ctx.lineWidth=1.5;if(S.cloneSrc){ctx.strokeStyle='#2e9e5b';ctx.setLineDash([]);ctx.beginPath();ctx.arc(S.cloneSrc.x,S.cloneSrc.y,7,0,7);ctx.moveTo(S.cloneSrc.x-11,S.cloneSrc.y);ctx.lineTo(S.cloneSrc.x+11,S.cloneSrc.y);ctx.moveTo(S.cloneSrc.x,S.cloneSrc.y-11);ctx.lineTo(S.cloneSrc.x,S.cloneSrc.y+11);ctx.stroke()} if(S.cursor){ctx.strokeStyle='#3b7dd8';ctx.setLineDash([4,3]);ctx.beginPath();ctx.arc(S.cursor.x,S.cursor.y,S.brush/2,0,7);ctx.stroke();if(S.cloneOff){ctx.strokeStyle='#2e9e5b';ctx.beginPath();ctx.arc(S.cursor.x-S.cloneOff.dx,S.cursor.y-S.cloneOff.dy,S.brush/2,0,7);ctx.stroke()}} ctx.restore()}} function toCanvas(e){const r=cv.getBoundingClientRect();return{x:(e.clientX-r.left)*(cv.width/r.width),y:(e.clientY-r.top)*(cv.height/r.height)}} cv.addEventListener('pointerdown',e=>{const p=toCanvas(e);if(S.cloneOn){S.cursor=p;if(!S.cloneSrc){S.cloneSrc={x:p.x,y:p.y};return} if(!S.cloneOff)S.cloneOff={dx:p.x-S.cloneSrc.x,dy:p.y-S.cloneSrc.y};uctx.clearRect(0,0,cv.width,cv.height);uctx.drawImage(edited,0,0);S.canUndo=!0;bdx.clearRect(0,0,cv.width,cv.height);bdx.drawImage(edited,0,0);cloneStampAt(p);S.cloneStroke={last:p};cv.setPointerCapture(e.pointerId);return} if(S.cropFor!=null){S.cropRect={x0:p.x,y0:p.y,x1:p.x,y1:p.y};cv.setPointerCapture(e.pointerId);return} if(S.measureDepth!=null){S.calibPts.push(p);if(S.calibPts.length===2){const ppm=pxm(),it=S.items[S.measureDepth];if(ppm&&it){it.depth=Math.max(.05,dist(S.calibPts[0],S.calibPts[1])/ppm);if(it.depthEl)it.depthEl.value=Math.round(it.depth*1000);evaluate()} S.measureDepth=null;S.calibPts=[]} return} if(S.calibrating){S.calibPts.push(p);if(S.calibPts.length===2){const ruler={a:S.calibPts[0],b:S.calibPts[1]};if(!S.calib[S.backdropType]){const mm=parseFloat(prompt('Real distance between the two points, in mm:','820'));if(mm>0){S.calib[S.backdropType]=dist(ruler.a,ruler.b)/(mm/1000);S.scaleLines[S.backdropType].push(ruler);document.getElementById('calState').textContent='set ✓'}}else{S.scaleLines[S.backdropType].push(ruler)} S.calibrating=!1;S.calibPts=[];evaluate()} return} if(S.selected!=null&&S.items[S.selected]&&S.items[S.selected]._handle&&dist(p,S.items[S.selected]._handle)<16){S.rotating=S.selected;cv.setPointerCapture(e.pointerId);return} if(S.selected!=null&&S.items[S.selected]&&S.items[S.selected]._resize&&dist(p,S.items[S.selected]._resize)<16){S.resizing=S.selected;cv.setPointerCapture(e.pointerId);return} if(S.selected!=null&&S.items[S.selected]&&S.items[S.selected]._del&&dist(p,S.items[S.selected]._del)<13){S.items[S.selected].remove();return} const sls=S.scaleLines[S.backdropType]||[];if(!S.calibrating)for(let k=sls.length-1;k>=0;k--){const sl=sls[k];if(sl._x&&dist(p,sl._x)<12){sls.splice(k,1);evaluate();return} if(sl._mm&&Math.abs(p.x-sl._mm.x)<26&&Math.abs(p.y-sl._mm.y)<10){const cur=S.calib[S.backdropType]?Math.round(dist(sl.a,sl.b)/(S.calib[S.backdropType]/1000)):'';const v=parseFloat(prompt('This line measures how many mm?\n(edits the scale — every furniture piece rescales to match)',cur));if(v>0){S.calib[S.backdropType]=dist(sl.a,sl.b)/(v/1000);document.getElementById('calState').textContent='set ✓';evaluate()} return} if(dist(p,sl.a)<12){S.dragScale={k,which:'a'};cv.setPointerCapture(e.pointerId);return} if(dist(p,sl.b)<12){S.dragScale={k,which:'b'};cv.setPointerCapture(e.pointerId);return}} const ppm=drawPxm();for(let i=S.items.length-1;i>=0;i--)if(itemContains(S.items[i],p,ppm)){S.selected=i;S.drag={i,dx:p.x-S.items[i].x,dy:p.y-S.items[i].y};cv.setPointerCapture(e.pointerId);return} if(!S.calibrating)for(let k=sls.length-1;k>=0;k--){const sl=sls[k];if(pointSegDist(p,sl.a,sl.b)<8){S.dragScale={k,which:'line',a0:{x:sl.a.x,y:sl.a.y},b0:{x:sl.b.x,y:sl.b.y},s:p};cv.setPointerCapture(e.pointerId);return}} S.selected=null});cv.addEventListener('pointermove',e=>{const p=toCanvas(e);if(S.cropFor!=null){if(S.cropRect){S.cropRect.x1=p.x;S.cropRect.y1=p.y}return} if(S.cloneOn){S.cursor=p;if(S.cloneStroke){cloneLine(S.cloneStroke.last,p);S.cloneStroke.last=p}return} if(S.dragScale){const sl=S.scaleLines[S.backdropType][S.dragScale.k];if(!sl){S.dragScale=null;return} if(S.dragScale.which==='a')sl.a={x:p.x,y:p.y};else if(S.dragScale.which==='b')sl.b={x:p.x,y:p.y};else{const dx=p.x-S.dragScale.s.x,dy=p.y-S.dragScale.s.y;sl.a={x:S.dragScale.a0.x+dx,y:S.dragScale.a0.y+dy};sl.b={x:S.dragScale.b0.x+dx,y:S.dragScale.b0.y+dy}} return} if(S.rotating!=null){const it=S.items[S.rotating];let deg=(Math.atan2(p.y-it.y,p.x-it.x)+Math.PI/2)*180/Math.PI;deg=((deg+180)%360+360)%360-180;it.rot=deg*Math.PI/180;if(it.rotEl)it.rotEl.value=Math.round(deg);evaluate();return} if(S.resizing!=null){const it=S.items[S.resizing],ppm=drawPxm();const c=Math.cos(-it.rot),s=Math.sin(-it.rot),dx=p.x-it.x,dy=p.y-it.y;const lx=dx*c-dy*s,ly=dx*s+dy*c;it.w=Math.max(0.05,2*Math.abs(lx)/ppm);it.d=Math.max(0.05,2*Math.abs(ly)/ppm);syncSize(it);evaluate();return} if(!S.drag)return;const it=S.items[S.drag.i];it.x=p.x-S.drag.dx;it.y=p.y-S.drag.dy;evaluate()});cv.addEventListener('pointerup',()=>{if(S.cropFor!=null){if(S.cropRect)applyCrop(S.items[S.cropFor],S.cropRect);S.cropFor=null;S.cropRect=null;cv.style.cursor='default'} S.cloneStroke=null;S.drag=null;S.rotating=null;S.resizing=null;S.dragScale=null});cv.addEventListener('pointerleave',()=>{S.cursor=null});const vid=document.getElementById('vid');document.getElementById('fVideo').onchange=e=>{const f=e.target.files[0];if(!f)return;vid.src=URL.createObjectURL(f);vid.hidden=!1;document.getElementById('vidTools').hidden=!1};document.getElementById('capBtn').onclick=()=>{const c=document.createElement('canvas');c.width=vid.videoWidth;c.height=vid.videoHeight;c.getContext('2d').drawImage(vid,0,0);const img=new Image();img.onload=()=>{S.videoFrame=img;setBackdrop('video')};img.src=c.toDataURL();showBackdropPick()};document.getElementById('fLayout').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{S.layoutImg=img;setBackdrop('layout')};img.src=URL.createObjectURL(f);showBackdropPick()};function setBackdrop(type){S.backdropType=type;S.backdrop=type==='video'?S.videoFrame:S.layoutImg;resetEdited();document.querySelector(`input[name=bd][value=${type}]`).checked=!0;document.getElementById('calState').textContent=pxm()?'set ✓':'not set';evaluate()} function showBackdropPick(){const both=S.videoFrame&&S.layoutImg;document.getElementById('backdropPick').hidden=!both} document.querySelectorAll('input[name=bd]').forEach(r=>r.onchange=()=>setBackdrop(r.value));document.getElementById('calBtn').onclick=()=>{if(!S.backdrop){alert('Upload a space (capture a video frame, or a 2D layout) first.');return} S.calibrating=!0;S.calibPts=[]};const mb=document.getElementById('markBlack'),mw=document.getElementById('markWhite');mb.onchange=()=>{if(mb.checked){mw.checked=!1;S.markColor='#000'}else mb.checked=!0};mw.onchange=()=>{if(mw.checked){mb.checked=!1;S.markColor='#fff'}else mw.checked=!0};const itemsEl=document.getElementById('items');function syncSize(it){if(it.wEl)it.wEl.value=Math.round(it.w*1000);if(it.dEl)it.dEl.value=Math.round(it.d*1000);if(it.selEl)it.selEl.value='Custom…'} function imgToCanvas(img){const MAX=1600;let w=img.naturalWidth||img.width,h=img.naturalHeight||img.height;const k=Math.min(1,MAX/Math.max(w,h));w=Math.round(w*k);h=Math.round(h*k);const c=document.createElement('canvas');c.width=w;c.height=h;c.getContext('2d').drawImage(img,0,0,w,h);return c} function trimCanvas(c){const W=c.width,H=c.height;let d;try{d=c.getContext('2d').getImageData(0,0,W,H).data}catch(e){return null} let minX=W,minY=H,maxX=0,maxY=0,found=!1;for(let y=0;y
10){found=!0;if(x
maxX)maxX=x;if(y
maxY)maxY=y} if(!found)return null;const w=maxX-minX+1,h=maxY-minY+1,t=document.createElement('canvas');t.width=w;t.height=h;t.getContext('2d').drawImage(c,minX,minY,w,h,0,0,w,h);return{canvas:t,w,h}} function applyCutout(it){if(!it.rawCutout)return;const c=imgToCanvas(it.rawCutout);const t=trimCanvas(c),src=t?t.canvas:c,aw=t?t.w:c.width,ah=t?t.h:c.height;const out=new Image();out.onload=()=>{it.cutout=out;it.d=Math.max(.05,it.w*ah/aw);syncSize(it);evaluate()};out.src=src.toDataURL()} function applyCrop(it,rect){if(!it.cutout||!it.cutout.complete)return;const ppm=drawPxm();const fr=(px,py)=>{const c=Math.cos(-it.rot),s=Math.sin(-it.rot),dx=px-it.x,dy=py-it.y,lx=dx*c-dy*s,ly=dx*s+dy*c,W=it.w*ppm,H=it.d*ppm;return[(lx+W/2)/W,(ly+H/2)/H]};const a=fr(rect.x0,rect.y0),b=fr(rect.x1,rect.y1);let fx0=Math.max(0,Math.min(a[0],b[0])),fx1=Math.min(1,Math.max(a[0],b[0]));let fy0=Math.max(0,Math.min(a[1],b[1])),fy1=Math.min(1,Math.max(a[1],b[1]));if(fx1-fx0<0.03||fy1-fy0<0.03)return;const iw=it.cutout.naturalWidth||it.cutout.width,ih=it.cutout.naturalHeight||it.cutout.height;const cc=document.createElement('canvas');cc.width=Math.max(1,Math.round((fx1-fx0)*iw));cc.height=Math.max(1,Math.round((fy1-fy0)*ih));cc.getContext('2d').drawImage(it.cutout,fx0*iw,fy0*ih,(fx1-fx0)*iw,(fy1-fy0)*ih,0,0,cc.width,cc.height);const cx=(rect.x0+rect.x1)/2,cy=(rect.y0+rect.y1)/2;const out=new Image();out.onload=()=>{it.cutout=out;it.w=Math.max(.05,it.w*(fx1-fx0));it.d=Math.max(.05,it.d*(fy1-fy0));it.x=cx;it.y=cy;syncSize(it);evaluate()};out.src=cc.toDataURL()} function addItem(){if(S.items.length>=10)return;const i=S.items.length;const def=Object.keys(FURN)[0],d0=FURN[def];const it={name:def,w:d0[0],d:d0[1],rot:0,x:cv.width/2,y:cv.height/2,cutout:null,removeBg:!1,rawCutout:null,depth:d0[2]};S.items.push(it);S.selected=i;const card=document.createElement('div');card.className='card item';card.style.borderLeftColor=COLORS[i];card.innerHTML=`
remove
Name (rename freely)
Size (mm)
W
H
D
📏
Rotate
Cutout image (auto-trimmed)
✂ Crop
`;itemsEl.appendChild(card);const sel=card.querySelector('.sel'),nm=card.querySelector('.nm'),wEl=card.querySelector('.w'),dEl=card.querySelector('.d'),depthEl=card.querySelector('.depth'),rotEl=card.querySelector('.rot');Object.keys(FURN).forEach(k=>{const o=document.createElement('option');o.textContent=k;sel.appendChild(o)});it.selEl=sel;it.wEl=wEl;it.dEl=dEl;it.depthEl=depthEl;it.rotEl=rotEl;it.nmEl=nm;sel.value=it.name;nm.value=it.name;wEl.value=Math.round(it.w*1000);dEl.value=Math.round(it.d*1000);depthEl.value=Math.round(it.depth*1000);sel.onchange=()=>{if(sel.value==='Custom…'){evaluate();return} const dim=FURN[sel.value];it.name=sel.value;nm.value=sel.value;it.w=dim[0];it.d=dim[1];it.depth=dim[2];wEl.value=Math.round(dim[0]*1000);dEl.value=Math.round(dim[1]*1000);depthEl.value=Math.round(dim[2]*1000);evaluate()};nm.oninput=()=>{it.name=nm.value};wEl.oninput=()=>{it.w=Math.max(.05,wEl.value/1000);sel.value='Custom…';evaluate()};dEl.oninput=()=>{it.d=Math.max(.05,dEl.value/1000);sel.value='Custom…';evaluate()};rotEl.oninput=ev=>{it.rot=ev.target.value*Math.PI/180;evaluate()};depthEl.oninput=()=>{it.depth=Math.max(.05,depthEl.value/1000);sel.value='Custom…';evaluate()};card.querySelector('.measd').onclick=()=>{if(!pxm()){alert('Set the scale first (section 2), then measure the depth.');return}S.measureDepth=S.items.indexOf(it);S.calibPts=[]};card.querySelector('.cropBtn').onclick=()=>{if(!it.cutout){alert('Upload a cutout image for this piece first.');return}S.cropFor=S.items.indexOf(it);S.cropRect=null;cv.style.cursor='crosshair'};const cut=card.querySelector('.cut');cut.onchange=ev=>{const f=ev.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{it.rawCutout=img;sel.value='Custom…';applyCutout(it)};img.src=URL.createObjectURL(f)};it.remove=()=>{const idx=S.items.indexOf(it);if(idx<0)return;S.items.splice(idx,1);card.remove();if(S.selected===idx)S.selected=null;else if(S.selected>idx)S.selected--;document.getElementById('addItem').disabled=S.items.length>=10;evaluate()};card.querySelector('.rm').onclick=it.remove;document.getElementById('addItem').disabled=S.items.length>=10;evaluate()} document.getElementById('addItem').onclick=addItem;function ddmm(){const d=new Date();return String(d.getDate()).padStart(2,'0')+'-'+String(d.getMonth()+1).padStart(2,'0')} function dl(blob,name){const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(a.href)} function buildPdf(jpeg,w,h){const content=`q ${w} 0 0 ${h} 0 0 cm /Im0 Do Q`;const objs=["<>","<>",`<>>>/Contents 5 0 R>>`,`<>\nstream\n${jpeg}\nendstream`,`<>\nstream\n${content}\nendstream`];let pdf="%PDF-1.3\n"const off=[];objs.forEach((o,i)=>{off.push(pdf.length);pdf+=`${i+1} 0 obj\n${o}\nendobj\n`});const xref=pdf.length;pdf+=`xref\n0 ${objs.length+1}\n0000000000 65535 f \n`;off.forEach(o=>{pdf+=String(o).padStart(10,'0')+"00000 n \n"});pdf+=`trailer\n<>\nstartxref\n${xref}\n%%EOF`;const bytes=new Uint8Array(pdf.length);for(let i=0;i
dl(b,name+'.png'),'image/png');else{const jpeg=atob(cv.toDataURL('image/jpeg',0.92).split(',')[1]);dl(buildPdf(jpeg,cv.width,cv.height),name+'.pdf')} S.selected=sel;S.exporting=!1;draw()} document.getElementById('expPng').onclick=()=>exportImage('png');document.getElementById('expPdf').onclick=()=>exportImage('pdf');const v3dBtn=document.getElementById('v3d');v3dBtn.onclick=()=>{S.view3d=!S.view3d;v3dBtn.textContent=S.view3d?'▦ 2D view':'🧊 3D view';v3dBtn.classList.toggle('primary',S.view3d)};const cloneToggle=document.getElementById('cloneToggle');cloneToggle.onclick=()=>{S.cloneOn=!S.cloneOn;cloneToggle.textContent='🖈 Copy brush: '+(S.cloneOn?'on':'off');cloneToggle.classList.toggle('primary',S.cloneOn);cv.style.cursor=S.cloneOn?'crosshair':'default';if(S.cloneOn){S.selected=null;S.cloneSrc=null;S.cloneOff=null}};document.getElementById('cloneUndo').onclick=()=>{if(!S.canUndo)return;ectx.clearRect(0,0,cv.width,cv.height);ectx.drawImage(undoCanvas,0,0);S.canUndo=!1};document.getElementById('cloneReset').onclick=()=>{resetEdited()};document.getElementById('brushSize').oninput=ev=>{S.brush=+ev.target.value};function selfTest(){const sq=[{x:0,y:0},{x:10,y:0},{x:10,y:10},{x:0,y:10}];console.assert(pointInPolygon({x:5,y:5},sq)===!0,'pip inside');console.assert(pointInPolygon({x:15,y:5},sq)===!1,'pip outside');console.assert(Math.abs(pointPolyDist({x:15,y:5},sq)-5)<1e-6,'poly dist');const c=itemCorners({x:0,y:0,w:2,d:1,rot:0},10);console.assert(Math.abs(c[2].x-10)<1e-6&&Math.abs(c[2].y-5)<1e-6,'corners');console.assert(footprintCorners({x:0,y:0,w:2,depth:1,rot:0},10)[2].x===10,'footprint uses depth');const ep=expandPoly(sq,2);console.assert(ep[0].x<0&&ep[0].y<0,'expand');console.assert(V3x>0&&V3y>0&&Math.abs(V3x-.433)<.001,'depth-box offset');ectx.clearRect(0,0,cv.width,cv.height);ectx.fillStyle='#123456';ectx.fillRect(0,0,80,80);bdx.clearRect(0,0,cv.width,cv.height);bdx.drawImage(edited,0,0);S.cloneOff={dx:100,dy:0};cloneStampAt({x:115,y:30});const cd=ectx.getImageData(115,30,1,1).data;console.assert(cd[0]===0x12&&cd[1]===0x34&&cd[2]===0x56,'copy brush stamps source');resetEdited();console.log('selftest ok')} selfTest();(function loop(){draw();requestAnimationFrame(loop)})()