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 zkontroluj připojení k CDN Three.js.`; } 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 = { latitude: 49.7536, longitude: 13.5394, bearing: 0 }; 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'); const 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'; scene.add(modelRoot); const groundGroup = new THREE.Group(); groundGroup.name = 'Terén a zahrada'; 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 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 }), 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 }), metal: new THREE.MeshStandardMaterial({ color: 0x545b58, roughness: 0.6, metalness: 0.18 }), trunk: new THREE.MeshStandardMaterial({ color: 0x735d46, roughness: 1 }), leaf: new THREE.MeshStandardMaterial({ color: 0x657c5c, roughness: 1 }), leaf2: new THREE.MeshStandardMaterial({ color: 0x839273, roughness: 1 }), 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 }), pavingLine: new THREE.MeshStandardMaterial({ color: 0xe4e0d2, roughness: 0.9 }) }; 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 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; } // 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; } 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.13 * (1 - blend) + rawGround(x, z) * blend; } function buildGround() { const nw = [-13, 20], ne = [13, 20], se = [13.5, -19.5], sw = [-12.5, -20]; const nx = 34, nz = 54; 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; 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, 'Interpolovaný terén parcely 66/17'); // 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]]; 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); // 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]); 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(roadY); buildGarden(); } function buildParking(roadY) { const y = (x, z) => groundHeight(x, z) + 0.035; const drivewayPts = [[-9.2, y(-9.2, 20.2), 20.2], [-5.3, y(-5.3, 20.2), 20.2], [-5.3, y(-5.3, 17.8), 17.8], [-9.2, y(-9.2, 17.8), 17.8]]; poly(parkingGroup, 'Příjezd na pozemek', drivewayPts, mats.gravel); const pad = [[-10.3, y(-10.3, 17.7), 17.7], [-3.7, y(-3.7, 17.7), 17.7], [-3.7, y(-3.7, 14.9), 14.9], [-10.3, y(-10.3, 14.9), 14.9]]; poly(parkingGroup, 'Orientační parkovací plocha', pad, mats.gravel); line(parkingGroup, 'Osa příjezdu', [[-7.25, roadY(20) + 0.03, 20], [-7.25, y(-7.25, 17.8) + 0.04, 17.8]], 0x8f958b, 0.65); function addCar(cx, cz, material, label) { const car = group(parkingGroup, label); car.position.set(cx, groundHeight(cx, cz) + 0.12, cz); 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(-8.15, 16.35, mats.car1, 'Orientační automobil 1'); addCar(-5.75, 16.35, mats.car2, 'Orientační automobil 2'); // A short walk connects the front door and the gravel parking area. const walkway = [[-0.9, y(-0.9, 16.5), 16.5], [0.55, y(0.55, 16.5), 16.5], [0.55, y(0.55, 14.7), 14.7], [-0.9, y(-0.9, 14.7), 14.7]]; poly(parkingGroup, 'Pěší vstup', walkway, mats.path); } function addTree(x, z, height, variant = 0) { const g = group(groundGroup, 'Orientační strom'); g.position.set(x, groundHeight(x, z), z); const trunkHeight = height * 0.42; const trunk = mesh(new THREE.CylinderGeometry(0.11, 0.17, trunkHeight, 7), mats.trunk, g, 'Kmen'); trunk.position.y = trunkHeight / 2; const material = variant % 2 ? mats.leaf2 : mats.leaf; for (let i = 0; i < 3; i++) { const radius = height * (0.27 - i * 0.04); const cone = mesh(new THREE.ConeGeometry(radius, height * 0.42, 8), material, g, 'Koruna'); cone.position.set(0, trunkHeight + height * (0.17 + i * 0.18), 0); } } function buildGarden() { const trees = [[-10.7, -3.7, 3.7], [10.7, -3.4, 3.4], [-10.1, -10.2, 3.6], [11.0, -11.3, 4.1], [-7.4, -16.9, 3.4], [3.5, -17.1, 3.8], [9.6, 4.3, 3.1]]; trees.forEach(([x, z, h], i) => addTree(x, z, h, i)); for (const [x, z] of [[-9.5, -6.5], [10.3, -6.4], [-5.9, -18.1], [6.1, -18.2]]) { const shrub = mesh(new THREE.SphereGeometry(0.65, 10, 8), mats.leaf2, groundGroup, 'Orientační keř'); shrub.scale.set(1.2, 0.8, 0.9); shrub.position.set(x, groundHeight(x, z) + 0.44, z); } } 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] ]; 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.22, height = 3.046; const positionAt = (distance) => [p1[0] + dx * distance / length, p1[1] + dz * distance / length]; 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(houseGroup, 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, 0, height); if (opening.bottom > 0) wallSegment(left, right, 0, 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; box(houseGroup, opening.kind === 'door' ? 'Vstupní dveře – zjednodušené' : 'Zasklení – zjednodušené', 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(houseGroup, 'Rám otvoru', jx, opening.bottom + jambHeight / 2, jz, frameWidth, jambHeight, 0.09, mats.frame, angle); } box(houseGroup, 'Horní rám otvoru', x, top - frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle); if (opening.bottom > 0) box(houseGroup, 'Parapet okna', x, opening.bottom + frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle); cursor = right; } wallSegment(cursor, length, 0, height); } function hipRoof(x0, x1, z0, z1, name, ridgeHeight = 5.832) { const overhang = 0.38, eave = 3.06; x0 -= overhang; x1 += overhang; z0 -= overhang; z1 += overhang; const xm = (x0 + x1) / 2, zm = (z0 + z1) / 2; const width = x1 - x0, depth = z1 - z0; const v = (x, y, z) => [x, y, z]; if (width >= depth) { const inset = depth / 2; const a = v(x0 + inset, ridgeHeight, zm), b = v(x1 - inset, ridgeHeight, zm); poly(houseGroup, `${name} – severní střešní rovina`, [v(x0, eave, z1), v(x1, eave, z1), b, a], mats.roof); poly(houseGroup, `${name} – jižní střešní rovina`, [v(x1, eave, z0), v(x0, eave, z0), a, b], mats.roof); poly(houseGroup, `${name} – západní valba`, [v(x0, eave, z1), v(x0, eave, z0), a], mats.roof); poly(houseGroup, `${name} – východní valba`, [v(x1, eave, z0), v(x1, eave, z1), b], mats.roof); box(houseGroup, `${name} – hřeben`, (a[0] + b[0]) / 2, ridgeHeight, zm, Math.max(0.18, b[0] - a[0]), 0.12, 0.12, mats.roof); } else { const inset = width / 2; const a = v(xm, ridgeHeight, z0 + inset), b = v(xm, ridgeHeight, z1 - inset); poly(houseGroup, `${name} – východní střešní rovina`, [v(x1, eave, z0), v(x1, eave, z1), b, a], mats.roof); poly(houseGroup, `${name} – západní střešní rovina`, [v(x0, eave, z1), v(x0, eave, z0), a, b], mats.roof); poly(houseGroup, `${name} – severní valba`, [v(x0, eave, z1), v(x1, eave, z1), b], mats.roof); poly(houseGroup, `${name} – jižní valba`, [v(x1, eave, z0), v(x0, eave, z0), a], mats.roof); box(houseGroup, `${name} – hřeben`, xm, ridgeHeight, (a[2] + b[2]) / 2, 0.12, 0.12, Math.max(0.18, b[2] - a[2]), mats.roof); } // Eaves and downpipes make the roof silhouette readable at presentation scale. box(houseGroup, `${name} – okap sever`, xm, eave - 0.04, z1, width, 0.12, 0.12, mats.frame); box(houseGroup, `${name} – okap jih`, xm, eave - 0.04, z0, width, 0.12, 0.12, mats.frame); box(houseGroup, `${name} – okap západ`, x0, eave - 0.04, zm, 0.12, 0.12, depth, mats.frame); box(houseGroup, `${name} – okap východ`, x1, eave - 0.04, zm, 0.12, 0.12, depth, mats.frame); } function buildHouse() { makeFootprintSlab(); const openings = [ [{ center: 2.0, width: 1.0, bottom: 0, height: 2.18, kind: 'door' }, { center: 6.2, width: 2.05, bottom: 0.85, height: 1.38 }], [{ center: 2.5, width: 1.35, bottom: 0.9, height: 1.25 }, { center: 6.5, width: 1.1, bottom: 0.9, height: 1.25 }], [{ center: 2.5, width: 1.65, bottom: 0.9, height: 1.35 }, { center: 7.4, width: 3.25, bottom: 0, height: 2.22, kind: 'door' }], [{ center: 1.7, width: 1.55, bottom: 0.85, height: 1.4 }], [{ center: 3.25, width: 3.4, bottom: 0, height: 2.2, kind: 'door' }], [{ center: 2.2, width: 1.4, bottom: 0.9, height: 1.2 }, { center: 8.4, width: 1.7, bottom: 0.9, height: 1.2 }], [{ center: 2.3, width: 1.45, bottom: 0.9, height: 1.25 }, { center: 5.4, width: 1.25, bottom: 0.9, height: 1.25 }], [] ]; for (let i = 0; i < footprint.length; i++) makeWallEdge(footprint[i], footprint[(i + 1) % footprint.length], openings[i]); hipRoof(-1.575, 8.7, -2.775, 6.525, 'Hlavní valbová střecha'); hipRoof(-8.7, -1.575, -6.525, 5.145, 'Střecha západního křídla'); // Chimney shown in the elevations, with a simple cap. box(houseGroup, 'Komín', 5.7, 5.78, 1.8, 0.64, 1.25, 0.64, mats.foundation); box(houseGroup, 'Hlavice komína', 5.7, 6.42, 1.8, 0.82, 0.12, 0.82, mats.frame); // Covered terrace is included in the site sheets; its posts and canopy are deliberately simple. const deck = group(modelRoot, 'Krytá terasa – schematicky'); box(deck, 'Podlaha terasy', -4.0, -0.02, 0.05, 5.1, 0.18, 3.15, mats.terrace); for (const [x, z] of [[-6.35, -1.38], [-1.65, -1.38], [-6.35, 1.48], [-1.65, 1.48]]) { const ground = groundHeight(x, z); box(deck, 'Sloupek kryté terasy', x, (2.92 + ground) / 2, z, 0.14, 2.92 - ground, 0.14, mats.wall); } box(deck, 'Stříška terasy', -4.0, 3.02, 0.05, 5.35, 0.16, 3.35, mats.terrace); box(deck, 'Schod z terasy', -4.0, -0.19, -1.7, 2.5, 0.18, 0.45, mats.path); } buildGround(); buildHouse(); function viewportSize() { let w = window.innerWidth, h = window.innerHeight; const infoOpen = document.body.classList.contains('info-open'); const sunOpen = document.body.classList.contains('sun-open'); if (w <= 850 && (infoOpen || sunOpen)) { const panelHeight = Math.min((infoOpen ? 0.72 : 0.68) * h, 570); h = Math.max(120, h - panelHeight - 16); } else if (sunOpen) { w = Math.max(240, w - 380); } else if (infoOpen) { w = Math.max(240, w - 390); } return [w, h]; } 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); } 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, 77, 8], target: [0, 0.1, 8], label: 'Pozemek / shora' } }; function setPreset(name) { 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', () => { site.latitude = 49.7536; site.longitude = 13.5394; site.bearing = 0; $('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing; modelRoot.rotation.y = 0; 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í · souřadnice obce 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); $('#saveGlb').addEventListener('click', () => { const exporter = new GLTFExporter(); exporter.parse(modelRoot, (result) => { saveBlob(new Blob([result], { type: 'model/gltf-binary' }), 'klabava-pracovni-model.glb'); toast('Model Klabava GLB je připravený ke stažení.'); }, (error) => { console.error(error); toast('Model se nepodařilo exportovat.'); }, { binary: true, onlyVisible: true, trs: false }); }); 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); 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)`); renderer.render(scene, camera); drawSunPath(); } window.addEventListener('keydown', event => { 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 === '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 { 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 zkontroluj připojení k CDN Three.js.`; $('#webStatus').textContent = '3D není dostupné'; }