Add .DS_Store file to locenice directory
continuous-integration/drone/push Build is passing

This commit is contained in:
martin.fencl
2026-11-06 20:46:55 +01:00
parent b5631467a2
commit 16804546e9
36 changed files with 86875 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
// Illustrative furniture; the supplied drawings document room uses, not a furnishing design.
export function furnish(ctx,data){
const {THREE,planPoint,box,mesh,group}=ctx;const material=c=>new THREE.MeshStandardMaterial({color:c,roughness:.82});
const m={wood:material('#bea17b'),light:material('#dfceb1'),sage:material('#9eab9c'),fabric:material('#c5b8a5'),white:material('#efede4'),dark:material('#36413e'),water:material('#b8ced0'),metal:material('#9da6a3')};
for(const f of data.levels){
f.furnitureColliders=[];let parent=f.furniture,base=0;
function block(name,x,y,w,d,h,z,mat=m.wood){const [X,Z]=planPoint(x,y);return box(parent,name,X,base+z+h/2,Z,w,h,d,mat);}
function solid(x,y,w,d){const [X,Z]=planPoint(x,y);f.furnitureColliders.push({minX:X-w/2,maxX:X+w/2,minZ:Z-d/2,maxZ:Z+d/2});}
function oval(name,x,y,w,d,h,z,mat){const o=mesh(new THREE.SphereGeometry(1,16,8),mat,parent,name);const [X,Z]=planPoint(x,y);o.position.set(X,base+z+h/2,Z);o.scale.set(w/2,h/2,d/2);return o;}
function cabinet(x,y,w,d,h=.86){block('Skříňka',x,y,w,d,h,.04,m.sage);block('Pracovní deska',x,y,w+.035,d+.035,.045,h+.04,m.light);for(let a=x-w/2+.6;a<x+w/2;a+=.6)block('Spára',a,y,.009,d+.008,h-.08,.12,m.light);solid(x,y,w,d);}
function table(x,y,w,d,h=.75){block('Stůl – deska',x,y,w,d,.045,h-.045,m.light);for(const a of [-1,1])for(const b of [-1,1])block('Noha stolu',x+a*(w/2-.08),y+b*(d/2-.08),.05,.05,h-.06,.02);solid(x,y,w,d);}
function chair(x,y,turn=0){const g=group(parent,'Židle');const old=parent;parent=g;block('Sedák',x,y,.43,.43,.08,.43,m.fabric);block('Opěradlo',x,y+.18,.43,.07,.42,.48,m.fabric);for(const a of [-1,1])for(const b of [-1,1])block('Noha židle',x+a*.16,y+b*.16,.035,.035,.43,.01);parent=old;}
function bed(x,y,w,d=2.04){block('Postel – rám',x,y,w,d,.29,.08);block('Matrace',x,y,w-.05,d-.05,.20,.37,m.white);block('Čelo postele',x,y+d/2-.04,w,.08,.82,.08,m.light);block('Přikrývka',x,y-.2,w-.04,d-.5,.05,.59,m.sage);for(let i=0;i<(w>1.4?2:1);i++)oval('Polštář',x+(w>1.4?(i-.5)*.78:0),y+d/2-.32,.65,.4,.13,.59,m.white);solid(x,y,w,d);}
function sofa(x,y,w=2.2){block('Pohovka – sedák',x,y,w,.9,.37,.12,m.fabric);block('Opěradlo pohovky',x,y+.38,w,.17,.72,.12,m.sage);for(const a of [-1,1])block('Područka',x+a*(w/2-.08),y,.16,.92,.56,.12,m.sage);for(let a=x-w/2+.4;a<x+w/2-.15;a+=.65)block('Polštář sedáku',a,y-.03,.61,.65,.09,.49,m.white);solid(x,y,w,.92);}
function wc(x,y){oval('WC',x,y,.39,.65,.35,.17,m.white);oval('Sedátko',x,y,.39,.65,.04,.52,m.white);oval('Mísa',x,y-.02,.25,.39,.015,.56,m.water);solid(x,y,.39,.65);}
function basin(x,y,w=.65){cabinet(x,y,w,.48,.76);oval('Umyvadlo',x,y,w-.12,.36,.06,.85,m.white);oval('Mísa umyvadla',x,y,w-.25,.25,.012,.90,m.water);block('Baterie',x,y+.17,.025,.025,.22,.88,m.metal);}
function shower(x,y,w=.9,d=.9){block('Sprchová vanička',x,y,w,d,.055,.01,m.white);block('Sprchová tyč',x+w/2-.08,y,.025,.025,1.65,.45,m.metal);block('Sprchová hlavice',x+w/2-.16,y,.22,.22,.025,2.08,m.metal);}
function kitchen(x,y,w){cabinet(x,y,w,.62);block('Dřez',x-w/3,y,.55,.42,.015,.95,m.metal);block('Vnitřek dřezu',x-w/3,y,.45,.33,.014,.96,m.dark);block('Baterie dřezu',x-w/3,y-.22,.025,.025,.27,.96,m.metal);block('Varná deska',x+w/4,y,.55,.49,.02,.95,m.dark);for(const a of [-.13,.13])for(const b of [-.13,.13])oval('Varná zóna',x+w/4+a,y+b,.16,.16,.007,.975,m.metal);}
for(const r of f.rooms){parent=group(f.furniture,r.id+' · orientační vybavení');base=r.level;
switch(r.id){
case 'S.02':cabinet(9.5,.65,1.6,.48,1.6);cabinet(10.27,2,.5,1.5,1.6);break;
case '1.01':cabinet(7.85,.65,.6,.55,2.3);cabinet(9.2,.62,1.25,.45,.45);break;
case '1.04':wc(10.18,3.12);break;
case '1.05':shower(9.48,4.25,.88,.88);basin(9.55,5.38,.65);break;
case '1.06':bed(8.8,10.5,1.6);cabinet(9.65,7.1,.6,1.8,2.25);table(8.25,8.25,.95,.55);break;
case '1.07':bed(6.57,10.65,.95);table(5.5,7.94,1.25,.6);chair(5.6,8.7);cabinet(5.17,10.8,.65,1.4,2.15);break;
case '1.08':block('Koberec',2.55,9.7,2.7,2.3,.012,.01,m.white);sofa(2.55,10.75,2.65);table(2.55,9.5,1.15,.65,.42);cabinet(4.10,7.25,.42,1.5,.44);block('TV',4.18,7.25,.06,1.2,.78,.95,m.dark);break;
case '1.09':kitchen(2.6,1.36,3.25);cabinet(.82,2.14,.6,.85,1.95);table(2.3,4.6,1.6,.85);chair(1.8,3.87);chair(2.7,3.87);chair(1.8,5.35);chair(2.7,5.35);break;
case '2.03':kitchen(9.62,7.4,.6);cabinet(8.55,6.27,2,.6);table(8.0,8.05,1.5,.78);chair(7.5,8.72);chair(8.4,8.72);sofa(6.1,10.9,2.1);table(6.1,9.65,1.1,.6,.4);cabinet(9.63,10.5,.5,1.8,.48);block('TV',9.72,10.5,.07,1.3,.75,1,m.dark);break;
case '2.04':bed(2.35,9.75,1.6);cabinet(4.08,9.2,.58,1.7,1.85);table(2.4,5.85,1.4,.58);chair(2.5,6.6);break;
case '2.05':block('Vana',2.35,2.4,1.65,.76,.54,.04,m.white);oval('Vana – vnitřek',2.35,2.4,1.43,.58,.06,.58,m.water);solid(2.35,2.4,1.65,.76);basin(4.3,3.25,.65);wc(1.78,4.5);break;
case '2.06':table(8.8,1.5,1.2,.75);chair(8.4,2.15);chair(9.2,2.15);break;
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import {pointInPolygon} from './plan.js?v=1850';
export function installInterior(ctx){
const {THREE,house,houseGroup,modelRoot,roofGroup,roofTimber,controls,scene,renderer,planPoint,groundGroup,parkingGroup,boundaryGroup,resize,saveBlob,exportGlb,solar,setSolarPlaying,toggleInfo,toggleSun}=ctx;
const $=s=>document.querySelector(s),canvas=renderer.domElement,perspective=ctx.getCamera(),ortho=new THREE.OrthographicCamera(-10,10,10,-10,.035,180);
const state={enabled:false,mode:'axon',floor:'ground',panel:true,furniture:true,labels:true,fill:true,room:'1.08',position:new THREE.Vector3(),yaw:0,pitch:0,keys:new Set(),pad:new Set(),pointer:null,saved:null,userCamera:false};
let data=house.levels[1],labels=[];
const light=new THREE.AmbientLight(0xfff7e8,1.35);light.visible=false;scene.add(light);
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 area=v=>v.toFixed(2).replace('.',',')+' m²';
function rebuildMap(){
$('#interiorRoom').innerHTML='<option value="">Vyberte místnost…</option>';$('#interiorMap').innerHTML='';$('#interiorLabels').innerHTML='';labels=[];
$('#floorSummary').textContent=data.rooms.length+' místností · '+area(data.area)+(state.floor==='upper'?' + balkón 27,88 m²':'');
$('.interior-heading .eyebrow').textContent='KOVÁŘOVA / '+data.title;
const file=state.floor==='ground'?'1-D.1.1.3.2_Půdorys 1NP_420x525_20.10.2026.pdf':state.floor==='upper'?'2-D.1.1.3.3_Půdorys 2NP_420x525_20.10.2026.pdf':'3-D.1.1.3.1_Půdorys 1PP_420x525_20.10.2026.pdf';$('#floorSource').href='./references/'+encodeURIComponent(file);
for(const room of data.rooms){
const option=document.createElement('option');option.value=room.id;option.textContent=room.id+' '+room.name+' · '+area(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(([x,y])=>`${26+x*22},${10+y*22}`).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);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',26+room.label[0]*22);text.setAttribute('y',13+room.label[1]*22);text.setAttribute('text-anchor','middle');text.textContent=room.id;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>${area(room.area)}</small>`;tag.addEventListener('click',()=>selectRoom(room.id));$('#interiorLabels').append(tag);labels.push({room,tag});
}
}
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,room.level+1.62,z);state.yaw=Math.atan2(lx-x,lz-z);state.pitch=-.04;mode('walk');$('#interiorRoom').value=room.id;$('#interiorViewTitle').textContent=room.id+' '+room.name;updateWalkCamera();}
function selectFloor(id){state.floor=id;data=house.levels.find(f=>f.id===id);state.room=data.rooms.find(r=>!r.name.includes('Schodiště'))?.id||data.rooms[0].id;state.position.set(0,0,0);rebuildMap();document.querySelectorAll('[data-floor]').forEach(b=>{b.classList.toggle('active',b.dataset.floor===id);b.setAttribute('aria-pressed',String(b.dataset.floor===id));});if(state.mode==='walk')selectRoom(state.room);else{sync();resize();fit();}}
function switchCamera(c){ctx.setCamera(c);controls.object=c;}
function sync(){
const walking=state.enabled&&state.mode==='walk',cut=state.enabled&&!walking;
for(const f of house.levels){f.root.visible=state.enabled?f===data:f.id!=='basement';f.full.visible=!cut;f.cut.visible=cut;f.ceiling.visible=!cut;f.furniture.visible=state.furniture;f.stairs.visible=true;}
house.foundation.visible=!state.enabled;roofGroup.visible=!cut;roofTimber.visible=!cut;light.visible=state.enabled&&state.fill;house.materials.glass.opacity=walking?.15:.36;
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 on=b.dataset.interiorMode===state.mode;b.classList.toggle('active',on);b.setAttribute('aria-pressed',String(on));});
$('#interiorHint').textContent=walking?'W A S D: chůze · tažením se rozhlížejte · podlaží přepněte v panelu':'Tažením otáčejte · kolečkem přibližujte · vyberte místnost';
if(!walking)$('#interiorViewTitle').textContent=(state.mode==='plan'?'Půdorys · ':'3D řez · ')+data.title;
if(state.enabled){groundGroup.visible=walking&&data.id!=='basement'&&state.saved.ground;parkingGroup.visible=groundGroup.visible;boundaryGroup.visible=false;}
labels.forEach(({tag})=>tag.hidden=!state.enabled||walking||!state.labels);
}
function enter(){if(state.enabled)return;state.saved={position:perspective.position.clone(),target:controls.target.clone(),fov:perspective.fov,near:perspective.near,ground:groundGroup.visible,parking:parkingGroup.visible,boundary:boundaryGroup.visible,playing:solar.playing};setSolarPlaying(false);toggleInfo(false);toggleSun(false);state.enabled=true;state.mode='axon';state.panel=true;state.userCamera=false;controls.minDistance=2;controls.maxDistance=95;sync();resize();fit();}
function clearKeys(){state.keys.clear();state.pad.clear();document.querySelectorAll('#walkPad .held').forEach(b=>b.classList.remove('held'));}
function exit(){if(!state.enabled)return;state.enabled=false;clearKeys();state.pointer=null;sync();const s=state.saved;switchCamera(perspective);controls.enabled=true;controls.enableRotate=true;controls.minDistance=15;controls.minPolarAngle=.08;controls.maxPolarAngle=1.52;perspective.up.set(0,1,0);perspective.fov=s.fov;perspective.near=s.near;perspective.position.copy(s.position);controls.target.copy(s.target);groundGroup.visible=s.ground;parkingGroup.visible=s.parking;boundaryGroup.visible=s.boundary;controls.update();resize();setSolarPlaying(s.playing);}
function mode(value){if(!state.enabled)enter();state.mode=value;state.userCamera=false;clearKeys();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(state.room);else updateWalkCamera();}else fit();}
function fit(){if(!state.enabled)return;state.userCamera=false;if(state.mode==='walk'){selectRoom(state.room);return;}const rect=canvas.getBoundingClientRect(),aspect=rect.width/rect.height;const center=state.floor==='basement'?[7.9,2.3]:[5.4,6.15];const target=toWorld(center,data.base+.4);controls.target.copy(target);if(state.mode==='plan'){const h=Math.max(state.floor==='basement'?7:15,(state.floor==='basement'?8:14)/aspect);ortho.left=-h*aspect/2;ortho.right=-ortho.left;ortho.top=h/2;ortho.bottom=-h/2;ortho.zoom=1;const up=new THREE.Vector3(0,0,-1).applyQuaternion(modelRoot.quaternion);ortho.up.copy(up);ortho.position.copy(target).add(new THREE.Vector3(0,45,0));ortho.lookAt(target);ortho.updateProjectionMatrix();}else{perspective.up.set(0,1,0);const dist=Math.max(state.floor==='basement'?15:29,(state.floor==='basement'?12:27)/aspect);const offset=new THREE.Vector3(9,20,22).normalize().multiplyScalar(dist).applyQuaternion(modelRoot.quaternion);perspective.position.copy(target).add(offset);perspective.lookAt(target);}controls.update();}
function resizeView(){if(!state.enabled)return;if(state.mode==='plan'||state.mode==='axon'&&!state.userCamera)fit();}
function canStand(x,z){const px=x+5.465,py=z+6.18,r=.12;if(!pointInPolygon(data.outline,px,py))return false;const attic=data.id==='upper'&&data.rooms.find(r=>['2.04','2.05'].includes(r.id)&&pointInPolygon(r.polygon,px,py));if(attic&&Math.min(5.71,3.43+.492*(px+.3)-.13,3.43+.598*(py-.25)-.13,3.43+.598*(12.36-py)-.13)<attic.level+1.72)return false;for(const b of [...data.colliders,...(state.furniture?data.furnitureColliders:[])])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)/.035));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;}const x=state.position.x+5.465,y=state.position.z+6.18;const r=data.rooms.find(r=>pointInPolygon(r.polygon,x,y));if(r){state.position.y=r.level+1.62;state.room=r.id;}}
function updateWalkCamera(){const dir=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(dir)));}
function updateLabels(){if(!state.enabled||state.mode==='walk'||!state.labels)return;const c=ctx.getCamera(),r=canvas.getBoundingClientRect();c.updateMatrixWorld();const occupied=[];for(const {room,tag}of labels){const p=toWorld(room.label,room.level+.08).project(c),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<85||y>r.height-78;if(tag.hidden)continue;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')),side=Number(k.has('KeyD')||p.has('right'))-Number(k.has('KeyA')||p.has('left'));state.yaw+=(Number(k.has('ArrowLeft'))-Number(k.has('ArrowRight')))*dt*1.4;const d=dt*1.4/Math.max(1,Math.hypot(forward,side));move((Math.sin(state.yaw)*forward-Math.cos(state.yaw)*side)*d,(Math.cos(state.yaw)*forward+Math.sin(state.yaw)*side)*d);updateWalkCamera();}updateLabels();}
function keydown(e){if(e.key==='Escape'){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'].includes(e.code)){e.preventDefault();state.keys.add(e.code);}}
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'){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.15,Math.min(1.15,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,()=>state.pointer=null);
for(const b of document.querySelectorAll('[data-walk]')){b.addEventListener('pointerdown',e=>{e.preventDefault();b.setPointerCapture(e.pointerId);state.pad.add(b.dataset.walk);b.classList.add('held');});for(const event of ['pointerup','pointercancel','lostpointercapture'])b.addEventListener(event,()=>{state.pad.delete(b.dataset.walk);b.classList.remove('held');});}
$('#interiorLaunch').addEventListener('click',enter);$('#interiorExit').addEventListener('click',exit);$('#interiorRoom').addEventListener('change',e=>e.target.value&&selectRoom(e.target.value));document.querySelectorAll('[data-floor]').forEach(b=>b.addEventListener('click',()=>selectFloor(b.dataset.floor)));document.querySelectorAll('[data-interior-mode]').forEach(b=>b.addEventListener('click',()=>{mode(b.dataset.interiorMode);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();});$('#interiorRoomLabels').addEventListener('change',e=>{state.labels=e.target.checked;sync();});$('#interiorFill').addEventListener('change',e=>{state.fill=e.target.checked;sync();});$('#interiorWallColor').addEventListener('input',e=>house.wallMaterial.color.set(e.target.value));$('#interiorSaveGlb').addEventListener('click',exportGlb);
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+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+' · '+area(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('Kovářova · '+$('#interiorViewTitle').textContent,22*scale,canvas.height+26*scale);c.font=`${9*scale}px Arial`;c.fillText('Stávající stav · vybavení je pouze orientační',22*scale,canvas.height+44*scale);out.toBlob(blob=>blob&&saveBlob(blob,'kovarova-'+state.floor+'-'+state.mode+'.png'));}
$('#interiorSavePng').addEventListener('click',savePng);rebuildMap();sync();
window.KOVAROVA_INTERIOR={enter,exit,setMode:mode,selectRoom,selectFloor,canStand:(x,y)=>canStand(...planPoint(x,y)),getState:()=>({enabled:state.enabled,mode:state.mode,floor:state.floor,room:state.room,position:state.position.toArray(),canStand:canStand(state.position.x,state.position.z)})};
return {get enabled(){return state.enabled;},get walking(){return state.enabled&&state.mode==='walk';},frame,resize:resizeView,keydown,exit,enter,savePng};
}
+566
View File
@@ -0,0 +1,566 @@
import {buildHouse,buildSite} from './model.js?v=1850';
import {furnish} from './furniture.js?v=1850';
import { installInterior } from './interior-view.js?v=1905';
let THREE, OrbitControls, GLTFExporter, solarPosition;
try {
const modules = await Promise.all([
import('three'),
import('three/addons/controls/OrbitControls.js'),
import('three/addons/exporters/GLTFExporter.js'),
import('./solar.js')
]);
THREE = modules[0];
OrbitControls = modules[1].OrbitControls;
GLTFExporter = modules[2].GLTFExporter;
solarPosition = modules[3].solarPosition;
} catch (error) {
const loading = document.querySelector('#loading');
if (loading) {
loading.classList.add('error');
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a ověř přítomnost složky vendor/three.</small>`;
}
throw error;
}
const $ = (selector) => document.querySelector(selector);
const RAD = Math.PI / 180;
const DAY_MS = 86400000;
const mod = (x, n) => ((x % n) + n) % n;
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const mix = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
const czNum = (v, n = 1) => Number(v).toFixed(n).replace('.', ',');
const canvas = $('#view');
const loading = $('#loading');
const toastEl = $('#toast');
const SITE_DEFAULT = { latitude:50.05091024, longitude:14.31001395, bearing:251.172 };
const site = { ...SITE_DEFAULT };
const solar = {
date: '2026-06-21', minute: 600, playing: false, speed: 30, shadows: true, path: false,
start: 0, end: 0, length: 1440, rise: null, set: null, noon: null, samples: [],
position: null, direct: 0.8, dayFactor: 1, sky: [0.89, 0.91, 0.87], accumulator: 0, lastTick: 0
};
const scene = new THREE.Scene();
scene.background = new THREE.Color('#e7ebe3');
let camera = new THREE.PerspectiveCamera(36, 1, 0.1, 220);
camera.position.set(28, 21, 36);
let renderer;
try {
renderer = new THREE.WebGLRenderer({
canvas, antialias: true, alpha: false, preserveDrawingBuffer: true,
powerPreference: 'high-performance'
});
} catch (error) {
loading.classList.add('error');
loading.innerHTML = `3D grafiku nelze spustit.<br>${error.message}<br><small>Zkontroluj podporu WebGL v prohlížeči.</small>`;
throw error;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.075;
controls.minDistance = 15;
controls.maxDistance = 95;
controls.minPolarAngle = 0.08;
controls.maxPolarAngle = 1.52;
controls.target.set(0, 2, 0);
scene.add(new THREE.HemisphereLight(0xf7f8ee, 0x6c705f, 1.55));
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
const sunLight = new THREE.DirectionalLight(0xfff5df, 2.25);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(1536, 1536);
sunLight.shadow.camera.left = -32;
sunLight.shadow.camera.right = 32;
sunLight.shadow.camera.top = 36;
sunLight.shadow.camera.bottom = -36;
sunLight.shadow.camera.near = 0.1;
sunLight.shadow.camera.far = 110;
sunLight.shadow.bias = -0.00015;
sunLight.shadow.normalBias = 0.025;
const sunTarget = new THREE.Object3D();
sunTarget.position.set(0, 2, 0);
scene.add(sunLight, sunTarget);
sunLight.target = sunTarget;
const modelRoot = new THREE.Group();
modelRoot.name = 'Kovářova – dům a pozemek';
// The scene uses true north = +z and east = −x; the site bearing turns the plot clockwise as seen from above.
modelRoot.rotation.y = -site.bearing * RAD;
scene.add(modelRoot);
const groundGroup = new THREE.Group(); groundGroup.name = 'Interpolovaný terén'; modelRoot.add(groundGroup);
const boundaryGroup = new THREE.Group(); boundaryGroup.name = 'Orientační hranice parcely'; modelRoot.add(boundaryGroup);
const parkingGroup = new THREE.Group(); parkingGroup.name = 'Příjezd a parkování'; modelRoot.add(parkingGroup);
const houseGroup = new THREE.Group(); houseGroup.name = 'Rodinný dům – pracovní hmota'; houseGroup.position.set(0, 0, 0); modelRoot.add(houseGroup);
const shellGroup = new THREE.Group(); shellGroup.name = 'Obvodové stěny a otvory'; houseGroup.add(shellGroup);
const roofGroup = new THREE.Group(); roofGroup.name = 'Střecha, podbití a komín'; houseGroup.add(roofGroup);
const roofTimber = new THREE.Group(); roofTimber.name = 'Trámy krytých ploch'; houseGroup.add(roofTimber);
let interior = null;
function group(parent,name){const o=new THREE.Group();o.name=name;parent.add(o);return o;}
function mesh(geometry,material,parent,name=''){const o=new THREE.Mesh(geometry,material);o.name=name;o.castShadow=true;o.receiveShadow=true;parent.add(o);return o;}
function box(parent,name,x,y,z,sx,sy,sz,material,rotationY=0){const o=mesh(new THREE.BoxGeometry(sx,sy,sz),material,parent,name);o.position.set(x,y,z);o.rotation.y=rotationY;return o;}
const planPoint=(x,y)=>[x-5.465,y-6.18];
const siteData=await fetch('./data/site.json').then(r=>r.json());
const context={THREE,houseGroup,modelRoot,shellGroup,roofGroup,roofTimber,groundGroup,boundaryGroup,parkingGroup,planPoint,box,mesh,group,siteData};
const house=buildHouse(context);furnish(context,house);const terrain=buildSite(context,house);
modelRoot.userData={project:'Kovářova 193/54 – stávající stav',source:'Dodané PDF vs-studio 2026, RÚIAN, ČÚZK DMR 5G',units:'metres',furnishing:'illustrative',terrainDatumBpv:372.45,terrainDatumStatus:'estimated, not survey-linked',rooms:house.levels.flatMap(f=>f.rooms.map(r=>({id:r.id,name:r.name,area:r.area,floor:r.level})))};
$('#terrainDatum').addEventListener('change',e=>{const v=Number(e.target.value);if(Number.isFinite(v)&&v>=365&&v<=380){terrain.setDatum(v);modelRoot.userData.terrainDatumBpv=v;}});
let exteriorPreset=null;
function viewportSize() {
const rect = canvas.getBoundingClientRect();
return [Math.max(1, rect.width), Math.max(1, rect.height)];
}
function resize() {
const [w, h] = viewportSize();
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(Math.round(w), Math.round(h), false);
interior?.resize();
if(exteriorPreset&&!interior?.enabled)positionPreset(exteriorPreset);
}
window.addEventListener('resize', resize);
resize();
function toast(message) {
toastEl.textContent = message;
toastEl.style.opacity = '1';
clearTimeout(toast.timer);
toast.timer = setTimeout(() => { toastEl.style.opacity = '0'; }, 2600);
}
function saveBlob(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
const presets = {
entry: { position: [25, 19, -30], target: [0, 2.4, 0], label: 'Dům / příjezd' },
garden: { position: [-29, 18, 24], target: [0, 2.4, 0], label: 'Dům / zahrada' },
top: { position: [-10, 70, 3], target: [-10, 0, 3], label: 'Pozemek / shora' }
};
function setPreset(name) {
if (interior?.enabled) interior.exit();
exteriorPreset=name;
const p = presets[name] || presets.entry;
positionPreset(name);
controls.target.fromArray(p.target);
controls.update();
$('#directionText').textContent = p.label;
document.querySelectorAll('[data-preset]').forEach(button => button.classList.toggle('active', button.dataset.preset === name));
}
function positionPreset(name){
const p=presets[name]||presets.entry,[w,h]=viewportSize(),factor=Math.max(1,.85/(w/h));
const target=new THREE.Vector3(...p.target),offset=new THREE.Vector3(...p.position).sub(target).multiplyScalar(factor);
camera.position.copy(target).add(offset);controls.target.copy(target);controls.update();
}
controls.addEventListener('start',()=>{if(!interior?.enabled)exteriorPreset=null;});
document.querySelectorAll('[data-preset]').forEach(button => button.addEventListener('click', () => setPreset(button.dataset.preset)));
$('#resetView').addEventListener('click', () => setPreset('entry'));
setPreset('entry');
$('#siteTerrain').addEventListener('change', (event) => { groundGroup.visible = event.target.checked; });
$('#siteBoundary').addEventListener('change', (event) => { boundaryGroup.visible = event.target.checked; });
$('#siteParking').addEventListener('change', (event) => { parkingGroup.visible = event.target.checked; });
function toggleInfo(force) {
const on = force ?? !$('#panel').classList.contains('open');
$('#panel').classList.toggle('open', on);
document.body.classList.toggle('info-open', on);
$('#infoButton').classList.toggle('active', on);
$('#infoButton').setAttribute('aria-expanded', String(on));
if (on) toggleSun(false);
resize();
}
function toggleSun(force) {
const on = force ?? !$('#sunPanel').classList.contains('open');
$('#sunPanel').classList.toggle('open', on);
document.body.classList.toggle('sun-open', on);
$('#sunToggle').classList.toggle('active', on);
$('#sunToggle').setAttribute('aria-expanded', String(on));
if (on) toggleInfo(false);
resize();
}
$('#infoButton').addEventListener('click', () => toggleInfo());
$('#infoClose').addEventListener('click', () => toggleInfo(false));
$('#sunToggle').addEventListener('click', () => toggleSun());
$('#sunClose').addEventListener('click', () => toggleSun(false));
// Local civil-time helpers keep the sun controls aligned with Europe/Prague and DST.
const localFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Prague', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
});
function localParts(ms) {
const out = {};
for (const part of localFormatter.formatToParts(new Date(ms))) if (part.type !== 'literal') out[part.type] = +part.value;
return out;
}
function utcOffset(ms) {
const p = localParts(ms);
return Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000) / 60000);
}
function localMidnight(iso) {
const [y, m, d] = iso.split('-').map(Number);
const naive = Date.UTC(y, m - 1, d);
let utc = naive;
for (let i = 0; i < 4; i++) utc = naive - utcOffset(utc) * 60000;
return utc;
}
function clockText(ms) {
if (ms === null || !Number.isFinite(ms)) return '—';
const p = localParts(ms);
return `${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}`;
}
function czDate(iso) {
const [y, m, d] = iso.split('-').map(Number);
return `${d}. ${m}. ${y}`;
}
function zoneText(ms) {
const offset = utcOffset(ms);
return `${offset === 120 ? 'SELČ' : offset === 60 ? 'SEČ' : 'Praha'} · UTC${offset >= 0 ? '+' : ''}${offset / 60}`;
}
function timeForClock(text) {
if (!/^\d{1,2}:\d{2}$/.test(text)) return null;
const [h, m] = text.split(':').map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m) || h < 0 || h > 23 || m < 0 || m > 59) return null;
const normalized = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
const [y, mo, d] = solar.date.split('-').map(Number);
const naive = Date.UTC(y, mo - 1, d, h, m);
const offsets = [...new Set([utcOffset(solar.start - 12 * 3600000), utcOffset(solar.start), utcOffset(solar.end - 1), utcOffset(solar.end + 12 * 3600000)])];
const candidates = offsets.map(offset => naive - offset * 60000)
.filter(utc => utc >= solar.start && utc < solar.end && clockText(utc) === normalized).sort((a, b) => a - b);
if (candidates.length) return candidates[0];
for (let i = 0; i < solar.length; i++) {
const utc = solar.start + i * 60000, p = localParts(utc);
if (p.hour * 60 + p.minute >= h * 60 + m) return utc;
}
return solar.end - 60000;
}
function refineCrossing(a, b, threshold) {
let fa = solarPosition(a, site.latitude, site.longitude).elevation - threshold;
for (let i = 0; i < 25; i++) {
const mid = (a + b) / 2;
const fm = solarPosition(mid, site.latitude, site.longitude).elevation - threshold;
if ((fa < 0) === (fm < 0)) { a = mid; fa = fm; } else b = mid;
}
return (a + b) / 2;
}
let graphBase = '';
function rebuildSolarDay(preserveClock = true) {
const previousClock = preserveClock && solar.start ? clockText(solar.start + solar.minute * 60000) : ($('#sunClock').value || '10:00');
solar.start = localMidnight(solar.date);
const [y, m, d] = solar.date.split('-').map(Number);
const nextDate = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
solar.end = localMidnight(nextDate);
solar.length = Math.round((solar.end - solar.start) / 60000);
solar.rise = null; solar.set = null; solar.samples = [];
let previous = null, max = { altitude: -999, ms: solar.start };
for (let utc = solar.start; utc <= solar.end; utc += 600000) {
const pos = solarPosition(utc, site.latitude, site.longitude);
solar.samples.push({ ms: utc, ...pos });
if (pos.elevation > max.altitude) max = { altitude: pos.elevation, ms: utc };
if (previous) {
if (previous.elevation < -0.833 && pos.elevation >= -0.833) solar.rise = refineCrossing(previous.ms, utc, -0.833);
if (previous.elevation >= -0.833 && pos.elevation < -0.833) solar.set = refineCrossing(previous.ms, utc, -0.833);
}
previous = { ms: utc, ...pos };
}
let lo = Math.max(solar.start, max.ms - 600000), hi = Math.min(solar.end, max.ms + 600000);
for (let i = 0; i < 36; i++) {
const a = lo + (hi - lo) / 3, b = hi - (hi - lo) / 3;
if (solarPosition(a, site.latitude, site.longitude).elevation < solarPosition(b, site.latitude, site.longitude).elevation) lo = a; else hi = b;
}
solar.noon = (lo + hi) / 2;
solar.minute = clamp((timeForClock(previousClock) - solar.start) / 60000, 0, solar.length - 1);
$('#sunDate').value = solar.date;
$('#sunTime').max = solar.length - 1;
$('#sunRise').textContent = clockText(solar.rise === null ? null : solar.rise + 30000);
$('#sunSet').textContent = clockText(solar.set === null ? null : solar.set + 30000);
$('#sunNoon').textContent = clockText(solar.noon + 30000);
document.querySelectorAll('[data-season]').forEach(button => button.classList.toggle('active', solar.date.endsWith(button.dataset.season)));
const ticks = $('.sun-ticks');
ticks.style.position = 'relative'; ticks.style.height = '12px';
[...ticks.children].forEach((el, i) => {
const minutes = i === 4 ? solar.length : (timeForClock(el.textContent) - solar.start) / 60000;
el.style.cssText = `position:absolute;left:${100 * minutes / solar.length}%;transform:translateX(${i === 0 ? 0 : i === 4 ? -100 : -50}%)`;
});
$('#solarMessage').textContent = solar.length === 1440 ? '' : `Tento den má ${solar.length / 60} hodin. Časová osa respektuje změnu letního času.`;
drawSolarGraphBase();
updateSolar();
}
function drawSolarGraphBase() {
const y = altitude => 70 - clamp(altitude, 0, 90) * 0.65;
const points = solar.samples.map(p => `${8 + 288 * (p.ms - solar.start) / (solar.end - solar.start)},${y(p.elevation)}`);
const curve = `M${points.join(' L')}`;
graphBase = `<defs><linearGradient id="solarFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d4b465" stop-opacity=".34"/><stop offset="1" stop-color="#d4b465" stop-opacity=".04"/></linearGradient></defs><line x1="8" y1="70" x2="296" y2="70" stroke="#dce2d3"/><line x1="8" y1="40.75" x2="296" y2="40.75" stroke="#e4e8dd" stroke-dasharray="3 4"/><text x="294" y="37" text-anchor="end" fill="#a1a891" font-size="8">45°</text><path d="${curve} L296,70 L8,70 Z" fill="url(#solarFill)"/><path d="${curve}" fill="none" stroke="#c29b4b" stroke-width="1.5"/>`;
}
function drawSolarGraph() {
if (!solar.position) return;
const x = 8 + 288 * solar.minute / solar.length;
const y = 70 - clamp(solar.position.elevation, 0, 90) * 0.65;
$('#sunGraph').innerHTML = graphBase + `<line x1="${x}" y1="10" x2="${x}" y2="75" stroke="#a97731" stroke-width="1" stroke-dasharray="2 3"/><circle cx="${x}" cy="${y}" r="4" fill="#bc8933" stroke="#fafbf7" stroke-width="2"/>`;
}
function updateSolar() {
solar.minute = clamp(solar.minute, 0, solar.length - 1);
const utc = solar.start + solar.minute * 60000;
solar.position = solarPosition(utc, site.latitude, site.longitude);
const { elevation, azimuth } = solar.position;
solar.dayFactor = smooth(-10, 4, elevation);
solar.direct = elevation > 0 ? (1.02 - 0.32 * Math.exp(-Math.max(elevation, 0) / 9)) * smooth(0, 1.2, elevation) : 0;
const daylight = solar.dayFactor;
solar.sky = mix([0.075, 0.112, 0.157], [0.906, 0.922, 0.890], daylight);
if (elevation > -7 && elevation < 9) solar.sky = mix(solar.sky, [0.89, 0.83, 0.74], 0.16 * Math.sin((elevation + 7) / 16 * Math.PI));
scene.background.setRGB(...solar.sky);
const az = azimuth * RAD, alt = elevation * RAD;
const distance = 65;
modelRoot.updateMatrixWorld(true);
sunTarget.position.set(0, 1.2, houseGroup.position.z + 1).applyMatrix4(modelRoot.matrixWorld);
sunLight.position.set(-Math.sin(az) * Math.cos(alt), Math.sin(alt), Math.cos(az) * Math.cos(alt)).multiplyScalar(distance).add(sunTarget.position);
sunLight.intensity = Math.max(0, solar.direct) * 2.55;
sunLight.color.setRGB(1, elevation < 12 ? 0.87 + elevation / 100 : 0.96, elevation < 12 ? 0.68 + elevation / 55 : 0.88);
sunLight.castShadow = solar.shadows && elevation > -0.5;
solar.lastTick = utc;
$('#sunClock').value = clockText(utc);
$('#sunTime').value = Math.floor(solar.minute);
$('#sunTime').setAttribute('aria-valuetext', clockText(utc));
$('#zoneLabel').textContent = zoneText(utc);
$('#sunElevation').textContent = `${czNum(elevation)}°`;
$('#sunAzimuth').textContent = `${czNum(azimuth)}°`;
const dirs = ['sever', 'severovýchod', 'východ', 'jihovýchod', 'jih', 'jihozápad', 'západ', 'severozápad'];
$('#sunDirection').textContent = `${dirs[Math.round(azimuth / 45) % 8]} · od severu`;
$('#dayState').textContent = elevation > 0 ? 'Slunce nad obzorem' : elevation > -0.833 ? 'Slunce na obzoru' : elevation > -6 ? 'Soumrak' : 'Noc · bez přímého slunce';
$('#sceneTime').textContent = `${czDate(solar.date)} · ${clockText(utc)} ${utcOffset(utc) === 120 ? 'SELČ' : 'SEČ'}`;
document.body.classList.toggle('night', elevation < -4);
document.body.style.backgroundColor = `rgb(${solar.sky.map(v => Math.round(v * 255)).join(',')})`;
drawSolarGraph();
}
function solarFrame(dt) {
if (!solar.playing) return;
solar.minute += dt * solar.speed;
if (solar.minute >= solar.length) solar.minute = 0;
solar.accumulator += dt;
if (solar.accumulator >= 0.075) { solar.accumulator = 0; updateSolar(); }
}
function setSolarPlaying(on) {
solar.playing = on;
$('#sunPlay').innerHTML = on ? 'Ⅱ Pozastavit' : '▶ Přehrát den';
$('#sunPlay').setAttribute('aria-pressed', String(on));
}
$('#sunDate').addEventListener('change', (event) => {
if (!event.target.value) return;
setSolarPlaying(false); solar.date = event.target.value; rebuildSolarDay(true);
});
function applyClock(){
const utc=timeForClock($('#sunClock').value);if(utc===null)return;
setSolarPlaying(false);solar.minute=(utc-solar.start)/60000;updateSolar();
}
$('#sunClock').addEventListener('change',applyClock);
$('#sunClock').addEventListener('blur',applyClock);
$('#sunClock').addEventListener('input',()=>{clearTimeout(applyClock.timer);applyClock.timer=setTimeout(applyClock,250);});
$('#sunClock').addEventListener('keydown',e=>{if(e.key==='Enter'){clearTimeout(applyClock.timer);applyClock();e.target.blur();}});
$('#sunTime').addEventListener('input', (event) => { setSolarPlaying(false); solar.minute = +event.target.value; updateSolar(); });
document.querySelectorAll('[data-season]').forEach(button => button.addEventListener('click', () => {
solar.date = `${solar.date.slice(0, 4)}-${button.dataset.season}`; setSolarPlaying(false); rebuildSolarDay(true);
}));
$('#sunPlay').addEventListener('click', () => setSolarPlaying(!solar.playing));
$('#sunSpeed').addEventListener('change', (event) => { solar.speed = +event.target.value; });
$('#sunNow').addEventListener('click', () => {
const now = new Date();
solar.date = `${localParts(now.getTime()).year}-${String(localParts(now.getTime()).month).padStart(2, '0')}-${String(localParts(now.getTime()).day).padStart(2, '0')}`;
rebuildSolarDay(false);
solar.minute = (now.getTime() - solar.start) / 60000;
updateSolar();
});
$('#sunShadows').addEventListener('change', (event) => { solar.shadows = event.target.checked; updateSolar(); });
$('#sunPath').addEventListener('change', (event) => { solar.path = event.target.checked; });
function readSiteNumbers() {
const lat = +$('#sunLat').value, lon = +$('#sunLon').value, bearing = +$('#siteBearing').value;
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(bearing) || !$('#sunLat').checkValidity() || !$('#sunLon').checkValidity() || !$('#siteBearing').checkValidity()) {
toast('Zadej hodnoty v povoleném rozsahu.');
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
return;
}
site.latitude = lat; site.longitude = lon; site.bearing = bearing;
modelRoot.rotation.y = -bearing * RAD;
rebuildSolarDay(true);
}
['sunLat', 'sunLon', 'siteBearing'].forEach(id => $(`#${id}`).addEventListener('change', readSiteNumbers));
$('#sunResetSite').addEventListener('click', () => {
Object.assign(site, SITE_DEFAULT);
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
modelRoot.rotation.y = -site.bearing * RAD;
rebuildSolarDay(true);
});
$('#compassGroup').insertAdjacentHTML('beforeend', '<circle id="compassSun" cx="42" cy="16" r="3" fill="#c89439" stroke="#fff7d7" stroke-width="1"/>');
const sunGraph = $('#sunGraph');
function scrubGraph(event) {
const rect = sunGraph.getBoundingClientRect();
solar.minute = clamp(((event.clientX - rect.left) / rect.width * 304 - 8) / 288 * solar.length, 0, solar.length - 1);
setSolarPlaying(false); updateSolar();
}
sunGraph.addEventListener('pointerdown', event => { sunGraph.setPointerCapture(event.pointerId); scrubGraph(event); });
sunGraph.addEventListener('pointermove', event => { if (sunGraph.hasPointerCapture(event.pointerId)) scrubGraph(event); });
sunGraph.addEventListener('pointerup', event => { if (sunGraph.hasPointerCapture(event.pointerId)) sunGraph.releasePointerCapture(event.pointerId); });
async function saveSunImage() {
setSolarPlaying(false); updateSolar();
controls.update();
drawSunPath();
renderer.render(scene, camera);
const source = renderer.domElement;
const out = document.createElement('canvas');
const [viewWidth] = viewportSize();
const ratio = source.width / viewWidth;
const footer = Math.round(66 * ratio);
out.width = source.width; out.height = source.height + footer;
const ctx = out.getContext('2d');
ctx.fillStyle = '#f8f9f3'; ctx.fillRect(0, 0, out.width, out.height);
ctx.drawImage(source, 0, 0);
const scale = out.width / viewWidth;
if (solar.path && $('#sunOverlay').innerHTML) {
const svgText = `<svg xmlns="http://www.w3.org/2000/svg" width="${viewWidth}" height="${source.height / ratio}" viewBox="0 0 ${viewWidth} ${source.height / ratio}"><style>text{font:10px Arial;fill:#96712e;paint-order:stroke;stroke:#f3f4e9;stroke-width:3px;stroke-linejoin:round}</style>${$('#sunOverlay').innerHTML}</svg>`;
const url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
const overlay = new Image();
await new Promise((resolve, reject) => { overlay.onload = resolve; overlay.onerror = reject; overlay.src = url; });
ctx.drawImage(overlay, 0, 0, source.width, source.height);
URL.revokeObjectURL(url);
}
ctx.fillStyle = '#2d3c31'; ctx.font = `${12 * scale}px Arial`;
ctx.fillText(`Kovářova · ${czDate(solar.date)} · ${clockText(solar.start + solar.minute * 60000)} · ${zoneText(solar.start + solar.minute * 60000)}`, 24 * scale, source.height + 28 * scale);
ctx.fillStyle = '#7b8372'; ctx.font = `${9 * scale}px Arial`;
ctx.fillText('Orientační studie oslunění · Kovářova 193/54, Praha – Stodůlky', 24 * scale, source.height + 46 * scale);
out.toBlob(blob => { if (blob) saveBlob(blob, 'kovarova-slunce.png'); });
}
$('#sunSavePng').addEventListener('click', saveSunImage);
$('#savePng').addEventListener('click', saveSunImage);
let exporting = false;
function exportGlb() {
if (exporting) return;
exporting = true;
const button = $('#saveGlb'); button.disabled = true;
const exporter = new GLTFExporter();
modelRoot.updateMatrixWorld(true);
const basement=house.levels[0].root,wasVisible=basement.visible;
if(!interior?.enabled)basement.visible=true;
exporter.parse(modelRoot, (result) => {
basement.visible=wasVisible;
saveBlob(new Blob([result], { type: 'model/gltf-binary' }), interior?.enabled ? 'kovarova-interier.glb' : 'kovarova-pracovni-model.glb');
toast('Model Kovářova GLB je připravený ke stažení.');
exporting = false; button.disabled = false;
}, (error) => { basement.visible=wasVisible; console.error(error); toast('Model se nepodařilo exportovat.'); exporting = false; button.disabled = false; }, { binary: true, onlyVisible: true, trs: false });
}
$('#saveGlb').addEventListener('click', exportGlb);
function drawSunPath() {
const svg = $('#sunOverlay');
const [width, height] = viewportSize();
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
const dot = $('#compassSun');
const azimuth = solar.position?.azimuth ?? 0;
dot.setAttribute('cx', 42 + 26 * Math.sin(azimuth * RAD));
dot.setAttribute('cy', 42 - 26 * Math.cos(azimuth * RAD));
dot.style.display = solar.position?.elevation > 0 ? 'block' : 'none';
if (!solar.path) { svg.innerHTML = ''; return; }
modelRoot.updateMatrixWorld(true);
const center = new THREE.Vector3(0, 1.2, houseGroup.position.z + 1).applyMatrix4(modelRoot.matrixWorld);
const pointAt = (az, alt, distance = 15.5) => new THREE.Vector3(
center.x - Math.sin(az * RAD) * Math.cos(alt * RAD) * distance,
center.y + Math.sin(alt * RAD) * distance,
center.z + Math.cos(az * RAD) * Math.cos(alt * RAD) * distance
);
let path = '', started = false;
for (const sample of solar.samples) {
if (sample.elevation < 0) { started = false; continue; }
const v = pointAt(sample.azimuth, sample.elevation).project(camera);
if (v.z < -1 || v.z > 1) { started = false; continue; }
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
path += `${started ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)} `;
started = true;
}
let out = `<path d="${path}" fill="none" stroke="#b28b40" stroke-width="1.5" stroke-dasharray="5 5" opacity=".8"/>`;
for (const hour of [6, 9, 12, 15, 18, 21]) {
const utc = timeForClock(`${String(hour).padStart(2, '0')}:00`);
if (utc === null) continue;
const pos = solarPosition(utc, site.latitude, site.longitude);
if (pos.elevation < 0) continue;
const v = pointAt(pos.azimuth, pos.elevation).project(camera);
if (v.z < -1 || v.z > 1) continue;
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
out += `<circle cx="${x}" cy="${y}" r="2.5" fill="#bd984f"/><text x="${x + 6}" y="${y - 7}">${hour}:00</text>`;
}
if (solar.position?.elevation >= 0) {
const v = pointAt(solar.position.azimuth, solar.position.elevation).project(camera);
if (v.z >= -1 && v.z <= 1) {
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
out += `<circle cx="${x}" cy="${y}" r="18" fill="#e2bc5c" opacity=".13"/><circle cx="${x}" cy="${y}" r="6.5" fill="#e7bd59" stroke="#fff5cf" stroke-width="2"/>`;
}
}
svg.innerHTML = out;
}
function animate(now = 0) {
requestAnimationFrame(animate);
const dt = Math.min(0.1, Math.max(0, (now - (animate.last || now)) / 1000));
animate.last = now;
solarFrame(dt);
if (!interior?.walking) controls.update();
interior?.frame(dt);
const cameraHeading = Math.atan2(camera.position.x - controls.target.x, camera.position.z - controls.target.z) / RAD;
// North is +z, so a camera on the north side looking south shows north at the bottom of the compass.
$('#compassGroup').setAttribute('transform', `rotate(${cameraHeading + 180},42,42)`);
renderer.render(scene, camera);
drawSunPath();
}
window.addEventListener('keydown', event => {
if (interior?.enabled) { interior.keydown(event); return; }
if (event.key === 'Escape') { toggleInfo(false); toggleSun(false); }
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'SUMMARY'].includes(event.target.tagName)) return;
if (event.code === 'Space') { event.preventDefault(); setSolarPlaying(!solar.playing); }
if (event.key.toLowerCase() === 's') toggleSun();
if (event.key.toLowerCase() === 'i') interior?.enter();
if (event.key === '1') setPreset('entry');
if (event.key === '2') setPreset('garden');
if (event.key === '3') setPreset('top');
});
document.addEventListener('visibilitychange', () => { if (document.hidden) setSolarPlaying(false); });
window.SUN_SIM = {
position: solarPosition,
getState: () => ({ date: solar.date, time: clockText(solar.start + solar.minute * 60000), utc: new Date(solar.start + solar.minute * 60000).toISOString(), lat: site.latitude, lon: site.longitude, bearing: site.bearing, azimuth: solar.position.azimuth, elevation: solar.position.elevation, rise: clockText(solar.rise), set: clockText(solar.set), noon: clockText(solar.noon), shadows: solar.shadows, playing: solar.playing }),
setDate: date => { solar.date = date; rebuildSolarDay(true); },
setTime: time => { solar.minute = (timeForClock(time) - solar.start) / 60000; updateSolar(); },
pause: () => setSolarPlaying(false)
};
try {
interior = installInterior({house,...context,THREE,houseGroup,modelRoot,shellGroup,roofGroup,roofTimber,controls,scene,renderer,planPoint,box,mesh,group,groundGroup,parkingGroup,boundaryGroup,resize,saveBlob,exportGlb,solar,setSolarPlaying,toggleInfo,toggleSun,getCamera:()=>camera,setCamera:value=>{camera=value;}});
rebuildSolarDay(false);
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
toggleSun(false); toggleInfo(false);
loading.classList.add('done');
setTimeout(() => { loading.style.display = 'none'; }, 300);
requestAnimationFrame(animate);
} catch (error) {
console.error(error);
loading.classList.add('error');
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a ověř přítomnost složky vendor/three.</small>`;
$('#webStatus').textContent = '3D není dostupné';
}
+126
View File
@@ -0,0 +1,126 @@
import {floors,walls,outline,pointInPolygon} from './plan.js?v=1850';
export function buildHouse(ctx){
const {THREE,houseGroup,modelRoot,roofGroup,groundGroup,boundaryGroup,parkingGroup,planPoint,box,mesh,group,siteData}=ctx;
const mat=(color,extra={})=>new THREE.MeshStandardMaterial({color,roughness:.86,...extra});
const m={wall:mat('#e5e0d5'),trim:mat('#cfbfa5'),plinth:mat('#99998f'),roof:mat('#786e63',{side:THREE.DoubleSide}),frame:mat('#e3dfd3'),glass:mat('#a5c6cc',{transparent:true,opacity:.36,roughness:.2,side:THREE.DoubleSide}),oak:mat('#c5a77e'),tile:mat('#c8c6bc'),brick:mat('#ae9180'),deck:mat('#b29c7d'),ceiling:mat('#f1eee6',{side:THREE.DoubleSide}),metal:mat('#606964',{metalness:.3}),grass:mat('#a3b091'),road:mat('#969c94')};
const block=(p,n,x,z,w,d,b,t,material)=>{const [X,Z]=planPoint(x,z);return box(p,n,X,(b+t)/2,Z,w,t-b,d,material);};
function surface(p,n,polygon,height,material,holes=[]){
const pts=polygon.map(q=>new THREE.Vector2(...q)),hs=holes.map(h=>h.map(q=>new THREE.Vector2(...q)));
const all=[...polygon,...holes.flat()];const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.Float32BufferAttribute(all.flatMap(([x,y])=>{const [X,Z]=planPoint(x,y);return [X,typeof height==='function'?height(x,y):height,Z];}),3));
g.setIndex(THREE.ShapeUtils.triangulateShape(pts,hs).flatMap(([a,b,c])=>[c,b,a]));g.computeVertexNormals();return mesh(g,material,p,n);
}
function face(p,n,pts,material){const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(pts.flatMap(([x,h,z])=>{const [X,Z]=planPoint(x,z);return[X,h,Z];}),3));const ix=[];for(let i=1;i<pts.length-1;i++)ix.push(0,i,i+1);g.setIndex(ix);g.computeVertexNormals();return mesh(g,material,p,n);}
const levels=floors.map(f=>({...f,root:group(houseGroup,f.title),colliders:[]}));
const wallBox=(p,axis,fixed,a,b,t,bottom,top,ma,n)=>block(p,n,axis==='x'?fixed:(a+b)/2,axis==='y'?fixed:(a+b)/2,axis==='x'?t:b-a,axis==='y'?t:b-a,bottom,top,ma);
for(const f of levels){
f.full=group(f.root,'Stěny a otvory');f.cut=group(f.root,'Stěny v řezu');f.ground=group(f.root,'Podlahy');f.ceiling=group(f.root,'Podhledy');f.furniture=group(f.root,'Orientační vybavení');f.stairs=group(f.root,'Schodiště');
const b=f.base;
if(f.id==='ground')surface(f.ground,'Prahy 1NP',outline,-.055,m.tile,[[[5.07,1.0],[6.15,1.0],[6.15,4.27],[5.07,4.27]]]);
if(f.id==='upper'){
surface(f.ground,'Nosná deska hlavní části',[[4.19,5.45],[10.44,5.45],[10.44,12.36],[4.19,12.36]],3.54,m.tile);
surface(f.ground,'Podlaha podkroví',[[1.22,1.67],[4.72,1.67],[4.72,11.09],[1.22,11.09]],3.24,m.oak);
}
for(const r of f.rooms){
if(!r.id.endsWith('.01')||r.id==='1.01')surface(f.ground,r.id+' '+r.name+' · '+r.area+' m²',r.polygon,r.level+.007,m[r.floor]||m.oak);
if(r.height && r.name!=='Schodiště')surface(f.ceiling,'Podhled '+r.id,r.polygon,r.level+r.height,m.ceiling);
// Floor pattern is clipped to each polygon.
const x0=Math.min(...r.polygon.map(v=>v[0])),x1=Math.max(...r.polygon.map(v=>v[0])),y0=Math.min(...r.polygon.map(v=>v[1])),y1=Math.max(...r.polygon.map(v=>v[1]));
const pos=[],step=['wood','deck'].includes(r.floor)?.22:.6;
for(let y=Math.ceil(y0/step)*step;y<y1;y+=step){let a=null;for(let x=x0+.006;x<x1+.025;x+=.025){const ok=pointInPolygon(r.polygon,x,y);if(ok&&a===null)a=x;if(!ok&&a!==null){const A=planPoint(a,y),B=planPoint(x-.025,y);pos.push(A[0],r.level+.012,A[1],B[0],r.level+.012,B[1]);a=null;}}if(a!==null){const A=planPoint(a,y),B=planPoint(x1-.005,y);pos.push(A[0],r.level+.012,A[1],B[0],r.level+.012,B[1]);}}
const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));const lines=new THREE.LineSegments(g,new THREE.LineBasicMaterial({color:'#706b5f',transparent:true,opacity:.16}));f.ground.add(lines);
}
for(const [axis,fixed,start,end,t,bottom,top,openings] of walls[f.id]){
const cutTop=Math.min(top,bottom+1.12);
function solid(a,b,lo=bottom,hi=top,collide=true){if(b-a<.002||hi-lo<.002)return;wallBox(f.full,axis,fixed,a,b,t,lo,hi,m.wall,'Zdivo');if(lo<cutTop)wallBox(f.cut,axis,fixed,a,b,t,lo,Math.min(hi,cutTop),m.wall,'Zdivo v řezu');
if(collide&&lo<bottom+.4){const x=axis==='x'?fixed:(a+b)/2,y=axis==='y'?fixed:(a+b)/2;const [X,Z]=planPoint(x,y),sx=axis==='x'?t:b-a,sz=axis==='y'?t:b-a;f.colliders.push({minX:X-sx/2,maxX:X+sx/2,minZ:Z-sz/2,maxZ:Z+sz/2});}}
let cursor=start;
for(const [a,b,sill,head,kind] of openings){solid(cursor,a);solid(a,b,bottom,sill,kind==='window');solid(a,b,head,top,false);
const window=kind==='window';for(const parent of [f.full,f.cut]){const cap=parent===f.cut?cutTop:head;
for(const edge of [a,b])if(cap>sill)wallBox(parent,axis,fixed,edge-.025,edge+.025,t+.025,sill,cap,m.frame,'Rám otvoru');
if(head<=cap)wallBox(parent,axis,fixed,a,b,t+.035,head-.05,head,m.frame,'Horní rám');
if(window&&sill<cap)wallBox(parent,axis,fixed,a,b,t+.08,sill-.035,sill+.015,m.frame,'Parapet');
if(window&&cap>sill){wallBox(parent,axis,fixed,a+.025,b-.025,.025,sill+.02,cap-.025,m.glass,'Zasklení').castShadow=false;if(b-a>.8)wallBox(parent,axis,fixed,(a+b)/2-.025,(a+b)/2+.025,.09,sill,cap,m.frame,'Sloupek okna');}
}
// Exterior doors remain open for the walkthrough; balcony doors have a light side frame.
cursor=b;
}solid(cursor,end);
}
f.cut.visible=false;
}
// Sloping podkroví ceilings: ruled surfaces, matching the lower roof and the stated maximum clear height.
const upper=levels[2];
const lowCeiling=(x,y)=>Math.min(5.71,3.43+.492*(x+.3)-.13,3.43+.598*(y-.25)-.13,3.43+.598*(12.36-y)-.13);
upper.ceiling.children.filter(o=>/2.04|2.05/.test(o.name)).forEach(o=>upper.ceiling.remove(o));
for(const r of upper.rooms.filter(r=>['2.04','2.05'].includes(r.id))){
const xs=r.polygon.map(p=>p[0]),ys=r.polygon.map(p=>p[1]),pos=[],indices=[];
for(let x=Math.min(...xs);x<Math.max(...xs);x+=.15)for(let y=Math.min(...ys);y<Math.max(...ys);y+=.15){
const x1=Math.min(x+.15,Math.max(...xs)),y1=Math.min(y+.15,Math.max(...ys)),k=pos.length/3;
const cx=(x+x1)/2,cy=(y+y1)/2;if([[1.65,6.4,.72,.8],[1.65,8.2,.72,.8],[3.25,2.25,.8,.64]].some(([a,b,w,d])=>Math.abs(cx-a)<w/2-.1&&Math.abs(cy-b)<d/2-.1))continue;
for(const [a,b]of [[x,y],[x1,y],[x1,y1],[x,y1]]){const [X,Z]=planPoint(a,b);pos.push(X,lowCeiling(a,b),Z);}indices.push(k,k+2,k+1,k,k+3,k+2);
}
const geom=new THREE.BufferGeometry();geom.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));geom.setIndex(indices);geom.computeVertexNormals();mesh(geom,m.ceiling,upper.ceiling,'Šikmý podhled '+r.id);
}
// Clip attic partitions to the roof envelope so walls never pierce its planes.
for(const parent of [upper.full,upper.cut])for(const o of parent.children){if(!o.isMesh||!o.geometry.attributes.position)continue;const x=o.position.x+5.465,y=o.position.z+6.18;if(x>4.5)continue;
const a=o.geometry.attributes.position;for(let i=0;i<a.count;i++){const X=a.getX(i)+x,Y=a.getZ(i)+y;const cap=lowCeiling(X,Y);if(a.getY(i)+o.position.y>cap)a.setY(i,cap-o.position.y);}a.needsUpdate=true;o.geometry.computeVertexNormals();}
// Stair is a schematic 17 × 200 / 275 flight with a turn at its upper end.
for(let i=0;i<13;i++)block(levels[1].stairs,'Stupeň 1NP → 2NP',5.61,4.15-i*.245,1.08,.25,-.05,(i+1)*.2,m.oak);
for(let i=0;i<4;i++)block(levels[1].stairs,'Zatáčka schodiště',5.3+i*.32,1.09,.34,.88,2.6,2.8+i*.2,m.oak);
surface(upper.ground,'Podesta 2NP',[[5.07,4.27],[6.27,4.27],[6.27,5.57],[5.07,5.57]],3.4,m.oak);
for(let i=0;i<10;i++)block(levels[0].stairs,'Stupeň 1PP → 1NP',5.57,1.8+i*.265,1,.267,-2.2,-1.9+(i+1)*.19,m.plinth);
surface(levels[0].ground,'Podesta sklepa',[[5.07,1.02],[6.73,1.02],[6.73,1.72],[5.07,1.72]],-1.9,m.tile);
block(levels[0].full,'Pilíř ve sklepě',8.89,1.9,.32,.32,-2.2,-.14,m.wall);block(levels[0].cut,'Pilíř ve sklepě v řezu',8.89,1.9,.32,.32,-2.2,-1.08,m.wall);
block(upper.full,'Horní stěna nad napojením nižší střechy',5.36,5.685,2.28,.47,5.78,6.8,m.wall);
// Balcony slab and open metal guardrail.
const balcony=upper.rooms.find(r=>r.id==='2.06');surface(upper.ground,'Balkón – spodní líc',balcony.polygon,3.14,m.plinth);
const rail=group(upper.full,'Balkónové zábradlí');for(let i=0;i<balcony.polygon.length-1;i++){if(i===2)continue;const a=balcony.polygon[i],b=balcony.polygon[i+1],d=Math.hypot(b[0]-a[0],b[1]-a[1]),count=Math.ceil(d/.9);for(let j=0;j<=count;j++){const x=a[0]+(b[0]-a[0])*j/count,z=a[1]+(b[1]-a[1])*j/count;block(rail,'Sloupek',x,z,.035,.035,3.45,4.5,m.metal);}const [X,Z]=planPoint((a[0]+b[0])/2,(a[1]+b[1])/2);box(rail,'Madlo',X,4.5,Z,d,.035,.035,m.metal,-Math.atan2(b[1]-a[1],b[0]-a[0]));}
upper.cut.add(rail.clone());
for(let i=0;i<3;i++)block(levels[1].ground,'Vstupní schod',11.1+i*.29,1.17,.3,1.4,-.6,-.15*(i+1),m.plinth);
function hip(x0,y0,x1,y1,eave,ridge){
const cx=(x0+x1)/2,cy=(y0+y1)/2,dx=x1-x0,dy=y1-y0;const h=(ridge-eave)/Math.tan(31*Math.PI/180);const A=[x0,eave,y0],B=[x1,eave,y0],C=[x1,eave,y1],D=[x0,eave,y1];
const R1=dx<dy?[cx,ridge,y0+h]:[x0+h,ridge,cy],R2=dx<dy?[cx,ridge,y1-h]:[x1-h,ridge,cy];
if(dx<dy){face(roofGroup,'Valba',[A,B,R1],m.roof);face(roofGroup,'Střešní rovina',[B,C,R2,R1],m.roof);face(roofGroup,'Valba',[C,D,R2],m.roof);face(roofGroup,'Střešní rovina',[D,A,R1,R2],m.roof);}else{face(roofGroup,'Střecha',[A,B,R2,R1],m.roof);face(roofGroup,'Valba',[B,C,R2],m.roof);face(roofGroup,'Střecha',[C,D,R1,R2],m.roof);face(roofGroup,'Valba',[D,A,R1],m.roof);}
for(const [a,b]of [[A,B],[B,C],[C,D],[D,A]]){const [X,Z]=planPoint((a[0]+b[0])/2,(a[2]+b[2])/2);box(roofGroup,'Okap a římsa',X,eave-.06,Z,Math.hypot(a[0]-b[0],a[2]-b[2]),.13,.13,m.metal,-Math.atan2(b[2]-a[2],b[0]-a[0]));}
}
hip(3.89,5.15,10.74,12.66,6.8,8.85);
hip(4.42,.25,6.8,5.75,5.9,6.53);
const A=[-.3,3.43,.25],B=[4.72,5.9,4.38],C=[4.72,5.9,8.23],D=[-.3,3.43,12.36];
const hole=(x,y,w,d)=>[[x-w/2,y-d/2],[x+w/2,y-d/2],[x+w/2,y+d/2],[x-w/2,y+d/2]];
surface(roofGroup,'Severní střešní rovina',[A,B,C,D].map(v=>[v[0],v[2]]),(x,y)=>3.43+.492*(x+.3),m.roof,[hole(1.65,6.4,.72,.8),hole(1.65,8.2,.72,.8)]);
surface(roofGroup,'Východní valba nižší střechy',[[A[0],A[2]],[4.72,.25],[B[0],B[2]]],(x,y)=>3.43+.598*(y-.25),m.roof,[hole(3.25,2.25,.8,.64)]);
face(roofGroup,'Západní valba nižší střechy',[D,C,[4.72,3.43,12.36]],m.roof);
block(roofGroup,'Komín',4.92,5.25,.65,.65,5.4,8.72,m.wall);block(roofGroup,'Hlava komína',4.92,5.25,.78,.78,8.72,8.84,m.plinth);
// Roof windows are schematic; their plan positions follow the dashed openings.
for(const [x,y]of [[1.65,6.4],[1.65,8.2],[3.25,2.25]]){
const w=x<2?.72:.8,d=x<2?.8:.64;
const height=(a,b)=>x<2?3.43+.492*(a+.3)+.025:3.43+.598*(b-.25)+.025;
surface(roofGroup,'Zasklení střešního okna',hole(x,y,w,d),height,m.glass).castShadow=false;
surface(roofGroup,'Rám střešního okna',hole(x,y,w+.12,d+.12),height,m.frame,[hole(x,y,w,d)]);
const outer=hole(x,y,w,d),inner=hole(x,y,w-.20,d-.20);
for(let i=0;i<4;i++){const j=(i+1)%4;face(upper.ceiling,'Ostění střešního okna',[[...inner[i]],[...inner[j]],[...outer[j]],[...outer[i]]].map(([a,b],k)=>[a,k<2?lowCeiling(a,b):height(a,b),b]),m.ceiling);}
}
const foundation=group(houseGroup,'Sokl');for(let i=0;i<outline.length;i++){const a=outline[i],b=outline[(i+1)%outline.length];wallBox(foundation,a[0]===b[0]?'x':'y',a[0]===b[0]?a[0]:a[1],a[0]===b[0]?Math.min(a[1],b[1]):Math.min(a[0],b[0]),a[0]===b[0]?Math.max(a[1],b[1]):Math.max(a[0],b[0]),.24,-1.1,-.05,m.plinth,'Sokl');}
return {levels,materials:m,block,surface,face,foundation,wallMaterial:m.wall};
}
export function buildSite(ctx,data){
const {THREE,groundGroup,boundaryGroup,parkingGroup,planPoint,mesh,box,siteData:s}=ctx;const {surface,materials:m}=data;
let datum=372.45;
const raw=(x,y)=>{const e=s.origin[0]+s.ux[0]*x+s.uy[0]*y,n=s.origin[1]+s.ux[1]*x+s.uy[1]*y;const ix=Math.max(0,Math.min(s.width-1.001,e-s.bbox[0]-.5)),iy=Math.max(0,Math.min(s.height-1.001,s.bbox[3]-n-.5));const i=Math.floor(ix),j=Math.floor(iy),u=ix-i,v=iy-j;return (1-v)*((1-u)*s.heights[j][i]+u*s.heights[j][i+1])+v*((1-u)*s.heights[j+1][i]+u*s.heights[j+1][i+1]);};
const height=(x,y)=>raw(x,y)-datum;
const terrainMeshes=[],boundaries=[];
for(const parcel of s.parcels){
const polygon=parcel.polygon.slice(0,-1),pos=[],indices=[];
const triangles=THREE.ShapeUtils.triangulateShape(polygon.map(p=>new THREE.Vector2(...p)),[]);
for(const tri of triangles){const [a,b,c]=tri.map(i=>polygon[i]);const n=Math.ceil(Math.max(Math.hypot(a[0]-b[0],a[1]-b[1]),Math.hypot(a[0]-c[0],a[1]-c[1]),Math.hypot(b[0]-c[0],b[1]-c[1]))/1.2);const rows=[];
for(let i=0;i<=n;i++){rows[i]=[];for(let j=0;j<=n-i;j++){const x=a[0]+(b[0]-a[0])*i/n+(c[0]-a[0])*j/n,y=a[1]+(b[1]-a[1])*i/n+(c[1]-a[1])*j/n;rows[i][j]=pos.length/3;const [X,Z]=planPoint(x,y);pos.push(X,height(x,y),Z);}}
for(let i=0;i<n;i++)for(let j=0;j<n-i;j++){indices.push(rows[i][j],rows[i][j+1],rows[i+1][j]);if(j<n-i-1)indices.push(rows[i+1][j],rows[i][j+1],rows[i+1][j+1]);}
}
const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));g.setIndex(indices);g.computeVertexNormals();const o=mesh(g,m.grass,groundGroup,'Terén parcely '+parcel.id);terrainMeshes.push(o);
const points=[...polygon,polygon[0]].map(([x,y])=>{const [X,Z]=planPoint(x,y);return new THREE.Vector3(X,height(x,y)+.06,Z);});const line=new THREE.Line(new THREE.BufferGeometry().setFromPoints(points),new THREE.LineBasicMaterial({color:'#4d6a4b'}));line.name='Hranice '+parcel.id;boundaryGroup.add(line);boundaries.push(line);
}
surface(parkingGroup,'Orientační poloha ulice Kovářova',[[-8,15],[19,15],[19,19],[-8,19]],height,m.road);
// Editable datum is explicit: the drawings do not tie ±0.000 to a Bpv elevation.
function setDatum(value){const delta=datum-value;datum=value;groundGroup.position.y+=delta;boundaryGroup.position.y+=delta;parkingGroup.position.y+=delta;}
return {setDatum,get datum(){return datum;},raw};
}
+77
View File
@@ -0,0 +1,77 @@
// Coordinates in metres, x right and y down in the supplied floor plans.
// Printed room areas are authoritative labels; digitised polygons are approximate.
const rect=(x,y,w,d)=>[[x,y],[x+w,y],[x+w,y+d],[x,y+d]];
const room=(id,name,area,polygon,level,height,floor,label,spawn,look)=>({id,name,short:id+' '+name,area,polygon,level,height,floor,label,spawn:spawn||label,look:look||[label[0],label[1]-1]});
export const rooms=[
room('S.01','Schodiště',3.56,[[5.07,1.02],[6.73,1.02],[6.73,2.98],[6.07,2.98],[6.07,4.12],[5.07,4.12]],-1.9,1.9,'tile',[5.6,1.6],[5.6,1.6],[6.8,1.5]),
room('S.02','Sklep',10.66,rect(6.73,.30,3.90,2.74),-2.2,2.06,'brick',[8.7,1.65],[7.7,1.7],[9.6,1.7]),
room('1.01','Zádveří',5.48,[[6.73,.30],[10.63,.30],[10.63,1.99],[7.5,1.99],[7.5,.90],[6.73,.90]],0,3.1,'tile',[8.95,1.14],[8.9,1.15],[7.9,1.15]),
room('1.02','Schodiště',5.58,[[5.07,.9],[6.73,.9],[6.73,1.99],[6.15,1.99],[6.15,4.27],[5.07,4.27]],0,5.78,'wood',[5.6,2.8],[5.6,4.12],[5.6,1.7]),
room('1.03','Chodba',9.39,[[6.5,2.14],[9.63,2.14],[9.63,3.46],[8.28,3.46],[8.28,5.45],[5.07,5.45],[5.07,4.27],[6.5,4.27]],0,3.1,'tile',[7.15,4.3],[6.9,4.5],[7.1,2.7]),
room('1.04','WC',1.18,rect(9.73,2.14,.9,1.32),0,2.6,'tile',[10.18,2.8],[10.17,2.58],[10.17,3.2]),
room('1.05','Koupelna',2.97,rect(8.46,3.75,1.51,2),0,2.6,'tile',[9.2,4.65],[8.9,5.12],[9.5,4.1]),
room('1.06','Pokoj',20.01,[[4.72,5.9],[9.97,5.9],[9.97,11.89],[7.37,11.89],[7.37,7.43],[4.72,7.43]],0,3.1,'wood',[8.65,8.4],[8.45,7],[8.7,10.3]),
room('1.07','Pokoj',11.09,rect(4.72,7.53,2.55,4.36),0,3.06,'wood',[5.98,9.4],[5.4,9.1],[6.1,10.4]),
room('1.08','Obývací pokoj',21.85,rect(.47,6.11,3.95,5.48),0,2.82,'wood',[2.4,8.6],[2.7,8.5],[1.4,10]),
room('1.09','Kuchyň',21.16,[[.47,1.02],[4.77,1.02],[4.77,4.26],[4.42,4.26],[4.42,5.94],[.47,5.94]],-.04,2.86,'tile',[2.6,3.3],[2.6,3.3],[1.3,1.5]),
room('2.01','Schodiště',4.98,rect(5.07,.9,1.20,4.54),3.4,2.38,'wood',[5.67,4.75],[5.67,5.05],[5.67,2.4]),
room('2.02','Chodba',4.79,rect(4.72,5.92,1.72,2.6),3.55,2.57,'tile',[5.6,7.3],[5.6,6.6],[5.6,8]),
room('2.03','Obývací pokoj + kuchyň',26.42,[[6.56,5.92],[9.97,5.92],[9.97,11.89],[4.72,11.89],[4.72,8.64],[6.56,8.64]],3.55,2.57,'wood',[8.3,9.1],[7.4,9.1],[8.7,10.5]),
room('2.04','Pokoj',17.20,rect(1.32,5.44,3.1,5.55),3.25,2.46,'wood',[2.95,8.4],[3.6,7.1],[2.5,9.8]),
room('2.05','Koupelna',11.65,rect(1.32,1.77,3.4,3.44),3.3,2.41,'tile',[3.1,3.4],[3.8,4.1],[2.7,2.6]),
room('2.06','Balkón',27.88,[[6.5,-.31],[11.34,-.31],[11.34,5.45],[6.5,5.45]],3.45,0,'deck',[8.6,2.5],[8.6,4],[8.6,1])
];
export const outline=[[0,.55],[6.43,.55],[6.43,0],[10.93,0],[10.93,3.63],[10.44,3.63],[10.44,12.36],[4.19,12.36],[4.19,12.06],[0,12.06]];
export const floors=[{id:'basement',title:'1PP · Suterén',area:14.22,base:-2.2,rooms:rooms.filter(r=>r.id.startsWith('S.')),outline:[[4.87,.72],[6.43,.72],[6.43,0],[10.93,0],[10.93,3.34],[6.27,3.34],[6.27,4.42],[4.87,4.42]]},
{id:'ground',title:'1NP · Přízemí',area:98.71,base:0,rooms:rooms.filter(r=>r.id.startsWith('1.')),outline},
{id:'upper',title:'2NP · Patro',area:65.04,base:3.55,rooms:rooms.filter(r=>r.id.startsWith('2.')),outline:[[.95,1.4],[4.72,1.4],[4.72,.55],[6.5,.55],[6.5,-.31],[11.34,-.31],[11.34,5.45],[10.44,5.45],[10.44,12.36],[4.19,12.36],[4.19,11.3],[.95,11.3]]}];
// Wall: axis (x = vertical in plan), fixed coordinate, start, end, thickness, bottom, top, openings.
// Opening: start, end, sill relative to datum, head, kind (door/window).
export const walls={
basement:[
['y',.15,6.43,10.93,.3,-2.2,-.14,[[7.95,8.45,-.64,-.14,'window']]],
['x',10.78,0,3.19,.3,-2.2,-.14,[]],['y',3.19,6.58,10.93,.3,-2.2,-.14,[]],
['x',6.58,.3,3.19,.3,-2.2,-.14,[[1.02,1.72,-2.2,-.25,'door']]],
['y',.87,4.87,6.58,.3,-1.9,0,[[5.34,5.84,-.53,-.11,'window']]],
['x',4.97,.9,4.27,.2,-1.9,0,[]],['x',6.17,1.9,4.27,.2,-1.9,0,[]],
['y',4.27,4.97,6.27,.2,-1.9,0,[]]],
ground:[
['y',.785,0,5.3,.47,0,3.43,[[2,3.2,.65,2.43,'window']]],
['y',.70,5.3,6.43,.3,0,3.43,[[5.34,5.84,1.7,2.12,'window']]],
['y',.15,6.43,10.93,.3,0,3.14,[[7.75,9.44,.92,2.74,'window']]],
['x',.235,.55,12.06,.47,0,3.43,[[3.6,4.8,.65,2.43,'window'],[7.5,8.7,.65,2.43,'window']]],
['y',11.825,0,4.19,.47,0,3.43,[[1.5,2.7,.65,2.43,'window']]],
['x',4.425,11.59,12.36,.47,0,3.55,[]],
['y',12.125,4.19,10.44,.47,0,3.55,[[5.8,7,.65,2.43,'window'],[7.63,8.83,.65,2.43,'window']]],
['x',10.205,3.63,12.36,.47,0,3.55,[[4.26,4.99,1.5,2.5,'window'],[8.46,9.66,.65,2.43,'window']]],
['y',3.545,9.63,10.93,.17,0,3.14,[]],
['x',10.78,0,3.63,.3,0,3.14,[[.52,1.79,0,2.25,'door'],[2.47,2.85,1.5,2.5,'window']]],
['x',6.58,0,.9,.3,0,3.43,[]],
['x',7.35,.3,1.99,.3,0,3.1,[]],
['y',2.065,6.15,10.63,.15,0,3.1,[[7.5,8.75,0,2.10,'door']]],
['x',9.68,2.14,3.46,.1,0,2.6,[[2.25,2.95,0,1.77,'door']]],
['y',3.6,8.28,9.97,.3,0,2.6,[]],
['x',8.37,3.6,5.75,.18,0,2.6,[[4.55,5.30,0,1.77,'door']]],
['y',5.825,8.28,10.44,.15,0,3.1,[]],
['y',5.675,4.42,8.28,.45,0,3.1,[[6.52,7.89,0,2.20,'door']]],
['x',4.92,1.02,5.45,.3,0,3.1,[[4.32,5.25,0,2.10,'door']]],
['x',6.325,1.99,4.27,.35,0,3.1,[]],
['y',4.34,5.07,6.15,.15,0,2.6,[[5.12,5.87,0,1.8,'door']]],
['y',6.025,.47,4.42,.17,0,2.82,[[1.87,3.03,0,2.20,'door']]],
['x',4.57,5.9,11.89,.30,0,3.06,[[8.26,9.63,0,2.07,'door']]],
['y',7.48,4.72,7.37,.1,0,3.06,[]],['x',7.32,7.48,11.89,.1,0,3.06,[]]],
upper:[
['x',1.22,1.67,11.09,.2,3.25,4.25,[]],['y',1.67,1.22,4.72,.2,3.3,4.15,[]],['y',11.09,1.22,4.42,.2,3.25,4.15,[]],
['y',5.325,1.32,4.72,.23,3.3,5.71,[[3.58,4.38,3.3,5.07,'door']]],
['x',4.57,5.44,12.36,.3,3.25,6.8,[[6.78,7.53,3.25,5.02,'door']]],
['x',4.895,.9,5.92,.35,3.3,5.78,[[5.30,5.92,3.3,5.40,'door']]],
['x',6.325,.55,5.92,.35,3.4,5.9,[[1.22,2.25,3.45,5.3,'door'],[4.12,5.12,3.45,5.5,'door']]],
['y',.725,4.72,6.5,.35,3.4,5.9,[]],
['y',5.57,5.07,6.27,.26,3.4,5.78,[[5.12,6.02,3.4,5.17,'door']]],
['y',5.685,6.5,10.44,.47,3.55,6.8,[[7.78,9.24,3.55,5.75,'door']]],
['x',10.205,5.45,12.36,.47,3.55,6.8,[[8.46,9.66,4.18,5.96,'window']]],
['y',12.125,4.19,10.44,.47,3.55,6.8,[[5.8,7,4.18,5.96,'window'],[7.64,8.84,4.18,5.96,'window']]],
['x',6.5,5.92,8.58,.12,3.55,6.12,[[6.77,7.57,3.55,5.57,'door']]],
['y',8.58,4.72,7.85,.12,3.55,6.12,[]]]
};
export function pointInPolygon(p,x,y){let ok=false;for(let i=0,j=p.length-1;i<p.length;j=i++){const a=p[i],b=p[j];if((a[1]>y)!==(b[1]>y)&&x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])ok=!ok;}return ok;}
+23
View File
@@ -0,0 +1,23 @@
// Pure function retained from the supplied Meeus / NOAA implementation.
// Arguments: UTC Unix milliseconds, degrees latitude, degrees longitude.
const RAD=Math.PI/180,DAY_MS=86400000;
const mod=(x,n)=>((x%n)+n)%n,clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
export function solarPosition(ms, lat, lon) {
const jd = ms / DAY_MS + 2440587.5, t = (jd - 2451545) / 36525;
const L = mod(280.46646 + t * (36000.76983 + t * .0003032), 360) * RAD;
const M = (357.52911 + t * (35999.05029 - .0001537 * t)) * RAD;
const e = .016708634 - t * (.000042037 + .0000001267 * t);
const C = Math.sin(M) * (1.914602 - t * (.004817 + .000014 * t)) + Math.sin(2 * M) * (.019993 - .000101 * t) + Math.sin(3 * M) * .000289;
const omega = (125.04 - 1934.136 * t) * RAD;
const lambda = L + (C - .00569 - .00478 * Math.sin(omega)) * RAD;
const mean = 23 + (26 + (21.448 - t * (46.815 + t * (.00059 - t * .001813))) / 60) / 60;
const obliq = (mean + .00256 * Math.cos(omega)) * RAD;
const dec = Math.asin(Math.sin(obliq) * Math.sin(lambda));
const y = Math.tan(obliq / 2) ** 2;
const eq = 4 / RAD * (y * Math.sin(2 * L) - 2 * e * Math.sin(M) + 4 * e * y * Math.sin(M) * Math.cos(2 * L) - .5 * y * y * Math.sin(4 * L) - 1.25 * e * e * Math.sin(2 * M));
const solarMinutes = mod(mod(ms, DAY_MS) / 60000 + eq + 4 * lon, 1440);
const H = (solarMinutes / 4 - 180) * RAD, phi = lat * RAD;
const elevation = Math.asin(clamp(Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H), -1, 1)) / RAD;
const azimuth = mod(Math.atan2(Math.sin(H), Math.cos(H) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi)) / RAD + 180, 360);
return { elevation, azimuth, declination: dec / RAD, equationOfTime: eq };
}