188 lines
9.6 KiB
JavaScript
188 lines
9.6 KiB
JavaScript
/*!
|
|
* 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 };
|
|
})();
|