/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // IFC4 (STEP, ISO 10303-21) met driehoeksnetten (IfcTriangulatedFaceSet), eenheid mm, Z omhoog. // Ruimtelijke opbouw: project > terrein > gebouw > bouwlaag; elementen eronder of in een samenstelling. window.IfcWriter = (() => { const B64 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$'; function guid() { const b = new Uint8Array(16); if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(b); else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256); b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80; let out = B64[b[0] >> 6] + B64[b[0] & 63]; for (let i = 1; i < 16; i += 3) { const n = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2]; out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63]; } return out; } // Tekst: ' verdubbelen, \ escapen, alles buiten ASCII als \X2\hhhh\X0\. function str(s) { if (s === null || s === undefined) return '$'; let out = '', ext = ''; const flush = () => { if (ext) { out += '\\X2\\' + ext + '\\X0\\'; ext = ''; } }; for (const ch of String(s)) { const c = ch.codePointAt(0); if (c >= 32 && c < 127) { flush(); out += ch === "'" ? "''" : ch === '\\' ? '\\\\' : ch; } else if (c >= 127 && c <= 0xffff) ext += c.toString(16).toUpperCase().padStart(4, '0'); else if (c > 0xffff) ext += '003F'; } flush(); return `'${out}'`; } function real(v) { const r = Math.round(v * 100) / 100; const s = Object.is(r, -0) || !isFinite(r) ? '0' : String(r); return /[.eE]/.test(s) ? s : s + '.'; } const list = (a) => `(${a.join(',')})`; const ref = (n) => `#${n}`; function create(opts) { const o = opts || {}; const lines = []; let next = 1; const add = (type, args) => { const id = next++; lines.push(`#${id}=${type}(${args.join(',')});`); return id; }; const mm = add('IFCSIUNIT', ['*', '.LENGTHUNIT.', '.MILLI.', '.METRE.']); const m2 = add('IFCSIUNIT', ['*', '.AREAUNIT.', '$', '.SQUARE_METRE.']); const m3 = add('IFCSIUNIT', ['*', '.VOLUMEUNIT.', '$', '.CUBIC_METRE.']); const rad = add('IFCSIUNIT', ['*', '.PLANEANGLEUNIT.', '$', '.RADIAN.']); const units = add('IFCUNITASSIGNMENT', [list([mm, m2, m3, rad].map(ref))]); const origin = add('IFCCARTESIANPOINT', [list(['0.', '0.', '0.'])]); const axis = add('IFCAXIS2PLACEMENT3D', [ref(origin), '$', '$']); const ctx = add('IFCGEOMETRICREPRESENTATIONCONTEXT', ['$', str('Model'), '3', '1.E-05', ref(axis), '$']); const body = add('IFCGEOMETRICREPRESENTATIONSUBCONTEXT', [str('Body'), str('Model'), '*', '*', '*', '*', ref(ctx), '$', '.MODEL_VIEW.', '$']); const project = add('IFCPROJECT', [str(guid()), '$', str(o.project || 'Woning'), str(o.description || null), '$', '$', '$', list([ref(ctx)]), ref(units)]); const sitePl = add('IFCLOCALPLACEMENT', ['$', ref(axis)]); const site = add('IFCSITE', [str(guid()), '$', str('Terrein'), '$', '$', ref(sitePl), '$', '$', '.ELEMENT.', '$', '$', '$', '$', '$']); const bldPl = add('IFCLOCALPLACEMENT', [ref(sitePl), ref(axis)]); const a = o.address || {}; const addr = a.lines || a.town ? add('IFCPOSTALADDRESS', ['$', '$', '$', '$', a.lines ? list([str(a.lines)]) : '$', '$', str(a.town || null), '$', '$', str('Nederland')]) : null; const building = add('IFCBUILDING', [str(guid()), '$', str(o.building || 'Woning'), '$', '$', ref(bldPl), '$', '$', '.ELEMENT.', '$', '$', addr ? ref(addr) : '$']); const stPl = add('IFCLOCALPLACEMENT', [ref(bldPl), ref(axis)]); const storey = add('IFCBUILDINGSTOREY', [str(guid()), '$', str(o.storey || 'Begane grond'), '$', '$', ref(stPl), '$', '$', '.ELEMENT.', '0.']); add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(project), list([ref(site)])]); add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(site), list([ref(building)])]); add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(building), list([ref(storey)])]); // Kleur [r, g, b] (0..1) als oppervlaktestijl, één per kleur. const styles = new Map(); function style(rgb) { const k = rgb.map(c => Math.round(c * 1000)).join(','); if (!styles.has(k)) { const col = add('IFCCOLOURRGB', ['$', ...rgb.map(real)]); const shading = add('IFCSURFACESTYLESHADING', [ref(col), '0.']); styles.set(k, add('IFCSURFACESTYLE', ['$', '.BOTH.', list([ref(shading)])])); } return styles.get(k); } const contained = []; const materials = new Map(); // naam -> { id, els: [] } const psets = new Map(); // sleutel -> { props, els: [] } // Driehoeken als vlakke lijst [x,y,z, x,y,z, x,y,z, ...] in mm; punten worden samengevoegd. function shape(tri, rgb) { const idx = new Map(), pts = [], faces = []; const key = (i) => `${Math.round(tri[i] * 10)},${Math.round(tri[i + 1] * 10)},${Math.round(tri[i + 2] * 10)}`; const vid = (i) => { const k = key(i); if (!idx.has(k)) { idx.set(k, pts.length + 1); pts.push(`(${real(tri[i])},${real(tri[i + 1])},${real(tri[i + 2])})`); } return idx.get(k); }; for (let i = 0; i + 8 < tri.length; i += 9) { const f = [vid(i), vid(i + 3), vid(i + 6)]; if (f[0] !== f[1] && f[1] !== f[2] && f[0] !== f[2]) faces.push(`(${f.join(',')})`); } if (!faces.length) return null; const pl = add('IFCCARTESIANPOINTLIST3D', [`(${pts.join(',')})`]); const fs = add('IFCTRIANGULATEDFACESET', [ref(pl), '$', '$', `(${faces.join(',')})`, '$']); if (rgb) add('IFCSTYLEDITEM', [ref(fs), list([ref(style(rgb))]), '$']); const rep = add('IFCSHAPEREPRESENTATION', [ref(body), str('Body'), str('Tessellation'), list([ref(fs)])]); return add('IFCPRODUCTDEFINITIONSHAPE', ['$', '$', list([ref(rep)])]); } // el: { type: 'IFCMEMBER', predefined: 'STUD', name, tag, tri, color: [r, g, b], material, props: {naam: waarde}, objectType } function element(el) { const rep = el.tri ? shape(el.tri, el.color) : null; if (el.tri && !rep) return null; const pl = add('IFCLOCALPLACEMENT', [ref(stPl), ref(axis)]); const args = [str(guid()), '$', str(el.name), str(el.description || null), str(el.objectType || null), ref(pl), rep ? ref(rep) : '$', str(el.tag || null)]; if (el.type === 'IFCELEMENTASSEMBLY') args.push('.FACTORY.', `.${el.predefined || 'NOTDEFINED'}.`); else args.push(`.${el.predefined || 'NOTDEFINED'}.`); const id = add(el.type, args); if (el.material) { if (!materials.has(el.material)) { const id = add('IFCMATERIAL', [str(el.material), '$', '$']); // Ook aan het materiaal, voor viewers die op materiaal kleuren. if (el.color) { const si = add('IFCSTYLEDITEM', ['$', list([ref(style(el.color))]), '$']); const sr = add('IFCSTYLEDREPRESENTATION', [ref(ctx), str('Style'), str('Material'), list([ref(si)])]); add('IFCMATERIALDEFINITIONREPRESENTATION', ['$', '$', list([ref(sr)]), ref(id)]); } materials.set(el.material, { id, els: [] }); } materials.get(el.material).els.push(id); } if (el.props) { const k = JSON.stringify(el.props); if (!psets.has(k)) psets.set(k, { props: el.props, els: [] }); psets.get(k).els.push(id); } return id; } function contain(id) { if (id) contained.push(id); } function aggregate(parent, children) { const c = children.filter(Boolean); if (parent && c.length) add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(parent), list(c.map(ref))]); } const value = (v) => typeof v === 'number' ? `IFCLENGTHMEASURE(${real(v)})` : `IFCLABEL(${str(v)})`; function toString(filename) { if (contained.length) add('IFCRELCONTAINEDINSPATIALSTRUCTURE', [str(guid()), '$', '$', '$', list(contained.map(ref)), ref(storey)]); materials.forEach(m => add('IFCRELASSOCIATESMATERIAL', [str(guid()), '$', '$', '$', list(m.els.map(ref)), ref(m.id)])); psets.forEach(p => { const props = Object.entries(p.props).filter(([, v]) => v !== null && v !== undefined && v !== '') .map(([n, v]) => add('IFCPROPERTYSINGLEVALUE', [str(n), '$', value(v), '$'])); if (!props.length) return; const ps = add('IFCPROPERTYSET', [str(guid()), '$', str(o.psetName || 'Pset_Tectum'), '$', list(props.map(ref))]); add('IFCRELDEFINESBYPROPERTIES', [str(guid()), '$', '$', '$', list(p.els.map(ref)), ref(ps)]); }); contained.length = 0; materials.clear(); psets.clear(); const now = new Date().toISOString().slice(0, 19); return [ 'ISO-10303-21;', 'HEADER;', "FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]'),'2;1');", `FILE_NAME(${str(filename || 'woning.ifc')},${str(now)},(${str(o.author || '')}),(${str(o.organization || '')}),${str(o.app || 'Tectum')},${str(o.app || 'Tectum')},'');`, "FILE_SCHEMA(('IFC4'));", 'ENDSEC;', 'DATA;', ...lines, 'ENDSEC;', 'END-ISO-10303-21;', '' ].join('\n'); } function save(filename) { const blob = new Blob([toString(filename)], { type: 'application/x-step' }); const url = URL.createObjectURL(blob); const el = document.createElement('a'); el.href = url; el.download = filename; document.body.appendChild(el); el.click(); document.body.removeChild(el); URL.revokeObjectURL(url); } return { element, contain, aggregate, toString, save, count: () => next - 1 }; } return { create, guid }; })();