diff --git a/.DS_Store b/.DS_Store
index 8d43b8d..ead1009 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/locenice/MANIFEST.json b/locenice/MANIFEST.json
new file mode 100644
index 0000000..7a62c08
--- /dev/null
+++ b/locenice/MANIFEST.json
@@ -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"
+ }
+}
diff --git a/locenice/README_CZ.md b/locenice/README_CZ.md
new file mode 100644
index 0000000..59c08a4
--- /dev/null
+++ b/locenice/README_CZ.md
@@ -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
+
+
+
+
+
+
+
+ Ločenice | Model domu / web
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Standardy ☀ Slunce ⚙︎ Stáhnout model
+ GLB
+
+
Model podle projektu
+
Otáčecí exteriérový model v metrech. Hlavní rozměry a polohy otvorů
+ vycházejí z kót. Fasádní obklad: HardiePlank podle dodaného vzorníku; ostatní barevnost z přiložených vizualizací.
+
Parametry
+
Obálka domu 15,340 × 9,390 m
+
Hřeben nad podlahou 1. NP 5,137 m
+
Vrchol komína 5,837 m
+
Krytá terasa / navazující ochoz −0,03 / −0,21
+ m
+
Barevnost
+
Fasáda Krytina
+
+
Zvolené barvy a textura fasádního obkladu se uloží i do staženého GLB.
+
Vzorník barev StoColor System (PDF)
+
Kolmé pohledy
+
+ Vyber pohled...
+ Severní pohled
+ Východní pohled
+ Jižní pohled
+ Západní pohled
+
+
Stáhnout
+ model GLB
+
+
+ Okolí, výškopis a přesnost
+ Tři spodní přepínače jsou nezávislé a při otevření zapnuté. 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.
+ 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.
+ 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: 591,840 m Bpv . U vstupu −0,480 m , u
+ horní podesty schodů z parkování +0,390 m , na konci zahradních schodů dle C.3 −1,395 m .
+
+ Povrch kombinuje původní zaměřené body s upravenými úrovněmi C.3. Mezi body je interpolovaný ;
+ 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.
+ Rozdíl v podkladech: projekt uvádí výměru 1 010 m² , obrys převzatý z vektorové kresby PDF v
+ měřítku domu vychází přibližně 988 m² (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.
+ Výpočet slunce a převod světových stran do os modelu jsou zachované. Původní azimut severního štítu
+ 351,5° a přibližná poloha obce nejsou tímto geodeticky ověřeny.
+
+ 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.
+
+
Úpravy modelu
+
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.
+
Rozsah a omezení
+
Pracovní vizualizační 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í.
+
Profily oken, tašky, oplechování, technické vybavení a spoje jsou
+ zjednodušené. Zábradlí je kovové podle pohledů a technické zprávy. V
+ případě rozdílu mají výkresy přednost před vizualizacemi.
+
Použité podklady
+
D.1.1.2.1.1 Půdorys 1NP D.1.1.2.1.3 Půdorys střechy D.1.1.2.3.1
+ Pohledy D.1.1.2.2.1–3 Řezy A–A, B–B, C–C D.1.1.1 Technická
+ zpráva Obrázky 6_1.jpeg a 6_2.jpeg
+
Uložit pohled PNG Původní barvy
+
Samostatný soubor · funguje bez internetu ·
+ verze 05 · sloup terasy, vinotéka a vysoký strop
+
+ AKTUÁLNÍ POHLED Terasa /
+ jihovýchod
+
+
Vstup Terasa Shora Terén Kóty Otáčení Reset
+
+
+
+
Tažením otáčej · Kolečkem přibliž
+ · Pravým tlačítkem posuň
+
+
+
+ SLUNCE NAD DOMEM 21. 6. 2026 · 10:00
+
+
+
+
STUDIE OSVĚTLENÍ
+
Slunce a stíny
+
×
+
+ Datum Místní čas
+ Jaro Léto Podzim Zima
+ Slunce nad obzorem SELČ ·
+ UTC+2
+
+ Posun času
+
+
+ 00:00 06:00 12:00 18:00 24:00
+
+ ▶ Přehrát den
+ 15 min / s
+ 30 min / s
+ 60 min / s
+ Teď
+
+
+
Výška nad obzorem— Střed slunečního
+ kotouče
+
Azimut slunce— Od severu po
+ směru hodin
+
+ Východ — Poledne — Západ —
+ Vržené stíny Dráha ve 3D
+ Posouvej čas a sleduj, kam dopadá slunce na terasu a fasády. Natočení pohledu
+ polohu slunce nemění.
+ Uložit obrázek s datem a časem
+
+ Poloha a orientace domu
+ Zeměpisná šířka (° N) Zeměpisná délka (°
+ E) Azimut severního štítu domu (°, od severu)
+ Výchozí poloha: Ločenice, přibližný bod obce , nikoli geodeticky určený bod parcely. Ponecháno
+ původní nastavení 351,5° , 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.
+ Jižní štít: 171,5° · vstupní fasáda: 261,5°.
+ Obnovit Ločenice a sever z projektu
+ Časové pásmo zůstává Europe/Prague . Letní čas se přepočítává podle zvoleného data a pravidel
+ prohlížeče.
+
+
+ Co simulace počítá a co ne
+ 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.
+ Stíny vytváří geometrie tohoto modelu. Okolní domy, stromy, ploty a vzdálený horizont nejsou
+ modelovány. 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í.
+ 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.
+ 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.
+ Zdroje výpočtu a polohy: NOAA / Meeus · Ločenice – souřadnice . Připojení je potřeba jen pro otevření
+ těchto zdrojů, ne pro simulaci.
+
+
+
+
+
+
+ Zapnout interiérPůdorys · 3D bez střechy · procházení
+
+← Exteriér INTERIÉR / 1. NADZEMNÍ PODLAŽÍ
Dům bez střechy
+
+
Uvnitř domu 9 místností
+
+ Klikněte na místnost v mapě nebo vyberte připravený pohled.
+ Kuchyň a obytný prostor
+
+ Kuchyně Z kuchyně ven
+ Obývací prostor Trámy a vysoké okno
+ Vysoký strop nad kuchyní
+
+ Přejít do místnosti
+ Vyberte místnost…
+
+
+
+ Vybavení
+ Názvy místností
+ Přisvětlení interiéru
+
+ 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é.
+ Podklady a přesnost modelu
+ Dispozice: 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.
+ Kuchyň: 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é.
+ Trámy: 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.
+ Orientační: 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.
+ 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řisvětlení slouží k čitelnosti interiéru. Simulace slunce není výpočtem vnitřního denního osvětlení.
+ Původní půdorys Kuchyň · 1 Kuchyň · 2 Výhled · 1 Výhled · 2
+
+ Uložit aktuální pohled PNG
+ Stáhnout zobrazený model GLB
+
+
+
+↑ ← ↓ →
+
Podklad Zavřít ×
+
+
+
+
+
Načítám 3D model...
+
+
+ALLSTAV / STANDARDY 2025
Váš dům, váš výběr × Katalogové varianty a materiály přímo v modelu.
+Exteriér Interiér Můj výběr
+
+
+
Katalog ALLSTAV Zavřít ×
+Obnovit výchozí výběr? 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í.
Zrušit Obnovit
+
+
+
+
+
+
+Načítání modelu…
+Projekt a podklady
+
+ LOČENICE / ZDROJOVÝ MODEL
Projekt a podklady Zavřít
+
+
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ů.
+
Kuchyň a obývací pokoj
+
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.
+
Pozemek a výšky
+
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.
+
+
Editace modelu Soubor models/dum.glb 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.
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/locenice/locenice/assets/catalog/door0.jpg b/locenice/locenice/assets/catalog/door0.jpg
new file mode 100644
index 0000000..028e59d
Binary files /dev/null and b/locenice/locenice/assets/catalog/door0.jpg differ
diff --git a/locenice/locenice/assets/catalog/door1.jpg b/locenice/locenice/assets/catalog/door1.jpg
new file mode 100644
index 0000000..f952617
Binary files /dev/null and b/locenice/locenice/assets/catalog/door1.jpg differ
diff --git a/locenice/locenice/assets/catalog/door2.jpg b/locenice/locenice/assets/catalog/door2.jpg
new file mode 100644
index 0000000..1265019
Binary files /dev/null and b/locenice/locenice/assets/catalog/door2.jpg differ
diff --git a/locenice/locenice/assets/catalog/door3.jpg b/locenice/locenice/assets/catalog/door3.jpg
new file mode 100644
index 0000000..6e826ef
Binary files /dev/null and b/locenice/locenice/assets/catalog/door3.jpg differ
diff --git a/locenice/locenice/assets/catalog/door4.jpg b/locenice/locenice/assets/catalog/door4.jpg
new file mode 100644
index 0000000..176dbe0
Binary files /dev/null and b/locenice/locenice/assets/catalog/door4.jpg differ
diff --git a/locenice/locenice/assets/catalog/door5.jpg b/locenice/locenice/assets/catalog/door5.jpg
new file mode 100644
index 0000000..45712c3
Binary files /dev/null and b/locenice/locenice/assets/catalog/door5.jpg differ
diff --git a/locenice/locenice/assets/catalog/door6.jpg b/locenice/locenice/assets/catalog/door6.jpg
new file mode 100644
index 0000000..08e5e36
Binary files /dev/null and b/locenice/locenice/assets/catalog/door6.jpg differ
diff --git a/locenice/locenice/assets/catalog/door7.jpg b/locenice/locenice/assets/catalog/door7.jpg
new file mode 100644
index 0000000..df8ebae
Binary files /dev/null and b/locenice/locenice/assets/catalog/door7.jpg differ
diff --git a/locenice/locenice/assets/catalog/intsill0.jpg b/locenice/locenice/assets/catalog/intsill0.jpg
new file mode 100644
index 0000000..52f947e
Binary files /dev/null and b/locenice/locenice/assets/catalog/intsill0.jpg differ
diff --git a/locenice/locenice/assets/catalog/intsill1.jpg b/locenice/locenice/assets/catalog/intsill1.jpg
new file mode 100644
index 0000000..015d027
Binary files /dev/null and b/locenice/locenice/assets/catalog/intsill1.jpg differ
diff --git a/locenice/locenice/assets/catalog/intsill2.jpg b/locenice/locenice/assets/catalog/intsill2.jpg
new file mode 100644
index 0000000..b191da0
Binary files /dev/null and b/locenice/locenice/assets/catalog/intsill2.jpg differ
diff --git a/locenice/locenice/assets/catalog/lam1.jpg b/locenice/locenice/assets/catalog/lam1.jpg
new file mode 100644
index 0000000..277dc51
Binary files /dev/null and b/locenice/locenice/assets/catalog/lam1.jpg differ
diff --git a/locenice/locenice/assets/catalog/lam2.jpg b/locenice/locenice/assets/catalog/lam2.jpg
new file mode 100644
index 0000000..f816215
Binary files /dev/null and b/locenice/locenice/assets/catalog/lam2.jpg differ
diff --git a/locenice/locenice/assets/catalog/lam3.jpg b/locenice/locenice/assets/catalog/lam3.jpg
new file mode 100644
index 0000000..e5aff8b
Binary files /dev/null and b/locenice/locenice/assets/catalog/lam3.jpg differ
diff --git a/locenice/locenice/assets/catalog/lam4.jpg b/locenice/locenice/assets/catalog/lam4.jpg
new file mode 100644
index 0000000..84541e9
Binary files /dev/null and b/locenice/locenice/assets/catalog/lam4.jpg differ
diff --git a/locenice/locenice/assets/catalog/lam5.jpg b/locenice/locenice/assets/catalog/lam5.jpg
new file mode 100644
index 0000000..2db462e
Binary files /dev/null and b/locenice/locenice/assets/catalog/lam5.jpg differ
diff --git a/locenice/locenice/assets/catalog/lam6.jpg b/locenice/locenice/assets/catalog/lam6.jpg
new file mode 100644
index 0000000..52f3159
Binary files /dev/null and b/locenice/locenice/assets/catalog/lam6.jpg differ
diff --git a/locenice/locenice/assets/catalog/lam7.jpg b/locenice/locenice/assets/catalog/lam7.jpg
new file mode 100644
index 0000000..b5eeb0d
Binary files /dev/null and b/locenice/locenice/assets/catalog/lam7.jpg differ
diff --git a/locenice/locenice/assets/catalog/lam8.jpg b/locenice/locenice/assets/catalog/lam8.jpg
new file mode 100644
index 0000000..cbb72c7
Binary files /dev/null and b/locenice/locenice/assets/catalog/lam8.jpg differ
diff --git a/locenice/locenice/assets/catalog/lam9.jpg b/locenice/locenice/assets/catalog/lam9.jpg
new file mode 100644
index 0000000..b21cfe6
Binary files /dev/null and b/locenice/locenice/assets/catalog/lam9.jpg differ
diff --git a/locenice/locenice/assets/catalog/page12.jpg b/locenice/locenice/assets/catalog/page12.jpg
new file mode 100644
index 0000000..bbd7bd8
Binary files /dev/null and b/locenice/locenice/assets/catalog/page12.jpg differ
diff --git a/locenice/locenice/assets/catalog/page13.jpg b/locenice/locenice/assets/catalog/page13.jpg
new file mode 100644
index 0000000..5aaecf0
Binary files /dev/null and b/locenice/locenice/assets/catalog/page13.jpg differ
diff --git a/locenice/locenice/assets/catalog/page14.jpg b/locenice/locenice/assets/catalog/page14.jpg
new file mode 100644
index 0000000..584f78e
Binary files /dev/null and b/locenice/locenice/assets/catalog/page14.jpg differ
diff --git a/locenice/locenice/assets/catalog/page15.jpg b/locenice/locenice/assets/catalog/page15.jpg
new file mode 100644
index 0000000..f88ab3a
Binary files /dev/null and b/locenice/locenice/assets/catalog/page15.jpg differ
diff --git a/locenice/locenice/assets/catalog/page16.jpg b/locenice/locenice/assets/catalog/page16.jpg
new file mode 100644
index 0000000..c48af6a
Binary files /dev/null and b/locenice/locenice/assets/catalog/page16.jpg differ
diff --git a/locenice/locenice/assets/catalog/page17.jpg b/locenice/locenice/assets/catalog/page17.jpg
new file mode 100644
index 0000000..f28dcb5
Binary files /dev/null and b/locenice/locenice/assets/catalog/page17.jpg differ
diff --git a/locenice/locenice/assets/catalog/page18.jpg b/locenice/locenice/assets/catalog/page18.jpg
new file mode 100644
index 0000000..3be68a4
Binary files /dev/null and b/locenice/locenice/assets/catalog/page18.jpg differ
diff --git a/locenice/locenice/assets/catalog/page19.jpg b/locenice/locenice/assets/catalog/page19.jpg
new file mode 100644
index 0000000..f997138
Binary files /dev/null and b/locenice/locenice/assets/catalog/page19.jpg differ
diff --git a/locenice/locenice/assets/catalog/page20.jpg b/locenice/locenice/assets/catalog/page20.jpg
new file mode 100644
index 0000000..fb085c5
Binary files /dev/null and b/locenice/locenice/assets/catalog/page20.jpg differ
diff --git a/locenice/locenice/assets/catalog/page21.jpg b/locenice/locenice/assets/catalog/page21.jpg
new file mode 100644
index 0000000..3e90881
Binary files /dev/null and b/locenice/locenice/assets/catalog/page21.jpg differ
diff --git a/locenice/locenice/assets/catalog/page22.jpg b/locenice/locenice/assets/catalog/page22.jpg
new file mode 100644
index 0000000..74d4a2e
Binary files /dev/null and b/locenice/locenice/assets/catalog/page22.jpg differ
diff --git a/locenice/locenice/assets/catalog/page23.jpg b/locenice/locenice/assets/catalog/page23.jpg
new file mode 100644
index 0000000..560dc7c
Binary files /dev/null and b/locenice/locenice/assets/catalog/page23.jpg differ
diff --git a/locenice/locenice/assets/catalog/page24.jpg b/locenice/locenice/assets/catalog/page24.jpg
new file mode 100644
index 0000000..3c4dc16
Binary files /dev/null and b/locenice/locenice/assets/catalog/page24.jpg differ
diff --git a/locenice/locenice/assets/catalog/page25.jpg b/locenice/locenice/assets/catalog/page25.jpg
new file mode 100644
index 0000000..18caccd
Binary files /dev/null and b/locenice/locenice/assets/catalog/page25.jpg differ
diff --git a/locenice/locenice/assets/catalog/prum.jpg b/locenice/locenice/assets/catalog/prum.jpg
new file mode 100644
index 0000000..d8cdc1a
Binary files /dev/null and b/locenice/locenice/assets/catalog/prum.jpg differ
diff --git a/locenice/locenice/assets/catalog/requestedDoor.jpg b/locenice/locenice/assets/catalog/requestedDoor.jpg
new file mode 100644
index 0000000..b06fabe
Binary files /dev/null and b/locenice/locenice/assets/catalog/requestedDoor.jpg differ
diff --git a/locenice/locenice/assets/catalog/roof0.jpg b/locenice/locenice/assets/catalog/roof0.jpg
new file mode 100644
index 0000000..5087fa8
Binary files /dev/null and b/locenice/locenice/assets/catalog/roof0.jpg differ
diff --git a/locenice/locenice/assets/catalog/roof1.jpg b/locenice/locenice/assets/catalog/roof1.jpg
new file mode 100644
index 0000000..e16bbb5
Binary files /dev/null and b/locenice/locenice/assets/catalog/roof1.jpg differ
diff --git a/locenice/locenice/assets/catalog/roof2.jpg b/locenice/locenice/assets/catalog/roof2.jpg
new file mode 100644
index 0000000..c271e33
Binary files /dev/null and b/locenice/locenice/assets/catalog/roof2.jpg differ
diff --git a/locenice/locenice/assets/catalog/roof3.jpg b/locenice/locenice/assets/catalog/roof3.jpg
new file mode 100644
index 0000000..5f59976
Binary files /dev/null and b/locenice/locenice/assets/catalog/roof3.jpg differ
diff --git a/locenice/locenice/assets/catalog/roof4.jpg b/locenice/locenice/assets/catalog/roof4.jpg
new file mode 100644
index 0000000..e791808
Binary files /dev/null and b/locenice/locenice/assets/catalog/roof4.jpg differ
diff --git a/locenice/locenice/assets/catalog/tile1.jpg b/locenice/locenice/assets/catalog/tile1.jpg
new file mode 100644
index 0000000..e397599
Binary files /dev/null and b/locenice/locenice/assets/catalog/tile1.jpg differ
diff --git a/locenice/locenice/assets/catalog/tile2.jpg b/locenice/locenice/assets/catalog/tile2.jpg
new file mode 100644
index 0000000..2b806b2
Binary files /dev/null and b/locenice/locenice/assets/catalog/tile2.jpg differ
diff --git a/locenice/locenice/assets/catalog/tile3.jpg b/locenice/locenice/assets/catalog/tile3.jpg
new file mode 100644
index 0000000..03d5c79
Binary files /dev/null and b/locenice/locenice/assets/catalog/tile3.jpg differ
diff --git a/locenice/locenice/assets/catalog/tile4.jpg b/locenice/locenice/assets/catalog/tile4.jpg
new file mode 100644
index 0000000..d9d9965
Binary files /dev/null and b/locenice/locenice/assets/catalog/tile4.jpg differ
diff --git a/locenice/locenice/assets/catalog/tile5.jpg b/locenice/locenice/assets/catalog/tile5.jpg
new file mode 100644
index 0000000..3acfd19
Binary files /dev/null and b/locenice/locenice/assets/catalog/tile5.jpg differ
diff --git a/locenice/locenice/assets/catalog/tile6.jpg b/locenice/locenice/assets/catalog/tile6.jpg
new file mode 100644
index 0000000..4b01a60
Binary files /dev/null and b/locenice/locenice/assets/catalog/tile6.jpg differ
diff --git a/locenice/locenice/assets/catalog/tile7.jpg b/locenice/locenice/assets/catalog/tile7.jpg
new file mode 100644
index 0000000..a48bb17
Binary files /dev/null and b/locenice/locenice/assets/catalog/tile7.jpg differ
diff --git a/locenice/locenice/assets/catalog/tile8.jpg b/locenice/locenice/assets/catalog/tile8.jpg
new file mode 100644
index 0000000..7dd6387
Binary files /dev/null and b/locenice/locenice/assets/catalog/tile8.jpg differ
diff --git a/locenice/locenice/assets/catalog/tile9.jpg b/locenice/locenice/assets/catalog/tile9.jpg
new file mode 100644
index 0000000..83393e2
Binary files /dev/null and b/locenice/locenice/assets/catalog/tile9.jpg differ
diff --git a/locenice/locenice/assets/references/kitchen1.jpg b/locenice/locenice/assets/references/kitchen1.jpg
new file mode 100644
index 0000000..c304b70
Binary files /dev/null and b/locenice/locenice/assets/references/kitchen1.jpg differ
diff --git a/locenice/locenice/assets/references/kitchen2.jpg b/locenice/locenice/assets/references/kitchen2.jpg
new file mode 100644
index 0000000..240b136
Binary files /dev/null and b/locenice/locenice/assets/references/kitchen2.jpg differ
diff --git a/locenice/locenice/assets/references/plan.jpg b/locenice/locenice/assets/references/plan.jpg
new file mode 100644
index 0000000..c835196
Binary files /dev/null and b/locenice/locenice/assets/references/plan.jpg differ
diff --git a/locenice/locenice/assets/references/survey.png b/locenice/locenice/assets/references/survey.png
new file mode 100644
index 0000000..8eb90e7
Binary files /dev/null and b/locenice/locenice/assets/references/survey.png differ
diff --git a/locenice/locenice/assets/references/view1.jpg b/locenice/locenice/assets/references/view1.jpg
new file mode 100644
index 0000000..288432e
Binary files /dev/null and b/locenice/locenice/assets/references/view1.jpg differ
diff --git a/locenice/locenice/assets/references/view2.jpg b/locenice/locenice/assets/references/view2.jpg
new file mode 100644
index 0000000..cb45038
Binary files /dev/null and b/locenice/locenice/assets/references/view2.jpg differ
diff --git a/locenice/locenice/assets/ui/image-1.jpg b/locenice/locenice/assets/ui/image-1.jpg
new file mode 100644
index 0000000..b5455c7
Binary files /dev/null and b/locenice/locenice/assets/ui/image-1.jpg differ
diff --git a/locenice/locenice/css/app.css b/locenice/locenice/css/app.css
new file mode 100644
index 0000000..27268f4
--- /dev/null
+++ b/locenice/locenice/css/app.css
@@ -0,0 +1,1728 @@
+
+ :root {
+ --ink: #26342c;
+ --muted: #737b72;
+ --paper: #fafbf6;
+ --line: #d8ded4;
+ --accent: #365944
+ }
+
+ * {
+ box-sizing: border-box
+ }
+
+ html,
+ body {
+ margin: 0;
+ height: 100%;
+ overflow: hidden;
+ font-family: Arial, Helvetica, sans-serif;
+ color: var(--ink);
+ background: #e7ebe3
+ }
+
+ button,
+ input,
+ select {
+ font: inherit
+ }
+
+ button {
+ cursor: pointer;
+ color: inherit
+ }
+
+ button:focus-visible,
+ select:focus-visible,
+ input:focus-visible {
+ outline: 3px solid #91aa92;
+ outline-offset: 3px
+ }
+
+ #view {
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ display: block;
+ touch-action: none
+ }
+
+ header {
+ position: fixed;
+ left: 32px;
+ top: 29px;
+ pointer-events: none
+ }
+
+ .eyebrow {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 2.6px;
+ color: #647669;
+ display: flex;
+ align-items: center;
+ gap: 9px
+ }
+
+ .dot {
+ height: 6px;
+ width: 6px;
+ border-radius: 50%;
+ background: #536e57
+ }
+
+ h1 {
+ font-size: 31px;
+ font-weight: 500;
+ letter-spacing: -1.1px;
+ line-height: 1.13;
+ margin: 10px 0 7px
+ }
+
+ .sub {
+ font-size: 12px;
+ color: var(--muted);
+ line-height: 1.5
+ }
+
+ .metrics {
+ display: flex;
+ gap: 24px;
+ margin-top: 20px
+ }
+
+ .metric strong {
+ font-size: 18px;
+ font-weight: 500;
+ letter-spacing: -.4px
+ }
+
+ .metric small {
+ display: block;
+ margin-top: 4px;
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 1.6px;
+ color: var(--muted)
+ }
+
+ .topright {
+ position: fixed;
+ right: 27px;
+ top: 28px;
+ display: flex;
+ gap: 8px
+ }
+
+ .btn {
+ border: 1px solid #d4dcd1;
+ background: #f9fbf5ed;
+ border-radius: 9px;
+ padding: 10px 14px;
+ font-size: 12px;
+ line-height: 1;
+ box-shadow: 0 2px 8px #243c2210
+ }
+
+ .btn:hover {
+ background: #fff
+ }
+
+ .btn.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: white
+ }
+
+ .btn.active {
+ background: #dfebdf;
+ border-color: #99af9a
+ }
+
+ .icon {
+ width: 37px;
+ height: 37px;
+ padding: 0;
+ font-size: 16px
+ }
+
+ .bottom {
+ position: fixed;
+ left: 50%;
+ bottom: 32px;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 11px;
+ max-width: calc(100% - 32px)
+ }
+
+ .toolbar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 7px;
+ border: 1px solid #ffffffb0;
+ background: #f9fbf6ed;
+ border-radius: 15px;
+ box-shadow: 0 10px 34px #24381e16;
+ white-space: nowrap
+ }
+
+ .tool {
+ border: 0;
+ background: transparent;
+ padding: 10px 13px;
+ font-size: 12px;
+ border-radius: 8px
+ }
+
+ .tool.active {
+ background: var(--accent);
+ color: white
+ }
+
+ .tool:hover {
+ background: #e8eee5
+ }
+
+ .tool.active:hover {
+ background: #365944
+ }
+
+ .divider {
+ width: 1px;
+ height: 21px;
+ background: #dce2d8;
+ margin: 0 4px
+ }
+
+ .help {
+ font-size: 10px;
+ letter-spacing: .25px;
+ color: #687567;
+ text-align: center;
+ background: #e7ebe3c0;
+ border-radius: 6px;
+ padding: 4px 9px
+ }
+
+ .compass {
+ position: fixed;
+ right: 29px;
+ bottom: 38px;
+ width: 84px;
+ height: 84px
+ }
+
+ .compass svg {
+ width: 84px;
+ height: 84px;
+ overflow: visible
+ }
+
+ .compass text {
+ font: 9px Arial;
+ fill: #596959
+ }
+
+ .compass line {
+ stroke: #b4c0ae;
+ stroke-width: 1
+ }
+
+ #northArrow {
+ fill: #365944
+ }
+
+ #direction {
+ position: fixed;
+ left: 32px;
+ bottom: 37px;
+ font-size: 11px;
+ line-height: 1.6;
+ color: #576d5c
+ }
+
+ .tag {
+ font-size: 8px;
+ letter-spacing: 1.8px;
+ color: #7d8979;
+ display: block
+ }
+
+ .panel {
+ position: fixed;
+ right: 27px;
+ top: 77px;
+ max-height: calc(100% - 184px);
+ width: 337px;
+ overflow: auto;
+ background: #fafcf6f5;
+ border: 1px solid #fff;
+ border-radius: 15px;
+ box-shadow: 0 12px 45px #29392525;
+ padding: 23px;
+ display: none
+ }
+
+ .panel.open {
+ display: block
+ }
+
+ .panel h2 {
+ font-size: 19px;
+ letter-spacing: -.3px;
+ font-weight: 500;
+ margin: 0 0 10px
+ }
+
+ .panel h3 {
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 1.8px;
+ color: #70816e;
+ margin: 23px 0 10px
+ }
+
+ .panel p {
+ font-size: 12px;
+ line-height: 1.65;
+ color: #667261;
+ margin: 7px 0
+ }
+
+ .panel .row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 12px;
+ border-bottom: 1px solid #e0e6db;
+ padding: 9px 0
+ }
+
+ .swatches {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 10px
+ }
+
+ .swatches label {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ font-size: 10px;
+ color: #667261
+ }
+
+ input[type=color] {
+ width: 100%;
+ height: 30px;
+ border: 0;
+ background: none;
+ padding: 0;
+ cursor: pointer
+ }
+
+ select {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #d6e0d0;
+ border-radius: 6px;
+ background: #f5f8f0;
+ color: #334931;
+ font-size: 12px
+ }
+
+ .note {
+ border-left: 2px solid #a5b99d;
+ padding-left: 11px
+ }
+
+ .panel .buttons {
+ display: flex;
+ gap: 8px;
+ margin-top: 15px;
+ flex-wrap: wrap
+ }
+
+ .status {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 10px;
+ color: #6c7f68;
+ margin-top: 16px
+ }
+
+ #loading {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: #e7ebe3;
+ z-index: 10;
+ gap: 20px
+ }
+
+ .spinner {
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ border: 2px solid #bccabb;
+ border-top-color: #365944;
+ animation: spin 1s linear infinite
+ }
+
+ @keyframes spin {
+ to {
+ transform: rotate(360deg)
+ }
+ }
+
+ #loading .label {
+ font-size: 12px;
+ color: #597054
+ }
+
+ #loading.error {
+ padding: 30px;
+ text-align: center
+ }
+
+ .errorDetail {
+ font-size: 12px;
+ max-width: 480px;
+ line-height: 1.7;
+ color: #51624d
+ }
+
+ #measure {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ width: 100%;
+ height: 100%;
+ display: none
+ }
+
+ #measure line {
+ stroke: #3f5b42;
+ stroke-width: 1
+ }
+
+ #measure circle {
+ fill: #456749
+ }
+
+ #measure text {
+ fill: #3a553d;
+ font: 12px Arial;
+ paint-order: stroke;
+ stroke: #eff3e9;
+ stroke-width: 5px;
+ stroke-linejoin: round
+ }
+
+ #toast {
+ position: fixed;
+ left: 50%;
+ top: 28px;
+ transform: translateX(-50%);
+ font-size: 12px;
+ background: #2f4739;
+ color: #fff;
+ padding: 11px 17px;
+ border-radius: 9px;
+ opacity: 0;
+ transition: opacity .25s;
+ pointer-events: none
+ }
+
+ .full {
+ display: none
+ }
+
+ @media(max-width:850px) {
+ header {
+ left: 20px;
+ top: 22px
+ }
+
+ .topright {
+ right: 18px;
+ top: 22px
+ }
+
+ h1 {
+ font-size: 26px
+ }
+
+ .metrics {
+ gap: 19px;
+ margin-top: 15px
+ }
+
+ .metric strong {
+ font-size: 16px
+ }
+
+ .bottom {
+ bottom: 21px
+ }
+
+ .tool {
+ padding: 10px
+ }
+
+ .compass {
+ display: none
+ }
+
+ #direction {
+ bottom: 114px;
+ left: 20px
+ }
+
+ .panel {
+ right: 18px;
+ top: 69px;
+ width: 320px
+ }
+
+ .help {
+ font-size: 9px
+ }
+
+ .optional {
+ display: none
+ }
+ }
+
+ @media(max-width:520px) {
+ header {
+ left: 16px;
+ top: 19px
+ }
+
+ .eyebrow {
+ font-size: 8px;
+ letter-spacing: 1.7px
+ }
+
+ h1 {
+ font-size: 24px
+ }
+
+ .sub {
+ font-size: 10px;
+ max-width: 210px
+ }
+
+ .metrics {
+ gap: 16px;
+ margin-top: 13px
+ }
+
+ .metric strong {
+ font-size: 15px
+ }
+
+ .metric small {
+ font-size: 8px;
+ letter-spacing: 1px
+ }
+
+ .topright {
+ right: 13px;
+ top: 18px
+ }
+
+ .topright .btn.primary {
+ font-size: 10px;
+ padding: 11px 10px
+ }
+
+ .topright .icon {
+ width: 33px
+ }
+
+ .toolbar {
+ gap: 0;
+ padding: 5px;
+ border-radius: 12px
+ }
+
+ .tool {
+ padding: 10px 9px;
+ font-size: 10px
+ }
+
+ .divider {
+ margin: 0 1px
+ }
+
+ .bottom {
+ bottom: 17px;
+ gap: 8px
+ }
+
+ .help {
+ max-width: 320px;
+ font-size: 9px;
+ line-height: 1.5
+ }
+
+ #direction {
+ bottom: 100px;
+ left: 17px;
+ font-size: 10px
+ }
+
+ .panel {
+ top: 62px;
+ right: 12px;
+ left: 12px;
+ width: auto;
+ max-height: calc(100% - 159px);
+ padding: 20px
+ }
+
+ .btn {
+ font-size: 11px
+ }
+
+ .measureWord {
+ display: none
+ }
+
+ .topright .downloadText {
+ display: none
+ }
+ }
+
+ :root {
+ --solar: #b8812d;
+ --solar-soft: #f4e9cf;
+ --sun-side: 376px;
+ --sun-sheet: 340px
+ }
+
+ body.sun-open #view,
+ body.sun-open #measure,
+ body.sun-open #sunOverlay {
+ width: calc(100% - var(--sun-side))
+ }
+
+ #sunOverlay {
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ overflow: hidden
+ }
+
+ body.sun-open .bottom {
+ left: calc((100% - var(--sun-side))/2)
+ }
+
+ body.sun-open .compass {
+ right: calc(var(--sun-side) + 18px)
+ }
+
+ header h1 {
+ font-size: 30px;
+ letter-spacing: -1px
+ }
+
+ header .metrics {
+ margin-top: 18px;
+ gap: 22px
+ }
+
+ .solar-mark {
+ color: var(--solar);
+ font-size: 16px;
+ line-height: 1
+ }
+
+ #sunToggle {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ font-weight: 600
+ }
+
+ #sunToggle.active {
+ background: #f3e8ce;
+ border-color: #d8bd85
+ }
+
+ #sunPanel {
+ display: none;
+ position: fixed;
+ right: 16px;
+ top: 79px;
+ bottom: 20px;
+ width: 348px;
+ padding: 22px;
+ background: #fafbf7f7;
+ border: 1px solid #fff;
+ border-radius: 18px;
+ box-shadow: 0 12px 45px #29392517;
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ scrollbar-color: #c1cabe transparent;
+ z-index: 3
+ }
+
+ #sunPanel.open {
+ display: block
+ }
+
+ .sun-panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 15px;
+ margin-bottom: 17px
+ }
+
+ .sun-kicker {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 2px;
+ color: #8d753d;
+ margin-bottom: 6px
+ }
+
+ #sunPanel h2 {
+ font-size: 23px;
+ letter-spacing: -.65px;
+ font-weight: 500;
+ margin: 0
+ }
+
+ #sunPanel .close-panel {
+ border: 0;
+ background: #eef1e9;
+ border-radius: 50%;
+ width: 29px;
+ height: 29px;
+ font-size: 20px;
+ line-height: 20px;
+ color: #6a7466
+ }
+
+ .sun-date-row {
+ display: grid;
+ grid-template-columns: 1fr 105px;
+ gap: 12px;
+ align-items: end
+ }
+
+ .sun-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+ font-size: 10px;
+ color: #6f7969
+ }
+
+ .sun-field input:not([type=range]),
+ .sun-field select {
+ width: 100%;
+ min-width: 0;
+ padding: 9px 9px;
+ height: 39px;
+ border: 1px solid #d9e0d2;
+ border-radius: 8px;
+ background: #fff;
+ color: #354232;
+ font-size: 12px
+ }
+
+ .sun-field #sunClock {
+ font-size: 20px;
+ letter-spacing: -.6px;
+ padding: 6px;
+ width: 105px
+ }
+
+ .sun-seasons {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 5px;
+ margin-top: 11px
+ }
+
+ .sun-seasons button {
+ border: 1px solid #e0e4d9;
+ border-radius: 7px;
+ background: #f4f6ef;
+ padding: 8px 2px;
+ font-size: 10px;
+ color: #53624d
+ }
+
+ .sun-seasons button.active {
+ background: var(--solar-soft);
+ border-color: #dac08c;
+ color: #765923
+ }
+
+ .sun-status-line {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 10px;
+ color: #7b826f;
+ margin: 16px 0 5px;
+ gap: 8px
+ }
+
+ #dayState {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: #856527
+ }
+
+ #dayState:before {
+ content: '';
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #c3933c
+ }
+
+ .sun-graph {
+ display: block;
+ width: 100%;
+ height: 85px;
+ cursor: crosshair;
+ touch-action: none
+ }
+
+ .sun-scrub {
+ width: 100%;
+ accent-color: var(--solar);
+ margin: 8px 0 3px;
+ cursor: ew-resize;
+ height: 22px
+ }
+
+ .sun-ticks {
+ display: flex;
+ justify-content: space-between;
+ font-size: 9px;
+ color: #8b9283
+ }
+
+ .sun-play-row {
+ display: grid;
+ grid-template-columns: 1fr 90px 46px;
+ gap: 7px;
+ margin-top: 14px
+ }
+
+ .sun-play-row button,
+ .sun-play-row select {
+ height: 36px;
+ padding: 7px 9px;
+ font-size: 11px;
+ border-radius: 8px;
+ border: 1px solid #d6ddce;
+ background: #f2f5ec;
+ white-space: nowrap
+ }
+
+ .sun-play-row #sunPlay {
+ background: #3c5d48;
+ color: white;
+ border-color: #3c5d48;
+ font-weight: 600
+ }
+
+ .sun-play-row #sunSpeed {
+ font-size: 10px;
+ padding: 7px 4px
+ }
+
+ .sun-readouts {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+ margin-top: 20px;
+ padding-top: 17px;
+ border-top: 1px solid #e1e5d9
+ }
+
+ .sun-stat {
+ font-size: 10px;
+ color: #7b8372
+ }
+
+ .sun-stat strong {
+ display: block;
+ font-weight: 500;
+ font-size: 24px;
+ letter-spacing: -.7px;
+ color: #324630;
+ margin: 4px 0 3px;
+ font-variant-numeric: tabular-nums
+ }
+
+ .sun-stat small {
+ font-size: 10px;
+ color: #929883
+ }
+
+ .sun-events {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 10px;
+ color: #718066;
+ margin: 16px 0 2px;
+ padding: 12px 0;
+ border-top: 1px solid #e5e8df;
+ border-bottom: 1px solid #e5e8df
+ }
+
+ .sun-events b {
+ font-weight: 600;
+ color: #3e563b;
+ margin-left: 4px
+ }
+
+ .sun-options {
+ display: flex;
+ gap: 17px;
+ flex-wrap: wrap;
+ margin: 14px 0 12px
+ }
+
+ .sun-options label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: #52684d;
+ cursor: pointer
+ }
+
+ .sun-options input {
+ accent-color: #597447;
+ margin: 0;
+ width: 14px;
+ height: 14px
+ }
+
+ .sun-hint {
+ font-size: 10px;
+ line-height: 1.6;
+ color: #84907a;
+ margin: 8px 0 13px
+ }
+
+ .sun-advanced {
+ margin-top: 15px;
+ border-top: 1px solid #e1e5d9;
+ padding-top: 13px
+ }
+
+ .sun-advanced summary {
+ font-size: 11px;
+ cursor: pointer;
+ color: #4d6645;
+ padding: 3px 0
+ }
+
+ .sun-advanced .fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ margin-top: 14px
+ }
+
+ .sun-advanced .full-row {
+ grid-column: 1 / -1
+ }
+
+ .sun-advanced p {
+ font-size: 10px;
+ line-height: 1.65;
+ color: #7f8975;
+ margin: 10px 0
+ }
+
+ .sun-advanced a {
+ color: #536a4f
+ }
+
+ .sun-advanced .mini-btn {
+ font-size: 10px;
+ border: 1px solid #d2dbcb;
+ background: #f5f8ef;
+ border-radius: 7px;
+ padding: 8px 10px
+ }
+
+ .sun-export {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #ced8c6;
+ border-radius: 8px;
+ background: #eef3e8;
+ font-size: 11px;
+ color: #47603e;
+ margin-top: 13px
+ }
+
+ .sun-footnote {
+ font-size: 9px;
+ line-height: 1.6;
+ color: #969d8b;
+ margin: 13px 0 0
+ }
+
+ #sunStamp {
+ position: fixed;
+ left: 32px;
+ top: 180px;
+ pointer-events: none;
+ color: #6c775f;
+ font-size: 11px;
+ line-height: 1.55
+ }
+
+ #sunStamp strong {
+ font-weight: 500;
+ color: #a07836;
+ font-variant-numeric: tabular-nums
+ }
+
+ #solarMessage {
+ color: #977026;
+ font-size: 10px;
+ line-height: 1.5;
+ min-height: 0
+ }
+
+ #solarMessage:empty {
+ display: none
+ }
+
+ #panel {
+ z-index: 4
+ }
+
+ #sunOverlay text {
+ font: 10px Arial;
+ paint-order: stroke;
+ stroke: #f3f4e9;
+ stroke-width: 3px;
+ stroke-linejoin: round;
+ fill: #9a702d
+ }
+
+ #sunOverlay path,
+ #sunOverlay line {
+ vector-effect: non-scaling-stroke
+ }
+
+ body.night header,
+ body.night header h1 {
+ color: #d3dec9
+ }
+
+ body.night header .sub,
+ body.night header .metric small,
+ body.night header .eyebrow,
+ body.night #direction,
+ body.night #sunStamp {
+ color: #aab9a5
+ }
+
+ body.night #sunStamp strong {
+ color: #d6ba76
+ }
+
+ #toast {
+ z-index: 12;
+ top: 20px
+ }
+
+ @media(min-width:851px) {
+ .topright {
+ right: 20px;
+ top: 25px
+ }
+
+ .help {
+ font-size: 9px
+ }
+
+ .sun-open #direction {
+ bottom: 26px;
+ font-size: 10px
+ }
+
+ .sun-open .bottom {
+ bottom: 28px
+ }
+
+ .sun-open .compass {
+ bottom: 28px
+ }
+
+ .sun-open .toolbar .tool {
+ font-size: 11px;
+ padding: 10px 10px
+ }
+ }
+
+ @media(max-width:1100px) and (min-width:851px) {
+ header {
+ left: 22px;
+ top: 27px
+ }
+
+ header h1 {
+ font-size: 25px
+ }
+
+ .metrics {
+ display: none !important
+ }
+
+ #sunStamp {
+ left: 22px;
+ top: 115px
+ }
+
+ .sun-open .compass {
+ display: none
+ }
+
+ .sun-open #direction {
+ display: none
+ }
+
+ .sun-open .toolbar .tool {
+ padding: 10px 7px;
+ font-size: 10px
+ }
+ }
+
+ @media(max-width:850px) {
+ :root {
+ --sun-sheet: min(42vh, 350px)
+ }
+
+ body.sun-open #view,
+ body.sun-open #measure,
+ body.sun-open #sunOverlay {
+ width: 100%;
+ height: calc(100% - var(--sun-sheet))
+ }
+
+ body.sun-open .bottom {
+ left: 50%;
+ bottom: calc(var(--sun-sheet) + 8px);
+ gap: 0
+ }
+
+ body.sun-open .help {
+ display: none
+ }
+
+ body.sun-open #direction {
+ display: none
+ }
+
+ body.sun-open .compass {
+ display: none
+ }
+
+ #sunPanel {
+ top: auto;
+ bottom: 0;
+ right: 0;
+ left: 0;
+ width: 100%;
+ height: var(--sun-sheet);
+ padding: 16px 21px 25px;
+ border-radius: 20px 20px 0 0;
+ box-shadow: 0 -8px 30px #26392514
+ }
+
+ .sun-panel-head {
+ display: none
+ }
+
+ .sun-date-row {
+ grid-template-columns: 1fr 120px;
+ gap: 16px
+ }
+
+ .sun-date-row .sun-field {
+ font-size: 9px
+ }
+
+ .sun-field #sunClock {
+ width: 100%;
+ font-size: 23px;
+ height: 40px
+ }
+
+ .sun-field input:not([type=range]) {
+ height: 40px
+ }
+
+ .sun-seasons {
+ margin-top: 9px
+ }
+
+ .sun-seasons button {
+ padding: 7px 0;
+ font-size: 10px
+ }
+
+ .sun-status-line {
+ margin: 12px 0 0
+ }
+
+ .sun-graph {
+ height: 62px
+ }
+
+ .sun-scrub {
+ margin: 4px 0 0
+ }
+
+ .sun-play-row {
+ margin-top: 10px;
+ grid-template-columns: 1fr 94px 51px
+ }
+
+ .sun-readouts {
+ margin-top: 15px;
+ padding-top: 12px
+ }
+
+ .sun-stat strong {
+ font-size: 23px
+ }
+
+ .sun-events {
+ margin-top: 13px
+ }
+
+ #sunStamp {
+ left: 18px;
+ top: 105px;
+ font-size: 10px
+ }
+
+ header {
+ left: 18px;
+ top: 19px
+ }
+
+ header h1 {
+ font-size: 25px;
+ margin-top: 9px
+ }
+
+ .metrics {
+ display: none !important
+ }
+
+ header .sub {
+ max-width: 230px;
+ font-size: 10px
+ }
+
+ .topright {
+ right: 14px;
+ top: 19px;
+ gap: 6px
+ }
+
+ #saveGlb {
+ display: none
+ }
+
+ #sunToggle {
+ font-size: 10px;
+ padding: 10px 9px;
+ gap: 4px
+ }
+
+ .topright .icon {
+ width: 33px;
+ height: 33px
+ }
+
+ .sun-open .tool {
+ font-size: 10px;
+ padding: 9px 8px
+ }
+
+ .sun-open .bottom {
+ max-width: calc(100% - 16px)
+ }
+
+ #panel {
+ top: 66px;
+ bottom: 15px;
+ max-height: none
+ }
+ }
+
+ @media(max-width:420px) {
+ header h1 {
+ font-size: 23px
+ }
+
+ header .eyebrow {
+ font-size: 8px;
+ letter-spacing: 1.8px
+ }
+
+ #sunToggle .toggle-word {
+ display: none
+ }
+
+ #sunToggle {
+ width: 34px;
+ height: 34px;
+ justify-content: center;
+ padding: 0
+ }
+
+ .sun-open .tool {
+ padding: 9px 7px;
+ font-size: 10px
+ }
+ }
+
+ @media(max-height:500px) and (max-width:850px) {
+ :root {
+ --sun-sheet: 170px
+ }
+
+ header .sub,
+ #sunStamp {
+ display: none
+ }
+
+ header h1 {
+ font-size: 21px
+ }
+
+ .sun-graph {
+ display: none
+ }
+
+ #sunPanel {
+ padding: 12px 20px
+ }
+
+ .sun-readouts {
+ display: none
+ }
+ }
+
+ /* Optional surroundings: independent switches, all on when the page opens. */
+ .site-controls {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 6px;
+ border: 1px solid #fff;
+ border-radius: 13px;
+ background: #f9fbf6f2;
+ box-shadow: 0 6px 24px #24381e13;
+ max-width: 100%;
+ }
+
+ .site-switch {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 9px 10px;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ font-size: 11px;
+ color: #596b55;
+ cursor: pointer;
+ white-space: nowrap;
+ user-select: none;
+ }
+
+ .site-switch:hover {
+ background: #eef2e8;
+ }
+
+ .site-switch.selected {
+ background: #e1ecdc;
+ border-color: #b9cbb1;
+ color: #304d35;
+ }
+
+ .site-switch input {
+ appearance: none;
+ width: 13px;
+ height: 13px;
+ margin: 0;
+ border: 1px solid #a7b29f;
+ border-radius: 4px;
+ background: #fff;
+ flex-shrink: 0;
+ cursor: pointer;
+ display: grid;
+ place-items: center;
+ }
+
+ .site-switch input:checked {
+ background: #426448;
+ border-color: #426448;
+ }
+
+ .site-switch input:checked:after {
+ content: '\2713';
+ color: #fff;
+ font-size: 10px;
+ line-height: 1;
+ }
+
+ .site-fit {
+ display: grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ flex-shrink: 0;
+ border: 0;
+ border-left: 1px solid #dce2d5;
+ border-radius: 5px;
+ background: transparent;
+ color: #59734e;
+ }
+
+ .site-fit:hover {
+ background: #e7eee1;
+ }
+
+ .site-fit svg {
+ width: 17px;
+ height: 17px;
+ }
+
+ #siteMeta {
+ font-size: 9px;
+ letter-spacing: .1px;
+ color: #6e8066;
+ line-height: 1.2;
+ background: #f5f8eef0;
+ padding: 4px 8px;
+ border-radius: 5px;
+ pointer-events: none;
+ }
+
+ #siteMeta[hidden] {
+ display: none;
+ }
+
+ .bottom {
+ gap: 7px;
+ z-index: 2;
+ }
+
+ .site-note {
+ margin-top: 14px;
+ border-top: 1px solid #dce4d5;
+ padding-top: 10px;
+ }
+
+ .site-note summary {
+ cursor: pointer;
+ font-size: 12px;
+ color: #37563b;
+ padding: 6px 0;
+ }
+
+ .site-note p {
+ font-size: 11px;
+ }
+
+ @media(max-width:850px) {
+ .bottom {
+ max-width: calc(100% - 20px);
+ }
+
+ #direction {
+ bottom: 161px;
+ }
+
+ .site-controls {
+ gap: 2px;
+ padding: 5px;
+ }
+
+ .site-switch {
+ font-size: 10px;
+ padding: 8px 8px;
+ gap: 5px;
+ }
+
+ body.sun-open .bottom {
+ gap: 5px;
+ }
+
+ #siteMeta {
+ font-size: 8px;
+ }
+ }
+
+ @media(max-width:520px) {
+ .bottom {
+ gap: 5px;
+ bottom: 12px;
+ }
+
+ .site-switch {
+ font-size: 9px;
+ padding: 8px 6px;
+ gap: 5px;
+ }
+
+ .site-fit {
+ width: 27px;
+ height: 28px;
+ }
+
+ .site-controls {
+ gap: 0;
+ padding: 4px;
+ }
+
+ .site-switch input {
+ width: 12px;
+ height: 12px;
+ }
+
+ .bottom .help {
+ display: none;
+ }
+
+ #direction {
+ bottom: 111px;
+ }
+
+ body.sun-open #siteMeta {
+ display: none;
+ }
+ }
+
+ @media(max-width:360px) {
+ .site-switch {
+ font-size: 8.5px;
+ padding: 8px 4px;
+ gap: 4px;
+ }
+
+ .site-fit {
+ width: 24px;
+ }
+ }
+ /* Fixed product palette, scoped to facade cladding only. */
+ .swatches { grid-template-columns: 1fr 1fr; }
+ .hardie-card { margin: 15px 0 17px; padding: 14px; border: 1px solid #d8ded4; border-radius: 11px; background: #ffffffb8; }
+ .hardie-kicker { font-size: 9px; letter-spacing: 1.4px; text-transform: uppercase; color: #677b61; margin-bottom: 8px; }
+ .hardie-card h4 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -.2px; }
+ .hardie-card .hardie-spec { font-size: 10px; line-height: 1.6; margin: 5px 0 10px; }
+ .hardie-preview { height: 48px; border-radius: 4px; background: #776e65; background-size: 400% 300%; background-position: 48% 48%; border: 1px solid #0002; box-shadow: inset 0 -3px 0 #0002, inset 0 1px 0 #fff2; }
+ .hardie-selection { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; padding: 9px 0 10px; color: #304835; }
+ .hardie-selection strong { font-size: 12px; font-weight: 600; }
+ .hardie-selection small { font-size: 9px; color: #778270; white-space: nowrap; }
+ .hardie-palette { border: 0; padding: 0; margin: 0; min-width: 0; }
+ .hardie-palette legend { font-size: 9px; color: #6d7b64; margin: 0 0 8px; padding: 0; }
+ .hardie-options { display: grid; grid-template-columns: 1fr; gap: 4px; }
+ .hardie-option { display: flex; align-items: center; gap: 8px; min-height: 33px; padding: 5px 8px; border: 1px solid #e1e6dd; border-radius: 6px; cursor: pointer; background: #fbfcf9; position: relative; }
+ .hardie-option:hover { border-color: #aabda1; background: #f4f7ef; }
+ .hardie-option.selected { background: #eaf1e5; border-color: #87a57c; }
+ .hardie-option:focus-within { outline: 2px solid #91aa92; outline-offset: 2px; }
+ .hardie-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
+ .hardie-chip { width: 22px; height: 21px; border-radius: 3px; flex-shrink: 0; border: 1px solid #0002; background: var(--chip); }
+ .hardie-option-name { font-size: 11px; flex: 1; line-height: 1.3; }
+ .hardie-option-code { font-size: 9px; color: #7d8577; font-variant-numeric: tabular-nums; }
+ .hardie-check { width: 11px; font-size: 12px; color: #365944; opacity: 0; }
+ .hardie-option.selected .hardie-check { opacity: 1; }
+ .hardie-card .hardie-scope { font-size: 10px; color: #6d7965; margin: 10px 0 7px; line-height: 1.5; }
+ .hardie-link { color: #365944; font-size: 11px; text-underline-offset: 3px; }
+ .hardie-detail-buttons { display: flex; gap: 6px; margin-top: 10px; }
+ .hardie-detail-buttons button { background: #f3f6ef; border: 1px solid #d6dfcf; border-radius: 6px; padding: 7px 8px; font-size: 10px; flex: 1; }
+ .hardie-detail-buttons button:hover { background: #e7eee0; }
+ .hardie-note { margin-top: 11px; border-top: 1px solid #e0e6da; padding-top: 8px; }
+ .hardie-note summary { font-size: 10px; cursor: pointer; color: #65765c; }
+ .hardie-card .hardie-note p { font-size: 10px; line-height: 1.55; }
+ .hardie-reference { width: 100%; height: auto; display: block; border-radius: 5px; margin: 8px 0; }
+ @media(max-width:360px) { .hardie-card { padding: 11px; } .hardie-option-code { font-size: 8px; } }
+
+ .hardie-structure-button { width: 100%; margin-top: 6px; padding: 7px; border: 1px solid #d6dfcf; border-radius: 6px; background: #f3f6ef; font-size: 10px; }
+ .hardie-preview-caption { font-size: 9px; color: #7a8372; margin-top: 4px; }
+
+
+/* Optional interior. No source exterior selector is changed outside this state. */
+#interiorLaunch{position:fixed;left:32px;top:225px;z-index:3;display:flex;align-items:center;gap:11px;padding:12px 17px;border:1px solid #b8c7b0;background:#fafcf6f5;border-radius:12px;box-shadow:0 5px 25px #263b2520;color:#365944;font-size:12px;font-weight:600}
+#interiorLaunch svg{width:20px;height:20px}#interiorLaunch small{display:block;margin-top:4px;font-size:9px;font-weight:400;color:#7a8574;text-align:left}
+#interiorLaunch:disabled{opacity:.6;cursor:wait}
+.interior-heading,#interiorPanel,#interiorTools,#interiorLabels,#walkPad{display:none}
+body.interior-active header,body.interior-active #interiorLaunch,body.interior-active .bottom,body.interior-active #direction,body.interior-active .compass,body.interior-active #measure,body.interior-active #sunStamp{display:none!important}
+body.interior-active .interior-heading{display:flex;position:fixed;top:23px;left:25px;z-index:3;align-items:center;gap:17px;pointer-events:none}
+.interior-heading button{pointer-events:auto;padding:11px 13px}.interior-heading .eyebrow{font-size:8px;letter-spacing:1.8px}.interior-heading h2{margin:5px 0 0;font-size:23px;font-weight:500;letter-spacing:-.65px;color:#2c4434}
+body.interior-active:not(.interior-panel-closed):not(.sun-open) #view,body.interior-active:not(.interior-panel-closed):not(.sun-open) #sunOverlay{width:calc(100% - 340px)}
+body.interior-active #interiorPanel{display:block;position:fixed;z-index:3;right:16px;top:78px;bottom:20px;width:308px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;padding:20px;background:#fafcf7f7;border:1px solid #fff;box-shadow:0 10px 35px #263b2520;border-radius:16px}
+body.interior-panel-closed #interiorPanel,body.interior-active.sun-open #interiorPanel{display:none}
+.interior-panel-title{display:flex;justify-content:space-between;align-items:center;gap:6px;margin:0 0 12px}
+.interior-panel-title h3{margin:0;font-size:16px;font-weight:500;letter-spacing:-.3px}.interior-panel-title span{font-size:9px;color:#75816f}
+.int-kicker{font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:#7c8675;margin:20px 0 10px}
+#interiorMap{width:100%;height:auto;display:block;border-radius:10px;background:#eff1e9;border:1px solid #e0e5d8}
+#interiorMap .map-room{cursor:pointer;fill:#dcd4c1;stroke:#f5f4eb;stroke-width:2;transition:fill .15s}
+#interiorMap .map-room:hover,#interiorMap .map-room.active{fill:#aebda2}
+#interiorMap text{font:9px Arial;fill:#465743;pointer-events:none}
+#interiorMap .map-wall{fill:#8e9986;pointer-events:none}
+.int-map-hint{font-size:9px!important;line-height:1.5!important;margin:7px 0 0!important;color:#85917d!important}
+.int-views{display:grid;grid-template-columns:1fr 1fr;gap:6px}
+.int-views button{padding:11px 8px;text-align:left;border:1px solid #d9e0d2;border-radius:8px;background:#f2f6ed;color:#38523a;font-size:10px;line-height:1.35}
+.int-views button.active{background:#dfe9d8;border-color:#a6ba99}.int-views button:hover{background:#e5ecde}
+#interiorRoom{margin-top:2px;font-size:11px;background:#fff}
+.int-options{display:grid;grid-template-columns:1fr;gap:10px;padding-top:14px;margin-top:16px;border-top:1px solid #e0e6d9}
+.int-options label{font-size:11px;display:flex;align-items:center;gap:8px;color:#51654a}.int-options input{accent-color:#426448}
+#interiorPanel p{font-size:10px;line-height:1.6;color:#77836e;margin:10px 0}
+.int-source{font-size:10px;border-top:1px solid #dce3d4;padding-top:12px;margin-top:16px}.int-source summary{cursor:pointer;line-height:1.5;color:#536b49}
+.int-source-buttons{display:flex;gap:5px;flex-wrap:wrap;margin:10px 0}
+.int-source-buttons button{border:1px solid #d4ddca;border-radius:7px;font-size:10px;background:#f5f7ef;padding:8px;color:#486140}
+.int-export{width:100%;margin-top:12px;border:1px solid #d0dbc6;border-radius:8px;background:#eef3e7;padding:10px;font-size:10px;color:#49613f}
+body.interior-active #interiorTools{display:flex;position:fixed;z-index:4;left:calc((100% - 340px)/2);bottom:22px;transform:translateX(-50%);flex-direction:column;align-items:center;gap:8px;max-width:calc(100% - 24px)}
+body.interior-panel-closed #interiorTools{left:50%}body.interior-active.sun-open #interiorTools{left:calc((100% - var(--sun-side))/2)}
+.int-mode-bar{display:flex;align-items:center;gap:4px;white-space:nowrap;padding:6px;background:#fafcf6f5;border:1px solid #fff;border-radius:12px;box-shadow:0 5px 26px #263b2525}
+.int-mode-bar button{border:0;background:transparent;border-radius:7px;padding:10px 11px;font-size:11px;color:#52684a}.int-mode-bar button.active{background:#365944;color:#fff}.int-mode-bar button:hover:not(.active){background:#e8eee2}
+.int-mode-bar .int-nav-button{background:#e9efe3;font-size:15px;line-height:14px;padding:9px 10px}
+#interiorHint{font-size:9px;line-height:1.45;color:#71806a;text-align:center;background:#f0f3e8de;border-radius:6px;padding:4px 10px;max-width:460px}
+body.interior-active #interiorLabels{display:block;position:fixed;inset:0;pointer-events:none;z-index:2}
+.int-room-tag{position:absolute;transform:translate(-50%,-50%);border:1px solid #ffffffdc;border-radius:7px;background:#fafcf4ea;padding:5px 7px;box-shadow:0 2px 10px #253a2220;pointer-events:auto;white-space:nowrap;line-height:1.2;font-size:10px;color:#334c35}
+.int-room-tag b{font-weight:600}.int-room-tag small{display:block;font-size:8px;color:#7c8874;margin-top:3px}.int-room-tag:hover{border-color:#739069;background:#edf4e5}
+body.interior-walk #sunOverlay{display:none}
+body.interior-walk #walkPad{display:grid;position:fixed;z-index:4;left:24px;bottom:94px;grid-template-columns:38px 38px 38px;grid-template-rows:34px 34px;gap:4px}
+#walkPad button{touch-action:none;user-select:none;-webkit-user-select:none;border:1px solid #d5dfcc;background:#f8fbf1ee;border-radius:8px;color:#425e3c;font-size:17px;box-shadow:0 3px 15px #314a2520}#walkPad button:active,#walkPad button.held{background:#bfd0b2}
+#walkPad [data-walk=forward]{grid-column:2;grid-row:1}#walkPad [data-walk=left]{grid-column:1;grid-row:2}#walkPad [data-walk=back]{grid-column:2;grid-row:2}#walkPad [data-walk=right]{grid-column:3;grid-row:2}
+#interiorReferenceDialog{width:min(1120px,96vw);max-height:94vh;border:1px solid #e2e8da;border-radius:14px;background:#fafcf6;padding:16px;color:#36503c;box-shadow:0 25px 90px #14241666}
+#interiorReferenceDialog::backdrop{background:#1e2b22b3;backdrop-filter:blur(3px)}
+.int-ref-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.int-ref-head h3{font-size:14px;font-weight:500;margin:0}.int-ref-head button{border:0;background:#e9eee3;border-radius:7px;padding:8px 12px}
+#interiorReferenceImage{display:block;max-width:100%;max-height:76vh;object-fit:contain;margin:auto}.int-ref-caption{font-size:11px;line-height:1.5;margin:10px 0 0;color:#7b8574}
+@media(max-width:1100px) and (min-width:851px){.interior-heading h2{font-size:19px}.interior-heading .eyebrow{display:none}.interior-heading{max-width:calc(100% - 370px)}}
+@media(max-width:850px){
+ #interiorLaunch{top:147px;left:18px;padding:10px 13px;font-size:11px}#interiorLaunch small{font-size:8px}
+ body.interior-active .interior-heading{left:16px;top:18px;gap:10px;max-width:calc(100% - 110px)}.interior-heading h2{font-size:17px;margin-top:3px}.interior-heading .eyebrow{font-size:7px;letter-spacing:1px}.interior-heading button{padding:9px 10px;font-size:10px}
+ body.interior-active:not(.interior-panel-closed):not(.sun-open) #view,body.interior-active:not(.interior-panel-closed):not(.sun-open) #sunOverlay{width:100%;height:calc(100% - 242px)}
+ body.interior-active #interiorPanel{top:auto;left:0;right:0;bottom:0;width:100%;height:242px;border-radius:18px 18px 0 0;padding:15px 18px 24px}
+ #interiorMap{width:150px;float:left;margin-right:14px;margin-bottom:9px}.interior-panel-title{margin-bottom:9px}.interior-panel-title h3{font-size:14px}
+ .int-views{grid-template-columns:1fr 1fr;gap:5px}.int-views button{font-size:9px;padding:9px 6px}.int-kicker{margin:10px 0 7px;font-size:8px}.int-map-hint{display:none}#interiorRoom{font-size:10px;padding:8px}.int-options{clear:both;grid-template-columns:1fr 1fr;margin-top:14px;gap:10px}.int-options label{font-size:10px}
+ body.interior-active #interiorTools{left:50%;bottom:250px;gap:5px;width:max-content;max-width:calc(100% - 16px)}
+ body.interior-active.sun-open #interiorTools{left:50%;bottom:calc(var(--sun-sheet) + 8px)}body.interior-panel-closed #interiorTools{bottom:17px}
+ .int-mode-bar{padding:5px;gap:2px}.int-mode-bar button{font-size:10px;padding:9px 9px}.int-mode-bar .int-nav-button{padding:9px 10px}#interiorHint{display:none}
+ body.interior-walk #walkPad{left:12px;bottom:304px;grid-template-columns:36px 36px 36px;grid-template-rows:32px 32px;gap:4px}body.interior-walk.interior-panel-closed #walkPad{bottom:80px}body.interior-walk.sun-open #walkPad{bottom:calc(var(--sun-sheet) + 62px)}
+ .int-room-tag{font-size:8px;padding:4px 5px}.int-room-tag small{font-size:7px}.int-room-tag b .room-word{display:none}
+ #interiorReferenceDialog{padding:12px}.int-ref-head h3{font-size:12px}
+}
+@media(max-width:420px){
+ body.interior-active .interior-heading{max-width:calc(100% - 98px);gap:8px}.interior-heading h2{font-size:15px}.interior-heading button{font-size:9px;padding:9px 8px}
+ #interiorMap{width:132px;margin-right:10px}.int-views button{font-size:8.5px;padding:8px 5px}.int-mode-bar button{padding:9px 8px}
+}
+@media(max-height:550px) and (max-width:850px){
+ body.interior-active:not(.interior-panel-closed):not(.sun-open) #view{height:100%;width:calc(100% - 260px)}
+ body.interior-active #interiorPanel{left:auto;right:10px;top:65px;bottom:10px;width:245px;height:auto;border-radius:13px;padding:12px}#interiorMap{float:none;width:100%;margin-right:0}
+ body.interior-active #interiorTools{left:calc((100% - 260px)/2);bottom:12px}body.interior-panel-closed #interiorTools{left:50%}body.interior-walk #walkPad{bottom:64px}
+}
+
+
+
+/* Interior-only paint controls; facade palette and furniture are independent. */
+.int-paint{clear:both;margin:18px 0 0;padding:14px 0 0;border-top:1px solid #e0e6d9}
+.int-paint-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}
+.int-paint-head h4{margin:0;font-size:12px;font-weight:600;color:#38523a}
+.int-paint-head span{font-size:9px;color:#7c8675}
+.int-paint-fields{display:grid;grid-template-columns:48px minmax(0,1fr);gap:10px;align-items:end}
+.int-paint-picker{display:block;height:43px;padding:4px;border:1px solid #ccd6c6;border-radius:9px;background:#fff;overflow:hidden;cursor:pointer}
+#interiorWallColor{display:block;width:100%;height:100%;padding:0;border:0;border-radius:5px;background:transparent;cursor:pointer}
+#interiorWallColor::-webkit-color-swatch-wrapper{padding:0}
+#interiorWallColor::-webkit-color-swatch{border:1px solid #0002;border-radius:4px}
+#interiorWallColor::-moz-color-swatch{border:1px solid #0002;border-radius:4px}
+.int-paint-hex{min-width:0;display:flex;flex-direction:column;gap:4px;font-size:9px;color:#75816f}
+#interiorWallHex{display:block;width:100%;min-width:0;height:32px;border:1px solid #d6e0d0;border-radius:7px;padding:7px 9px;background:#fff;color:#334931;font:11px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.7px;text-transform:uppercase}
+.int-paint-presets{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:5px;margin-top:10px}
+.int-paint-presets button{display:flex;align-items:center;gap:6px;min-width:0;min-height:34px;padding:6px;border:1px solid #d9e0d2;border-radius:7px;background:#f7f9f3;color:#51654a;text-align:left;font-size:9px;line-height:1.25}
+.int-paint-presets button:hover{background:#eaf0e4;border-color:#a6ba99}
+.int-paint-presets button[aria-pressed=true]{border-color:#78946c;background:#e5eedf;color:#2e4b35;box-shadow:inset 0 0 0 1px #78946c}
+.int-paint-chip{display:block;width:19px;height:19px;flex:0 0 19px;border-radius:4px;background:var(--paint);border:1px solid #0002}
+.int-paint-reset{display:block;margin:10px 0 0;padding:7px 9px;border:1px solid #d6dfcf;border-radius:7px;background:#f3f6ef;color:#49613f;font-size:10px}
+.int-paint-reset:hover{background:#e7eee0}
+#interiorPanel .int-paint-note{font-size:9px;line-height:1.55;margin:8px 0 0;color:#7c8675}
+@media(max-width:850px){.int-paint{margin-top:14px;padding-top:12px}.int-paint-presets{grid-template-columns:repeat(3,minmax(0,1fr))}.int-paint-head h4{font-size:11px}.int-paint-presets button{min-height:36px}.int-paint-fields{grid-template-columns:48px minmax(0,1fr)}}
+@media(max-width:360px){.int-paint-presets{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media(max-height:550px) and (max-width:850px){.int-paint-presets{grid-template-columns:repeat(2,minmax(0,1fr))}}
+
+
+/* ALLSTAV 2025 configuration panel. Independent from the retained sun and site tools. */
+:root{--std-side:424px;--std-sheet:min(48dvh,440px);--std-line:#dce2d7;--std-green:#365944}
+#standardsToggle{display:flex;align-items:center;gap:7px;font-weight:600;background:#365944;color:#fff;border-color:#365944}
+#standardsToggle svg{width:15px;height:15px}#standardsToggle.active{background:#244633}
+#standardsPanel{display:none;position:fixed;z-index:5;right:16px;top:78px;bottom:17px;width:392px;background:#fafbf7;border:1px solid #fff;border-radius:18px;box-shadow:0 12px 44px #20311f22;overflow:hidden;flex-direction:column}
+body.std-open #standardsPanel{display:flex}body.std-open #view,body.std-open #measure,body.std-open #sunOverlay{width:calc(100% - var(--std-side))!important;height:100%!important}
+body.std-open .bottom,body.std-open #interiorTools{left:calc((100% - var(--std-side))/2)!important}
+body.std-open #interiorPanel,body.std-open #panel{display:none!important}body.std-open .compass{right:calc(var(--std-side) + 18px)}
+body.std-open .interior-heading{max-width:calc(100% - var(--std-side) - 30px)}body.std-open #direction{font-size:10px;bottom:24px}
+.std-head{padding:20px 20px 14px}.std-kicker{font-size:9px;letter-spacing:2px;font-weight:700;color:#829077}.std-head-row{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:6px}.std-head h2{font-size:23px;letter-spacing:-.6px;font-weight:500;margin:0}.std-close{border:0;background:#edf1e7;border-radius:50%;width:29px;height:29px;flex-shrink:0;font-size:20px;line-height:20px}.std-head p{font-size:10px;line-height:1.5;color:#7c8774;margin:6px 0 0}
+.std-tabs{margin:0 16px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;background:#ecf0e7;border:1px solid #e1e6db;padding:4px;border-radius:10px}.std-tabs button{border:0;border-radius:7px;padding:10px 5px;background:none;font-size:12px}.std-tabs button[aria-selected=true]{background:#365944;color:white;box-shadow:0 2px 5px #233a2520}
+.std-scroll{overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#c1cbbb transparent;flex:1;min-height:0;padding:0 17px 18px}.std-scroll [hidden]{display:none!important}.std-shortcuts{display:flex;gap:5px;margin:13px 0 14px}.std-shortcuts button{flex:1;border:1px solid #dbe2d4;border-radius:7px;background:#f3f6ee;padding:8px 3px;font-size:10px}.std-shortcuts button:hover{background:#e4ecde}
+.std-notice{font-size:10px;line-height:1.6;color:#777f6e;background:#eef2e9;border-radius:8px;padding:10px 11px;margin:10px 0}.std-notice strong{color:#53624b;font-weight:600}.std-notice.amber{background:#f6efdf;color:#8a744a}.std-notice.amber strong{color:#795f36}.std-notice.blue{background:#eaf0f2;color:#576e75}
+.std-section{border-bottom:1px solid var(--std-line);padding:0}.std-section>summary{display:flex;align-items:center;gap:10px;list-style:none;cursor:pointer;padding:16px 0;line-height:1.3}.std-section>summary::-webkit-details-marker{display:none}.std-section>summary:after{content:'+';font-size:18px;color:#8d9985;margin-left:auto}.std-section[open]>summary:after{content:'\2212'}.std-section>summary .std-number{font-size:10px;width:22px;height:22px;border:1px solid #d6decd;background:#eef2e8;border-radius:7px;display:flex;align-items:center;justify-content:center;color:#738568;flex-shrink:0}.std-section>summary strong{display:block;font-size:12px;font-weight:600}.std-section>summary small{display:block;font-size:10px;font-weight:400;color:#849078;margin-top:4px;max-width:271px}.std-section .std-body{padding-bottom:17px}
+.std-label{display:flex;justify-content:space-between;align-items:center;font-size:11px;font-weight:600;margin:13px 0 9px;color:#4a5c42;gap:7px}.std-source{border:0;padding:3px 0;background:none;color:#849375;font-size:9px;text-decoration:underline;text-underline-offset:3px;cursor:pointer}.std-body p{font-size:10px;line-height:1.6;color:#7c8673;margin:8px 0 10px}
+.std-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.std-grid.cols3{grid-template-columns:repeat(3,minmax(0,1fr))}.std-grid.cols5{grid-template-columns:repeat(5,minmax(0,1fr))}.std-grid.cols2{grid-template-columns:repeat(2,minmax(0,1fr))}
+.std-card{position:relative;border:1px solid #dce2d5;background:white;border-radius:9px;padding:5px 5px 7px;min-width:0;min-height:65px;text-align:center;font-size:10px;line-height:1.35;transition:border-color .12s,box-shadow .12s}.std-card:hover{border-color:#8aa27c}.std-card[aria-pressed=true]{border-color:#55754a;box-shadow:0 0 0 1px #55754a;background:#f0f5e9}.std-card[aria-pressed=true]:after{content:'\2713';position:absolute;right:3px;top:3px;border-radius:50%;width:15px;height:15px;background:#365944;color:#fff;font-size:10px;line-height:15px}.std-card .sample{display:block;width:100%;height:32px;border-radius:5px;margin-bottom:6px;border:1px solid #00000008;background-size:cover;background-position:center}.std-card img{display:block;object-fit:cover;width:100%;height:48px;border-radius:4px;margin-bottom:6px}.std-card.door img{height:70px;object-fit:contain;background:#fff}.std-card.wood img{height:56px;object-fit:cover}.std-card strong{font-weight:500;display:block}.std-card small{display:block;margin-top:3px;font-size:8px;color:#87947c}.std-card .premium{color:#9e793e}.std-card .visual{color:#7b8990}
+.std-swatch-inline{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:-1px;border:1px solid #0001}.std-two{display:grid;grid-template-columns:1fr 1fr;gap:9px}.std-field{display:block;font-size:10px;color:#75816b;margin:10px 0;line-height:1.5}.std-field select,.std-field input:not([type=color]):not([type=range]):not([type=checkbox]){display:block;width:100%;padding:10px 9px;border:1px solid #d7dfcf;border-radius:7px;background:#fff;font-size:11px;color:#3e5338;margin-top:5px;min-height:36px}.std-field input[type=color]{display:block;width:100%;height:35px;border:1px solid #d7dfcf;border-radius:7px;padding:3px;margin-top:5px;background:white}.std-field input[type=range]{width:100%;accent-color:#365944}.std-range-head{display:flex;justify-content:space-between;font-size:10px;color:#718165;margin-top:11px}.std-range-head strong{font-size:12px;color:#365944;font-weight:600}
+.std-action{border:1px solid #d3dec8;background:#eff4e8;border-radius:7px;padding:9px 11px;font-size:10px;color:#46603c}.std-action.wide{width:100%;margin-top:10px}.std-action:hover{background:#e2ebd8}.std-action.dark{background:#365944;border-color:#365944;color:white}
+.std-toggle{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;font-weight:600;cursor:pointer;margin:3px 0 10px;padding:10px;background:#eef3e8;border-radius:9px}.std-toggle input{appearance:none;width:35px;height:21px;border-radius:12px;background:#bdc6b6;position:relative;flex-shrink:0;cursor:pointer;transition:background .15s}.std-toggle input:before{content:'';position:absolute;width:15px;height:15px;border-radius:50%;background:white;left:3px;top:3px;box-shadow:0 1px 4px #0002;transition:transform .15s}.std-toggle input:checked{background:#365944}.std-toggle input:checked:before{transform:translateX(14px)}
+.std-check{display:flex;gap:9px;align-items:center;font-size:11px;color:#63765a;margin:12px 0;line-height:1.5}.std-check input{accent-color:#365944}.std-disabled{opacity:.45;pointer-events:none}.std-inventory{margin:10px 0;border:1px solid #e0e5da;border-radius:9px;padding:5px 11px}.std-inventory>div{font-size:10px;line-height:1.5;padding:8px 0;border-bottom:1px solid #e6eadf}.std-inventory>div:last-child{border:0}.std-inventory b{font-weight:600;color:#49623f}.std-inventory small{display:block;color:#8a9480;font-size:9px;margin-top:3px}
+.std-footer{padding:12px 16px;border-top:1px solid #dce2d6;background:#f5f8ee;flex-shrink:0}.std-saved{font-size:9px;color:#859378;display:flex;gap:6px;align-items:center;margin-bottom:8px}.std-saved:before{content:'';width:5px;height:5px;border-radius:50%;background:#648257}.std-footer-row{display:grid;grid-template-columns:1fr 1fr;gap:7px}.std-footer-row button{font-size:11px;padding:10px}.std-summary-title{font-size:18px;font-weight:500;letter-spacing:-.3px;margin:19px 0 7px}.std-summary-group{margin-top:16px}.std-summary-group h4{font-size:10px;text-transform:uppercase;letter-spacing:1px;font-weight:600;color:#728466;margin:16px 0 7px}.std-summary-row{display:grid;grid-template-columns:1fr 1.5fr;gap:9px;font-size:10px;line-height:1.6;padding:8px 0;border-bottom:1px solid #e2e7dc}.std-summary-row span{color:#7e8a72}.std-summary-row b{font-weight:500;text-align:right;color:#41583b}.std-summary-row small{display:block;font-size:8px;color:#9b7945}
+.std-export-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:16px}.std-export-grid button{border:1px solid #d5dfcd;background:#f2f6ec;padding:11px 7px;border-radius:8px;font-size:10px}.std-reset{color:#9c6852!important;background:#f7f0e8!important}.std-fine{font-size:9px!important;line-height:1.6!important;color:#949e89!important;margin:12px 0 0!important}
+#stdCatalogDialog{width:min(800px,94vw);max-height:94dvh;border:1px solid #fff;border-radius:15px;background:#fafbf7;padding:15px;box-shadow:0 20px 70px #13271066;color:#36523d}#stdCatalogDialog::backdrop,#stdResetDialog::backdrop{background:#1f2e23a8;backdrop-filter:blur(3px)}.std-dialog-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:9px}.std-dialog-head h3{margin:0;font-size:13px;font-weight:500}.std-dialog-head button{border:0;background:#e9efe1;padding:8px 11px;border-radius:7px;font-size:11px}#stdCatalogImage{display:block;max-width:100%;max-height:75dvh;object-fit:contain;margin:auto}#stdCatalogCaption{font-size:10px;color:#7c8871;line-height:1.5;margin:10px 0 0}.std-dialog-pager{display:flex;gap:7px;align-items:center;justify-content:space-between;margin-top:10px}.std-dialog-pager button{border:1px solid #d6dfcc;border-radius:7px;background:#edf3e5;padding:8px 11px;font-size:10px}
+#stdResetDialog{width:min(390px,92vw);border:1px solid white;border-radius:16px;background:#fafbf7;padding:23px;color:#36523d}#stdResetDialog h3{font-size:19px;font-weight:500;margin:0 0 10px}#stdResetDialog p{font-size:12px;line-height:1.7;color:#7a866f}#stdResetDialog .std-two{margin-top:19px}
+body.std-open #interiorLaunch{z-index:2}
+@media(min-width:851px) and (max-width:1180px){body.std-open .compass,body.std-open #direction,body.std-open .help{display:none}body.std-open .tool{padding:9px 7px;font-size:10px}body.std-open .site-switch{font-size:9px}body.std-open .bottom{bottom:18px}body.std-open header .metrics{display:none!important}body.std-open #sunStamp{top:125px}body.std-open .interior-heading h2{font-size:17px}body.std-open .interior-heading .eyebrow{display:none}}
+@media(max-width:850px){
+ #standardsPanel{top:auto;left:0;right:0;bottom:0;width:100%;height:var(--std-sheet);border-radius:18px 18px 0 0}
+ body.std-open #view,body.std-open #measure,body.std-open #sunOverlay{width:100%!important;height:calc(100% - var(--std-sheet))!important}
+ body.std-open .bottom,body.std-open #interiorTools{left:50%!important;bottom:calc(var(--std-sheet) + 8px)!important;gap:0}
+ body.std-open .site-controls,body.std-open .help,body.std-open #direction,body.std-open .compass{display:none}
+ body.std-open .interior-heading{max-width:calc(100% - 126px)}body.std-open #walkPad{bottom:calc(var(--std-sheet) + 65px)!important}
+ #standardsToggle{padding:8px 9px;font-size:10px;gap:4px;min-height:34px}.std-head{padding:12px 17px 9px}.std-head .std-kicker{font-size:8px}.std-head h2{font-size:18px}.std-head p{display:none}.std-head-row{margin-top:3px}.std-close{width:25px;height:25px}
+ .std-tabs{margin:0 14px}.std-tabs button{padding:8px 4px;font-size:11px}.std-scroll{padding:0 16px 18px}.std-shortcuts{margin:9px 0}.std-footer{padding:8px 15px}.std-saved{margin-bottom:5px;font-size:8px}.std-footer-row button{padding:8px;font-size:10px}.std-section>summary{padding:13px 0}
+ .std-card.door img{height:62px}.std-notice{font-size:9px;padding:8px 10px}.std-card{font-size:9px}.std-section>summary small{max-width:calc(100vw - 115px)}body.std-open header h1{font-size:23px}body.std-open #interiorLaunch{top:139px;font-size:10px;padding:8px 10px}
+}
+@media(max-width:530px){#standardsToggle .std-toggle-word{display:none}#standardsToggle{width:34px;justify-content:center;padding:0}.topright{gap:5px!important}.topright #infoButton{display:none}.std-head .std-kicker{display:none}.std-head-row{margin:0}.std-head{padding:11px 16px 8px}.std-head h2{font-size:17px}body.std-open #sunStamp{top:98px;font-size:9px}body.std-open .interior-heading .eyebrow{font-size:6px;letter-spacing:.6px}}
+@media(max-height:550px) and (max-width:850px){:root{--std-side:340px}#standardsPanel{left:auto;right:8px;top:60px;bottom:8px;width:324px;height:auto;border-radius:12px}body.std-open #view,body.std-open #sunOverlay{width:calc(100% - 340px)!important;height:100%!important}body.std-open .bottom,body.std-open #interiorTools{left:calc((100% - 340px)/2)!important;bottom:10px!important}.std-head{padding:9px 14px}.std-head h2{font-size:16px}.std-footer{display:none}body.std-open #walkPad{bottom:60px!important}body.std-open .interior-heading{max-width:calc(100% - 370px)}}
+
+@media(max-width:850px){body.std-open header .sub,body.std-open #sunStamp,body.std-open #interiorLaunch{display:none!important}body.std-open header h1{font-size:19px;line-height:1.2;max-width:calc(100vw - 116px);margin-top:9px}body.std-open header .eyebrow{font-size:7px;letter-spacing:1.3px}body.std-open #toast{top:73px;max-width:90%;font-size:10px}}
+
diff --git a/locenice/locenice/css/web.css b/locenice/locenice/css/web.css
new file mode 100644
index 0000000..5dddcfb
--- /dev/null
+++ b/locenice/locenice/css/web.css
@@ -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}}
diff --git a/locenice/locenice/data/app-config.json b/locenice/locenice/data/app-config.json
new file mode 100644
index 0000000..5642b4c
--- /dev/null
+++ b/locenice/locenice/data/app-config.json
@@ -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"
+}
\ No newline at end of file
diff --git a/locenice/locenice/data/assets.json b/locenice/locenice/data/assets.json
new file mode 100644
index 0000000..9ebe011
--- /dev/null
+++ b/locenice/locenice/data/assets.json
@@ -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"
+}
\ No newline at end of file
diff --git a/locenice/locenice/data/catalog.json b/locenice/locenice/data/catalog.json
new file mode 100644
index 0000000..ecd04de
--- /dev/null
+++ b/locenice/locenice/data/catalog.json
@@ -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": ""
+ }
+}
diff --git a/locenice/locenice/data/references.json b/locenice/locenice/data/references.json
new file mode 100644
index 0000000..6bd870f
--- /dev/null
+++ b/locenice/locenice/data/references.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/locenice/locenice/data/scene.json b/locenice/locenice/data/scene.json
new file mode 100644
index 0000000..4a3c1ec
--- /dev/null
+++ b/locenice/locenice/data/scene.json
@@ -0,0 +1,1727 @@
+{
+ "siteStudy": {
+ "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
+ }
+ },
+ "vespaProp": {
+ "description": "Original stylized classic Vespa-style scooter in matte black; approximate visualization prop, not manufacturer CAD.",
+ "positionMetres": [
+ -8.72,
+ -0.48,
+ -3.15
+ ],
+ "yawDegrees": -94,
+ "surface": "Site_Entrance_paving",
+ "surfaceElevation": -0.48,
+ "bounds": {
+ "min": [
+ -9.109809535077924,
+ -0.48,
+ -4.111515026914823
+ ],
+ "max": [
+ -8.365626753584094,
+ 0.907,
+ -2.2210983876797625
+ ]
+ },
+ "visibleWith": "parking",
+ "houseAndTerrainUnchanged": true
+ },
+ "hardiePlank": {
+ "product": "HardiePlank struktura dřeva",
+ "manufacturer": "James Hardie",
+ "defaultColor": "oriskove-hneda",
+ "selectedColor": {
+ "id": "oriskove-hneda",
+ "name": "Oříškově hnědá",
+ "code": "5771220",
+ "hex": "#776e65",
+ "texture": 4
+ },
+ "palette": [
+ {
+ "id": "snehove-bila",
+ "name": "Sněhově bílá",
+ "code": "5671220",
+ "hex": "#ffffff",
+ "texture": 0
+ },
+ {
+ "id": "sterkove-seda",
+ "name": "Štěrkově šedá",
+ "code": "5751220",
+ "hex": "#d0c8bb",
+ "texture": 1
+ },
+ {
+ "id": "mlhove-seda",
+ "name": "Mlhově šedá",
+ "code": "5841220",
+ "hex": "#b9c1c3",
+ "texture": 2
+ },
+ {
+ "id": "ocelove-seda",
+ "name": "Ocelově šedá",
+ "code": "5851220",
+ "hex": "#758896",
+ "texture": 3
+ },
+ {
+ "id": "oriskove-hneda",
+ "name": "Oříškově hnědá",
+ "code": "5771220",
+ "hex": "#776e65",
+ "texture": 4
+ },
+ {
+ "id": "espresso",
+ "name": "Espresso",
+ "code": "5460122",
+ "hex": "#241510",
+ "texture": 5
+ },
+ {
+ "id": "antracitove-seda",
+ "name": "Antracitově šedá",
+ "code": "5420122",
+ "hex": "#4c5053",
+ "texture": 6
+ }
+ ],
+ "boardMaterial": 56,
+ "backingMaterial": 57,
+ "boardNodes": [
+ 14,
+ 15,
+ 34,
+ 35,
+ 37,
+ 38,
+ 39,
+ 41,
+ 42,
+ 43
+ ],
+ "backingNodes": [
+ 13,
+ 33,
+ 36,
+ 40
+ ],
+ "sourceProductURL": "https://www.jameshardie.cz/cz/hardie-plank",
+ "paletteSource": "User supplied swatch image, chip medians; not calibrated.",
+ "geometryStatus": "Original geometry and horizontal board layout preserved. Nominal product size is a specification, not verification of the source geometry or installation.",
+ "textureStatus": "Illustrative embossed painted-cedar color and normal maps, guided by the supplied photo; not a manufacturer scan.",
+ "scope": "Vertical facade cladding and associated backing at entrance and terrace only. Deck fascia, deck surface, stairs and all other materials unchanged."
+ },
+ "interiorStudy": {
+ "version": 2,
+ "defaultEnabled": false,
+ "originalNodeCount": 157,
+ "originalMeshCount": 156,
+ "originalMaterialCount": 58,
+ "sourceBinarySHA256": "cbfbfd1282bb6451f5e4a4b616902b176c6c8c216f2c416234bb822c4a4394fb",
+ "originalBinaryLength": 8803052,
+ "originalGeometryUnchanged": true,
+ "primarySource": "D.1.1.2.1.1 Pudorys 1NP_pdfA.PDF, sheet dated 21.09.2026",
+ "secondarySources": [
+ "D.1.1.2.3.1 Pohledy_pdfA.PDF",
+ "pohled_ven.jpeg",
+ "PHOTO-2026-05-26-20-38-11.jpg",
+ "mrvz1_uprava.jpg",
+ "mrvz2.jpeg"
+ ],
+ "coordinateSystem": "Existing model: +X south gable, +Z entrance/west facade, Y vertical; finished floor Y=0.",
+ "sourcePriority": "Existing exterior dimensions and openings retained; internal partition layout from dimensioned plan. Kitchen U-shape and finishes adapted from user images to room 108 without relocating exterior openings.",
+ "limitations": [
+ "Illustrative furniture dimensions, exact cabinet modules and appliance models not provided.",
+ "Flat ceiling height 2.60 m applies only to the other rooms. Room 108 has a continuous pitched ceiling following the original 23-degree roof and 4.82 m interior ridge. Timber sections are illustrative; not structural design.",
+ "Room area labels copied from plan, not independently measured as-built floor areas.",
+ "Open interior doors are for navigation; no construction changes implied.",
+ "Existing site model outside windows, not a surveyed distant landscape or stitched photo panorama.",
+ "Interior lighting is an illustrative fill light, not a daylight calculation."
+ ],
+ "cutHeight": 1.12,
+ "footprint": [
+ [
+ -7.31,
+ -4.335
+ ],
+ [
+ 3.362,
+ -4.335
+ ],
+ [
+ 3.362,
+ -0.337
+ ],
+ [
+ 7.31,
+ -0.337
+ ],
+ [
+ 7.31,
+ 4.335
+ ],
+ [
+ -0.762,
+ 4.335
+ ],
+ [
+ -0.762,
+ 3.535
+ ],
+ [
+ -7.31,
+ 3.535
+ ]
+ ],
+ "rooms": [
+ {
+ "id": "101",
+ "name": "Zadveri",
+ "label": "Zádveří",
+ "area": "5,20",
+ "poly": [
+ [
+ -4.41,
+ 0.725
+ ],
+ [
+ -2.56,
+ 0.725
+ ],
+ [
+ -2.56,
+ 3.535
+ ],
+ [
+ -4.41,
+ 3.535
+ ]
+ ],
+ "anchor": [
+ -3.4,
+ 2.12
+ ],
+ "eye": [
+ -3.25,
+ 1.62,
+ 2.72
+ ],
+ "look": [
+ -3.05,
+ 1.5,
+ 0.1
+ ]
+ },
+ {
+ "id": "102",
+ "name": "Chodba",
+ "label": "Chodba",
+ "area": "6,36",
+ "poly": [
+ [
+ -4.41,
+ -0.335
+ ],
+ [
+ -4.11,
+ -0.335
+ ],
+ [
+ -4.11,
+ -0.485
+ ],
+ [
+ -3.96,
+ -0.485
+ ],
+ [
+ -3.96,
+ -1.555
+ ],
+ [
+ -2.56,
+ -1.555
+ ],
+ [
+ -2.56,
+ -0.335
+ ],
+ [
+ -0.91,
+ -0.335
+ ],
+ [
+ -0.91,
+ 0.725
+ ],
+ [
+ -4.41,
+ 0.725
+ ]
+ ],
+ "anchor": [
+ -3.1,
+ 0.13
+ ],
+ "eye": [
+ -3.1,
+ 1.62,
+ 0.25
+ ],
+ "look": [
+ -0.4,
+ 1.5,
+ 0.22
+ ]
+ },
+ {
+ "id": "103",
+ "name": "Pokoj 103",
+ "label": "Pokoj 103",
+ "area": "10,62",
+ "poly": [
+ [
+ -7.31,
+ -0.335
+ ],
+ [
+ -4.56,
+ -0.335
+ ],
+ [
+ -4.56,
+ 3.535
+ ],
+ [
+ -7.31,
+ 3.535
+ ]
+ ],
+ "anchor": [
+ -5.6,
+ 1.64
+ ],
+ "eye": [
+ -5.64,
+ 1.62,
+ 0.95
+ ],
+ "look": [
+ -5.1,
+ 1.25,
+ 3.1
+ ]
+ },
+ {
+ "id": "104",
+ "name": "Pokoj 104",
+ "label": "Pokoj 104",
+ "area": "12,35",
+ "poly": [
+ [
+ -7.31,
+ -4.335
+ ],
+ [
+ -4.11,
+ -4.335
+ ],
+ [
+ -4.11,
+ -0.485
+ ],
+ [
+ -7.31,
+ -0.485
+ ]
+ ],
+ "anchor": [
+ -5.56,
+ -2.0
+ ],
+ "eye": [
+ -5.5,
+ 1.62,
+ -1.3
+ ],
+ "look": [
+ -5.2,
+ 1.2,
+ -3.9
+ ]
+ },
+ {
+ "id": "105",
+ "name": "Koupelna",
+ "label": "Koupelna",
+ "area": "7,72",
+ "poly": [
+ [
+ -3.96,
+ -4.335
+ ],
+ [
+ -0.91,
+ -4.335
+ ],
+ [
+ -0.91,
+ -1.705
+ ],
+ [
+ -3.96,
+ -1.705
+ ]
+ ],
+ "anchor": [
+ -2.68,
+ -2.78
+ ],
+ "eye": [
+ -2.96,
+ 1.62,
+ -2.47
+ ],
+ "look": [
+ -1.4,
+ 1.05,
+ -2.9
+ ]
+ },
+ {
+ "id": "106",
+ "name": "WC",
+ "label": "WC",
+ "area": "1,40",
+ "poly": [
+ [
+ -2.41,
+ -1.555
+ ],
+ [
+ -1.06,
+ -1.555
+ ],
+ [
+ -1.06,
+ -0.485
+ ],
+ [
+ -2.41,
+ -0.485
+ ]
+ ],
+ "anchor": [
+ -1.85,
+ -1.0
+ ],
+ "eye": [
+ -2.04,
+ 1.62,
+ -1.015
+ ],
+ "look": [
+ -1.2,
+ 0.9,
+ -1.01
+ ]
+ },
+ {
+ "id": "107",
+ "name": "Loznice",
+ "label": "Ložnice",
+ "area": "15,86",
+ "poly": [
+ [
+ -0.76,
+ -4.335
+ ],
+ [
+ 3.362,
+ -4.335
+ ],
+ [
+ 3.362,
+ -0.485
+ ],
+ [
+ -0.76,
+ -0.485
+ ]
+ ],
+ "anchor": [
+ 1.05,
+ -3.5
+ ],
+ "eye": [
+ 0.85,
+ 1.62,
+ -1.2
+ ],
+ "look": [
+ 2.6,
+ 1.0,
+ -2.6
+ ]
+ },
+ {
+ "id": "108",
+ "name": "Obyvaci pokoj + KK",
+ "label": "Obývací pokoj + KK",
+ "area": "37,69",
+ "poly": [
+ [
+ -0.76,
+ -0.335
+ ],
+ [
+ 7.31,
+ -0.335
+ ],
+ [
+ 7.31,
+ 4.335
+ ],
+ [
+ -0.76,
+ 4.335
+ ]
+ ],
+ "anchor": [
+ 3.3,
+ 0.5
+ ],
+ "eye": [
+ 3.8,
+ 1.62,
+ 1.05
+ ],
+ "look": [
+ 0.5,
+ 1.0,
+ 3.1
+ ]
+ },
+ {
+ "id": "109",
+ "name": "Technicka mistnost",
+ "label": "Technická místnost",
+ "area": "3,09",
+ "poly": [
+ [
+ -2.41,
+ 1.475
+ ],
+ [
+ -0.91,
+ 1.475
+ ],
+ [
+ -0.91,
+ 3.535
+ ],
+ [
+ -2.41,
+ 3.535
+ ]
+ ],
+ "anchor": [
+ -1.75,
+ 2.65
+ ],
+ "eye": [
+ -1.85,
+ 1.62,
+ 2.25
+ ],
+ "look": [
+ -1.35,
+ 1.0,
+ 3.15
+ ]
+ }
+ ],
+ "wallColliders": [
+ {
+ "lo": [
+ -4.11,
+ -4.335
+ ],
+ "hi": [
+ -3.96,
+ -1.4
+ ]
+ },
+ {
+ "lo": [
+ -4.11,
+ -0.6
+ ],
+ "hi": [
+ -3.96,
+ -0.485
+ ]
+ },
+ {
+ "lo": [
+ -7.31,
+ -0.485
+ ],
+ "hi": [
+ -4.035,
+ -0.33499999999999996
+ ]
+ },
+ {
+ "lo": [
+ -3.96,
+ -1.7049999999999998
+ ],
+ "hi": [
+ -3.82,
+ -1.555
+ ]
+ },
+ {
+ "lo": [
+ -3.12,
+ -1.7049999999999998
+ ],
+ "hi": [
+ -0.91,
+ -1.555
+ ]
+ },
+ {
+ "lo": [
+ -2.56,
+ -1.555
+ ],
+ "hi": [
+ -2.4099999999999997,
+ -1.5
+ ]
+ },
+ {
+ "lo": [
+ -2.56,
+ -0.8
+ ],
+ "hi": [
+ -2.4099999999999997,
+ -0.485
+ ]
+ },
+ {
+ "lo": [
+ -2.56,
+ -0.485
+ ],
+ "hi": [
+ -0.91,
+ -0.33499999999999996
+ ]
+ },
+ {
+ "lo": [
+ -0.9099999999999999,
+ -4.335
+ ],
+ "hi": [
+ -0.76,
+ -0.225
+ ]
+ },
+ {
+ "lo": [
+ -0.9099999999999999,
+ 0.575
+ ],
+ "hi": [
+ -0.76,
+ 3.535
+ ]
+ },
+ {
+ "lo": [
+ -0.76,
+ -0.485
+ ],
+ "hi": [
+ 0.29,
+ -0.33499999999999996
+ ]
+ },
+ {
+ "lo": [
+ 1.09,
+ -0.485
+ ],
+ "hi": [
+ 3.362,
+ -0.33499999999999996
+ ]
+ },
+ {
+ "lo": [
+ -4.5600000000000005,
+ -0.335
+ ],
+ "hi": [
+ -4.41,
+ -0.13
+ ]
+ },
+ {
+ "lo": [
+ -4.5600000000000005,
+ 0.67
+ ],
+ "hi": [
+ -4.41,
+ 3.535
+ ]
+ },
+ {
+ "lo": [
+ -2.56,
+ 0.725
+ ],
+ "hi": [
+ -2.4099999999999997,
+ 1.965
+ ]
+ },
+ {
+ "lo": [
+ -2.56,
+ 2.665
+ ],
+ "hi": [
+ -2.4099999999999997,
+ 3.535
+ ]
+ },
+ {
+ "lo": [
+ -2.41,
+ 1.325
+ ],
+ "hi": [
+ -0.91,
+ 1.4749999999999999
+ ]
+ }
+ ],
+ "furnitureColliders": [
+ {
+ "lo": [
+ -0.76,
+ 1.15
+ ],
+ "hi": [
+ -0.075,
+ 4.33
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ -0.075,
+ 3.66
+ ],
+ "hi": [
+ 2.05,
+ 4.33
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ 1.22,
+ 1.37
+ ],
+ "hi": [
+ 2.05,
+ 3.68
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ 2.65,
+ 1.62
+ ],
+ "hi": [
+ 4.28,
+ 2.53
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ 6.3,
+ 1.29
+ ],
+ "hi": [
+ 7.1,
+ 2.09
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ 6.3,
+ 2.45
+ ],
+ "hi": [
+ 7.1,
+ 3.25
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ 4.45,
+ 1.15
+ ],
+ "hi": [
+ 5.45,
+ 3.42
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ 5.2,
+ 2.5
+ ],
+ "hi": [
+ 6.0,
+ 3.35
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ 5.06,
+ 3.92
+ ],
+ "hi": [
+ 7.14,
+ 4.3
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ 6.68,
+ -0.18
+ ],
+ "hi": [
+ 7.24,
+ 0.42
+ ],
+ "room": "108"
+ },
+ {
+ "lo": [
+ 1.38,
+ -3.8
+ ],
+ "hi": [
+ 3.32,
+ -1.97
+ ],
+ "room": "107"
+ },
+ {
+ "lo": [
+ -0.71,
+ -3.74
+ ],
+ "hi": [
+ -0.05,
+ -1.76
+ ],
+ "room": "107"
+ },
+ {
+ "lo": [
+ -7.2,
+ 0.0
+ ],
+ "hi": [
+ -6.29,
+ 1.99
+ ],
+ "room": "103"
+ },
+ {
+ "lo": [
+ -7.2,
+ -2.58
+ ],
+ "hi": [
+ -6.29,
+ -0.5900000000000001
+ ],
+ "room": "104"
+ },
+ {
+ "lo": [
+ -7.21,
+ 2.29
+ ],
+ "hi": [
+ -6.6,
+ 3.48
+ ],
+ "room": "103"
+ },
+ {
+ "lo": [
+ -5.18,
+ 2.1
+ ],
+ "hi": [
+ -4.64,
+ 3.48
+ ],
+ "room": "103"
+ },
+ {
+ "lo": [
+ -7.21,
+ -4.27
+ ],
+ "hi": [
+ -6.6,
+ -2.97
+ ],
+ "room": "104"
+ },
+ {
+ "lo": [
+ -4.75,
+ -4.25
+ ],
+ "hi": [
+ -4.16,
+ -2.92
+ ],
+ "room": "104"
+ },
+ {
+ "lo": [
+ -4.4,
+ 0.86
+ ],
+ "hi": [
+ -3.73,
+ 2.56
+ ],
+ "room": "101"
+ },
+ {
+ "lo": [
+ -2.4,
+ 0.72
+ ],
+ "hi": [
+ -0.92,
+ 1.33
+ ],
+ "room": "102"
+ },
+ {
+ "lo": [
+ -1.9,
+ -3.48
+ ],
+ "hi": [
+ -1.07,
+ -1.73
+ ],
+ "room": "105"
+ },
+ {
+ "lo": [
+ -2.9,
+ -2.16
+ ],
+ "hi": [
+ -2.05,
+ -1.72
+ ],
+ "room": "105"
+ },
+ {
+ "lo": [
+ -3.94,
+ -4.3
+ ],
+ "hi": [
+ -3.0,
+ -3.36
+ ],
+ "room": "105"
+ },
+ {
+ "lo": [
+ -1.61,
+ 2.75
+ ],
+ "hi": [
+ -0.94,
+ 3.43
+ ],
+ "room": "109"
+ },
+ {
+ "lo": [
+ -1.59,
+ 1.56
+ ],
+ "hi": [
+ -0.97,
+ 2.2
+ ],
+ "room": "109"
+ }
+ ],
+ "cutOriginalNodes": [
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 21,
+ 22,
+ 23,
+ 24,
+ 25,
+ 26,
+ 27,
+ 28,
+ 29,
+ 30,
+ 31,
+ 32,
+ 33,
+ 34,
+ 35,
+ 36,
+ 37,
+ 38,
+ 39,
+ 40,
+ 41,
+ 42,
+ 43,
+ 44,
+ 45,
+ 46,
+ 47,
+ 48,
+ 49,
+ 50,
+ 51,
+ 52,
+ 53,
+ 315
+ ],
+ "roofNodes": [
+ 54,
+ 55,
+ 56,
+ 57,
+ 58,
+ 59,
+ 60,
+ 61,
+ 62,
+ 63,
+ 64,
+ 65,
+ 66,
+ 67,
+ 68,
+ 69,
+ 70,
+ 71,
+ 72,
+ 73,
+ 74,
+ 75,
+ 76
+ ],
+ "presets": [
+ {
+ "id": "kitchen",
+ "label": "Kuchyně",
+ "eye": [
+ 3.78,
+ 1.62,
+ 0.9
+ ],
+ "look": [
+ 0.45,
+ 1.05,
+ 3.0
+ ]
+ },
+ {
+ "id": "outlook",
+ "label": "Z kuchyně ven",
+ "eye": [
+ 0.62,
+ 1.62,
+ 2.7
+ ],
+ "look": [
+ 7.26,
+ 1.53,
+ 1.05
+ ]
+ },
+ {
+ "id": "living",
+ "label": "Obývací prostor",
+ "eye": [
+ 3.7,
+ 1.62,
+ 0.54
+ ],
+ "look": [
+ 6.3,
+ 1.2,
+ 2.55
+ ]
+ },
+ {
+ "id": "vault",
+ "label": "Trámy a vysoké okno",
+ "eye": [
+ 1.0,
+ 1.7,
+ 3.2
+ ],
+ "look": [
+ 6.5,
+ 2.85,
+ 0.3
+ ],
+ "fov": 70
+ },
+ {
+ "id": "kitchenVault",
+ "label": "Vysoký strop nad kuchyní",
+ "eye": [
+ 3.8,
+ 1.62,
+ 1.05
+ ],
+ "look": [
+ 0.05,
+ 3.05,
+ 2.7
+ ]
+ }
+ ],
+ "userCorrections": [
+ "Wine fridge at the wall-side end of the peninsula.",
+ "Continuous high ceiling over the entire kitchen/living room; no low kitchen bulkhead.",
+ "Interior wall-paint picker independent of exterior stucco and ceilings.",
+ "Timber frames revised mainly from pohled_ven.jpeg: stronger ties and posts, longitudinal knee braces and purlin; separate natural-oak map with lengthwise UVs."
+ ],
+ "wallPaint": {
+ "materialIndices": [
+ 58
+ ],
+ "defaultHex": "#eeeae1",
+ "selectedHex": "#eeeae1",
+ "scope": "All interior plaster walls and reveals only; not ceilings, timber, furniture, tiles or facade."
+ },
+ "timberReference": {
+ "source": "pohled_ven.jpeg",
+ "secondarySource": "PHOTO-2026-05-26-20-38-11.jpg",
+ "node": 226,
+ "framePositionsX": [
+ 0.95,
+ 3.25,
+ 5.55
+ ],
+ "exactStructuralDimensionsVerified": false,
+ "note": "Visual proportions and joint arrangement are inferred from the supplied images; not construction or structural documentation."
+ }
+ },
+ "modelRevision": "06 - ALLSTAV 2025 configurator",
+ "allstavStandards": {
+ "version": 1,
+ "catalog": "allstav_standardy_2025.pdf",
+ "catalogDate": "2025-09",
+ "materials": {
+ "entry": [
+ 84
+ ],
+ "extSill": [
+ 85
+ ],
+ "gutter": [
+ 86
+ ],
+ "roof": [
+ 14,
+ 15,
+ 16,
+ 17,
+ 18,
+ 19
+ ],
+ "entryGlass": [
+ 87
+ ],
+ "hardware": [
+ 88
+ ],
+ "blinds": [
+ 89
+ ],
+ "floor101": [
+ 90
+ ],
+ "floor102": [
+ 91
+ ],
+ "floor103": [
+ 92
+ ],
+ "floor104": [
+ 93
+ ],
+ "floor105": [
+ 94
+ ],
+ "floor106": [
+ 95
+ ],
+ "floor107": [
+ 96
+ ],
+ "floor108": [
+ 97
+ ],
+ "floor109": [
+ 98
+ ],
+ "intSill": [
+ 99
+ ],
+ "intDoor": [
+ 100
+ ],
+ "worktop": [
+ 101
+ ],
+ "kitchenFront": [
+ 64
+ ],
+ "kitchenBody": [
+ 65
+ ],
+ "backsplash": [
+ 102
+ ],
+ "wallTile105": [
+ 103
+ ],
+ "wallTile106": [
+ 104
+ ],
+ "ceramic": [
+ 105
+ ],
+ "recess": [
+ 106
+ ],
+ "showerGlass": [
+ 107
+ ],
+ "faucet": [
+ 108
+ ],
+ "whiteTechnical": [
+ 109
+ ]
+ },
+ "textures": {
+ "lam1": 13,
+ "lam2": 14,
+ "lam3": 15,
+ "lam4": 16,
+ "lam5": 17,
+ "lam6": 18,
+ "lam7": 19,
+ "lam8": 20,
+ "lam9": 21,
+ "tile1": 22,
+ "tile2": 23,
+ "tile3": 24,
+ "tile4": 25,
+ "tile5": 26,
+ "tile6": 27,
+ "tile7": 28,
+ "tile8": 29,
+ "tile9": 30,
+ "prum": 31
+ },
+ "windows": [
+ {
+ "node": 4,
+ "name": "05_Frames_East_1__frame",
+ "axis": 2,
+ "sign": -1,
+ "low": [
+ -6.570000171661377,
+ 0.949999988079071,
+ -4.639999866485596
+ ],
+ "high": [
+ -4.730000019073486,
+ 2.069999933242798,
+ -4.53000020980835
+ ]
+ },
+ {
+ "node": 8,
+ "name": "05_Frames_East_2__frame",
+ "axis": 2,
+ "sign": -1,
+ "low": [
+ -2.630000114440918,
+ 0.949999988079071,
+ -4.639999866485596
+ ],
+ "high": [
+ -1.8600000143051147,
+ 2.049999952316284,
+ -4.53000020980835
+ ]
+ },
+ {
+ "node": 10,
+ "name": "05_Frames_East_3__frame",
+ "axis": 2,
+ "sign": -1,
+ "low": [
+ 0.7799999713897705,
+ 0.949999988079071,
+ -4.639999866485596
+ ],
+ "high": [
+ 2.619999885559082,
+ 2.069999933242798,
+ -4.53000020980835
+ ]
+ },
+ {
+ "node": 16,
+ "name": "05_Frames_West_entrance_1__frame",
+ "axis": 2,
+ "sign": 1,
+ "low": [
+ -6.550000190734863,
+ 1.149999976158142,
+ 3.7230000495910645
+ ],
+ "high": [
+ -4.730000019073486,
+ 2.059999942779541,
+ 3.8329999446868896
+ ]
+ },
+ {
+ "node": 23,
+ "name": "05_Frames_West_entrance_3__frame",
+ "axis": 2,
+ "sign": 1,
+ "low": [
+ -2.3450000286102295,
+ 1.149999976158142,
+ 3.7230000495910645
+ ],
+ "high": [
+ -1.5750000476837158,
+ 2.059999942779541,
+ 3.8329999446868896
+ ]
+ },
+ {
+ "node": 27,
+ "name": "05_Frames_West_main_1__frame",
+ "axis": 2,
+ "sign": 1,
+ "low": [
+ -0.3149999976158142,
+ 1.2999999523162842,
+ 4.53000020980835
+ ],
+ "high": [
+ 1.4249999523162842,
+ 2.069999933242798,
+ 4.639999866485596
+ ]
+ },
+ {
+ "node": 29,
+ "name": "05_Frames_West_main_2__frame",
+ "axis": 2,
+ "sign": 1,
+ "low": [
+ 2.3350000381469727,
+ -0.009999999776482582,
+ 4.53000020980835
+ ],
+ "high": [
+ 3.115000009536743,
+ 2.065000057220459,
+ 4.639999866485596
+ ]
+ },
+ {
+ "node": 44,
+ "name": "05_Frames_Terrace_back_1__frame",
+ "axis": 2,
+ "sign": -1,
+ "low": [
+ 4.619999885559082,
+ -0.009999999776482582,
+ -0.6349999904632568
+ ],
+ "high": [
+ 6.460000038146973,
+ 2.069999933242798,
+ -0.5249999761581421
+ ]
+ },
+ {
+ "node": 48,
+ "name": "05_Frames_South_1__frame",
+ "axis": 0,
+ "sign": 1,
+ "low": [
+ 7.505000114440918,
+ -0.009999999776482582,
+ 0.6819999814033508
+ ],
+ "high": [
+ 7.614999771118164,
+ 2.069999933242798,
+ 3.322000026702881
+ ]
+ }
+ ],
+ "triangleExcludedNode": 51,
+ "sourceHierarchy": [
+ "explicit user choices",
+ "project documentation",
+ "supplied ALLSTAV 2025 brochure"
+ ],
+ "scopeNotes": [
+ "Kitchen selections are visualization only, not brochure standards.",
+ "Unlabelled floor/tile samples retain numerical visual indices, not invented product codes.",
+ "Sliding doors, ventilation and heating variants are specifications only; hidden services are not redesigned.",
+ "Door and sanitary geometry is illustrative, project opening/footprint is retained."
+ ]
+ },
+ "webRevision": "locenice-web-01",
+ "webTextureLibrarySize": 32
+}
\ No newline at end of file
diff --git a/locenice/locenice/data/site.json b/locenice/locenice/data/site.json
new file mode 100644
index 0000000..50cc380
--- /dev/null
+++ b/locenice/locenice/data/site.json
@@ -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."
+}
\ No newline at end of file
diff --git a/locenice/locenice/index.php b/locenice/locenice/index.php
new file mode 100644
index 0000000..b9a2a8c
--- /dev/null
+++ b/locenice/locenice/index.php
@@ -0,0 +1,10 @@
+ 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 += `${d.t} `; } $('#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='
Stahnout zdrojovy GLB Zkusit lokalni renderer ';
+ $('#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 = `45\u00b0 `;
+ }
+ function updateSolarGraph() { const p = solar.pos, x = 8 + 288 * solar.minute / solar.length, y = 70 - clamp(p.elevation, 0, 90) * .65; $('#sunGraph').innerHTML = graphBase + ` `; }
+ 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 ? '❚❚ Pozastavit' : '▶ 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 = ` `;
+ 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 += `${hour}:00 `; }
+ 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 += ` `; } }
+ 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 = `${$('#sunOverlay').innerHTML} `, 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=' ';
+ svg+=' ';
+ svg+='Terasa ';
+ for(const r of interior.data.rooms){
+ const path=r.poly.map(p=>interiorMapPoint(...p).join(',')).join(' '),a=interiorMapPoint(...r.anchor);
+ svg+=`${r.id} `;
+ }
+ for(const b of interior.data.wallColliders){let a=interiorMapPoint(...b.lo),c=interiorMapPoint(...b.hi);svg+=` `;}
+ svg+=' ';
+ svg+='1. NP · orientace podle půdorysu ';
+ $('#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=`${r.id} ${r.label} ${r.area} m² `;
+ 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.clientXr.right||e.clientYr.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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>`Katalog \u00b7 str. ${p} `;
+ function stdCards(key,options=STD_OPTIONS[key],cols='',kind='',tier='standard'){
+ return `${options.map(o=>`
${o[4]?` `:o[2]?` `:''}${stdEsc(o[1])} ${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'} `).join('')}
`;
+ }
+ 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)=>`${n} ${title} ${body}
`;
+ const stdSelect=(key,label,options)=>`${label}${options.map(([v,t])=>`${t} `).join('')} `;
+ const stdCheck=(key,label)=>` ${label} `;
+ const stdColor=(key,label)=>`${label} `;
+ const stdNotice=(s,t='')=>`${s}
`;
+ 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)||vmax)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='Pohled na vstup Terasa a \u017ealuzie Cel\u00fd pozemek
';
+ ext+=stdSection('entry','01','Vchodov\u00e9 dve\u0159e',`Tvar k\u0159\u00eddla ${stdSource(12)}
${stdCards('entryModel',undefined,'','door')}Odst\u00edn dve\u0159\u00ed
${stdCards('entryColor')}${stdNotice('V\u00fdchoz\u00ed: 3 skla, RAL 7016. 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')}Zobrazit v\u00e1\u0161 referen\u010dn\u00ed obr\u00e1zek 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.
`,true);
+ ext+=stdSection('extSill','02','Venkovn\u00ed parapety',`Ta\u017een\u00fd hlin\u00edk ${stdSource(13)}
${stdCards('extSill',undefined,'cols3')}Volba m\u011bn\u00ed parapety. N\u00e1\u0161lapn\u00e9 prahy u terasov\u00fdch dve\u0159\u00ed z\u016fst\u00e1vaj\u00ed samostatn\u00e9.
`);
+ ext+=stdSection('gutter','03','Okapy a svody',`Lindab ${stdSource(13)}
${stdCards('gutter')}${stdNotice('M\u011bn\u00ed se jen okapov\u00fd syst\u00e9m , nikoli lemov\u00e1n\u00ed st\u0159echy, kom\u00edn nebo z\u00e1bradl\u00ed.')}Zkratky a k\u00f3dy jsou p\u0159evzat\u00e9 z katalogu. CERN 015 = v\u00fdchoz\u00ed volba.
`);
+ ext+=stdSection('roof','04','St\u0159e\u0161n\u00ed ta\u0161ky',`Benders Palema Candor ${stdSource(14)}
${stdCards('roof',undefined,'cols3')}Betonov\u00e1 ta\u0161ka, vysoce leskl\u00e1 povrchov\u00e1 \u00faprava. N\u00e1hled zachov\u00e1v\u00e1 geometrii krytiny p\u016fvodn\u00edho modelu.
`);
+ ext+=stdSection('blinds','05','Venkovn\u00ed \u017ealuzie',`Osadit venkovn\u00ed \u017ealuzie Climax Zetta 70 \u00b7 p\u0159\u00edplatek ${stdSource(15)}
${stdNotice('9 obd\u00e9ln\u00edkov\u00fdch otvor\u016f. 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.')}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.
`);
+ ext+=stdNotice('Fas\u00e1da James Hardie Plank a okol\u00ed domu z\u016fst\u00e1vaj\u00ed v p\u016fvodn\u00edch ovl\u00e1dac\u00edch prvc\u00edch. Otev\u0159\u00edt materi\u00e1ly fas\u00e1dy ');
+ $('#std-exterior').innerHTML=ext;
+ let int='P\u016fdorys 3D \u0159ez Kuchy\u0148 Koupelna
';
+ int+=stdSection('floors','01','Podlahy po m\u00edstnostech',`KronoSwiss / keramick\u00e1 dla\u017eba ${stdSource(18)}
M\u00edstnost${STD_ROOMS.map(r=>`${r[0]} \u00b7 ${r[1]} \u00b7 ${r[2]} m\u00b2 `).join('')} KrytinaLamin\u00e1t KronoSwiss Keramick\u00e1 dla\u017eba N\u00e1hled m\u00edstnostiP\u0159ej\u00edt do m\u00edstnosti
Stejn\u00fd vzorek pro m\u00edstnosti se stejnou krytinou
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.
`,true);
+ int+=stdSection('tiles','02','Obklady koupelny a WC',`Rektifikovan\u00e9 obklady ${stdSource(18)}
M\u00edstnost105 \u00b7 Koupelna 106 \u00b7 WC ${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']])}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.
`);
+ int+=stdSection('doors','03','Interi\u00e9rov\u00e9 dve\u0159e a kov\u00e1n\u00ed',`Pr\u00fcm CPL ${stdSource(19)}
${stdCards('intDoor',[['sample','Fotografie z katalogu','',false,'prum'],['custom','Vlastn\u00ed odst\u00edn','#c3aa85']], 'cols2','wood','catalog')}${stdColor('intDoorColor','Vlastn\u00ed barva \u00b7 pouze vizualizace')}
P\u0159esn\u00fd dekor / k\u00f3d po konzultaci ${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']])}Brou\u0161en\u00fd nerez \u00b7 rozetov\u00e9 kov\u00e1n\u00ed
${stdCards('intHandle',undefined,'cols2')}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.
`);
+ int+=stdSection('finishes','04','Parapety a malba',`Helopal \u00b7 lit\u00fd mramor ${stdSource(16)}
${stdCards('intSill',undefined,'cols3')}Fotografie ukazuj\u00ed katalogov\u00fd vzorek. V modelu se m\u011bn\u00ed odst\u00edn; \u017e\u00edh\u00e1n\u00ed je na fotografii.
Malba st\u011bn
${stdCards('wallPaint',STD_OPTIONS.wallPaint.map((o,i)=>[...o]),'','', 'visual')}${stdColor('wallPaint','Vlastn\u00ed odst\u00edn st\u011bn')}${stdNotice('Standard: HET b\u00edl\u00e1, \u0161pachtlov\u00e1n\u00ed Q2. 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('Kuchy\u0148 nen\u00ed v katalogu barevn\u011b ani typov\u011b specifikovan\u00e1. 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')}\u010cela sk\u0159\u00edn\u011bk
${stdCards('kitchenColor',undefined,'cols3','', 'visual')}${stdColor('kitchenColor','Vlastn\u00ed odst\u00edn \u010del')}Korpusy sk\u0159\u00edn\u011bk
${stdCards('kitchenBodyColor',undefined,'cols3','', 'visual')}${stdColor('kitchenBodyColor','Vlastn\u00ed odst\u00edn korpus\u016f')}Sjednotit korpusy s \u010dely Pracovn\u00ed deska
${stdCards('worktop',undefined,'','', 'visual')}Obklad za linkou
${stdCards('backsplash',undefined,'cols3','', 'visual')}Detail kuchyn\u011b 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.
`);
+ int+=stdSection('bath','06','Koupelna a sanita',`Proveden\u00ed sprchy ${stdSource(21)}
${stdCards('shower',undefined,'cols2','', 'catalog')}
Detail sprchy Baterie
${stdCards('faucet',undefined,'cols2','', 'visual')}Standardem jsou chromov\u00e9 Ravak Classic. \u010cern\u00e1 je vlastn\u00ed vizualiza\u010dn\u00ed alternativa, ne potvrzen\u00fd produkt z katalogu.
Pevn\u00e9 vybaven\u00ed dle projektu ${stdSource(20)}
Vana Ravak Chrome \u00b7 1700 \u00d7 750 mm Ravak Classic, click-clack, hlavice s hadic\u00ed 1,5 m.
Umyvadlo Laufen Pro \u00b7 600 \u00d7 420 mm Ravak Classic, chromov\u00fd sifon. Mal\u00e9 umyvadlo na WC dle p\u016fdorysu: konkr\u00e9tn\u00ed typ potvrdit.
2\u00d7 WC \u00b7 koupelna a samostatn\u00e9 WC Geberit 112, Ravak Uni Chrome Rimoff, softclose, b\u00edl\u00e9 tla\u010d\u00edtko Sigma.
Topn\u00fd \u017eeb\u0159\u00edk Koralux \u00b7 500 \u00d7 1500 mm Volba p\u0159ipojen\u00ed v technick\u00e9 specifikaci.
Geometrie vybaven\u00ed je zjednodu\u0161en\u00e1. Rozm\u00edst\u011bn\u00ed vych\u00e1z\u00ed z p\u016fdorysu; nenahrazuje d\u00edlensk\u00fd n\u00e1vrh koupelny.
`);
+ int+=stdSection('tech','07','Technika a dal\u0161\u00ed specifikace',`${stdNotice('Volby v t\u00e9to sekci se ukl\u00e1daj\u00ed do soupisu. Model nep\u0159epo\u010d\u00edt\u00e1v\u00e1 ani nem\u011bn\u00ed rozvody, prostupy, v\u00fdkony a polohy jednotek.','blue')}V\u011btr\u00e1n\u00ed a chlazen\u00ed ${stdSource(25)}
${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']])}V\u00fdchoz\u00ed technick\u00e9 volby sleduj\u00ed popisy v p\u016fdorysu; nacen\u011bn\u00ed a rozsah potvrzuje dodavatel. ${stdSource(16)}
Voda a vyt\u00e1p\u011bn\u00ed ${stdSource(24)}
${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']])}Teplovodn\u00ed podlahov\u00e9 topen\u00ed 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.
ABB Levit \u00b7 b\u00edl\u00e1 / b\u00edl\u00e1 Standard z\u00e1suvek a vyp\u00edna\u010d\u016f. ${stdSource(17)}
Tepeln\u011bizola\u010dn\u00ed p\u016fdn\u00ed sch\u016fdky Fakro LWT V dom\u011b nen\u00ed b\u011b\u017en\u00e9 schodi\u0161t\u011b. Rozm\u011br otvoru potvrdit podle projektu. ${stdSource(22)}
`);
+ $('#std-interior').innerHTML=int;
+ $('#std-summary').innerHTML=`V\u00e1\u0161 v\u00fdb\u011br ${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')}
Pozn\u00e1mka pro dodavatele Zkop\u00edrovat odkaz s v\u00fdb\u011brem Export nastaven\u00ed JSON Na\u010d\u00edst nastaven\u00ed JSON St\u00e1hnout soupis TXT Ulo\u017eit aktu\u00e1ln\u00ed n\u00e1hled PNG Export viditeln\u00e9ho 3D GLB Obnovit v\u00fdchoz\u00ed standardy Otev\u0159\u00edt zdrojov\u00fd katalog
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.
`;
+ }
+ 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])=>`${stdEsc(heading)} ${rows.map(([a,b,c])=>`${stdEsc(a)} ${stdEsc(b)}${stdEsc(c)}
`).join('')} `).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.clientXr.right||e.clientYr.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();
\ No newline at end of file
diff --git a/locenice/locenice/js/bootstrap.js b/locenice/locenice/js/bootstrap.js
new file mode 100644
index 0000000..fe25ba7
--- /dev/null
+++ b/locenice/locenice/js/bootstrap.js
@@ -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='
Nahrajte celou slozku vcetne js, css, data, models a assets. Otevrete ji pres HTTP(S), nikoli dvojklikem na HTML.
Stahnout model GLB ';
+ box.querySelector('.errorDetail').textContent=error.message;document.getElementById('webStatus').textContent='Chyba nacitani';
+}
diff --git a/locenice/locenice/js/legacy-renderer.js b/locenice/locenice/js/legacy-renderer.js
new file mode 100644
index 0000000..4c97a4a
--- /dev/null
+++ b/locenice/locenice/js/legacy-renderer.js
@@ -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);}
+ };
+}
diff --git a/locenice/locenice/js/legacy-shaders.js b/locenice/locenice/js/legacy-shaders.js
new file mode 100644
index 0000000..fe79a9f
--- /dev/null
+++ b/locenice/locenice/js/legacy-shaders.js
@@ -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};
diff --git a/locenice/locenice/js/model-io.js b/locenice/locenice/js/model-io.js
new file mode 100644
index 0000000..f21c0b6
--- /dev/null
+++ b/locenice/locenice/js/model-io.js
@@ -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(offbytes.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;
+}
diff --git a/locenice/locenice/js/project-panel.js b/locenice/locenice/js/project-panel.js
new file mode 100644
index 0000000..3f0a307
--- /dev/null
+++ b/locenice/locenice/js/project-panel.js
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ $('#projectToggle').onclick=()=>dialog.showModal();$('#projectClose').onclick=()=>dialog.close();
+ dialog.addEventListener('click',e=>{if(e.target===dialog){const r=dialog.getBoundingClientRect();if(e.clientXr.right||e.clientYr.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])=>`${esc(title)} `).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=`${site.cadastralAreaInProject.toLocaleString('cs-CZ')} m\u00b2 V\u00fdm\u011bra dle geometrick\u00e9ho pl\u00e1nu
${site.digitizedOutlineArea.toLocaleString('cs-CZ')} m\u00b2 Obrys dosavadn\u00edho 3D modelu
${site.referenceElevation.toLocaleString('cs-CZ',{minimumFractionDigits:3})} m V\u00fd\u0161kov\u00e1 nula / Bpv
${site.surveyPoints.length} bod\u016f V\u00fd\u0161kopis / grafick\u00fd ode\u010det polohy
`;
+ $('#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.xb.x&&box.yb.y))continue;occupied.push(box);label=`${p.id} / ${value} `;break;}return `${p.id} / ${value} m Bpv ${label} `;}).join('');
+ }};
+}
diff --git a/locenice/locenice/js/solar.js b/locenice/locenice/js/solar.js
new file mode 100644
index 0000000..cd8bd7e
--- /dev/null
+++ b/locenice/locenice/js/solar.js
@@ -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 };
+ }
diff --git a/locenice/locenice/js/three-renderer.js b/locenice/locenice/js/three-renderer.js
new file mode 100644
index 0000000..a307cc2
--- /dev/null
+++ b/locenice/locenice/js/three-renderer.js
@@ -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{
+ 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());}
+ };
+}
diff --git a/locenice/locenice/models/dum.glb b/locenice/locenice/models/dum.glb
new file mode 100644
index 0000000..7ef6d9f
Binary files /dev/null and b/locenice/locenice/models/dum.glb differ
diff --git a/locenice/tests/data_checks.mjs b/locenice/tests/data_checks.mjs
new file mode 100644
index 0000000..b1ae2d8
--- /dev/null
+++ b/locenice/tests/data_checks.mjs
@@ -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);
diff --git a/locenice/tools/prepare_export.py b/locenice/tools/prepare_export.py
new file mode 100644
index 0000000..e39605f
--- /dev/null
+++ b/locenice/tools/prepare_export.py
@@ -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('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('