254 lines
14 KiB
JavaScript
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 };
|
|
})();
|