From 8d7f011188974a6d2fd9977b653aa7809a27a7bf Mon Sep 17 00:00:00 2001 From: "martin.fencl" Date: Sat, 31 Oct 2026 19:01:41 +0100 Subject: [PATCH] Refactor site parameters and improve ground modeling - Updated site coordinates and bearing to reflect accurate positioning. - Enhanced ground height calculations based on surveyed data. - Adjusted road and parcel boundary definitions for better accuracy. - Improved parking layout and added car models to reflect design. - Refined roof construction logic and added detailed roof geometry. - Updated window and door placements according to revised floor plans. - Enhanced comments for clarity and understanding of the code structure. --- klabava/README_CZ.md | 6 +- klabava/data/model.json | 14 +- klabava/index.html | 18 +-- klabava/js/klabava.js | 286 ++++++++++++++++++++++++---------------- 4 files changed, 195 insertions(+), 129 deletions(-) diff --git a/klabava/README_CZ.md b/klabava/README_CZ.md index 492bc97..4c77079 100644 --- a/klabava/README_CZ.md +++ b/klabava/README_CZ.md @@ -17,14 +17,14 @@ Potom otevřete . Prohlížeč potřebuje WebGL a připoj - Tažením myši model otáčíte, kolečkem přibližujete a pravým tlačítkem posouváte. - Příjezd, Zahrada a Shora nastaví pohled; Terén, Parcela a Parkování přepínají vrstvy pozemku. - Panel Slunce zachovává volbu data a času, sezónní dny, přehrávání dne, rychlost, východ/poledne/západ, azimut, výšku slunce, stíny, časové pásmo Europe/Prague a PNG. -- Zeměpisná poloha je nastavena přibližně na střed obce Klabava (49,7536° N, 13,5394° E). Natočení pozemku lze upravit v rozšířeném nastavení slunce. +- Zeměpisná poloha je střed parcely 66/17 (49,7520° N, 13,5440° E) podle souřadnicové sítě S-JTSK ve výškopisu. Severka situace ukazuje sever sítě S-JTSK, který je tady o 8,5° západněji než zeměpisný sever; uliční strana domu proto míří 1,5° východně od zeměpisného severu. Natočení pozemku lze upravit v rozšířeném nastavení slunce. - Stáhnout model GLB exportuje aktuálně viditelný 3D model domu a pozemku. Sluneční ovládání je součástí webu, nikoli souboru GLB. ## Podklady a míra přesnosti -Z půdorysu a pohledů vychází obrys domu 17,40 × 13,05 m, zastavěná plocha 178,75 m², užitná plocha 153,74 m² a úrovně hřebenů +5,275 a +5,832 m. Řez uvádí sklon střechy 23°. Hmota je modelována ve dvou navazujících křídlech; vyšší střecha je omezena na hlubší křídlo a její rozpon vychází z řezu a pohledů. Situace uvádí parcelu 66/17 o výměře 1 037 m² a 206,54 m² včetně krytého vstupu a terasy. Dvě parkovací stání jsou u komunikace vpravo od domu; plocha je zakreslená přibližně 6,625 × 5,0 m. Ve výškopisu jsou zaměřené hodnoty přibližně 349,5 m n. m. u komunikace a 347,2 m n. m. u zadní části parcely. +Z půdorysu a pohledů vychází obrys domu 17,40 × 13,05 m, zastavěná plocha 178,75 m², užitná plocha 153,74 m² a úrovně hřebenů +5,275 a +5,832 m. Řez uvádí sklon střechy 23°, přesah 0,6 m, podbití +2,766 a okap +3,046 m. Vyšší valbová střecha kryje kuchyňské křídlo, krytý vstup a krytou terasu (13,05 × 11,925 m, hřeben +5,832 m), nižší křídlo s ložnicemi (9,3 m, hřeben +5,275 m); na uliční straně mají obě střechy společnou rovinu. Situace uvádí parcelu 66/17 o výměře 1 037 m² a 206,54 m² včetně krytého vstupu (1,5 × 6,525 m) a terasy (3,75 × 4,8 m). Dvě parkovací stání 6,625 × 5,0 m jsou mezi komunikací a krytým vstupem před kuchyňským křídlem, tedy vpravo při pohledu z ulice. Dům je 5,0 m od komunikace, 2,0 m od východní a asi 3,3 m od západní hranice. Bodové výšky ve výškopisu klesají přibližně z 349,2 m n. m. u komunikace na 347,1 m n. m. u zadní hranice; východní strana je asi o 0,4 m výš. -Objem domu, polohy a rozměry hlavních otvorů a výšky střech vycházejí z kót půdorysu, pohledů a řezu. Sklon střechy odpovídá řezu 23°; vyšší a nižší valbová hmota jsou v místě napojení zjednodušené a komín je usazen na šikmé rovině střechy. Rámy otvorů, terén, příjezd a parkovací plocha slouží k prostorové orientaci. Hranice parcely je převzatá graficky ze situačního PDF, nikoli z vytyčovacích souřadnic; výšky terénu jsou interpolované mezi zaměřenými body. Model není BIM ani podklad pro stavbu, zemní práce nebo vytyčení. +Obrys domu, polohy a rozměry otvorů, střechy, komín a sloupky terasy a vstupu vycházejí z kót a vektorů půdorysu, pohledů a řezu. Terén je rovina proložená 27 bodovými výškami parcely (střední odchylka 4 cm); podlaha ±0,000 je 0,30 m nad terénem uprostřed domu. Rámy otvorů, nosná konstrukce terasy a vstupu, auta a materiály slouží k prostorové orientaci. Hranice parcely je převzatá graficky ze situačního PDF a výškopisu, nikoli z vytyčovacích souřadnic. Model není BIM ani podklad pro stavbu, zemní práce nebo vytyčení. Soubor `data/model.json` uchovává hlavní údaje projektu. Dodané PDF jsou zkopírované do `references/` a dostupné z panelu Projekt. Barvy fasády, střešní krytiny, oken a vybavení nejsou zatím vybrané. diff --git a/klabava/data/model.json b/klabava/data/model.json index 127cf83..3fedc29 100644 --- a/klabava/data/model.json +++ b/klabava/data/model.json @@ -10,11 +10,13 @@ "ridgeM": 5.832, "secondaryRidgeM": 5.275, "chimneyM": 6.5, - "surveyGroundNearRoadBpv": 349.48, - "surveyGroundRearBpv": 347.2, - "latitude": 49.7536, - "longitude": 13.5394, - "latitudeLongitudeNote": "Přibližný střed obce Klabava; nikoli souřadnice parcely.", + "surveyGroundNearRoadBpv": 349.2, + "surveyGroundRearBpv": 347.1, + "latitude": 49.7520, + "longitude": 13.5440, + "latitudeLongitudeNote": "Střed parcely 66/17 ze souřadnicové sítě S-JTSK ve výškopisu, převedený do WGS84.", + "siteBearingDeg": 1.5, + "siteBearingNote": "Uliční strana domu míří 10,0° od severu sítě S-JTSK (severka situace); meridiánová konvergence je -8,5°, tedy 1,5° od zeměpisného severu.", "standards": null, - "modelNote": "Vizualizační model podle PDF podkladů; vyšší střecha je omezena na hlubší křídlo domu. Hranice a výšky terénu jsou převzaté a interpolované orientačně." + "modelNote": "Vizualizační model podle PDF podkladů; vyšší valbová střecha kryje kuchyňské křídlo, terasu a krytý vstup, nižší křídlo s ložnicemi. Terén je rovina proložená bodovými výškami výškopisu." } diff --git a/klabava/index.html b/klabava/index.html index 116a8e5..bcd9fd9 100644 --- a/klabava/index.html +++ b/klabava/index.html @@ -6,9 +6,9 @@ Klabava | Rodinný dům - - - + + + @@ -59,8 +59,8 @@
Rozsah modelu a přesnost -

