continuous-integration/drone/push Build is passing
- Implemented computeMikkTSpaceTangents for consistent tangent generation. - Added mergeGeometries to combine multiple BufferGeometries. - Introduced mergeAttributes for merging BufferAttributes. - Created deepCloneAttribute for cloning BufferAttributes. - Developed interleaveAttributes and deinterleaveAttribute for attribute management. - Added estimateBytesUsed to calculate memory usage of geometries. - Implemented mergeVertices to optimize vertex data. - Created toTrianglesDrawMode for converting geometries to triangle mode. - Added computeMorphedAttributes for handling morph targets in geometries. - Implemented mergeGroups to consolidate geometry groups. - Developed toCreasedNormals for generating smooth normals with crease angles.
204 lines
18 KiB
JavaScript
204 lines
18 KiB
JavaScript
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=`<b>${room.short}</b><small>${formatArea(room.area)}</small>`;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;i<localFootprint.length;i++)if(distanceToEdge(x,z,localFootprint[i],localFootprint[(i+1)%localFootprint.length])<.295+r)return false;
|
||
for(const b of data.wallColliders)if(x>b.minX-r&&x<b.maxX+r&&z>b.minZ-r&&z<b.maxZ+r)return false;
|
||
if(state.furniture)for(const b of furnishing.colliders)if(x>b.minX-r&&x<b.maxX+r&&z>b.minZ-r&&z<b.maxZ+r)return false;
|
||
return true;
|
||
}
|
||
function move(dx,dz){const n=Math.max(1,Math.ceil(Math.hypot(dx,dz)/.045));for(let i=0;i<n;i++){if(canStand(state.position.x+dx/n,state.position.z))state.position.x+=dx/n;if(canStand(state.position.x,state.position.z+dz/n))state.position.z+=dz/n;}}
|
||
function updateWalkCamera(){
|
||
const direction=new THREE.Vector3(Math.sin(state.yaw)*Math.cos(state.pitch),Math.sin(state.pitch),Math.cos(state.yaw)*Math.cos(state.pitch));
|
||
perspective.position.copy(localToWorld(state.position));perspective.up.set(0,1,0);perspective.lookAt(localToWorld(state.position.clone().add(direction)));
|
||
}
|
||
function updateLabels(){
|
||
if(!state.enabled||state.mode==='walk'||!state.labels)return;
|
||
const camera=ctx.getCamera(),r=canvas.getBoundingClientRect();camera.updateMatrixWorld();
|
||
const occupied=[];
|
||
for(const {room,tag}of labels){
|
||
const p=toWorld(room.label,.08).project(camera),x=(p.x+1)*r.width/2,y=(1-p.y)*r.height/2;
|
||
tag.style.left=x+'px';tag.style.top=y+'px';tag.hidden=p.z<-1||p.z>1||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.x<a.x+a.w+3&&b.x+b.w+3>a.x&&b.y<a.y+a.h+3&&b.y+b.h+3>a.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};
|
||
}
|