v0.7.0: exports voor CAD, BIM en renders (Bestand › Exporteren, los van Opslaan/Ctrl+S): CAD (DXF R12, plattegrond met wanden/sparingen/gevelbekleding/kozijnen met draaicirkel, ruimtes, inrichting, dakrand, elektra en echte maatvoering, plus vier gevels als lijntekening met verborgen lijnen weg; lagen PL-/AZ-, 1:1 mm, afgestemd op 1:50; in CAD op te slaan als DWG), BIM (IFC4 met alleen de bouwconstructie: stijlen, regels, vloer- en dakbalken, stalen nokbalk, palen, spaanplaat/OSB/cementplaat en elektra per leiding, gegroepeerd per unit en element met Pset_Tectum en kleur per soort) en Render (OBJ + MTL + texturen in een ZIP voor Blender, volledige afgewerkte woning, kleuren lineair, PBR); schoorsteen tegen een buitenwand (ChimneyModel: sparing in wand en dak met raveelwerk, steenstrip/stuc, verslepen langs de gevel); kozijnen koppelen met een eigen schakelaar; kozijnindeling (deurblad, zij-/bovenlicht, schuifbladen) gedeeld tussen 3D, plattegrond en aanzicht
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
86fcd57b96
commit
adb3c74f85
@@ -0,0 +1,133 @@
|
||||
/*!
|
||||
* 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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user