TEST
continuous-integration/drone/push Build is passing

This commit is contained in:
martin.fencl
2026-10-22 00:46:52 +02:00
parent ed986e970e
commit f6e9d95f58
86 changed files with 6926 additions and 1 deletions
Vendored
BIN
View File
Binary file not shown.
+334
View File
@@ -0,0 +1,334 @@
{
"README_CZ.md": {
"bytes": 8392,
"sha256": "19e19ce4c58e8f5d052d930c78e0442dfa7bf62ed22dd483750c8a422a483ef7"
},
"TEST_REPORT.json": {
"bytes": 2154,
"sha256": "38d04a56f405d48a916cf8ec456d34d0fe997fd52b21d0d4f296cd1d9c25d436"
},
"THIRD_PARTY.txt": {
"bytes": 496,
"sha256": "0dab04f18aaefe62937d313eb9ff128cf279687da956ac1593e3d8387c196142"
},
"blender/prepare_model.py": {
"bytes": 3342,
"sha256": "0a461d270effa128a3c44a58bd96e7cf52b771b396a7793bd496f596c9854d1b"
},
"locenice/app.html": {
"bytes": 36841,
"sha256": "6e347d9245e3e072d4bb6bab7212464957fbc44ebb338827b8ce6a72c652cc15"
},
"locenice/assets/catalog/door0.jpg": {
"bytes": 1828,
"sha256": "9161b2f44a4a5118fa7256332efe8e393a8e6f964b03f5cf923ca15e2829d18e"
},
"locenice/assets/catalog/door1.jpg": {
"bytes": 1910,
"sha256": "036bce257db9690e0ae6d870c962d59d22854c585cb3849ace3bc3ea2e292cdb"
},
"locenice/assets/catalog/door2.jpg": {
"bytes": 1859,
"sha256": "b800bde543e2e3a658ad39b225fb2ab87e104c03a11cc24bef1b1febe9c3412c"
},
"locenice/assets/catalog/door3.jpg": {
"bytes": 2131,
"sha256": "6fb178efd1f827c39c44fa074fc13aadca327271b03539142a4345aee6a5447c"
},
"locenice/assets/catalog/door4.jpg": {
"bytes": 2209,
"sha256": "8168c67a52d57d6e30af0f9211ebbb5bd36330a810bfb6d9965c3526f951668e"
},
"locenice/assets/catalog/door5.jpg": {
"bytes": 2091,
"sha256": "6bb82a9208873c21e47d1df04391990c9678cbbd620679dad855cab43947224c"
},
"locenice/assets/catalog/door6.jpg": {
"bytes": 2183,
"sha256": "e23d18692d5a02173d24172c6928fe29fafd1e42d0b219fe283dbf2bd9eed5a1"
},
"locenice/assets/catalog/door7.jpg": {
"bytes": 2142,
"sha256": "27a3f4392ee3aef6584bd0074399088c99a1ac706b6965caec1b61f362875561"
},
"locenice/assets/catalog/intsill0.jpg": {
"bytes": 3997,
"sha256": "4d07213542ee0f2a53a10d8f22e00f0b874fdfca548a8f6d995ccb7060e513c1"
},
"locenice/assets/catalog/intsill1.jpg": {
"bytes": 3818,
"sha256": "f12e3283459b853d5418d96a151bb49681bda4d4e00ced1951c04eea22d34a5c"
},
"locenice/assets/catalog/intsill2.jpg": {
"bytes": 3721,
"sha256": "4308a8db2270f71c057ca4501cc1b08f71d47490e4ba7c37984012d5ff50689f"
},
"locenice/assets/catalog/lam1.jpg": {
"bytes": 2820,
"sha256": "e01b9ee523b114b7f02df037c828445d1afbff1313322fa60577a68bef710d24"
},
"locenice/assets/catalog/lam2.jpg": {
"bytes": 3011,
"sha256": "7f37991b35cc44e7eb8ce1f86b7679df8b2ac22f24deed7b110afd8f57ee1654"
},
"locenice/assets/catalog/lam3.jpg": {
"bytes": 2676,
"sha256": "d91daf17cf54063ce90bc54a809b273c36c8e14768b035454f561aef2b591d78"
},
"locenice/assets/catalog/lam4.jpg": {
"bytes": 2678,
"sha256": "8eab4cecb5083c1135b4f5186f34e19ebc54cb7aa2d5a1366475ba190287c703"
},
"locenice/assets/catalog/lam5.jpg": {
"bytes": 1817,
"sha256": "2d12cd34fa4c3e02c66b0db5bc7a11f35470f6f65e4a194dfbf4c48cf45e6a1d"
},
"locenice/assets/catalog/lam6.jpg": {
"bytes": 2468,
"sha256": "0c68c1a8273cb5545c195e20394b211ac48d4f77c12a9393492ec4a7b8af7167"
},
"locenice/assets/catalog/lam7.jpg": {
"bytes": 2496,
"sha256": "b080bf3ece148b3f210a61af8e8b7c5156bda5e8d4c1ee204122c0791dd91793"
},
"locenice/assets/catalog/lam8.jpg": {
"bytes": 3304,
"sha256": "13de01ed496dffec443527b174c91ba2b70682aa57f4f71a6abc8d93dea949bd"
},
"locenice/assets/catalog/lam9.jpg": {
"bytes": 2219,
"sha256": "0d0921cbd7c8d4ddd53c5ad60f9ce1d576588f6646feea0d3b3fd56d6b03d219"
},
"locenice/assets/catalog/page12.jpg": {
"bytes": 148801,
"sha256": "063672ea2626ee0e3935fe59ba3e790c8df71393766e042182192f7f029f2f56"
},
"locenice/assets/catalog/page13.jpg": {
"bytes": 127287,
"sha256": "108ac1321f652d4280f8855ac3c8cddc70d2533066730aca0eb6f4a20c458cce"
},
"locenice/assets/catalog/page14.jpg": {
"bytes": 146019,
"sha256": "cdcacfd61673e20f3b8c5de2225991d68b00aefac63b912d79f5d2819401d48e"
},
"locenice/assets/catalog/page15.jpg": {
"bytes": 117331,
"sha256": "c8f8f3874a0d4c21744299931cf2317cf2b0aabf9ac762060251e05dc3fe6da0"
},
"locenice/assets/catalog/page16.jpg": {
"bytes": 128511,
"sha256": "e1d4f27404554729a2d1ad718244b4941cf38e223b023648b4a573cfe10a5cbd"
},
"locenice/assets/catalog/page17.jpg": {
"bytes": 151989,
"sha256": "3dc907b795c22abf260398b9816acc11ab47b11e7b0f503df592ed0b66a5ba1a"
},
"locenice/assets/catalog/page18.jpg": {
"bytes": 125562,
"sha256": "c3a2304c8daa3a0f76417a4622ba8f565bad99950323cd74dff45b3dffa9e28d"
},
"locenice/assets/catalog/page19.jpg": {
"bytes": 119551,
"sha256": "6440a8c153f03e2270a8ffbc98edb54aa4636ab6e6bcc2e259cf1e44455d095c"
},
"locenice/assets/catalog/page20.jpg": {
"bytes": 106137,
"sha256": "b1aeaa729e4fb2d6ba784a861d6d101f9e675e77901d49ba86d7eb010b1960dc"
},
"locenice/assets/catalog/page21.jpg": {
"bytes": 91231,
"sha256": "3385abbcbfebc59edf42a631f671e5af7be9901b8944797ad4bb0b9a4ac0d634"
},
"locenice/assets/catalog/page22.jpg": {
"bytes": 94318,
"sha256": "206988c563e47b2263271878a7ac7efcd4b2bbd4a9615f1b7454a4fac9e4a449"
},
"locenice/assets/catalog/page23.jpg": {
"bytes": 129965,
"sha256": "e7d82787f8af877884c92e637cd829ba85eb8c508a6268054c12ac87bec3eea2"
},
"locenice/assets/catalog/page24.jpg": {
"bytes": 157070,
"sha256": "bff6ac7315c114fa69944696fec3df7057868f1ddf9a294ae55e13514833139c"
},
"locenice/assets/catalog/page25.jpg": {
"bytes": 137049,
"sha256": "00df86ed78e0aa8cc2389733d33bffd3fb5f2bf6cddacf95381d630ecc81f0ae"
},
"locenice/assets/catalog/prum.jpg": {
"bytes": 2656,
"sha256": "cc47e7ea76e63aeee82ad3d6c4fdd8c048f84539361afaf55473af914e146409"
},
"locenice/assets/catalog/requestedDoor.jpg": {
"bytes": 3790,
"sha256": "ba8ca3f723e3a0593615aa2a888df44ad872f7a9c6cc9f3eb6a4641865e3d43f"
},
"locenice/assets/catalog/roof0.jpg": {
"bytes": 4599,
"sha256": "984511ec7fd693452a9118e3b4bf6d7a9735c55762ca962cc0bc9e54c69d549f"
},
"locenice/assets/catalog/roof1.jpg": {
"bytes": 3969,
"sha256": "d27467601eba1ce2190ce8707f2371cc99372cfbe643747021b1830808c4ad5c"
},
"locenice/assets/catalog/roof2.jpg": {
"bytes": 4326,
"sha256": "b09ec26315ace228c62b978e708f4a461d68deb4a2f8dac405ce3b8af498e29e"
},
"locenice/assets/catalog/roof3.jpg": {
"bytes": 4106,
"sha256": "f743e6bcf4a0510986192e55a807cf13e70c5b93cdb5352dc50a00c5b8e3ad6b"
},
"locenice/assets/catalog/roof4.jpg": {
"bytes": 3961,
"sha256": "b0c034d4072c19696e4198963123e347a038b5f8346969f947f0dcec39b1cd9d"
},
"locenice/assets/catalog/tile1.jpg": {
"bytes": 1968,
"sha256": "5c786000fe2027ea5f5cf42a6976562a506dbc47099eb428865d38736e4006f2"
},
"locenice/assets/catalog/tile2.jpg": {
"bytes": 2829,
"sha256": "ff310d5fd6279233ba4bd21e17f16dcfd7bb5038bfb2f09971e3bed386b9ac64"
},
"locenice/assets/catalog/tile3.jpg": {
"bytes": 2566,
"sha256": "49f57228671e28141e96f3f02b43f420c5a42d6e930e42ff21e3baa08f4ca246"
},
"locenice/assets/catalog/tile4.jpg": {
"bytes": 2536,
"sha256": "54cbdfe7031c6da5b9a55d223f31c98fcbd23e5302a30804bc9352cb6969ff1c"
},
"locenice/assets/catalog/tile5.jpg": {
"bytes": 2832,
"sha256": "ee225992e87178f635010bb90c9ccff0557394e29c915987878e038fb8e5c99a"
},
"locenice/assets/catalog/tile6.jpg": {
"bytes": 3423,
"sha256": "d18ba495c6e6e06459ded48a6f87bfa824dc1fa83315db926fda263bac2856ed"
},
"locenice/assets/catalog/tile7.jpg": {
"bytes": 2282,
"sha256": "3b0f42f66264ec5dfc11467dc4905042cb01448bcaee725f49cf4632f75a56bd"
},
"locenice/assets/catalog/tile8.jpg": {
"bytes": 2338,
"sha256": "81f972783c5abc5c6e4d895f0778a1650df5c7d1df8a3bd960c5ff73680a0f5f"
},
"locenice/assets/catalog/tile9.jpg": {
"bytes": 2632,
"sha256": "9bb6ddaf21bb0e7ede49888d365b3f37c6ffc8045040c0a70f0336e21b4ffdbb"
},
"locenice/assets/references/kitchen1.jpg": {
"bytes": 100673,
"sha256": "90e8b220093eae30564f8cd979a5bb1b454d58032abbe431e747cdc50804fc13"
},
"locenice/assets/references/kitchen2.jpg": {
"bytes": 332927,
"sha256": "4617eb7ab1b967adb9e54daf4abf55207fb6819d29fb4973cd9126262ae50c0d"
},
"locenice/assets/references/plan.jpg": {
"bytes": 588857,
"sha256": "27f84372a48c41c645ef751ab78ebde43b2fac22cc7b46e1393038967a1d311a"
},
"locenice/assets/references/survey.png": {
"bytes": 207493,
"sha256": "e6fb9c57df1d53241846ff99362ca93899509cdb5564089f93993ed2646c1b86"
},
"locenice/assets/references/view1.jpg": {
"bytes": 221160,
"sha256": "2ae4fa95cca2ec52716a821e28956d48f0752aa148fcfb9a8c3b69956a55d6bc"
},
"locenice/assets/references/view2.jpg": {
"bytes": 159227,
"sha256": "4fef97b2d48568e7538e1c88084cf46341b843e4c2ebabe11ed9354040178bb8"
},
"locenice/assets/ui/image-1.jpg": {
"bytes": 10404,
"sha256": "db0fef3bc718c8feb15612d250384d189f8d311eed64c3837681c852c74e0c65"
},
"locenice/css/app.css": {
"bytes": 63876,
"sha256": "453197f4a027cd1585074fc3d8da5f4259c15b38fa755358daf7315d973b4728"
},
"locenice/css/web.css": {
"bytes": 3260,
"sha256": "4218b686835e38ed44b6a9d58cba4c284dc95e4c7f4e450d9b0f28b1733fe350"
},
"locenice/data/app-config.json": {
"bytes": 528,
"sha256": "7378244aea1cdce3c24dfb3e04241d7908326ca85ded6ac6b672aa29b81a0916"
},
"locenice/data/assets.json": {
"bytes": 2094,
"sha256": "c2d7822874607330c2dd256c4b7e9e56ac29147b7f8f49bf7c97aba449bd3af5"
},
"locenice/data/catalog.json": {
"bytes": 7798,
"sha256": "719958ad36182df4dd0f19307fb5e083c3ab0c8e83250289e8fa3cf208e14f95"
},
"locenice/data/references.json": {
"bytes": 1328,
"sha256": "9e3fd1b5e3c8db02cf63065b443cb140043896af3dd6dc1aaad87b7247c54b61"
},
"locenice/data/scene.json": {
"bytes": 31475,
"sha256": "a8f5d8bf68be9b73e04b27347b3dca6d3d954a4f0e8f2b35fa2db55a9d2c9089"
},
"locenice/data/site.json": {
"bytes": 5897,
"sha256": "b5222c0fe12d4192f51f74a233e89b97b462ec2dc259718c8b7a0260adfa85b3"
},
"locenice/index.php": {
"bytes": 348,
"sha256": "c473e40e59838adc34902a4d223f592ac6f79ecadb12ddc1374b76bdb64128db"
},
"locenice/js/app.js": {
"bytes": 118457,
"sha256": "cc89e78969e9cdf1672661e1b501722da53f4969ca2db04ae2e2b2b2dddb6625"
},
"locenice/js/bootstrap.js": {
"bytes": 2186,
"sha256": "daef518d18425f86d9daaa9fb01dceb5671ca17afcee8c37658886b8d2e5934c"
},
"locenice/js/legacy-renderer.js": {
"bytes": 12513,
"sha256": "04b7fe36cbc2af577849abf6cdaf497ce35c8203f3982faf81e62c40c7a39063"
},
"locenice/js/legacy-shaders.js": {
"bytes": 3481,
"sha256": "65b346b36da103b89343e5445ff0959cba90f2c55227d6851391bad72d1481c6"
},
"locenice/js/model-io.js": {
"bytes": 1374,
"sha256": "25b03a405d0c6d7eba6b49352f7dfb11e26d7c8eb2e953075829be19f4209d53"
},
"locenice/js/project-panel.js": {
"bytes": 3795,
"sha256": "241742a4ebb764af21596fe6cfe6fcf45082dfeb38add1a76bf1c5950896654c"
},
"locenice/js/solar.js": {
"bytes": 1894,
"sha256": "14ae60b6740d645febf593773b321a6d3e61abf133b4956297c695518c41eb12"
},
"locenice/js/three-renderer.js": {
"bytes": 8428,
"sha256": "dad3161a5e809a7a467e96853bf65b4bb1dcc3c9a2109fdf2e1ea835e0898a67"
},
"locenice/models/dum.glb": {
"bytes": 17414380,
"sha256": "0a0bf5ccceb5cddc11a7770531990b6fecdd57f82d9cd84158ad64b5e7548324"
},
"tests/data_checks.mjs": {
"bytes": 2012,
"sha256": "bf01da3b9aab0c7517aae1219866999015ec2c2110e0736d8f7d879ca3fc01a5"
},
"tools/prepare_export.py": {
"bytes": 6619,
"sha256": "22e5b6151cac9a8c687f8f3a0007ea116fa9208662164297a611ae6bfdc366cd"
},
"tools/vendor_three.py": {
"bytes": 2017,
"sha256": "197501bc7f0ef169a43265b19ad4bb8441ea5b90db9d6ade1b535df9a3cf6f90"
}
}
+148
View File
@@ -0,0 +1,148 @@
# Ločenice / webový model pro PHP hosting
## Nahrání na web
1. Zálohujte dosavadní složku `locenice`.
2. Nahrajte **celý obsah přiložené složky `locenice/`** do stejné složky na hostingu.
3. Otevřete `/locenice/` nebo `/locenice/index.php` a obnovte stránku bez cache.
PHP jen vrací HTML. Pro samotný provoz nepotřebujete Node.js, npm, databázi ani sestavování aplikace.
Složky `blender`, `tools` a `tests` na hosting nenahrávejte; jsou určené pro úpravy na počítači.
Základ `index.php` zůstává stejný jako dosud:
```php
<?php
header('Content-Type: text/html; charset=utf-8');
readfile(__DIR__ . '/app.html');
```
Dodaný `index.php` navíc kontroluje existenci souboru a posílá hlavičku `nosniff`.
Relativní cesty počítají s tím, že `index.php` i `app.html` jsou ve stejné složce.
Nenahrávejte pouze PHP nebo pouze HTML. Stránka potřebuje i `js`, `css`, `data`, `models` a `assets`.
Na běžném Apache není nutné přidávat `.htaccess` ani přesměrování.
Server musí vydávat `.js` jako JavaScript, `.json` jako JSON a umožnit stažení `.glb`.
## Co je kde
| Soubor / složka | Účel |
| --- | --- |
| `locenice/index.php` | Vstup na dosavadním PHP hostingu. |
| `locenice/app.html` | Struktura stránky; už bez vloženého modelu. |
| `locenice/models/dum.glb` | Celý zdrojový model, včetně alternativ a textur. |
| `locenice/data/catalog.json` | Možnosti standardů, názvy, barvy a výchozí volby. |
| `locenice/data/scene.json` | Vazby materiálů, místnosti, kamery, kolizní obrysy a metadata. |
| `locenice/data/site.json` | 27 bodů výškopisu a zvlášť souřadnice z geometrického plánu. |
| `locenice/data/app-config.json` | Model, renderer, cesty a maximální hustota vykreslování. |
| `locenice/assets/` | Samostatné obrázky katalogu a referencí. |
| `locenice/js/app.js` | Ovládání, pohledy, stav a pravidla konfigurátoru. |
| `locenice/js/three-renderer.js` | Nový renderer nad Three.js. |
| `locenice/js/legacy-renderer.js` | Lokální záloha nad původním WebGL. |
| `locenice/js/solar.js` | Oddělený výpočet polohy slunce. |
| `locenice/js/model-io.js` | Načtení a zápis kontejneru GLB. |
| `locenice/css/` | Vzhled aplikace. |
## Three.js a záložní renderer
Výchozí `renderer: "auto"` nejprve zkusí Three.js **0.180.0 / r180**, přesně připnutou verzi z CDN jsDelivr.
Model, katalog ani fotografie se na CDN neposílají. Prohlížeč odtud stahuje knihovnu, takže jde o externí síťový požadavek.
Knihovna Three.js není fyzicky součástí tohoto ZIPu. Při nedostupnosti CDN nastoupí lokální renderer.
Nejde tedy o úplné odstranění původního vykreslování: je zachované jako oddělená záloha.
Obě cesty vyžadují WebGL 2; zablokovanou grafiku prohlížeče záloha neobejde.
V levém dolním rohu nebo v dialogu **Projekt a podklady** je uveden aktivní renderer.
Vynucené režimy pro diagnostiku: `index.php?renderer=three` a `index.php?renderer=legacy`.
### Volitelně vše z vlastního hostingu
Na počítači s Pythonem a připojením k internetu spusťte z kořene balíčku:
```sh
python tools/vendor_three.py
```
Skript stáhne připnuté soubory a jejich relativní závislosti z oficiálního repozitáře Three.js, včetně licence.
Potom nahrajte vytvořenou složku `locenice/vendor` a upravený `locenice/data/app-config.json`.
Stahovací skript se nespouští na webu a neslouží jako veřejný instalátor.
Jeho síťovou část zde kvůli blokovanému připojení nebylo možné ověřit.
## Model, projekt a dodané fotografie
Stavební geometrie i svažitý terén jsou převzaté z dodaného modelu ALLSTAV v2.
Neproběhlo nové zaměření ani kompletní přemodelování domu z PDF.
Kuchyň a obývací prostor dosavadního modelu zůstávají včetně zelených čel, desky, vinotéky, vybavení a trámů.
Čtyři nově dodané obrázky jsou dostupné v dialogu pro porovnání.
Obrázky nejsou zaměřené ani mezi sebou zcela shodné; jejich odlišné rámy a dispozice neřídí přesun oken.
Nejde o fotorealistickou rekonstrukci, BIM, výrobní návrh kuchyně nebo dokumentaci pro stavbu.
Výškopis je dostupný jako podklad a lze zapnout jeho body nad 3D modelem.
Výšková nula je 591,840 m Bpv. Grafické x/z polohy 27 bodů jsou převzaté z dosavadního modelu.
Souřadnice S-JTSK z geometrického plánu jsou uložené odděleně, nepředstírají souřadnice vytyčení domu.
Doklad uvádí parcelu 3480/37 o výměře 1 010 m²; dosavadní grafický obrys modelu má 987,89 m².
Tento rozdíl je uvedený, nikoli skrytě odstraněný deformací modelu.
Výšky upraveného terénu u domu a původní zaměření nejsou zaměnitelné.
## Standardy a sdílení
Zachované volby: dveře, parapety, okapy, krytina, žaluzie, podlahy po místnostech, obklady,
malba, vnitřní dveře, kuchyň, sprcha a další specifikace.
Žaluzie se týkají devíti obdélníkových otvorů, nikdy trojúhelníku nebo vstupních dveří.
Výchozí barvy jsou RAL 7016 / antracit / CERN 015 / tmavě šedivá.
Na webu je export samostatného HTML nahrazen **odkazem s výběrem**.
Odkaz otevře tento web s vloženým nastavením. Obsahuje i poznámku pro dodavatele; nevkládejte do ní citlivé údaje.
Nastavení je ve fragmentu URL za `#`; neukládá se do serverové databáze.
Zachovaný je export/import JSON, TXT a export viditelného modelu GLB; PNG zachycuje 3D canvas.
Soubor `models/dum.glb` je kompletní editovatelný zdroj. Export **viditelného výběru** ho nenahrazuje.
Dosavadní JSON výběry lze importovat. Lokální uložení je pouze v daném prohlížeči.
Změna výchozích hodnot v katalogu nepřepíše již uložený výběr; použijte tlačítko obnovy výchozích voleb.
## Úpravy v Blenderu
Soubor `.blend` v balíčku není. Přiložený skript jej vytvoří ve vašem Blenderu:
```sh
blender --background --python blender/prepare_model.py
```
Výsledek: `blender/dum_locenice.blend`. Skript vytvoří pojmenované kolekce a skryje neaktivní varianty.
Skript zde nebyl spuštěn, protože Blender není v prostředí nainstalovaný.
Alternativně importujte `locenice/models/dum.glb` do Blenderu ručně.
Neslučujte vše do jednoho objektu ani materiálu. Zachovejte vlastnosti `loceniceId`, `interiorLayer`, `stdWhen`, `stdBlind` a `windowNode`.
Při exportu do GLB zapněte vlastní vlastnosti / **Custom Properties (extras)**.
Exportujte všechny varianty, nikoli jen viditelné objekty; vypněte filtr **Visible / Renderable / Selected objects only**.
Pak spusťte:
```sh
python tools/prepare_export.py upraveny_model.glb --out pripraveno
```
Nástroj znovu naváže čísla objektů a materiálů na stabilní ID a doplní celý katalog textur.
Po záloze webu vyměňte **oba** soubory `models/dum.glb` a `data/scene.json` za připravené výsledky.
Nástroj byl ověřen na testu s přeházeným pořadím objektů a materiálů, ne na skutečném exportu z Blenderu.
Transformované objekty po novém exportu vyžadují Three.js; původní záložný renderer je pro takový model odmítne namísto chybného zobrazení.
Změna dispozice navíc vyžaduje kontrolu kolizních obrysů, poloh žaluzií, podlah a připravených kamer v `data/scene.json`.
Samotné přejmenování nebo výměna GLB tyto pravidla automaticky nepřeprojektuje.
## Soukromí a provoz
Balíček obsahuje referenční obrázky a výřezy podkladů v `assets`. Na veřejném webu jsou přímo dostupné.
Pro neveřejný projekt chraňte **celou složku**, nikoli pouze `index.php`.
Meta `noindex` není zabezpečení. Aplikace sama nemá přihlášení ani serverové ukládání.
## Ověření a omezení
Ověřena je syntaxe PHP a JavaScriptu, vracení stránky přes PHP, HTTP cesty a typy souborů,
struktura GLB, katalog a jeho vazby, změny výběrů a rozložení rozhraní na desktopu i mobilu.
Testy ovládání použily simulované grafické API. **Nejde o ověření skutečného 3D vykreslení.**
Zdejší prohlížeč blokuje WebGL i externí knihovny, proto nebyl renderer Three.js plně spuštěn.
Barevné podání a výkon je potřeba zkontrolovat po nasazení v běžném prohlížeči.
Opakovatelná kontrola dat na počítači s Node.js:
```sh
node tests/data_checks.mjs
```
Node.js je zde pouze pro dobrovolný test, nikoli pro hosting nebo běh webu.
+93
View File
@@ -0,0 +1,93 @@
{
"data_checks_passed": 96,
"ui_checks_passed": 14,
"ui_page_errors": [],
"ui_graphics": "Mock WebGL API for controller/layout tests. No real 3D rendering.",
"syntax_checks": [
"PHP index.php lint",
"JavaScript modules node --check",
"Python helpers compilation"
],
"http_checks": "Local PHP server returned HTTP 200 for page, scripts, JSON, CSS and model.",
"export_mapping_test": "Synthetic reversed node/material order: passed. Not tested with a real Blender export.",
"not_verified": [
"Live WebGL 2 rendering",
"Three.js CDN imports in an unrestricted browser",
"Blender helper execution",
"Network part of the optional vendoring helper",
"User hosting deployment"
],
"environment_limitation": "Browser policy blocks WebGL and navigations; outbound container network blocked.",
"ui_checks": [
{
"name": "Defaults",
"ok": true,
"value": true
},
{
"name": "Blinds all nine rectangular openings",
"ok": true,
"value": true
},
{
"name": "Blinds off",
"ok": true,
"value": true
},
{
"name": "Nine floor rooms",
"ok": true,
"value": true
},
{
"name": "Kitchen independent colours",
"ok": true,
"value": true
},
{
"name": "Invalid colour rejected",
"ok": true,
"value": true
},
{
"name": "Walkin alternative",
"ok": true,
"value": true
},
{
"name": "Source binary roundtrip",
"ok": true,
"value": true
},
{
"name": "Summary export schema",
"ok": true,
"value": true
},
{
"name": "Pure astronomical function",
"ok": true,
"value": true
},
{
"name": "Reference photos loaded",
"ok": true,
"value": true
},
{
"name": "Survey overlay 27 points maximum",
"ok": true,
"value": true
},
{
"name": "No horizontal page overflow on mobile",
"ok": true,
"value": true
},
{
"name": "Mobile interior panel visible",
"ok": true,
"value": true
}
]
}
+8
View File
@@ -0,0 +1,8 @@
Three.js r180 / 0.180.0 is loaded from jsDelivr by default. It is not bundled here.
Source: https://github.com/mrdoob/three.js/tree/r180
License: MIT, Copyright (c) 2010-2025 three.js authors.
The optional vendor downloader fetches and preserves the upstream LICENSE.
User supplied model, drawings, catalogue samples and reference photographs:
provided for this project. No third-party asset ownership is claimed.
Do not treat these files as a separately licensed general-purpose asset library.
+65
View File
@@ -0,0 +1,65 @@
"""Run in Blender, not system Python:
blender --background --python blender/prepare_model.py
Creates an editable .blend next to this script. Existing scene is replaced.
Keeps every configuration variant; helper collections are hidden in viewport.
"""
from pathlib import Path
import json
import bpy
ROOT=Path(__file__).resolve().parent.parent
SOURCE=ROOT/'locenice'/'models'/'dum.glb'
TARGET=Path(__file__).resolve().parent/'dum_locenice.blend'
if not SOURCE.is_file():
raise FileNotFoundError(SOURCE)
if TARGET.exists():
raise FileExistsError(f'Rename or remove {TARGET} before running again.')
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(SOURCE))
defaults=json.loads((ROOT/'locenice'/'data'/'catalog.json').read_text())['defaults']
scene=bpy.context.scene
scene.unit_settings.system='METRIC'
scene.unit_settings.scale_length=1.0
collections={}
for name in ['01_Exterior','02_Interior_walls','03_Kitchen_and_furniture','04_Site','05_Cutaway_alternatives','06_Standards_alternatives']:
col=bpy.data.collections.new(name);scene.collection.children.link(col);collections[name]=col
for obj in list(scene.objects):
if obj.type!='MESH':continue
props=obj
if props.get('stdHidden') or props.get('stdWhen') or props.get('stdBlind'):
key='06_Standards_alternatives'
elif props.get('interiorLayer') in ['cut','cutShell']:
key='05_Cutaway_alternatives'
elif props.get('interiorLayer')=='furniture':
key='03_Kitchen_and_furniture'
elif props.get('interiorLayer'):
key='02_Interior_walls'
elif props.get('siteLayer') or props.get('legacySite'):
key='04_Site'
else:key='01_Exterior'
for c in list(obj.users_collection):c.objects.unlink(obj)
collections[key].objects.link(obj)
# Stable ID comes from the GLB extras. Do not delete it during future editing.
obj['locenice_edit_note']='Keep loceniceId and configuration properties.'
condition=props.get('stdWhen',{})
hidden=bool(props.get('stdHidden')) or bool(props.get('stdBlind') and not defaults['blinds'])
if hasattr(condition,'items'):
hidden=hidden or any(defaults.get(k)!=v for k,v in condition.items())
obj.hide_set(hidden)
obj.hide_render=hidden
collections['05_Cutaway_alternatives'].hide_viewport=True
# Keep matching default variants visible; all alternatives remain in the file.
note=bpy.data.texts.new('READ_ME_Locenice')
note.write('''This is a visualisation mesh, not BIM. Units: metres.
The source includes multiple, mutually exclusive variants in the same location.
Use named collections to inspect them. Do not join all meshes or all materials.
loceniceId, stdWhen, stdBlind, windowNode and interiorLayer must stay on objects.
Detailed construction dimensions come from the project, not reference photos.
After exporting all variants to GLB, run tools/prepare_export.py.
Blender reorders glTF nodes, materials and textures. The included web manifest
is indexed for the supplied GLB; the preparation tool rebinds stable IDs.
A changed wall or cabinet also requires checking web collision volumes / camera presets.
''')
scene['locenice_metadata_json']=json.dumps(json.loads((ROOT/'locenice'/'data'/'scene.json').read_text()),ensure_ascii=False)
bpy.ops.wm.save_as_mainfile(filepath=str(TARGET))
print('Saved:',TARGET)
+2 -1
View File
@@ -1,3 +1,4 @@
<?php
readfile(__DIR__ . '/dum_3D_slunce.html');
header('Content-Type: text/html; charset=utf-8');
readfile(__DIR__ . '/app.html');
+348
View File
@@ -0,0 +1,348 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="allstav-config-version" content="2025.2">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Lo&#269;enice | Model domu / web</title>
<link rel="stylesheet" href="./css/app.css">
<link rel="stylesheet" href="./css/web.css">
<meta name="robots" content="noindex,nofollow">
</head>
<body>
<canvas id="view" aria-label="Interaktivn&#237; trojrozm&#283;rn&#253; model rodinn&#233;ho domu"></canvas>
<svg id="measure" aria-hidden="true"></svg>
<header>
<div class="eyebrow"><span class="dot"></span>RODINN&#221; D&#366;M - Lo&#269;enice</div>
<h1>Model domu / web &ndash; Lo&#269;enice (Novohradsk&#233; podh&#367;&#345;&#237;)</h1>
<div class="sub">Lo&#269;enice &middot; 3D model + simulace slunce</div>
<div class="metrics">
<div class="metric"><strong>15,340 m</strong><small>D&#233;lka</small></div>
<div class="metric"><strong>9,390 m</strong><small>&#352;&#237;&#345;ka</small></div>
<div class="metric"><strong>23&deg;</strong><small>St&#345;echa</small></div>
</div>
</header>
<div class="topright"><button id="standardsToggle" class="btn" type="button" aria-controls="standardsPanel" aria-expanded="false" title="Standardy a vybaveni domu"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M3 5h14M3 10h14M3 15h14"/><circle cx="7" cy="5" r="2" fill="currentColor"/><circle cx="13" cy="10" r="2" fill="currentColor"/><circle cx="7" cy="15" r="2" fill="currentColor"/></svg><span class="std-toggle-word">Standardy</span></button><button class="btn" id="sunToggle" aria-controls="sunPanel" aria-expanded="false"
title="Ovl&#225;d&#225;n&#237; slunce"><span class="solar-mark">&#9728;</span><span
class="toggle-word">Slunce</span></button><button class="btn icon" id="infoButton"
aria-label="Nastaven&#237; a informace o modelu" title="Nastaven&#237;">&#9881;&#xFE0E;</button><button
class="btn primary" id="saveGlb">St&#225;hnout <span
class="downloadText">model
</span>GLB</button></div>
<div class="panel" id="panel">
<h2>Model podle projektu</h2>
<p>Ot&#225;&#269;ec&#237; exteri&#233;rov&#253; model v metrech. Hlavn&#237; rozm&#283;ry a polohy otvor&#367;
vych&#225;zej&#237; z k&#243;t. Fas&#225;dn&#237; obklad: HardiePlank podle dodan&#233;ho vzorn&#237;ku; ostatn&#237; barevnost z p&#345;ilo&#382;en&#253;ch vizualizac&#237;.</p>
<h3>Parametry</h3>
<div class="row"><span>Ob&#225;lka domu</span><strong>15,340 &times; 9,390 m</strong></div>
<div class="row"><span>H&#345;eben nad podlahou 1. NP</span><strong>5,137 m</strong></div>
<div class="row"><span>Vrchol kom&#237;na</span><strong>5,837 m</strong></div>
<div class="row"><span>Kryt&#225; terasa / navazuj&#237;c&#237; ochoz</span><strong>&minus;0,03 / &minus;0,21
m</strong></div>
<h3>Barevnost</h3>
<div class="swatches"><label>Fas&#225;da<input id="wallColor" type="color" value="#c9c2ad"></label><label>Krytina<input id="roofColor" type="color" value="#414542"></label></div>
<section class="hardie-card" aria-labelledby="hardieTitle">
<div class="hardie-kicker">Fas&#225;dn&#237; obklad &#183; vstup a terasa</div>
<h4 id="hardieTitle">HardiePlank struktura d&#345;eva</h4>
<p class="hardie-spec">James Hardie &#183; vl&#225;knocement<br>Rozm&#283;r v&#253;robku: 3 600 &#215; 180 &#215; 8 mm</p>
<div id="hardiePreview" class="hardie-preview" role="img" aria-label="Orienta&#269;n&#237; n&#225;hled obkladu"></div>
<div class="hardie-preview-caption">ZV&#282;T&#352;EN&#221; DETAIL STRUKTURY &#183; ORIENTA&#268;N&#282;</div>
<div class="hardie-selection" aria-live="polite"><strong id="hardieColorName">O&#345;&#237;&#353;kov&#283; hn&#283;d&#225;</strong><small id="hardieProductCode">5771220 &#183; v&#253;choz&#237;</small></div>
<fieldset class="hardie-palette"><legend>Standardn&#237; barvy podle dodan&#233;ho vzorn&#237;ku</legend><div class="hardie-options"><label class="hardie-option" style="--chip:#ffffff"><input type="radio" name="hardieColor" value="snehove-bila" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Sn&#283;hov&#283; b&#237;l&#225;</span><span class="hardie-option-code">5671220</span><span class="hardie-check" aria-hidden="true">&#10003;</span></label><label class="hardie-option" style="--chip:#d0c8bb"><input type="radio" name="hardieColor" value="sterkove-seda" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">&#352;t&#283;rkov&#283; &#353;ed&#225;</span><span class="hardie-option-code">5751220</span><span class="hardie-check" aria-hidden="true">&#10003;</span></label><label class="hardie-option" style="--chip:#b9c1c3"><input type="radio" name="hardieColor" value="mlhove-seda" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Mlhov&#283; &#353;ed&#225;</span><span class="hardie-option-code">5841220</span><span class="hardie-check" aria-hidden="true">&#10003;</span></label><label class="hardie-option" style="--chip:#758896"><input type="radio" name="hardieColor" value="ocelove-seda" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Ocelov&#283; &#353;ed&#225;</span><span class="hardie-option-code">5851220</span><span class="hardie-check" aria-hidden="true">&#10003;</span></label><label class="hardie-option selected" style="--chip:#776e65"><input type="radio" name="hardieColor" value="oriskove-hneda" checked autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">O&#345;&#237;&#353;kov&#283; hn&#283;d&#225;</span><span class="hardie-option-code">5771220</span><span class="hardie-check" aria-hidden="true">&#10003;</span></label><label class="hardie-option" style="--chip:#241510"><input type="radio" name="hardieColor" value="espresso" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Espresso</span><span class="hardie-option-code">5460122</span><span class="hardie-check" aria-hidden="true">&#10003;</span></label><label class="hardie-option" style="--chip:#4c5053"><input type="radio" name="hardieColor" value="antracitove-seda" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Antracitov&#283; &#353;ed&#225;</span><span class="hardie-option-code">5420122</span><span class="hardie-check" aria-hidden="true">&#10003;</span></label></div></fieldset>
<p class="hardie-scope">M&#283;n&#237; se pouze svisl&#253; fas&#225;dn&#237; obklad u vstupu a na terase, v&#269;etn&#283; bo&#269;n&#237;ch st&#283;n. Podlaha a &#269;ela terasy ani schody se nem&#283;n&#237;.</p>
<a class="hardie-link" href="https://www.jameshardie.cz/cz/hardie-plank" target="_blank" rel="noopener noreferrer">HardiePlank na webu James Hardie &#8599;</a>
<div class="hardie-detail-buttons"><button type="button" data-cladding-view="entry">Detail vstupu</button><button type="button" data-cladding-view="terrace">Detail terasy</button></div>
<button class="hardie-structure-button" type="button" data-cladding-view="structure">P&#345;ibl&#237;&#382;it strukturu ve 3D</button>
<details class="hardie-note"><summary>Odst&#237;ny, struktura a p&#345;esnost modelu</summary>
<p>Barvy jsou ode&#269;ten&#233; z va&#353;eho obr&#225;zku vzorn&#237;ku, nikoli z kalibrovan&#253;ch dat v&#253;robce. Odst&#237;n ve sc&#233;n&#283; ovliv&#328;uje simulovan&#233; slunce, st&#237;n i monitor. Shodu s dodan&#253;m v&#253;robkem ov&#283;&#345;te na fyzick&#233;m vzorku.</p>
<img class="hardie-reference" src="./assets/ui/image-1.jpg" alt="Produktov&#225; fotografie struktury d&#345;eva z dodan&#233;ho vzorn&#237;ku">
<p>Ve 3D je dopln&#283;n jemn&#253; simulovan&#253; reli&#233;f inspirovan&#253; touto fotografi&#237;. Nejde o origin&#225;ln&#237; v&#253;robn&#237; texturu nebo 3D sken.</p>
<p>Vodorovn&#233; &#269;len&#283;n&#237;, geometrie a sp&#225;ry jsou zachov&#225;ny z p&#367;vodn&#237;ho modelu. Uveden&#253; rozm&#283;r desky je specifikace v&#253;robku, nikoli ov&#283;&#345;en&#237; klade&#269;sk&#233;ho pl&#225;nu nebo mont&#225;&#382;n&#237;ch detail&#367;.</p>
</details>
</section>
<p style="font-size:10px">Zvolen&#233; barvy a textura fas&#225;dn&#237;ho obkladu se ulo&#382;&#237; i do sta&#382;en&#233;ho GLB.</p>
<p style="font-size:10px"><a
href="https://www.sto.cz/cepcom/cz/documents/download_colour_ranges_1/StoColor_System_2022_CZ.pdf"
target="_blank" rel="noopener">Vzorn&#237;k barev StoColor System (PDF)</a></p>
<h3>Kolm&#233; pohledy</h3>
<select id="elevation">
<option value="">Vyber pohled...</option>
<option value="north">Severn&#237; pohled</option>
<option value="east">V&#253;chodn&#237; pohled</option>
<option value="south">Ji&#382;n&#237; pohled</option>
<option value="west">Z&#225;padn&#237; pohled</option>
</select>
<div class="buttons"><button class="btn" onclick="document.getElementById('saveGlb').click()">St&#225;hnout
model GLB</button></div>
<details class="site-note">
<summary>Okolí, výškopis a přesnost</summary>
<p><b>Tři spodní přepínače jsou nezávislé a při otevření zapnuté.</b> Zapnutí zároveň zaměří příslušný
pohled. Ikona vedle přepínačů zarámuje zapnuté okolí; Vstup, Terasa a Reset vrátí pohled k domu. Vypnutí
poslední vrstvy obnoví předchozí pohled.</p>
<p>Geometrie, měřítko a poloha původního domu, otvorů, střechy, teras a jejich schodů jsou zachované.
Nahrazen je pouze původní schematický terén, štěrk a vstupní zpevněná plocha.</p>
<p>Podklady okolí: C.2 a C.3 (umístění a upravené úrovně), C.4 (celý pozemek), C.5 (komunikace) a „Ločenice
p.č. 3480/37 – polohopis a výškopis“. Výšková nula: <b>591,840 m Bpv</b>. U vstupu <b>−0,480 m</b>, u
horní podesty schodů z parkování <b>+0,390 m</b>, na konci zahradních schodů dle C.3 <b>−1,395 m</b>.
</p>
<p>Povrch kombinuje původní zaměřené body s upravenými úrovněmi C.3. Mezi body je <b>interpolovaný</b>;
navázání okrajů, skladba dlažby a dvě auta jsou vizualizační zjednodušení. Parkovací plocha směrem k
ulici stoupá; není zobrazena jako jediná vodorovná deska.</p>
<p><b>Rozdíl v podkladech:</b> projekt uvádí výměru <b>1 010 m²</b>, obrys převzatý z vektorové kresby PDF v
měřítku domu vychází přibližně <b>988 m²</b> (o 2,2 % méně). Obrys není uměle zvětšen na deklarovanou
výměru. Přesnou hranici je nutné ověřit ze souřadnic nebo DWG; tato vizualizace není podklad pro
vytyčení ani zemní práce.</p>
<p>Výpočet slunce a převod světových stran do os modelu jsou zachované. Původní azimut severního štítu
<b>351,5°</b> a přibližná poloha obce nejsou tímto geodeticky ověřeny.
</p>
<p>PNG zachycuje právě viditelné vrstvy. GLB exportuje scénu s právě zapnutým okolím a zvolenými barvami;
ovládání slunce zůstává jen v HTML.</p>
</details>
<h3>Úpravy modelu</h3>
<p>Doplněn rohový sloup kryté terasy podle jižního a východního pohledu D.1.1.2.3.1. Průřez 200 × 200 mm a odsazení od hrany jsou odečtené orientačně z kresby, nikoli staticky ověřené. Vinotéka je u stěny a nad celou kuchyní pokračuje vysoký strop obýváku.</p>
<h3>Rozsah a omezen&#237;</h3>
<p class="note">Pracovn&#237; vizualiza&#269;n&#237; model, nikoli BIM nebo podklad pro stavbu. S volitelnou orientační vrstvou interiéru, bez skrytých konstrukcí. Terén okolí je doplněn podle C.3 a výškopisu; mezi body je
interpolovaný. Hranice jsou převzaté graficky z PDF, nikoli ze souřadnic pro vytyčení.</p>
<p>Profily oken, ta&#353;ky, oplechov&#225;n&#237;, technick&#233; vybaven&#237; a spoje jsou
zjednodu&#353;en&#233;. Z&#225;bradl&#237; je kovov&#233; podle pohled&#367; a technick&#233; zpr&#225;vy. V
p&#345;&#237;pad&#283; rozd&#237;lu maj&#237; v&#253;kresy p&#345;ednost p&#345;ed vizualizacemi.</p>
<h3>Pou&#382;it&#233; podklady</h3>
<p style="font-size:10px">D.1.1.2.1.1 P&#367;dorys 1NP<br>D.1.1.2.1.3 P&#367;dorys st&#345;echy<br>D.1.1.2.3.1
Pohledy<br>D.1.1.2.2.1&ndash;3 &#344;ezy A&ndash;A, B&ndash;B, C&ndash;C<br>D.1.1.1 Technick&#225;
zpr&#225;va<br>Obr&#225;zky 6_1.jpeg a 6_2.jpeg</p>
<div class="buttons"><button class="btn" id="savePng">Ulo&#382;it pohled PNG</button><button class="btn"
id="resetColors">P&#367;vodn&#237; barvy</button></div>
<div class="status"><span class="dot"></span>Samostatn&#253; soubor &middot; funguje bez internetu &middot;
verze 05 · sloup terasy, vinotéka a vysoký strop</div>
</div>
<div id="direction"><span class="tag">AKTU&#193;LN&#205; POHLED</span><span id="directionText">Terasa /
jihov&#253;chod</span></div>
<div class="bottom">
<nav class="toolbar" aria-label="Ovl&#225;d&#225;n&#237; modelu"><button class="tool"
data-preset="entry">Vstup</button><button class="tool"
data-preset="terrace">Terasa</button><button class="tool" data-preset="top">Shora</button><span
class="divider"></span><button class="tool" id="terrainButton"
title="Zobrazit nebo skr&#253;t ter&#233;n">Ter&#233;n</button><button class="tool"
id="measureButton">K&#243;ty</button><button class="tool optional active"
id="rotateButton">Ot&#225;&#269;en&#237;</button><span class="divider"></span><button class="tool"
id="resetView" title="Obnovit v&#253;choz&#237; pohled">Reset</button></nav>
<div class="site-controls" role="group" aria-label="Volitelné okolí domu; výchozí stav zapnuto">
<label class="site-switch" title="Komunikace a navazující terén mezi ulicí a domem"><input type="checkbox"
id="siteRoad" autocomplete="off" checked><span>Cesta a přístup</span></label>
<label class="site-switch" title="Celá zahrada a graficky převzatá hranice parcely"><input type="checkbox"
id="siteParcel" autocomplete="off" checked><span>Celý pozemek</span></label>
<label class="site-switch" title="Parkovací plocha, napojení a schody; dvě orientační auta"><input
type="checkbox" id="siteParking" autocomplete="off" checked><span>Parkování</span></label>
<button id="siteFit" class="site-fit" type="button" title="Zarámovat zapnuté okolí"
aria-label="Zarámovat zapnuté okolí"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" aria-hidden="true">
<path d="M9 4H4v5M15 4h5v5M20 15v5h-5M9 20H4v-5" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</svg></button>
</div>
<div id="siteMeta" hidden aria-live="polite"></div>
<div class="help">Ta&#382;en&#237;m ot&#225;&#269;ej &nbsp;&middot;&nbsp; Kole&#269;kem p&#345;ibli&#382;
&nbsp;&middot;&nbsp; Prav&#253;m tla&#269;&#237;tkem posu&#328;</div>
</div>
<div class="compass"><svg viewBox="0 0 84 84">
<circle cx="42" cy="42" r="27" fill="none" stroke="#bec8b7" />
<g id="compassGroup">
<line x1="42" y1="13" x2="42" y2="71" />
<line x1="13" y1="42" x2="71" y2="42" />
<path id="northArrow" d="M42 18 L37 32 L42 29 L47 32 Z" /><text x="42" y="8"
text-anchor="middle">S</text><text x="42" y="81" text-anchor="middle">J</text><text x="3" y="45"
text-anchor="middle">Z</text><text x="80" y="45" text-anchor="middle">V</text>
</g>
</svg></div>
<svg id="sunOverlay" aria-hidden="true"></svg>
<div id="sunStamp"><span>SLUNCE NAD DOMEM</span><br><strong id="sceneTime">21. 6. 2026 · 10:00</strong></div>
<section id="sunPanel" aria-label="Simulace slunce">
<div class="sun-panel-head">
<div>
<div class="sun-kicker">STUDIE OSVĚTLENÍ</div>
<h2>Slunce a stíny</h2>
</div><button class="close-panel" id="sunClose" aria-label="Skrýt panel slunce">×</button>
</div>
<div class="sun-date-row"><label class="sun-field">Datum<input id="sunDate" type="date" min="2000-01-01"
max="2100-12-31" value="2026-06-21"></label><label class="sun-field">Místní čas<input id="sunClock"
type="text" inputmode="numeric" value="10:00" maxlength="5" placeholder="HH:MM"
aria-label="Místní čas ve formátu HH:MM"></label></div>
<div class="sun-seasons" aria-label="Typické dny roku"><button data-season="03-20">Jaro</button><button
data-season="06-21" class="active">Léto</button><button data-season="09-23">Podzim</button><button
data-season="12-21">Zima</button></div>
<div class="sun-status-line"><span id="dayState">Slunce nad obzorem</span><span id="zoneLabel">SELČ ·
UTC+2</span></div>
<svg id="sunGraph" class="sun-graph" viewBox="0 0 304 86" role="img"
aria-label="Výška slunce během dne. Kliknutím změníš čas."></svg>
<label class="sr-only" for="sunTime"
style="position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)">Posun času</label>
<input class="sun-scrub" id="sunTime" type="range" min="0" max="1439" value="600" step="1"
aria-valuetext="10:00">
<div class="sun-ticks">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
</div>
<div class="sun-play-row"><button id="sunPlay" type="button">▶ Přehrát den</button><select id="sunSpeed"
aria-label="Rychlost simulace">
<option value="15">15 min / s</option>
<option value="30" selected>30 min / s</option>
<option value="60">60 min / s</option>
</select><button id="sunNow" title="Aktuální datum a čas v Praze">Teď</button></div>
<div id="solarMessage" role="status"></div>
<div class="sun-readouts">
<div class="sun-stat">Výška nad obzorem<strong id="sunElevation">—</strong><small>Střed slunečního
kotouče</small></div>
<div class="sun-stat">Azimut slunce<strong id="sunAzimuth">—</strong><small id="sunDirection">Od severu po
směru hodin</small></div>
</div>
<div class="sun-events"><span>Východ <b id="sunRise">—</b></span><span>Poledne <b
id="sunNoon">—</b></span><span>Západ <b id="sunSet">—</b></span></div>
<div class="sun-options"><label><input type="checkbox" id="sunShadows" checked>Vržené stíny</label><label><input
type="checkbox" id="sunPath">Dráha ve 3D</label></div>
<p class="sun-hint" id="pathHint">Posouvej čas a sleduj, kam dopadá slunce na terasu a fasády. Natočení pohledu
polohu slunce nemění.</p>
<button class="sun-export" id="sunSavePng">Uložit obrázek s datem a časem</button>
<details class="sun-advanced">
<summary>Poloha a orientace domu</summary>
<div class="fields"><label class="sun-field">Zeměpisná šířka (° N)<input id="sunLat" type="number" min="-89"
max="89" step="0.0001" value="48.8242"></label><label class="sun-field">Zeměpisná délka (°
E)<input id="sunLon" type="number" min="-180" max="180" step="0.0001" value="14.5275"></label><label
class="sun-field full-row">Azimut severního štítu domu (°, od severu)<input id="houseBearing"
type="number" min="0" max="359.9" step="0.1" value="351.5"></label></div>
<p>Výchozí poloha: <b>Ločenice, přibližný bod obce</b>, nikoli geodeticky určený bod parcely. Ponecháno
původní nastavení <b>351,5°</b>, v původním HTML označené jako odečet severky D.1.1.2.1.1. Přiložené
situace a výškopis bez číselných souřadnic neumožňují toto natočení nezávisle potvrdit. Nejde o ověřený
azimut stavby.</p>
<p id="bearingNote">Jižní štít: 171,5° · vstupní fasáda: 261,5°.</p>
<button class="mini-btn" id="sunResetSite">Obnovit Ločenice a sever z projektu</button>
<p>Časové pásmo zůstává <b>Europe/Prague</b>. Letní čas se přepočítává podle zvoleného data a pravidel
prohlížeče.</p>
</details>
<details class="sun-advanced">
<summary>Co simulace počítá a co ne</summary>
<p>Sluneční poloha se počítá z data, času a zeměpisných souřadnic podle astronomických rovnic Meeus / NOAA.
Směr přímého světla i stínová mapa se mění spolu s časem. Zobrazená výška slunce je geometrická. Východ
a západ jsou určeny pro výšku středu −0,833°, bez místního horizontu.</p>
<p>Stíny vytváří geometrie tohoto modelu. <b>Okolní domy, stromy, ploty a vzdálený horizont nejsou
modelovány.</b> Terén navazuje na body výškopisu a úpravy C.3; mezi body je interpolovaný. Nejde o
výpočet vnitřního denního osvětlení, tepelných
zisků ani výkonu fotovoltaiky. Oblačnost není zahrnuta; jas a barva jsou vizualizační.</p>
<p>Dráha ve 3D je vysvětlující překryv v symbolickém měřítku; slunce není ve skutečné vzdálenosti. Noční
pohled je mírně přisvětlen pro orientaci. Velmi nízké slunce může mít stín mimo rozsah terénu.</p>
<p>Podklady: D.1.1.2.1.1 (severka), D.1.1.2.3.1 (fasády), D.1.1.1 a B (orientace a terasa); situační výkresy
C.1–C.5 pro umístění a „Ločenice p.č. 3480/37 – polohopis a výškopis“ pro terén.</p>
<p>Zdroje výpočtu a polohy: <a href="https://gml.noaa.gov/grad/solcalc/calcdetails.html" target="_blank"
rel="noopener">NOAA / Meeus</a> · <a href="https://www.mistopisy.cz/pruvodce/obec/8006/locenice/"
target="_blank" rel="noopener">Ločenice – souřadnice</a>. Připojení je potřeba jen pro otevření
těchto zdrojů, ne pro simulaci.</p>
</details>
<p class="sun-footnote">Pracovní studie stínů · bez internetu<br>Geometrie domu zůstává zachována. Export GLB
neobsahuje ovládání slunce.</p>
</section>
<button id="interiorLaunch" type="button" aria-controls="interiorPanel" aria-expanded="false" disabled>
<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>Zapnout interiér<small>Půdorys · 3D bez střechy · procházení</small></span>
</button>
<div class="interior-heading"><button class="btn" id="interiorExit" title="Zpět na původní exteriér (Esc)">← Exteriér</button><div><div class="eyebrow">INTERIÉR / 1. NADZEMNÍ PODLAŽÍ</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>9 místností</span></div>
<svg id="interiorMap" viewBox="0 0 330 205" role="group" aria-label="Kliknutím na místnost přejdete dovnitř"></svg>
<p class="int-map-hint">Klikněte na místnost v mapě nebo vyberte připravený pohled.</p>
<div class="int-kicker">Kuchyň a obytný prostor</div>
<div class="int-views">
<button data-interior-view="kitchen">Kuchyně</button><button data-interior-view="outlook">Z kuchyně ven</button>
<button data-interior-view="living">Obývací prostor</button><button data-interior-view="vault">Trámy a vysoké okno</button>
<button data-interior-view="kitchenVault" style="grid-column:1/-1">Vysoký strop nad kuchyní</button>
</div>
<div class="int-kicker">Přejít do místnosti</div>
<select id="interiorRoom" aria-label="Pohled z vybrané místnosti"><option value="">Vyberte místnost…</option></select>
<section class="int-paint" aria-labelledby="interiorPaintTitle">
<div class="int-paint-head"><h4 id="interiorPaintTitle">Barva zd&#237;</h4><span>Vnit&#345;n&#237; om&#237;tka</span></div>
<div class="int-paint-fields">
<label class="int-paint-picker" title="Vybrat vlastn&#237; barvu zd&#237;"><input id="interiorWallColor" type="color" value="#eeeae1" aria-label="Barva vnit&#345;n&#237;ch zd&#237;" aria-describedby="interiorPaintScope"></label>
<label class="int-paint-hex" for="interiorWallHex">Vlastn&#237; odst&#237;n / HEX<input id="interiorWallHex" type="text" value="#EEEAE1" maxlength="7" spellcheck="false" autocapitalize="characters" autocomplete="off" aria-label="HEX k&#243;d barvy vnit&#345;n&#237;ch zd&#237;"></label>
</div>
<div class="int-paint-presets" role="group" aria-label="P&#345;ipraven&#233; barvy zd&#237;">
<button type="button" data-interior-paint="#eeeae1" aria-pressed="true"><span class="int-paint-chip" style="--paint:#eeeae1" aria-hidden="true"></span>Tepl&#225; b&#237;l&#225;</button>
<button type="button" data-interior-paint="#ffffff" aria-pressed="false"><span class="int-paint-chip" style="--paint:#ffffff" aria-hidden="true"></span>&#268;ist&#225; b&#237;l&#225;</button>
<button type="button" data-interior-paint="#dfceb3" aria-pressed="false"><span class="int-paint-chip" style="--paint:#dfceb3" aria-hidden="true"></span>P&#237;skov&#225;</button>
<button type="button" data-interior-paint="#d9d9d4" aria-pressed="false"><span class="int-paint-chip" style="--paint:#d9d9d4" aria-hidden="true"></span>Sv&#283;tle &#353;ed&#225;</button>
<button type="button" data-interior-paint="#c4cebc" aria-pressed="false"><span class="int-paint-chip" style="--paint:#c4cebc" aria-hidden="true"></span>&#352;alv&#283;jov&#225;</button>
<button type="button" data-interior-paint="#d5c7b9" aria-pressed="false"><span class="int-paint-chip" style="--paint:#d5c7b9" aria-hidden="true"></span>Jemn&#283; b&#233;&#382;ov&#225;</button>
</div>
<button class="int-paint-reset" id="interiorWallReset" type="button">Obnovit p&#367;vodn&#237; barvu zd&#237;</button>
<p class="int-paint-note" id="interiorPaintScope">M&#283;n&#237; v&#353;echny vnit&#345;n&#237; om&#237;tnut&#233; st&#283;ny. Strop, tr&#225;my, obklady a fas&#225;da z&#367;st&#225;vaj&#237; beze zm&#283;ny. Zvolen&#253; odst&#237;n se prom&#237;tne i do PNG a GLB.</p>
</section>
<div class="int-options">
<label><input type="checkbox" id="interiorFurniture" checked>Vybavení</label>
<label><input type="checkbox" id="interiorRoomLabels" checked>Názvy místností</label>
<label><input type="checkbox" id="interiorFill" checked>Přisvětlení interiéru</label>
</div>
<p>V pohledech shora je střecha skrytá a stěny zkrácené do výšky 1,12 m. Procházení obnoví plnou výšku stěn, podhledy a trámy. Dveře mezi místnostmi jsou zobrazené otevřené.</p>
<details class="int-source"><summary>Podklady a přesnost modelu</summary>
<p><b>Dispozice:</b> půdorys D.1.1.2.1.1 ze dne 21. 9. 2026. Čísla místností a popisky ploch jsou převzaté z výkresu. Obvod domu, poloha oken a vnějších dveří jsou beze změny z původního modelu.</p>
<p><b>Kuchyň:</b> zelená dvířka, dubové plochy, vysoké skříně, otevřené police a poloostrov podle dodaných obrázků. Vinotéka je na konci poloostrova u stěny. Nad celou kuchyní a obývacím prostorem je souvislý vysoký šikmý strop s přiznanými trámy. Uspořádání do U je přizpůsobené místnosti 108 a průchodům; přesné výrobní rozměry skříněk ani typy spotřebičů nejsou doložené.</p>
<p><b>Trámy:</b> vzhled upraven hlavně podle pohled_ven.jpeg: vodorovné trámy, svislé podpěry, krátké podélné vzpěry a navazující horní trám. Světlé dřevo má kresbu podél jednotlivých prvků. Přesné průřezy a spoje nejsou z obrázků ověřené; jde o vzhledovou studii, ne statický návrh.</p>
<p><b>Orientační:</b> rozměry nábytku, světlá výška ostatních místností 2,60 m, vysoký strop obytného prostoru podle stávajícího sklonu střechy, průřezy a detaily trámů, sanita a technické vybavení. Jde o prostorovou studii, nikoli výrobní dokumentaci nebo statický návrh.</p>
<p>Rámy oken zůstaly v původní barvě, i když mají referenční obrázky různé odstíny. Výhled používá stávající 3D okolí; fotografie nejsou přesným panoramatem okolní krajiny. V procházení jsou okenní skla opticky skrytá pro volný výhled.</p>
<p>Přisvětlení slouží k čitelnosti interiéru. Simulace slunce není výpočtem vnitřního denního osvětlení.</p>
<div class="int-source-buttons"><button data-int-ref="plan">Původní půdorys</button><button data-int-ref="kitchen1">Kuchyň · 1</button><button data-int-ref="kitchen2">Kuchyň · 2</button><button data-int-ref="view1">Výhled · 1</button><button data-int-ref="view2">Výhled · 2</button></div>
</details>
<button class="int-export" id="interiorSavePng">Uložit aktuální pohled PNG</button>
<button class="int-export" id="interiorSaveGlb">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 · kliknutím na místnost vstupte dovnitř</div>
<nav class="int-mode-bar" aria-label="Režim interiéru"><button data-interior-mode="plan">Půdorys</button><button data-interior-mode="axon" class="active">3D bez střechy</button><button data-interior-mode="walk">Procházet</button><button id="interiorFit" class="int-nav-button" title="Zarámovat dům / obnovit pohled">⌖</button><button id="interiorPanelToggle" class="int-nav-button" title="Skrýt nebo zobrazit navigaci" aria-expanded="true">☷</button></nav>
</div>
<div id="walkPad" aria-label="Ovládání chůze"><button data-walk="forward" aria-label="Jít vpřed">↑</button><button data-walk="left" aria-label="Krok vlevo">←</button><button data-walk="back" aria-label="Jít vzad">↓</button><button data-walk="right" aria-label="Krok vpravo">→</button></div>
<dialog id="interiorReferenceDialog"><div class="int-ref-head"><h3 id="interiorReferenceTitle">Podklad</h3><button id="interiorReferenceClose" aria-label="Zavřít podklad">Zavřít ×</button></div><img id="interiorReferenceImage" alt="Dodaný podklad interiéru"><p class="int-ref-caption" id="interiorReferenceCaption"></p></dialog>
<div id="toast"></div>
<div id="loading">
<div class="spinner"></div>
<div class="label">Na&#269;&#237;t&#225;m 3D model...</div>
</div>
<aside id="standardsPanel" aria-label="Výběr standardů ALLSTAV">
<div class="std-head"><div class="std-kicker">ALLSTAV / STANDARDY 2025</div><div class="std-head-row"><h2>Váš dům, váš výběr</h2><button id="stdClose" class="std-close" aria-label="Zavřít standardy" type="button">×</button></div><p>Katalogové varianty a materiály přímo v modelu.</p></div>
<div class="std-tabs" role="tablist" aria-label="Kategorie standardů"><button role="tab" id="std-tab-exterior" data-std-tab="exterior" aria-selected="true" aria-controls="std-exterior">Exteriér</button><button role="tab" id="std-tab-interior" data-std-tab="interior" aria-selected="false" aria-controls="std-interior">Interiér</button><button role="tab" id="std-tab-summary" data-std-tab="summary" aria-selected="false" aria-controls="std-summary">Můj výběr</button></div>
<div class="std-scroll"><div id="std-exterior" role="tabpanel" aria-labelledby="std-tab-exterior"></div><div id="std-interior" role="tabpanel" aria-labelledby="std-tab-interior" hidden></div><div id="std-summary" role="tabpanel" aria-labelledby="std-tab-summary" hidden></div></div>
<div class="std-footer"><div id="stdSaved" class="std-saved" role="status">Připraveno</div><div class="std-footer-row"><button class="std-action dark" data-std-action="summary">Přehled výběru</button><button class="std-action" data-std-action="exportJson">Zálohovat výběr</button></div></div></aside>
<dialog id="stdCatalogDialog" aria-labelledby="stdCatalogTitle"><div class="std-dialog-head"><h3 id="stdCatalogTitle">Katalog ALLSTAV</h3><button id="stdCatalogClose">Zavřít ×</button></div><img id="stdCatalogImage" alt="Katalog ALLSTAV"><p id="stdCatalogCaption"></p><div class="std-dialog-pager"><button id="stdCatalogPrev">← Předchozí</button><button id="stdCatalogNext">Další →</button></div></dialog>
<dialog id="stdResetDialog" aria-labelledby="stdResetTitle"><h3 id="stdResetTitle">Obnovit výchozí výběr?</h3><p>Vaše volby standardů budou nahrazeny výchozím nastavením. Před obnovením je můžete zálohovat jako JSON. Slunce a okolí domu se nemění.</p><div class="std-two"><button id="stdResetCancel" class="std-action">Zrušit</button><button id="stdResetConfirm" class="std-action dark">Obnovit</button></div></dialog>
<input id="stdImport" type="file" accept=".json,application/json" hidden>
<script id="stdSavedState" type="application/json">{}</script>
<script type="module" src="./js/bootstrap.js"></script>
<div id="webStatus" role="status">Načítání modelu…</div>
<button id="projectToggle" class="btn" type="button">Projekt a podklady</button>
<dialog id="projectDialog" aria-labelledby="projectTitle">
<div class="web-dialog-head"><div><small>LOČENICE / ZDROJOVÝ MODEL</small><h2 id="projectTitle">Projekt a podklady</h2></div><button id="projectClose" class="btn" aria-label="Zavřít">Zavřít</button></div>
<div class="web-dialog-body">
<p class="web-lead">Dům, katalog a ovládání jsou samostatné soubory. Stavební geometrie vychází z dosavadního modelu, vybavení z referenčních obrázků.</p>
<h3>Kuchyň a obývací pokoj</h3><div id="projectPhotos" class="web-photo-grid"></div>
<p class="web-note">Fotografie se liší v rámech oken a detailech nábytku. Jde o vzhledové reference, nikoli o výrobní výkres kuchyně nebo zaměřené panorama.</p>
<h3>Pozemek a výšky</h3><div id="surveyStats" class="web-stat-grid"></div>
<p class="web-note">Body výškopisu jsou v Bpv; poloha v 3D je převzatá z grafického odečtu PDF. Upravený terén u domu je jiná vrstva než původní zaměření. Rozpor plochy není opraven natažením modelu.</p>
<div class="web-actions"><button class="btn" id="surveyShow">Zobrazit body v modelu</button><button class="btn" data-int-ref="survey">Otevřít výškopis</button><a class="btn" href="./data/site.json" download="locenice_pozemek.json">Data pozemku JSON</a></div>
<h3>Editace modelu</h3><p>Soubor <code>models/dum.glb</code> lze importovat do Blenderu. Kompletní zdroj obsahuje i skryté varianty dveří, žaluzií a interiéru. Export „viditelný výběr“ je naopak pouze konkrétní varianta.</p>
<div class="web-actions"><a class="btn primary" href="./models/dum.glb" download="dum_zdrojovy.glb">Stáhnout celý zdrojový GLB</a><a class="btn" href="./data/catalog.json" download="allstav_katalog.json">Katalog JSON</a></div>
<p class="web-note" id="backendNote"></p>
</div>
</dialog>
<svg id="surveyOverlay" aria-label="Body výškopisu" style="display:none"></svg>
<noscript><div class="web-fatal">Pro 3D model povolte JavaScript. <a href="./models/dum.glb">Stáhnout GLB</a></div></noscript>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
/* Deployment-specific UI; the original configurator layout stays independent. */
#webStatus{position:fixed;left:32px;bottom:13px;z-index:5;font-size:9px;color:#5e6b5b;pointer-events:none;background:#fafcf5d9;padding:3px 6px;border-radius:5px}
#projectToggle{position:fixed;left:32px;top:291px;z-index:5;font-size:11px;padding:9px 13px}
body.interior-active #projectToggle{top:76px;left:25px}
#projectDialog{width:min(920px,calc(100% - 32px));max-height:90dvh;padding:0;border:1px solid #dce4d5;border-radius:17px;color:#263b2c;background:#fafcf6;box-shadow:0 20px 85px #0a221740}
#projectDialog::backdrop{background:#13271988;backdrop-filter:blur(4px)}
.web-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:23px 27px;border-bottom:1px solid #dae2d5;position:sticky;top:0;background:#fafcf6;z-index:1}
.web-dialog-head small{font-size:9px;letter-spacing:2px;color:#6b7f64}.web-dialog-head h2{font-weight:500;font-size:25px;margin:7px 0 0}
.web-dialog-body{padding:8px 27px 25px;font-size:13px;line-height:1.65}.web-dialog-body h3{font-weight:600;font-size:16px;margin:24px 0 13px}.web-lead{font-size:14px;color:#657461}
.web-note{font-size:11px;color:#6c7865}.web-photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.web-photo-grid button{padding:0;border:1px solid #d9e0d1;border-radius:10px;overflow:hidden;background:white;text-align:left;line-height:1.5}.web-photo-grid img{display:block;width:100%;height:210px;object-fit:cover}.web-photo-grid span{display:block;padding:10px 12px;font-size:12px}
.web-stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.web-stat-grid>div{background:#edf2e9;padding:14px;border-radius:9px}.web-stat-grid strong{display:block;font-size:19px;font-weight:500}.web-stat-grid span{display:block;font-size:10px;margin-top:4px;color:#6d7e65;line-height:1.5}
.web-actions{display:flex;flex-wrap:wrap;gap:9px;margin:15px 0}.web-actions a{display:inline-block;text-decoration:none}.web-actions .btn{line-height:1.4}
#surveyOverlay{position:fixed;top:0;left:0;pointer-events:none;z-index:2;overflow:hidden}#surveyOverlay circle{fill:#d97028;stroke:white;stroke-width:1}#surveyOverlay text{font:10px Arial;fill:#293e2b;stroke:#ffffffe6;stroke-width:3;paint-order:stroke;stroke-linejoin:round}
.web-fatal{position:fixed;inset:20%;z-index:1000;background:white;padding:40px;font:18px Arial;color:#26342c}
@media(max-width:850px){#projectToggle{top:206px;left:18px;font-size:10px;padding:8px 11px}body.interior-active #projectToggle{top:65px;left:16px}#webStatus{bottom:7px;left:12px;font-size:8px}.web-dialog-head{padding:17px}.web-dialog-body{padding:8px 17px 20px}.web-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.web-photo-grid img{height:130px}.web-dialog-head h2{font-size:21px}#projectDialog{width:calc(100% - 18px);max-height:94dvh}body.std-open #projectToggle{display:none}}
@media(max-width:420px){.web-photo-grid{grid-template-columns:1fr}.web-photo-grid img{height:175px}.web-actions .btn{flex:1;min-width:130px;text-align:center}.web-stat-grid{gap:7px}.web-stat-grid>div{padding:11px}}
@media(max-height:550px){#projectToggle{top:auto;bottom:120px}}
@media(max-width:850px){body.std-open #webStatus{display:none}}
+15
View File
@@ -0,0 +1,15 @@
{
"version": "locenice-web-01",
"model": "./models/dum.glb",
"renderer": "auto",
"threeModule": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"threeLoader": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/loaders/GLTFLoader.js",
"cdnTimeoutMs": 6500,
"maxPixelRatio": 1.5,
"startAutoRotate": false,
"catalog": "./data/catalog.json",
"assets": "./data/assets.json",
"references": "./data/references.json",
"sceneMetadata": "./data/scene.json",
"site": "./data/site.json"
}
+52
View File
@@ -0,0 +1,52 @@
{
"page12": "./assets/catalog/page12.jpg",
"page13": "./assets/catalog/page13.jpg",
"page14": "./assets/catalog/page14.jpg",
"page15": "./assets/catalog/page15.jpg",
"page16": "./assets/catalog/page16.jpg",
"page17": "./assets/catalog/page17.jpg",
"page18": "./assets/catalog/page18.jpg",
"page19": "./assets/catalog/page19.jpg",
"page20": "./assets/catalog/page20.jpg",
"page21": "./assets/catalog/page21.jpg",
"page22": "./assets/catalog/page22.jpg",
"page23": "./assets/catalog/page23.jpg",
"page24": "./assets/catalog/page24.jpg",
"page25": "./assets/catalog/page25.jpg",
"lam1": "./assets/catalog/lam1.jpg",
"lam2": "./assets/catalog/lam2.jpg",
"lam3": "./assets/catalog/lam3.jpg",
"lam4": "./assets/catalog/lam4.jpg",
"lam5": "./assets/catalog/lam5.jpg",
"lam6": "./assets/catalog/lam6.jpg",
"lam7": "./assets/catalog/lam7.jpg",
"lam8": "./assets/catalog/lam8.jpg",
"lam9": "./assets/catalog/lam9.jpg",
"tile1": "./assets/catalog/tile1.jpg",
"tile2": "./assets/catalog/tile2.jpg",
"tile3": "./assets/catalog/tile3.jpg",
"tile4": "./assets/catalog/tile4.jpg",
"tile5": "./assets/catalog/tile5.jpg",
"tile6": "./assets/catalog/tile6.jpg",
"tile7": "./assets/catalog/tile7.jpg",
"tile8": "./assets/catalog/tile8.jpg",
"tile9": "./assets/catalog/tile9.jpg",
"door0": "./assets/catalog/door0.jpg",
"door1": "./assets/catalog/door1.jpg",
"door2": "./assets/catalog/door2.jpg",
"door3": "./assets/catalog/door3.jpg",
"door4": "./assets/catalog/door4.jpg",
"door5": "./assets/catalog/door5.jpg",
"door6": "./assets/catalog/door6.jpg",
"door7": "./assets/catalog/door7.jpg",
"requestedDoor": "./assets/catalog/requestedDoor.jpg",
"roof0": "./assets/catalog/roof0.jpg",
"roof1": "./assets/catalog/roof1.jpg",
"roof2": "./assets/catalog/roof2.jpg",
"roof3": "./assets/catalog/roof3.jpg",
"roof4": "./assets/catalog/roof4.jpg",
"intsill0": "./assets/catalog/intsill0.jpg",
"intsill1": "./assets/catalog/intsill1.jpg",
"intsill2": "./assets/catalog/intsill2.jpg",
"prum": "./assets/catalog/prum.jpg"
}
+529
View File
@@ -0,0 +1,529 @@
{
"rooms": [
[
"101",
"Zádveří",
"5,20",
"lam"
],
[
"102",
"Chodba",
"6,36",
"lam"
],
[
"103",
"Pokoj 1",
"10,62",
"lam"
],
[
"104",
"Pokoj 2",
"12,35",
"lam"
],
[
"105",
"Koupelna",
"7,72",
"tile"
],
[
"106",
"WC",
"1,40",
"tile"
],
[
"107",
"Ložnice",
"15,86",
"lam"
],
[
"108",
"Obývací pokoj + KK",
"37,69",
"lam"
],
[
"109",
"Technická místnost",
"3,09",
"tile"
]
],
"options": {
"entryModel": [
[
"solid",
"Plné",
"",
false,
"door0"
],
[
"line1",
"Jemná linka",
"",
true,
"door1"
],
[
"line2",
"Dvě linky",
"",
true,
"door2"
],
[
"slit",
"Svislé sklo",
"",
true,
"door3"
],
[
"three",
"3 skla",
"",
true,
"requestedDoor"
],
[
"four",
"4 skla",
"",
true,
"door5"
],
[
"five",
"5 skel",
"",
true,
"door6"
],
[
"wide",
"Velké sklo",
"",
true,
"door7"
]
],
"entryColor": [
[
"3003",
"RAL 3003",
"#8d1d2c"
],
[
"7012",
"RAL 7012",
"#575d5e"
],
[
"7016",
"RAL 7016",
"#383e42"
],
[
"9016",
"RAL 9016",
"#f1f0ea"
]
],
"extSill": [
[
"anthracite",
"Antracit",
"#383e42"
],
[
"white",
"Bílá",
"#eeefeb"
],
[
"elox",
"Elox",
"#bfc3c4"
],
[
"silver",
"Stříbrná",
"#b2b5b5"
],
[
"brown",
"Hnědá",
"#5c4338",
true
]
],
"gutter": [
[
"015",
"015 CERN",
"#262b2b"
],
[
"087",
"087 TMSE",
"#444f52"
],
[
"035",
"035 BRSE",
"#394c55"
],
[
"434",
"434 HNED",
"#5a4441"
],
[
"758",
"758 TMCE",
"#82282d",
true
],
[
"742",
"742 CICE",
"#a34832",
true
],
[
"045",
"045 SMET",
"#aaaeb2",
true
],
[
"044",
"044 SEME",
"#82878b",
true
]
],
"roof": [
[
"chestnut",
"Kaštanová",
"#6e3930",
false,
"roof0"
],
[
"brick",
"Cihlová",
"#934837",
false,
"roof1"
],
[
"terracotta",
"Terakota",
"#94402e",
false,
"roof2"
],
[
"black",
"Černá",
"#252a2b",
false,
"roof3"
],
[
"darkgray",
"Tmavě šedivá",
"#464a48",
false,
"roof4"
]
],
"blindColor": [
[
"140H",
"140 H",
"#e6e7e5"
],
[
"9007",
"9007",
"#969996"
],
[
"7012",
"7012",
"#575d5e"
],
[
"7022",
"7022",
"#4c4a44"
],
[
"7016",
"7016",
"#383e42"
]
],
"intSill": [
[
"white",
"Bílá",
"#edecea",
false,
"intsill0"
],
[
"gray",
"Žíhaná šedá",
"#b9b9b5",
false,
"intsill1"
],
[
"brown",
"Žíhaná hnědá",
"#b4a692",
false,
"intsill2"
]
],
"intHandle": [
[
"square",
"Hranatá"
],
[
"round",
"Oblá"
]
],
"shower": [
[
"tray",
"S vaničkou"
],
[
"walkin",
"Walk-in"
]
],
"kitchenColor": [
[
"#35463e",
"Zelená",
"#35463e"
],
[
"#eeeae1",
"Krémová",
"#eeeae1"
],
[
"#e9e9e5",
"Bílá",
"#e9e9e5"
],
[
"#b3aaa0",
"Kašmír",
"#b3aaa0"
],
[
"#686d69",
"Šedá",
"#686d69"
],
[
"#343a3d",
"Antracit",
"#343a3d"
]
],
"worktop": [
[
"oak",
"Dřevo",
"#b59369"
],
[
"white",
"Bílá",
"#eeeeea"
],
[
"graphite",
"Grafit",
"#444946"
],
[
"stone",
"Kámen",
"#c7c5b9"
]
],
"backsplash": [
[
"stone",
"Světlý kámen",
"#c7c5b9"
],
[
"white",
"Bílá",
"#eaeae5"
],
[
"worktop",
"Jako deska",
"#b59369"
]
],
"faucet": [
[
"chrome",
"Chrom",
"#bec1c1"
],
[
"black",
"Černá",
"#303434"
]
],
"wallPaint": [
[
"#ffffff",
"HET bílá",
"#ffffff"
],
[
"#eeeae1",
"Teplá bílá",
"#eeeae1"
],
[
"#ded4c7",
"Písková",
"#ded4c7"
],
[
"#ccd2c5",
"Šalvěj",
"#ccd2c5"
]
],
"kitchenBodyColor": [
[
"#35463e",
"Zelená",
"#35463e"
],
[
"#eeeae1",
"Krémová",
"#eeeae1"
],
[
"#e9e9e5",
"Bílá",
"#e9e9e5"
],
[
"#b3aaa0",
"Kašmír",
"#b3aaa0"
],
[
"#686d69",
"Šedá",
"#686d69"
],
[
"#343a3d",
"Antracit",
"#343a3d"
]
]
},
"defaults": {
"entryModel": "three",
"entryColor": "7016",
"extSill": "anthracite",
"gutter": "015",
"roof": "darkgray",
"blinds": false,
"blindColor": "7016",
"blindDrop": 100,
"floors": {
"101": {
"type": "lam",
"sample": 6
},
"102": {
"type": "lam",
"sample": 6
},
"103": {
"type": "lam",
"sample": 6
},
"104": {
"type": "lam",
"sample": 6
},
"105": {
"type": "tile",
"sample": 1
},
"106": {
"type": "tile",
"sample": 1
},
"107": {
"type": "lam",
"sample": 6
},
"108": {
"type": "lam",
"sample": 6
},
"109": {
"type": "tile",
"sample": 1
}
},
"wallTile105": 1,
"wallTile106": 1,
"tileCorner": "profile",
"intSill": "white",
"wallPaint": "#ffffff",
"intDoor": "sample",
"intDoorColor": "#c3aa85",
"intDoorCode": "",
"intDoorType": "hinged",
"intHandle": "square",
"kitchenColor": "#35463e",
"kitchenBodyColor": "#35463e",
"worktop": "oak",
"backsplash": "stone",
"shower": "tray",
"faucet": "chrome",
"hood": "facade",
"fans": true,
"aircon": true,
"ventilation": "local",
"hotWaterCirculation": false,
"radiator": "electric",
"note": ""
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"plan": {
"title": "D.1.1.2.1.1 · Půdorys 1. NP",
"caption": "Dodaný projektový podklad. Dispozice a popisky místností vycházejí z tohoto výkresu.",
"src": "./assets/references/plan.jpg"
},
"kitchen1": {
"title": "Kuchyň · mrvz1_uprava.jpg",
"caption": "Dodaný referenční obrázek kuchyně. V modelu jsou moduly přizpůsobené půdorysu; jejich výrobní rozměry nejsou doložené.",
"src": "./assets/references/kitchen1.jpg"
},
"kitchen2": {
"title": "Kuchyň · mrvz2.jpeg",
"caption": "Dodaný referenční obrázek kuchyně.",
"src": "./assets/references/kitchen2.jpg"
},
"view1": {
"title": "Výhled · pohled_ven.jpeg",
"caption": "Dodaný referenční obrázek obytného prostoru a výhledu. Není použit jako georeferencované panorama.",
"src": "./assets/references/view1.jpg"
},
"view2": {
"title": "Obytný prostor · PHOTO-2026-05-26-20-38-11.jpg",
"caption": "Dodaný referenční obrázek materiálů, trámů a obytného prostoru.",
"src": "./assets/references/view2.jpg"
},
"survey": {
"title": "Polohopis a vyskopis - dodany podklad",
"caption": "Vysky jsou v Bpv. Body v modelu maji polohu graficky odectenou z PDF; nejde o zamerene souradnice domu.",
"src": "./assets/references/survey.png"
}
}
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
{
"version": "2026-10-site-01",
"referenceElevation": 591.84,
"source": "C.2-C.5 and Locenice p.c.3480/37 polohopis a vyskopis; digitized PDF vectors, not survey coordinates.",
"cadastralAreaInProject": 1010,
"digitizedOutlineArea": 987.89,
"note": "The graphical outline is about 988 m2, versus 1010 m2 in the project. It is NOT scaled to force area equality; house dimensions are unchanged.",
"finishedGroundC3": [
[
-7.67,
4.695,
-0.48
],
[
-7.67,
-4.695,
-0.48
],
[
3.713,
-4.695,
-0.992
],
[
3.713,
-0.688,
-0.871
],
[
7.67,
-0.688,
-1.14
],
[
7.67,
4.695,
-0.971
],
[
-1.113,
4.695,
-0.48
]
],
"surveyPoints": [
{
"id": 1,
"xz": [
-22.86387745468233,
6.9162902718421835
],
"altitude": 593.13
},
{
"id": 2,
"xz": [
-26.00649803769423,
5.740604360018231
],
"altitude": 593.19
},
{
"id": 3,
"xz": [
-22.733664786123356,
-2.9186632403462283
],
"altitude": 592.64
},
{
"id": 4,
"xz": [
-19.66526683318922,
-1.6042685750232388
],
"altitude": 592.54
},
{
"id": 5,
"xz": [
-16.849177040431528,
-9.283203935982339
],
"altitude": 592.08
},
{
"id": 6,
"xz": [
-19.809586816163616,
-10.38887051378567
],
"altitude": 592.09
},
{
"id": 7,
"xz": [
-19.100913252951532,
0.5664836927799971
],
"altitude": 592.84
},
{
"id": 8,
"xz": [
-21.249447968471955,
7.071889756172209
],
"altitude": 592.85
},
{
"id": 9,
"xz": [
-19.62233327368599,
2.5536226040233165
],
"altitude": 592.59
},
{
"id": 10,
"xz": [
-9.809681750990507,
7.518430876054446
],
"altitude": 592.14
},
{
"id": 11,
"xz": [
-8.73082925476737,
0.6499201351270848
],
"altitude": 591.85
},
{
"id": 12,
"xz": [
7.4440611266407775,
0.4236299245776415
],
"altitude": 590.74
},
{
"id": 13,
"xz": [
8.057613164117942,
7.427291898661351
],
"altitude": 590.96
},
{
"id": 14,
"xz": [
22.42368569156818,
7.487149207097606
],
"altitude": 589.96
},
{
"id": 15,
"xz": [
22.980738978199824,
0.027095859342831083
],
"altitude": 589.65
},
{
"id": 16,
"xz": [
25.962873515635593,
7.010131955908398
],
"altitude": 589.82
},
{
"id": 17,
"xz": [
36.80267751573004,
5.954683418127402
],
"altitude": 589.03
},
{
"id": 18,
"xz": [
37.69887332216715,
-0.19213899494552378
],
"altitude": 588.82
},
{
"id": 19,
"xz": [
47.409464441441116,
4.094040845154811
],
"altitude": 588.54
},
{
"id": 20,
"xz": [
51.16376748811935,
-2.0537658393134657
],
"altitude": 588.06
},
{
"id": 21,
"xz": [
54.12316842449226,
-9.187005561860445
],
"altitude": 587.79
},
{
"id": 22,
"xz": [
41.075319538836055,
-8.900127720728307
],
"altitude": 588.45
},
{
"id": 23,
"xz": [
23.482474278537843,
-7.205827671852879
],
"altitude": 589.47
},
{
"id": 24,
"xz": [
6.9940948033230175,
-7.604159411778007
],
"altitude": 590.56
},
{
"id": 25,
"xz": [
-5.631153944681365,
-8.570201470985344
],
"altitude": 591.41
},
{
"id": 26,
"xz": [
-14.560744944331192,
-8.695220715590304
],
"altitude": 591.9
},
{
"id": 27,
"xz": [
-17.020111569954853,
-2.760537937058604
],
"altitude": 592.24
}
],
"parkingSpot": 0.39,
"entranceSpot": -0.48,
"gardenStairSpot": -1.395,
"originalNodeCount": 111,
"originalBinaryBytes": null,
"terrainClosure": {
"version": "2",
"description": "Continuous boundary walls and underside added to the three terrain slabs. Original grass surfaces and house geometry unchanged.",
"thickness_m": 0.6
},
"cadastralSource": "VYKAZ DOSAVADNIHO A NOVEHO STAVU UDAJU KN, plan 738-33/2023, 1.8.2023",
"cadastralCoordinates": [
{
"id": "461-1364",
"Y": 754888.21,
"X": 1183134.81,
"qualityCode": 8
},
{
"id": "461-1508",
"Y": 754858.48,
"X": 1183140.31,
"qualityCode": 8
},
{
"id": "559-1",
"Y": 754878.61,
"X": 1183200.41,
"qualityCode": 8
},
{
"id": "559-2",
"Y": 754846.53,
"X": 1183210.96,
"qualityCode": 8
},
{
"id": "1",
"Y": 754873.34,
"X": 1183137.56,
"qualityCode": 8
},
{
"id": "2",
"Y": 754860.49,
"X": 1183206.37,
"qualityCode": 8
},
{
"id": "3",
"Y": 754866.57,
"X": 1183185.6,
"qualityCode": 3
}
],
"cadastralBoundary3480_37": [
"1",
"461-1508",
"559-2",
"2",
"3"
],
"sourceDistinction": "Cadastral coordinates are S-JTSK plan coordinates. Survey heights are Bpv. Survey xz is a graphical registration in the existing model, not a geodetic transformation. No house setting-out coordinates were provided."
}
+10
View File
@@ -0,0 +1,10 @@
<?php
// Upload this whole folder, not only this file. No Node.js or database required.
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff');
$file = __DIR__ . '/app.html';
if (!is_readable($file)) {
http_response_code(500);
exit('Chybi app.html. Nahrajte celou slozku locenice.');
}
readfile($file);
+992
View File
@@ -0,0 +1,992 @@
import {solarPosition} from './solar.js';
import {parseGLB,encodeGLB} from './model-io.js';
import {createLegacyRenderer} from './legacy-renderer.js';
import {installProjectPanel} from './project-panel.js';
const resources=window.LOCENICE_RESOURCES,config=resources.config;
let backend=null,projectPanel=null;
'use strict';
const $ = s => document.querySelector(s), canvas = $('#view');
let originalBytes = resources.modelBytes, gltf = null, binary = null, gl = null, meshes = [], shadowProgram, program, shadowFbo, shadowTex;
let showTerrain = true, showMeasure = false, autoRotate = false, orthographic = false, dirty = true, lastTime = 0;
let target = [.8, 1.15, 0], theta = 2.25, phi = .30, distance = 35.0, aspect = 1, vp, lightVP, eye = [0, 0, 0];
let animation = null, pointerMap = new Map(), pinch = null, wheelTimer = 0;
const initialCamera = { theta: 2.25, phi: .30, distance: 35.0, target: [.8, 1.15, 0], ortho: false };
// Opening composition follows the supplied reference; house geometry is unchanged.
// Keep this separate from the existing Entry / Terrace / Top view presets.
const startupCamera = { theta: .63, phi: .30, distance: 41.35, target: [-2.8, 2.3, 0], ortho: false };
function applyStartupView() {
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
$('#elevation').value = '';
$('#panel').classList.remove('open');
$('#infoButton').classList.remove('active');
// Match the existing portrait resize rule, without fitting the entire long parcel.
setCamera({ ...startupCamera, distance: startupCamera.distance * Math.max(1, 1 / aspect) }, 'Voln\u00fd pohled', true);
autoRotate = true;
$('#rotateButton').classList.add('active');
dirty = true;
}
const originalColors = {};
const HARDIE_DEFAULT = 'oriskove-hneda';
const hardieTextures = [], hardieImages = [], hardieObjectURLs = [];
let hardieSelected = HARDIE_DEFAULT;
async function loadHardieTextures(){backend.images.forEach((im,i)=>hardieImages[i]=im);}
function hardieLinearRGB(hex) { return hexToRgb(hex).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4); }
function setHardieColor(id) {
const data = gltf?.extras?.hardiePlank;
const color = data?.palette.find(c => c.id === id || c.code === id || c.name === id);
if (!color) return false;
hardieSelected = color.id;
const boards = gltf.materials[data.boardMaterial], backing = gltf.materials[data.backingMaterial];
boards.name = data.product + ' - ' + color.name;
boards.pbrMetallicRoughness.baseColorFactor = [1, 1, 1, 1];
boards.pbrMetallicRoughness.baseColorTexture.index = color.texture;
boards.extras.color = color.name; boards.extras.productCode = color.code;
backing.pbrMetallicRoughness.baseColorFactor = [...hardieLinearRGB(color.hex).map(c => c * .72), 1];
data.selectedColor = { ...color };
$('#hardieColorName').textContent = color.name;
$('#hardieProductCode').textContent = color.code + (color.id === HARDIE_DEFAULT ? ' \u00b7 v\u00fdchoz\u00ed' : '');
const preview = $('#hardiePreview'); preview.style.backgroundColor = color.hex;
if (hardieImages[color.texture]) preview.style.backgroundImage = 'url("' + hardieImages[color.texture].src + '")';
preview.setAttribute('aria-label', data.product + ', ' + color.name + ' - orienta\u010dn\u00ed n\u00e1hled');
document.querySelectorAll('input[name="hardieColor"]').forEach(input => {
input.checked = input.value === color.id; input.closest('label').classList.toggle('selected', input.checked);
});
dirty = true; return true;
}
function focusHardieDetail(key) { if(interior.enabled)toggleInterior(false);
const portraitScale = Math.max(1, 1 / aspect);
const poses = {
entry: { theta: -.07, phi: .075, distance: 13.1 * portraitScale, target: [-4.05, 1.33, 3.90], ortho: false },
terrace: { theta: 2.43, phi: .10, distance: 12.5 * portraitScale, target: [5.30, 1.35, -2.20], ortho: false },
structure: { theta: Math.PI / 2, phi: 0, distance: 1.6 * portraitScale, target: [3.729, 1.32, -4.03], ortho: false }
};
if (!poses[key]) return;
$('#panel').classList.remove('open'); $('#infoButton').classList.remove('active');
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
$('#elevation').value = '';
setCamera(poses[key], key === 'entry' ? 'HardiePlank \u00b7 vstup' : key === 'terrace' ? 'HardiePlank \u00b7 terasa' : 'HardiePlank \u00b7 detail struktury');
}
function initHardiePalette() {
document.querySelectorAll('input[name="hardieColor"]').forEach(input => {
input.addEventListener('change', () => { if (input.checked) setHardieColor(input.value); });
});
document.querySelectorAll('[data-cladding-view]').forEach(button => button.onclick = () => focusHardieDetail(button.dataset.claddingView));
setHardieColor(HARDIE_DEFAULT);
window.HARDIE_PLANK = {
getState: () => ({ product: gltf.extras.hardiePlank.product, selected: { ...gltf.extras.hardiePlank.selectedColor }, defaultColor: HARDIE_DEFAULT,
palette: gltf.extras.hardiePlank.palette.map(c => ({ ...c })), boardNodes: [...gltf.extras.hardiePlank.boardNodes],
backingNodes: [...gltf.extras.hardiePlank.backingNodes], scope: 'facade_only', originalGeometryPreserved: true, textureIsIllustrative: true }),
setColor: id => setHardieColor(id), reset: () => setHardieColor(HARDIE_DEFAULT), detail: key => focusHardieDetail(key)
};
}
const vadd = (a, b) => a.map((x, i) => x + b[i]), vsub = (a, b) => a.map((x, i) => x - b[i]), scale = (a, s) => a.map(x => x * s), dot = (a, b) => a.reduce((r, x, i) => r + x * b[i], 0), norm = a => scale(a, 1 / Math.hypot(...a)), cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
function mul(a, b) { const c = new Float32Array(16); for (let j = 0; j < 4; j++)for (let i = 0; i < 4; i++)for (let k = 0; k < 4; k++)c[j * 4 + i] += a[k * 4 + i] * b[j * 4 + k]; return c; }
function lookAt(e, t, up = [0, 1, 0]) { const z = norm(vsub(e, t)), x = norm(cross(up, z)), y = cross(z, x); return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, e), -dot(y, e), -dot(z, e), 1]); }
function perspective(fovy, asp, n, f) { const q = 1 / Math.tan(fovy / 2); return new Float32Array([q / asp, 0, 0, 0, 0, q, 0, 0, 0, 0, (f + n) / (n - f), -1, 0, 0, 2 * f * n / (n - f), 0]); }
function ortho(l, r, b, t, n, f) { return new Float32Array([2 / (r - l), 0, 0, 0, 0, 2 / (t - b), 0, 0, 0, 0, -2 / (f - n), 0, -(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1]); }
function point(m, p) { const a = [...p, 1], o = [0, 0, 0, 0]; for (let i = 0; i < 4; i++)for (let j = 0; j < 4; j++)o[i] += m[j * 4 + i] * a[j]; return o; }
function getAccessor(i) { const a = gltf.accessors[i], v = gltf.bufferViews[a.bufferView]; return { a, v, offset: (v.byteOffset || 0) + (a.byteOffset || 0) }; }
function visible(m) { return standardsVisible(m) && interiorVisibility(m) && meshVisible(m); }
async function initGL(){
const requested=new URLSearchParams(location.search).get('renderer')||config.renderer;
let primaryError=null;
if(requested!=='legacy')try{
const task=(async()=>{const {createThreeRenderer}=await import('./three-renderer.js');return await createThreeRenderer({canvas,gltf,binary,bytes:originalBytes,config});})();
// Do not fall back after WebGL initialization begins: that would double-bind a canvas.
backend=await task;
}catch(error){primaryError=error;console.warn('Three.js unavailable, using local renderer:',error);}
if(!backend){if(requested==='three')throw primaryError||Error('Three.js unavailable');backend=await createLegacyRenderer({canvas,gltf,binary});}
gl=backend;meshes=backend.meshes;
gltf.materials.forEach((m,i)=>originalColors[i]=[...(m.pbrMetallicRoughness?.baseColorFactor||[.7,.7,.7,1])]);
window.MODEL_INFO={objects:meshes.length,triangles:meshes.reduce((n,m)=>n+m.count/3,0),loaded:true,backend:backend.backend};
document.getElementById('webStatus').textContent=backend.label+' / GLB';
resize();
}
function resize() { if (!gl) return; const r = canvas.getBoundingClientRect(), w = r.width, h = r.height, dpr = Math.min(devicePixelRatio || 1, 1.7); const newAspect = w / h; if (window.SUN_SIM && !interior.enabled) { distance *= Math.max(1, 1 / newAspect) / Math.max(1, 1 / aspect); animation = null; initialCamera.distance = Math.max(41, 38 / newAspect); presetDefs.entry.distance = Math.max(39, 38 / newAspect); presetDefs.top.distance = Math.max(46, 25 / newAspect); presetDefs.top.theta = newAspect < 1 ? Math.PI / 2 : 0; } viewW = w; viewH = h; backend.resize(w,h,Math.min(dpr,config.maxPixelRatio||1.5)); aspect = newAspect; if(interior.enabled && interior.mode !== 'walk' && !interior.userCamera)interiorFitCamera(); dirty = true; }
function camera() { if(interior.enabled && interior.mode === 'walk'){interiorCamera();return;} eye = vadd(target, [Math.sin(theta) * Math.cos(phi) * distance, Math.sin(phi) * distance, Math.cos(theta) * Math.cos(phi) * distance]); const view = lookAt(eye, target); const span = distance * .28; const projection = orthographic ? ortho(-span * aspect, span * aspect, -span, span, .1, 1000) : perspective(34 * Math.PI / 180, aspect, .1, 1000); vp = mul(projection, view); }
function render(){
if(!backend)return;
camera();
const lookTarget=interior.enabled&&interior.mode==='walk'?vadd(eye,[Math.sin(interior.yaw)*Math.cos(interior.pitch),Math.sin(interior.pitch),Math.cos(interior.yaw)*Math.cos(interior.pitch)]):target;
backend.draw({solar,interior,eye,vp,lightVP,lookTarget,orthographic:orthographic&&!(interior.enabled&&interior.mode==='walk'),visible});
solar.shadowDirty=false;
$('#compassGroup').setAttribute('transform',`rotate(${(theta-Math.PI/2)*180/Math.PI-solar.bearing},42,42)`);
if(showMeasure)drawMeasures();if(solar.pos)solarOverlay();drawInteriorLabels();
projectPanel?.drawSurvey(project,p=>point(vp,p),viewW,viewH);
dirty=false;
}
function project(p) { let q = point(vp, p); return [(q[0] / q[3] * .5 + .5) * viewW, (-q[1] / q[3] * .5 + .5) * viewH]; }
function drawMeasures() {
const dims = [{ a: [-7.67, .12, 5.54], b: [7.67, .12, 5.54], t: '15,340 m' }, { a: [-8.48, .12, -4.695], b: [-8.48, .12, 4.695], t: '9,390 m' }, { a: [7.9, 0, 0], b: [7.9, 5.137, 0], t: '5,137 m' }];
let h = ''; for (const d of dims) { let a = project(d.a), b = project(d.b), x = (a[0] + b[0]) / 2, y = (a[1] + b[1]) / 2 - 10; h += `<line x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}"/><circle cx="${a[0]}" cy="${a[1]}" r="2.5"/><circle cx="${b[0]}" cy="${b[1]}" r="2.5"/><text x="${x}" y="${y}" text-anchor="middle">${d.t}</text>`; } $('#measure').innerHTML = h;
}
function animate(time) { requestAnimationFrame(animate); const dt = Math.min(.05, (time - lastTime) / 1000 || .016); lastTime = time; solarFrame(dt); stepInterior(dt); if (animation) { const a = animation, t = Math.min(1, (time - a.start) / 650), k = t * t * (3 - 2 * t); theta = a.from.theta + (a.to.theta - a.from.theta) * k; phi = a.from.phi + (a.to.phi - a.from.phi) * k; distance = a.from.distance + (a.to.distance - a.from.distance) * k; target = a.from.target.map((v, i) => v + (a.to.target[i] - v) * k); dirty = true; if (t >= 1) { orthographic = a.to.ortho; animation = null; } } if (autoRotate && !pointerMap.size) { theta += dt * .11; dirty = true; } if (dirty && gl) render(); }
function setCamera(to, label, instant = false) { autoRotate = false; $('#rotateButton').classList.remove('active'); if (instant) { theta = to.theta; phi = to.phi; distance = to.distance; target = [...to.target]; orthographic = to.ortho; animation = null; } else { let tt = to.theta; while (tt - theta > Math.PI) tt -= 2 * Math.PI; while (tt - theta < -Math.PI) tt += 2 * Math.PI; animation = { start: performance.now(), from: { theta, phi, distance, target: [...target] }, to: { ...to, theta: tt } }; orthographic = to.ortho; } $('#directionText').textContent = label; dirty = true; }
const presetDefs = { entry: { theta: -.59, phi: .24, distance: 33.4, target: [-.25, 1.20, 0], ortho: false }, terrace: initialCamera, top: { theta: 0, phi: 1.5706, distance: 35, target: [1, .1, 0], ortho: true } };
const presetNames = { entry: 'Vstup / severoz\u00e1pad', terrace: 'Terasa / jihov\u00fdchod', top: 'Pohled shora' };
function setPreset(key, instant = false) { if(interior.enabled)toggleInterior(false); document.querySelectorAll('[data-preset]').forEach(b => b.classList.toggle('active', b.dataset.preset === key)); $('#elevation').value = ''; setCamera(presetDefs[key], presetNames[key], instant); }
window.setPreset = setPreset;
function pan(dx, dy) { const right = [Math.cos(theta), 0, -Math.sin(theta)]; const up = norm(cross(norm(vsub(eye, target)), right)); const k = distance * .0008; target = vadd(target, vadd(scale(right, -dx * k), scale(up, dy * k))); }
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerdown', e => { if(interior.enabled && interior.mode==='walk'){e.preventDefault();canvas.setPointerCapture(e.pointerId);interior.lookPointer={id:e.pointerId,x:e.clientX,y:e.clientY};interior.keys.clear();return;} if(interior.enabled)interior.userCamera=true; canvas.setPointerCapture(e.pointerId); pointerMap.set(e.pointerId, { x: e.clientX, y: e.clientY, button: e.button }); animation = null; autoRotate = false; $('#rotateButton').classList.remove('active'); if (pointerMap.size === 2) { const p = [...pointerMap.values()]; pinch = { d: Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y), x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 }; } });
canvas.addEventListener('pointermove', e => { if(interior.enabled && interior.mode==='walk'){const p=interior.lookPointer;if(p && p.id===e.pointerId){interior.yaw-=(e.clientX-p.x)*.004;interior.pitch=clamp(interior.pitch-(e.clientY-p.y)*.003,-1.3,1.3);p.x=e.clientX;p.y=e.clientY;dirty=true;}return;} const p = pointerMap.get(e.pointerId); if (!p) return; let dx = e.clientX - p.x, dy = e.clientY - p.y; pointerMap.set(e.pointerId, { ...p, x: e.clientX, y: e.clientY }); if (pointerMap.size === 2) { const ps = [...pointerMap.values()], d = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y), x = (ps[0].x + ps[1].x) / 2, y = (ps[0].y + ps[1].y) / 2; if (pinch) { distance = Math.max(1.2, Math.min(500, distance * pinch.d / Math.max(1, d))); pan(x - pinch.x, y - pinch.y); } pinch = { d, x, y }; } else if (p.button === 2 || e.shiftKey) pan(dx, dy); else { theta -= dx * .006; phi = Math.max(.03, Math.min(1.51, phi + dy * .006)); orthographic = false; } document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active')); $('#directionText').textContent = 'Voln\u00fd pohled'; dirty = true; });
function pointerEnd(e) { if(interior.lookPointer?.id===e.pointerId)interior.lookPointer=null; pointerMap.delete(e.pointerId); pinch = null; }
canvas.addEventListener('pointerup', pointerEnd); canvas.addEventListener('pointercancel', pointerEnd);
canvas.addEventListener('wheel', e => { e.preventDefault(); if(interior.enabled && interior.mode==='walk'){interior.fov=clamp(interior.fov+e.deltaY*.025,45,88);dirty=true;return;} if(interior.enabled)interior.userCamera=true; distance = Math.max(1.2, Math.min(500, distance * Math.exp(e.deltaY * .001))); animation = null; dirty = true; }, { passive: false });
document.querySelectorAll('[data-preset]').forEach(b => b.onclick = () => setPreset(b.dataset.preset));
$('#resetView').onclick = () => setPreset('terrace');
$('#terrainButton').onclick = () => { showTerrain = !showTerrain; $('#terrainButton').classList.toggle('active', !showTerrain); updateSolar(); dirty = true; };
$('#measureButton').onclick = () => { showMeasure = !showMeasure; $('#measure').style.display = showMeasure ? 'block' : 'none'; $('#measureButton').classList.toggle('active', showMeasure); dirty = true; };
$('#rotateButton').onclick = () => { autoRotate = !autoRotate; $('#rotateButton').classList.toggle('active', autoRotate); orthographic = false; dirty = true; };
$('#infoButton').onclick = () => { $('#panel').classList.toggle('open'); $('#infoButton').classList.toggle('active'); };
$('#elevation').onchange = e => { const d = { north: [-Math.PI / 2, 'Severn\u00ed pohled'], east: [Math.PI, 'V\u00fdchodn\u00ed pohled'], south: [Math.PI / 2, 'Ji\u017en\u00ed pohled'], west: [0, 'Z\u00e1padn\u00ed pohled'] }[e.target.value]; if (!d) return; document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active')); setCamera({ theta: d[0], phi: 0, distance: aspect < .8 ? 70 : 29, target: [0, 1.8, 0], ortho: true }, d[1]); };
function hexToRgb(s) { return [1, 3, 5].map(i => parseInt(s.slice(i, i + 2), 16) / 255); }
function recolor(type, hex) { const c = hexToRgb(hex); gltf.materials.forEach((m, i) => { let ratio = 1, match = false; if (type === 'wall') match = m.name === 'stucco'; if (type === 'roof') { match = m.name.startsWith('tile'); ratio = m.name === 'tile_1' ? 1.06 : m.name === 'tile_2' ? .94 : m.name === 'tile_3' ? 1.10 : m.name === 'tile_4' ? .88 : m.name === 'tile_edge' ? .90 : 1; } if (match) m.pbrMetallicRoughness.baseColorFactor = [...c.map(v => Math.min(1, v * ratio)), 1]; }); dirty = true; }
$('#wallColor').oninput = e => recolor('wall', e.target.value); $('#roofColor').oninput = e => recolor('roof', e.target.value);
$('#resetColors').onclick = () => { for (const [i, c] of Object.entries(originalColors)) { if (!gltf.materials[+i].extras?.interiorMaterial) gltf.materials[+i].pbrMetallicRoughness.baseColorFactor = [...c]; } $('#wallColor').value = '#c9c2ad'; $('#roofColor').value = '#414542'; setHardieColor(HARDIE_DEFAULT); dirty = true; };
function toast(s) { $('#toast').textContent = s; $('#toast').style.opacity = 1; setTimeout(() => $('#toast').style.opacity = 0, 2600); }
function saveBlob(blob, name) { const a = document.createElement('a'), u = URL.createObjectURL(blob); a.href = u; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(u), 5000); }
function currentGlb() { if (!gltf) return originalBytes; const j = new TextEncoder().encode(JSON.stringify(exportVisibleGltf())), jl = Math.ceil(j.length / 4) * 4, bl = Math.ceil(binary.byteLength / 4) * 4, n = 12 + 8 + jl + 8 + bl, out = new Uint8Array(n), v = new DataView(out.buffer); v.setUint32(0, 0x46546c67, true); v.setUint32(4, 2, true); v.setUint32(8, n, true); v.setUint32(12, jl, true); v.setUint32(16, 0x4e4f534a, true); out.fill(32, 20, 20 + jl); out.set(j, 20); v.setUint32(20 + jl, bl, true); v.setUint32(24 + jl, 0x004e4942, true); out.set(binary, 28 + jl); return out; }
$('#saveGlb').onclick = () => { saveBlob(new Blob([currentGlb()], { type: 'model/gltf-binary' }), 'dum_3D_HardiePlank_' + hardieSelected + '.glb'); toast('Model GLB je p\u0159ipraven\u00fd ke sta\u017een\u00ed.'); };
$('#savePng').onclick = () => { render(); canvas.toBlob(b => saveBlob(b, 'dum_pohled.png'), 'image/png'); };
window.addEventListener('resize', resize);
window.addEventListener('keydown', e => { if(interior.enabled){handleInteriorKey(e);return;} if(e.code==='KeyI' && !/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)){toggleInterior(true);return;} if (e.key === 'Escape') { $('#panel').classList.remove('open'); $('#infoButton').classList.remove('active'); } if (/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return; if (e.key === '1') setPreset('entry'); if (e.key === '2') setPreset('terrace'); if (e.key === '3') setPreset('top'); if (e.key === 'r') setPreset('terrace'); });
async function start(){
try{
({gltf,binary}=parseGLB(originalBytes));
gltf.extras={...gltf.extras,...structuredClone(resources.scene)};
await initGL();await loadHardieTextures();initHardiePalette();initInterior();initSolar();
distance=Math.max(41,38/aspect);initialCamera.distance=distance;presetDefs.entry.distance=Math.max(39,38/aspect);presetDefs.top.distance=Math.max(46,25/aspect);
if(aspect<1)presetDefs.top.theta=Math.PI/2;initSiteControls();applyStartupView();initStandards();
projectPanel=installProjectPanel({resources,backend,getMode:()=>interior,project,focusSite:()=>{stdClose();if(interior.enabled)toggleInterior(false);focusSite('parcel');},invalidate:()=>{dirty=true;}});
autoRotate=!!config.startAutoRotate;$('#rotateButton').classList.toggle('active',autoRotate);
$('#loading').style.display='none';requestAnimationFrame(animate);
window.LOCENICE_APP={backend:backend.backend,exportSource:()=>encodeGLB(gltf,binary),getMeshes:()=>meshes.map(m=>({name:m.name,nodeIndex:m.nodeIndex,visible:visible(m),material:m.material,std:m.std})),render};
if('ontouchstart'in window)$('.help').innerHTML='Jednim prstem otacejte; dvema prsty priblizte a posunte.';
}catch(error){console.error(error);$('#loading').className='error';$('#loading').innerHTML='<div class="errorDetail"></div><a class="btn primary" href="./models/dum.glb" download>Stahnout zdrojovy GLB</a><a class="btn" href="?renderer=legacy">Zkusit lokalni renderer</a>';
$('#loading .errorDetail').textContent='Model se nepodarilo spustit: '+error.message;$('#webStatus').textContent='3D neni dostupne';}
}
// Solar geometry: Meeus / NOAA. UTC is the only time basis for the ephemeris.
// Model axes: +X = south gable, +Z = entrance (west) side, +Y = up.
const RAD = Math.PI / 180, DAY_MS = 86400000;
const solar = {
date: '2026-06-21', minute: 600, lat: 48.8242, lon: 14.5275, bearing: 351.5,
playing: false, speed: 30, shadows: true, path: false, start: 0, end: 0, length: 1440,
rise: null, set: null, noon: null, samples: [], pos: null, light: [1, 1, 1], sky: [.906, .922, .890],
sunColor: [1, 1, .95], ambient: [.40, .43, .46], direct: .85, dayFactor: 1, shadowDirty: true,
accumulator: 0, clock: 0
};
let viewW = innerWidth, viewH = innerHeight;
const mod = (x, n) => ((x % n) + n) % n;
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const smooth = (a, b, x) => { let t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const mix3 = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
const czNum = (v, d = 1) => v.toFixed(d).replace('.', ',');
const localFormatter = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Prague', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
function localParts(ms) { const o = {}; for (const p of localFormatter.formatToParts(new Date(ms))) if (p.type !== 'literal') o[p.type] = +p.value; return o; }
function utcOffset(ms) { const p = localParts(ms); return Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000) / 60000); }
function localMidnight(date) { const [y, m, d] = date.split('-').map(Number), naive = Date.UTC(y, m - 1, d); let u = naive; for (let i = 0; i < 4; i++)u = naive - utcOffset(u) * 60000; return u; }
function isoDate(ms) { const p = localParts(ms); return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`; }
function clockText(ms) { if (ms === null || !Number.isFinite(ms)) return '\u2014'; const p = localParts(ms); return String(p.hour).padStart(2, '0') + ':' + String(p.minute).padStart(2, '0'); }
function czDate(iso) { const [y, m, d] = iso.split('-').map(Number); return `${d}. ${m}. ${y}`; }
function zoneText(ms) { const o = utcOffset(ms); return (o === 120 ? 'SEL\u010c' : o === 60 ? 'SE\u010c' : 'Praha') + ' \u00b7 UTC' + (o >= 0 ? '+' : '') + o / 60; }
function solarVector(azimuth, elevation) { const a = (azimuth - solar.bearing) * RAD, e = elevation * RAD; return [-Math.cos(a) * Math.cos(e), Math.sin(e), -Math.sin(a) * Math.cos(e)]; }
function timeForClock(text) {
const [h, m] = text.split(':').map(Number); if (!/^\d{1,2}:\d{2}$/.test(text) || !Number.isFinite(h) || !Number.isFinite(m) || h < 0 || h > 23 || m < 0 || m > 59) return null; text = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
const [y, mo, d] = solar.date.split('-').map(Number), naive = Date.UTC(y, mo - 1, d, h, m);
const offsets = [...new Set([utcOffset(solar.start - 12 * 3600000), utcOffset(solar.start), utcOffset(solar.end - 1), utcOffset(solar.end + 12 * 3600000)])];
const candidates = offsets.map(o => naive - o * 60000).filter(u => u >= solar.start && u < solar.end && clockText(u) === text).sort((a, b) => a - b);
if (candidates.length) return candidates[0];
// The missing spring-forward hour advances to the first valid following clock time.
for (let i = 0; i < solar.length; i++) { const u = solar.start + i * 60000, p = localParts(u); if (p.hour * 60 + p.minute >= h * 60 + m) return u; }
return solar.end - 60000;
}
function rootElevation(a, b, threshold) { let fa = solarPosition(a, solar.lat, solar.lon).elevation - threshold; for (let i = 0; i < 24; i++) { const m = (a + b) / 2, fm = solarPosition(m, solar.lat, solar.lon).elevation - threshold; if ((fa < 0) === (fm < 0)) { a = m; fa = fm; } else b = m; } return (a + b) / 2; }
function rebuildSolarDay(preserveClock = true) {
const clock = preserveClock && solar.start ? clockText(solar.start + solar.minute * 60000) : $('#sunClock').value || '10:00';
solar.start = localMidnight(solar.date);
const [y, m, d] = solar.date.split('-').map(Number), next = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
solar.end = localMidnight(next); solar.length = Math.round((solar.end - solar.start) / 60000); solar.rise = null; solar.set = null; solar.samples = [];
let prev = null, max = { alt: -999, ms: solar.start };
for (let u = solar.start; u <= solar.end; u += 600000) {
const p = solarPosition(u, solar.lat, solar.lon); solar.samples.push({ ms: u, ...p }); if (p.elevation > max.alt) max = { alt: p.elevation, ms: u };
if (prev) { if (prev.elevation < -.833 && p.elevation >= -.833) solar.rise = rootElevation(prev.ms, u, -.833); if (prev.elevation >= -.833 && p.elevation < -.833) solar.set = rootElevation(prev.ms, u, -.833); } prev = { ms: u, ...p };
}
let lo = Math.max(solar.start, max.ms - 600000), hi = Math.min(solar.end, max.ms + 600000); for (let i = 0; i < 36; i++) { let a = lo + (hi - lo) / 3, b = hi - (hi - lo) / 3; if (solarPosition(a, solar.lat, solar.lon).elevation < solarPosition(b, solar.lat, solar.lon).elevation) lo = a; else hi = b; } solar.noon = (lo + hi) / 2;
solar.minute = clamp((timeForClock(clock) - solar.start) / 60000, 0, solar.length - 1);
$('#sunDate').value = solar.date; $('#sunTime').max = solar.length - 1;
$('#sunRise').textContent = clockText(solar.rise === null ? null : solar.rise + 30000); $('#sunSet').textContent = clockText(solar.set === null ? null : solar.set + 30000); $('#sunNoon').textContent = clockText(solar.noon + 30000);
document.querySelectorAll('[data-season]').forEach(b => b.classList.toggle('active', solar.date.endsWith(b.dataset.season)));
const tickEl = $('.sun-ticks'); tickEl.style.position = 'relative'; tickEl.style.height = '12px';
[...tickEl.children].forEach((el, i) => { const mm = i === 4 ? solar.length : (timeForClock(el.textContent) - solar.start) / 60000; el.style.cssText = `position:absolute;left:${100 * mm / solar.length}%;transform:translateX(${i === 0 ? 0 : i === 4 ? -100 : -50}%)`; });
$('#solarMessage').textContent = solar.length === 1440 ? '' : `Tento den m\u00e1 ${solar.length / 60} hodin. \u010casov\u00e1 osa respektuje zm\u011bnu letn\u00edho \u010dasu.`;
drawSolarGraphBase(); updateSolar();
}
let graphBase = '';
function drawSolarGraphBase() {
const y = alt => 70 - clamp(alt, 0, 90) * .65;
const pts = solar.samples.map(p => `${8 + 288 * (p.ms - solar.start) / (solar.end - solar.start)},${y(p.elevation)}`);
const curve = 'M' + pts.join(' L'), max = solarPosition(solar.noon, solar.lat, solar.lon).elevation;
graphBase = `<defs><linearGradient id="solarFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d4b465" stop-opacity=".34"/><stop offset="1" stop-color="#d4b465" stop-opacity=".04"/></linearGradient></defs><line x1="8" y1="70" x2="296" y2="70" stroke="#dce2d3"/><line x1="8" y1="40.75" x2="296" y2="40.75" stroke="#e4e8dd" stroke-dasharray="3 4"/><text x="294" y="37" text-anchor="end" fill="#a1a891" font-size="8">45\u00b0</text><path d="${curve} L296,70 L8,70 Z" fill="url(#solarFill)"/><path d="${curve}" fill="none" stroke="#c29b4b" stroke-width="1.5"/>`;
}
function updateSolarGraph() { const p = solar.pos, x = 8 + 288 * solar.minute / solar.length, y = 70 - clamp(p.elevation, 0, 90) * .65; $('#sunGraph').innerHTML = graphBase + `<line x1="${x}" y1="10" x2="${x}" y2="75" stroke="#a97731" stroke-width="1" stroke-dasharray="2 3"/><circle cx="${x}" cy="${y}" r="4" fill="#bc8933" stroke="#fafbf7" stroke-width="2"/>`; }
function updateSolar() {
solar.clock = solar.start + solar.minute * 60000; solar.pos = solarPosition(solar.clock, solar.lat, solar.lon);
const a = solar.pos.elevation; solar.light = solarVector(solar.pos.azimuth, a);
solar.dayFactor = smooth(-10, 4, a); const daylight = solar.dayFactor;
const warm = 1 - smooth(0, 24, a);
solar.sunColor = mix3([1.07, 1.03, .95], [1.30, .78, .38], warm);
solar.direct = a > 0 ? (1.02 - .32 * Math.exp(-Math.max(a, 0) / 9)) * smooth(0, 1.2, a) : 0;
solar.ambient = mix3([.017, .027, .047], [.38, .415, .44], daylight);
solar.sky = mix3([.075, .112, .157], [.906, .922, .890], daylight);
if (a > -7 && a < 9) solar.sky = mix3(solar.sky, [.89, .83, .74], .16 * Math.sin((a + 7) / 16 * Math.PI));
const lt = [0, 1, 0], lp = vadd(lt, scale(solar.light, 70));
lightVP = mul(ortho(-22, 22, -21, 21, 1, 145), lookAt(lp, lt, Math.abs(solar.light[1]) > .999 ? [0, 0, 1] : [0, 1, 0]));
updateSiteLight();
solar.shadowDirty = true; dirty = true;
$('#sunClock').value = clockText(solar.clock); $('#sunTime').value = Math.floor(solar.minute); $('#sunTime').setAttribute('aria-valuetext', clockText(solar.clock));
$('#zoneLabel').textContent = zoneText(solar.clock);
$('#sunElevation').textContent = czNum(a) + '\u00b0'; $('#sunAzimuth').textContent = czNum(solar.pos.azimuth) + '\u00b0';
const dirs = ['sever', 'severov\u00fdchod', 'v\u00fdchod', 'jihov\u00fdchod', 'jih', 'jihoz\u00e1pad', 'z\u00e1pad', 'severoz\u00e1pad'];
$('#sunDirection').textContent = dirs[Math.round(solar.pos.azimuth / 45) % 8] + ' \u00b7 od severu';
$('#dayState').textContent = a > 0 ? 'Slunce nad obzorem' : a > -.833 ? 'Slunce na obzoru' : a > -6 ? 'Soumrak' : 'Noc \u00b7 bez p\u0159\u00edm\u00e9ho slunce';
$('#sceneTime').textContent = czDate(solar.date) + ' \u00b7 ' + clockText(solar.clock) + ' ' + (utcOffset(solar.clock) === 120 ? 'SEL\u010c' : 'SE\u010c');
$('#bearingNote').textContent = `Ji\u017en\u00ed \u0161t\u00edt: ${czNum(mod(solar.bearing + 180, 360))}\u00b0 \u00b7 vstupn\u00ed fas\u00e1da: ${czNum(mod(solar.bearing + 270, 360))}\u00b0.`;
document.body.classList.toggle('night', a < -4); document.body.style.backgroundColor = `rgb(${solar.sky.map(v => Math.round(v * 255)).join(',')})`;
updateSolarGraph();
}
function solarFrame(dt) { if (!solar.playing) return; solar.minute += dt * solar.speed; if (solar.minute >= solar.length) solar.minute = 0; solar.accumulator += dt; if (solar.accumulator >= .075) { solar.accumulator = 0; updateSolar(); } }
function setSolarPlaying(on) { solar.playing = on; $('#sunPlay').innerHTML = on ? '&#10074;&#10074; Pozastavit' : '&#9654; P\u0159ehr\u00e1t den'; $('#sunPlay').setAttribute('aria-pressed', String(on)); }
function toggleSunPanel(on) { const yes = on === undefined ? !$('#sunPanel').classList.contains('open') : on; $('#sunPanel').classList.toggle('open', yes); document.body.classList.toggle('sun-open', yes); $('#sunToggle').classList.toggle('active', yes); $('#sunToggle').setAttribute('aria-expanded', String(yes)); if (yes) { $('#panel').classList.remove('open'); $('#infoButton').classList.remove('active'); } resize(); }
function solarOverlay() {
const svg = $('#sunOverlay'); svg.setAttribute('viewBox', `0 0 ${viewW} ${viewH}`);
const compassSun = $('#compassSun'); if (compassSun) { let a = solar.pos.azimuth * RAD; compassSun.setAttribute('cx', 42 + 26 * Math.sin(a)); compassSun.setAttribute('cy', 42 - 26 * Math.cos(a)); compassSun.style.display = solar.pos.elevation > 0 ? 'block' : 'none'; }
if (!solar.path) { svg.innerHTML = ''; return; }
const center = [0, .8, 0], radius = 12.8;
const pathPoint = (az, al) => vadd(center, scale(solarVector(az, al), radius));
let d = '', started = false; for (const p of solar.samples) { if (p.elevation < 0) { started = false; continue; } const v = pathPoint(p.azimuth, p.elevation), q = point(vp, v); if (q[3] <= 0) { started = false; continue; } const a = project(v); d += (started ? 'L' : 'M') + a.map(x => x.toFixed(1)).join(','); started = true; }
let out = `<path d="${d}" fill="none" stroke="#b28b40" stroke-width="1.5" stroke-dasharray="5 5" opacity=".7"/>`;
for (let hour = 6; hour <= 21; hour += 3) { const ms = timeForClock(String(hour).padStart(2, '0') + ':00'), p = solarPosition(ms, solar.lat, solar.lon); if (p.elevation < 0) continue; const v = pathPoint(p.azimuth, p.elevation); if (point(vp, v)[3] <= 0) continue; const q = project(v); out += `<circle cx="${q[0]}" cy="${q[1]}" r="2.5" fill="#bd984f"/><text x="${q[0] + 6}" y="${q[1] - 7}">${hour}:00</text>`; }
if (solar.pos.elevation >= 0) { const v = pathPoint(solar.pos.azimuth, solar.pos.elevation), q = project(v); if (point(vp, v)[3] > 0) { const base = project([v[0], center[1], v[2]]); out += `<line x1="${q[0]}" y1="${q[1]}" x2="${base[0]}" y2="${base[1]}" stroke="#c09a4f" stroke-dasharray="3 5" opacity=".45"/><circle cx="${q[0]}" cy="${q[1]}" r="18" fill="#e2bc5c" opacity=".13"/><circle cx="${q[0]}" cy="${q[1]}" r="11" fill="#e2bc5c" opacity=".25"/><circle cx="${q[0]}" cy="${q[1]}" r="6.5" fill="#e7bd59" stroke="#fff5cf" stroke-width="2"/>`; } }
svg.innerHTML = out;
}
async function saveSolarImage() {
setSolarPlaying(false); updateSolar(); render();
const out = document.createElement('canvas'), ratio = canvas.width / viewW, footer = Math.round(77 * ratio); out.width = canvas.width; out.height = canvas.height + footer; const ctx = out.getContext('2d'); ctx.drawImage(canvas, 0, 0);
if (solar.path) { try { const xml = `<svg xmlns="http://www.w3.org/2000/svg" width="${viewW}" height="${viewH}" viewBox="0 0 ${viewW} ${viewH}"><style>text{font:10px Arial;fill:#96712e;paint-order:stroke;stroke:#f3f4e9;stroke-width:3px;stroke-linejoin:round}</style>${$('#sunOverlay').innerHTML}</svg>`, u = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml' })); const im = new Image(); await new Promise((resolve, reject) => { im.onload = resolve; im.onerror = reject; im.src = u; }); ctx.drawImage(im, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(u); } catch (e) { console.warn('Solar overlay export', e); } }
ctx.fillStyle = '#fafbf6'; ctx.fillRect(0, canvas.height, canvas.width, footer); ctx.fillStyle = '#3e5138'; ctx.font = `${Math.round(12 * ratio)}px Arial`;
let primary = `Lo\u010denice | ${czDate(solar.date)} | ${clockText(solar.clock)} ${zoneText(solar.clock)} | v\u00fd\u0161ka ${czNum(solar.pos.elevation)}\u00b0 | azimut ${czNum(solar.pos.azimuth)}\u00b0`;
let secondary = `Terén: C.3 + interpolace výškopisu. Bez okolní zástavby, stromů a místního horizontu.`;
const max = canvas.width - 32 * ratio; while (ctx.measureText(primary).width > max) { const px = parseFloat(ctx.font); if (px <= 8 * ratio) break; ctx.font = `${px - 1}px Arial`; }
ctx.fillText(primary, 16 * ratio, canvas.height + 26 * ratio); ctx.font = `${Math.round(10 * ratio)}px Arial`; ctx.fillStyle = '#7d8976';
if (ctx.measureText(secondary).width > max) secondary = 'Orienta\u010dn\u00ed studie. Bez okoln\u00ed z\u00e1stavby a strom\u016f.';
ctx.fillText(secondary, 16 * ratio, canvas.height + 47 * ratio); ctx.fillText(`GPS ${solar.lat.toFixed(4)}, ${solar.lon.toFixed(4)} | severn\u00ed \u0161t\u00edt ${czNum(solar.bearing)}\u00b0`, 16 * ratio, canvas.height + 64 * ratio);
out.toBlob(b => { if (b) saveBlob(b, `dum_slunce_${solar.date}_${clockText(solar.clock).replace(':', '-')}.png`); }, 'image/png');
}
function initSolar() {
$('#sunDate').onchange = e => { if (!e.target.value || !e.target.checkValidity()) { toast('Zadej datum v letech 2000 a\u017e 2100.'); e.target.value = solar.date; return; } setSolarPlaying(false); solar.date = e.target.value; rebuildSolarDay(); };
$('#sunClock').onchange = e => { if (!e.target.value) return; setSolarPlaying(false); const u = timeForClock(e.target.value); if (u === null) { toast('Zadej \u010das HH:MM, nap\u0159. 09:30.'); e.target.value = clockText(solar.clock); return; } solar.minute = (u - solar.start) / 60000; updateSolar(); };
$('#sunTime').oninput = e => { setSolarPlaying(false); solar.minute = +e.target.value; updateSolar(); };
$('#sunPlay').onclick = () => setSolarPlaying(!solar.playing); $('#sunSpeed').onchange = e => solar.speed = +e.target.value;
$('#sunNow').onclick = () => { const now = Date.now(); setSolarPlaying(false); solar.date = isoDate(now); rebuildSolarDay(false); solar.minute = clamp((now - solar.start) / 60000, 0, solar.length - 1); updateSolar(); };
document.querySelectorAll('[data-season]').forEach(b => b.onclick = () => { setSolarPlaying(false); solar.date = solar.date.slice(0, 4) + '-' + b.dataset.season; rebuildSolarDay(); });
$('#sunShadows').onchange = e => { solar.shadows = e.target.checked; solar.shadowDirty = true; dirty = true; };
$('#sunPath').onchange = e => { solar.path = e.target.checked; dirty = true; $('#pathHint').textContent = solar.path ? 'Dr\u00e1ha je symbolick\u00e1, v mal\u00e9m m\u011b\u0159\u00edtku. Pro celou dr\u00e1hu oddal pohled kole\u010dkem nebo dv\u011bma prsty.' : 'Posouvej \u010das a sleduj, kam dopad\u00e1 slunce na terasu a fas\u00e1dy. Nato\u010den\u00ed pohledu polohu slunce nem\u011bn\u00ed.'; };
for (const [id, key] of [['sunLat', 'lat'], ['sunLon', 'lon'], ['houseBearing', 'bearing']]) $('#' + id).onchange = e => { const val = +e.target.value; if (!e.target.value || !Number.isFinite(val) || !e.target.checkValidity()) { toast('Zadej hodnotu v povolen\u00e9m rozsahu.'); e.target.value = solar[key]; return; } solar[key] = val; rebuildSolarDay(); };
$('#sunResetSite').onclick = () => { solar.lat = 48.8242; solar.lon = 14.5275; solar.bearing = 351.5; $('#sunLat').value = solar.lat; $('#sunLon').value = solar.lon; $('#houseBearing').value = solar.bearing; rebuildSolarDay(); };
$('#sunToggle').onclick = () => toggleSunPanel(); $('#sunClose').onclick = () => toggleSunPanel(false); $('#sunSavePng').onclick = saveSolarImage; $('#savePng').onclick = saveSolarImage;
$('#infoButton').onclick = () => { const open = !$('#panel').classList.contains('open'); $('#panel').classList.toggle('open', open); $('#infoButton').classList.toggle('active', open); if (open) toggleSunPanel(false); };
const graph = $('#sunGraph'); function scrubGraph(e) { const r = graph.getBoundingClientRect(); solar.minute = clamp(((e.clientX - r.left) / r.width * 304 - 8) / 288 * solar.length, 0, solar.length - 1); setSolarPlaying(false); updateSolar(); }
graph.addEventListener('pointerdown', e => { graph.setPointerCapture(e.pointerId); scrubGraph(e); }); graph.addEventListener('pointermove', e => { if (graph.hasPointerCapture(e.pointerId)) scrubGraph(e); }); graph.addEventListener('pointerup', e => { if (graph.hasPointerCapture(e.pointerId)) graph.releasePointerCapture(e.pointerId); });
window.addEventListener('keydown', e => { if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'SUMMARY'].includes(e.target.tagName)) return; if (e.code === 'Space') { e.preventDefault(); setSolarPlaying(!solar.playing); } if (e.key.toLowerCase() === 's') toggleSunPanel(); });
document.addEventListener('visibilitychange', () => { if (document.hidden) setSolarPlaying(false); });
const cp = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); cp.id = 'compassSun'; cp.setAttribute('r', '3'); cp.setAttribute('fill', '#c89439'); cp.setAttribute('stroke', '#fff7d7'); cp.setAttribute('stroke-width', '1'); $('#compassGroup').appendChild(cp);
toggleSunPanel(false); rebuildSolarDay(false);
// Read-only diagnostics and small public control API for verification and reuse.
window.SUN_SIM = { position: solarPosition, getState: () => ({ date: solar.date, time: clockText(solar.clock), utc: new Date(solar.clock).toISOString(), dayMinutes: solar.length, bearing: solar.bearing, lat: solar.lat, lon: solar.lon, azimuth: solar.pos.azimuth, elevation: solar.pos.elevation, light: solar.light.slice(), rise: clockText(solar.rise), set: clockText(solar.set), noon: clockText(solar.noon), shadows: solar.shadows, playing: solar.playing }), setDate: d => { solar.date = d; rebuildSolarDay(); }, setTime: t => { solar.minute = (timeForClock(t) - solar.start) / 60000; updateSolar(); }, setBearing: b => { solar.bearing = mod(b, 360); $('#houseBearing').value = solar.bearing; updateSolar(); }, pause: () => setSolarPlaying(false) };
}
// Independent site layers: initSiteControls enables all three on each fresh open.
const site = { road: false, parcel: false, parking: false, savedCamera: null, lastFocus: null };
const siteActive = () => site.road || site.parcel || site.parking;
function meshVisible(m) {
if (m.legacySite) return false;
switch (m.siteLayer) {
case 'local': return showTerrain;
case 'north': return showTerrain && siteActive();
case 'parcel': case 'boundary': return showTerrain && site.parcel;
case 'road': return site.road;
case 'parking': return site.parking;
default: return showTerrain || !m.context;
}
}
function visibleBounds() {
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
for (const m of meshes) if (visible(m) && m.bounds) {
for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], m.bounds.min[i]); hi[i] = Math.max(hi[i], m.bounds.max[i]); }
}
return { lo, hi };
}
function boxCorners(lo, hi) {
const out = []; for (let k = 0; k < 8; k++) out.push([0, 1, 2].map(i => k & (1 << i) ? hi[i] : lo[i])); return out;
}
function fittedSiteCamera(mode) {
let bounds = visibleBounds();
if (mode === 'parking') bounds = { lo: [-22.2, -.8, -8.0], hi: [-6.3, 5.8, 7.1] };
const mobile = aspect < 1;
const th = mode === 'parcel' ? (mobile ? Math.PI / 2 + .12 : .24) : (mobile ? -.94 : -.44);
const ph = mode === 'parcel' ? (mobile ? 1.02 : .88) : .72;
const center = bounds.lo.map((v, i) => (v + bounds.hi[i]) / 2);
const forward = [Math.sin(th) * Math.cos(ph), Math.sin(ph), Math.cos(th) * Math.cos(ph)];
const right = [Math.cos(th), 0, -Math.sin(th)], up = cross(forward, right);
const tangent = Math.tan(17 * Math.PI / 180);
const usableY = mobile ? .58 : .69, usableX = .82;
let dist = 16;
for (const p of boxCorners(bounds.lo, bounds.hi)) {
const d = vsub(p, center), depth = dot(d, forward);
dist = Math.max(dist, depth + Math.abs(dot(d, right)) / (tangent * aspect * usableX), depth + Math.abs(dot(d, up)) / (tangent * usableY));
}
return { theta: th, phi: ph, target: center, distance: dist + 2, ortho: false };
}
function focusSite(mode, instant = false) {
if (!siteActive()) { setPreset('terrace', instant); return; }
mode = mode || (site.parcel ? 'parcel' : site.road ? 'road' : 'parking');
site.lastFocus = mode;
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
$('#elevation').value = '';
const label = { road: 'Cesta a přístup k domu', parcel: 'Celý pozemek', parking: 'Parkování u příjezdu' }[mode];
setCamera(fittedSiteCamera(mode), label, instant);
}
function setSiteLayer(key, on, focus = true) {
if (!['road', 'parcel', 'parking'].includes(key)) return;
const was = siteActive();
if (!was && on) site.savedCamera = { theta, phi, distance, target: [...target], ortho: orthographic, label: $('#directionText').textContent };
site[key] = !!on;
const ids = { road: 'siteRoad', parcel: 'siteParcel', parking: 'siteParking' };
const input = $('#' + ids[key]); input.checked = site[key];
input.closest('label').classList.toggle('selected', site[key]);
document.body.classList.toggle('site-active', siteActive());
$('#siteMeta').hidden = !siteActive();
$('#siteMeta').textContent = 'C.3 + výškopis · mezi body interpolováno';
updateSolar();
if (focus) {
if (siteActive()) focusSite(on ? key : null);
else if (site.savedCamera) {
setCamera(site.savedCamera, site.savedCamera.label || 'Dům');
site.savedCamera = null; site.lastFocus = null;
}
}
dirty = true;
}
function updateSiteLight() {
if(interior.enabled){
const center=[1,2,0],lightEye=vadd(center,scale(solar.light,45)),view=lookAt(lightEye,center,Math.abs(solar.light[1])>.999?[0,0,1]:[0,1,0]);
const cs=boxCorners([-8,-.6,-5],[10,5.9,5]).map(p=>point(view,p)),xs=cs.map(p=>p[0]),ys=cs.map(p=>p[1]);
lightVP=mul(ortho(Math.min(...xs)-2,Math.max(...xs)+2,Math.min(...ys)-2,Math.max(...ys)+2,1,100),view);return;
}
if (!siteActive()) return;
const { lo, hi } = visibleBounds(), center = lo.map((v, i) => (v + hi[i]) / 2);
const lightEye = vadd(center, scale(solar.light, 115));
const view = lookAt(lightEye, center, Math.abs(solar.light[1]) > .999 ? [0, 0, 1] : [0, 1, 0]);
const cs = boxCorners(lo, hi).map(p => point(view, p));
const xs = cs.map(p => p[0]), ys = cs.map(p => p[1]);
lightVP = mul(ortho(Math.min(...xs) - 3, Math.max(...xs) + 3, Math.min(...ys) - 3, Math.max(...ys) + 3, 1, 240), view);
}
function exportVisibleGltf(){
const selected=new Set(meshes.filter(visible).map(m=>m.nodeIndex));
const keep=new Set();function retain(i){const n=gltf.nodes[i];const children=(n.children||[]).filter(retain);const yes=selected.has(i)||children.length>0;if(yes)keep.add(i);return yes;}
for(const sc of gltf.scenes||[])for(const i of sc.nodes||[])retain(i);
const nodes=gltf.nodes.map((n,i)=>({...n,...(n.children?{children:n.children.filter(c=>keep.has(c))}:{})}));
const scenes=(gltf.scenes||[]).map(sc=>({...sc,nodes:(sc.nodes||[]).filter(i=>keep.has(i))}));
return {...gltf,nodes,scenes,extras:{...gltf.extras,visibleSiteLayers:{road:site.road,parcel:site.parcel,parking:site.parking,terrain:showTerrain},allstavSelection:stdClone(stdState),interiorView:{enabled:interior.enabled,mode:interior.mode}}};
}
function initSiteControls() {
for (const [id, key] of [['siteRoad', 'road'], ['siteParcel', 'parcel'], ['siteParking', 'parking']]) {
const input = $('#' + id); input.onchange = () => setSiteLayer(key, input.checked);
setSiteLayer(key, true, false);
}
$('#siteFit').onclick = () => focusSite();
window.SITE_SIM = {
getState: () => ({
road: site.road, parcel: site.parcel, parking: site.parking, terrain: showTerrain,
visibleObjects: meshes.filter(visible).length, referenceElevation: 591.840,
projectArea: 1010, digitizedArea: 987.89, houseGeometryUnchanged: true
}),
setLayer: (key, on, focus = true) => setSiteLayer(key, on, focus),
focus: mode => focusSite(mode),
reset: () => { for (const key of ['road', 'parcel', 'parking']) setSiteLayer(key, false, false); setPreset('terrace'); },
getCamera: () => ({ theta, phi, distance, target: [...target], aspect }),
checkGround: () => gltf.extras.siteStudy.finishedGroundC3
};
}
// Optional additive interior, independent of the retained exterior and solar state.
const interior = {
enabled: false, mode: 'axon', furniture: true, labels: true, fill: true,
position: [3.78, 1.62, .90], yaw: 0, pitch: 0, fov: 64,
saved: null, data: null, lookPointer: null, keys: new Set(), pad: new Set(),
panel: true, userCamera: false, lastView: 'kitchen', currentRoom: '',
roof: new Set(), cutOriginals: new Set(), ready: false
};
function interiorVisibility(m) {
const layer = m.interiorLayer;
if (!interior.enabled) return !layer;
const walking = interior.mode === 'walk';
if (layer) {
if (layer === 'cut' || layer === 'cutShell') return !walking;
if (['wall', 'liner', 'door', 'ceiling', 'beams'].includes(layer)) return walking;
if (layer === 'furniture') return interior.furniture;
return true;
}
if (!walking) {
if (interior.roof.has(m.nodeIndex) || interior.cutOriginals.has(m.nodeIndex)) return false;
if (m.siteLayer || m.context || /^(25_|26_|27_)/.test(m.name)) return false;
} else if (m.glass) return false; // Optical opening only; source glazing is untouched.
return true;
}
function interiorPointIn(poly, x, z) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[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;
}
function interiorCanStand(x, z) {
const d = interior.data, r = .16;
if (!d || ![[x-r,z],[x+r,z],[x,z-r],[x,z+r]].every(p => interiorPointIn(d.footprint,p[0],p[1]))) return false;
for (const b of d.wallColliders) if (x > b.lo[0]-r && x < b.hi[0]+r && z > b.lo[1]-r && z < b.hi[1]+r) return false;
if (interior.furniture) for (const b of d.furnitureColliders) if (x > b.lo[0]-r && x < b.hi[0]+r && z > b.lo[1]-r && z < b.hi[1]+r) return false;
return true;
}
function moveInterior(dx, dz) {
// Small substeps and axis sliding keep narrow 700 mm doorways usable.
const steps = Math.max(1, Math.ceil(Math.hypot(dx,dz)/.055));
for (let i = 0; i < steps; i++) {
let x = interior.position[0], z = interior.position[2];
if (interiorCanStand(x+dx/steps,z)) interior.position[0] = x+dx/steps;
x = interior.position[0];
if (interiorCanStand(x,z+dz/steps)) interior.position[2] = z+dz/steps;
}
dirty = true;
}
function stepInterior(dt) {
if (!interior.enabled || interior.mode !== 'walk') return;
const k = interior.keys, p = interior.pad;
let f = (k.has('KeyW')||k.has('ArrowUp')||p.has('forward')?1:0)-(k.has('KeyS')||k.has('ArrowDown')||p.has('back')?1:0);
let side = (k.has('KeyD')||p.has('right')?1:0)-(k.has('KeyA')||p.has('left')?1:0);
const turn = (k.has('ArrowLeft')||k.has('KeyQ')?1:0)-(k.has('ArrowRight')||k.has('KeyE')?1:0);
if (turn) {interior.yaw += turn*dt*1.25;dirty=true;}
if (f || side) {
const len = Math.hypot(f,side), speed = (k.has('ShiftLeft')||k.has('ShiftRight')?2.15:1.2)*dt;
f/=len;side/=len;
moveInterior((Math.sin(interior.yaw)*f-Math.cos(interior.yaw)*side)*speed,(Math.cos(interior.yaw)*f+Math.sin(interior.yaw)*side)*speed);
}
}
function interiorCamera() {
eye = [...interior.position];
const dir = [Math.sin(interior.yaw)*Math.cos(interior.pitch),Math.sin(interior.pitch),Math.cos(interior.yaw)*Math.cos(interior.pitch)];
vp = mul(perspective(interior.fov*Math.PI/180,aspect,.035,400),lookAt(eye,vadd(eye,dir)));
}
function interiorFitCamera() {
if (!interior.enabled || interior.mode === 'walk' || !interior.data) return;
const mobile = aspect < 1;
theta = interior.mode === 'plan' ? (mobile ? Math.PI/2 : 0) : .68;
phi = interior.mode === 'plan' ? 1.5706 : .93;
target = [1.6,.4,0]; orthographic = true;animation=null;autoRotate=false;
const forward = [Math.sin(theta)*Math.cos(phi),Math.sin(phi),Math.cos(theta)*Math.cos(phi)];
const right = [Math.cos(theta),0,-Math.sin(theta)], up = cross(forward,right);
distance = 10;
for (const p of boxCorners([-8.05,-.2,-5.0],[11.68,2.3,5.05])) {
const q = vsub(p,target);
distance = Math.max(distance,Math.abs(dot(q,right))/(.28*aspect*.89),Math.abs(dot(q,up))/(.28*.76));
}
dirty = true;
}
function updateInteriorInterface() {
document.body.classList.toggle('interior-active',interior.enabled);
document.body.classList.toggle('interior-walk',interior.enabled&&interior.mode==='walk');
document.body.classList.toggle('interior-panel-closed',interior.enabled&&!interior.panel);
$('#interiorLaunch').setAttribute('aria-expanded',String(interior.enabled));
$('#interiorPanelToggle').setAttribute('aria-expanded',String(interior.panel));
document.querySelectorAll('[data-interior-mode]').forEach(b=>b.classList.toggle('active',b.dataset.interiorMode===interior.mode));
const walking=interior.mode==='walk';
$('#interiorRoomLabels').disabled=walking;
$('#interiorHint').textContent=walking?'W / A / S / D nebo šipky · tažením se rozhlížejte · Shift = rychleji · Esc = exteriér':'Tažením otáčejte · kolečkem přibližujte · kliknutím na místnost vstupte dovnitř';
if(!walking)$('#interiorViewTitle').textContent=interior.mode==='plan'?'Půdorys 1. NP':'Dům bez střechy';
if(!interior.enabled)$('#interiorLabels').replaceChildren();
}
function toggleInterior(on) {
if (!interior.ready) return;
on = on === undefined ? !interior.enabled : !!on;
if (on === interior.enabled) return;
if (on) {
interior.saved = {theta,phi,distance,target:[...target],ortho:orthographic,autoRotate,aspect,
label:$('#directionText').textContent,sunOpen:$('#sunPanel').classList.contains('open'),panelOpen:$('#panel').classList.contains('open'),
presets:[...document.querySelectorAll('[data-preset].active')].map(b=>b.dataset.preset),elevation:$('#elevation').value};
interior.enabled=true;interior.mode='axon';interior.panel=true;interior.userCamera=false;
toggleSunPanel(false);$('#panel').classList.remove('open');$('#infoButton').classList.remove('active');
autoRotate=false;animation=null;pointerMap.clear();$('#rotateButton').classList.remove('active');
updateInteriorInterface();resize();interiorFitCamera();
toast('Interiér zapnutý. Klikněte na místnost nebo zvolte Procházet.');
} else {
const s=interior.saved;
interior.enabled=false;interior.keys.clear();interior.pad.clear();interior.lookPointer=null;
updateInteriorInterface();toggleSunPanel(s?.sunOpen||false);resize();
if(s){
setCamera({...s,distance:s.distance*Math.max(1,1/aspect)/Math.max(1,1/s.aspect)},s.label,true);
autoRotate=s.autoRotate;$('#rotateButton').classList.toggle('active',autoRotate);
$('#panel').classList.toggle('open',s.panelOpen);$('#infoButton').classList.toggle('active',s.panelOpen);
$('#elevation').value=s.elevation;
document.querySelectorAll('[data-preset]').forEach(b=>b.classList.toggle('active',s.presets.includes(b.dataset.preset)));
}
}
updateSolar();solar.shadowDirty=true;dirty=true;
}
function setInteriorMode(mode) {
if(!['plan','axon','walk'].includes(mode)) return;
if(!interior.enabled)toggleInterior(true);
interior.mode=mode;interior.userCamera=false;interior.keys.clear();interior.pad.clear();animation=null;autoRotate=false;pointerMap.clear();
if(mode==='walk'){
const preset=interior.data.presets.find(p=>p.id===interior.lastView)||interior.data.presets[0];
enterInteriorView(preset.id);return;
}
document.querySelectorAll('[data-interior-view]').forEach(b=>b.classList.remove('active'));
$('#interiorRoom').value='';updateInteriorInterface();interiorFitCamera();updateSolar();dirty=true;
}
function enterInteriorView(id) {
if(!interior.ready)return false;
if(!interior.enabled)toggleInterior(true);
const v=interior.data.presets.find(p=>p.id===id)||interior.data.rooms.find(r=>r.id===id);
if(!v)return false;
interior.mode='walk';interior.lastView=id;interior.position=[...v.eye];
const dir=vsub(v.look,v.eye);interior.yaw=Math.atan2(dir[0],dir[2]);interior.pitch=Math.atan2(dir[1],Math.hypot(dir[0],dir[2]));
interior.fov=v.fov||64;interior.keys.clear();interior.pad.clear();interior.lookPointer=null;pointerMap.clear();autoRotate=false;animation=null;
document.querySelectorAll('[data-interior-view]').forEach(b=>b.classList.toggle('active',b.dataset.interiorView===id));
$('#interiorRoom').value=interior.data.rooms.some(r=>r.id===id)?id:'';
$('#interiorViewTitle').textContent=v.label;
updateInteriorInterface();updateSolar();solar.shadowDirty=true;dirty=true;return true;
}
function handleInteriorKey(e) {
if($('#interiorReferenceDialog').open||$('#stdCatalogDialog').open||$('#stdResetDialog').open)return;
if(e.key==='Escape'){e.preventDefault();toggleInterior(false);return;}
if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;
const modes={Digit1:'plan',Digit2:'axon',Digit3:'walk'};
if(modes[e.code]){e.preventDefault();setInteriorMode(modes[e.code]);return;}
if(e.code==='KeyR'){e.preventDefault();if(interior.mode==='walk')enterInteriorView(interior.lastView);else{interior.userCamera=false;interiorFitCamera();}return;}
if(interior.mode==='walk'&&['KeyW','KeyA','KeyS','KeyD','KeyQ','KeyE','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','ShiftLeft','ShiftRight'].includes(e.code)){
e.preventDefault();interior.keys.add(e.code);
}
}
function interiorMapPoint(x,z){return [17+(x+7.67)*19.2,12+(z+4.695)*19.2];}
function buildInteriorMap() {
let svg='<rect x="1" y="1" width="328" height="203" rx="10" fill="#f0f2e9"/>';
svg+='<rect x="236" y="12" width="76" height="77" fill="#e5dcc4" stroke="#d2d8c7"/>';
svg+='<text x="274" y="53" text-anchor="middle">Terasa</text>';
for(const r of interior.data.rooms){
const path=r.poly.map(p=>interiorMapPoint(...p).join(',')).join(' '),a=interiorMapPoint(...r.anchor);
svg+=`<g data-map-room="${r.id}" role="button" tabindex="0" aria-label="${r.id} ${r.label}"><polygon class="map-room" points="${path}"/><text x="${a[0]}" y="${a[1]+3}" text-anchor="middle">${r.id}</text></g>`;
}
for(const b of interior.data.wallColliders){let a=interiorMapPoint(...b.lo),c=interiorMapPoint(...b.hi);svg+=`<rect class="map-wall" x="${a[0]}" y="${a[1]}" width="${c[0]-a[0]}" height="${c[1]-a[1]}"/>`;}
svg+='<g id="interiorMapMarker" style="display:none"><path d="M0 0L-5 13L5 13Z" fill="#bd8541" opacity=".8"/><circle r="3.7" fill="#365944" stroke="#fff" stroke-width="1.4"/></g>';
svg+='<text x="17" y="198">1. NP · orientace podle půdorysu</text>';
$('#interiorMap').innerHTML=svg;
document.querySelectorAll('[data-map-room]').forEach(el=>{
el.onclick=()=>enterInteriorView(el.dataset.mapRoom);
el.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();enterInteriorView(el.dataset.mapRoom);}};
});
}
function drawInteriorLabels() {
if(!interior.enabled)return;
const el=$('#interiorLabels'),walking=interior.mode==='walk';
if(walking||!interior.labels){el.replaceChildren();}
else{
if(!el.children.length)for(const r of interior.data.rooms){
const b=document.createElement('button');b.className='int-room-tag';b.dataset.room=r.id;
b.innerHTML=`<b>${r.id} <span class="room-word">${r.label}</span></b><small>${r.area} m²</small>`;
b.title='Vstoupit: '+r.label;b.onclick=()=>enterInteriorView(r.id);el.appendChild(b);
}
const rect=canvas.getBoundingClientRect();
for(const b of el.children){const r=interior.data.rooms.find(r=>r.id===b.dataset.room),p=project([r.anchor[0],.20,r.anchor[1]]);
b.style.left=(p[0]+rect.left)+'px';b.style.top=(p[1]+rect.top)+'px';
b.style.display=p[0]<20||p[0]>viewW-20||p[1]<80||p[1]>viewH-65?'none':'block';}
}
const marker=$('#interiorMapMarker');if(marker){marker.style.display=walking?'block':'none';if(walking){const p=interiorMapPoint(interior.position[0],interior.position[2]);marker.setAttribute('transform',`translate(${p[0]},${p[1]}) rotate(${-interior.yaw*180/Math.PI})`);}}
if(walking){const room=interior.data.rooms.find(r=>interiorPointIn(r.poly,interior.position[0],interior.position[2]));
const id=room?.id||'';if(id!==interior.currentRoom){interior.currentRoom=id;document.querySelectorAll('[data-map-room] .map-room').forEach(p=>p.classList.toggle('active',p.parentElement.dataset.mapRoom===id));}}
}
// Interior paint uses linear RGB, like the other interior PBR materials.
// Only explicitly tagged plaster is edited; no name-based facade match.
const INTERIOR_PAINT_STORAGE = 'locenice.interior.wallPaint.v1';
function normalizeInteriorHex(value) {
if (typeof value !== 'string') return null;
let hex = value.trim().replace(/^#/, '');
if (/^[0-9a-f]{3}$/i.test(hex)) hex = hex.split('').map(c=>c+c).join('');
return /^[0-9a-f]{6}$/i.test(hex) ? '#'+hex.toLowerCase() : null;
}
function setInteriorWallColor(value, persist = true) {
const hex=normalizeInteriorHex(value), paint=interior.data?.wallPaint;
if (!hex || !gltf || !paint) return false;
const rgb=hardieLinearRGB(hex);
for (const index of paint.materialIndices) {
const material=gltf.materials[index];
if (material?.extras?.interiorRole !== 'wallPaint') continue;
material.pbrMetallicRoughness.baseColorFactor=[...rgb,1];
}
interior.wallColor=hex; paint.selectedHex=hex;
$('#interiorWallColor').value=hex;
$('#interiorWallHex').value=hex.toUpperCase();
$('#interiorWallHex').removeAttribute('aria-invalid');
document.querySelectorAll('[data-interior-paint]').forEach(button=>{
button.setAttribute('aria-pressed',String(button.dataset.interiorPaint===hex));
});
// file://, private mode and disabled storage must never break rendering.
if (persist) try {
if (hex===paint.defaultHex) localStorage.removeItem(INTERIOR_PAINT_STORAGE);
else localStorage.setItem(INTERIOR_PAINT_STORAGE,hex);
} catch (_) { /* The live view and export still keep the selected color. */ }
dirty=true;
return true;
}
function resetInteriorWallColor() {
return setInteriorWallColor(interior.data?.wallPaint?.defaultHex || '#eeeae1');
}
function initInteriorWallPaint() {
const paint=interior.data.wallPaint;
let color=paint.selectedHex || paint.defaultHex;
try { color=normalizeInteriorHex(localStorage.getItem(INTERIOR_PAINT_STORAGE)) || color; } catch (_) {}
setInteriorWallColor(color,false);
$('#interiorWallColor').addEventListener('input',e=>setInteriorWallColor(e.target.value));
$('#interiorWallColor').addEventListener('change',e=>setInteriorWallColor(e.target.value));
const hexInput=$('#interiorWallHex');
const commitHex=()=>{
if (!setInteriorWallColor(hexInput.value)) {
hexInput.value=interior.wallColor.toUpperCase();
toast('Zadejte platnou barvu, nap\u0159\u00edklad #EEEAE1.');
}
};
hexInput.addEventListener('change',commitHex);
hexInput.addEventListener('keydown',e=>{
if (e.key==='Enter') { e.preventDefault();commitHex();hexInput.blur(); }
});
document.querySelectorAll('[data-interior-paint]').forEach(button=>{
button.addEventListener('click',()=>setInteriorWallColor(button.dataset.interiorPaint));
});
$('#interiorWallReset').addEventListener('click',resetInteriorWallColor);
}
function initInterior() {
interior.data=gltf.extras.interiorStudy;interior.roof=new Set(interior.data.roofNodes);interior.cutOriginals=new Set(interior.data.cutOriginalNodes);
for(const r of interior.data.rooms){const op=document.createElement('option');op.value=r.id;op.textContent=`${r.id} · ${r.label} · ${r.area} m²`;$('#interiorRoom').appendChild(op);}
buildInteriorMap();
initInteriorWallPaint();
$('#interiorLaunch').onclick=()=>toggleInterior(true);$('#interiorExit').onclick=()=>toggleInterior(false);
document.querySelectorAll('[data-interior-mode]').forEach(b=>b.onclick=()=>setInteriorMode(b.dataset.interiorMode));
document.querySelectorAll('[data-interior-view]').forEach(b=>b.onclick=()=>enterInteriorView(b.dataset.interiorView));
$('#interiorRoom').onchange=e=>{if(e.target.value)enterInteriorView(e.target.value);};
$('#interiorFurniture').onchange=e=>{interior.furniture=e.target.checked;solar.shadowDirty=true;dirty=true;};
$('#interiorRoomLabels').onchange=e=>{interior.labels=e.target.checked;dirty=true;};
$('#interiorFill').onchange=e=>{interior.fill=e.target.checked;dirty=true;};
$('#interiorFit').onclick=()=>{if(interior.mode==='walk')enterInteriorView(interior.lastView);else{interior.userCamera=false;interiorFitCamera();}};
$('#interiorPanelToggle').onclick=()=>{if($('#sunPanel').classList.contains('open'))toggleSunPanel(false);else interior.panel=!interior.panel;updateInteriorInterface();resize();};
$('#interiorSavePng').onclick=()=>{render();canvas.toBlob(b=>{if(b)saveBlob(b,'dum_interier_'+interior.mode+'.png');},'image/png');};
$('#interiorSaveGlb').onclick=()=>{saveBlob(new Blob([currentGlb()],{type:'model/gltf-binary'}),'dum_interier_'+interior.mode+'_'+hardieSelected+'.glb');};
const refs=resources.references;
document.querySelectorAll('[data-int-ref]').forEach(b=>b.onclick=()=>{const r=refs[b.dataset.intRef];$('#interiorReferenceTitle').textContent=r.title;$('#interiorReferenceImage').src=r.src;$('#interiorReferenceImage').alt=r.title;$('#interiorReferenceCaption').textContent=r.caption;$('#interiorReferenceDialog').showModal();});
$('#interiorReferenceClose').onclick=()=>$('#interiorReferenceDialog').close();
$('#interiorReferenceDialog').addEventListener('click',e=>{if(e.target.id==='interiorReferenceDialog'){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}});
document.querySelectorAll('[data-walk]').forEach(b=>{
const release=()=>{interior.pad.delete(b.dataset.walk);b.classList.remove('held');};
b.onpointerdown=e=>{e.preventDefault();b.setPointerCapture(e.pointerId);interior.pad.add(b.dataset.walk);b.classList.add('held');};
b.onpointerup=release;b.onpointercancel=release;b.onlostpointercapture=release;
});
window.addEventListener('keyup',e=>interior.keys.delete(e.code));
window.addEventListener('blur',()=>{interior.keys.clear();interior.pad.clear();interior.lookPointer=null;});
document.addEventListener('visibilitychange',()=>{if(document.hidden){interior.keys.clear();interior.pad.clear();}});
interior.ready=true;$('#interiorLaunch').disabled=false;
window.INTERIOR = {
toggle:toggleInterior,mode:setInteriorMode,view:enterInteriorView,
setWallColor:setInteriorWallColor, resetWallColor:resetInteriorWallColor,
getState:()=>({enabled:interior.enabled,mode:interior.mode,position:[...interior.position],yaw:interior.yaw,pitch:interior.pitch,
furniture:interior.furniture,labels:interior.labels,fill:interior.fill,currentRoom:interior.currentRoom,
wallColor:interior.wallColor,defaultWallColor:interior.data.wallPaint.defaultHex,timberReference:interior.data.timberReference,
rooms:interior.data.rooms.map(r=>({id:r.id,name:r.label,area:r.area})),originalGeometryUnchanged:false,standardsRevision:1,userCorrections:interior.data.userCorrections,
originalNodes:interior.data.originalNodeCount,sourceBinarySHA256:interior.data.sourceBinarySHA256}),
checkPosition:(x,z)=>interiorCanStand(x,z),move:(dx,dz)=>moveInterior(dx,dz),
setLook:(yaw,pitch)=>{interior.yaw=yaw;interior.pitch=clamp(pitch,-1.3,1.3);dirty=true;}
};
}
// ALLSTAV configurator. The PDF is the source; unlabeled samples are not product codes.
const STD_ASSETS=resources.assets;
const STD_STORAGE = 'locenice.allstav.2025.v1';
let stdStorageKey = STD_STORAGE;
const STD_ROOMS=resources.catalog.rooms,STD_OPTIONS=resources.catalog.options,STD_DEFAULTS=resources.catalog.defaults;
const stdClone = x=>JSON.parse(JSON.stringify(x));
let stdState=stdClone(STD_DEFAULTS),stdReady=false,stdSwitching=false,stdTab='exterior',stdFloorRoom='108',stdTileRoom='105',stdCatalogPage=12,stdSaveTimer=0;
const stdEsc = s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const stdOption=(key,val=stdState[key])=>(STD_OPTIONS[key]||[]).find(o=>o[0]===val);
const stdLabel=(key,val=stdState[key])=>stdOption(key,val)?.[1]||String(val);
const stdSource=p=>`<button class="std-source" type="button" data-std-page="${p}">Katalog \u00b7 str. ${p}</button>`;
function stdCards(key,options=STD_OPTIONS[key],cols='',kind='',tier='standard'){
return `<div class="std-grid ${cols}" data-card-group="${key}">${options.map(o=>`<button type="button" class="std-card ${kind}" data-std-key="${key}" data-std-value="${stdEsc(o[0])}" aria-pressed="false" title="${stdEsc(o[1])}">${o[4]?`<img loading="lazy" src="${STD_ASSETS[o[4]]}" alt="${stdEsc(o[1])}">`:o[2]?`<span class="sample" style="background:${o[2]}"></span>`:''}<strong>${stdEsc(o[1])}</strong><small class="${o[3]?'premium':tier==='visual'?'visual':''}">${o[3]?'P\u0159\u00edplatek':((key==='wallPaint'&&o[0]==='#ffffff')||(key==='faucet'&&o[0]==='chrome'))?'Standard':tier==='visual'?'Vlastn\u00ed n\u00e1hled':tier==='catalog'?'Varianta katalogu':'Standard'}</small></button>`).join('')}</div>`;
}
function stdSampleCards(key,type){
return stdCards(key,Array.from({length:9},(_,i)=>[String(i+1),`Vzorek ${String(i+1).padStart(2,'0')}`,'',false,`${type}${i+1}`]),'cols3','wood','catalog');
}
const stdSection=(id,n,title,body,open=false)=>`<details class="std-section" id="std-section-${id}" ${open?'open':''}><summary><span class="std-number">${n}</span><span><strong>${title}</strong><small class="std-summary-current" data-std-current="${id}"></small></span></summary><div class="std-body">${body}</div></details>`;
const stdSelect=(key,label,options)=>`<label class="std-field">${label}<select data-std-input="${key}">${options.map(([v,t])=>`<option value="${stdEsc(v)}">${t}</option>`).join('')}</select></label>`;
const stdCheck=(key,label)=>`<label class="std-check"><input type="checkbox" data-std-input="${key}">${label}</label>`;
const stdColor=(key,label)=>`<label class="std-field">${label}<input type="color" data-std-input="${key}" aria-label="${label}"></label>`;
const stdNotice=(s,t='')=>`<div class="std-notice ${t}">${s}</div>`;
function standardsVisible(m){
const e=m.std||{};
if(e.stdHidden)return false;
if(e.stdFullOnly && interior.enabled && interior.mode!=='walk')return false;
if(e.stdBlind && (!stdState.blinds || (e.stdBlindFraction!==undefined && e.stdBlindFraction>stdState.blindDrop/100)))return false;
if(e.stdWhen && Object.entries(e.stdWhen).some(([k,v])=>stdState[k]!==v))return false;
return true;
}
function stdSetMaterial(role,color,texture){
const meta=gltf.extras.allstavStandards;
for(const idx of meta.materials[role]||[]){
const mat=gltf.materials[idx],p=mat.pbrMetallicRoughness;
if(color)p.baseColorFactor=[...hardieLinearRGB(color),p.baseColorFactor?.[3]??1];
if(texture!==undefined){if(texture===null)delete p.baseColorTexture;else p.baseColorTexture={index:typeof texture==='number'?texture:meta.textures[texture]};}
for(const m of meshes){if(m.material!==idx)continue;m.surfaceKind=p.baseColorTexture?3:2;}
}
}
function stdApply(save=true){
if(!gltf)return;
stdSetMaterial('entry',stdOption('entryColor')[2]);stdSetMaterial('extSill',stdOption('extSill')[2]);stdSetMaterial('gutter',stdOption('gutter')[2]);
recolor('roof',stdOption('roof')[2]);for(const i of gltf.extras.allstavStandards.materials.roof)gltf.materials[i].pbrMetallicRoughness.roughnessFactor=.32;if($('#roofColor'))$('#roofColor').value=stdOption('roof')[2];
stdSetMaterial('blinds',stdOption('blindColor')[2]);
for(const r of STD_ROOMS){const f=stdState.floors[r[0]];stdSetMaterial('floor'+r[0],'#ffffff',f.type+f.sample);for(const i of gltf.extras.allstavStandards.materials['floor'+r[0]]){gltf.materials[i].pbrMetallicRoughness.baseColorTexture.extensions={KHR_texture_transform:{scale:f.type==='tile'?[.22/.6,1.28/.6]:[1,1]}};}}
gltf.extensionsUsed=[...new Set([...(gltf.extensionsUsed||[]),'KHR_texture_transform'])];
stdSetMaterial('wallTile105','#ffffff','tile'+stdState.wallTile105);stdSetMaterial('wallTile106','#ffffff','tile'+stdState.wallTile106);
stdSetMaterial('intSill',stdOption('intSill')[2]);
stdSetMaterial('intDoor',stdState.intDoor==='sample'?'#ffffff':stdState.intDoorColor,stdState.intDoor==='sample'?'prum':null);
setInteriorWallColor(stdState.wallPaint,false);
stdSetMaterial('kitchenFront',stdState.kitchenColor);stdSetMaterial('kitchenBody',stdState.kitchenBodyColor);
const work=stdOption('worktop'),tex=stdState.worktop==='oak'?9:stdState.worktop==='stone'?'tile1':null;
stdSetMaterial('worktop',tex!==null?'#ffffff':work[2],tex);
const back=stdState.backsplash==='worktop'?stdState.worktop:stdState.backsplash;
stdSetMaterial('backsplash',back==='stone'||back==='oak'?'#ffffff':'#eeeeda',back==='stone'?'tile1':back==='oak'?9:null);
if(stdState.backsplash==='worktop'&&back==='graphite')stdSetMaterial('backsplash','#444946',null);
stdSetMaterial('faucet',stdOption('faucet')[2]);
for(const i of gltf.extras.allstavStandards.materials.faucet){gltf.materials[i].pbrMetallicRoughness.metallicFactor=stdState.faucet==='chrome'?.8:.05;}
gltf.extras.allstavSelection=stdClone(stdState);
solar.shadowDirty=true;dirty=true;
if(stdReady){stdSync();if(save){clearTimeout(stdSaveTimer);stdSaveTimer=setTimeout(stdPersist,220);}}
}
function stdNormalize(input){
if(!input||typeof input!=='object'||Array.isArray(input))throw Error('Chyb\u00ed platn\u00e9 nastaven\u00ed.');
const d=stdClone(STD_DEFAULTS),enums={intDoor:['sample','custom'],intDoorType:['hinged','sliding','pocket'],tileCorner:['profile','miter'],hood:['facade','recirculation'],ventilation:['local','central','none'],radiator:['electric','combined']};
for(const k of Object.keys(d)){
if(input[k]===undefined)continue;
const v=input[k];
if(k==='floors'){
if(!v||typeof v!=='object')throw Error('Neplatn\u00e9 podlahy.');
for(const r of STD_ROOMS){if(v[r[0]]===undefined)continue;const f=v[r[0]];if(!f||!['lam','tile'].includes(f.type)||!Number.isInteger(f.sample)||f.sample<1||f.sample>9)throw Error('Neplatn\u00e1 podlaha m\u00edstnosti '+r[0]);d.floors[r[0]]={type:f.type,sample:f.sample};}
} else if(['wallPaint','kitchenColor','kitchenBodyColor','intDoorColor'].includes(k)){
if(typeof v!=='string'||!/^#[0-9a-f]{6}$/i.test(v))throw Error('Neplatn\u00e1 barva: '+k);d[k]=v.toLowerCase();
} else if(STD_OPTIONS[k]){if(!STD_OPTIONS[k].some(o=>o[0]===v))throw Error('Nezn\u00e1m\u00e1 varianta: '+k);d[k]=v;
} else if(enums[k]){if(!enums[k].includes(v))throw Error('Nezn\u00e1m\u00e1 varianta: '+k);d[k]=v;
} else if(typeof d[k]==='boolean'){if(typeof v!=='boolean')throw Error('Neplatn\u00fd p\u0159ep\u00edna\u010d: '+k);d[k]=v;
} else if(typeof d[k]==='number'){const max=k==='blindDrop'?100:9,min=k==='blindDrop'?0:1;if(!Number.isInteger(v)||v<min||v>max)throw Error('Hodnota mimo rozsah: '+k);d[k]=v;
} else if(typeof d[k]==='string'){if(typeof v!=='string'||v.length>(k==='note'?2000:120))throw Error('Neplatn\u00fd text: '+k);d[k]=v;}
}
// Backwards compatibility: older exports used one colour for both.
if(input.kitchenBodyColor===undefined)d.kitchenBodyColor=d.kitchenColor;
return d;
}
function stdPersist(){try{localStorage.setItem(stdStorageKey,JSON.stringify(stdState));$('#stdSaved').textContent='Ulo\u017eeno v tomto prohl\u00ed\u017ee\u010di \u00b7 z\u00e1lohu st\u00e1hnete jako JSON';}catch(e){$('#stdSaved').textContent='Prohl\u00ed\u017ee\u010d nepovolil ulo\u017een\u00ed. St\u00e1hn\u011bte JSON.';}}
function stdBuildPanel(){
let ext='<div class="std-shortcuts"><button data-std-view="entry">Pohled na vstup</button><button data-std-view="terrace">Terasa a \u017ealuzie</button><button data-std-view="site">Cel\u00fd pozemek</button></div>';
ext+=stdSection('entry','01','Vchodov\u00e9 dve\u0159e',`<div class="std-label">Tvar k\u0159\u00eddla ${stdSource(12)}</div>${stdCards('entryModel',undefined,'','door')}<div class="std-label">Odst\u00edn dve\u0159\u00ed</div>${stdCards('entryColor')}${stdNotice('<strong>V\u00fdchoz\u00ed: 3 skla, RAL 7016.</strong> Prosklen\u00e9 proveden\u00ed je v katalogu p\u0159\u00edplatkov\u00e9. N\u00e1zvy tvar\u016f jsou popisn\u00e9; modelov\u00e9 k\u00f3dy katalog neuv\u00e1d\u00ed.','amber')}<button class="std-action wide" data-std-page="requestedDoor">Zobrazit v\u00e1\u0161 referen\u010dn\u00ed obr\u00e1zek</button><p>Masivn\u00ed d\u0159ev\u011bn\u00e9 dve\u0159e KeraT\u00fcr. 3D tvar je ilustrativn\u00ed; stavebn\u00ed otvor a bo\u010dn\u00ed sv\u011btl\u00edk p\u016fvodn\u00edho modelu z\u016fst\u00e1vaj\u00ed.</p>`,true);
ext+=stdSection('extSill','02','Venkovn\u00ed parapety',`<div class="std-label">Ta\u017een\u00fd hlin\u00edk ${stdSource(13)}</div>${stdCards('extSill',undefined,'cols3')}<p>Volba m\u011bn\u00ed parapety. N\u00e1\u0161lapn\u00e9 prahy u terasov\u00fdch dve\u0159\u00ed z\u016fst\u00e1vaj\u00ed samostatn\u00e9.</p>`);
ext+=stdSection('gutter','03','Okapy a svody',`<div class="std-label">Lindab ${stdSource(13)}</div>${stdCards('gutter')}${stdNotice('M\u011bn\u00ed se <strong>jen okapov\u00fd syst\u00e9m</strong>, nikoli lemov\u00e1n\u00ed st\u0159echy, kom\u00edn nebo z\u00e1bradl\u00ed.')}<p>Zkratky a k\u00f3dy jsou p\u0159evzat\u00e9 z katalogu. CERN 015 = v\u00fdchoz\u00ed volba.</p>`);
ext+=stdSection('roof','04','St\u0159e\u0161n\u00ed ta\u0161ky',`<div class="std-label">Benders Palema Candor ${stdSource(14)}</div>${stdCards('roof',undefined,'cols3')}<p>Betonov\u00e1 ta\u0161ka, vysoce leskl\u00e1 povrchov\u00e1 \u00faprava. N\u00e1hled zachov\u00e1v\u00e1 geometrii krytiny p\u016fvodn\u00edho modelu.</p>`);
ext+=stdSection('blinds','05','Venkovn\u00ed \u017ealuzie',`<label class="std-toggle">Osadit venkovn\u00ed \u017ealuzie<input type="checkbox" data-std-input="blinds"></label><div class="std-label">Climax Zetta 70 \u00b7 p\u0159\u00edplatek ${stdSource(15)}</div><div id="stdBlindsControls">${stdCards('blindColor',undefined,'cols5','', 'catalog')}<div class="std-range-head"><span>Sta\u017een\u00ed lamel</span><strong id="stdBlindDropLabel">100 %</strong></div><label class="std-field"><input type="range" min="0" max="100" step="5" data-std-input="blindDrop" aria-label="Sta\u017een\u00ed \u017ealuzi\u00ed"></label></div>${stdNotice('<strong>9 obd\u00e9ln\u00edkov\u00fdch otvor\u016f.</strong> Troj\u00faheln\u00edkov\u00e9 okno ani vchodov\u00e9 dve\u0159e \u017ealuzie nemaj\u00ed. Vodic\u00ed li\u0161ty maj\u00ed shodnou barvu; \u0161irok\u00e9 ji\u017en\u00ed zasklen\u00ed je rozd\u011blen\u00e9.')}<p>Podom\u00edtkov\u00fd kastl\u00edk, elektrick\u00e9 ovl\u00e1d\u00e1n\u00ed. Sta\u017een\u00ed je spole\u010dn\u00fd ilustrativn\u00ed n\u00e1hled, ne n\u00e1vrh pohonu.</p>`);
ext+=stdNotice('Fas\u00e1da James Hardie Plank a okol\u00ed domu z\u016fst\u00e1vaj\u00ed v p\u016fvodn\u00edch ovl\u00e1dac\u00edch prvc\u00edch. <button class="std-source" data-std-action="original">Otev\u0159\u00edt materi\u00e1ly fas\u00e1dy</button>');
$('#std-exterior').innerHTML=ext;
let int='<div class="std-shortcuts"><button data-std-view="plan">P\u016fdorys</button><button data-std-view="axon">3D \u0159ez</button><button data-std-view="108">Kuchy\u0148</button><button data-std-view="105">Koupelna</button></div>';
int+=stdSection('floors','01','Podlahy po m\u00edstnostech',`<div class="std-label">KronoSwiss / keramick\u00e1 dla\u017eba ${stdSource(18)}</div><label class="std-field">M\u00edstnost<select id="stdFloorRoom">${STD_ROOMS.map(r=>`<option value="${r[0]}">${r[0]} \u00b7 ${r[1]} \u00b7 ${r[2]} m\u00b2</option>`).join('')}</select></label><div class="std-two"><label class="std-field">Krytina<select id="stdFloorType"><option value="lam">Lamin\u00e1t KronoSwiss</option><option value="tile">Keramick\u00e1 dla\u017eba</option></select></label><label class="std-field">N\u00e1hled m\u00edstnosti<button class="std-action wide" data-std-action="floorView">P\u0159ej\u00edt do m\u00edstnosti</button></label></div><div id="stdFloorSamples"></div><button class="std-action wide" data-std-action="copyFloor">Stejn\u00fd vzorek pro m\u00edstnosti se stejnou krytinou</button><div id="stdFloorProjectNote" class="std-notice"></div><p>Vzorky 01\u201309 odpov\u00eddaj\u00ed obr\u00e1zk\u016fm na str. 18, nikoli objednac\u00edm k\u00f3d\u016fm. M\u011b\u0159\u00edtko kresby a sp\u00e1ry jsou pouze n\u00e1hledov\u00e9.</p>`,true);
int+=stdSection('tiles','02','Obklady koupelny a WC',`<div class="std-label">Rektifikovan\u00e9 obklady ${stdSource(18)}</div><label class="std-field">M\u00edstnost<select id="stdTileRoom"><option value="105">105 \u00b7 Koupelna</option><option value="106">106 \u00b7 WC</option></select></label>${stdSampleCards('wallTile','tile')}${stdSelect('tileCorner','Zakon\u010den\u00ed hran \u00b7 specifikace',[['profile','Hlin\u00edkov\u00e1 eloxov\u00e1 li\u0161ta \u00b7 standard'],['miter','Kamenick\u00fd roh \u00b7 p\u0159\u00edplatek']])}<p>Obklad do v\u00fd\u0161ky 2,1 m dle technick\u00e9 zpr\u00e1vy. Ve 3D \u0159ezu je zobrazen pouze po v\u00fd\u0161ku \u0159ezu; ukon\u010den\u00ed hran se eviduje v soupisu.</p>`);
int+=stdSection('doors','03','Interi\u00e9rov\u00e9 dve\u0159e a kov\u00e1n\u00ed',`<div class="std-label">Pr\u00fcm CPL ${stdSource(19)}</div>${stdCards('intDoor',[['sample','Fotografie z katalogu','',false,'prum'],['custom','Vlastn\u00ed odst\u00edn','#c3aa85']], 'cols2','wood','catalog')}<div id="stdDoorCustom">${stdColor('intDoorColor','Vlastn\u00ed barva \u00b7 pouze vizualizace')}</div><label class="std-field">P\u0159esn\u00fd dekor / k\u00f3d po konzultaci<input type="text" data-std-input="intDoorCode" maxlength="120" placeholder="Zat\u00edm neur\u010deno"></label>${stdNotice('Katalog uv\u00e1d\u00ed v\u00edce ne\u017e 20 CPL dekor\u016f, ale jejich vzorn\u00edk a k\u00f3dy neobsahuje. Fotografie dve\u0159\u00ed je referen\u010dn\u00ed, nikoli p\u0159esn\u00fd dekor.','amber')}${stdSelect('intDoorType','Proveden\u00ed \u00b7 specifikace',[['hinged','Oto\u010dn\u00e9 pln\u00e9 \u00b7 standard'],['sliding','Posuvn\u00e9 po zdi \u00b7 p\u0159\u00edplatek'],['pocket','Do pouzdra Eclisse \u00b7 p\u0159\u00edplatek']])}<div class="std-label">Brou\u0161en\u00fd nerez \u00b7 rozetov\u00e9 kov\u00e1n\u00ed</div>${stdCards('intHandle',undefined,'cols2')}<p>Koupelna: Klima 2. Koupelna a WC: WC z\u00e1mek. Posuvn\u00e9 varianty se zaznamenaj\u00ed do soupisu; 3D otvory ani kolize p\u0159\u00ed\u010dek se nep\u0159eprojektov\u00e1vaj\u00ed.</p>`);
int+=stdSection('finishes','04','Parapety a malba',`<div class="std-label">Helopal \u00b7 lit\u00fd mramor ${stdSource(16)}</div>${stdCards('intSill',undefined,'cols3')}<p>Fotografie ukazuj\u00ed katalogov\u00fd vzorek. V modelu se m\u011bn\u00ed odst\u00edn; \u017e\u00edh\u00e1n\u00ed je na fotografii.</p><div class="std-label">Malba st\u011bn</div>${stdCards('wallPaint',STD_OPTIONS.wallPaint.map((o,i)=>[...o]),'','', 'visual')}${stdColor('wallPaint','Vlastn\u00ed odst\u00edn st\u011bn')}${stdNotice('<strong>Standard: HET b\u00edl\u00e1, \u0161pachtlov\u00e1n\u00ed Q2.</strong> Ostatn\u00ed barvy jsou vlastn\u00ed n\u00e1hled, nikoli katalogov\u00fd vzorn\u00edk. Barva st\u011bn nem\u011bn\u00ed tr\u00e1my ani strop.')}`);
int+=stdSection('kitchen','05','Kuchy\u0148 \u00b7 vlastn\u00ed n\u00e1vrh',`${stdNotice('<strong>Kuchy\u0148 nen\u00ed v katalogu barevn\u011b ani typov\u011b specifikovan\u00e1.</strong> Projekt ponech\u00e1v\u00e1 jej\u00ed dod\u00e1vku na realizaci. N\u00e1sleduj\u00edc\u00ed volby slou\u017e\u00ed pouze pro slad\u011bn\u00ed interi\u00e9ru.','blue')}<div class="std-label">\u010cela sk\u0159\u00edn\u011bk</div>${stdCards('kitchenColor',undefined,'cols3','', 'visual')}${stdColor('kitchenColor','Vlastn\u00ed odst\u00edn \u010del')}<div class="std-label">Korpusy sk\u0159\u00edn\u011bk</div>${stdCards('kitchenBodyColor',undefined,'cols3','', 'visual')}${stdColor('kitchenBodyColor','Vlastn\u00ed odst\u00edn korpus\u016f')}<button class="std-action wide" data-std-action="matchKitchenBody">Sjednotit korpusy s \u010dely</button><div class="std-label">Pracovn\u00ed deska</div>${stdCards('worktop',undefined,'','', 'visual')}<div class="std-label">Obklad za linkou</div>${stdCards('backsplash',undefined,'cols3','', 'visual')}<button class="std-action wide" data-std-view="kitchen">Detail kuchyn\u011b</button><p>Rozvr\u017een\u00ed linky vych\u00e1z\u00ed z p\u016fvodn\u00edho HTML. Typy spot\u0159ebi\u010d\u016f, rozm\u011bry modul\u016f a cena nejsou t\u00edmto n\u00e1hledem ur\u010deny.</p>`);
int+=stdSection('bath','06','Koupelna a sanita',`<div class="std-label">Proveden\u00ed sprchy ${stdSource(21)}</div>${stdCards('shower',undefined,'cols2','', 'catalog')}<div id="stdShowerSpec" class="std-notice"></div><button class="std-action wide" data-std-view="shower">Detail sprchy</button><div class="std-label">Baterie</div>${stdCards('faucet',undefined,'cols2','', 'visual')}<p>Standardem jsou chromov\u00e9 Ravak Classic. \u010cern\u00e1 je vlastn\u00ed vizualiza\u010dn\u00ed alternativa, ne potvrzen\u00fd produkt z katalogu.</p><div class="std-label">Pevn\u00e9 vybaven\u00ed dle projektu ${stdSource(20)}</div><div class="std-inventory"><div><b>Vana Ravak Chrome \u00b7 1700 \u00d7 750 mm</b><small>Ravak Classic, click-clack, hlavice s hadic\u00ed 1,5 m.</small></div><div><b>Umyvadlo Laufen Pro \u00b7 600 \u00d7 420 mm</b><small>Ravak Classic, chromov\u00fd sifon. Mal\u00e9 umyvadlo na WC dle p\u016fdorysu: konkr\u00e9tn\u00ed typ potvrdit.</small></div><div><b>2\u00d7 WC \u00b7 koupelna a samostatn\u00e9 WC</b><small>Geberit 112, Ravak Uni Chrome Rimoff, softclose, b\u00edl\u00e9 tla\u010d\u00edtko Sigma.</small></div><div><b>Topn\u00fd \u017eeb\u0159\u00edk Koralux \u00b7 500 \u00d7 1500 mm</b><small>Volba p\u0159ipojen\u00ed v technick\u00e9 specifikaci.</small></div></div><p>Geometrie vybaven\u00ed je zjednodu\u0161en\u00e1. Rozm\u00edst\u011bn\u00ed vych\u00e1z\u00ed z p\u016fdorysu; nenahrazuje d\u00edlensk\u00fd n\u00e1vrh koupelny.</p>`);
int+=stdSection('tech','07','Technika a dal\u0161\u00ed specifikace',`${stdNotice('<strong>Volby v t\u00e9to sekci se ukl\u00e1daj\u00ed do soupisu.</strong> Model nep\u0159epo\u010d\u00edt\u00e1v\u00e1 ani nem\u011bn\u00ed rozvody, prostupy, v\u00fdkony a polohy jednotek.','blue')}<div class="std-label">V\u011btr\u00e1n\u00ed a chlazen\u00ed ${stdSource(25)}</div>${stdSelect('ventilation','Rekuperace',[['local','Lok\u00e1ln\u00ed Dalap Zephir \u00b7 dle projektu'],['central','Centr\u00e1ln\u00ed Brink Flair / Air Excellent'],['none','Bez rekuperace \u00b7 zm\u011bna proti projektu']])}${stdCheck('aircon','Klimatizace Panasonic \u00b7 voliteln\u00e9 vybaven\u00ed')}${stdCheck('fans','Nucen\u00e9 v\u011btr\u00e1n\u00ed koupelny / WC \u00b7 Airflow ECO 15')}${stdSelect('hood','Digesto\u0159',[['facade','V\u00fdvod na fas\u00e1du \u00d8 150 mm \u00b7 p\u0159\u00edplatek'],['recirculation','Cirkula\u010dn\u00ed \u00b7 standard']])}<p>V\u00fdchoz\u00ed technick\u00e9 volby sleduj\u00ed popisy v p\u016fdorysu; nacen\u011bn\u00ed a rozsah potvrzuje dodavatel. ${stdSource(16)}</p><div class="std-label">Voda a vyt\u00e1p\u011bn\u00ed ${stdSource(24)}</div>${stdCheck('hotWaterCirculation','Cirkulace tepl\u00e9 vody \u00b7 na p\u0159\u00e1n\u00ed')}${stdSelect('radiator','Topn\u00fd \u017eeb\u0159\u00edk',[['electric','Elektropatrona \u00b7 standard'],['combined','Nav\u00edc samostatn\u00fd topn\u00fd okruh \u00b7 p\u0159\u00edplatek']])}<div class="std-inventory"><div><b>Teplovodn\u00ed podlahov\u00e9 topen\u00ed</b><small>Panasonic vzduch\u2013voda, integrovan\u00fd z\u00e1sobn\u00edk T\u00daV. Katalog uv\u00e1d\u00ed do 5 kW, skute\u010dn\u00fd n\u00e1vrh dle projektu TZB.</small></div><div><b>ABB Levit \u00b7 b\u00edl\u00e1 / b\u00edl\u00e1</b><small>Standard z\u00e1suvek a vyp\u00edna\u010d\u016f. ${stdSource(17)}</small></div><div><b>Tepeln\u011bizola\u010dn\u00ed p\u016fdn\u00ed sch\u016fdky Fakro LWT</b><small>V dom\u011b nen\u00ed b\u011b\u017en\u00e9 schodi\u0161t\u011b. Rozm\u011br otvoru potvrdit podle projektu. ${stdSource(22)}</small></div></div>`);
$('#std-interior').innerHTML=int;
$('#std-summary').innerHTML=`<h3 class="std-summary-title">V\u00e1\u0161 v\u00fdb\u011br</h3>${stdNotice('P\u0159ehled variant, nikoli cenov\u00e1 nab\u00eddka. P\u0159\u00edplatky ani dostupnost katalog 2025 neoce\u0148uje. P\u0159ed objedn\u00e1n\u00edm potvr\u010fte aktu\u00e1ln\u00ed vzorn\u00edky s ALLSTAV.','amber')}<div id="stdSummaryContent"></div><label class="std-field">Pozn\u00e1mka pro dodavatele<textarea data-std-input="note" maxlength="2000" rows="3" style="width:100%;resize:vertical;padding:9px;border:1px solid #d7dfcf;border-radius:7px;font:inherit" placeholder="Up\u0159esn\u011bn\u00ed, k\u00f3dy dekor\u016f, dotazy..."></textarea></label><div class="std-export-grid"><button data-std-action="saveHtml">Zkop\u00edrovat odkaz s v\u00fdb\u011brem</button><button data-std-action="exportJson">Export nastaven\u00ed JSON</button><button data-std-action="importJson">Na\u010d\u00edst nastaven\u00ed JSON</button><button data-std-action="exportText">St\u00e1hnout soupis TXT</button><button data-std-action="png">Ulo\u017eit aktu\u00e1ln\u00ed n\u00e1hled PNG</button><button data-std-action="glb">Export viditeln\u00e9ho 3D GLB</button><button data-std-action="reset" class="std-reset">Obnovit v\u00fdchoz\u00ed standardy</button><button data-std-page="12">Otev\u0159\u00edt zdrojov\u00fd katalog</button></div><p class="std-fine">Zdroj: dodan\u00fd ALLSTAV Standardy, z\u00e1\u0159\u00ed 2025, a projekt RD. Fotografie a vzorky jsou v samostatn\u00e9 slo\u017ece assets. RGB n\u00e1hled nenahrazuje fyzick\u00fd vzorek RAL ani materi\u00e1lu. 3D n\u00e1hled nen\u00ed prov\u00e1d\u011bc\u00ed dokumentace.</p>`;
}
function stdSummary(){
const s=stdState,premium='P\u0159\u00edplatek',vis='Vlastn\u00ed vizualizace',spec='Specifikace; bez zm\u011bny geometrie';
const groups=[['Exteri\u00e9r',[
['Vchodov\u00e9 dve\u0159e',`KeraT\u00fcr \u00b7 ${stdLabel('entryModel')} \u00b7 ${stdLabel('entryColor')}`,stdOption('entryModel')[3]?premium:'Standard'],
['Venkovn\u00ed parapety',stdLabel('extSill'),stdOption('extSill')[3]?premium:'Standard'],['Okapy / svody',`Lindab ${stdLabel('gutter')}`,stdOption('gutter')[3]?premium:'Standard'],
['St\u0159e\u0161n\u00ed ta\u0161ky',`Benders Palema Candor \u00b7 ${stdLabel('roof')}`,'Standard'],
['Venkovn\u00ed \u017ealuzie',s.blinds?`Zetta 70 \u00b7 ${stdLabel('blindColor')} \u00b7 ${s.blindDrop} %`:'Vypnuto',s.blinds?premium+'; 9 otvor\u016f, bez troj\u00faheln\u00edku':'Voliteln\u00e9; projekt s nimi po\u010d\u00edt\u00e1']]],
['Podlahy',STD_ROOMS.map(r=>{const f=s.floors[r[0]];return[`${r[0]} ${r[1]} (${r[2]} m\u00b2)`,`${f.type==='lam'?'KronoSwiss lamin\u00e1t':'Keramick\u00e1 dla\u017eba'} \u00b7 vzorek ${String(f.sample).padStart(2,'0')}`,f.type===r[3]?'K\u00f3d dekoru potvrdit':'Zm\u011bna druhu proti projektu'];})],
['Povrchy a dve\u0159e',[
['Obklad koupelny',`Vzorek ${s.wallTile105} \u00b7 do 2,1 m`,'K\u00f3d dekoru potvrdit'],['Obklad WC',`Vzorek ${s.wallTile106} \u00b7 do 2,1 m`,'K\u00f3d dekoru potvrdit'],
['Ukon\u010den\u00ed obkladu',s.tileCorner==='profile'?'Eloxov\u00e1 li\u0161ta':'Kamenick\u00fd roh',s.tileCorner==='miter'?premium+'; '+spec:spec],['Vnit\u0159n\u00ed parapety','Helopal \u00b7 '+stdLabel('intSill'),'Standard'],
['Malba',s.wallPaint==='#ffffff'?'HET b\u00edl\u00e1 \u00b7 Q2':s.wallPaint,s.wallPaint==='#ffffff'?'Standard':vis],
['Interi\u00e9rov\u00e9 dve\u0159e','Pr\u00fcm CPL \u00b7 '+(s.intDoorCode||'dekor neur\u010den'),s.intDoor==='sample'?'Referen\u010dn\u00ed fotografie':vis+' '+s.intDoorColor],
['Proveden\u00ed dve\u0159\u00ed',({hinged:'Oto\u010dn\u00e9 pln\u00e9',sliding:'Posuvn\u00e9 po zdi',pocket:'Do pouzdra Eclisse'})[s.intDoorType],s.intDoorType==='hinged'?'Standard':premium+'; '+spec],
['Kov\u00e1n\u00ed',stdLabel('intHandle')+' rozeta \u00b7 brou\u0161en\u00fd nerez','Standard; WC z\u00e1mky, koupelna Klima 2']]],
['Kuchy\u0148 \u00b7 vlastn\u00ed n\u00e1vrh',[
['\u010cela sk\u0159\u00edn\u011bk',stdLabel('kitchenColor')+' '+s.kitchenColor,vis],['Korpusy sk\u0159\u00edn\u011bk',stdLabel('kitchenBodyColor')+' '+s.kitchenBodyColor,vis],['Pracovn\u00ed deska',stdLabel('worktop'),vis],['Obklad linky',stdLabel('backsplash'),vis]]],
['Koupelna a sanita',[
['Sprcha',s.shower==='tray'?'Ravak Chrome 900 \u00d7 900 mm, vani\u010dka + z\u00e1st\u011bna':'Walk-in, z\u00e1st\u011bna 800 mm, \u017el\u00e1bek 750 mm','Katalog str. 21; variantu a cenu potvrdit'],
['Baterie',s.faucet==='chrome'?'Ravak Classic \u00b7 chrom':'\u010cern\u00e9 proveden\u00ed \u00b7 typ neur\u010den',s.faucet==='chrome'?'Standard':vis],
['Vana','Ravak Chrome 1700 \u00d7 750 mm','Dle projektu'],['Umyvadlo koupelny','Laufen Pro 600 \u00d7 420 mm','Mal\u00e9 umyvadlo na WC: typ potvrdit'],['WC','2\u00d7 Geberit 112 / Ravak Uni Chrome Rimoff','Softclose; b\u00edl\u00e1 Sigma; dle p\u016fdorysu']]],
['Technika \u00b7 specifikace',[
['Rekuperace',({local:'Lok\u00e1ln\u00ed Dalap Zephir',central:'Brink Flair / Air Excellent',none:'Bez rekuperace'})[s.ventilation],spec],['Klimatizace',s.aircon?'Panasonic':'Bez klimatizace',spec],['Nucen\u00e9 v\u011btr\u00e1n\u00ed',s.fans?'Airflow ECO 15':'Bez dopl\u0148kov\u00fdch ventil\u00e1tor\u016f',s.fans?premium+'; '+spec:spec],
['Digesto\u0159',s.hood==='facade'?'V\u00fdvod na fas\u00e1du \u00d8 150 mm':'Cirkula\u010dn\u00ed',s.hood==='facade'?premium+'; '+spec:'Standard; '+spec],['Cirkulace T\u00daV',s.hotWaterCirculation?'Ano':'Ne',spec],['Koralux 500 \u00d7 1500',s.radiator==='electric'?'Elektropatrona':'Elektropatrona + samostatn\u00fd okruh',s.radiator==='combined'?premium+'; '+spec:'Standard'],['Z\u00e1suvky / vyp\u00edna\u010de','ABB Levit b\u00edl\u00e1 / b\u00edl\u00e1','Standard'],['Vyt\u00e1p\u011bn\u00ed','Teplovodn\u00ed podlahov\u00e9 / Panasonic vzduch-voda','V\u00fdkon a rozvody dle projektu TZB'],['P\u016fdn\u00ed sch\u016fdky','Fakro LWT','Rozm\u011br potvrdit dle projektu']]]];
return groups;
}
function stdSync(){
if(!stdReady)return;
for(const el of document.querySelectorAll('[data-std-input]')){const v=stdState[el.dataset.stdInput];if(el.type==='checkbox')el.checked=v;else if(document.activeElement!==el)el.value=v;}
for(const b of document.querySelectorAll('[data-std-key]')){
const k=b.dataset.stdKey;const v=k==='floorSample'?stdState.floors[stdFloorRoom].sample:k==='wallTile'?stdState['wallTile'+stdTileRoom]:stdState[k];b.setAttribute('aria-pressed',String(String(v)===b.dataset.stdValue));
}
const currents={entry:stdLabel('entryModel')+' \u00b7 '+stdLabel('entryColor'),extSill:stdLabel('extSill'),gutter:'Lindab '+stdLabel('gutter'),roof:stdLabel('roof'),blinds:stdState.blinds?stdLabel('blindColor')+' \u00b7 '+stdState.blindDrop+' %':'Vypnuto \u00b7 voliteln\u00e9',floors:'9 m\u00edstnost\u00ed \u00b7 samostatn\u00fd v\u00fdb\u011br dekoru',tiles:'Koupelna '+stdState.wallTile105+' \u00b7 WC '+stdState.wallTile106,doors:'Pr\u00fcm CPL \u00b7 '+stdLabel('intHandle')+' rozeta',finishes:'Helopal '+stdLabel('intSill')+' \u00b7 '+(stdState.wallPaint==='#ffffff'?'HET b\u00edl\u00e1':stdState.wallPaint),kitchen:stdLabel('kitchenColor')+' \u00b7 '+stdLabel('worktop'),bath:stdLabel('shower')+' \u00b7 '+stdLabel('faucet'),tech:'V\u011btr\u00e1n\u00ed, topen\u00ed, elektro'};
for(const e of document.querySelectorAll('[data-std-current]'))e.textContent=currents[e.dataset.stdCurrent]||'';
$('#stdBlindsControls').classList.toggle('std-disabled',!stdState.blinds);$('#stdBlindsControls').querySelectorAll('button,input').forEach(e=>e.disabled=!stdState.blinds);$('#stdBlindDropLabel').textContent=stdState.blindDrop+' %';
$('#stdDoorCustom').hidden=stdState.intDoor!=='custom';
$('#stdFloorRoom').value=stdFloorRoom;$('#stdFloorType').value=stdState.floors[stdFloorRoom].type;$('#stdTileRoom').value=stdTileRoom;
const f=stdState.floors[stdFloorRoom],r=STD_ROOMS.find(r=>r[0]===stdFloorRoom);
if($('#stdFloorSamples').dataset.type!==f.type){$('#stdFloorSamples').innerHTML=stdSampleCards('floorSample',f.type);$('#stdFloorSamples').dataset.type=f.type;$('#stdFloorSamples').querySelectorAll('button').forEach(b=>b.setAttribute('aria-pressed',String(+b.dataset.stdValue===f.sample)));}
$('#stdFloorProjectNote').textContent='Projekt: '+(r[3]==='lam'?'lamin\u00e1t':'keramick\u00e1 dla\u017eba')+'. '+(f.type===r[3]?'Zvolen\u00fd druh krytiny odpov\u00edd\u00e1 projektu.':'Zvolen\u00fd druh se od projektu li\u0161\u00ed. Nutn\u00e9 potvrdit skladbu a v\u00fd\u0161ku podlahy.');
$('#stdFloorProjectNote').classList.toggle('amber',f.type!==r[3]);
$('#stdShowerSpec').textContent=stdState.shower==='tray'?'Ravak Chrome 900 \u00d7 900 mm, vani\u010dka a z\u00e1st\u011bna. Ravak Classic a sprchov\u00fd set 922 se 3 funkcemi.':'Walk-in: nerezov\u00fd odtokov\u00fd \u017el\u00e1bek 750 mm, pevn\u00e1 z\u00e1st\u011bna 800 mm, sp\u00e1dov\u00fd profil. Potvrdit skladbu podlahy, hydroizolaci a cenu.';
$('#stdSummaryContent').innerHTML=stdSummary().map(([heading,rows])=>`<section class="std-summary-group"><h4>${stdEsc(heading)}</h4>${rows.map(([a,b,c])=>`<div class="std-summary-row"><span>${stdEsc(a)}</span><b>${stdEsc(b)}<small>${stdEsc(c)}</small></b></div>`).join('')}</section>`).join('');
}
function stdSet(key,value){
const next=stdClone(stdState);
if(key==='floorSample')next.floors[stdFloorRoom].sample=+value;else if(key==='wallTile')next['wallTile'+stdTileRoom]=+value;else next[key]=value;
try{stdState=stdNormalize(next);if(['note','intDoorCode','intDoorType','tileCorner','hood','fans','aircon','ventilation','hotWaterCirculation','radiator'].includes(key)){gltf.extras.allstavSelection=stdClone(stdState);stdSync();clearTimeout(stdSaveTimer);stdSaveTimer=setTimeout(stdPersist,220);}else stdApply();}catch(e){toast(e.message);}
}
function stdClose(){document.body.classList.remove('std-open');$('#standardsToggle').classList.remove('active');$('#standardsToggle').setAttribute('aria-expanded','false');resize();}
function stdOpen(tab=stdTab){
stdSwitching=true;
if($('#sunPanel').classList.contains('open'))toggleSunPanel(false);
$('#panel').classList.remove('open');$('#infoButton').classList.remove('active');
if(tab==='interior'&&!interior.enabled)toggleInterior(true);if(tab==='exterior'&&interior.enabled)toggleInterior(false);
if($('#sunPanel').classList.contains('open'))toggleSunPanel(false);
document.body.classList.add('std-open');stdTab=tab;
$('#standardsToggle').classList.add('active');$('#standardsToggle').setAttribute('aria-expanded','true');
document.querySelectorAll('[data-std-tab]').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.stdTab===tab)));
['exterior','interior','summary'].forEach(k=>$('#std-'+k).hidden=k!==tab);
autoRotate=false;$('#rotateButton')?.classList.remove('active');resize();stdSync();stdSwitching=false;
}
function stdView(id){
if(['entry','terrace','site'].includes(id)){
stdOpen('exterior');if(id==='site'){setPreset('entry');return;}
setCamera(id==='entry'?{theta:-.12,phi:.09,distance:Math.max(8.5,8.5/aspect),target:[-3.65,1.22,3.84],ortho:false}:{theta:1.05,phi:.34,distance:Math.max(37,34/aspect),target:[1.0,1.0,0],ortho:false},id==='entry'?'Vstup \u00b7 standardy':'Terasa \u00b7 standardy');return;
}
stdOpen('interior');interior.furniture=true;$('#interiorFurniture').checked=true;
if(id==='plan'||id==='axon'){setInteriorMode(id);return;}
if(id==='shower'){
enterInteriorView('105');interior.position=[-2.20,1.55,-2.10];const look=[-3.48,.93,-3.82],dir=vsub(look,interior.position);interior.yaw=Math.atan2(dir[0],dir[2]);interior.pitch=Math.atan2(dir[1],Math.hypot(dir[0],dir[2]));interior.fov=72;dirty=true;return;
}
if(id==='kitchen'){const v=interior.data.presets.find(p=>/kitchen/i.test(p.id));enterInteriorView(v?.id||'108');return;}
enterInteriorView(id);
}
function stdShowCatalog(page){
stdCatalogPage=page==='requestedDoor'?'requestedDoor':Math.min(25,Math.max(12,+page||12));
const key=stdCatalogPage==='requestedDoor'?'requestedDoor':'page'+stdCatalogPage;
$('#stdCatalogImage').src=STD_ASSETS[key];$('#stdCatalogImage').alt=stdCatalogPage==='requestedDoor'?'V\u00e1\u0161 referen\u010dn\u00ed obr\u00e1zek dve\u0159\u00ed':'ALLSTAV Standardy 2025, strana '+stdCatalogPage;
$('#stdCatalogTitle').textContent=stdCatalogPage==='requestedDoor'?'V\u00fdchoz\u00ed dve\u0159e \u00b7 v\u00e1\u0161 obr\u00e1zek':'ALLSTAV Standardy 2025 \u00b7 strana '+stdCatalogPage;
$('#stdCatalogCaption').textContent='P\u016fvodn\u00ed podklad. Odst\u00edny na displeji a fotografie jsou pouze orienta\u010dn\u00ed; konkr\u00e9tn\u00ed v\u00fdrobek a dostupnost ov\u011b\u0159te u dodavatele.';
if(!$('#stdCatalogDialog').open)$('#stdCatalogDialog').showModal();
interior.keys.clear();interior.pad.clear();
}
function stdExportData(){return{schema:'allstav-rd-config',version:1,catalog:'ALLSTAV Standardy, zari 2025',project:'RD Locenice',exportedAt:new Date().toISOString(),selection:stdClone(stdState),summary:stdSummary(),scope:'Illustrative geometry. Kitchen is a custom visualization. Technical alternatives are specification-only.'};}
function stdText(){return 'RD LOCENICE | VYBER STANDARDU\nALLSTAV, katalog zari 2025\n'+new Date().toLocaleDateString('cs-CZ')+'\n\n'+stdSummary().map(([title,rows])=>title.toUpperCase()+'\n'+rows.map(([a,b,c])=>a+': '+b+' ['+c+']').join('\n')).join('\n\n')+'\n\nPOZNAMKA\n'+(stdState.note||'-')+'\n\nSoupis neni cenovou nabidkou. Kody neoznacenych vzorku, ceny, dostupnost a projektove zmeny potvrdit s dodavatelem. Vlastni kuchynske a barevne alternativy nejsou katalogovym standardem. 3D je ilustrativni, nikoli provadeci dokumentace.\n';}
function stdSaveHtml(){
const payload=stdExportData();const bytes=new TextEncoder().encode(JSON.stringify(payload));
let binaryText='';bytes.forEach(b=>binaryText+=String.fromCharCode(b));
const encoded=btoa(binaryText).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
const url=new URL(location.href);url.searchParams.delete('renderer');url.hash='vyber='+encoded;
// Hash stays in the browser, not a server-side database. It contains the note too.
if(navigator.clipboard&&window.isSecureContext)navigator.clipboard.writeText(url.href).then(()=>toast('Odkaz s vyberem byl zkopirovan. Obsahuje i poznamku.'),()=>prompt('Zkopirujte odkaz; obsahuje i poznamku:',url.href));
else prompt('Zkopirujte odkaz; obsahuje i poznamku:',url.href);
}
async function stdImport(file){
if(!file)return;
try{if(file.size>2*1024*1024)throw Error('Soubor je p\u0159\u00edli\u0161 velk\u00fd. Limit je 2 MB.');const data=JSON.parse(await file.text());if(data.schema!=='allstav-rd-config'||data.version!==1)throw Error('Nezn\u00e1m\u00fd form\u00e1t nebo verze JSON.');const next=stdNormalize(data.selection);stdState=next;stdApply();toast('Nastaven\u00ed bylo na\u010dteno.');}catch(e){toast('Import se nezda\u0159il: '+e.message);}
$('#stdImport').value='';
}
function stdAction(action){
if(action==='floorView')return stdView(stdFloorRoom);
if(action==='matchKitchenBody')return stdSet('kitchenBodyColor',stdState.kitchenColor);
if(action==='copyFloor'){const f=stdState.floors[stdFloorRoom];for(const r of STD_ROOMS)if(stdState.floors[r[0]].type===f.type)stdState.floors[r[0]].sample=f.sample;stdApply();toast('Vzorek p\u0159enesen do m\u00edstnost\u00ed se stejnou krytinou.');return;}
if(action==='exportJson')return saveBlob(new Blob([JSON.stringify(stdExportData(),null,2)],{type:'application/json'}),'allstav_muj_vyber.json');
if(action==='exportText')return saveBlob(new Blob(['\ufeff'+stdText()],{type:'text/plain;charset=utf-8'}),'allstav_soupis_standardu.txt');
if(action==='importJson')return $('#stdImport').click();
if(action==='saveHtml')return stdSaveHtml();
if(action==='summary')return stdOpen('summary');
if(action==='png'){render();canvas.toBlob(b=>{if(b)saveBlob(b,'dum_ALLSTAV_nahled.png');});return;}
if(action==='glb')return saveBlob(new Blob([currentGlb()],{type:'model/gltf-binary'}),'dum_ALLSTAV_viditelny_vyber.glb');
if(action==='reset')return $('#stdResetDialog').showModal();
if(action==='original'){stdClose();$('#panel').classList.add('open');$('#infoButton').classList.add('active');}
}
function initStandards(){
let loaded=false,embedded={};
stdState=stdClone(STD_DEFAULTS);
try{embedded=JSON.parse($('#stdSavedState').textContent||'{}');}catch(e){console.info('ALLSTAV: invalid embedded settings ignored.');}
if(embedded.selection){
try{stdState=stdNormalize(embedded.selection);loaded=true;}catch(e){console.info('ALLSTAV: invalid embedded selection ignored.');}
let id=embedded.storageId;
if(typeof id!=='string'||!/^[a-z0-9_-]{1,100}$/i.test(id)){
// Stable fallback for HTML exports made by the previous version.
let hash=2166136261;for(const ch of JSON.stringify(embedded.selection)){hash=Math.imul(hash^ch.charCodeAt(0),16777619);}
id='legacy-'+(hash>>>0).toString(36);
}
stdStorageKey=STD_STORAGE+'.copy.'+id;
}else stdStorageKey=STD_STORAGE;
// Edits made after opening an exported copy take precedence on reload.
// A new copy has its own key and opens with its embedded selection.
try{const stored=localStorage.getItem(stdStorageKey);if(stored){stdState=stdNormalize(JSON.parse(stored));loaded=true;}}catch(e){console.info('ALLSTAV: invalid or inaccessible local settings ignored.');}
stdBuildPanel();stdReady=true;stdApply(false);
$('#standardsToggle').onclick=()=>document.body.classList.contains('std-open')?stdClose():stdOpen(interior.enabled?'interior':stdTab==='interior'?'exterior':stdTab);
$('#stdClose').onclick=stdClose;
$('#standardsPanel').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.stdKey){const k=b.dataset.stdKey,v=b.dataset.stdValue;stdSet(k,v);}else if(b.dataset.stdTab){stdOpen(b.dataset.stdTab);}else if(b.dataset.stdPage){stdShowCatalog(b.dataset.stdPage);}else if(b.dataset.stdView){stdView(b.dataset.stdView);}else if(b.dataset.stdAction){stdAction(b.dataset.stdAction);}});
$('#standardsPanel').addEventListener('input',e=>{const el=e.target,k=el.dataset.stdInput;if(!k)return;let v=el.type==='checkbox'?el.checked:el.type==='range'?+el.value:el.value;stdSet(k,v);});
$('#stdFloorRoom').onchange=e=>{stdFloorRoom=e.target.value;stdSync();};
$('#stdFloorType').onchange=e=>{stdState.floors[stdFloorRoom]={type:e.target.value,sample:1};stdApply();};
$('#stdTileRoom').onchange=e=>{stdTileRoom=e.target.value;stdSync();};
$('#stdCatalogClose').onclick=()=>$('#stdCatalogDialog').close();$('#stdCatalogPrev').onclick=()=>stdShowCatalog(typeof stdCatalogPage==='number'?Math.max(12,stdCatalogPage-1):12);$('#stdCatalogNext').onclick=()=>stdShowCatalog(typeof stdCatalogPage==='number'?Math.min(25,stdCatalogPage+1):12);
$('#stdResetCancel').onclick=()=>$('#stdResetDialog').close();$('#stdResetConfirm').onclick=()=>{stdState=stdClone(STD_DEFAULTS);stdApply();$('#stdResetDialog').close();toast('V\u00fdchoz\u00ed v\u00fdb\u011br byl obnoven.');};
$('#stdImport').onchange=e=>stdImport(e.target.files[0]);
for(const id of ['stdCatalogDialog','stdResetDialog'])$('#'+id).addEventListener('click',e=>{if(e.target.id===id){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}});
const oldSun=toggleSunPanel;toggleSunPanel=function(on){if(on&&!stdSwitching)stdClose();return oldSun(on);};
const oldToggle=toggleInterior;toggleInterior=function(on){if(!stdSwitching&&document.body.classList.contains('std-open'))stdClose();return oldToggle(on);};
window.INTERIOR.toggle=toggleInterior;
const oldPaint=setInteriorWallColor;setInteriorWallColor=function(hex,persist=true){const result=oldPaint(hex,persist);if(persist&&stdReady){stdState.wallPaint=interior.wallColor;stdSync();stdPersist();}return result;};window.INTERIOR.setWallColor=setInteriorWallColor;
const oldInfo=$('#infoButton').onclick;$('#infoButton').onclick=e=>{stdClose();oldInfo?.(e);};
const oldPanel=$('#interiorPanelToggle').onclick;$('#interiorPanelToggle').onclick=e=>{stdClose();oldPanel?.(e);};
// Keep legacy wall colour controls synchronized with the new selection.
document.querySelectorAll('[data-wall-color]').forEach(b=>b.addEventListener('click',()=>{stdState.wallPaint=interior.wallColor;stdSync();stdPersist();}));
$('#roofColor').disabled=true;$('#roofColor').title='Barvu krytiny vyberte v sekci Standardy / Exteri\u00e9r.';
$('#resetColors')?.addEventListener('click',()=>stdApply());
window.addEventListener('keydown',e=>{if($('#stdCatalogDialog').open||$('#stdResetDialog').open){e.stopImmediatePropagation();return;}if(e.key==='Escape'&&document.body.classList.contains('std-open')){e.preventDefault();e.stopImmediatePropagation();stdClose();}},true);
window.ALLSTAV_CONFIG={getState:()=>stdClone(stdState),defaults:()=>stdClone(STD_DEFAULTS),set:(patch)=>{stdState=stdNormalize({...stdClone(stdState),...patch});stdApply();return stdClone(stdState);},open:stdOpen,close:stdClose,view:stdView,summary:stdSummary,exportData:stdExportData,normalize:stdNormalize,visibleWindows:()=>[...new Set(meshes.filter(m=>m.std?.stdBlind&&visible(m)).map(m=>m.std.windowNode))],sourcePage:stdShowCatalog,saveHtml:stdSaveHtml,validate:()=>({windowCount:gltf.extras.allstavStandards.windows.length,triangleExcluded:!meshes.some(m=>m.std?.stdBlind&&m.std.windowNode===gltf.extras.allstavStandards.triangleExcludedNode),rooms:STD_ROOMS.length,materials:gltf.extras.allstavStandards.materials})};
if(loaded)$('#stdSaved').textContent='Na\u010dten v\u00e1\u0161 ulo\u017een\u00fd v\u00fdb\u011br';else $('#stdSaved').textContent='V\u00fdchoz\u00ed v\u00fdb\u011br \u00b7 zm\u011bny se ukl\u00e1daj\u00ed automaticky';
window.addEventListener('pagehide',()=>{if(stdSaveTimer){clearTimeout(stdSaveTimer);stdPersist();}});
stdOpen('exterior');stdView('terrace');
}
start();
+23
View File
@@ -0,0 +1,23 @@
/** Only application startup. All paths are relative to /locenice/index.php. */
async function json(path){const response=await fetch(path,{cache:'no-cache'});if(!response.ok)throw Error(path+' / HTTP '+response.status);return response.json();}
try{
const config=await json('./data/app-config.json');
const [catalog,assets,references,scene,site]=await Promise.all([config.catalog,config.assets,config.references,config.sceneMetadata,config.site].map(json));
const response=await fetch(config.model);if(!response.ok)throw Error('Model / HTTP '+response.status);
const modelBytes=new Uint8Array(await response.arrayBuffer());
config.threeModule=new URL(config.threeModule,location.href).href;config.threeLoader=new URL(config.threeLoader,location.href).href;
// The standard GLTFLoader imports the same pinned Three.js copy.
const map=document.createElement('script');map.type='importmap';map.textContent=JSON.stringify({imports:{three:config.threeModule}});document.head.appendChild(map);
const hash=location.hash;
if(hash.startsWith('#vyber='))try{
let text=hash.slice(7).replace(/-/g,'+').replace(/_/g,'/');text+='='.repeat((4-text.length%4)%4);
if(text.length>32000)throw Error('Too large');const bytes=Uint8Array.from(atob(text),ch=>ch.charCodeAt(0));const payload=JSON.parse(new TextDecoder().decode(bytes));
if(payload.schema!=='allstav-rd-config'||payload.version!==1||!payload.selection)throw Error('Invalid selection');
document.getElementById('stdSavedState').textContent=JSON.stringify({selection:payload.selection});
}catch(error){console.warn('Invalid shared selection:',error);}
window.LOCENICE_RESOURCES={config,catalog,assets,references,scene,site,modelBytes};
await import('./app.js');
}catch(error){
console.error(error);const box=document.getElementById('loading');box.className='error';box.innerHTML='<div class="errorDetail"></div><p>Nahrajte celou slozku vcetne js, css, data, models a assets. Otevrete ji pres HTTP(S), nikoli dvojklikem na HTML.</p><a href="./models/dum.glb" download>Stahnout model GLB</a>';
box.querySelector('.errorDetail').textContent=error.message;document.getElementById('webStatus').textContent='Chyba nacitani';
}
+89
View File
@@ -0,0 +1,89 @@
import {vs,fs,depthvs,depthfs} from './legacy-shaders.js';
/** Self-contained fallback. No external dependencies. Keeps the original shaders. */
export async function createLegacyRenderer({canvas,gltf,binary}) {
let frame=null,solar,interior,eye,vp,lightVP,gl,program,shadowProgram,shadowFbo,shadowTex;
const meshes=[],originalColors={},hardieTextures=[],hardieImages=[],hardieObjectURLs=[];
const mul=(a,b)=>{const c=new Float32Array(16);for(let j=0;j<4;j++)for(let i=0;i<4;i++)for(let k=0;k<4;k++)c[j*4+i]+=a[k*4+i]*b[j*4+k];return c;};
const lookAt=()=>new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]);
const ortho=lookAt;
function compile(type,src){const sh=gl.createShader(type);gl.shaderSource(sh,src);gl.compileShader(sh);if(!gl.getShaderParameter(sh,gl.COMPILE_STATUS))throw Error(gl.getShaderInfoLog(sh));return sh;}
function link(v,f){const pr=gl.createProgram();gl.attachShader(pr,compile(gl.VERTEX_SHADER,v));gl.attachShader(pr,compile(gl.FRAGMENT_SHADER,f));gl.linkProgram(pr);if(!gl.getProgramParameter(pr,gl.LINK_STATUS))throw Error(gl.getProgramInfoLog(pr));return pr;}
async function loadHardieTextures() {
const anisotropy = gl.getExtension('EXT_texture_filter_anisotropic');
for (const [index, texture] of (gltf.textures || []).entries()) {
const imageSpec = gltf.images[texture.source], view = gltf.bufferViews[imageSpec.bufferView];
const data = binary.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength);
const url = URL.createObjectURL(new Blob([data], { type: imageSpec.mimeType })); hardieObjectURLs.push(url);
const image = new Image();
await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(Error('Nelze na\u010d\u00edst vlo\u017eenou texturu HardiePlank.')); image.src = url; });
const gpuTexture = gl.createTexture(); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, gpuTexture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.generateMipmap(gl.TEXTURE_2D);
if (anisotropy) gl.texParameterf(gl.TEXTURE_2D, anisotropy.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(anisotropy.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
hardieTextures[index] = gpuTexture; hardieImages[index] = image;
}
gl.activeTexture(gl.TEXTURE0);
}
function getAccessor(i) { const a = gltf.accessors[i], v = gltf.bufferViews[a.bufferView]; return { a, v, offset: (v.byteOffset || 0) + (a.byteOffset || 0) }; }
function visible(m) { return frame.visible(m); }
function initGL() {
gl = canvas.getContext('webgl2', { antialias: true, alpha: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
if (!gl) throw Error('Prohl\u00ed\u017ee\u010d nepodporuje WebGL 2. Soubor GLB si m\u016f\u017ee\u0161 st\u00e1hnout a otev\u0159\u00edt v 3D programu.');
program = link(vs, fs); shadowProgram = link(depthvs, depthfs);
const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, binary, gl.STATIC_DRAW);
const ibuf = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, binary, gl.STATIC_DRAW);
for (const [nodeIndex, node] of gltf.nodes.entries()) {
if (node.mesh === undefined) continue; const mesh = gltf.meshes[node.mesh]; for (const p of mesh.primitives) {
const pos = getAccessor(p.attributes.POSITION), nor = getAccessor(p.attributes.NORMAL), ind = getAccessor(p.indices), mat = gltf.materials[p.material], name = node.name || mesh.name || '';
if (node.matrix && node.matrix.some((v, i) => Math.abs(v - (i % 5 === 0 ? 1 : 0)) > 1e-6)) throw Error('Unexpected non-identity model transform');
const vao = gl.createVertexArray(); gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, pos.a.componentType, false, pos.v.byteStride || 0, pos.offset); gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 3, nor.a.componentType, false, nor.v.byteStride || 0, nor.offset); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf);
for (const [attribute, location, size] of [['TEXCOORD_0', 2, 2], ['TANGENT', 3, 4]]) {
if (p.attributes[attribute] !== undefined) { const a = getAccessor(p.attributes[attribute]); gl.enableVertexAttribArray(location); gl.vertexAttribPointer(location, size, a.a.componentType, false, a.v.byteStride || 0, a.offset); }
else { gl.disableVertexAttribArray(location); if (location === 2) gl.vertexAttrib2f(location, 0, 0); else gl.vertexAttrib4f(location, 1, 0, 0, 1); }
}
const pbr = mat.pbrMetallicRoughness || {}; originalColors[p.material] = [...(pbr.baseColorFactor || [.7, .7, .7, 1])];
meshes.push({ std: node.extras || {}, transparent: mat.alphaMode === 'BLEND', vao, count: ind.a.count, type: ind.a.componentType, offset: ind.offset, material: p.material, name, context: /^(90_|91_|28_|23_)/.test(name), nodeIndex, legacySite: !!node.extras?.legacySite, siteLayer: node.extras?.siteLayer || null, castShadow: node.extras?.castShadow !== false, bounds: { min: pos.a.min, max: pos.a.max }, glass: (mat.name || '').startsWith('glass'), interiorLayer: node.extras?.interiorLayer || null, interiorMaterial: !!mat.extras?.interiorMaterial, interiorStyle: mat.extras?.interiorStyle || 0, surfaceKind: mat.extras?.hardieRole === 'boards' ? (node.extras?.interiorLayer === 'cutShell' ? 3 : 1) : mat.extras?.hardieRole === 'backing' ? 2 : (mat.extras?.interiorMaterial || mat.extras?.linearColor) ? (pbr.baseColorTexture ? 3 : 2) : 0, pbr });
}
}
gl.bindVertexArray(null); gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.disable(gl.CULL_FACE);
shadowTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, shadowTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT24, 2048, 2048, 0, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
shadowFbo = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, shadowTex, 0); gl.drawBuffers([gl.NONE]); gl.readBuffer(gl.NONE); if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) throw Error('Shadow framebuffer incomplete'); gl.bindFramebuffer(gl.FRAMEBUFFER, null);
const lightPos = [-15, 29, 12], lightTarget = [.5, 0, 0]; lightVP = mul(ortho(-20, 20, -19, 19, 1, 65), lookAt(lightPos, lightTarget));
}
function renderShadow() { gl.bindFramebuffer(gl.FRAMEBUFFER, shadowFbo); gl.viewport(0, 0, 2048, 2048); gl.clear(gl.DEPTH_BUFFER_BIT); gl.useProgram(shadowProgram); gl.uniformMatrix4fv(gl.getUniformLocation(shadowProgram, 'uVP'), false, lightVP); gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(2.0, 3.0); for (const m of meshes) { if (!visible(m) || m.glass || m.transparent || !m.castShadow) continue; gl.bindVertexArray(m.vao); gl.drawElements(gl.TRIANGLES, m.count, m.type, m.offset); } gl.disable(gl.POLYGON_OFFSET_FILL); gl.bindFramebuffer(gl.FRAMEBUFFER, null); }
function render() {
if (solar.shadowDirty && solar.shadows && solar.direct > 0) { renderShadow(); } gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(...solar.sky, 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.useProgram(program); gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uVP'), false, vp); gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uLightVP'), false, lightVP); gl.uniform3fv(gl.getUniformLocation(program, 'uEye'), eye); gl.uniform3fv(gl.getUniformLocation(program, 'uLight'), solar.light); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, shadowTex); gl.uniform1i(gl.getUniformLocation(program, 'uShadow'), 0); gl.uniform1i(gl.getUniformLocation(program, 'uBaseMap'), 1); gl.uniform1i(gl.getUniformLocation(program, 'uNormalMap'), 2);
gl.uniform3fv(gl.getUniformLocation(program, 'uAmbient'), solar.ambient); gl.uniform3fv(gl.getUniformLocation(program, 'uSunColor'), solar.sunColor); gl.uniform1f(gl.getUniformLocation(program, 'uDirect'), solar.direct); gl.uniform1f(gl.getUniformLocation(program, 'uSkyDay'), solar.dayFactor); gl.uniform1f(gl.getUniformLocation(program, 'uShadows'), solar.shadows ? 1 : 0);
const transparentMeshes=meshes.filter(m=>m.transparent&&visible(m)).sort((a,b)=>{const dist=m=>{const c=m.bounds.min.map((v,i)=>(v+m.bounds.max[i])/2);return c.reduce((d,v,i)=>d+(v-eye[i])**2,0)};return dist(b)-dist(a);});
for (const m of meshes.filter(m=>!m.transparent).concat(transparentMeshes)) { if (!visible(m)) continue; const p = gltf.materials[m.material].pbrMetallicRoughness;
if(m.transparent){gl.enable(gl.BLEND);gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);gl.depthMask(false);}else{gl.disable(gl.BLEND);gl.depthMask(true);}
gl.uniform2fv(gl.getUniformLocation(program,'uUvScale'),p.baseColorTexture?.extensions?.KHR_texture_transform?.scale||[1,1]); gl.uniform4fv(gl.getUniformLocation(program, 'uColor'), p.baseColorFactor || [.7, .7, .7, 1]); gl.uniform1f(gl.getUniformLocation(program, 'uRoughness'), p.roughnessFactor || .8); gl.uniform1f(gl.getUniformLocation(program, 'uMetal'), p.metallicFactor || 0); gl.uniform1f(gl.getUniformLocation(program, 'uGlass'), m.glass ? 1 : 0);
gl.uniform1f(gl.getUniformLocation(program, 'uSurfaceKind'), m.surfaceKind);
gl.uniform1f(gl.getUniformLocation(program, 'uInteriorMaterial'), m.interiorMaterial ? 1 : 0);
gl.uniform1f(gl.getUniformLocation(program, 'uInteriorFill'), interior.enabled && interior.fill ? .30 : 0);
gl.uniform1f(gl.getUniformLocation(program, 'uInteriorStyle'), m.interiorStyle);
if (m.surfaceKind === 3) {gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,hardieTextures[p.baseColorTexture.index]);}
if (m.surfaceKind === 1) {
const mat = gltf.materials[m.material];
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, hardieTextures[p.baseColorTexture.index]); gl.uniform1i(gl.getUniformLocation(program, 'uBaseMap'), 1);
gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, hardieTextures[mat.normalTexture.index]); gl.uniform1i(gl.getUniformLocation(program, 'uNormalMap'), 2);
gl.uniform1f(gl.getUniformLocation(program, 'uNormalScale'), mat.normalTexture.scale ?? 1);
}
gl.bindVertexArray(m.vao); gl.drawElements(gl.TRIANGLES, m.count, m.type, m.offset); }
gl.depthMask(true);gl.disable(gl.BLEND);gl.bindVertexArray(null);
}
for(const node of gltf.nodes){
const different=(values,base)=>values&&values.some((v,i)=>Math.abs(v-base[i])>1e-6);
if(different(node.translation,[0,0,0])||different(node.rotation,[0,0,0,1])||different(node.scale,[1,1,1])||different(node.matrix,[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]))throw Error('Model has edited transforms; enable the Three.js renderer.');
}
initGL(); await loadHardieTextures();
return {backend:'local-webgl',label:'Lokalni WebGL / zaloha',meshes,images:hardieImages,
resize(w,h,dpr){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr);},
draw(f){frame=f;({solar,interior,eye,vp,lightVP}=f);render();},
dispose(){hardieObjectURLs.forEach(URL.revokeObjectURL);}
};
}
+33
View File
@@ -0,0 +1,33 @@
// Retained offline fallback shaders from the supplied prototype.
const vs = `#version 300 es
precision highp float;
layout(location=0) in vec3 position;
layout(location=1) in vec3 normal;
layout(location=2) in vec2 texCoord;
layout(location=3) in vec4 tangent;
uniform mat4 uVP; uniform mat4 uLightVP; uniform vec2 uUvScale;
out vec3 vNormal; out vec3 vWorld; out vec4 vShadow; out vec2 vUv; out vec4 vTangent;
void main(){vUv=texCoord*uUvScale;vTangent=tangent;vWorld=position;vNormal=normal;vShadow=uLightVP*vec4(position,1.);gl_Position=uVP*vec4(position,1.);}`;
const fs = `#version 300 es
precision highp float;
in vec3 vNormal; in vec3 vWorld; in vec4 vShadow; in vec2 vUv; in vec4 vTangent;
uniform vec4 uColor; uniform vec3 uEye; uniform vec3 uLight;
uniform float uInteriorMaterial; uniform float uInteriorFill; uniform float uInteriorStyle;
uniform float uSurfaceKind; uniform sampler2D uBaseMap; uniform sampler2D uNormalMap; uniform float uNormalScale;
uniform sampler2D uShadow; uniform float uGlass; uniform float uMetal; uniform float uRoughness;
uniform vec3 uAmbient; uniform vec3 uSunColor; uniform float uDirect; uniform float uSkyDay; uniform float uShadows;
out vec4 outColor;
vec3 srgbToLinear(vec3 c){return mix(c/12.92,pow((c+.055)/1.055,vec3(2.4)),step(vec3(.04045),c));}
float shade(vec3 n){if(uShadows<.5||uDirect<.001)return 1.;vec3 p=vShadow.xyz/vShadow.w*.5+.5;if(p.x<0.||p.x>1.||p.y<0.||p.y>1.||p.z>1.||p.z<0.)return 1.;float bias=max(.00045*(1.-max(dot(n,uLight),0.)),.00014);vec2 grad=vec2(0.);if(uSurfaceKind>.5){vec3 dx=dFdx(p),dy=dFdy(p);float det=dx.x*dy.y-dx.y*dy.x;if(abs(det)>1e-12)grad=vec2(dx.z*dy.y-dy.z*dx.y,dx.x*dy.z-dy.x*dx.z)/det;}float s=0.;for(int x=-1;x<=1;x++)for(int y=-1;y<=1;y++){vec2 uv=p.xy+vec2(float(x),float(y))/2048.;float d=texture(uShadow,uv).r;vec2 center=(floor(uv*2048.)+.5)/2048.;float receiver=p.z+dot(grad,center-p.xy);s+=receiver-bias<=d?1.:0.;}return s/9.;}
void main(){vec3 n=normalize(vNormal);if(!gl_FrontFacing)n=-n;vec3 gn=n;
if(uSurfaceKind>.5&&uSurfaceKind<1.5){vec3 t=normalize(vTangent.xyz-n*dot(n,vTangent.xyz));vec3 bt=normalize(cross(n,t))*vTangent.w;vec3 mn=texture(uNormalMap,vUv).xyz*2.-1.;mn.xy*=uNormalScale;n=normalize(mat3(t,bt,n)*mn);}
vec3 view=normalize(uEye-vWorld);float ndl=max(dot(n,uLight),0.);float sh=shade(gn);vec3 base=uSurfaceKind>.5?uColor.rgb:pow(uColor.rgb,vec3(2.2));
if((uSurfaceKind>.5&&uSurfaceKind<1.5)||uSurfaceKind>2.5)base*=srgbToLinear(texture(uBaseMap,vUv).rgb);vec3 ambient=uAmbient*(.84+.16*n.y)+vec3(uInteriorMaterial*uInteriorFill);vec3 col=base*(ambient+uSunColor*ndl*sh*uDirect);vec3 halfway=normalize(uLight+view);float spec=pow(max(dot(n,halfway),0.),mix(90.,8.,uRoughness));col+=uSunColor*spec*sh*(.018+uMetal*.15)*uDirect;
if(uGlass>.5){vec3 reflected=reflect(-view,n);float fres=pow(1.-max(dot(n,view),0.),4.);vec3 skycol=mix(vec3(.19,.26,.26),vec3(.50,.60,.64),smoothstep(-.25,.8,reflected.y));skycol*=mix(.035,1.,uSkyDay);col=mix(base*(ambient*.75),skycol,.32+fres*.48);col+=uSunColor*.14*pow(max(dot(n,halfway),0.),100.)*sh*uDirect;}
if(uInteriorStyle>.5)col=max(col,base*.85);
col=pow(max(col,vec3(0.)),vec3(1./2.2));outColor=vec4(clamp(col,0.,1.),uColor.a);}`;
const depthvs = `#version 300 es
precision highp float;layout(location=0) in vec3 position;uniform mat4 uVP;void main(){gl_Position=uVP*vec4(position,1.);}`;
const depthfs = `#version 300 es
precision highp float;void main(){}`;
export {vs, fs, depthvs, depthfs};
+17
View File
@@ -0,0 +1,17 @@
/** Minimal glTF 2.0 container I/O, independent of either rendering backend. */
export function parseGLB(bytes){
if(!(bytes instanceof Uint8Array)||bytes.byteLength<28)throw Error('Invalid GLB length');
const v=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);
if(v.getUint32(0,true)!==0x46546c67||v.getUint32(4,true)!==2)throw Error('Expected glTF 2.0');
if(v.getUint32(8,true)!==bytes.byteLength)throw Error('GLB length mismatch');
let gltf=null,binary=null,off=12;
while(off<bytes.length){if(off+8>bytes.length)throw Error('Invalid GLB chunk');const size=v.getUint32(off,true),type=v.getUint32(off+4,true);if(size%4||off+8+size>bytes.length)throw Error('Invalid GLB chunk bounds');
const part=bytes.subarray(off+8,off+8+size);if(type===0x4e4f534a)gltf=JSON.parse(new TextDecoder().decode(part));else if(type===0x004e4942)binary=part;off+=8+size;}
if(!gltf||!binary)throw Error('Missing GLB data');return {gltf,binary};
}
export function encodeGLB(gltf,binary){
const text=new TextEncoder().encode(JSON.stringify(gltf)),jl=(text.length+3)&~3,bl=(binary.length+3)&~3;
const out=new Uint8Array(28+jl+bl),v=new DataView(out.buffer);
[0x46546c67,2,out.length,jl,0x4e4f534a].forEach((n,i)=>v.setUint32(4*i,n,true));out.fill(32,20,20+jl);out.set(text,20);
v.setUint32(20+jl,bl,true);v.setUint32(24+jl,0x004e4942,true);out.set(binary,28+jl);return out;
}
+20
View File
@@ -0,0 +1,20 @@
/** Source references and non-geodetic survey overlay. Does not alter house dimensions. */
export function installProjectPanel({resources,backend,getMode,focusSite,invalidate}){
const $=s=>document.querySelector(s),dialog=$('#projectDialog'),overlay=$('#surveyOverlay');let survey=false;
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
$('#projectToggle').onclick=()=>dialog.showModal();$('#projectClose').onclick=()=>dialog.close();
dialog.addEventListener('click',e=>{if(e.target===dialog){const r=dialog.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)dialog.close();}});
const photos=[['kitchen1','Kuchy\u0148 / zelen\u00e1 a dub'],['kitchen2','Kuchy\u0148 / vnit\u0159n\u00ed strana'],['view1','Ob\u00fdvac\u00ed pokoj / tr\u00e1my'],['view2','Ob\u00fdvac\u00ed pokoj / vybaven\u00ed']];
$('#projectPhotos').innerHTML=photos.map(([key,title])=>`<button type="button" data-photo="${key}"><img loading="lazy" src="${esc(resources.references[key].src)}" alt="${esc(title)}"><span>${esc(title)}</span></button>`).join('');
$('#projectPhotos').onclick=e=>{const key=e.target.closest('[data-photo]')?.dataset.photo;if(!key)return;const r=resources.references[key];dialog.close();$('#interiorReferenceTitle').textContent=r.title;$('#interiorReferenceImage').src=r.src;$('#interiorReferenceImage').alt=r.title;$('#interiorReferenceCaption').textContent=r.caption;$('#interiorReferenceDialog').showModal();};
const site=resources.site;
$('#surveyStats').innerHTML=`<div><strong>${site.cadastralAreaInProject.toLocaleString('cs-CZ')} m\u00b2</strong><span>V\u00fdm\u011bra dle geometrick\u00e9ho pl\u00e1nu</span></div><div><strong>${site.digitizedOutlineArea.toLocaleString('cs-CZ')} m\u00b2</strong><span>Obrys dosavadn\u00edho 3D modelu</span></div><div><strong>${site.referenceElevation.toLocaleString('cs-CZ',{minimumFractionDigits:3})} m</strong><span>V\u00fd\u0161kov\u00e1 nula / Bpv</span></div><div><strong>${site.surveyPoints.length} bod\u016f</strong><span>V\u00fd\u0161kopis / grafick\u00fd ode\u010det polohy</span></div>`;
$('#surveyShow').onclick=()=>{survey=!survey;$('#surveyShow').textContent=survey?'Skr\u00fdt body v\u00fd\u0161kopisu':'Zobrazit body v modelu';dialog.close();if(survey)focusSite();invalidate();};
$('#backendNote').textContent=backend.backend==='three'?'Aktivn\u00ed renderer: Three.js r180. Knihovna se na\u010d\u00edt\u00e1 z CDN; model i podklady z va\u0161eho hostingu.':'Aktivn\u00ed renderer: lok\u00e1ln\u00ed WebGL. Three.js se nepoda\u0159ilo na\u010d\u00edst nebo byl zvolen z\u00e1lo\u017en\u00ed re\u017eim. Model a konfigur\u00e1tor funguj\u00ed bez CDN.';
return {drawSurvey(project,clip,width,height){
if(!survey||getMode().enabled){overlay.style.display='none';return;}
overlay.style.display='block';overlay.setAttribute('viewBox',`0 0 ${width} ${height}`);overlay.style.width=width+'px';overlay.style.height=height+'px';
const occupied=[];
overlay.innerHTML=site.surveyPoints.map(p=>{const xyz=[p.xz[0],p.altitude-site.referenceElevation+.10,p.xz[1]],q=clip(xyz);if(q[3]<=0)return '';const [x,y]=project(xyz);if(x<0||y<0||x>width||y>height)return '';const value=p.altitude.toFixed(2).replace('.',',');let label='';for(const [dx,dy]of [[6,-6],[6,17],[-78,-6]]){const box={x:x+dx,y:y+dy-11,w:77,h:15};if(box.x<4||box.y<4||box.x+box.w>width-4||box.y+box.h>height-4)continue;if(occupied.some(b=>box.x<b.x+b.w&&box.x+box.w>b.x&&box.y<b.y+b.h&&box.y+box.h>b.y))continue;occupied.push(box);label=`<text x="${dx}" y="${dy}">${p.id} / ${value}</text>`;break;}return `<g transform="translate(${x.toFixed(1)},${y.toFixed(1)})"><title>${p.id} / ${value} m Bpv</title><circle r="3"/>${label}</g>`;}).join('');
}};
}
+23
View File
@@ -0,0 +1,23 @@
// Pure function retained from the supplied Meeus / NOAA implementation.
// Arguments: UTC Unix milliseconds, degrees latitude, degrees longitude.
const RAD=Math.PI/180,DAY_MS=86400000;
const mod=(x,n)=>((x%n)+n)%n,clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
export function solarPosition(ms, lat, lon) {
const jd = ms / DAY_MS + 2440587.5, t = (jd - 2451545) / 36525;
const L = mod(280.46646 + t * (36000.76983 + t * .0003032), 360) * RAD;
const M = (357.52911 + t * (35999.05029 - .0001537 * t)) * RAD;
const e = .016708634 - t * (.000042037 + .0000001267 * t);
const C = Math.sin(M) * (1.914602 - t * (.004817 + .000014 * t)) + Math.sin(2 * M) * (.019993 - .000101 * t) + Math.sin(3 * M) * .000289;
const omega = (125.04 - 1934.136 * t) * RAD;
const lambda = L + (C - .00569 - .00478 * Math.sin(omega)) * RAD;
const mean = 23 + (26 + (21.448 - t * (46.815 + t * (.00059 - t * .001813))) / 60) / 60;
const obliq = (mean + .00256 * Math.cos(omega)) * RAD;
const dec = Math.asin(Math.sin(obliq) * Math.sin(lambda));
const y = Math.tan(obliq / 2) ** 2;
const eq = 4 / RAD * (y * Math.sin(2 * L) - 2 * e * Math.sin(M) + 4 * e * y * Math.sin(M) * Math.cos(2 * L) - .5 * y * y * Math.sin(4 * L) - 1.25 * e * e * Math.sin(2 * M));
const solarMinutes = mod(mod(ms, DAY_MS) / 60000 + eq + 4 * lon, 1440);
const H = (solarMinutes / 4 - 180) * RAD, phi = lat * RAD;
const elevation = Math.asin(clamp(Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H), -1, 1)) / RAD;
const azimuth = mod(Math.atan2(Math.sin(H), Math.cos(H) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi)) / RAD + 180, 360);
return { elevation, azimuth, declination: dec / RAD, equationOfTime: eq };
}
+114
View File
@@ -0,0 +1,114 @@
/** Standard Three.js backend. UI and catalogue never depend on THREE internals. */
export async function createThreeRenderer({canvas,gltf,binary,bytes,config}) {
let timer;
const modules=Promise.all([import(config.threeModule),import(config.threeLoader)]);
const [T,{GLTFLoader}]=await Promise.race([modules,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Three.js CDN timeout')),config.cdnTimeoutMs||6500);})]).finally(()=>clearTimeout(timer));
const loader=new GLTFLoader();
const documentModel=await loader.parseAsync(bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength),'');
const scene=new T.Scene();scene.add(documentModel.scene);
const renderer=new T.WebGLRenderer({canvas,antialias:true,alpha:false,preserveDrawingBuffer:true,powerPreference:'high-performance'});
renderer.outputColorSpace=T.SRGBColorSpace;
renderer.toneMapping=T.ACESFilmicToneMapping;renderer.toneMappingExposure=1.05;
renderer.shadowMap.enabled=true;renderer.shadowMap.type=T.PCFSoftShadowMap;renderer.shadowMap.autoUpdate=false;
const ambient=new T.AmbientLight(0xffffff,.7);
const skyLight=new T.HemisphereLight(0xf5f8ff,0x67604e,1.7);
const sun=new T.DirectionalLight(0xffffff,3.0);
const fill=new T.HemisphereLight(0xfff5df,0x77716a,0);
scene.add(ambient,skyLight,sun,sun.target,fill);
sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);sun.shadow.bias=-.00008;sun.shadow.normalBias=.025;
const persp=new T.PerspectiveCamera(),ortho=new T.OrthographicCamera();
const textures=await Promise.all((gltf.textures||[]).map((_,i)=>documentModel.parser.getDependency('texture',i)));
const images=[];
for (let i=0;i<textures.length;i++) {
const imageSpec=gltf.images[gltf.textures[i].source];
if(imageSpec.bufferView!==undefined){const b=gltf.bufferViews[imageSpec.bufferView];const src=URL.createObjectURL(new Blob([binary.subarray(b.byteOffset||0,(b.byteOffset||0)+b.byteLength)],{type:imageSpec.mimeType}));images[i]={src};}
textures[i].anisotropy=Math.min(8,renderer.capabilities.getMaxAnisotropy());
}
const entries=[],matCopies=new Map(),associations=documentModel.parser.associations;
documentModel.scene.updateMatrixWorld(true);
documentModel.scene.traverse(obj=>{
if(!obj.isMesh)return;
const association=associations.get(obj)||{};
let parent=obj,nodeIndex=association.nodes;
while(nodeIndex===undefined&&parent){const a=associations.get(parent);if(a?.nodes!==undefined)nodeIndex=a.nodes;parent=parent.parent;}
if(nodeIndex===undefined)return;
const node=gltf.nodes[nodeIndex],meshSpec=gltf.meshes[node.mesh];
const primitiveIndex=association.primitives||0;
const primitive=meshSpec.primitives[primitiveIndex]||meshSpec.primitives[0];
const materialIndex=primitive.material;
if(materialIndex===undefined)return;
const spec=gltf.materials[materialIndex],name=node.name||meshSpec.name||obj.name;
// Separate copies prevent a material variant from changing unrelated objects.
let material=matCopies.get(materialIndex);
if(!material){material=obj.material.clone();material.side=T.DoubleSide;matCopies.set(materialIndex,material);}
obj.material=material;
const box=new T.Box3().setFromObject(obj);
const pbr=spec.pbrMetallicRoughness||{};
const desc={object:obj,std:node.extras||{},transparent:spec.alphaMode==='BLEND',material:materialIndex,name,nodeIndex,
count:obj.geometry.index?.count||obj.geometry.attributes.position.count,context:/^(90_|91_|28_|23_)/.test(name),
legacySite:!!node.extras?.legacySite,siteLayer:node.extras?.siteLayer||null,castShadow:node.extras?.castShadow!==false,
bounds:{min:box.min.toArray(),max:box.max.toArray()},glass:(spec.name||'').startsWith('glass'),
interiorLayer:node.extras?.interiorLayer||null,interiorMaterial:!!spec.extras?.interiorMaterial,
interiorStyle:spec.extras?.interiorStyle||0,pbr};
obj.castShadow=desc.castShadow&&!desc.glass&&!desc.transparent;obj.receiveShadow=!desc.transparent;
if(desc.transparent){material.transparent=true;material.depthWrite=false;}
entries.push(desc);
});
const stamps=new Map(),textureCopies=new Map();
function localTexture(index,role,materialIndex,scale) {
const key=materialIndex+':'+role+':'+index+':'+scale.join(',');
if(!textureCopies.has(key)){
const tx=textures[index]?.clone();if(!tx)return null;
tx.colorSpace=role==='map'?T.SRGBColorSpace:T.NoColorSpace;
tx.repeat.set(...scale);tx.needsUpdate=true;textureCopies.set(key,tx);
}
return textureCopies.get(key);
}
function syncMaterials(){
for(const [index,mat] of matCopies){
const spec=gltf.materials[index],p=spec.pbrMetallicRoughness||{};
const stamp=JSON.stringify([p,spec.normalTexture,spec.emissiveFactor,spec.name]);if(stamps.get(index)===stamp)continue;stamps.set(index,stamp);
const rgba=p.baseColorFactor||[.7,.7,.7,1];mat.color.setRGB(...rgba.slice(0,3));
// Legacy exterior factors were sRGB; tagged PBR / interior factors are already linear.
if(!spec.extras?.interiorMaterial&&!spec.extras?.linearColor&&!spec.extras?.hardieRole)mat.color.convertSRGBToLinear();
mat.opacity=rgba[3]??1;mat.roughness=p.roughnessFactor??.8;mat.metalness=p.metallicFactor??0;
const tex=p.baseColorTexture;
mat.map=tex?localTexture(tex.index,'map',index,tex.extensions?.KHR_texture_transform?.scale||[1,1]):null;
mat.normalMap=spec.normalTexture?localTexture(spec.normalTexture.index,'normalMap',index,[1,1]):null;
if(mat.normalScale&&spec.normalTexture)mat.normalScale.setScalar(spec.normalTexture.scale??1);
if(spec.extras?.interiorStyle===1){mat.emissive.copy(mat.color);mat.emissiveIntensity=.75;}
mat.needsUpdate=true;
}
}
const temp=new T.Vector3();let oldVisible='',oldInterior='';
function shadowFit(frame){
const low=[Infinity,Infinity,Infinity],high=[-Infinity,-Infinity,-Infinity];
for(const m of entries){if(!m.object.visible||m.glass)continue;for(let a=0;a<3;a++){low[a]=Math.min(low[a],m.bounds.min[a]);high[a]=Math.max(high[a],m.bounds.max[a]);}}
if(!Number.isFinite(low[0]))return;
if(frame.interior.enabled){low.splice(0,3,-9,-1,-6);high.splice(0,3,11,6,6);}
const center=low.map((v,i)=>(v+high[i])/2);
sun.target.position.fromArray(center);sun.position.fromArray(center).addScaledVector(temp.fromArray(frame.solar.light),120);
sun.target.updateMatrixWorld();sun.updateMatrixWorld();
const c=sun.shadow.camera;c.near=.1;c.far=320;c.position.copy(sun.position);c.up.set(0,1,0);c.lookAt(sun.target.position);c.updateMatrixWorld();
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
for(let a=0;a<8;a++){temp.set(a&1?high[0]:low[0],a&2?high[1]:low[1],a&4?high[2]:low[2]).applyMatrix4(c.matrixWorldInverse);minX=Math.min(minX,temp.x);maxX=Math.max(maxX,temp.x);minY=Math.min(minY,temp.y);maxY=Math.max(maxY,temp.y);}
c.left=minX-2;c.right=maxX+2;c.bottom=minY-2;c.top=maxY+2;c.updateProjectionMatrix();renderer.shadowMap.needsUpdate=true;
}
return {backend:'three',label:'Three.js r'+T.REVISION,meshes:entries,images,
resize(w,h,dpr){renderer.setPixelRatio(dpr);renderer.setSize(w,h,false);},
draw(frame){
syncMaterials();
let key='';for(const m of entries){m.object.visible=!!frame.visible(m);key+=m.object.visible?'1':'0';}
if(frame.solar.shadowDirty||key!==oldVisible||oldInterior!==frame.interior.mode){shadowFit(frame);oldVisible=key;oldInterior=frame.interior.mode;}
sun.intensity=Math.max(0,frame.solar.direct)*3.0;sun.color.fromArray(frame.solar.sunColor);sun.castShadow=frame.solar.shadows;
const day=frame.solar.dayFactor;skyLight.intensity=.18+1.5*day;ambient.intensity=.12+.45*day;
fill.intensity=frame.interior.enabled&&frame.interior.fill?1.3:0;
scene.background=new T.Color().setRGB(...frame.solar.sky).convertSRGBToLinear();
const camera=frame.orthographic?ortho:persp;
camera.position.fromArray(frame.eye);camera.up.set(0,1,0);camera.lookAt(temp.fromArray(frame.lookTarget));camera.updateMatrixWorld();
camera.projectionMatrix.fromArray(frame.vp).multiply(camera.matrixWorld);camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
renderer.render(scene,camera);
},
dispose(){renderer.dispose();images.forEach(i=>i?.src&&URL.revokeObjectURL(i.src));textureCopies.forEach(t=>t.dispose());}
};
}
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import {parseGLB,encodeGLB} from '../locenice/js/model-io.js';
import {solarPosition} from '../locenice/js/solar.js';
const root=new URL('../locenice/',import.meta.url);
const bytes=new Uint8Array(fs.readFileSync(new URL('models/dum.glb',root)));
const {gltf,binary}=parseGLB(bytes);
const catalog=JSON.parse(fs.readFileSync(new URL('data/catalog.json',root),'utf8'));
let count=0;function check(name,test){assert.ok(test,name);count++;console.log('PASS',name)}
check('GLB roundtrip',parseGLB(encodeGLB(gltf,binary)).gltf.nodes.length===gltf.nodes.length);
check('source model has 9 configurable rectangular windows',gltf.extras.allstavStandards.windows.length===9);
check('source model has 9 rooms',gltf.extras.interiorStudy.rooms.length===9);
check('default entry',catalog.defaults.entryModel==='three'&&catalog.defaults.entryColor==='7016');
check('default gutters',catalog.defaults.gutter==='015');
check('default roof',catalog.defaults.roof==='darkgray');
check('default sills',catalog.defaults.extSill==='anthracite');
check('blinds off at startup',catalog.defaults.blinds===false);
check('stable IDs unique',new Set(gltf.nodes.map(n=>n.extras.loceniceId)).size===gltf.nodes.length);
const excluded=gltf.extras.allstavStandards.triangleExcludedNode;
check('triangle has no blinds',!gltf.nodes.some(n=>n.extras?.stdBlind&&n.extras.windowNode===excluded));
for(const [role,indices]of Object.entries(gltf.extras.allstavStandards.materials))check('material role '+role,indices.length>0&&indices.every(i=>gltf.materials[i]));
for(const file of ['assets.json','references.json']){
const assets=JSON.parse(fs.readFileSync(new URL('data/'+file,root),'utf8'));
for(const [id,value]of Object.entries(assets)){const uri=typeof value==='string'?value:value.src;check('asset '+id,fs.existsSync(new URL(uri,root)));}
}
check('June noon sun',solarPosition(Date.UTC(2026,5,21,11),48.8242,14.5275).elevation>60);
console.log('All checks passed:',count);
+109
View File
@@ -0,0 +1,109 @@
"""Prepare a Blender GLB export for the configurator.
python tools/prepare_export.py path/to/edited.glb --out prepared
Produces prepared/dum.glb and prepared/scene.json. Keeps the original complete
catalogue texture library, rebinds material/node indices using loceniceId.
It does not update floor polygons, collision volumes, windows or structural design.
"""
from pathlib import Path
from copy import deepcopy
import argparse, json, struct
ROOT=Path(__file__).resolve().parent.parent
def read_glb(path):
data=Path(path).read_bytes()
if len(data)<28 or struct.unpack_from('<II',data)!=(0x46546c67,2):
raise ValueError('Expected glTF 2.0 GLB')
if struct.unpack_from('<I',data,8)[0]!=len(data):raise ValueError('GLB size mismatch')
offset=12;g=None;b=None
while offset<len(data):
size,kind=struct.unpack_from('<II',data,offset)
if offset+8+size>len(data):raise ValueError('Truncated GLB')
chunk=data[offset+8:offset+8+size]
if kind==0x4e4f534a:g=json.loads(chunk)
elif kind==0x004e4942:b=chunk
offset+=8+size
if g is None or b is None:raise ValueError('Missing JSON or binary chunk')
return g,b
def write_glb(path,g,b):
j=json.dumps(g,ensure_ascii=False,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
b=bytes(b);b+=b'\0'*((-len(b))%4)
Path(path).write_bytes(struct.pack('<IIIII',0x46546c67,2,28+len(j)+len(b),len(j),0x4e4f534a)+j+struct.pack('<II',len(b),0x004e4942)+b)
def rebind(source,edited,output,metadata=None):
old,oldbin=read_glb(source);new,newbin=read_glb(edited);newbin=bytearray(newbin)
if len(new.get('buffers',[]))!=1:raise ValueError('Use a single embedded GLB buffer')
maps={}
for plural in ['nodes','materials']:
by_id={v.get('extras',{}).get('loceniceId'):i for i,v in enumerate(new[plural]) if v.get('extras',{}).get('loceniceId')}
by_name={v.get('name'):i for i,v in enumerate(new[plural]) if v.get('name')}
mapping={}
for i,value in enumerate(old[plural]):
key=value.get('extras',{}).get('loceniceId');index=by_id.get(key,by_name.get(value.get('name')))
if index is not None:
mapping[i]=index
# Restore unsupported glTF extras lost by an editor, retain edited properties.
new[plural][index]['extras']={**deepcopy(value.get('extras',{})),**new[plural][index].get('extras',{})}
maps[plural]=mapping
def node(i):
if i not in maps['nodes']:raise ValueError(f'Missing configured object {old["nodes"][i].get("name",i)}. Preserve loceniceId, do not join variants.')
return maps['nodes'][i]
def material(i):
if i not in maps['materials']:raise ValueError(f'Missing material {old["materials"][i].get("name",i)}. Do not merge material slots.')
return maps['materials'][i]
meta=deepcopy(metadata or old['extras'])
h=meta['hardiePlank'];h['boardMaterial']=material(h['boardMaterial']);h['backingMaterial']=material(h['backingMaterial'])
for key in ['boardNodes','backingNodes']:h[key]=[node(i)for i in h[key]]
interior=meta['interiorStudy']
for key in ['roofNodes','cutOriginalNodes']:interior[key]=[node(i)for i in interior[key]]
interior['wallPaint']['materialIndices']=[material(i)for i in interior['wallPaint']['materialIndices']]
if 'node'in interior.get('timberReference',{}):interior['timberReference']['node']=node(interior['timberReference']['node'])
std=meta['allstavStandards'];std['materials']={k:[material(i)for i in v]for k,v in std['materials'].items()}
std['triangleExcludedNode']=node(std['triangleExcludedNode'])
for window in std['windows']:window['node']=node(window['node'])
for value in new['nodes']:
extras=value.get('extras',{})
if 'windowNode'in extras:extras['windowNode']=node(extras['windowNode'])
# The app uses a stable texture library: indices 0..31, including inactive samples.
# Blender exports only used images; append the complete library from the source GLB.
count=old.get('extras',{}).get('webTextureLibrarySize',32)
for value in new.get('materials',[]):
def shift(obj):
if not isinstance(obj,dict):return
for key,val in obj.items():
if isinstance(val,dict):
if key.endswith('Texture')and isinstance(val.get('index'),int):val['index']+=count
else:shift(val)
shift(value)
oldsamplers=old.get('samplers',[]);sampler_offset=len(new.setdefault('samplers',[]));new['samplers']+=deepcopy(oldsamplers)
lib=[];images={}
for texture in old['textures'][:count]:
src=texture['source']
if src not in images:
im=deepcopy(old['images'][src]);bv=old['bufferViews'][im['bufferView']]
start=bv.get('byteOffset',0);payload=oldbin[start:start+bv['byteLength']]
newbin+=b'\0'*((-len(newbin))%4);offset=len(newbin);newbin+=payload
view_index=len(new.setdefault('bufferViews',[]));new['bufferViews'].append({'buffer':0,'byteOffset':offset,'byteLength':len(payload)})
im['bufferView']=view_index;images[src]=len(new.setdefault('images',[]));new['images'].append(im)
tex=deepcopy(texture);tex['source']=images[src]
if 'sampler'in tex:tex['sampler']+=sampler_offset
lib.append(tex)
new['textures']=lib+new.get('textures',[])
new['buffers'][0]['byteLength']=len(newbin)
meta['webTextureLibrarySize']=count;meta['webRevision']='locenice-web-edited'
new['extras']={**new.get('extras',{}),**meta}
output=Path(output);output.mkdir(parents=True,exist_ok=True)
if (output/'dum.glb').exists()or(output/'scene.json').exists():raise FileExistsError('Output already exists; choose a new directory')
write_glb(output/'dum.glb',new,newbin)
(output/'scene.json').write_text(json.dumps(meta,ensure_ascii=False,indent=2))
return {'nodes':len(new['nodes']),'materials':len(new['materials']),'textures':len(new['textures']),'mappedNodes':len(maps['nodes'])}
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('edited',type=Path);parser.add_argument('--out',type=Path,default=Path('prepared'))
parser.add_argument('--source',type=Path,default=ROOT/'locenice'/'models'/'dum.glb')
args=parser.parse_args()
print(rebind(args.source,args.edited,args.out))
print('Back up the website first. Replace models/dum.glb AND data/scene.json together.')
print('Use Three.js for exported transforms. Recheck dimensions, variants, collisions and camera presets.')
+42
View File
@@ -0,0 +1,42 @@
"""Optional: download pinned Three.js r180 locally, on a computer with internet.
python tools/vendor_three.py
Then upload the generated locenice/vendor folder AND data/app-config.json.
Does not upload anything. No credentials or server-side installer required.
"""
from pathlib import Path
from urllib.request import Request, urlopen
import json, re, hashlib
ROOT=Path(__file__).resolve().parent.parent/'locenice'
BASE='https://raw.githubusercontent.com/mrdoob/three.js/r180/'
VENDOR=ROOT/'vendor'/'three-r180'
seen=set()
def download(relative: str):
if relative in seen:return
if '..' in Path(relative).parts or relative.startswith('/'):
raise ValueError('Unsafe dependency path: '+relative)
seen.add(relative)
req=Request(BASE+relative,headers={'User-Agent':'Locenice-vendor-setup/1.0'})
with urlopen(req,timeout=30) as response:
data=response.read()
destination=VENDOR/relative
destination.parent.mkdir(parents=True,exist_ok=True)
destination.write_bytes(data)
if relative.endswith('.js'):
code=data.decode('utf-8')
for dep in re.findall(r"(?:from\s*|import\s*)['\"]([^'\"]+)['\"]",code):
if not dep.startswith('.'):continue
joined=(destination.parent/dep).resolve()
if not joined.is_relative_to(VENDOR.resolve()):raise ValueError('Out of vendor root')
download(joined.relative_to(VENDOR.resolve()).as_posix())
print(relative,hashlib.sha256(data).hexdigest())
if __name__=='__main__':
for filename in ['build/three.module.js','examples/jsm/loaders/GLTFLoader.js','LICENSE']:
download(filename)
config_path=ROOT/'data'/'app-config.json'
config=json.loads(config_path.read_text())
config['threeModule']='./vendor/three-r180/build/three.module.js'
config['threeLoader']='./vendor/three-r180/examples/jsm/loaders/GLTFLoader.js'
config_path.write_text(json.dumps(config,ensure_ascii=False,indent=2))
print('Done. Upload the vendor folder and app-config.json.')