@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# Ločenice / webový model pro PHP hosting
|
||||
|
||||
## Nahrání na web
|
||||
|
||||
1. Zálohujte dosavadní složku `locenice`.
|
||||
2. Nahrajte **celý obsah přiložené složky `locenice/`** do stejné složky na hostingu.
|
||||
3. Otevřete `/locenice/` nebo `/locenice/index.php` a obnovte stránku bez cache.
|
||||
|
||||
PHP jen vrací HTML. Pro samotný provoz nepotřebujete Node.js, npm, databázi ani sestavování aplikace.
|
||||
Složky `blender`, `tools` a `tests` na hosting nenahrávejte; jsou určené pro úpravy na počítači.
|
||||
|
||||
Základ `index.php` zůstává stejný jako dosud:
|
||||
|
||||
```php
|
||||
<?php
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
readfile(__DIR__ . '/app.html');
|
||||
```
|
||||
|
||||
Dodaný `index.php` navíc kontroluje existenci souboru a posílá hlavičku `nosniff`.
|
||||
Relativní cesty počítají s tím, že `index.php` i `app.html` jsou ve stejné složce.
|
||||
Nenahrávejte pouze PHP nebo pouze HTML. Stránka potřebuje i `js`, `css`, `data`, `models` a `assets`.
|
||||
Na běžném Apache není nutné přidávat `.htaccess` ani přesměrování.
|
||||
Server musí vydávat `.js` jako JavaScript, `.json` jako JSON a umožnit stažení `.glb`.
|
||||
|
||||
## Co je kde
|
||||
|
||||
| Soubor / složka | Účel |
|
||||
| --- | --- |
|
||||
| `locenice/index.php` | Vstup na dosavadním PHP hostingu. |
|
||||
| `locenice/app.html` | Struktura stránky; už bez vloženého modelu. |
|
||||
| `locenice/models/dum.glb` | Celý zdrojový model, včetně alternativ a textur. |
|
||||
| `locenice/data/catalog.json` | Možnosti standardů, názvy, barvy a výchozí volby. |
|
||||
| `locenice/data/scene.json` | Vazby materiálů, místnosti, kamery, kolizní obrysy a metadata. |
|
||||
| `locenice/data/site.json` | 27 bodů výškopisu a zvlášť souřadnice z geometrického plánu. |
|
||||
| `locenice/data/app-config.json` | Model, renderer, cesty a maximální hustota vykreslování. |
|
||||
| `locenice/assets/` | Samostatné obrázky katalogu a referencí. |
|
||||
| `locenice/js/app.js` | Ovládání, pohledy, stav a pravidla konfigurátoru. |
|
||||
| `locenice/js/three-renderer.js` | Nový renderer nad Three.js. |
|
||||
| `locenice/js/legacy-renderer.js` | Lokální záloha nad původním WebGL. |
|
||||
| `locenice/js/solar.js` | Oddělený výpočet polohy slunce. |
|
||||
| `locenice/js/model-io.js` | Načtení a zápis kontejneru GLB. |
|
||||
| `locenice/css/` | Vzhled aplikace. |
|
||||
|
||||
## Three.js a záložní renderer
|
||||
|
||||
Výchozí `renderer: "auto"` nejprve zkusí Three.js **0.180.0 / r180**, přesně připnutou verzi z CDN jsDelivr.
|
||||
Model, katalog ani fotografie se na CDN neposílají. Prohlížeč odtud stahuje knihovnu, takže jde o externí síťový požadavek.
|
||||
Knihovna Three.js není fyzicky součástí tohoto ZIPu. Při nedostupnosti CDN nastoupí lokální renderer.
|
||||
Nejde tedy o úplné odstranění původního vykreslování: je zachované jako oddělená záloha.
|
||||
Obě cesty vyžadují WebGL 2; zablokovanou grafiku prohlížeče záloha neobejde.
|
||||
|
||||
V levém dolním rohu nebo v dialogu **Projekt a podklady** je uveden aktivní renderer.
|
||||
Vynucené režimy pro diagnostiku: `index.php?renderer=three` a `index.php?renderer=legacy`.
|
||||
|
||||
### Volitelně vše z vlastního hostingu
|
||||
|
||||
Na počítači s Pythonem a připojením k internetu spusťte z kořene balíčku:
|
||||
|
||||
```sh
|
||||
python tools/vendor_three.py
|
||||
```
|
||||
|
||||
Skript stáhne připnuté soubory a jejich relativní závislosti z oficiálního repozitáře Three.js, včetně licence.
|
||||
Potom nahrajte vytvořenou složku `locenice/vendor` a upravený `locenice/data/app-config.json`.
|
||||
Stahovací skript se nespouští na webu a neslouží jako veřejný instalátor.
|
||||
Jeho síťovou část zde kvůli blokovanému připojení nebylo možné ověřit.
|
||||
|
||||
## Model, projekt a dodané fotografie
|
||||
|
||||
Stavební geometrie i svažitý terén jsou převzaté z dodaného modelu ALLSTAV v2.
|
||||
Neproběhlo nové zaměření ani kompletní přemodelování domu z PDF.
|
||||
Kuchyň a obývací prostor dosavadního modelu zůstávají včetně zelených čel, desky, vinotéky, vybavení a trámů.
|
||||
Čtyři nově dodané obrázky jsou dostupné v dialogu pro porovnání.
|
||||
Obrázky nejsou zaměřené ani mezi sebou zcela shodné; jejich odlišné rámy a dispozice neřídí přesun oken.
|
||||
Nejde o fotorealistickou rekonstrukci, BIM, výrobní návrh kuchyně nebo dokumentaci pro stavbu.
|
||||
|
||||
Výškopis je dostupný jako podklad a lze zapnout jeho body nad 3D modelem.
|
||||
Výšková nula je 591,840 m Bpv. Grafické x/z polohy 27 bodů jsou převzaté z dosavadního modelu.
|
||||
Souřadnice S-JTSK z geometrického plánu jsou uložené odděleně, nepředstírají souřadnice vytyčení domu.
|
||||
Doklad uvádí parcelu 3480/37 o výměře 1 010 m²; dosavadní grafický obrys modelu má 987,89 m².
|
||||
Tento rozdíl je uvedený, nikoli skrytě odstraněný deformací modelu.
|
||||
Výšky upraveného terénu u domu a původní zaměření nejsou zaměnitelné.
|
||||
|
||||
## Standardy a sdílení
|
||||
|
||||
Zachované volby: dveře, parapety, okapy, krytina, žaluzie, podlahy po místnostech, obklady,
|
||||
malba, vnitřní dveře, kuchyň, sprcha a další specifikace.
|
||||
Žaluzie se týkají devíti obdélníkových otvorů, nikdy trojúhelníku nebo vstupních dveří.
|
||||
Výchozí barvy jsou RAL 7016 / antracit / CERN 015 / tmavě šedivá.
|
||||
|
||||
Na webu je export samostatného HTML nahrazen **odkazem s výběrem**.
|
||||
Odkaz otevře tento web s vloženým nastavením. Obsahuje i poznámku pro dodavatele; nevkládejte do ní citlivé údaje.
|
||||
Nastavení je ve fragmentu URL za `#`; neukládá se do serverové databáze.
|
||||
Zachovaný je export/import JSON, TXT a export viditelného modelu GLB; PNG zachycuje 3D canvas.
|
||||
Soubor `models/dum.glb` je kompletní editovatelný zdroj. Export **viditelného výběru** ho nenahrazuje.
|
||||
Dosavadní JSON výběry lze importovat. Lokální uložení je pouze v daném prohlížeči.
|
||||
Změna výchozích hodnot v katalogu nepřepíše již uložený výběr; použijte tlačítko obnovy výchozích voleb.
|
||||
|
||||
## Úpravy v Blenderu
|
||||
|
||||
Soubor `.blend` v balíčku není. Přiložený skript jej vytvoří ve vašem Blenderu:
|
||||
|
||||
```sh
|
||||
blender --background --python blender/prepare_model.py
|
||||
```
|
||||
|
||||
Výsledek: `blender/dum_locenice.blend`. Skript vytvoří pojmenované kolekce a skryje neaktivní varianty.
|
||||
Skript zde nebyl spuštěn, protože Blender není v prostředí nainstalovaný.
|
||||
Alternativně importujte `locenice/models/dum.glb` do Blenderu ručně.
|
||||
Neslučujte vše do jednoho objektu ani materiálu. Zachovejte vlastnosti `loceniceId`, `interiorLayer`, `stdWhen`, `stdBlind` a `windowNode`.
|
||||
|
||||
Při exportu do GLB zapněte vlastní vlastnosti / **Custom Properties (extras)**.
|
||||
Exportujte všechny varianty, nikoli jen viditelné objekty; vypněte filtr **Visible / Renderable / Selected objects only**.
|
||||
Pak spusťte:
|
||||
|
||||
```sh
|
||||
python tools/prepare_export.py upraveny_model.glb --out pripraveno
|
||||
```
|
||||
|
||||
Nástroj znovu naváže čísla objektů a materiálů na stabilní ID a doplní celý katalog textur.
|
||||
Po záloze webu vyměňte **oba** soubory `models/dum.glb` a `data/scene.json` za připravené výsledky.
|
||||
Nástroj byl ověřen na testu s přeházeným pořadím objektů a materiálů, ne na skutečném exportu z Blenderu.
|
||||
Transformované objekty po novém exportu vyžadují Three.js; původní záložný renderer je pro takový model odmítne namísto chybného zobrazení.
|
||||
Změna dispozice navíc vyžaduje kontrolu kolizních obrysů, poloh žaluzií, podlah a připravených kamer v `data/scene.json`.
|
||||
Samotné přejmenování nebo výměna GLB tyto pravidla automaticky nepřeprojektuje.
|
||||
|
||||
## Soukromí a provoz
|
||||
|
||||
Balíček obsahuje referenční obrázky a výřezy podkladů v `assets`. Na veřejném webu jsou přímo dostupné.
|
||||
Pro neveřejný projekt chraňte **celou složku**, nikoli pouze `index.php`.
|
||||
Meta `noindex` není zabezpečení. Aplikace sama nemá přihlášení ani serverové ukládání.
|
||||
|
||||
## Ověření a omezení
|
||||
|
||||
Ověřena je syntaxe PHP a JavaScriptu, vracení stránky přes PHP, HTTP cesty a typy souborů,
|
||||
struktura GLB, katalog a jeho vazby, změny výběrů a rozložení rozhraní na desktopu i mobilu.
|
||||
Testy ovládání použily simulované grafické API. **Nejde o ověření skutečného 3D vykreslení.**
|
||||
Zdejší prohlížeč blokuje WebGL i externí knihovny, proto nebyl renderer Three.js plně spuštěn.
|
||||
Barevné podání a výkon je potřeba zkontrolovat po nasazení v běžném prohlížeči.
|
||||
|
||||
Opakovatelná kontrola dat na počítači s Node.js:
|
||||
|
||||
```sh
|
||||
node tests/data_checks.mjs
|
||||
```
|
||||
|
||||
Node.js je zde pouze pro dobrovolný test, nikoli pro hosting nebo běh webu.
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"data_checks_passed": 96,
|
||||
"ui_checks_passed": 14,
|
||||
"ui_page_errors": [],
|
||||
"ui_graphics": "Mock WebGL API for controller/layout tests. No real 3D rendering.",
|
||||
"syntax_checks": [
|
||||
"PHP index.php lint",
|
||||
"JavaScript modules node --check",
|
||||
"Python helpers compilation"
|
||||
],
|
||||
"http_checks": "Local PHP server returned HTTP 200 for page, scripts, JSON, CSS and model.",
|
||||
"export_mapping_test": "Synthetic reversed node/material order: passed. Not tested with a real Blender export.",
|
||||
"not_verified": [
|
||||
"Live WebGL 2 rendering",
|
||||
"Three.js CDN imports in an unrestricted browser",
|
||||
"Blender helper execution",
|
||||
"Network part of the optional vendoring helper",
|
||||
"User hosting deployment"
|
||||
],
|
||||
"environment_limitation": "Browser policy blocks WebGL and navigations; outbound container network blocked.",
|
||||
"ui_checks": [
|
||||
{
|
||||
"name": "Defaults",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Blinds all nine rectangular openings",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Blinds off",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Nine floor rooms",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Kitchen independent colours",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Invalid colour rejected",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Walkin alternative",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Source binary roundtrip",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Summary export schema",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Pure astronomical function",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Reference photos loaded",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Survey overlay 27 points maximum",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "No horizontal page overflow on mobile",
|
||||
"ok": true,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "Mobile interior panel visible",
|
||||
"ok": true,
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
Three.js r180 / 0.180.0 is loaded from jsDelivr by default. It is not bundled here.
|
||||
Source: https://github.com/mrdoob/three.js/tree/r180
|
||||
License: MIT, Copyright (c) 2010-2025 three.js authors.
|
||||
The optional vendor downloader fetches and preserves the upstream LICENSE.
|
||||
|
||||
User supplied model, drawings, catalogue samples and reference photographs:
|
||||
provided for this project. No third-party asset ownership is claimed.
|
||||
Do not treat these files as a separately licensed general-purpose asset library.
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Run in Blender, not system Python:
|
||||
blender --background --python blender/prepare_model.py
|
||||
Creates an editable .blend next to this script. Existing scene is replaced.
|
||||
Keeps every configuration variant; helper collections are hidden in viewport.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import bpy
|
||||
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
SOURCE=ROOT/'locenice'/'models'/'dum.glb'
|
||||
TARGET=Path(__file__).resolve().parent/'dum_locenice.blend'
|
||||
if not SOURCE.is_file():
|
||||
raise FileNotFoundError(SOURCE)
|
||||
if TARGET.exists():
|
||||
raise FileExistsError(f'Rename or remove {TARGET} before running again.')
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=str(SOURCE))
|
||||
defaults=json.loads((ROOT/'locenice'/'data'/'catalog.json').read_text())['defaults']
|
||||
scene=bpy.context.scene
|
||||
scene.unit_settings.system='METRIC'
|
||||
scene.unit_settings.scale_length=1.0
|
||||
collections={}
|
||||
for name in ['01_Exterior','02_Interior_walls','03_Kitchen_and_furniture','04_Site','05_Cutaway_alternatives','06_Standards_alternatives']:
|
||||
col=bpy.data.collections.new(name);scene.collection.children.link(col);collections[name]=col
|
||||
for obj in list(scene.objects):
|
||||
if obj.type!='MESH':continue
|
||||
props=obj
|
||||
if props.get('stdHidden') or props.get('stdWhen') or props.get('stdBlind'):
|
||||
key='06_Standards_alternatives'
|
||||
elif props.get('interiorLayer') in ['cut','cutShell']:
|
||||
key='05_Cutaway_alternatives'
|
||||
elif props.get('interiorLayer')=='furniture':
|
||||
key='03_Kitchen_and_furniture'
|
||||
elif props.get('interiorLayer'):
|
||||
key='02_Interior_walls'
|
||||
elif props.get('siteLayer') or props.get('legacySite'):
|
||||
key='04_Site'
|
||||
else:key='01_Exterior'
|
||||
for c in list(obj.users_collection):c.objects.unlink(obj)
|
||||
collections[key].objects.link(obj)
|
||||
# Stable ID comes from the GLB extras. Do not delete it during future editing.
|
||||
obj['locenice_edit_note']='Keep loceniceId and configuration properties.'
|
||||
condition=props.get('stdWhen',{})
|
||||
hidden=bool(props.get('stdHidden')) or bool(props.get('stdBlind') and not defaults['blinds'])
|
||||
if hasattr(condition,'items'):
|
||||
hidden=hidden or any(defaults.get(k)!=v for k,v in condition.items())
|
||||
obj.hide_set(hidden)
|
||||
obj.hide_render=hidden
|
||||
collections['05_Cutaway_alternatives'].hide_viewport=True
|
||||
# Keep matching default variants visible; all alternatives remain in the file.
|
||||
note=bpy.data.texts.new('READ_ME_Locenice')
|
||||
note.write('''This is a visualisation mesh, not BIM. Units: metres.
|
||||
The source includes multiple, mutually exclusive variants in the same location.
|
||||
Use named collections to inspect them. Do not join all meshes or all materials.
|
||||
loceniceId, stdWhen, stdBlind, windowNode and interiorLayer must stay on objects.
|
||||
Detailed construction dimensions come from the project, not reference photos.
|
||||
After exporting all variants to GLB, run tools/prepare_export.py.
|
||||
Blender reorders glTF nodes, materials and textures. The included web manifest
|
||||
is indexed for the supplied GLB; the preparation tool rebinds stable IDs.
|
||||
A changed wall or cabinet also requires checking web collision volumes / camera presets.
|
||||
''')
|
||||
scene['locenice_metadata_json']=json.dumps(json.loads((ROOT/'locenice'/'data'/'scene.json').read_text()),ensure_ascii=False)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(TARGET))
|
||||
print('Saved:',TARGET)
|
||||
@@ -1,3 +1,4 @@
|
||||
<?php
|
||||
|
||||
readfile(__DIR__ . '/dum_3D_slunce.html');
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
readfile(__DIR__ . '/app.html');
|
||||
@@ -0,0 +1,348 @@
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="allstav-config-version" content="2025.2">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>Ločenice | Model domu / web</title>
|
||||
|
||||
|
||||
|
||||
|
||||
<link rel="stylesheet" href="./css/app.css">
|
||||
<link rel="stylesheet" href="./css/web.css">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<canvas id="view" aria-label="Interaktivní trojrozměrný model rodinného domu"></canvas>
|
||||
<svg id="measure" aria-hidden="true"></svg>
|
||||
<header>
|
||||
<div class="eyebrow"><span class="dot"></span>RODINNÝ DŮM - Ločenice</div>
|
||||
<h1>Model domu / web – Ločenice (Novohradské podhůří)</h1>
|
||||
<div class="sub">Ločenice · 3D model + simulace slunce</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><strong>15,340 m</strong><small>Délka</small></div>
|
||||
<div class="metric"><strong>9,390 m</strong><small>Šířka</small></div>
|
||||
<div class="metric"><strong>23°</strong><small>Střecha</small></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="topright"><button id="standardsToggle" class="btn" type="button" aria-controls="standardsPanel" aria-expanded="false" title="Standardy a vybaveni domu"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M3 5h14M3 10h14M3 15h14"/><circle cx="7" cy="5" r="2" fill="currentColor"/><circle cx="13" cy="10" r="2" fill="currentColor"/><circle cx="7" cy="15" r="2" fill="currentColor"/></svg><span class="std-toggle-word">Standardy</span></button><button class="btn" id="sunToggle" aria-controls="sunPanel" aria-expanded="false"
|
||||
title="Ovládání slunce"><span class="solar-mark">☀</span><span
|
||||
class="toggle-word">Slunce</span></button><button class="btn icon" id="infoButton"
|
||||
aria-label="Nastavení a informace o modelu" title="Nastavení">⚙︎</button><button
|
||||
class="btn primary" id="saveGlb">Stáhnout <span
|
||||
class="downloadText">model
|
||||
</span>GLB</button></div>
|
||||
<div class="panel" id="panel">
|
||||
<h2>Model podle projektu</h2>
|
||||
<p>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í.</p>
|
||||
<h3>Parametry</h3>
|
||||
<div class="row"><span>Obálka domu</span><strong>15,340 × 9,390 m</strong></div>
|
||||
<div class="row"><span>Hřeben nad podlahou 1. NP</span><strong>5,137 m</strong></div>
|
||||
<div class="row"><span>Vrchol komína</span><strong>5,837 m</strong></div>
|
||||
<div class="row"><span>Krytá terasa / navazující ochoz</span><strong>−0,03 / −0,21
|
||||
m</strong></div>
|
||||
<h3>Barevnost</h3>
|
||||
<div class="swatches"><label>Fasáda<input id="wallColor" type="color" value="#c9c2ad"></label><label>Krytina<input id="roofColor" type="color" value="#414542"></label></div>
|
||||
<section class="hardie-card" aria-labelledby="hardieTitle">
|
||||
<div class="hardie-kicker">Fasádní obklad · vstup a terasa</div>
|
||||
<h4 id="hardieTitle">HardiePlank struktura dřeva</h4>
|
||||
<p class="hardie-spec">James Hardie · vláknocement<br>Rozměr výrobku: 3 600 × 180 × 8 mm</p>
|
||||
<div id="hardiePreview" class="hardie-preview" role="img" aria-label="Orientační náhled obkladu"></div>
|
||||
<div class="hardie-preview-caption">ZVĚTŠENÝ DETAIL STRUKTURY · ORIENTAČNĚ</div>
|
||||
<div class="hardie-selection" aria-live="polite"><strong id="hardieColorName">Oříškově hnědá</strong><small id="hardieProductCode">5771220 · výchozí</small></div>
|
||||
<fieldset class="hardie-palette"><legend>Standardní barvy podle dodaného vzorníku</legend><div class="hardie-options"><label class="hardie-option" style="--chip:#ffffff"><input type="radio" name="hardieColor" value="snehove-bila" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Sněhově bílá</span><span class="hardie-option-code">5671220</span><span class="hardie-check" aria-hidden="true">✓</span></label><label class="hardie-option" style="--chip:#d0c8bb"><input type="radio" name="hardieColor" value="sterkove-seda" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Štěrkově šedá</span><span class="hardie-option-code">5751220</span><span class="hardie-check" aria-hidden="true">✓</span></label><label class="hardie-option" style="--chip:#b9c1c3"><input type="radio" name="hardieColor" value="mlhove-seda" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Mlhově šedá</span><span class="hardie-option-code">5841220</span><span class="hardie-check" aria-hidden="true">✓</span></label><label class="hardie-option" style="--chip:#758896"><input type="radio" name="hardieColor" value="ocelove-seda" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Ocelově šedá</span><span class="hardie-option-code">5851220</span><span class="hardie-check" aria-hidden="true">✓</span></label><label class="hardie-option selected" style="--chip:#776e65"><input type="radio" name="hardieColor" value="oriskove-hneda" checked autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Oříškově hnědá</span><span class="hardie-option-code">5771220</span><span class="hardie-check" aria-hidden="true">✓</span></label><label class="hardie-option" style="--chip:#241510"><input type="radio" name="hardieColor" value="espresso" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Espresso</span><span class="hardie-option-code">5460122</span><span class="hardie-check" aria-hidden="true">✓</span></label><label class="hardie-option" style="--chip:#4c5053"><input type="radio" name="hardieColor" value="antracitove-seda" autocomplete="off"><span class="hardie-chip" aria-hidden="true"></span><span class="hardie-option-name">Antracitově šedá</span><span class="hardie-option-code">5420122</span><span class="hardie-check" aria-hidden="true">✓</span></label></div></fieldset>
|
||||
<p class="hardie-scope">Mění se pouze svislý fasádní obklad u vstupu a na terase, včetně bočních stěn. Podlaha a čela terasy ani schody se nemění.</p>
|
||||
<a class="hardie-link" href="https://www.jameshardie.cz/cz/hardie-plank" target="_blank" rel="noopener noreferrer">HardiePlank na webu James Hardie ↗</a>
|
||||
<div class="hardie-detail-buttons"><button type="button" data-cladding-view="entry">Detail vstupu</button><button type="button" data-cladding-view="terrace">Detail terasy</button></div>
|
||||
<button class="hardie-structure-button" type="button" data-cladding-view="structure">Přiblížit strukturu ve 3D</button>
|
||||
<details class="hardie-note"><summary>Odstíny, struktura a přesnost modelu</summary>
|
||||
<p>Barvy jsou odečtené z vašeho obrázku vzorníku, nikoli z kalibrovaných dat výrobce. Odstín ve scéně ovlivňuje simulované slunce, stín i monitor. Shodu s dodaným výrobkem ověřte na fyzickém vzorku.</p>
|
||||
<img class="hardie-reference" src="./assets/ui/image-1.jpg" alt="Produktová fotografie struktury dřeva z dodaného vzorníku">
|
||||
<p>Ve 3D je doplněn jemný simulovaný reliéf inspirovaný touto fotografií. Nejde o originální výrobní texturu nebo 3D sken.</p>
|
||||
<p>Vodorovné členění, geometrie a spáry jsou zachovány z původního modelu. Uvedený rozměr desky je specifikace výrobku, nikoli ověření kladečského plánu nebo montážních detailů.</p>
|
||||
</details>
|
||||
</section>
|
||||
<p style="font-size:10px">Zvolené barvy a textura fasádního obkladu se uloží i do staženého GLB.</p>
|
||||
<p style="font-size:10px"><a
|
||||
href="https://www.sto.cz/cepcom/cz/documents/download_colour_ranges_1/StoColor_System_2022_CZ.pdf"
|
||||
target="_blank" rel="noopener">Vzorník barev StoColor System (PDF)</a></p>
|
||||
<h3>Kolmé pohledy</h3>
|
||||
<select id="elevation">
|
||||
<option value="">Vyber pohled...</option>
|
||||
<option value="north">Severní pohled</option>
|
||||
<option value="east">Východní pohled</option>
|
||||
<option value="south">Jižní pohled</option>
|
||||
<option value="west">Západní pohled</option>
|
||||
</select>
|
||||
<div class="buttons"><button class="btn" onclick="document.getElementById('saveGlb').click()">Stáhnout
|
||||
model GLB</button></div>
|
||||
|
||||
<details class="site-note">
|
||||
<summary>Okolí, výškopis a přesnost</summary>
|
||||
<p><b>Tři spodní přepínače jsou nezávislé a při otevření zapnuté.</b> Zapnutí zároveň zaměří příslušný
|
||||
pohled. Ikona vedle přepínačů zarámuje zapnuté okolí; Vstup, Terasa a Reset vrátí pohled k domu. Vypnutí
|
||||
poslední vrstvy obnoví předchozí pohled.</p>
|
||||
<p>Geometrie, měřítko a poloha původního domu, otvorů, střechy, teras a jejich schodů jsou zachované.
|
||||
Nahrazen je pouze původní schematický terén, štěrk a vstupní zpevněná plocha.</p>
|
||||
<p>Podklady okolí: C.2 a C.3 (umístění a upravené úrovně), C.4 (celý pozemek), C.5 (komunikace) a „Ločenice
|
||||
p.č. 3480/37 – polohopis a výškopis“. Výšková nula: <b>591,840 m Bpv</b>. U vstupu <b>−0,480 m</b>, u
|
||||
horní podesty schodů z parkování <b>+0,390 m</b>, na konci zahradních schodů dle C.3 <b>−1,395 m</b>.
|
||||
</p>
|
||||
<p>Povrch kombinuje původní zaměřené body s upravenými úrovněmi C.3. Mezi body je <b>interpolovaný</b>;
|
||||
navázání okrajů, skladba dlažby a dvě auta jsou vizualizační zjednodušení. Parkovací plocha směrem k
|
||||
ulici stoupá; není zobrazena jako jediná vodorovná deska.</p>
|
||||
<p><b>Rozdíl v podkladech:</b> projekt uvádí výměru <b>1 010 m²</b>, obrys převzatý z vektorové kresby PDF v
|
||||
měřítku domu vychází přibližně <b>988 m²</b> (o 2,2 % méně). Obrys není uměle zvětšen na deklarovanou
|
||||
výměru. Přesnou hranici je nutné ověřit ze souřadnic nebo DWG; tato vizualizace není podklad pro
|
||||
vytyčení ani zemní práce.</p>
|
||||
<p>Výpočet slunce a převod světových stran do os modelu jsou zachované. Původní azimut severního štítu
|
||||
<b>351,5°</b> a přibližná poloha obce nejsou tímto geodeticky ověřeny.
|
||||
</p>
|
||||
<p>PNG zachycuje právě viditelné vrstvy. GLB exportuje scénu s právě zapnutým okolím a zvolenými barvami;
|
||||
ovládání slunce zůstává jen v HTML.</p>
|
||||
</details>
|
||||
<h3>Úpravy modelu</h3>
|
||||
<p>Doplněn rohový sloup kryté terasy podle jižního a východního pohledu D.1.1.2.3.1. Průřez 200 × 200 mm a odsazení od hrany jsou odečtené orientačně z kresby, nikoli staticky ověřené. Vinotéka je u stěny a nad celou kuchyní pokračuje vysoký strop obýváku.</p>
|
||||
<h3>Rozsah a omezení</h3>
|
||||
<p class="note">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í.</p>
|
||||
<p>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.</p>
|
||||
<h3>Použité podklady</h3>
|
||||
<p style="font-size:10px">D.1.1.2.1.1 Půdorys 1NP<br>D.1.1.2.1.3 Půdorys střechy<br>D.1.1.2.3.1
|
||||
Pohledy<br>D.1.1.2.2.1–3 Řezy A–A, B–B, C–C<br>D.1.1.1 Technická
|
||||
zpráva<br>Obrázky 6_1.jpeg a 6_2.jpeg</p>
|
||||
<div class="buttons"><button class="btn" id="savePng">Uložit pohled PNG</button><button class="btn"
|
||||
id="resetColors">Původní barvy</button></div>
|
||||
<div class="status"><span class="dot"></span>Samostatný soubor · funguje bez internetu ·
|
||||
verze 05 · sloup terasy, vinotéka a vysoký strop</div>
|
||||
</div>
|
||||
<div id="direction"><span class="tag">AKTUÁLNÍ POHLED</span><span id="directionText">Terasa /
|
||||
jihovýchod</span></div>
|
||||
<div class="bottom">
|
||||
<nav class="toolbar" aria-label="Ovládání modelu"><button class="tool"
|
||||
data-preset="entry">Vstup</button><button class="tool"
|
||||
data-preset="terrace">Terasa</button><button class="tool" data-preset="top">Shora</button><span
|
||||
class="divider"></span><button class="tool" id="terrainButton"
|
||||
title="Zobrazit nebo skrýt terén">Terén</button><button class="tool"
|
||||
id="measureButton">Kóty</button><button class="tool optional active"
|
||||
id="rotateButton">Otáčení</button><span class="divider"></span><button class="tool"
|
||||
id="resetView" title="Obnovit výchozí pohled">Reset</button></nav>
|
||||
|
||||
<div class="site-controls" role="group" aria-label="Volitelné okolí domu; výchozí stav zapnuto">
|
||||
<label class="site-switch" title="Komunikace a navazující terén mezi ulicí a domem"><input type="checkbox"
|
||||
id="siteRoad" autocomplete="off" checked><span>Cesta a přístup</span></label>
|
||||
<label class="site-switch" title="Celá zahrada a graficky převzatá hranice parcely"><input type="checkbox"
|
||||
id="siteParcel" autocomplete="off" checked><span>Celý pozemek</span></label>
|
||||
<label class="site-switch" title="Parkovací plocha, napojení a schody; dvě orientační auta"><input
|
||||
type="checkbox" id="siteParking" autocomplete="off" checked><span>Parkování</span></label>
|
||||
<button id="siteFit" class="site-fit" type="button" title="Zarámovat zapnuté okolí"
|
||||
aria-label="Zarámovat zapnuté okolí"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5" aria-hidden="true">
|
||||
<path d="M9 4H4v5M15 4h5v5M20 15v5h-5M9 20H4v-5" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
</svg></button>
|
||||
</div>
|
||||
<div id="siteMeta" hidden aria-live="polite"></div>
|
||||
<div class="help">Tažením otáčej · Kolečkem přibliž
|
||||
· Pravým tlačítkem posuň</div>
|
||||
</div>
|
||||
<div class="compass"><svg viewBox="0 0 84 84">
|
||||
<circle cx="42" cy="42" r="27" fill="none" stroke="#bec8b7" />
|
||||
<g id="compassGroup">
|
||||
<line x1="42" y1="13" x2="42" y2="71" />
|
||||
<line x1="13" y1="42" x2="71" y2="42" />
|
||||
<path id="northArrow" d="M42 18 L37 32 L42 29 L47 32 Z" /><text x="42" y="8"
|
||||
text-anchor="middle">S</text><text x="42" y="81" text-anchor="middle">J</text><text x="3" y="45"
|
||||
text-anchor="middle">Z</text><text x="80" y="45" text-anchor="middle">V</text>
|
||||
</g>
|
||||
</svg></div>
|
||||
<svg id="sunOverlay" aria-hidden="true"></svg>
|
||||
<div id="sunStamp"><span>SLUNCE NAD DOMEM</span><br><strong id="sceneTime">21. 6. 2026 · 10:00</strong></div>
|
||||
<section id="sunPanel" aria-label="Simulace slunce">
|
||||
<div class="sun-panel-head">
|
||||
<div>
|
||||
<div class="sun-kicker">STUDIE OSVĚTLENÍ</div>
|
||||
<h2>Slunce a stíny</h2>
|
||||
</div><button class="close-panel" id="sunClose" aria-label="Skrýt panel slunce">×</button>
|
||||
</div>
|
||||
<div class="sun-date-row"><label class="sun-field">Datum<input id="sunDate" type="date" min="2000-01-01"
|
||||
max="2100-12-31" value="2026-06-21"></label><label class="sun-field">Místní čas<input id="sunClock"
|
||||
type="text" inputmode="numeric" value="10:00" maxlength="5" placeholder="HH:MM"
|
||||
aria-label="Místní čas ve formátu HH:MM"></label></div>
|
||||
<div class="sun-seasons" aria-label="Typické dny roku"><button data-season="03-20">Jaro</button><button
|
||||
data-season="06-21" class="active">Léto</button><button data-season="09-23">Podzim</button><button
|
||||
data-season="12-21">Zima</button></div>
|
||||
<div class="sun-status-line"><span id="dayState">Slunce nad obzorem</span><span id="zoneLabel">SELČ ·
|
||||
UTC+2</span></div>
|
||||
<svg id="sunGraph" class="sun-graph" viewBox="0 0 304 86" role="img"
|
||||
aria-label="Výška slunce během dne. Kliknutím změníš čas."></svg>
|
||||
<label class="sr-only" for="sunTime"
|
||||
style="position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)">Posun času</label>
|
||||
<input class="sun-scrub" id="sunTime" type="range" min="0" max="1439" value="600" step="1"
|
||||
aria-valuetext="10:00">
|
||||
<div class="sun-ticks">
|
||||
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
|
||||
</div>
|
||||
<div class="sun-play-row"><button id="sunPlay" type="button">▶ Přehrát den</button><select id="sunSpeed"
|
||||
aria-label="Rychlost simulace">
|
||||
<option value="15">15 min / s</option>
|
||||
<option value="30" selected>30 min / s</option>
|
||||
<option value="60">60 min / s</option>
|
||||
</select><button id="sunNow" title="Aktuální datum a čas v Praze">Teď</button></div>
|
||||
<div id="solarMessage" role="status"></div>
|
||||
<div class="sun-readouts">
|
||||
<div class="sun-stat">Výška nad obzorem<strong id="sunElevation">—</strong><small>Střed slunečního
|
||||
kotouče</small></div>
|
||||
<div class="sun-stat">Azimut slunce<strong id="sunAzimuth">—</strong><small id="sunDirection">Od severu po
|
||||
směru hodin</small></div>
|
||||
</div>
|
||||
<div class="sun-events"><span>Východ <b id="sunRise">—</b></span><span>Poledne <b
|
||||
id="sunNoon">—</b></span><span>Západ <b id="sunSet">—</b></span></div>
|
||||
<div class="sun-options"><label><input type="checkbox" id="sunShadows" checked>Vržené stíny</label><label><input
|
||||
type="checkbox" id="sunPath">Dráha ve 3D</label></div>
|
||||
<p class="sun-hint" id="pathHint">Posouvej čas a sleduj, kam dopadá slunce na terasu a fasády. Natočení pohledu
|
||||
polohu slunce nemění.</p>
|
||||
<button class="sun-export" id="sunSavePng">Uložit obrázek s datem a časem</button>
|
||||
<details class="sun-advanced">
|
||||
<summary>Poloha a orientace domu</summary>
|
||||
<div class="fields"><label class="sun-field">Zeměpisná šířka (° N)<input id="sunLat" type="number" min="-89"
|
||||
max="89" step="0.0001" value="48.8242"></label><label class="sun-field">Zeměpisná délka (°
|
||||
E)<input id="sunLon" type="number" min="-180" max="180" step="0.0001" value="14.5275"></label><label
|
||||
class="sun-field full-row">Azimut severního štítu domu (°, od severu)<input id="houseBearing"
|
||||
type="number" min="0" max="359.9" step="0.1" value="351.5"></label></div>
|
||||
<p>Výchozí poloha: <b>Ločenice, přibližný bod obce</b>, nikoli geodeticky určený bod parcely. Ponecháno
|
||||
původní nastavení <b>351,5°</b>, v původním HTML označené jako odečet severky D.1.1.2.1.1. Přiložené
|
||||
situace a výškopis bez číselných souřadnic neumožňují toto natočení nezávisle potvrdit. Nejde o ověřený
|
||||
azimut stavby.</p>
|
||||
<p id="bearingNote">Jižní štít: 171,5° · vstupní fasáda: 261,5°.</p>
|
||||
<button class="mini-btn" id="sunResetSite">Obnovit Ločenice a sever z projektu</button>
|
||||
<p>Časové pásmo zůstává <b>Europe/Prague</b>. Letní čas se přepočítává podle zvoleného data a pravidel
|
||||
prohlížeče.</p>
|
||||
</details>
|
||||
<details class="sun-advanced">
|
||||
<summary>Co simulace počítá a co ne</summary>
|
||||
<p>Sluneční poloha se počítá z data, času a zeměpisných souřadnic podle astronomických rovnic Meeus / NOAA.
|
||||
Směr přímého světla i stínová mapa se mění spolu s časem. Zobrazená výška slunce je geometrická. Východ
|
||||
a západ jsou určeny pro výšku středu −0,833°, bez místního horizontu.</p>
|
||||
<p>Stíny vytváří geometrie tohoto modelu. <b>Okolní domy, stromy, ploty a vzdálený horizont nejsou
|
||||
modelovány.</b> Terén navazuje na body výškopisu a úpravy C.3; mezi body je interpolovaný. Nejde o
|
||||
výpočet vnitřního denního osvětlení, tepelných
|
||||
zisků ani výkonu fotovoltaiky. Oblačnost není zahrnuta; jas a barva jsou vizualizační.</p>
|
||||
<p>Dráha ve 3D je vysvětlující překryv v symbolickém měřítku; slunce není ve skutečné vzdálenosti. Noční
|
||||
pohled je mírně přisvětlen pro orientaci. Velmi nízké slunce může mít stín mimo rozsah terénu.</p>
|
||||
<p>Podklady: D.1.1.2.1.1 (severka), D.1.1.2.3.1 (fasády), D.1.1.1 a B (orientace a terasa); situační výkresy
|
||||
C.1–C.5 pro umístění a „Ločenice p.č. 3480/37 – polohopis a výškopis“ pro terén.</p>
|
||||
<p>Zdroje výpočtu a polohy: <a href="https://gml.noaa.gov/grad/solcalc/calcdetails.html" target="_blank"
|
||||
rel="noopener">NOAA / Meeus</a> · <a href="https://www.mistopisy.cz/pruvodce/obec/8006/locenice/"
|
||||
target="_blank" rel="noopener">Ločenice – souřadnice</a>. Připojení je potřeba jen pro otevření
|
||||
těchto zdrojů, ne pro simulaci.</p>
|
||||
</details>
|
||||
<p class="sun-footnote">Pracovní studie stínů · bez internetu<br>Geometrie domu zůstává zachována. Export GLB
|
||||
neobsahuje ovládání slunce.</p>
|
||||
</section>
|
||||
|
||||
<button id="interiorLaunch" type="button" aria-controls="interiorPanel" aria-expanded="false" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M3 11 12 4 21 11M5 10v10h14V10M10 20v-7h5v7"/></svg>
|
||||
<span>Zapnout interiér<small>Půdorys · 3D bez střechy · procházení</small></span>
|
||||
</button>
|
||||
<div class="interior-heading"><button class="btn" id="interiorExit" title="Zpět na původní exteriér (Esc)">← Exteriér</button><div><div class="eyebrow">INTERIÉR / 1. NADZEMNÍ PODLAŽÍ</div><h2 id="interiorViewTitle">Dům bez střechy</h2></div></div>
|
||||
<section id="interiorPanel" aria-label="Pohledy a nastavení interiéru">
|
||||
<div class="interior-panel-title"><h3>Uvnitř domu</h3><span>9 místností</span></div>
|
||||
<svg id="interiorMap" viewBox="0 0 330 205" role="group" aria-label="Kliknutím na místnost přejdete dovnitř"></svg>
|
||||
<p class="int-map-hint">Klikněte na místnost v mapě nebo vyberte připravený pohled.</p>
|
||||
<div class="int-kicker">Kuchyň a obytný prostor</div>
|
||||
<div class="int-views">
|
||||
<button data-interior-view="kitchen">Kuchyně</button><button data-interior-view="outlook">Z kuchyně ven</button>
|
||||
<button data-interior-view="living">Obývací prostor</button><button data-interior-view="vault">Trámy a vysoké okno</button>
|
||||
<button data-interior-view="kitchenVault" style="grid-column:1/-1">Vysoký strop nad kuchyní</button>
|
||||
</div>
|
||||
<div class="int-kicker">Přejít do místnosti</div>
|
||||
<select id="interiorRoom" aria-label="Pohled z vybrané místnosti"><option value="">Vyberte místnost…</option></select>
|
||||
<section class="int-paint" aria-labelledby="interiorPaintTitle">
|
||||
<div class="int-paint-head"><h4 id="interiorPaintTitle">Barva zdí</h4><span>Vnitřní omítka</span></div>
|
||||
<div class="int-paint-fields">
|
||||
<label class="int-paint-picker" title="Vybrat vlastní barvu zdí"><input id="interiorWallColor" type="color" value="#eeeae1" aria-label="Barva vnitřních zdí" aria-describedby="interiorPaintScope"></label>
|
||||
<label class="int-paint-hex" for="interiorWallHex">Vlastní odstín / HEX<input id="interiorWallHex" type="text" value="#EEEAE1" maxlength="7" spellcheck="false" autocapitalize="characters" autocomplete="off" aria-label="HEX kód barvy vnitřních zdí"></label>
|
||||
</div>
|
||||
<div class="int-paint-presets" role="group" aria-label="Připravené barvy zdí">
|
||||
<button type="button" data-interior-paint="#eeeae1" aria-pressed="true"><span class="int-paint-chip" style="--paint:#eeeae1" aria-hidden="true"></span>Teplá bílá</button>
|
||||
<button type="button" data-interior-paint="#ffffff" aria-pressed="false"><span class="int-paint-chip" style="--paint:#ffffff" aria-hidden="true"></span>Čistá bílá</button>
|
||||
<button type="button" data-interior-paint="#dfceb3" aria-pressed="false"><span class="int-paint-chip" style="--paint:#dfceb3" aria-hidden="true"></span>Písková</button>
|
||||
<button type="button" data-interior-paint="#d9d9d4" aria-pressed="false"><span class="int-paint-chip" style="--paint:#d9d9d4" aria-hidden="true"></span>Světle šedá</button>
|
||||
<button type="button" data-interior-paint="#c4cebc" aria-pressed="false"><span class="int-paint-chip" style="--paint:#c4cebc" aria-hidden="true"></span>Šalvějová</button>
|
||||
<button type="button" data-interior-paint="#d5c7b9" aria-pressed="false"><span class="int-paint-chip" style="--paint:#d5c7b9" aria-hidden="true"></span>Jemně béžová</button>
|
||||
</div>
|
||||
<button class="int-paint-reset" id="interiorWallReset" type="button">Obnovit původní barvu zdí</button>
|
||||
<p class="int-paint-note" id="interiorPaintScope">Mění všechny vnitřní omítnuté stěny. Strop, trámy, obklady a fasáda zůstávají beze změny. Zvolený odstín se promítne i do PNG a GLB.</p>
|
||||
</section>
|
||||
|
||||
<div class="int-options">
|
||||
<label><input type="checkbox" id="interiorFurniture" checked>Vybavení</label>
|
||||
<label><input type="checkbox" id="interiorRoomLabels" checked>Názvy místností</label>
|
||||
<label><input type="checkbox" id="interiorFill" checked>Přisvětlení interiéru</label>
|
||||
</div>
|
||||
<p>V pohledech shora je střecha skrytá a stěny zkrácené do výšky 1,12 m. Procházení obnoví plnou výšku stěn, podhledy a trámy. Dveře mezi místnostmi jsou zobrazené otevřené.</p>
|
||||
<details class="int-source"><summary>Podklady a přesnost modelu</summary>
|
||||
<p><b>Dispozice:</b> půdorys D.1.1.2.1.1 ze dne 21. 9. 2026. Čísla místností a popisky ploch jsou převzaté z výkresu. Obvod domu, poloha oken a vnějších dveří jsou beze změny z původního modelu.</p>
|
||||
<p><b>Kuchyň:</b> zelená dvířka, dubové plochy, vysoké skříně, otevřené police a poloostrov podle dodaných obrázků. Vinotéka je na konci poloostrova u stěny. Nad celou kuchyní a obývacím prostorem je souvislý vysoký šikmý strop s přiznanými trámy. Uspořádání do U je přizpůsobené místnosti 108 a průchodům; přesné výrobní rozměry skříněk ani typy spotřebičů nejsou doložené.</p>
|
||||
<p><b>Trámy:</b> vzhled upraven hlavně podle pohled_ven.jpeg: vodorovné trámy, svislé podpěry, krátké podélné vzpěry a navazující horní trám. Světlé dřevo má kresbu podél jednotlivých prvků. Přesné průřezy a spoje nejsou z obrázků ověřené; jde o vzhledovou studii, ne statický návrh.</p>
|
||||
<p><b>Orientační:</b> rozměry nábytku, světlá výška ostatních místností 2,60 m, vysoký strop obytného prostoru podle stávajícího sklonu střechy, průřezy a detaily trámů, sanita a technické vybavení. Jde o prostorovou studii, nikoli výrobní dokumentaci nebo statický návrh.</p>
|
||||
<p>Rámy oken zůstaly v původní barvě, i když mají referenční obrázky různé odstíny. Výhled používá stávající 3D okolí; fotografie nejsou přesným panoramatem okolní krajiny. V procházení jsou okenní skla opticky skrytá pro volný výhled.</p>
|
||||
<p>Přisvětlení slouží k čitelnosti interiéru. Simulace slunce není výpočtem vnitřního denního osvětlení.</p>
|
||||
<div class="int-source-buttons"><button data-int-ref="plan">Původní půdorys</button><button data-int-ref="kitchen1">Kuchyň · 1</button><button data-int-ref="kitchen2">Kuchyň · 2</button><button data-int-ref="view1">Výhled · 1</button><button data-int-ref="view2">Výhled · 2</button></div>
|
||||
</details>
|
||||
<button class="int-export" id="interiorSavePng">Uložit aktuální pohled PNG</button>
|
||||
<button class="int-export" id="interiorSaveGlb">Stáhnout zobrazený model GLB</button>
|
||||
</section>
|
||||
<div id="interiorLabels" aria-label="Místnosti v půdorysu"></div>
|
||||
<div id="interiorTools"><div id="interiorHint">Tažením otáčejte · kolečkem přibližujte · kliknutím na místnost vstupte dovnitř</div>
|
||||
<nav class="int-mode-bar" aria-label="Režim interiéru"><button data-interior-mode="plan">Půdorys</button><button data-interior-mode="axon" class="active">3D bez střechy</button><button data-interior-mode="walk">Procházet</button><button id="interiorFit" class="int-nav-button" title="Zarámovat dům / obnovit pohled">⌖</button><button id="interiorPanelToggle" class="int-nav-button" title="Skrýt nebo zobrazit navigaci" aria-expanded="true">☷</button></nav>
|
||||
</div>
|
||||
<div id="walkPad" aria-label="Ovládání chůze"><button data-walk="forward" aria-label="Jít vpřed">↑</button><button data-walk="left" aria-label="Krok vlevo">←</button><button data-walk="back" aria-label="Jít vzad">↓</button><button data-walk="right" aria-label="Krok vpravo">→</button></div>
|
||||
<dialog id="interiorReferenceDialog"><div class="int-ref-head"><h3 id="interiorReferenceTitle">Podklad</h3><button id="interiorReferenceClose" aria-label="Zavřít podklad">Zavřít ×</button></div><img id="interiorReferenceImage" alt="Dodaný podklad interiéru"><p class="int-ref-caption" id="interiorReferenceCaption"></p></dialog>
|
||||
|
||||
<div id="toast"></div>
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
<div class="label">Načítám 3D model...</div>
|
||||
</div>
|
||||
<aside id="standardsPanel" aria-label="Výběr standardů ALLSTAV">
|
||||
<div class="std-head"><div class="std-kicker">ALLSTAV / STANDARDY 2025</div><div class="std-head-row"><h2>Váš dům, váš výběr</h2><button id="stdClose" class="std-close" aria-label="Zavřít standardy" type="button">×</button></div><p>Katalogové varianty a materiály přímo v modelu.</p></div>
|
||||
<div class="std-tabs" role="tablist" aria-label="Kategorie standardů"><button role="tab" id="std-tab-exterior" data-std-tab="exterior" aria-selected="true" aria-controls="std-exterior">Exteriér</button><button role="tab" id="std-tab-interior" data-std-tab="interior" aria-selected="false" aria-controls="std-interior">Interiér</button><button role="tab" id="std-tab-summary" data-std-tab="summary" aria-selected="false" aria-controls="std-summary">Můj výběr</button></div>
|
||||
<div class="std-scroll"><div id="std-exterior" role="tabpanel" aria-labelledby="std-tab-exterior"></div><div id="std-interior" role="tabpanel" aria-labelledby="std-tab-interior" hidden></div><div id="std-summary" role="tabpanel" aria-labelledby="std-tab-summary" hidden></div></div>
|
||||
<div class="std-footer"><div id="stdSaved" class="std-saved" role="status">Připraveno</div><div class="std-footer-row"><button class="std-action dark" data-std-action="summary">Přehled výběru</button><button class="std-action" data-std-action="exportJson">Zálohovat výběr</button></div></div></aside>
|
||||
<dialog id="stdCatalogDialog" aria-labelledby="stdCatalogTitle"><div class="std-dialog-head"><h3 id="stdCatalogTitle">Katalog ALLSTAV</h3><button id="stdCatalogClose">Zavřít ×</button></div><img id="stdCatalogImage" alt="Katalog ALLSTAV"><p id="stdCatalogCaption"></p><div class="std-dialog-pager"><button id="stdCatalogPrev">← Předchozí</button><button id="stdCatalogNext">Další →</button></div></dialog>
|
||||
<dialog id="stdResetDialog" aria-labelledby="stdResetTitle"><h3 id="stdResetTitle">Obnovit výchozí výběr?</h3><p>Vaše volby standardů budou nahrazeny výchozím nastavením. Před obnovením je můžete zálohovat jako JSON. Slunce a okolí domu se nemění.</p><div class="std-two"><button id="stdResetCancel" class="std-action">Zrušit</button><button id="stdResetConfirm" class="std-action dark">Obnovit</button></div></dialog>
|
||||
<input id="stdImport" type="file" accept=".json,application/json" hidden>
|
||||
<script id="stdSavedState" type="application/json">{}</script>
|
||||
|
||||
|
||||
|
||||
<script type="module" src="./js/bootstrap.js"></script>
|
||||
<div id="webStatus" role="status">Načítání modelu…</div>
|
||||
<button id="projectToggle" class="btn" type="button">Projekt a podklady</button>
|
||||
<dialog id="projectDialog" aria-labelledby="projectTitle">
|
||||
<div class="web-dialog-head"><div><small>LOČENICE / ZDROJOVÝ MODEL</small><h2 id="projectTitle">Projekt a podklady</h2></div><button id="projectClose" class="btn" aria-label="Zavřít">Zavřít</button></div>
|
||||
<div class="web-dialog-body">
|
||||
<p class="web-lead">Dům, katalog a ovládání jsou samostatné soubory. Stavební geometrie vychází z dosavadního modelu, vybavení z referenčních obrázků.</p>
|
||||
<h3>Kuchyň a obývací pokoj</h3><div id="projectPhotos" class="web-photo-grid"></div>
|
||||
<p class="web-note">Fotografie se liší v rámech oken a detailech nábytku. Jde o vzhledové reference, nikoli o výrobní výkres kuchyně nebo zaměřené panorama.</p>
|
||||
<h3>Pozemek a výšky</h3><div id="surveyStats" class="web-stat-grid"></div>
|
||||
<p class="web-note">Body výškopisu jsou v Bpv; poloha v 3D je převzatá z grafického odečtu PDF. Upravený terén u domu je jiná vrstva než původní zaměření. Rozpor plochy není opraven natažením modelu.</p>
|
||||
<div class="web-actions"><button class="btn" id="surveyShow">Zobrazit body v modelu</button><button class="btn" data-int-ref="survey">Otevřít výškopis</button><a class="btn" href="./data/site.json" download="locenice_pozemek.json">Data pozemku JSON</a></div>
|
||||
<h3>Editace modelu</h3><p>Soubor <code>models/dum.glb</code> lze importovat do Blenderu. Kompletní zdroj obsahuje i skryté varianty dveří, žaluzií a interiéru. Export „viditelný výběr“ je naopak pouze konkrétní varianta.</p>
|
||||
<div class="web-actions"><a class="btn primary" href="./models/dum.glb" download="dum_zdrojovy.glb">Stáhnout celý zdrojový GLB</a><a class="btn" href="./data/catalog.json" download="allstav_katalog.json">Katalog JSON</a></div>
|
||||
<p class="web-note" id="backendNote"></p>
|
||||
</div>
|
||||
</dialog>
|
||||
<svg id="surveyOverlay" aria-label="Body výškopisu" style="display:none"></svg>
|
||||
<noscript><div class="web-fatal">Pro 3D model povolte JavaScript. <a href="./models/dum.glb">Stáhnout GLB</a></div></noscript>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 575 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -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}}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
// Upload this whole folder, not only this file. No Node.js or database required.
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
$file = __DIR__ . '/app.html';
|
||||
if (!is_readable($file)) {
|
||||
http_response_code(500);
|
||||
exit('Chybi app.html. Nahrajte celou slozku locenice.');
|
||||
}
|
||||
readfile($file);
|
||||
@@ -0,0 +1,992 @@
|
||||
import {solarPosition} from './solar.js';
|
||||
import {parseGLB,encodeGLB} from './model-io.js';
|
||||
import {createLegacyRenderer} from './legacy-renderer.js';
|
||||
import {installProjectPanel} from './project-panel.js';
|
||||
const resources=window.LOCENICE_RESOURCES,config=resources.config;
|
||||
let backend=null,projectPanel=null;
|
||||
'use strict';
|
||||
const $ = s => document.querySelector(s), canvas = $('#view');
|
||||
|
||||
let originalBytes = resources.modelBytes, gltf = null, binary = null, gl = null, meshes = [], shadowProgram, program, shadowFbo, shadowTex;
|
||||
let showTerrain = true, showMeasure = false, autoRotate = false, orthographic = false, dirty = true, lastTime = 0;
|
||||
let target = [.8, 1.15, 0], theta = 2.25, phi = .30, distance = 35.0, aspect = 1, vp, lightVP, eye = [0, 0, 0];
|
||||
let animation = null, pointerMap = new Map(), pinch = null, wheelTimer = 0;
|
||||
const initialCamera = { theta: 2.25, phi: .30, distance: 35.0, target: [.8, 1.15, 0], ortho: false };
|
||||
// Opening composition follows the supplied reference; house geometry is unchanged.
|
||||
// Keep this separate from the existing Entry / Terrace / Top view presets.
|
||||
const startupCamera = { theta: .63, phi: .30, distance: 41.35, target: [-2.8, 2.3, 0], ortho: false };
|
||||
function applyStartupView() {
|
||||
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
|
||||
$('#elevation').value = '';
|
||||
$('#panel').classList.remove('open');
|
||||
$('#infoButton').classList.remove('active');
|
||||
// Match the existing portrait resize rule, without fitting the entire long parcel.
|
||||
setCamera({ ...startupCamera, distance: startupCamera.distance * Math.max(1, 1 / aspect) }, 'Voln\u00fd pohled', true);
|
||||
autoRotate = true;
|
||||
$('#rotateButton').classList.add('active');
|
||||
dirty = true;
|
||||
}
|
||||
const originalColors = {};
|
||||
const HARDIE_DEFAULT = 'oriskove-hneda';
|
||||
const hardieTextures = [], hardieImages = [], hardieObjectURLs = [];
|
||||
let hardieSelected = HARDIE_DEFAULT;
|
||||
async function loadHardieTextures(){backend.images.forEach((im,i)=>hardieImages[i]=im);}
|
||||
function hardieLinearRGB(hex) { return hexToRgb(hex).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4); }
|
||||
function setHardieColor(id) {
|
||||
const data = gltf?.extras?.hardiePlank;
|
||||
const color = data?.palette.find(c => c.id === id || c.code === id || c.name === id);
|
||||
if (!color) return false;
|
||||
hardieSelected = color.id;
|
||||
const boards = gltf.materials[data.boardMaterial], backing = gltf.materials[data.backingMaterial];
|
||||
boards.name = data.product + ' - ' + color.name;
|
||||
boards.pbrMetallicRoughness.baseColorFactor = [1, 1, 1, 1];
|
||||
boards.pbrMetallicRoughness.baseColorTexture.index = color.texture;
|
||||
boards.extras.color = color.name; boards.extras.productCode = color.code;
|
||||
backing.pbrMetallicRoughness.baseColorFactor = [...hardieLinearRGB(color.hex).map(c => c * .72), 1];
|
||||
data.selectedColor = { ...color };
|
||||
$('#hardieColorName').textContent = color.name;
|
||||
$('#hardieProductCode').textContent = color.code + (color.id === HARDIE_DEFAULT ? ' \u00b7 v\u00fdchoz\u00ed' : '');
|
||||
const preview = $('#hardiePreview'); preview.style.backgroundColor = color.hex;
|
||||
if (hardieImages[color.texture]) preview.style.backgroundImage = 'url("' + hardieImages[color.texture].src + '")';
|
||||
preview.setAttribute('aria-label', data.product + ', ' + color.name + ' - orienta\u010dn\u00ed n\u00e1hled');
|
||||
document.querySelectorAll('input[name="hardieColor"]').forEach(input => {
|
||||
input.checked = input.value === color.id; input.closest('label').classList.toggle('selected', input.checked);
|
||||
});
|
||||
dirty = true; return true;
|
||||
}
|
||||
function focusHardieDetail(key) { if(interior.enabled)toggleInterior(false);
|
||||
const portraitScale = Math.max(1, 1 / aspect);
|
||||
const poses = {
|
||||
entry: { theta: -.07, phi: .075, distance: 13.1 * portraitScale, target: [-4.05, 1.33, 3.90], ortho: false },
|
||||
terrace: { theta: 2.43, phi: .10, distance: 12.5 * portraitScale, target: [5.30, 1.35, -2.20], ortho: false },
|
||||
structure: { theta: Math.PI / 2, phi: 0, distance: 1.6 * portraitScale, target: [3.729, 1.32, -4.03], ortho: false }
|
||||
};
|
||||
if (!poses[key]) return;
|
||||
$('#panel').classList.remove('open'); $('#infoButton').classList.remove('active');
|
||||
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
|
||||
$('#elevation').value = '';
|
||||
setCamera(poses[key], key === 'entry' ? 'HardiePlank \u00b7 vstup' : key === 'terrace' ? 'HardiePlank \u00b7 terasa' : 'HardiePlank \u00b7 detail struktury');
|
||||
}
|
||||
function initHardiePalette() {
|
||||
document.querySelectorAll('input[name="hardieColor"]').forEach(input => {
|
||||
input.addEventListener('change', () => { if (input.checked) setHardieColor(input.value); });
|
||||
});
|
||||
document.querySelectorAll('[data-cladding-view]').forEach(button => button.onclick = () => focusHardieDetail(button.dataset.claddingView));
|
||||
setHardieColor(HARDIE_DEFAULT);
|
||||
window.HARDIE_PLANK = {
|
||||
getState: () => ({ product: gltf.extras.hardiePlank.product, selected: { ...gltf.extras.hardiePlank.selectedColor }, defaultColor: HARDIE_DEFAULT,
|
||||
palette: gltf.extras.hardiePlank.palette.map(c => ({ ...c })), boardNodes: [...gltf.extras.hardiePlank.boardNodes],
|
||||
backingNodes: [...gltf.extras.hardiePlank.backingNodes], scope: 'facade_only', originalGeometryPreserved: true, textureIsIllustrative: true }),
|
||||
setColor: id => setHardieColor(id), reset: () => setHardieColor(HARDIE_DEFAULT), detail: key => focusHardieDetail(key)
|
||||
};
|
||||
}
|
||||
|
||||
const vadd = (a, b) => a.map((x, i) => x + b[i]), vsub = (a, b) => a.map((x, i) => x - b[i]), scale = (a, s) => a.map(x => x * s), dot = (a, b) => a.reduce((r, x, i) => r + x * b[i], 0), norm = a => scale(a, 1 / Math.hypot(...a)), cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||
function mul(a, b) { const c = new Float32Array(16); for (let j = 0; j < 4; j++)for (let i = 0; i < 4; i++)for (let k = 0; k < 4; k++)c[j * 4 + i] += a[k * 4 + i] * b[j * 4 + k]; return c; }
|
||||
function lookAt(e, t, up = [0, 1, 0]) { const z = norm(vsub(e, t)), x = norm(cross(up, z)), y = cross(z, x); return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, e), -dot(y, e), -dot(z, e), 1]); }
|
||||
function perspective(fovy, asp, n, f) { const q = 1 / Math.tan(fovy / 2); return new Float32Array([q / asp, 0, 0, 0, 0, q, 0, 0, 0, 0, (f + n) / (n - f), -1, 0, 0, 2 * f * n / (n - f), 0]); }
|
||||
function ortho(l, r, b, t, n, f) { return new Float32Array([2 / (r - l), 0, 0, 0, 0, 2 / (t - b), 0, 0, 0, 0, -2 / (f - n), 0, -(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1]); }
|
||||
function point(m, p) { const a = [...p, 1], o = [0, 0, 0, 0]; for (let i = 0; i < 4; i++)for (let j = 0; j < 4; j++)o[i] += m[j * 4 + i] * a[j]; return o; }
|
||||
|
||||
function getAccessor(i) { const a = gltf.accessors[i], v = gltf.bufferViews[a.bufferView]; return { a, v, offset: (v.byteOffset || 0) + (a.byteOffset || 0) }; }
|
||||
function visible(m) { return standardsVisible(m) && interiorVisibility(m) && meshVisible(m); }
|
||||
async function initGL(){
|
||||
const requested=new URLSearchParams(location.search).get('renderer')||config.renderer;
|
||||
let primaryError=null;
|
||||
if(requested!=='legacy')try{
|
||||
const task=(async()=>{const {createThreeRenderer}=await import('./three-renderer.js');return await createThreeRenderer({canvas,gltf,binary,bytes:originalBytes,config});})();
|
||||
// Do not fall back after WebGL initialization begins: that would double-bind a canvas.
|
||||
backend=await task;
|
||||
}catch(error){primaryError=error;console.warn('Three.js unavailable, using local renderer:',error);}
|
||||
if(!backend){if(requested==='three')throw primaryError||Error('Three.js unavailable');backend=await createLegacyRenderer({canvas,gltf,binary});}
|
||||
gl=backend;meshes=backend.meshes;
|
||||
gltf.materials.forEach((m,i)=>originalColors[i]=[...(m.pbrMetallicRoughness?.baseColorFactor||[.7,.7,.7,1])]);
|
||||
window.MODEL_INFO={objects:meshes.length,triangles:meshes.reduce((n,m)=>n+m.count/3,0),loaded:true,backend:backend.backend};
|
||||
document.getElementById('webStatus').textContent=backend.label+' / GLB';
|
||||
resize();
|
||||
}
|
||||
function resize() { if (!gl) return; const r = canvas.getBoundingClientRect(), w = r.width, h = r.height, dpr = Math.min(devicePixelRatio || 1, 1.7); const newAspect = w / h; if (window.SUN_SIM && !interior.enabled) { distance *= Math.max(1, 1 / newAspect) / Math.max(1, 1 / aspect); animation = null; initialCamera.distance = Math.max(41, 38 / newAspect); presetDefs.entry.distance = Math.max(39, 38 / newAspect); presetDefs.top.distance = Math.max(46, 25 / newAspect); presetDefs.top.theta = newAspect < 1 ? Math.PI / 2 : 0; } viewW = w; viewH = h; backend.resize(w,h,Math.min(dpr,config.maxPixelRatio||1.5)); aspect = newAspect; if(interior.enabled && interior.mode !== 'walk' && !interior.userCamera)interiorFitCamera(); dirty = true; }
|
||||
function camera() { if(interior.enabled && interior.mode === 'walk'){interiorCamera();return;} eye = vadd(target, [Math.sin(theta) * Math.cos(phi) * distance, Math.sin(phi) * distance, Math.cos(theta) * Math.cos(phi) * distance]); const view = lookAt(eye, target); const span = distance * .28; const projection = orthographic ? ortho(-span * aspect, span * aspect, -span, span, .1, 1000) : perspective(34 * Math.PI / 180, aspect, .1, 1000); vp = mul(projection, view); }
|
||||
function render(){
|
||||
if(!backend)return;
|
||||
camera();
|
||||
const lookTarget=interior.enabled&&interior.mode==='walk'?vadd(eye,[Math.sin(interior.yaw)*Math.cos(interior.pitch),Math.sin(interior.pitch),Math.cos(interior.yaw)*Math.cos(interior.pitch)]):target;
|
||||
backend.draw({solar,interior,eye,vp,lightVP,lookTarget,orthographic:orthographic&&!(interior.enabled&&interior.mode==='walk'),visible});
|
||||
solar.shadowDirty=false;
|
||||
$('#compassGroup').setAttribute('transform',`rotate(${(theta-Math.PI/2)*180/Math.PI-solar.bearing},42,42)`);
|
||||
if(showMeasure)drawMeasures();if(solar.pos)solarOverlay();drawInteriorLabels();
|
||||
projectPanel?.drawSurvey(project,p=>point(vp,p),viewW,viewH);
|
||||
dirty=false;
|
||||
}
|
||||
function project(p) { let q = point(vp, p); return [(q[0] / q[3] * .5 + .5) * viewW, (-q[1] / q[3] * .5 + .5) * viewH]; }
|
||||
function drawMeasures() {
|
||||
const dims = [{ a: [-7.67, .12, 5.54], b: [7.67, .12, 5.54], t: '15,340 m' }, { a: [-8.48, .12, -4.695], b: [-8.48, .12, 4.695], t: '9,390 m' }, { a: [7.9, 0, 0], b: [7.9, 5.137, 0], t: '5,137 m' }];
|
||||
let h = ''; for (const d of dims) { let a = project(d.a), b = project(d.b), x = (a[0] + b[0]) / 2, y = (a[1] + b[1]) / 2 - 10; h += `<line x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}"/><circle cx="${a[0]}" cy="${a[1]}" r="2.5"/><circle cx="${b[0]}" cy="${b[1]}" r="2.5"/><text x="${x}" y="${y}" text-anchor="middle">${d.t}</text>`; } $('#measure').innerHTML = h;
|
||||
}
|
||||
function animate(time) { requestAnimationFrame(animate); const dt = Math.min(.05, (time - lastTime) / 1000 || .016); lastTime = time; solarFrame(dt); stepInterior(dt); if (animation) { const a = animation, t = Math.min(1, (time - a.start) / 650), k = t * t * (3 - 2 * t); theta = a.from.theta + (a.to.theta - a.from.theta) * k; phi = a.from.phi + (a.to.phi - a.from.phi) * k; distance = a.from.distance + (a.to.distance - a.from.distance) * k; target = a.from.target.map((v, i) => v + (a.to.target[i] - v) * k); dirty = true; if (t >= 1) { orthographic = a.to.ortho; animation = null; } } if (autoRotate && !pointerMap.size) { theta += dt * .11; dirty = true; } if (dirty && gl) render(); }
|
||||
function setCamera(to, label, instant = false) { autoRotate = false; $('#rotateButton').classList.remove('active'); if (instant) { theta = to.theta; phi = to.phi; distance = to.distance; target = [...to.target]; orthographic = to.ortho; animation = null; } else { let tt = to.theta; while (tt - theta > Math.PI) tt -= 2 * Math.PI; while (tt - theta < -Math.PI) tt += 2 * Math.PI; animation = { start: performance.now(), from: { theta, phi, distance, target: [...target] }, to: { ...to, theta: tt } }; orthographic = to.ortho; } $('#directionText').textContent = label; dirty = true; }
|
||||
const presetDefs = { entry: { theta: -.59, phi: .24, distance: 33.4, target: [-.25, 1.20, 0], ortho: false }, terrace: initialCamera, top: { theta: 0, phi: 1.5706, distance: 35, target: [1, .1, 0], ortho: true } };
|
||||
const presetNames = { entry: 'Vstup / severoz\u00e1pad', terrace: 'Terasa / jihov\u00fdchod', top: 'Pohled shora' };
|
||||
function setPreset(key, instant = false) { if(interior.enabled)toggleInterior(false); document.querySelectorAll('[data-preset]').forEach(b => b.classList.toggle('active', b.dataset.preset === key)); $('#elevation').value = ''; setCamera(presetDefs[key], presetNames[key], instant); }
|
||||
window.setPreset = setPreset;
|
||||
function pan(dx, dy) { const right = [Math.cos(theta), 0, -Math.sin(theta)]; const up = norm(cross(norm(vsub(eye, target)), right)); const k = distance * .0008; target = vadd(target, vadd(scale(right, -dx * k), scale(up, dy * k))); }
|
||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||
canvas.addEventListener('pointerdown', e => { if(interior.enabled && interior.mode==='walk'){e.preventDefault();canvas.setPointerCapture(e.pointerId);interior.lookPointer={id:e.pointerId,x:e.clientX,y:e.clientY};interior.keys.clear();return;} if(interior.enabled)interior.userCamera=true; canvas.setPointerCapture(e.pointerId); pointerMap.set(e.pointerId, { x: e.clientX, y: e.clientY, button: e.button }); animation = null; autoRotate = false; $('#rotateButton').classList.remove('active'); if (pointerMap.size === 2) { const p = [...pointerMap.values()]; pinch = { d: Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y), x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 }; } });
|
||||
canvas.addEventListener('pointermove', e => { if(interior.enabled && interior.mode==='walk'){const p=interior.lookPointer;if(p && p.id===e.pointerId){interior.yaw-=(e.clientX-p.x)*.004;interior.pitch=clamp(interior.pitch-(e.clientY-p.y)*.003,-1.3,1.3);p.x=e.clientX;p.y=e.clientY;dirty=true;}return;} const p = pointerMap.get(e.pointerId); if (!p) return; let dx = e.clientX - p.x, dy = e.clientY - p.y; pointerMap.set(e.pointerId, { ...p, x: e.clientX, y: e.clientY }); if (pointerMap.size === 2) { const ps = [...pointerMap.values()], d = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y), x = (ps[0].x + ps[1].x) / 2, y = (ps[0].y + ps[1].y) / 2; if (pinch) { distance = Math.max(1.2, Math.min(500, distance * pinch.d / Math.max(1, d))); pan(x - pinch.x, y - pinch.y); } pinch = { d, x, y }; } else if (p.button === 2 || e.shiftKey) pan(dx, dy); else { theta -= dx * .006; phi = Math.max(.03, Math.min(1.51, phi + dy * .006)); orthographic = false; } document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active')); $('#directionText').textContent = 'Voln\u00fd pohled'; dirty = true; });
|
||||
function pointerEnd(e) { if(interior.lookPointer?.id===e.pointerId)interior.lookPointer=null; pointerMap.delete(e.pointerId); pinch = null; }
|
||||
canvas.addEventListener('pointerup', pointerEnd); canvas.addEventListener('pointercancel', pointerEnd);
|
||||
canvas.addEventListener('wheel', e => { e.preventDefault(); if(interior.enabled && interior.mode==='walk'){interior.fov=clamp(interior.fov+e.deltaY*.025,45,88);dirty=true;return;} if(interior.enabled)interior.userCamera=true; distance = Math.max(1.2, Math.min(500, distance * Math.exp(e.deltaY * .001))); animation = null; dirty = true; }, { passive: false });
|
||||
document.querySelectorAll('[data-preset]').forEach(b => b.onclick = () => setPreset(b.dataset.preset));
|
||||
$('#resetView').onclick = () => setPreset('terrace');
|
||||
$('#terrainButton').onclick = () => { showTerrain = !showTerrain; $('#terrainButton').classList.toggle('active', !showTerrain); updateSolar(); dirty = true; };
|
||||
$('#measureButton').onclick = () => { showMeasure = !showMeasure; $('#measure').style.display = showMeasure ? 'block' : 'none'; $('#measureButton').classList.toggle('active', showMeasure); dirty = true; };
|
||||
$('#rotateButton').onclick = () => { autoRotate = !autoRotate; $('#rotateButton').classList.toggle('active', autoRotate); orthographic = false; dirty = true; };
|
||||
$('#infoButton').onclick = () => { $('#panel').classList.toggle('open'); $('#infoButton').classList.toggle('active'); };
|
||||
$('#elevation').onchange = e => { const d = { north: [-Math.PI / 2, 'Severn\u00ed pohled'], east: [Math.PI, 'V\u00fdchodn\u00ed pohled'], south: [Math.PI / 2, 'Ji\u017en\u00ed pohled'], west: [0, 'Z\u00e1padn\u00ed pohled'] }[e.target.value]; if (!d) return; document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active')); setCamera({ theta: d[0], phi: 0, distance: aspect < .8 ? 70 : 29, target: [0, 1.8, 0], ortho: true }, d[1]); };
|
||||
function hexToRgb(s) { return [1, 3, 5].map(i => parseInt(s.slice(i, i + 2), 16) / 255); }
|
||||
function recolor(type, hex) { const c = hexToRgb(hex); gltf.materials.forEach((m, i) => { let ratio = 1, match = false; if (type === 'wall') match = m.name === 'stucco'; if (type === 'roof') { match = m.name.startsWith('tile'); ratio = m.name === 'tile_1' ? 1.06 : m.name === 'tile_2' ? .94 : m.name === 'tile_3' ? 1.10 : m.name === 'tile_4' ? .88 : m.name === 'tile_edge' ? .90 : 1; } if (match) m.pbrMetallicRoughness.baseColorFactor = [...c.map(v => Math.min(1, v * ratio)), 1]; }); dirty = true; }
|
||||
$('#wallColor').oninput = e => recolor('wall', e.target.value); $('#roofColor').oninput = e => recolor('roof', e.target.value);
|
||||
$('#resetColors').onclick = () => { for (const [i, c] of Object.entries(originalColors)) { if (!gltf.materials[+i].extras?.interiorMaterial) gltf.materials[+i].pbrMetallicRoughness.baseColorFactor = [...c]; } $('#wallColor').value = '#c9c2ad'; $('#roofColor').value = '#414542'; setHardieColor(HARDIE_DEFAULT); dirty = true; };
|
||||
function toast(s) { $('#toast').textContent = s; $('#toast').style.opacity = 1; setTimeout(() => $('#toast').style.opacity = 0, 2600); }
|
||||
function saveBlob(blob, name) { const a = document.createElement('a'), u = URL.createObjectURL(blob); a.href = u; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(u), 5000); }
|
||||
function currentGlb() { if (!gltf) return originalBytes; const j = new TextEncoder().encode(JSON.stringify(exportVisibleGltf())), jl = Math.ceil(j.length / 4) * 4, bl = Math.ceil(binary.byteLength / 4) * 4, n = 12 + 8 + jl + 8 + bl, out = new Uint8Array(n), v = new DataView(out.buffer); v.setUint32(0, 0x46546c67, true); v.setUint32(4, 2, true); v.setUint32(8, n, true); v.setUint32(12, jl, true); v.setUint32(16, 0x4e4f534a, true); out.fill(32, 20, 20 + jl); out.set(j, 20); v.setUint32(20 + jl, bl, true); v.setUint32(24 + jl, 0x004e4942, true); out.set(binary, 28 + jl); return out; }
|
||||
$('#saveGlb').onclick = () => { saveBlob(new Blob([currentGlb()], { type: 'model/gltf-binary' }), 'dum_3D_HardiePlank_' + hardieSelected + '.glb'); toast('Model GLB je p\u0159ipraven\u00fd ke sta\u017een\u00ed.'); };
|
||||
$('#savePng').onclick = () => { render(); canvas.toBlob(b => saveBlob(b, 'dum_pohled.png'), 'image/png'); };
|
||||
window.addEventListener('resize', resize);
|
||||
window.addEventListener('keydown', e => { if(interior.enabled){handleInteriorKey(e);return;} if(e.code==='KeyI' && !/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)){toggleInterior(true);return;} if (e.key === 'Escape') { $('#panel').classList.remove('open'); $('#infoButton').classList.remove('active'); } if (/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return; if (e.key === '1') setPreset('entry'); if (e.key === '2') setPreset('terrace'); if (e.key === '3') setPreset('top'); if (e.key === 'r') setPreset('terrace'); });
|
||||
async function start(){
|
||||
try{
|
||||
({gltf,binary}=parseGLB(originalBytes));
|
||||
gltf.extras={...gltf.extras,...structuredClone(resources.scene)};
|
||||
await initGL();await loadHardieTextures();initHardiePalette();initInterior();initSolar();
|
||||
distance=Math.max(41,38/aspect);initialCamera.distance=distance;presetDefs.entry.distance=Math.max(39,38/aspect);presetDefs.top.distance=Math.max(46,25/aspect);
|
||||
if(aspect<1)presetDefs.top.theta=Math.PI/2;initSiteControls();applyStartupView();initStandards();
|
||||
projectPanel=installProjectPanel({resources,backend,getMode:()=>interior,project,focusSite:()=>{stdClose();if(interior.enabled)toggleInterior(false);focusSite('parcel');},invalidate:()=>{dirty=true;}});
|
||||
autoRotate=!!config.startAutoRotate;$('#rotateButton').classList.toggle('active',autoRotate);
|
||||
$('#loading').style.display='none';requestAnimationFrame(animate);
|
||||
window.LOCENICE_APP={backend:backend.backend,exportSource:()=>encodeGLB(gltf,binary),getMeshes:()=>meshes.map(m=>({name:m.name,nodeIndex:m.nodeIndex,visible:visible(m),material:m.material,std:m.std})),render};
|
||||
if('ontouchstart'in window)$('.help').innerHTML='Jednim prstem otacejte; dvema prsty priblizte a posunte.';
|
||||
}catch(error){console.error(error);$('#loading').className='error';$('#loading').innerHTML='<div class="errorDetail"></div><a class="btn primary" href="./models/dum.glb" download>Stahnout zdrojovy GLB</a><a class="btn" href="?renderer=legacy">Zkusit lokalni renderer</a>';
|
||||
$('#loading .errorDetail').textContent='Model se nepodarilo spustit: '+error.message;$('#webStatus').textContent='3D neni dostupne';}
|
||||
}
|
||||
|
||||
// Solar geometry: Meeus / NOAA. UTC is the only time basis for the ephemeris.
|
||||
// Model axes: +X = south gable, +Z = entrance (west) side, +Y = up.
|
||||
const RAD = Math.PI / 180, DAY_MS = 86400000;
|
||||
const solar = {
|
||||
date: '2026-06-21', minute: 600, lat: 48.8242, lon: 14.5275, bearing: 351.5,
|
||||
playing: false, speed: 30, shadows: true, path: false, start: 0, end: 0, length: 1440,
|
||||
rise: null, set: null, noon: null, samples: [], pos: null, light: [1, 1, 1], sky: [.906, .922, .890],
|
||||
sunColor: [1, 1, .95], ambient: [.40, .43, .46], direct: .85, dayFactor: 1, shadowDirty: true,
|
||||
accumulator: 0, clock: 0
|
||||
};
|
||||
let viewW = innerWidth, viewH = innerHeight;
|
||||
const mod = (x, n) => ((x % n) + n) % n;
|
||||
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
|
||||
const smooth = (a, b, x) => { let t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
|
||||
const mix3 = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
|
||||
const czNum = (v, d = 1) => v.toFixed(d).replace('.', ',');
|
||||
const localFormatter = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Prague', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
|
||||
function localParts(ms) { const o = {}; for (const p of localFormatter.formatToParts(new Date(ms))) if (p.type !== 'literal') o[p.type] = +p.value; return o; }
|
||||
function utcOffset(ms) { const p = localParts(ms); return Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000) / 60000); }
|
||||
function localMidnight(date) { const [y, m, d] = date.split('-').map(Number), naive = Date.UTC(y, m - 1, d); let u = naive; for (let i = 0; i < 4; i++)u = naive - utcOffset(u) * 60000; return u; }
|
||||
function isoDate(ms) { const p = localParts(ms); return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`; }
|
||||
function clockText(ms) { if (ms === null || !Number.isFinite(ms)) return '\u2014'; const p = localParts(ms); return String(p.hour).padStart(2, '0') + ':' + String(p.minute).padStart(2, '0'); }
|
||||
function czDate(iso) { const [y, m, d] = iso.split('-').map(Number); return `${d}. ${m}. ${y}`; }
|
||||
function zoneText(ms) { const o = utcOffset(ms); return (o === 120 ? 'SEL\u010c' : o === 60 ? 'SE\u010c' : 'Praha') + ' \u00b7 UTC' + (o >= 0 ? '+' : '') + o / 60; }
|
||||
|
||||
function solarVector(azimuth, elevation) { const a = (azimuth - solar.bearing) * RAD, e = elevation * RAD; return [-Math.cos(a) * Math.cos(e), Math.sin(e), -Math.sin(a) * Math.cos(e)]; }
|
||||
function timeForClock(text) {
|
||||
const [h, m] = text.split(':').map(Number); if (!/^\d{1,2}:\d{2}$/.test(text) || !Number.isFinite(h) || !Number.isFinite(m) || h < 0 || h > 23 || m < 0 || m > 59) return null; text = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
||||
const [y, mo, d] = solar.date.split('-').map(Number), naive = Date.UTC(y, mo - 1, d, h, m);
|
||||
const offsets = [...new Set([utcOffset(solar.start - 12 * 3600000), utcOffset(solar.start), utcOffset(solar.end - 1), utcOffset(solar.end + 12 * 3600000)])];
|
||||
const candidates = offsets.map(o => naive - o * 60000).filter(u => u >= solar.start && u < solar.end && clockText(u) === text).sort((a, b) => a - b);
|
||||
if (candidates.length) return candidates[0];
|
||||
// The missing spring-forward hour advances to the first valid following clock time.
|
||||
for (let i = 0; i < solar.length; i++) { const u = solar.start + i * 60000, p = localParts(u); if (p.hour * 60 + p.minute >= h * 60 + m) return u; }
|
||||
return solar.end - 60000;
|
||||
}
|
||||
function rootElevation(a, b, threshold) { let fa = solarPosition(a, solar.lat, solar.lon).elevation - threshold; for (let i = 0; i < 24; i++) { const m = (a + b) / 2, fm = solarPosition(m, solar.lat, solar.lon).elevation - threshold; if ((fa < 0) === (fm < 0)) { a = m; fa = fm; } else b = m; } return (a + b) / 2; }
|
||||
function rebuildSolarDay(preserveClock = true) {
|
||||
const clock = preserveClock && solar.start ? clockText(solar.start + solar.minute * 60000) : $('#sunClock').value || '10:00';
|
||||
solar.start = localMidnight(solar.date);
|
||||
const [y, m, d] = solar.date.split('-').map(Number), next = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
|
||||
solar.end = localMidnight(next); solar.length = Math.round((solar.end - solar.start) / 60000); solar.rise = null; solar.set = null; solar.samples = [];
|
||||
let prev = null, max = { alt: -999, ms: solar.start };
|
||||
for (let u = solar.start; u <= solar.end; u += 600000) {
|
||||
const p = solarPosition(u, solar.lat, solar.lon); solar.samples.push({ ms: u, ...p }); if (p.elevation > max.alt) max = { alt: p.elevation, ms: u };
|
||||
if (prev) { if (prev.elevation < -.833 && p.elevation >= -.833) solar.rise = rootElevation(prev.ms, u, -.833); if (prev.elevation >= -.833 && p.elevation < -.833) solar.set = rootElevation(prev.ms, u, -.833); } prev = { ms: u, ...p };
|
||||
}
|
||||
let lo = Math.max(solar.start, max.ms - 600000), hi = Math.min(solar.end, max.ms + 600000); for (let i = 0; i < 36; i++) { let a = lo + (hi - lo) / 3, b = hi - (hi - lo) / 3; if (solarPosition(a, solar.lat, solar.lon).elevation < solarPosition(b, solar.lat, solar.lon).elevation) lo = a; else hi = b; } solar.noon = (lo + hi) / 2;
|
||||
solar.minute = clamp((timeForClock(clock) - solar.start) / 60000, 0, solar.length - 1);
|
||||
$('#sunDate').value = solar.date; $('#sunTime').max = solar.length - 1;
|
||||
$('#sunRise').textContent = clockText(solar.rise === null ? null : solar.rise + 30000); $('#sunSet').textContent = clockText(solar.set === null ? null : solar.set + 30000); $('#sunNoon').textContent = clockText(solar.noon + 30000);
|
||||
document.querySelectorAll('[data-season]').forEach(b => b.classList.toggle('active', solar.date.endsWith(b.dataset.season)));
|
||||
const tickEl = $('.sun-ticks'); tickEl.style.position = 'relative'; tickEl.style.height = '12px';
|
||||
[...tickEl.children].forEach((el, i) => { const mm = i === 4 ? solar.length : (timeForClock(el.textContent) - solar.start) / 60000; el.style.cssText = `position:absolute;left:${100 * mm / solar.length}%;transform:translateX(${i === 0 ? 0 : i === 4 ? -100 : -50}%)`; });
|
||||
$('#solarMessage').textContent = solar.length === 1440 ? '' : `Tento den m\u00e1 ${solar.length / 60} hodin. \u010casov\u00e1 osa respektuje zm\u011bnu letn\u00edho \u010dasu.`;
|
||||
drawSolarGraphBase(); updateSolar();
|
||||
}
|
||||
let graphBase = '';
|
||||
function drawSolarGraphBase() {
|
||||
const y = alt => 70 - clamp(alt, 0, 90) * .65;
|
||||
const pts = solar.samples.map(p => `${8 + 288 * (p.ms - solar.start) / (solar.end - solar.start)},${y(p.elevation)}`);
|
||||
const curve = 'M' + pts.join(' L'), max = solarPosition(solar.noon, solar.lat, solar.lon).elevation;
|
||||
graphBase = `<defs><linearGradient id="solarFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d4b465" stop-opacity=".34"/><stop offset="1" stop-color="#d4b465" stop-opacity=".04"/></linearGradient></defs><line x1="8" y1="70" x2="296" y2="70" stroke="#dce2d3"/><line x1="8" y1="40.75" x2="296" y2="40.75" stroke="#e4e8dd" stroke-dasharray="3 4"/><text x="294" y="37" text-anchor="end" fill="#a1a891" font-size="8">45\u00b0</text><path d="${curve} L296,70 L8,70 Z" fill="url(#solarFill)"/><path d="${curve}" fill="none" stroke="#c29b4b" stroke-width="1.5"/>`;
|
||||
}
|
||||
function updateSolarGraph() { const p = solar.pos, x = 8 + 288 * solar.minute / solar.length, y = 70 - clamp(p.elevation, 0, 90) * .65; $('#sunGraph').innerHTML = graphBase + `<line x1="${x}" y1="10" x2="${x}" y2="75" stroke="#a97731" stroke-width="1" stroke-dasharray="2 3"/><circle cx="${x}" cy="${y}" r="4" fill="#bc8933" stroke="#fafbf7" stroke-width="2"/>`; }
|
||||
function updateSolar() {
|
||||
solar.clock = solar.start + solar.minute * 60000; solar.pos = solarPosition(solar.clock, solar.lat, solar.lon);
|
||||
const a = solar.pos.elevation; solar.light = solarVector(solar.pos.azimuth, a);
|
||||
solar.dayFactor = smooth(-10, 4, a); const daylight = solar.dayFactor;
|
||||
const warm = 1 - smooth(0, 24, a);
|
||||
solar.sunColor = mix3([1.07, 1.03, .95], [1.30, .78, .38], warm);
|
||||
solar.direct = a > 0 ? (1.02 - .32 * Math.exp(-Math.max(a, 0) / 9)) * smooth(0, 1.2, a) : 0;
|
||||
solar.ambient = mix3([.017, .027, .047], [.38, .415, .44], daylight);
|
||||
solar.sky = mix3([.075, .112, .157], [.906, .922, .890], daylight);
|
||||
if (a > -7 && a < 9) solar.sky = mix3(solar.sky, [.89, .83, .74], .16 * Math.sin((a + 7) / 16 * Math.PI));
|
||||
const lt = [0, 1, 0], lp = vadd(lt, scale(solar.light, 70));
|
||||
lightVP = mul(ortho(-22, 22, -21, 21, 1, 145), lookAt(lp, lt, Math.abs(solar.light[1]) > .999 ? [0, 0, 1] : [0, 1, 0]));
|
||||
updateSiteLight();
|
||||
solar.shadowDirty = true; dirty = true;
|
||||
$('#sunClock').value = clockText(solar.clock); $('#sunTime').value = Math.floor(solar.minute); $('#sunTime').setAttribute('aria-valuetext', clockText(solar.clock));
|
||||
$('#zoneLabel').textContent = zoneText(solar.clock);
|
||||
$('#sunElevation').textContent = czNum(a) + '\u00b0'; $('#sunAzimuth').textContent = czNum(solar.pos.azimuth) + '\u00b0';
|
||||
const dirs = ['sever', 'severov\u00fdchod', 'v\u00fdchod', 'jihov\u00fdchod', 'jih', 'jihoz\u00e1pad', 'z\u00e1pad', 'severoz\u00e1pad'];
|
||||
$('#sunDirection').textContent = dirs[Math.round(solar.pos.azimuth / 45) % 8] + ' \u00b7 od severu';
|
||||
$('#dayState').textContent = a > 0 ? 'Slunce nad obzorem' : a > -.833 ? 'Slunce na obzoru' : a > -6 ? 'Soumrak' : 'Noc \u00b7 bez p\u0159\u00edm\u00e9ho slunce';
|
||||
$('#sceneTime').textContent = czDate(solar.date) + ' \u00b7 ' + clockText(solar.clock) + ' ' + (utcOffset(solar.clock) === 120 ? 'SEL\u010c' : 'SE\u010c');
|
||||
$('#bearingNote').textContent = `Ji\u017en\u00ed \u0161t\u00edt: ${czNum(mod(solar.bearing + 180, 360))}\u00b0 \u00b7 vstupn\u00ed fas\u00e1da: ${czNum(mod(solar.bearing + 270, 360))}\u00b0.`;
|
||||
document.body.classList.toggle('night', a < -4); document.body.style.backgroundColor = `rgb(${solar.sky.map(v => Math.round(v * 255)).join(',')})`;
|
||||
updateSolarGraph();
|
||||
}
|
||||
function solarFrame(dt) { if (!solar.playing) return; solar.minute += dt * solar.speed; if (solar.minute >= solar.length) solar.minute = 0; solar.accumulator += dt; if (solar.accumulator >= .075) { solar.accumulator = 0; updateSolar(); } }
|
||||
function setSolarPlaying(on) { solar.playing = on; $('#sunPlay').innerHTML = on ? '❚❚ 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 = `<path d="${d}" fill="none" stroke="#b28b40" stroke-width="1.5" stroke-dasharray="5 5" opacity=".7"/>`;
|
||||
for (let hour = 6; hour <= 21; hour += 3) { const ms = timeForClock(String(hour).padStart(2, '0') + ':00'), p = solarPosition(ms, solar.lat, solar.lon); if (p.elevation < 0) continue; const v = pathPoint(p.azimuth, p.elevation); if (point(vp, v)[3] <= 0) continue; const q = project(v); out += `<circle cx="${q[0]}" cy="${q[1]}" r="2.5" fill="#bd984f"/><text x="${q[0] + 6}" y="${q[1] - 7}">${hour}:00</text>`; }
|
||||
if (solar.pos.elevation >= 0) { const v = pathPoint(solar.pos.azimuth, solar.pos.elevation), q = project(v); if (point(vp, v)[3] > 0) { const base = project([v[0], center[1], v[2]]); out += `<line x1="${q[0]}" y1="${q[1]}" x2="${base[0]}" y2="${base[1]}" stroke="#c09a4f" stroke-dasharray="3 5" opacity=".45"/><circle cx="${q[0]}" cy="${q[1]}" r="18" fill="#e2bc5c" opacity=".13"/><circle cx="${q[0]}" cy="${q[1]}" r="11" fill="#e2bc5c" opacity=".25"/><circle cx="${q[0]}" cy="${q[1]}" r="6.5" fill="#e7bd59" stroke="#fff5cf" stroke-width="2"/>`; } }
|
||||
svg.innerHTML = out;
|
||||
}
|
||||
async function saveSolarImage() {
|
||||
setSolarPlaying(false); updateSolar(); render();
|
||||
const out = document.createElement('canvas'), ratio = canvas.width / viewW, footer = Math.round(77 * ratio); out.width = canvas.width; out.height = canvas.height + footer; const ctx = out.getContext('2d'); ctx.drawImage(canvas, 0, 0);
|
||||
if (solar.path) { try { const xml = `<svg xmlns="http://www.w3.org/2000/svg" width="${viewW}" height="${viewH}" viewBox="0 0 ${viewW} ${viewH}"><style>text{font:10px Arial;fill:#96712e;paint-order:stroke;stroke:#f3f4e9;stroke-width:3px;stroke-linejoin:round}</style>${$('#sunOverlay').innerHTML}</svg>`, u = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml' })); const im = new Image(); await new Promise((resolve, reject) => { im.onload = resolve; im.onerror = reject; im.src = u; }); ctx.drawImage(im, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(u); } catch (e) { console.warn('Solar overlay export', e); } }
|
||||
ctx.fillStyle = '#fafbf6'; ctx.fillRect(0, canvas.height, canvas.width, footer); ctx.fillStyle = '#3e5138'; ctx.font = `${Math.round(12 * ratio)}px Arial`;
|
||||
let primary = `Lo\u010denice | ${czDate(solar.date)} | ${clockText(solar.clock)} ${zoneText(solar.clock)} | v\u00fd\u0161ka ${czNum(solar.pos.elevation)}\u00b0 | azimut ${czNum(solar.pos.azimuth)}\u00b0`;
|
||||
let secondary = `Terén: C.3 + interpolace výškopisu. Bez okolní zástavby, stromů a místního horizontu.`;
|
||||
const max = canvas.width - 32 * ratio; while (ctx.measureText(primary).width > max) { const px = parseFloat(ctx.font); if (px <= 8 * ratio) break; ctx.font = `${px - 1}px Arial`; }
|
||||
ctx.fillText(primary, 16 * ratio, canvas.height + 26 * ratio); ctx.font = `${Math.round(10 * ratio)}px Arial`; ctx.fillStyle = '#7d8976';
|
||||
if (ctx.measureText(secondary).width > max) secondary = 'Orienta\u010dn\u00ed studie. Bez okoln\u00ed z\u00e1stavby a strom\u016f.';
|
||||
ctx.fillText(secondary, 16 * ratio, canvas.height + 47 * ratio); ctx.fillText(`GPS ${solar.lat.toFixed(4)}, ${solar.lon.toFixed(4)} | severn\u00ed \u0161t\u00edt ${czNum(solar.bearing)}\u00b0`, 16 * ratio, canvas.height + 64 * ratio);
|
||||
out.toBlob(b => { if (b) saveBlob(b, `dum_slunce_${solar.date}_${clockText(solar.clock).replace(':', '-')}.png`); }, 'image/png');
|
||||
}
|
||||
function initSolar() {
|
||||
$('#sunDate').onchange = e => { if (!e.target.value || !e.target.checkValidity()) { toast('Zadej datum v letech 2000 a\u017e 2100.'); e.target.value = solar.date; return; } setSolarPlaying(false); solar.date = e.target.value; rebuildSolarDay(); };
|
||||
$('#sunClock').onchange = e => { if (!e.target.value) return; setSolarPlaying(false); const u = timeForClock(e.target.value); if (u === null) { toast('Zadej \u010das HH:MM, nap\u0159. 09:30.'); e.target.value = clockText(solar.clock); return; } solar.minute = (u - solar.start) / 60000; updateSolar(); };
|
||||
$('#sunTime').oninput = e => { setSolarPlaying(false); solar.minute = +e.target.value; updateSolar(); };
|
||||
$('#sunPlay').onclick = () => setSolarPlaying(!solar.playing); $('#sunSpeed').onchange = e => solar.speed = +e.target.value;
|
||||
$('#sunNow').onclick = () => { const now = Date.now(); setSolarPlaying(false); solar.date = isoDate(now); rebuildSolarDay(false); solar.minute = clamp((now - solar.start) / 60000, 0, solar.length - 1); updateSolar(); };
|
||||
document.querySelectorAll('[data-season]').forEach(b => b.onclick = () => { setSolarPlaying(false); solar.date = solar.date.slice(0, 4) + '-' + b.dataset.season; rebuildSolarDay(); });
|
||||
$('#sunShadows').onchange = e => { solar.shadows = e.target.checked; solar.shadowDirty = true; dirty = true; };
|
||||
$('#sunPath').onchange = e => { solar.path = e.target.checked; dirty = true; $('#pathHint').textContent = solar.path ? 'Dr\u00e1ha je symbolick\u00e1, v mal\u00e9m m\u011b\u0159\u00edtku. Pro celou dr\u00e1hu oddal pohled kole\u010dkem nebo dv\u011bma prsty.' : 'Posouvej \u010das a sleduj, kam dopad\u00e1 slunce na terasu a fas\u00e1dy. Nato\u010den\u00ed pohledu polohu slunce nem\u011bn\u00ed.'; };
|
||||
for (const [id, key] of [['sunLat', 'lat'], ['sunLon', 'lon'], ['houseBearing', 'bearing']]) $('#' + id).onchange = e => { const val = +e.target.value; if (!e.target.value || !Number.isFinite(val) || !e.target.checkValidity()) { toast('Zadej hodnotu v povolen\u00e9m rozsahu.'); e.target.value = solar[key]; return; } solar[key] = val; rebuildSolarDay(); };
|
||||
$('#sunResetSite').onclick = () => { solar.lat = 48.8242; solar.lon = 14.5275; solar.bearing = 351.5; $('#sunLat').value = solar.lat; $('#sunLon').value = solar.lon; $('#houseBearing').value = solar.bearing; rebuildSolarDay(); };
|
||||
$('#sunToggle').onclick = () => toggleSunPanel(); $('#sunClose').onclick = () => toggleSunPanel(false); $('#sunSavePng').onclick = saveSolarImage; $('#savePng').onclick = saveSolarImage;
|
||||
$('#infoButton').onclick = () => { const open = !$('#panel').classList.contains('open'); $('#panel').classList.toggle('open', open); $('#infoButton').classList.toggle('active', open); if (open) toggleSunPanel(false); };
|
||||
const graph = $('#sunGraph'); function scrubGraph(e) { const r = graph.getBoundingClientRect(); solar.minute = clamp(((e.clientX - r.left) / r.width * 304 - 8) / 288 * solar.length, 0, solar.length - 1); setSolarPlaying(false); updateSolar(); }
|
||||
graph.addEventListener('pointerdown', e => { graph.setPointerCapture(e.pointerId); scrubGraph(e); }); graph.addEventListener('pointermove', e => { if (graph.hasPointerCapture(e.pointerId)) scrubGraph(e); }); graph.addEventListener('pointerup', e => { if (graph.hasPointerCapture(e.pointerId)) graph.releasePointerCapture(e.pointerId); });
|
||||
window.addEventListener('keydown', e => { if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'SUMMARY'].includes(e.target.tagName)) return; if (e.code === 'Space') { e.preventDefault(); setSolarPlaying(!solar.playing); } if (e.key.toLowerCase() === 's') toggleSunPanel(); });
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) setSolarPlaying(false); });
|
||||
const cp = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); cp.id = 'compassSun'; cp.setAttribute('r', '3'); cp.setAttribute('fill', '#c89439'); cp.setAttribute('stroke', '#fff7d7'); cp.setAttribute('stroke-width', '1'); $('#compassGroup').appendChild(cp);
|
||||
toggleSunPanel(false); rebuildSolarDay(false);
|
||||
// Read-only diagnostics and small public control API for verification and reuse.
|
||||
window.SUN_SIM = { position: solarPosition, getState: () => ({ date: solar.date, time: clockText(solar.clock), utc: new Date(solar.clock).toISOString(), dayMinutes: solar.length, bearing: solar.bearing, lat: solar.lat, lon: solar.lon, azimuth: solar.pos.azimuth, elevation: solar.pos.elevation, light: solar.light.slice(), rise: clockText(solar.rise), set: clockText(solar.set), noon: clockText(solar.noon), shadows: solar.shadows, playing: solar.playing }), setDate: d => { solar.date = d; rebuildSolarDay(); }, setTime: t => { solar.minute = (timeForClock(t) - solar.start) / 60000; updateSolar(); }, setBearing: b => { solar.bearing = mod(b, 360); $('#houseBearing').value = solar.bearing; updateSolar(); }, pause: () => setSolarPlaying(false) };
|
||||
}
|
||||
|
||||
// Independent site layers: initSiteControls enables all three on each fresh open.
|
||||
const site = { road: false, parcel: false, parking: false, savedCamera: null, lastFocus: null };
|
||||
const siteActive = () => site.road || site.parcel || site.parking;
|
||||
function meshVisible(m) {
|
||||
if (m.legacySite) return false;
|
||||
switch (m.siteLayer) {
|
||||
case 'local': return showTerrain;
|
||||
case 'north': return showTerrain && siteActive();
|
||||
case 'parcel': case 'boundary': return showTerrain && site.parcel;
|
||||
case 'road': return site.road;
|
||||
case 'parking': return site.parking;
|
||||
default: return showTerrain || !m.context;
|
||||
}
|
||||
}
|
||||
function visibleBounds() {
|
||||
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
||||
for (const m of meshes) if (visible(m) && m.bounds) {
|
||||
for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], m.bounds.min[i]); hi[i] = Math.max(hi[i], m.bounds.max[i]); }
|
||||
}
|
||||
return { lo, hi };
|
||||
}
|
||||
function boxCorners(lo, hi) {
|
||||
const out = []; for (let k = 0; k < 8; k++) out.push([0, 1, 2].map(i => k & (1 << i) ? hi[i] : lo[i])); return out;
|
||||
}
|
||||
function fittedSiteCamera(mode) {
|
||||
let bounds = visibleBounds();
|
||||
if (mode === 'parking') bounds = { lo: [-22.2, -.8, -8.0], hi: [-6.3, 5.8, 7.1] };
|
||||
const mobile = aspect < 1;
|
||||
const th = mode === 'parcel' ? (mobile ? Math.PI / 2 + .12 : .24) : (mobile ? -.94 : -.44);
|
||||
const ph = mode === 'parcel' ? (mobile ? 1.02 : .88) : .72;
|
||||
const center = bounds.lo.map((v, i) => (v + bounds.hi[i]) / 2);
|
||||
const forward = [Math.sin(th) * Math.cos(ph), Math.sin(ph), Math.cos(th) * Math.cos(ph)];
|
||||
const right = [Math.cos(th), 0, -Math.sin(th)], up = cross(forward, right);
|
||||
const tangent = Math.tan(17 * Math.PI / 180);
|
||||
const usableY = mobile ? .58 : .69, usableX = .82;
|
||||
let dist = 16;
|
||||
for (const p of boxCorners(bounds.lo, bounds.hi)) {
|
||||
const d = vsub(p, center), depth = dot(d, forward);
|
||||
dist = Math.max(dist, depth + Math.abs(dot(d, right)) / (tangent * aspect * usableX), depth + Math.abs(dot(d, up)) / (tangent * usableY));
|
||||
}
|
||||
return { theta: th, phi: ph, target: center, distance: dist + 2, ortho: false };
|
||||
}
|
||||
function focusSite(mode, instant = false) {
|
||||
if (!siteActive()) { setPreset('terrace', instant); return; }
|
||||
mode = mode || (site.parcel ? 'parcel' : site.road ? 'road' : 'parking');
|
||||
site.lastFocus = mode;
|
||||
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
|
||||
$('#elevation').value = '';
|
||||
const label = { road: 'Cesta a přístup k domu', parcel: 'Celý pozemek', parking: 'Parkování u příjezdu' }[mode];
|
||||
setCamera(fittedSiteCamera(mode), label, instant);
|
||||
}
|
||||
function setSiteLayer(key, on, focus = true) {
|
||||
if (!['road', 'parcel', 'parking'].includes(key)) return;
|
||||
const was = siteActive();
|
||||
if (!was && on) site.savedCamera = { theta, phi, distance, target: [...target], ortho: orthographic, label: $('#directionText').textContent };
|
||||
site[key] = !!on;
|
||||
const ids = { road: 'siteRoad', parcel: 'siteParcel', parking: 'siteParking' };
|
||||
const input = $('#' + ids[key]); input.checked = site[key];
|
||||
input.closest('label').classList.toggle('selected', site[key]);
|
||||
document.body.classList.toggle('site-active', siteActive());
|
||||
$('#siteMeta').hidden = !siteActive();
|
||||
$('#siteMeta').textContent = 'C.3 + výškopis · mezi body interpolováno';
|
||||
updateSolar();
|
||||
if (focus) {
|
||||
if (siteActive()) focusSite(on ? key : null);
|
||||
else if (site.savedCamera) {
|
||||
setCamera(site.savedCamera, site.savedCamera.label || 'Dům');
|
||||
site.savedCamera = null; site.lastFocus = null;
|
||||
}
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
function updateSiteLight() {
|
||||
if(interior.enabled){
|
||||
const center=[1,2,0],lightEye=vadd(center,scale(solar.light,45)),view=lookAt(lightEye,center,Math.abs(solar.light[1])>.999?[0,0,1]:[0,1,0]);
|
||||
const cs=boxCorners([-8,-.6,-5],[10,5.9,5]).map(p=>point(view,p)),xs=cs.map(p=>p[0]),ys=cs.map(p=>p[1]);
|
||||
lightVP=mul(ortho(Math.min(...xs)-2,Math.max(...xs)+2,Math.min(...ys)-2,Math.max(...ys)+2,1,100),view);return;
|
||||
}
|
||||
if (!siteActive()) return;
|
||||
const { lo, hi } = visibleBounds(), center = lo.map((v, i) => (v + hi[i]) / 2);
|
||||
const lightEye = vadd(center, scale(solar.light, 115));
|
||||
const view = lookAt(lightEye, center, Math.abs(solar.light[1]) > .999 ? [0, 0, 1] : [0, 1, 0]);
|
||||
const cs = boxCorners(lo, hi).map(p => point(view, p));
|
||||
const xs = cs.map(p => p[0]), ys = cs.map(p => p[1]);
|
||||
lightVP = mul(ortho(Math.min(...xs) - 3, Math.max(...xs) + 3, Math.min(...ys) - 3, Math.max(...ys) + 3, 1, 240), view);
|
||||
}
|
||||
function exportVisibleGltf(){
|
||||
const selected=new Set(meshes.filter(visible).map(m=>m.nodeIndex));
|
||||
const keep=new Set();function retain(i){const n=gltf.nodes[i];const children=(n.children||[]).filter(retain);const yes=selected.has(i)||children.length>0;if(yes)keep.add(i);return yes;}
|
||||
for(const sc of gltf.scenes||[])for(const i of sc.nodes||[])retain(i);
|
||||
const nodes=gltf.nodes.map((n,i)=>({...n,...(n.children?{children:n.children.filter(c=>keep.has(c))}:{})}));
|
||||
const scenes=(gltf.scenes||[]).map(sc=>({...sc,nodes:(sc.nodes||[]).filter(i=>keep.has(i))}));
|
||||
return {...gltf,nodes,scenes,extras:{...gltf.extras,visibleSiteLayers:{road:site.road,parcel:site.parcel,parking:site.parking,terrain:showTerrain},allstavSelection:stdClone(stdState),interiorView:{enabled:interior.enabled,mode:interior.mode}}};
|
||||
}
|
||||
|
||||
function initSiteControls() {
|
||||
for (const [id, key] of [['siteRoad', 'road'], ['siteParcel', 'parcel'], ['siteParking', 'parking']]) {
|
||||
const input = $('#' + id); input.onchange = () => setSiteLayer(key, input.checked);
|
||||
setSiteLayer(key, true, false);
|
||||
}
|
||||
$('#siteFit').onclick = () => focusSite();
|
||||
window.SITE_SIM = {
|
||||
getState: () => ({
|
||||
road: site.road, parcel: site.parcel, parking: site.parking, terrain: showTerrain,
|
||||
visibleObjects: meshes.filter(visible).length, referenceElevation: 591.840,
|
||||
projectArea: 1010, digitizedArea: 987.89, houseGeometryUnchanged: true
|
||||
}),
|
||||
setLayer: (key, on, focus = true) => setSiteLayer(key, on, focus),
|
||||
focus: mode => focusSite(mode),
|
||||
reset: () => { for (const key of ['road', 'parcel', 'parking']) setSiteLayer(key, false, false); setPreset('terrace'); },
|
||||
getCamera: () => ({ theta, phi, distance, target: [...target], aspect }),
|
||||
checkGround: () => gltf.extras.siteStudy.finishedGroundC3
|
||||
};
|
||||
}
|
||||
|
||||
// Optional additive interior, independent of the retained exterior and solar state.
|
||||
const interior = {
|
||||
enabled: false, mode: 'axon', furniture: true, labels: true, fill: true,
|
||||
position: [3.78, 1.62, .90], yaw: 0, pitch: 0, fov: 64,
|
||||
saved: null, data: null, lookPointer: null, keys: new Set(), pad: new Set(),
|
||||
panel: true, userCamera: false, lastView: 'kitchen', currentRoom: '',
|
||||
roof: new Set(), cutOriginals: new Set(), ready: false
|
||||
};
|
||||
function interiorVisibility(m) {
|
||||
const layer = m.interiorLayer;
|
||||
if (!interior.enabled) return !layer;
|
||||
const walking = interior.mode === 'walk';
|
||||
if (layer) {
|
||||
if (layer === 'cut' || layer === 'cutShell') return !walking;
|
||||
if (['wall', 'liner', 'door', 'ceiling', 'beams'].includes(layer)) return walking;
|
||||
if (layer === 'furniture') return interior.furniture;
|
||||
return true;
|
||||
}
|
||||
if (!walking) {
|
||||
if (interior.roof.has(m.nodeIndex) || interior.cutOriginals.has(m.nodeIndex)) return false;
|
||||
if (m.siteLayer || m.context || /^(25_|26_|27_)/.test(m.name)) return false;
|
||||
} else if (m.glass) return false; // Optical opening only; source glazing is untouched.
|
||||
return true;
|
||||
}
|
||||
function interiorPointIn(poly, x, z) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const a = poly[i], b = poly[j];
|
||||
if ((a[1] > z) !== (b[1] > z) && x < (b[0] - a[0]) * (z - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
function interiorCanStand(x, z) {
|
||||
const d = interior.data, r = .16;
|
||||
if (!d || ![[x-r,z],[x+r,z],[x,z-r],[x,z+r]].every(p => interiorPointIn(d.footprint,p[0],p[1]))) return false;
|
||||
for (const b of d.wallColliders) if (x > b.lo[0]-r && x < b.hi[0]+r && z > b.lo[1]-r && z < b.hi[1]+r) return false;
|
||||
if (interior.furniture) for (const b of d.furnitureColliders) if (x > b.lo[0]-r && x < b.hi[0]+r && z > b.lo[1]-r && z < b.hi[1]+r) return false;
|
||||
return true;
|
||||
}
|
||||
function moveInterior(dx, dz) {
|
||||
// Small substeps and axis sliding keep narrow 700 mm doorways usable.
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(dx,dz)/.055));
|
||||
for (let i = 0; i < steps; i++) {
|
||||
let x = interior.position[0], z = interior.position[2];
|
||||
if (interiorCanStand(x+dx/steps,z)) interior.position[0] = x+dx/steps;
|
||||
x = interior.position[0];
|
||||
if (interiorCanStand(x,z+dz/steps)) interior.position[2] = z+dz/steps;
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
function stepInterior(dt) {
|
||||
if (!interior.enabled || interior.mode !== 'walk') return;
|
||||
const k = interior.keys, p = interior.pad;
|
||||
let f = (k.has('KeyW')||k.has('ArrowUp')||p.has('forward')?1:0)-(k.has('KeyS')||k.has('ArrowDown')||p.has('back')?1:0);
|
||||
let side = (k.has('KeyD')||p.has('right')?1:0)-(k.has('KeyA')||p.has('left')?1:0);
|
||||
const turn = (k.has('ArrowLeft')||k.has('KeyQ')?1:0)-(k.has('ArrowRight')||k.has('KeyE')?1:0);
|
||||
if (turn) {interior.yaw += turn*dt*1.25;dirty=true;}
|
||||
if (f || side) {
|
||||
const len = Math.hypot(f,side), speed = (k.has('ShiftLeft')||k.has('ShiftRight')?2.15:1.2)*dt;
|
||||
f/=len;side/=len;
|
||||
moveInterior((Math.sin(interior.yaw)*f-Math.cos(interior.yaw)*side)*speed,(Math.cos(interior.yaw)*f+Math.sin(interior.yaw)*side)*speed);
|
||||
}
|
||||
}
|
||||
function interiorCamera() {
|
||||
eye = [...interior.position];
|
||||
const dir = [Math.sin(interior.yaw)*Math.cos(interior.pitch),Math.sin(interior.pitch),Math.cos(interior.yaw)*Math.cos(interior.pitch)];
|
||||
vp = mul(perspective(interior.fov*Math.PI/180,aspect,.035,400),lookAt(eye,vadd(eye,dir)));
|
||||
}
|
||||
function interiorFitCamera() {
|
||||
if (!interior.enabled || interior.mode === 'walk' || !interior.data) return;
|
||||
const mobile = aspect < 1;
|
||||
theta = interior.mode === 'plan' ? (mobile ? Math.PI/2 : 0) : .68;
|
||||
phi = interior.mode === 'plan' ? 1.5706 : .93;
|
||||
target = [1.6,.4,0]; orthographic = true;animation=null;autoRotate=false;
|
||||
const forward = [Math.sin(theta)*Math.cos(phi),Math.sin(phi),Math.cos(theta)*Math.cos(phi)];
|
||||
const right = [Math.cos(theta),0,-Math.sin(theta)], up = cross(forward,right);
|
||||
distance = 10;
|
||||
for (const p of boxCorners([-8.05,-.2,-5.0],[11.68,2.3,5.05])) {
|
||||
const q = vsub(p,target);
|
||||
distance = Math.max(distance,Math.abs(dot(q,right))/(.28*aspect*.89),Math.abs(dot(q,up))/(.28*.76));
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
function updateInteriorInterface() {
|
||||
document.body.classList.toggle('interior-active',interior.enabled);
|
||||
document.body.classList.toggle('interior-walk',interior.enabled&&interior.mode==='walk');
|
||||
document.body.classList.toggle('interior-panel-closed',interior.enabled&&!interior.panel);
|
||||
$('#interiorLaunch').setAttribute('aria-expanded',String(interior.enabled));
|
||||
$('#interiorPanelToggle').setAttribute('aria-expanded',String(interior.panel));
|
||||
document.querySelectorAll('[data-interior-mode]').forEach(b=>b.classList.toggle('active',b.dataset.interiorMode===interior.mode));
|
||||
const walking=interior.mode==='walk';
|
||||
$('#interiorRoomLabels').disabled=walking;
|
||||
$('#interiorHint').textContent=walking?'W / A / S / D nebo šipky · tažením se rozhlížejte · Shift = rychleji · Esc = exteriér':'Tažením otáčejte · kolečkem přibližujte · kliknutím na místnost vstupte dovnitř';
|
||||
if(!walking)$('#interiorViewTitle').textContent=interior.mode==='plan'?'Půdorys 1. NP':'Dům bez střechy';
|
||||
if(!interior.enabled)$('#interiorLabels').replaceChildren();
|
||||
}
|
||||
function toggleInterior(on) {
|
||||
if (!interior.ready) return;
|
||||
on = on === undefined ? !interior.enabled : !!on;
|
||||
if (on === interior.enabled) return;
|
||||
if (on) {
|
||||
interior.saved = {theta,phi,distance,target:[...target],ortho:orthographic,autoRotate,aspect,
|
||||
label:$('#directionText').textContent,sunOpen:$('#sunPanel').classList.contains('open'),panelOpen:$('#panel').classList.contains('open'),
|
||||
presets:[...document.querySelectorAll('[data-preset].active')].map(b=>b.dataset.preset),elevation:$('#elevation').value};
|
||||
interior.enabled=true;interior.mode='axon';interior.panel=true;interior.userCamera=false;
|
||||
toggleSunPanel(false);$('#panel').classList.remove('open');$('#infoButton').classList.remove('active');
|
||||
autoRotate=false;animation=null;pointerMap.clear();$('#rotateButton').classList.remove('active');
|
||||
updateInteriorInterface();resize();interiorFitCamera();
|
||||
toast('Interiér zapnutý. Klikněte na místnost nebo zvolte Procházet.');
|
||||
} else {
|
||||
const s=interior.saved;
|
||||
interior.enabled=false;interior.keys.clear();interior.pad.clear();interior.lookPointer=null;
|
||||
updateInteriorInterface();toggleSunPanel(s?.sunOpen||false);resize();
|
||||
if(s){
|
||||
setCamera({...s,distance:s.distance*Math.max(1,1/aspect)/Math.max(1,1/s.aspect)},s.label,true);
|
||||
autoRotate=s.autoRotate;$('#rotateButton').classList.toggle('active',autoRotate);
|
||||
$('#panel').classList.toggle('open',s.panelOpen);$('#infoButton').classList.toggle('active',s.panelOpen);
|
||||
$('#elevation').value=s.elevation;
|
||||
document.querySelectorAll('[data-preset]').forEach(b=>b.classList.toggle('active',s.presets.includes(b.dataset.preset)));
|
||||
}
|
||||
}
|
||||
updateSolar();solar.shadowDirty=true;dirty=true;
|
||||
}
|
||||
function setInteriorMode(mode) {
|
||||
if(!['plan','axon','walk'].includes(mode)) return;
|
||||
if(!interior.enabled)toggleInterior(true);
|
||||
interior.mode=mode;interior.userCamera=false;interior.keys.clear();interior.pad.clear();animation=null;autoRotate=false;pointerMap.clear();
|
||||
if(mode==='walk'){
|
||||
const preset=interior.data.presets.find(p=>p.id===interior.lastView)||interior.data.presets[0];
|
||||
enterInteriorView(preset.id);return;
|
||||
}
|
||||
document.querySelectorAll('[data-interior-view]').forEach(b=>b.classList.remove('active'));
|
||||
$('#interiorRoom').value='';updateInteriorInterface();interiorFitCamera();updateSolar();dirty=true;
|
||||
}
|
||||
function enterInteriorView(id) {
|
||||
if(!interior.ready)return false;
|
||||
if(!interior.enabled)toggleInterior(true);
|
||||
const v=interior.data.presets.find(p=>p.id===id)||interior.data.rooms.find(r=>r.id===id);
|
||||
if(!v)return false;
|
||||
interior.mode='walk';interior.lastView=id;interior.position=[...v.eye];
|
||||
const dir=vsub(v.look,v.eye);interior.yaw=Math.atan2(dir[0],dir[2]);interior.pitch=Math.atan2(dir[1],Math.hypot(dir[0],dir[2]));
|
||||
interior.fov=v.fov||64;interior.keys.clear();interior.pad.clear();interior.lookPointer=null;pointerMap.clear();autoRotate=false;animation=null;
|
||||
document.querySelectorAll('[data-interior-view]').forEach(b=>b.classList.toggle('active',b.dataset.interiorView===id));
|
||||
$('#interiorRoom').value=interior.data.rooms.some(r=>r.id===id)?id:'';
|
||||
$('#interiorViewTitle').textContent=v.label;
|
||||
updateInteriorInterface();updateSolar();solar.shadowDirty=true;dirty=true;return true;
|
||||
}
|
||||
function handleInteriorKey(e) {
|
||||
if($('#interiorReferenceDialog').open||$('#stdCatalogDialog').open||$('#stdResetDialog').open)return;
|
||||
if(e.key==='Escape'){e.preventDefault();toggleInterior(false);return;}
|
||||
if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;
|
||||
const modes={Digit1:'plan',Digit2:'axon',Digit3:'walk'};
|
||||
if(modes[e.code]){e.preventDefault();setInteriorMode(modes[e.code]);return;}
|
||||
if(e.code==='KeyR'){e.preventDefault();if(interior.mode==='walk')enterInteriorView(interior.lastView);else{interior.userCamera=false;interiorFitCamera();}return;}
|
||||
if(interior.mode==='walk'&&['KeyW','KeyA','KeyS','KeyD','KeyQ','KeyE','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','ShiftLeft','ShiftRight'].includes(e.code)){
|
||||
e.preventDefault();interior.keys.add(e.code);
|
||||
}
|
||||
}
|
||||
function interiorMapPoint(x,z){return [17+(x+7.67)*19.2,12+(z+4.695)*19.2];}
|
||||
function buildInteriorMap() {
|
||||
let svg='<rect x="1" y="1" width="328" height="203" rx="10" fill="#f0f2e9"/>';
|
||||
svg+='<rect x="236" y="12" width="76" height="77" fill="#e5dcc4" stroke="#d2d8c7"/>';
|
||||
svg+='<text x="274" y="53" text-anchor="middle">Terasa</text>';
|
||||
for(const r of interior.data.rooms){
|
||||
const path=r.poly.map(p=>interiorMapPoint(...p).join(',')).join(' '),a=interiorMapPoint(...r.anchor);
|
||||
svg+=`<g data-map-room="${r.id}" role="button" tabindex="0" aria-label="${r.id} ${r.label}"><polygon class="map-room" points="${path}"/><text x="${a[0]}" y="${a[1]+3}" text-anchor="middle">${r.id}</text></g>`;
|
||||
}
|
||||
for(const b of interior.data.wallColliders){let a=interiorMapPoint(...b.lo),c=interiorMapPoint(...b.hi);svg+=`<rect class="map-wall" x="${a[0]}" y="${a[1]}" width="${c[0]-a[0]}" height="${c[1]-a[1]}"/>`;}
|
||||
svg+='<g id="interiorMapMarker" style="display:none"><path d="M0 0L-5 13L5 13Z" fill="#bd8541" opacity=".8"/><circle r="3.7" fill="#365944" stroke="#fff" stroke-width="1.4"/></g>';
|
||||
svg+='<text x="17" y="198">1. NP · orientace podle půdorysu</text>';
|
||||
$('#interiorMap').innerHTML=svg;
|
||||
document.querySelectorAll('[data-map-room]').forEach(el=>{
|
||||
el.onclick=()=>enterInteriorView(el.dataset.mapRoom);
|
||||
el.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();enterInteriorView(el.dataset.mapRoom);}};
|
||||
});
|
||||
}
|
||||
function drawInteriorLabels() {
|
||||
if(!interior.enabled)return;
|
||||
const el=$('#interiorLabels'),walking=interior.mode==='walk';
|
||||
if(walking||!interior.labels){el.replaceChildren();}
|
||||
else{
|
||||
if(!el.children.length)for(const r of interior.data.rooms){
|
||||
const b=document.createElement('button');b.className='int-room-tag';b.dataset.room=r.id;
|
||||
b.innerHTML=`<b>${r.id} <span class="room-word">${r.label}</span></b><small>${r.area} m²</small>`;
|
||||
b.title='Vstoupit: '+r.label;b.onclick=()=>enterInteriorView(r.id);el.appendChild(b);
|
||||
}
|
||||
const rect=canvas.getBoundingClientRect();
|
||||
for(const b of el.children){const r=interior.data.rooms.find(r=>r.id===b.dataset.room),p=project([r.anchor[0],.20,r.anchor[1]]);
|
||||
b.style.left=(p[0]+rect.left)+'px';b.style.top=(p[1]+rect.top)+'px';
|
||||
b.style.display=p[0]<20||p[0]>viewW-20||p[1]<80||p[1]>viewH-65?'none':'block';}
|
||||
}
|
||||
const marker=$('#interiorMapMarker');if(marker){marker.style.display=walking?'block':'none';if(walking){const p=interiorMapPoint(interior.position[0],interior.position[2]);marker.setAttribute('transform',`translate(${p[0]},${p[1]}) rotate(${-interior.yaw*180/Math.PI})`);}}
|
||||
if(walking){const room=interior.data.rooms.find(r=>interiorPointIn(r.poly,interior.position[0],interior.position[2]));
|
||||
const id=room?.id||'';if(id!==interior.currentRoom){interior.currentRoom=id;document.querySelectorAll('[data-map-room] .map-room').forEach(p=>p.classList.toggle('active',p.parentElement.dataset.mapRoom===id));}}
|
||||
}
|
||||
// Interior paint uses linear RGB, like the other interior PBR materials.
|
||||
// Only explicitly tagged plaster is edited; no name-based facade match.
|
||||
const INTERIOR_PAINT_STORAGE = 'locenice.interior.wallPaint.v1';
|
||||
function normalizeInteriorHex(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
let hex = value.trim().replace(/^#/, '');
|
||||
if (/^[0-9a-f]{3}$/i.test(hex)) hex = hex.split('').map(c=>c+c).join('');
|
||||
return /^[0-9a-f]{6}$/i.test(hex) ? '#'+hex.toLowerCase() : null;
|
||||
}
|
||||
function setInteriorWallColor(value, persist = true) {
|
||||
const hex=normalizeInteriorHex(value), paint=interior.data?.wallPaint;
|
||||
if (!hex || !gltf || !paint) return false;
|
||||
const rgb=hardieLinearRGB(hex);
|
||||
for (const index of paint.materialIndices) {
|
||||
const material=gltf.materials[index];
|
||||
if (material?.extras?.interiorRole !== 'wallPaint') continue;
|
||||
material.pbrMetallicRoughness.baseColorFactor=[...rgb,1];
|
||||
}
|
||||
interior.wallColor=hex; paint.selectedHex=hex;
|
||||
$('#interiorWallColor').value=hex;
|
||||
$('#interiorWallHex').value=hex.toUpperCase();
|
||||
$('#interiorWallHex').removeAttribute('aria-invalid');
|
||||
document.querySelectorAll('[data-interior-paint]').forEach(button=>{
|
||||
button.setAttribute('aria-pressed',String(button.dataset.interiorPaint===hex));
|
||||
});
|
||||
// file://, private mode and disabled storage must never break rendering.
|
||||
if (persist) try {
|
||||
if (hex===paint.defaultHex) localStorage.removeItem(INTERIOR_PAINT_STORAGE);
|
||||
else localStorage.setItem(INTERIOR_PAINT_STORAGE,hex);
|
||||
} catch (_) { /* The live view and export still keep the selected color. */ }
|
||||
dirty=true;
|
||||
return true;
|
||||
}
|
||||
function resetInteriorWallColor() {
|
||||
return setInteriorWallColor(interior.data?.wallPaint?.defaultHex || '#eeeae1');
|
||||
}
|
||||
function initInteriorWallPaint() {
|
||||
const paint=interior.data.wallPaint;
|
||||
let color=paint.selectedHex || paint.defaultHex;
|
||||
try { color=normalizeInteriorHex(localStorage.getItem(INTERIOR_PAINT_STORAGE)) || color; } catch (_) {}
|
||||
setInteriorWallColor(color,false);
|
||||
$('#interiorWallColor').addEventListener('input',e=>setInteriorWallColor(e.target.value));
|
||||
$('#interiorWallColor').addEventListener('change',e=>setInteriorWallColor(e.target.value));
|
||||
const hexInput=$('#interiorWallHex');
|
||||
const commitHex=()=>{
|
||||
if (!setInteriorWallColor(hexInput.value)) {
|
||||
hexInput.value=interior.wallColor.toUpperCase();
|
||||
toast('Zadejte platnou barvu, nap\u0159\u00edklad #EEEAE1.');
|
||||
}
|
||||
};
|
||||
hexInput.addEventListener('change',commitHex);
|
||||
hexInput.addEventListener('keydown',e=>{
|
||||
if (e.key==='Enter') { e.preventDefault();commitHex();hexInput.blur(); }
|
||||
});
|
||||
document.querySelectorAll('[data-interior-paint]').forEach(button=>{
|
||||
button.addEventListener('click',()=>setInteriorWallColor(button.dataset.interiorPaint));
|
||||
});
|
||||
$('#interiorWallReset').addEventListener('click',resetInteriorWallColor);
|
||||
}
|
||||
|
||||
function initInterior() {
|
||||
interior.data=gltf.extras.interiorStudy;interior.roof=new Set(interior.data.roofNodes);interior.cutOriginals=new Set(interior.data.cutOriginalNodes);
|
||||
for(const r of interior.data.rooms){const op=document.createElement('option');op.value=r.id;op.textContent=`${r.id} · ${r.label} · ${r.area} m²`;$('#interiorRoom').appendChild(op);}
|
||||
buildInteriorMap();
|
||||
initInteriorWallPaint();
|
||||
$('#interiorLaunch').onclick=()=>toggleInterior(true);$('#interiorExit').onclick=()=>toggleInterior(false);
|
||||
document.querySelectorAll('[data-interior-mode]').forEach(b=>b.onclick=()=>setInteriorMode(b.dataset.interiorMode));
|
||||
document.querySelectorAll('[data-interior-view]').forEach(b=>b.onclick=()=>enterInteriorView(b.dataset.interiorView));
|
||||
$('#interiorRoom').onchange=e=>{if(e.target.value)enterInteriorView(e.target.value);};
|
||||
$('#interiorFurniture').onchange=e=>{interior.furniture=e.target.checked;solar.shadowDirty=true;dirty=true;};
|
||||
$('#interiorRoomLabels').onchange=e=>{interior.labels=e.target.checked;dirty=true;};
|
||||
$('#interiorFill').onchange=e=>{interior.fill=e.target.checked;dirty=true;};
|
||||
$('#interiorFit').onclick=()=>{if(interior.mode==='walk')enterInteriorView(interior.lastView);else{interior.userCamera=false;interiorFitCamera();}};
|
||||
$('#interiorPanelToggle').onclick=()=>{if($('#sunPanel').classList.contains('open'))toggleSunPanel(false);else interior.panel=!interior.panel;updateInteriorInterface();resize();};
|
||||
$('#interiorSavePng').onclick=()=>{render();canvas.toBlob(b=>{if(b)saveBlob(b,'dum_interier_'+interior.mode+'.png');},'image/png');};
|
||||
$('#interiorSaveGlb').onclick=()=>{saveBlob(new Blob([currentGlb()],{type:'model/gltf-binary'}),'dum_interier_'+interior.mode+'_'+hardieSelected+'.glb');};
|
||||
const refs=resources.references;
|
||||
document.querySelectorAll('[data-int-ref]').forEach(b=>b.onclick=()=>{const r=refs[b.dataset.intRef];$('#interiorReferenceTitle').textContent=r.title;$('#interiorReferenceImage').src=r.src;$('#interiorReferenceImage').alt=r.title;$('#interiorReferenceCaption').textContent=r.caption;$('#interiorReferenceDialog').showModal();});
|
||||
$('#interiorReferenceClose').onclick=()=>$('#interiorReferenceDialog').close();
|
||||
$('#interiorReferenceDialog').addEventListener('click',e=>{if(e.target.id==='interiorReferenceDialog'){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}});
|
||||
document.querySelectorAll('[data-walk]').forEach(b=>{
|
||||
const release=()=>{interior.pad.delete(b.dataset.walk);b.classList.remove('held');};
|
||||
b.onpointerdown=e=>{e.preventDefault();b.setPointerCapture(e.pointerId);interior.pad.add(b.dataset.walk);b.classList.add('held');};
|
||||
b.onpointerup=release;b.onpointercancel=release;b.onlostpointercapture=release;
|
||||
});
|
||||
window.addEventListener('keyup',e=>interior.keys.delete(e.code));
|
||||
window.addEventListener('blur',()=>{interior.keys.clear();interior.pad.clear();interior.lookPointer=null;});
|
||||
document.addEventListener('visibilitychange',()=>{if(document.hidden){interior.keys.clear();interior.pad.clear();}});
|
||||
interior.ready=true;$('#interiorLaunch').disabled=false;
|
||||
window.INTERIOR = {
|
||||
toggle:toggleInterior,mode:setInteriorMode,view:enterInteriorView,
|
||||
setWallColor:setInteriorWallColor, resetWallColor:resetInteriorWallColor,
|
||||
getState:()=>({enabled:interior.enabled,mode:interior.mode,position:[...interior.position],yaw:interior.yaw,pitch:interior.pitch,
|
||||
furniture:interior.furniture,labels:interior.labels,fill:interior.fill,currentRoom:interior.currentRoom,
|
||||
wallColor:interior.wallColor,defaultWallColor:interior.data.wallPaint.defaultHex,timberReference:interior.data.timberReference,
|
||||
rooms:interior.data.rooms.map(r=>({id:r.id,name:r.label,area:r.area})),originalGeometryUnchanged:false,standardsRevision:1,userCorrections:interior.data.userCorrections,
|
||||
originalNodes:interior.data.originalNodeCount,sourceBinarySHA256:interior.data.sourceBinarySHA256}),
|
||||
checkPosition:(x,z)=>interiorCanStand(x,z),move:(dx,dz)=>moveInterior(dx,dz),
|
||||
setLook:(yaw,pitch)=>{interior.yaw=yaw;interior.pitch=clamp(pitch,-1.3,1.3);dirty=true;}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ALLSTAV configurator. The PDF is the source; unlabeled samples are not product codes.
|
||||
const STD_ASSETS=resources.assets;
|
||||
const STD_STORAGE = 'locenice.allstav.2025.v1';
|
||||
let stdStorageKey = STD_STORAGE;
|
||||
const STD_ROOMS=resources.catalog.rooms,STD_OPTIONS=resources.catalog.options,STD_DEFAULTS=resources.catalog.defaults;
|
||||
const stdClone = x=>JSON.parse(JSON.stringify(x));
|
||||
let stdState=stdClone(STD_DEFAULTS),stdReady=false,stdSwitching=false,stdTab='exterior',stdFloorRoom='108',stdTileRoom='105',stdCatalogPage=12,stdSaveTimer=0;
|
||||
const stdEsc = s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const stdOption=(key,val=stdState[key])=>(STD_OPTIONS[key]||[]).find(o=>o[0]===val);
|
||||
const stdLabel=(key,val=stdState[key])=>stdOption(key,val)?.[1]||String(val);
|
||||
const stdSource=p=>`<button class="std-source" type="button" data-std-page="${p}">Katalog \u00b7 str. ${p}</button>`;
|
||||
function stdCards(key,options=STD_OPTIONS[key],cols='',kind='',tier='standard'){
|
||||
return `<div class="std-grid ${cols}" data-card-group="${key}">${options.map(o=>`<button type="button" class="std-card ${kind}" data-std-key="${key}" data-std-value="${stdEsc(o[0])}" aria-pressed="false" title="${stdEsc(o[1])}">${o[4]?`<img loading="lazy" src="${STD_ASSETS[o[4]]}" alt="${stdEsc(o[1])}">`:o[2]?`<span class="sample" style="background:${o[2]}"></span>`:''}<strong>${stdEsc(o[1])}</strong><small class="${o[3]?'premium':tier==='visual'?'visual':''}">${o[3]?'P\u0159\u00edplatek':((key==='wallPaint'&&o[0]==='#ffffff')||(key==='faucet'&&o[0]==='chrome'))?'Standard':tier==='visual'?'Vlastn\u00ed n\u00e1hled':tier==='catalog'?'Varianta katalogu':'Standard'}</small></button>`).join('')}</div>`;
|
||||
}
|
||||
function stdSampleCards(key,type){
|
||||
return stdCards(key,Array.from({length:9},(_,i)=>[String(i+1),`Vzorek ${String(i+1).padStart(2,'0')}`,'',false,`${type}${i+1}`]),'cols3','wood','catalog');
|
||||
}
|
||||
const stdSection=(id,n,title,body,open=false)=>`<details class="std-section" id="std-section-${id}" ${open?'open':''}><summary><span class="std-number">${n}</span><span><strong>${title}</strong><small class="std-summary-current" data-std-current="${id}"></small></span></summary><div class="std-body">${body}</div></details>`;
|
||||
const stdSelect=(key,label,options)=>`<label class="std-field">${label}<select data-std-input="${key}">${options.map(([v,t])=>`<option value="${stdEsc(v)}">${t}</option>`).join('')}</select></label>`;
|
||||
const stdCheck=(key,label)=>`<label class="std-check"><input type="checkbox" data-std-input="${key}">${label}</label>`;
|
||||
const stdColor=(key,label)=>`<label class="std-field">${label}<input type="color" data-std-input="${key}" aria-label="${label}"></label>`;
|
||||
const stdNotice=(s,t='')=>`<div class="std-notice ${t}">${s}</div>`;
|
||||
function standardsVisible(m){
|
||||
const e=m.std||{};
|
||||
if(e.stdHidden)return false;
|
||||
if(e.stdFullOnly && interior.enabled && interior.mode!=='walk')return false;
|
||||
if(e.stdBlind && (!stdState.blinds || (e.stdBlindFraction!==undefined && e.stdBlindFraction>stdState.blindDrop/100)))return false;
|
||||
if(e.stdWhen && Object.entries(e.stdWhen).some(([k,v])=>stdState[k]!==v))return false;
|
||||
return true;
|
||||
}
|
||||
function stdSetMaterial(role,color,texture){
|
||||
const meta=gltf.extras.allstavStandards;
|
||||
for(const idx of meta.materials[role]||[]){
|
||||
const mat=gltf.materials[idx],p=mat.pbrMetallicRoughness;
|
||||
if(color)p.baseColorFactor=[...hardieLinearRGB(color),p.baseColorFactor?.[3]??1];
|
||||
if(texture!==undefined){if(texture===null)delete p.baseColorTexture;else p.baseColorTexture={index:typeof texture==='number'?texture:meta.textures[texture]};}
|
||||
for(const m of meshes){if(m.material!==idx)continue;m.surfaceKind=p.baseColorTexture?3:2;}
|
||||
}
|
||||
}
|
||||
function stdApply(save=true){
|
||||
if(!gltf)return;
|
||||
stdSetMaterial('entry',stdOption('entryColor')[2]);stdSetMaterial('extSill',stdOption('extSill')[2]);stdSetMaterial('gutter',stdOption('gutter')[2]);
|
||||
recolor('roof',stdOption('roof')[2]);for(const i of gltf.extras.allstavStandards.materials.roof)gltf.materials[i].pbrMetallicRoughness.roughnessFactor=.32;if($('#roofColor'))$('#roofColor').value=stdOption('roof')[2];
|
||||
stdSetMaterial('blinds',stdOption('blindColor')[2]);
|
||||
for(const r of STD_ROOMS){const f=stdState.floors[r[0]];stdSetMaterial('floor'+r[0],'#ffffff',f.type+f.sample);for(const i of gltf.extras.allstavStandards.materials['floor'+r[0]]){gltf.materials[i].pbrMetallicRoughness.baseColorTexture.extensions={KHR_texture_transform:{scale:f.type==='tile'?[.22/.6,1.28/.6]:[1,1]}};}}
|
||||
gltf.extensionsUsed=[...new Set([...(gltf.extensionsUsed||[]),'KHR_texture_transform'])];
|
||||
stdSetMaterial('wallTile105','#ffffff','tile'+stdState.wallTile105);stdSetMaterial('wallTile106','#ffffff','tile'+stdState.wallTile106);
|
||||
stdSetMaterial('intSill',stdOption('intSill')[2]);
|
||||
stdSetMaterial('intDoor',stdState.intDoor==='sample'?'#ffffff':stdState.intDoorColor,stdState.intDoor==='sample'?'prum':null);
|
||||
setInteriorWallColor(stdState.wallPaint,false);
|
||||
stdSetMaterial('kitchenFront',stdState.kitchenColor);stdSetMaterial('kitchenBody',stdState.kitchenBodyColor);
|
||||
const work=stdOption('worktop'),tex=stdState.worktop==='oak'?9:stdState.worktop==='stone'?'tile1':null;
|
||||
stdSetMaterial('worktop',tex!==null?'#ffffff':work[2],tex);
|
||||
const back=stdState.backsplash==='worktop'?stdState.worktop:stdState.backsplash;
|
||||
stdSetMaterial('backsplash',back==='stone'||back==='oak'?'#ffffff':'#eeeeda',back==='stone'?'tile1':back==='oak'?9:null);
|
||||
if(stdState.backsplash==='worktop'&&back==='graphite')stdSetMaterial('backsplash','#444946',null);
|
||||
stdSetMaterial('faucet',stdOption('faucet')[2]);
|
||||
for(const i of gltf.extras.allstavStandards.materials.faucet){gltf.materials[i].pbrMetallicRoughness.metallicFactor=stdState.faucet==='chrome'?.8:.05;}
|
||||
gltf.extras.allstavSelection=stdClone(stdState);
|
||||
solar.shadowDirty=true;dirty=true;
|
||||
if(stdReady){stdSync();if(save){clearTimeout(stdSaveTimer);stdSaveTimer=setTimeout(stdPersist,220);}}
|
||||
}
|
||||
function stdNormalize(input){
|
||||
if(!input||typeof input!=='object'||Array.isArray(input))throw Error('Chyb\u00ed platn\u00e9 nastaven\u00ed.');
|
||||
const d=stdClone(STD_DEFAULTS),enums={intDoor:['sample','custom'],intDoorType:['hinged','sliding','pocket'],tileCorner:['profile','miter'],hood:['facade','recirculation'],ventilation:['local','central','none'],radiator:['electric','combined']};
|
||||
for(const k of Object.keys(d)){
|
||||
if(input[k]===undefined)continue;
|
||||
const v=input[k];
|
||||
if(k==='floors'){
|
||||
if(!v||typeof v!=='object')throw Error('Neplatn\u00e9 podlahy.');
|
||||
for(const r of STD_ROOMS){if(v[r[0]]===undefined)continue;const f=v[r[0]];if(!f||!['lam','tile'].includes(f.type)||!Number.isInteger(f.sample)||f.sample<1||f.sample>9)throw Error('Neplatn\u00e1 podlaha m\u00edstnosti '+r[0]);d.floors[r[0]]={type:f.type,sample:f.sample};}
|
||||
} else if(['wallPaint','kitchenColor','kitchenBodyColor','intDoorColor'].includes(k)){
|
||||
if(typeof v!=='string'||!/^#[0-9a-f]{6}$/i.test(v))throw Error('Neplatn\u00e1 barva: '+k);d[k]=v.toLowerCase();
|
||||
} else if(STD_OPTIONS[k]){if(!STD_OPTIONS[k].some(o=>o[0]===v))throw Error('Nezn\u00e1m\u00e1 varianta: '+k);d[k]=v;
|
||||
} else if(enums[k]){if(!enums[k].includes(v))throw Error('Nezn\u00e1m\u00e1 varianta: '+k);d[k]=v;
|
||||
} else if(typeof d[k]==='boolean'){if(typeof v!=='boolean')throw Error('Neplatn\u00fd p\u0159ep\u00edna\u010d: '+k);d[k]=v;
|
||||
} else if(typeof d[k]==='number'){const max=k==='blindDrop'?100:9,min=k==='blindDrop'?0:1;if(!Number.isInteger(v)||v<min||v>max)throw Error('Hodnota mimo rozsah: '+k);d[k]=v;
|
||||
} else if(typeof d[k]==='string'){if(typeof v!=='string'||v.length>(k==='note'?2000:120))throw Error('Neplatn\u00fd text: '+k);d[k]=v;}
|
||||
}
|
||||
// Backwards compatibility: older exports used one colour for both.
|
||||
if(input.kitchenBodyColor===undefined)d.kitchenBodyColor=d.kitchenColor;
|
||||
return d;
|
||||
}
|
||||
function stdPersist(){try{localStorage.setItem(stdStorageKey,JSON.stringify(stdState));$('#stdSaved').textContent='Ulo\u017eeno v tomto prohl\u00ed\u017ee\u010di \u00b7 z\u00e1lohu st\u00e1hnete jako JSON';}catch(e){$('#stdSaved').textContent='Prohl\u00ed\u017ee\u010d nepovolil ulo\u017een\u00ed. St\u00e1hn\u011bte JSON.';}}
|
||||
function stdBuildPanel(){
|
||||
let ext='<div class="std-shortcuts"><button data-std-view="entry">Pohled na vstup</button><button data-std-view="terrace">Terasa a \u017ealuzie</button><button data-std-view="site">Cel\u00fd pozemek</button></div>';
|
||||
ext+=stdSection('entry','01','Vchodov\u00e9 dve\u0159e',`<div class="std-label">Tvar k\u0159\u00eddla ${stdSource(12)}</div>${stdCards('entryModel',undefined,'','door')}<div class="std-label">Odst\u00edn dve\u0159\u00ed</div>${stdCards('entryColor')}${stdNotice('<strong>V\u00fdchoz\u00ed: 3 skla, RAL 7016.</strong> Prosklen\u00e9 proveden\u00ed je v katalogu p\u0159\u00edplatkov\u00e9. N\u00e1zvy tvar\u016f jsou popisn\u00e9; modelov\u00e9 k\u00f3dy katalog neuv\u00e1d\u00ed.','amber')}<button class="std-action wide" data-std-page="requestedDoor">Zobrazit v\u00e1\u0161 referen\u010dn\u00ed obr\u00e1zek</button><p>Masivn\u00ed d\u0159ev\u011bn\u00e9 dve\u0159e KeraT\u00fcr. 3D tvar je ilustrativn\u00ed; stavebn\u00ed otvor a bo\u010dn\u00ed sv\u011btl\u00edk p\u016fvodn\u00edho modelu z\u016fst\u00e1vaj\u00ed.</p>`,true);
|
||||
ext+=stdSection('extSill','02','Venkovn\u00ed parapety',`<div class="std-label">Ta\u017een\u00fd hlin\u00edk ${stdSource(13)}</div>${stdCards('extSill',undefined,'cols3')}<p>Volba m\u011bn\u00ed parapety. N\u00e1\u0161lapn\u00e9 prahy u terasov\u00fdch dve\u0159\u00ed z\u016fst\u00e1vaj\u00ed samostatn\u00e9.</p>`);
|
||||
ext+=stdSection('gutter','03','Okapy a svody',`<div class="std-label">Lindab ${stdSource(13)}</div>${stdCards('gutter')}${stdNotice('M\u011bn\u00ed se <strong>jen okapov\u00fd syst\u00e9m</strong>, nikoli lemov\u00e1n\u00ed st\u0159echy, kom\u00edn nebo z\u00e1bradl\u00ed.')}<p>Zkratky a k\u00f3dy jsou p\u0159evzat\u00e9 z katalogu. CERN 015 = v\u00fdchoz\u00ed volba.</p>`);
|
||||
ext+=stdSection('roof','04','St\u0159e\u0161n\u00ed ta\u0161ky',`<div class="std-label">Benders Palema Candor ${stdSource(14)}</div>${stdCards('roof',undefined,'cols3')}<p>Betonov\u00e1 ta\u0161ka, vysoce leskl\u00e1 povrchov\u00e1 \u00faprava. N\u00e1hled zachov\u00e1v\u00e1 geometrii krytiny p\u016fvodn\u00edho modelu.</p>`);
|
||||
ext+=stdSection('blinds','05','Venkovn\u00ed \u017ealuzie',`<label class="std-toggle">Osadit venkovn\u00ed \u017ealuzie<input type="checkbox" data-std-input="blinds"></label><div class="std-label">Climax Zetta 70 \u00b7 p\u0159\u00edplatek ${stdSource(15)}</div><div id="stdBlindsControls">${stdCards('blindColor',undefined,'cols5','', 'catalog')}<div class="std-range-head"><span>Sta\u017een\u00ed lamel</span><strong id="stdBlindDropLabel">100 %</strong></div><label class="std-field"><input type="range" min="0" max="100" step="5" data-std-input="blindDrop" aria-label="Sta\u017een\u00ed \u017ealuzi\u00ed"></label></div>${stdNotice('<strong>9 obd\u00e9ln\u00edkov\u00fdch otvor\u016f.</strong> Troj\u00faheln\u00edkov\u00e9 okno ani vchodov\u00e9 dve\u0159e \u017ealuzie nemaj\u00ed. Vodic\u00ed li\u0161ty maj\u00ed shodnou barvu; \u0161irok\u00e9 ji\u017en\u00ed zasklen\u00ed je rozd\u011blen\u00e9.')}<p>Podom\u00edtkov\u00fd kastl\u00edk, elektrick\u00e9 ovl\u00e1d\u00e1n\u00ed. Sta\u017een\u00ed je spole\u010dn\u00fd ilustrativn\u00ed n\u00e1hled, ne n\u00e1vrh pohonu.</p>`);
|
||||
ext+=stdNotice('Fas\u00e1da James Hardie Plank a okol\u00ed domu z\u016fst\u00e1vaj\u00ed v p\u016fvodn\u00edch ovl\u00e1dac\u00edch prvc\u00edch. <button class="std-source" data-std-action="original">Otev\u0159\u00edt materi\u00e1ly fas\u00e1dy</button>');
|
||||
$('#std-exterior').innerHTML=ext;
|
||||
let int='<div class="std-shortcuts"><button data-std-view="plan">P\u016fdorys</button><button data-std-view="axon">3D \u0159ez</button><button data-std-view="108">Kuchy\u0148</button><button data-std-view="105">Koupelna</button></div>';
|
||||
int+=stdSection('floors','01','Podlahy po m\u00edstnostech',`<div class="std-label">KronoSwiss / keramick\u00e1 dla\u017eba ${stdSource(18)}</div><label class="std-field">M\u00edstnost<select id="stdFloorRoom">${STD_ROOMS.map(r=>`<option value="${r[0]}">${r[0]} \u00b7 ${r[1]} \u00b7 ${r[2]} m\u00b2</option>`).join('')}</select></label><div class="std-two"><label class="std-field">Krytina<select id="stdFloorType"><option value="lam">Lamin\u00e1t KronoSwiss</option><option value="tile">Keramick\u00e1 dla\u017eba</option></select></label><label class="std-field">N\u00e1hled m\u00edstnosti<button class="std-action wide" data-std-action="floorView">P\u0159ej\u00edt do m\u00edstnosti</button></label></div><div id="stdFloorSamples"></div><button class="std-action wide" data-std-action="copyFloor">Stejn\u00fd vzorek pro m\u00edstnosti se stejnou krytinou</button><div id="stdFloorProjectNote" class="std-notice"></div><p>Vzorky 01\u201309 odpov\u00eddaj\u00ed obr\u00e1zk\u016fm na str. 18, nikoli objednac\u00edm k\u00f3d\u016fm. M\u011b\u0159\u00edtko kresby a sp\u00e1ry jsou pouze n\u00e1hledov\u00e9.</p>`,true);
|
||||
int+=stdSection('tiles','02','Obklady koupelny a WC',`<div class="std-label">Rektifikovan\u00e9 obklady ${stdSource(18)}</div><label class="std-field">M\u00edstnost<select id="stdTileRoom"><option value="105">105 \u00b7 Koupelna</option><option value="106">106 \u00b7 WC</option></select></label>${stdSampleCards('wallTile','tile')}${stdSelect('tileCorner','Zakon\u010den\u00ed hran \u00b7 specifikace',[['profile','Hlin\u00edkov\u00e1 eloxov\u00e1 li\u0161ta \u00b7 standard'],['miter','Kamenick\u00fd roh \u00b7 p\u0159\u00edplatek']])}<p>Obklad do v\u00fd\u0161ky 2,1 m dle technick\u00e9 zpr\u00e1vy. Ve 3D \u0159ezu je zobrazen pouze po v\u00fd\u0161ku \u0159ezu; ukon\u010den\u00ed hran se eviduje v soupisu.</p>`);
|
||||
int+=stdSection('doors','03','Interi\u00e9rov\u00e9 dve\u0159e a kov\u00e1n\u00ed',`<div class="std-label">Pr\u00fcm CPL ${stdSource(19)}</div>${stdCards('intDoor',[['sample','Fotografie z katalogu','',false,'prum'],['custom','Vlastn\u00ed odst\u00edn','#c3aa85']], 'cols2','wood','catalog')}<div id="stdDoorCustom">${stdColor('intDoorColor','Vlastn\u00ed barva \u00b7 pouze vizualizace')}</div><label class="std-field">P\u0159esn\u00fd dekor / k\u00f3d po konzultaci<input type="text" data-std-input="intDoorCode" maxlength="120" placeholder="Zat\u00edm neur\u010deno"></label>${stdNotice('Katalog uv\u00e1d\u00ed v\u00edce ne\u017e 20 CPL dekor\u016f, ale jejich vzorn\u00edk a k\u00f3dy neobsahuje. Fotografie dve\u0159\u00ed je referen\u010dn\u00ed, nikoli p\u0159esn\u00fd dekor.','amber')}${stdSelect('intDoorType','Proveden\u00ed \u00b7 specifikace',[['hinged','Oto\u010dn\u00e9 pln\u00e9 \u00b7 standard'],['sliding','Posuvn\u00e9 po zdi \u00b7 p\u0159\u00edplatek'],['pocket','Do pouzdra Eclisse \u00b7 p\u0159\u00edplatek']])}<div class="std-label">Brou\u0161en\u00fd nerez \u00b7 rozetov\u00e9 kov\u00e1n\u00ed</div>${stdCards('intHandle',undefined,'cols2')}<p>Koupelna: Klima 2. Koupelna a WC: WC z\u00e1mek. Posuvn\u00e9 varianty se zaznamenaj\u00ed do soupisu; 3D otvory ani kolize p\u0159\u00ed\u010dek se nep\u0159eprojektov\u00e1vaj\u00ed.</p>`);
|
||||
int+=stdSection('finishes','04','Parapety a malba',`<div class="std-label">Helopal \u00b7 lit\u00fd mramor ${stdSource(16)}</div>${stdCards('intSill',undefined,'cols3')}<p>Fotografie ukazuj\u00ed katalogov\u00fd vzorek. V modelu se m\u011bn\u00ed odst\u00edn; \u017e\u00edh\u00e1n\u00ed je na fotografii.</p><div class="std-label">Malba st\u011bn</div>${stdCards('wallPaint',STD_OPTIONS.wallPaint.map((o,i)=>[...o]),'','', 'visual')}${stdColor('wallPaint','Vlastn\u00ed odst\u00edn st\u011bn')}${stdNotice('<strong>Standard: HET b\u00edl\u00e1, \u0161pachtlov\u00e1n\u00ed Q2.</strong> Ostatn\u00ed barvy jsou vlastn\u00ed n\u00e1hled, nikoli katalogov\u00fd vzorn\u00edk. Barva st\u011bn nem\u011bn\u00ed tr\u00e1my ani strop.')}`);
|
||||
int+=stdSection('kitchen','05','Kuchy\u0148 \u00b7 vlastn\u00ed n\u00e1vrh',`${stdNotice('<strong>Kuchy\u0148 nen\u00ed v katalogu barevn\u011b ani typov\u011b specifikovan\u00e1.</strong> Projekt ponech\u00e1v\u00e1 jej\u00ed dod\u00e1vku na realizaci. N\u00e1sleduj\u00edc\u00ed volby slou\u017e\u00ed pouze pro slad\u011bn\u00ed interi\u00e9ru.','blue')}<div class="std-label">\u010cela sk\u0159\u00edn\u011bk</div>${stdCards('kitchenColor',undefined,'cols3','', 'visual')}${stdColor('kitchenColor','Vlastn\u00ed odst\u00edn \u010del')}<div class="std-label">Korpusy sk\u0159\u00edn\u011bk</div>${stdCards('kitchenBodyColor',undefined,'cols3','', 'visual')}${stdColor('kitchenBodyColor','Vlastn\u00ed odst\u00edn korpus\u016f')}<button class="std-action wide" data-std-action="matchKitchenBody">Sjednotit korpusy s \u010dely</button><div class="std-label">Pracovn\u00ed deska</div>${stdCards('worktop',undefined,'','', 'visual')}<div class="std-label">Obklad za linkou</div>${stdCards('backsplash',undefined,'cols3','', 'visual')}<button class="std-action wide" data-std-view="kitchen">Detail kuchyn\u011b</button><p>Rozvr\u017een\u00ed linky vych\u00e1z\u00ed z p\u016fvodn\u00edho HTML. Typy spot\u0159ebi\u010d\u016f, rozm\u011bry modul\u016f a cena nejsou t\u00edmto n\u00e1hledem ur\u010deny.</p>`);
|
||||
int+=stdSection('bath','06','Koupelna a sanita',`<div class="std-label">Proveden\u00ed sprchy ${stdSource(21)}</div>${stdCards('shower',undefined,'cols2','', 'catalog')}<div id="stdShowerSpec" class="std-notice"></div><button class="std-action wide" data-std-view="shower">Detail sprchy</button><div class="std-label">Baterie</div>${stdCards('faucet',undefined,'cols2','', 'visual')}<p>Standardem jsou chromov\u00e9 Ravak Classic. \u010cern\u00e1 je vlastn\u00ed vizualiza\u010dn\u00ed alternativa, ne potvrzen\u00fd produkt z katalogu.</p><div class="std-label">Pevn\u00e9 vybaven\u00ed dle projektu ${stdSource(20)}</div><div class="std-inventory"><div><b>Vana Ravak Chrome \u00b7 1700 \u00d7 750 mm</b><small>Ravak Classic, click-clack, hlavice s hadic\u00ed 1,5 m.</small></div><div><b>Umyvadlo Laufen Pro \u00b7 600 \u00d7 420 mm</b><small>Ravak Classic, chromov\u00fd sifon. Mal\u00e9 umyvadlo na WC dle p\u016fdorysu: konkr\u00e9tn\u00ed typ potvrdit.</small></div><div><b>2\u00d7 WC \u00b7 koupelna a samostatn\u00e9 WC</b><small>Geberit 112, Ravak Uni Chrome Rimoff, softclose, b\u00edl\u00e9 tla\u010d\u00edtko Sigma.</small></div><div><b>Topn\u00fd \u017eeb\u0159\u00edk Koralux \u00b7 500 \u00d7 1500 mm</b><small>Volba p\u0159ipojen\u00ed v technick\u00e9 specifikaci.</small></div></div><p>Geometrie vybaven\u00ed je zjednodu\u0161en\u00e1. Rozm\u00edst\u011bn\u00ed vych\u00e1z\u00ed z p\u016fdorysu; nenahrazuje d\u00edlensk\u00fd n\u00e1vrh koupelny.</p>`);
|
||||
int+=stdSection('tech','07','Technika a dal\u0161\u00ed specifikace',`${stdNotice('<strong>Volby v t\u00e9to sekci se ukl\u00e1daj\u00ed do soupisu.</strong> Model nep\u0159epo\u010d\u00edt\u00e1v\u00e1 ani nem\u011bn\u00ed rozvody, prostupy, v\u00fdkony a polohy jednotek.','blue')}<div class="std-label">V\u011btr\u00e1n\u00ed a chlazen\u00ed ${stdSource(25)}</div>${stdSelect('ventilation','Rekuperace',[['local','Lok\u00e1ln\u00ed Dalap Zephir \u00b7 dle projektu'],['central','Centr\u00e1ln\u00ed Brink Flair / Air Excellent'],['none','Bez rekuperace \u00b7 zm\u011bna proti projektu']])}${stdCheck('aircon','Klimatizace Panasonic \u00b7 voliteln\u00e9 vybaven\u00ed')}${stdCheck('fans','Nucen\u00e9 v\u011btr\u00e1n\u00ed koupelny / WC \u00b7 Airflow ECO 15')}${stdSelect('hood','Digesto\u0159',[['facade','V\u00fdvod na fas\u00e1du \u00d8 150 mm \u00b7 p\u0159\u00edplatek'],['recirculation','Cirkula\u010dn\u00ed \u00b7 standard']])}<p>V\u00fdchoz\u00ed technick\u00e9 volby sleduj\u00ed popisy v p\u016fdorysu; nacen\u011bn\u00ed a rozsah potvrzuje dodavatel. ${stdSource(16)}</p><div class="std-label">Voda a vyt\u00e1p\u011bn\u00ed ${stdSource(24)}</div>${stdCheck('hotWaterCirculation','Cirkulace tepl\u00e9 vody \u00b7 na p\u0159\u00e1n\u00ed')}${stdSelect('radiator','Topn\u00fd \u017eeb\u0159\u00edk',[['electric','Elektropatrona \u00b7 standard'],['combined','Nav\u00edc samostatn\u00fd topn\u00fd okruh \u00b7 p\u0159\u00edplatek']])}<div class="std-inventory"><div><b>Teplovodn\u00ed podlahov\u00e9 topen\u00ed</b><small>Panasonic vzduch\u2013voda, integrovan\u00fd z\u00e1sobn\u00edk T\u00daV. Katalog uv\u00e1d\u00ed do 5 kW, skute\u010dn\u00fd n\u00e1vrh dle projektu TZB.</small></div><div><b>ABB Levit \u00b7 b\u00edl\u00e1 / b\u00edl\u00e1</b><small>Standard z\u00e1suvek a vyp\u00edna\u010d\u016f. ${stdSource(17)}</small></div><div><b>Tepeln\u011bizola\u010dn\u00ed p\u016fdn\u00ed sch\u016fdky Fakro LWT</b><small>V dom\u011b nen\u00ed b\u011b\u017en\u00e9 schodi\u0161t\u011b. Rozm\u011br otvoru potvrdit podle projektu. ${stdSource(22)}</small></div></div>`);
|
||||
$('#std-interior').innerHTML=int;
|
||||
$('#std-summary').innerHTML=`<h3 class="std-summary-title">V\u00e1\u0161 v\u00fdb\u011br</h3>${stdNotice('P\u0159ehled variant, nikoli cenov\u00e1 nab\u00eddka. P\u0159\u00edplatky ani dostupnost katalog 2025 neoce\u0148uje. P\u0159ed objedn\u00e1n\u00edm potvr\u010fte aktu\u00e1ln\u00ed vzorn\u00edky s ALLSTAV.','amber')}<div id="stdSummaryContent"></div><label class="std-field">Pozn\u00e1mka pro dodavatele<textarea data-std-input="note" maxlength="2000" rows="3" style="width:100%;resize:vertical;padding:9px;border:1px solid #d7dfcf;border-radius:7px;font:inherit" placeholder="Up\u0159esn\u011bn\u00ed, k\u00f3dy dekor\u016f, dotazy..."></textarea></label><div class="std-export-grid"><button data-std-action="saveHtml">Zkop\u00edrovat odkaz s v\u00fdb\u011brem</button><button data-std-action="exportJson">Export nastaven\u00ed JSON</button><button data-std-action="importJson">Na\u010d\u00edst nastaven\u00ed JSON</button><button data-std-action="exportText">St\u00e1hnout soupis TXT</button><button data-std-action="png">Ulo\u017eit aktu\u00e1ln\u00ed n\u00e1hled PNG</button><button data-std-action="glb">Export viditeln\u00e9ho 3D GLB</button><button data-std-action="reset" class="std-reset">Obnovit v\u00fdchoz\u00ed standardy</button><button data-std-page="12">Otev\u0159\u00edt zdrojov\u00fd katalog</button></div><p class="std-fine">Zdroj: dodan\u00fd ALLSTAV Standardy, z\u00e1\u0159\u00ed 2025, a projekt RD. Fotografie a vzorky jsou v samostatn\u00e9 slo\u017ece assets. RGB n\u00e1hled nenahrazuje fyzick\u00fd vzorek RAL ani materi\u00e1lu. 3D n\u00e1hled nen\u00ed prov\u00e1d\u011bc\u00ed dokumentace.</p>`;
|
||||
}
|
||||
function stdSummary(){
|
||||
const s=stdState,premium='P\u0159\u00edplatek',vis='Vlastn\u00ed vizualizace',spec='Specifikace; bez zm\u011bny geometrie';
|
||||
const groups=[['Exteri\u00e9r',[
|
||||
['Vchodov\u00e9 dve\u0159e',`KeraT\u00fcr \u00b7 ${stdLabel('entryModel')} \u00b7 ${stdLabel('entryColor')}`,stdOption('entryModel')[3]?premium:'Standard'],
|
||||
['Venkovn\u00ed parapety',stdLabel('extSill'),stdOption('extSill')[3]?premium:'Standard'],['Okapy / svody',`Lindab ${stdLabel('gutter')}`,stdOption('gutter')[3]?premium:'Standard'],
|
||||
['St\u0159e\u0161n\u00ed ta\u0161ky',`Benders Palema Candor \u00b7 ${stdLabel('roof')}`,'Standard'],
|
||||
['Venkovn\u00ed \u017ealuzie',s.blinds?`Zetta 70 \u00b7 ${stdLabel('blindColor')} \u00b7 ${s.blindDrop} %`:'Vypnuto',s.blinds?premium+'; 9 otvor\u016f, bez troj\u00faheln\u00edku':'Voliteln\u00e9; projekt s nimi po\u010d\u00edt\u00e1']]],
|
||||
['Podlahy',STD_ROOMS.map(r=>{const f=s.floors[r[0]];return[`${r[0]} ${r[1]} (${r[2]} m\u00b2)`,`${f.type==='lam'?'KronoSwiss lamin\u00e1t':'Keramick\u00e1 dla\u017eba'} \u00b7 vzorek ${String(f.sample).padStart(2,'0')}`,f.type===r[3]?'K\u00f3d dekoru potvrdit':'Zm\u011bna druhu proti projektu'];})],
|
||||
['Povrchy a dve\u0159e',[
|
||||
['Obklad koupelny',`Vzorek ${s.wallTile105} \u00b7 do 2,1 m`,'K\u00f3d dekoru potvrdit'],['Obklad WC',`Vzorek ${s.wallTile106} \u00b7 do 2,1 m`,'K\u00f3d dekoru potvrdit'],
|
||||
['Ukon\u010den\u00ed obkladu',s.tileCorner==='profile'?'Eloxov\u00e1 li\u0161ta':'Kamenick\u00fd roh',s.tileCorner==='miter'?premium+'; '+spec:spec],['Vnit\u0159n\u00ed parapety','Helopal \u00b7 '+stdLabel('intSill'),'Standard'],
|
||||
['Malba',s.wallPaint==='#ffffff'?'HET b\u00edl\u00e1 \u00b7 Q2':s.wallPaint,s.wallPaint==='#ffffff'?'Standard':vis],
|
||||
['Interi\u00e9rov\u00e9 dve\u0159e','Pr\u00fcm CPL \u00b7 '+(s.intDoorCode||'dekor neur\u010den'),s.intDoor==='sample'?'Referen\u010dn\u00ed fotografie':vis+' '+s.intDoorColor],
|
||||
['Proveden\u00ed dve\u0159\u00ed',({hinged:'Oto\u010dn\u00e9 pln\u00e9',sliding:'Posuvn\u00e9 po zdi',pocket:'Do pouzdra Eclisse'})[s.intDoorType],s.intDoorType==='hinged'?'Standard':premium+'; '+spec],
|
||||
['Kov\u00e1n\u00ed',stdLabel('intHandle')+' rozeta \u00b7 brou\u0161en\u00fd nerez','Standard; WC z\u00e1mky, koupelna Klima 2']]],
|
||||
['Kuchy\u0148 \u00b7 vlastn\u00ed n\u00e1vrh',[
|
||||
['\u010cela sk\u0159\u00edn\u011bk',stdLabel('kitchenColor')+' '+s.kitchenColor,vis],['Korpusy sk\u0159\u00edn\u011bk',stdLabel('kitchenBodyColor')+' '+s.kitchenBodyColor,vis],['Pracovn\u00ed deska',stdLabel('worktop'),vis],['Obklad linky',stdLabel('backsplash'),vis]]],
|
||||
['Koupelna a sanita',[
|
||||
['Sprcha',s.shower==='tray'?'Ravak Chrome 900 \u00d7 900 mm, vani\u010dka + z\u00e1st\u011bna':'Walk-in, z\u00e1st\u011bna 800 mm, \u017el\u00e1bek 750 mm','Katalog str. 21; variantu a cenu potvrdit'],
|
||||
['Baterie',s.faucet==='chrome'?'Ravak Classic \u00b7 chrom':'\u010cern\u00e9 proveden\u00ed \u00b7 typ neur\u010den',s.faucet==='chrome'?'Standard':vis],
|
||||
['Vana','Ravak Chrome 1700 \u00d7 750 mm','Dle projektu'],['Umyvadlo koupelny','Laufen Pro 600 \u00d7 420 mm','Mal\u00e9 umyvadlo na WC: typ potvrdit'],['WC','2\u00d7 Geberit 112 / Ravak Uni Chrome Rimoff','Softclose; b\u00edl\u00e1 Sigma; dle p\u016fdorysu']]],
|
||||
['Technika \u00b7 specifikace',[
|
||||
['Rekuperace',({local:'Lok\u00e1ln\u00ed Dalap Zephir',central:'Brink Flair / Air Excellent',none:'Bez rekuperace'})[s.ventilation],spec],['Klimatizace',s.aircon?'Panasonic':'Bez klimatizace',spec],['Nucen\u00e9 v\u011btr\u00e1n\u00ed',s.fans?'Airflow ECO 15':'Bez dopl\u0148kov\u00fdch ventil\u00e1tor\u016f',s.fans?premium+'; '+spec:spec],
|
||||
['Digesto\u0159',s.hood==='facade'?'V\u00fdvod na fas\u00e1du \u00d8 150 mm':'Cirkula\u010dn\u00ed',s.hood==='facade'?premium+'; '+spec:'Standard; '+spec],['Cirkulace T\u00daV',s.hotWaterCirculation?'Ano':'Ne',spec],['Koralux 500 \u00d7 1500',s.radiator==='electric'?'Elektropatrona':'Elektropatrona + samostatn\u00fd okruh',s.radiator==='combined'?premium+'; '+spec:'Standard'],['Z\u00e1suvky / vyp\u00edna\u010de','ABB Levit b\u00edl\u00e1 / b\u00edl\u00e1','Standard'],['Vyt\u00e1p\u011bn\u00ed','Teplovodn\u00ed podlahov\u00e9 / Panasonic vzduch-voda','V\u00fdkon a rozvody dle projektu TZB'],['P\u016fdn\u00ed sch\u016fdky','Fakro LWT','Rozm\u011br potvrdit dle projektu']]]];
|
||||
return groups;
|
||||
}
|
||||
function stdSync(){
|
||||
if(!stdReady)return;
|
||||
for(const el of document.querySelectorAll('[data-std-input]')){const v=stdState[el.dataset.stdInput];if(el.type==='checkbox')el.checked=v;else if(document.activeElement!==el)el.value=v;}
|
||||
for(const b of document.querySelectorAll('[data-std-key]')){
|
||||
const k=b.dataset.stdKey;const v=k==='floorSample'?stdState.floors[stdFloorRoom].sample:k==='wallTile'?stdState['wallTile'+stdTileRoom]:stdState[k];b.setAttribute('aria-pressed',String(String(v)===b.dataset.stdValue));
|
||||
}
|
||||
const currents={entry:stdLabel('entryModel')+' \u00b7 '+stdLabel('entryColor'),extSill:stdLabel('extSill'),gutter:'Lindab '+stdLabel('gutter'),roof:stdLabel('roof'),blinds:stdState.blinds?stdLabel('blindColor')+' \u00b7 '+stdState.blindDrop+' %':'Vypnuto \u00b7 voliteln\u00e9',floors:'9 m\u00edstnost\u00ed \u00b7 samostatn\u00fd v\u00fdb\u011br dekoru',tiles:'Koupelna '+stdState.wallTile105+' \u00b7 WC '+stdState.wallTile106,doors:'Pr\u00fcm CPL \u00b7 '+stdLabel('intHandle')+' rozeta',finishes:'Helopal '+stdLabel('intSill')+' \u00b7 '+(stdState.wallPaint==='#ffffff'?'HET b\u00edl\u00e1':stdState.wallPaint),kitchen:stdLabel('kitchenColor')+' \u00b7 '+stdLabel('worktop'),bath:stdLabel('shower')+' \u00b7 '+stdLabel('faucet'),tech:'V\u011btr\u00e1n\u00ed, topen\u00ed, elektro'};
|
||||
for(const e of document.querySelectorAll('[data-std-current]'))e.textContent=currents[e.dataset.stdCurrent]||'';
|
||||
$('#stdBlindsControls').classList.toggle('std-disabled',!stdState.blinds);$('#stdBlindsControls').querySelectorAll('button,input').forEach(e=>e.disabled=!stdState.blinds);$('#stdBlindDropLabel').textContent=stdState.blindDrop+' %';
|
||||
$('#stdDoorCustom').hidden=stdState.intDoor!=='custom';
|
||||
$('#stdFloorRoom').value=stdFloorRoom;$('#stdFloorType').value=stdState.floors[stdFloorRoom].type;$('#stdTileRoom').value=stdTileRoom;
|
||||
const f=stdState.floors[stdFloorRoom],r=STD_ROOMS.find(r=>r[0]===stdFloorRoom);
|
||||
if($('#stdFloorSamples').dataset.type!==f.type){$('#stdFloorSamples').innerHTML=stdSampleCards('floorSample',f.type);$('#stdFloorSamples').dataset.type=f.type;$('#stdFloorSamples').querySelectorAll('button').forEach(b=>b.setAttribute('aria-pressed',String(+b.dataset.stdValue===f.sample)));}
|
||||
$('#stdFloorProjectNote').textContent='Projekt: '+(r[3]==='lam'?'lamin\u00e1t':'keramick\u00e1 dla\u017eba')+'. '+(f.type===r[3]?'Zvolen\u00fd druh krytiny odpov\u00edd\u00e1 projektu.':'Zvolen\u00fd druh se od projektu li\u0161\u00ed. Nutn\u00e9 potvrdit skladbu a v\u00fd\u0161ku podlahy.');
|
||||
$('#stdFloorProjectNote').classList.toggle('amber',f.type!==r[3]);
|
||||
$('#stdShowerSpec').textContent=stdState.shower==='tray'?'Ravak Chrome 900 \u00d7 900 mm, vani\u010dka a z\u00e1st\u011bna. Ravak Classic a sprchov\u00fd set 922 se 3 funkcemi.':'Walk-in: nerezov\u00fd odtokov\u00fd \u017el\u00e1bek 750 mm, pevn\u00e1 z\u00e1st\u011bna 800 mm, sp\u00e1dov\u00fd profil. Potvrdit skladbu podlahy, hydroizolaci a cenu.';
|
||||
$('#stdSummaryContent').innerHTML=stdSummary().map(([heading,rows])=>`<section class="std-summary-group"><h4>${stdEsc(heading)}</h4>${rows.map(([a,b,c])=>`<div class="std-summary-row"><span>${stdEsc(a)}</span><b>${stdEsc(b)}<small>${stdEsc(c)}</small></b></div>`).join('')}</section>`).join('');
|
||||
}
|
||||
function stdSet(key,value){
|
||||
const next=stdClone(stdState);
|
||||
if(key==='floorSample')next.floors[stdFloorRoom].sample=+value;else if(key==='wallTile')next['wallTile'+stdTileRoom]=+value;else next[key]=value;
|
||||
try{stdState=stdNormalize(next);if(['note','intDoorCode','intDoorType','tileCorner','hood','fans','aircon','ventilation','hotWaterCirculation','radiator'].includes(key)){gltf.extras.allstavSelection=stdClone(stdState);stdSync();clearTimeout(stdSaveTimer);stdSaveTimer=setTimeout(stdPersist,220);}else stdApply();}catch(e){toast(e.message);}
|
||||
}
|
||||
function stdClose(){document.body.classList.remove('std-open');$('#standardsToggle').classList.remove('active');$('#standardsToggle').setAttribute('aria-expanded','false');resize();}
|
||||
function stdOpen(tab=stdTab){
|
||||
stdSwitching=true;
|
||||
if($('#sunPanel').classList.contains('open'))toggleSunPanel(false);
|
||||
$('#panel').classList.remove('open');$('#infoButton').classList.remove('active');
|
||||
if(tab==='interior'&&!interior.enabled)toggleInterior(true);if(tab==='exterior'&&interior.enabled)toggleInterior(false);
|
||||
if($('#sunPanel').classList.contains('open'))toggleSunPanel(false);
|
||||
document.body.classList.add('std-open');stdTab=tab;
|
||||
$('#standardsToggle').classList.add('active');$('#standardsToggle').setAttribute('aria-expanded','true');
|
||||
document.querySelectorAll('[data-std-tab]').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.stdTab===tab)));
|
||||
['exterior','interior','summary'].forEach(k=>$('#std-'+k).hidden=k!==tab);
|
||||
autoRotate=false;$('#rotateButton')?.classList.remove('active');resize();stdSync();stdSwitching=false;
|
||||
}
|
||||
function stdView(id){
|
||||
if(['entry','terrace','site'].includes(id)){
|
||||
stdOpen('exterior');if(id==='site'){setPreset('entry');return;}
|
||||
setCamera(id==='entry'?{theta:-.12,phi:.09,distance:Math.max(8.5,8.5/aspect),target:[-3.65,1.22,3.84],ortho:false}:{theta:1.05,phi:.34,distance:Math.max(37,34/aspect),target:[1.0,1.0,0],ortho:false},id==='entry'?'Vstup \u00b7 standardy':'Terasa \u00b7 standardy');return;
|
||||
}
|
||||
stdOpen('interior');interior.furniture=true;$('#interiorFurniture').checked=true;
|
||||
if(id==='plan'||id==='axon'){setInteriorMode(id);return;}
|
||||
if(id==='shower'){
|
||||
enterInteriorView('105');interior.position=[-2.20,1.55,-2.10];const look=[-3.48,.93,-3.82],dir=vsub(look,interior.position);interior.yaw=Math.atan2(dir[0],dir[2]);interior.pitch=Math.atan2(dir[1],Math.hypot(dir[0],dir[2]));interior.fov=72;dirty=true;return;
|
||||
}
|
||||
if(id==='kitchen'){const v=interior.data.presets.find(p=>/kitchen/i.test(p.id));enterInteriorView(v?.id||'108');return;}
|
||||
enterInteriorView(id);
|
||||
}
|
||||
function stdShowCatalog(page){
|
||||
stdCatalogPage=page==='requestedDoor'?'requestedDoor':Math.min(25,Math.max(12,+page||12));
|
||||
const key=stdCatalogPage==='requestedDoor'?'requestedDoor':'page'+stdCatalogPage;
|
||||
$('#stdCatalogImage').src=STD_ASSETS[key];$('#stdCatalogImage').alt=stdCatalogPage==='requestedDoor'?'V\u00e1\u0161 referen\u010dn\u00ed obr\u00e1zek dve\u0159\u00ed':'ALLSTAV Standardy 2025, strana '+stdCatalogPage;
|
||||
$('#stdCatalogTitle').textContent=stdCatalogPage==='requestedDoor'?'V\u00fdchoz\u00ed dve\u0159e \u00b7 v\u00e1\u0161 obr\u00e1zek':'ALLSTAV Standardy 2025 \u00b7 strana '+stdCatalogPage;
|
||||
$('#stdCatalogCaption').textContent='P\u016fvodn\u00ed podklad. Odst\u00edny na displeji a fotografie jsou pouze orienta\u010dn\u00ed; konkr\u00e9tn\u00ed v\u00fdrobek a dostupnost ov\u011b\u0159te u dodavatele.';
|
||||
if(!$('#stdCatalogDialog').open)$('#stdCatalogDialog').showModal();
|
||||
interior.keys.clear();interior.pad.clear();
|
||||
}
|
||||
function stdExportData(){return{schema:'allstav-rd-config',version:1,catalog:'ALLSTAV Standardy, zari 2025',project:'RD Locenice',exportedAt:new Date().toISOString(),selection:stdClone(stdState),summary:stdSummary(),scope:'Illustrative geometry. Kitchen is a custom visualization. Technical alternatives are specification-only.'};}
|
||||
function stdText(){return 'RD LOCENICE | VYBER STANDARDU\nALLSTAV, katalog zari 2025\n'+new Date().toLocaleDateString('cs-CZ')+'\n\n'+stdSummary().map(([title,rows])=>title.toUpperCase()+'\n'+rows.map(([a,b,c])=>a+': '+b+' ['+c+']').join('\n')).join('\n\n')+'\n\nPOZNAMKA\n'+(stdState.note||'-')+'\n\nSoupis neni cenovou nabidkou. Kody neoznacenych vzorku, ceny, dostupnost a projektove zmeny potvrdit s dodavatelem. Vlastni kuchynske a barevne alternativy nejsou katalogovym standardem. 3D je ilustrativni, nikoli provadeci dokumentace.\n';}
|
||||
function stdSaveHtml(){
|
||||
const payload=stdExportData();const bytes=new TextEncoder().encode(JSON.stringify(payload));
|
||||
let binaryText='';bytes.forEach(b=>binaryText+=String.fromCharCode(b));
|
||||
const encoded=btoa(binaryText).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
const url=new URL(location.href);url.searchParams.delete('renderer');url.hash='vyber='+encoded;
|
||||
// Hash stays in the browser, not a server-side database. It contains the note too.
|
||||
if(navigator.clipboard&&window.isSecureContext)navigator.clipboard.writeText(url.href).then(()=>toast('Odkaz s vyberem byl zkopirovan. Obsahuje i poznamku.'),()=>prompt('Zkopirujte odkaz; obsahuje i poznamku:',url.href));
|
||||
else prompt('Zkopirujte odkaz; obsahuje i poznamku:',url.href);
|
||||
}
|
||||
|
||||
async function stdImport(file){
|
||||
if(!file)return;
|
||||
try{if(file.size>2*1024*1024)throw Error('Soubor je p\u0159\u00edli\u0161 velk\u00fd. Limit je 2 MB.');const data=JSON.parse(await file.text());if(data.schema!=='allstav-rd-config'||data.version!==1)throw Error('Nezn\u00e1m\u00fd form\u00e1t nebo verze JSON.');const next=stdNormalize(data.selection);stdState=next;stdApply();toast('Nastaven\u00ed bylo na\u010dteno.');}catch(e){toast('Import se nezda\u0159il: '+e.message);}
|
||||
$('#stdImport').value='';
|
||||
}
|
||||
function stdAction(action){
|
||||
if(action==='floorView')return stdView(stdFloorRoom);
|
||||
if(action==='matchKitchenBody')return stdSet('kitchenBodyColor',stdState.kitchenColor);
|
||||
if(action==='copyFloor'){const f=stdState.floors[stdFloorRoom];for(const r of STD_ROOMS)if(stdState.floors[r[0]].type===f.type)stdState.floors[r[0]].sample=f.sample;stdApply();toast('Vzorek p\u0159enesen do m\u00edstnost\u00ed se stejnou krytinou.');return;}
|
||||
if(action==='exportJson')return saveBlob(new Blob([JSON.stringify(stdExportData(),null,2)],{type:'application/json'}),'allstav_muj_vyber.json');
|
||||
if(action==='exportText')return saveBlob(new Blob(['\ufeff'+stdText()],{type:'text/plain;charset=utf-8'}),'allstav_soupis_standardu.txt');
|
||||
if(action==='importJson')return $('#stdImport').click();
|
||||
if(action==='saveHtml')return stdSaveHtml();
|
||||
if(action==='summary')return stdOpen('summary');
|
||||
if(action==='png'){render();canvas.toBlob(b=>{if(b)saveBlob(b,'dum_ALLSTAV_nahled.png');});return;}
|
||||
if(action==='glb')return saveBlob(new Blob([currentGlb()],{type:'model/gltf-binary'}),'dum_ALLSTAV_viditelny_vyber.glb');
|
||||
if(action==='reset')return $('#stdResetDialog').showModal();
|
||||
if(action==='original'){stdClose();$('#panel').classList.add('open');$('#infoButton').classList.add('active');}
|
||||
}
|
||||
function initStandards(){
|
||||
let loaded=false,embedded={};
|
||||
stdState=stdClone(STD_DEFAULTS);
|
||||
try{embedded=JSON.parse($('#stdSavedState').textContent||'{}');}catch(e){console.info('ALLSTAV: invalid embedded settings ignored.');}
|
||||
if(embedded.selection){
|
||||
try{stdState=stdNormalize(embedded.selection);loaded=true;}catch(e){console.info('ALLSTAV: invalid embedded selection ignored.');}
|
||||
let id=embedded.storageId;
|
||||
if(typeof id!=='string'||!/^[a-z0-9_-]{1,100}$/i.test(id)){
|
||||
// Stable fallback for HTML exports made by the previous version.
|
||||
let hash=2166136261;for(const ch of JSON.stringify(embedded.selection)){hash=Math.imul(hash^ch.charCodeAt(0),16777619);}
|
||||
id='legacy-'+(hash>>>0).toString(36);
|
||||
}
|
||||
stdStorageKey=STD_STORAGE+'.copy.'+id;
|
||||
}else stdStorageKey=STD_STORAGE;
|
||||
// Edits made after opening an exported copy take precedence on reload.
|
||||
// A new copy has its own key and opens with its embedded selection.
|
||||
try{const stored=localStorage.getItem(stdStorageKey);if(stored){stdState=stdNormalize(JSON.parse(stored));loaded=true;}}catch(e){console.info('ALLSTAV: invalid or inaccessible local settings ignored.');}
|
||||
stdBuildPanel();stdReady=true;stdApply(false);
|
||||
$('#standardsToggle').onclick=()=>document.body.classList.contains('std-open')?stdClose():stdOpen(interior.enabled?'interior':stdTab==='interior'?'exterior':stdTab);
|
||||
$('#stdClose').onclick=stdClose;
|
||||
$('#standardsPanel').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.stdKey){const k=b.dataset.stdKey,v=b.dataset.stdValue;stdSet(k,v);}else if(b.dataset.stdTab){stdOpen(b.dataset.stdTab);}else if(b.dataset.stdPage){stdShowCatalog(b.dataset.stdPage);}else if(b.dataset.stdView){stdView(b.dataset.stdView);}else if(b.dataset.stdAction){stdAction(b.dataset.stdAction);}});
|
||||
$('#standardsPanel').addEventListener('input',e=>{const el=e.target,k=el.dataset.stdInput;if(!k)return;let v=el.type==='checkbox'?el.checked:el.type==='range'?+el.value:el.value;stdSet(k,v);});
|
||||
$('#stdFloorRoom').onchange=e=>{stdFloorRoom=e.target.value;stdSync();};
|
||||
$('#stdFloorType').onchange=e=>{stdState.floors[stdFloorRoom]={type:e.target.value,sample:1};stdApply();};
|
||||
$('#stdTileRoom').onchange=e=>{stdTileRoom=e.target.value;stdSync();};
|
||||
$('#stdCatalogClose').onclick=()=>$('#stdCatalogDialog').close();$('#stdCatalogPrev').onclick=()=>stdShowCatalog(typeof stdCatalogPage==='number'?Math.max(12,stdCatalogPage-1):12);$('#stdCatalogNext').onclick=()=>stdShowCatalog(typeof stdCatalogPage==='number'?Math.min(25,stdCatalogPage+1):12);
|
||||
$('#stdResetCancel').onclick=()=>$('#stdResetDialog').close();$('#stdResetConfirm').onclick=()=>{stdState=stdClone(STD_DEFAULTS);stdApply();$('#stdResetDialog').close();toast('V\u00fdchoz\u00ed v\u00fdb\u011br byl obnoven.');};
|
||||
$('#stdImport').onchange=e=>stdImport(e.target.files[0]);
|
||||
for(const id of ['stdCatalogDialog','stdResetDialog'])$('#'+id).addEventListener('click',e=>{if(e.target.id===id){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}});
|
||||
const oldSun=toggleSunPanel;toggleSunPanel=function(on){if(on&&!stdSwitching)stdClose();return oldSun(on);};
|
||||
const oldToggle=toggleInterior;toggleInterior=function(on){if(!stdSwitching&&document.body.classList.contains('std-open'))stdClose();return oldToggle(on);};
|
||||
window.INTERIOR.toggle=toggleInterior;
|
||||
const oldPaint=setInteriorWallColor;setInteriorWallColor=function(hex,persist=true){const result=oldPaint(hex,persist);if(persist&&stdReady){stdState.wallPaint=interior.wallColor;stdSync();stdPersist();}return result;};window.INTERIOR.setWallColor=setInteriorWallColor;
|
||||
const oldInfo=$('#infoButton').onclick;$('#infoButton').onclick=e=>{stdClose();oldInfo?.(e);};
|
||||
const oldPanel=$('#interiorPanelToggle').onclick;$('#interiorPanelToggle').onclick=e=>{stdClose();oldPanel?.(e);};
|
||||
// Keep legacy wall colour controls synchronized with the new selection.
|
||||
document.querySelectorAll('[data-wall-color]').forEach(b=>b.addEventListener('click',()=>{stdState.wallPaint=interior.wallColor;stdSync();stdPersist();}));
|
||||
$('#roofColor').disabled=true;$('#roofColor').title='Barvu krytiny vyberte v sekci Standardy / Exteri\u00e9r.';
|
||||
$('#resetColors')?.addEventListener('click',()=>stdApply());
|
||||
window.addEventListener('keydown',e=>{if($('#stdCatalogDialog').open||$('#stdResetDialog').open){e.stopImmediatePropagation();return;}if(e.key==='Escape'&&document.body.classList.contains('std-open')){e.preventDefault();e.stopImmediatePropagation();stdClose();}},true);
|
||||
window.ALLSTAV_CONFIG={getState:()=>stdClone(stdState),defaults:()=>stdClone(STD_DEFAULTS),set:(patch)=>{stdState=stdNormalize({...stdClone(stdState),...patch});stdApply();return stdClone(stdState);},open:stdOpen,close:stdClose,view:stdView,summary:stdSummary,exportData:stdExportData,normalize:stdNormalize,visibleWindows:()=>[...new Set(meshes.filter(m=>m.std?.stdBlind&&visible(m)).map(m=>m.std.windowNode))],sourcePage:stdShowCatalog,saveHtml:stdSaveHtml,validate:()=>({windowCount:gltf.extras.allstavStandards.windows.length,triangleExcluded:!meshes.some(m=>m.std?.stdBlind&&m.std.windowNode===gltf.extras.allstavStandards.triangleExcludedNode),rooms:STD_ROOMS.length,materials:gltf.extras.allstavStandards.materials})};
|
||||
if(loaded)$('#stdSaved').textContent='Na\u010dten v\u00e1\u0161 ulo\u017een\u00fd v\u00fdb\u011br';else $('#stdSaved').textContent='V\u00fdchoz\u00ed v\u00fdb\u011br \u00b7 zm\u011bny se ukl\u00e1daj\u00ed automaticky';
|
||||
window.addEventListener('pagehide',()=>{if(stdSaveTimer){clearTimeout(stdSaveTimer);stdPersist();}});
|
||||
stdOpen('exterior');stdView('terrace');
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Only application startup. All paths are relative to /locenice/index.php. */
|
||||
async function json(path){const response=await fetch(path,{cache:'no-cache'});if(!response.ok)throw Error(path+' / HTTP '+response.status);return response.json();}
|
||||
try{
|
||||
const config=await json('./data/app-config.json');
|
||||
const [catalog,assets,references,scene,site]=await Promise.all([config.catalog,config.assets,config.references,config.sceneMetadata,config.site].map(json));
|
||||
const response=await fetch(config.model);if(!response.ok)throw Error('Model / HTTP '+response.status);
|
||||
const modelBytes=new Uint8Array(await response.arrayBuffer());
|
||||
config.threeModule=new URL(config.threeModule,location.href).href;config.threeLoader=new URL(config.threeLoader,location.href).href;
|
||||
// The standard GLTFLoader imports the same pinned Three.js copy.
|
||||
const map=document.createElement('script');map.type='importmap';map.textContent=JSON.stringify({imports:{three:config.threeModule}});document.head.appendChild(map);
|
||||
const hash=location.hash;
|
||||
if(hash.startsWith('#vyber='))try{
|
||||
let text=hash.slice(7).replace(/-/g,'+').replace(/_/g,'/');text+='='.repeat((4-text.length%4)%4);
|
||||
if(text.length>32000)throw Error('Too large');const bytes=Uint8Array.from(atob(text),ch=>ch.charCodeAt(0));const payload=JSON.parse(new TextDecoder().decode(bytes));
|
||||
if(payload.schema!=='allstav-rd-config'||payload.version!==1||!payload.selection)throw Error('Invalid selection');
|
||||
document.getElementById('stdSavedState').textContent=JSON.stringify({selection:payload.selection});
|
||||
}catch(error){console.warn('Invalid shared selection:',error);}
|
||||
window.LOCENICE_RESOURCES={config,catalog,assets,references,scene,site,modelBytes};
|
||||
await import('./app.js');
|
||||
}catch(error){
|
||||
console.error(error);const box=document.getElementById('loading');box.className='error';box.innerHTML='<div class="errorDetail"></div><p>Nahrajte celou slozku vcetne js, css, data, models a assets. Otevrete ji pres HTTP(S), nikoli dvojklikem na HTML.</p><a href="./models/dum.glb" download>Stahnout model GLB</a>';
|
||||
box.querySelector('.errorDetail').textContent=error.message;document.getElementById('webStatus').textContent='Chyba nacitani';
|
||||
}
|
||||
@@ -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);}
|
||||
};
|
||||
}
|
||||
@@ -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};
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Minimal glTF 2.0 container I/O, independent of either rendering backend. */
|
||||
export function parseGLB(bytes){
|
||||
if(!(bytes instanceof Uint8Array)||bytes.byteLength<28)throw Error('Invalid GLB length');
|
||||
const v=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);
|
||||
if(v.getUint32(0,true)!==0x46546c67||v.getUint32(4,true)!==2)throw Error('Expected glTF 2.0');
|
||||
if(v.getUint32(8,true)!==bytes.byteLength)throw Error('GLB length mismatch');
|
||||
let gltf=null,binary=null,off=12;
|
||||
while(off<bytes.length){if(off+8>bytes.length)throw Error('Invalid GLB chunk');const size=v.getUint32(off,true),type=v.getUint32(off+4,true);if(size%4||off+8+size>bytes.length)throw Error('Invalid GLB chunk bounds');
|
||||
const part=bytes.subarray(off+8,off+8+size);if(type===0x4e4f534a)gltf=JSON.parse(new TextDecoder().decode(part));else if(type===0x004e4942)binary=part;off+=8+size;}
|
||||
if(!gltf||!binary)throw Error('Missing GLB data');return {gltf,binary};
|
||||
}
|
||||
export function encodeGLB(gltf,binary){
|
||||
const text=new TextEncoder().encode(JSON.stringify(gltf)),jl=(text.length+3)&~3,bl=(binary.length+3)&~3;
|
||||
const out=new Uint8Array(28+jl+bl),v=new DataView(out.buffer);
|
||||
[0x46546c67,2,out.length,jl,0x4e4f534a].forEach((n,i)=>v.setUint32(4*i,n,true));out.fill(32,20,20+jl);out.set(text,20);
|
||||
v.setUint32(20+jl,bl,true);v.setUint32(24+jl,0x004e4942,true);out.set(binary,28+jl);return out;
|
||||
}
|
||||
@@ -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.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)dialog.close();}});
|
||||
const photos=[['kitchen1','Kuchy\u0148 / zelen\u00e1 a dub'],['kitchen2','Kuchy\u0148 / vnit\u0159n\u00ed strana'],['view1','Ob\u00fdvac\u00ed pokoj / tr\u00e1my'],['view2','Ob\u00fdvac\u00ed pokoj / vybaven\u00ed']];
|
||||
$('#projectPhotos').innerHTML=photos.map(([key,title])=>`<button type="button" data-photo="${key}"><img loading="lazy" src="${esc(resources.references[key].src)}" alt="${esc(title)}"><span>${esc(title)}</span></button>`).join('');
|
||||
$('#projectPhotos').onclick=e=>{const key=e.target.closest('[data-photo]')?.dataset.photo;if(!key)return;const r=resources.references[key];dialog.close();$('#interiorReferenceTitle').textContent=r.title;$('#interiorReferenceImage').src=r.src;$('#interiorReferenceImage').alt=r.title;$('#interiorReferenceCaption').textContent=r.caption;$('#interiorReferenceDialog').showModal();};
|
||||
const site=resources.site;
|
||||
$('#surveyStats').innerHTML=`<div><strong>${site.cadastralAreaInProject.toLocaleString('cs-CZ')} m\u00b2</strong><span>V\u00fdm\u011bra dle geometrick\u00e9ho pl\u00e1nu</span></div><div><strong>${site.digitizedOutlineArea.toLocaleString('cs-CZ')} m\u00b2</strong><span>Obrys dosavadn\u00edho 3D modelu</span></div><div><strong>${site.referenceElevation.toLocaleString('cs-CZ',{minimumFractionDigits:3})} m</strong><span>V\u00fd\u0161kov\u00e1 nula / Bpv</span></div><div><strong>${site.surveyPoints.length} bod\u016f</strong><span>V\u00fd\u0161kopis / grafick\u00fd ode\u010det polohy</span></div>`;
|
||||
$('#surveyShow').onclick=()=>{survey=!survey;$('#surveyShow').textContent=survey?'Skr\u00fdt body v\u00fd\u0161kopisu':'Zobrazit body v modelu';dialog.close();if(survey)focusSite();invalidate();};
|
||||
$('#backendNote').textContent=backend.backend==='three'?'Aktivn\u00ed renderer: Three.js r180. Knihovna se na\u010d\u00edt\u00e1 z CDN; model i podklady z va\u0161eho hostingu.':'Aktivn\u00ed renderer: lok\u00e1ln\u00ed WebGL. Three.js se nepoda\u0159ilo na\u010d\u00edst nebo byl zvolen z\u00e1lo\u017en\u00ed re\u017eim. Model a konfigur\u00e1tor funguj\u00ed bez CDN.';
|
||||
return {drawSurvey(project,clip,width,height){
|
||||
if(!survey||getMode().enabled){overlay.style.display='none';return;}
|
||||
overlay.style.display='block';overlay.setAttribute('viewBox',`0 0 ${width} ${height}`);overlay.style.width=width+'px';overlay.style.height=height+'px';
|
||||
const occupied=[];
|
||||
overlay.innerHTML=site.surveyPoints.map(p=>{const xyz=[p.xz[0],p.altitude-site.referenceElevation+.10,p.xz[1]],q=clip(xyz);if(q[3]<=0)return '';const [x,y]=project(xyz);if(x<0||y<0||x>width||y>height)return '';const value=p.altitude.toFixed(2).replace('.',',');let label='';for(const [dx,dy]of [[6,-6],[6,17],[-78,-6]]){const box={x:x+dx,y:y+dy-11,w:77,h:15};if(box.x<4||box.y<4||box.x+box.w>width-4||box.y+box.h>height-4)continue;if(occupied.some(b=>box.x<b.x+b.w&&box.x+box.w>b.x&&box.y<b.y+b.h&&box.y+box.h>b.y))continue;occupied.push(box);label=`<text x="${dx}" y="${dy}">${p.id} / ${value}</text>`;break;}return `<g transform="translate(${x.toFixed(1)},${y.toFixed(1)})"><title>${p.id} / ${value} m Bpv</title><circle r="3"/>${label}</g>`;}).join('');
|
||||
}};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/** Standard Three.js backend. UI and catalogue never depend on THREE internals. */
|
||||
export async function createThreeRenderer({canvas,gltf,binary,bytes,config}) {
|
||||
let timer;
|
||||
const modules=Promise.all([import(config.threeModule),import(config.threeLoader)]);
|
||||
const [T,{GLTFLoader}]=await Promise.race([modules,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Three.js CDN timeout')),config.cdnTimeoutMs||6500);})]).finally(()=>clearTimeout(timer));
|
||||
const loader=new GLTFLoader();
|
||||
const documentModel=await loader.parseAsync(bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength),'');
|
||||
const scene=new T.Scene();scene.add(documentModel.scene);
|
||||
const renderer=new T.WebGLRenderer({canvas,antialias:true,alpha:false,preserveDrawingBuffer:true,powerPreference:'high-performance'});
|
||||
renderer.outputColorSpace=T.SRGBColorSpace;
|
||||
renderer.toneMapping=T.ACESFilmicToneMapping;renderer.toneMappingExposure=1.05;
|
||||
renderer.shadowMap.enabled=true;renderer.shadowMap.type=T.PCFSoftShadowMap;renderer.shadowMap.autoUpdate=false;
|
||||
const ambient=new T.AmbientLight(0xffffff,.7);
|
||||
const skyLight=new T.HemisphereLight(0xf5f8ff,0x67604e,1.7);
|
||||
const sun=new T.DirectionalLight(0xffffff,3.0);
|
||||
const fill=new T.HemisphereLight(0xfff5df,0x77716a,0);
|
||||
scene.add(ambient,skyLight,sun,sun.target,fill);
|
||||
sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);sun.shadow.bias=-.00008;sun.shadow.normalBias=.025;
|
||||
const persp=new T.PerspectiveCamera(),ortho=new T.OrthographicCamera();
|
||||
const textures=await Promise.all((gltf.textures||[]).map((_,i)=>documentModel.parser.getDependency('texture',i)));
|
||||
const images=[];
|
||||
for (let i=0;i<textures.length;i++) {
|
||||
const imageSpec=gltf.images[gltf.textures[i].source];
|
||||
if(imageSpec.bufferView!==undefined){const b=gltf.bufferViews[imageSpec.bufferView];const src=URL.createObjectURL(new Blob([binary.subarray(b.byteOffset||0,(b.byteOffset||0)+b.byteLength)],{type:imageSpec.mimeType}));images[i]={src};}
|
||||
textures[i].anisotropy=Math.min(8,renderer.capabilities.getMaxAnisotropy());
|
||||
}
|
||||
const entries=[],matCopies=new Map(),associations=documentModel.parser.associations;
|
||||
documentModel.scene.updateMatrixWorld(true);
|
||||
documentModel.scene.traverse(obj=>{
|
||||
if(!obj.isMesh)return;
|
||||
const association=associations.get(obj)||{};
|
||||
let parent=obj,nodeIndex=association.nodes;
|
||||
while(nodeIndex===undefined&&parent){const a=associations.get(parent);if(a?.nodes!==undefined)nodeIndex=a.nodes;parent=parent.parent;}
|
||||
if(nodeIndex===undefined)return;
|
||||
const node=gltf.nodes[nodeIndex],meshSpec=gltf.meshes[node.mesh];
|
||||
const primitiveIndex=association.primitives||0;
|
||||
const primitive=meshSpec.primitives[primitiveIndex]||meshSpec.primitives[0];
|
||||
const materialIndex=primitive.material;
|
||||
if(materialIndex===undefined)return;
|
||||
const spec=gltf.materials[materialIndex],name=node.name||meshSpec.name||obj.name;
|
||||
// Separate copies prevent a material variant from changing unrelated objects.
|
||||
let material=matCopies.get(materialIndex);
|
||||
if(!material){material=obj.material.clone();material.side=T.DoubleSide;matCopies.set(materialIndex,material);}
|
||||
obj.material=material;
|
||||
const box=new T.Box3().setFromObject(obj);
|
||||
const pbr=spec.pbrMetallicRoughness||{};
|
||||
const desc={object:obj,std:node.extras||{},transparent:spec.alphaMode==='BLEND',material:materialIndex,name,nodeIndex,
|
||||
count:obj.geometry.index?.count||obj.geometry.attributes.position.count,context:/^(90_|91_|28_|23_)/.test(name),
|
||||
legacySite:!!node.extras?.legacySite,siteLayer:node.extras?.siteLayer||null,castShadow:node.extras?.castShadow!==false,
|
||||
bounds:{min:box.min.toArray(),max:box.max.toArray()},glass:(spec.name||'').startsWith('glass'),
|
||||
interiorLayer:node.extras?.interiorLayer||null,interiorMaterial:!!spec.extras?.interiorMaterial,
|
||||
interiorStyle:spec.extras?.interiorStyle||0,pbr};
|
||||
obj.castShadow=desc.castShadow&&!desc.glass&&!desc.transparent;obj.receiveShadow=!desc.transparent;
|
||||
if(desc.transparent){material.transparent=true;material.depthWrite=false;}
|
||||
entries.push(desc);
|
||||
});
|
||||
const stamps=new Map(),textureCopies=new Map();
|
||||
function localTexture(index,role,materialIndex,scale) {
|
||||
const key=materialIndex+':'+role+':'+index+':'+scale.join(',');
|
||||
if(!textureCopies.has(key)){
|
||||
const tx=textures[index]?.clone();if(!tx)return null;
|
||||
tx.colorSpace=role==='map'?T.SRGBColorSpace:T.NoColorSpace;
|
||||
tx.repeat.set(...scale);tx.needsUpdate=true;textureCopies.set(key,tx);
|
||||
}
|
||||
return textureCopies.get(key);
|
||||
}
|
||||
function syncMaterials(){
|
||||
for(const [index,mat] of matCopies){
|
||||
const spec=gltf.materials[index],p=spec.pbrMetallicRoughness||{};
|
||||
const stamp=JSON.stringify([p,spec.normalTexture,spec.emissiveFactor,spec.name]);if(stamps.get(index)===stamp)continue;stamps.set(index,stamp);
|
||||
const rgba=p.baseColorFactor||[.7,.7,.7,1];mat.color.setRGB(...rgba.slice(0,3));
|
||||
// Legacy exterior factors were sRGB; tagged PBR / interior factors are already linear.
|
||||
if(!spec.extras?.interiorMaterial&&!spec.extras?.linearColor&&!spec.extras?.hardieRole)mat.color.convertSRGBToLinear();
|
||||
mat.opacity=rgba[3]??1;mat.roughness=p.roughnessFactor??.8;mat.metalness=p.metallicFactor??0;
|
||||
const tex=p.baseColorTexture;
|
||||
mat.map=tex?localTexture(tex.index,'map',index,tex.extensions?.KHR_texture_transform?.scale||[1,1]):null;
|
||||
mat.normalMap=spec.normalTexture?localTexture(spec.normalTexture.index,'normalMap',index,[1,1]):null;
|
||||
if(mat.normalScale&&spec.normalTexture)mat.normalScale.setScalar(spec.normalTexture.scale??1);
|
||||
if(spec.extras?.interiorStyle===1){mat.emissive.copy(mat.color);mat.emissiveIntensity=.75;}
|
||||
mat.needsUpdate=true;
|
||||
}
|
||||
}
|
||||
const temp=new T.Vector3();let oldVisible='',oldInterior='';
|
||||
function shadowFit(frame){
|
||||
const low=[Infinity,Infinity,Infinity],high=[-Infinity,-Infinity,-Infinity];
|
||||
for(const m of entries){if(!m.object.visible||m.glass)continue;for(let a=0;a<3;a++){low[a]=Math.min(low[a],m.bounds.min[a]);high[a]=Math.max(high[a],m.bounds.max[a]);}}
|
||||
if(!Number.isFinite(low[0]))return;
|
||||
if(frame.interior.enabled){low.splice(0,3,-9,-1,-6);high.splice(0,3,11,6,6);}
|
||||
const center=low.map((v,i)=>(v+high[i])/2);
|
||||
sun.target.position.fromArray(center);sun.position.fromArray(center).addScaledVector(temp.fromArray(frame.solar.light),120);
|
||||
sun.target.updateMatrixWorld();sun.updateMatrixWorld();
|
||||
const c=sun.shadow.camera;c.near=.1;c.far=320;c.position.copy(sun.position);c.up.set(0,1,0);c.lookAt(sun.target.position);c.updateMatrixWorld();
|
||||
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
|
||||
for(let a=0;a<8;a++){temp.set(a&1?high[0]:low[0],a&2?high[1]:low[1],a&4?high[2]:low[2]).applyMatrix4(c.matrixWorldInverse);minX=Math.min(minX,temp.x);maxX=Math.max(maxX,temp.x);minY=Math.min(minY,temp.y);maxY=Math.max(maxY,temp.y);}
|
||||
c.left=minX-2;c.right=maxX+2;c.bottom=minY-2;c.top=maxY+2;c.updateProjectionMatrix();renderer.shadowMap.needsUpdate=true;
|
||||
}
|
||||
return {backend:'three',label:'Three.js r'+T.REVISION,meshes:entries,images,
|
||||
resize(w,h,dpr){renderer.setPixelRatio(dpr);renderer.setSize(w,h,false);},
|
||||
draw(frame){
|
||||
syncMaterials();
|
||||
let key='';for(const m of entries){m.object.visible=!!frame.visible(m);key+=m.object.visible?'1':'0';}
|
||||
if(frame.solar.shadowDirty||key!==oldVisible||oldInterior!==frame.interior.mode){shadowFit(frame);oldVisible=key;oldInterior=frame.interior.mode;}
|
||||
sun.intensity=Math.max(0,frame.solar.direct)*3.0;sun.color.fromArray(frame.solar.sunColor);sun.castShadow=frame.solar.shadows;
|
||||
const day=frame.solar.dayFactor;skyLight.intensity=.18+1.5*day;ambient.intensity=.12+.45*day;
|
||||
fill.intensity=frame.interior.enabled&&frame.interior.fill?1.3:0;
|
||||
scene.background=new T.Color().setRGB(...frame.solar.sky).convertSRGBToLinear();
|
||||
const camera=frame.orthographic?ortho:persp;
|
||||
camera.position.fromArray(frame.eye);camera.up.set(0,1,0);camera.lookAt(temp.fromArray(frame.lookTarget));camera.updateMatrixWorld();
|
||||
camera.projectionMatrix.fromArray(frame.vp).multiply(camera.matrixWorld);camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
|
||||
renderer.render(scene,camera);
|
||||
},
|
||||
dispose(){renderer.dispose();images.forEach(i=>i?.src&&URL.revokeObjectURL(i.src));textureCopies.forEach(t=>t.dispose());}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Prepare a Blender GLB export for the configurator.
|
||||
|
||||
python tools/prepare_export.py path/to/edited.glb --out prepared
|
||||
Produces prepared/dum.glb and prepared/scene.json. Keeps the original complete
|
||||
catalogue texture library, rebinds material/node indices using loceniceId.
|
||||
It does not update floor polygons, collision volumes, windows or structural design.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from copy import deepcopy
|
||||
import argparse, json, struct
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
|
||||
def read_glb(path):
|
||||
data=Path(path).read_bytes()
|
||||
if len(data)<28 or struct.unpack_from('<II',data)!=(0x46546c67,2):
|
||||
raise ValueError('Expected glTF 2.0 GLB')
|
||||
if struct.unpack_from('<I',data,8)[0]!=len(data):raise ValueError('GLB size mismatch')
|
||||
offset=12;g=None;b=None
|
||||
while offset<len(data):
|
||||
size,kind=struct.unpack_from('<II',data,offset)
|
||||
if offset+8+size>len(data):raise ValueError('Truncated GLB')
|
||||
chunk=data[offset+8:offset+8+size]
|
||||
if kind==0x4e4f534a:g=json.loads(chunk)
|
||||
elif kind==0x004e4942:b=chunk
|
||||
offset+=8+size
|
||||
if g is None or b is None:raise ValueError('Missing JSON or binary chunk')
|
||||
return g,b
|
||||
|
||||
def write_glb(path,g,b):
|
||||
j=json.dumps(g,ensure_ascii=False,separators=(',',':')).encode();j+=b' '*((-len(j))%4)
|
||||
b=bytes(b);b+=b'\0'*((-len(b))%4)
|
||||
Path(path).write_bytes(struct.pack('<IIIII',0x46546c67,2,28+len(j)+len(b),len(j),0x4e4f534a)+j+struct.pack('<II',len(b),0x004e4942)+b)
|
||||
|
||||
def rebind(source,edited,output,metadata=None):
|
||||
old,oldbin=read_glb(source);new,newbin=read_glb(edited);newbin=bytearray(newbin)
|
||||
if len(new.get('buffers',[]))!=1:raise ValueError('Use a single embedded GLB buffer')
|
||||
maps={}
|
||||
for plural in ['nodes','materials']:
|
||||
by_id={v.get('extras',{}).get('loceniceId'):i for i,v in enumerate(new[plural]) if v.get('extras',{}).get('loceniceId')}
|
||||
by_name={v.get('name'):i for i,v in enumerate(new[plural]) if v.get('name')}
|
||||
mapping={}
|
||||
for i,value in enumerate(old[plural]):
|
||||
key=value.get('extras',{}).get('loceniceId');index=by_id.get(key,by_name.get(value.get('name')))
|
||||
if index is not None:
|
||||
mapping[i]=index
|
||||
# Restore unsupported glTF extras lost by an editor, retain edited properties.
|
||||
new[plural][index]['extras']={**deepcopy(value.get('extras',{})),**new[plural][index].get('extras',{})}
|
||||
maps[plural]=mapping
|
||||
def node(i):
|
||||
if i not in maps['nodes']:raise ValueError(f'Missing configured object {old["nodes"][i].get("name",i)}. Preserve loceniceId, do not join variants.')
|
||||
return maps['nodes'][i]
|
||||
def material(i):
|
||||
if i not in maps['materials']:raise ValueError(f'Missing material {old["materials"][i].get("name",i)}. Do not merge material slots.')
|
||||
return maps['materials'][i]
|
||||
meta=deepcopy(metadata or old['extras'])
|
||||
h=meta['hardiePlank'];h['boardMaterial']=material(h['boardMaterial']);h['backingMaterial']=material(h['backingMaterial'])
|
||||
for key in ['boardNodes','backingNodes']:h[key]=[node(i)for i in h[key]]
|
||||
interior=meta['interiorStudy']
|
||||
for key in ['roofNodes','cutOriginalNodes']:interior[key]=[node(i)for i in interior[key]]
|
||||
interior['wallPaint']['materialIndices']=[material(i)for i in interior['wallPaint']['materialIndices']]
|
||||
if 'node'in interior.get('timberReference',{}):interior['timberReference']['node']=node(interior['timberReference']['node'])
|
||||
std=meta['allstavStandards'];std['materials']={k:[material(i)for i in v]for k,v in std['materials'].items()}
|
||||
std['triangleExcludedNode']=node(std['triangleExcludedNode'])
|
||||
for window in std['windows']:window['node']=node(window['node'])
|
||||
for value in new['nodes']:
|
||||
extras=value.get('extras',{})
|
||||
if 'windowNode'in extras:extras['windowNode']=node(extras['windowNode'])
|
||||
# The app uses a stable texture library: indices 0..31, including inactive samples.
|
||||
# Blender exports only used images; append the complete library from the source GLB.
|
||||
count=old.get('extras',{}).get('webTextureLibrarySize',32)
|
||||
for value in new.get('materials',[]):
|
||||
def shift(obj):
|
||||
if not isinstance(obj,dict):return
|
||||
for key,val in obj.items():
|
||||
if isinstance(val,dict):
|
||||
if key.endswith('Texture')and isinstance(val.get('index'),int):val['index']+=count
|
||||
else:shift(val)
|
||||
shift(value)
|
||||
oldsamplers=old.get('samplers',[]);sampler_offset=len(new.setdefault('samplers',[]));new['samplers']+=deepcopy(oldsamplers)
|
||||
lib=[];images={}
|
||||
for texture in old['textures'][:count]:
|
||||
src=texture['source']
|
||||
if src not in images:
|
||||
im=deepcopy(old['images'][src]);bv=old['bufferViews'][im['bufferView']]
|
||||
start=bv.get('byteOffset',0);payload=oldbin[start:start+bv['byteLength']]
|
||||
newbin+=b'\0'*((-len(newbin))%4);offset=len(newbin);newbin+=payload
|
||||
view_index=len(new.setdefault('bufferViews',[]));new['bufferViews'].append({'buffer':0,'byteOffset':offset,'byteLength':len(payload)})
|
||||
im['bufferView']=view_index;images[src]=len(new.setdefault('images',[]));new['images'].append(im)
|
||||
tex=deepcopy(texture);tex['source']=images[src]
|
||||
if 'sampler'in tex:tex['sampler']+=sampler_offset
|
||||
lib.append(tex)
|
||||
new['textures']=lib+new.get('textures',[])
|
||||
new['buffers'][0]['byteLength']=len(newbin)
|
||||
meta['webTextureLibrarySize']=count;meta['webRevision']='locenice-web-edited'
|
||||
new['extras']={**new.get('extras',{}),**meta}
|
||||
output=Path(output);output.mkdir(parents=True,exist_ok=True)
|
||||
if (output/'dum.glb').exists()or(output/'scene.json').exists():raise FileExistsError('Output already exists; choose a new directory')
|
||||
write_glb(output/'dum.glb',new,newbin)
|
||||
(output/'scene.json').write_text(json.dumps(meta,ensure_ascii=False,indent=2))
|
||||
return {'nodes':len(new['nodes']),'materials':len(new['materials']),'textures':len(new['textures']),'mappedNodes':len(maps['nodes'])}
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('edited',type=Path);parser.add_argument('--out',type=Path,default=Path('prepared'))
|
||||
parser.add_argument('--source',type=Path,default=ROOT/'locenice'/'models'/'dum.glb')
|
||||
args=parser.parse_args()
|
||||
print(rebind(args.source,args.edited,args.out))
|
||||
print('Back up the website first. Replace models/dum.glb AND data/scene.json together.')
|
||||
print('Use Three.js for exported transforms. Recheck dimensions, variants, collisions and camera presets.')
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Optional: download pinned Three.js r180 locally, on a computer with internet.
|
||||
python tools/vendor_three.py
|
||||
Then upload the generated locenice/vendor folder AND data/app-config.json.
|
||||
Does not upload anything. No credentials or server-side installer required.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
import json, re, hashlib
|
||||
ROOT=Path(__file__).resolve().parent.parent/'locenice'
|
||||
BASE='https://raw.githubusercontent.com/mrdoob/three.js/r180/'
|
||||
VENDOR=ROOT/'vendor'/'three-r180'
|
||||
seen=set()
|
||||
|
||||
def download(relative: str):
|
||||
if relative in seen:return
|
||||
if '..' in Path(relative).parts or relative.startswith('/'):
|
||||
raise ValueError('Unsafe dependency path: '+relative)
|
||||
seen.add(relative)
|
||||
req=Request(BASE+relative,headers={'User-Agent':'Locenice-vendor-setup/1.0'})
|
||||
with urlopen(req,timeout=30) as response:
|
||||
data=response.read()
|
||||
destination=VENDOR/relative
|
||||
destination.parent.mkdir(parents=True,exist_ok=True)
|
||||
destination.write_bytes(data)
|
||||
if relative.endswith('.js'):
|
||||
code=data.decode('utf-8')
|
||||
for dep in re.findall(r"(?:from\s*|import\s*)['\"]([^'\"]+)['\"]",code):
|
||||
if not dep.startswith('.'):continue
|
||||
joined=(destination.parent/dep).resolve()
|
||||
if not joined.is_relative_to(VENDOR.resolve()):raise ValueError('Out of vendor root')
|
||||
download(joined.relative_to(VENDOR.resolve()).as_posix())
|
||||
print(relative,hashlib.sha256(data).hexdigest())
|
||||
|
||||
if __name__=='__main__':
|
||||
for filename in ['build/three.module.js','examples/jsm/loaders/GLTFLoader.js','LICENSE']:
|
||||
download(filename)
|
||||
config_path=ROOT/'data'/'app-config.json'
|
||||
config=json.loads(config_path.read_text())
|
||||
config['threeModule']='./vendor/three-r180/build/three.module.js'
|
||||
config['threeLoader']='./vendor/three-r180/examples/jsm/loaders/GLTFLoader.js'
|
||||
config_path.write_text(json.dumps(config,ensure_ascii=False,indent=2))
|
||||
print('Done. Upload the vendor folder and app-config.json.')
|
||||