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
+187
View File
@@ -0,0 +1,187 @@
/*!
* 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 };
})();