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:
co-authored by
Claude Opus 5.5
parent
decbbb4b72
commit
432e0f494a
+143
-24
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user