import { installInterior } from './interior-view.js'; 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.
${error.message}
Spusť web přes místní server a ověř přítomnost složky vendor/three.`; } 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'); // 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: [], 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.
${error.message}
Zkontroluj podporu WebGL v prohlížeči.`; 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, 1.2, 8.2); 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, 1.2, 8.2); scene.add(sunLight, sunTarget); 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); 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, 8.4); 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; 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 }), 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 }), glass: new THREE.MeshStandardMaterial({ color: 0x9aafb2, roughness: 0.2, metalness: 0.05, transparent: true, opacity: 0.78, side: THREE.DoubleSide }), 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 }), tire: new THREE.MeshStandardMaterial({ color: 0x333635, roughness: 0.95 }) }; function mesh(geometry, material, parent, name = '') { const object = new THREE.Mesh(geometry, material); object.name = name; object.castShadow = true; object.receiveShadow = true; parent.add(object); return object; } function box(parent, name, x, y, z, sx, sy, sz, material, rotationY = 0) { const object = mesh(new THREE.BoxGeometry(sx, sy, sz), material, parent, name); object.position.set(x, y, z); object.rotation.y = rotationY; return object; } function poly(parent, name, points, material) { const positions = []; for (const p of points) positions.push(p[0], p[1], p[2]); const indices = []; for (let i = 1; i < points.length - 1; i++) indices.push(0, i, i + 1); const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); return mesh(geometry, material, parent, name); } function slopedBox(parent, name, x, z, sx, sz, lowerAt, upperAt, material) { const x0 = x - sx / 2, x1 = x + sx / 2, z0 = z - sz / 2, z1 = z + sz / 2; const vertices = [ [x0, lowerAt(x0, z0), z0], [x1, lowerAt(x1, z0), z0], [x1, lowerAt(x1, z1), z1], [x0, lowerAt(x0, z1), z1], [x0, upperAt(x0, z0), z0], [x1, upperAt(x1, z0), z0], [x1, upperAt(x1, z1), z1], [x0, upperAt(x0, z1), z1] ]; const faces = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]; const positions = [], indices = []; for (const face of faces) { const base = positions.length / 3; for (const index of face) positions.push(...vertices[index]); for (let i = 1; i < face.length - 1; i++) indices.push(base, base + i, base + i + 1); } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); return mesh(geometry, material, parent, name); } function line(parent, name, points, color = 0x657361, opacity = 0.85) { const geometry = new THREE.BufferGeometry().setFromPoints(points.map(p => new THREE.Vector3(...p))); const material = new THREE.LineBasicMaterial({ color, transparent: opacity < 1, opacity }); const object = new THREE.Line(geometry, material); object.name = name; parent.add(object); return object; } function group(parent, name) { const g = new THREE.Group(); g.name = name; parent.add(g); return g; } // 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; const blend = smooth(0, 4.2, Math.hypot(dx, dz)); return -0.30 * (1 - blend) + rawGround(x, z) * blend; } function buildGround() { // 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 = 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; const z = leftZ * (1 - u) + rightZ * u; positions.push(x, groundHeight(x, z), z); if (i < nx && j < nz) { const a = j * (nx + 1) + i, b = a + 1, c = a + (nx + 1), d = c + 1; indices.push(a, b, c, b, d, c); } } } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); mesh(geometry, mats.grass, groundGroup, 'Terén parcely 66/17 podle výškopisu'); // 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', [[-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 = [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]; const post = mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.12, 6), mats.frame, boundaryGroup, `Orientační lom hranice ${i + 1}`); post.position.set(x, y + 0.05, z); post.castShadow = false; } buildParking(); } function buildParking() { const y = (x, z) => groundHeight(x, z) + 0.035; // 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(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]) { const wheel = mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.19, 12), mats.tire, car, `${label} kolo`); wheel.rotation.z = Math.PI / 2; wheel.position.set(x, 0.35, z); } box(car, `${label} přední sklo`, 0, 0.95, 1.12, 1.38, 0.43, 0.08, mats.glass, 0.18); } addCar(14.4, 1.45, mats.car1, 'Orientační automobil 1'); addCar(14.4, 3.97, mats.car2, 'Orientační automobil 2'); } // 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(); shape.moveTo(footprint[0][0], footprint[0][1]); footprint.slice(1).forEach(([x, z]) => shape.lineTo(x, z)); shape.closePath(); const geometry = new THREE.ShapeGeometry(shape); geometry.rotateX(Math.PI / 2); const slab = mesh(geometry, mats.foundation, houseGroup, 'Základová deska domu'); slab.position.y = -0.19; return slab; } 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 = 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); box(shellGroup, name, x, (bottom + top) / 2, z, to - from, top - bottom, thickness, mats.wall, angle); }; const ordered = [...openings].sort((a, b) => a.center - b.center); let cursor = 0; for (const opening of ordered) { const left = clamp(opening.center - opening.width / 2, 0.05, length - 0.05); const right = clamp(opening.center + opening.width / 2, 0.05, length - 0.05); if (left <= cursor || right <= left) continue; wallSegment(cursor, left, wallBottom, height); if (opening.bottom > wallBottom) wallSegment(left, right, wallBottom, opening.bottom, 'Parapet pod oknem'); const top = Math.min(height, opening.bottom + opening.height); wallSegment(left, right, top, height, 'Nadpraží otvoru'); const [x, z] = positionAt((left + right) / 2); const centerY = opening.bottom + (top - opening.bottom) / 2; const paneMaterial = opening.kind === 'door' ? mats.door : mats.glass; const paneName = opening.kind === 'door' ? 'Vstupní dveře – zjednodušené' : opening.kind === 'glazedDoor' ? 'Prosklené dveře – zjednodušené' : 'Okno – zjednodušené'; box(shellGroup, paneName, x, centerY, z, right - left - 0.10, Math.max(0.12, top - opening.bottom - 0.10), 0.055, paneMaterial, angle); const frameWidth = 0.055; const jambHeight = top - opening.bottom; for (const offset of [-1, 1]) { const [jx, jz] = positionAt((left + right) / 2 + offset * (right - left - frameWidth) / 2); box(shellGroup, 'Rám otvoru', jx, opening.bottom + jambHeight / 2, jz, frameWidth, jambHeight, 0.09, mats.frame, angle); } for (let pane = 1; pane < (opening.panes || 1); pane++) { const offset = (pane / opening.panes - 0.5) * (right - left); const mullionX = x + dx / length * offset; const mullionZ = z + dz / length * offset; box(shellGroup, 'Svislý rám okna', mullionX, opening.bottom + jambHeight / 2, mullionZ, frameWidth, jambHeight, 0.09, mats.frame, angle); } box(shellGroup, 'Horní rám otvoru', x, top - frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle); if (opening.bottom > 0) box(shellGroup, 'Parapet okna', x, opening.bottom + frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle); cursor = right; } wallSegment(cursor, length, wallBottom, height); } // 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 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, roofGroup, 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, roofGroup, '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(roofGroup, 'Č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(roofGroup, 'Oplechování komína', chimneyX, chimneyZ, 0.5, 0.5, roofAt, (x, z) => roofAt(x, z) + 0.06, mats.metal); slopedBox(roofGroup, 'Komín', chimneyX, chimneyZ, 0.36, 0.36, (x, z) => roofAt(x, z) + 0.04, () => 6.38, mats.foundation); box(roofGroup, '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(roofTimber, '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 = [ // 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]); buildRoof(); // 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]); // 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(); buildHouse(); 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(); } 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: [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.8, 90, -1], target: [0.8, 0.1, -1], label: 'Pozemek / shora' } }; function setPreset(name) { if (interior?.enabled) interior.exit(); const p = presets[name] || presets.entry; camera.position.fromArray(p.position); 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)); } 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 = `45°`; } 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 + ``; } 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); }); $('#sunClock').addEventListener('change', (event) => { const utc = timeForClock(event.target.value); if (utc === null) { event.target.value = clockText(solar.start + solar.minute * 60000); return; } setSolarPlaying(false); solar.minute = (utc - solar.start) / 60000; updateSolar(); }); $('#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', ''); 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 = `${$('#sunOverlay').innerHTML}`; 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(`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í · parcela 66/17, Klabava', 24 * scale, source.height + 46 * scale); out.toBlob(blob => { if (blob) saveBlob(blob, 'klabava-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); exporter.parse(modelRoot, (result) => { saveBlob(new Blob([result], { type: 'model/gltf-binary' }), interior?.enabled ? 'klabava-interier.glb' : 'klabava-pracovni-model.glb'); toast('Model Klabava GLB je připravený ke stažení.'); exporting = false; button.disabled = false; }, (error) => { 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 = ``; 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 += `${hour}:00`; } 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 += ``; } } 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({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.
${error.message}
Spusť web přes místní server a ověř přítomnost složky vendor/three.`; $('#webStatus').textContent = '3D není dostupné'; }