/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // Wavefront OBJ + MTL + texturen (PNG) uit three.js-objecten, in meters, Y omhoog (Blender: standaard import). // Texturen: herhaling/verschuiving (texture.matrix) zit in de UV's, de MTL verwijst naar textures/*.png. // PBR via de MTL-uitbreiding (Pr ruwheid, Pm metaal, d doorzichtigheid, Ke gloed), die Blender inleest. window.ObjWriter = (() => { const f4 = (v) => { const r = Math.round(v * 10000) / 10000; return Object.is(r, -0) ? '0' : String(r); }; const f3 = (v) => { const r = Math.round(v * 1000) / 1000; return Object.is(r, -0) ? '0' : String(r); }; const safe = (s) => String(s || '').replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 40); function pngBytes(image) { try { const w = image.naturalWidth || image.videoWidth || image.width, h = image.naturalHeight || image.videoHeight || image.height; if (!w || !h) return null; const c = document.createElement('canvas'); c.width = w; c.height = h; c.getContext('2d').drawImage(image, 0, 0); const bin = atob(c.toDataURL('image/png').split(',')[1]); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } catch (e) { return null; } } // objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; name: basisnaam van de bestanden. function build(objects, opts) { const o = opts || {}; const name = o.name || 'woning', origin = o.origin || { x: 0, y: 0, z: 0 }; const enc = new TextEncoder(); const obj = [`# Tectum${o.app ? ' ' + o.app : ''}\n# meters, Y omhoog\nmtllib ${name}.mtl\n`], chunks = []; const mats = new Map(), textures = new Map(), files = []; let vBase = 1, tBase = 1, nBase = 1, meshNr = 0, triCount = 0; const flush = () => { if (obj.length > 2000) { chunks.push(enc.encode(obj.join(''))); obj.length = 0; } }; function textureFile(tex) { if (!tex || !tex.image) return null; const key = tex.image; if (!textures.has(key)) { const bytes = pngBytes(tex.image); const file = bytes ? `textures/tex_${textures.size + 1}.png` : null; if (bytes) files.push({ name: file, data: bytes }); textures.set(key, file); } return textures.get(key); } function matName(m) { if (!mats.has(m)) mats.set(m, { name: `m${mats.size + 1}_${safe(m.name || m.type)}`, mat: m, map: textureFile(m.map) }); return mats.get(m); } const v = new THREE.Vector3(), nm = new THREE.Matrix3(), uv = new THREE.Vector2(); // Eerst de hele boom bijwerken: een mesh alleen gebruikt de (verouderde) matrix van zijn groep. const visit = (root) => root && (root.updateMatrixWorld(true), root.traverseVisible(mesh => { if (!mesh.isMesh || !mesh.geometry || !mesh.geometry.attributes.position) return; const g = mesh.geometry, 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 out = [`o ${safe(mesh.parent && mesh.parent.name) || 'deel'}_${++meshNr}\n`]; for (let i = 0; i < pos.count; i++) { v.fromBufferAttribute(pos, i).applyMatrix4(mesh.matrixWorld); out.push(`v ${f4(v.x - origin.x)} ${f4(v.y - origin.y)} ${f4(v.z - origin.z)}\n`); } if (nrm) for (let i = 0; i < nrm.count; i++) { v.fromBufferAttribute(nrm, i).applyMatrix3(nm).normalize(); out.push(`vn ${f3(v.x)} ${f3(v.y)} ${f3(v.z)}\n`); } let tCount = 0; groups.forEach(gr => { const m = list[gr.materialIndex] || list[0]; if (!m || m.visible === false) return; const mi = matName(m); out.push(`usemtl ${mi.name}\n`); const map = mi.map && m.map ? m.map : null; if (map) map.updateMatrix(); const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count); for (let k = gr.start; k + 2 < end; k += 3) { let c = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j); if (flip) c = [c[0], c[2], c[1]]; const corner = c.map(ci => { let s = `${vBase + ci}`; if (uvA && map) { uv.fromBufferAttribute(uvA, ci).applyMatrix3(map.matrix); out.push(`vt ${f4(uv.x)} ${f4(uv.y)}\n`); s += `/${tBase + tCount++}`; } else if (nrm) s += '/'; if (nrm) s += `/${nBase + ci}`; return s; }); out.push(`f ${corner.join(' ')}\n`); triCount++; } }); vBase += pos.count; tBase += tCount; if (nrm) nBase += nrm.count; obj.push(out.join('')); flush(); })); objects.forEach(visit); chunks.push(enc.encode(obj.join(''))); // De app toont kleurwaarden zoals ze zijn (geen kleurbeheer); Blender rekent lineair: sRGB -> lineair. const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); const col = (c) => c ? `${f3(lin(c.r))} ${f3(lin(c.g))} ${f3(lin(c.b))}` : '0.6 0.6 0.6'; const mtl = [`# Tectum materialen\n`]; mats.forEach(({ name: n, mat: m, map }) => { mtl.push(`\nnewmtl ${n}\n`); mtl.push(`Kd ${col(m.color)}\n`); mtl.push('Ks 0.5 0.5 0.5\n'); if (m.emissive && (m.emissive.r || m.emissive.g || m.emissive.b)) { const k = m.emissiveIntensity !== undefined ? m.emissiveIntensity : 1; mtl.push(`Ke ${f3(lin(m.emissive.r) * k)} ${f3(lin(m.emissive.g) * k)} ${f3(lin(m.emissive.b) * k)}\n`); } if (m.roughness !== undefined) mtl.push(`Pr ${f3(m.roughness)}\n`); if (m.metalness !== undefined) mtl.push(`Pm ${f3(m.metalness)}\n`); const d = m.transparent ? (m.opacity !== undefined ? m.opacity : 1) : 1; mtl.push(`d ${f3(d)}\nillum 2\n`); if (map) mtl.push(`map_Kd ${map}\n`); }); return { files: [{ name: `${name}.obj`, data: chunks }, { name: `${name}.mtl`, data: enc.encode(mtl.join('')) }].concat(files), stats: { meshes: meshNr, triangles: triCount, materials: mats.size, textures: files.length } }; } return { build }; })();