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
@@ -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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user