Půdorysná hmota, poloha a rozměry hlavních otvorů a výšky střech vycházejí z půdorysu, pohledů a řezu. Střecha je složená ze dvou valbových hmot se sklony podle řezu; komín dosedá na šikmou rovinu. Terén navazuje na zaměřené body přibližně od 349,5 m n. m. u komunikace k 347,2 m n. m. v zadní části parcely; mezi body je interpolovaný.

-

Hranice parcely je převzatá graficky z PDF situace, ne ze souřadnic pro vytyčení. Napojení střešních hmot, rámy oken a dveří, příjezd, parkovací plocha a materiály jsou zjednodušené pro prostorovou orientaci. Model slouží k prezentaci a studii oslunění, nikoli jako BIM, geodetický nebo stavební podklad.

+

Půdorysná hmota, poloha a rozměry otvorů a výšky střech vycházejí z kót půdorysu, pohledů a řezu. Střechu tvoří dvě valbové střechy se sklonem 23° a přesahem 0,6 m; vyšší kryje i terasu a krytý vstup, nižší křídlo s ložnicemi. Terén je rovina proložená zaměřenými body výškopisu, přibližně 349,2 m n. m. u komunikace a 347,1 m n. m. u zadní hranice.

+

Hranice parcely je převzatá graficky z PDF situace a výškopisu, ne ze souřadnic pro vytyčení. Rámy oken a dveří, nosná konstrukce terasy a vstupu, auta a materiály jsou zjednodušené pro prostorovou orientaci. Model slouží k prezentaci a studii oslunění, nikoli jako BIM, geodetický nebo stavební podklad.

Pracovní vizualizace · standardy doplníme po jejich výběru
@@ -106,11 +106,11 @@
Výška nad obzorem—Střed slunečního kotouče
Azimut slunce—Od severu po směru hodin
Východ —Poledne —Západ —
-

Posouvej čas a sleduj, kam dopadá slunce na fasády a zahradu. Sever modelu vychází ze severky v situaci.

+

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.

Poloha a orientace domu -
-

Výchozí poloha odpovídá přibližnému středu obce Klabava. Natočení je odečtené orientačně ze severky v situačním výkresu. Přesné souřadnice parcely a azimut stavby nebyly ve vstupních podkladech uvedeny.

+
+

Výchozí poloha je střed parcely 66/17 podle souřadnicové sítě S-JTSK ve výškopisu. Severka v situaci ukazuje sever sítě S-JTSK, který je tady o 8,5° západněji než zeměpisný sever. Uliční strana domu proto míří 1,5° východně od zeměpisného severu; kladné natočení otáčí pozemek po směru hodinových ručiček.

Časové pásmo zůstává Europe/Prague. Letní čas se přepočítává podle zvoleného data a pravidel prohlížeče.

