Files
Tectum/js/house/house-model.js
T

1235 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 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 } },
// Gevelbekleding van alle buitenwanden (override per wand: unit.facade):
// rabat = vochtdoek + kruislat + rabat (richting: cladding);
// steen = cement-gebonden plaat + steenblok (stone = steencode, WallModel.STONE).
// Het trasraam (eigen afwerking/steen) komt onder beide.
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel' }, // rabatColor: WallModel.RABAT_COLORS
cladding: 'horizontaal', // richting rabat, voorlopig voor alle buitenwanden
// Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte
// vanaf het beloopbare vloervlak (WallModel.trasraamFor).
// finish: 'cementplaat' of een steencode (WallModel.TRASRAAM_STONES).
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' },
// Binnenzijde van alle wanden (voorlopig globaal): geschilderd in
// interiorColor (egaal, zonder plaatnaden) of kale spaanplaat.
interiorPaint: true,
interiorColor: '#f2efe9',
sandwich: true, // sandwichpanelen rondom (SandwichModel), vanaf loopvlak 600 mm de grond in
groundLevel: 0, // maaiveld: zoveel mm onder het loopvlak (bovenkant vloer); voor de 3D-omgeving
floorFinish: 'visgraat_001', // vloerafwerking (FloorModel.FINISHES), voorlopig globaal
roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS),
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS),
elektra: ElektraModel.normalizeSettings() // meterkast, schakelaars, wcd, leidingen (ElektraModel)
};
}
function createUnitEntry(id, attach, unitOpts) {
const unit = UnitModel.createDefaultState(unitOpts);
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting(), facade: { walls: {} } };
}
// Gevelbekleding: keuze voor de woning, override per wand.
const FACADE_TYPES = { rabat: 'Rabat', steen: 'Steen' };
// { type, stone } voor één wand van een unit (u = entry uit state.units).
function facadeFor(state, u, side) {
const f = state.settings.facade;
const o = (u.facade && u.facade.walls && u.facade.walls[side]) || null;
const type = o && FACADE_TYPES[o.type] ? o.type : f.type;
const stone = o && o.type === 'steen' && WallModel.isStoneFinish(o.stone) ? o.stone : f.stone;
return { type, stone, override: !!o };
}
// TIJDELIJK (testen): een nieuwe woning is standaard het voorbeeldontwerp
// (ook als json/premade/vlinderwoning.json): 92 m², A (straat) en B (tuin)
// met een vlinderdak (lessenaars laag op de naad), C een lage tuinkamer
// met plat dak, veel glas. Op false zetten voor de gewone standaard (één unit).
const TEST_DEFAULT = true;
function createVlinderwoning() {
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
st.project.naam = 'Vlinderwoning';
st.settings.trasraam.finish = 'K490';
const A = createUnitEntry('A', null, { width: 10000, depth: 4000, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10000, depth: 3600, windowOn: [] });
const C = createUnitEntry('C', { to: 'A', side: 'rechts', align: 'start', offset: 0 }, { width: 4000, depth: 4000, windowOn: [] });
st.units.push(A, B, C);
const h = (u, k, l, r) => Object.assign(u.unit.walls[k].wall, { heightAll: Math.min(l, r), heightLeft: l, heightRight: r, heightMid: null });
const HI = 3400, LO = 2700;
h(A, 'voor', HI, HI); h(A, 'achter', LO, LO); h(A, 'links', HI, LO); h(A, 'rechts', LO, HI);
h(B, 'voor', LO, LO); h(B, 'achter', HI, HI); h(B, 'links', LO, HI); h(B, 'rechts', HI, LO);
['voor', 'achter', 'links', 'rechts'].forEach(k => h(C, k, LO, LO));
A.roof.covering = B.roof.covering = 'riet_002';
C.roof.covering = 'dakleer';
// Vulling (KozijnModel): deuren met glasstrook, ramen met een
// borstwering (vanaf 900 mm) met ventilatierooster.
const ops = (u, side, list) => {
u.unit.walls[side].openings = list.map(([type, w, hh, y, anchor, offset], i) => ({
id: i + 1, type, w, h: hh, y, anchor, offset,
fill: Object.assign(KozijnModel.defaultFill(type), type === 'raam' ? { rooster: y >= 900 } : { glas: true })
}));
};
ops(A, 'voor', [['raam', 1200, 1100, 1000, 'left', 700], ['voordeur', 1150, 2300, 0, 'left', 2700], ['raam', 500, 2300, 0, 'left', 3950],
['raam', 2200, 600, 2000, 'left', 5200], ['raam', 900, 2100, 300, 'right', 1800], ['raam', 900, 2100, 300, 'right', 600]]);
ops(A, 'links', [['raam', 1400, 1200, 900, 'middle', 0]]);
ops(B, 'achter', [['raam', 600, 2600, 250, 'left', 800], ['raam', 600, 2600, 250, 'left', 1700], ['deur', 900, 2300, 0, 'middle', -1600],
['raam', 2000, 2300, 0, 'middle', 0], ['deur', 900, 2300, 0, 'middle', 1600], ['raam', 1200, 1200, 1000, 'right', 700]]);
ops(B, 'links', [['raam', 1600, 500, 1700, 'middle', 0]]);
ops(B, 'rechts', [['raam', 1000, 1300, 900, 'left', 500], ['raam', 1000, 1300, 900, 'right', 500]]);
ops(C, 'voor', [['raam', 2600, 1500, 700, 'middle', 0]]);
ops(C, 'rechts', [['raam', 1100, 2100, 250, 'left', 500], ['raam', 1100, 2100, 250, 'right', 500]]);
ops(C, 'achter', [['deur', 900, 2200, 0, 'left', 500], ['raam', 2000, 2200, 0, 'right', 400]]);
ensureSeamWalls(st);
computeParts(st, { skip3D: true });
return st;
}
function createDefaultState(template) {
if (TEST_DEFAULT && (!template || template === 'single')) return createVlinderwoning();
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, fill: KozijnModel.defaultFill('voordeur') }
];
state.units[1].unit.walls.achter.openings = [
{ id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450, fill: KozijnModel.defaultFill('raam') },
{ id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') },
{ id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450, fill: KozijnModel.defaultFill('raam') }
];
}
ensureSeamWalls(state);
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 + beplating (2x, of 1x bij een naadwand-deel met
// buitenzijde 'geen'); de diepte van stijlen en regels wordt daarop afgestemd.
const skinSides = (wallState) => ((wallState.materials || {}).exterior === 'geen' ? 1 : 2);
function setThickness(wallState, thickness) {
const depth = Math.max(20, Math.round(thickness - skinSides(wallState) * wallState.wall.sheathingThick));
wallState.wall.studDepth = depth;
wallState.wall.plateDepth = depth;
}
function thicknessOf(wallState) {
return Math.max(wallState.wall.studDepth, wallState.wall.plateDepth) + skinSides(wallState) * wallState.wall.sheathingThick;
}
// ------------------------------------------------------------
// Naadwanden: op elke naad tussen twee units staat standaard een tussenwand
// met in het midden een deur. Hij bestaat uit twee delen, één per unit
// (de modules worden los vervoerd), die met hun regelwerk tegen elkaar aan
// staan: balken en isolatie in beide delen, beplating alleen aan de
// kamerzijde (buitenMateriaal 'geen' aan de naadzijde). Eén wand-state
// (hoogte, openingen) voor beide delen; de openingen gelden voor het deel
// in unit `seam.a` en worden in het andere deel gespiegeld, zodat de deur
// recht door beide delen gaat. De gebruiker kan hem aanpassen of
// verwijderen; alleen bij een nieuwe woning en bij "unit toevoegen" worden
// ontbrekende naadwanden aangemaakt (ensureSeamWalls).
// state.interiorWalls: { id, seam: { a, b }, wall }
// ------------------------------------------------------------
const DEFAULT_SEAM_LEAF_MM = 100; // dikte per deel (regelwerk + 1x beplating)
function seamWallDefaults(height) {
const wall = WallEditor.getDefaultState();
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = height || wall.wall.heightAll;
wall.materials = { interior: 'spaanplaat', exterior: 'geen' };
const d = WallModel.OPENING_TYPES.deur;
// Binnendeur: wit, zonder glas.
wall.openings = [{ id: 1, type: 'deur', w: d.w, h: d.h, y: d.y, anchor: 'middle', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit' }) }];
setThickness(wall, DEFAULT_SEAM_LEAF_MM);
return wall;
}
const sameSeam = (s, a, b) => (s.a === a && s.b === b) || (s.a === b && s.b === a);
// Maakt ontbrekende naadwanden aan (alleen naden met onlyUnitId, indien gegeven).
function ensureSeamWalls(state, onlyUnitId) {
const { seams } = computeSeams(unitRects(state));
seams.forEach(sm => {
if (onlyUnitId && sm.a !== onlyUnitId && sm.b !== onlyUnitId) return;
if (state.interiorWalls.some(iw => iw.seam && sameSeam(iw.seam, sm.a, sm.b))) return;
const id = state.interiorWalls.reduce((m, w) => Math.max(m, w.id), 0) + 1;
const height = Math.min(...[sm.a, sm.b].map(uid => {
const w = state.units.find(u => u.id === uid).unit.walls.voor.wall;
return Math.min(w.heightLeft, w.heightRight);
}));
state.interiorWalls.push({ id, seam: { a: sm.a, b: sm.b }, wall: seamWallDefaults(height) });
});
}
// Naadwanden van één unit bijwerken na een gewijzigde koppeling: naadwanden
// van die unit waarvan de naad niet meer bestaat vervallen, ontbrekende
// naden krijgen er een.
function syncSeamWalls(state, unitId) {
const { seams } = computeSeams(unitRects(state));
state.interiorWalls = state.interiorWalls.filter(iw => !iw.seam || (iw.seam.a !== unitId && iw.seam.b !== unitId) ||
seams.some(sm => sameSeam(iw.seam, sm.a, sm.b)));
ensureSeamWalls(state, unitId);
}
// Naden van een unit zonder naadwand (bijv. verwijderd): [{ a, b }].
function missingSeamWalls(state, unitId) {
const { seams } = computeSeams(unitRects(state));
return seams.filter(sm => (sm.a === unitId || sm.b === unitId) && !state.interiorWalls.some(iw => iw.seam && sameSeam(iw.seam, sm.a, sm.b)));
}
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, ..., stage, lampenVisible, skip3D }
// stage = bouwfase (BuildStages, standaard afgewerkt): bepaalt welke lagen
// gebouwd worden; de berekening (stats, totalen) is altijd volledig.
// ------------------------------------------------------------
// 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.
// Grenzen (UnitModel.LIMITS) afdwingen: vloermaat, goot/plat dak en nok.
// Muteert state.
function enforceLimits(state) {
const L = UnitModel.LIMITS;
state.units.forEach(u => {
UnitModel.clampSize(u.unit.floor.floor);
const f = UnitModel.roofForm(u.unit);
if (f.low > L.eave + 0.5 || f.high > L.ridge + 0.5) UnitModel.applyRoofForm(u.unit, f);
});
}
function applyGlobalSettings(state) {
const sp = state.settings.spacing;
const tr = state.settings.trasraam;
enforceLimits(state);
state.units.forEach(u => {
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
Object.entries(u.unit.walls).forEach(([side, ws]) => {
ws.wall.minSpacing = sp.exterior.min;
ws.wall.maxSpacing = sp.exterior.max;
const fc = facadeFor(state, u, side);
ws.materials = Object.assign({}, ws.materials, fc.type === 'steen'
? { exterior: 'cementplaat', stone: fc.stone, cladding: state.settings.cladding, trasraam, rabatColor: null }
: { exterior: 'vochtdoek', stone: null, cladding: state.settings.cladding, trasraam, rabatColor: state.settings.facade.rabatColor });
});
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.
// pieces: buitenwandstukken + naadwanddelen met een blootliggend buitendeel.
function assignFacades(pieces) {
const segs = [];
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';
// Binnenhoek-doorloop (negatieve skinExt, zie trimTop): zit dat stuk
// over de hele hoogte in de buur-unit, dan begint de gevel pas daarna.
// Loopt de gevel erboven door (lager dak van de buur), dan telt het
// hele stuk mee, zodat de gevel één geheel blijft met de rest.
const e = p.skinExt || {}, tt = p.trimTop || {};
const maxY = WallModel.topLine(p.config).maxY + (p.skinAbove || 0);
const trim = (k) => e[k] < 0 && !(tt[k] > 0 && tt[k] < maxY - 1) ? -e[k] : 0;
const tL = trim('left'), tR = trim('right');
const q0 = UnitModel.localToWorld(p.place, -W / 2 + tL, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2 - tR, T / 2);
segs.push({ piece: p, trimL: tL, 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 = Math.max(0, (first.piece.skinExt && first.piece.skinExt.left) || 0);
// Horizontaal latten-raster: vanaf het trasraam van een gewoon stuk
// (absolute hoogte), zodat opbouw/naadwand-stukken erop aansluiten.
const ref = run.segs.find(sg => !sg.piece.place.baseY && sg.piece.skinFrom === undefined);
const gridAbs = ref ? ref.piece.place.floorTop + WallModel.skinStartY(ref.piece.trasraam) : undefined;
const floorAbs = ref ? ref.piece.place.floorTop : undefined;
run.segs.forEach(sg => {
const p = sg.piece;
sg.piece.facade = {
u0: Math.abs(sg.sAt0 - sLeft) + extLeft - sg.trimL, extStart: extLeft > 0, isEnd: Math.abs(sg.sAtW - sRight) < 5,
gridY0: gridAbs === undefined ? undefined : gridAbs - p.place.floorTop,
yOff: floorAbs === undefined ? 0 : p.place.floorTop - floorAbs
};
});
});
}
function computeParts(state, opts) {
const o = opts || {};
const build = !o.skip3D;
const stage = BuildStages.clamp(o.stage);
const shows = (key) => BuildStages.shows(stage, key);
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, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove) {
if (!build) return { group: null, stats: null, specs: null };
const built = WallModel.build3D(config, frame, {
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove,
painted: state.settings.interiorPaint && shows('afwerking'), stage, rabatColor: state.settings.facade.rabatColor
});
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
}
// Wandstukken van één zijde (UnitModel.wallPieces), met plaats in de
// woning. raised: opbouw-zones (zie raiseSeamWalls). Een opbouwstuk
// (baseY) staat op die hoogte boven het loopvlak.
function wallOf(u, rect, layout, wl, raised) {
const ws = u.unit.walls[wl.side];
const buitenMateriaal = (ws.materials || {}).exterior || 'vochtdoek';
const cladding = (ws.materials || {}).cladding || 'horizontaal';
const stone = buitenMateriaal === 'cementplaat' ? ((ws.materials || {}).stone || null) : null;
const trasraam = buitenMateriaal === 'vochtdoek' || stone ? ((ws.materials || {}).trasraam || null) : null;
const wp = UnitModel.wallPieces(wl, ws, raised);
const pieces = wp.pieces.map(p => {
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
const base = p.baseY || 0;
return Object.assign(p, {
element: `${u.id}-W${wl.nr}${p.suffix}`,
label: `Unit ${u.id} ${wl.label.toLowerCase()}` + (base ? ` opbouw (vanaf ${base} mm)` : p.suffix ? ` (${p.suffix})` : ''),
buitenMateriaal, cladding, trasraam: base ? null : trasraam, stone,
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 + base, baseY: base }
}); // 3D volgt na assignFacades (latwerk/doek per gevel)
});
// Naad-zones met opbouw: in de wand-editor alleen gearceerd tot base.
// Naast de opbouw (haakse wand in de hoek) blijft het over de volle hoogte.
const cutsU = [];
wl.cutsU.forEach(([a, b]) => {
const r = pieces.find(p => p.baseY && p.u0 < b - EPS && p.u1 > a + EPS);
if (!r) { cutsU.push([a, b]); return; }
if (r.u0 > a + EPS) cutsU.push([a, r.u0]);
cutsU.push([Math.max(a, r.u0), Math.min(b, r.u1), r.baseY]);
if (r.u1 < b - EPS) cutsU.push([r.u1, b]);
});
return Object.assign({}, wl, { cutsU, pieces, dropped: wp.dropped, removed: pieces.length === 0 });
}
const units = state.units.map(u => {
const rect = rectById[u.id];
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
// Kopbalk op een naad langs voor-/achterrand (haaks op de balken): daar
// worden de modules weer aan elkaar gemonteerd. Zelfde voor het dak.
const rims = { front: cuts[u.id].voor.length > 0, back: cuts[u.id].achter.length > 0 };
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
materials: o.floorMaterials, osbVisible: shows('beplating'),
finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null
}) : null;
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), in de vorm die
// de wanden aangeven (plat, lessenaar, zadel, mansarde of wolfsdak;
// RoofModel.computeShape).
const shape = RoofModel.computeShape(u.unit, layout);
const deckFrame = FloorModel.computeFrame(u.roof.deck.floor);
const roofW = deckFrame.width, roofD = deckFrame.depth;
const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0;
const ur = RoofModel.computeUnitRoof(u.roof, shape, { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }, rims);
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', mansarde: 'mansarde', wolf: 'wolfsdak' }[shape.type];
const multi = ur.planes.length > 1;
ur.planes.forEach((pl, i) => {
pl.element = `${u.id}-D${i + 1}`;
const deg = Math.round(Math.abs(pl.theta) * 180 / Math.PI * 10) / 10;
pl.label = `Unit ${u.id} dak${multi ? ' deel ' + (i + 1) : ''} (${shapeLabel}${deg ? ', ' + String(deg).replace('.', ',') + '°' : ''})`;
});
const roof = {
element: `${u.id}-D1`, shape, covering: ur.covering, planes: ur.planes, specs: ur.specs,
unitRoof: ur, // o.a. het bovenste deel van een wolfsdak (cap, RoofModel.capRegions)
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, // 3D: group-Y = 0 op het loopvlak
// Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut).
ceilingAt: (x, z) => layout.floorTop + ur.undersideAt(x - rect.cx, z - rect.cz),
topAt: (x, z) => layout.floorTop + ur.topAt(x - rect.cx, z - rect.cz),
ridges: ur.ridges.map(r => ({ axis: r.axis, value: r.value + (r.axis === 'x' ? rect.cx : rect.cz) })),
top: layout.floorTop + ur.maxTop,
// Bouwfase: plafondplaat bij "beplating", bedekking bij "gevel".
group: build ? RoofModel.buildUnit3D(ur, {
materials: o.roofMaterials,
osbVisible: shows('beplating'),
dakleerVisible: shows('gevel')
}) : null
};
return {
id: u.id, rect, layout, floorData, floorSpecs, floorGroup, walls, roof,
floorElement: `${u.id}-V1`
};
});
// Opbouw op een naad: staat een unit met een hogere daklijn tegen een
// unit met een lager dak, dan loopt zijn buitenwand aan de naadzijde door
// vanaf de bovenkant van het lage dak tot zijn eigen daklijn (zoals een
// buitenwand die bij een schuin dak omhoog groeit). Ramen daar teken je
// op die buitenwand. De naadwand eronder stopt op die hoogte (raisedCap).
// Alleen bij een rechte voet: het lage dak ligt langs de hele naad even
// hoog; anders blijft de naadwand doorlopen met buitenafwerking.
const raisedCap = {}; // `${uid}|${andere uid}` -> base (mm vanaf loopvlak uid)
(function raiseSeamWalls() {
const zones = {}; // uid -> side -> [{ u0, u1, base, other }]
seams.forEach(sm => {
const horiz = Math.abs(sm.z0 - sm.z1) < EPS;
const seamPerp = horiz ? sm.z0 : sm.x0;
const s0 = horiz ? sm.x0 : sm.z0, s1 = horiz ? sm.x1 : sm.z1;
[[sm.a, sm.b, horiz ? 'achter' : 'rechts'], [sm.b, sm.a, horiz ? 'voor' : 'links']].forEach(([uid, oid, side]) => {
const u = units.find(x => x.id === uid), other = units.find(x => x.id === oid);
const ts = [s0, s1].concat(other.roof.ridges.filter(r => r.axis === (horiz ? 'x' : 'z') && r.value > s0 && r.value < s1).map(r => r.value));
const tops = ts.map(t => horiz ? other.roof.topAt(t, seamPerp) : other.roof.topAt(seamPerp, t));
if (Math.max(...tops) - Math.min(...tops) > 2) return; // schuine voet: terugval
const base = Math.round(tops[0] - u.layout.floorTop);
const wl = u.walls.find(w => w.side === side);
const sd = UnitModel.WALL_SIDES.find(x => x.key === side);
const c0 = sd.axis === 'x' ? u.rect.cx : u.rect.cz;
const start = (sd.axis === 'x' ? wl.cx : wl.cz) - wl.width / 2;
const toU = (t) => sd.dir > 0 ? t - c0 - start : start + wl.width - (t - c0);
const z0 = Math.min(toU(s0), toU(s1)), z1 = Math.max(toU(s0), toU(s1));
wl.cutsU.forEach(([a, b]) => {
let lo = Math.max(a, z0), hi = Math.min(b, z1);
if (lo < EPS) lo = wl.endInset.left;
if (hi > wl.width - EPS) hi = wl.width - wl.endInset.right;
if (hi - lo > EPS) ((zones[uid] = zones[uid] || {})[side] = zones[uid][side] || []).push({ u0: lo, u1: hi, base, other: oid });
});
});
});
Object.keys(zones).forEach(uid => {
const u = units.find(x => x.id === uid);
const su = state.units.find(x => x.id === uid);
Object.keys(zones[uid]).forEach(side => {
const i = u.walls.findIndex(w => w.side === side);
const wl = u.layout.walls.find(w => w.side === side);
const next = wallOf(su, u.rect, u.layout, wl, zones[uid][side]);
u.walls[i] = next;
zones[uid][side].forEach(z => {
if (next.pieces.some(p => p.baseY && Math.abs(p.u0 - z.u0) < EPS)) raisedCap[`${uid}|${z.other}`] = z.base;
});
});
});
})();
// Binnenwanden staan op de vloer van de unit waarin hun midden valt.
// Naadwanden (iw.seam) leveren twee delen op, één per unit (seamLeaves).
const interior = [];
let bwNr = 0, nwNr = 0;
state.interiorWalls.forEach(iw => {
if (iw.seam) { nwNr++; seamLeaves(iw, nwNr).forEach(l => interior.push(l)); return; }
bwNr++;
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';
interior.push({
id: iw.id, element: `BW-${bwNr}`, label: `Binnenwand ${bwNr}`, config, frame, buitenMateriaal,
place: { cx: lay.cx, cz: lay.cz, rotY: lay.rotY, width: lay.width, thickness: WallModel.thicknessOf(config), floorTop }
});
});
// Twee delen van een naadwand. Elk deel staat met zijn regelwerk op de
// naad (buitenzijde 'geen' = naadzijde) in zijn eigen unit. Een deel loopt van wand tot wand: waar een buitenwandstuk de hartlijn
// van het deel kruist, stopt het (voetafdrukken, zoals bij de
// gevellampen). De hoogte volgt de daklijn van de buitenwand van de unit
// aan de naadzijde (RoofModel.wallProfile), zodat het deel onder het dak
// van zijn eigen unit past. Steekt een deel boven het dak van de andere
// unit uit (bijv. hoge unit tegen een lage), dan krijgt dat stuk van de
// naadzijde buitenafwerking (vochtdoek + latwerk, skinFrom) die met de
// gevel van de woning meeloopt; het regelwerk blijft dat van de naadwand.
function seamLeaves(iw, nr) {
const sm = seams.find(x => sameSeam(x, iw.seam.a, iw.seam.b));
if (!sm) return []; // units niet (meer) tegen elkaar: naadwand vervalt
const horiz = Math.abs(sm.z0 - sm.z1) < EPS;
// Buitenwand van de unit aan de naadzijde (side +1: unit a, lage kant).
const seamSide = (side) => horiz ? (side > 0 ? 'achter' : 'voor') : (side > 0 ? 'rechts' : 'links');
const unitState = (uid) => state.units.find(u => u.id === uid).unit;
const baseConfig = WallModel.configFromState(iw.wall);
const maxD = Math.max(baseConfig.studDepth, baseConfig.plateDepth);
const T1 = WallModel.thicknessOf(baseConfig, 'geen');
// Plaats (iw.seam.in): leeg = midden op de naad (twee delen), 'a'/'b' =
// geheel in die unit: één binnenwand (beplating aan beide zijden) tegen
// de naad aan, met de hoogte van de daklijn van die unit.
const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
const T2 = WallModel.thicknessOf(baseConfig, 'spaanplaat');
// Opbouwstukken (baseY) staan boven de naadwand, niet ernaast.
const extPieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { if (!p.baseY) extPieces.push(p); })));
const along = (q) => horiz ? q.x : q.z, perpOf = (q) => horiz ? q.z : q.x;
const seamPerp = horiz ? sm.z0 : sm.x0;
const s0 = horiz ? sm.x0 : sm.z0, s1 = horiz ? sm.x1 : sm.z1;
const leaves = [];
[[sm.a, 1], [sm.b, -1]].forEach(([uid, side]) => {
// side +1: unit a ligt aan de lage kant, de naad aan zijn +kant.
if (inUnit && uid !== inUnit) return;
const T = inUnit ? T2 : T1;
const line = seamPerp - side * T / 2;
const blocked = [];
extPieces.forEach(p => {
const cs = [[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([i, j]) => UnitModel.localToWorld(p.place, i * p.place.width / 2, j * p.place.thickness / 2));
const pMin = Math.min(...cs.map(perpOf)), pMax = Math.max(...cs.map(perpOf));
if (line > pMin + 0.5 && line < pMax - 0.5) blocked.push([Math.min(...cs.map(along)), Math.max(...cs.map(along))]);
});
let cursor = s0, best = null;
blocked.sort((p, q) => p[0] - q[0]).concat([[s1, s1]]).forEach(([a, b]) => {
const hi = Math.min(a, s1);
if (hi - cursor > (best ? best[1] - best[0] : 0)) best = [cursor, hi];
cursor = Math.max(cursor, b);
});
if (!best || best[1] - best[0] < 300) return;
const width = Math.round(best[1] - best[0]), mid = (best[0] + best[1]) / 2;
const rotY = horiz ? (side > 0 ? 0 : Math.PI) : (side > 0 ? Math.PI / 2 : -Math.PI / 2);
const place = {
cx: horiz ? mid : line, cz: horiz ? line : mid, rotY, width, thickness: T,
normal: { x: Math.round(Math.sin(rotY)), y: Math.round(Math.cos(rotY)) }, // naadzijde
floorTop: units.find(u => u.id === uid).layout.floorTop
};
leaves.push({ uid, side, place });
});
if (!leaves.length) return [];
// Hoogtes van een deel: daklijn van de buitenwand aan de naadzijde, op
// de uiteinden van het deel (incl. nok als die erin valt).
const heightsOf = (lf) => {
const u = units.find(x => x.id === lf.uid);
const prof = RoofModel.wallProfile(unitState(lf.uid), u.layout, seamSide(lf.side));
const axisOf = (q) => horiz ? q.x - u.rect.cx : q.z - u.rect.cz;
const W = lf.place.width;
const pL = axisOf(UnitModel.localToWorld(lf.place, -W / 2, 0)), pR = axisOf(UnitModel.localToWorld(lf.place, W / 2, 0));
// Opbouw erboven (raiseSeamWalls): het deel stopt op de voet daarvan.
const cap = raisedCap[`${lf.uid}|${lf.uid === sm.a ? sm.b : sm.a}`];
const lim = (h) => cap !== undefined ? Math.min(h, cap) : h;
const hL = lim(RoofModel.profileHeight(prof, pL)), hR = lim(RoofModel.profileHeight(prof, pR));
// Tussenpunten (nok, knikken) die in het deel vallen, als fractie vanaf links.
const lo = Math.min(pL, pR), hi = Math.max(pL, pR);
const inner = prof.slice(1, -1).filter(q => q.p > lo + 1 && q.p < hi - 1)
.map(q => ({ f: Math.abs(q.p - pL) / (hi - lo), h: Math.round(lim(q.h)) })).sort((a, b) => a.f - b.f);
return {
heightLeft: Math.round(hL), heightRight: Math.round(hR),
heightMid: null, peakX: undefined, topPts: inner.length ? inner : undefined
};
};
const paramsOf = (lf) => Object.assign({}, iw.wall.wall, { width: lf.place.width }, heightsOf(lf));
// Laagste bovenkant dak van de andere unit langs dit deel (mm vanaf het
// loopvlak van het deel), of null als het deel er niet bovenuit steekt.
const exposedFrom = (lf, config) => {
const other = units.find(u => u.id === (lf.uid === sm.a ? sm.b : sm.a));
const W = lf.place.width;
const ends = [-W / 2, W / 2].map(lx => along(UnitModel.localToWorld(lf.place, lx, 0)));
const lo = Math.min(...ends), hi = Math.max(...ends);
const ts = ends.concat(other.roof.ridges.filter(r => r.axis === (horiz ? 'x' : 'z') && r.value > lo && r.value < hi).map(r => r.value));
const from = Math.min(...ts.map(t => horiz ? other.roof.topAt(t, seamPerp) : other.roof.topAt(seamPerp, t))) - lf.place.floorTop;
return from < WallModel.topLine(config).maxY - 50 ? Math.max(0, Math.round(from)) : null;
};
// Openingen: opgeslagen voor het eerste deel; het andere deel krijgt
// dezelfde openingen op dezelfde plek in de wereld (gespiegeld).
const first = leaves[0];
const firstParams = paramsOf(first);
// Voor de wand-editor: breedte en daklijn van het eerste deel.
Object.assign(iw.wall.wall, { width: first.place.width, heightLeft: firstParams.heightLeft, heightRight: firstParams.heightRight, heightMid: null, topPts: firstParams.topPts });
const firstConfig = WallModel.buildConfig(firstParams, iw.wall.openings || []);
const toLocalU = (pl, q) => {
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
return (q.x - pl.cx) * c - (q.z - pl.cz) * sn + pl.width / 2;
};
return leaves.map((lf, k) => {
let config = firstConfig;
if (k > 0) {
const ops = firstConfig.openings.map(op => {
const wa = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x, 0);
const wb = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x + op.w, 0);
const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb);
// Gespiegeld (andere kijkrichting): schuine bovenzijde klapt om.
// Het kozijn (fill) staat alleen in het eerste deel.
const flip = ua > ub;
return { id: op.id, type: op.type, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
});
config = WallModel.buildConfig(paramsOf(lf), ops);
}
const frame = WallModel.computeFrame(config);
const skinFrom = exposedFrom(lf, config);
return {
id: iw.id, seam: iw.seam, seamUnit: lf.uid,
element: `NW-${nr}${lf.uid}`, label: `Naadwand ${nr} (unit ${lf.uid})`,
config, frame, place: lf.place,
// Het regelwerk staat gecentreerd op lokaal z = 0, de beplating
// alleen aan de achterzijde: bij het bouwen schuiven zodat het
// midden van de totale dikte op place.cx/cz ligt (regelwerk op de naad).
// Geheel in één unit: beplating aan beide zijden, dus symmetrisch.
shiftZ: inUnit ? 0 : (T1 - maxD) / 2,
...(skinFrom === null
? { buitenMateriaal: inUnit ? 'spaanplaat' : 'geen' }
: { buitenMateriaal: 'vochtdoek', skinFrom, spaanplaatBelow: !!inUnit, cladding: state.settings.cladding || 'horizontaal', trasraam: null })
};
});
}
// Schuine regel op de bovenregel (WallModel, CONFIG.topWedge): helt het
// dak dwars over de wand (goot van een zadel-/lessenaardak, onder een
// wolfseind), dan zit er tussen de vlakke bovenregel en de onderkant van
// het dak een wig. Per positie langs de wand de hoogte van die wig aan
// de buiten- (o) en binnenkant (i) van het regelwerk (mm), of null.
function topWedge(p, roof) {
const cfg = p.config, half = cfg.wallWidth / 2, zF = cfg.studDepth / 2, tl = WallModel.topLine(cfg);
const xs = new Set([-half, half].concat(tl.kinks));
for (let x = -half + 250; x < half; x += 250) xs.add(x);
const pts = [...xs].sort((a, b) => a - b).map(x => {
const top = p.place.floorTop + tl.at(x);
const at = (z) => { const w = UnitModel.localToWorld(p.place, x, z); return Math.max(0, Math.min(800, roof.ceilingAt(w.x, w.z) - top)); };
return { x, o: Math.round(at(zF)), i: Math.round(at(-zF)) };
});
return pts.some(q => Math.max(q.o, q.i) >= 5) ? { pts } : null;
}
// Doek en latwerk volgen de hele gevel (niet per unit/wandstuk), ook
// over het blootliggende deel van een naadwand. Daarna pas bouwen.
const extPieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => extPieces.push(p))));
// Gevel tot de bovenkant van het dak: tussen wandbovenkant en bovenkant
// dak zit het dakpakket (dek + bedekking, verschilt per dak). De
// buitenlagen van de wand lopen daar door (skinAbove, mm). Boeideel en
// daktrim komen daar bovenop (computeEdge, facadeDepth); de houtconstructie
// en de gevel zijn gelijk met of zonder boeideel.
units.forEach(u => {
u.walls.forEach(w => w.pieces.forEach(p => {
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
// (vanaf de bovenkant van de wand: daar kan nog een schuine regel op)
const wallTop = p.place.floorTop + WallModel.topLine(p.config).at(0);
p.skinAbove = Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - Math.min(wallTop, u.roof.ceilingAt(m.x, m.z))));
p.config.topWedge = topWedge(p, u.roof);
// Binnenhoek-doorloop (negatieve skinExt): tot de bovenkant van het
// dak van de buur-unit die daar staat geen buitenlagen (trimTop).
const e = p.skinExt || {}, W = p.place.width;
p.trimTop = {};
[['left', -1], ['right', 1]].forEach(([k, d]) => {
if (!(e[k] < 0)) return;
const q = UnitModel.localToWorld(p.place, d * (W / 2 + e[k] / 2), p.place.thickness / 2 + 20);
const nb = units.find(o => o.id !== u.id && q.x > o.rect.x0 && q.x < o.rect.x1 && q.z > o.rect.z0 && q.z < o.rect.z1);
p.trimTop[k] = nb ? Math.round(nb.roof.topAt(q.x, q.z) - p.place.floorTop) : 0;
});
}));
});
// Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken;
// boeidelen aan/uit per unit. De rand volgt de dakvorm (en hoogte) van
// de unit waar de ribbe bij hoort, ook bij platte daken: een hoogte-
// verschil verspringt, en vanaf de boeideelhoogte krijgt het hoge dak een
// eigen rand en loopt die van het lage door (RoofModel.computeEdge).
// Dikte van de gevelafwerking langs een dakrand-ribbe (boeideel komt
// erbovenop): gevelstuk op die lijn met dezelfde buitenrichting (+), of
// een gevel van een ander deel die naar dit dak toe kijkt (-).
const facadeDepth = (a, b, nrm) => {
const horiz = Math.abs(nrm.z) > 0.5;
const line = horiz ? a.z : a.x, s0 = Math.min(horiz ? a.x : a.z, horiz ? b.x : b.z), s1 = Math.max(horiz ? a.x : a.z, horiz ? b.x : b.z);
let best = 0, bestOv = 1;
extPieces.forEach(p => {
const pn = { x: p.place.normal.x, z: p.place.normal.y };
const dot = pn.x * nrm.x + pn.z * nrm.z;
if (Math.abs(dot) < 0.5) return;
const q0 = UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2), q1 = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
if (Math.abs((horiz ? q0.z : q0.x) - line) > 2) return;
const ov = Math.min(s1, Math.max(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z)) - Math.max(s0, Math.min(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z));
if (ov <= bestOv) return;
bestOv = ov;
best = Math.sign(dot) * WallModel.claddingOffset(p.buitenMateriaal, p.stone);
});
return best;
};
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,
topAt: u.roof.topAt, ridges: u.roof.ridges
})), facadeDepth) : null;
// Latwerk stopt onder het boeideel/de daktrim die boven het wandstuk
// zit (battenAbove, mm t.o.v. de wandbovenkant, kan negatief zijn): rand
// bovenkant = dak + EDGE_ABOVE, hoogte fasciaHeight resp. trimHeight + EDGE_ABOVE.
const pointInPoly = (q, poly) => {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j];
if ((a.z > q.z) !== (b.z > q.z) && q.x < (b.x - a.x) * (q.z - a.z) / (b.z - a.z) + a.x) inside = !inside;
}
return inside;
};
if (roofEdge) extPieces.forEach(p => {
const nrm = { x: p.place.normal.x, z: p.place.normal.y };
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
// Een paar afstanden vóór de gevel: de rand begint op de dakrand, die
// op of iets vóór het wandvlak kan liggen.
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
if (!sg) return;
const h = sg.fascia ? roofEdge.fasciaHeight : roofEdge.trimHeight + roofEdge.edgeAbove;
p.battenAbove = (p.skinAbove || 0) + roofEdge.edgeAbove - h;
});
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
extPieces.concat(interior).forEach(p => {
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove);
if (built.group && p.shiftZ) {
const outer = new THREE.Group();
built.group.position.z = p.shiftZ * 0.001;
outer.add(built.group);
built.group = outer;
}
Object.assign(p, built);
});
// Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots).
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
// Zichtbaarheid: dakrand en lampen in de laatste bouwfase; lampen ook via "Lampen".
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking')
}) : null;
// Binnenspots in het plafond, per unit (LightingModel.computeIndoor).
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false && shows('afwerking');
// Elektra: meterkast, schakelaars (letter = ook op hun lampen), wcd's,
// groepen en leidingen naar de meterkast (ElektraModel).
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge);
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
// 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: shows('gevel')
}) : null;
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, 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).
// ------------------------------------------------------------
// ------------------------------------------------------------
// Afmetingen (infopaneel 3D + blad 1 PDF). Oppervlakken in m2, maten in mm.
// bruto vloer = vloer-rechthoeken (tot buitenkant constructie)
// leefbaar = bruto vloer min de voetafdruk van alle wanden
// gevel = buitenvlak van alle gevelstukken (incl. doorloop tot
// onder de dakrand), bruto en netto (min openingen)
// binnenwanden = geplaatst oppervlak aan de kamerzijde (alle wanden)
// dak = hellend dakoppervlak (bedekking) en in plattegrond
// groundLevel: maaiveld onder het loopvlak (mm), voor de bouwhoogte.
// ------------------------------------------------------------
function computeDimensions(parts, groundLevel) {
const m2 = (mm2) => mm2 / 1e6;
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 floorMM2 = rects.reduce((a, r) => a + r.W * r.D, 0);
const ext = [], all = allWallPieces(parts);
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
// Voetafdruk: gewone wanden (geen opbouw boven een lager dak).
const footprintMM2 = all.filter(p => !p.place.baseY).reduce((a, p) => a + p.place.width * p.place.thickness, 0);
const wallArea = (p, y0) => {
const tl = WallModel.topLine(p.config), W = p.config.wallWidth;
const poly = [{ x: -W / 2, y: y0 }, { x: W / 2, y: y0 }, ...tl.pts.slice().reverse()];
return Math.max(0, polygonArea(poly)) + (p.skinAbove || 0) * W;
};
const openingsMM2 = (p) => (p.config.openings || []).reduce((a, op) => a + WallModel.openingArea(op), 0);
const facadePieces = ext.concat(parts.interior.filter(p => p.skinFrom !== undefined));
const gevelBruto = facadePieces.reduce((a, p) => a + wallArea(p, p.skinFrom || 0), 0);
const gevelOpen = ext.reduce((a, p) => a + openingsMM2(p), 0);
const glas = ext.reduce((a, p) => a + (p.config.openings || []).filter(op => op.type === 'raam').reduce((b, op) => b + WallModel.openingArea(op), 0), 0);
const binnen = all.reduce((a, p) => a + ((p.stats && p.stats.spaanplaatAreaM2) || 0) + ((p.stats && p.stats.spaanplaatBuitenAreaM2) || 0), 0);
const dakM2 = parts.units.reduce((a, u) => a + (u.roof.specs.dakleerM2 || 0) + (u.roof.specs.rietM2 || 0) + (u.roof.specs.felsM2 || 0), 0);
const dakPlanMM2 = parts.units.reduce((a, u) => a + u.roof.rect.W * u.roof.rect.D, 0);
// Inhoud (bruto): per unit gemiddelde hoogte bovenkant dak boven de
// onderkant vloer, bemonsterd op een raster.
const inhoudMM3 = parts.units.reduce((a, u) => {
const r = u.rect, n = 8;
let sum = 0;
for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) sum += u.roof.topAt(r.x0 + (i + 0.5) * r.W / n, r.z0 + (j + 0.5) * r.D / n);
return a + r.W * r.D * (sum / (n * n));
}, 0);
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
const top = parts.roofEdge ? parts.roofEdge.top : Math.max(...parts.units.map(u => u.roof.top));
return {
lengthX: bx1 - bx0, lengthZ: bz1 - bz0, height: top - (floorTop - (groundLevel || 0)),
brutoM2: m2(floorMM2), leefbaarM2: m2(floorMM2 - footprintMM2),
gevelBrutoM2: m2(gevelBruto), gevelOpeningM2: m2(gevelOpen), gevelNettoM2: m2(gevelBruto - gevelOpen), glasM2: m2(glas),
binnenwandM2: binnen, dakM2, dakPlanM2: m2(dakPlanMM2), inhoudM3: inhoudMM3 / 1e9
};
}
// Als rijen [label, waarde, eenheid] (UI-kaart en PDF-tabel).
function dimensionRows(d, fmt) {
return [
['Buitenmaten (b × d)', `${Math.round(d.lengthX)} × ${Math.round(d.lengthZ)}`, 'mm'],
['Bouwhoogte (vanaf maaiveld)', Math.round(d.height), 'mm'],
['Bruto vloeroppervlak (buitenwerks)', fmt(d.brutoM2, 2), 'm²'],
['Leefbare ruimte (netto vloer)', fmt(d.leefbaarM2, 2), 'm²'],
['Gevel buitenkant (bruto)', fmt(d.gevelBrutoM2, 2), 'm²'],
['Openingen in gevel', fmt(d.gevelOpeningM2, 2), 'm²'],
['Gevel buitenkant (netto)', fmt(d.gevelNettoM2, 2), 'm²'],
['Glas (ramen)', fmt(d.glasM2, 2), 'm²'],
['Wanden binnenkant (alle)', fmt(d.binnenwandM2, 2), 'm²'],
['Dak (dakoppervlak)', fmt(d.dakM2, 2), 'm²'],
['Dak (plattegrond)', fmt(d.dakPlanM2, 2), 'm²'],
['Inhoud (bruto)', fmt(d.inhoudM3, 1), 'm³']
];
}
// ------------------------------------------------------------
// Transport: elke unit gaat als losse module op de wagen (vloer, wanden
// incl. zijn naadwanddelen, dak; zonder dakrand). Buitenmaat incl. de
// gevelafwerking (latwerk + rabat, steen). Breedteklassen indicatief
// (wegvervoer NL): tot 2550 mm regulier, tot 3500 mm met ontheffing,
// daarboven met begeleiding. Controleer altijd bij de vervoerder.
// ------------------------------------------------------------
const TRANSPORT = { widthRegular: 2550, widthPermit: 3500 };
const TRANSPORT_CLASSES = { regulier: 'Regulier', ontheffing: 'Ontheffing', begeleiding: 'Ontheffing + begeleiding' };
function computeTransport(parts) {
const kgOf = (p) => (p.specs && p.specs.totalKg) || 0;
return parts.units.map(u => {
const leaves = parts.interior.filter(p => p.seamUnit === u.id);
const kg = {
vloer: u.floorSpecs.totalKg,
wanden: u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + kgOf(p), 0), 0) + leaves.reduce((a, p) => a + kgOf(p), 0),
dak: u.roof.specs.totalKg
};
// Afwerking buiten het wandvlak per zijde (0 als de wand vervalt).
const out = (side) => Math.max(0, ...((u.walls.find(w => w.side === side) || { pieces: [] }).pieces
.filter(p => !p.baseY)
.map(p => Math.max(WallModel.claddingOffset(p.buitenMateriaal, p.stone),
p.trasraam && WallModel.isStoneFinish(p.trasraam.finish) ? WallModel.STONE.THICK_MM : 0))));
const ew = u.rect.W + out('links') + out('rechts'), ed = u.rect.D + out('voor') + out('achter');
const width = Math.min(ew, ed);
return {
id: u.id, kg, totalKg: kg.vloer + kg.wanden + kg.dak,
length: Math.max(ew, ed), width, height: u.roof.top,
cls: width <= TRANSPORT.widthRegular ? 'regulier' : width <= TRANSPORT.widthPermit ? 'ontheffing' : 'begeleiding'
};
});
}
function computeTotals(parts) {
const pieces = allWallPieces(parts);
const kinds = new Map();
const sawList = new Map();
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
const saw = { lengthMM: 0, cuts: 0, angles: new Map() };
function addPiece(kind, width, depth, length, cutAngles) {
const ca = cutAngles || [0, 0];
saw.lengthMM += length;
saw.cuts += ca.length;
ca.forEach(a => saw.angles.set(a, (saw.angles.get(a) || 0) + 1));
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.joistLen));
['front', 'back'].filter(k => fc.rims[k]).forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.width));
u.roof.planes.forEach(pl => {
const rc = pl.data.calcData;
// Sporen in een hellend dakvlak: loodrechte snede aan beide koppen.
const ang = Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen, [ang, ang]));
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
});
// Wolfsdak: bovenste deel (sporen, hoekkepers, nokbalk, kopbalken).
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
if (cap) cap.beams.forEach(bm => addPiece('dakbalk', cap.joistW, cap.joistH, bm.length, [bm.angle, bm.angle]));
});
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, b.cutAngles); }));
const purchase = WallModel.planPurchase(wallBom);
const sumP = (fn) => pieces.reduce((acc, p) => acc + fn(p), 0);
const stonePiece = pieces.find(p => p.trasraam && WallModel.isStoneFinish(p.trasraam.finish));
const stoneCode = stonePiece ? stonePiece.trasraam.finish : null;
const stoneCodes = [...new Set(pieces.filter(p => p.stone).map(p => p.stone))].join(', ');
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),
// Zaagmachine: totale zaaglengte, sneden en hoeken (tijd: CostModel).
saw: {
meters: saw.lengthMM / 1000, cuts: saw.cuts,
angles: Array.from(saw.angles.entries()).map(([deg, count]) => ({ deg, count })).sort((a, b) => a.deg - b.deg)
},
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: `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm (bovenop de sporen)`, m2: sumU(u => u.roof.specs.beschotM2 || 0), m3: sumU(u => u.roof.specs.beschotVolM3 || 0), kg: sumU(u => u.roof.specs.beschotKg || 0) },
{ naam: `Riet (${RoofModel.RIET.thickMM} mm)`, m2: sumU(u => u.roof.specs.rietM2 || 0), m3: null, kg: sumU(u => u.roof.specs.rietKg || 0) },
{ naam: `Felsplaten (staande naad, ${RoofModel.FELS.thickMM} mm)`, m2: sumU(u => u.roof.specs.felsM2 || 0), m3: null, kg: sumU(u => u.roof.specs.felsKg || 0) },
{
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: `Steenstrips gevel${stoneCodes ? ' (' + stoneCodes + ')' : ''}`,
m2: sumP(p => p.stats.stoneAreaM2 || 0), m3: null, kg: 0 // gewicht volgt
},
{
naam: `Steenstrips trasraam${stoneCode ? ' (' + stoneCode + ')' : ''}`,
m2: sumP(p => p.stats.trasraamStoneAreaM2 || 0), m3: null, kg: 0 // gewicht volgt
},
{
naam: `Isolatieblok steen (${WallModel.STONE.THICK_MM - WallModel.STONE.STRIP_MM} mm)`,
m2: sumP(p => (p.stats.trasraamIsolatieM3 || 0) + (p.stats.stoneIsolatieM3 || 0)) / ((WallModel.STONE.THICK_MM - WallModel.STONE.STRIP_MM) / 1000),
m3: sumP(p => (p.stats.trasraamIsolatieM3 || 0) + (p.stats.stoneIsolatieM3 || 0)), kg: 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)
},
{
naam: `Rabat ${WallModel.CLADDING_THICK_MM} mm (gevelbekleding)`, m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.rabatAreaM2 || 0), 0),
m3: null, kg: 0 // gewicht volgt
}
],
weights,
totalKg: weights.reduce((a, r) => a + r.kg, 0),
roofEdge: parts.roofEdge ? {
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
fasciaHeight: parts.roofEdge.fasciaHeight,
fasciaColor: (RoofModel.FASCIA_COLORS[parts.roofEdge.fasciaColor] || {}).label || '',
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,
indoor: (parts.lighting.indoor || []).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) => {
if (iw.seam) {
// Naadwand: ligt op de naad tussen twee units, geen eigen coördinaten.
if (!ids.has(iw.seam.a) || !ids.has(iw.seam.b)) throw new Error(`naadwand #${i + 1}: unit ${iw.seam.a} of ${iw.seam.b} bestaat niet`);
if (iw.seam.in !== undefined && !['a', 'b'].includes(iw.seam.in)) throw new Error(`naadwand #${i + 1}: ongeldige plaats`);
} else {
['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;
// v0.4.15: gevelbekleding per woning; een wand met cementplaat wordt een
// steen-override (de rest volgt de woning, standaard rabat).
const hadFacade = !!state.settings.facade;
state.settings.facade = Object.assign(def.facade, state.settings.facade || {});
if (!FACADE_TYPES[state.settings.facade.type]) state.settings.facade.type = 'rabat';
if (!WallModel.isStoneFinish(state.settings.facade.stone)) state.settings.facade.stone = def.facade.stone;
if (!WallModel.RABAT_COLORS[state.settings.facade.rabatColor]) state.settings.facade.rabatColor = def.facade.rabatColor;
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;
if (!WallModel.isStoneFinish(state.settings.trasraam.finish)) state.settings.trasraam.finish = 'cementplaat';
state.settings.sandwich = state.settings.sandwich !== false;
if (!/^#[0-9a-f]{6}$/i.test(state.settings.interiorColor || '')) state.settings.interiorColor = def.interiorColor;
state.settings.interiorPaint = state.settings.interiorPaint !== false;
// v0.4.15: maaiveld standaard gelijk met de bovenkant vloer (was 350 mm eronder).
if (!(state.settings.groundLevel >= 0) || (!state.settings.floorFinish && state.settings.groundLevel === 350)) state.settings.groundLevel = def.groundLevel;
if (!FloorModel.FINISHES[state.settings.floorFinish]) state.settings.floorFinish = def.floorFinish;
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.settings.elektra = ElektraModel.normalizeSettings(state.settings.elektra);
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';
if (!RoofModel.COVERINGS[u.roof.covering]) u.roof.covering = 'dakleer';
u.lighting = { walls: (u.lighting && u.lighting.walls) || {} };
u.facade = { walls: (u.facade && u.facade.walls) || {} };
if (!hadFacade) Object.entries(u.unit.walls).forEach(([side, ws]) => {
if ((ws.materials || {}).exterior === 'cementplaat' && !u.facade.walls[side]) u.facade.walls[side] = { type: 'steen' };
});
});
return state;
}
return {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
};
})();