992 lines
116 KiB
JavaScript
992 lines
116 KiB
JavaScript
import {solarPosition} from './solar.js';
|
|
import {parseGLB,encodeGLB} from './model-io.js';
|
|
import {createLegacyRenderer} from './legacy-renderer.js';
|
|
import {installProjectPanel} from './project-panel.js';
|
|
const resources=window.LOCENICE_RESOURCES,config=resources.config;
|
|
let backend=null,projectPanel=null;
|
|
'use strict';
|
|
const $ = s => document.querySelector(s), canvas = $('#view');
|
|
|
|
let originalBytes = resources.modelBytes, gltf = null, binary = null, gl = null, meshes = [], shadowProgram, program, shadowFbo, shadowTex;
|
|
let showTerrain = true, showMeasure = false, autoRotate = false, orthographic = false, dirty = true, lastTime = 0;
|
|
let target = [.8, 1.15, 0], theta = 2.25, phi = .30, distance = 35.0, aspect = 1, vp, lightVP, eye = [0, 0, 0];
|
|
let animation = null, pointerMap = new Map(), pinch = null, wheelTimer = 0;
|
|
const initialCamera = { theta: 2.25, phi: .30, distance: 35.0, target: [.8, 1.15, 0], ortho: false };
|
|
// Opening composition follows the supplied reference; house geometry is unchanged.
|
|
// Keep this separate from the existing Entry / Terrace / Top view presets.
|
|
const startupCamera = { theta: .63, phi: .30, distance: 41.35, target: [-2.8, 2.3, 0], ortho: false };
|
|
function applyStartupView() {
|
|
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
|
|
$('#elevation').value = '';
|
|
$('#panel').classList.remove('open');
|
|
$('#infoButton').classList.remove('active');
|
|
// Match the existing portrait resize rule, without fitting the entire long parcel.
|
|
setCamera({ ...startupCamera, distance: startupCamera.distance * Math.max(1, 1 / aspect) }, 'Voln\u00fd pohled', true);
|
|
autoRotate = true;
|
|
$('#rotateButton').classList.add('active');
|
|
dirty = true;
|
|
}
|
|
const originalColors = {};
|
|
const HARDIE_DEFAULT = 'oriskove-hneda';
|
|
const hardieTextures = [], hardieImages = [], hardieObjectURLs = [];
|
|
let hardieSelected = HARDIE_DEFAULT;
|
|
async function loadHardieTextures(){backend.images.forEach((im,i)=>hardieImages[i]=im);}
|
|
function hardieLinearRGB(hex) { return hexToRgb(hex).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4); }
|
|
function setHardieColor(id) {
|
|
const data = gltf?.extras?.hardiePlank;
|
|
const color = data?.palette.find(c => c.id === id || c.code === id || c.name === id);
|
|
if (!color) return false;
|
|
hardieSelected = color.id;
|
|
const boards = gltf.materials[data.boardMaterial], backing = gltf.materials[data.backingMaterial];
|
|
boards.name = data.product + ' - ' + color.name;
|
|
boards.pbrMetallicRoughness.baseColorFactor = [1, 1, 1, 1];
|
|
boards.pbrMetallicRoughness.baseColorTexture.index = color.texture;
|
|
boards.extras.color = color.name; boards.extras.productCode = color.code;
|
|
backing.pbrMetallicRoughness.baseColorFactor = [...hardieLinearRGB(color.hex).map(c => c * .72), 1];
|
|
data.selectedColor = { ...color };
|
|
$('#hardieColorName').textContent = color.name;
|
|
$('#hardieProductCode').textContent = color.code + (color.id === HARDIE_DEFAULT ? ' \u00b7 v\u00fdchoz\u00ed' : '');
|
|
const preview = $('#hardiePreview'); preview.style.backgroundColor = color.hex;
|
|
if (hardieImages[color.texture]) preview.style.backgroundImage = 'url("' + hardieImages[color.texture].src + '")';
|
|
preview.setAttribute('aria-label', data.product + ', ' + color.name + ' - orienta\u010dn\u00ed n\u00e1hled');
|
|
document.querySelectorAll('input[name="hardieColor"]').forEach(input => {
|
|
input.checked = input.value === color.id; input.closest('label').classList.toggle('selected', input.checked);
|
|
});
|
|
dirty = true; return true;
|
|
}
|
|
function focusHardieDetail(key) { if(interior.enabled)toggleInterior(false);
|
|
const portraitScale = Math.max(1, 1 / aspect);
|
|
const poses = {
|
|
entry: { theta: -.07, phi: .075, distance: 13.1 * portraitScale, target: [-4.05, 1.33, 3.90], ortho: false },
|
|
terrace: { theta: 2.43, phi: .10, distance: 12.5 * portraitScale, target: [5.30, 1.35, -2.20], ortho: false },
|
|
structure: { theta: Math.PI / 2, phi: 0, distance: 1.6 * portraitScale, target: [3.729, 1.32, -4.03], ortho: false }
|
|
};
|
|
if (!poses[key]) return;
|
|
$('#panel').classList.remove('open'); $('#infoButton').classList.remove('active');
|
|
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
|
|
$('#elevation').value = '';
|
|
setCamera(poses[key], key === 'entry' ? 'HardiePlank \u00b7 vstup' : key === 'terrace' ? 'HardiePlank \u00b7 terasa' : 'HardiePlank \u00b7 detail struktury');
|
|
}
|
|
function initHardiePalette() {
|
|
document.querySelectorAll('input[name="hardieColor"]').forEach(input => {
|
|
input.addEventListener('change', () => { if (input.checked) setHardieColor(input.value); });
|
|
});
|
|
document.querySelectorAll('[data-cladding-view]').forEach(button => button.onclick = () => focusHardieDetail(button.dataset.claddingView));
|
|
setHardieColor(HARDIE_DEFAULT);
|
|
window.HARDIE_PLANK = {
|
|
getState: () => ({ product: gltf.extras.hardiePlank.product, selected: { ...gltf.extras.hardiePlank.selectedColor }, defaultColor: HARDIE_DEFAULT,
|
|
palette: gltf.extras.hardiePlank.palette.map(c => ({ ...c })), boardNodes: [...gltf.extras.hardiePlank.boardNodes],
|
|
backingNodes: [...gltf.extras.hardiePlank.backingNodes], scope: 'facade_only', originalGeometryPreserved: true, textureIsIllustrative: true }),
|
|
setColor: id => setHardieColor(id), reset: () => setHardieColor(HARDIE_DEFAULT), detail: key => focusHardieDetail(key)
|
|
};
|
|
}
|
|
|
|
const vadd = (a, b) => a.map((x, i) => x + b[i]), vsub = (a, b) => a.map((x, i) => x - b[i]), scale = (a, s) => a.map(x => x * s), dot = (a, b) => a.reduce((r, x, i) => r + x * b[i], 0), norm = a => scale(a, 1 / Math.hypot(...a)), cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
function mul(a, b) { const c = new Float32Array(16); for (let j = 0; j < 4; j++)for (let i = 0; i < 4; i++)for (let k = 0; k < 4; k++)c[j * 4 + i] += a[k * 4 + i] * b[j * 4 + k]; return c; }
|
|
function lookAt(e, t, up = [0, 1, 0]) { const z = norm(vsub(e, t)), x = norm(cross(up, z)), y = cross(z, x); return new Float32Array([x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, e), -dot(y, e), -dot(z, e), 1]); }
|
|
function perspective(fovy, asp, n, f) { const q = 1 / Math.tan(fovy / 2); return new Float32Array([q / asp, 0, 0, 0, 0, q, 0, 0, 0, 0, (f + n) / (n - f), -1, 0, 0, 2 * f * n / (n - f), 0]); }
|
|
function ortho(l, r, b, t, n, f) { return new Float32Array([2 / (r - l), 0, 0, 0, 0, 2 / (t - b), 0, 0, 0, 0, -2 / (f - n), 0, -(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1]); }
|
|
function point(m, p) { const a = [...p, 1], o = [0, 0, 0, 0]; for (let i = 0; i < 4; i++)for (let j = 0; j < 4; j++)o[i] += m[j * 4 + i] * a[j]; return o; }
|
|
|
|
function getAccessor(i) { const a = gltf.accessors[i], v = gltf.bufferViews[a.bufferView]; return { a, v, offset: (v.byteOffset || 0) + (a.byteOffset || 0) }; }
|
|
function visible(m) { return standardsVisible(m) && interiorVisibility(m) && meshVisible(m); }
|
|
async function initGL(){
|
|
const requested=new URLSearchParams(location.search).get('renderer')||config.renderer;
|
|
let primaryError=null;
|
|
if(requested!=='legacy')try{
|
|
const task=(async()=>{const {createThreeRenderer}=await import('./three-renderer.js');return await createThreeRenderer({canvas,gltf,binary,bytes:originalBytes,config});})();
|
|
// Do not fall back after WebGL initialization begins: that would double-bind a canvas.
|
|
backend=await task;
|
|
}catch(error){primaryError=error;console.warn('Three.js unavailable, using local renderer:',error);}
|
|
if(!backend){if(requested==='three')throw primaryError||Error('Three.js unavailable');backend=await createLegacyRenderer({canvas,gltf,binary});}
|
|
gl=backend;meshes=backend.meshes;
|
|
gltf.materials.forEach((m,i)=>originalColors[i]=[...(m.pbrMetallicRoughness?.baseColorFactor||[.7,.7,.7,1])]);
|
|
window.MODEL_INFO={objects:meshes.length,triangles:meshes.reduce((n,m)=>n+m.count/3,0),loaded:true,backend:backend.backend};
|
|
document.getElementById('webStatus').textContent=backend.label+' / GLB';
|
|
resize();
|
|
}
|
|
function resize() { if (!gl) return; const r = canvas.getBoundingClientRect(), w = r.width, h = r.height, dpr = Math.min(devicePixelRatio || 1, 1.7); const newAspect = w / h; if (window.SUN_SIM && !interior.enabled) { distance *= Math.max(1, 1 / newAspect) / Math.max(1, 1 / aspect); animation = null; initialCamera.distance = Math.max(41, 38 / newAspect); presetDefs.entry.distance = Math.max(39, 38 / newAspect); presetDefs.top.distance = Math.max(46, 25 / newAspect); presetDefs.top.theta = newAspect < 1 ? Math.PI / 2 : 0; } viewW = w; viewH = h; backend.resize(w,h,Math.min(dpr,config.maxPixelRatio||1.5)); aspect = newAspect; if(interior.enabled && interior.mode !== 'walk' && !interior.userCamera)interiorFitCamera(); dirty = true; }
|
|
function camera() { if(interior.enabled && interior.mode === 'walk'){interiorCamera();return;} eye = vadd(target, [Math.sin(theta) * Math.cos(phi) * distance, Math.sin(phi) * distance, Math.cos(theta) * Math.cos(phi) * distance]); const view = lookAt(eye, target); const span = distance * .28; const projection = orthographic ? ortho(-span * aspect, span * aspect, -span, span, .1, 1000) : perspective(34 * Math.PI / 180, aspect, .1, 1000); vp = mul(projection, view); }
|
|
function render(){
|
|
if(!backend)return;
|
|
camera();
|
|
const lookTarget=interior.enabled&&interior.mode==='walk'?vadd(eye,[Math.sin(interior.yaw)*Math.cos(interior.pitch),Math.sin(interior.pitch),Math.cos(interior.yaw)*Math.cos(interior.pitch)]):target;
|
|
backend.draw({solar,interior,eye,vp,lightVP,lookTarget,orthographic:orthographic&&!(interior.enabled&&interior.mode==='walk'),visible});
|
|
solar.shadowDirty=false;
|
|
$('#compassGroup').setAttribute('transform',`rotate(${(theta-Math.PI/2)*180/Math.PI-solar.bearing},42,42)`);
|
|
if(showMeasure)drawMeasures();if(solar.pos)solarOverlay();drawInteriorLabels();
|
|
projectPanel?.drawSurvey(project,p=>point(vp,p),viewW,viewH);
|
|
dirty=false;
|
|
}
|
|
function project(p) { let q = point(vp, p); return [(q[0] / q[3] * .5 + .5) * viewW, (-q[1] / q[3] * .5 + .5) * viewH]; }
|
|
function drawMeasures() {
|
|
const dims = [{ a: [-7.67, .12, 5.54], b: [7.67, .12, 5.54], t: '15,340 m' }, { a: [-8.48, .12, -4.695], b: [-8.48, .12, 4.695], t: '9,390 m' }, { a: [7.9, 0, 0], b: [7.9, 5.137, 0], t: '5,137 m' }];
|
|
let h = ''; for (const d of dims) { let a = project(d.a), b = project(d.b), x = (a[0] + b[0]) / 2, y = (a[1] + b[1]) / 2 - 10; h += `<line x1="${a[0]}" y1="${a[1]}" x2="${b[0]}" y2="${b[1]}"/><circle cx="${a[0]}" cy="${a[1]}" r="2.5"/><circle cx="${b[0]}" cy="${b[1]}" r="2.5"/><text x="${x}" y="${y}" text-anchor="middle">${d.t}</text>`; } $('#measure').innerHTML = h;
|
|
}
|
|
function animate(time) { requestAnimationFrame(animate); const dt = Math.min(.05, (time - lastTime) / 1000 || .016); lastTime = time; solarFrame(dt); stepInterior(dt); if (animation) { const a = animation, t = Math.min(1, (time - a.start) / 650), k = t * t * (3 - 2 * t); theta = a.from.theta + (a.to.theta - a.from.theta) * k; phi = a.from.phi + (a.to.phi - a.from.phi) * k; distance = a.from.distance + (a.to.distance - a.from.distance) * k; target = a.from.target.map((v, i) => v + (a.to.target[i] - v) * k); dirty = true; if (t >= 1) { orthographic = a.to.ortho; animation = null; } } if (autoRotate && !pointerMap.size) { theta += dt * .11; dirty = true; } if (dirty && gl) render(); }
|
|
function setCamera(to, label, instant = false) { autoRotate = false; $('#rotateButton').classList.remove('active'); if (instant) { theta = to.theta; phi = to.phi; distance = to.distance; target = [...to.target]; orthographic = to.ortho; animation = null; } else { let tt = to.theta; while (tt - theta > Math.PI) tt -= 2 * Math.PI; while (tt - theta < -Math.PI) tt += 2 * Math.PI; animation = { start: performance.now(), from: { theta, phi, distance, target: [...target] }, to: { ...to, theta: tt } }; orthographic = to.ortho; } $('#directionText').textContent = label; dirty = true; }
|
|
const presetDefs = { entry: { theta: -.59, phi: .24, distance: 33.4, target: [-.25, 1.20, 0], ortho: false }, terrace: initialCamera, top: { theta: 0, phi: 1.5706, distance: 35, target: [1, .1, 0], ortho: true } };
|
|
const presetNames = { entry: 'Vstup / severoz\u00e1pad', terrace: 'Terasa / jihov\u00fdchod', top: 'Pohled shora' };
|
|
function setPreset(key, instant = false) { if(interior.enabled)toggleInterior(false); document.querySelectorAll('[data-preset]').forEach(b => b.classList.toggle('active', b.dataset.preset === key)); $('#elevation').value = ''; setCamera(presetDefs[key], presetNames[key], instant); }
|
|
window.setPreset = setPreset;
|
|
function pan(dx, dy) { const right = [Math.cos(theta), 0, -Math.sin(theta)]; const up = norm(cross(norm(vsub(eye, target)), right)); const k = distance * .0008; target = vadd(target, vadd(scale(right, -dx * k), scale(up, dy * k))); }
|
|
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
|
canvas.addEventListener('pointerdown', e => { if(interior.enabled && interior.mode==='walk'){e.preventDefault();canvas.setPointerCapture(e.pointerId);interior.lookPointer={id:e.pointerId,x:e.clientX,y:e.clientY};interior.keys.clear();return;} if(interior.enabled)interior.userCamera=true; canvas.setPointerCapture(e.pointerId); pointerMap.set(e.pointerId, { x: e.clientX, y: e.clientY, button: e.button }); animation = null; autoRotate = false; $('#rotateButton').classList.remove('active'); if (pointerMap.size === 2) { const p = [...pointerMap.values()]; pinch = { d: Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y), x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 }; } });
|
|
canvas.addEventListener('pointermove', e => { if(interior.enabled && interior.mode==='walk'){const p=interior.lookPointer;if(p && p.id===e.pointerId){interior.yaw-=(e.clientX-p.x)*.004;interior.pitch=clamp(interior.pitch-(e.clientY-p.y)*.003,-1.3,1.3);p.x=e.clientX;p.y=e.clientY;dirty=true;}return;} const p = pointerMap.get(e.pointerId); if (!p) return; let dx = e.clientX - p.x, dy = e.clientY - p.y; pointerMap.set(e.pointerId, { ...p, x: e.clientX, y: e.clientY }); if (pointerMap.size === 2) { const ps = [...pointerMap.values()], d = Math.hypot(ps[0].x - ps[1].x, ps[0].y - ps[1].y), x = (ps[0].x + ps[1].x) / 2, y = (ps[0].y + ps[1].y) / 2; if (pinch) { distance = Math.max(1.2, Math.min(500, distance * pinch.d / Math.max(1, d))); pan(x - pinch.x, y - pinch.y); } pinch = { d, x, y }; } else if (p.button === 2 || e.shiftKey) pan(dx, dy); else { theta -= dx * .006; phi = Math.max(.03, Math.min(1.51, phi + dy * .006)); orthographic = false; } document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active')); $('#directionText').textContent = 'Voln\u00fd pohled'; dirty = true; });
|
|
function pointerEnd(e) { if(interior.lookPointer?.id===e.pointerId)interior.lookPointer=null; pointerMap.delete(e.pointerId); pinch = null; }
|
|
canvas.addEventListener('pointerup', pointerEnd); canvas.addEventListener('pointercancel', pointerEnd);
|
|
canvas.addEventListener('wheel', e => { e.preventDefault(); if(interior.enabled && interior.mode==='walk'){interior.fov=clamp(interior.fov+e.deltaY*.025,45,88);dirty=true;return;} if(interior.enabled)interior.userCamera=true; distance = Math.max(1.2, Math.min(500, distance * Math.exp(e.deltaY * .001))); animation = null; dirty = true; }, { passive: false });
|
|
document.querySelectorAll('[data-preset]').forEach(b => b.onclick = () => setPreset(b.dataset.preset));
|
|
$('#resetView').onclick = () => setPreset('terrace');
|
|
$('#terrainButton').onclick = () => { showTerrain = !showTerrain; $('#terrainButton').classList.toggle('active', !showTerrain); updateSolar(); dirty = true; };
|
|
$('#measureButton').onclick = () => { showMeasure = !showMeasure; $('#measure').style.display = showMeasure ? 'block' : 'none'; $('#measureButton').classList.toggle('active', showMeasure); dirty = true; };
|
|
$('#rotateButton').onclick = () => { autoRotate = !autoRotate; $('#rotateButton').classList.toggle('active', autoRotate); orthographic = false; dirty = true; };
|
|
$('#infoButton').onclick = () => { $('#panel').classList.toggle('open'); $('#infoButton').classList.toggle('active'); };
|
|
$('#elevation').onchange = e => { const d = { north: [-Math.PI / 2, 'Severn\u00ed pohled'], east: [Math.PI, 'V\u00fdchodn\u00ed pohled'], south: [Math.PI / 2, 'Ji\u017en\u00ed pohled'], west: [0, 'Z\u00e1padn\u00ed pohled'] }[e.target.value]; if (!d) return; document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active')); setCamera({ theta: d[0], phi: 0, distance: aspect < .8 ? 70 : 29, target: [0, 1.8, 0], ortho: true }, d[1]); };
|
|
function hexToRgb(s) { return [1, 3, 5].map(i => parseInt(s.slice(i, i + 2), 16) / 255); }
|
|
function recolor(type, hex) { const c = hexToRgb(hex); gltf.materials.forEach((m, i) => { let ratio = 1, match = false; if (type === 'wall') match = m.name === 'stucco'; if (type === 'roof') { match = m.name.startsWith('tile'); ratio = m.name === 'tile_1' ? 1.06 : m.name === 'tile_2' ? .94 : m.name === 'tile_3' ? 1.10 : m.name === 'tile_4' ? .88 : m.name === 'tile_edge' ? .90 : 1; } if (match) m.pbrMetallicRoughness.baseColorFactor = [...c.map(v => Math.min(1, v * ratio)), 1]; }); dirty = true; }
|
|
$('#wallColor').oninput = e => recolor('wall', e.target.value); $('#roofColor').oninput = e => recolor('roof', e.target.value);
|
|
$('#resetColors').onclick = () => { for (const [i, c] of Object.entries(originalColors)) { if (!gltf.materials[+i].extras?.interiorMaterial) gltf.materials[+i].pbrMetallicRoughness.baseColorFactor = [...c]; } $('#wallColor').value = '#c9c2ad'; $('#roofColor').value = '#414542'; setHardieColor(HARDIE_DEFAULT); dirty = true; };
|
|
function toast(s) { $('#toast').textContent = s; $('#toast').style.opacity = 1; setTimeout(() => $('#toast').style.opacity = 0, 2600); }
|
|
function saveBlob(blob, name) { const a = document.createElement('a'), u = URL.createObjectURL(blob); a.href = u; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(u), 5000); }
|
|
function currentGlb() { if (!gltf) return originalBytes; const j = new TextEncoder().encode(JSON.stringify(exportVisibleGltf())), jl = Math.ceil(j.length / 4) * 4, bl = Math.ceil(binary.byteLength / 4) * 4, n = 12 + 8 + jl + 8 + bl, out = new Uint8Array(n), v = new DataView(out.buffer); v.setUint32(0, 0x46546c67, true); v.setUint32(4, 2, true); v.setUint32(8, n, true); v.setUint32(12, jl, true); v.setUint32(16, 0x4e4f534a, true); out.fill(32, 20, 20 + jl); out.set(j, 20); v.setUint32(20 + jl, bl, true); v.setUint32(24 + jl, 0x004e4942, true); out.set(binary, 28 + jl); return out; }
|
|
$('#saveGlb').onclick = () => { saveBlob(new Blob([currentGlb()], { type: 'model/gltf-binary' }), 'dum_3D_HardiePlank_' + hardieSelected + '.glb'); toast('Model GLB je p\u0159ipraven\u00fd ke sta\u017een\u00ed.'); };
|
|
$('#savePng').onclick = () => { render(); canvas.toBlob(b => saveBlob(b, 'dum_pohled.png'), 'image/png'); };
|
|
window.addEventListener('resize', resize);
|
|
window.addEventListener('keydown', e => { if(interior.enabled){handleInteriorKey(e);return;} if(e.code==='KeyI' && !/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)){toggleInterior(true);return;} if (e.key === 'Escape') { $('#panel').classList.remove('open'); $('#infoButton').classList.remove('active'); } if (/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return; if (e.key === '1') setPreset('entry'); if (e.key === '2') setPreset('terrace'); if (e.key === '3') setPreset('top'); if (e.key === 'r') setPreset('terrace'); });
|
|
async function start(){
|
|
try{
|
|
({gltf,binary}=parseGLB(originalBytes));
|
|
gltf.extras={...gltf.extras,...structuredClone(resources.scene)};
|
|
await initGL();await loadHardieTextures();initHardiePalette();initInterior();initSolar();
|
|
distance=Math.max(41,38/aspect);initialCamera.distance=distance;presetDefs.entry.distance=Math.max(39,38/aspect);presetDefs.top.distance=Math.max(46,25/aspect);
|
|
if(aspect<1)presetDefs.top.theta=Math.PI/2;initSiteControls();applyStartupView();initStandards();
|
|
projectPanel=installProjectPanel({resources,backend,getMode:()=>interior,project,focusSite:()=>{stdClose();if(interior.enabled)toggleInterior(false);focusSite('parcel');},invalidate:()=>{dirty=true;}});
|
|
autoRotate=!!config.startAutoRotate;$('#rotateButton').classList.toggle('active',autoRotate);
|
|
$('#loading').style.display='none';requestAnimationFrame(animate);
|
|
window.LOCENICE_APP={backend:backend.backend,exportSource:()=>encodeGLB(gltf,binary),getMeshes:()=>meshes.map(m=>({name:m.name,nodeIndex:m.nodeIndex,visible:visible(m),material:m.material,std:m.std})),render};
|
|
if('ontouchstart'in window)$('.help').innerHTML='Jednim prstem otacejte; dvema prsty priblizte a posunte.';
|
|
}catch(error){console.error(error);$('#loading').className='error';$('#loading').innerHTML='<div class="errorDetail"></div><a class="btn primary" href="./models/dum.glb" download>Stahnout zdrojovy GLB</a><a class="btn" href="?renderer=legacy">Zkusit lokalni renderer</a>';
|
|
$('#loading .errorDetail').textContent='Model se nepodarilo spustit: '+error.message;$('#webStatus').textContent='3D neni dostupne';}
|
|
}
|
|
|
|
// Solar geometry: Meeus / NOAA. UTC is the only time basis for the ephemeris.
|
|
// Model axes: +X = south gable, +Z = entrance (west) side, +Y = up.
|
|
const RAD = Math.PI / 180, DAY_MS = 86400000;
|
|
const solar = {
|
|
date: '2026-06-21', minute: 600, lat: 48.8242, lon: 14.5275, bearing: 351.5,
|
|
playing: false, speed: 30, shadows: true, path: false, start: 0, end: 0, length: 1440,
|
|
rise: null, set: null, noon: null, samples: [], pos: null, light: [1, 1, 1], sky: [.906, .922, .890],
|
|
sunColor: [1, 1, .95], ambient: [.40, .43, .46], direct: .85, dayFactor: 1, shadowDirty: true,
|
|
accumulator: 0, clock: 0
|
|
};
|
|
let viewW = innerWidth, viewH = innerHeight;
|
|
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) => { let t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
|
|
const mix3 = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
|
|
const czNum = (v, d = 1) => v.toFixed(d).replace('.', ',');
|
|
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 o = {}; for (const p of localFormatter.formatToParts(new Date(ms))) if (p.type !== 'literal') o[p.type] = +p.value; return o; }
|
|
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(date) { const [y, m, d] = date.split('-').map(Number), naive = Date.UTC(y, m - 1, d); let u = naive; for (let i = 0; i < 4; i++)u = naive - utcOffset(u) * 60000; return u; }
|
|
function isoDate(ms) { const p = localParts(ms); return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`; }
|
|
function clockText(ms) { if (ms === null || !Number.isFinite(ms)) return '\u2014'; 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 o = utcOffset(ms); return (o === 120 ? 'SEL\u010c' : o === 60 ? 'SE\u010c' : 'Praha') + ' \u00b7 UTC' + (o >= 0 ? '+' : '') + o / 60; }
|
|
|
|
function solarVector(azimuth, elevation) { const a = (azimuth - solar.bearing) * RAD, e = elevation * RAD; return [-Math.cos(a) * Math.cos(e), Math.sin(e), -Math.sin(a) * Math.cos(e)]; }
|
|
function timeForClock(text) {
|
|
const [h, m] = text.split(':').map(Number); if (!/^\d{1,2}:\d{2}$/.test(text) || !Number.isFinite(h) || !Number.isFinite(m) || h < 0 || h > 23 || m < 0 || m > 59) return null; text = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
|
const [y, mo, d] = solar.date.split('-').map(Number), 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(o => naive - o * 60000).filter(u => u >= solar.start && u < solar.end && clockText(u) === text).sort((a, b) => a - b);
|
|
if (candidates.length) return candidates[0];
|
|
// The missing spring-forward hour advances to the first valid following clock time.
|
|
for (let i = 0; i < solar.length; i++) { const u = solar.start + i * 60000, p = localParts(u); if (p.hour * 60 + p.minute >= h * 60 + m) return u; }
|
|
return solar.end - 60000;
|
|
}
|
|
function rootElevation(a, b, threshold) { let fa = solarPosition(a, solar.lat, solar.lon).elevation - threshold; for (let i = 0; i < 24; i++) { const m = (a + b) / 2, fm = solarPosition(m, solar.lat, solar.lon).elevation - threshold; if ((fa < 0) === (fm < 0)) { a = m; fa = fm; } else b = m; } return (a + b) / 2; }
|
|
function rebuildSolarDay(preserveClock = true) {
|
|
const clock = 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), next = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
|
|
solar.end = localMidnight(next); solar.length = Math.round((solar.end - solar.start) / 60000); solar.rise = null; solar.set = null; solar.samples = [];
|
|
let prev = null, max = { alt: -999, ms: solar.start };
|
|
for (let u = solar.start; u <= solar.end; u += 600000) {
|
|
const p = solarPosition(u, solar.lat, solar.lon); solar.samples.push({ ms: u, ...p }); if (p.elevation > max.alt) max = { alt: p.elevation, ms: u };
|
|
if (prev) { if (prev.elevation < -.833 && p.elevation >= -.833) solar.rise = rootElevation(prev.ms, u, -.833); if (prev.elevation >= -.833 && p.elevation < -.833) solar.set = rootElevation(prev.ms, u, -.833); } prev = { ms: u, ...p };
|
|
}
|
|
let lo = Math.max(solar.start, max.ms - 600000), hi = Math.min(solar.end, max.ms + 600000); for (let i = 0; i < 36; i++) { let a = lo + (hi - lo) / 3, b = hi - (hi - lo) / 3; if (solarPosition(a, solar.lat, solar.lon).elevation < solarPosition(b, solar.lat, solar.lon).elevation) lo = a; else hi = b; } solar.noon = (lo + hi) / 2;
|
|
solar.minute = clamp((timeForClock(clock) - 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(b => b.classList.toggle('active', solar.date.endsWith(b.dataset.season)));
|
|
const tickEl = $('.sun-ticks'); tickEl.style.position = 'relative'; tickEl.style.height = '12px';
|
|
[...tickEl.children].forEach((el, i) => { const mm = i === 4 ? solar.length : (timeForClock(el.textContent) - solar.start) / 60000; el.style.cssText = `position:absolute;left:${100 * mm / solar.length}%;transform:translateX(${i === 0 ? 0 : i === 4 ? -100 : -50}%)`; });
|
|
$('#solarMessage').textContent = solar.length === 1440 ? '' : `Tento den m\u00e1 ${solar.length / 60} hodin. \u010casov\u00e1 osa respektuje zm\u011bnu letn\u00edho \u010dasu.`;
|
|
drawSolarGraphBase(); updateSolar();
|
|
}
|
|
let graphBase = '';
|
|
function drawSolarGraphBase() {
|
|
const y = alt => 70 - clamp(alt, 0, 90) * .65;
|
|
const pts = solar.samples.map(p => `${8 + 288 * (p.ms - solar.start) / (solar.end - solar.start)},${y(p.elevation)}`);
|
|
const curve = 'M' + pts.join(' L'), max = solarPosition(solar.noon, solar.lat, solar.lon).elevation;
|
|
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\u00b0</text><path d="${curve} L296,70 L8,70 Z" fill="url(#solarFill)"/><path d="${curve}" fill="none" stroke="#c29b4b" stroke-width="1.5"/>`;
|
|
}
|
|
function updateSolarGraph() { const p = solar.pos, x = 8 + 288 * solar.minute / solar.length, y = 70 - clamp(p.elevation, 0, 90) * .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.clock = solar.start + solar.minute * 60000; solar.pos = solarPosition(solar.clock, solar.lat, solar.lon);
|
|
const a = solar.pos.elevation; solar.light = solarVector(solar.pos.azimuth, a);
|
|
solar.dayFactor = smooth(-10, 4, a); const daylight = solar.dayFactor;
|
|
const warm = 1 - smooth(0, 24, a);
|
|
solar.sunColor = mix3([1.07, 1.03, .95], [1.30, .78, .38], warm);
|
|
solar.direct = a > 0 ? (1.02 - .32 * Math.exp(-Math.max(a, 0) / 9)) * smooth(0, 1.2, a) : 0;
|
|
solar.ambient = mix3([.017, .027, .047], [.38, .415, .44], daylight);
|
|
solar.sky = mix3([.075, .112, .157], [.906, .922, .890], daylight);
|
|
if (a > -7 && a < 9) solar.sky = mix3(solar.sky, [.89, .83, .74], .16 * Math.sin((a + 7) / 16 * Math.PI));
|
|
const lt = [0, 1, 0], lp = vadd(lt, scale(solar.light, 70));
|
|
lightVP = mul(ortho(-22, 22, -21, 21, 1, 145), lookAt(lp, lt, Math.abs(solar.light[1]) > .999 ? [0, 0, 1] : [0, 1, 0]));
|
|
updateSiteLight();
|
|
solar.shadowDirty = true; dirty = true;
|
|
$('#sunClock').value = clockText(solar.clock); $('#sunTime').value = Math.floor(solar.minute); $('#sunTime').setAttribute('aria-valuetext', clockText(solar.clock));
|
|
$('#zoneLabel').textContent = zoneText(solar.clock);
|
|
$('#sunElevation').textContent = czNum(a) + '\u00b0'; $('#sunAzimuth').textContent = czNum(solar.pos.azimuth) + '\u00b0';
|
|
const dirs = ['sever', 'severov\u00fdchod', 'v\u00fdchod', 'jihov\u00fdchod', 'jih', 'jihoz\u00e1pad', 'z\u00e1pad', 'severoz\u00e1pad'];
|
|
$('#sunDirection').textContent = dirs[Math.round(solar.pos.azimuth / 45) % 8] + ' \u00b7 od severu';
|
|
$('#dayState').textContent = a > 0 ? 'Slunce nad obzorem' : a > -.833 ? 'Slunce na obzoru' : a > -6 ? 'Soumrak' : 'Noc \u00b7 bez p\u0159\u00edm\u00e9ho slunce';
|
|
$('#sceneTime').textContent = czDate(solar.date) + ' \u00b7 ' + clockText(solar.clock) + ' ' + (utcOffset(solar.clock) === 120 ? 'SEL\u010c' : 'SE\u010c');
|
|
$('#bearingNote').textContent = `Ji\u017en\u00ed \u0161t\u00edt: ${czNum(mod(solar.bearing + 180, 360))}\u00b0 \u00b7 vstupn\u00ed fas\u00e1da: ${czNum(mod(solar.bearing + 270, 360))}\u00b0.`;
|
|
document.body.classList.toggle('night', a < -4); document.body.style.backgroundColor = `rgb(${solar.sky.map(v => Math.round(v * 255)).join(',')})`;
|
|
updateSolarGraph();
|
|
}
|
|
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 >= .075) { solar.accumulator = 0; updateSolar(); } }
|
|
function setSolarPlaying(on) { solar.playing = on; $('#sunPlay').innerHTML = on ? '❚❚ Pozastavit' : '▶ P\u0159ehr\u00e1t den'; $('#sunPlay').setAttribute('aria-pressed', String(on)); }
|
|
function toggleSunPanel(on) { const yes = on === undefined ? !$('#sunPanel').classList.contains('open') : on; $('#sunPanel').classList.toggle('open', yes); document.body.classList.toggle('sun-open', yes); $('#sunToggle').classList.toggle('active', yes); $('#sunToggle').setAttribute('aria-expanded', String(yes)); if (yes) { $('#panel').classList.remove('open'); $('#infoButton').classList.remove('active'); } resize(); }
|
|
function solarOverlay() {
|
|
const svg = $('#sunOverlay'); svg.setAttribute('viewBox', `0 0 ${viewW} ${viewH}`);
|
|
const compassSun = $('#compassSun'); if (compassSun) { let a = solar.pos.azimuth * RAD; compassSun.setAttribute('cx', 42 + 26 * Math.sin(a)); compassSun.setAttribute('cy', 42 - 26 * Math.cos(a)); compassSun.style.display = solar.pos.elevation > 0 ? 'block' : 'none'; }
|
|
if (!solar.path) { svg.innerHTML = ''; return; }
|
|
const center = [0, .8, 0], radius = 12.8;
|
|
const pathPoint = (az, al) => vadd(center, scale(solarVector(az, al), radius));
|
|
let d = '', started = false; for (const p of solar.samples) { if (p.elevation < 0) { started = false; continue; } const v = pathPoint(p.azimuth, p.elevation), q = point(vp, v); if (q[3] <= 0) { started = false; continue; } const a = project(v); d += (started ? 'L' : 'M') + a.map(x => x.toFixed(1)).join(','); started = true; }
|
|
let out = `<path d="${d}" fill="none" stroke="#b28b40" stroke-width="1.5" stroke-dasharray="5 5" opacity=".7"/>`;
|
|
for (let hour = 6; hour <= 21; hour += 3) { const ms = timeForClock(String(hour).padStart(2, '0') + ':00'), p = solarPosition(ms, solar.lat, solar.lon); if (p.elevation < 0) continue; const v = pathPoint(p.azimuth, p.elevation); if (point(vp, v)[3] <= 0) continue; const q = project(v); out += `<circle cx="${q[0]}" cy="${q[1]}" r="2.5" fill="#bd984f"/><text x="${q[0] + 6}" y="${q[1] - 7}">${hour}:00</text>`; }
|
|
if (solar.pos.elevation >= 0) { const v = pathPoint(solar.pos.azimuth, solar.pos.elevation), q = project(v); if (point(vp, v)[3] > 0) { const base = project([v[0], center[1], v[2]]); out += `<line x1="${q[0]}" y1="${q[1]}" x2="${base[0]}" y2="${base[1]}" stroke="#c09a4f" stroke-dasharray="3 5" opacity=".45"/><circle cx="${q[0]}" cy="${q[1]}" r="18" fill="#e2bc5c" opacity=".13"/><circle cx="${q[0]}" cy="${q[1]}" r="11" fill="#e2bc5c" opacity=".25"/><circle cx="${q[0]}" cy="${q[1]}" r="6.5" fill="#e7bd59" stroke="#fff5cf" stroke-width="2"/>`; } }
|
|
svg.innerHTML = out;
|
|
}
|
|
async function saveSolarImage() {
|
|
setSolarPlaying(false); updateSolar(); render();
|
|
const out = document.createElement('canvas'), ratio = canvas.width / viewW, footer = Math.round(77 * ratio); out.width = canvas.width; out.height = canvas.height + footer; const ctx = out.getContext('2d'); ctx.drawImage(canvas, 0, 0);
|
|
if (solar.path) { try { const xml = `<svg xmlns="http://www.w3.org/2000/svg" width="${viewW}" height="${viewH}" viewBox="0 0 ${viewW} ${viewH}"><style>text{font:10px Arial;fill:#96712e;paint-order:stroke;stroke:#f3f4e9;stroke-width:3px;stroke-linejoin:round}</style>${$('#sunOverlay').innerHTML}</svg>`, u = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml' })); const im = new Image(); await new Promise((resolve, reject) => { im.onload = resolve; im.onerror = reject; im.src = u; }); ctx.drawImage(im, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(u); } catch (e) { console.warn('Solar overlay export', e); } }
|
|
ctx.fillStyle = '#fafbf6'; ctx.fillRect(0, canvas.height, canvas.width, footer); ctx.fillStyle = '#3e5138'; ctx.font = `${Math.round(12 * ratio)}px Arial`;
|
|
let primary = `Lo\u010denice | ${czDate(solar.date)} | ${clockText(solar.clock)} ${zoneText(solar.clock)} | v\u00fd\u0161ka ${czNum(solar.pos.elevation)}\u00b0 | azimut ${czNum(solar.pos.azimuth)}\u00b0`;
|
|
let secondary = `Terén: C.3 + interpolace výškopisu. Bez okolní zástavby, stromů a místního horizontu.`;
|
|
const max = canvas.width - 32 * ratio; while (ctx.measureText(primary).width > max) { const px = parseFloat(ctx.font); if (px <= 8 * ratio) break; ctx.font = `${px - 1}px Arial`; }
|
|
ctx.fillText(primary, 16 * ratio, canvas.height + 26 * ratio); ctx.font = `${Math.round(10 * ratio)}px Arial`; ctx.fillStyle = '#7d8976';
|
|
if (ctx.measureText(secondary).width > max) secondary = 'Orienta\u010dn\u00ed studie. Bez okoln\u00ed z\u00e1stavby a strom\u016f.';
|
|
ctx.fillText(secondary, 16 * ratio, canvas.height + 47 * ratio); ctx.fillText(`GPS ${solar.lat.toFixed(4)}, ${solar.lon.toFixed(4)} | severn\u00ed \u0161t\u00edt ${czNum(solar.bearing)}\u00b0`, 16 * ratio, canvas.height + 64 * ratio);
|
|
out.toBlob(b => { if (b) saveBlob(b, `dum_slunce_${solar.date}_${clockText(solar.clock).replace(':', '-')}.png`); }, 'image/png');
|
|
}
|
|
function initSolar() {
|
|
$('#sunDate').onchange = e => { if (!e.target.value || !e.target.checkValidity()) { toast('Zadej datum v letech 2000 a\u017e 2100.'); e.target.value = solar.date; return; } setSolarPlaying(false); solar.date = e.target.value; rebuildSolarDay(); };
|
|
$('#sunClock').onchange = e => { if (!e.target.value) return; setSolarPlaying(false); const u = timeForClock(e.target.value); if (u === null) { toast('Zadej \u010das HH:MM, nap\u0159. 09:30.'); e.target.value = clockText(solar.clock); return; } solar.minute = (u - solar.start) / 60000; updateSolar(); };
|
|
$('#sunTime').oninput = e => { setSolarPlaying(false); solar.minute = +e.target.value; updateSolar(); };
|
|
$('#sunPlay').onclick = () => setSolarPlaying(!solar.playing); $('#sunSpeed').onchange = e => solar.speed = +e.target.value;
|
|
$('#sunNow').onclick = () => { const now = Date.now(); setSolarPlaying(false); solar.date = isoDate(now); rebuildSolarDay(false); solar.minute = clamp((now - solar.start) / 60000, 0, solar.length - 1); updateSolar(); };
|
|
document.querySelectorAll('[data-season]').forEach(b => b.onclick = () => { setSolarPlaying(false); solar.date = solar.date.slice(0, 4) + '-' + b.dataset.season; rebuildSolarDay(); });
|
|
$('#sunShadows').onchange = e => { solar.shadows = e.target.checked; solar.shadowDirty = true; dirty = true; };
|
|
$('#sunPath').onchange = e => { solar.path = e.target.checked; dirty = true; $('#pathHint').textContent = solar.path ? 'Dr\u00e1ha je symbolick\u00e1, v mal\u00e9m m\u011b\u0159\u00edtku. Pro celou dr\u00e1hu oddal pohled kole\u010dkem nebo dv\u011bma prsty.' : 'Posouvej \u010das a sleduj, kam dopad\u00e1 slunce na terasu a fas\u00e1dy. Nato\u010den\u00ed pohledu polohu slunce nem\u011bn\u00ed.'; };
|
|
for (const [id, key] of [['sunLat', 'lat'], ['sunLon', 'lon'], ['houseBearing', 'bearing']]) $('#' + id).onchange = e => { const val = +e.target.value; if (!e.target.value || !Number.isFinite(val) || !e.target.checkValidity()) { toast('Zadej hodnotu v povolen\u00e9m rozsahu.'); e.target.value = solar[key]; return; } solar[key] = val; rebuildSolarDay(); };
|
|
$('#sunResetSite').onclick = () => { solar.lat = 48.8242; solar.lon = 14.5275; solar.bearing = 351.5; $('#sunLat').value = solar.lat; $('#sunLon').value = solar.lon; $('#houseBearing').value = solar.bearing; rebuildSolarDay(); };
|
|
$('#sunToggle').onclick = () => toggleSunPanel(); $('#sunClose').onclick = () => toggleSunPanel(false); $('#sunSavePng').onclick = saveSolarImage; $('#savePng').onclick = saveSolarImage;
|
|
$('#infoButton').onclick = () => { const open = !$('#panel').classList.contains('open'); $('#panel').classList.toggle('open', open); $('#infoButton').classList.toggle('active', open); if (open) toggleSunPanel(false); };
|
|
const graph = $('#sunGraph'); function scrubGraph(e) { const r = graph.getBoundingClientRect(); solar.minute = clamp(((e.clientX - r.left) / r.width * 304 - 8) / 288 * solar.length, 0, solar.length - 1); setSolarPlaying(false); updateSolar(); }
|
|
graph.addEventListener('pointerdown', e => { graph.setPointerCapture(e.pointerId); scrubGraph(e); }); graph.addEventListener('pointermove', e => { if (graph.hasPointerCapture(e.pointerId)) scrubGraph(e); }); graph.addEventListener('pointerup', e => { if (graph.hasPointerCapture(e.pointerId)) graph.releasePointerCapture(e.pointerId); });
|
|
window.addEventListener('keydown', e => { if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'SUMMARY'].includes(e.target.tagName)) return; if (e.code === 'Space') { e.preventDefault(); setSolarPlaying(!solar.playing); } if (e.key.toLowerCase() === 's') toggleSunPanel(); });
|
|
document.addEventListener('visibilitychange', () => { if (document.hidden) setSolarPlaying(false); });
|
|
const cp = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); cp.id = 'compassSun'; cp.setAttribute('r', '3'); cp.setAttribute('fill', '#c89439'); cp.setAttribute('stroke', '#fff7d7'); cp.setAttribute('stroke-width', '1'); $('#compassGroup').appendChild(cp);
|
|
toggleSunPanel(false); rebuildSolarDay(false);
|
|
// Read-only diagnostics and small public control API for verification and reuse.
|
|
window.SUN_SIM = { position: solarPosition, getState: () => ({ date: solar.date, time: clockText(solar.clock), utc: new Date(solar.clock).toISOString(), dayMinutes: solar.length, bearing: solar.bearing, lat: solar.lat, lon: solar.lon, azimuth: solar.pos.azimuth, elevation: solar.pos.elevation, light: solar.light.slice(), rise: clockText(solar.rise), set: clockText(solar.set), noon: clockText(solar.noon), shadows: solar.shadows, playing: solar.playing }), setDate: d => { solar.date = d; rebuildSolarDay(); }, setTime: t => { solar.minute = (timeForClock(t) - solar.start) / 60000; updateSolar(); }, setBearing: b => { solar.bearing = mod(b, 360); $('#houseBearing').value = solar.bearing; updateSolar(); }, pause: () => setSolarPlaying(false) };
|
|
}
|
|
|
|
// Independent site layers: initSiteControls enables all three on each fresh open.
|
|
const site = { road: false, parcel: false, parking: false, savedCamera: null, lastFocus: null };
|
|
const siteActive = () => site.road || site.parcel || site.parking;
|
|
function meshVisible(m) {
|
|
if (m.legacySite) return false;
|
|
switch (m.siteLayer) {
|
|
case 'local': return showTerrain;
|
|
case 'north': return showTerrain && siteActive();
|
|
case 'parcel': case 'boundary': return showTerrain && site.parcel;
|
|
case 'road': return site.road;
|
|
case 'parking': return site.parking;
|
|
default: return showTerrain || !m.context;
|
|
}
|
|
}
|
|
function visibleBounds() {
|
|
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
for (const m of meshes) if (visible(m) && m.bounds) {
|
|
for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], m.bounds.min[i]); hi[i] = Math.max(hi[i], m.bounds.max[i]); }
|
|
}
|
|
return { lo, hi };
|
|
}
|
|
function boxCorners(lo, hi) {
|
|
const out = []; for (let k = 0; k < 8; k++) out.push([0, 1, 2].map(i => k & (1 << i) ? hi[i] : lo[i])); return out;
|
|
}
|
|
function fittedSiteCamera(mode) {
|
|
let bounds = visibleBounds();
|
|
if (mode === 'parking') bounds = { lo: [-22.2, -.8, -8.0], hi: [-6.3, 5.8, 7.1] };
|
|
const mobile = aspect < 1;
|
|
const th = mode === 'parcel' ? (mobile ? Math.PI / 2 + .12 : .24) : (mobile ? -.94 : -.44);
|
|
const ph = mode === 'parcel' ? (mobile ? 1.02 : .88) : .72;
|
|
const center = bounds.lo.map((v, i) => (v + bounds.hi[i]) / 2);
|
|
const forward = [Math.sin(th) * Math.cos(ph), Math.sin(ph), Math.cos(th) * Math.cos(ph)];
|
|
const right = [Math.cos(th), 0, -Math.sin(th)], up = cross(forward, right);
|
|
const tangent = Math.tan(17 * Math.PI / 180);
|
|
const usableY = mobile ? .58 : .69, usableX = .82;
|
|
let dist = 16;
|
|
for (const p of boxCorners(bounds.lo, bounds.hi)) {
|
|
const d = vsub(p, center), depth = dot(d, forward);
|
|
dist = Math.max(dist, depth + Math.abs(dot(d, right)) / (tangent * aspect * usableX), depth + Math.abs(dot(d, up)) / (tangent * usableY));
|
|
}
|
|
return { theta: th, phi: ph, target: center, distance: dist + 2, ortho: false };
|
|
}
|
|
function focusSite(mode, instant = false) {
|
|
if (!siteActive()) { setPreset('terrace', instant); return; }
|
|
mode = mode || (site.parcel ? 'parcel' : site.road ? 'road' : 'parking');
|
|
site.lastFocus = mode;
|
|
document.querySelectorAll('[data-preset]').forEach(b => b.classList.remove('active'));
|
|
$('#elevation').value = '';
|
|
const label = { road: 'Cesta a přístup k domu', parcel: 'Celý pozemek', parking: 'Parkování u příjezdu' }[mode];
|
|
setCamera(fittedSiteCamera(mode), label, instant);
|
|
}
|
|
function setSiteLayer(key, on, focus = true) {
|
|
if (!['road', 'parcel', 'parking'].includes(key)) return;
|
|
const was = siteActive();
|
|
if (!was && on) site.savedCamera = { theta, phi, distance, target: [...target], ortho: orthographic, label: $('#directionText').textContent };
|
|
site[key] = !!on;
|
|
const ids = { road: 'siteRoad', parcel: 'siteParcel', parking: 'siteParking' };
|
|
const input = $('#' + ids[key]); input.checked = site[key];
|
|
input.closest('label').classList.toggle('selected', site[key]);
|
|
document.body.classList.toggle('site-active', siteActive());
|
|
$('#siteMeta').hidden = !siteActive();
|
|
$('#siteMeta').textContent = 'C.3 + výškopis · mezi body interpolováno';
|
|
updateSolar();
|
|
if (focus) {
|
|
if (siteActive()) focusSite(on ? key : null);
|
|
else if (site.savedCamera) {
|
|
setCamera(site.savedCamera, site.savedCamera.label || 'Dům');
|
|
site.savedCamera = null; site.lastFocus = null;
|
|
}
|
|
}
|
|
dirty = true;
|
|
}
|
|
function updateSiteLight() {
|
|
if(interior.enabled){
|
|
const center=[1,2,0],lightEye=vadd(center,scale(solar.light,45)),view=lookAt(lightEye,center,Math.abs(solar.light[1])>.999?[0,0,1]:[0,1,0]);
|
|
const cs=boxCorners([-8,-.6,-5],[10,5.9,5]).map(p=>point(view,p)),xs=cs.map(p=>p[0]),ys=cs.map(p=>p[1]);
|
|
lightVP=mul(ortho(Math.min(...xs)-2,Math.max(...xs)+2,Math.min(...ys)-2,Math.max(...ys)+2,1,100),view);return;
|
|
}
|
|
if (!siteActive()) return;
|
|
const { lo, hi } = visibleBounds(), center = lo.map((v, i) => (v + hi[i]) / 2);
|
|
const lightEye = vadd(center, scale(solar.light, 115));
|
|
const view = lookAt(lightEye, center, Math.abs(solar.light[1]) > .999 ? [0, 0, 1] : [0, 1, 0]);
|
|
const cs = boxCorners(lo, hi).map(p => point(view, p));
|
|
const xs = cs.map(p => p[0]), ys = cs.map(p => p[1]);
|
|
lightVP = mul(ortho(Math.min(...xs) - 3, Math.max(...xs) + 3, Math.min(...ys) - 3, Math.max(...ys) + 3, 1, 240), view);
|
|
}
|
|
function exportVisibleGltf(){
|
|
const selected=new Set(meshes.filter(visible).map(m=>m.nodeIndex));
|
|
const keep=new Set();function retain(i){const n=gltf.nodes[i];const children=(n.children||[]).filter(retain);const yes=selected.has(i)||children.length>0;if(yes)keep.add(i);return yes;}
|
|
for(const sc of gltf.scenes||[])for(const i of sc.nodes||[])retain(i);
|
|
const nodes=gltf.nodes.map((n,i)=>({...n,...(n.children?{children:n.children.filter(c=>keep.has(c))}:{})}));
|
|
const scenes=(gltf.scenes||[]).map(sc=>({...sc,nodes:(sc.nodes||[]).filter(i=>keep.has(i))}));
|
|
return {...gltf,nodes,scenes,extras:{...gltf.extras,visibleSiteLayers:{road:site.road,parcel:site.parcel,parking:site.parking,terrain:showTerrain},allstavSelection:stdClone(stdState),interiorView:{enabled:interior.enabled,mode:interior.mode}}};
|
|
}
|
|
|
|
function initSiteControls() {
|
|
for (const [id, key] of [['siteRoad', 'road'], ['siteParcel', 'parcel'], ['siteParking', 'parking']]) {
|
|
const input = $('#' + id); input.onchange = () => setSiteLayer(key, input.checked);
|
|
setSiteLayer(key, true, false);
|
|
}
|
|
$('#siteFit').onclick = () => focusSite();
|
|
window.SITE_SIM = {
|
|
getState: () => ({
|
|
road: site.road, parcel: site.parcel, parking: site.parking, terrain: showTerrain,
|
|
visibleObjects: meshes.filter(visible).length, referenceElevation: 591.840,
|
|
projectArea: 1010, digitizedArea: 987.89, houseGeometryUnchanged: true
|
|
}),
|
|
setLayer: (key, on, focus = true) => setSiteLayer(key, on, focus),
|
|
focus: mode => focusSite(mode),
|
|
reset: () => { for (const key of ['road', 'parcel', 'parking']) setSiteLayer(key, false, false); setPreset('terrace'); },
|
|
getCamera: () => ({ theta, phi, distance, target: [...target], aspect }),
|
|
checkGround: () => gltf.extras.siteStudy.finishedGroundC3
|
|
};
|
|
}
|
|
|
|
// Optional additive interior, independent of the retained exterior and solar state.
|
|
const interior = {
|
|
enabled: false, mode: 'axon', furniture: true, labels: true, fill: true,
|
|
position: [3.78, 1.62, .90], yaw: 0, pitch: 0, fov: 64,
|
|
saved: null, data: null, lookPointer: null, keys: new Set(), pad: new Set(),
|
|
panel: true, userCamera: false, lastView: 'kitchen', currentRoom: '',
|
|
roof: new Set(), cutOriginals: new Set(), ready: false
|
|
};
|
|
function interiorVisibility(m) {
|
|
const layer = m.interiorLayer;
|
|
if (!interior.enabled) return !layer;
|
|
const walking = interior.mode === 'walk';
|
|
if (layer) {
|
|
if (layer === 'cut' || layer === 'cutShell') return !walking;
|
|
if (['wall', 'liner', 'door', 'ceiling', 'beams'].includes(layer)) return walking;
|
|
if (layer === 'furniture') return interior.furniture;
|
|
return true;
|
|
}
|
|
if (!walking) {
|
|
if (interior.roof.has(m.nodeIndex) || interior.cutOriginals.has(m.nodeIndex)) return false;
|
|
if (m.siteLayer || m.context || /^(25_|26_|27_)/.test(m.name)) return false;
|
|
} else if (m.glass) return false; // Optical opening only; source glazing is untouched.
|
|
return true;
|
|
}
|
|
function interiorPointIn(poly, x, z) {
|
|
let inside = false;
|
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
const a = poly[i], b = poly[j];
|
|
if ((a[1] > z) !== (b[1] > z) && x < (b[0] - a[0]) * (z - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
function interiorCanStand(x, z) {
|
|
const d = interior.data, r = .16;
|
|
if (!d || ![[x-r,z],[x+r,z],[x,z-r],[x,z+r]].every(p => interiorPointIn(d.footprint,p[0],p[1]))) return false;
|
|
for (const b of d.wallColliders) if (x > b.lo[0]-r && x < b.hi[0]+r && z > b.lo[1]-r && z < b.hi[1]+r) return false;
|
|
if (interior.furniture) for (const b of d.furnitureColliders) if (x > b.lo[0]-r && x < b.hi[0]+r && z > b.lo[1]-r && z < b.hi[1]+r) return false;
|
|
return true;
|
|
}
|
|
function moveInterior(dx, dz) {
|
|
// Small substeps and axis sliding keep narrow 700 mm doorways usable.
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(dx,dz)/.055));
|
|
for (let i = 0; i < steps; i++) {
|
|
let x = interior.position[0], z = interior.position[2];
|
|
if (interiorCanStand(x+dx/steps,z)) interior.position[0] = x+dx/steps;
|
|
x = interior.position[0];
|
|
if (interiorCanStand(x,z+dz/steps)) interior.position[2] = z+dz/steps;
|
|
}
|
|
dirty = true;
|
|
}
|
|
function stepInterior(dt) {
|
|
if (!interior.enabled || interior.mode !== 'walk') return;
|
|
const k = interior.keys, p = interior.pad;
|
|
let f = (k.has('KeyW')||k.has('ArrowUp')||p.has('forward')?1:0)-(k.has('KeyS')||k.has('ArrowDown')||p.has('back')?1:0);
|
|
let side = (k.has('KeyD')||p.has('right')?1:0)-(k.has('KeyA')||p.has('left')?1:0);
|
|
const turn = (k.has('ArrowLeft')||k.has('KeyQ')?1:0)-(k.has('ArrowRight')||k.has('KeyE')?1:0);
|
|
if (turn) {interior.yaw += turn*dt*1.25;dirty=true;}
|
|
if (f || side) {
|
|
const len = Math.hypot(f,side), speed = (k.has('ShiftLeft')||k.has('ShiftRight')?2.15:1.2)*dt;
|
|
f/=len;side/=len;
|
|
moveInterior((Math.sin(interior.yaw)*f-Math.cos(interior.yaw)*side)*speed,(Math.cos(interior.yaw)*f+Math.sin(interior.yaw)*side)*speed);
|
|
}
|
|
}
|
|
function interiorCamera() {
|
|
eye = [...interior.position];
|
|
const dir = [Math.sin(interior.yaw)*Math.cos(interior.pitch),Math.sin(interior.pitch),Math.cos(interior.yaw)*Math.cos(interior.pitch)];
|
|
vp = mul(perspective(interior.fov*Math.PI/180,aspect,.035,400),lookAt(eye,vadd(eye,dir)));
|
|
}
|
|
function interiorFitCamera() {
|
|
if (!interior.enabled || interior.mode === 'walk' || !interior.data) return;
|
|
const mobile = aspect < 1;
|
|
theta = interior.mode === 'plan' ? (mobile ? Math.PI/2 : 0) : .68;
|
|
phi = interior.mode === 'plan' ? 1.5706 : .93;
|
|
target = [1.6,.4,0]; orthographic = true;animation=null;autoRotate=false;
|
|
const forward = [Math.sin(theta)*Math.cos(phi),Math.sin(phi),Math.cos(theta)*Math.cos(phi)];
|
|
const right = [Math.cos(theta),0,-Math.sin(theta)], up = cross(forward,right);
|
|
distance = 10;
|
|
for (const p of boxCorners([-8.05,-.2,-5.0],[11.68,2.3,5.05])) {
|
|
const q = vsub(p,target);
|
|
distance = Math.max(distance,Math.abs(dot(q,right))/(.28*aspect*.89),Math.abs(dot(q,up))/(.28*.76));
|
|
}
|
|
dirty = true;
|
|
}
|
|
function updateInteriorInterface() {
|
|
document.body.classList.toggle('interior-active',interior.enabled);
|
|
document.body.classList.toggle('interior-walk',interior.enabled&&interior.mode==='walk');
|
|
document.body.classList.toggle('interior-panel-closed',interior.enabled&&!interior.panel);
|
|
$('#interiorLaunch').setAttribute('aria-expanded',String(interior.enabled));
|
|
$('#interiorPanelToggle').setAttribute('aria-expanded',String(interior.panel));
|
|
document.querySelectorAll('[data-interior-mode]').forEach(b=>b.classList.toggle('active',b.dataset.interiorMode===interior.mode));
|
|
const walking=interior.mode==='walk';
|
|
$('#interiorRoomLabels').disabled=walking;
|
|
$('#interiorHint').textContent=walking?'W / A / S / D nebo šipky · tažením se rozhlížejte · Shift = rychleji · Esc = exteriér':'Tažením otáčejte · kolečkem přibližujte · kliknutím na místnost vstupte dovnitř';
|
|
if(!walking)$('#interiorViewTitle').textContent=interior.mode==='plan'?'Půdorys 1. NP':'Dům bez střechy';
|
|
if(!interior.enabled)$('#interiorLabels').replaceChildren();
|
|
}
|
|
function toggleInterior(on) {
|
|
if (!interior.ready) return;
|
|
on = on === undefined ? !interior.enabled : !!on;
|
|
if (on === interior.enabled) return;
|
|
if (on) {
|
|
interior.saved = {theta,phi,distance,target:[...target],ortho:orthographic,autoRotate,aspect,
|
|
label:$('#directionText').textContent,sunOpen:$('#sunPanel').classList.contains('open'),panelOpen:$('#panel').classList.contains('open'),
|
|
presets:[...document.querySelectorAll('[data-preset].active')].map(b=>b.dataset.preset),elevation:$('#elevation').value};
|
|
interior.enabled=true;interior.mode='axon';interior.panel=true;interior.userCamera=false;
|
|
toggleSunPanel(false);$('#panel').classList.remove('open');$('#infoButton').classList.remove('active');
|
|
autoRotate=false;animation=null;pointerMap.clear();$('#rotateButton').classList.remove('active');
|
|
updateInteriorInterface();resize();interiorFitCamera();
|
|
toast('Interiér zapnutý. Klikněte na místnost nebo zvolte Procházet.');
|
|
} else {
|
|
const s=interior.saved;
|
|
interior.enabled=false;interior.keys.clear();interior.pad.clear();interior.lookPointer=null;
|
|
updateInteriorInterface();toggleSunPanel(s?.sunOpen||false);resize();
|
|
if(s){
|
|
setCamera({...s,distance:s.distance*Math.max(1,1/aspect)/Math.max(1,1/s.aspect)},s.label,true);
|
|
autoRotate=s.autoRotate;$('#rotateButton').classList.toggle('active',autoRotate);
|
|
$('#panel').classList.toggle('open',s.panelOpen);$('#infoButton').classList.toggle('active',s.panelOpen);
|
|
$('#elevation').value=s.elevation;
|
|
document.querySelectorAll('[data-preset]').forEach(b=>b.classList.toggle('active',s.presets.includes(b.dataset.preset)));
|
|
}
|
|
}
|
|
updateSolar();solar.shadowDirty=true;dirty=true;
|
|
}
|
|
function setInteriorMode(mode) {
|
|
if(!['plan','axon','walk'].includes(mode)) return;
|
|
if(!interior.enabled)toggleInterior(true);
|
|
interior.mode=mode;interior.userCamera=false;interior.keys.clear();interior.pad.clear();animation=null;autoRotate=false;pointerMap.clear();
|
|
if(mode==='walk'){
|
|
const preset=interior.data.presets.find(p=>p.id===interior.lastView)||interior.data.presets[0];
|
|
enterInteriorView(preset.id);return;
|
|
}
|
|
document.querySelectorAll('[data-interior-view]').forEach(b=>b.classList.remove('active'));
|
|
$('#interiorRoom').value='';updateInteriorInterface();interiorFitCamera();updateSolar();dirty=true;
|
|
}
|
|
function enterInteriorView(id) {
|
|
if(!interior.ready)return false;
|
|
if(!interior.enabled)toggleInterior(true);
|
|
const v=interior.data.presets.find(p=>p.id===id)||interior.data.rooms.find(r=>r.id===id);
|
|
if(!v)return false;
|
|
interior.mode='walk';interior.lastView=id;interior.position=[...v.eye];
|
|
const dir=vsub(v.look,v.eye);interior.yaw=Math.atan2(dir[0],dir[2]);interior.pitch=Math.atan2(dir[1],Math.hypot(dir[0],dir[2]));
|
|
interior.fov=v.fov||64;interior.keys.clear();interior.pad.clear();interior.lookPointer=null;pointerMap.clear();autoRotate=false;animation=null;
|
|
document.querySelectorAll('[data-interior-view]').forEach(b=>b.classList.toggle('active',b.dataset.interiorView===id));
|
|
$('#interiorRoom').value=interior.data.rooms.some(r=>r.id===id)?id:'';
|
|
$('#interiorViewTitle').textContent=v.label;
|
|
updateInteriorInterface();updateSolar();solar.shadowDirty=true;dirty=true;return true;
|
|
}
|
|
function handleInteriorKey(e) {
|
|
if($('#interiorReferenceDialog').open||$('#stdCatalogDialog').open||$('#stdResetDialog').open)return;
|
|
if(e.key==='Escape'){e.preventDefault();toggleInterior(false);return;}
|
|
if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;
|
|
const modes={Digit1:'plan',Digit2:'axon',Digit3:'walk'};
|
|
if(modes[e.code]){e.preventDefault();setInteriorMode(modes[e.code]);return;}
|
|
if(e.code==='KeyR'){e.preventDefault();if(interior.mode==='walk')enterInteriorView(interior.lastView);else{interior.userCamera=false;interiorFitCamera();}return;}
|
|
if(interior.mode==='walk'&&['KeyW','KeyA','KeyS','KeyD','KeyQ','KeyE','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','ShiftLeft','ShiftRight'].includes(e.code)){
|
|
e.preventDefault();interior.keys.add(e.code);
|
|
}
|
|
}
|
|
function interiorMapPoint(x,z){return [17+(x+7.67)*19.2,12+(z+4.695)*19.2];}
|
|
function buildInteriorMap() {
|
|
let svg='<rect x="1" y="1" width="328" height="203" rx="10" fill="#f0f2e9"/>';
|
|
svg+='<rect x="236" y="12" width="76" height="77" fill="#e5dcc4" stroke="#d2d8c7"/>';
|
|
svg+='<text x="274" y="53" text-anchor="middle">Terasa</text>';
|
|
for(const r of interior.data.rooms){
|
|
const path=r.poly.map(p=>interiorMapPoint(...p).join(',')).join(' '),a=interiorMapPoint(...r.anchor);
|
|
svg+=`<g data-map-room="${r.id}" role="button" tabindex="0" aria-label="${r.id} ${r.label}"><polygon class="map-room" points="${path}"/><text x="${a[0]}" y="${a[1]+3}" text-anchor="middle">${r.id}</text></g>`;
|
|
}
|
|
for(const b of interior.data.wallColliders){let a=interiorMapPoint(...b.lo),c=interiorMapPoint(...b.hi);svg+=`<rect class="map-wall" x="${a[0]}" y="${a[1]}" width="${c[0]-a[0]}" height="${c[1]-a[1]}"/>`;}
|
|
svg+='<g id="interiorMapMarker" style="display:none"><path d="M0 0L-5 13L5 13Z" fill="#bd8541" opacity=".8"/><circle r="3.7" fill="#365944" stroke="#fff" stroke-width="1.4"/></g>';
|
|
svg+='<text x="17" y="198">1. NP · orientace podle půdorysu</text>';
|
|
$('#interiorMap').innerHTML=svg;
|
|
document.querySelectorAll('[data-map-room]').forEach(el=>{
|
|
el.onclick=()=>enterInteriorView(el.dataset.mapRoom);
|
|
el.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();enterInteriorView(el.dataset.mapRoom);}};
|
|
});
|
|
}
|
|
function drawInteriorLabels() {
|
|
if(!interior.enabled)return;
|
|
const el=$('#interiorLabels'),walking=interior.mode==='walk';
|
|
if(walking||!interior.labels){el.replaceChildren();}
|
|
else{
|
|
if(!el.children.length)for(const r of interior.data.rooms){
|
|
const b=document.createElement('button');b.className='int-room-tag';b.dataset.room=r.id;
|
|
b.innerHTML=`<b>${r.id} <span class="room-word">${r.label}</span></b><small>${r.area} m²</small>`;
|
|
b.title='Vstoupit: '+r.label;b.onclick=()=>enterInteriorView(r.id);el.appendChild(b);
|
|
}
|
|
const rect=canvas.getBoundingClientRect();
|
|
for(const b of el.children){const r=interior.data.rooms.find(r=>r.id===b.dataset.room),p=project([r.anchor[0],.20,r.anchor[1]]);
|
|
b.style.left=(p[0]+rect.left)+'px';b.style.top=(p[1]+rect.top)+'px';
|
|
b.style.display=p[0]<20||p[0]>viewW-20||p[1]<80||p[1]>viewH-65?'none':'block';}
|
|
}
|
|
const marker=$('#interiorMapMarker');if(marker){marker.style.display=walking?'block':'none';if(walking){const p=interiorMapPoint(interior.position[0],interior.position[2]);marker.setAttribute('transform',`translate(${p[0]},${p[1]}) rotate(${-interior.yaw*180/Math.PI})`);}}
|
|
if(walking){const room=interior.data.rooms.find(r=>interiorPointIn(r.poly,interior.position[0],interior.position[2]));
|
|
const id=room?.id||'';if(id!==interior.currentRoom){interior.currentRoom=id;document.querySelectorAll('[data-map-room] .map-room').forEach(p=>p.classList.toggle('active',p.parentElement.dataset.mapRoom===id));}}
|
|
}
|
|
// Interior paint uses linear RGB, like the other interior PBR materials.
|
|
// Only explicitly tagged plaster is edited; no name-based facade match.
|
|
const INTERIOR_PAINT_STORAGE = 'locenice.interior.wallPaint.v1';
|
|
function normalizeInteriorHex(value) {
|
|
if (typeof value !== 'string') return null;
|
|
let hex = value.trim().replace(/^#/, '');
|
|
if (/^[0-9a-f]{3}$/i.test(hex)) hex = hex.split('').map(c=>c+c).join('');
|
|
return /^[0-9a-f]{6}$/i.test(hex) ? '#'+hex.toLowerCase() : null;
|
|
}
|
|
function setInteriorWallColor(value, persist = true) {
|
|
const hex=normalizeInteriorHex(value), paint=interior.data?.wallPaint;
|
|
if (!hex || !gltf || !paint) return false;
|
|
const rgb=hardieLinearRGB(hex);
|
|
for (const index of paint.materialIndices) {
|
|
const material=gltf.materials[index];
|
|
if (material?.extras?.interiorRole !== 'wallPaint') continue;
|
|
material.pbrMetallicRoughness.baseColorFactor=[...rgb,1];
|
|
}
|
|
interior.wallColor=hex; paint.selectedHex=hex;
|
|
$('#interiorWallColor').value=hex;
|
|
$('#interiorWallHex').value=hex.toUpperCase();
|
|
$('#interiorWallHex').removeAttribute('aria-invalid');
|
|
document.querySelectorAll('[data-interior-paint]').forEach(button=>{
|
|
button.setAttribute('aria-pressed',String(button.dataset.interiorPaint===hex));
|
|
});
|
|
// file://, private mode and disabled storage must never break rendering.
|
|
if (persist) try {
|
|
if (hex===paint.defaultHex) localStorage.removeItem(INTERIOR_PAINT_STORAGE);
|
|
else localStorage.setItem(INTERIOR_PAINT_STORAGE,hex);
|
|
} catch (_) { /* The live view and export still keep the selected color. */ }
|
|
dirty=true;
|
|
return true;
|
|
}
|
|
function resetInteriorWallColor() {
|
|
return setInteriorWallColor(interior.data?.wallPaint?.defaultHex || '#eeeae1');
|
|
}
|
|
function initInteriorWallPaint() {
|
|
const paint=interior.data.wallPaint;
|
|
let color=paint.selectedHex || paint.defaultHex;
|
|
try { color=normalizeInteriorHex(localStorage.getItem(INTERIOR_PAINT_STORAGE)) || color; } catch (_) {}
|
|
setInteriorWallColor(color,false);
|
|
$('#interiorWallColor').addEventListener('input',e=>setInteriorWallColor(e.target.value));
|
|
$('#interiorWallColor').addEventListener('change',e=>setInteriorWallColor(e.target.value));
|
|
const hexInput=$('#interiorWallHex');
|
|
const commitHex=()=>{
|
|
if (!setInteriorWallColor(hexInput.value)) {
|
|
hexInput.value=interior.wallColor.toUpperCase();
|
|
toast('Zadejte platnou barvu, nap\u0159\u00edklad #EEEAE1.');
|
|
}
|
|
};
|
|
hexInput.addEventListener('change',commitHex);
|
|
hexInput.addEventListener('keydown',e=>{
|
|
if (e.key==='Enter') { e.preventDefault();commitHex();hexInput.blur(); }
|
|
});
|
|
document.querySelectorAll('[data-interior-paint]').forEach(button=>{
|
|
button.addEventListener('click',()=>setInteriorWallColor(button.dataset.interiorPaint));
|
|
});
|
|
$('#interiorWallReset').addEventListener('click',resetInteriorWallColor);
|
|
}
|
|
|
|
function initInterior() {
|
|
interior.data=gltf.extras.interiorStudy;interior.roof=new Set(interior.data.roofNodes);interior.cutOriginals=new Set(interior.data.cutOriginalNodes);
|
|
for(const r of interior.data.rooms){const op=document.createElement('option');op.value=r.id;op.textContent=`${r.id} · ${r.label} · ${r.area} m²`;$('#interiorRoom').appendChild(op);}
|
|
buildInteriorMap();
|
|
initInteriorWallPaint();
|
|
$('#interiorLaunch').onclick=()=>toggleInterior(true);$('#interiorExit').onclick=()=>toggleInterior(false);
|
|
document.querySelectorAll('[data-interior-mode]').forEach(b=>b.onclick=()=>setInteriorMode(b.dataset.interiorMode));
|
|
document.querySelectorAll('[data-interior-view]').forEach(b=>b.onclick=()=>enterInteriorView(b.dataset.interiorView));
|
|
$('#interiorRoom').onchange=e=>{if(e.target.value)enterInteriorView(e.target.value);};
|
|
$('#interiorFurniture').onchange=e=>{interior.furniture=e.target.checked;solar.shadowDirty=true;dirty=true;};
|
|
$('#interiorRoomLabels').onchange=e=>{interior.labels=e.target.checked;dirty=true;};
|
|
$('#interiorFill').onchange=e=>{interior.fill=e.target.checked;dirty=true;};
|
|
$('#interiorFit').onclick=()=>{if(interior.mode==='walk')enterInteriorView(interior.lastView);else{interior.userCamera=false;interiorFitCamera();}};
|
|
$('#interiorPanelToggle').onclick=()=>{if($('#sunPanel').classList.contains('open'))toggleSunPanel(false);else interior.panel=!interior.panel;updateInteriorInterface();resize();};
|
|
$('#interiorSavePng').onclick=()=>{render();canvas.toBlob(b=>{if(b)saveBlob(b,'dum_interier_'+interior.mode+'.png');},'image/png');};
|
|
$('#interiorSaveGlb').onclick=()=>{saveBlob(new Blob([currentGlb()],{type:'model/gltf-binary'}),'dum_interier_'+interior.mode+'_'+hardieSelected+'.glb');};
|
|
const refs=resources.references;
|
|
document.querySelectorAll('[data-int-ref]').forEach(b=>b.onclick=()=>{const r=refs[b.dataset.intRef];$('#interiorReferenceTitle').textContent=r.title;$('#interiorReferenceImage').src=r.src;$('#interiorReferenceImage').alt=r.title;$('#interiorReferenceCaption').textContent=r.caption;$('#interiorReferenceDialog').showModal();});
|
|
$('#interiorReferenceClose').onclick=()=>$('#interiorReferenceDialog').close();
|
|
$('#interiorReferenceDialog').addEventListener('click',e=>{if(e.target.id==='interiorReferenceDialog'){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}});
|
|
document.querySelectorAll('[data-walk]').forEach(b=>{
|
|
const release=()=>{interior.pad.delete(b.dataset.walk);b.classList.remove('held');};
|
|
b.onpointerdown=e=>{e.preventDefault();b.setPointerCapture(e.pointerId);interior.pad.add(b.dataset.walk);b.classList.add('held');};
|
|
b.onpointerup=release;b.onpointercancel=release;b.onlostpointercapture=release;
|
|
});
|
|
window.addEventListener('keyup',e=>interior.keys.delete(e.code));
|
|
window.addEventListener('blur',()=>{interior.keys.clear();interior.pad.clear();interior.lookPointer=null;});
|
|
document.addEventListener('visibilitychange',()=>{if(document.hidden){interior.keys.clear();interior.pad.clear();}});
|
|
interior.ready=true;$('#interiorLaunch').disabled=false;
|
|
window.INTERIOR = {
|
|
toggle:toggleInterior,mode:setInteriorMode,view:enterInteriorView,
|
|
setWallColor:setInteriorWallColor, resetWallColor:resetInteriorWallColor,
|
|
getState:()=>({enabled:interior.enabled,mode:interior.mode,position:[...interior.position],yaw:interior.yaw,pitch:interior.pitch,
|
|
furniture:interior.furniture,labels:interior.labels,fill:interior.fill,currentRoom:interior.currentRoom,
|
|
wallColor:interior.wallColor,defaultWallColor:interior.data.wallPaint.defaultHex,timberReference:interior.data.timberReference,
|
|
rooms:interior.data.rooms.map(r=>({id:r.id,name:r.label,area:r.area})),originalGeometryUnchanged:false,standardsRevision:1,userCorrections:interior.data.userCorrections,
|
|
originalNodes:interior.data.originalNodeCount,sourceBinarySHA256:interior.data.sourceBinarySHA256}),
|
|
checkPosition:(x,z)=>interiorCanStand(x,z),move:(dx,dz)=>moveInterior(dx,dz),
|
|
setLook:(yaw,pitch)=>{interior.yaw=yaw;interior.pitch=clamp(pitch,-1.3,1.3);dirty=true;}
|
|
};
|
|
}
|
|
|
|
|
|
// ALLSTAV configurator. The PDF is the source; unlabeled samples are not product codes.
|
|
const STD_ASSETS=resources.assets;
|
|
const STD_STORAGE = 'locenice.allstav.2025.v1';
|
|
let stdStorageKey = STD_STORAGE;
|
|
const STD_ROOMS=resources.catalog.rooms,STD_OPTIONS=resources.catalog.options,STD_DEFAULTS=resources.catalog.defaults;
|
|
const stdClone = x=>JSON.parse(JSON.stringify(x));
|
|
let stdState=stdClone(STD_DEFAULTS),stdReady=false,stdSwitching=false,stdTab='exterior',stdFloorRoom='108',stdTileRoom='105',stdCatalogPage=12,stdSaveTimer=0;
|
|
const stdEsc = s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
const stdOption=(key,val=stdState[key])=>(STD_OPTIONS[key]||[]).find(o=>o[0]===val);
|
|
const stdLabel=(key,val=stdState[key])=>stdOption(key,val)?.[1]||String(val);
|
|
const stdSource=p=>`<button class="std-source" type="button" data-std-page="${p}">Katalog \u00b7 str. ${p}</button>`;
|
|
function stdCards(key,options=STD_OPTIONS[key],cols='',kind='',tier='standard'){
|
|
return `<div class="std-grid ${cols}" data-card-group="${key}">${options.map(o=>`<button type="button" class="std-card ${kind}" data-std-key="${key}" data-std-value="${stdEsc(o[0])}" aria-pressed="false" title="${stdEsc(o[1])}">${o[4]?`<img loading="lazy" src="${STD_ASSETS[o[4]]}" alt="${stdEsc(o[1])}">`:o[2]?`<span class="sample" style="background:${o[2]}"></span>`:''}<strong>${stdEsc(o[1])}</strong><small class="${o[3]?'premium':tier==='visual'?'visual':''}">${o[3]?'P\u0159\u00edplatek':((key==='wallPaint'&&o[0]==='#ffffff')||(key==='faucet'&&o[0]==='chrome'))?'Standard':tier==='visual'?'Vlastn\u00ed n\u00e1hled':tier==='catalog'?'Varianta katalogu':'Standard'}</small></button>`).join('')}</div>`;
|
|
}
|
|
function stdSampleCards(key,type){
|
|
return stdCards(key,Array.from({length:9},(_,i)=>[String(i+1),`Vzorek ${String(i+1).padStart(2,'0')}`,'',false,`${type}${i+1}`]),'cols3','wood','catalog');
|
|
}
|
|
const stdSection=(id,n,title,body,open=false)=>`<details class="std-section" id="std-section-${id}" ${open?'open':''}><summary><span class="std-number">${n}</span><span><strong>${title}</strong><small class="std-summary-current" data-std-current="${id}"></small></span></summary><div class="std-body">${body}</div></details>`;
|
|
const stdSelect=(key,label,options)=>`<label class="std-field">${label}<select data-std-input="${key}">${options.map(([v,t])=>`<option value="${stdEsc(v)}">${t}</option>`).join('')}</select></label>`;
|
|
const stdCheck=(key,label)=>`<label class="std-check"><input type="checkbox" data-std-input="${key}">${label}</label>`;
|
|
const stdColor=(key,label)=>`<label class="std-field">${label}<input type="color" data-std-input="${key}" aria-label="${label}"></label>`;
|
|
const stdNotice=(s,t='')=>`<div class="std-notice ${t}">${s}</div>`;
|
|
function standardsVisible(m){
|
|
const e=m.std||{};
|
|
if(e.stdHidden)return false;
|
|
if(e.stdFullOnly && interior.enabled && interior.mode!=='walk')return false;
|
|
if(e.stdBlind && (!stdState.blinds || (e.stdBlindFraction!==undefined && e.stdBlindFraction>stdState.blindDrop/100)))return false;
|
|
if(e.stdWhen && Object.entries(e.stdWhen).some(([k,v])=>stdState[k]!==v))return false;
|
|
return true;
|
|
}
|
|
function stdSetMaterial(role,color,texture){
|
|
const meta=gltf.extras.allstavStandards;
|
|
for(const idx of meta.materials[role]||[]){
|
|
const mat=gltf.materials[idx],p=mat.pbrMetallicRoughness;
|
|
if(color)p.baseColorFactor=[...hardieLinearRGB(color),p.baseColorFactor?.[3]??1];
|
|
if(texture!==undefined){if(texture===null)delete p.baseColorTexture;else p.baseColorTexture={index:typeof texture==='number'?texture:meta.textures[texture]};}
|
|
for(const m of meshes){if(m.material!==idx)continue;m.surfaceKind=p.baseColorTexture?3:2;}
|
|
}
|
|
}
|
|
function stdApply(save=true){
|
|
if(!gltf)return;
|
|
stdSetMaterial('entry',stdOption('entryColor')[2]);stdSetMaterial('extSill',stdOption('extSill')[2]);stdSetMaterial('gutter',stdOption('gutter')[2]);
|
|
recolor('roof',stdOption('roof')[2]);for(const i of gltf.extras.allstavStandards.materials.roof)gltf.materials[i].pbrMetallicRoughness.roughnessFactor=.32;if($('#roofColor'))$('#roofColor').value=stdOption('roof')[2];
|
|
stdSetMaterial('blinds',stdOption('blindColor')[2]);
|
|
for(const r of STD_ROOMS){const f=stdState.floors[r[0]];stdSetMaterial('floor'+r[0],'#ffffff',f.type+f.sample);for(const i of gltf.extras.allstavStandards.materials['floor'+r[0]]){gltf.materials[i].pbrMetallicRoughness.baseColorTexture.extensions={KHR_texture_transform:{scale:f.type==='tile'?[.22/.6,1.28/.6]:[1,1]}};}}
|
|
gltf.extensionsUsed=[...new Set([...(gltf.extensionsUsed||[]),'KHR_texture_transform'])];
|
|
stdSetMaterial('wallTile105','#ffffff','tile'+stdState.wallTile105);stdSetMaterial('wallTile106','#ffffff','tile'+stdState.wallTile106);
|
|
stdSetMaterial('intSill',stdOption('intSill')[2]);
|
|
stdSetMaterial('intDoor',stdState.intDoor==='sample'?'#ffffff':stdState.intDoorColor,stdState.intDoor==='sample'?'prum':null);
|
|
setInteriorWallColor(stdState.wallPaint,false);
|
|
stdSetMaterial('kitchenFront',stdState.kitchenColor);stdSetMaterial('kitchenBody',stdState.kitchenBodyColor);
|
|
const work=stdOption('worktop'),tex=stdState.worktop==='oak'?9:stdState.worktop==='stone'?'tile1':null;
|
|
stdSetMaterial('worktop',tex!==null?'#ffffff':work[2],tex);
|
|
const back=stdState.backsplash==='worktop'?stdState.worktop:stdState.backsplash;
|
|
stdSetMaterial('backsplash',back==='stone'||back==='oak'?'#ffffff':'#eeeeda',back==='stone'?'tile1':back==='oak'?9:null);
|
|
if(stdState.backsplash==='worktop'&&back==='graphite')stdSetMaterial('backsplash','#444946',null);
|
|
stdSetMaterial('faucet',stdOption('faucet')[2]);
|
|
for(const i of gltf.extras.allstavStandards.materials.faucet){gltf.materials[i].pbrMetallicRoughness.metallicFactor=stdState.faucet==='chrome'?.8:.05;}
|
|
gltf.extras.allstavSelection=stdClone(stdState);
|
|
solar.shadowDirty=true;dirty=true;
|
|
if(stdReady){stdSync();if(save){clearTimeout(stdSaveTimer);stdSaveTimer=setTimeout(stdPersist,220);}}
|
|
}
|
|
function stdNormalize(input){
|
|
if(!input||typeof input!=='object'||Array.isArray(input))throw Error('Chyb\u00ed platn\u00e9 nastaven\u00ed.');
|
|
const d=stdClone(STD_DEFAULTS),enums={intDoor:['sample','custom'],intDoorType:['hinged','sliding','pocket'],tileCorner:['profile','miter'],hood:['facade','recirculation'],ventilation:['local','central','none'],radiator:['electric','combined']};
|
|
for(const k of Object.keys(d)){
|
|
if(input[k]===undefined)continue;
|
|
const v=input[k];
|
|
if(k==='floors'){
|
|
if(!v||typeof v!=='object')throw Error('Neplatn\u00e9 podlahy.');
|
|
for(const r of STD_ROOMS){if(v[r[0]]===undefined)continue;const f=v[r[0]];if(!f||!['lam','tile'].includes(f.type)||!Number.isInteger(f.sample)||f.sample<1||f.sample>9)throw Error('Neplatn\u00e1 podlaha m\u00edstnosti '+r[0]);d.floors[r[0]]={type:f.type,sample:f.sample};}
|
|
} else if(['wallPaint','kitchenColor','kitchenBodyColor','intDoorColor'].includes(k)){
|
|
if(typeof v!=='string'||!/^#[0-9a-f]{6}$/i.test(v))throw Error('Neplatn\u00e1 barva: '+k);d[k]=v.toLowerCase();
|
|
} else if(STD_OPTIONS[k]){if(!STD_OPTIONS[k].some(o=>o[0]===v))throw Error('Nezn\u00e1m\u00e1 varianta: '+k);d[k]=v;
|
|
} else if(enums[k]){if(!enums[k].includes(v))throw Error('Nezn\u00e1m\u00e1 varianta: '+k);d[k]=v;
|
|
} else if(typeof d[k]==='boolean'){if(typeof v!=='boolean')throw Error('Neplatn\u00fd p\u0159ep\u00edna\u010d: '+k);d[k]=v;
|
|
} else if(typeof d[k]==='number'){const max=k==='blindDrop'?100:9,min=k==='blindDrop'?0:1;if(!Number.isInteger(v)||v<min||v>max)throw Error('Hodnota mimo rozsah: '+k);d[k]=v;
|
|
} else if(typeof d[k]==='string'){if(typeof v!=='string'||v.length>(k==='note'?2000:120))throw Error('Neplatn\u00fd text: '+k);d[k]=v;}
|
|
}
|
|
// Backwards compatibility: older exports used one colour for both.
|
|
if(input.kitchenBodyColor===undefined)d.kitchenBodyColor=d.kitchenColor;
|
|
return d;
|
|
}
|
|
function stdPersist(){try{localStorage.setItem(stdStorageKey,JSON.stringify(stdState));$('#stdSaved').textContent='Ulo\u017eeno v tomto prohl\u00ed\u017ee\u010di \u00b7 z\u00e1lohu st\u00e1hnete jako JSON';}catch(e){$('#stdSaved').textContent='Prohl\u00ed\u017ee\u010d nepovolil ulo\u017een\u00ed. St\u00e1hn\u011bte JSON.';}}
|
|
function stdBuildPanel(){
|
|
let ext='<div class="std-shortcuts"><button data-std-view="entry">Pohled na vstup</button><button data-std-view="terrace">Terasa a \u017ealuzie</button><button data-std-view="site">Cel\u00fd pozemek</button></div>';
|
|
ext+=stdSection('entry','01','Vchodov\u00e9 dve\u0159e',`<div class="std-label">Tvar k\u0159\u00eddla ${stdSource(12)}</div>${stdCards('entryModel',undefined,'','door')}<div class="std-label">Odst\u00edn dve\u0159\u00ed</div>${stdCards('entryColor')}${stdNotice('<strong>V\u00fdchoz\u00ed: 3 skla, RAL 7016.</strong> Prosklen\u00e9 proveden\u00ed je v katalogu p\u0159\u00edplatkov\u00e9. N\u00e1zvy tvar\u016f jsou popisn\u00e9; modelov\u00e9 k\u00f3dy katalog neuv\u00e1d\u00ed.','amber')}<button class="std-action wide" data-std-page="requestedDoor">Zobrazit v\u00e1\u0161 referen\u010dn\u00ed obr\u00e1zek</button><p>Masivn\u00ed d\u0159ev\u011bn\u00e9 dve\u0159e KeraT\u00fcr. 3D tvar je ilustrativn\u00ed; stavebn\u00ed otvor a bo\u010dn\u00ed sv\u011btl\u00edk p\u016fvodn\u00edho modelu z\u016fst\u00e1vaj\u00ed.</p>`,true);
|
|
ext+=stdSection('extSill','02','Venkovn\u00ed parapety',`<div class="std-label">Ta\u017een\u00fd hlin\u00edk ${stdSource(13)}</div>${stdCards('extSill',undefined,'cols3')}<p>Volba m\u011bn\u00ed parapety. N\u00e1\u0161lapn\u00e9 prahy u terasov\u00fdch dve\u0159\u00ed z\u016fst\u00e1vaj\u00ed samostatn\u00e9.</p>`);
|
|
ext+=stdSection('gutter','03','Okapy a svody',`<div class="std-label">Lindab ${stdSource(13)}</div>${stdCards('gutter')}${stdNotice('M\u011bn\u00ed se <strong>jen okapov\u00fd syst\u00e9m</strong>, nikoli lemov\u00e1n\u00ed st\u0159echy, kom\u00edn nebo z\u00e1bradl\u00ed.')}<p>Zkratky a k\u00f3dy jsou p\u0159evzat\u00e9 z katalogu. CERN 015 = v\u00fdchoz\u00ed volba.</p>`);
|
|
ext+=stdSection('roof','04','St\u0159e\u0161n\u00ed ta\u0161ky',`<div class="std-label">Benders Palema Candor ${stdSource(14)}</div>${stdCards('roof',undefined,'cols3')}<p>Betonov\u00e1 ta\u0161ka, vysoce leskl\u00e1 povrchov\u00e1 \u00faprava. N\u00e1hled zachov\u00e1v\u00e1 geometrii krytiny p\u016fvodn\u00edho modelu.</p>`);
|
|
ext+=stdSection('blinds','05','Venkovn\u00ed \u017ealuzie',`<label class="std-toggle">Osadit venkovn\u00ed \u017ealuzie<input type="checkbox" data-std-input="blinds"></label><div class="std-label">Climax Zetta 70 \u00b7 p\u0159\u00edplatek ${stdSource(15)}</div><div id="stdBlindsControls">${stdCards('blindColor',undefined,'cols5','', 'catalog')}<div class="std-range-head"><span>Sta\u017een\u00ed lamel</span><strong id="stdBlindDropLabel">100 %</strong></div><label class="std-field"><input type="range" min="0" max="100" step="5" data-std-input="blindDrop" aria-label="Sta\u017een\u00ed \u017ealuzi\u00ed"></label></div>${stdNotice('<strong>9 obd\u00e9ln\u00edkov\u00fdch otvor\u016f.</strong> Troj\u00faheln\u00edkov\u00e9 okno ani vchodov\u00e9 dve\u0159e \u017ealuzie nemaj\u00ed. Vodic\u00ed li\u0161ty maj\u00ed shodnou barvu; \u0161irok\u00e9 ji\u017en\u00ed zasklen\u00ed je rozd\u011blen\u00e9.')}<p>Podom\u00edtkov\u00fd kastl\u00edk, elektrick\u00e9 ovl\u00e1d\u00e1n\u00ed. Sta\u017een\u00ed je spole\u010dn\u00fd ilustrativn\u00ed n\u00e1hled, ne n\u00e1vrh pohonu.</p>`);
|
|
ext+=stdNotice('Fas\u00e1da James Hardie Plank a okol\u00ed domu z\u016fst\u00e1vaj\u00ed v p\u016fvodn\u00edch ovl\u00e1dac\u00edch prvc\u00edch. <button class="std-source" data-std-action="original">Otev\u0159\u00edt materi\u00e1ly fas\u00e1dy</button>');
|
|
$('#std-exterior').innerHTML=ext;
|
|
let int='<div class="std-shortcuts"><button data-std-view="plan">P\u016fdorys</button><button data-std-view="axon">3D \u0159ez</button><button data-std-view="108">Kuchy\u0148</button><button data-std-view="105">Koupelna</button></div>';
|
|
int+=stdSection('floors','01','Podlahy po m\u00edstnostech',`<div class="std-label">KronoSwiss / keramick\u00e1 dla\u017eba ${stdSource(18)}</div><label class="std-field">M\u00edstnost<select id="stdFloorRoom">${STD_ROOMS.map(r=>`<option value="${r[0]}">${r[0]} \u00b7 ${r[1]} \u00b7 ${r[2]} m\u00b2</option>`).join('')}</select></label><div class="std-two"><label class="std-field">Krytina<select id="stdFloorType"><option value="lam">Lamin\u00e1t KronoSwiss</option><option value="tile">Keramick\u00e1 dla\u017eba</option></select></label><label class="std-field">N\u00e1hled m\u00edstnosti<button class="std-action wide" data-std-action="floorView">P\u0159ej\u00edt do m\u00edstnosti</button></label></div><div id="stdFloorSamples"></div><button class="std-action wide" data-std-action="copyFloor">Stejn\u00fd vzorek pro m\u00edstnosti se stejnou krytinou</button><div id="stdFloorProjectNote" class="std-notice"></div><p>Vzorky 01\u201309 odpov\u00eddaj\u00ed obr\u00e1zk\u016fm na str. 18, nikoli objednac\u00edm k\u00f3d\u016fm. M\u011b\u0159\u00edtko kresby a sp\u00e1ry jsou pouze n\u00e1hledov\u00e9.</p>`,true);
|
|
int+=stdSection('tiles','02','Obklady koupelny a WC',`<div class="std-label">Rektifikovan\u00e9 obklady ${stdSource(18)}</div><label class="std-field">M\u00edstnost<select id="stdTileRoom"><option value="105">105 \u00b7 Koupelna</option><option value="106">106 \u00b7 WC</option></select></label>${stdSampleCards('wallTile','tile')}${stdSelect('tileCorner','Zakon\u010den\u00ed hran \u00b7 specifikace',[['profile','Hlin\u00edkov\u00e1 eloxov\u00e1 li\u0161ta \u00b7 standard'],['miter','Kamenick\u00fd roh \u00b7 p\u0159\u00edplatek']])}<p>Obklad do v\u00fd\u0161ky 2,1 m dle technick\u00e9 zpr\u00e1vy. Ve 3D \u0159ezu je zobrazen pouze po v\u00fd\u0161ku \u0159ezu; ukon\u010den\u00ed hran se eviduje v soupisu.</p>`);
|
|
int+=stdSection('doors','03','Interi\u00e9rov\u00e9 dve\u0159e a kov\u00e1n\u00ed',`<div class="std-label">Pr\u00fcm CPL ${stdSource(19)}</div>${stdCards('intDoor',[['sample','Fotografie z katalogu','',false,'prum'],['custom','Vlastn\u00ed odst\u00edn','#c3aa85']], 'cols2','wood','catalog')}<div id="stdDoorCustom">${stdColor('intDoorColor','Vlastn\u00ed barva \u00b7 pouze vizualizace')}</div><label class="std-field">P\u0159esn\u00fd dekor / k\u00f3d po konzultaci<input type="text" data-std-input="intDoorCode" maxlength="120" placeholder="Zat\u00edm neur\u010deno"></label>${stdNotice('Katalog uv\u00e1d\u00ed v\u00edce ne\u017e 20 CPL dekor\u016f, ale jejich vzorn\u00edk a k\u00f3dy neobsahuje. Fotografie dve\u0159\u00ed je referen\u010dn\u00ed, nikoli p\u0159esn\u00fd dekor.','amber')}${stdSelect('intDoorType','Proveden\u00ed \u00b7 specifikace',[['hinged','Oto\u010dn\u00e9 pln\u00e9 \u00b7 standard'],['sliding','Posuvn\u00e9 po zdi \u00b7 p\u0159\u00edplatek'],['pocket','Do pouzdra Eclisse \u00b7 p\u0159\u00edplatek']])}<div class="std-label">Brou\u0161en\u00fd nerez \u00b7 rozetov\u00e9 kov\u00e1n\u00ed</div>${stdCards('intHandle',undefined,'cols2')}<p>Koupelna: Klima 2. Koupelna a WC: WC z\u00e1mek. Posuvn\u00e9 varianty se zaznamenaj\u00ed do soupisu; 3D otvory ani kolize p\u0159\u00ed\u010dek se nep\u0159eprojektov\u00e1vaj\u00ed.</p>`);
|
|
int+=stdSection('finishes','04','Parapety a malba',`<div class="std-label">Helopal \u00b7 lit\u00fd mramor ${stdSource(16)}</div>${stdCards('intSill',undefined,'cols3')}<p>Fotografie ukazuj\u00ed katalogov\u00fd vzorek. V modelu se m\u011bn\u00ed odst\u00edn; \u017e\u00edh\u00e1n\u00ed je na fotografii.</p><div class="std-label">Malba st\u011bn</div>${stdCards('wallPaint',STD_OPTIONS.wallPaint.map((o,i)=>[...o]),'','', 'visual')}${stdColor('wallPaint','Vlastn\u00ed odst\u00edn st\u011bn')}${stdNotice('<strong>Standard: HET b\u00edl\u00e1, \u0161pachtlov\u00e1n\u00ed Q2.</strong> Ostatn\u00ed barvy jsou vlastn\u00ed n\u00e1hled, nikoli katalogov\u00fd vzorn\u00edk. Barva st\u011bn nem\u011bn\u00ed tr\u00e1my ani strop.')}`);
|
|
int+=stdSection('kitchen','05','Kuchy\u0148 \u00b7 vlastn\u00ed n\u00e1vrh',`${stdNotice('<strong>Kuchy\u0148 nen\u00ed v katalogu barevn\u011b ani typov\u011b specifikovan\u00e1.</strong> Projekt ponech\u00e1v\u00e1 jej\u00ed dod\u00e1vku na realizaci. N\u00e1sleduj\u00edc\u00ed volby slou\u017e\u00ed pouze pro slad\u011bn\u00ed interi\u00e9ru.','blue')}<div class="std-label">\u010cela sk\u0159\u00edn\u011bk</div>${stdCards('kitchenColor',undefined,'cols3','', 'visual')}${stdColor('kitchenColor','Vlastn\u00ed odst\u00edn \u010del')}<div class="std-label">Korpusy sk\u0159\u00edn\u011bk</div>${stdCards('kitchenBodyColor',undefined,'cols3','', 'visual')}${stdColor('kitchenBodyColor','Vlastn\u00ed odst\u00edn korpus\u016f')}<button class="std-action wide" data-std-action="matchKitchenBody">Sjednotit korpusy s \u010dely</button><div class="std-label">Pracovn\u00ed deska</div>${stdCards('worktop',undefined,'','', 'visual')}<div class="std-label">Obklad za linkou</div>${stdCards('backsplash',undefined,'cols3','', 'visual')}<button class="std-action wide" data-std-view="kitchen">Detail kuchyn\u011b</button><p>Rozvr\u017een\u00ed linky vych\u00e1z\u00ed z p\u016fvodn\u00edho HTML. Typy spot\u0159ebi\u010d\u016f, rozm\u011bry modul\u016f a cena nejsou t\u00edmto n\u00e1hledem ur\u010deny.</p>`);
|
|
int+=stdSection('bath','06','Koupelna a sanita',`<div class="std-label">Proveden\u00ed sprchy ${stdSource(21)}</div>${stdCards('shower',undefined,'cols2','', 'catalog')}<div id="stdShowerSpec" class="std-notice"></div><button class="std-action wide" data-std-view="shower">Detail sprchy</button><div class="std-label">Baterie</div>${stdCards('faucet',undefined,'cols2','', 'visual')}<p>Standardem jsou chromov\u00e9 Ravak Classic. \u010cern\u00e1 je vlastn\u00ed vizualiza\u010dn\u00ed alternativa, ne potvrzen\u00fd produkt z katalogu.</p><div class="std-label">Pevn\u00e9 vybaven\u00ed dle projektu ${stdSource(20)}</div><div class="std-inventory"><div><b>Vana Ravak Chrome \u00b7 1700 \u00d7 750 mm</b><small>Ravak Classic, click-clack, hlavice s hadic\u00ed 1,5 m.</small></div><div><b>Umyvadlo Laufen Pro \u00b7 600 \u00d7 420 mm</b><small>Ravak Classic, chromov\u00fd sifon. Mal\u00e9 umyvadlo na WC dle p\u016fdorysu: konkr\u00e9tn\u00ed typ potvrdit.</small></div><div><b>2\u00d7 WC \u00b7 koupelna a samostatn\u00e9 WC</b><small>Geberit 112, Ravak Uni Chrome Rimoff, softclose, b\u00edl\u00e9 tla\u010d\u00edtko Sigma.</small></div><div><b>Topn\u00fd \u017eeb\u0159\u00edk Koralux \u00b7 500 \u00d7 1500 mm</b><small>Volba p\u0159ipojen\u00ed v technick\u00e9 specifikaci.</small></div></div><p>Geometrie vybaven\u00ed je zjednodu\u0161en\u00e1. Rozm\u00edst\u011bn\u00ed vych\u00e1z\u00ed z p\u016fdorysu; nenahrazuje d\u00edlensk\u00fd n\u00e1vrh koupelny.</p>`);
|
|
int+=stdSection('tech','07','Technika a dal\u0161\u00ed specifikace',`${stdNotice('<strong>Volby v t\u00e9to sekci se ukl\u00e1daj\u00ed do soupisu.</strong> Model nep\u0159epo\u010d\u00edt\u00e1v\u00e1 ani nem\u011bn\u00ed rozvody, prostupy, v\u00fdkony a polohy jednotek.','blue')}<div class="std-label">V\u011btr\u00e1n\u00ed a chlazen\u00ed ${stdSource(25)}</div>${stdSelect('ventilation','Rekuperace',[['local','Lok\u00e1ln\u00ed Dalap Zephir \u00b7 dle projektu'],['central','Centr\u00e1ln\u00ed Brink Flair / Air Excellent'],['none','Bez rekuperace \u00b7 zm\u011bna proti projektu']])}${stdCheck('aircon','Klimatizace Panasonic \u00b7 voliteln\u00e9 vybaven\u00ed')}${stdCheck('fans','Nucen\u00e9 v\u011btr\u00e1n\u00ed koupelny / WC \u00b7 Airflow ECO 15')}${stdSelect('hood','Digesto\u0159',[['facade','V\u00fdvod na fas\u00e1du \u00d8 150 mm \u00b7 p\u0159\u00edplatek'],['recirculation','Cirkula\u010dn\u00ed \u00b7 standard']])}<p>V\u00fdchoz\u00ed technick\u00e9 volby sleduj\u00ed popisy v p\u016fdorysu; nacen\u011bn\u00ed a rozsah potvrzuje dodavatel. ${stdSource(16)}</p><div class="std-label">Voda a vyt\u00e1p\u011bn\u00ed ${stdSource(24)}</div>${stdCheck('hotWaterCirculation','Cirkulace tepl\u00e9 vody \u00b7 na p\u0159\u00e1n\u00ed')}${stdSelect('radiator','Topn\u00fd \u017eeb\u0159\u00edk',[['electric','Elektropatrona \u00b7 standard'],['combined','Nav\u00edc samostatn\u00fd topn\u00fd okruh \u00b7 p\u0159\u00edplatek']])}<div class="std-inventory"><div><b>Teplovodn\u00ed podlahov\u00e9 topen\u00ed</b><small>Panasonic vzduch\u2013voda, integrovan\u00fd z\u00e1sobn\u00edk T\u00daV. Katalog uv\u00e1d\u00ed do 5 kW, skute\u010dn\u00fd n\u00e1vrh dle projektu TZB.</small></div><div><b>ABB Levit \u00b7 b\u00edl\u00e1 / b\u00edl\u00e1</b><small>Standard z\u00e1suvek a vyp\u00edna\u010d\u016f. ${stdSource(17)}</small></div><div><b>Tepeln\u011bizola\u010dn\u00ed p\u016fdn\u00ed sch\u016fdky Fakro LWT</b><small>V dom\u011b nen\u00ed b\u011b\u017en\u00e9 schodi\u0161t\u011b. Rozm\u011br otvoru potvrdit podle projektu. ${stdSource(22)}</small></div></div>`);
|
|
$('#std-interior').innerHTML=int;
|
|
$('#std-summary').innerHTML=`<h3 class="std-summary-title">V\u00e1\u0161 v\u00fdb\u011br</h3>${stdNotice('P\u0159ehled variant, nikoli cenov\u00e1 nab\u00eddka. P\u0159\u00edplatky ani dostupnost katalog 2025 neoce\u0148uje. P\u0159ed objedn\u00e1n\u00edm potvr\u010fte aktu\u00e1ln\u00ed vzorn\u00edky s ALLSTAV.','amber')}<div id="stdSummaryContent"></div><label class="std-field">Pozn\u00e1mka pro dodavatele<textarea data-std-input="note" maxlength="2000" rows="3" style="width:100%;resize:vertical;padding:9px;border:1px solid #d7dfcf;border-radius:7px;font:inherit" placeholder="Up\u0159esn\u011bn\u00ed, k\u00f3dy dekor\u016f, dotazy..."></textarea></label><div class="std-export-grid"><button data-std-action="saveHtml">Zkop\u00edrovat odkaz s v\u00fdb\u011brem</button><button data-std-action="exportJson">Export nastaven\u00ed JSON</button><button data-std-action="importJson">Na\u010d\u00edst nastaven\u00ed JSON</button><button data-std-action="exportText">St\u00e1hnout soupis TXT</button><button data-std-action="png">Ulo\u017eit aktu\u00e1ln\u00ed n\u00e1hled PNG</button><button data-std-action="glb">Export viditeln\u00e9ho 3D GLB</button><button data-std-action="reset" class="std-reset">Obnovit v\u00fdchoz\u00ed standardy</button><button data-std-page="12">Otev\u0159\u00edt zdrojov\u00fd katalog</button></div><p class="std-fine">Zdroj: dodan\u00fd ALLSTAV Standardy, z\u00e1\u0159\u00ed 2025, a projekt RD. Fotografie a vzorky jsou v samostatn\u00e9 slo\u017ece assets. RGB n\u00e1hled nenahrazuje fyzick\u00fd vzorek RAL ani materi\u00e1lu. 3D n\u00e1hled nen\u00ed prov\u00e1d\u011bc\u00ed dokumentace.</p>`;
|
|
}
|
|
function stdSummary(){
|
|
const s=stdState,premium='P\u0159\u00edplatek',vis='Vlastn\u00ed vizualizace',spec='Specifikace; bez zm\u011bny geometrie';
|
|
const groups=[['Exteri\u00e9r',[
|
|
['Vchodov\u00e9 dve\u0159e',`KeraT\u00fcr \u00b7 ${stdLabel('entryModel')} \u00b7 ${stdLabel('entryColor')}`,stdOption('entryModel')[3]?premium:'Standard'],
|
|
['Venkovn\u00ed parapety',stdLabel('extSill'),stdOption('extSill')[3]?premium:'Standard'],['Okapy / svody',`Lindab ${stdLabel('gutter')}`,stdOption('gutter')[3]?premium:'Standard'],
|
|
['St\u0159e\u0161n\u00ed ta\u0161ky',`Benders Palema Candor \u00b7 ${stdLabel('roof')}`,'Standard'],
|
|
['Venkovn\u00ed \u017ealuzie',s.blinds?`Zetta 70 \u00b7 ${stdLabel('blindColor')} \u00b7 ${s.blindDrop} %`:'Vypnuto',s.blinds?premium+'; 9 otvor\u016f, bez troj\u00faheln\u00edku':'Voliteln\u00e9; projekt s nimi po\u010d\u00edt\u00e1']]],
|
|
['Podlahy',STD_ROOMS.map(r=>{const f=s.floors[r[0]];return[`${r[0]} ${r[1]} (${r[2]} m\u00b2)`,`${f.type==='lam'?'KronoSwiss lamin\u00e1t':'Keramick\u00e1 dla\u017eba'} \u00b7 vzorek ${String(f.sample).padStart(2,'0')}`,f.type===r[3]?'K\u00f3d dekoru potvrdit':'Zm\u011bna druhu proti projektu'];})],
|
|
['Povrchy a dve\u0159e',[
|
|
['Obklad koupelny',`Vzorek ${s.wallTile105} \u00b7 do 2,1 m`,'K\u00f3d dekoru potvrdit'],['Obklad WC',`Vzorek ${s.wallTile106} \u00b7 do 2,1 m`,'K\u00f3d dekoru potvrdit'],
|
|
['Ukon\u010den\u00ed obkladu',s.tileCorner==='profile'?'Eloxov\u00e1 li\u0161ta':'Kamenick\u00fd roh',s.tileCorner==='miter'?premium+'; '+spec:spec],['Vnit\u0159n\u00ed parapety','Helopal \u00b7 '+stdLabel('intSill'),'Standard'],
|
|
['Malba',s.wallPaint==='#ffffff'?'HET b\u00edl\u00e1 \u00b7 Q2':s.wallPaint,s.wallPaint==='#ffffff'?'Standard':vis],
|
|
['Interi\u00e9rov\u00e9 dve\u0159e','Pr\u00fcm CPL \u00b7 '+(s.intDoorCode||'dekor neur\u010den'),s.intDoor==='sample'?'Referen\u010dn\u00ed fotografie':vis+' '+s.intDoorColor],
|
|
['Proveden\u00ed dve\u0159\u00ed',({hinged:'Oto\u010dn\u00e9 pln\u00e9',sliding:'Posuvn\u00e9 po zdi',pocket:'Do pouzdra Eclisse'})[s.intDoorType],s.intDoorType==='hinged'?'Standard':premium+'; '+spec],
|
|
['Kov\u00e1n\u00ed',stdLabel('intHandle')+' rozeta \u00b7 brou\u0161en\u00fd nerez','Standard; WC z\u00e1mky, koupelna Klima 2']]],
|
|
['Kuchy\u0148 \u00b7 vlastn\u00ed n\u00e1vrh',[
|
|
['\u010cela sk\u0159\u00edn\u011bk',stdLabel('kitchenColor')+' '+s.kitchenColor,vis],['Korpusy sk\u0159\u00edn\u011bk',stdLabel('kitchenBodyColor')+' '+s.kitchenBodyColor,vis],['Pracovn\u00ed deska',stdLabel('worktop'),vis],['Obklad linky',stdLabel('backsplash'),vis]]],
|
|
['Koupelna a sanita',[
|
|
['Sprcha',s.shower==='tray'?'Ravak Chrome 900 \u00d7 900 mm, vani\u010dka + z\u00e1st\u011bna':'Walk-in, z\u00e1st\u011bna 800 mm, \u017el\u00e1bek 750 mm','Katalog str. 21; variantu a cenu potvrdit'],
|
|
['Baterie',s.faucet==='chrome'?'Ravak Classic \u00b7 chrom':'\u010cern\u00e9 proveden\u00ed \u00b7 typ neur\u010den',s.faucet==='chrome'?'Standard':vis],
|
|
['Vana','Ravak Chrome 1700 \u00d7 750 mm','Dle projektu'],['Umyvadlo koupelny','Laufen Pro 600 \u00d7 420 mm','Mal\u00e9 umyvadlo na WC: typ potvrdit'],['WC','2\u00d7 Geberit 112 / Ravak Uni Chrome Rimoff','Softclose; b\u00edl\u00e1 Sigma; dle p\u016fdorysu']]],
|
|
['Technika \u00b7 specifikace',[
|
|
['Rekuperace',({local:'Lok\u00e1ln\u00ed Dalap Zephir',central:'Brink Flair / Air Excellent',none:'Bez rekuperace'})[s.ventilation],spec],['Klimatizace',s.aircon?'Panasonic':'Bez klimatizace',spec],['Nucen\u00e9 v\u011btr\u00e1n\u00ed',s.fans?'Airflow ECO 15':'Bez dopl\u0148kov\u00fdch ventil\u00e1tor\u016f',s.fans?premium+'; '+spec:spec],
|
|
['Digesto\u0159',s.hood==='facade'?'V\u00fdvod na fas\u00e1du \u00d8 150 mm':'Cirkula\u010dn\u00ed',s.hood==='facade'?premium+'; '+spec:'Standard; '+spec],['Cirkulace T\u00daV',s.hotWaterCirculation?'Ano':'Ne',spec],['Koralux 500 \u00d7 1500',s.radiator==='electric'?'Elektropatrona':'Elektropatrona + samostatn\u00fd okruh',s.radiator==='combined'?premium+'; '+spec:'Standard'],['Z\u00e1suvky / vyp\u00edna\u010de','ABB Levit b\u00edl\u00e1 / b\u00edl\u00e1','Standard'],['Vyt\u00e1p\u011bn\u00ed','Teplovodn\u00ed podlahov\u00e9 / Panasonic vzduch-voda','V\u00fdkon a rozvody dle projektu TZB'],['P\u016fdn\u00ed sch\u016fdky','Fakro LWT','Rozm\u011br potvrdit dle projektu']]]];
|
|
return groups;
|
|
}
|
|
function stdSync(){
|
|
if(!stdReady)return;
|
|
for(const el of document.querySelectorAll('[data-std-input]')){const v=stdState[el.dataset.stdInput];if(el.type==='checkbox')el.checked=v;else if(document.activeElement!==el)el.value=v;}
|
|
for(const b of document.querySelectorAll('[data-std-key]')){
|
|
const k=b.dataset.stdKey;const v=k==='floorSample'?stdState.floors[stdFloorRoom].sample:k==='wallTile'?stdState['wallTile'+stdTileRoom]:stdState[k];b.setAttribute('aria-pressed',String(String(v)===b.dataset.stdValue));
|
|
}
|
|
const currents={entry:stdLabel('entryModel')+' \u00b7 '+stdLabel('entryColor'),extSill:stdLabel('extSill'),gutter:'Lindab '+stdLabel('gutter'),roof:stdLabel('roof'),blinds:stdState.blinds?stdLabel('blindColor')+' \u00b7 '+stdState.blindDrop+' %':'Vypnuto \u00b7 voliteln\u00e9',floors:'9 m\u00edstnost\u00ed \u00b7 samostatn\u00fd v\u00fdb\u011br dekoru',tiles:'Koupelna '+stdState.wallTile105+' \u00b7 WC '+stdState.wallTile106,doors:'Pr\u00fcm CPL \u00b7 '+stdLabel('intHandle')+' rozeta',finishes:'Helopal '+stdLabel('intSill')+' \u00b7 '+(stdState.wallPaint==='#ffffff'?'HET b\u00edl\u00e1':stdState.wallPaint),kitchen:stdLabel('kitchenColor')+' \u00b7 '+stdLabel('worktop'),bath:stdLabel('shower')+' \u00b7 '+stdLabel('faucet'),tech:'V\u011btr\u00e1n\u00ed, topen\u00ed, elektro'};
|
|
for(const e of document.querySelectorAll('[data-std-current]'))e.textContent=currents[e.dataset.stdCurrent]||'';
|
|
$('#stdBlindsControls').classList.toggle('std-disabled',!stdState.blinds);$('#stdBlindsControls').querySelectorAll('button,input').forEach(e=>e.disabled=!stdState.blinds);$('#stdBlindDropLabel').textContent=stdState.blindDrop+' %';
|
|
$('#stdDoorCustom').hidden=stdState.intDoor!=='custom';
|
|
$('#stdFloorRoom').value=stdFloorRoom;$('#stdFloorType').value=stdState.floors[stdFloorRoom].type;$('#stdTileRoom').value=stdTileRoom;
|
|
const f=stdState.floors[stdFloorRoom],r=STD_ROOMS.find(r=>r[0]===stdFloorRoom);
|
|
if($('#stdFloorSamples').dataset.type!==f.type){$('#stdFloorSamples').innerHTML=stdSampleCards('floorSample',f.type);$('#stdFloorSamples').dataset.type=f.type;$('#stdFloorSamples').querySelectorAll('button').forEach(b=>b.setAttribute('aria-pressed',String(+b.dataset.stdValue===f.sample)));}
|
|
$('#stdFloorProjectNote').textContent='Projekt: '+(r[3]==='lam'?'lamin\u00e1t':'keramick\u00e1 dla\u017eba')+'. '+(f.type===r[3]?'Zvolen\u00fd druh krytiny odpov\u00edd\u00e1 projektu.':'Zvolen\u00fd druh se od projektu li\u0161\u00ed. Nutn\u00e9 potvrdit skladbu a v\u00fd\u0161ku podlahy.');
|
|
$('#stdFloorProjectNote').classList.toggle('amber',f.type!==r[3]);
|
|
$('#stdShowerSpec').textContent=stdState.shower==='tray'?'Ravak Chrome 900 \u00d7 900 mm, vani\u010dka a z\u00e1st\u011bna. Ravak Classic a sprchov\u00fd set 922 se 3 funkcemi.':'Walk-in: nerezov\u00fd odtokov\u00fd \u017el\u00e1bek 750 mm, pevn\u00e1 z\u00e1st\u011bna 800 mm, sp\u00e1dov\u00fd profil. Potvrdit skladbu podlahy, hydroizolaci a cenu.';
|
|
$('#stdSummaryContent').innerHTML=stdSummary().map(([heading,rows])=>`<section class="std-summary-group"><h4>${stdEsc(heading)}</h4>${rows.map(([a,b,c])=>`<div class="std-summary-row"><span>${stdEsc(a)}</span><b>${stdEsc(b)}<small>${stdEsc(c)}</small></b></div>`).join('')}</section>`).join('');
|
|
}
|
|
function stdSet(key,value){
|
|
const next=stdClone(stdState);
|
|
if(key==='floorSample')next.floors[stdFloorRoom].sample=+value;else if(key==='wallTile')next['wallTile'+stdTileRoom]=+value;else next[key]=value;
|
|
try{stdState=stdNormalize(next);if(['note','intDoorCode','intDoorType','tileCorner','hood','fans','aircon','ventilation','hotWaterCirculation','radiator'].includes(key)){gltf.extras.allstavSelection=stdClone(stdState);stdSync();clearTimeout(stdSaveTimer);stdSaveTimer=setTimeout(stdPersist,220);}else stdApply();}catch(e){toast(e.message);}
|
|
}
|
|
function stdClose(){document.body.classList.remove('std-open');$('#standardsToggle').classList.remove('active');$('#standardsToggle').setAttribute('aria-expanded','false');resize();}
|
|
function stdOpen(tab=stdTab){
|
|
stdSwitching=true;
|
|
if($('#sunPanel').classList.contains('open'))toggleSunPanel(false);
|
|
$('#panel').classList.remove('open');$('#infoButton').classList.remove('active');
|
|
if(tab==='interior'&&!interior.enabled)toggleInterior(true);if(tab==='exterior'&&interior.enabled)toggleInterior(false);
|
|
if($('#sunPanel').classList.contains('open'))toggleSunPanel(false);
|
|
document.body.classList.add('std-open');stdTab=tab;
|
|
$('#standardsToggle').classList.add('active');$('#standardsToggle').setAttribute('aria-expanded','true');
|
|
document.querySelectorAll('[data-std-tab]').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.stdTab===tab)));
|
|
['exterior','interior','summary'].forEach(k=>$('#std-'+k).hidden=k!==tab);
|
|
autoRotate=false;$('#rotateButton')?.classList.remove('active');resize();stdSync();stdSwitching=false;
|
|
}
|
|
function stdView(id){
|
|
if(['entry','terrace','site'].includes(id)){
|
|
stdOpen('exterior');if(id==='site'){setPreset('entry');return;}
|
|
setCamera(id==='entry'?{theta:-.12,phi:.09,distance:Math.max(8.5,8.5/aspect),target:[-3.65,1.22,3.84],ortho:false}:{theta:1.05,phi:.34,distance:Math.max(37,34/aspect),target:[1.0,1.0,0],ortho:false},id==='entry'?'Vstup \u00b7 standardy':'Terasa \u00b7 standardy');return;
|
|
}
|
|
stdOpen('interior');interior.furniture=true;$('#interiorFurniture').checked=true;
|
|
if(id==='plan'||id==='axon'){setInteriorMode(id);return;}
|
|
if(id==='shower'){
|
|
enterInteriorView('105');interior.position=[-2.20,1.55,-2.10];const look=[-3.48,.93,-3.82],dir=vsub(look,interior.position);interior.yaw=Math.atan2(dir[0],dir[2]);interior.pitch=Math.atan2(dir[1],Math.hypot(dir[0],dir[2]));interior.fov=72;dirty=true;return;
|
|
}
|
|
if(id==='kitchen'){const v=interior.data.presets.find(p=>/kitchen/i.test(p.id));enterInteriorView(v?.id||'108');return;}
|
|
enterInteriorView(id);
|
|
}
|
|
function stdShowCatalog(page){
|
|
stdCatalogPage=page==='requestedDoor'?'requestedDoor':Math.min(25,Math.max(12,+page||12));
|
|
const key=stdCatalogPage==='requestedDoor'?'requestedDoor':'page'+stdCatalogPage;
|
|
$('#stdCatalogImage').src=STD_ASSETS[key];$('#stdCatalogImage').alt=stdCatalogPage==='requestedDoor'?'V\u00e1\u0161 referen\u010dn\u00ed obr\u00e1zek dve\u0159\u00ed':'ALLSTAV Standardy 2025, strana '+stdCatalogPage;
|
|
$('#stdCatalogTitle').textContent=stdCatalogPage==='requestedDoor'?'V\u00fdchoz\u00ed dve\u0159e \u00b7 v\u00e1\u0161 obr\u00e1zek':'ALLSTAV Standardy 2025 \u00b7 strana '+stdCatalogPage;
|
|
$('#stdCatalogCaption').textContent='P\u016fvodn\u00ed podklad. Odst\u00edny na displeji a fotografie jsou pouze orienta\u010dn\u00ed; konkr\u00e9tn\u00ed v\u00fdrobek a dostupnost ov\u011b\u0159te u dodavatele.';
|
|
if(!$('#stdCatalogDialog').open)$('#stdCatalogDialog').showModal();
|
|
interior.keys.clear();interior.pad.clear();
|
|
}
|
|
function stdExportData(){return{schema:'allstav-rd-config',version:1,catalog:'ALLSTAV Standardy, zari 2025',project:'RD Locenice',exportedAt:new Date().toISOString(),selection:stdClone(stdState),summary:stdSummary(),scope:'Illustrative geometry. Kitchen is a custom visualization. Technical alternatives are specification-only.'};}
|
|
function stdText(){return 'RD LOCENICE | VYBER STANDARDU\nALLSTAV, katalog zari 2025\n'+new Date().toLocaleDateString('cs-CZ')+'\n\n'+stdSummary().map(([title,rows])=>title.toUpperCase()+'\n'+rows.map(([a,b,c])=>a+': '+b+' ['+c+']').join('\n')).join('\n\n')+'\n\nPOZNAMKA\n'+(stdState.note||'-')+'\n\nSoupis neni cenovou nabidkou. Kody neoznacenych vzorku, ceny, dostupnost a projektove zmeny potvrdit s dodavatelem. Vlastni kuchynske a barevne alternativy nejsou katalogovym standardem. 3D je ilustrativni, nikoli provadeci dokumentace.\n';}
|
|
function stdSaveHtml(){
|
|
const payload=stdExportData();const bytes=new TextEncoder().encode(JSON.stringify(payload));
|
|
let binaryText='';bytes.forEach(b=>binaryText+=String.fromCharCode(b));
|
|
const encoded=btoa(binaryText).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
|
const url=new URL(location.href);url.searchParams.delete('renderer');url.hash='vyber='+encoded;
|
|
// Hash stays in the browser, not a server-side database. It contains the note too.
|
|
if(navigator.clipboard&&window.isSecureContext)navigator.clipboard.writeText(url.href).then(()=>toast('Odkaz s vyberem byl zkopirovan. Obsahuje i poznamku.'),()=>prompt('Zkopirujte odkaz; obsahuje i poznamku:',url.href));
|
|
else prompt('Zkopirujte odkaz; obsahuje i poznamku:',url.href);
|
|
}
|
|
|
|
async function stdImport(file){
|
|
if(!file)return;
|
|
try{if(file.size>2*1024*1024)throw Error('Soubor je p\u0159\u00edli\u0161 velk\u00fd. Limit je 2 MB.');const data=JSON.parse(await file.text());if(data.schema!=='allstav-rd-config'||data.version!==1)throw Error('Nezn\u00e1m\u00fd form\u00e1t nebo verze JSON.');const next=stdNormalize(data.selection);stdState=next;stdApply();toast('Nastaven\u00ed bylo na\u010dteno.');}catch(e){toast('Import se nezda\u0159il: '+e.message);}
|
|
$('#stdImport').value='';
|
|
}
|
|
function stdAction(action){
|
|
if(action==='floorView')return stdView(stdFloorRoom);
|
|
if(action==='matchKitchenBody')return stdSet('kitchenBodyColor',stdState.kitchenColor);
|
|
if(action==='copyFloor'){const f=stdState.floors[stdFloorRoom];for(const r of STD_ROOMS)if(stdState.floors[r[0]].type===f.type)stdState.floors[r[0]].sample=f.sample;stdApply();toast('Vzorek p\u0159enesen do m\u00edstnost\u00ed se stejnou krytinou.');return;}
|
|
if(action==='exportJson')return saveBlob(new Blob([JSON.stringify(stdExportData(),null,2)],{type:'application/json'}),'allstav_muj_vyber.json');
|
|
if(action==='exportText')return saveBlob(new Blob(['\ufeff'+stdText()],{type:'text/plain;charset=utf-8'}),'allstav_soupis_standardu.txt');
|
|
if(action==='importJson')return $('#stdImport').click();
|
|
if(action==='saveHtml')return stdSaveHtml();
|
|
if(action==='summary')return stdOpen('summary');
|
|
if(action==='png'){render();canvas.toBlob(b=>{if(b)saveBlob(b,'dum_ALLSTAV_nahled.png');});return;}
|
|
if(action==='glb')return saveBlob(new Blob([currentGlb()],{type:'model/gltf-binary'}),'dum_ALLSTAV_viditelny_vyber.glb');
|
|
if(action==='reset')return $('#stdResetDialog').showModal();
|
|
if(action==='original'){stdClose();$('#panel').classList.add('open');$('#infoButton').classList.add('active');}
|
|
}
|
|
function initStandards(){
|
|
let loaded=false,embedded={};
|
|
stdState=stdClone(STD_DEFAULTS);
|
|
try{embedded=JSON.parse($('#stdSavedState').textContent||'{}');}catch(e){console.info('ALLSTAV: invalid embedded settings ignored.');}
|
|
if(embedded.selection){
|
|
try{stdState=stdNormalize(embedded.selection);loaded=true;}catch(e){console.info('ALLSTAV: invalid embedded selection ignored.');}
|
|
let id=embedded.storageId;
|
|
if(typeof id!=='string'||!/^[a-z0-9_-]{1,100}$/i.test(id)){
|
|
// Stable fallback for HTML exports made by the previous version.
|
|
let hash=2166136261;for(const ch of JSON.stringify(embedded.selection)){hash=Math.imul(hash^ch.charCodeAt(0),16777619);}
|
|
id='legacy-'+(hash>>>0).toString(36);
|
|
}
|
|
stdStorageKey=STD_STORAGE+'.copy.'+id;
|
|
}else stdStorageKey=STD_STORAGE;
|
|
// Edits made after opening an exported copy take precedence on reload.
|
|
// A new copy has its own key and opens with its embedded selection.
|
|
try{const stored=localStorage.getItem(stdStorageKey);if(stored){stdState=stdNormalize(JSON.parse(stored));loaded=true;}}catch(e){console.info('ALLSTAV: invalid or inaccessible local settings ignored.');}
|
|
stdBuildPanel();stdReady=true;stdApply(false);
|
|
$('#standardsToggle').onclick=()=>document.body.classList.contains('std-open')?stdClose():stdOpen(interior.enabled?'interior':stdTab==='interior'?'exterior':stdTab);
|
|
$('#stdClose').onclick=stdClose;
|
|
$('#standardsPanel').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.stdKey){const k=b.dataset.stdKey,v=b.dataset.stdValue;stdSet(k,v);}else if(b.dataset.stdTab){stdOpen(b.dataset.stdTab);}else if(b.dataset.stdPage){stdShowCatalog(b.dataset.stdPage);}else if(b.dataset.stdView){stdView(b.dataset.stdView);}else if(b.dataset.stdAction){stdAction(b.dataset.stdAction);}});
|
|
$('#standardsPanel').addEventListener('input',e=>{const el=e.target,k=el.dataset.stdInput;if(!k)return;let v=el.type==='checkbox'?el.checked:el.type==='range'?+el.value:el.value;stdSet(k,v);});
|
|
$('#stdFloorRoom').onchange=e=>{stdFloorRoom=e.target.value;stdSync();};
|
|
$('#stdFloorType').onchange=e=>{stdState.floors[stdFloorRoom]={type:e.target.value,sample:1};stdApply();};
|
|
$('#stdTileRoom').onchange=e=>{stdTileRoom=e.target.value;stdSync();};
|
|
$('#stdCatalogClose').onclick=()=>$('#stdCatalogDialog').close();$('#stdCatalogPrev').onclick=()=>stdShowCatalog(typeof stdCatalogPage==='number'?Math.max(12,stdCatalogPage-1):12);$('#stdCatalogNext').onclick=()=>stdShowCatalog(typeof stdCatalogPage==='number'?Math.min(25,stdCatalogPage+1):12);
|
|
$('#stdResetCancel').onclick=()=>$('#stdResetDialog').close();$('#stdResetConfirm').onclick=()=>{stdState=stdClone(STD_DEFAULTS);stdApply();$('#stdResetDialog').close();toast('V\u00fdchoz\u00ed v\u00fdb\u011br byl obnoven.');};
|
|
$('#stdImport').onchange=e=>stdImport(e.target.files[0]);
|
|
for(const id of ['stdCatalogDialog','stdResetDialog'])$('#'+id).addEventListener('click',e=>{if(e.target.id===id){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}});
|
|
const oldSun=toggleSunPanel;toggleSunPanel=function(on){if(on&&!stdSwitching)stdClose();return oldSun(on);};
|
|
const oldToggle=toggleInterior;toggleInterior=function(on){if(!stdSwitching&&document.body.classList.contains('std-open'))stdClose();return oldToggle(on);};
|
|
window.INTERIOR.toggle=toggleInterior;
|
|
const oldPaint=setInteriorWallColor;setInteriorWallColor=function(hex,persist=true){const result=oldPaint(hex,persist);if(persist&&stdReady){stdState.wallPaint=interior.wallColor;stdSync();stdPersist();}return result;};window.INTERIOR.setWallColor=setInteriorWallColor;
|
|
const oldInfo=$('#infoButton').onclick;$('#infoButton').onclick=e=>{stdClose();oldInfo?.(e);};
|
|
const oldPanel=$('#interiorPanelToggle').onclick;$('#interiorPanelToggle').onclick=e=>{stdClose();oldPanel?.(e);};
|
|
// Keep legacy wall colour controls synchronized with the new selection.
|
|
document.querySelectorAll('[data-wall-color]').forEach(b=>b.addEventListener('click',()=>{stdState.wallPaint=interior.wallColor;stdSync();stdPersist();}));
|
|
$('#roofColor').disabled=true;$('#roofColor').title='Barvu krytiny vyberte v sekci Standardy / Exteri\u00e9r.';
|
|
$('#resetColors')?.addEventListener('click',()=>stdApply());
|
|
window.addEventListener('keydown',e=>{if($('#stdCatalogDialog').open||$('#stdResetDialog').open){e.stopImmediatePropagation();return;}if(e.key==='Escape'&&document.body.classList.contains('std-open')){e.preventDefault();e.stopImmediatePropagation();stdClose();}},true);
|
|
window.ALLSTAV_CONFIG={getState:()=>stdClone(stdState),defaults:()=>stdClone(STD_DEFAULTS),set:(patch)=>{stdState=stdNormalize({...stdClone(stdState),...patch});stdApply();return stdClone(stdState);},open:stdOpen,close:stdClose,view:stdView,summary:stdSummary,exportData:stdExportData,normalize:stdNormalize,visibleWindows:()=>[...new Set(meshes.filter(m=>m.std?.stdBlind&&visible(m)).map(m=>m.std.windowNode))],sourcePage:stdShowCatalog,saveHtml:stdSaveHtml,validate:()=>({windowCount:gltf.extras.allstavStandards.windows.length,triangleExcluded:!meshes.some(m=>m.std?.stdBlind&&m.std.windowNode===gltf.extras.allstavStandards.triangleExcludedNode),rooms:STD_ROOMS.length,materials:gltf.extras.allstavStandards.materials})};
|
|
if(loaded)$('#stdSaved').textContent='Na\u010dten v\u00e1\u0161 ulo\u017een\u00fd v\u00fdb\u011br';else $('#stdSaved').textContent='V\u00fdchoz\u00ed v\u00fdb\u011br \u00b7 zm\u011bny se ukl\u00e1daj\u00ed automaticky';
|
|
window.addEventListener('pagehide',()=>{if(stdSaveTimer){clearTimeout(stdSaveTimer);stdPersist();}});
|
|
stdOpen('exterior');stdView('terrace');
|
|
}
|
|
|
|
start(); |