This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
import {buildHouse,buildSite} from './model.js?v=1850';
|
||||
import {furnish} from './furniture.js?v=1850';
|
||||
import { installInterior } from './interior-view.js?v=1905';
|
||||
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 ověř přítomnost složky vendor/three.</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');
|
||||
|
||||
const SITE_DEFAULT = { latitude:50.05091024, longitude:14.31001395, bearing:251.172 };
|
||||
const site = { ...SITE_DEFAULT };
|
||||
const solar = {
|
||||
date: '2026-06-21', minute: 600, playing: false, speed: 30, shadows: true, path: false,
|
||||
start: 0, end: 0, length: 1440, rise: null, set: null, noon: null, samples: [],
|
||||
position: null, direct: 0.8, dayFactor: 1, sky: [0.89, 0.91, 0.87], accumulator: 0, lastTick: 0
|
||||
};
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color('#e7ebe3');
|
||||
let camera = new THREE.PerspectiveCamera(36, 1, 0.1, 220);
|
||||
camera.position.set(28, 21, 36);
|
||||
|
||||
let renderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
canvas, antialias: true, alpha: false, preserveDrawingBuffer: true,
|
||||
powerPreference: 'high-performance'
|
||||
});
|
||||
} catch (error) {
|
||||
loading.classList.add('error');
|
||||
loading.innerHTML = `3D grafiku nelze spustit.<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, 2, 0);
|
||||
|
||||
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, 2, 0);
|
||||
scene.add(sunLight, sunTarget);
|
||||
sunLight.target = sunTarget;
|
||||
|
||||
const modelRoot = new THREE.Group();
|
||||
modelRoot.name = 'Kovářova – 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, 0); modelRoot.add(houseGroup);
|
||||
|
||||
const shellGroup = new THREE.Group(); shellGroup.name = 'Obvodové stěny a otvory'; houseGroup.add(shellGroup);
|
||||
const roofGroup = new THREE.Group(); roofGroup.name = 'Střecha, podbití a komín'; houseGroup.add(roofGroup);
|
||||
const roofTimber = new THREE.Group(); roofTimber.name = 'Trámy krytých ploch'; houseGroup.add(roofTimber);
|
||||
let interior = null;
|
||||
|
||||
function group(parent,name){const o=new THREE.Group();o.name=name;parent.add(o);return o;}
|
||||
function mesh(geometry,material,parent,name=''){const o=new THREE.Mesh(geometry,material);o.name=name;o.castShadow=true;o.receiveShadow=true;parent.add(o);return o;}
|
||||
function box(parent,name,x,y,z,sx,sy,sz,material,rotationY=0){const o=mesh(new THREE.BoxGeometry(sx,sy,sz),material,parent,name);o.position.set(x,y,z);o.rotation.y=rotationY;return o;}
|
||||
const planPoint=(x,y)=>[x-5.465,y-6.18];
|
||||
const siteData=await fetch('./data/site.json').then(r=>r.json());
|
||||
const context={THREE,houseGroup,modelRoot,shellGroup,roofGroup,roofTimber,groundGroup,boundaryGroup,parkingGroup,planPoint,box,mesh,group,siteData};
|
||||
const house=buildHouse(context);furnish(context,house);const terrain=buildSite(context,house);
|
||||
modelRoot.userData={project:'Kovářova 193/54 – stávající stav',source:'Dodané PDF vs-studio 2026, RÚIAN, ČÚZK DMR 5G',units:'metres',furnishing:'illustrative',terrainDatumBpv:372.45,terrainDatumStatus:'estimated, not survey-linked',rooms:house.levels.flatMap(f=>f.rooms.map(r=>({id:r.id,name:r.name,area:r.area,floor:r.level})))};
|
||||
$('#terrainDatum').addEventListener('change',e=>{const v=Number(e.target.value);if(Number.isFinite(v)&&v>=365&&v<=380){terrain.setDatum(v);modelRoot.userData.terrainDatumBpv=v;}});
|
||||
let exteriorPreset=null;
|
||||
function viewportSize() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return [Math.max(1, rect.width), Math.max(1, rect.height)];
|
||||
}
|
||||
function resize() {
|
||||
const [w, h] = viewportSize();
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(Math.round(w), Math.round(h), false);
|
||||
interior?.resize();
|
||||
if(exteriorPreset&&!interior?.enabled)positionPreset(exteriorPreset);
|
||||
}
|
||||
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: [25, 19, -30], target: [0, 2.4, 0], label: 'Dům / příjezd' },
|
||||
garden: { position: [-29, 18, 24], target: [0, 2.4, 0], label: 'Dům / zahrada' },
|
||||
top: { position: [-10, 70, 3], target: [-10, 0, 3], label: 'Pozemek / shora' }
|
||||
};
|
||||
function setPreset(name) {
|
||||
if (interior?.enabled) interior.exit();
|
||||
exteriorPreset=name;
|
||||
const p = presets[name] || presets.entry;
|
||||
positionPreset(name);
|
||||
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));
|
||||
}
|
||||
function positionPreset(name){
|
||||
const p=presets[name]||presets.entry,[w,h]=viewportSize(),factor=Math.max(1,.85/(w/h));
|
||||
const target=new THREE.Vector3(...p.target),offset=new THREE.Vector3(...p.position).sub(target).multiplyScalar(factor);
|
||||
camera.position.copy(target).add(offset);controls.target.copy(target);controls.update();
|
||||
}
|
||||
controls.addEventListener('start',()=>{if(!interior?.enabled)exteriorPreset=null;});
|
||||
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);
|
||||
});
|
||||
function applyClock(){
|
||||
const utc=timeForClock($('#sunClock').value);if(utc===null)return;
|
||||
setSolarPlaying(false);solar.minute=(utc-solar.start)/60000;updateSolar();
|
||||
}
|
||||
$('#sunClock').addEventListener('change',applyClock);
|
||||
$('#sunClock').addEventListener('blur',applyClock);
|
||||
$('#sunClock').addEventListener('input',()=>{clearTimeout(applyClock.timer);applyClock.timer=setTimeout(applyClock,250);});
|
||||
$('#sunClock').addEventListener('keydown',e=>{if(e.key==='Enter'){clearTimeout(applyClock.timer);applyClock();e.target.blur();}});
|
||||
$('#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(`Kovářova · ${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í · Kovářova 193/54, Praha – Stodůlky', 24 * scale, source.height + 46 * scale);
|
||||
out.toBlob(blob => { if (blob) saveBlob(blob, 'kovarova-slunce.png'); });
|
||||
}
|
||||
$('#sunSavePng').addEventListener('click', saveSunImage);
|
||||
$('#savePng').addEventListener('click', saveSunImage);
|
||||
|
||||
let exporting = false;
|
||||
function exportGlb() {
|
||||
if (exporting) return;
|
||||
exporting = true;
|
||||
const button = $('#saveGlb'); button.disabled = true;
|
||||
const exporter = new GLTFExporter();
|
||||
modelRoot.updateMatrixWorld(true);
|
||||
const basement=house.levels[0].root,wasVisible=basement.visible;
|
||||
if(!interior?.enabled)basement.visible=true;
|
||||
exporter.parse(modelRoot, (result) => {
|
||||
basement.visible=wasVisible;
|
||||
saveBlob(new Blob([result], { type: 'model/gltf-binary' }), interior?.enabled ? 'kovarova-interier.glb' : 'kovarova-pracovni-model.glb');
|
||||
toast('Model Kovářova GLB je připravený ke stažení.');
|
||||
exporting = false; button.disabled = false;
|
||||
}, (error) => { basement.visible=wasVisible; console.error(error); toast('Model se nepodařilo exportovat.'); exporting = false; button.disabled = false; }, { binary: true, onlyVisible: true, trs: false });
|
||||
}
|
||||
$('#saveGlb').addEventListener('click', exportGlb);
|
||||
|
||||
function drawSunPath() {
|
||||
const svg = $('#sunOverlay');
|
||||
const [width, height] = viewportSize();
|
||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
const dot = $('#compassSun');
|
||||
const azimuth = solar.position?.azimuth ?? 0;
|
||||
dot.setAttribute('cx', 42 + 26 * Math.sin(azimuth * RAD));
|
||||
dot.setAttribute('cy', 42 - 26 * Math.cos(azimuth * RAD));
|
||||
dot.style.display = solar.position?.elevation > 0 ? 'block' : 'none';
|
||||
if (!solar.path) { svg.innerHTML = ''; return; }
|
||||
modelRoot.updateMatrixWorld(true);
|
||||
const center = new THREE.Vector3(0, 1.2, houseGroup.position.z + 1).applyMatrix4(modelRoot.matrixWorld);
|
||||
const pointAt = (az, alt, distance = 15.5) => new THREE.Vector3(
|
||||
center.x - Math.sin(az * RAD) * Math.cos(alt * RAD) * distance,
|
||||
center.y + Math.sin(alt * RAD) * distance,
|
||||
center.z + Math.cos(az * RAD) * Math.cos(alt * RAD) * distance
|
||||
);
|
||||
let path = '', started = false;
|
||||
for (const sample of solar.samples) {
|
||||
if (sample.elevation < 0) { started = false; continue; }
|
||||
const v = pointAt(sample.azimuth, sample.elevation).project(camera);
|
||||
if (v.z < -1 || v.z > 1) { started = false; continue; }
|
||||
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
|
||||
path += `${started ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)} `;
|
||||
started = true;
|
||||
}
|
||||
let out = `<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);
|
||||
if (!interior?.walking) controls.update();
|
||||
interior?.frame(dt);
|
||||
const cameraHeading = Math.atan2(camera.position.x - controls.target.x, camera.position.z - controls.target.z) / RAD;
|
||||
// North is +z, so a camera on the north side looking south shows north at the bottom of the compass.
|
||||
$('#compassGroup').setAttribute('transform', `rotate(${cameraHeading + 180},42,42)`);
|
||||
renderer.render(scene, camera);
|
||||
drawSunPath();
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', event => {
|
||||
if (interior?.enabled) { interior.keydown(event); return; }
|
||||
if (event.key === 'Escape') { toggleInfo(false); toggleSun(false); }
|
||||
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'SUMMARY'].includes(event.target.tagName)) return;
|
||||
if (event.code === 'Space') { event.preventDefault(); setSolarPlaying(!solar.playing); }
|
||||
if (event.key.toLowerCase() === 's') toggleSun();
|
||||
if (event.key.toLowerCase() === 'i') interior?.enter();
|
||||
if (event.key === '1') setPreset('entry');
|
||||
if (event.key === '2') setPreset('garden');
|
||||
if (event.key === '3') setPreset('top');
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) setSolarPlaying(false); });
|
||||
|
||||
window.SUN_SIM = {
|
||||
position: solarPosition,
|
||||
getState: () => ({ date: solar.date, time: clockText(solar.start + solar.minute * 60000), utc: new Date(solar.start + solar.minute * 60000).toISOString(), lat: site.latitude, lon: site.longitude, bearing: site.bearing, azimuth: solar.position.azimuth, elevation: solar.position.elevation, rise: clockText(solar.rise), set: clockText(solar.set), noon: clockText(solar.noon), shadows: solar.shadows, playing: solar.playing }),
|
||||
setDate: date => { solar.date = date; rebuildSolarDay(true); },
|
||||
setTime: time => { solar.minute = (timeForClock(time) - solar.start) / 60000; updateSolar(); },
|
||||
pause: () => setSolarPlaying(false)
|
||||
};
|
||||
|
||||
try {
|
||||
interior = installInterior({house,...context,THREE,houseGroup,modelRoot,shellGroup,roofGroup,roofTimber,controls,scene,renderer,planPoint,box,mesh,group,groundGroup,parkingGroup,boundaryGroup,resize,saveBlob,exportGlb,solar,setSolarPlaying,toggleInfo,toggleSun,getCamera:()=>camera,setCamera:value=>{camera=value;}});
|
||||
rebuildSolarDay(false);
|
||||
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
|
||||
toggleSun(false); toggleInfo(false);
|
||||
loading.classList.add('done');
|
||||
setTimeout(() => { loading.style.display = 'none'; }, 300);
|
||||
requestAnimationFrame(animate);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
loading.classList.add('error');
|
||||
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a ověř přítomnost složky vendor/three.</small>`;
|
||||
$('#webStatus').textContent = '3D není dostupné';
|
||||
}
|
||||
Reference in New Issue
Block a user