continuous-integration/drone/push Build is passing
- 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.
893 lines
47 KiB
JavaScript
893 lines
47 KiB
JavaScript
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é';
|
||
}
|