/*! * 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; clipBelow: alles onder deze hoogte (m, na // de verschuiving) valt weg, driehoeken die de snede kruisen worden afgesneden (AR zet de onderkant van het model op de // grond: met clipBelow 0 op het maaiveld valt die samen met de oorsprong). Geeft { blob, stats }. function build(objects, opts) { const o = opts || {}; const origin = o.origin || { x: 0, y: 0, z: 0 }; const clip = typeof o.clipBelow === 'number', clipY = o.clipBelow, EPS = 1e-6; const originV = new THREE.Vector3(origin.x, origin.y, origin.z); 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++); }; // Afgesneden driehoeken: losse hoekpunten (geïnterpoleerd), niet gedeeld via de remap. const raw = (ci) => { const p = new THREE.Vector3().fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).sub(originV); const n = new THREE.Vector3().fromBufferAttribute(nrm, ci).applyMatrix3(nm); if (n.lengthSq() < 1e-12) n.copy(face); else n.normalize(); const t = new THREE.Vector2(); if (b.map && uvA) t.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix); return { p, n, t }; }; const lerp = (A, B, f) => ({ p: new THREE.Vector3().lerpVectors(A.p, B.p, f), n: new THREE.Vector3().lerpVectors(A.n, B.n, f).normalize(), t: new THREE.Vector2().lerpVectors(A.t, B.t, f) }); const push = (q) => { b.pos.push(q.p.x, q.p.y, q.p.z); b.nrm.push(q.n.x, q.n.y, q.n.z); if (b.map) b.uv.push(q.t.x, b.map.flipY ? 1 - q.t.y : q.t.y); return 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); if (clip) { const ys = s.map(ci => v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).y - origin.y); const inside = ys.map(y => y >= clipY - EPS), nIn = inside.filter(Boolean).length; if (!nIn) continue; if (nIn < 3) { const q = s.map(raw), poly = []; for (let j = 0; j < 3; j++) { const k2 = (j + 1) % 3; if (inside[j]) poly.push(q[j]); if (inside[j] !== inside[k2]) poly.push(lerp(q[j], q[k2], (clipY - ys[j]) / (ys[k2] - ys[j]))); } const c = poly.map(push); for (let j = 1; j + 1 < c.length; j++) { if (flip) b.idx.push(c[0], c[j + 1], c[j]); else b.idx.push(c[0], c[j], c[j + 1]); triCount++; } continue; } } 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 }; })();