This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Kovářova 193/54 – prostorová studie stávajícího stavu
|
||||
|
||||
Místní interaktivní model domu v Praze – Stodůlkách. Podklady jsou výkresy **stávajícího stavu**, nikoli návrh nového stavu. Konstrukce označené k bourání jsou proto zachované.
|
||||
|
||||
## Spuštění
|
||||
|
||||
Na Macu spusťte `Spustit.command` (vyžaduje Python 3). Nebo otevřete terminál v rozbalené složce a spusťte:
|
||||
|
||||
```
|
||||
python3 -m http.server 8018 --bind 127.0.0.1
|
||||
```
|
||||
|
||||
V prohlížeči otevřete http://127.0.0.1:8018/. Pokud tento port už používá předchozí náhled, použijte například 8019. Soubor index.html je třeba otevřít přes server, nikoli přes file://. Všechny knihovny jsou přibalené; po rozbalení není potřeba připojení k internetu.
|
||||
|
||||
## Ovládání
|
||||
|
||||
- Exteriér: tažení otáčí, kolečko přibližuje, pravé tlačítko posouvá.
|
||||
- Interiér: 1PP, 1NP a 2NP přepínají podlaží. Dostupný je půdorys, 3D řez a procházení.
|
||||
- Výběr místnosti v mapě nebo seznamu přesune kameru dovnitř. WASD slouží k chůzi, šipky k chůzi/otáčení; na dotykovém zařízení je směrový ovladač. Tažením se rozhlížíte.
|
||||
- Schodiště je schematické. Podlaží přepínejte tlačítky, průchod po schodech je nepropojuje automaticky.
|
||||
- Vybavení, popisy a přisvětlení lze vypnout, barvu stěn změnit.
|
||||
- Slunce: datum, pražský místní čas včetně letního času, typické dny, přehrávání dne, stíny a dráha slunce.
|
||||
- PNG uloží aktuální pohled. GLB v exteriéru uloží celý model včetně sklepa; v interiéru jen vybrané zobrazené podlaží. Rozměry GLB jsou v metrech, osa Y směřuje vzhůru.
|
||||
|
||||
## Zdroj a přesnost domu
|
||||
|
||||
Sedm přiložených PDF ve složce references: půdorysy 1PP/1NP/2NP, řez A, situace a čtyři pohledy. Výkresy vs-studio mají datum 22. 9. 2026, dodané soubory 20. 10. 2026.
|
||||
|
||||
Plochy v popiscích jsou tištěné hodnoty dokumentace:
|
||||
|
||||
| Podlaží | Plocha |
|
||||
|---|---:|
|
||||
| Suterén | 14,22 m² |
|
||||
| Přízemí | 98,71 m² |
|
||||
| Patro bez balkónu | 65,04 m² |
|
||||
| Balkón | 27,88 m² |
|
||||
|
||||
Půdorysné souřadnice jsou digitalizované a geometricky zjednodušené; plochy polygonů se mohou od legendy mírně lišit. Schodišťové plochy nejsou přesným výkazem výměr. Rozdílné úrovně podlah jsou zachované: sklep −2,200 m, podesta −1,900 m, kuchyň přízemí −0,040 m; patro +3,250 až +3,550 m a balkón +3,450 m. Hlavní hřeben je +8,850 m, římsa +6,800 m. Nižší střechy a šikmé podhledy jsou prostorově přibližné podle pohledů a půdorysů.
|
||||
|
||||
**Vybavení a materiály jsou ilustrativní**, podle účelu místností. Nejsou doloženým skutečným stavem ani odsouhlaseným návrhem. Otvory, rámy, schodiště a drobné stavební detaily jsou zjednodušené. Přisvětlení není výpočet denního osvětlení.
|
||||
|
||||
## Pozemek a výškopis
|
||||
|
||||
Veřejný zdroj se podařilo získat: **ČÚZK / Zeměměřický úřad, DMR 5G**, licence CC BY 4.0, staženo 6. 11. 2026.
|
||||
|
||||
- Služba: https://ags.cuzk.cz/arcgis2/rest/services/dmr5g/ImageServer
|
||||
- Otevřená data: https://ags.cuzk.cz/opendata/
|
||||
- Použitý výřez v S-JTSK EPSG:5514: −751282, −1045964, −751208, −1045916.
|
||||
- Raster služby má základní rozlišení 2 m; export byl převzorkován po 1 m bilineární interpolací. Převzorkování nezvyšuje přesnost původních dat. Výšky jsou v Bpv.
|
||||
- Výřez a převod pozemku do modelu jsou uložené v data/site.json. Vizuální síť je znovu interpolovaná z těchto dat.
|
||||
|
||||
**Není doložená vazba podlahy ±0,000 na Bpv.** Hodnota 372,45 m v nastavení je odhad z veřejného terénu u domu, nikoli kóta dokumentace. Lze ji měnit v panelu Projekt. Veřejný historický model se liší od některých bodů v situačním výkresu. Terén proto slouží pouze k orientaci; jeho napojení na sokl a schody, opěrné zdi a terénní stupně je nutné ověřit zaměřením. Situace v příloze již obsahuje bodové výšky, ale bez samostatného seznamu bodů a jednoznačné vazby výšky podlahy je nepovažujeme za vyřešené výškové osazení.
|
||||
|
||||
Parcelní hranice jsou z veřejné služby RÚIAN (https://ags.cuzk.cz/arcgis/rest/services/RUIAN/MapServer), parcely 1089 (302 m²), 1090/1 (722 m²) a 1090/2 (34 m²). Součet 1 058 m² nevyjadřuje ověřené vlastnictví. Adresní bod RÚIAN 22111697, stavební objekt 21985642. https://vdp.cuzk.gov.cz/vdp/ruian/adresnimista/22111697
|
||||
|
||||
Souřadnice polohy: 50,05091024° N, 14,31001395° E. Orientace osy dolů v půdorysu je 251,172° od zeměpisného severu, odvozená z rohů budovy v RÚIAN. Hmota výkresu je georeferencovaná podle severní fasády; katastrální a výkresový obrys nejsou totožné.
|
||||
|
||||
## Výpočet slunce a omezení
|
||||
|
||||
Astronomická poloha podle rovnic Meeus / NOAA. Časové pásmo Europe/Prague. Model počítá přímé světlo a geometrické stíny; neřeší oblačnost, odrazy, okolní budovy, profil horizontu ani skutečné vlastnosti zasklení. Neslouží jako normové posouzení oslunění ani energetická simulace.
|
||||
|
||||
## Soubory
|
||||
|
||||
- js/plan.js: místnosti, kóty a otvory
|
||||
- js/model.js: dům a terén
|
||||
- js/furniture.js: orientační vybavení
|
||||
- js/interior-view.js: podlaží a procházení
|
||||
- js/kovarova.js, js/solar.js: aplikace a slunce
|
||||
- vendor/three: Three.js 0.180, licence MIT v přiloženém LICENSE
|
||||
|
||||
Model je prezentační studie. Není BIM, geodetický ani prováděcí podklad.
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/zsh
|
||||
cd -- "${0:A:h}"
|
||||
python3 -m http.server 8018 --bind 127.0.0.1 &
|
||||
server_pid=$!
|
||||
trap 'kill "$server_pid" 2>/dev/null' EXIT INT TERM
|
||||
open 'http://127.0.0.1:8018/'
|
||||
wait "$server_pid"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
:root{--solar:#bd984f;--solar-soft:#f5eedc;--sun-side:380px}
|
||||
header h1{font-size:32px;letter-spacing:-1px}
|
||||
.topright{z-index:5}
|
||||
.panel{z-index:6;max-height:calc(100dvh - 96px);overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}
|
||||
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}
|
||||
.panel-kicker{font-size:9px;font-weight:700;letter-spacing:1.7px;color:#8d753d;margin-bottom:6px}
|
||||
.panel-close{flex:0 0 30px;border:0;border-radius:50%;width:30px;height:30px;background:#eef1e9;color:#657160;font-size:21px;line-height:1}
|
||||
.panel .row{gap:12px}
|
||||
.panel .row strong{text-align:right}
|
||||
.panel .status{margin-top:18px}
|
||||
.site-switch{font-family:Arial,Helvetica,sans-serif}
|
||||
.site-controls{display:flex}
|
||||
#siteParking{accent-color:#536f56}
|
||||
#loading{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;gap:11px;background:#e7ebe3;color:#4f6252;font-size:12px;letter-spacing:.2px;transition:opacity .25s}
|
||||
#loading.done{opacity:0;pointer-events:none}
|
||||
#loading.error{inset:auto 50% 50% auto;transform:translate(50%,50%);width:min(420px,calc(100vw - 36px));padding:24px;background:#fafbf6;border:1px solid #d8ded4;border-radius:14px;box-shadow:0 12px 38px #26342c22;line-height:1.6}
|
||||
.loader-dot{width:8px;height:8px;border-radius:50%;background:#607f63;box-shadow:0 0 0 5px #607f6320;animation:pulse 1s ease-in-out infinite alternate}
|
||||
@keyframes pulse{to{transform:scale(.72);opacity:.5}}
|
||||
#toast{position:fixed;left:50%;top:22px;z-index:10;transform:translateX(-50%);padding:9px 13px;border-radius:8px;background:#26342ce8;color:white;font-size:11px;opacity:0;pointer-events:none;transition:opacity .2s}
|
||||
#sunOverlay{z-index:2}
|
||||
#sunPanel{z-index:7}
|
||||
#panel.open{display:block}
|
||||
body.info-open #view,body.info-open #measure,body.info-open #sunOverlay{width:calc(100% - 390px);height:100%}
|
||||
body.info-open .bottom{left:calc((100% - 390px)/2)}
|
||||
body.info-open .compass{right:408px}
|
||||
body.sun-open .bottom{left:calc((100% - var(--sun-side))/2)}
|
||||
body.sun-open .compass{right:calc(var(--sun-side) + 18px)}
|
||||
body.sun-open #view,body.sun-open #measure,body.sun-open #sunOverlay{width:calc(100% - var(--sun-side));height:100%}
|
||||
#sunStamp{z-index:2}
|
||||
@media(max-width:1100px) and (min-width:851px){header .metrics{gap:14px}.metric strong{font-size:16px}.topright{right:15px;gap:5px}.btn{padding:9px 11px}.toolbar .tool{padding:9px 8px;font-size:10px}.site-switch{font-size:9px;padding:8px 9px}.panel{width:320px}.sun-open #view,.sun-open #measure,.sun-open #sunOverlay{width:calc(100% - 360px)}#sunPanel{width:332px}.sun-open .bottom{left:calc((100% - 360px)/2)}.sun-open .compass{right:378px}}
|
||||
@media(max-width:850px){header{left:18px;top:17px}header h1{font-size:23px;margin:6px 0 4px}.metrics{display:none!important}.sub{font-size:10px}.topright{top:15px;right:12px;gap:5px}.topright .btn{padding:8px 10px;font-size:10px}.topright .btn.primary{padding:8px 10px}.bottom{max-width:calc(100% - 14px);bottom:13px;gap:6px}.toolbar{gap:1px;max-width:100%;overflow-x:auto;padding:5px;border-radius:12px}.toolbar .tool{padding:8px 8px;font-size:10px}.toolbar .divider{margin:0 2px;height:17px}.site-switch{padding:8px 7px;font-size:9px}.help{font-size:8px}.compass{right:13px;bottom:12px;width:62px;height:62px}.panel{left:8px;right:8px;top:auto;bottom:8px;width:auto;max-height:min(72dvh,570px);border-radius:15px;padding:17px}.panel h2{font-size:20px}.panel p{font-size:11px}#sunPanel{left:8px;right:8px;top:auto;bottom:8px;width:auto;max-height:min(68dvh,570px);padding:15px;border-radius:15px}.sun-panel-head{margin-bottom:10px}.sun-date-row{grid-template-columns:1fr 95px;gap:8px}.sun-field #sunClock{width:95px;font-size:18px}.sun-graph{height:70px}.sun-play-row{margin-top:8px}.sun-readouts{margin-top:12px;padding-top:12px}.sun-stat strong{font-size:21px}.sun-events{margin:10px 0 2px;padding:9px 0}body.info-open #view,body.info-open #measure,body.info-open #sunOverlay{width:100%;height:calc(100dvh - min(72dvh,570px) - 16px)}body.sun-open #view,body.sun-open #measure,body.sun-open #sunOverlay{width:100%;height:calc(100dvh - min(68dvh,570px) - 16px)}body.info-open .bottom{left:50%;bottom:calc(min(72dvh,570px) + 13px)}body.sun-open .bottom{left:50%;bottom:calc(min(68dvh,570px) + 13px)}body.info-open .compass,body.sun-open .compass{display:none}body.info-open header .sub,body.sun-open header .sub{display:none}#sunStamp{left:18px;top:87px;font-size:9px}#webStatus{left:10px;bottom:5px;font-size:7px}body.info-open .topright,body.sun-open .topright{z-index:8}}
|
||||
@media(max-width:520px){.topright .btn{padding:7px 8px}.topright #infoButton{font-size:0;width:33px;height:32px;position:relative}.topright #infoButton:after{content:'i';font-size:14px;font-weight:600}.topright #sunToggle .toggle-word{display:none}.topright #sunToggle{width:34px;justify-content:center;padding:0}.topright .btn.primary{font-size:9px}.bottom{max-width:calc(100% - 8px);bottom:8px}.toolbar .tool{padding:8px 6px;font-size:9px}.toolbar .site-switch{padding:7px 5px;font-size:8px}.toolbar #savePng{display:none}.toolbar{max-width:100vw}.help{display:none}#sunPanel{max-height:72dvh}.sun-hint{margin:5px 0 8px}.sun-advanced{margin-top:10px;padding-top:9px}}
|
||||
@media(max-width:390px){.topright{gap:3px}.topright .btn.primary{padding:7px}.toolbar .tool{padding:8px 5px}.toolbar .divider{display:none}.toolbar .site-switch{padding:6px 4px}.toolbar{gap:0}}
|
||||
|
||||
/* Interior shares the Ločenice controls; dimensions are specific to this plan. */
|
||||
body.interior-active #infoButton,body.interior-active #sunToggle{display:none}
|
||||
body.interior-active .topright{right:18px}
|
||||
body.interior-active #sunOverlay{display:none}
|
||||
body.interior-active #interiorPanel{z-index:5;width:308px;box-sizing:border-box}
|
||||
#interiorRoom{width:100%;border:1px solid #d6dfcf;border-radius:7px;padding:9px;color:#365944}
|
||||
#interiorPanel .wall-paint{justify-content:space-between}
|
||||
#interiorWallColor{width:42px;height:29px;border:1px solid #ccd6c6;border-radius:5px;padding:2px}
|
||||
.int-source-buttons a{font-size:10px;color:#365944;padding:6px;border:1px solid #d4ddca;border-radius:6px;text-decoration:none}
|
||||
#interiorMap .map-room:focus{outline:none;fill:#b5c7a5;stroke:#365944;stroke-width:2}
|
||||
body.interior-active #webStatus{display:none}
|
||||
body.interior-active #interiorLabels{overflow:hidden}
|
||||
@media(max-width:850px){
|
||||
#interiorLaunch{top:118px}
|
||||
body.interior-active .interior-heading{top:17px;left:14px;max-width:calc(100% - 130px)}
|
||||
body.interior-active .interior-heading .eyebrow{display:none}
|
||||
body.interior-active .interior-heading h2{font-size:14px;max-width:170px;line-height:1.2}
|
||||
body.interior-active #interiorPanel{left:0;right:0;top:auto;bottom:0;width:100%;height:242px;padding:15px 18px 24px;border-radius:18px 18px 0 0}
|
||||
body.interior-active:not(.interior-panel-closed) #view{width:100%;height:calc(100% - 242px)}
|
||||
body.interior-active.interior-panel-closed #view{width:100%;height:100%}
|
||||
body.interior-active #interiorTools{left:50%;bottom:250px}
|
||||
body.interior-active.interior-panel-closed #interiorTools{left:50%;bottom:17px}
|
||||
body.interior-walk #walkPad{bottom:304px}
|
||||
body.interior-walk.interior-panel-closed #walkPad{bottom:80px}
|
||||
#interiorMap{width:145px;float:left;margin-right:14px}
|
||||
.int-mode-bar button{padding:9px 8px}
|
||||
}
|
||||
@media(max-width:420px){
|
||||
body.interior-active .interior-heading h2{font-size:12px;max-width:112px}
|
||||
body.interior-active .interior-heading{gap:7px}
|
||||
body.interior-active .topright{right:10px}
|
||||
#interiorMap{width:130px}
|
||||
.int-room-tag{font-size:8px;padding:3px 4px}
|
||||
}
|
||||
|
||||
.floor-tabs{display:flex;gap:6px;margin:16px 0 8px}.floor-tabs button{flex:1;border:1px solid #ccd4c6;background:#f4f5ef;padding:9px 5px;border-radius:8px;color:#4b5d4c;font:600 12px inherit;cursor:pointer}.floor-tabs small{display:block;font-size:10px;font-weight:400;margin-top:3px}.floor-tabs .active{background:#405c44;color:white;border-color:#405c44}#interiorMap{max-height:245px}#floorSummary{font-size:10px}.panel a{color:#476449}#terrainDatum{margin:5px 0 12px;max-width:100%}.interior-panel-title{display:block}.interior-panel-title span{display:block;margin-top:6px}
|
||||
@media(max-width:530px){body:not(.interior-active) .topright #infoButton{display:inline-flex;align-items:center;justify-content:center}}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* 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}}
|
||||
|
||||
/* Výška plotu: druhá řada pod přepínači okolí, jen při zapnutém plotu. */
|
||||
.fence-heights[hidden]{display:none}.fence-heights{gap:3px}.fence-heights-label{font-size:10px;color:#6e8066;padding:0 6px 0 5px;white-space:nowrap}.fence-heights button.site-switch{background:transparent;font-family:inherit;line-height:1.1}.fence-heights button.site-switch.selected{background:#e1ecdc}
|
||||
@media(max-width:520px){.fence-heights-label{font-size:9px;padding:0 3px}.fence-heights button.site-switch{padding:8px 5px}}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,149 @@
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="theme-color" content="#e7ebe3">
|
||||
<title>Kovářova | Rodinný dům</title>
|
||||
<link rel="stylesheet" href="./css/app.css?v=20261106-1905">
|
||||
<link rel="stylesheet" href="./css/web.css?v=20261106-1905">
|
||||
<link rel="stylesheet" href="./css/kovarova.css?v=20261106-1905">
|
||||
<script type="importmap">
|
||||
{"imports":{"three":"./vendor/three/build/three.module.js","three/addons/":"./vendor/three/examples/jsm/"}}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="view" aria-label="Interaktivní trojrozměrný model rodinného domu v Kovářově"></canvas>
|
||||
<svg id="measure" aria-hidden="true"></svg>
|
||||
<header>
|
||||
<div class="eyebrow"><span class="dot"></span>RODINNÝ DŮM · KOVÁŘOVA</div>
|
||||
<h1>Kovářova</h1>
|
||||
<div class="sub">193/54 · Praha – Stodůlky · stávající stav</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><strong>98,71 m²</strong><small>Přízemí</small></div>
|
||||
<div class="metric"><strong>65,04 m²</strong><small>Patro bez balkónu</small></div>
|
||||
<div class="metric"><strong>14,22 m²</strong><small>Suterén</small></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="topright">
|
||||
<button class="btn" id="infoButton" type="button" aria-expanded="false" title="Projekt a podklady">Projekt</button>
|
||||
<button class="btn" id="sunToggle" type="button" 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 primary" id="saveGlb" type="button">Stáhnout <span class="downloadText">model</span> GLB</button>
|
||||
</div>
|
||||
|
||||
<aside class="panel" id="panel" aria-label="Informace o projektu">
|
||||
<div class="panel-head">
|
||||
<div><div class="panel-kicker">RODINNÝ DŮM · KOVÁŘOVA</div><h2>Dům a pozemek</h2></div>
|
||||
<button class="panel-close" id="infoClose" type="button" aria-label="Zavřít panel">×</button>
|
||||
</div>
|
||||
<p>Prostorová studie stávajícího domu podle dodané dokumentace vs-studio z roku 2026. Obsahuje suterén, přízemí, patro a balkón.</p>
|
||||
<h3>Dům v číslech</h3>
|
||||
<div class="row"><span>1PP · suterén</span><strong>14,22 m²</strong></div>
|
||||
<div class="row"><span>1NP · přízemí</span><strong>98,71 m²</strong></div>
|
||||
<div class="row"><span>2NP · uzavřené místnosti</span><strong>65,04 m²</strong></div>
|
||||
<div class="row"><span>Balkón</span><strong>27,88 m²</strong></div>
|
||||
<div class="row"><span>Obálka přízemí</span><strong>10,93 × 12,36 m</strong></div>
|
||||
<div class="row"><span>Nejvyšší hřeben</span><strong>+8,850 m</strong></div>
|
||||
<div class="row"><span>Parcely 1089, 1090/1 a 1090/2</span><strong>1 058 m²</strong></div>
|
||||
<h3>Terén a výškové osazení</h3>
|
||||
<p>Podklad pro terén byl získán z veřejného digitálního modelu reliéfu <a href="https://ags.cuzk.cz/arcgis2/rest/services/dmr5g/ImageServer" target="_blank" rel="noopener">ČÚZK DMR 5G ↗</a>. Jde o starší model, v okolí domu se liší od některých bodových výšek v přiložené situaci.</p>
|
||||
<p>Výkresy neuvádějí nadmořskou výšku podlahy ±0,000. Návaznost domu na DMR je proto předběžná a lze ji upravit:</p>
|
||||
<label class="sun-field">Odhad ±0,000 (m n. m. Bpv)<input id="terrainDatum" type="number" min="365" max="380" step="0.05" value="372.45"></label>
|
||||
<details class="site-note" open><summary>Co je doložené a co orientační</summary>
|
||||
<p>Dispozice, rozměry, výšky podlah, plochy a základní otvory vycházejí z výkresů stávajícího stavu. Model zachovává i konstrukce označené v podkladu k bourání; návrh nového stavu nebyl součástí přílohy.</p>
|
||||
<p>Vybavení, materiály, detaily střech, schodiště a okolí jsou zjednodušené. Nábytek je ilustrativní návrh podle účelu místností. Podlaží se při procházení přepínají tlačítky. Model není zaměřením ani prováděcím nebo BIM podkladem.</p>
|
||||
<p>Hranice parcel a orientace pocházejí z veřejných dat RÚIAN. Terén nenahrazuje aktuální geodetické zaměření; zejména opěrné zdi a terénní stupně nejsou podrobně doložené.</p></details>
|
||||
<div class="status"><span class="dot"></span>Stávající stav · vybavení a terén orientační</div>
|
||||
</aside>
|
||||
|
||||
<div id="direction"><span class="tag">AKTUÁLNÍ POHLED</span><span id="directionText">Dům / příjezd</span></div>
|
||||
<div class="bottom">
|
||||
<nav class="toolbar" aria-label="Ovládání modelu">
|
||||
<button class="tool active" data-preset="entry" type="button">Příjezd</button>
|
||||
<button class="tool" data-preset="garden" type="button">Zahrada</button>
|
||||
<button class="tool" data-preset="top" type="button">Shora</button>
|
||||
<span class="divider"></span>
|
||||
<label class="site-switch"><input type="checkbox" id="siteTerrain" checked><span>Terén</span></label>
|
||||
<label class="site-switch"><input type="checkbox" id="siteBoundary" checked><span>Parcela</span></label>
|
||||
<label class="site-switch"><input type="checkbox" id="siteParking" checked><span>Ulice</span></label>
|
||||
<span class="divider"></span>
|
||||
<button class="tool" id="savePng" type="button">Uložit PNG</button>
|
||||
<button class="tool" id="resetView" type="button">Reset</button>
|
||||
</nav>
|
||||
<div class="help">Tažením otáčej · kolečkem přibliž · pravým tlačítkem posuň</div>
|
||||
</div>
|
||||
|
||||
<div class="compass" aria-label="Severka">
|
||||
<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" type="button" 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 type="button" data-season="03-20">Jaro</button><button type="button" data-season="06-21" class="active">Léto</button><button type="button" data-season="09-23">Podzim</button><button type="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">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" type="button" 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">Posouvej čas a sleduj, kam dopadá slunce na fasády a zahradu. Sever modelu je zeměpisný sever přepočtený ze souřadnic S-JTSK.</p>
|
||||
<button class="sun-export" id="sunSavePng" type="button">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="50.05091024"></label><label class="sun-field">Zeměpisná délka (° E)<input id="sunLon" type="number" min="-180" max="180" step="0.0001" value="14.31001395"></label><label class="sun-field full-row">Natočení pozemku vůči severu (°)<input id="siteBearing" type="number" min="0" max="359.9" step="0.1" value="251.172"></label></div>
|
||||
<p>Poloha je převzatá z adresního bodu RÚIAN. Azimut 251,17° je směr osy dolů v dodaném půdorysu, určený z rohů domu v zeměpisných souřadnicích. Slunce i severka používají zeměpisný sever.</p>
|
||||
<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>
|
||||
<button class="mini-btn" id="sunResetSite" type="button">Obnovit polohu Kovářovy</button>
|
||||
</details>
|
||||
<details class="sun-advanced"><summary>Metoda výpočtu a přesnost</summary><p>Sluneční poloha se počítá z data, času a zeměpisných souřadnic podle astronomických rovnic Meeus / NOAA. Model zobrazuje přímé sluneční světlo a geometrické stíny. Nezahrnuje oblačnost, odrazy okolních budov, profil horizontu ani skutečné vlastnosti zasklení.</p></details>
|
||||
</section>
|
||||
|
||||
<button id="interiorLaunch" type="button" aria-controls="interiorPanel" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M3 11 12 4 21 11M5 10v10h14V10M10 20v-7h5v7"/></svg>
|
||||
<span>Prohlédnout interiér<small>Půdorys · 3D bez střechy · procházení</small></span>
|
||||
</button>
|
||||
<div class="interior-heading"><button class="btn" id="interiorExit" type="button">← Exteriér</button><div><div class="eyebrow">KOVÁŘOVA / INTERIÉR</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 id="floorSummary"></span></div>
|
||||
<nav class="floor-tabs" aria-label="Podlaží"><button type="button" data-floor="basement">1PP <small>Suterén</small></button><button type="button" data-floor="ground" class="active">1NP <small>Přízemí</small></button><button type="button" data-floor="upper">2NP <small>Patro</small></button></nav>
|
||||
<svg id="interiorMap" viewBox="0 0 300 292" role="group" aria-label="Mapa místností; výběrem vstoupíte dovnitř"></svg>
|
||||
<p class="int-map-hint">Kliknutím na místnost vstoupíte dovnitř.</p>
|
||||
<select id="interiorRoom" aria-label="Přejít do místnosti"></select>
|
||||
<div class="int-options">
|
||||
<label><input type="checkbox" id="interiorFurniture" checked>Orientační vybavení</label>
|
||||
<label><input type="checkbox" id="interiorRoomLabels" checked>Názvy a plochy místností</label>
|
||||
<label><input type="checkbox" id="interiorFill" checked>Přisvětlení interiéru</label>
|
||||
<label class="wall-paint">Barva stěn <input id="interiorWallColor" type="color" value="#e5e0d5" aria-label="Barva stěn"></label>
|
||||
</div>
|
||||
<p>V půdorysu a 3D řezu jsou stěny snížené. Procházení zobrazí plnou výšku vybraného podlaží. Mezi podlažími přecházejte tlačítky 1PP, 1NP a 2NP.</p>
|
||||
<details class="int-source"><summary>Podklady a přesnost</summary>
|
||||
<p>Plochy jsou převzaté z legend výkresů. Podlahy patra mají úrovně +3,250 až +3,550 m; sklep −2,200 m. Světlé výšky i šikmé podhledy jsou podle dostupných kót zjednodušené.</p>
|
||||
<p>Nábytek a povrchy nejsou doloženým stavem ani schváleným návrhem. Zařízení lze skrýt. Schodiště je schematické a v procházení nepřepíná podlaží automaticky.</p>
|
||||
<div class="int-source-buttons"><a id="floorSource" target="_blank" rel="noopener">Původní půdorys ↗</a><a href="./references/4-D.1.1.3.4_%C5%98ez%20A_297x630_20.10.2026.pdf" target="_blank" rel="noopener">Řez ↗</a></div>
|
||||
</details>
|
||||
<button class="int-export" id="interiorSavePng" type="button">Uložit aktuální pohled PNG</button>
|
||||
<button class="int-export" id="interiorSaveGlb" type="button">Stáhnout zobrazené podlaží GLB</button>
|
||||
</section>
|
||||
<div id="interiorLabels" aria-label="Místnosti v půdorysu"></div>
|
||||
<div id="interiorTools"><div id="interiorHint">Tažením otáčejte · kolečkem přibližujte · výběrem místnosti vstupte dovnitř</div><nav class="int-mode-bar" aria-label="Režim interiéru"><button type="button" data-interior-mode="plan">Půdorys</button><button type="button" data-interior-mode="axon" class="active">3D bez střechy</button><button type="button" data-interior-mode="walk">Procházet</button><button id="interiorFit" type="button" class="int-nav-button" title="Zarámovat dům nebo obnovit pohled">⌖</button><button id="interiorPanelToggle" type="button" class="int-nav-button" title="Skrýt nebo zobrazit panel" aria-expanded="true" aria-controls="interiorPanel">☷</button></nav></div>
|
||||
<div id="walkPad" aria-label="Ovládání chůze"><button type="button" data-walk="forward" aria-label="Jít vpřed">↑</button><button type="button" data-walk="left" aria-label="Krok vlevo">←</button><button type="button" data-walk="back" aria-label="Jít vzad">↓</button><button type="button" data-walk="right" aria-label="Krok vpravo">→</button></div>
|
||||
|
||||
<div id="loading" role="status"><span class="loader-dot"></span><span>Načítám 3D model…</span></div>
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<div id="webStatus">Stávající stav · Kovářova 193/54</div>
|
||||
|
||||
<script type="module" src="./js/kovarova.js?v=20261106-1905"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
// Illustrative furniture; the supplied drawings document room uses, not a furnishing design.
|
||||
export function furnish(ctx,data){
|
||||
const {THREE,planPoint,box,mesh,group}=ctx;const material=c=>new THREE.MeshStandardMaterial({color:c,roughness:.82});
|
||||
const m={wood:material('#bea17b'),light:material('#dfceb1'),sage:material('#9eab9c'),fabric:material('#c5b8a5'),white:material('#efede4'),dark:material('#36413e'),water:material('#b8ced0'),metal:material('#9da6a3')};
|
||||
for(const f of data.levels){
|
||||
f.furnitureColliders=[];let parent=f.furniture,base=0;
|
||||
function block(name,x,y,w,d,h,z,mat=m.wood){const [X,Z]=planPoint(x,y);return box(parent,name,X,base+z+h/2,Z,w,h,d,mat);}
|
||||
function solid(x,y,w,d){const [X,Z]=planPoint(x,y);f.furnitureColliders.push({minX:X-w/2,maxX:X+w/2,minZ:Z-d/2,maxZ:Z+d/2});}
|
||||
function oval(name,x,y,w,d,h,z,mat){const o=mesh(new THREE.SphereGeometry(1,16,8),mat,parent,name);const [X,Z]=planPoint(x,y);o.position.set(X,base+z+h/2,Z);o.scale.set(w/2,h/2,d/2);return o;}
|
||||
function cabinet(x,y,w,d,h=.86){block('Skříňka',x,y,w,d,h,.04,m.sage);block('Pracovní deska',x,y,w+.035,d+.035,.045,h+.04,m.light);for(let a=x-w/2+.6;a<x+w/2;a+=.6)block('Spára',a,y,.009,d+.008,h-.08,.12,m.light);solid(x,y,w,d);}
|
||||
function table(x,y,w,d,h=.75){block('Stůl – deska',x,y,w,d,.045,h-.045,m.light);for(const a of [-1,1])for(const b of [-1,1])block('Noha stolu',x+a*(w/2-.08),y+b*(d/2-.08),.05,.05,h-.06,.02);solid(x,y,w,d);}
|
||||
function chair(x,y,turn=0){const g=group(parent,'Židle');const old=parent;parent=g;block('Sedák',x,y,.43,.43,.08,.43,m.fabric);block('Opěradlo',x,y+.18,.43,.07,.42,.48,m.fabric);for(const a of [-1,1])for(const b of [-1,1])block('Noha židle',x+a*.16,y+b*.16,.035,.035,.43,.01);parent=old;}
|
||||
function bed(x,y,w,d=2.04){block('Postel – rám',x,y,w,d,.29,.08);block('Matrace',x,y,w-.05,d-.05,.20,.37,m.white);block('Čelo postele',x,y+d/2-.04,w,.08,.82,.08,m.light);block('Přikrývka',x,y-.2,w-.04,d-.5,.05,.59,m.sage);for(let i=0;i<(w>1.4?2:1);i++)oval('Polštář',x+(w>1.4?(i-.5)*.78:0),y+d/2-.32,.65,.4,.13,.59,m.white);solid(x,y,w,d);}
|
||||
function sofa(x,y,w=2.2){block('Pohovka – sedák',x,y,w,.9,.37,.12,m.fabric);block('Opěradlo pohovky',x,y+.38,w,.17,.72,.12,m.sage);for(const a of [-1,1])block('Područka',x+a*(w/2-.08),y,.16,.92,.56,.12,m.sage);for(let a=x-w/2+.4;a<x+w/2-.15;a+=.65)block('Polštář sedáku',a,y-.03,.61,.65,.09,.49,m.white);solid(x,y,w,.92);}
|
||||
function wc(x,y){oval('WC',x,y,.39,.65,.35,.17,m.white);oval('Sedátko',x,y,.39,.65,.04,.52,m.white);oval('Mísa',x,y-.02,.25,.39,.015,.56,m.water);solid(x,y,.39,.65);}
|
||||
function basin(x,y,w=.65){cabinet(x,y,w,.48,.76);oval('Umyvadlo',x,y,w-.12,.36,.06,.85,m.white);oval('Mísa umyvadla',x,y,w-.25,.25,.012,.90,m.water);block('Baterie',x,y+.17,.025,.025,.22,.88,m.metal);}
|
||||
function shower(x,y,w=.9,d=.9){block('Sprchová vanička',x,y,w,d,.055,.01,m.white);block('Sprchová tyč',x+w/2-.08,y,.025,.025,1.65,.45,m.metal);block('Sprchová hlavice',x+w/2-.16,y,.22,.22,.025,2.08,m.metal);}
|
||||
function kitchen(x,y,w){cabinet(x,y,w,.62);block('Dřez',x-w/3,y,.55,.42,.015,.95,m.metal);block('Vnitřek dřezu',x-w/3,y,.45,.33,.014,.96,m.dark);block('Baterie dřezu',x-w/3,y-.22,.025,.025,.27,.96,m.metal);block('Varná deska',x+w/4,y,.55,.49,.02,.95,m.dark);for(const a of [-.13,.13])for(const b of [-.13,.13])oval('Varná zóna',x+w/4+a,y+b,.16,.16,.007,.975,m.metal);}
|
||||
for(const r of f.rooms){parent=group(f.furniture,r.id+' · orientační vybavení');base=r.level;
|
||||
switch(r.id){
|
||||
case 'S.02':cabinet(9.5,.65,1.6,.48,1.6);cabinet(10.27,2,.5,1.5,1.6);break;
|
||||
case '1.01':cabinet(7.85,.65,.6,.55,2.3);cabinet(9.2,.62,1.25,.45,.45);break;
|
||||
case '1.04':wc(10.18,3.12);break;
|
||||
case '1.05':shower(9.48,4.25,.88,.88);basin(9.55,5.38,.65);break;
|
||||
case '1.06':bed(8.8,10.5,1.6);cabinet(9.65,7.1,.6,1.8,2.25);table(8.25,8.25,.95,.55);break;
|
||||
case '1.07':bed(6.57,10.65,.95);table(5.5,7.94,1.25,.6);chair(5.6,8.7);cabinet(5.17,10.8,.65,1.4,2.15);break;
|
||||
case '1.08':block('Koberec',2.55,9.7,2.7,2.3,.012,.01,m.white);sofa(2.55,10.75,2.65);table(2.55,9.5,1.15,.65,.42);cabinet(4.10,7.25,.42,1.5,.44);block('TV',4.18,7.25,.06,1.2,.78,.95,m.dark);break;
|
||||
case '1.09':kitchen(2.6,1.36,3.25);cabinet(.82,2.14,.6,.85,1.95);table(2.3,4.6,1.6,.85);chair(1.8,3.87);chair(2.7,3.87);chair(1.8,5.35);chair(2.7,5.35);break;
|
||||
case '2.03':kitchen(9.62,7.4,.6);cabinet(8.55,6.27,2,.6);table(8.0,8.05,1.5,.78);chair(7.5,8.72);chair(8.4,8.72);sofa(6.1,10.9,2.1);table(6.1,9.65,1.1,.6,.4);cabinet(9.63,10.5,.5,1.8,.48);block('TV',9.72,10.5,.07,1.3,.75,1,m.dark);break;
|
||||
case '2.04':bed(2.35,9.75,1.6);cabinet(4.08,9.2,.58,1.7,1.85);table(2.4,5.85,1.4,.58);chair(2.5,6.6);break;
|
||||
case '2.05':block('Vana',2.35,2.4,1.65,.76,.54,.04,m.white);oval('Vana – vnitřek',2.35,2.4,1.43,.58,.06,.58,m.water);solid(2.35,2.4,1.65,.76);basin(4.3,3.25,.65);wc(1.78,4.5);break;
|
||||
case '2.06':table(8.8,1.5,1.2,.75);chair(8.4,2.15);chair(9.2,2.15);break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {pointInPolygon} from './plan.js?v=1850';
|
||||
export function installInterior(ctx){
|
||||
const {THREE,house,houseGroup,modelRoot,roofGroup,roofTimber,controls,scene,renderer,planPoint,groundGroup,parkingGroup,boundaryGroup,resize,saveBlob,exportGlb,solar,setSolarPlaying,toggleInfo,toggleSun}=ctx;
|
||||
const $=s=>document.querySelector(s),canvas=renderer.domElement,perspective=ctx.getCamera(),ortho=new THREE.OrthographicCamera(-10,10,10,-10,.035,180);
|
||||
const state={enabled:false,mode:'axon',floor:'ground',panel:true,furniture:true,labels:true,fill:true,room:'1.08',position:new THREE.Vector3(),yaw:0,pitch:0,keys:new Set(),pad:new Set(),pointer:null,saved:null,userCamera:false};
|
||||
let data=house.levels[1],labels=[];
|
||||
const light=new THREE.AmbientLight(0xfff7e8,1.35);light.visible=false;scene.add(light);
|
||||
const toWorld=(p,y=0)=>{houseGroup.updateWorldMatrix(true,false);const [x,z]=planPoint(...p);return houseGroup.localToWorld(new THREE.Vector3(x,y,z));};
|
||||
const localToWorld=v=>{houseGroup.updateWorldMatrix(true,false);return houseGroup.localToWorld(v.clone());};
|
||||
const area=v=>v.toFixed(2).replace('.',',')+' m²';
|
||||
function rebuildMap(){
|
||||
$('#interiorRoom').innerHTML='<option value="">Vyberte místnost…</option>';$('#interiorMap').innerHTML='';$('#interiorLabels').innerHTML='';labels=[];
|
||||
$('#floorSummary').textContent=data.rooms.length+' místností · '+area(data.area)+(state.floor==='upper'?' + balkón 27,88 m²':'');
|
||||
$('.interior-heading .eyebrow').textContent='KOVÁŘOVA / '+data.title;
|
||||
const file=state.floor==='ground'?'1-D.1.1.3.2_Půdorys 1NP_420x525_20.10.2026.pdf':state.floor==='upper'?'2-D.1.1.3.3_Půdorys 2NP_420x525_20.10.2026.pdf':'3-D.1.1.3.1_Půdorys 1PP_420x525_20.10.2026.pdf';$('#floorSource').href='./references/'+encodeURIComponent(file);
|
||||
for(const room of data.rooms){
|
||||
const option=document.createElement('option');option.value=room.id;option.textContent=room.id+' '+room.name+' · '+area(room.area);$('#interiorRoom').append(option);
|
||||
const ns='http://www.w3.org/2000/svg',g=document.createElementNS(ns,'g'),p=document.createElementNS(ns,'polygon');p.setAttribute('points',room.polygon.map(([x,y])=>`${26+x*22},${10+y*22}`).join(' '));p.setAttribute('class','map-room');p.dataset.room=room.id;p.setAttribute('tabindex','0');p.setAttribute('role','button');p.setAttribute('aria-label',option.textContent);p.addEventListener('click',()=>selectRoom(room.id));p.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();selectRoom(room.id);}});g.append(p);
|
||||
const text=document.createElementNS(ns,'text');text.setAttribute('x',26+room.label[0]*22);text.setAttribute('y',13+room.label[1]*22);text.setAttribute('text-anchor','middle');text.textContent=room.id;g.append(text);$('#interiorMap').append(g);
|
||||
const tag=document.createElement('button');tag.type='button';tag.className='int-room-tag';tag.innerHTML=`<b>${room.short}</b><small>${area(room.area)}</small>`;tag.addEventListener('click',()=>selectRoom(room.id));$('#interiorLabels').append(tag);labels.push({room,tag});
|
||||
}
|
||||
}
|
||||
function selectRoom(id){const room=data.rooms.find(r=>r.id===id)||data.rooms[0];state.room=room.id;const [x,z]=planPoint(...room.spawn),[lx,lz]=planPoint(...room.look);state.position.set(x,room.level+1.62,z);state.yaw=Math.atan2(lx-x,lz-z);state.pitch=-.04;mode('walk');$('#interiorRoom').value=room.id;$('#interiorViewTitle').textContent=room.id+' '+room.name;updateWalkCamera();}
|
||||
function selectFloor(id){state.floor=id;data=house.levels.find(f=>f.id===id);state.room=data.rooms.find(r=>!r.name.includes('Schodiště'))?.id||data.rooms[0].id;state.position.set(0,0,0);rebuildMap();document.querySelectorAll('[data-floor]').forEach(b=>{b.classList.toggle('active',b.dataset.floor===id);b.setAttribute('aria-pressed',String(b.dataset.floor===id));});if(state.mode==='walk')selectRoom(state.room);else{sync();resize();fit();}}
|
||||
function switchCamera(c){ctx.setCamera(c);controls.object=c;}
|
||||
function sync(){
|
||||
const walking=state.enabled&&state.mode==='walk',cut=state.enabled&&!walking;
|
||||
for(const f of house.levels){f.root.visible=state.enabled?f===data:f.id!=='basement';f.full.visible=!cut;f.cut.visible=cut;f.ceiling.visible=!cut;f.furniture.visible=state.furniture;f.stairs.visible=true;}
|
||||
house.foundation.visible=!state.enabled;roofGroup.visible=!cut;roofTimber.visible=!cut;light.visible=state.enabled&&state.fill;house.materials.glass.opacity=walking?.15:.36;
|
||||
document.body.classList.toggle('interior-active',state.enabled);document.body.classList.toggle('interior-walk',walking);document.body.classList.toggle('interior-panel-closed',!state.panel);
|
||||
$('#interiorLaunch').setAttribute('aria-expanded',String(state.enabled));$('#interiorPanelToggle').setAttribute('aria-expanded',String(state.panel));$('#interiorRoomLabels').disabled=walking;
|
||||
document.querySelectorAll('[data-interior-mode]').forEach(b=>{const on=b.dataset.interiorMode===state.mode;b.classList.toggle('active',on);b.setAttribute('aria-pressed',String(on));});
|
||||
$('#interiorHint').textContent=walking?'W A S D: chůze · tažením se rozhlížejte · podlaží přepněte v panelu':'Tažením otáčejte · kolečkem přibližujte · vyberte místnost';
|
||||
if(!walking)$('#interiorViewTitle').textContent=(state.mode==='plan'?'Půdorys · ':'3D řez · ')+data.title;
|
||||
if(state.enabled){groundGroup.visible=walking&&data.id!=='basement'&&state.saved.ground;parkingGroup.visible=groundGroup.visible;boundaryGroup.visible=false;}
|
||||
labels.forEach(({tag})=>tag.hidden=!state.enabled||walking||!state.labels);
|
||||
}
|
||||
function enter(){if(state.enabled)return;state.saved={position:perspective.position.clone(),target:controls.target.clone(),fov:perspective.fov,near:perspective.near,ground:groundGroup.visible,parking:parkingGroup.visible,boundary:boundaryGroup.visible,playing:solar.playing};setSolarPlaying(false);toggleInfo(false);toggleSun(false);state.enabled=true;state.mode='axon';state.panel=true;state.userCamera=false;controls.minDistance=2;controls.maxDistance=95;sync();resize();fit();}
|
||||
function clearKeys(){state.keys.clear();state.pad.clear();document.querySelectorAll('#walkPad .held').forEach(b=>b.classList.remove('held'));}
|
||||
function exit(){if(!state.enabled)return;state.enabled=false;clearKeys();state.pointer=null;sync();const s=state.saved;switchCamera(perspective);controls.enabled=true;controls.enableRotate=true;controls.minDistance=15;controls.minPolarAngle=.08;controls.maxPolarAngle=1.52;perspective.up.set(0,1,0);perspective.fov=s.fov;perspective.near=s.near;perspective.position.copy(s.position);controls.target.copy(s.target);groundGroup.visible=s.ground;parkingGroup.visible=s.parking;boundaryGroup.visible=s.boundary;controls.update();resize();setSolarPlaying(s.playing);}
|
||||
function mode(value){if(!state.enabled)enter();state.mode=value;state.userCamera=false;clearKeys();controls.enabled=value!=='walk';controls.enableRotate=value!=='plan';controls.minPolarAngle=.001;controls.maxPolarAngle=1.52;switchCamera(value==='plan'?ortho:perspective);perspective.near=value==='walk'?.035:.1;perspective.fov=value==='walk'?65:36;perspective.updateProjectionMatrix();sync();resize();if(value==='walk'){if(state.position.lengthSq()===0)selectRoom(state.room);else updateWalkCamera();}else fit();}
|
||||
function fit(){if(!state.enabled)return;state.userCamera=false;if(state.mode==='walk'){selectRoom(state.room);return;}const rect=canvas.getBoundingClientRect(),aspect=rect.width/rect.height;const center=state.floor==='basement'?[7.9,2.3]:[5.4,6.15];const target=toWorld(center,data.base+.4);controls.target.copy(target);if(state.mode==='plan'){const h=Math.max(state.floor==='basement'?7:15,(state.floor==='basement'?8:14)/aspect);ortho.left=-h*aspect/2;ortho.right=-ortho.left;ortho.top=h/2;ortho.bottom=-h/2;ortho.zoom=1;const up=new THREE.Vector3(0,0,-1).applyQuaternion(modelRoot.quaternion);ortho.up.copy(up);ortho.position.copy(target).add(new THREE.Vector3(0,45,0));ortho.lookAt(target);ortho.updateProjectionMatrix();}else{perspective.up.set(0,1,0);const dist=Math.max(state.floor==='basement'?15:29,(state.floor==='basement'?12:27)/aspect);const offset=new THREE.Vector3(9,20,22).normalize().multiplyScalar(dist).applyQuaternion(modelRoot.quaternion);perspective.position.copy(target).add(offset);perspective.lookAt(target);}controls.update();}
|
||||
function resizeView(){if(!state.enabled)return;if(state.mode==='plan'||state.mode==='axon'&&!state.userCamera)fit();}
|
||||
function canStand(x,z){const px=x+5.465,py=z+6.18,r=.12;if(!pointInPolygon(data.outline,px,py))return false;const attic=data.id==='upper'&&data.rooms.find(r=>['2.04','2.05'].includes(r.id)&&pointInPolygon(r.polygon,px,py));if(attic&&Math.min(5.71,3.43+.492*(px+.3)-.13,3.43+.598*(py-.25)-.13,3.43+.598*(12.36-py)-.13)<attic.level+1.72)return false;for(const b of [...data.colliders,...(state.furniture?data.furnitureColliders:[])])if(x>b.minX-r&&x<b.maxX+r&&z>b.minZ-r&&z<b.maxZ+r)return false;return true;}
|
||||
function move(dx,dz){const n=Math.max(1,Math.ceil(Math.hypot(dx,dz)/.035));for(let i=0;i<n;i++){if(canStand(state.position.x+dx/n,state.position.z))state.position.x+=dx/n;if(canStand(state.position.x,state.position.z+dz/n))state.position.z+=dz/n;}const x=state.position.x+5.465,y=state.position.z+6.18;const r=data.rooms.find(r=>pointInPolygon(r.polygon,x,y));if(r){state.position.y=r.level+1.62;state.room=r.id;}}
|
||||
function updateWalkCamera(){const dir=new THREE.Vector3(Math.sin(state.yaw)*Math.cos(state.pitch),Math.sin(state.pitch),Math.cos(state.yaw)*Math.cos(state.pitch));perspective.position.copy(localToWorld(state.position));perspective.up.set(0,1,0);perspective.lookAt(localToWorld(state.position.clone().add(dir)));}
|
||||
function updateLabels(){if(!state.enabled||state.mode==='walk'||!state.labels)return;const c=ctx.getCamera(),r=canvas.getBoundingClientRect();c.updateMatrixWorld();const occupied=[];for(const {room,tag}of labels){const p=toWorld(room.label,room.level+.08).project(c),x=(p.x+1)*r.width/2,y=(1-p.y)*r.height/2;tag.style.left=x+'px';tag.style.top=y+'px';tag.hidden=p.z<-1||p.z>1||x<20||x>r.width-20||y<85||y>r.height-78;if(tag.hidden)continue;const w=tag.offsetWidth,h=tag.offsetHeight,b={x:x-w/2,y:y-h/2,w,h};if(occupied.some(a=>b.x<a.x+a.w+3&&b.x+b.w+3>a.x&&b.y<a.y+a.h+3&&b.y+b.h+3>a.y))tag.hidden=true;else occupied.push(b);}}
|
||||
function frame(dt){if(!state.enabled)return;if(state.mode==='walk'){const k=state.keys,p=state.pad;const forward=Number(k.has('KeyW')||k.has('ArrowUp')||p.has('forward'))-Number(k.has('KeyS')||k.has('ArrowDown')||p.has('back')),side=Number(k.has('KeyD')||p.has('right'))-Number(k.has('KeyA')||p.has('left'));state.yaw+=(Number(k.has('ArrowLeft'))-Number(k.has('ArrowRight')))*dt*1.4;const d=dt*1.4/Math.max(1,Math.hypot(forward,side));move((Math.sin(state.yaw)*forward-Math.cos(state.yaw)*side)*d,(Math.cos(state.yaw)*forward+Math.sin(state.yaw)*side)*d);updateWalkCamera();}updateLabels();}
|
||||
function keydown(e){if(e.key==='Escape'){exit();return;}if(/^(INPUT|SELECT|TEXTAREA|BUTTON|SUMMARY)$/.test(e.target.tagName))return;if(state.mode==='walk'&&['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)){e.preventDefault();state.keys.add(e.code);}}
|
||||
window.addEventListener('keyup',e=>state.keys.delete(e.code));window.addEventListener('blur',clearKeys);document.addEventListener('visibilitychange',clearKeys);
|
||||
canvas.addEventListener('pointerdown',e=>{if(!state.enabled)return;state.userCamera=true;if(state.mode==='walk'){document.activeElement?.blur();canvas.setPointerCapture(e.pointerId);state.pointer={id:e.pointerId,x:e.clientX,y:e.clientY};}});
|
||||
canvas.addEventListener('pointermove',e=>{const p=state.pointer;if(!p||p.id!==e.pointerId||state.mode!=='walk')return;state.yaw-=(e.clientX-p.x)*.004;state.pitch=Math.max(-1.15,Math.min(1.15,state.pitch-(e.clientY-p.y)*.003));p.x=e.clientX;p.y=e.clientY;});
|
||||
for(const event of ['pointerup','pointercancel','lostpointercapture'])canvas.addEventListener(event,()=>state.pointer=null);
|
||||
for(const b of document.querySelectorAll('[data-walk]')){b.addEventListener('pointerdown',e=>{e.preventDefault();b.setPointerCapture(e.pointerId);state.pad.add(b.dataset.walk);b.classList.add('held');});for(const event of ['pointerup','pointercancel','lostpointercapture'])b.addEventListener(event,()=>{state.pad.delete(b.dataset.walk);b.classList.remove('held');});}
|
||||
$('#interiorLaunch').addEventListener('click',enter);$('#interiorExit').addEventListener('click',exit);$('#interiorRoom').addEventListener('change',e=>e.target.value&&selectRoom(e.target.value));document.querySelectorAll('[data-floor]').forEach(b=>b.addEventListener('click',()=>selectFloor(b.dataset.floor)));document.querySelectorAll('[data-interior-mode]').forEach(b=>b.addEventListener('click',()=>{mode(b.dataset.interiorMode);b.blur();}));
|
||||
$('#interiorFit').addEventListener('click',fit);$('#interiorPanelToggle').addEventListener('click',()=>{state.panel=!state.panel;sync();resize();});$('#interiorFurniture').addEventListener('change',e=>{state.furniture=e.target.checked;sync();});$('#interiorRoomLabels').addEventListener('change',e=>{state.labels=e.target.checked;sync();});$('#interiorFill').addEventListener('change',e=>{state.fill=e.target.checked;sync();});$('#interiorWallColor').addEventListener('input',e=>house.wallMaterial.color.set(e.target.value));$('#interiorSaveGlb').addEventListener('click',exportGlb);
|
||||
function savePng(){renderer.render(scene,ctx.getCamera());updateLabels();const out=document.createElement('canvas'),scale=canvas.width/canvas.clientWidth;out.width=canvas.width;out.height=canvas.height+62*scale;const c=out.getContext('2d');c.fillStyle='#f8f9f3';c.fillRect(0,0,out.width,out.height);c.drawImage(canvas,0,0);if(state.labels&&state.mode!=='walk')for(const {room,tag}of labels){if(tag.hidden)continue;const x=parseFloat(tag.style.left)*scale,y=parseFloat(tag.style.top)*scale;c.font=`${10*scale}px Arial`;const text=room.short+' · '+area(room.area),w=c.measureText(text).width;c.fillStyle='#fafcf4eb';c.fillRect(x-w/2-6*scale,y-10*scale,w+12*scale,20*scale);c.fillStyle='#334c35';c.textAlign='center';c.fillText(text,x,y+3*scale);}c.textAlign='left';c.fillStyle='#2d3c31';c.font=`${12*scale}px Arial`;c.fillText('Kovářova · '+$('#interiorViewTitle').textContent,22*scale,canvas.height+26*scale);c.font=`${9*scale}px Arial`;c.fillText('Stávající stav · vybavení je pouze orientační',22*scale,canvas.height+44*scale);out.toBlob(blob=>blob&&saveBlob(blob,'kovarova-'+state.floor+'-'+state.mode+'.png'));}
|
||||
$('#interiorSavePng').addEventListener('click',savePng);rebuildMap();sync();
|
||||
window.KOVAROVA_INTERIOR={enter,exit,setMode:mode,selectRoom,selectFloor,canStand:(x,y)=>canStand(...planPoint(x,y)),getState:()=>({enabled:state.enabled,mode:state.mode,floor:state.floor,room:state.room,position:state.position.toArray(),canStand:canStand(state.position.x,state.position.z)})};
|
||||
return {get enabled(){return state.enabled;},get walking(){return state.enabled&&state.mode==='walk';},frame,resize:resizeView,keydown,exit,enter,savePng};
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import {buildHouse,buildSite} from './model.js?v=1850';
|
||||
import {furnish} from './furniture.js?v=1850';
|
||||
import { installInterior } from './interior-view.js?v=1905';
|
||||
let THREE, OrbitControls, GLTFExporter, solarPosition;
|
||||
try {
|
||||
const modules = await Promise.all([
|
||||
import('three'),
|
||||
import('three/addons/controls/OrbitControls.js'),
|
||||
import('three/addons/exporters/GLTFExporter.js'),
|
||||
import('./solar.js')
|
||||
]);
|
||||
THREE = modules[0];
|
||||
OrbitControls = modules[1].OrbitControls;
|
||||
GLTFExporter = modules[2].GLTFExporter;
|
||||
solarPosition = modules[3].solarPosition;
|
||||
} catch (error) {
|
||||
const loading = document.querySelector('#loading');
|
||||
if (loading) {
|
||||
loading.classList.add('error');
|
||||
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a ověř přítomnost složky vendor/three.</small>`;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const RAD = Math.PI / 180;
|
||||
const DAY_MS = 86400000;
|
||||
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) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
|
||||
const mix = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
|
||||
const czNum = (v, n = 1) => Number(v).toFixed(n).replace('.', ',');
|
||||
const canvas = $('#view');
|
||||
const loading = $('#loading');
|
||||
const toastEl = $('#toast');
|
||||
|
||||
const SITE_DEFAULT = { latitude:50.05091024, longitude:14.31001395, bearing:251.172 };
|
||||
const site = { ...SITE_DEFAULT };
|
||||
const solar = {
|
||||
date: '2026-06-21', minute: 600, playing: false, speed: 30, shadows: true, path: false,
|
||||
start: 0, end: 0, length: 1440, rise: null, set: null, noon: null, samples: [],
|
||||
position: null, direct: 0.8, dayFactor: 1, sky: [0.89, 0.91, 0.87], accumulator: 0, lastTick: 0
|
||||
};
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color('#e7ebe3');
|
||||
let camera = new THREE.PerspectiveCamera(36, 1, 0.1, 220);
|
||||
camera.position.set(28, 21, 36);
|
||||
|
||||
let renderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
canvas, antialias: true, alpha: false, preserveDrawingBuffer: true,
|
||||
powerPreference: 'high-performance'
|
||||
});
|
||||
} catch (error) {
|
||||
loading.classList.add('error');
|
||||
loading.innerHTML = `3D grafiku nelze spustit.<br>${error.message}<br><small>Zkontroluj podporu WebGL v prohlížeči.</small>`;
|
||||
throw error;
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.05;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.075;
|
||||
controls.minDistance = 15;
|
||||
controls.maxDistance = 95;
|
||||
controls.minPolarAngle = 0.08;
|
||||
controls.maxPolarAngle = 1.52;
|
||||
controls.target.set(0, 2, 0);
|
||||
|
||||
scene.add(new THREE.HemisphereLight(0xf7f8ee, 0x6c705f, 1.55));
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
|
||||
const sunLight = new THREE.DirectionalLight(0xfff5df, 2.25);
|
||||
sunLight.castShadow = true;
|
||||
sunLight.shadow.mapSize.set(1536, 1536);
|
||||
sunLight.shadow.camera.left = -32;
|
||||
sunLight.shadow.camera.right = 32;
|
||||
sunLight.shadow.camera.top = 36;
|
||||
sunLight.shadow.camera.bottom = -36;
|
||||
sunLight.shadow.camera.near = 0.1;
|
||||
sunLight.shadow.camera.far = 110;
|
||||
sunLight.shadow.bias = -0.00015;
|
||||
sunLight.shadow.normalBias = 0.025;
|
||||
const sunTarget = new THREE.Object3D();
|
||||
sunTarget.position.set(0, 2, 0);
|
||||
scene.add(sunLight, sunTarget);
|
||||
sunLight.target = sunTarget;
|
||||
|
||||
const modelRoot = new THREE.Group();
|
||||
modelRoot.name = 'Kovářova – dům a pozemek';
|
||||
// The scene uses true north = +z and east = −x; the site bearing turns the plot clockwise as seen from above.
|
||||
modelRoot.rotation.y = -site.bearing * RAD;
|
||||
scene.add(modelRoot);
|
||||
const groundGroup = new THREE.Group(); groundGroup.name = 'Interpolovaný terén'; modelRoot.add(groundGroup);
|
||||
const boundaryGroup = new THREE.Group(); boundaryGroup.name = 'Orientační hranice parcely'; modelRoot.add(boundaryGroup);
|
||||
const parkingGroup = new THREE.Group(); parkingGroup.name = 'Příjezd a parkování'; modelRoot.add(parkingGroup);
|
||||
const houseGroup = new THREE.Group(); houseGroup.name = 'Rodinný dům – pracovní hmota'; houseGroup.position.set(0, 0, 0); modelRoot.add(houseGroup);
|
||||
|
||||
const shellGroup = new THREE.Group(); shellGroup.name = 'Obvodové stěny a otvory'; houseGroup.add(shellGroup);
|
||||
const roofGroup = new THREE.Group(); roofGroup.name = 'Střecha, podbití a komín'; houseGroup.add(roofGroup);
|
||||
const roofTimber = new THREE.Group(); roofTimber.name = 'Trámy krytých ploch'; houseGroup.add(roofTimber);
|
||||
let interior = null;
|
||||
|
||||
function group(parent,name){const o=new THREE.Group();o.name=name;parent.add(o);return o;}
|
||||
function mesh(geometry,material,parent,name=''){const o=new THREE.Mesh(geometry,material);o.name=name;o.castShadow=true;o.receiveShadow=true;parent.add(o);return o;}
|
||||
function box(parent,name,x,y,z,sx,sy,sz,material,rotationY=0){const o=mesh(new THREE.BoxGeometry(sx,sy,sz),material,parent,name);o.position.set(x,y,z);o.rotation.y=rotationY;return o;}
|
||||
const planPoint=(x,y)=>[x-5.465,y-6.18];
|
||||
const siteData=await fetch('./data/site.json').then(r=>r.json());
|
||||
const context={THREE,houseGroup,modelRoot,shellGroup,roofGroup,roofTimber,groundGroup,boundaryGroup,parkingGroup,planPoint,box,mesh,group,siteData};
|
||||
const house=buildHouse(context);furnish(context,house);const terrain=buildSite(context,house);
|
||||
modelRoot.userData={project:'Kovářova 193/54 – stávající stav',source:'Dodané PDF vs-studio 2026, RÚIAN, ČÚZK DMR 5G',units:'metres',furnishing:'illustrative',terrainDatumBpv:372.45,terrainDatumStatus:'estimated, not survey-linked',rooms:house.levels.flatMap(f=>f.rooms.map(r=>({id:r.id,name:r.name,area:r.area,floor:r.level})))};
|
||||
$('#terrainDatum').addEventListener('change',e=>{const v=Number(e.target.value);if(Number.isFinite(v)&&v>=365&&v<=380){terrain.setDatum(v);modelRoot.userData.terrainDatumBpv=v;}});
|
||||
let exteriorPreset=null;
|
||||
function viewportSize() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return [Math.max(1, rect.width), Math.max(1, rect.height)];
|
||||
}
|
||||
function resize() {
|
||||
const [w, h] = viewportSize();
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(Math.round(w), Math.round(h), false);
|
||||
interior?.resize();
|
||||
if(exteriorPreset&&!interior?.enabled)positionPreset(exteriorPreset);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
function toast(message) {
|
||||
toastEl.textContent = message;
|
||||
toastEl.style.opacity = '1';
|
||||
clearTimeout(toast.timer);
|
||||
toast.timer = setTimeout(() => { toastEl.style.opacity = '0'; }, 2600);
|
||||
}
|
||||
function saveBlob(blob, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
}
|
||||
|
||||
const presets = {
|
||||
entry: { position: [25, 19, -30], target: [0, 2.4, 0], label: 'Dům / příjezd' },
|
||||
garden: { position: [-29, 18, 24], target: [0, 2.4, 0], label: 'Dům / zahrada' },
|
||||
top: { position: [-10, 70, 3], target: [-10, 0, 3], label: 'Pozemek / shora' }
|
||||
};
|
||||
function setPreset(name) {
|
||||
if (interior?.enabled) interior.exit();
|
||||
exteriorPreset=name;
|
||||
const p = presets[name] || presets.entry;
|
||||
positionPreset(name);
|
||||
controls.target.fromArray(p.target);
|
||||
controls.update();
|
||||
$('#directionText').textContent = p.label;
|
||||
document.querySelectorAll('[data-preset]').forEach(button => button.classList.toggle('active', button.dataset.preset === name));
|
||||
}
|
||||
function positionPreset(name){
|
||||
const p=presets[name]||presets.entry,[w,h]=viewportSize(),factor=Math.max(1,.85/(w/h));
|
||||
const target=new THREE.Vector3(...p.target),offset=new THREE.Vector3(...p.position).sub(target).multiplyScalar(factor);
|
||||
camera.position.copy(target).add(offset);controls.target.copy(target);controls.update();
|
||||
}
|
||||
controls.addEventListener('start',()=>{if(!interior?.enabled)exteriorPreset=null;});
|
||||
document.querySelectorAll('[data-preset]').forEach(button => button.addEventListener('click', () => setPreset(button.dataset.preset)));
|
||||
$('#resetView').addEventListener('click', () => setPreset('entry'));
|
||||
setPreset('entry');
|
||||
|
||||
$('#siteTerrain').addEventListener('change', (event) => { groundGroup.visible = event.target.checked; });
|
||||
$('#siteBoundary').addEventListener('change', (event) => { boundaryGroup.visible = event.target.checked; });
|
||||
$('#siteParking').addEventListener('change', (event) => { parkingGroup.visible = event.target.checked; });
|
||||
|
||||
function toggleInfo(force) {
|
||||
const on = force ?? !$('#panel').classList.contains('open');
|
||||
$('#panel').classList.toggle('open', on);
|
||||
document.body.classList.toggle('info-open', on);
|
||||
$('#infoButton').classList.toggle('active', on);
|
||||
$('#infoButton').setAttribute('aria-expanded', String(on));
|
||||
if (on) toggleSun(false);
|
||||
resize();
|
||||
}
|
||||
function toggleSun(force) {
|
||||
const on = force ?? !$('#sunPanel').classList.contains('open');
|
||||
$('#sunPanel').classList.toggle('open', on);
|
||||
document.body.classList.toggle('sun-open', on);
|
||||
$('#sunToggle').classList.toggle('active', on);
|
||||
$('#sunToggle').setAttribute('aria-expanded', String(on));
|
||||
if (on) toggleInfo(false);
|
||||
resize();
|
||||
}
|
||||
$('#infoButton').addEventListener('click', () => toggleInfo());
|
||||
$('#infoClose').addEventListener('click', () => toggleInfo(false));
|
||||
$('#sunToggle').addEventListener('click', () => toggleSun());
|
||||
$('#sunClose').addEventListener('click', () => toggleSun(false));
|
||||
|
||||
// Local civil-time helpers keep the sun controls aligned with Europe/Prague and DST.
|
||||
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 out = {};
|
||||
for (const part of localFormatter.formatToParts(new Date(ms))) if (part.type !== 'literal') out[part.type] = +part.value;
|
||||
return out;
|
||||
}
|
||||
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(iso) {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
const naive = Date.UTC(y, m - 1, d);
|
||||
let utc = naive;
|
||||
for (let i = 0; i < 4; i++) utc = naive - utcOffset(utc) * 60000;
|
||||
return utc;
|
||||
}
|
||||
function clockText(ms) {
|
||||
if (ms === null || !Number.isFinite(ms)) return '—';
|
||||
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 offset = utcOffset(ms);
|
||||
return `${offset === 120 ? 'SELČ' : offset === 60 ? 'SEČ' : 'Praha'} · UTC${offset >= 0 ? '+' : ''}${offset / 60}`;
|
||||
}
|
||||
function timeForClock(text) {
|
||||
if (!/^\d{1,2}:\d{2}$/.test(text)) return null;
|
||||
const [h, m] = text.split(':').map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m) || h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||
const normalized = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
const [y, mo, d] = solar.date.split('-').map(Number);
|
||||
const 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(offset => naive - offset * 60000)
|
||||
.filter(utc => utc >= solar.start && utc < solar.end && clockText(utc) === normalized).sort((a, b) => a - b);
|
||||
if (candidates.length) return candidates[0];
|
||||
for (let i = 0; i < solar.length; i++) {
|
||||
const utc = solar.start + i * 60000, p = localParts(utc);
|
||||
if (p.hour * 60 + p.minute >= h * 60 + m) return utc;
|
||||
}
|
||||
return solar.end - 60000;
|
||||
}
|
||||
function refineCrossing(a, b, threshold) {
|
||||
let fa = solarPosition(a, site.latitude, site.longitude).elevation - threshold;
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const mid = (a + b) / 2;
|
||||
const fm = solarPosition(mid, site.latitude, site.longitude).elevation - threshold;
|
||||
if ((fa < 0) === (fm < 0)) { a = mid; fa = fm; } else b = mid;
|
||||
}
|
||||
return (a + b) / 2;
|
||||
}
|
||||
|
||||
let graphBase = '';
|
||||
function rebuildSolarDay(preserveClock = true) {
|
||||
const previousClock = 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);
|
||||
const nextDate = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
|
||||
solar.end = localMidnight(nextDate);
|
||||
solar.length = Math.round((solar.end - solar.start) / 60000);
|
||||
solar.rise = null; solar.set = null; solar.samples = [];
|
||||
let previous = null, max = { altitude: -999, ms: solar.start };
|
||||
for (let utc = solar.start; utc <= solar.end; utc += 600000) {
|
||||
const pos = solarPosition(utc, site.latitude, site.longitude);
|
||||
solar.samples.push({ ms: utc, ...pos });
|
||||
if (pos.elevation > max.altitude) max = { altitude: pos.elevation, ms: utc };
|
||||
if (previous) {
|
||||
if (previous.elevation < -0.833 && pos.elevation >= -0.833) solar.rise = refineCrossing(previous.ms, utc, -0.833);
|
||||
if (previous.elevation >= -0.833 && pos.elevation < -0.833) solar.set = refineCrossing(previous.ms, utc, -0.833);
|
||||
}
|
||||
previous = { ms: utc, ...pos };
|
||||
}
|
||||
let lo = Math.max(solar.start, max.ms - 600000), hi = Math.min(solar.end, max.ms + 600000);
|
||||
for (let i = 0; i < 36; i++) {
|
||||
const a = lo + (hi - lo) / 3, b = hi - (hi - lo) / 3;
|
||||
if (solarPosition(a, site.latitude, site.longitude).elevation < solarPosition(b, site.latitude, site.longitude).elevation) lo = a; else hi = b;
|
||||
}
|
||||
solar.noon = (lo + hi) / 2;
|
||||
solar.minute = clamp((timeForClock(previousClock) - 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(button => button.classList.toggle('active', solar.date.endsWith(button.dataset.season)));
|
||||
const ticks = $('.sun-ticks');
|
||||
ticks.style.position = 'relative'; ticks.style.height = '12px';
|
||||
[...ticks.children].forEach((el, i) => {
|
||||
const minutes = i === 4 ? solar.length : (timeForClock(el.textContent) - solar.start) / 60000;
|
||||
el.style.cssText = `position:absolute;left:${100 * minutes / solar.length}%;transform:translateX(${i === 0 ? 0 : i === 4 ? -100 : -50}%)`;
|
||||
});
|
||||
$('#solarMessage').textContent = solar.length === 1440 ? '' : `Tento den má ${solar.length / 60} hodin. Časová osa respektuje změnu letního času.`;
|
||||
drawSolarGraphBase();
|
||||
updateSolar();
|
||||
}
|
||||
function drawSolarGraphBase() {
|
||||
const y = altitude => 70 - clamp(altitude, 0, 90) * 0.65;
|
||||
const points = solar.samples.map(p => `${8 + 288 * (p.ms - solar.start) / (solar.end - solar.start)},${y(p.elevation)}`);
|
||||
const curve = `M${points.join(' L')}`;
|
||||
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°</text><path d="${curve} L296,70 L8,70 Z" fill="url(#solarFill)"/><path d="${curve}" fill="none" stroke="#c29b4b" stroke-width="1.5"/>`;
|
||||
}
|
||||
function drawSolarGraph() {
|
||||
if (!solar.position) return;
|
||||
const x = 8 + 288 * solar.minute / solar.length;
|
||||
const y = 70 - clamp(solar.position.elevation, 0, 90) * 0.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.minute = clamp(solar.minute, 0, solar.length - 1);
|
||||
const utc = solar.start + solar.minute * 60000;
|
||||
solar.position = solarPosition(utc, site.latitude, site.longitude);
|
||||
const { elevation, azimuth } = solar.position;
|
||||
solar.dayFactor = smooth(-10, 4, elevation);
|
||||
solar.direct = elevation > 0 ? (1.02 - 0.32 * Math.exp(-Math.max(elevation, 0) / 9)) * smooth(0, 1.2, elevation) : 0;
|
||||
const daylight = solar.dayFactor;
|
||||
solar.sky = mix([0.075, 0.112, 0.157], [0.906, 0.922, 0.890], daylight);
|
||||
if (elevation > -7 && elevation < 9) solar.sky = mix(solar.sky, [0.89, 0.83, 0.74], 0.16 * Math.sin((elevation + 7) / 16 * Math.PI));
|
||||
scene.background.setRGB(...solar.sky);
|
||||
const az = azimuth * RAD, alt = elevation * RAD;
|
||||
const distance = 65;
|
||||
modelRoot.updateMatrixWorld(true);
|
||||
sunTarget.position.set(0, 1.2, houseGroup.position.z + 1).applyMatrix4(modelRoot.matrixWorld);
|
||||
sunLight.position.set(-Math.sin(az) * Math.cos(alt), Math.sin(alt), Math.cos(az) * Math.cos(alt)).multiplyScalar(distance).add(sunTarget.position);
|
||||
sunLight.intensity = Math.max(0, solar.direct) * 2.55;
|
||||
sunLight.color.setRGB(1, elevation < 12 ? 0.87 + elevation / 100 : 0.96, elevation < 12 ? 0.68 + elevation / 55 : 0.88);
|
||||
sunLight.castShadow = solar.shadows && elevation > -0.5;
|
||||
solar.lastTick = utc;
|
||||
$('#sunClock').value = clockText(utc);
|
||||
$('#sunTime').value = Math.floor(solar.minute);
|
||||
$('#sunTime').setAttribute('aria-valuetext', clockText(utc));
|
||||
$('#zoneLabel').textContent = zoneText(utc);
|
||||
$('#sunElevation').textContent = `${czNum(elevation)}°`;
|
||||
$('#sunAzimuth').textContent = `${czNum(azimuth)}°`;
|
||||
const dirs = ['sever', 'severovýchod', 'východ', 'jihovýchod', 'jih', 'jihozápad', 'západ', 'severozápad'];
|
||||
$('#sunDirection').textContent = `${dirs[Math.round(azimuth / 45) % 8]} · od severu`;
|
||||
$('#dayState').textContent = elevation > 0 ? 'Slunce nad obzorem' : elevation > -0.833 ? 'Slunce na obzoru' : elevation > -6 ? 'Soumrak' : 'Noc · bez přímého slunce';
|
||||
$('#sceneTime').textContent = `${czDate(solar.date)} · ${clockText(utc)} ${utcOffset(utc) === 120 ? 'SELČ' : 'SEČ'}`;
|
||||
document.body.classList.toggle('night', elevation < -4);
|
||||
document.body.style.backgroundColor = `rgb(${solar.sky.map(v => Math.round(v * 255)).join(',')})`;
|
||||
drawSolarGraph();
|
||||
}
|
||||
|
||||
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 >= 0.075) { solar.accumulator = 0; updateSolar(); }
|
||||
}
|
||||
function setSolarPlaying(on) {
|
||||
solar.playing = on;
|
||||
$('#sunPlay').innerHTML = on ? 'Ⅱ Pozastavit' : '▶ Přehrát den';
|
||||
$('#sunPlay').setAttribute('aria-pressed', String(on));
|
||||
}
|
||||
$('#sunDate').addEventListener('change', (event) => {
|
||||
if (!event.target.value) return;
|
||||
setSolarPlaying(false); solar.date = event.target.value; rebuildSolarDay(true);
|
||||
});
|
||||
function applyClock(){
|
||||
const utc=timeForClock($('#sunClock').value);if(utc===null)return;
|
||||
setSolarPlaying(false);solar.minute=(utc-solar.start)/60000;updateSolar();
|
||||
}
|
||||
$('#sunClock').addEventListener('change',applyClock);
|
||||
$('#sunClock').addEventListener('blur',applyClock);
|
||||
$('#sunClock').addEventListener('input',()=>{clearTimeout(applyClock.timer);applyClock.timer=setTimeout(applyClock,250);});
|
||||
$('#sunClock').addEventListener('keydown',e=>{if(e.key==='Enter'){clearTimeout(applyClock.timer);applyClock();e.target.blur();}});
|
||||
$('#sunTime').addEventListener('input', (event) => { setSolarPlaying(false); solar.minute = +event.target.value; updateSolar(); });
|
||||
document.querySelectorAll('[data-season]').forEach(button => button.addEventListener('click', () => {
|
||||
solar.date = `${solar.date.slice(0, 4)}-${button.dataset.season}`; setSolarPlaying(false); rebuildSolarDay(true);
|
||||
}));
|
||||
$('#sunPlay').addEventListener('click', () => setSolarPlaying(!solar.playing));
|
||||
$('#sunSpeed').addEventListener('change', (event) => { solar.speed = +event.target.value; });
|
||||
$('#sunNow').addEventListener('click', () => {
|
||||
const now = new Date();
|
||||
solar.date = `${localParts(now.getTime()).year}-${String(localParts(now.getTime()).month).padStart(2, '0')}-${String(localParts(now.getTime()).day).padStart(2, '0')}`;
|
||||
rebuildSolarDay(false);
|
||||
solar.minute = (now.getTime() - solar.start) / 60000;
|
||||
updateSolar();
|
||||
});
|
||||
$('#sunShadows').addEventListener('change', (event) => { solar.shadows = event.target.checked; updateSolar(); });
|
||||
$('#sunPath').addEventListener('change', (event) => { solar.path = event.target.checked; });
|
||||
function readSiteNumbers() {
|
||||
const lat = +$('#sunLat').value, lon = +$('#sunLon').value, bearing = +$('#siteBearing').value;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(bearing) || !$('#sunLat').checkValidity() || !$('#sunLon').checkValidity() || !$('#siteBearing').checkValidity()) {
|
||||
toast('Zadej hodnoty v povoleném rozsahu.');
|
||||
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
|
||||
return;
|
||||
}
|
||||
site.latitude = lat; site.longitude = lon; site.bearing = bearing;
|
||||
modelRoot.rotation.y = -bearing * RAD;
|
||||
rebuildSolarDay(true);
|
||||
}
|
||||
['sunLat', 'sunLon', 'siteBearing'].forEach(id => $(`#${id}`).addEventListener('change', readSiteNumbers));
|
||||
$('#sunResetSite').addEventListener('click', () => {
|
||||
Object.assign(site, SITE_DEFAULT);
|
||||
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
|
||||
modelRoot.rotation.y = -site.bearing * RAD;
|
||||
rebuildSolarDay(true);
|
||||
});
|
||||
$('#compassGroup').insertAdjacentHTML('beforeend', '<circle id="compassSun" cx="42" cy="16" r="3" fill="#c89439" stroke="#fff7d7" stroke-width="1"/>');
|
||||
|
||||
const sunGraph = $('#sunGraph');
|
||||
function scrubGraph(event) {
|
||||
const rect = sunGraph.getBoundingClientRect();
|
||||
solar.minute = clamp(((event.clientX - rect.left) / rect.width * 304 - 8) / 288 * solar.length, 0, solar.length - 1);
|
||||
setSolarPlaying(false); updateSolar();
|
||||
}
|
||||
sunGraph.addEventListener('pointerdown', event => { sunGraph.setPointerCapture(event.pointerId); scrubGraph(event); });
|
||||
sunGraph.addEventListener('pointermove', event => { if (sunGraph.hasPointerCapture(event.pointerId)) scrubGraph(event); });
|
||||
sunGraph.addEventListener('pointerup', event => { if (sunGraph.hasPointerCapture(event.pointerId)) sunGraph.releasePointerCapture(event.pointerId); });
|
||||
|
||||
async function saveSunImage() {
|
||||
setSolarPlaying(false); updateSolar();
|
||||
controls.update();
|
||||
drawSunPath();
|
||||
renderer.render(scene, camera);
|
||||
const source = renderer.domElement;
|
||||
const out = document.createElement('canvas');
|
||||
const [viewWidth] = viewportSize();
|
||||
const ratio = source.width / viewWidth;
|
||||
const footer = Math.round(66 * ratio);
|
||||
out.width = source.width; out.height = source.height + footer;
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.fillStyle = '#f8f9f3'; ctx.fillRect(0, 0, out.width, out.height);
|
||||
ctx.drawImage(source, 0, 0);
|
||||
const scale = out.width / viewWidth;
|
||||
if (solar.path && $('#sunOverlay').innerHTML) {
|
||||
const svgText = `<svg xmlns="http://www.w3.org/2000/svg" width="${viewWidth}" height="${source.height / ratio}" viewBox="0 0 ${viewWidth} ${source.height / ratio}"><style>text{font:10px Arial;fill:#96712e;paint-order:stroke;stroke:#f3f4e9;stroke-width:3px;stroke-linejoin:round}</style>${$('#sunOverlay').innerHTML}</svg>`;
|
||||
const url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
|
||||
const overlay = new Image();
|
||||
await new Promise((resolve, reject) => { overlay.onload = resolve; overlay.onerror = reject; overlay.src = url; });
|
||||
ctx.drawImage(overlay, 0, 0, source.width, source.height);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
ctx.fillStyle = '#2d3c31'; ctx.font = `${12 * scale}px Arial`;
|
||||
ctx.fillText(`Kovářova · ${czDate(solar.date)} · ${clockText(solar.start + solar.minute * 60000)} · ${zoneText(solar.start + solar.minute * 60000)}`, 24 * scale, source.height + 28 * scale);
|
||||
ctx.fillStyle = '#7b8372'; ctx.font = `${9 * scale}px Arial`;
|
||||
ctx.fillText('Orientační studie oslunění · Kovářova 193/54, Praha – Stodůlky', 24 * scale, source.height + 46 * scale);
|
||||
out.toBlob(blob => { if (blob) saveBlob(blob, 'kovarova-slunce.png'); });
|
||||
}
|
||||
$('#sunSavePng').addEventListener('click', saveSunImage);
|
||||
$('#savePng').addEventListener('click', saveSunImage);
|
||||
|
||||
let exporting = false;
|
||||
function exportGlb() {
|
||||
if (exporting) return;
|
||||
exporting = true;
|
||||
const button = $('#saveGlb'); button.disabled = true;
|
||||
const exporter = new GLTFExporter();
|
||||
modelRoot.updateMatrixWorld(true);
|
||||
const basement=house.levels[0].root,wasVisible=basement.visible;
|
||||
if(!interior?.enabled)basement.visible=true;
|
||||
exporter.parse(modelRoot, (result) => {
|
||||
basement.visible=wasVisible;
|
||||
saveBlob(new Blob([result], { type: 'model/gltf-binary' }), interior?.enabled ? 'kovarova-interier.glb' : 'kovarova-pracovni-model.glb');
|
||||
toast('Model Kovářova GLB je připravený ke stažení.');
|
||||
exporting = false; button.disabled = false;
|
||||
}, (error) => { basement.visible=wasVisible; console.error(error); toast('Model se nepodařilo exportovat.'); exporting = false; button.disabled = false; }, { binary: true, onlyVisible: true, trs: false });
|
||||
}
|
||||
$('#saveGlb').addEventListener('click', exportGlb);
|
||||
|
||||
function drawSunPath() {
|
||||
const svg = $('#sunOverlay');
|
||||
const [width, height] = viewportSize();
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
const dot = $('#compassSun');
|
||||
const azimuth = solar.position?.azimuth ?? 0;
|
||||
dot.setAttribute('cx', 42 + 26 * Math.sin(azimuth * RAD));
|
||||
dot.setAttribute('cy', 42 - 26 * Math.cos(azimuth * RAD));
|
||||
dot.style.display = solar.position?.elevation > 0 ? 'block' : 'none';
|
||||
if (!solar.path) { svg.innerHTML = ''; return; }
|
||||
modelRoot.updateMatrixWorld(true);
|
||||
const center = new THREE.Vector3(0, 1.2, houseGroup.position.z + 1).applyMatrix4(modelRoot.matrixWorld);
|
||||
const pointAt = (az, alt, distance = 15.5) => new THREE.Vector3(
|
||||
center.x - Math.sin(az * RAD) * Math.cos(alt * RAD) * distance,
|
||||
center.y + Math.sin(alt * RAD) * distance,
|
||||
center.z + Math.cos(az * RAD) * Math.cos(alt * RAD) * distance
|
||||
);
|
||||
let path = '', started = false;
|
||||
for (const sample of solar.samples) {
|
||||
if (sample.elevation < 0) { started = false; continue; }
|
||||
const v = pointAt(sample.azimuth, sample.elevation).project(camera);
|
||||
if (v.z < -1 || v.z > 1) { started = false; continue; }
|
||||
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
|
||||
path += `${started ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)} `;
|
||||
started = true;
|
||||
}
|
||||
let out = `<path d="${path}" fill="none" stroke="#b28b40" stroke-width="1.5" stroke-dasharray="5 5" opacity=".8"/>`;
|
||||
for (const hour of [6, 9, 12, 15, 18, 21]) {
|
||||
const utc = timeForClock(`${String(hour).padStart(2, '0')}:00`);
|
||||
if (utc === null) continue;
|
||||
const pos = solarPosition(utc, site.latitude, site.longitude);
|
||||
if (pos.elevation < 0) continue;
|
||||
const v = pointAt(pos.azimuth, pos.elevation).project(camera);
|
||||
if (v.z < -1 || v.z > 1) continue;
|
||||
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
|
||||
out += `<circle cx="${x}" cy="${y}" r="2.5" fill="#bd984f"/><text x="${x + 6}" y="${y - 7}">${hour}:00</text>`;
|
||||
}
|
||||
if (solar.position?.elevation >= 0) {
|
||||
const v = pointAt(solar.position.azimuth, solar.position.elevation).project(camera);
|
||||
if (v.z >= -1 && v.z <= 1) {
|
||||
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
|
||||
out += `<circle cx="${x}" cy="${y}" r="18" fill="#e2bc5c" opacity=".13"/><circle cx="${x}" cy="${y}" r="6.5" fill="#e7bd59" stroke="#fff5cf" stroke-width="2"/>`;
|
||||
}
|
||||
}
|
||||
svg.innerHTML = out;
|
||||
}
|
||||
|
||||
function animate(now = 0) {
|
||||
requestAnimationFrame(animate);
|
||||
const dt = Math.min(0.1, Math.max(0, (now - (animate.last || now)) / 1000));
|
||||
animate.last = now;
|
||||
solarFrame(dt);
|
||||
if (!interior?.walking) controls.update();
|
||||
interior?.frame(dt);
|
||||
const cameraHeading = Math.atan2(camera.position.x - controls.target.x, camera.position.z - controls.target.z) / RAD;
|
||||
// North is +z, so a camera on the north side looking south shows north at the bottom of the compass.
|
||||
$('#compassGroup').setAttribute('transform', `rotate(${cameraHeading + 180},42,42)`);
|
||||
renderer.render(scene, camera);
|
||||
drawSunPath();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', event => {
|
||||
if (interior?.enabled) { interior.keydown(event); return; }
|
||||
if (event.key === 'Escape') { toggleInfo(false); toggleSun(false); }
|
||||
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'SUMMARY'].includes(event.target.tagName)) return;
|
||||
if (event.code === 'Space') { event.preventDefault(); setSolarPlaying(!solar.playing); }
|
||||
if (event.key.toLowerCase() === 's') toggleSun();
|
||||
if (event.key.toLowerCase() === 'i') interior?.enter();
|
||||
if (event.key === '1') setPreset('entry');
|
||||
if (event.key === '2') setPreset('garden');
|
||||
if (event.key === '3') setPreset('top');
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) setSolarPlaying(false); });
|
||||
|
||||
window.SUN_SIM = {
|
||||
position: solarPosition,
|
||||
getState: () => ({ date: solar.date, time: clockText(solar.start + solar.minute * 60000), utc: new Date(solar.start + solar.minute * 60000).toISOString(), lat: site.latitude, lon: site.longitude, bearing: site.bearing, azimuth: solar.position.azimuth, elevation: solar.position.elevation, rise: clockText(solar.rise), set: clockText(solar.set), noon: clockText(solar.noon), shadows: solar.shadows, playing: solar.playing }),
|
||||
setDate: date => { solar.date = date; rebuildSolarDay(true); },
|
||||
setTime: time => { solar.minute = (timeForClock(time) - solar.start) / 60000; updateSolar(); },
|
||||
pause: () => setSolarPlaying(false)
|
||||
};
|
||||
|
||||
try {
|
||||
interior = installInterior({house,...context,THREE,houseGroup,modelRoot,shellGroup,roofGroup,roofTimber,controls,scene,renderer,planPoint,box,mesh,group,groundGroup,parkingGroup,boundaryGroup,resize,saveBlob,exportGlb,solar,setSolarPlaying,toggleInfo,toggleSun,getCamera:()=>camera,setCamera:value=>{camera=value;}});
|
||||
rebuildSolarDay(false);
|
||||
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
|
||||
toggleSun(false); toggleInfo(false);
|
||||
loading.classList.add('done');
|
||||
setTimeout(() => { loading.style.display = 'none'; }, 300);
|
||||
requestAnimationFrame(animate);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
loading.classList.add('error');
|
||||
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a ověř přítomnost složky vendor/three.</small>`;
|
||||
$('#webStatus').textContent = '3D není dostupné';
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {floors,walls,outline,pointInPolygon} from './plan.js?v=1850';
|
||||
export function buildHouse(ctx){
|
||||
const {THREE,houseGroup,modelRoot,roofGroup,groundGroup,boundaryGroup,parkingGroup,planPoint,box,mesh,group,siteData}=ctx;
|
||||
const mat=(color,extra={})=>new THREE.MeshStandardMaterial({color,roughness:.86,...extra});
|
||||
const m={wall:mat('#e5e0d5'),trim:mat('#cfbfa5'),plinth:mat('#99998f'),roof:mat('#786e63',{side:THREE.DoubleSide}),frame:mat('#e3dfd3'),glass:mat('#a5c6cc',{transparent:true,opacity:.36,roughness:.2,side:THREE.DoubleSide}),oak:mat('#c5a77e'),tile:mat('#c8c6bc'),brick:mat('#ae9180'),deck:mat('#b29c7d'),ceiling:mat('#f1eee6',{side:THREE.DoubleSide}),metal:mat('#606964',{metalness:.3}),grass:mat('#a3b091'),road:mat('#969c94')};
|
||||
const block=(p,n,x,z,w,d,b,t,material)=>{const [X,Z]=planPoint(x,z);return box(p,n,X,(b+t)/2,Z,w,t-b,d,material);};
|
||||
function surface(p,n,polygon,height,material,holes=[]){
|
||||
const pts=polygon.map(q=>new THREE.Vector2(...q)),hs=holes.map(h=>h.map(q=>new THREE.Vector2(...q)));
|
||||
const all=[...polygon,...holes.flat()];const g=new THREE.BufferGeometry();
|
||||
g.setAttribute('position',new THREE.Float32BufferAttribute(all.flatMap(([x,y])=>{const [X,Z]=planPoint(x,y);return [X,typeof height==='function'?height(x,y):height,Z];}),3));
|
||||
g.setIndex(THREE.ShapeUtils.triangulateShape(pts,hs).flatMap(([a,b,c])=>[c,b,a]));g.computeVertexNormals();return mesh(g,material,p,n);
|
||||
}
|
||||
function face(p,n,pts,material){const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(pts.flatMap(([x,h,z])=>{const [X,Z]=planPoint(x,z);return[X,h,Z];}),3));const ix=[];for(let i=1;i<pts.length-1;i++)ix.push(0,i,i+1);g.setIndex(ix);g.computeVertexNormals();return mesh(g,material,p,n);}
|
||||
const levels=floors.map(f=>({...f,root:group(houseGroup,f.title),colliders:[]}));
|
||||
const wallBox=(p,axis,fixed,a,b,t,bottom,top,ma,n)=>block(p,n,axis==='x'?fixed:(a+b)/2,axis==='y'?fixed:(a+b)/2,axis==='x'?t:b-a,axis==='y'?t:b-a,bottom,top,ma);
|
||||
for(const f of levels){
|
||||
f.full=group(f.root,'Stěny a otvory');f.cut=group(f.root,'Stěny v řezu');f.ground=group(f.root,'Podlahy');f.ceiling=group(f.root,'Podhledy');f.furniture=group(f.root,'Orientační vybavení');f.stairs=group(f.root,'Schodiště');
|
||||
const b=f.base;
|
||||
if(f.id==='ground')surface(f.ground,'Prahy 1NP',outline,-.055,m.tile,[[[5.07,1.0],[6.15,1.0],[6.15,4.27],[5.07,4.27]]]);
|
||||
if(f.id==='upper'){
|
||||
surface(f.ground,'Nosná deska hlavní části',[[4.19,5.45],[10.44,5.45],[10.44,12.36],[4.19,12.36]],3.54,m.tile);
|
||||
surface(f.ground,'Podlaha podkroví',[[1.22,1.67],[4.72,1.67],[4.72,11.09],[1.22,11.09]],3.24,m.oak);
|
||||
}
|
||||
for(const r of f.rooms){
|
||||
if(!r.id.endsWith('.01')||r.id==='1.01')surface(f.ground,r.id+' '+r.name+' · '+r.area+' m²',r.polygon,r.level+.007,m[r.floor]||m.oak);
|
||||
if(r.height && r.name!=='Schodiště')surface(f.ceiling,'Podhled '+r.id,r.polygon,r.level+r.height,m.ceiling);
|
||||
// Floor pattern is clipped to each polygon.
|
||||
const x0=Math.min(...r.polygon.map(v=>v[0])),x1=Math.max(...r.polygon.map(v=>v[0])),y0=Math.min(...r.polygon.map(v=>v[1])),y1=Math.max(...r.polygon.map(v=>v[1]));
|
||||
const pos=[],step=['wood','deck'].includes(r.floor)?.22:.6;
|
||||
for(let y=Math.ceil(y0/step)*step;y<y1;y+=step){let a=null;for(let x=x0+.006;x<x1+.025;x+=.025){const ok=pointInPolygon(r.polygon,x,y);if(ok&&a===null)a=x;if(!ok&&a!==null){const A=planPoint(a,y),B=planPoint(x-.025,y);pos.push(A[0],r.level+.012,A[1],B[0],r.level+.012,B[1]);a=null;}}if(a!==null){const A=planPoint(a,y),B=planPoint(x1-.005,y);pos.push(A[0],r.level+.012,A[1],B[0],r.level+.012,B[1]);}}
|
||||
const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));const lines=new THREE.LineSegments(g,new THREE.LineBasicMaterial({color:'#706b5f',transparent:true,opacity:.16}));f.ground.add(lines);
|
||||
}
|
||||
for(const [axis,fixed,start,end,t,bottom,top,openings] of walls[f.id]){
|
||||
const cutTop=Math.min(top,bottom+1.12);
|
||||
function solid(a,b,lo=bottom,hi=top,collide=true){if(b-a<.002||hi-lo<.002)return;wallBox(f.full,axis,fixed,a,b,t,lo,hi,m.wall,'Zdivo');if(lo<cutTop)wallBox(f.cut,axis,fixed,a,b,t,lo,Math.min(hi,cutTop),m.wall,'Zdivo v řezu');
|
||||
if(collide&&lo<bottom+.4){const x=axis==='x'?fixed:(a+b)/2,y=axis==='y'?fixed:(a+b)/2;const [X,Z]=planPoint(x,y),sx=axis==='x'?t:b-a,sz=axis==='y'?t:b-a;f.colliders.push({minX:X-sx/2,maxX:X+sx/2,minZ:Z-sz/2,maxZ:Z+sz/2});}}
|
||||
let cursor=start;
|
||||
for(const [a,b,sill,head,kind] of openings){solid(cursor,a);solid(a,b,bottom,sill,kind==='window');solid(a,b,head,top,false);
|
||||
const window=kind==='window';for(const parent of [f.full,f.cut]){const cap=parent===f.cut?cutTop:head;
|
||||
for(const edge of [a,b])if(cap>sill)wallBox(parent,axis,fixed,edge-.025,edge+.025,t+.025,sill,cap,m.frame,'Rám otvoru');
|
||||
if(head<=cap)wallBox(parent,axis,fixed,a,b,t+.035,head-.05,head,m.frame,'Horní rám');
|
||||
if(window&&sill<cap)wallBox(parent,axis,fixed,a,b,t+.08,sill-.035,sill+.015,m.frame,'Parapet');
|
||||
if(window&&cap>sill){wallBox(parent,axis,fixed,a+.025,b-.025,.025,sill+.02,cap-.025,m.glass,'Zasklení').castShadow=false;if(b-a>.8)wallBox(parent,axis,fixed,(a+b)/2-.025,(a+b)/2+.025,.09,sill,cap,m.frame,'Sloupek okna');}
|
||||
}
|
||||
// Exterior doors remain open for the walkthrough; balcony doors have a light side frame.
|
||||
cursor=b;
|
||||
}solid(cursor,end);
|
||||
}
|
||||
f.cut.visible=false;
|
||||
}
|
||||
// Sloping podkroví ceilings: ruled surfaces, matching the lower roof and the stated maximum clear height.
|
||||
const upper=levels[2];
|
||||
const lowCeiling=(x,y)=>Math.min(5.71,3.43+.492*(x+.3)-.13,3.43+.598*(y-.25)-.13,3.43+.598*(12.36-y)-.13);
|
||||
upper.ceiling.children.filter(o=>/2.04|2.05/.test(o.name)).forEach(o=>upper.ceiling.remove(o));
|
||||
for(const r of upper.rooms.filter(r=>['2.04','2.05'].includes(r.id))){
|
||||
const xs=r.polygon.map(p=>p[0]),ys=r.polygon.map(p=>p[1]),pos=[],indices=[];
|
||||
for(let x=Math.min(...xs);x<Math.max(...xs);x+=.15)for(let y=Math.min(...ys);y<Math.max(...ys);y+=.15){
|
||||
const x1=Math.min(x+.15,Math.max(...xs)),y1=Math.min(y+.15,Math.max(...ys)),k=pos.length/3;
|
||||
const cx=(x+x1)/2,cy=(y+y1)/2;if([[1.65,6.4,.72,.8],[1.65,8.2,.72,.8],[3.25,2.25,.8,.64]].some(([a,b,w,d])=>Math.abs(cx-a)<w/2-.1&&Math.abs(cy-b)<d/2-.1))continue;
|
||||
for(const [a,b]of [[x,y],[x1,y],[x1,y1],[x,y1]]){const [X,Z]=planPoint(a,b);pos.push(X,lowCeiling(a,b),Z);}indices.push(k,k+2,k+1,k,k+3,k+2);
|
||||
}
|
||||
const geom=new THREE.BufferGeometry();geom.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));geom.setIndex(indices);geom.computeVertexNormals();mesh(geom,m.ceiling,upper.ceiling,'Šikmý podhled '+r.id);
|
||||
}
|
||||
// Clip attic partitions to the roof envelope so walls never pierce its planes.
|
||||
for(const parent of [upper.full,upper.cut])for(const o of parent.children){if(!o.isMesh||!o.geometry.attributes.position)continue;const x=o.position.x+5.465,y=o.position.z+6.18;if(x>4.5)continue;
|
||||
const a=o.geometry.attributes.position;for(let i=0;i<a.count;i++){const X=a.getX(i)+x,Y=a.getZ(i)+y;const cap=lowCeiling(X,Y);if(a.getY(i)+o.position.y>cap)a.setY(i,cap-o.position.y);}a.needsUpdate=true;o.geometry.computeVertexNormals();}
|
||||
// Stair is a schematic 17 × 200 / 275 flight with a turn at its upper end.
|
||||
for(let i=0;i<13;i++)block(levels[1].stairs,'Stupeň 1NP → 2NP',5.61,4.15-i*.245,1.08,.25,-.05,(i+1)*.2,m.oak);
|
||||
for(let i=0;i<4;i++)block(levels[1].stairs,'Zatáčka schodiště',5.3+i*.32,1.09,.34,.88,2.6,2.8+i*.2,m.oak);
|
||||
surface(upper.ground,'Podesta 2NP',[[5.07,4.27],[6.27,4.27],[6.27,5.57],[5.07,5.57]],3.4,m.oak);
|
||||
for(let i=0;i<10;i++)block(levels[0].stairs,'Stupeň 1PP → 1NP',5.57,1.8+i*.265,1,.267,-2.2,-1.9+(i+1)*.19,m.plinth);
|
||||
surface(levels[0].ground,'Podesta sklepa',[[5.07,1.02],[6.73,1.02],[6.73,1.72],[5.07,1.72]],-1.9,m.tile);
|
||||
block(levels[0].full,'Pilíř ve sklepě',8.89,1.9,.32,.32,-2.2,-.14,m.wall);block(levels[0].cut,'Pilíř ve sklepě v řezu',8.89,1.9,.32,.32,-2.2,-1.08,m.wall);
|
||||
block(upper.full,'Horní stěna nad napojením nižší střechy',5.36,5.685,2.28,.47,5.78,6.8,m.wall);
|
||||
// Balcony slab and open metal guardrail.
|
||||
const balcony=upper.rooms.find(r=>r.id==='2.06');surface(upper.ground,'Balkón – spodní líc',balcony.polygon,3.14,m.plinth);
|
||||
const rail=group(upper.full,'Balkónové zábradlí');for(let i=0;i<balcony.polygon.length-1;i++){if(i===2)continue;const a=balcony.polygon[i],b=balcony.polygon[i+1],d=Math.hypot(b[0]-a[0],b[1]-a[1]),count=Math.ceil(d/.9);for(let j=0;j<=count;j++){const x=a[0]+(b[0]-a[0])*j/count,z=a[1]+(b[1]-a[1])*j/count;block(rail,'Sloupek',x,z,.035,.035,3.45,4.5,m.metal);}const [X,Z]=planPoint((a[0]+b[0])/2,(a[1]+b[1])/2);box(rail,'Madlo',X,4.5,Z,d,.035,.035,m.metal,-Math.atan2(b[1]-a[1],b[0]-a[0]));}
|
||||
upper.cut.add(rail.clone());
|
||||
for(let i=0;i<3;i++)block(levels[1].ground,'Vstupní schod',11.1+i*.29,1.17,.3,1.4,-.6,-.15*(i+1),m.plinth);
|
||||
function hip(x0,y0,x1,y1,eave,ridge){
|
||||
const cx=(x0+x1)/2,cy=(y0+y1)/2,dx=x1-x0,dy=y1-y0;const h=(ridge-eave)/Math.tan(31*Math.PI/180);const A=[x0,eave,y0],B=[x1,eave,y0],C=[x1,eave,y1],D=[x0,eave,y1];
|
||||
const R1=dx<dy?[cx,ridge,y0+h]:[x0+h,ridge,cy],R2=dx<dy?[cx,ridge,y1-h]:[x1-h,ridge,cy];
|
||||
if(dx<dy){face(roofGroup,'Valba',[A,B,R1],m.roof);face(roofGroup,'Střešní rovina',[B,C,R2,R1],m.roof);face(roofGroup,'Valba',[C,D,R2],m.roof);face(roofGroup,'Střešní rovina',[D,A,R1,R2],m.roof);}else{face(roofGroup,'Střecha',[A,B,R2,R1],m.roof);face(roofGroup,'Valba',[B,C,R2],m.roof);face(roofGroup,'Střecha',[C,D,R1,R2],m.roof);face(roofGroup,'Valba',[D,A,R1],m.roof);}
|
||||
for(const [a,b]of [[A,B],[B,C],[C,D],[D,A]]){const [X,Z]=planPoint((a[0]+b[0])/2,(a[2]+b[2])/2);box(roofGroup,'Okap a římsa',X,eave-.06,Z,Math.hypot(a[0]-b[0],a[2]-b[2]),.13,.13,m.metal,-Math.atan2(b[2]-a[2],b[0]-a[0]));}
|
||||
}
|
||||
hip(3.89,5.15,10.74,12.66,6.8,8.85);
|
||||
hip(4.42,.25,6.8,5.75,5.9,6.53);
|
||||
const A=[-.3,3.43,.25],B=[4.72,5.9,4.38],C=[4.72,5.9,8.23],D=[-.3,3.43,12.36];
|
||||
const hole=(x,y,w,d)=>[[x-w/2,y-d/2],[x+w/2,y-d/2],[x+w/2,y+d/2],[x-w/2,y+d/2]];
|
||||
surface(roofGroup,'Severní střešní rovina',[A,B,C,D].map(v=>[v[0],v[2]]),(x,y)=>3.43+.492*(x+.3),m.roof,[hole(1.65,6.4,.72,.8),hole(1.65,8.2,.72,.8)]);
|
||||
surface(roofGroup,'Východní valba nižší střechy',[[A[0],A[2]],[4.72,.25],[B[0],B[2]]],(x,y)=>3.43+.598*(y-.25),m.roof,[hole(3.25,2.25,.8,.64)]);
|
||||
face(roofGroup,'Západní valba nižší střechy',[D,C,[4.72,3.43,12.36]],m.roof);
|
||||
block(roofGroup,'Komín',4.92,5.25,.65,.65,5.4,8.72,m.wall);block(roofGroup,'Hlava komína',4.92,5.25,.78,.78,8.72,8.84,m.plinth);
|
||||
// Roof windows are schematic; their plan positions follow the dashed openings.
|
||||
for(const [x,y]of [[1.65,6.4],[1.65,8.2],[3.25,2.25]]){
|
||||
const w=x<2?.72:.8,d=x<2?.8:.64;
|
||||
const height=(a,b)=>x<2?3.43+.492*(a+.3)+.025:3.43+.598*(b-.25)+.025;
|
||||
surface(roofGroup,'Zasklení střešního okna',hole(x,y,w,d),height,m.glass).castShadow=false;
|
||||
surface(roofGroup,'Rám střešního okna',hole(x,y,w+.12,d+.12),height,m.frame,[hole(x,y,w,d)]);
|
||||
const outer=hole(x,y,w,d),inner=hole(x,y,w-.20,d-.20);
|
||||
for(let i=0;i<4;i++){const j=(i+1)%4;face(upper.ceiling,'Ostění střešního okna',[[...inner[i]],[...inner[j]],[...outer[j]],[...outer[i]]].map(([a,b],k)=>[a,k<2?lowCeiling(a,b):height(a,b),b]),m.ceiling);}
|
||||
}
|
||||
const foundation=group(houseGroup,'Sokl');for(let i=0;i<outline.length;i++){const a=outline[i],b=outline[(i+1)%outline.length];wallBox(foundation,a[0]===b[0]?'x':'y',a[0]===b[0]?a[0]:a[1],a[0]===b[0]?Math.min(a[1],b[1]):Math.min(a[0],b[0]),a[0]===b[0]?Math.max(a[1],b[1]):Math.max(a[0],b[0]),.24,-1.1,-.05,m.plinth,'Sokl');}
|
||||
return {levels,materials:m,block,surface,face,foundation,wallMaterial:m.wall};
|
||||
}
|
||||
export function buildSite(ctx,data){
|
||||
const {THREE,groundGroup,boundaryGroup,parkingGroup,planPoint,mesh,box,siteData:s}=ctx;const {surface,materials:m}=data;
|
||||
let datum=372.45;
|
||||
const raw=(x,y)=>{const e=s.origin[0]+s.ux[0]*x+s.uy[0]*y,n=s.origin[1]+s.ux[1]*x+s.uy[1]*y;const ix=Math.max(0,Math.min(s.width-1.001,e-s.bbox[0]-.5)),iy=Math.max(0,Math.min(s.height-1.001,s.bbox[3]-n-.5));const i=Math.floor(ix),j=Math.floor(iy),u=ix-i,v=iy-j;return (1-v)*((1-u)*s.heights[j][i]+u*s.heights[j][i+1])+v*((1-u)*s.heights[j+1][i]+u*s.heights[j+1][i+1]);};
|
||||
const height=(x,y)=>raw(x,y)-datum;
|
||||
const terrainMeshes=[],boundaries=[];
|
||||
for(const parcel of s.parcels){
|
||||
const polygon=parcel.polygon.slice(0,-1),pos=[],indices=[];
|
||||
const triangles=THREE.ShapeUtils.triangulateShape(polygon.map(p=>new THREE.Vector2(...p)),[]);
|
||||
for(const tri of triangles){const [a,b,c]=tri.map(i=>polygon[i]);const n=Math.ceil(Math.max(Math.hypot(a[0]-b[0],a[1]-b[1]),Math.hypot(a[0]-c[0],a[1]-c[1]),Math.hypot(b[0]-c[0],b[1]-c[1]))/1.2);const rows=[];
|
||||
for(let i=0;i<=n;i++){rows[i]=[];for(let j=0;j<=n-i;j++){const x=a[0]+(b[0]-a[0])*i/n+(c[0]-a[0])*j/n,y=a[1]+(b[1]-a[1])*i/n+(c[1]-a[1])*j/n;rows[i][j]=pos.length/3;const [X,Z]=planPoint(x,y);pos.push(X,height(x,y),Z);}}
|
||||
for(let i=0;i<n;i++)for(let j=0;j<n-i;j++){indices.push(rows[i][j],rows[i][j+1],rows[i+1][j]);if(j<n-i-1)indices.push(rows[i+1][j],rows[i][j+1],rows[i+1][j+1]);}
|
||||
}
|
||||
const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));g.setIndex(indices);g.computeVertexNormals();const o=mesh(g,m.grass,groundGroup,'Terén parcely '+parcel.id);terrainMeshes.push(o);
|
||||
const points=[...polygon,polygon[0]].map(([x,y])=>{const [X,Z]=planPoint(x,y);return new THREE.Vector3(X,height(x,y)+.06,Z);});const line=new THREE.Line(new THREE.BufferGeometry().setFromPoints(points),new THREE.LineBasicMaterial({color:'#4d6a4b'}));line.name='Hranice '+parcel.id;boundaryGroup.add(line);boundaries.push(line);
|
||||
}
|
||||
surface(parkingGroup,'Orientační poloha ulice Kovářova',[[-8,15],[19,15],[19,19],[-8,19]],height,m.road);
|
||||
// Editable datum is explicit: the drawings do not tie ±0.000 to a Bpv elevation.
|
||||
function setDatum(value){const delta=datum-value;datum=value;groundGroup.position.y+=delta;boundaryGroup.position.y+=delta;parkingGroup.position.y+=delta;}
|
||||
return {setDatum,get datum(){return datum;},raw};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Coordinates in metres, x right and y down in the supplied floor plans.
|
||||
// Printed room areas are authoritative labels; digitised polygons are approximate.
|
||||
const rect=(x,y,w,d)=>[[x,y],[x+w,y],[x+w,y+d],[x,y+d]];
|
||||
const room=(id,name,area,polygon,level,height,floor,label,spawn,look)=>({id,name,short:id+' '+name,area,polygon,level,height,floor,label,spawn:spawn||label,look:look||[label[0],label[1]-1]});
|
||||
export const rooms=[
|
||||
room('S.01','Schodiště',3.56,[[5.07,1.02],[6.73,1.02],[6.73,2.98],[6.07,2.98],[6.07,4.12],[5.07,4.12]],-1.9,1.9,'tile',[5.6,1.6],[5.6,1.6],[6.8,1.5]),
|
||||
room('S.02','Sklep',10.66,rect(6.73,.30,3.90,2.74),-2.2,2.06,'brick',[8.7,1.65],[7.7,1.7],[9.6,1.7]),
|
||||
room('1.01','Zádveří',5.48,[[6.73,.30],[10.63,.30],[10.63,1.99],[7.5,1.99],[7.5,.90],[6.73,.90]],0,3.1,'tile',[8.95,1.14],[8.9,1.15],[7.9,1.15]),
|
||||
room('1.02','Schodiště',5.58,[[5.07,.9],[6.73,.9],[6.73,1.99],[6.15,1.99],[6.15,4.27],[5.07,4.27]],0,5.78,'wood',[5.6,2.8],[5.6,4.12],[5.6,1.7]),
|
||||
room('1.03','Chodba',9.39,[[6.5,2.14],[9.63,2.14],[9.63,3.46],[8.28,3.46],[8.28,5.45],[5.07,5.45],[5.07,4.27],[6.5,4.27]],0,3.1,'tile',[7.15,4.3],[6.9,4.5],[7.1,2.7]),
|
||||
room('1.04','WC',1.18,rect(9.73,2.14,.9,1.32),0,2.6,'tile',[10.18,2.8],[10.17,2.58],[10.17,3.2]),
|
||||
room('1.05','Koupelna',2.97,rect(8.46,3.75,1.51,2),0,2.6,'tile',[9.2,4.65],[8.9,5.12],[9.5,4.1]),
|
||||
room('1.06','Pokoj',20.01,[[4.72,5.9],[9.97,5.9],[9.97,11.89],[7.37,11.89],[7.37,7.43],[4.72,7.43]],0,3.1,'wood',[8.65,8.4],[8.45,7],[8.7,10.3]),
|
||||
room('1.07','Pokoj',11.09,rect(4.72,7.53,2.55,4.36),0,3.06,'wood',[5.98,9.4],[5.4,9.1],[6.1,10.4]),
|
||||
room('1.08','Obývací pokoj',21.85,rect(.47,6.11,3.95,5.48),0,2.82,'wood',[2.4,8.6],[2.7,8.5],[1.4,10]),
|
||||
room('1.09','Kuchyň',21.16,[[.47,1.02],[4.77,1.02],[4.77,4.26],[4.42,4.26],[4.42,5.94],[.47,5.94]],-.04,2.86,'tile',[2.6,3.3],[2.6,3.3],[1.3,1.5]),
|
||||
room('2.01','Schodiště',4.98,rect(5.07,.9,1.20,4.54),3.4,2.38,'wood',[5.67,4.75],[5.67,5.05],[5.67,2.4]),
|
||||
room('2.02','Chodba',4.79,rect(4.72,5.92,1.72,2.6),3.55,2.57,'tile',[5.6,7.3],[5.6,6.6],[5.6,8]),
|
||||
room('2.03','Obývací pokoj + kuchyň',26.42,[[6.56,5.92],[9.97,5.92],[9.97,11.89],[4.72,11.89],[4.72,8.64],[6.56,8.64]],3.55,2.57,'wood',[8.3,9.1],[7.4,9.1],[8.7,10.5]),
|
||||
room('2.04','Pokoj',17.20,rect(1.32,5.44,3.1,5.55),3.25,2.46,'wood',[2.95,8.4],[3.6,7.1],[2.5,9.8]),
|
||||
room('2.05','Koupelna',11.65,rect(1.32,1.77,3.4,3.44),3.3,2.41,'tile',[3.1,3.4],[3.8,4.1],[2.7,2.6]),
|
||||
room('2.06','Balkón',27.88,[[6.5,-.31],[11.34,-.31],[11.34,5.45],[6.5,5.45]],3.45,0,'deck',[8.6,2.5],[8.6,4],[8.6,1])
|
||||
];
|
||||
export const outline=[[0,.55],[6.43,.55],[6.43,0],[10.93,0],[10.93,3.63],[10.44,3.63],[10.44,12.36],[4.19,12.36],[4.19,12.06],[0,12.06]];
|
||||
export const floors=[{id:'basement',title:'1PP · Suterén',area:14.22,base:-2.2,rooms:rooms.filter(r=>r.id.startsWith('S.')),outline:[[4.87,.72],[6.43,.72],[6.43,0],[10.93,0],[10.93,3.34],[6.27,3.34],[6.27,4.42],[4.87,4.42]]},
|
||||
{id:'ground',title:'1NP · Přízemí',area:98.71,base:0,rooms:rooms.filter(r=>r.id.startsWith('1.')),outline},
|
||||
{id:'upper',title:'2NP · Patro',area:65.04,base:3.55,rooms:rooms.filter(r=>r.id.startsWith('2.')),outline:[[.95,1.4],[4.72,1.4],[4.72,.55],[6.5,.55],[6.5,-.31],[11.34,-.31],[11.34,5.45],[10.44,5.45],[10.44,12.36],[4.19,12.36],[4.19,11.3],[.95,11.3]]}];
|
||||
// Wall: axis (x = vertical in plan), fixed coordinate, start, end, thickness, bottom, top, openings.
|
||||
// Opening: start, end, sill relative to datum, head, kind (door/window).
|
||||
export const walls={
|
||||
basement:[
|
||||
['y',.15,6.43,10.93,.3,-2.2,-.14,[[7.95,8.45,-.64,-.14,'window']]],
|
||||
['x',10.78,0,3.19,.3,-2.2,-.14,[]],['y',3.19,6.58,10.93,.3,-2.2,-.14,[]],
|
||||
['x',6.58,.3,3.19,.3,-2.2,-.14,[[1.02,1.72,-2.2,-.25,'door']]],
|
||||
['y',.87,4.87,6.58,.3,-1.9,0,[[5.34,5.84,-.53,-.11,'window']]],
|
||||
['x',4.97,.9,4.27,.2,-1.9,0,[]],['x',6.17,1.9,4.27,.2,-1.9,0,[]],
|
||||
['y',4.27,4.97,6.27,.2,-1.9,0,[]]],
|
||||
ground:[
|
||||
['y',.785,0,5.3,.47,0,3.43,[[2,3.2,.65,2.43,'window']]],
|
||||
['y',.70,5.3,6.43,.3,0,3.43,[[5.34,5.84,1.7,2.12,'window']]],
|
||||
['y',.15,6.43,10.93,.3,0,3.14,[[7.75,9.44,.92,2.74,'window']]],
|
||||
['x',.235,.55,12.06,.47,0,3.43,[[3.6,4.8,.65,2.43,'window'],[7.5,8.7,.65,2.43,'window']]],
|
||||
['y',11.825,0,4.19,.47,0,3.43,[[1.5,2.7,.65,2.43,'window']]],
|
||||
['x',4.425,11.59,12.36,.47,0,3.55,[]],
|
||||
['y',12.125,4.19,10.44,.47,0,3.55,[[5.8,7,.65,2.43,'window'],[7.63,8.83,.65,2.43,'window']]],
|
||||
['x',10.205,3.63,12.36,.47,0,3.55,[[4.26,4.99,1.5,2.5,'window'],[8.46,9.66,.65,2.43,'window']]],
|
||||
['y',3.545,9.63,10.93,.17,0,3.14,[]],
|
||||
['x',10.78,0,3.63,.3,0,3.14,[[.52,1.79,0,2.25,'door'],[2.47,2.85,1.5,2.5,'window']]],
|
||||
['x',6.58,0,.9,.3,0,3.43,[]],
|
||||
['x',7.35,.3,1.99,.3,0,3.1,[]],
|
||||
['y',2.065,6.15,10.63,.15,0,3.1,[[7.5,8.75,0,2.10,'door']]],
|
||||
['x',9.68,2.14,3.46,.1,0,2.6,[[2.25,2.95,0,1.77,'door']]],
|
||||
['y',3.6,8.28,9.97,.3,0,2.6,[]],
|
||||
['x',8.37,3.6,5.75,.18,0,2.6,[[4.55,5.30,0,1.77,'door']]],
|
||||
['y',5.825,8.28,10.44,.15,0,3.1,[]],
|
||||
['y',5.675,4.42,8.28,.45,0,3.1,[[6.52,7.89,0,2.20,'door']]],
|
||||
['x',4.92,1.02,5.45,.3,0,3.1,[[4.32,5.25,0,2.10,'door']]],
|
||||
['x',6.325,1.99,4.27,.35,0,3.1,[]],
|
||||
['y',4.34,5.07,6.15,.15,0,2.6,[[5.12,5.87,0,1.8,'door']]],
|
||||
['y',6.025,.47,4.42,.17,0,2.82,[[1.87,3.03,0,2.20,'door']]],
|
||||
['x',4.57,5.9,11.89,.30,0,3.06,[[8.26,9.63,0,2.07,'door']]],
|
||||
['y',7.48,4.72,7.37,.1,0,3.06,[]],['x',7.32,7.48,11.89,.1,0,3.06,[]]],
|
||||
upper:[
|
||||
['x',1.22,1.67,11.09,.2,3.25,4.25,[]],['y',1.67,1.22,4.72,.2,3.3,4.15,[]],['y',11.09,1.22,4.42,.2,3.25,4.15,[]],
|
||||
['y',5.325,1.32,4.72,.23,3.3,5.71,[[3.58,4.38,3.3,5.07,'door']]],
|
||||
['x',4.57,5.44,12.36,.3,3.25,6.8,[[6.78,7.53,3.25,5.02,'door']]],
|
||||
['x',4.895,.9,5.92,.35,3.3,5.78,[[5.30,5.92,3.3,5.40,'door']]],
|
||||
['x',6.325,.55,5.92,.35,3.4,5.9,[[1.22,2.25,3.45,5.3,'door'],[4.12,5.12,3.45,5.5,'door']]],
|
||||
['y',.725,4.72,6.5,.35,3.4,5.9,[]],
|
||||
['y',5.57,5.07,6.27,.26,3.4,5.78,[[5.12,6.02,3.4,5.17,'door']]],
|
||||
['y',5.685,6.5,10.44,.47,3.55,6.8,[[7.78,9.24,3.55,5.75,'door']]],
|
||||
['x',10.205,5.45,12.36,.47,3.55,6.8,[[8.46,9.66,4.18,5.96,'window']]],
|
||||
['y',12.125,4.19,10.44,.47,3.55,6.8,[[5.8,7,4.18,5.96,'window'],[7.64,8.84,4.18,5.96,'window']]],
|
||||
['x',6.5,5.92,8.58,.12,3.55,6.12,[[6.77,7.57,3.55,5.57,'door']]],
|
||||
['y',8.58,4.72,7.85,.12,3.55,6.12,[]]]
|
||||
};
|
||||
export function pointInPolygon(p,x,y){let ok=false;for(let i=0,j=p.length-1;i<p.length;j=i++){const a=p[i],b=p[j];if((a[1]>y)!==(b[1]>y)&&x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])ok=!ok;}return ok;}
|
||||
@@ -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 };
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2010-2025 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+58773
File diff suppressed because one or more lines are too long
+18251
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user