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
@@ -4,7 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* 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.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const m2 = (mm2) => mm2 / 1e6;
|
||||
const rects = parts.rects;
|
||||
@@ -2379,6 +2394,6 @@ window.HouseModel = (() => {
|
||||
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
||||
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
||||
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
|
||||
});
|
||||
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 origin = { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 };
|
||||
const out = ObjWriter.build(roots, { name, app: APP_VERSION, origin });
|
||||
const origin = HouseModel.groundOrigin(state, full);
|
||||
const out = ObjWriter.build(HouseModel.rootGroups(full), { name, app: APP_VERSION, origin });
|
||||
const edge = full.roofEdge;
|
||||
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 })) });
|
||||
|
||||
+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);
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user