Files
martin.fencl f0a6480c86
continuous-integration/drone/push Build is passing
feat: add BufferGeometryUtils for advanced geometry manipulation
- 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.
2026-11-02 13:38:01 +01:00

204 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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};
}