/*! * 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 } }; } // OBJ kent geen lampen: een Blender-script dat de OBJ importeert (als die er nog niet is) en de lampen als echte // spotlights plaatst. sources: LightingModel.lightSources (m, Y omhoog); Blender is Z omhoog: (x, -z, y). const BLENDER_WATT = { wandlamp: 10, dakrandspot: 8, binnenspot: 15 }; function blenderLights(sources, opts) { const o = opts || {}, origin = o.origin || { x: 0, y: 0, z: 0 }, name = o.name || 'woning'; const c = new THREE.Color(o.color !== undefined ? o.color : 0xffd9a0); const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); const t = (v) => `(${v.map(f4).join(', ')})`; const rows = sources.map(s => ` (${JSON.stringify(s.kind)}, ${t([s.x - origin.x, -(s.z - origin.z), s.y - origin.y])}, ${t([s.dir.x, -s.dir.z, s.dir.y])}, ` + `${f3(Math.min(Math.PI, s.angle * 2))}, ${f3(s.penumbra)}, ${BLENDER_WATT[s.kind] || 10}),\n`).join(''); return `# Tectum${o.app ? ' ' + o.app : ''}: lampen voor ${name}.obj # Blender: Scripting > Open > dit bestand > Run Script. Importeert ${name}.obj uit dezelfde map # (IMPORTEER = False als je de OBJ al zelf hebt geïmporteerd) en zet elke lamp als spotlight in de # collectie "Tectum lampen"; opnieuw uitvoeren vervangt de lampen. # HELDERHEID schaalt alle lampen; vermogens in watt per soort staan in de lijst (soort, plek, richting, # kegel, zachte rand, watt). import bpy, os from mathutils import Vector IMPORTEER = True HELDERHEID = 1.0 KLEUR = (${f3(lin(c.r))}, ${f3(lin(c.g))}, ${f3(lin(c.b))}) OBJ = "${name}.obj" LAMPEN = [ ${rows}] def map_van_script(): for area in bpy.context.screen.areas if bpy.context.screen else []: if area.type == 'TEXT_EDITOR' and area.spaces.active.text and area.spaces.active.text.filepath: return os.path.dirname(bpy.path.abspath(area.spaces.active.text.filepath)) return os.path.dirname(bpy.data.filepath) if bpy.data.filepath else "" pad = os.path.join(map_van_script(), OBJ) if IMPORTEER and not any(ob.get("tectum") == OBJ for ob in bpy.data.objects): if not os.path.exists(pad): raise Exception(f"{OBJ} niet gevonden naast dit script; zet IMPORTEER = False na een eigen import") if hasattr(bpy.ops.wm, "obj_import"): bpy.ops.wm.obj_import(filepath=pad) else: bpy.ops.import_scene.obj(filepath=pad) for ob in bpy.context.selected_objects: ob["tectum"] = OBJ oud = bpy.data.collections.get("Tectum lampen") if oud: for ob in list(oud.objects): bpy.data.objects.remove(ob, do_unlink=True) bpy.data.collections.remove(oud) col = bpy.data.collections.new("Tectum lampen") bpy.context.scene.collection.children.link(col) for i, (soort, plek, richting, kegel, rand, watt) in enumerate(LAMPEN): licht = bpy.data.lights.new(f"{soort}_{i + 1}", 'SPOT') licht.energy = watt * HELDERHEID licht.color = KLEUR licht.spot_size = kegel licht.spot_blend = rand licht.shadow_soft_size = 0.03 ob = bpy.data.objects.new(licht.name, licht) ob.location = plek ob.rotation_euler = Vector(richting).to_track_quat('-Z', 'Y').to_euler() col.objects.link(ob) print(f"Tectum: {len(LAMPEN)} lampen geplaatst") `; } return { build, blenderLights }; })();