/*! * 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: }], // interiorWalls: [{ id, x1, z1, x2, z2, wall: }] } // // Assenstelsel (mm, woning): X = breedte (rechts), Z = diepte (naar achter), // de eerste unit heeft zijn linker-voorhoek op (0, 0). // // Koppelen werkt als de ankers van openingen: een unit hangt aan een zijde // van een andere unit, uitgelijnd op start/midden/eind + verschuiving. Wordt // een unit groter/kleiner, dan schuiven de gekoppelde units mee. // Waar twee units tegen elkaar staan (naad) vervalt de wand van BEIDE units // over dat stuk (UnitModel.computeLayout met cuts). // // Plain script, geladen na unit-model.js. // ============================================================ window.HouseModel = (() => { const EPS = 0.5; // mm const ALIGN_LABELS = { x: { start: 'West uitlijnen', middle: 'Midden', end: 'Oost uitlijnen' }, z: { start: 'Noord uitlijnen', middle: 'Midden', end: 'Zuid uitlijnen' } }; // Windrichtingen t.o.v. het 2D-bovenaanzicht (boven = noord). const SIDE_LABELS = { voor: 'Noordzijde', achter: 'Zuidzijde', links: 'Westzijde', rechts: 'Oostzijde' }; const DEFAULT_INTERIOR_THICKNESS = 100; function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function createEmptyProject() { return { naam: '', plaats: '', straat: '', nummer: '', tekenaar: '', opmerking: '' }; } function nextUnitId(state) { const used = new Set(state.units.map(u => u.id)); for (let i = 0; i < 26; i++) { const id = String.fromCharCode(65 + i); if (!used.has(id)) return id; } return 'U' + (state.units.length + 1); } // template: 'single' (1 unit) of 'L' (2 units in L-vorm). // Woning-brede instellingen: regelwerk-h.o.h. voor ALLE buiten- resp. // binnenwanden (niet per wand), en de dakrand (RoofModel). function createDefaultSettings() { return { spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } }, cladding: 'horizontaal', // richting gevelbekleding, voorlopig voor alle buitenwanden // Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte // vanaf het beloopbare vloervlak (WallModel.trasraamFor). trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM }, sandwich: true, // sandwichpanelen rondom (SandwichModel), vanaf loopvlak 600 mm de grond in groundLevel: 350, // maaiveld: zoveel mm onder het loopvlak (bovenkant vloer); voor de 3D-omgeving roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS), lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS) }; } function createUnitEntry(id, attach, unitOpts) { const unit = UnitModel.createDefaultState(unitOpts); return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting() }; } function createDefaultState(template) { const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] }; state.units.push(createUnitEntry('A', null, { width: 6000, depth: 3000 })); if (template === 'L') { state.units.push(createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 3000, depth: 3000, windowOn: [] })); // Standaard L-woning: voordeur midden in de noordgevel van A, drie // ramen in de zuidgevel van B (bovenkanten gelijk met de voordeur). state.units[0].unit.walls.voor.openings = [ { id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0 } ]; state.units[1].unit.walls.achter.openings = [ { id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450 }, { id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0 }, { id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450 } ]; } 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) : null; Object.values(u.unit.walls).forEach(ws => { ws.wall.minSpacing = sp.exterior.min; ws.wall.maxSpacing = sp.exterior.max; ws.materials = Object.assign({}, ws.materials, { cladding: state.settings.cladding, trasraam }); }); if (u.roof.followFloor) { u.roof.deck.floor.width = u.unit.floor.floor.width; u.roof.deck.floor.depth = u.unit.floor.floor.depth; u.roof.offsetX = 0; u.roof.offsetZ = 0; } }); state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; }); } // Doorlopende gevels over alle units: collineaire, aansluitende buiten- // wandstukken (groupFacadeRuns, core/geometry.js). Zet per stuk p.facade = // { u0, extStart, isEnd } (zie WallModel.computeBattens): u0 = afstand // langs het wandvlak van het gevelbegin (links, van buitenaf gezien, incl. // hoekdoorloop van het eerste stuk) tot de linkerrand van het stuk. function assignFacades(units) { const segs = []; units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { const W = p.place.width, T = p.place.thickness; const normal = { x: p.place.normal.x, z: p.place.normal.y }; const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z', perp = axis === 'x' ? 'z' : 'x'; const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2); segs.push({ piece: p, axis, normal, line: q0[perp], sAt0: q0[axis], sAtW: q1[axis], s0: Math.min(q0[axis], q1[axis]), s1: Math.max(q0[axis], q1[axis]) }); }))); groupFacadeRuns(segs).forEach(run => { const dir = Math.sign(run.segs[0].sAtW - run.segs[0].sAt0) || 1; const sLeft = dir > 0 ? run.s0 : run.s1, sRight = dir > 0 ? run.s1 : run.s0; const first = run.segs.reduce((best, sg) => Math.abs(sg.sAt0 - sLeft) < Math.abs(best.sAt0 - sLeft) ? sg : best); const extLeft = (first.piece.skinExt && first.piece.skinExt.left) || 0; run.segs.forEach(sg => { sg.piece.facade = { u0: Math.abs(sg.sAt0 - sLeft) + extLeft, extStart: extLeft > 0, isEnd: Math.abs(sg.sAtW - sRight) < 5 }; }); }); } function computeParts(state, opts) { const o = opts || {}; const build = !o.skip3D; applyGlobalSettings(state); const rects = unitRects(state); const rectById = {}; rects.forEach(r => { rectById[r.id] = r; }); const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side])); const { cuts, seams } = computeSeams(rects, wallThickness); function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade) { if (!build) return { group: null, stats: null, specs: null }; const built = WallModel.build3D(config, frame, { materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false }); return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) }; } const units = state.units.map(u => { const rect = rectById[u.id]; const layout = UnitModel.computeLayout(u.unit, cuts[u.id]); // 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 }) : null; const walls = layout.walls.map(wl => { const ws = u.unit.walls[wl.side]; const buitenMateriaal = (ws.materials || {}).exterior || 'vochtdoek'; const cladding = (ws.materials || {}).cladding || 'horizontaal'; const trasraam = buitenMateriaal === 'vochtdoek' ? ((ws.materials || {}).trasraam || null) : null; const wp = UnitModel.wallPieces(wl, ws); const pieces = wp.pieces.map(p => { const c = UnitModel.localToWorld(wl, p.lxCenter, 0); return Object.assign(p, { element: `${u.id}-W${wl.nr}${p.suffix}`, label: `Unit ${u.id} ${wl.label.toLowerCase()}${p.suffix ? ' (' + p.suffix + ')' : ''}`, buitenMateriaal, cladding, trasraam, place: { cx: rect.cx + c.x, cz: rect.cz + c.z, rotY: wl.rotY, width: p.config.wallWidth, thickness: wl.thickness, normal: wl.normal, floorTop: layout.floorTop } }); // 3D volgt na assignFacades (latwerk/doek per gevel) }); return Object.assign({}, wl, { pieces, dropped: wp.dropped, removed: pieces.length === 0 }); }); // Dak: dek = vloer-state (180° gespiegeld, RoofModel), op de hoogste // wandbovenkant van deze unit. const roofData = FloorModel.computeFromState(u.roof.deck, rims); const roofSpecs = RoofModel.computeSpecs(roofData.calcData, roofData.resolvedOpenings); const wallTop = Math.max(...Object.values(u.unit.walls).map(ws => Math.max(ws.wall.heightLeft, ws.wall.heightRight))); const roofW = roofData.calcData.width, roofD = roofData.calcData.depth; const roofCx = rect.cx + (u.roof.offsetX || 0), roofCz = rect.cz + (u.roof.offsetZ || 0); const roof = { element: `${u.id}-D1`, data: roofData, specs: roofSpecs, rect: { x0: roofCx - roofW / 2, x1: roofCx + roofW / 2, z0: roofCz - roofD / 2, z1: roofCz + roofD / 2, cx: roofCx, cz: roofCz, W: roofW, D: roofD }, base: layout.floorTop + wallTop, top: layout.floorTop + wallTop + RoofModel.thickness(roofData.calcData), // Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM) // horen bij "Beplating". group: build ? RoofModel.build3D(roofData.calcData, roofData.resolvedOpenings, { 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` }; }); // Doek en latwerk volgen de hele gevel (niet per unit/wandstuk). assignFacades(units); units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { Object.assign(p, buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade)); }))); // Binnenwanden staan op de vloer van de unit waarin hun midden valt. // 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(Object.assign({ 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 } }, buildWall(config, frame, buitenMateriaal))); }); // Twee delen van een naadwand. Elk deel staat met zijn regelwerk op de // naad (buitenzijde 'geen' = naadzijde) in zijn eigen unit, en loopt van // wand tot wand: waar een buitenwandstuk de hartlijn van het deel kruist, // stopt het (voetafdrukken, zoals bij de gevellampen). 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; const baseConfig = WallModel.configFromState(iw.wall); const maxD = Math.max(baseConfig.studDepth, baseConfig.plateDepth); const T1 = WallModel.thicknessOf(baseConfig, 'geen'); const extPieces = []; units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => 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. const line = seamPerp - side * T1 / 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: T1, floorTop: units.find(u => u.id === uid).layout.floorTop }; leaves.push({ uid, side, place }); }); if (!leaves.length) return []; // Openingen: opgeslagen voor het eerste deel; het andere deel krijgt // dezelfde openingen op dezelfde plek in de wereld (gespiegeld). const first = leaves[0]; iw.wall.wall.width = first.place.width; // voor de wand-editor const firstConfig = WallModel.buildConfig(Object.assign({}, iw.wall.wall, { width: first.place.width }), 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(Object.assign({}, iw.wall.wall, { width: lf.place.width }), ops); } const frame = WallModel.computeFrame(config); const built = buildWall(config, frame, 'geen'); if (built.group) { // Het regelwerk staat gecentreerd op lokaal z = 0, de beplating // alleen aan de achterzijde: schuif zodat het midden van de totale // dikte op place.cx/cz ligt (regelwerk op de naad). const outer = new THREE.Group(); built.group.position.z = (T1 - maxD) / 2 * 0.001; outer.add(built.group); built.group = outer; } return Object.assign({ id: iw.id, seam: iw.seam, seamUnit: lf.uid, element: `NW-${nr}${lf.uid}`, label: `Naadwand ${nr} (unit ${lf.uid})`, config, frame, buitenMateriaal: 'geen', place: lf.place }, built); }); } // Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken; // boeidelen aan/uit per unit. const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof, units.map(u => ({ unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false })), Math.max(...units.map(u => u.roof.top))) : null; // Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots). const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge); // Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen". const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, { edgeVisible: o.beplatingVisible !== false, spotsVisible: o.lampenVisible !== false }) : null; // 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)); const rc = u.roof.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 sumU = (fn) => parts.units.reduce((acc, u) => acc + fn(u), 0); const byMat = (mat) => pieces.filter(p => p.buitenMateriaal === mat); const weights = parts.units.map(u => ({ label: `Unit ${u.id}`, element: u.id, kg: u.floorSpecs.totalKg + u.roof.specs.totalKg + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.totalKg, 0), 0) })); if (parts.interior.length) weights.push({ label: 'Binnenwanden', element: 'BW', kg: parts.interior.reduce((a, p) => a + p.specs.totalKg, 0) }); const isolatie = parts.units.map(u => ({ label: `Unit ${u.id}`, element: u.id, m3: u.floorSpecs.voidM3 + u.roof.specs.voidM3 + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.voidVolumeM3, 0), 0) })); if (parts.interior.length) isolatie.push({ label: 'Binnenwanden', element: 'BW', m3: parts.interior.reduce((a, p) => a + p.specs.voidVolumeM3, 0) }); const kindList = Array.from(kinds.values()); return { kinds: kindList, sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width), beamCount: kindList.reduce((a, k) => a + k.count, 0), purchase, purchaseWasteMM: purchase.reduce((a, s) => a + s.wasteMM, 0), purchaseStockMM: purchase.reduce((a, s) => a + s.stockMM, 0), woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3), woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg), battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) }, isolatie, isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0), plaatwerk: [ { naam: 'Spaanplaat (wanden)', m2: sumP(p => p.stats.spaanplaatAreaM2 + p.stats.spaanplaatBuitenAreaM2), m3: sumP(p => p.stats.spaanplaatVolumeM3 + p.stats.spaanplaatBuitenVolumeM3), kg: sumP(p => p.specs.spaanplaatKg) + byMat('spaanplaat').reduce((a, p) => a + p.specs.buitenKg, 0) }, { naam: 'OSB-plaat (vloeren + plafonds dak)', m2: sumU(u => u.floorSpecs.osbAreaM2 + u.roof.specs.osbAreaM2), m3: sumU(u => u.floorSpecs.osbVolM3 + u.roof.specs.osbVolM3), kg: sumU(u => u.floorSpecs.osbKg + u.roof.specs.osbKg) }, { naam: 'Dakleer (EPDM)', m2: sumU(u => u.roof.specs.dakleerM2), m3: null, kg: sumU(u => u.roof.specs.dakleerKg) }, { naam: 'Cementplaat (buitenzijde)', m2: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatAreaM2, 0), m3: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatVolumeM3, 0), kg: byMat('cementplaat').reduce((a, p) => a + p.specs.buitenKg, 0) }, { naam: 'Cementplaat (trasraam)', m2: sumP(p => p.stats.trasraamAreaM2 || 0), m3: sumP(p => p.stats.trasraamVolumeM3 || 0), kg: sumP(p => p.specs.trasraamKg || 0) }, { naam: parts.sandwich ? `Sandwichpanelen ${SandwichModel.PANEL_WIDTH_MM}x${SandwichModel.PANEL_LENGTH_MM}x${SandwichModel.THICK_MM} (${parts.sandwich.panelCount} platen)` : 'Sandwichpanelen', m2: parts.sandwich ? parts.sandwich.areaM2 : 0, m3: null, kg: parts.sandwich ? parts.sandwich.kg : 0 }, { naam: 'Vochtdoek (buitenzijde)', m2: byMat('vochtdoek').reduce((a, p) => a + p.stats.doekAreaM2, 0), m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + p.specs.buitenKg, 0) } ], weights, totalKg: weights.reduce((a, r) => a + r.kg, 0), roofEdge: parts.roofEdge ? { fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000, fasciaHeight: parts.roofEdge.fasciaHeight, overhang: parts.roofEdge.overhang, soffitM2: parts.roofEdge.soffitM2, trimLengthM: parts.roofEdge.trimLengthMM / 1000, trimHeight: parts.roofEdge.trimHeight, spots: parts.roofEdge.spots.length } : null, lamps: { total: parts.lighting.lamps.length, 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) => { ['x1', 'z1', 'x2', 'z2'].forEach(f => { if (typeof iw[f] !== 'number') throw new Error(`binnenwand #${i + 1}: ${f} ontbreekt`); }); try { WallModel.validateState(iw.wall || {}); } catch (err) { throw new Error(`binnenwand #${i + 1}: ${err.message}`); } }); } // Oud unit-ontwerp (type 'unit-design') -> woning met één unit. function fromUnitDesign(design) { const { type, version, exportedAt, project, ...unit } = design; return normalize({ project: Object.assign(createEmptyProject(), project || {}), units: [{ id: 'A', attach: null, unit }], interiorWalls: [] }); } function normalize(state) { state.project = Object.assign(createEmptyProject(), state.project || {}); state.interiorWalls = state.interiorWalls || []; const def = createDefaultSettings(); state.settings = state.settings || {}; state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {}); if (!WallModel.CLADDING_DIRECTIONS[state.settings.cladding]) state.settings.cladding = def.cladding; state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {}); state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false; if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM; state.settings.sandwich = state.settings.sandwich !== false; if (!(state.settings.groundLevel >= 0)) state.settings.groundLevel = def.groundLevel; const oldRoof = state.settings.roof || {}; // v0.3.0: edge/spots waren woning-breed state.settings.roof = RoofModel.normalizeSettings(oldRoof); // v0.3.1: type/richting van de verlichting stonden per unit; nu woning-breed. const legacyUnitLight = (state.units.find(u => u.lighting && u.lighting.mode) || {}).lighting; const lightIn = Object.assign({}, state.settings.lighting || {}); if (!lightIn.mode) lightIn.mode = legacyUnitLight ? legacyUnitLight.mode : (oldRoof.spots === false ? 'geen' : 'spots'); if (!lightIn.direction && legacyUnitLight && legacyUnitLight.direction) lightIn.direction = legacyUnitLight.direction; state.settings.lighting = LightingModel.normalizeSettings(lightIn); state.units.forEach(u => { if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' }; if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; } if (u.roof.fascia === undefined) u.roof.fascia = oldRoof.edge !== 'daktrim'; u.lighting = { walls: (u.lighting && u.lighting.walls) || {} }; }); return state; } return { ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs, interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize }; })();