190 lines
7.1 KiB
JavaScript
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 };
|
|
})();
|