Files

190 lines
7.1 KiB
JavaScript

/*!
* 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(tectumUrl(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 = 'tb-pop-wrap';
wrap.innerHTML = `<button type="button" class="tb-btn" title="Omgeving in 3D"></button>
<div class="tb-pop">${MODES.map(k => `<button type="button" class="pop-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</button>`).join('')}</div>`;
toolbar.appendChild(wrap);
const btn = wrap.querySelector('.tb-btn'), menu = wrap.querySelector('.tb-pop');
const close = UI.popover(btn, menu);
const sync = () => {
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i><span>${LABELS[m][1]}</span><i class="bi bi-chevron-down tb-caret"></i>`;
menu.querySelectorAll('.pop-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
};
menu.addEventListener('click', (e) => {
const opt = e.target.closest('.pop-option');
if (!opt) return;
close();
if (opt.dataset.mode === m) return;
m = opt.dataset.mode;
remember(m);
sync();
onChange(m);
});
sync();
return wrap;
}
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, MODES, LABELS, createEnvironment, apply, getMode, setMode: remember, lightFactor, createToggle };
})();