/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // Grondprojectie: port van three.js GroundedSkybox (r157+, MIT); wij draaien r128. window.Skybox = (() => { const PREF_KEY = 'tectum.sky'; const MODES = ['none', 'day', 'night']; const SKY_URLS = { day: 'assets/skybox.webp', night: 'assets/skybox-night.webp' }; const TILES_URL = 'assets/tiles.jpg'; const CAMERA_HEIGHT_M = 1.7; const RADIUS_M = 60; const TILE_M = 2.4; const MARGIN_M = 1.5; const SIZE_FACTOR = 1.2; const RIM_HEIGHT_M = 0.10, RIM_THICK_M = 0.025; const NIGHT_FACTOR = 0.15; const SUN_OFFSET = { x: 8, y: 14, z: 6 }; const cache = {}; function loadTexture(url) { if (!cache[url]) { cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(url, resolve, undefined, reject)); cache[url].catch(() => { delete cache[url]; }); } return cache[url]; } function buildSky(map) { const geometry = new THREE.SphereGeometry(RADIUS_M, 256, 128); geometry.scale(1, 1, -1); // binnenkant zichtbaar const pos = geometry.getAttribute('position'); const v = new THREE.Vector3(); const h = CAMERA_HEIGHT_M, y1 = -h * 3 / 2; for (let i = 0; i < pos.count; i++) { v.fromBufferAttribute(pos, i); if (v.y < 0) { const f = v.y < y1 ? -h / v.y : (1 - v.y * v.y / (3 * y1 * y1)); v.multiplyScalar(f); pos.setXYZ(i, v.x, v.y, v.z); } } pos.needsUpdate = true; const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map, depthWrite: false })); mesh.position.y = h - 0.01; mesh.renderOrder = -1; return mesh; } function buildTerrace(map, r) { const group = new THREE.Group(); const tex = map.clone(); tex.needsUpdate = true; tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.anisotropy = 8; tex.repeat.set(2 * r / TILE_M, 2 * r / TILE_M); const floor = new THREE.Mesh(new THREE.CircleGeometry(r, 128), new THREE.MeshStandardMaterial({ map: tex, roughness: 0.85 })); floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; group.add(floor); const ring = new THREE.Shape(); ring.absarc(0, 0, r + RIM_THICK_M, 0, Math.PI * 2, false); const hole = new THREE.Path(); hole.absarc(0, 0, r, 0, Math.PI * 2, true); ring.holes.push(hole); const rim = new THREE.Mesh( new THREE.ExtrudeGeometry(ring, { depth: RIM_HEIGHT_M, bevelEnabled: false, curveSegments: 128 }), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 }) ); rim.rotation.x = -Math.PI / 2; rim.castShadow = rim.receiveShadow = true; group.add(rim); return group; } function createEnvironment(scene) { const group = new THREE.Group(); group.visible = false; scene.add(group); const skies = {}; let terrace = null, terraceR = 0, tilesTex = null, mode = 'none'; function showSky() { Object.keys(skies).forEach(k => { skies[k].visible = k === mode; }); if (mode === 'none' || skies[mode]) return; const wanted = mode; loadTexture(SKY_URLS[wanted]).then(tex => { if (!skies[wanted]) { skies[wanted] = buildSky(tex); group.add(skies[wanted]); } Object.keys(skies).forEach(k => { skies[k].visible = k === mode; }); }).catch(() => console.warn('Tectum: lucht niet geladen (draait de app via een webserver?)')); } function setTerrace(r) { if (terrace && Math.abs(r - terraceR) < 0.05) return; terraceR = r; const build = () => { if (terrace) { group.remove(terrace); terrace.traverse(o => { if (o.geometry) o.geometry.dispose(); }); } terrace = buildTerrace(tilesTex, terraceR); group.add(terrace); }; if (tilesTex) build(); else loadTexture(TILES_URL).then(t => { tilesTex = t; build(); }).catch(() => {}); } return { group, setMode(m) { mode = m; group.visible = m !== 'none'; showSky(); }, fitTo(object3D, groundY) { if (!object3D) return; const box = new THREE.Box3().setFromObject(object3D); if (box.isEmpty()) return; const c = box.getCenter(new THREE.Vector3()); group.position.set(c.x, groundY, c.z); setTerrace((Math.hypot(box.max.x - box.min.x, box.max.z - box.min.z) / 2 + MARGIN_M) * SIZE_FACTOR); } }; } function getMode() { try { const v = localStorage.getItem(PREF_KEY); if (MODES.includes(v)) return v; return localStorage.getItem('tectum.skybox') === '1' ? 'day' : 'none'; // oude aan/uit-voorkeur } catch (e) { return 'none'; } } function remember(m) { try { localStorage.setItem(PREF_KEY, m); } catch (e) { } } const lightFactor = (m) => m === 'night' ? NIGHT_FACTOR : 1; function apply(m, o) { const on = m !== 'none'; o.env.setMode(m); if (o.grid) o.grid.visible = !on; if (o.controls) o.controls.maxPolarAngle = on ? Math.PI / 2 - 0.03 : Math.PI - 0.001; const f = lightFactor(m); if (o.ambient && o.base) o.ambient.intensity = o.base.ambient * f; if (o.sun && o.base) o.sun.intensity = o.base.sun * f; if (o.sun) { o.sun.castShadow = on; if (on) { const p = o.env.group.position, cam = o.sun.shadow.camera; cam.left = cam.bottom = -20; cam.right = cam.top = 20; cam.near = 0.5; cam.far = 80; cam.updateProjectionMatrix(); o.sun.shadow.mapSize.set(2048, 2048); o.sun.shadow.bias = -0.0005; o.sun.position.set(p.x + SUN_OFFSET.x, p.y + SUN_OFFSET.y, p.z + SUN_OFFSET.z); o.sun.target.position.copy(p); o.sun.target.updateMatrixWorld(); } } if (o.shadowRoot) o.shadowRoot.traverse(obj => { if (obj.isMesh) obj.castShadow = on; }); } const LABELS = { none: ['pencil-square', 'Ontwerp'], day: ['sun', 'Dag'], night: ['moon-stars', 'Nacht'] }; function createToggle(toolbar, onChange) { let m = getMode(); const wrap = document.createElement('div'); wrap.className = 'view-filter'; wrap.innerHTML = `
`; toolbar.appendChild(wrap); const btn = wrap.querySelector('.vf-btn'), menu = wrap.querySelector('.vf-menu'); const sync = () => { btn.innerHTML = `${LABELS[m][1]}`; menu.querySelectorAll('.vf-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m)); }; btn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('open'); }); menu.addEventListener('click', (e) => { e.stopPropagation(); const opt = e.target.closest('.vf-option'); if (!opt) return; menu.classList.remove('open'); if (opt.dataset.mode === m) return; m = opt.dataset.mode; remember(m); sync(); onChange(m); }); const closeOnOutside = () => { if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; } menu.classList.remove('open'); }; document.addEventListener('click', closeOnOutside); sync(); return wrap; } return { CAMERA_HEIGHT_M, NIGHT_FACTOR, MODES, LABELS, createEnvironment, apply, getMode, setMode: remember, lightFactor, createToggle }; })();