Tectum v0.4.0 — mijlpaal gevelopbouw

Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units,
woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op
vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-09-30 11:38:54 +02:00
co-authored by Claude Opus 5.5
commit c690e72d13
36 changed files with 9954 additions and 0 deletions
+569
View File
@@ -0,0 +1,569 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// house/house-model.js — HouseModel: ENIGE bron van de woning-logica. Een
// woning bestaat uit units (UnitModel) die aan elkaar gekoppeld zijn, plus
// binnenwanden. Geen DOM. Alle wand-/vloerlogica komt uit WallModel/
// FloorModel/UnitModel.
//
// Woning-state:
// { project: {...},
// units: [{ id: 'A', attach: null | { to, side, align, offset }, unit: <unit-state> }],
// interiorWalls: [{ id, x1, z1, x2, z2, wall: <wand-state> }] }
//
// Assenstelsel (mm, woning): X = breedte (rechts), Z = diepte (naar achter),
// de eerste unit heeft zijn linker-voorhoek op (0, 0).
//
// Koppelen werkt als de ankers van openingen: een unit hangt aan een zijde
// van een andere unit, uitgelijnd op start/midden/eind + verschuiving. Wordt
// een unit groter/kleiner, dan schuiven de gekoppelde units mee.
// Waar twee units tegen elkaar staan (naad) vervalt de wand van BEIDE units
// over dat stuk (UnitModel.computeLayout met cuts).
//
// Plain script, geladen na unit-model.js.
// ============================================================
window.HouseModel = (() => {
const EPS = 0.5; // mm
const ALIGN_LABELS = {
x: { start: 'West uitlijnen', middle: 'Midden', end: 'Oost uitlijnen' },
z: { start: 'Noord uitlijnen', middle: 'Midden', end: 'Zuid uitlijnen' }
};
// Windrichtingen t.o.v. het 2D-bovenaanzicht (boven = noord).
const SIDE_LABELS = { voor: 'Noordzijde', achter: 'Zuidzijde', links: 'Westzijde', rechts: 'Oostzijde' };
const DEFAULT_INTERIOR_THICKNESS = 100;
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
function createEmptyProject() {
return { naam: '', plaats: '', straat: '', nummer: '', tekenaar: '', opmerking: '' };
}
function nextUnitId(state) {
const used = new Set(state.units.map(u => u.id));
for (let i = 0; i < 26; i++) {
const id = String.fromCharCode(65 + i);
if (!used.has(id)) return id;
}
return 'U' + (state.units.length + 1);
}
// template: 'single' (1 unit) of 'L' (2 units in L-vorm).
// Woning-brede instellingen: regelwerk-h.o.h. voor ALLE buiten- resp.
// binnenwanden (niet per wand), en de dakrand (RoofModel).
function createDefaultSettings() {
return {
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
cladding: 'horizontaal', // richting gevelbekleding, voorlopig voor alle buitenwanden
// Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte
// vanaf het beloopbare vloervlak (WallModel.trasraamFor).
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM },
sandwich: true, // sandwichpanelen rondom (SandwichModel), vanaf loopvlak 600 mm de grond in
roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS),
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS)
};
}
function createUnitEntry(id, attach, unitOpts) {
const unit = UnitModel.createDefaultState(unitOpts);
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting() };
}
function createDefaultState(template) {
const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
state.units.push(createUnitEntry('A', null, { width: 6000, depth: 3000 }));
if (template === 'L') {
state.units.push(createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 3000, depth: 3000, windowOn: [] }));
// Standaard L-woning: voordeur midden in de noordgevel van A, drie
// ramen in de zuidgevel van B (bovenkanten gelijk met de voordeur).
state.units[0].unit.walls.voor.openings = [
{ id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0 }
];
state.units[1].unit.walls.achter.openings = [
{ id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450 },
{ id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0 },
{ id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450 }
];
}
computeParts(state, { skip3D: true });
return state;
}
// ------------------------------------------------------------
// Posities (vloer-rechthoeken) uit de koppelingen.
// ------------------------------------------------------------
function unitRects(state) {
const size = {};
state.units.forEach(u => {
const f = FloorModel.computeFrame(u.unit.floor.floor);
size[u.id] = { W: f.width, D: f.depth };
});
const rects = {};
const make = (id, x0, z0, error) => {
const { W, D } = size[id];
rects[id] = { id, x0, z0, x1: x0 + W, z1: z0 + D, cx: x0 + W / 2, cz: z0 + D / 2, W, D, error: error || null };
};
let pending = state.units.slice();
let progress = true;
while (pending.length && progress) {
progress = false;
pending = pending.filter(u => {
if (!u.attach) {
if (Object.keys(rects).length === 0) { make(u.id, 0, 0); progress = true; return false; }
return true; // tweede ongekoppelde unit: hieronder als fout geplaatst
}
const P = rects[u.attach.to];
if (!P) return true;
const { W, D } = size[u.id];
const a = u.attach, off = a.offset || 0;
const alongX = (align) => align === 'middle' ? P.cx - W / 2 + off : align === 'end' ? P.x1 - off - W : P.x0 + off;
const alongZ = (align) => align === 'middle' ? P.cz - D / 2 + off : align === 'end' ? P.z1 - off - D : P.z0 + off;
if (a.side === 'achter') make(u.id, alongX(a.align), P.z1);
else if (a.side === 'voor') make(u.id, alongX(a.align), P.z0 - D);
else if (a.side === 'rechts') make(u.id, P.x1, alongZ(a.align));
else make(u.id, P.x0 - W, alongZ(a.align));
progress = true;
return false;
});
}
// Onoplosbare koppelingen (kring/ontbrekende unit): rechts naast de rest.
pending.forEach(u => {
const maxX = Math.max(0, ...Object.values(rects).map(r => r.x1));
make(u.id, maxX + 1000, 0, 'Koppeling ongeldig (verwijst naar zichzelf of een ontbrekende unit).');
});
return state.units.map(u => rects[u.id]);
}
// Naden: zijden die precies tegen elkaar liggen en (deels) overlappen.
// Elke cut is [a, b, extA, extB]: extA/extB = hoever het aangrenzende
// wandstuk de naad in doorloopt. Eindigt de naad op de rand van de BUUR-unit
// (binnenhoek, bijv. bij een L-vorm), dan staat daar een haakse wand van die
// buur; het wandstuk loopt dan met diens dikte door, zodat de binnenhoek
// dicht is i.p.v. een gat van wanddikte x wanddikte.
// thickness(id, side) = dikte van die wand (mm).
function computeSeams(rects, thickness) {
const T = thickness || (() => 0);
const cuts = {};
rects.forEach(r => { cuts[r.id] = { voor: [], achter: [], links: [], rechts: [] }; });
const seams = [];
const overlap = (a0, a1, b0, b1) => [Math.max(a0, b0), Math.min(a1, b1)];
for (let i = 0; i < rects.length; i++) {
for (let j = 0; j < rects.length; j++) {
if (i === j) continue;
const A = rects[i], B = rects[j];
if (Math.abs(A.z1 - B.z0) < EPS) { // A achter | B voor
const [o0, o1] = overlap(A.x0, A.x1, B.x0, B.x1);
if (o1 - o0 > EPS) {
cuts[A.id].achter.push([o0 - A.cx, o1 - A.cx, B.x0 > A.x0 + EPS ? T(B.id, 'links') : 0, B.x1 < A.x1 - EPS ? T(B.id, 'rechts') : 0]);
cuts[B.id].voor.push([o0 - B.cx, o1 - B.cx, A.x0 > B.x0 + EPS ? T(A.id, 'links') : 0, A.x1 < B.x1 - EPS ? T(A.id, 'rechts') : 0]);
seams.push({ a: A.id, b: B.id, x0: o0, z0: A.z1, x1: o1, z1: A.z1 });
}
}
if (Math.abs(A.x1 - B.x0) < EPS) { // A rechts | B links
const [o0, o1] = overlap(A.z0, A.z1, B.z0, B.z1);
if (o1 - o0 > EPS) {
cuts[A.id].rechts.push([o0 - A.cz, o1 - A.cz, B.z0 > A.z0 + EPS ? T(B.id, 'voor') : 0, B.z1 < A.z1 - EPS ? T(B.id, 'achter') : 0]);
cuts[B.id].links.push([o0 - B.cz, o1 - B.cz, A.z0 > B.z0 + EPS ? T(A.id, 'voor') : 0, A.z1 < B.z1 - EPS ? T(A.id, 'achter') : 0]);
seams.push({ a: A.id, b: B.id, x0: A.x1, z0: o0, x1: A.x1, z1: o1 });
}
}
}
}
return { cuts, seams };
}
function overlappingPairs(rects) {
const pairs = [];
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const A = rects[i], B = rects[j];
if (Math.min(A.x1, B.x1) - Math.max(A.x0, B.x0) > EPS && Math.min(A.z1, B.z1) - Math.max(A.z0, B.z0) > EPS) {
pairs.push([A.id, B.id]);
}
}
}
return pairs;
}
// ------------------------------------------------------------
// Binnenwanden: een getekend lijnstuk (hartlijn, as-evenwijdig) met een
// gewone wand-state. Standaard 100 mm dik, aan beide zijden spaanplaat.
// ------------------------------------------------------------
function interiorWallDefaults(thickness, height) {
const wall = WallEditor.getDefaultState();
const t = thickness || DEFAULT_INTERIOR_THICKNESS;
const h = height || wall.wall.heightAll;
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = h;
wall.materials = { interior: 'spaanplaat', exterior: 'spaanplaat' };
wall.openings = [];
setThickness(wall, t);
return wall;
}
// Dikte = regelwerk + 2x beplating (WallModel.thicknessOf); de diepte van
// stijlen en regels wordt daarop afgestemd.
function setThickness(wallState, thickness) {
const depth = Math.max(20, Math.round(thickness - 2 * wallState.wall.sheathingThick));
wallState.wall.studDepth = depth;
wallState.wall.plateDepth = depth;
}
function thicknessOf(wallState) {
return Math.max(wallState.wall.studDepth, wallState.wall.plateDepth) + 2 * wallState.wall.sheathingThick;
}
function interiorLayout(iw) {
const dx = iw.x2 - iw.x1, dz = iw.z2 - iw.z1;
const alongX = Math.abs(dx) >= Math.abs(dz);
const width = Math.max(300, Math.round(alongX ? Math.abs(dx) : Math.abs(dz)));
iw.wall.wall.width = width;
return {
width, alongX,
cx: (iw.x1 + iw.x2) / 2, cz: (iw.z1 + iw.z2) / 2,
rotY: alongX ? 0 : -Math.PI / 2 // lokaal +X langs +X resp. +Z
};
}
// ------------------------------------------------------------
// Alle onderdelen doorgerekend (+ 3D, tenzij opts.skip3D).
// opts: { wallMaterials, floorMaterials, beplatingVisible, isolatieVisible, skip3D }
// ------------------------------------------------------------
// Past de woning-brede instellingen toe op de onderdeel-states (muteert
// state): h.o.h. van alle wanden, en de dakmaat als die de vloer volgt.
function applyGlobalSettings(state) {
const sp = state.settings.spacing;
const tr = state.settings.trasraam;
state.units.forEach(u => {
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height) : null;
Object.values(u.unit.walls).forEach(ws => {
ws.wall.minSpacing = sp.exterior.min;
ws.wall.maxSpacing = sp.exterior.max;
ws.materials = Object.assign({}, ws.materials, { cladding: state.settings.cladding, trasraam });
});
if (u.roof.followFloor) {
u.roof.deck.floor.width = u.unit.floor.floor.width;
u.roof.deck.floor.depth = u.unit.floor.floor.depth;
u.roof.offsetX = 0;
u.roof.offsetZ = 0;
}
});
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
}
// Doorlopende gevels over alle units: collineaire, aansluitende buiten-
// wandstukken (groupFacadeRuns, core/geometry.js). Zet per stuk p.facade =
// { u0, extStart, isEnd } (zie WallModel.computeBattens): u0 = afstand
// langs het wandvlak van het gevelbegin (links, van buitenaf gezien, incl.
// hoekdoorloop van het eerste stuk) tot de linkerrand van het stuk.
function assignFacades(units) {
const segs = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
const W = p.place.width, T = p.place.thickness;
const normal = { x: p.place.normal.x, z: p.place.normal.y };
const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z', perp = axis === 'x' ? 'z' : 'x';
const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2);
segs.push({ piece: p, axis, normal, line: q0[perp], sAt0: q0[axis], sAtW: q1[axis], s0: Math.min(q0[axis], q1[axis]), s1: Math.max(q0[axis], q1[axis]) });
})));
groupFacadeRuns(segs).forEach(run => {
const dir = Math.sign(run.segs[0].sAtW - run.segs[0].sAt0) || 1;
const sLeft = dir > 0 ? run.s0 : run.s1, sRight = dir > 0 ? run.s1 : run.s0;
const first = run.segs.reduce((best, sg) => Math.abs(sg.sAt0 - sLeft) < Math.abs(best.sAt0 - sLeft) ? sg : best);
const extLeft = (first.piece.skinExt && first.piece.skinExt.left) || 0;
run.segs.forEach(sg => {
sg.piece.facade = { u0: Math.abs(sg.sAt0 - sLeft) + extLeft, extStart: extLeft > 0, isEnd: Math.abs(sg.sAtW - sRight) < 5 };
});
});
}
function computeParts(state, opts) {
const o = opts || {};
const build = !o.skip3D;
applyGlobalSettings(state);
const rects = unitRects(state);
const rectById = {};
rects.forEach(r => { rectById[r.id] = r; });
const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side]));
const { cuts, seams } = computeSeams(rects, wallThickness);
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade) {
if (!build) return { group: null, stats: null, specs: null };
const built = WallModel.build3D(config, frame, {
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade,
spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false,
isolatieVisible: o.isolatieVisible !== false
});
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
}
const units = state.units.map(u => {
const rect = rectById[u.id];
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
const floorData = FloorModel.computeFromState(u.unit.floor);
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
materials: o.floorMaterials, osbVisible: o.beplatingVisible !== false, isolatieVisible: o.isolatieVisible !== false
}) : null;
const walls = layout.walls.map(wl => {
const ws = u.unit.walls[wl.side];
const buitenMateriaal = (ws.materials || {}).exterior || 'vochtdoek';
const cladding = (ws.materials || {}).cladding || 'horizontaal';
const trasraam = buitenMateriaal === 'vochtdoek' ? ((ws.materials || {}).trasraam || null) : null;
const wp = UnitModel.wallPieces(wl, ws);
const pieces = wp.pieces.map(p => {
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
return Object.assign(p, {
element: `${u.id}-W${wl.nr}${p.suffix}`,
label: `Unit ${u.id} ${wl.label.toLowerCase()}${p.suffix ? ' (' + p.suffix + ')' : ''}`,
buitenMateriaal, cladding, trasraam,
place: { cx: rect.cx + c.x, cz: rect.cz + c.z, rotY: wl.rotY, width: p.config.wallWidth, thickness: wl.thickness, normal: wl.normal, floorTop: layout.floorTop }
}); // 3D volgt na assignFacades (latwerk/doek per gevel)
});
return Object.assign({}, wl, { pieces, dropped: wp.dropped, removed: pieces.length === 0 });
});
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), op de hoogste
// wandbovenkant van deze unit.
const roofData = FloorModel.computeFromState(u.roof.deck);
const roofSpecs = RoofModel.computeSpecs(roofData.calcData, roofData.resolvedOpenings);
const wallTop = Math.max(...Object.values(u.unit.walls).map(ws => Math.max(ws.wall.heightLeft, ws.wall.heightRight)));
const roofW = roofData.calcData.width, roofD = roofData.calcData.depth;
const roofCx = rect.cx + (u.roof.offsetX || 0), roofCz = rect.cz + (u.roof.offsetZ || 0);
const roof = {
element: `${u.id}-D1`, data: roofData, specs: roofSpecs,
rect: { x0: roofCx - roofW / 2, x1: roofCx + roofW / 2, z0: roofCz - roofD / 2, z1: roofCz + roofD / 2, cx: roofCx, cz: roofCz, W: roofW, D: roofD },
base: layout.floorTop + wallTop,
top: layout.floorTop + wallTop + RoofModel.thickness(roofData.calcData),
// Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM)
// horen bij "Beplating", de dakisolatie bij "Isolatie".
group: build ? RoofModel.build3D(roofData.calcData, roofData.resolvedOpenings, {
materials: o.roofMaterials, isolatieVisible: o.isolatieVisible !== false,
osbVisible: o.beplatingVisible !== false,
dakleerVisible: o.beplatingVisible !== false
}) : null
};
return {
id: u.id, rect, layout, floorData, floorSpecs, floorGroup, walls, roof,
floorElement: `${u.id}-V1`
};
});
// Doek en latwerk volgen de hele gevel (niet per unit/wandstuk).
assignFacades(units);
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
Object.assign(p, buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade));
})));
// Binnenwanden staan op de vloer van de unit waarin hun midden valt.
const interior = state.interiorWalls.map((iw, i) => {
const lay = interiorLayout(iw);
const host = units.find(u => lay.cx >= u.rect.x0 && lay.cx <= u.rect.x1 && lay.cz >= u.rect.z0 && lay.cz <= u.rect.z1);
const floorTop = host ? host.layout.floorTop : Math.max(...units.map(u => u.layout.floorTop));
const config = WallModel.configFromState(iw.wall);
const frame = WallModel.computeFrame(config);
const buitenMateriaal = (iw.wall.materials || {}).exterior || 'spaanplaat';
return Object.assign({
id: iw.id, element: `BW-${i + 1}`, label: `Binnenwand ${i + 1}`, config, frame, buitenMateriaal,
place: { cx: lay.cx, cz: lay.cz, rotY: lay.rotY, width: lay.width, thickness: WallModel.thicknessOf(config), floorTop }
}, buildWall(config, frame, buitenMateriaal));
});
// Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken;
// boeidelen aan/uit per unit.
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
units.map(u => ({ unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false })),
Math.max(...units.map(u => u.roof.top))) : null;
// Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots).
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
// Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen".
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
edgeVisible: o.beplatingVisible !== false, spotsVisible: o.lampenVisible !== false
}) : null;
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials()) : null;
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false;
// Sandwichpanelen rondom de vloeren, vanaf het loopvlak naar beneden.
const sandwich = state.settings.sandwich && units.length
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
const sandwichGroup = build && sandwich ? SandwichModel.build3D(sandwich, o.sandwichMaterials || SandwichModel.createMaterials(), {
visible: o.beplatingVisible !== false
}) : null;
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, sandwich, sandwichGroup };
}
// Alle wandstukken (buiten + binnen) plat op een rij.
function allWallPieces(parts) {
const ext = [];
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
return ext.concat(parts.interior);
}
// ------------------------------------------------------------
// Totalen over de hele woning (overzicht + laatste PDF-blad).
// ------------------------------------------------------------
function computeTotals(parts) {
const pieces = allWallPieces(parts);
const kinds = new Map();
const sawList = new Map();
function addPiece(kind, width, depth, length) {
const k = kinds.get(kind) || { kind, count: 0, lengthMM: 0 };
k.count++; k.lengthMM += length;
kinds.set(kind, k);
const L = Math.round(length);
const key = `${width}|${depth}|${L}`;
const g = sawList.get(key) || { width, depth, length: L, count: 0 };
g.count++;
sawList.set(key, g);
}
parts.units.forEach(u => {
const fc = u.floorData.calcData;
fc.joists.forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.depth));
const rc = u.roof.data.calcData;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.depth));
});
const wallBom = [];
pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length); }));
const purchase = WallModel.planPurchase(wallBom);
const sumP = (fn) => pieces.reduce((acc, p) => acc + fn(p), 0);
const sumU = (fn) => parts.units.reduce((acc, u) => acc + fn(u), 0);
const byMat = (mat) => pieces.filter(p => p.buitenMateriaal === mat);
const weights = parts.units.map(u => ({
label: `Unit ${u.id}`, element: u.id,
kg: u.floorSpecs.totalKg + u.roof.specs.totalKg + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.totalKg, 0), 0)
}));
if (parts.interior.length) weights.push({ label: 'Binnenwanden', element: 'BW', kg: parts.interior.reduce((a, p) => a + p.specs.totalKg, 0) });
const isolatie = parts.units.map(u => ({
label: `Unit ${u.id}`, element: u.id,
m3: u.floorSpecs.voidM3 + u.roof.specs.voidM3 + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.voidVolumeM3, 0), 0)
}));
if (parts.interior.length) isolatie.push({ label: 'Binnenwanden', element: 'BW', m3: parts.interior.reduce((a, p) => a + p.specs.voidVolumeM3, 0) });
const kindList = Array.from(kinds.values());
return {
kinds: kindList,
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width),
beamCount: kindList.reduce((a, k) => a + k.count, 0),
purchase,
purchaseWasteMM: purchase.reduce((a, s) => a + s.wasteMM, 0),
purchaseStockMM: purchase.reduce((a, s) => a + s.stockMM, 0),
woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3),
woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg),
battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) },
isolatie,
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
plaatwerk: [
{
naam: 'Spaanplaat (wanden)',
m2: sumP(p => p.stats.spaanplaatAreaM2 + p.stats.spaanplaatBuitenAreaM2),
m3: sumP(p => p.stats.spaanplaatVolumeM3 + p.stats.spaanplaatBuitenVolumeM3),
kg: sumP(p => p.specs.spaanplaatKg) + byMat('spaanplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
},
{ naam: 'OSB-plaat (vloeren + plafonds dak)', m2: sumU(u => u.floorSpecs.osbAreaM2 + u.roof.specs.osbAreaM2), m3: sumU(u => u.floorSpecs.osbVolM3 + u.roof.specs.osbVolM3), kg: sumU(u => u.floorSpecs.osbKg + u.roof.specs.osbKg) },
{ naam: 'Dakleer (EPDM)', m2: sumU(u => u.roof.specs.dakleerM2), m3: null, kg: sumU(u => u.roof.specs.dakleerKg) },
{
naam: 'Cementplaat (buitenzijde)', m2: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatAreaM2, 0),
m3: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatVolumeM3, 0), kg: byMat('cementplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
},
{
naam: 'Cementplaat (trasraam)', m2: sumP(p => p.stats.trasraamAreaM2 || 0),
m3: sumP(p => p.stats.trasraamVolumeM3 || 0), kg: sumP(p => p.specs.trasraamKg || 0)
},
{
naam: parts.sandwich ? `Sandwichpanelen ${SandwichModel.PANEL_WIDTH_MM}x${SandwichModel.PANEL_LENGTH_MM}x${SandwichModel.THICK_MM} (${parts.sandwich.panelCount} platen)` : 'Sandwichpanelen', m2: parts.sandwich ? parts.sandwich.areaM2 : 0, m3: null,
kg: parts.sandwich ? parts.sandwich.kg : 0
},
{
naam: 'Vochtdoek (buitenzijde)', m2: byMat('vochtdoek').reduce((a, p) => a + p.stats.doekAreaM2, 0),
m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + p.specs.buitenKg, 0)
}
],
weights,
totalKg: weights.reduce((a, r) => a + r.kg, 0),
roofEdge: parts.roofEdge ? {
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
fasciaHeight: parts.roofEdge.fasciaHeight,
overhang: parts.roofEdge.overhang,
soffitM2: parts.roofEdge.soffitM2,
trimLengthM: parts.roofEdge.trimLengthMM / 1000,
trimHeight: parts.roofEdge.trimHeight,
spots: parts.roofEdge.spots.length
} : null,
lamps: {
total: parts.lighting.lamps.length,
both: parts.lighting.lamps.filter(l => l.direction === 'both').length,
down: parts.lighting.lamps.filter(l => l.direction === 'down').length,
up: parts.lighting.lamps.filter(l => l.direction === 'up').length
}
};
}
// ------------------------------------------------------------
// Validatie / conversie
// ------------------------------------------------------------
function validateState(design) {
if (!Array.isArray(design.units) || !design.units.length) throw new Error('veld "units" ontbreekt of is leeg');
const ids = new Set();
design.units.forEach((u, i) => {
if (!u.id || ids.has(u.id)) throw new Error(`unit #${i + 1}: id ontbreekt of is dubbel`);
ids.add(u.id);
if (u.attach && !['voor', 'achter', 'links', 'rechts'].includes(u.attach.side)) throw new Error(`unit ${u.id}: ongeldige koppelzijde`);
try { UnitModel.validateState(u.unit || {}); } catch (err) { throw new Error(`unit ${u.id}: ${err.message}`); }
});
(design.interiorWalls || []).forEach((iw, i) => {
['x1', 'z1', 'x2', 'z2'].forEach(f => { if (typeof iw[f] !== 'number') throw new Error(`binnenwand #${i + 1}: ${f} ontbreekt`); });
try { WallModel.validateState(iw.wall || {}); } catch (err) { throw new Error(`binnenwand #${i + 1}: ${err.message}`); }
});
}
// Oud unit-ontwerp (type 'unit-design') -> woning met één unit.
function fromUnitDesign(design) {
const { type, version, exportedAt, project, ...unit } = design;
return normalize({ project: Object.assign(createEmptyProject(), project || {}), units: [{ id: 'A', attach: null, unit }], interiorWalls: [] });
}
function normalize(state) {
state.project = Object.assign(createEmptyProject(), state.project || {});
state.interiorWalls = state.interiorWalls || [];
const def = createDefaultSettings();
state.settings = state.settings || {};
state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {});
if (!WallModel.CLADDING_DIRECTIONS[state.settings.cladding]) state.settings.cladding = def.cladding;
state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {});
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
state.settings.sandwich = state.settings.sandwich !== false;
const oldRoof = state.settings.roof || {}; // v0.3.0: edge/spots waren woning-breed
state.settings.roof = RoofModel.normalizeSettings(oldRoof);
// v0.3.1: type/richting van de verlichting stonden per unit; nu woning-breed.
const legacyUnitLight = (state.units.find(u => u.lighting && u.lighting.mode) || {}).lighting;
const lightIn = Object.assign({}, state.settings.lighting || {});
if (!lightIn.mode) lightIn.mode = legacyUnitLight ? legacyUnitLight.mode : (oldRoof.spots === false ? 'geen' : 'spots');
if (!lightIn.direction && legacyUnitLight && legacyUnitLight.direction) lightIn.direction = legacyUnitLight.direction;
state.settings.lighting = LightingModel.normalizeSettings(lightIn);
state.units.forEach(u => {
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; }
if (u.roof.fascia === undefined) u.roof.fascia = oldRoof.edge !== 'daktrim';
u.lighting = { walls: (u.lighting && u.lighting.walls) || {} };
});
return state;
}
return {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout,
computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize
};
})();
+255
View File
@@ -0,0 +1,255 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// house/house-pdf.js — HousePdf: de samenhangende woning-PDF.
// Blad 1 plattegrond woning (units, wandstukken, naden, binnenwanden)
// daarna per unit: vloer (FloorPdf) + elk wandstuk (WallPdf)
// daarna elke binnenwand (WallPdf)
// laatste totalen (balken, inkoop, zaaglijst, glaswol, plaatwerk, gewicht)
// De losse bladen komen uit de gedeelde WallPdf/FloorPdf.
// Plain script, geladen na house-model.js.
// ============================================================
window.HousePdf = (() => {
const KIND_LABELS = {
'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel',
'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl',
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel'
};
const PLAN_ELEMENT = 'PL-1';
function fmt(n, decimals = 1) {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
// Rechthoek van een wandstuk (bovenaanzicht) als 4 hoekpunten.
function pieceCorners(place, lx0, lx1) {
const t = place.thickness / 2;
return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([lx, lz]) => UnitModel.localToWorld(place, lx, lz));
}
// Bladvolgorde + elementnummers; gedeeld door generate() en de
// onderdelenlijst op blad 1.
function sheetList(parts) {
const list = [{ kind: 'plan', element: PLAN_ELEMENT, label: 'Plattegrond woning' }];
parts.units.forEach(u => {
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
list.push({ kind: 'roof', element: u.roof.element, label: `Unit ${u.id} dak (plat)`, unit: u });
});
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
return list;
}
function drawPlanSheet(doc, parts, sheets, sheetOpts) {
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal');
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
sheets.slice(1).map((s, i) => [s.element, s.label, String(i + 2)]),
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
const rects = parts.rects;
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
const drawX0 = 105, drawX1 = 405, drawY0 = 20, drawY1 = 228;
const margin = 32;
const scale = Math.min((drawX1 - drawX0 - margin * 2) / (bx1 - bx0), (drawY1 - drawY0 - margin * 2) / (bz1 - bz0));
const ox = (drawX0 + drawX1) / 2 - ((bx0 + bx1) / 2) * scale;
const oy = (drawY0 + drawY1) / 2 - ((bz0 + bz1) / 2) * scale;
const px = (x) => ox + x * scale, py = (z) => oy + z * scale;
const poly = (pts, style) => {
doc.lines(pts.slice(1).map((p, i) => [px(p.x) - px(pts[i].x), py(p.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], style, true);
};
// Vloeren + unit-letter
rects.forEach(r => {
doc.setDrawColor(...GREY);
doc.setLineWidth(0.2);
doc.rect(px(r.x0), py(r.z0), r.W * scale, r.D * scale, 'S');
doc.setTextColor(...GREY);
doc.setFontSize(16);
doc.text(r.id, px(r.cx), py(r.cz) + 2, { align: 'center' });
});
// Wandstukken (buiten + binnen) met openingen
const drawPiece = (p) => {
doc.setDrawColor(...RED);
doc.setLineWidth(0.3);
poly(pieceCorners(p.place, -p.place.width / 2, p.place.width / 2), 'S');
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 255, 255);
doc.setLineWidth(0.2);
p.config.openings.forEach(op => {
const c = pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w);
poly(c, 'FD');
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
doc.line(px(c[1].x), py(c[1].z), px(c[3].x), py(c[3].z));
});
};
HouseModel.allWallPieces(parts).forEach(drawPiece);
// Dak: dakranden (grijs gestippeld), boeideel/daktrim-buitenrand, spots
doc.setLineDashPattern([1, 1.2], 0);
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
parts.units.forEach(u => doc.rect(px(u.roof.rect.x0), py(u.roof.rect.z0), u.roof.rect.W * scale, u.roof.rect.D * scale, 'S'));
if (parts.roofEdge) {
doc.setLineWidth(0.25);
parts.roofEdge.segments.forEach(sg => {
doc.setDrawColor(...(sg.fascia ? BLACK : GREY));
doc.line(px(sg.outerA.x), py(sg.outerA.z), px(sg.outerB.x), py(sg.outerB.z));
});
doc.setLineDashPattern([], 0);
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 255, 255);
parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD'));
}
doc.setLineDashPattern([], 0);
// Gevellampen
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 211, 107);
doc.setLineWidth(0.15);
parts.lighting.lamps.forEach(l => {
doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD');
});
// Naden: gestippeld oranje
doc.setDrawColor(230, 130, 30);
doc.setLineWidth(0.4);
doc.setLineDashPattern([1.5, 1], 0);
parts.seams.forEach(s => doc.line(px(s.x0), py(s.z0), px(s.x1), py(s.z1)));
doc.setLineDashPattern([], 0);
// Maatlijnen buiten elk buiten-wandstuk + labels binnenwanden
doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE);
doc.setLineWidth(0.15);
doc.setFontSize(6);
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
const n = p.place.normal, off = 6;
const a = UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2);
const b = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
const x0 = px(a.x) + n.x * off, y0 = py(a.z) + n.y * off, x1 = px(b.x) + n.x * off, y1 = py(b.z) + n.y * off;
PdfSheet.dimLine(doc, x0, y0, x1, y1);
const text = `${p.element}: ${p.place.width}`;
const tx = (x0 + x1) / 2 + n.x * 2.5, ty = (y0 + y1) / 2 + n.y * 2.5 + (n.y > 0 ? 1.5 : 0);
if (n.y === 0) doc.text(text, tx, ty, { align: 'center', angle: 90 });
else doc.text(text, tx, ty, { align: 'center' });
})));
doc.setTextColor(...BLACK);
parts.interior.forEach(p => {
doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: p.place.rotY === 0 ? 0 : 90 });
});
// Buitenmaten woning
doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE);
doc.setFontSize(7);
const topY = py(bz0) - 20, leftX = px(bx0) - 20;
PdfSheet.dimLine(doc, px(bx0), topY, px(bx1), topY);
doc.text('X: ' + Math.round(bx1 - bx0), (px(bx0) + px(bx1)) / 2, topY - 1.5, { align: 'center' });
PdfSheet.dimLine(doc, leftX, py(bz0), leftX, py(bz1));
doc.text('Y: ' + Math.round(bz1 - bz0), leftX - 2, (py(bz0) + py(bz1)) / 2, { align: 'center', angle: 90 });
// Noordpijl (boven = noord)
doc.setFillColor(...BLUE);
doc.triangle(drawX1 - 8, drawY0, drawX1 - 11, drawY0 + 9, drawX1 - 5, drawY0 + 9, 'F');
doc.setTextColor(...BLUE);
doc.setFontSize(8);
doc.text('N', drawX1 - 8, drawY0 + 14, { align: 'center' });
doc.setTextColor(...GREY);
doc.setFontSize(6);
doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' +
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4);
}
function drawTotalsSheet(doc, totals, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal');
doc.setTextColor(...BLACK);
doc.setFontSize(14);
doc.text('Totalen woning', 15, 18);
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 16 });
let y = 30;
const kindRows = totals.kinds.map(k => [KIND_LABELS[k.kind] || k.kind, String(k.count), fmt(k.lengthMM / 1000, 2)]);
kindRows.push(['Totaal', String(totals.beamCount), fmt(totals.kinds.reduce((a, k) => a + k.lengthMM, 0) / 1000, 2)]);
y = PdfSheet.table(doc, 15, y, [0, 45, 65], ['Soort', 'Aantal', 'Lengte (m)'], kindRows, tableOpts('Houtwerk (balken)'));
y += 4;
y = PdfSheet.table(doc, 15, y, [0, 45], ['', ''], [
['Volume hout', fmt(totals.woodM3, 3) + ' m3'],
['Gewicht hout', fmt(totals.woodKg) + ' kg'],
['Latwerk 20x30 (kruislat)', `${totals.battens.count} st. \u00e0 ${WallModel.BATTEN.length} (${fmt(totals.battens.lengthM, 1)} m)`]
], tableOpts('Houtwerk totaal'));
y += 4;
const purchaseRows = [];
totals.purchase.forEach(sec => sec.perStock.forEach(st => purchaseRows.push([`${sec.width}x${sec.depth}`, String(st.stock), String(st.count)])));
purchaseRows.push(['Snijverlies', fmt(totals.purchaseWasteMM / 1000, 2) + ' m',
fmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%']);
PdfSheet.table(doc, 15, y, [0, 30, 55], ['Doorsnede', 'Lengte', 'Aantal'], purchaseRows, tableOpts('Inkoop wandbalken (alle wanden)'));
PdfSheet.table(doc, 120, 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'],
totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]),
Object.assign(tableOpts('Zaaglijst vuren (totaal)'), { maxY: 285 }));
y = 30;
const isoRows = totals.isolatie.map(r => [r.label, fmt(r.m3, 3) + ' m3']);
isoRows.push(['Totaal glaswol', fmt(totals.isolatieM3, 3) + ' m3']);
y = PdfSheet.table(doc, 215, y, [0, 50], ['Onderdeel', 'Inhoud'], isoRows, tableOpts('Isolatie (glaswol, spouwvullend)'));
y += 4;
y = PdfSheet.table(doc, 215, y, [0, 58, 80, 102], ['Materiaal', 'Opp. (m2)', 'Vol. (m3)', 'Gewicht'],
totals.plaatwerk.map(p => [p.naam, fmt(p.m2, 2), p.m3 === null ? 'n.v.t.' : fmt(p.m3, 3), fmt(p.kg) + ' kg']),
tableOpts('Plaatwerk'));
y += 4;
const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']);
wRows.push(['Totaal woning', fmt(totals.totalKg) + ' kg']);
y = PdfSheet.table(doc, 215, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht'));
if (totals.roofEdge) {
y += 4;
const e = totals.roofEdge, L = totals.lamps;
const rows = [];
if (e.fasciaLengthM > 0) {
rows.push(['Boeidelen', `${fmt(e.fasciaLengthM, 2)} m, ${e.fasciaHeight} mm hoog, overstek ${e.overhang} mm`]);
rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']);
}
if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]);
rows.push(['Boeideel-spots', e.spots + ' stuks']);
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
PdfSheet.table(doc, 215, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & buitenverlichting'));
}
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
function generate(state) {
const parts = HouseModel.computeParts(state, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
});
const totals = HouseModel.computeTotals(parts);
const sheets = sheetList(parts);
const project = state.project;
const doc = PdfSheet.createDoc();
sheets.forEach((sheet, i) => {
if (i > 0) doc.addPage('a3', 'landscape');
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts);
else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.unit.roof.data.calcData, sheet.unit.roof.data.resolvedOpenings, Object.assign({ roof: true }, opts));
else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, lamps: sheet.piece.lamps || [] }, opts));
else drawTotalsSheet(doc, totals, opts);
});
return doc;
}
return { generate, sheetList };
})();
+1103
View File
File diff suppressed because it is too large Load Diff