feat: add BufferGeometryUtils for advanced geometry manipulation
continuous-integration/drone/push Build is passing
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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// Schematic furnishing positions follow the supplied floor plan. Finishes are provisional.
|
||||
export function furnishInterior({THREE,parent,planPoint,box,mesh,group}) {
|
||||
const material=(color,roughness=.8)=>new THREE.MeshStandardMaterial({color,roughness});
|
||||
const m={oak:material(0xb99b70),oakLight:material(0xd2ba95),white:material(0xf0eee5),stone:material(0xe2ded2),sage:material(0x9ca894),cloth:material(0xd3c7b5),linen:material(0xeae5d9),pillow:material(0xb9bca9),dark:material(0x323a38),metal:new THREE.MeshStandardMaterial({color:0x8c9390,metalness:.65,roughness:.25}),water:material(0xc8d8d7),glass:new THREE.MeshStandardMaterial({color:0xb6d4d3,transparent:true,opacity:.23,roughness:.15,side:THREE.DoubleSide}),mirror:new THREE.MeshStandardMaterial({color:0xbdcaca,metalness:.65,roughness:.08})};
|
||||
const colliders=[];
|
||||
function block(p,name,px,py,w,d,h,y,mat){const [x,z]=planPoint(px,py);return box(p,name,x,y+h/2,z,d,h,w,mat);}
|
||||
function collide(px,py,w,d){const [x,z]=planPoint(px,py);colliders.push({minX:x-d/2,maxX:x+d/2,minZ:z-w/2,maxZ:z+w/2});}
|
||||
function cylinder(p,name,px,py,r,h,y,mat){const [x,z]=planPoint(px,py);const o=mesh(new THREE.CylinderGeometry(r,r,h,24),mat,p,name);o.position.set(x,y+h/2,z);return o;}
|
||||
function oval(p,name,px,py,w,d,h,y,mat){const [x,z]=planPoint(px,py);const o=mesh(new THREE.SphereGeometry(1,24,12),mat,p,name);o.scale.set(d/2,h/2,w/2);o.position.set(x,y+h/2,z);return o;}
|
||||
function cabinet(p,name,px,py,w,d,h=2.35,axis='py',mat=m.white){
|
||||
const g=group(p,name);block(g,'Korpus',px,py,w,d,h,.02,mat);block(g,'Sokl',px,py,w-.06,d-.06,.09,.02,m.dark);
|
||||
const span=axis==='py'?w:d,n=Math.max(1,Math.round(span/.55));
|
||||
for(let i=1;i<n;i++){
|
||||
const a=-span/2+i*span/n;
|
||||
block(g,'Spára dvířek',px+(axis==='py'?a:0),py+(axis==='px'?a:0),axis==='py'?.008:w+.002,axis==='px'?.008:d+.002,h-.16,.12,m.oakLight);
|
||||
}
|
||||
collide(px,py,w,d);return g;
|
||||
}
|
||||
function legs(p,px,py,w,d,h){for(const a of [-1,1])for(const b of [-1,1])block(p,'Noha',px+a*(w/2-.08),py+b*(d/2-.08),.045,.045,h,.02,m.oak);}
|
||||
function table(p,name,px,py,w,d,h=.75){const g=group(p,name);block(g,'Deska',px,py,w,d,.05,h-.05,m.oakLight);legs(g,px,py,w,d,h-.05);collide(px,py,w,d);return g;}
|
||||
function chair(p,px,py,rotation=0){
|
||||
const g=group(p,'Židle');const [x,z]=planPoint(px,py);g.position.set(x,0,z);g.rotation.y=rotation;
|
||||
box(g,'Sedák',0,.47,0,.43,.08,.43,m.cloth);box(g,'Opěradlo',0,.71,.19,.43,.45,.06,m.cloth);
|
||||
for(const a of [-1,1])for(const b of [-1,1])box(g,'Noha',a*.16,.22,b*.16,.035,.44,.035,m.oak);
|
||||
}
|
||||
function bed(p,name,px,py,w,d,head='py'){
|
||||
const g=group(p,name);block(g,'Rám postele',px,py,w,d,.29,.08,m.oak);block(g,'Matrace',px,py,w-.06,d-.06,.2,.37,m.linen);
|
||||
const across=head==='py'?w:d,along=head==='py'?d:w;
|
||||
block(g,'Čelo',px+(head==='px'?w/2-.04:0),py+(head==='py'?d/2-.04:0),head==='px'?.08:w,head==='py'?.08:d,.9,.07,m.oakLight);
|
||||
block(g,'Přikrývka',px-(head==='px'?.25:0),py-(head==='py'?.25:0),head==='px'?w-.55:w-.03,head==='py'?d-.55:d-.03,.055,.58,m.pillow);
|
||||
const count=across>1.3?2:1;
|
||||
for(let i=0;i<count;i++){
|
||||
const side=(i-(count-1)/2)*.8;
|
||||
oval(g,'Polštář',px+(head==='px'?along/2-.32:side),py+(head==='py'?along/2-.32:side),head==='py'?.64:.40,head==='py'?.40:.64,.16,.58,m.linen);
|
||||
}
|
||||
collide(px,py,w,d);return g;
|
||||
}
|
||||
function sink(p,name,px,py,w,d){
|
||||
const g=group(p,name);block(g,'Umyvadlová skříňka',px,py,w,d,.52,.3,m.oakLight);block(g,'Umyvadlová deska',px,py,w+.03,d+.03,.06,.82,m.white);
|
||||
const alongPx=w>d;const n=Math.max(w,d)>1.1?2:1;
|
||||
for(let i=0;i<n;i++){
|
||||
const off=(i-(n-1)/2)*.62,cx=px+(alongPx?off:0),cy=py+(alongPx?0:off);
|
||||
oval(g,'Umyvadlo',cx,cy,alongPx?.5:.36,alongPx?.36:.5,.05,.88,m.white);
|
||||
oval(g,'Dno mísy',cx,cy,alongPx?.37:.25,alongPx?.25:.37,.014,.912,m.water);
|
||||
cylinder(g,'Baterie',cx+(alongPx?0:.18),cy+(alongPx?.18:0),.018,.20,.86,m.metal);
|
||||
}
|
||||
collide(px,py,w,d);return g;
|
||||
}
|
||||
function wc(p,px,py,axis='px'){
|
||||
const g=group(p,'Závěsné WC');const w=axis==='px'?.65:.38,d=axis==='px'?.38:.65;
|
||||
oval(g,'Keramická mísa',px,py,w,d,.34,.15,m.white);oval(g,'Sedátko',px,py,w,d,.045,.49,m.white);
|
||||
oval(g,'Vnitřek mísy',px-.035,py, w*.59,d*.57,.016,.526,m.water);collide(px,py,w,d);
|
||||
}
|
||||
function shower(p,px,py,w,d){
|
||||
const g=group(p,'Sprchový kout');block(g,'Vanička v úrovni podlahy',px,py,w,d,.025,.016,m.stone);
|
||||
block(g,'Odtok',px+w/2-.1,py,.035,d*.55,.005,.043,m.metal);
|
||||
const [x,z]=planPoint(px+w/2-.08,py);const o=mesh(new THREE.CylinderGeometry(.014,.014,1.35,10),m.metal,g,'Sprchová tyč');o.position.set(x,1.45,z);
|
||||
cylinder(g,'Sprchová hlavice',px+w/2-.2,py,.11,.018,2.08,m.metal);
|
||||
// Open entry to the shower; only the side screen is solid.
|
||||
block(g,'Skleněná zástěna',px,py+d/2,w,.014,2,.03,m.glass);
|
||||
}
|
||||
|
||||
const kitchen=group(parent,'Kuchyň do U s poloostrovem');
|
||||
// Run at garden wall, then west side and peninsula; clear aisle 1.30 m.
|
||||
for(let i=0;i<6;i++){
|
||||
const py=.625+i*.60;cabinet(kitchen,'Spodní kuchyňská skříňka',.605,py,.60,.59,.82,'px',m.sage);
|
||||
block(kitchen,'Pracovní deska',.605,py,.64,.60,.045,.84,m.stone);
|
||||
}
|
||||
cabinet(kitchen,'Dřezová část',1.545,.605,1.30,.60,.82,'py',m.sage);
|
||||
block(kitchen,'Pracovní deska',1.545,.605,1.30,.64,.045,.84,m.stone);
|
||||
cabinet(kitchen,'Poloostrov',2.645,1.745,.90,2.30,.82,'px',m.sage);
|
||||
block(kitchen,'Deska poloostrova',2.645,1.745,.96,2.34,.045,.84,m.oakLight);
|
||||
block(kitchen,'Dřez',1.45,.61,.60,.44,.012,.89,m.metal);block(kitchen,'Dřez – vnitřek',1.45,.61,.50,.34,.013,.90,m.dark);
|
||||
cylinder(kitchen,'Dřezová baterie',1.45,.39,.018,.30,.89,m.metal);
|
||||
block(kitchen,'Varná deska',.61,2.45,.49,.60,.015,.89,m.dark);
|
||||
for(const a of [-.13,.13])for(const b of [-.16,.16])cylinder(kitchen,'Varná zóna',.61+a,2.45+b,.083,.003,.908,m.metal);
|
||||
block(kitchen,'Vestavná trouba',.91,3.04,.016,.48,.42,.27,m.dark);block(kitchen,'Madlo trouby',.93,3.04,.025,.36,.025,.65,m.metal);
|
||||
cabinet(kitchen,'Vysoká potravinová skříň',.62,3.92,.64,.60,2.30,'px',m.sage);
|
||||
cabinet(kitchen,'Americká lednice',.66,4.78,.72,.92,1.88,'px',m.metal);
|
||||
block(kitchen,'Lednice – madla',1.035,4.78,.025,.045,.62,.78,m.dark);
|
||||
|
||||
const living=group(parent,'Obývací pokoj a jídelna');
|
||||
block(living,'Koberec',5.55,2.1,2.6,3.1,.012,.016,m.linen);
|
||||
block(living,'Pohovka – sedací část',5.0,1.86,.95,2.80,.40,.12,m.cloth);
|
||||
block(living,'Pohovka – lenoška',5.65,.91,1.7,.90,.40,.12,m.cloth);
|
||||
block(living,'Pohovka – opěradlo',4.51,1.86,.18,2.80,.76,.12,m.cloth);
|
||||
block(living,'Pohovka – bočnice',5.0,3.23,1.0,.15,.60,.12,m.cloth);
|
||||
for(let py=.8;py<3;py+=.75)block(living,'Sedací polštář',5.05,py,.78,.68,.12,.51,m.linen);
|
||||
collide(5.0,1.86,1.1,2.8);collide(5.65,.91,1.7,.9);
|
||||
table(living,'Konferenční stolek',6.25,2.25,.70,1.15,.40);
|
||||
cabinet(living,'Nízká TV skříňka',7.65,1.95,.45,2.30,.42,'px',m.oak);
|
||||
block(living,'Televize',7.89,1.95,.05,1.45,.82,1.05,m.dark);
|
||||
cylinder(living,'Kamna',7.582,4.763,.24,1.02,.02,m.dark);cylinder(living,'Kouřovod',7.582,4.763,.075,1.56,1.04,m.dark);
|
||||
collide(7.582,4.763,.52,.52);
|
||||
table(living,'Jídelní stůl pro šest',3.95,5.30,2.1,.86);
|
||||
for(const px of [3.25,3.95,4.65]){chair(living,px,4.57,Math.PI/2);chair(living,px,6.03,-Math.PI/2);}
|
||||
|
||||
const storage=group(parent,'Šatny a zádveří');
|
||||
for(const py of [2.956,4.306])cabinet(storage,'Šatní skříň',10.26,py,1.96,.60,2.35,'py');
|
||||
cabinet(storage,'Šatní skříň v zádveří',10.2,7.13,1.90,.60,2.35,'py');
|
||||
cabinet(storage,'Botník',10.20,4.98,1.90,.44,.48,'py',m.oakLight);
|
||||
block(storage,'Zrcadlo v zádveří',10.20,4.76,1.20,.012,1.25,.92,m.mirror);
|
||||
for(const py of [11.406,13.125])cabinet(storage,'Šatní skříň u ložnice',6.68,py,2.55,.60,2.35,'py');
|
||||
|
||||
const bedrooms=group(parent,'Pokoje a ložnice');
|
||||
bed(bedrooms,'Lůžko pokoje u zahrady',6.96,7.53,1.95,1.0,'px');
|
||||
cabinet(bedrooms,'Šatní skříň pokoje',6.65,10.65,2.50,.65,2.35,'py');
|
||||
table(bedrooms,'Pracovní stůl u zahrady',4.7,10.64,1.15,.62);chair(bedrooms,4.7,9.85,Math.PI/2);
|
||||
bed(bedrooms,'Lůžko pokoje u příjezdu',12.25,16.08,.92,2.0,'py');
|
||||
cabinet(bedrooms,'Šatní skříň pokoje u příjezdu',9.85,15.8,.60,2.50,2.35,'px');
|
||||
table(bedrooms,'Pracovní stůl u příjezdu',11.7,12.75,1.45,.65);chair(bedrooms,11.7,13.55,-Math.PI/2);
|
||||
bed(bedrooms,'Manželská postel',5.95,16.06,1.80,2.05,'py');
|
||||
for(const px of [4.77,7.13])cabinet(bedrooms,'Noční stolek',px,16.75,.48,.50,.46,'py',m.oakLight);
|
||||
|
||||
const bath=group(parent,'Sanita – společná koupelna');
|
||||
shower(bath,12.005,10.08,1.50,1.0);
|
||||
// Quarter-round corner tub, bounded by the 1600 x 1600 footprint in the PDF.
|
||||
const tubShape=new THREE.Shape();tubShape.moveTo(0,0);tubShape.lineTo(1.6,0);tubShape.absarc(0,0,1.6,0,Math.PI/2,false);tubShape.lineTo(0,0);
|
||||
const tubGeometry=new THREE.ExtrudeGeometry(tubShape,{depth:.53,bevelEnabled:true,bevelSize:.025,bevelThickness:.025,bevelSegments:2,steps:1,curveSegments:24});
|
||||
tubGeometry.rotateX(-Math.PI/2);
|
||||
const tub=mesh(tubGeometry,m.white,bath,'Rohová vana 1600');const [tx,tz]=planPoint(12.73,12.26);tub.position.set(tx,.055,tz);
|
||||
// Local x increases towards room, local -z towards garden; oval basin is schematic.
|
||||
oval(bath,'Vana – vnitřní mísa',12.05,11.53,1.15,.83,.035,.62,m.water);
|
||||
collide(12.0,11.5,1.55,1.55);
|
||||
sink(bath,'Dvojumyvadlo',10.65,12.01,1.25,.50);
|
||||
block(bath,'Zrcadlo',10.65,12.27,1.28,.012,.82,1.08,m.mirror);wc(bath,10.0,9.91,'py');
|
||||
const ensuite=group(parent,'Sanita – koupelna u ložnice');
|
||||
shower(ensuite,8.60,16.60,1.55,1.0);wc(ensuite,9.06,13.95,'px');
|
||||
sink(ensuite,'Dvojumyvadlo u ložnice',9.15,15.30,.48,1.35);
|
||||
block(ensuite,'Zrcadlo',9.41,15.30,.012,1.40,.80,1.1,m.mirror);
|
||||
for(let i=0;i<7;i++)block(ensuite,'Topný žebřík',7.83,15.35,.04,.55,.025,.60+i*.10,m.white);
|
||||
|
||||
const technical=group(parent,'Technické vybavení – orientační');
|
||||
cabinet(technical,'Rekuperace / tepelné čerpadlo',12.10,7.15,1.0,.60,1.9,'py',m.white);
|
||||
for(let i=0;i<2;i++){
|
||||
block(technical,i?'Sušička':'Pračka',12.39,9.13,.62,.62,.82,.04+i*.84,m.white);
|
||||
const o=cylinder(technical,'Kruhová dvířka spotřebiče',12.39,8.805,.20,.025,.44+i*.84,m.dark);o.rotation.x=Math.PI/2;o.position.y=.44+i*.84;
|
||||
const [x,z]=planPoint(12.39,8.80);o.position.x=x;o.position.z=z;
|
||||
}
|
||||
collide(12.39,9.13,.62,.62);
|
||||
cabinet(technical,'Mrazák',11.65,9.13,.63,.62,1.45,'py',m.white);
|
||||
block(technical,'Elektroměnič a baterie',10.20,7.72,.65,.30,1.1,.6,m.white);
|
||||
collide(10.20,7.72,.65,.30);
|
||||
return {colliders,materials:m};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Plan coordinates in metres: px garden -> road; py west -> east.
|
||||
// Areas are the printed PDF values; names (except zádveří) follow the drawn fixtures.
|
||||
export const rooms = [
|
||||
{id:'living',name:'Obývací pokoj + kuchyň',short:'Obývák + kuchyň',area:49.88,polygon:[[.295,.295],[7.955,.295],[7.955,6.791],[.295,6.791]],label:[4.0,3.7],spawn:[3.7,3.7],look:[1.2,1.8],floor:'wood'},
|
||||
{id:'spare',name:'Místnost bez určení',short:'Místnost',area:7.05,polygon:[[8.1,.295],[11.255,.295],[11.255,2.531],[8.1,2.531]],label:[9.7,1.4],spawn:[9.7,1.4],look:[8.5,2.3],floor:'tile'},
|
||||
{id:'wardrobe',name:'Šatna',short:'Šatna',area:6.15,polygon:[[8.1,2.656],[11.255,2.656],[11.255,4.605],[8.1,4.605]],label:[9.7,3.63],spawn:[8.8,3.65],look:[10.5,3.65],floor:'wood'},
|
||||
{id:'entry',name:'Zádveří',short:'Zádveří',area:8.58,polygon:[[8.1,4.75],[11.255,4.75],[11.255,7.432],[8.1,7.432]],label:[9.8,6.1],spawn:[10.3,6.1],look:[8.3,5.8],floor:'tile'},
|
||||
{id:'hall',name:'Chodba',short:'Chodba',area:7.59,polygon:[[8.1,7.557],[9.421,7.557],[9.421,13.406],[8.1,13.406]],label:[8.76,10.5],spawn:[8.76,8.15],look:[8.76,12.6],floor:'wood'},
|
||||
{id:'technical',name:'Technická místnost',short:'Technická',area:7.10,polygon:[[9.546,7.557],[11.38,7.557],[11.38,6.821],[12.755,6.821],[12.755,9.456],[9.546,9.456]],label:[11.15,8.35],spawn:[10.5,8.4],look:[12.3,8.1],floor:'tile'},
|
||||
{id:'bath',name:'Koupelna',short:'Koupelna',area:8.66,polygon:[[9.546,9.581],[12.755,9.581],[12.755,12.282],[9.546,12.282]],label:[10.6,10.9],spawn:[10.25,10.95],look:[12.15,11.0],floor:'tile'},
|
||||
{id:'front',name:'Pokoj u příjezdu',short:'Pokoj',area:15.08,polygon:[[9.546,12.407],[12.755,12.407],[12.755,17.105],[9.546,17.105]],label:[11.0,14.6],spawn:[10.9,14.3],look:[12.0,16.0],floor:'wood'},
|
||||
{id:'garden',name:'Pokoj u zahrady',short:'Pokoj',area:15.62,polygon:[[4.045,7.015],[7.955,7.015],[7.955,10.981],[4.045,10.981]],label:[5.7,8.8],spawn:[5.4,8.7],look:[7.0,7.5],floor:'wood'},
|
||||
{id:'master',name:'Ložnice se šatnou',short:'Ložnice + šatna',area:22.35,polygon:[[4.045,11.106],[7.955,11.106],[7.955,13.425],[5.344,13.425],[5.344,13.55],[7.673,13.55],[7.673,17.105],[4.045,17.105]],label:[4.65,14.2],spawn:[4.6,14.5],look:[5.95,16.2],floor:'wood'},
|
||||
{id:'ensuite',name:'Koupelna u ložnice',short:'Koupelna',area:5.68,polygon:[[7.798,13.55],[9.421,13.55],[9.421,17.105],[7.798,17.105]],label:[8.6,15.2],spawn:[8.35,14.55],look:[8.6,16.65],floor:'tile'}
|
||||
];
|
||||
|
||||
// axis=px: fixed px, runs along py; axis=py: fixed py, runs along px.
|
||||
export const partitions = [
|
||||
['px',8.0275,.295,13.425,.145,[[5.19,6.09],[9.257,10.156],[11.805,12.708]]],
|
||||
['py',2.5935,8.1,11.255,.125,[[8.349,9.248]]],
|
||||
['py',4.6775,8.1,11.255,.145,[[8.349,9.248]]],
|
||||
['py',7.4945,8.1,11.255,.125,[[8.349,9.248]]],
|
||||
['py',6.903,3.775,7.955,.224,[]],
|
||||
['py',11.0435,4.045,7.955,.125,[]],
|
||||
['px',9.4835,7.557,13.406,.125,[[8.006,8.905],[10.656,11.457],[12.457,13.357]]],
|
||||
['py',9.5185,9.546,12.755,.125,[]],
|
||||
['py',12.3445,9.546,12.755,.125,[]],
|
||||
['py',13.4785,8.1,9.421,.145,[]],
|
||||
['py',13.4875,5.344,7.955,.125,[]],
|
||||
['px',7.7355,13.55,17.105,.125,[[14.152,14.953]]],
|
||||
['px',9.4835,13.406,17.105,.125,[]],
|
||||
['py',10.63,11.255,12.755,.10,[]]
|
||||
];
|
||||
|
||||
export function buildInterior({THREE,houseGroup,planPoint,box,mesh,group}) {
|
||||
const root=group(houseGroup,'Interiér podle půdorysu');
|
||||
const floors=group(root,'Podlahy místností'),fullWalls=group(root,'Příčky – plná výška'),cutWalls=group(root,'Příčky – řez 1,12 m');
|
||||
const ceiling=group(root,'Podhled +2,600 m'),furniture=group(root,'Orientační vybavení');
|
||||
const height=2.6,cutHeight=1.12,doorHead=2.1;
|
||||
const wallMaterial=new THREE.MeshStandardMaterial({color:0xeeeae1,roughness:.92});
|
||||
const wood=new THREE.MeshStandardMaterial({color:0xc7ac87,roughness:.85});
|
||||
const tile=new THREE.MeshStandardMaterial({color:0xc8c6be,roughness:.83});
|
||||
const trim=new THREE.MeshStandardMaterial({color:0xe4d5bb,roughness:.78});
|
||||
const grout=new THREE.LineBasicMaterial({color:0x8c8373,transparent:true,opacity:.16});
|
||||
const wallColliders=[];
|
||||
function horizontal(parent,name,polygon,y,material,down=false) {
|
||||
const points=polygon.map(p=>planPoint(...p));
|
||||
const triangles=THREE.ShapeUtils.triangulateShape(points.map(([x,z])=>new THREE.Vector2(x,z)),[]);
|
||||
const geom=new THREE.BufferGeometry();
|
||||
geom.setAttribute('position',new THREE.Float32BufferAttribute(points.flatMap(([x,z])=>[x,y,z]),3));
|
||||
const indices=triangles.flatMap(([a,b,c])=>{
|
||||
const A=points[a],B=points[b],C=points[c];
|
||||
const normalY=(B[1]-A[1])*(C[0]-A[0])-(B[0]-A[0])*(C[1]-A[1]);
|
||||
return (normalY>0)!==down?[a,b,c]:[c,b,a];
|
||||
});
|
||||
geom.setIndex(indices);geom.computeVertexNormals();
|
||||
const m=mesh(geom,material,parent,name);m.castShadow=false;return m;
|
||||
}
|
||||
// A continuous surface also fills thresholds, so doors have no visible cracks.
|
||||
const outline=[[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]];
|
||||
horizontal(floors,'Podlaha a prahy',outline,.004,tile);
|
||||
horizontal(ceiling,'Souvislý rovný podhled',outline,height,new THREE.MeshStandardMaterial({color:0xf5f3eb,roughness:1,side:THREE.DoubleSide}),true);
|
||||
for(const room of rooms) {
|
||||
const m=horizontal(floors,`${room.name} · ${room.area.toFixed(2)} m²`,room.polygon,.01,room.floor==='wood'?wood:tile);
|
||||
m.userData.roomId=room.id;
|
||||
// Thin joints: plank rhythm in dry rooms, large tiles in service rooms.
|
||||
const loX=Math.min(...room.polygon.map(p=>p[0])),hiX=Math.max(...room.polygon.map(p=>p[0]));
|
||||
const loY=Math.min(...room.polygon.map(p=>p[1])),hiY=Math.max(...room.polygon.map(p=>p[1]));
|
||||
const pts=[];
|
||||
const inside=(px,py)=>pointInPolygon(room.polygon,px,py);
|
||||
function joint(a,b) { const [x,z]=planPoint(...a),[xx,zz]=planPoint(...b);pts.push(x,.012,z,xx,.012,zz); }
|
||||
for(let py=Math.ceil(loY/(room.floor==='wood'?.20:.6))*(room.floor==='wood'?.20:.6);py<hiY;py+=room.floor==='wood'?.20:.6) {
|
||||
let start=null;
|
||||
for(let px=loX+.002;px<=hiX+.03;px+=.025) {
|
||||
const ok=px<hiX&&inside(px,py);
|
||||
if(ok&&start===null)start=px;
|
||||
if(!ok&&start!==null){joint([start,py],[Math.min(px,hiX-.002),py]);start=null;}
|
||||
}
|
||||
}
|
||||
if(room.floor==='tile')for(let px=Math.ceil(loX/.6)*.6;px<hiX;px+=.6){
|
||||
let start=null;for(let py=loY+.002;py<=hiY+.03;py+=.025){
|
||||
const ok=py<hiY&&inside(px,py);if(ok&&start===null)start=py;
|
||||
if(!ok&&start!==null){joint([px,start],[px,Math.min(py,hiY-.002)]);start=null;}
|
||||
}
|
||||
}
|
||||
const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.Float32BufferAttribute(pts,3));
|
||||
const joints=new THREE.LineSegments(geometry,grout);joints.name='Spáry podlahy';floors.add(joints);
|
||||
}
|
||||
function wallBox(parent,axis,fixed,start,end,thickness,bottom,top,material,name) {
|
||||
const px=axis==='px'?fixed:(start+end)/2,py=axis==='py'?fixed:(start+end)/2;
|
||||
const [x,z]=planPoint(px,py);
|
||||
return box(parent,name,x,(bottom+top)/2,z,axis==='px'?end-start:thickness,top-bottom,axis==='px'?thickness:end-start,material);
|
||||
}
|
||||
for(const [axis,fixed,start,end,thickness,holes] of partitions) {
|
||||
let cursor=start;
|
||||
const solid=(a,b)=>{
|
||||
if(b-a<.001)return;
|
||||
const m=wallBox(fullWalls,axis,fixed,a,b,thickness,0,height,wallMaterial,'Vnitřní příčka');
|
||||
wallBox(cutWalls,axis,fixed,a,b,thickness,0,cutHeight,wallMaterial,'Vnitřní příčka v řezu');
|
||||
const sx=axis==='px'?b-a:thickness,sz=axis==='px'?thickness:b-a;
|
||||
wallColliders.push({minX:m.position.x-sx/2,maxX:m.position.x+sx/2,minZ:m.position.z-sz/2,maxZ:m.position.z+sz/2});
|
||||
for(const parent of [fullWalls,cutWalls])wallBox(parent,axis,fixed,a,b,thickness+.016,.015,.09,trim,'Soklová lišta');
|
||||
};
|
||||
for(const [a,b] of holes){
|
||||
solid(cursor,a);
|
||||
wallBox(fullWalls,axis,fixed,a,b,thickness,doorHead,height,wallMaterial,'Nadpraží vnitřních dveří');
|
||||
for(const parent of [fullWalls,cutWalls])for(const edge of [a,b])wallBox(parent,axis,fixed,edge-.018,edge+.018,thickness+.035,.01,parent===fullWalls?doorHead:cutHeight,trim,'Obložková zárubeň');
|
||||
wallBox(fullWalls,axis,fixed,a-.018,b+.018,thickness+.035,doorHead-.04,doorHead,trim,'Horní zárubeň');
|
||||
cursor=b;
|
||||
}
|
||||
solid(cursor,end);
|
||||
}
|
||||
// Attic access as drawn in vestibule; a flush hatch, not a freestanding object.
|
||||
const [hx,hz]=planPoint(8.9,6.3);
|
||||
box(ceiling,'Půdní výlez 1000 × 700',hx,2.586,hz,.70,.018,1.0,trim);
|
||||
fullWalls.visible=false;ceiling.visible=false;root.visible=false;
|
||||
return {root,floors,fullWalls,cutWalls,ceiling,furniture,rooms,wallColliders,wallMaterial,cutHeight,height};
|
||||
}
|
||||
|
||||
export function pointInPolygon(polygon,x,z) {
|
||||
let inside=false;
|
||||
for(let i=0,j=polygon.length-1;i<polygon.length;j=i++){
|
||||
const a=polygon[i],b=polygon[j];
|
||||
if((a[1]>z)!==(b[1]>z)&&x<(b[0]-a[0])*(z-a[1])/(b[1]-a[1])+a[0])inside=!inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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};
|
||||
}
|
||||
+42
-33
@@ -1,3 +1,4 @@
|
||||
import { installInterior } from './interior-view.js';
|
||||
let THREE, OrbitControls, GLTFExporter, solarPosition;
|
||||
try {
|
||||
const modules = await Promise.all([
|
||||
@@ -14,7 +15,7 @@ try {
|
||||
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 zkontroluj připojení k CDN Three.js.</small>`;
|
||||
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;
|
||||
}
|
||||
@@ -43,7 +44,7 @@ const solar = {
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color('#e7ebe3');
|
||||
const camera = new THREE.PerspectiveCamera(36, 1, 0.1, 220);
|
||||
let camera = new THREE.PerspectiveCamera(36, 1, 0.1, 220);
|
||||
camera.position.set(28, 21, 36);
|
||||
|
||||
let renderer;
|
||||
@@ -100,6 +101,11 @@ const boundaryGroup = new THREE.Group(); boundaryGroup.name = 'Orientační hran
|
||||
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, 8.4); 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;
|
||||
|
||||
const mats = {
|
||||
grass: new THREE.MeshStandardMaterial({ color: 0x9ba78f, roughness: 1 }),
|
||||
road: new THREE.MeshStandardMaterial({ color: 0x676d6b, roughness: 0.98 }),
|
||||
@@ -301,7 +307,7 @@ function makeWallEdge(p1, p2, openings = []) {
|
||||
const wallSegment = (from, to, bottom, top, name = 'Obvodová stěna') => {
|
||||
if (to - from < 0.02 || top - bottom < 0.02) return;
|
||||
const [x, z] = positionAt((from + to) / 2);
|
||||
box(houseGroup, name, x, (bottom + top) / 2, z, to - from, top - bottom, thickness, mats.wall, angle);
|
||||
box(shellGroup, name, x, (bottom + top) / 2, z, to - from, top - bottom, thickness, mats.wall, angle);
|
||||
};
|
||||
const ordered = [...openings].sort((a, b) => a.center - b.center);
|
||||
let cursor = 0;
|
||||
@@ -317,21 +323,21 @@ function makeWallEdge(p1, p2, openings = []) {
|
||||
const centerY = opening.bottom + (top - opening.bottom) / 2;
|
||||
const paneMaterial = opening.kind === 'door' ? mats.door : mats.glass;
|
||||
const paneName = opening.kind === 'door' ? 'Vstupní dveře – zjednodušené' : opening.kind === 'glazedDoor' ? 'Prosklené dveře – zjednodušené' : 'Okno – zjednodušené';
|
||||
box(houseGroup, paneName, x, centerY, z, right - left - 0.10, Math.max(0.12, top - opening.bottom - 0.10), 0.055, paneMaterial, angle);
|
||||
box(shellGroup, paneName, x, centerY, z, right - left - 0.10, Math.max(0.12, top - opening.bottom - 0.10), 0.055, paneMaterial, angle);
|
||||
const frameWidth = 0.055;
|
||||
const jambHeight = top - opening.bottom;
|
||||
for (const offset of [-1, 1]) {
|
||||
const [jx, jz] = positionAt((left + right) / 2 + offset * (right - left - frameWidth) / 2);
|
||||
box(houseGroup, 'Rám otvoru', jx, opening.bottom + jambHeight / 2, jz, frameWidth, jambHeight, 0.09, mats.frame, angle);
|
||||
box(shellGroup, 'Rám otvoru', jx, opening.bottom + jambHeight / 2, jz, frameWidth, jambHeight, 0.09, mats.frame, angle);
|
||||
}
|
||||
for (let pane = 1; pane < (opening.panes || 1); pane++) {
|
||||
const offset = (pane / opening.panes - 0.5) * (right - left);
|
||||
const mullionX = x + dx / length * offset;
|
||||
const mullionZ = z + dz / length * offset;
|
||||
box(houseGroup, 'Svislý rám okna', mullionX, opening.bottom + jambHeight / 2, mullionZ, frameWidth, jambHeight, 0.09, mats.frame, angle);
|
||||
box(shellGroup, 'Svislý rám okna', mullionX, opening.bottom + jambHeight / 2, mullionZ, frameWidth, jambHeight, 0.09, mats.frame, angle);
|
||||
}
|
||||
box(houseGroup, 'Horní rám otvoru', x, top - frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
|
||||
if (opening.bottom > 0) box(houseGroup, 'Parapet okna', x, opening.bottom + frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
|
||||
box(shellGroup, 'Horní rám otvoru', x, top - frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
|
||||
if (opening.bottom > 0) box(shellGroup, 'Parapet okna', x, opening.bottom + frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
|
||||
cursor = right;
|
||||
}
|
||||
wallSegment(cursor, length, wallBottom, height);
|
||||
@@ -364,7 +370,7 @@ function roofFace(name, outline, distance) {
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setIndex(triangles.flat());
|
||||
geometry.computeVertexNormals();
|
||||
return mesh(geometry, mats.roof, houseGroup, name);
|
||||
return mesh(geometry, mats.roof, roofGroup, name);
|
||||
}
|
||||
|
||||
function buildRoof() {
|
||||
@@ -389,21 +395,21 @@ function buildRoof() {
|
||||
.map(([px, py]) => planPoint(px, py));
|
||||
const soffitGeometry = new THREE.ShapeGeometry(new THREE.Shape(eaves.map(([x, z]) => new THREE.Vector2(x, z))));
|
||||
soffitGeometry.rotateX(Math.PI / 2);
|
||||
const soffit = mesh(soffitGeometry, mats.soffit, houseGroup, 'Podbití střechy');
|
||||
const soffit = mesh(soffitGeometry, mats.soffit, roofGroup, 'Podbití střechy');
|
||||
soffit.position.y = SOFFIT;
|
||||
soffit.castShadow = false;
|
||||
for (let i = 0; i < eaves.length; i++) {
|
||||
const [ax, az] = eaves[i], [bx, bz] = eaves[(i + 1) % eaves.length];
|
||||
box(houseGroup, 'Čelní prkno a okap', (ax + bx) / 2, (SOFFIT + EAVE) / 2, (az + bz) / 2,
|
||||
box(roofGroup, 'Čelní prkno a okap', (ax + bx) / 2, (SOFFIT + EAVE) / 2, (az + bz) / 2,
|
||||
Math.hypot(bx - ax, bz - az) + 0.06, EAVE - SOFFIT + 0.04, 0.06, mats.frame, Math.atan2(-(bz - az), bx - ax));
|
||||
}
|
||||
|
||||
// The chimney stands over the stove flue (plan 7.58 / 4.76); its top at +6.500 is 1.255 m above the roof on the low side.
|
||||
const [chimneyX, chimneyZ] = planPoint(7.582, 4.763);
|
||||
const roofAt = (x, z) => roofHeight(z + 6.525, 8.7 - x);
|
||||
slopedBox(houseGroup, 'Oplechování komína', chimneyX, chimneyZ, 0.5, 0.5, roofAt, (x, z) => roofAt(x, z) + 0.06, mats.metal);
|
||||
slopedBox(houseGroup, 'Komín', chimneyX, chimneyZ, 0.36, 0.36, (x, z) => roofAt(x, z) + 0.04, () => 6.38, mats.foundation);
|
||||
box(houseGroup, 'Hlavice komína', chimneyX, 6.44, chimneyZ, 0.46, 0.12, 0.46, mats.frame);
|
||||
slopedBox(roofGroup, 'Oplechování komína', chimneyX, chimneyZ, 0.5, 0.5, roofAt, (x, z) => roofAt(x, z) + 0.06, mats.metal);
|
||||
slopedBox(roofGroup, 'Komín', chimneyX, chimneyZ, 0.36, 0.36, (x, z) => roofAt(x, z) + 0.04, () => 6.38, mats.foundation);
|
||||
box(roofGroup, 'Hlavice komína', chimneyX, 6.44, chimneyZ, 0.46, 0.12, 0.46, mats.frame);
|
||||
}
|
||||
|
||||
function timberPost(parent, px, py, base) {
|
||||
@@ -412,7 +418,7 @@ function timberPost(parent, px, py, base) {
|
||||
}
|
||||
function timberBeam(parent, from, to) {
|
||||
const [ax, az] = planPoint(...from), [bx, bz] = planPoint(...to);
|
||||
box(parent, 'Trám', (ax + bx) / 2, SOFFIT - 0.1, (az + bz) / 2, Math.hypot(bx - ax, bz - az), 0.2, 0.14, mats.timber, Math.atan2(-(bz - az), bx - ax));
|
||||
box(roofTimber, 'Trám', (ax + bx) / 2, SOFFIT - 0.1, (az + bz) / 2, Math.hypot(bx - ax, bz - az), 0.2, 0.14, mats.timber, Math.atan2(-(bz - az), bx - ax));
|
||||
}
|
||||
|
||||
function buildHouse() {
|
||||
@@ -460,18 +466,8 @@ buildGround();
|
||||
buildHouse();
|
||||
|
||||
function viewportSize() {
|
||||
let w = window.innerWidth, h = window.innerHeight;
|
||||
const infoOpen = document.body.classList.contains('info-open');
|
||||
const sunOpen = document.body.classList.contains('sun-open');
|
||||
if (w <= 850 && (infoOpen || sunOpen)) {
|
||||
const panelHeight = Math.min((infoOpen ? 0.72 : 0.68) * h, 570);
|
||||
h = Math.max(120, h - panelHeight - 16);
|
||||
} else if (sunOpen) {
|
||||
w = Math.max(240, w - 380);
|
||||
} else if (infoOpen) {
|
||||
w = Math.max(240, w - 390);
|
||||
}
|
||||
return [w, h];
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return [Math.max(1, rect.width), Math.max(1, rect.height)];
|
||||
}
|
||||
function resize() {
|
||||
const [w, h] = viewportSize();
|
||||
@@ -479,6 +475,7 @@ function resize() {
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(Math.round(w), Math.round(h), false);
|
||||
interior?.resize();
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
@@ -503,6 +500,7 @@ const presets = {
|
||||
top: { position: [0.8, 90, -1], target: [0.8, 0.1, -1], label: 'Pozemek / shora' }
|
||||
};
|
||||
function setPreset(name) {
|
||||
if (interior?.enabled) interior.exit();
|
||||
const p = presets[name] || presets.entry;
|
||||
camera.position.fromArray(p.position);
|
||||
controls.target.fromArray(p.target);
|
||||
@@ -790,13 +788,20 @@ async function saveSunImage() {
|
||||
$('#sunSavePng').addEventListener('click', saveSunImage);
|
||||
$('#savePng').addEventListener('click', saveSunImage);
|
||||
|
||||
$('#saveGlb').addEventListener('click', () => {
|
||||
let exporting = false;
|
||||
function exportGlb() {
|
||||
if (exporting) return;
|
||||
exporting = true;
|
||||
const button = $('#saveGlb'); button.disabled = true;
|
||||
const exporter = new GLTFExporter();
|
||||
modelRoot.updateMatrixWorld(true);
|
||||
exporter.parse(modelRoot, (result) => {
|
||||
saveBlob(new Blob([result], { type: 'model/gltf-binary' }), 'klabava-pracovni-model.glb');
|
||||
saveBlob(new Blob([result], { type: 'model/gltf-binary' }), interior?.enabled ? 'klabava-interier.glb' : 'klabava-pracovni-model.glb');
|
||||
toast('Model Klabava GLB je připravený ke stažení.');
|
||||
}, (error) => { console.error(error); toast('Model se nepodařilo exportovat.'); }, { binary: true, onlyVisible: true, trs: false });
|
||||
});
|
||||
exporting = false; button.disabled = false;
|
||||
}, (error) => { 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');
|
||||
@@ -850,7 +855,8 @@ function animate(now = 0) {
|
||||
const dt = Math.min(0.1, Math.max(0, (now - (animate.last || now)) / 1000));
|
||||
animate.last = now;
|
||||
solarFrame(dt);
|
||||
controls.update();
|
||||
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)`);
|
||||
@@ -859,10 +865,12 @@ function animate(now = 0) {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -878,6 +886,7 @@ window.SUN_SIM = {
|
||||
};
|
||||
|
||||
try {
|
||||
interior = installInterior({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);
|
||||
@@ -887,6 +896,6 @@ try {
|
||||
} 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 zkontroluj připojení k CDN Three.js.</small>`;
|
||||
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é';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user