@@ -120,6 +120,6 @@
Pracovní model · Klabava 66/17
- + diff --git a/klabava/js/klabava.js b/klabava/js/klabava.js index 7224be5..de11103 100644 --- a/klabava/js/klabava.js +++ b/klabava/js/klabava.js @@ -31,7 +31,10 @@ const canvas = $('#view'); const loading = $('#loading'); const toastEl = $('#toast'); -const site = { latitude: 49.7536, longitude: 13.5394, bearing: 0 }; +// Centre of parcel 66/17 from the S-JTSK grid in the survey. The site plan's north arrow is S-JTSK grid north, +// 8.5° west of true north here, so the road side of the house faces 1.5° east of true north. +const SITE_DEFAULT = { latitude: 49.7520, longitude: 13.5440, bearing: 1.5 }; +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: [], @@ -89,6 +92,8 @@ sunLight.target = sunTarget; const modelRoot = new THREE.Group(); modelRoot.name = 'Klabava – 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); @@ -99,7 +104,6 @@ const mats = { grass: new THREE.MeshStandardMaterial({ color: 0x9ba78f, roughness: 1 }), road: new THREE.MeshStandardMaterial({ color: 0x676d6b, roughness: 0.98 }), gravel: new THREE.MeshStandardMaterial({ color: 0xc5c1b4, roughness: 1 }), - path: new THREE.MeshStandardMaterial({ color: 0xb8b7a9, roughness: 0.9 }), wall: new THREE.MeshStandardMaterial({ color: 0xdedbd2, roughness: 0.86 }), foundation: new THREE.MeshStandardMaterial({ color: 0xb9b9b1, roughness: 0.9, side: THREE.DoubleSide }), roof: new THREE.MeshStandardMaterial({ color: 0x555c59, roughness: 0.82, metalness: 0.02, side: THREE.DoubleSide }), @@ -107,6 +111,8 @@ const mats = { frame: new THREE.MeshStandardMaterial({ color: 0x626966, roughness: 0.7 }), door: new THREE.MeshStandardMaterial({ color: 0x958d7d, roughness: 0.8 }), terrace: new THREE.MeshStandardMaterial({ color: 0xaaa492, roughness: 0.88 }), + soffit: new THREE.MeshStandardMaterial({ color: 0xd6d2c7, roughness: 0.9, side: THREE.DoubleSide }), + timber: new THREE.MeshStandardMaterial({ color: 0x8c7a63, roughness: 0.85 }), metal: new THREE.MeshStandardMaterial({ color: 0x545b58, roughness: 0.6, metalness: 0.18 }), car1: new THREE.MeshStandardMaterial({ color: 0x777e7b, roughness: 0.45, metalness: 0.08 }), car2: new THREE.MeshStandardMaterial({ color: 0xa59f91, roughness: 0.45, metalness: 0.08 }), @@ -167,8 +173,16 @@ function line(parent, name, points, color = 0x657361, opacity = 0.85) { } function group(parent, name) { const g = new THREE.Group(); g.name = name; parent.add(g); return g; } -// Survey-led ground slope: about 349.5 m Bpv by the road and 347.2 m Bpv at the rear. -function rawGround(x, z) { return 0.12 - (20 - z) * (2.25 / 40) + x * 0.004; } +// Floor-plan frame used for the dimensions below (Půdorys přízemí, Situace): px runs from the garden façade (0) +// to the road side (13.05 m), py from the west façade (0) to the east gable (17.4 m). The road side faces north, +// so px maps to +z and py to −x (east); houseGroup sits 8.4 m north of the site origin. +const planPoint = (px, py) => [8.7 - py, px - 6.525]; +const sitePoint = (px, py) => [8.7 - py, px - 6.525 + houseGroup.position.z]; + +// Plane fitted to the 27 spot heights inside parcel 66/17 (rms 4 cm): about 349.2 m Bpv at the road boundary, +// 347.1 m Bpv at the rear and 0.4 m higher on the east side. Floor ±0.000 sits 0.30 m above the surveyed ground +// at the centre of the house, matching the −0.300 terrain line in the elevations. +function rawGround(x, z) { return -0.694 + 0.0468 * z - 0.0178 * x; } function groundHeight(x, z) { const dx = Math.max(Math.abs(x) - 9.5, 0); const dz = z > 15.8 ? z - 15.8 : z < 0.4 ? 0.4 - z : 0; @@ -177,13 +191,16 @@ function groundHeight(x, z) { } function buildGround() { - const nw = [-13, 20], ne = [13, 20], se = [13.5, -19.5], sw = [-12.5, -20]; - const nx = 34, nz = 54; + // Parcel corners from the site plan in floor-plan metres; they agree with the S-JTSK survey within 0.1 m + // (5.0 m to the road, 3.18–3.37 m to the west boundary, 2.0 m to the east boundary, 27.44 m to the rear). + const roadEast = sitePoint(18.04, 19.40), roadWest = sitePoint(18.05, -3.11); + const rearEast = sitePoint(-27.19, 19.39), rearWest = sitePoint(-27.50, -3.78); + const nx = 30, nz = 60; const positions = [], indices = []; for (let j = 0; j <= nz; j++) { const v = j / nz; - const leftX = nw[0] * (1 - v) + sw[0] * v, leftZ = nw[1] * (1 - v) + sw[1] * v; - const rightX = ne[0] * (1 - v) + se[0] * v, rightZ = ne[1] * (1 - v) + se[1] * v; + const leftX = roadEast[0] * (1 - v) + rearEast[0] * v, leftZ = roadEast[1] * (1 - v) + rearEast[1] * v; + const rightX = roadWest[0] * (1 - v) + rearWest[0] * v, rightZ = roadWest[1] * (1 - v) + rearWest[1] * v; for (let i = 0; i <= nx; i++) { const u = i / nx; const x = leftX * (1 - u) + rightX * u; @@ -199,16 +216,16 @@ function buildGround() { geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); - mesh(geometry, mats.grass, groundGroup, 'Interpolovaný terén parcely 66/17'); + mesh(geometry, mats.grass, groundGroup, 'Terén parcely 66/17 podle výškopisu'); - // The northern road and its edge follow the topographic fall across the site. - const roadY = (z) => rawGround(0, z) + 0.01; - const roadPoints = [[-17, roadY(26), 26], [17, roadY(26), 26], [17, roadY(20.4), 20.4], [-17, roadY(20.4), 20.4]]; + // The local road (parcel 66/8) runs along the northern boundary and follows the surveyed fall. + const roadY = (x, z) => rawGround(x, z) + 0.01; + const roadPoints = [[-18, 26], [20, 26], [20, 19.95], [-18, 19.95]].map(([x, z]) => [x, roadY(x, z), z]); poly(parkingGroup, 'Místní komunikace', roadPoints, mats.road); - line(parkingGroup, 'Okraj komunikace', [[-17, roadY(20.3) + 0.02, 20.3], [17, roadY(20.3) + 0.02, 20.3]], 0xbfc4bc, 0.8); + line(parkingGroup, 'Okraj komunikace', [[-18, roadY(-18, 20.05) + 0.02, 20.05], [20, roadY(20, 20.05) + 0.02, 20.05]], 0xbfc4bc, 0.8); // Parcel outline is intentionally thin and diagrammatic; the supplied survey has no parcel staking coordinates. - const border = [nw, ne, se, sw].map(([x, z]) => [x, groundHeight(x, z) + 0.045, z]); + const border = [roadWest, roadEast, rearEast, rearWest].map(([x, z]) => [x, groundHeight(x, z) + 0.045, z]); line(boundaryGroup, 'Grafická hranice parcely 66/17', [...border, border[0]], 0x4f694f, 0.95); for (let i = 0; i < border.length; i++) { const [x, y, z] = border[i]; @@ -217,21 +234,25 @@ function buildGround() { post.castShadow = false; } - buildParking(roadY); + buildParking(); } -function buildParking(roadY) { +function buildParking() { const y = (x, z) => groundHeight(x, z) + 0.035; - // Situace places the two-car apron on the road side; the 6.625 × 5.0 m pad is at the right of this view. - const drivewayPts = [[5.0, y(5.0, 20.45), 20.45], [8.9, y(8.9, 20.45), 20.45], [8.9, y(8.9, 19.85), 19.85], [5.0, y(5.0, 19.85), 19.85]]; - poly(parkingGroup, 'Příjezd z komunikace', drivewayPts, mats.gravel); - const pad = [[3.7, y(3.7, 20.0), 20.0], [10.325, y(10.325, 20.0), 20.0], [10.325, y(10.325, 15.0), 15.0], [3.7, y(3.7, 15.0), 15.0]]; - poly(parkingGroup, 'Orientační parkovací plocha', pad, mats.gravel); - line(parkingGroup, 'Napojení na komunikaci', [[6.95, roadY(20.3) + 0.04, 20.3], [6.95, y(6.95, 19.85) + 0.04, 19.85]], 0x8f958b, 0.65); + // Situace: two parking spaces 6.625 × 5.0 m between the road boundary and the covered entry, in front of the + // recessed kitchen wing. The covered entry strip under the roof is paved as well. + const pad = [[11.55, -0.1], [11.55, 6.525], [18.05, 6.525], [18.05, -0.1]].map(([px, py]) => { + const [x, z] = sitePoint(px, py); + return [x, y(x, z), z]; + }); + poly(parkingGroup, 'Parkovací stání a krytý vstup', pad, mats.gravel); - function addCar(cx, cz, material, label) { + function addCar(px, py, material, label) { + const [cx, cz] = sitePoint(px, py); const car = group(parkingGroup, label); car.position.set(cx, groundHeight(cx, cz) + 0.035, cz); + // Parked nose-in towards the house, as drawn in the floor plan. + car.rotation.y = Math.PI; box(car, `${label} karoserie`, 0, 0.42, 0, 2.05, 0.72, 4.35, material); box(car, `${label} kabina`, 0, 0.93, -0.1, 1.55, 0.62, 2.45, mats.glass); for (const x of [-1.02, 1.02]) for (const z of [-1.42, 1.42]) { @@ -241,19 +262,21 @@ function buildParking(roadY) { } box(car, `${label} přední sklo`, 0, 0.95, 1.12, 1.38, 0.43, 0.08, mats.glass, 0.18); } - addCar(5.55, 17.5, mats.car1, 'Orientační automobil 1'); - addCar(8.45, 17.5, mats.car2, 'Orientační automobil 2'); - // The front walk runs from the entrance to the right-hand parking apron. - const walkToDoor = [[-0.3, y(-0.3, 16.4), 16.4], [1.15, y(1.15, 16.4), 16.4], [1.15, y(1.15, 14.7), 14.7], [-0.3, y(-0.3, 14.7), 14.7]]; - const walkToParking = [[0.8, y(0.8, 14.7), 14.7], [4.0, y(4.0, 14.7), 14.7], [4.0, y(4.0, 15.35), 15.35], [0.8, y(0.8, 15.35), 15.35]]; - poly(parkingGroup, 'Pěší vstup od domu', walkToDoor, mats.path); - poly(parkingGroup, 'Pěší napojení parkování', walkToParking, mats.path); + addCar(14.4, 1.45, mats.car1, 'Orientační automobil 1'); + addCar(14.4, 3.97, mats.car2, 'Orientační automobil 2'); } -const footprint = [ - [-1.575, 6.525], [8.7, 6.525], [8.7, -2.775], [-1.575, -2.775], - [-1.575, -6.525], [-8.7, -6.525], [-8.7, 5.145], [-1.575, 5.145] -]; +// Section and elevations: soffit +2.766, eave +3.046, 23° roof planes with 0.6 m overhangs; all openings end at +2.300. +const SOFFIT = 2.766, EAVE = 3.046, HEAD = 2.3, OVERHANG = 0.6, ROOF_SLOPE = Math.tan(23 * RAD); + +// Outer face of the external walls: kitchen/living wing 11.55 × 7.125 m with the covered entry recess on the road +// side, bedroom wing 9.3 × 10.875 m reaching the road line, terrace notch 3.75 m deep. Area 178.75 m². +const footprint = [[0, 0], [11.55, 0], [11.55, 6.525], [13.05, 6.525], [13.05, 17.4], [3.75, 17.4], [3.75, 7.125], [0, 7.125]] + .map(([px, py]) => planPoint(px, py)); +const footprintSign = Math.sign(footprint.reduce((sum, [x, z], i) => { + const [x2, z2] = footprint[(i + 1) % footprint.length]; + return sum + x * z2 - x2 * z; +}, 0)); function makeFootprintSlab() { const shape = new THREE.Shape(); @@ -271,8 +294,10 @@ function makeWallEdge(p1, p2, openings = []) { const dx = p2[0] - p1[0], dz = p2[1] - p1[1]; const length = Math.hypot(dx, dz); const angle = Math.atan2(-dz, dx); - const thickness = 0.295, height = 3.046, wallBottom = -0.30; - const positionAt = (distance) => [p1[0] + dx * distance / length, p1[1] + dz * distance / length]; + const thickness = 0.295, height = SOFFIT, wallBottom = -0.30; + // Dimension chains are measured on the outer face, so the wall body sits inside the footprint line. + const inX = -dz / length * footprintSign * thickness / 2, inZ = dx / length * footprintSign * thickness / 2; + const positionAt = (distance) => [p1[0] + dx * distance / length + inX, p1[1] + dz * distance / length + inZ]; const wallSegment = (from, to, bottom, top, name = 'Obvodová stěna') => { if (to - from < 0.02 || top - bottom < 0.02) return; const [x, z] = positionAt((from + to) / 2); @@ -312,85 +337,123 @@ function makeWallEdge(p1, p2, openings = []) { wallSegment(cursor, length, wallBottom, height); } -function hipPyramid(x0, x1, z0, z1, name, ridgeHeight, options = {}) { - const eave = 3.046; - const west = options.overhang?.west ?? 0.25, east = options.overhang?.east ?? 0.25; - const south = options.overhang?.south ?? 0.25, north = options.overhang?.north ?? 0.25; - x0 -= west; x1 += east; z0 -= south; z1 += north; - const xm = (x0 + x1) / 2, zm = (z0 + z1) / 2; - const v = (x, y, z) => [x, y, z]; - const apex = v(xm, ridgeHeight, zm); - poly(houseGroup, `${name} – severní valba`, [v(x0, eave, z1), v(x1, eave, z1), apex], mats.roof); - poly(houseGroup, `${name} – jižní valba`, [v(x1, eave, z0), v(x0, eave, z0), apex], mats.roof); - poly(houseGroup, `${name} – západní valba`, [v(x0, eave, z0), v(x0, eave, z1), apex], mats.roof); - poly(houseGroup, `${name} – východní valba`, [v(x1, eave, z1), v(x1, eave, z0), apex], mats.roof); - const width = x1 - x0, depth = z1 - z0; - const edges = options.eaveEdges ?? { west: true, east: true, south: true, north: true }; - if (edges.north) box(houseGroup, `${name} – okap sever`, xm, eave - 0.04, z1, width, 0.12, 0.12, mats.frame); - if (edges.south) box(houseGroup, `${name} – okap jih`, xm, eave - 0.04, z0, width, 0.12, 0.12, mats.frame); - if (edges.west) box(houseGroup, `${name} – okap západ`, x0, eave - 0.04, zm, 0.12, 0.12, depth, mats.frame); - if (edges.east) box(houseGroup, `${name} – okap východ`, x1, eave - 0.04, zm, 0.12, 0.12, depth, mats.frame); - return { x0, x1, z0, z1, ridgeX: xm, ridgeZ: zm, eave, ridgeHeight }; +// Eave lines in floor-plan metres. The higher roof spans the kitchen wing, covered entry and covered terrace +// (13.05 × 11.925 m → ridge +5.832); the lower roof spans the bedroom wing (9.3 m → ridge +5.275) and shares the +// road-side plane with the higher roof. +const roofPlan = { + south: -OVERHANG, north: 13.05 + OVERHANG, west: -OVERHANG, + highEast: 11.925 + OVERHANG, lowSouth: 3.75 - OVERHANG, east: 17.4 + OVERHANG +}; +function roofHeight(px, py) { + const r = roofPlan, hip = (distance) => EAVE + distance * ROOF_SLOPE; + const high = px >= r.south && px <= r.north && py >= r.west && py <= r.highEast + ? hip(Math.min(px - r.south, r.north - px, py - r.west, r.highEast - py)) : -Infinity; + const low = px >= r.lowSouth && px <= r.north && py >= r.west && py <= r.east + ? hip(Math.min(px - r.lowSouth, r.north - px, py - r.west, r.east - py)) : -Infinity; + return Math.max(high, low); } -function hipRoofHeight(roof, x, z) { - const fall = Math.max( - Math.abs(x - roof.ridgeX) / ((roof.x1 - roof.x0) / 2), - Math.abs(z - roof.ridgeZ) / ((roof.z1 - roof.z0) / 2) - ); - return roof.eave + (roof.ridgeHeight - roof.eave) * (1 - fall); +function roofFace(name, outline, distance) { + const triangles = THREE.ShapeUtils.triangulateShape(outline.map(([px, py]) => new THREE.Vector2(px, py)), []); + const positions = []; + for (const [px, py] of outline) { + const [x, z] = planPoint(px, py); + positions.push(x, EAVE + distance(px, py) * ROOF_SLOPE, z); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setIndex(triangles.flat()); + geometry.computeVertexNormals(); + return mesh(geometry, mats.roof, houseGroup, name); +} + +function buildRoof() { + const r = roofPlan; + const highHalf = (r.highEast - r.west) / 2, lowHalf = (r.north - r.lowSouth) / 2; + const ridgePy = r.west + highHalf, ridgeSouth = r.south + highHalf, ridgeNorth = r.north - highHalf; + const lowRidgePx = r.lowSouth + lowHalf, lowRidgeEast = r.east - lowHalf, lowRidgeWest = r.highEast - lowHalf; + roofFace('Střecha – severní rovina nad vstupem a ložnicemi', + [[r.north, r.west], [r.north, r.east], [lowRidgePx, lowRidgeEast], [lowRidgePx, lowRidgeWest], [ridgeNorth, ridgePy]], (px) => r.north - px); + roofFace('Vyšší střecha – západní rovina', + [[r.south, r.west], [r.north, r.west], [ridgeNorth, ridgePy], [ridgeSouth, ridgePy]], (px, py) => py - r.west); + roofFace('Vyšší střecha – jižní valba nad terasou', + [[r.south, r.highEast], [r.south, r.west], [ridgeSouth, ridgePy]], (px) => px - r.south); + roofFace('Vyšší střecha – východní rovina nad terasou', + [[r.south, r.highEast], [r.lowSouth, r.highEast], [lowRidgePx, lowRidgeWest], [ridgeNorth, ridgePy], [ridgeSouth, ridgePy]], (px, py) => r.highEast - py); + roofFace('Nižší střecha – jižní rovina', + [[r.lowSouth, r.highEast], [r.lowSouth, r.east], [lowRidgePx, lowRidgeEast], [lowRidgePx, lowRidgeWest]], (px) => px - r.lowSouth); + roofFace('Nižší střecha – východní valba', + [[r.north, r.east], [r.lowSouth, r.east], [lowRidgePx, lowRidgeEast]], (px, py) => r.east - py); + + const eaves = [[r.south, r.west], [r.north, r.west], [r.north, r.east], [r.lowSouth, r.east], [r.lowSouth, r.highEast], [r.south, r.highEast]] + .map(([px, py]) => planPoint(px, py)); + const soffitGeometry = new THREE.ShapeGeometry(new THREE.Shape(eaves.map(([x, z]) => new THREE.Vector2(x, z)))); + soffitGeometry.rotateX(Math.PI / 2); + const soffit = mesh(soffitGeometry, mats.soffit, houseGroup, 'Podbití střechy'); + soffit.position.y = SOFFIT; + soffit.castShadow = false; + for (let i = 0; i < eaves.length; i++) { + const [ax, az] = eaves[i], [bx, bz] = eaves[(i + 1) % eaves.length]; + box(houseGroup, 'Čelní prkno a okap', (ax + bx) / 2, (SOFFIT + EAVE) / 2, (az + bz) / 2, + Math.hypot(bx - ax, bz - az) + 0.06, EAVE - SOFFIT + 0.04, 0.06, mats.frame, Math.atan2(-(bz - az), bx - ax)); + } + + // The chimney stands over the stove flue (plan 7.58 / 4.76); its top at +6.500 is 1.255 m above the roof on the low side. + const [chimneyX, chimneyZ] = planPoint(7.582, 4.763); + const roofAt = (x, z) => roofHeight(z + 6.525, 8.7 - x); + slopedBox(houseGroup, 'Oplechování komína', chimneyX, chimneyZ, 0.5, 0.5, roofAt, (x, z) => roofAt(x, z) + 0.06, mats.metal); + slopedBox(houseGroup, 'Komín', chimneyX, chimneyZ, 0.36, 0.36, (x, z) => roofAt(x, z) + 0.04, () => 6.38, mats.foundation); + box(houseGroup, 'Hlavice komína', chimneyX, 6.44, chimneyZ, 0.46, 0.12, 0.46, mats.frame); +} + +function timberPost(parent, px, py, base) { + const [x, z] = planPoint(px, py); + box(parent, 'Sloupek', x, (base + SOFFIT) / 2, z, 0.14, SOFFIT - base, 0.14, mats.timber); +} +function timberBeam(parent, from, to) { + const [ax, az] = planPoint(...from), [bx, bz] = planPoint(...to); + box(parent, 'Trám', (ax + bx) / 2, SOFFIT - 0.1, (az + bz) / 2, Math.hypot(bx - ax, bz - az), 0.2, 0.14, mats.timber, Math.atan2(-(bz - az), bx - ax)); } function buildHouse() { makeFootprintSlab(); + const win = (center, width, sill, panes = 1) => ({ center, width, bottom: sill, height: HEAD - sill, panes }); + const door = (center, width, kind = 'glazedDoor', panes = 1) => ({ center, width, bottom: 0, height: HEAD, kind, panes }); + // Openings per footprint edge, centred from the first corner of the edge along the outer face (plan dimension chains). + // "1300 (1000)" windows are 1.3 m high on a 1.0 m sill, "1000 (1300)" windows 1.0 m high on a 1.3 m sill. const openings = [ - // North long façade: three windows, positioned from the dimension chain in the floor plan. - [{ center: 1.66, width: 0.8, bottom: 1.3, height: 1.3 }, { center: 4.805, width: 1.3, bottom: 1.3, height: 1.3, panes: 2 }, { center: 7.525, width: 1.8, bottom: 1.3, height: 1.3, panes: 2 }], - // East end façade: two window groups. - [{ center: 1.8, width: 1.8, bottom: 1.3, height: 1.3, panes: 2 }, { center: 6.9, width: 1.8, bottom: 1.3, height: 1.3, panes: 2 }], - // South long façade: 1.8 m glazed door, 1.1 m door, and 1.8 m window from the dimension chain. - [{ center: 2.575, width: 1.8, bottom: 1.3, height: 1.3, panes: 2 }, { center: 4.925, width: 1.1, bottom: 0, height: 2.3, kind: 'door' }, { center: 8.475, width: 1.8, bottom: 0, height: 2.3, kind: 'glazedDoor', panes: 2 }], - [{ center: 1.8, width: 1.3, bottom: 1.3, height: 1.3 }], - // The garden-end door is 1.1 m wide in the plan. - [{ center: 1.125, width: 1.1, bottom: 0, height: 2.3, kind: 'door' }], - // West end: the entry door and two larger window groups shown in the elevations. - [{ center: 2.315, width: 1.8, bottom: 1.3, height: 1.3, panes: 2 }, { center: 6.221, width: 1.8, bottom: 1.3, height: 1.3, panes: 2 }, { center: 9.07, width: 1.1, bottom: 0, height: 2.3, kind: 'door' }], + // West façade: 1295 | 1800 window | 2105 | 1800 window | 1399 | 1100 door | 2050. + [win(2.195, 1.8, 1.0, 2), win(6.1, 1.8, 1.0, 2), door(8.949, 1.1)], + // Recessed road side under the covered entry: 5050 wall, then the 1500 entrance with a sidelight. + [win(5.26, 0.42, 0), door(5.9975, 0.955, 'door')], [], - [] + // Road side of the bedroom wing: 1260 | 800 | 2095 | 1300 | 1170 | 1800 | 2450, all on 1.3 m sills. + [win(1.66, 0.8, 1.3), win(4.805, 1.3, 1.3), win(7.525, 1.8, 1.3, 2)], + // East gable: 900 | 1800 window | 6600. + [win(1.8, 1.8, 1.0, 2)], + // Garden side of the bedroom wing: 1675 | 1800 door | 900 | 1100 door | 2100 | 1800 door | 900. + [door(2.575, 1.8, 'glazedDoor', 2), door(4.925, 1.1), door(8.475, 1.8, 'glazedDoor', 2)], + // Living room to the terrace: 2700 sliding door from the inner corner, then 1075 wall. + [door(1.375, 2.65, 'glazedDoor', 2)], + // South wall of the kitchen: 575 | 1100 door | 5450. + [door(1.125, 1.1)] ]; for (let i = 0; i < footprint.length; i++) makeWallEdge(footprint[i], footprint[(i + 1) % footprint.length], openings[i]); - // The section's 23° pitch and both elevations' ridge levels define two separate hipped masses. - // Their inner eaves meet at the common wall; neither roof projects through the other. - hipPyramid(-1.575, 8.7, -2.775, 6.525, 'Nižší valbová střecha', 5.275, { - overhang: { west: 0, east: 0.25, south: 0.38, north: 0.38 }, - eaveEdges: { west: false, east: true, south: true, north: true } - }); - const highRoof = hipPyramid(-8.7, -1.575, -6.525, 5.145, 'Vyšší valbová střecha', 5.832, { - overhang: { west: 0.25, east: 0, south: 0.69, north: 0.69 }, - eaveEdges: { west: true, east: false, south: true, north: true } - }); + buildRoof(); - // The elevations set the chimney top at +6.500 m and show 1.255 m above the roof plane. - // The base is cut to the sloping roof plane so the masonry cannot float above or pass through it. - const chimneyX = -5.14, chimneyZ = 0.69; - const chimneyRoofY = (x, z) => hipRoofHeight(highRoof, x, z); - slopedBox(houseGroup, 'Oplechování komína', chimneyX, chimneyZ, 0.82, 0.82, - chimneyRoofY, (x, z) => chimneyRoofY(x, z) + 0.08, mats.metal); - slopedBox(houseGroup, 'Komín', chimneyX, chimneyZ, 0.64, 0.64, - (x, z) => chimneyRoofY(x, z) + 0.05, () => 6.38, mats.foundation); - box(houseGroup, 'Hlavice komína', -5.14, 6.44, 0.69, 0.82, 0.12, 0.82, mats.frame); + // Covered terrace 3.75 × 4.8 m under the higher roof, with one corner post (plan 0.14 / 11.78) and beams under the soffit. + const terrace = group(houseGroup, 'Krytá terasa'); + const [tx, tz] = planPoint(3.75 / 2, (7.125 + 11.925) / 2); + box(terrace, 'Podlaha terasy', tx, -0.175, tz, 11.925 - 7.125, 0.25, 3.75, mats.terrace); + timberPost(terrace, 0.144, 11.778, -0.05); + timberBeam(terrace, [0.144, 7.125], [0.144, 11.85]); + timberBeam(terrace, [0.07, 11.778], [3.75, 11.778]); - // The covered terrace is 4.585 × 3.533 m and sits directly outside the south glazed door. - const deck = group(modelRoot, 'Krytá terasa – schematicky'); - deck.position.set(0.225, 0, houseGroup.position.z - 4.542); - box(deck, 'Podlaha terasy', 0, -0.15, 0, 4.585, 0.15, 3.533, mats.terrace); - for (const [x, z] of [[-2.12, -1.55], [2.12, -1.55], [-2.12, 1.55], [2.12, 1.55]]) { - const ground = groundHeight(deck.position.x + x, deck.position.z + z); - const postTop = 3.02; - box(deck, 'Sloupek kryté terasy', x, (postTop + ground) / 2, z, 0.14, postTop - ground, 0.14, mats.wall); - } - box(deck, 'Stříška terasy', 0, 3.1, 0, 5.05, 0.16, 4.0, mats.terrace); - box(deck, 'Schod z terasy', 0, -0.25, -2.0, 2.5, 0.18, 0.45, mats.path); + // Covered entry 1.5 × 6.525 m in front of the recessed road side, with three posts from the floor plan. + const entry = group(houseGroup, 'Krytý vstup'); + for (const [px, py] of [[11.617, 0.147], [12.901, 0.147], [12.901, 6.455]]) timberPost(entry, px, py, -0.27); + timberBeam(entry, [12.901, 0.07], [12.901, 6.525]); + timberBeam(entry, [11.55, 0.147], [12.97, 0.147]); } buildGround(); @@ -437,7 +500,7 @@ function saveBlob(blob, filename) { const presets = { entry: { position: [24, 18, 31], target: [0, 1.15, 9.3], label: 'Dům / příjezd' }, garden: { position: [-2, 15, -22], target: [0, 1.25, 7], label: 'Dům / zahrada' }, - top: { position: [0, 77, 8], target: [0, 0.1, 8], label: 'Pozemek / shora' } + top: { position: [0.8, 90, -1], target: [0.8, 0.1, -1], label: 'Pozemek / shora' } }; function setPreset(name) { const p = presets[name] || presets.entry; @@ -609,7 +672,7 @@ function updateSolar() { 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.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; @@ -673,14 +736,14 @@ function readSiteNumbers() { return; } site.latitude = lat; site.longitude = lon; site.bearing = bearing; - modelRoot.rotation.y = bearing * RAD; + modelRoot.rotation.y = -bearing * RAD; rebuildSolarDay(true); } ['sunLat', 'sunLon', 'siteBearing'].forEach(id => $(`#${id}`).addEventListener('change', readSiteNumbers)); $('#sunResetSite').addEventListener('click', () => { - site.latitude = 49.7536; site.longitude = 13.5394; site.bearing = 0; + Object.assign(site, SITE_DEFAULT); $('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing; - modelRoot.rotation.y = 0; + modelRoot.rotation.y = -site.bearing * RAD; rebuildSolarDay(true); }); $('#compassGroup').insertAdjacentHTML('beforeend', ''); @@ -721,7 +784,7 @@ async function saveSunImage() { ctx.fillStyle = '#2d3c31'; ctx.font = `${12 * scale}px Arial`; ctx.fillText(`Klabava · ${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í · souřadnice obce Klabava', 24 * scale, source.height + 46 * scale); + ctx.fillText('Orientační studie oslunění · parcela 66/17, Klabava', 24 * scale, source.height + 46 * scale); out.toBlob(blob => { if (blob) saveBlob(blob, 'klabava-slunce.png'); }); } $('#sunSavePng').addEventListener('click', saveSunImage); @@ -748,7 +811,7 @@ function drawSunPath() { 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.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 ); @@ -789,7 +852,8 @@ function animate(now = 0) { solarFrame(dt); controls.update(); const cameraHeading = Math.atan2(camera.position.x - controls.target.x, camera.position.z - controls.target.z) / RAD; - $('#compassGroup').setAttribute('transform', `rotate(${cameraHeading},42,42)`); + // 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(); }