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:
BIN
Binary file not shown.
+31
-3
@@ -4,13 +4,13 @@ Tato složka obsahuje samostatný web s otočným modelem domu a pozemku, simula
|
||||
|
||||
## Spuštění na počítači
|
||||
|
||||
Web používá ES moduly a Three.js z připnuté verze CDN jsDelivr. Spusťte místní server z této složky:
|
||||
Web používá ES moduly a přibalené Three.js 0.180.0 ve složce `vendor/`. Spusťte místní server z této složky:
|
||||
|
||||
```sh
|
||||
python3 -m http.server 8000
|
||||
```
|
||||
|
||||
Potom otevřete <http://localhost:8000>. Prohlížeč potřebuje WebGL a připojení k CDN Three.js. Při nasazení na hosting nahrajte celý obsah složky `klabava/`; soubory `index.html`, `css/`, `js/`, `data/` a `references/` musí zůstat pohromadě. `index.php` je vstup pro PHP hosting.
|
||||
Potom otevřete <http://localhost:8000>. Prohlížeč potřebuje WebGL. Knihovny se načítají lokálně, takže připojení k CDN není potřeba. Samotný soubor `index.html` neotvírejte přes `file://`; použijte místní server nebo hosting. Při nasazení na hosting nahrajte celý obsah složky `klabava/`; soubory `index.html`, `css/`, `js/`, `data/`, `vendor/` a `references/` musí zůstat pohromadě. `index.php` je vstup pro PHP hosting.
|
||||
|
||||
## Ovládání
|
||||
|
||||
@@ -26,7 +26,7 @@ Z půdorysu a pohledů vychází obrys domu 17,40 × 13,05 m, zastavěná plocha
|
||||
|
||||
Obrys domu, polohy a rozměry otvorů, střechy, komín a sloupky terasy a vstupu vycházejí z kót a vektorů půdorysu, pohledů a řezu. Terén je rovina proložená 27 bodovými výškami parcely (střední odchylka 4 cm); podlaha ±0,000 je 0,30 m nad terénem uprostřed domu. Rámy otvorů, nosná konstrukce terasy a vstupu, auta a materiály slouží k prostorové orientaci. Hranice parcely je převzatá graficky ze situačního PDF a výškopisu, nikoli z vytyčovacích souřadnic. Model není BIM ani podklad pro stavbu, zemní práce nebo vytyčení.
|
||||
|
||||
Soubor `data/model.json` uchovává hlavní údaje projektu. Dodané PDF jsou zkopírované do `references/` jako podklad modelu; web na ně neodkazuje. Barvy fasády, střešní krytiny, oken a vybavení nejsou zatím vybrané.
|
||||
Soubor `data/model.json` uchovává hlavní údaje projektu. Dodané PDF jsou zkopírované do `references/` jako podklad modelu; panel interiéru odkazuje na půdorys a řez. Barvy fasády, střešní krytiny, oken a vybavení nejsou zatím vybrané.
|
||||
|
||||
## Soubory
|
||||
|
||||
@@ -37,3 +37,31 @@ Soubor `data/model.json` uchovává hlavní údaje projektu. Dodané PDF jsou zk
|
||||
- `css/app.css` a `css/web.css` — vizuální základ z původního webu; `css/klabava.css` obsahuje úpravy pro Klabavu.
|
||||
- `data/model.json` — souhrn údajů z výkresů.
|
||||
- `references/` — výškopis, situace, půdorys, pohledy a řez.
|
||||
|
||||
## Nový interiér
|
||||
|
||||
Klikněte na **Prohlédnout interiér** (nebo klávesu I). Ovládání navazuje na Ločenici, dispozice je nově sestavená podle Klabavy.
|
||||
|
||||
- **Půdorys:** ortografický pohled shora s plochami místností.
|
||||
- **3D bez střechy:** otočný pohled se skutečným řezem stěn ve výšce 1,12 m.
|
||||
- **Procházet:** stěny a rovný podhled mají plnou výšku 2,60 m. Klikněte do modelu a použijte W/A/S/D, šipky nebo tlačítka na obrazovce. Tažením se rozhlížíte, Shift zrychlí chůzi. Kolečko mění zorný úhel.
|
||||
- Mapa, výběr místnosti a připravené pohledy přenesou kameru dovnitř. Průchody jsou volné; zárubně jsou zobrazené bez dveřních křídel.
|
||||
- Vybavení, popisky a přisvětlení lze přepnout. Barva mění vnitřní příčky; obvodový plášť a strop používají původní materiály.
|
||||
- Tlačítko ☷ skryje panel, ⌖ zarámuje dům nebo obnoví pohled. **Exteriér / Esc** vrací původní kameru, vrstvy okolí a otevřený panel. Simulace slunce se v interiéru pozastaví a při návratu obnoví původní stav přehrávání.
|
||||
- **PNG** ukládá aktuální pohled včetně popisků a stručné poznámky. **GLB** obsahuje právě viditelné vrstvy: v řezu skutečně zkrácené stěny, při procházení plnou výšku. Z exteriéru se exportuje celý dům včetně interiéru a viditelných vrstev pozemku. Světla a ovládání webu nejsou součástí GLB.
|
||||
|
||||
Dispozice obsahuje 11 místností o součtu ploch z výkresu 153,74 m²: obývací pokoj s kuchyní, místnost bez určení, šatnu, zádveří, chodbu, technickou místnost, společnou koupelnu, dva pokoje, ložnici se šatnou a koupelnu u ložnice. Názvy jsou odvozené ze zakresleného vybavení. Místnost 7,05 m² je ponechaná prázdná a přístupná přes šatnu 6,15 m².
|
||||
|
||||
Poloha a tloušťky příček, dveřních otvorů a uspořádání vybavení vycházejí z půdorysu. Světlá výška 2,60 m vychází z řezu; výška vnitřních otvorů 2,10 m je vizualizační předpoklad. Kuchyň do U s poloostrovem, sedačka, jídelní stůl, postele, skříně, sanita a technické vybavení jsou zjednodušené. Rozměry výrobků, jejich přesné provedení a materiály nejsou závazně navržené. Podhled zůstává rovný, podle dokumentace Klabavy.
|
||||
|
||||
Přisvětlení slouží ke čtení interiéru. Pohled bez střechy ani přisvětlený interiér nejsou výpočtem denního osvětlení.
|
||||
|
||||
### Nové soubory
|
||||
|
||||
- `js/interior-model.js`: místnosti, podlahy, příčky, dveřní otvory, strop a kolizní hranice.
|
||||
- `js/interior-furniture.js`: vybavení a jeho kolizní hranice.
|
||||
- `js/interior-view.js`: přepínání režimů, procházení, mapa, popisky a PNG.
|
||||
- `vendor/three/`: lokální Three.js, OrbitControls, GLTFExporter a licence MIT.
|
||||
- `tests/interior-checks.mjs`: geometrická kontrola a průchodnost ze zádveří do všech místností s vybavením i bez něj. Spuštění: `node tests/interior-checks.mjs` (Node.js 22+).
|
||||
|
||||
Kontrola: všechny místnosti jsou dostupné z chodby/zádveří, součet popsaných ploch odpovídá výkresu, geometrie má konečné souřadnice. V prohlížeči ověřeny režimy půdorys, 3D a chůze, mobilní rozložení, návrat do exteriéru a export PNG/GLB.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Three.js r180 / 0.180.0 is loaded from jsDelivr. It is not bundled here.
|
||||
Three.js r180 / 0.180.0 is bundled in vendor/three/.
|
||||
The MIT license is included at vendor/three/LICENSE.
|
||||
Source files were obtained from the version-pinned official npm distribution on jsDelivr.
|
||||
Source: https://github.com/mrdoob/three.js/tree/r180
|
||||
License: MIT, Copyright (c) 2010-2025 three.js authors.
|
||||
|
||||
|
||||
@@ -31,3 +31,38 @@ body.sun-open #view,body.sun-open #measure,body.sun-open #sunOverlay{width:calc(
|
||||
@media(max-width:850px){header{left:18px;top:17px}header h1{font-size:23px;margin:6px 0 4px}.metrics{display:none!important}.sub{font-size:10px}.topright{top:15px;right:12px;gap:5px}.topright .btn{padding:8px 10px;font-size:10px}.topright .btn.primary{padding:8px 10px}.bottom{max-width:calc(100% - 14px);bottom:13px;gap:6px}.toolbar{gap:1px;max-width:100%;overflow-x:auto;padding:5px;border-radius:12px}.toolbar .tool{padding:8px 8px;font-size:10px}.toolbar .divider{margin:0 2px;height:17px}.site-switch{padding:8px 7px;font-size:9px}.help{font-size:8px}.compass{right:13px;bottom:12px;width:62px;height:62px}.panel{left:8px;right:8px;top:auto;bottom:8px;width:auto;max-height:min(72dvh,570px);border-radius:15px;padding:17px}.panel h2{font-size:20px}.panel p{font-size:11px}#sunPanel{left:8px;right:8px;top:auto;bottom:8px;width:auto;max-height:min(68dvh,570px);padding:15px;border-radius:15px}.sun-panel-head{margin-bottom:10px}.sun-date-row{grid-template-columns:1fr 95px;gap:8px}.sun-field #sunClock{width:95px;font-size:18px}.sun-graph{height:70px}.sun-play-row{margin-top:8px}.sun-readouts{margin-top:12px;padding-top:12px}.sun-stat strong{font-size:21px}.sun-events{margin:10px 0 2px;padding:9px 0}body.info-open #view,body.info-open #measure,body.info-open #sunOverlay{width:100%;height:calc(100dvh - min(72dvh,570px) - 16px)}body.sun-open #view,body.sun-open #measure,body.sun-open #sunOverlay{width:100%;height:calc(100dvh - min(68dvh,570px) - 16px)}body.info-open .bottom{left:50%;bottom:calc(min(72dvh,570px) + 13px)}body.sun-open .bottom{left:50%;bottom:calc(min(68dvh,570px) + 13px)}body.info-open .compass,body.sun-open .compass{display:none}body.info-open header .sub,body.sun-open header .sub{display:none}#sunStamp{left:18px;top:87px;font-size:9px}#webStatus{left:10px;bottom:5px;font-size:7px}body.info-open .topright,body.sun-open .topright{z-index:8}}
|
||||
@media(max-width:520px){.topright .btn{padding:7px 8px}.topright #infoButton{font-size:0;width:33px;height:32px;position:relative}.topright #infoButton:after{content:'i';font-size:14px;font-weight:600}.topright #sunToggle .toggle-word{display:none}.topright #sunToggle{width:34px;justify-content:center;padding:0}.topright .btn.primary{font-size:9px}.bottom{max-width:calc(100% - 8px);bottom:8px}.toolbar .tool{padding:8px 6px;font-size:9px}.toolbar .site-switch{padding:7px 5px;font-size:8px}.toolbar #savePng{display:none}.toolbar{max-width:100vw}.help{display:none}#sunPanel{max-height:72dvh}.sun-hint{margin:5px 0 8px}.sun-advanced{margin-top:10px;padding-top:9px}}
|
||||
@media(max-width:390px){.topright{gap:3px}.topright .btn.primary{padding:7px}.toolbar .tool{padding:8px 5px}.toolbar .divider{display:none}.toolbar .site-switch{padding:6px 4px}.toolbar{gap:0}}
|
||||
|
||||
/* Interior shares the Ločenice controls; dimensions are specific to this plan. */
|
||||
body.interior-active #infoButton,body.interior-active #sunToggle{display:none}
|
||||
body.interior-active .topright{right:18px}
|
||||
body.interior-active #sunOverlay{display:none}
|
||||
body.interior-active #interiorPanel{z-index:5;width:308px;box-sizing:border-box}
|
||||
#interiorRoom{width:100%;border:1px solid #d6dfcf;border-radius:7px;padding:9px;color:#365944}
|
||||
#interiorPanel .wall-paint{justify-content:space-between}
|
||||
#interiorWallColor{width:42px;height:29px;border:1px solid #ccd6c6;border-radius:5px;padding:2px}
|
||||
.int-source-buttons a{font-size:10px;color:#365944;padding:6px;border:1px solid #d4ddca;border-radius:6px;text-decoration:none}
|
||||
#interiorMap .map-room:focus{outline:none;fill:#b5c7a5;stroke:#365944;stroke-width:2}
|
||||
body.interior-active #webStatus{display:none}
|
||||
body.interior-active #interiorLabels{overflow:hidden}
|
||||
@media(max-width:850px){
|
||||
#interiorLaunch{top:118px}
|
||||
body.interior-active .interior-heading{top:17px;left:14px;max-width:calc(100% - 130px)}
|
||||
body.interior-active .interior-heading .eyebrow{display:none}
|
||||
body.interior-active .interior-heading h2{font-size:14px;max-width:170px;line-height:1.2}
|
||||
body.interior-active #interiorPanel{left:0;right:0;top:auto;bottom:0;width:100%;height:242px;padding:15px 18px 24px;border-radius:18px 18px 0 0}
|
||||
body.interior-active:not(.interior-panel-closed) #view{width:100%;height:calc(100% - 242px)}
|
||||
body.interior-active.interior-panel-closed #view{width:100%;height:100%}
|
||||
body.interior-active #interiorTools{left:50%;bottom:250px}
|
||||
body.interior-active.interior-panel-closed #interiorTools{left:50%;bottom:17px}
|
||||
body.interior-walk #walkPad{bottom:304px}
|
||||
body.interior-walk.interior-panel-closed #walkPad{bottom:80px}
|
||||
#interiorMap{width:145px;float:left;margin-right:14px}
|
||||
.int-mode-bar button{padding:9px 8px}
|
||||
}
|
||||
@media(max-width:420px){
|
||||
body.interior-active .interior-heading h2{font-size:12px;max-width:112px}
|
||||
body.interior-active .interior-heading{gap:7px}
|
||||
body.interior-active .topright{right:10px}
|
||||
#interiorMap{width:130px}
|
||||
.int-room-tag{font-size:8px;padding:3px 4px}
|
||||
}
|
||||
|
||||
+18
-4
@@ -5,18 +5,32 @@
|
||||
"buildingFootprintM2": 178.75,
|
||||
"coveredEntryAndTerraceM2": 206.54,
|
||||
"usableFloorAreaM2": 153.74,
|
||||
"footprintEnvelopeM": [17.4, 13.05],
|
||||
"footprintEnvelopeM": [
|
||||
17.4,
|
||||
13.05
|
||||
],
|
||||
"eaveM": 3.046,
|
||||
"ridgeM": 5.832,
|
||||
"secondaryRidgeM": 5.275,
|
||||
"chimneyM": 6.5,
|
||||
"surveyGroundNearRoadBpv": 349.2,
|
||||
"surveyGroundRearBpv": 347.1,
|
||||
"latitude": 49.7520,
|
||||
"longitude": 13.5440,
|
||||
"latitude": 49.752,
|
||||
"longitude": 13.544,
|
||||
"latitudeLongitudeNote": "Střed parcely 66/17 ze souřadnicové sítě S-JTSK ve výškopisu, převedený do WGS84.",
|
||||
"siteBearingDeg": 1.5,
|
||||
"siteBearingNote": "Uliční strana domu míří 10,0° od severu sítě S-JTSK (severka situace); meridiánová konvergence je -8,5°, tedy 1,5° od zeměpisného severu.",
|
||||
"standards": null,
|
||||
"modelNote": "Vizualizační model podle PDF podkladů; vyšší valbová střecha kryje kuchyňské křídlo, terasu a krytý vstup, nižší křídlo s ložnicemi. Terén je rovina proložená bodovými výškami výškopisu."
|
||||
"modelNote": "Vizualizační model podle PDF podkladů; vyšší valbová střecha kryje kuchyňské křídlo, terasu a krytý vstup, nižší křídlo s ložnicemi. Terén je rovina proložená bodovými výškami výškopisu.",
|
||||
"interior": {
|
||||
"rooms": 11,
|
||||
"areaM2": 153.74,
|
||||
"ceilingHeightM": 2.6,
|
||||
"cutHeightM": 1.12,
|
||||
"doorHeadAssumedM": 2.1,
|
||||
"layoutSource": "references/Pudorys prizemi (1).pdf",
|
||||
"heightSource": "references/Rez.pdf",
|
||||
"furnishing": "Orientační podle rozmístění ve výkresu; materiály dočasné.",
|
||||
"roomNames": "Odvozené podle zakresleného vybavení. Místnost 7,05 m² bez určení."
|
||||
}
|
||||
}
|
||||
|
||||
+41
-6
@@ -6,11 +6,11 @@
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="theme-color" content="#e7ebe3">
|
||||
<title>Klabava | Rodinný dům</title>
|
||||
<link rel="stylesheet" href="./css/app.css?v=20261031e">
|
||||
<link rel="stylesheet" href="./css/web.css?v=20261031e">
|
||||
<link rel="stylesheet" href="./css/klabava.css?v=20261031e">
|
||||
<link rel="stylesheet" href="./css/app.css?v=20261102-interior">
|
||||
<link rel="stylesheet" href="./css/web.css?v=20261102-interior">
|
||||
<link rel="stylesheet" href="./css/klabava.css?v=20261102-interior">
|
||||
<script type="importmap">
|
||||
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"}}
|
||||
{"imports":{"three":"./vendor/three/build/three.module.js","three/addons/":"./vendor/three/examples/jsm/"}}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -47,7 +47,7 @@
|
||||
<div class="row"><span>Rozměry obálky v půdorysu</span><strong>17,40 × 13,05 m</strong></div>
|
||||
<div class="row"><span>Hřebeny / komín</span><strong>+5,275 a +5,832 / +6,500 m</strong></div>
|
||||
<h3>Vzhled a vybavení</h3>
|
||||
<p>Barvy fasády, krytiny, oken a další standardy zůstávají otevřené. Model používá neutrální dočasné materiály, aby bylo možné ověřit hmotu domu, umístění a stíny.</p>
|
||||
<p>Barvy fasády, krytiny, oken a další standardy zůstávají otevřené. Model používá neutrální dočasné materiály. Interiér nabízí dispozici a orientační vybavení podle půdorysu, pohled bez střechy i procházení.</p>
|
||||
<details class="site-note" open>
|
||||
<summary>Rozsah modelu a přesnost</summary>
|
||||
<p>Půdorysná hmota, poloha a rozměry otvorů a výšky střech vycházejí z kót půdorysu, pohledů a řezu. Střechu tvoří dvě valbové střechy se sklonem 23° a přesahem 0,6 m; vyšší kryje i terasu a krytý vstup, nižší křídlo s ložnicemi. Terén je rovina proložená zaměřenými body výškopisu, přibližně 349,2 m n. m. u komunikace a 347,1 m n. m. u zadní hranice.</p>
|
||||
@@ -107,10 +107,45 @@
|
||||
</details>
|
||||
<details class="sun-advanced"><summary>Metoda výpočtu a přesnost</summary><p>Sluneční poloha se počítá z data, času a zeměpisných souřadnic podle astronomických rovnic Meeus / NOAA. Model zobrazuje přímé sluneční světlo a geometrické stíny. Nezahrnuje oblačnost, odrazy okolních budov, profil horizontu ani skutečné vlastnosti zasklení.</p></details>
|
||||
</section>
|
||||
|
||||
<button id="interiorLaunch" type="button" aria-controls="interiorPanel" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M3 11 12 4 21 11M5 10v10h14V10M10 20v-7h5v7"/></svg>
|
||||
<span>Prohlédnout interiér<small>Půdorys · 3D bez střechy · procházení</small></span>
|
||||
</button>
|
||||
<div class="interior-heading"><button class="btn" id="interiorExit" type="button">← Exteriér</button><div><div class="eyebrow">KLABAVA / INTERIÉR PŘÍZEMÍ</div><h2 id="interiorViewTitle">Dům bez střechy</h2></div></div>
|
||||
<section id="interiorPanel" aria-label="Pohledy a nastavení interiéru">
|
||||
<div class="interior-panel-title"><h3>Uvnitř domu</h3><span>11 místností · 153,74 m²</span></div>
|
||||
<svg id="interiorMap" viewBox="0 0 348 270" role="group" aria-label="Mapa místností; výběrem vstoupíte dovnitř"></svg>
|
||||
<p class="int-map-hint">Vyberte místnost v mapě nebo připravený pohled.</p>
|
||||
<div class="int-kicker">Připravené pohledy</div>
|
||||
<div class="int-views"><button type="button" data-interior-view="kitchen">Kuchyň</button><button type="button" data-interior-view="living">Obývací pokoj</button><button type="button" data-interior-view="entry">Od vstupu</button><button type="button" data-interior-view="master">Ložnice</button></div>
|
||||
<div class="int-kicker">Přejít do místnosti</div>
|
||||
<select id="interiorRoom" aria-label="Přejít do vybrané místnosti"><option value="">Vyberte místnost…</option></select>
|
||||
<div class="int-options">
|
||||
<label><input type="checkbox" id="interiorFurniture" checked>Vybavení podle půdorysu</label>
|
||||
<label><input type="checkbox" id="interiorRoomLabels" checked>Názvy a plochy místností</label>
|
||||
<label><input type="checkbox" id="interiorFill" checked>Přisvětlení interiéru</label>
|
||||
<label class="wall-paint">Barva vnitřních příček <input id="interiorWallColor" type="color" value="#eeeae1" aria-label="Barva vnitřních příček"></label>
|
||||
</div>
|
||||
<p>V půdorysu a 3D pohledu je střecha skrytá a stěny zkrácené na 1,12 m. Při procházení mají stěny i rovný podhled výšku 2,60 m.</p>
|
||||
<details class="int-source"><summary>Podklady a přesnost</summary>
|
||||
<p>Dispozice, plochy, poloha příček a průchody vycházejí z dodaného půdorysu přízemí. Světlá výška 2,60 m je převzatá z řezu. Názvy místností jsou odvozené ze zakresleného vybavení.</p>
|
||||
<p>Místnost 7,05 m² nemá ve výkresu uvedený účel a zůstává prázdná. Vstupuje se do ní přes šatnu. Ložnice 22,35 m² zahrnuje vlastní šatnovou část.</p>
|
||||
<p>Nábytek, sanita, spotřebiče a materiály jsou orientační. Uspořádání kuchyně do U, lůžka, skříně a technické vybavení odpovídají schématu v půdorysu. Vnitřní dveře jsou zobrazené jako volné průchody se zárubněmi; výška otvoru 2,10 m je předpoklad.</p>
|
||||
<p>Přisvětlení pomáhá čitelnosti interiéru. Pohled bez střechy ani přisvětlený interiér neslouží k posouzení skutečného denního osvětlení.</p>
|
||||
<div class="int-source-buttons"><a href="./references/Pudorys%20prizemi%20(1).pdf" target="_blank" rel="noopener">Původní půdorys ↗</a><a href="./references/Rez.pdf" target="_blank" rel="noopener">Řez ↗</a></div>
|
||||
</details>
|
||||
<button class="int-export" id="interiorSavePng" type="button">Uložit aktuální pohled PNG</button>
|
||||
<button class="int-export" id="interiorSaveGlb" type="button">Stáhnout zobrazený model GLB</button>
|
||||
</section>
|
||||
<div id="interiorLabels" aria-label="Místnosti v půdorysu"></div>
|
||||
<div id="interiorTools"><div id="interiorHint">Tažením otáčejte · kolečkem přibližujte · výběrem místnosti vstupte dovnitř</div><nav class="int-mode-bar" aria-label="Režim interiéru"><button type="button" data-interior-mode="plan">Půdorys</button><button type="button" data-interior-mode="axon" class="active">3D bez střechy</button><button type="button" data-interior-mode="walk">Procházet</button><button id="interiorFit" type="button" class="int-nav-button" title="Zarámovat dům nebo obnovit pohled">⌖</button><button id="interiorPanelToggle" type="button" class="int-nav-button" title="Skrýt nebo zobrazit panel" aria-expanded="true" aria-controls="interiorPanel">☷</button></nav></div>
|
||||
<div id="walkPad" aria-label="Ovládání chůze"><button type="button" data-walk="forward" aria-label="Jít vpřed">↑</button><button type="button" data-walk="left" aria-label="Krok vlevo">←</button><button type="button" data-walk="back" aria-label="Jít vzad">↓</button><button type="button" data-walk="right" aria-label="Krok vpravo">→</button></div>
|
||||
|
||||
<div id="loading" role="status"><span class="loader-dot"></span><span>Načítám 3D model…</span></div>
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<div id="webStatus">Pracovní model · Klabava 66/17</div>
|
||||
|
||||
<script type="module" src="./js/klabava.js?v=20261031e"></script>
|
||||
<script type="module" src="./js/klabava.js?v=20261102-interior"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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é';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as THREE from '../vendor/three/build/three.module.js';
|
||||
import {buildInterior,pointInPolygon} from '../js/interior-model.js';
|
||||
import {furnishInterior} from '../js/interior-furniture.js';
|
||||
const houseGroup=new THREE.Group();
|
||||
const planPoint=(px,py)=>[8.7-py,px-6.525];
|
||||
const group=(parent,name)=>{const g=new THREE.Group();g.name=name;parent.add(g);return g;};
|
||||
const mesh=(geometry,material,parent,name)=>{const m=new THREE.Mesh(geometry,material);m.name=name;parent.add(m);return m;};
|
||||
const box=(parent,name,x,y,z,sx,sy,sz,material,rotationY=0)=>{const m=mesh(new THREE.BoxGeometry(sx,sy,sz),material,parent,name);m.position.set(x,y,z);m.rotation.y=rotationY;return m;};
|
||||
const ctx={THREE,houseGroup,planPoint,group,mesh,box};
|
||||
const data=buildInterior(ctx),furniture=furnishInterior({...ctx,parent:data.furniture});
|
||||
assert.equal(data.rooms.length,11);assert.ok(Math.abs(data.rooms.reduce((a,r)=>a+r.area,0)-153.74)<1e-6);
|
||||
const footprint=[[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 distance=(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,withFurniture){
|
||||
if(!pointInPolygon(footprint,x,z))return false;
|
||||
for(let i=0;i<footprint.length;i++)if(distance(x,z,footprint[i],footprint[(i+1)%footprint.length])<.455)return false;
|
||||
for(const b of [...data.wallColliders,...(withFurniture?furniture.colliders:[])])if(x>b.minX-.16&&x<b.maxX+.16&&z>b.minZ-.16&&z<b.maxZ+.16)return false;
|
||||
return true;
|
||||
}
|
||||
// Flood fill uses a person of radius 160 mm, not a point; all room spawns must be reachable from the foyer.
|
||||
for(const withFurniture of [false,true]){
|
||||
const step=.08,w=221,h=170,x0=-8.8,z0=-6.6,walkable=new Uint8Array(w*h),visited=new Uint8Array(w*h);
|
||||
for(let j=0;j<h;j++)for(let i=0;i<w;i++)walkable[j*w+i]=canStand(x0+i*step,z0+j*step,withFurniture)?1:0;
|
||||
const nearest=([x,z])=>Math.round((z-z0)/step)*w+Math.round((x-x0)/step);
|
||||
const first=nearest(planPoint(...data.rooms.find(r=>r.id==='entry').spawn));assert.equal(walkable[first],1);
|
||||
const q=[first];visited[first]=1;
|
||||
for(let n=0;n<q.length;n++){const v=q[n];for(const next of [v-1,v+1,v-w,v+w])if(next>=0&&next<w*h&&!visited[next]&&walkable[next]){visited[next]=1;q.push(next);}}
|
||||
for(const room of data.rooms){const p=planPoint(...room.spawn);assert.ok(canStand(...p,withFurniture),'Spawn collides: '+room.id);assert.equal(visited[nearest(p)],1,'Room unreachable from entry: '+room.id+' furniture='+withFurniture);}
|
||||
console.log('All 11 rooms reachable with furniture '+withFurniture+'; walkable cells '+q.length);
|
||||
}
|
||||
let count=0,triangles=0;houseGroup.traverse(o=>{if(!o.isMesh&&!o.isLineSegments)return;count++;const p=o.geometry.getAttribute('position');for(const n of p.array)assert.ok(Number.isFinite(n));if(o.isMesh)triangles+=(o.geometry.index?.count??p.count)/3;});
|
||||
const floor=data.floors.children.find(o=>o.isMesh);assert.ok(floor.geometry.getAttribute('normal').getY(0)>.99,'Floor must face up');
|
||||
const report={rooms:data.rooms.length,areaM2:153.74,objects:count,triangles,wallColliders:data.wallColliders.length,furnitureColliders:furniture.colliders.length,connectivity:'PASS in furnished and empty modes',geometry:'PASS finite coordinates and upward floor normals'};
|
||||
console.log(JSON.stringify(report,null,2));
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2010-2025 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+58773
File diff suppressed because one or more lines are too long
+18251
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Reference in New Issue
Block a user