Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
432e0f494a |
@@ -20,11 +20,12 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
|||||||
```
|
```
|
||||||
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
||||||
css/app.css alle stijl
|
css/app.css alle stijl
|
||||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF)
|
js/vendor/ three.js r128, eigen OrbitControls, jsPDF, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF),
|
||||||
|
model-viewer 4.3.1 (Apache-2.0, Google; UMD-bundel met eigen three.js, AR in de light-versie)
|
||||||
js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel
|
js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel
|
||||||
(inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen,
|
(inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen,
|
||||||
DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver
|
DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver
|
||||||
(obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen,
|
(obj-writer.js), GLB-schrijver (glb-writer.js), ZIP (zip-writer.js), labels, camerageheugen,
|
||||||
skybox, licentie, logo
|
skybox, licentie, logo
|
||||||
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
||||||
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
||||||
@@ -339,7 +340,7 @@ assets/ logo, texturen, skybox
|
|||||||
Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via
|
Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via
|
||||||
`light.html?model=<sleutel uit HouseModel.MODELS>`). Inbedden: de stylesheets en scripts uit `light.html`
|
`light.html?model=<sleutel uit HouseModel.MODELS>`). Inbedden: de stylesheets en scripts uit `light.html`
|
||||||
plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
|
plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
|
||||||
`TectumLight.mount(el, { model, frames })`.
|
`TectumLight.mount(el, { model, frames, url, modelViewer })`.
|
||||||
|
|
||||||
- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont
|
- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont
|
||||||
alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
|
alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
|
||||||
@@ -379,6 +380,16 @@ plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
|
|||||||
kosten) en als laatste het stappenplan van de site ("Hoe verloopt het traject?") met een blok over maatwerk
|
kosten) en als laatste het stappenplan van de site ("Hoe verloopt het traject?") met een blok over maatwerk
|
||||||
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
|
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
|
||||||
in `LightPdf.COMPANY` en `STEPS`.
|
in `LightPdf.COMPANY` en `STEPS`.
|
||||||
|
- **AR / schermvullend:** knop rechtsboven in de weergave. Op een telefoon of tablet: het ontwerp als GLB
|
||||||
|
(`GlbWriter`, afgewerkte woning zoals in 3D, één primitive per materiaal, texturen als JPEG/PNG tot 1024 px,
|
||||||
|
lichtbundels weg; maaiveld onder het midden = oorsprong, `HouseModel.groundOrigin`) in model-viewer, over de
|
||||||
|
hele pagina, met de knop "Bekijk in je tuin" (ware grootte; `ar-modes="webxr scene-viewer quick-look"`, op iOS
|
||||||
|
maakt model-viewer zelf de USDZ). model-viewer wordt pas bij de eerste keer geladen
|
||||||
|
(`js/vendor/model-viewer-umd.min.js` naast `js/light/`, of `modelViewer`/`data-model-viewer`). AR vraagt
|
||||||
|
https; Scene Viewer (Android zonder WebXR) heeft bovendien een publieke URL van de GLB nodig, een blob-URL
|
||||||
|
werkt daar niet. Op de desktop: de weergave schermvullend (Fullscreen API; het scrollwiel zoomt dan direct).
|
||||||
|
Per ontwerp wordt de GLB één keer gemaakt; `el.tectumLight.glb()` geeft `{ blob, url, stats }` (op
|
||||||
|
`light.html` de testknop GLB).
|
||||||
|
|
||||||
## Kosten en prijzen (CSV)
|
## Kosten en prijzen (CSV)
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,15 @@
|
|||||||
.tectum-light.view-cut .tl-cutbar { display: flex; }
|
.tectum-light.view-cut .tl-cutbar { display: flex; }
|
||||||
.tectum-light .tl-cutbar input { width: 150px; accent-color: var(--tl-primary); }
|
.tectum-light .tl-cutbar input { width: 150px; accent-color: var(--tl-primary); }
|
||||||
.tectum-light .tl-cutbar output { min-width: 4.2em; font-weight: 600; font-variant-numeric: tabular-nums; }
|
.tectum-light .tl-cutbar output { min-width: 4.2em; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
/* Rechtsboven: AR (telefoon/tablet) of schermvullend (desktop). */
|
||||||
|
.tectum-light .tl-xbtn {
|
||||||
|
position: absolute; top: 10px; right: 10px; width: 35px; height: 35px; padding: 0; border: 0; border-radius: 8px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer;
|
||||||
|
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); color: var(--tl-text);
|
||||||
|
}
|
||||||
|
.tectum-light .tl-xbtn:hover { color: var(--tl-primary); }
|
||||||
|
.tectum-light .tl-xbtn[hidden] { display: none; }
|
||||||
|
.tectum-light.has-xbtn .tl-busy { right: 53px; }
|
||||||
.tectum-light .tl-hint { left: 10px; bottom: 10px; font-size: 12px; color: var(--tl-muted); padding: 3px 8px; box-shadow: none; pointer-events: none; }
|
.tectum-light .tl-hint { left: 10px; bottom: 10px; font-size: 12px; color: var(--tl-muted); padding: 3px 8px; box-shadow: none; pointer-events: none; }
|
||||||
.tectum-light .tl-busy { top: 10px; right: 10px; display: none; align-items: center; gap: 8px; padding: 6px 12px; }
|
.tectum-light .tl-busy { top: 10px; right: 10px; display: none; align-items: center; gap: 8px; padding: 6px 12px; }
|
||||||
.tectum-light.is-busy .tl-busy { display: inline-flex; }
|
.tectum-light.is-busy .tl-busy { display: inline-flex; }
|
||||||
@@ -174,6 +183,8 @@
|
|||||||
.tectum-light .tl-hint { display: none; }
|
.tectum-light .tl-hint { display: none; }
|
||||||
.tectum-light .tl-viewbar { left: 8px; right: 8px; top: 8px; }
|
.tectum-light .tl-viewbar { left: 8px; right: 8px; top: 8px; }
|
||||||
.tectum-light .tl-viewbar button { flex: 1; justify-content: center; }
|
.tectum-light .tl-viewbar button { flex: 1; justify-content: center; }
|
||||||
|
.tectum-light.has-xbtn .tl-viewbar { right: 51px; }
|
||||||
|
.tectum-light .tl-xbtn { top: 8px; right: 8px; width: 37px; height: 37px; }
|
||||||
.tectum-light .tl-cutbar { left: 8px; right: 8px; top: 50px; }
|
.tectum-light .tl-cutbar { left: 8px; right: 8px; top: 50px; }
|
||||||
.tectum-light .tl-cutbar input { flex: 1; width: auto; }
|
.tectum-light .tl-cutbar input { flex: 1; width: auto; }
|
||||||
.tectum-light .tl-busy { top: auto; bottom: 10px; right: 10px; }
|
.tectum-light .tl-busy { top: auto; bottom: 10px; right: 10px; }
|
||||||
@@ -187,3 +198,34 @@
|
|||||||
.tectum-light .tl-viewbar button .bi { display: none; }
|
.tectum-light .tl-viewbar button .bi { display: none; }
|
||||||
.tectum-light .tl-facts { grid-template-columns: 1fr; }
|
.tectum-light .tl-facts { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AR-weergave (model-viewer) over de hele pagina. Staat buiten .tectum-light: die is een container en zou het kader van
|
||||||
|
position: fixed worden. Kleur van de knop = --tl-primary. */
|
||||||
|
html.tl-noscroll, html.tl-noscroll body { overflow: hidden; }
|
||||||
|
.tl-arview {
|
||||||
|
position: fixed; inset: 0; z-index: 2147483000; display: none; background: #dfe6ec; color: #212121;
|
||||||
|
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.tl-arview.on { display: block; }
|
||||||
|
.tl-arview *, .tl-arview *::before, .tl-arview *::after { box-sizing: border-box; }
|
||||||
|
.tl-arview model-viewer { display: block; width: 100%; height: 100%; background: #dfe6ec; --poster-color: transparent; }
|
||||||
|
.tl-arview .tl-ar-top {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: max(10px, env(safe-area-inset-top)) 12px 10px 16px; pointer-events: none;
|
||||||
|
}
|
||||||
|
.tl-arview .tl-ar-title { font-size: 17px; font-weight: 700; }
|
||||||
|
.tl-arview .tl-ar-close {
|
||||||
|
pointer-events: auto; width: 44px; height: 44px; margin: 0; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; color: inherit;
|
||||||
|
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.tl-arview .tl-ar-go {
|
||||||
|
position: absolute; left: 50%; bottom: calc(44px + env(safe-area-inset-bottom)); transform: translateX(-50%);
|
||||||
|
display: inline-flex; align-items: center; gap: 9px; height: 50px; margin: 0; padding: 0 24px; border: 0; border-radius: 25px;
|
||||||
|
background: #b95913; color: #fff; font-family: inherit; font-size: 16px; font-weight: 600; line-height: 1; white-space: nowrap; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.tl-arview .tl-ar-go .bi { font-size: 22px; }
|
||||||
|
.tl-arview .tl-ar-note {
|
||||||
|
position: absolute; left: 16px; right: 16px; bottom: calc(14px + env(safe-area-inset-bottom));
|
||||||
|
text-align: center; font-size: 12px; color: #5b6673; pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
* Zie LICENTIE.md.
|
* Zie LICENTIE.md.
|
||||||
*/
|
*/
|
||||||
const APP_VERSION = '0.7.4';
|
const APP_VERSION = '0.7.5';
|
||||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||||
|
|
||||||
let tabs = [];
|
let tabs = [];
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// glTF 2.0 binair (GLB) uit three.js-objecten, in meters, Y omhoog; bedoeld voor AR (model-viewer: WebXR, Scene Viewer,
|
||||||
|
// Quick Look) en andere viewers. Alles in wereldcoördinaten samengevoegd tot één mesh met één primitive per materiaal
|
||||||
|
// (weinig draw calls op een telefoon). Texturen: herhaling/verschuiving (texture.matrix) zit in de UV's, beelden als
|
||||||
|
// JPEG (of PNG bij doorzichtig), maximaal MAX_TEX px. Lichteffecten (bundels, vegen: onbelicht en doorschijnend zonder
|
||||||
|
// dieptebuffer, of additief) blijven weg; die horen bij de eigen belichting, niet in een model.
|
||||||
|
|
||||||
|
window.GlbWriter = (() => {
|
||||||
|
const MAX_TEX = 1024;
|
||||||
|
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||||
|
const WRAP = { 1000: 10497, 1001: 33071, 1002: 33648 }; // three Repeat/Clamp/Mirrored -> glTF
|
||||||
|
|
||||||
|
const isEffect = (m) => m.blending !== THREE.NormalBlending || (m.isMeshBasicMaterial && m.transparent && m.depthWrite === false);
|
||||||
|
|
||||||
|
// Beeld als JPEG/PNG-bytes, afmetingen een macht van 2 (mipmaps en herhalen ook op oudere toestellen), hoogstens
|
||||||
|
// MAX_TEX; null als het (nog) niet te tekenen is. UV's zijn 0..1, dus het rekken verandert niets aan het beeld.
|
||||||
|
const pot = (n) => Math.min(MAX_TEX, Math.pow(2, Math.max(0, Math.round(Math.log2(n)))));
|
||||||
|
function imageBytes(image, png) {
|
||||||
|
try {
|
||||||
|
const w0 = image.naturalWidth || image.videoWidth || image.width, h0 = image.naturalHeight || image.videoHeight || image.height;
|
||||||
|
if (!w0 || !h0) return null;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = pot(w0); c.height = pot(h0);
|
||||||
|
c.getContext('2d').drawImage(image, 0, 0, c.width, c.height);
|
||||||
|
const bin = atob(c.toDataURL(png ? 'image/png' : 'image/jpeg', 0.85).split(',')[1]);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return { data: out, mime: png ? 'image/png' : 'image/jpeg' };
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters. Geeft { blob, stats }.
|
||||||
|
function build(objects, opts) {
|
||||||
|
const o = opts || {};
|
||||||
|
const origin = o.origin || { x: 0, y: 0, z: 0 };
|
||||||
|
const buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
|
||||||
|
const images = new Map(), samplers = new Map(), textures = new Map();
|
||||||
|
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
|
||||||
|
const bin = [];
|
||||||
|
let binLength = 0, triCount = 0;
|
||||||
|
function addView(data, target) {
|
||||||
|
const pad = (4 - binLength % 4) % 4;
|
||||||
|
if (pad) { bin.push(new Uint8Array(pad)); binLength += pad; }
|
||||||
|
const view = { buffer: 0, byteOffset: binLength, byteLength: data.byteLength };
|
||||||
|
if (target) view.target = target;
|
||||||
|
bin.push(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||||
|
binLength += data.byteLength;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
const views = [];
|
||||||
|
|
||||||
|
function textureIndex(tex, png) {
|
||||||
|
if (!tex || !tex.image) return null;
|
||||||
|
if (!images.has(tex.image)) {
|
||||||
|
const img = imageBytes(tex.image, png);
|
||||||
|
if (img) { views.push(addView(img.data)); gltfImages.push({ bufferView: views.length - 1, mimeType: img.mime }); }
|
||||||
|
images.set(tex.image, img ? gltfImages.length - 1 : null);
|
||||||
|
}
|
||||||
|
const source = images.get(tex.image);
|
||||||
|
if (source === null) return null;
|
||||||
|
const sKey = `${tex.wrapS}/${tex.wrapT}`;
|
||||||
|
if (!samplers.has(sKey)) {
|
||||||
|
gltfSamplers.push({ magFilter: 9729, minFilter: 9987, wrapS: WRAP[tex.wrapS] || 10497, wrapT: WRAP[tex.wrapT] || 10497 });
|
||||||
|
samplers.set(sKey, gltfSamplers.length - 1);
|
||||||
|
}
|
||||||
|
const tKey = `${source}/${samplers.get(sKey)}`;
|
||||||
|
if (!textures.has(tKey)) { gltfTextures.push({ source, sampler: samplers.get(sKey) }); textures.set(tKey, gltfTextures.length - 1); }
|
||||||
|
return textures.get(tKey);
|
||||||
|
}
|
||||||
|
function bucketOf(m) {
|
||||||
|
if (!buckets.has(m)) {
|
||||||
|
const map = m.map && m.map.image ? m.map : null;
|
||||||
|
const tex = map ? textureIndex(map, !!m.transparent) : null;
|
||||||
|
buckets.set(m, { mat: m, map: tex !== null ? map : null, tex, pos: [], nrm: [], uv: [], idx: [], n: 0 });
|
||||||
|
}
|
||||||
|
return buckets.get(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = new THREE.Vector3(), nm = new THREE.Matrix3(), uv = new THREE.Vector2();
|
||||||
|
const pa = new THREE.Vector3(), pb = new THREE.Vector3(), pc = new THREE.Vector3(), face = new THREE.Vector3();
|
||||||
|
const visit = (root) => root && (root.updateMatrixWorld(true), root.traverseVisible(mesh => {
|
||||||
|
if (!mesh.isMesh || !mesh.geometry || !mesh.geometry.attributes.position) return;
|
||||||
|
let g = mesh.geometry;
|
||||||
|
if (!g.attributes.normal) { g = g.clone(); g.computeVertexNormals(); }
|
||||||
|
const pos = g.attributes.position, nrm = g.attributes.normal, uvA = g.attributes.uv, idx = g.index;
|
||||||
|
nm.getNormalMatrix(mesh.matrixWorld);
|
||||||
|
const flip = mesh.matrixWorld.determinant() < 0;
|
||||||
|
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||||
|
const groups = g.groups && g.groups.length ? g.groups : [{ start: 0, count: idx ? idx.count : pos.count, materialIndex: 0 }];
|
||||||
|
const remaps = new Map(); // bucket -> bronhoekpunt -> index in de bucket
|
||||||
|
groups.forEach(gr => {
|
||||||
|
const m = list[gr.materialIndex] || list[0];
|
||||||
|
if (!m || m.visible === false || isEffect(m)) return;
|
||||||
|
const b = bucketOf(m);
|
||||||
|
if (!remaps.has(b)) remaps.set(b, new Int32Array(pos.count).fill(-1));
|
||||||
|
const remap = remaps.get(b);
|
||||||
|
if (b.map) b.map.updateMatrix();
|
||||||
|
const add = (ci) => {
|
||||||
|
if (remap[ci] >= 0) return remap[ci];
|
||||||
|
v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld);
|
||||||
|
b.pos.push(v.x - origin.x, v.y - origin.y, v.z - origin.z);
|
||||||
|
// Een lege normaal (bron met nul-driehoeken) wordt de vlaknormaal: glTF eist lengte 1.
|
||||||
|
v.fromBufferAttribute(nrm, ci).applyMatrix3(nm);
|
||||||
|
if (v.lengthSq() < 1e-12) v.copy(face); else v.normalize();
|
||||||
|
b.nrm.push(v.x, v.y, v.z);
|
||||||
|
if (b.map) {
|
||||||
|
if (uvA) uv.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix); else uv.set(0, 0);
|
||||||
|
// three (flipY) heeft v = 0 onderaan het beeld, glTF bovenaan.
|
||||||
|
b.uv.push(uv.x, b.map.flipY ? 1 - uv.y : uv.y);
|
||||||
|
}
|
||||||
|
return (remap[ci] = b.n++);
|
||||||
|
};
|
||||||
|
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
|
||||||
|
for (let k = gr.start; k + 2 < end; k += 3) {
|
||||||
|
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
|
||||||
|
// Driehoeken zonder oppervlakte (< 0,1 mm²) overslaan.
|
||||||
|
pa.fromBufferAttribute(pos, s[0]).applyMatrix4(mesh.matrixWorld);
|
||||||
|
pb.fromBufferAttribute(pos, s[1]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||||
|
pc.fromBufferAttribute(pos, s[2]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||||
|
face.crossVectors(pb, pc);
|
||||||
|
const len = face.length();
|
||||||
|
if (len < 2e-7) continue;
|
||||||
|
face.multiplyScalar(flip ? -1 / len : 1 / len);
|
||||||
|
const c = s.map(add);
|
||||||
|
if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
|
||||||
|
triCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
objects.forEach(visit);
|
||||||
|
|
||||||
|
// Accessors en materialen per bucket.
|
||||||
|
const accessors = [], materials = [], primitives = [];
|
||||||
|
let unlit = false;
|
||||||
|
const accessor = (arr, type, comps, ctype, target, minMax) => {
|
||||||
|
views.push(addView(arr, target));
|
||||||
|
const a = { bufferView: views.length - 1, componentType: ctype, count: arr.length / comps, type };
|
||||||
|
if (minMax) Object.assign(a, minMax);
|
||||||
|
accessors.push(a);
|
||||||
|
return accessors.length - 1;
|
||||||
|
};
|
||||||
|
buckets.forEach(b => {
|
||||||
|
if (!b.idx.length) return;
|
||||||
|
const m = b.mat, pos = new Float32Array(b.pos);
|
||||||
|
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
||||||
|
for (let i = 0; i < pos.length; i++) { const a = i % 3; if (pos[i] < min[a]) min[a] = pos[i]; if (pos[i] > max[a]) max[a] = pos[i]; }
|
||||||
|
const attributes = {
|
||||||
|
POSITION: accessor(pos, 'VEC3', 3, 5126, 34962, { min, max }),
|
||||||
|
NORMAL: accessor(new Float32Array(b.nrm), 'VEC3', 3, 5126, 34962)
|
||||||
|
};
|
||||||
|
if (b.map) attributes.TEXCOORD_0 = accessor(new Float32Array(b.uv), 'VEC2', 2, 5126, 34962);
|
||||||
|
const big = b.n >= 65535; // 65535 zelf mag niet in Uint16 (glTF)
|
||||||
|
const indices = accessor(big ? new Uint32Array(b.idx) : new Uint16Array(b.idx), 'SCALAR', 1, big ? 5125 : 5123, 34963);
|
||||||
|
|
||||||
|
// De app toont kleurwaarden zoals ze zijn (geen kleurbeheer); glTF-factoren zijn lineair, beelden sRGB.
|
||||||
|
const c = m.color || { r: 0.6, g: 0.6, b: 0.6 };
|
||||||
|
const alpha = m.transparent ? (m.opacity !== undefined ? m.opacity : 1) : 1;
|
||||||
|
const pbr = {
|
||||||
|
baseColorFactor: [lin(c.r), lin(c.g), lin(c.b), alpha],
|
||||||
|
metallicFactor: m.metalness !== undefined ? m.metalness : 0,
|
||||||
|
roughnessFactor: m.roughness !== undefined ? m.roughness : 0.85
|
||||||
|
};
|
||||||
|
if (b.tex !== null) pbr.baseColorTexture = { index: b.tex };
|
||||||
|
const mat = { name: m.name || m.type, pbrMetallicRoughness: pbr };
|
||||||
|
if (m.emissive && (m.emissive.r || m.emissive.g || m.emissive.b)) {
|
||||||
|
const k = m.emissiveIntensity !== undefined ? m.emissiveIntensity : 1;
|
||||||
|
mat.emissiveFactor = [m.emissive.r, m.emissive.g, m.emissive.b].map(x => Math.min(1, lin(x) * k));
|
||||||
|
}
|
||||||
|
if (m.transparent && (alpha < 1 || b.tex !== null)) mat.alphaMode = 'BLEND';
|
||||||
|
if (m.side !== THREE.FrontSide) mat.doubleSided = true;
|
||||||
|
if (m.isMeshBasicMaterial) { mat.extensions = { KHR_materials_unlit: {} }; unlit = true; }
|
||||||
|
materials.push(mat);
|
||||||
|
primitives.push({ attributes, indices, material: materials.length - 1, mode: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
asset: { version: '2.0', generator: `Tectum${o.app ? ' ' + o.app : ''}` },
|
||||||
|
scene: 0,
|
||||||
|
scenes: [{ name: o.name || 'woning', nodes: [0] }],
|
||||||
|
nodes: [{ name: o.name || 'woning', mesh: 0 }],
|
||||||
|
meshes: [{ name: o.name || 'woning', primitives }],
|
||||||
|
materials, accessors, bufferViews: views,
|
||||||
|
buffers: [{ byteLength: 0 }]
|
||||||
|
};
|
||||||
|
if (gltfImages.length) Object.assign(json, { images: gltfImages, samplers: gltfSamplers, textures: gltfTextures });
|
||||||
|
if (unlit) json.extensionsUsed = ['KHR_materials_unlit'];
|
||||||
|
const binPad = (4 - binLength % 4) % 4;
|
||||||
|
if (binPad) { bin.push(new Uint8Array(binPad)); binLength += binPad; }
|
||||||
|
json.buffers[0].byteLength = binLength;
|
||||||
|
|
||||||
|
// GLB: kop (12 bytes), JSON-blok (opgevuld met spaties), BIN-blok (opgevuld met nullen).
|
||||||
|
let jsonBytes = new TextEncoder().encode(JSON.stringify(json));
|
||||||
|
const jsonPad = (4 - jsonBytes.length % 4) % 4;
|
||||||
|
if (jsonPad) { const p = new Uint8Array(jsonBytes.length + jsonPad).fill(0x20); p.set(jsonBytes); jsonBytes = p; }
|
||||||
|
const total = 12 + 8 + jsonBytes.length + 8 + binLength;
|
||||||
|
const head = new DataView(new ArrayBuffer(20));
|
||||||
|
head.setUint32(0, 0x46546C67, true); head.setUint32(4, 2, true); head.setUint32(8, total, true);
|
||||||
|
head.setUint32(12, jsonBytes.length, true); head.setUint32(16, 0x4E4F534A, true);
|
||||||
|
const binHead = new DataView(new ArrayBuffer(8));
|
||||||
|
binHead.setUint32(0, binLength, true); binHead.setUint32(4, 0x004E4942, true);
|
||||||
|
const blob = new Blob([head, jsonBytes, binHead].concat(bin), { type: 'model/gltf-binary' });
|
||||||
|
return { blob, stats: { triangles: triCount, materials: materials.length, textures: gltfImages.length, bytes: total } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { build };
|
||||||
|
})();
|
||||||
+16
-1
@@ -2051,6 +2051,21 @@ window.HouseModel = (() => {
|
|||||||
return ext.concat(parts.interior);
|
return ext.concat(parts.interior);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alle 3D-groepen van de woning (na placeGroups), bijv. voor een scène of een export.
|
||||||
|
function rootGroups(parts) {
|
||||||
|
return parts.units.map(u => u.floorGroup).concat(allWallPieces(parts).map(p => p.group), parts.units.map(u => u.roof.group),
|
||||||
|
[parts.roofEdgeGroup, parts.postsGroup, parts.chimneysGroup, parts.sandwichGroup, parts.lightingGroup, parts.elektraGroup, parts.roomsGroup, parts.fixturesGroup]).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oorsprong voor exports (m): maaiveld onder het midden van de units.
|
||||||
|
function groundOrigin(st, parts) {
|
||||||
|
const rs = parts.rects;
|
||||||
|
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
|
||||||
|
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
|
||||||
|
const ground = Math.max(...parts.units.map(u => u.layout.floorTop)) - (st.settings.groundLevel || 0);
|
||||||
|
return { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 };
|
||||||
|
}
|
||||||
|
|
||||||
function computeDimensions(parts, groundLevel) {
|
function computeDimensions(parts, groundLevel) {
|
||||||
const m2 = (mm2) => mm2 / 1e6;
|
const m2 = (mm2) => mm2 / 1e6;
|
||||||
const rects = parts.rects;
|
const rects = parts.rects;
|
||||||
@@ -2379,6 +2394,6 @@ window.HouseModel = (() => {
|
|||||||
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
||||||
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
||||||
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
|
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
|
||||||
computeParts, placeGroups, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
computeParts, placeGroups, rootGroups, groundOrigin, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+2
-7
@@ -2846,14 +2846,9 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true
|
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true
|
||||||
});
|
});
|
||||||
HouseModel.placeGroups(full);
|
HouseModel.placeGroups(full);
|
||||||
const roots = full.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(full).map(p => p.group), full.units.map(u => u.roof.group),
|
|
||||||
[full.roofEdgeGroup, full.postsGroup, full.chimneysGroup, full.sandwichGroup, full.lightingGroup, full.elektraGroup, full.roomsGroup, full.fixturesGroup]).filter(Boolean);
|
|
||||||
const bx0 = Math.min(...full.rects.map(r => r.x0)), bx1 = Math.max(...full.rects.map(r => r.x1));
|
|
||||||
const bz0 = Math.min(...full.rects.map(r => r.z0)), bz1 = Math.max(...full.rects.map(r => r.z1));
|
|
||||||
const ground = Math.max(...full.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
|
|
||||||
const name = filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning';
|
const name = filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning';
|
||||||
const origin = { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 };
|
const origin = HouseModel.groundOrigin(state, full);
|
||||||
const out = ObjWriter.build(roots, { name, app: APP_VERSION, origin });
|
const out = ObjWriter.build(HouseModel.rootGroups(full), { name, app: APP_VERSION, origin });
|
||||||
const edge = full.roofEdge;
|
const edge = full.roofEdge;
|
||||||
const lights = LightingModel.lightSources(full.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, full.lighting.indoor);
|
const lights = LightingModel.lightSources(full.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, full.lighting.indoor);
|
||||||
out.files.push({ name: `${name}_lampen.py`, data: new TextEncoder().encode(ObjWriter.blenderLights(lights, { name, app: APP_VERSION, origin })) });
|
out.files.push({ name: `${name}_lampen.py`, data: new TextEncoder().encode(ObjWriter.blenderLights(lights, { name, app: APP_VERSION, origin })) });
|
||||||
|
|||||||
+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
|
// 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).
|
// 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
|
// 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 = (() => {
|
window.TectumLight = (() => {
|
||||||
const esc = UI.esc;
|
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 nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
||||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||||
@@ -33,6 +51,7 @@ window.TectumLight = (() => {
|
|||||||
<label class="tl-cutbar"><span>Snijhoogte</span>
|
<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">
|
<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>
|
<output>${meters(SECTION.start)}</output></label>
|
||||||
|
<button type="button" class="tl-xbtn" hidden></button>
|
||||||
<div class="tl-hint"></div>
|
<div class="tl-hint"></div>
|
||||||
<div class="tl-busy" aria-live="polite"><span class="tl-spinner"></span>Berekenen…</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>
|
<div class="tl-toast" role="status" aria-live="polite"></div>
|
||||||
@@ -81,8 +100,7 @@ window.TectumLight = (() => {
|
|||||||
mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef');
|
mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef');
|
||||||
const compute = (st) => HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
|
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 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),
|
const rootsOf = HouseModel.rootGroups;
|
||||||
[p.roofEdgeGroup, p.postsGroup, p.chimneysGroup, p.sandwichGroup, p.lightingGroup, p.elektraGroup, p.roomsGroup, p.fixturesGroup]).filter(Boolean);
|
|
||||||
|
|
||||||
// Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond.
|
// 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;
|
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();
|
placeCut();
|
||||||
if (view === 'plan') drawPlan();
|
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.
|
// Snijvlak en witte afdekking op de snijhoogte; alleen dit bij het schuiven.
|
||||||
function placeCut() {
|
function placeCut() {
|
||||||
const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop));
|
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.
|
// Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie.
|
||||||
function snapshot() {
|
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() };
|
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||||
if (view !== '3d') {
|
const url = as3d(() => {
|
||||||
view = '3d';
|
if (other && poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
||||||
applyView();
|
renderer.setPixelRatio(1);
|
||||||
if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
renderer.setSize(W, H, false);
|
||||||
}
|
camera.aspect = W / H;
|
||||||
renderer.setPixelRatio(1);
|
camera.updateProjectionMatrix();
|
||||||
renderer.setSize(W, H, false);
|
controls.update();
|
||||||
camera.aspect = W / H;
|
renderer.render(scene, camera);
|
||||||
camera.updateProjectionMatrix();
|
return renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||||||
controls.update();
|
});
|
||||||
renderer.render(scene, camera);
|
|
||||||
const url = renderer.domElement.toDataURL('image/jpeg', 0.88);
|
|
||||||
renderer.setPixelRatio(pr);
|
renderer.setPixelRatio(pr);
|
||||||
if (prev !== '3d') {
|
if (other) {
|
||||||
view = prev;
|
|
||||||
camera.position.copy(keep.pos);
|
camera.position.copy(keep.pos);
|
||||||
controls.target.copy(keep.target);
|
controls.target.copy(keep.target);
|
||||||
controls.update();
|
controls.update();
|
||||||
applyView();
|
|
||||||
}
|
}
|
||||||
resize();
|
resize();
|
||||||
return { url, width: W, height: H };
|
return { url, width: W, height: H };
|
||||||
@@ -609,20 +629,111 @@ window.TectumLight = (() => {
|
|||||||
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
|
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
|
||||||
const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); });
|
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
|
// 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;
|
let engaged = false;
|
||||||
const stageEl = $('.tl-stage');
|
const stageEl = $('.tl-stage');
|
||||||
stageEl.addEventListener('pointerdown', () => { engaged = true; });
|
stageEl.addEventListener('pointerdown', () => { engaged = true; });
|
||||||
stageEl.addEventListener('pointerleave', () => { engaged = false; });
|
stageEl.addEventListener('pointerleave', () => { engaged = false; });
|
||||||
stageEl.addEventListener('wheel', (e) => {
|
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();
|
e.stopPropagation();
|
||||||
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
||||||
}, { capture: true });
|
}, { capture: true });
|
||||||
io.observe(stage);
|
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() {
|
(function loop() {
|
||||||
raf = requestAnimationFrame(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');
|
root.classList.add('is-busy');
|
||||||
@@ -636,11 +747,19 @@ window.TectumLight = (() => {
|
|||||||
unmount() {
|
unmount() {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
ro.disconnect(); io.disconnect(); offCosts(); controls.dispose();
|
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);
|
if (group) disposeGeo(group);
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
root.innerHTML = '';
|
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 => {
|
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
|
||||||
if (el.dataset.mounted) return;
|
if (el.dataset.mounted) return;
|
||||||
el.dataset.mounted = '1';
|
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);
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount);
|
||||||
|
|||||||
Vendored
+1117
File diff suppressed because one or more lines are too long
Vendored
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+18
-3
@@ -19,7 +19,7 @@
|
|||||||
.page { max-width: 1400px; margin: 0 auto; }
|
.page { max-width: 1400px; margin: 0 auto; }
|
||||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
.page-head img { height: 34px; }
|
.page-head img { height: 34px; }
|
||||||
.page-head select { font: inherit; padding: 6px 10px; border: 1px solid #c5ced9; border-radius: 8px; background: #fff; }
|
.page-head select, .page-head button { font: inherit; padding: 6px 10px; border: 1px solid #c5ced9; border-radius: 8px; background: #fff; }
|
||||||
@media (max-width: 820px) { body { padding: 12px; } }
|
@media (max-width: 820px) { body { padding: 12px; } }
|
||||||
</style>
|
</style>
|
||||||
<script src="js/vendor/three.min.js"></script>
|
<script src="js/vendor/three.min.js"></script>
|
||||||
@@ -30,7 +30,11 @@
|
|||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<img src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
<img src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
||||||
<!-- Alleen voor het losse testen: op een modelpagina staat het model vast. -->
|
<!-- Alleen voor het losse testen: op een modelpagina staat het model vast. -->
|
||||||
<select id="model-pick" aria-label="Model"></select>
|
<div>
|
||||||
|
<select id="model-pick" aria-label="Model"></select>
|
||||||
|
<!-- Alleen voor het testen: de GLB die op een telefoon in AR wordt getoond (bijv. in Blender of een glTF-viewer). -->
|
||||||
|
<button type="button" id="glb-dl" title="De GLB die AR gebruikt downloaden">GLB</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="configurator" class="tectum-light"></div>
|
<div id="configurator" class="tectum-light"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,6 +44,7 @@
|
|||||||
<script src="js/core/view-layers.js"></script>
|
<script src="js/core/view-layers.js"></script>
|
||||||
<script src="js/core/ui-kit.js"></script>
|
<script src="js/core/ui-kit.js"></script>
|
||||||
<script src="js/core/skybox.js"></script>
|
<script src="js/core/skybox.js"></script>
|
||||||
|
<script src="js/core/glb-writer.js"></script>
|
||||||
<script src="js/vendor/jspdf.umd.min.js"></script>
|
<script src="js/vendor/jspdf.umd.min.js"></script>
|
||||||
<script src="js/vendor/qrcode.js"></script>
|
<script src="js/vendor/qrcode.js"></script>
|
||||||
<script src="js/core/pdf-sheet.js"></script>
|
<script src="js/core/pdf-sheet.js"></script>
|
||||||
@@ -75,7 +80,17 @@
|
|||||||
const pick = document.getElementById('model-pick');
|
const pick = document.getElementById('model-pick');
|
||||||
pick.innerHTML = Object.entries(HouseModel.MODELS).map(([k, m]) => `<option value="${k}"${k === model ? ' selected' : ''}>${m.label}</option>`).join('');
|
pick.innerHTML = Object.entries(HouseModel.MODELS).map(([k, m]) => `<option value="${k}"${k === model ? ' selected' : ''}>${m.label}</option>`).join('');
|
||||||
pick.addEventListener('change', () => { location.search = '?model=' + pick.value; });
|
pick.addEventListener('change', () => { location.search = '?model=' + pick.value; });
|
||||||
document.getElementById('configurator').dataset.model = model;
|
const el = document.getElementById('configurator'), btn = document.getElementById('glb-dl');
|
||||||
|
el.dataset.model = model;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const g = el.tectumLight && el.tectumLight.glb();
|
||||||
|
if (!g) return;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = g.url;
|
||||||
|
a.download = `${model}.glb`;
|
||||||
|
a.click();
|
||||||
|
btn.textContent = `GLB · ${(g.stats.bytes / 1e6).toLocaleString('nl-NL', { maximumFractionDigits: 1 })} MB · ${g.stats.triangles.toLocaleString('nl-NL')} driehoeken`;
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script src="js/light/light.js"></script>
|
<script src="js/light/light.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user