199 lines
9.5 KiB
JavaScript
199 lines
9.5 KiB
JavaScript
/*!
|
|
* 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 };
|
|
})();
|