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:
Machiel
2026-10-12 20:00:32 +02:00
co-authored by Claude Opus 5.5
parent 86fcd57b96
commit adb3c74f85
22 changed files with 2371 additions and 184 deletions
+186
View File
@@ -0,0 +1,186 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// BIM-export van de bouwconstructie (IFC4): balken, platen en elektra, geen afwerking of inrichting.
// De geometrie komt uit de 3D-opbouw (dezelfde als in de app), in twee fasen: 'elektra' (alle balken en
// leidingen, nog zonder platen, dus niets weggelaten) en 'beplating' (alleen de platen).
// Assen: X = oost, Y = noord, Z = omhoog; 0,0 = zuidwesthoek van de units, Z = 0 op peil (bovenkant vloer).
// Opbouw: unit (samenstelling) > element A-W1 / A-V / A-D (samenstelling) > onderdelen; binnenwanden los.
window.HouseIfc = (() => {
const WOOD = 'Hout', ELEKTRA_KIND = { kast: 1, doos: 1, buis: 1 };
// Productiekleuren per soort (elektra houdt de kleur uit de app).
const COLORS = {
Stijl: [0.93, 0.74, 0.42], Regel: [0.85, 0.48, 0.18], Vloerbalk: [0.62, 0.40, 0.20], Dakbalk: [0.78, 0.30, 0.22],
Paal: [0.45, 0.28, 0.15], 'Stalen nokbalk': [0.35, 0.42, 0.55],
Spaanplaat: [0.40, 0.68, 0.42], OSB: [0.95, 0.86, 0.50], Cementplaat: [0.62, 0.64, 0.68]
};
const rgbOf = (mesh) => { const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; return m && m.color ? [m.color.r, m.color.g, m.color.b] : null; };
function materialsSet() {
return {
wall: WallModel.createMaterials(), floor: FloorModel.createMaterials(), roof: RoofModel.createMaterials(),
elektra: ElektraModel.createMaterials(), post: PostModel.createMaterials()
};
}
// Materiaal -> soort onderdeel (alles wat hier niet in staat, gaat niet mee).
function classifier(M) {
const map = new Map();
map.set(M.wall.woodMaterial, { kind: 'hout', where: 'wand' });
map.set(M.floor.woodMaterial, { kind: 'hout', where: 'vloer' });
map.set(M.roof.woodMaterial, { kind: 'hout', where: 'dak' });
map.set(M.roof.steelMaterial, { kind: 'staal' });
map.set(M.wall.spaanplaatMaterial, { kind: 'plaat', label: 'Spaanplaat' });
map.set(M.wall.cementplaatMaterial, { kind: 'plaat', label: 'Cementplaat' });
map.set(M.floor.osbMaterial, { kind: 'plaat', label: 'OSB' });
map.set(M.roof.osbMaterial, { kind: 'plaat', label: 'OSB' });
Object.entries(M.elektra.conduit).forEach(([type, m]) => map.set(m, { kind: 'buis', type }));
map.set(M.elektra.boxMaterial, { kind: 'doos' });
map.set(M.elektra.kastMaterial, { kind: 'kast' });
map.set(M.elektra.deurMaterial, { kind: 'kast' });
Object.values(M.post.wood).forEach(m => map.set(m, { kind: 'paal' }));
return (mesh) => {
const m0 = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
return m0 ? map.get((m0.userData && m0.userData.base) || m0) || null : null;
};
}
function build(state, stage) {
const M = materialsSet();
const parts = HouseModel.computeParts(state, {
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
stage, lampenVisible: false
});
HouseModel.placeGroups(parts);
return { parts, classify: classifier(M) };
}
function generate(state) {
const frame = build(state, BuildStages.INDEX.elektra);
const sheet = build(state, BuildStages.INDEX.beplating);
const parts = frame.parts;
if (!parts.units.length) throw new Error('geen units in het ontwerp');
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
const peil = Math.max(...parts.units.map(u => u.layout.floorTop));
const v = new THREE.Vector3(), ax = new THREE.Vector3(), box = new THREE.Box3(), size = new THREE.Vector3();
function triangles(mesh, out) {
const g = mesh.geometry, pos = g.attributes.position, idx = g.index;
const n = idx ? idx.count : pos.count;
for (let i = 0; i < n; i++) {
v.fromBufferAttribute(pos, idx ? idx.getX(i) : i).applyMatrix4(mesh.matrixWorld);
out.push(v.x * 1000 - bx0, bz1 - v.z * 1000, v.y * 1000 - peil);
}
return out;
}
// Maat in de eigen assen (afgeschuinde sporen houden zo lengte en dikte), oplopend in mm.
function dims(mesh) {
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
box.copy(mesh.geometry.boundingBox).getSize(size);
const s = [size.x * Math.abs(mesh.scale.x), size.y * Math.abs(mesh.scale.y), size.z * Math.abs(mesh.scale.z)].map(d => Math.round(d * 1000));
const longest = s.indexOf(Math.max(...s));
ax.set(longest === 0 ? 1 : 0, longest === 1 ? 1 : 0, longest === 2 ? 1 : 0).transformDirection(mesh.matrixWorld);
return { sorted: s.slice().sort((a, b) => a - b), vertical: Math.abs(ax.y) > 0.7 };
}
const label = (d) => d.sorted.join(' × ');
const groups = new Map(); // sleutel -> { name, tag, objectType, unit, members: [] }
const loose = [];
const merged = new Map(); // leidingen per run, meterkast
const groupOf = (key, info) => { if (!groups.has(key)) groups.set(key, Object.assign({ members: [] }, info)); return groups.get(key); };
function member(mesh, c, tag) {
const d = dims(mesh);
if (d.sorted[0] < 1) return null; // vlakken zonder dikte (kopse afdekking)
const tri = triangles(mesh, []);
const props = (name, material) => ({ Element: tag || null, Onderdeel: name, Afmeting: label(d), Lengte: d.sorted[2], Materiaal: material });
if (c.kind === 'plaat') {
const name = `${c.label} ${d.sorted[0]} mm`;
return { type: 'IFCPLATE', predefined: 'SHEET', name, material: c.label, tri, color: COLORS[c.label], props: props(name, c.label) };
}
const el = (type, predefined, name, material) => ({ type, predefined, name, material, tri, color: COLORS[name], props: props(name, material) });
if (c.kind === 'staal') return el('IFCBEAM', 'BEAM', 'Stalen nokbalk', 'Staal');
if (c.kind === 'paal') return el('IFCCOLUMN', 'COLUMN', 'Paal', WOOD);
if (c.where === 'vloer') return el('IFCBEAM', 'JOIST', 'Vloerbalk', WOOD);
if (c.where === 'dak') return el('IFCMEMBER', 'RAFTER', 'Dakbalk', WOOD);
return d.vertical ? el('IFCMEMBER', 'STUD', 'Stijl', WOOD) : el('IFCMEMBER', 'PLATE', 'Regel', WOOD);
}
function collect(src, group, plates, target) {
if (!group) return;
group.updateMatrixWorld(true);
group.traverseVisible(o => {
if (!o.isMesh) return;
const c = src.classify(o);
if (!c || (c.kind === 'plaat') !== plates) return;
if (ELEKTRA_KIND[c.kind]) {
if (c.kind === 'doos') {
loose.push({ type: 'IFCJUNCTIONBOX', predefined: 'POWER', name: 'Inbouwdoos', material: 'Kunststof', tri: triangles(o, []), color: rgbOf(o), props: { Onderdeel: 'Inbouwdoos', Materiaal: 'Kunststof' } });
return;
}
const key = c.kind === 'kast' ? 'kast' : `buis:${o.userData.run}`;
if (!merged.has(key)) {
merged.set(key, c.kind === 'kast'
? { type: 'IFCELECTRICDISTRIBUTIONBOARD', predefined: 'DISTRIBUTIONBOARD', name: 'Meterkast', material: 'Kunststof', tri: [], color: rgbOf(o), props: { Onderdeel: 'Meterkast' } }
: { type: 'IFCCABLECARRIERSEGMENT', predefined: 'CONDUITSEGMENT', name: `Installatiebuis ${c.type}`, material: `PVC ${c.type}`, tri: [], len: 0, color: rgbOf(o), props: { Onderdeel: `Installatiebuis ${c.type}`, Materiaal: 'PVC' } });
}
const e = merged.get(key);
triangles(o, e.tri);
if (e.len !== undefined && o.geometry.type === 'CylinderGeometry') e.len += o.geometry.parameters.height * 1000;
return;
}
const m = member(o, c, target && target.tag);
if (m) (target ? target.members : loose).push(m);
});
}
[[frame, false], [sheet, true]].forEach(([src, plates]) => {
const P = src.parts;
P.units.forEach(u => {
collect(src, u.floorGroup, plates, groupOf(u.floorElement, { name: `Unit ${u.id} vloer`, tag: u.floorElement, objectType: 'Vloerelement', unit: u.id }));
u.walls.forEach(w => w.pieces.forEach(p => {
collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Wandelement', unit: u.id }));
}));
collect(src, u.roof.group, plates, groupOf(`${u.id}-D`, { name: `Unit ${u.id} dak`, tag: `${u.id}-D`, objectType: 'Dakelement', unit: u.id }));
});
P.interior.forEach(p => collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Binnenwand', unit: null })));
collect(src, P.postsGroup, plates, null);
collect(src, P.elektraGroup, plates, null);
});
merged.forEach(e => {
if (e.len) e.props.Lengte = Math.round(e.len);
delete e.len;
loose.push(e);
});
const pr = state.project || {};
const ifc = IfcWriter.create({
project: pr.naam || 'Woning', building: pr.naam || 'Woning', author: pr.tekenaar || '',
app: `Tectum${typeof APP_VERSION !== 'undefined' ? ' ' + APP_VERSION : ''}`,
address: { lines: [pr.straat, pr.nummer].filter(Boolean).join(' '), town: pr.plaats || '' }
});
const unitParts = new Map(parts.units.map(u => [u.id, []]));
groups.forEach(g => {
const ids = g.members.map(m => ifc.element(m)).filter(Boolean);
if (!ids.length) return;
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: g.name, tag: g.tag, objectType: g.objectType, props: { Element: g.tag } });
ifc.aggregate(asm, ids);
if (g.unit && unitParts.has(g.unit)) unitParts.get(g.unit).push(asm);
else ifc.contain(asm);
});
unitParts.forEach((ids, id) => {
if (!ids.length) return;
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: `Unit ${id}`, tag: id, objectType: 'Unit' });
ifc.aggregate(asm, ids);
ifc.contain(asm);
});
loose.forEach(e => ifc.contain(ifc.element(e)));
return ifc;
}
return { generate };
})();