/*! * 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; }; } // rough: buizen en dozen bouwen; de plaatfase verzamelt alleen platen en heeft ze niet nodig. function build(state, layers, rough) { const M = materialsSet(); const parts = HouseModel.computeParts(state, { wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post, layers, elektraRough: rough }); HouseModel.placeGroups(parts); return { parts, classify: classifier(M) }; } function generate(state) { const frame = build(state, ViewLayers.upTo(1), true); const sheet = build(state, ViewLayers.upTo(2), false); 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 }; })();