979 lines
55 KiB
JavaScript
979 lines
55 KiB
JavaScript
/*!
|
|
* 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' },
|
|
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)
|
|
};
|
|
}
|
|
|
|
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 testontwerp:
|
|
// L-vorm, unit A lessenaar (noord 2600, zuid 3200) met riet. Op false
|
|
// zetten om terug te gaan naar de gewone standaard (één unit).
|
|
const TEST_DEFAULT = true;
|
|
|
|
function createDefaultState(template) {
|
|
if (TEST_DEFAULT && (!template || template === 'single')) {
|
|
const st = createDefaultState('L');
|
|
const A = st.units[0], W = A.unit.walls;
|
|
const h = (k, l, r) => Object.assign(W[k].wall, { heightAll: Math.min(l, r), heightLeft: l, heightRight: r, heightMid: null });
|
|
h('achter', 3200, 3200); h('links', 2600, 3200); h('rechts', 3200, 2600);
|
|
A.roof.covering = 'riet_001';
|
|
computeParts(st, { skip3D: true });
|
|
return st;
|
|
}
|
|
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 }
|
|
];
|
|
}
|
|
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;
|
|
wall.openings = [{ id: 1, type: 'deur', w: d.w, h: d.h, y: d.y, anchor: 'middle', offset: 0 }];
|
|
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) });
|
|
});
|
|
}
|
|
|
|
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, 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, 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 }
|
|
: { exterior: 'vochtdoek', stone: null, 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.
|
|
// 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;
|
|
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) {
|
|
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,
|
|
painted: state.settings.interiorPaint,
|
|
spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false
|
|
});
|
|
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: o.beplatingVisible !== false,
|
|
finish: { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 }
|
|
}) : 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 of zadel; 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' }[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, planes: ur.planes, specs: ur.specs,
|
|
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,
|
|
// Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM)
|
|
// horen bij "Beplating".
|
|
group: build ? RoofModel.buildUnit3D(ur, {
|
|
materials: o.roofMaterials,
|
|
osbVisible: o.beplatingVisible !== false,
|
|
dakleerVisible: o.beplatingVisible !== false
|
|
}) : 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));
|
|
const peak = prof.length > 2 ? prof[1] : null;
|
|
// 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));
|
|
const hasPeak = !!peak && peak.p > Math.min(pL, pR) + 1 && peak.p < Math.max(pL, pR) - 1 && lim(peak.h) > Math.max(hL, hR) + 0.5;
|
|
return {
|
|
heightLeft: Math.round(hL), heightRight: Math.round(hR),
|
|
heightMid: hasPeak ? Math.round(lim(peak.h)) : null, peakX: hasPeak ? Math.abs(peak.p - pL) : 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: firstParams.heightMid });
|
|
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 u = Math.min(toLocalU(lf.place, wa), toLocalU(lf.place, wb));
|
|
return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y, anchor: 'left', offset: Math.round(u) };
|
|
});
|
|
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 })
|
|
};
|
|
});
|
|
}
|
|
|
|
// 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 onder de dakrand: tussen wandbovenkant en onderkant boeideel
|
|
// resp. daktrim zit het dakpakket (dek + bedekking, verschilt per dak).
|
|
// De buitenlagen van de wand lopen daar door (skinAbove, mm), zodat er
|
|
// geen dakrand bloot ligt, bij elke daksoort.
|
|
const roofSt = RoofModel.normalizeSettings(state.settings.roof);
|
|
const flatTopsPre = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
|
|
const flatTopPre = flatTopsPre.length ? Math.max(...flatTopsPre) : 0;
|
|
units.forEach(u => {
|
|
const su = state.units.find(x => x.id === u.id);
|
|
const below = su.roof.fascia !== false ? roofSt.fasciaHeight - RoofModel.EDGE_ABOVE_MM : roofSt.trimHeight;
|
|
u.walls.forEach(w => w.pieces.forEach(p => {
|
|
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
|
|
const top = u.roof.shape.type === 'plat' ? flatTopPre : u.roof.topAt(m.x, m.z);
|
|
p.skinAbove = Math.max(0, Math.round(top - u.roof.ceilingAt(m.x, m.z) - below));
|
|
// 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;
|
|
});
|
|
}));
|
|
});
|
|
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);
|
|
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);
|
|
});
|
|
|
|
// Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken;
|
|
// boeidelen aan/uit per unit. De rand volgt de dakvorm van de unit waar
|
|
// de ribbe bij hoort; platte daken delen één randhoogte (de hoogste).
|
|
const flatTops = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
|
|
const flatTop = flatTops.length ? Math.max(...flatTops) : 0;
|
|
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.shape.type === 'plat' ? () => flatTop : u.roof.topAt, ridges: u.roof.ridges
|
|
}))) : 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;
|
|
// 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;
|
|
|
|
// 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.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;
|
|
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen));
|
|
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
|
|
});
|
|
});
|
|
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 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),
|
|
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: `Riet (${RoofModel.RIET.thickMM} mm)`, m2: sumU(u => u.roof.specs.rietM2 || 0), m3: null, kg: sumU(u => u.roof.specs.rietKg || 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)
|
|
}
|
|
],
|
|
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;
|
|
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.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,
|
|
computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize
|
|
};
|
|
})();
|