Files

254 lines
14 KiB
JavaScript

/*!
* 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 };
})();