v0.7.5: AR en schermvullend in de light-versie: knop rechtsboven in de weergave; op een telefoon/tablet het ontwerp als GLB in model-viewer over de hele pagina met "Bekijk in je tuin" (ware grootte, WebXR/Scene Viewer/Quick Look, op iOS maakt model-viewer de USDZ; model-viewer 4.3.1, Apache-2.0, UMD met eigen three.js, pas geladen bij de eerste keer AR), op de desktop de weergave schermvullend (scrollwiel zoomt dan direct); GlbWriter (js/core/glb-writer.js): glTF 2.0 binair uit three.js-objecten, één primitive per materiaal, PBR met lineaire kleuren, texturen als JPEG/PNG op een macht van 2 tot 1024 px met de textuurmatrix in de UV's, lichtbundels en -vegen weg, nul-driehoeken eruit en lege normalen naar de vlaknormaal (valideert zonder fouten); GLB één keer per ontwerp, el.tectumLight.glb() en een testknop GLB op light.html; HouseModel.rootGroups en groundOrigin gedeeld met de render-export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-14 12:06:09 +02:00
co-authored by Claude Opus 5.5
parent decbbb4b72
commit 432e0f494a
10 changed files with 1766 additions and 39 deletions
+143 -24
View File
@@ -8,9 +8,27 @@
// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met
// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen).
// Inbedden: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
// TectumLight.mount(el, { model, frames, url }); url = adres voor de QR-code in de PDF (standaard de modelpagina).
// TectumLight.mount(el, { model, frames, url, modelViewer }); url = adres voor de QR-code in de PDF (standaard de
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script).
// Knop rechtsboven in de weergave: op een telefoon/tablet het ontwerp als GLB in model-viewer, met AR (WebXR, Scene
// Viewer, Quick Look); op de desktop schermvullend.
window.TectumLight = (() => {
const esc = UI.esc;
// model-viewer (Apache-2.0, eigen three.js ingebakken) pas laden bij de eerste keer AR.
const SCRIPT_SRC = (document.currentScript && document.currentScript.src) || location.href;
let mvLoading = null;
function loadModelViewer(src) {
if (window.customElements.get('model-viewer')) return Promise.resolve();
if (!mvLoading) mvLoading = new Promise((ok, fail) => {
const s = document.createElement('script');
s.src = src;
s.onload = ok;
s.onerror = () => { mvLoading = null; fail(new Error(`model-viewer niet geladen (${src})`)); };
document.head.appendChild(s);
});
return mvLoading;
}
const AR_DEVICE = /android|iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
const clone = (o) => JSON.parse(JSON.stringify(o));
@@ -33,6 +51,7 @@ window.TectumLight = (() => {
<label class="tl-cutbar"><span>Snijhoogte</span>
<input type="range" min="${SECTION.min}" max="${SECTION.max}" step="50" value="${SECTION.start}" aria-label="Snijhoogte boven de vloer">
<output>${meters(SECTION.start)}</output></label>
<button type="button" class="tl-xbtn" hidden></button>
<div class="tl-hint"></div>
<div class="tl-busy" aria-live="polite"><span class="tl-spinner"></span>Berekenen…</div>
<div class="tl-toast" role="status" aria-live="polite"></div>
@@ -81,8 +100,7 @@ window.TectumLight = (() => {
mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef');
const compute = (st) => HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); });
const rootsOf = (p) => p.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(p).map(x => x.group), p.units.map(u => u.roof.group),
[p.roofEdgeGroup, p.postsGroup, p.chimneysGroup, p.sandwichGroup, p.lightingGroup, p.elektraGroup, p.roomsGroup, p.fixturesGroup]).filter(Boolean);
const rootsOf = HouseModel.rootGroups;
// Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond.
let group = null, caps = null, view = '3d', fitted = false, center = { x: 0, z: 0, size: 10 }, sectionMM = SECTION.start;
@@ -109,6 +127,12 @@ window.TectumLight = (() => {
placeCut();
if (view === 'plan') drawPlan();
}
// fn in de gewone 3D-weergave (dak erop, niets afgesneden), daarna terug naar de gekozen weergave.
function as3d(fn) {
const prev = view;
if (prev !== '3d') { view = '3d'; applyView(); }
try { return fn(); } finally { if (prev !== '3d') { view = prev; applyView(); } }
}
// Snijvlak en witte afdekking op de snijhoogte; alleen dit bij het schuiven.
function placeCut() {
const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop));
@@ -551,27 +575,23 @@ window.TectumLight = (() => {
// Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie.
function snapshot() {
const W = 1600, H = 1500, prev = view, pr = renderer.getPixelRatio();
const W = 1600, H = 1500, other = view !== '3d', pr = renderer.getPixelRatio();
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
if (view !== '3d') {
view = '3d';
applyView();
if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
}
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
camera.aspect = W / H;
camera.updateProjectionMatrix();
controls.update();
renderer.render(scene, camera);
const url = renderer.domElement.toDataURL('image/jpeg', 0.88);
const url = as3d(() => {
if (other && poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
camera.aspect = W / H;
camera.updateProjectionMatrix();
controls.update();
renderer.render(scene, camera);
return renderer.domElement.toDataURL('image/jpeg', 0.88);
});
renderer.setPixelRatio(pr);
if (prev !== '3d') {
view = prev;
if (other) {
camera.position.copy(keep.pos);
controls.target.copy(keep.target);
controls.update();
applyView();
}
resize();
return { url, width: W, height: H };
@@ -609,20 +629,111 @@ window.TectumLight = (() => {
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); });
// In een pagina: het scrollwiel scrolt de pagina, tenzij je in de 3D hebt geklikt (tot je de muis eruit haalt) of
// Ctrl/⌘ ingedrukt houdt; dan zoomt het.
// Ctrl/⌘ ingedrukt houdt (of schermvullend); dan zoomt het.
let engaged = false;
const stageEl = $('.tl-stage');
stageEl.addEventListener('pointerdown', () => { engaged = true; });
stageEl.addEventListener('pointerleave', () => { engaged = false; });
stageEl.addEventListener('wheel', (e) => {
if (engaged || e.ctrlKey || e.metaKey || view === 'plan') return;
if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() === stageEl) return;
e.stopPropagation();
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
}, { capture: true });
io.observe(stage);
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
let glb = null;
function makeGlb() {
if (glb && glb.parts === cur.parts) return glb;
if (glb) URL.revokeObjectURL(glb.url);
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts), { name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts) }));
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
return glb;
}
// Knop rechtsboven: AR op een telefoon/tablet, schermvullend op de desktop.
const xbtn = $('.tl-xbtn');
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
const canFull = !!(stageEl.requestFullscreen || stageEl.webkitRequestFullscreen);
function setXbtn() {
const full = fsEl() === stageEl;
xbtn.innerHTML = `<i class="bi bi-${AR_DEVICE ? 'badge-ar' : full ? 'fullscreen-exit' : 'fullscreen'}"></i>`;
xbtn.title = AR_DEVICE ? 'Bekijk het ontwerp in je eigen tuin (AR)' : full ? 'Schermvullend uit (Esc)' : 'Schermvullend';
xbtn.setAttribute('aria-label', xbtn.title);
}
if (AR_DEVICE || canFull) { xbtn.hidden = false; root.classList.add('has-xbtn'); setXbtn(); }
function toggleFull() {
const r = fsEl() === stageEl ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
: (stageEl.requestFullscreen || stageEl.webkitRequestFullscreen).call(stageEl);
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
}
const onFull = () => { root.classList.toggle('is-full', fsEl() === stageEl); setXbtn(); };
document.addEventListener('fullscreenchange', onFull);
document.addEventListener('webkitfullscreenchange', onFull);
// AR: model-viewer in een eigen laag over de hele pagina (buiten de tool: die is een container en dus het
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan.
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
let arView = null, arOpen = false;
const AR_NOTE = 'Ware grootte · met twee vingers verkleinen of draaien';
function closeAr() {
if (!arOpen) return;
arOpen = false;
arView.classList.remove('on');
document.documentElement.classList.remove('tl-noscroll');
}
const arKey = (e) => { if (arOpen && e.key === 'Escape') closeAr(); };
document.addEventListener('keydown', arKey);
function showAr(url) {
if (!arView) {
arView = document.createElement('div');
arView.className = 'tl-arview';
arView.setAttribute('role', 'dialog');
arView.setAttribute('aria-modal', 'true');
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
ar ar-modes="webxr scene-viewer quick-look" ar-scale="auto" ar-placement="floor">
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
</model-viewer>
<div class="tl-ar-top"><b class="tl-ar-title"></b><button type="button" class="tl-ar-close" aria-label="Sluiten"><i class="bi bi-x-lg"></i></button></div>
<div class="tl-ar-note"></div>`;
document.body.appendChild(arView);
const mv = arView.querySelector('model-viewer'), note = arView.querySelector('.tl-ar-note');
arView.querySelector('.tl-ar-close').addEventListener('click', closeAr);
mv.addEventListener('load', () => {
note.textContent = mv.canActivateAR ? AR_NOTE : 'AR werkt niet op dit toestel of in deze browser; draaien en zoomen kan wel.';
});
mv.addEventListener('ar-status', (e) => { if (e.detail.status === 'failed') note.textContent = 'AR kon niet starten op dit toestel.'; });
}
const mv = arView.querySelector('model-viewer');
arView.setAttribute('aria-label', `${model.label} in 3D en AR`);
arView.querySelector('.tl-ar-title').textContent = model.label;
mv.alt = `3D-model van ${model.label}`;
if (mv.getAttribute('src') !== url) { arView.querySelector('.tl-ar-note').textContent = ''; mv.setAttribute('src', url); }
arView.classList.add('on');
document.documentElement.classList.add('tl-noscroll');
arOpen = true;
}
async function openAr() {
if (!cur || busy) return;
busy = true;
root.classList.add('is-busy');
try {
await new Promise(r => setTimeout(r, 30)); // eerst de spinner tekenen
const g = makeGlb();
await loadModelViewer(mvSrc);
showAr(g.url);
} catch (err) {
console.error(err);
toast('De AR-weergave kon niet worden geopend.');
}
busy = false;
root.classList.remove('is-busy');
}
xbtn.addEventListener('click', () => { if (AR_DEVICE) openAr(); else toggleFull(); });
(function loop() {
raf = requestAnimationFrame(loop);
if (visible && view !== 'plan') renderer.render(scene, camera);
if (visible && !arOpen && view !== 'plan') renderer.render(scene, camera);
})();
root.classList.add('is-busy');
@@ -636,11 +747,19 @@ window.TectumLight = (() => {
unmount() {
cancelAnimationFrame(raf);
ro.disconnect(); io.disconnect(); offCosts(); controls.dispose();
document.removeEventListener('keydown', arKey);
document.removeEventListener('fullscreenchange', onFull);
document.removeEventListener('webkitfullscreenchange', onFull);
closeAr();
if (arView) arView.remove();
if (glb) URL.revokeObjectURL(glb.url);
if (group) disposeGeo(group);
renderer.dispose();
root.innerHTML = '';
},
getChoices: () => clone(ch)
getChoices: () => clone(ch),
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
glb: () => cur && makeGlb()
};
}
@@ -648,7 +767,7 @@ window.TectumLight = (() => {
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
if (el.dataset.mounted) return;
el.dataset.mounted = '1';
try { mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; }
try { el.tectumLight = mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; }
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount);