Files
Tectum/js/core/skybox.js
T

217 lines
9.2 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/skybox.js — omgeving voor de 3D-weergaven (Ontwerp / Dag / Nacht).
// Een panorama (assets/skybox.webp of skybox-night.webp) wordt op de grond
// geprojecteerd, zodat het object op de grond staat; onder het object ligt
// een ronde tegelvloer (assets/tiles.jpg) met een witte opstaande rand, groot
// genoeg voor het hele object. Vloer en rand reageren op het licht en vangen
// de schaduw op. Nacht: omgevingslicht op NIGHT_FACTOR (lampen blijven 100%).
//
// Grondprojectie: port van three.js GroundedSkybox (r157+, MIT; wij draaien
// r128): een binnenstebuiten bol waarvan de onderhelft plat gedrukt wordt op
// een vloer op CAMERA_HEIGHT_M onder het midden van de bol.
//
// Vereist een webserver (texturen via TextureLoader). Plain script.
// ============================================================
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; // hoogte van de panoramacamera boven de grond
const RADIUS_M = 60; // straal van de omgevingsbol
const TILE_M = 2.4; // één tegeltextuur (1024 px) beslaat zoveel meter
const MARGIN_M = 1.5; // vrije rand tussen object en opstaande rand
const SIZE_FACTOR = 1.2; // tegelvloer 20% ruimer dan object + marge
const RIM_HEIGHT_M = 0.10, RIM_THICK_M = 0.025;
const NIGHT_FACTOR = 0.15; // omgevingslicht 's nachts
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) {
// Onderhelft plat op y = -h; vloeiende overgang naar de bol.
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; // vlakke vloer net onder de tegelvloer
mesh.renderOrder = -1;
return mesh;
}
// Ronde tegelvloer (straal r) + witte opstaande rand erlangs.
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; // extrusie omhoog
rim.castShadow = rim.receiveShadow = true;
group.add(rim);
return group;
}
// Omgeving voor één scene: { group, setMode(mode), fitTo(object3D, groundY) }.
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(); },
// Midden en straal uit de omhullende van het object (horizontaal), vloer op groundY (m).
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) { /* geen opslag: alleen deze sessie */ }
}
const lightFactor = (m) => m === 'night' ? NIGHT_FACTOR : 1;
// Omgeving toepassen: lucht/vloer, raster, camera niet onder de grond, zon
// met schaduw, en het licht (nacht: NIGHT_FACTOR).
// o: { env, grid, controls, sun, ambient, base: { ambient, sun }, shadowRoot }
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; });
}
// Keuzemenu in een 3D-werkbalk: Ontwerp (geen omgeving) / Dag / Nacht.
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 = `<button type="button" class="btn-3d-toggle vf-btn" title="Omgeving"></button>
<div class="vf-menu">${MODES.map(k => `<div class="vf-item vf-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</div>`).join('')}</div>`;
toolbar.appendChild(wrap);
const btn = wrap.querySelector('.vf-btn'), menu = wrap.querySelector('.vf-menu');
const sync = () => {
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i>${LABELS[m][1]}<i class="bi bi-chevron-down"></i>`;
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 };
})();