Files
martinfencl.eu/klabava/js/klabava.js
T
martin.fencl 8d7f011188
continuous-integration/drone/push Build is passing
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.
2026-10-31 19:01:41 +01:00

893 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let THREE, OrbitControls, GLTFExporter, solarPosition;
try {
const modules = await Promise.all([
import('three'),
import('three/addons/controls/OrbitControls.js'),
import('three/addons/exporters/GLTFExporter.js'),
import('./solar.js')
]);
THREE = modules[0];
OrbitControls = modules[1].OrbitControls;
GLTFExporter = modules[2].GLTFExporter;
solarPosition = modules[3].solarPosition;
} catch (error) {
const loading = document.querySelector('#loading');
if (loading) {
loading.classList.add('error');
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a zkontroluj připojení k CDN Three.js.</small>`;
}
throw error;
}
const $ = (selector) => document.querySelector(selector);
const RAD = Math.PI / 180;
const DAY_MS = 86400000;
const mod = (x, n) => ((x % n) + n) % n;
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const mix = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
const czNum = (v, n = 1) => Number(v).toFixed(n).replace('.', ',');
const canvas = $('#view');
const loading = $('#loading');
const toastEl = $('#toast');
// 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');
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.<br>${error.message}<br><small>Zkontroluj podporu WebGL v prohlížeči.</small>`;
throw error;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.075;
controls.minDistance = 15;
controls.maxDistance = 95;
controls.minPolarAngle = 0.08;
controls.maxPolarAngle = 1.52;
controls.target.set(0, 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 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(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, 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(houseGroup, 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(houseGroup, '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(houseGroup, 'Svislý rám okna', mullionX, opening.bottom + jambHeight / 2, mullionZ, 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, 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, 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 = [
// 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() {
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.8, 90, -1], target: [0.8, 0.1, -1], 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 = `<defs><linearGradient id="solarFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d4b465" stop-opacity=".34"/><stop offset="1" stop-color="#d4b465" stop-opacity=".04"/></linearGradient></defs><line x1="8" y1="70" x2="296" y2="70" stroke="#dce2d3"/><line x1="8" y1="40.75" x2="296" y2="40.75" stroke="#e4e8dd" stroke-dasharray="3 4"/><text x="294" y="37" text-anchor="end" fill="#a1a891" font-size="8">45°</text><path d="${curve} L296,70 L8,70 Z" fill="url(#solarFill)"/><path d="${curve}" fill="none" stroke="#c29b4b" stroke-width="1.5"/>`;
}
function drawSolarGraph() {
if (!solar.position) return;
const x = 8 + 288 * solar.minute / solar.length;
const y = 70 - clamp(solar.position.elevation, 0, 90) * 0.65;
$('#sunGraph').innerHTML = graphBase + `<line x1="${x}" y1="10" x2="${x}" y2="75" stroke="#a97731" stroke-width="1" stroke-dasharray="2 3"/><circle cx="${x}" cy="${y}" r="4" fill="#bc8933" stroke="#fafbf7" stroke-width="2"/>`;
}
function updateSolar() {
solar.minute = clamp(solar.minute, 0, solar.length - 1);
const utc = solar.start + solar.minute * 60000;
solar.position = solarPosition(utc, site.latitude, site.longitude);
const { elevation, azimuth } = solar.position;
solar.dayFactor = smooth(-10, 4, elevation);
solar.direct = elevation > 0 ? (1.02 - 0.32 * Math.exp(-Math.max(elevation, 0) / 9)) * smooth(0, 1.2, elevation) : 0;
const daylight = solar.dayFactor;
solar.sky = mix([0.075, 0.112, 0.157], [0.906, 0.922, 0.890], daylight);
if (elevation > -7 && elevation < 9) solar.sky = mix(solar.sky, [0.89, 0.83, 0.74], 0.16 * Math.sin((elevation + 7) / 16 * Math.PI));
scene.background.setRGB(...solar.sky);
const az = azimuth * RAD, alt = elevation * RAD;
const distance = 65;
modelRoot.updateMatrixWorld(true);
sunTarget.position.set(0, 1.2, houseGroup.position.z + 1).applyMatrix4(modelRoot.matrixWorld);
sunLight.position.set(-Math.sin(az) * Math.cos(alt), Math.sin(alt), Math.cos(az) * Math.cos(alt)).multiplyScalar(distance).add(sunTarget.position);
sunLight.intensity = Math.max(0, solar.direct) * 2.55;
sunLight.color.setRGB(1, elevation < 12 ? 0.87 + elevation / 100 : 0.96, elevation < 12 ? 0.68 + elevation / 55 : 0.88);
sunLight.castShadow = solar.shadows && elevation > -0.5;
solar.lastTick = utc;
$('#sunClock').value = clockText(utc);
$('#sunTime').value = Math.floor(solar.minute);
$('#sunTime').setAttribute('aria-valuetext', clockText(utc));
$('#zoneLabel').textContent = zoneText(utc);
$('#sunElevation').textContent = `${czNum(elevation)}°`;
$('#sunAzimuth').textContent = `${czNum(azimuth)}°`;
const dirs = ['sever', 'severovýchod', 'východ', 'jihovýchod', 'jih', 'jihozápad', 'západ', 'severozápad'];
$('#sunDirection').textContent = `${dirs[Math.round(azimuth / 45) % 8]} · od severu`;
$('#dayState').textContent = elevation > 0 ? 'Slunce nad obzorem' : elevation > -0.833 ? 'Slunce na obzoru' : elevation > -6 ? 'Soumrak' : 'Noc · bez přímého slunce';
$('#sceneTime').textContent = `${czDate(solar.date)} · ${clockText(utc)} ${utcOffset(utc) === 120 ? 'SELČ' : 'SEČ'}`;
document.body.classList.toggle('night', elevation < -4);
document.body.style.backgroundColor = `rgb(${solar.sky.map(v => Math.round(v * 255)).join(',')})`;
drawSolarGraph();
}
function solarFrame(dt) {
if (!solar.playing) return;
solar.minute += dt * solar.speed;
if (solar.minute >= solar.length) solar.minute = 0;
solar.accumulator += dt;
if (solar.accumulator >= 0.075) { solar.accumulator = 0; updateSolar(); }
}
function setSolarPlaying(on) {
solar.playing = on;
$('#sunPlay').innerHTML = on ? 'Ⅱ Pozastavit' : '▶ Přehrát den';
$('#sunPlay').setAttribute('aria-pressed', String(on));
}
$('#sunDate').addEventListener('change', (event) => {
if (!event.target.value) return;
setSolarPlaying(false); solar.date = event.target.value; rebuildSolarDay(true);
});
$('#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', '<circle id="compassSun" cx="42" cy="16" r="3" fill="#c89439" stroke="#fff7d7" stroke-width="1"/>');
const sunGraph = $('#sunGraph');
function scrubGraph(event) {
const rect = sunGraph.getBoundingClientRect();
solar.minute = clamp(((event.clientX - rect.left) / rect.width * 304 - 8) / 288 * solar.length, 0, solar.length - 1);
setSolarPlaying(false); updateSolar();
}
sunGraph.addEventListener('pointerdown', event => { sunGraph.setPointerCapture(event.pointerId); scrubGraph(event); });
sunGraph.addEventListener('pointermove', event => { if (sunGraph.hasPointerCapture(event.pointerId)) scrubGraph(event); });
sunGraph.addEventListener('pointerup', event => { if (sunGraph.hasPointerCapture(event.pointerId)) sunGraph.releasePointerCapture(event.pointerId); });
async function saveSunImage() {
setSolarPlaying(false); updateSolar();
controls.update();
drawSunPath();
renderer.render(scene, camera);
const source = renderer.domElement;
const out = document.createElement('canvas');
const [viewWidth] = viewportSize();
const ratio = source.width / viewWidth;
const footer = Math.round(66 * ratio);
out.width = source.width; out.height = source.height + footer;
const ctx = out.getContext('2d');
ctx.fillStyle = '#f8f9f3'; ctx.fillRect(0, 0, out.width, out.height);
ctx.drawImage(source, 0, 0);
const scale = out.width / viewWidth;
if (solar.path && $('#sunOverlay').innerHTML) {
const svgText = `<svg xmlns="http://www.w3.org/2000/svg" width="${viewWidth}" height="${source.height / ratio}" viewBox="0 0 ${viewWidth} ${source.height / ratio}"><style>text{font:10px Arial;fill:#96712e;paint-order:stroke;stroke:#f3f4e9;stroke-width:3px;stroke-linejoin:round}</style>${$('#sunOverlay').innerHTML}</svg>`;
const url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
const overlay = new Image();
await new Promise((resolve, reject) => { overlay.onload = resolve; overlay.onerror = reject; overlay.src = url; });
ctx.drawImage(overlay, 0, 0, source.width, source.height);
URL.revokeObjectURL(url);
}
ctx.fillStyle = '#2d3c31'; ctx.font = `${12 * scale}px Arial`;
ctx.fillText(`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);
$('#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 = `<path d="${path}" fill="none" stroke="#b28b40" stroke-width="1.5" stroke-dasharray="5 5" opacity=".8"/>`;
for (const hour of [6, 9, 12, 15, 18, 21]) {
const utc = timeForClock(`${String(hour).padStart(2, '0')}:00`);
if (utc === null) continue;
const pos = solarPosition(utc, site.latitude, site.longitude);
if (pos.elevation < 0) continue;
const v = pointAt(pos.azimuth, pos.elevation).project(camera);
if (v.z < -1 || v.z > 1) continue;
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
out += `<circle cx="${x}" cy="${y}" r="2.5" fill="#bd984f"/><text x="${x + 6}" y="${y - 7}">${hour}:00</text>`;
}
if (solar.position?.elevation >= 0) {
const v = pointAt(solar.position.azimuth, solar.position.elevation).project(camera);
if (v.z >= -1 && v.z <= 1) {
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
out += `<circle cx="${x}" cy="${y}" r="18" fill="#e2bc5c" opacity=".13"/><circle cx="${x}" cy="${y}" r="6.5" fill="#e7bd59" stroke="#fff5cf" stroke-width="2"/>`;
}
}
svg.innerHTML = out;
}
function animate(now = 0) {
requestAnimationFrame(animate);
const dt = Math.min(0.1, Math.max(0, (now - (animate.last || now)) / 1000));
animate.last = now;
solarFrame(dt);
controls.update();
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 (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.<br>${error.message}<br><small>Spusť web přes místní server a zkontroluj připojení k CDN Three.js.</small>`;
$('#webStatus').textContent = '3D není dostupné';
}