import {buildInterior,pointInPolygon} from './interior-model.js'; import {furnishInterior} from './interior-furniture.js'; export function installInterior(ctx) { const {THREE,houseGroup,modelRoot,shellGroup,roofGroup,roofTimber,controls,scene,renderer,planPoint,box,mesh,group,groundGroup,parkingGroup,boundaryGroup,resize,saveBlob,exportGlb,solar,setSolarPlaying,toggleInfo,toggleSun}=ctx; const $=s=>document.querySelector(s),canvas=renderer.domElement; const data=buildInterior(ctx),furnishing=furnishInterior({...ctx,parent:data.furniture}); const perspective=ctx.getCamera(); const ortho=new THREE.OrthographicCamera(-12,12,10,-10,.035,220); const state={enabled:false,mode:'axon',panel:true,furniture:true,labels:true,fill:true,room:'living',position:new THREE.Vector3(),yaw:0,pitch:0,keys:new Set(),pad:new Set(),pointer:null,saved:null,userCamera:false}; const cutShell=group(houseGroup,'Obvodové stěny – řez 1,12 m'); shellGroup.children.forEach(original=>{ if(!original.isMesh||!original.geometry.parameters)return; const {width,height,depth}=original.geometry.parameters; if(!width||!height||!depth)return; const bottom=original.position.y-height/2,top=Math.min(data.cutHeight,original.position.y+height/2); if(top<=bottom)return; const copy=original.clone();copy.geometry=new THREE.BoxGeometry(width,top-bottom,depth);copy.position.y=(bottom+top)/2;copy.name+=' – řez';cutShell.add(copy); }); cutShell.visible=false; data.root.visible=true;data.fullWalls.visible=true;data.cutWalls.visible=false;data.ceiling.visible=true; modelRoot.userData.interior={source:'Pudorys prizemi (1).pdf + Rez.pdf',heightM:2.6,cutHeightM:1.12,areasFromDrawing:true,furnishing:'schematic',rooms:data.rooms.map(({id,name,area})=>({id,name,area}))}; const fillLight=new THREE.AmbientLight(0xfff5df,1.25);fillLight.visible=false;scene.add(fillLight); const innerLight=new THREE.HemisphereLight(0xfff9ed,0xb7a98e,.7);innerLight.visible=false;scene.add(innerLight); const glassMaterials=new Set();shellGroup.traverse(o=>{if(o.isMesh&&o.material.transparent)glassMaterials.add(o.material);}); const glassOpacity=new Map([...glassMaterials].map(m=>[m,m.opacity])); const localFootprint=[[0,0],[11.55,0],[11.55,6.525],[13.05,6.525],[13.05,17.4],[3.75,17.4],[3.75,7.125],[0,7.125]].map(p=>planPoint(...p)); const labels=[]; const toWorld=(p,y=0)=>{houseGroup.updateWorldMatrix(true,false);const [x,z]=planPoint(...p);return houseGroup.localToWorld(new THREE.Vector3(x,y,z));}; const localToWorld=v=>{houseGroup.updateWorldMatrix(true,false);return houseGroup.localToWorld(v.clone());}; const formatArea=v=>v.toFixed(2).replace('.',',')+' m²'; function selectRoom(id){ const room=data.rooms.find(r=>r.id===id)||data.rooms[0];state.room=room.id; const [x,z]=planPoint(...room.spawn),[lx,lz]=planPoint(...room.look); state.position.set(x,1.62,z);state.yaw=Math.atan2(lx-x,lz-z);state.pitch=-.04; mode('walk');$('#interiorRoom').value=room.id; document.querySelectorAll('#interiorMap .map-room').forEach(el=>el.classList.toggle('active',el.dataset.room===id)); $('#interiorViewTitle').textContent=room.name; updateWalkCamera(); } for(const room of data.rooms){ const option=document.createElement('option');option.value=room.id;option.textContent=room.name+' · '+formatArea(room.area);$('#interiorRoom').append(option); const ns='http://www.w3.org/2000/svg',g=document.createElementNS(ns,'g'),p=document.createElementNS(ns,'polygon'); p.setAttribute('points',room.polygon.map(([px,py])=>`${10+py*18.8},${10+(13.05-px)*18.8}`).join(' ')); p.setAttribute('class','map-room');p.dataset.room=room.id;p.setAttribute('tabindex','0');p.setAttribute('role','button');p.setAttribute('aria-label',option.textContent); const title=document.createElementNS(ns,'title');title.textContent=option.textContent;p.append(title); p.addEventListener('click',()=>selectRoom(room.id));p.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();selectRoom(room.id);}});g.append(p); const text=document.createElementNS(ns,'text');text.setAttribute('x',10+room.label[1]*18.8);text.setAttribute('y',13+(13.05-room.label[0])*18.8);text.setAttribute('text-anchor','middle');text.textContent=room.area.toFixed(2).replace('.',',');g.append(text);$('#interiorMap').append(g); const tag=document.createElement('button');tag.type='button';tag.className='int-room-tag';tag.innerHTML=`${room.short}${formatArea(room.area)}`;tag.setAttribute('aria-label','Vstoupit: '+option.textContent);tag.addEventListener('click',()=>selectRoom(room.id));$('#interiorLabels').append(tag);labels.push({room,tag}); } function switchCamera(camera){ctx.setCamera(camera);controls.object=camera;} function sync(){ const walking=state.enabled&&state.mode==='walk',cut=state.enabled&&!walking; shellGroup.visible=!cut;cutShell.visible=cut;roofGroup.visible=!cut;roofTimber.visible=!cut; data.fullWalls.visible=!cut;data.cutWalls.visible=cut;data.ceiling.visible=!cut;data.furniture.visible=state.furniture; fillLight.visible=state.enabled&&state.fill;innerLight.visible=fillLight.visible; for(const [m,opacity]of glassOpacity)m.opacity=walking?.13:opacity; document.body.classList.toggle('interior-active',state.enabled);document.body.classList.toggle('interior-walk',walking);document.body.classList.toggle('interior-panel-closed',!state.panel); $('#interiorLaunch').setAttribute('aria-expanded',String(state.enabled));$('#interiorPanelToggle').setAttribute('aria-expanded',String(state.panel)); $('#interiorRoomLabels').disabled=walking; document.querySelectorAll('[data-interior-mode]').forEach(b=>{const active=b.dataset.interiorMode===state.mode;b.classList.toggle('active',active);b.setAttribute('aria-pressed',String(active));}); $('#interiorHint').textContent=walking?'W A S D: chůze · šipky: chůze / otáčení · tažením se rozhlížejte · Esc: exteriér':'Tažením otáčejte · kolečkem přibližujte · výběrem místnosti vstupte dovnitř'; if(!walking)$('#interiorViewTitle').textContent=state.mode==='plan'?'Půdorys přízemí':'Dům bez střechy'; if(state.enabled){groundGroup.visible=walking&&state.saved.ground;parkingGroup.visible=walking&&state.saved.parking;boundaryGroup.visible=walking&&state.saved.boundary;} labels.forEach(({tag})=>tag.hidden=!state.enabled||walking||!state.labels); } function enter(){ if(state.enabled)return; state.saved={position:perspective.position.clone(),quaternion:perspective.quaternion.clone(),target:controls.target.clone(),fov:perspective.fov,near:perspective.near,minDistance:controls.minDistance,maxDistance:controls.maxDistance,minPolar:controls.minPolarAngle,maxPolar:controls.maxPolarAngle,ground:groundGroup.visible,parking:parkingGroup.visible,boundary:boundaryGroup.visible,playing:solar.playing,info:document.body.classList.contains('info-open'),sun:document.body.classList.contains('sun-open')}; setSolarPlaying(false);toggleInfo(false);toggleSun(false); state.enabled=true;state.panel=true;state.mode='axon';state.userCamera=false;controls.minDistance=3;controls.maxDistance=100; sync();resize();fit();$('#interiorExit').focus({preventScroll:true}); } function exit(){ if(!state.enabled)return; state.enabled=false;clearKeys();state.pointer=null;state.panel=true;sync(); const s=state.saved;switchCamera(perspective);controls.enabled=true;controls.enableRotate=true;controls.minDistance=s.minDistance;controls.maxDistance=s.maxDistance;controls.minPolarAngle=s.minPolar;controls.maxPolarAngle=s.maxPolar; perspective.fov=s.fov;perspective.near=s.near;perspective.position.copy(s.position);perspective.quaternion.copy(s.quaternion);controls.target.copy(s.target); groundGroup.visible=s.ground;parkingGroup.visible=s.parking;boundaryGroup.visible=s.boundary; controls.update();resize();if(s.info)toggleInfo(true);if(s.sun)toggleSun(true);setSolarPlaying(s.playing);$('#interiorLaunch').focus({preventScroll:true}); } function mode(value){ if(!state.enabled)enter(); state.mode=value;state.userCamera=false;clearKeys();state.pointer=null; controls.enabled=value!=='walk';controls.enableRotate=value!=='plan';controls.minPolarAngle=.001;controls.maxPolarAngle=1.52; switchCamera(value==='plan'?ortho:perspective); perspective.near=value==='walk'?.035:.1;perspective.fov=value==='walk'?65:36;perspective.updateProjectionMatrix(); sync();resize(); if(value==='walk'){ if(state.position.lengthSq()===0)selectRoom('living');else updateWalkCamera(); }else fit(); } function fit(){ if(!state.enabled)return; state.userCamera=false; if(state.mode==='walk'){selectRoom(state.room);return;} const camera=ctx.getCamera(),rect=canvas.getBoundingClientRect(),aspect=rect.width/rect.height; const target=toWorld([6.525,8.7],.45);controls.target.copy(target); if(state.mode==='plan'){ const h=Math.max(18,22/aspect);ortho.left=-h*aspect/2;ortho.right=h*aspect/2;ortho.top=h/2;ortho.bottom=-h/2;ortho.zoom=1; // Garden at the bottom, matching the supplied plan, with the road at the top. const up=houseGroup.localToWorld(new THREE.Vector3(0,0,1)).sub(houseGroup.localToWorld(new THREE.Vector3())).normalize(); ortho.up.copy(up);ortho.position.copy(target).add(new THREE.Vector3(0,50,0));ortho.lookAt(target);ortho.updateProjectionMatrix(); }else{ perspective.up.set(0,1,0);const distance=Math.max(35,33/aspect); const offset=new THREE.Vector3(9,18,-23).normalize().multiplyScalar(distance).applyQuaternion(modelRoot.quaternion); perspective.position.copy(target).add(offset);perspective.lookAt(target); } controls.update(); } function resizeView(){ if(!state.enabled)return; const rect=canvas.getBoundingClientRect(); if(state.mode==='plan'){ const h=Math.max(18,22/(rect.width/rect.height));ortho.left=-h*rect.width/rect.height/2;ortho.right=-ortho.left;ortho.top=h/2;ortho.bottom=-h/2;ortho.updateProjectionMatrix(); }else if(state.mode==='axon'&&!state.userCamera)fit(); } function distanceToEdge(x,z,a,b){const dx=b[0]-a[0],dz=b[1]-a[1],t=Math.max(0,Math.min(1,((x-a[0])*dx+(z-a[1])*dz)/(dx*dx+dz*dz)));return Math.hypot(x-a[0]-t*dx,z-a[1]-t*dz);} function canStand(x,z){ const r=.16; if(!pointInPolygon(localFootprint,x,z))return false; for(let i=0;ib.minX-r&&xb.minZ-r&&zb.minX-r&&xb.minZ-r&&z1||x<20||x>r.width-20||y<78||y>r.height-70; if(tag.hidden)continue; // Keep small screens readable; all rooms remain in the map and selector. const w=tag.offsetWidth,h=tag.offsetHeight,b={x:x-w/2,y:y-h/2,w,h}; if(occupied.some(a=>b.xa.x&&b.ya.y))tag.hidden=true;else occupied.push(b); } } function frame(dt){ if(!state.enabled)return; if(state.mode==='walk'){ const k=state.keys,p=state.pad; const forward=Number(k.has('KeyW')||k.has('ArrowUp')||p.has('forward'))-Number(k.has('KeyS')||k.has('ArrowDown')||p.has('back')); const side=Number(k.has('KeyD')||p.has('right'))-Number(k.has('KeyA')||p.has('left')); const turn=Number(k.has('ArrowLeft'))-Number(k.has('ArrowRight'));state.yaw+=turn*dt*1.4; const distance=dt*(k.has('ShiftLeft')||k.has('ShiftRight')?2.8:1.45)/Math.max(1,Math.hypot(forward,side)); move((Math.sin(state.yaw)*forward-Math.cos(state.yaw)*side)*distance,(Math.cos(state.yaw)*forward+Math.sin(state.yaw)*side)*distance);updateWalkCamera(); } updateLabels(); } function keydown(e){ if(e.key==='Escape'){e.preventDefault();exit();return;} if(/^(INPUT|SELECT|TEXTAREA|BUTTON|SUMMARY)$/.test(e.target.tagName))return; if(state.mode==='walk'&&['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','ShiftLeft','ShiftRight'].includes(e.code)){e.preventDefault();state.keys.add(e.code);} } function clearKeys(){state.keys.clear();state.pad.clear();document.querySelectorAll('#walkPad .held').forEach(b=>b.classList.remove('held'));} window.addEventListener('keyup',e=>state.keys.delete(e.code));window.addEventListener('blur',clearKeys);document.addEventListener('visibilitychange',clearKeys); canvas.addEventListener('pointerdown',e=>{ if(!state.enabled)return;state.userCamera=true; if(state.mode==='walk'){e.preventDefault();document.activeElement?.blur();canvas.setPointerCapture(e.pointerId);state.pointer={id:e.pointerId,x:e.clientX,y:e.clientY};} }); canvas.addEventListener('pointermove',e=>{const p=state.pointer;if(!p||p.id!==e.pointerId||state.mode!=='walk')return;state.yaw-=(e.clientX-p.x)*.004;state.pitch=Math.max(-1.25,Math.min(1.25,state.pitch-(e.clientY-p.y)*.003));p.x=e.clientX;p.y=e.clientY;}); for(const event of ['pointerup','pointercancel','lostpointercapture'])canvas.addEventListener(event,e=>{if(state.pointer?.id===e.pointerId)state.pointer=null;}); canvas.addEventListener('wheel',e=>{if(state.enabled&&state.mode==='walk'){e.preventDefault();perspective.fov=Math.max(45,Math.min(85,perspective.fov+e.deltaY*.025));perspective.updateProjectionMatrix();}},{passive:false}); for(const button of document.querySelectorAll('[data-walk]')){ button.addEventListener('pointerdown',e=>{e.preventDefault();button.setPointerCapture(e.pointerId);state.pad.add(button.dataset.walk);button.classList.add('held');}); for(const event of ['pointerup','pointercancel','lostpointercapture'])button.addEventListener(event,()=>{state.pad.delete(button.dataset.walk);button.classList.remove('held');}); } $('#interiorLaunch').addEventListener('click',enter);$('#interiorExit').addEventListener('click',exit); $('#interiorRoom').addEventListener('change',e=>{if(e.target.value)selectRoom(e.target.value);}); document.querySelectorAll('[data-interior-mode]').forEach(b=>b.addEventListener('click',()=>{mode(b.dataset.interiorMode);b.blur();})); document.querySelectorAll('[data-interior-view]').forEach(b=>b.addEventListener('click',()=>{ const id=b.dataset.interiorView;selectRoom(id==='kitchen'?'living':id); if(id==='living'){const [x,z]=planPoint(3.7,4.15),[lx,lz]=planPoint(6.2,1.9);state.position.set(x,1.62,z);state.yaw=Math.atan2(lx-x,lz-z);state.pitch=-.07;updateWalkCamera();} b.blur(); })); $('#interiorFit').addEventListener('click',fit); $('#interiorPanelToggle').addEventListener('click',()=>{state.panel=!state.panel;sync();resize();}); $('#interiorFurniture').addEventListener('change',e=>{state.furniture=e.target.checked;sync();if(state.mode==='walk'&&!canStand(state.position.x,state.position.z))selectRoom(state.room);}); $('#interiorRoomLabels').addEventListener('change',e=>{state.labels=e.target.checked;sync();}); $('#interiorFill').addEventListener('change',e=>{state.fill=e.target.checked;sync();}); $('#interiorWallColor').addEventListener('input',e=>data.wallMaterial.color.set(e.target.value)); $('#interiorSaveGlb').addEventListener('click',exportGlb); async function savePng(){ renderer.render(scene,ctx.getCamera());updateLabels(); const out=document.createElement('canvas'),scale=canvas.width/canvas.clientWidth;out.width=canvas.width;out.height=canvas.height+Math.round(62*scale); const c=out.getContext('2d');c.fillStyle='#f8f9f3';c.fillRect(0,0,out.width,out.height);c.drawImage(canvas,0,0); if(state.labels&&state.mode!=='walk')for(const {room,tag}of labels){if(tag.hidden)continue;const x=parseFloat(tag.style.left)*scale,y=parseFloat(tag.style.top)*scale;c.font=`${10*scale}px Arial`;const text=room.short+' · '+formatArea(room.area),w=c.measureText(text).width;c.fillStyle='#fafcf4eb';c.fillRect(x-w/2-6*scale,y-10*scale,w+12*scale,20*scale);c.fillStyle='#334c35';c.textAlign='center';c.fillText(text,x,y+3*scale);} c.textAlign='left';c.fillStyle='#2d3c31';c.font=`${12*scale}px Arial`;c.fillText('Klabava · '+$('#interiorViewTitle').textContent,22*scale,canvas.height+26*scale); c.fillStyle='#7b8372';c.font=`${9*scale}px Arial`;c.fillText('Dispozice podle půdorysu · vybavení a materiály orientační',22*scale,canvas.height+44*scale); out.toBlob(blob=>{if(blob)saveBlob(blob,'klabava-interier-'+state.mode+'.png');}); } $('#interiorSavePng').addEventListener('click',savePng); // Small inspection API also useful when embedding this viewer in another page. window.KLABAVA_INTERIOR={enter,exit,setMode:mode,selectRoom,getState:()=>({enabled:state.enabled,mode:state.mode,room:state.room,furniture:state.furniture,panel:state.panel,position:state.position.toArray(),canStand:canStand(state.position.x,state.position.z),rooms:data.rooms.map(({id,name,area,spawn})=>({id,name,area,spawn})),wallColliders:data.wallColliders.length}),canStand:(px,py)=>canStand(...planPoint(px,py))}; return {get enabled(){return state.enabled;},get walking(){return state.enabled&&state.mode==='walk';},frame,resize:resizeView,keydown,exit,enter,savePng}; }