/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // ============================================================ // unit/unit-model.js — UnitModel: ENIGE bron van de unit-logica (1 vloer + // 4 wanden). Geen DOM. Bevat zelf geen wand-/vloerlogica: alles gaat via // WallModel/FloorModel. Gebruikt door de woning (house/*). // // Een unit-state is: // { layout: { corners }, floor: , walls: { voor, achter, links, rechts: } } // // Assenstelsel (mm, unit-lokaal): X = breedte, Z = diepte, vloermidden op // (0, 0), "voor" op Z = -diepte/2 (zelfde als FloorModel.build3D). // // Naden: wanneer een andere unit tegen een zijde aan staat, vervalt de wand // over dat stuk (cuts: [a, b, extA, extB], zie HouseModel.computeSeams — // extA/extB laten het aangrenzende wandstuk de naad in doorlopen om een // binnenhoek te sluiten). Een wand kan daardoor in meerdere stukken uiteenvallen // (bijv. de achterwand bij een L-vorm); elk stuk is een volwaardige wand // (eigen regelwerk, hoekstijlen, PDF-blad). Een wand die haaks op een // vervallen hoek staat, loopt door tot de vloerrand, zodat de buitengevel // over de naad heen doorloopt. // // Plain script, geladen na wall-model.js en floor-model.js. // ============================================================ window.UnitModel = (() => { // Elke wand staat met zijn buitenzijde (+Z lokaal, "zuid" in de wand- // editor) naar buiten. rotY draait lokaal +Z naar de buitenrichting; "links" // in de wand-editor = links gezien van buitenaf. axis = de as waarlangs de // wand loopt; dir = +1 als lokaal +X met die as meeloopt. // normal = buitenrichting in de plattegrond (x rechts, y omlaag; voor boven). // Weergave in windrichtingen t.o.v. het 2D-bovenaanzicht (boven = noord): // voor = noord, achter = zuid, links = west, rechts = oost. De sleutels // (voor/achter/links/rechts) blijven intern, voor compatibele JSON. const WALL_SIDES = [ { key: 'voor', label: 'Wand noord', nr: 1, rotY: Math.PI, axis: 'x', dir: -1, normal: { x: 0, y: -1 } }, { key: 'achter', label: 'Wand zuid', nr: 2, rotY: 0, axis: 'x', dir: 1, normal: { x: 0, y: 1 } }, { key: 'links', label: 'Wand west', nr: 3, rotY: -Math.PI / 2, axis: 'z', dir: 1, normal: { x: -1, y: 0 } }, { key: 'rechts', label: 'Wand oost', nr: 4, rotY: Math.PI / 2, axis: 'z', dir: -1, normal: { x: 1, y: 0 } } ]; // Hoekaansluiting: welke wanden lopen door over de volle vloermaat, en welke // passen er tussenin. const CORNER_MODES = { 'voor-achter': 'Noord/zuid doorlopend', 'zijkanten': 'West/oost doorlopend' }; const DEFAULT_HEIGHT_MM = 2600; // Kortere wandstukken (restje naast een naad) worden weggelaten. const MIN_PIECE_MM = 300; const EPS = 0.5; // mm function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function createDefaultState(opts) { const o = opts || {}; const floor = FloorEditor.getDefaultState(); floor.floor.width = o.width || 6000; floor.floor.depth = o.depth || 3000; const walls = {}; WALL_SIDES.forEach(s => { const wall = WallEditor.getDefaultState(); // Standaard unithoogte (los van de standaard van een losse wand). const h = o.height || DEFAULT_HEIGHT_MM; wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = h; if (!(o.windowOn || ['voor']).includes(s.key)) wall.openings = []; walls[s.key] = wall; }); const state = { layout: { corners: 'voor-achter' }, floor, walls }; computeLayout(state, {}); return state; } // Lokaal wand-punt (mm, x langs de wand gecentreerd, z dwars) -> X/Z in het // omliggende stelsel. Zelfde rotatie als THREE's rotation.y. function localToWorld(w, lx, lz) { const c = Math.cos(w.rotY), s = Math.sin(w.rotY); return { x: w.cx + lx * c + lz * s, z: w.cz - lx * s + lz * c }; } // Maat + positie van elke wand t.o.v. het vloermidden, rekening houdend met // naden. cuts = { voor: [[a, b], ...], ... } in unit-lokale as-coördinaten // (X voor voor/achter, Z voor links/rechts). Zet ook de afgeleide // wandbreedte in de wand-states (muteert state). function computeLayout(state, cuts) { const c = cuts || {}; const floorFrame = FloorModel.computeFrame(state.floor.floor); const W = floorFrame.width, D = floorFrame.depth; const T = {}; WALL_SIDES.forEach(s => { T[s.key] = WallModel.thicknessOf(WallModel.configFromState(state.walls[s.key])); }); // Vervalt de wand van `side` op as-positie p (bijv. een hoek)? const cutAt = (side, p) => (c[side] || []).some(([a, b]) => a <= p + EPS && b >= p - EPS); // Effectieve dikte van de doorlopende wand op een hoek: 0 als die daar vervalt. const tEff = (side, p) => cutAt(side, p) ? 0 : T[side]; let span; // per zijde [s0, s1] langs de eigen as if (state.layout.corners === 'zijkanten') { span = { links: [-D / 2, D / 2], rechts: [-D / 2, D / 2], voor: [-W / 2 + tEff('links', -D / 2), W / 2 - tEff('rechts', -D / 2)], achter: [-W / 2 + tEff('links', D / 2), W / 2 - tEff('rechts', D / 2)] }; } else { span = { voor: [-W / 2, W / 2], achter: [-W / 2, W / 2], links: [-D / 2 + tEff('voor', -W / 2), D / 2 - tEff('achter', -W / 2)], rechts: [-D / 2 + tEff('voor', W / 2), D / 2 - tEff('achter', W / 2)] }; } const walls = WALL_SIDES.map(s => { const [s0, s1] = span[s.key]; const width = Math.max(MIN_PIECE_MM, Math.round(s1 - s0)); const mid = (s0 + s1) / 2; const off = { voor: -D / 2 + T.voor / 2, achter: D / 2 - T.achter / 2, links: -W / 2 + T.links / 2, rechts: W / 2 - T.rechts / 2 }[s.key]; const cx = s.axis === 'x' ? mid : off; const cz = s.axis === 'x' ? off : mid; // Naden omgerekend naar "vanaf links gezien van buitenaf" (u, 0..width), // incl. doorloop (ext) van het wandstuk vóór resp. ná de naad. const toU = (p) => s.dir > 0 ? p - s0 : s1 - p; const rawU = (c[s.key] || []) .map(([a, b, ea, eb]) => { const ua = toU(a), ub = toU(b); const [u0, u1, e0, e1] = ua <= ub ? [ua, ub, ea || 0, eb || 0] : [ub, ua, eb || 0, ea || 0]; return [Math.max(0, u0), Math.min(width, u1), e0, e1]; }) .filter(([a, b]) => b - a > EPS) .sort((p, q) => p[0] - q[0]); // Resterende stukken wand; een stuk dat aan een naad grenst loopt de // naad in door met de doorloop (ext) van die kant. // [u0, u1, inL, inR]: inL/inR = hoever het stuk aan die kant de naad // in loopt (binnenhoek dicht, zie computeSeams). const pieces = []; let cursor = 0, inL = 0; rawU.forEach(([a, b, e0, e1]) => { const end = Math.min(b, a + e0); if (end - cursor >= MIN_PIECE_MM) pieces.push([cursor, end, inL, end - a]); cursor = Math.max(cursor, b - e1); inL = b - cursor; }); if (width - cursor >= MIN_PIECE_MM) pieces.push([cursor, width, inL, 0]); // Werkelijk vervallende zones (voor de weergave in de wand-editor). const cutsU = rawU.map(([a, b, e0, e1]) => [a + e0, b - e1]).filter(([a, b]) => b - a > EPS); state.walls[s.key].wall.width = width; // Hoekdoorloop buitenlagen (WallModel.cornerExt): een tussenpassende // wand bedekt ook de kopse kant van de doorlopende wand ernaast. const full = s.axis === 'x' ? W / 2 : D / 2; const ext0 = Math.max(0, Math.round(s0 + full)), ext1 = Math.max(0, Math.round(full - s1)); const skinExt = s.dir > 0 ? { left: ext0, right: ext1 } : { left: ext1, right: ext0 }; // Doorlopende wand die op een hoek vervalt: de haakse wand loopt daar // tot de vloerrand door en staat dus op deze wandlijn (endInset, u-mm // vanaf links/rechts). Een opbouw in die naad-zone begint erna. const through = state.layout.corners === 'zijkanten' ? s.axis === 'z' : s.axis === 'x'; const pL = s.dir > 0 ? s0 : s1, pR = s.dir > 0 ? s1 : s0; const endInset = { left: through && cutAt(s.key, pL) ? T[WALL_ENDS[s.key].left] : 0, right: through && cutAt(s.key, pR) ? T[WALL_ENDS[s.key].right] : 0 }; return { side: s.key, label: s.label, nr: s.nr, rotY: s.rotY, normal: s.normal, axis: s.axis, thickness: T[s.key], width, cx, cz, cutsU, pieces, skinExt, endInset }; }); return { floorWidth: W, floorDepth: D, floorTop: FloorModel.topHeight(floorFrame), walls }; } // Wandstukken van één zijde als losse wand-configs. Openingen worden eerst // op de volle zijde opgelost (ankers gelden voor de hele zijde) en daarna // aan het stuk toegewezen waar ze volledig in vallen; openingen die in een // naad vallen vervallen (dropped). // raised (optioneel): [{ u0, u1, base }] = opbouw in een naad-zone: daar // staat de wand pas vanaf hoogte `base` (mm vanaf het loopvlak, bijv. boven // het lagere dak van de buur-unit) tot de daklijn. Zo'n stuk heeft // baseY = base; openingen die er volledig boven base in vallen horen erbij // (y t.o.v. de onderkant van het stuk). Te lage opbouw vervalt. const MIN_RAISE_MM = 150; function wallPieces(wallLayout, wallState, raised) { const fullConfig = WallModel.configFromState(wallState); const W = wallLayout.width; const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight; // Tussenpunten van de daklijn (nok, knikken; WallModel.wallTopPoints); // een stuk krijgt de punten die erin vallen, verder de daklijn ter // plaatse als gewone helling. const tops = WallModel.wallTopPoints(wallState.wall, W, hL, hR); const tp = [{ x: 0, h: hL }, ...tops, { x: W, h: hR }]; const heightAt = (u) => { let i = 0; while (i < tp.length - 2 && u > tp[i + 1].x) i++; const a = tp[i], b = tp[i + 1]; return a.h + (u - a.x) / (b.x - a.x) * (b.h - a.h); }; const topsIn = (a, b, base) => { const inner = tops.filter(q => q.x > a + 1 && q.x < b - 1); return inner.length ? { heightMid: null, topPts: inner.map(q => ({ f: (q.x - a) / (b - a), h: q.h - base })) } : { heightMid: null, topPts: undefined }; }; const multi = wallLayout.pieces.length > 1; const used = new Set(); const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => { const openings = fullConfig.openings .filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS) .map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; }); const params = Object.assign({}, wallState.wall, { width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b)) }, topsIn(a, b, 0)); const config = WallModel.buildConfig(params, openings); return { suffix: multi ? String.fromCharCode(97 + i) : '', // a, b, ... u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2, // Loopt het stuk een naad in (binnenhoek), dan ligt dat deel binnen // de wand van de buur-unit: buitenlagen daar niet (negatieve // doorloop, zie WallModel.cornerExt) en een binnenplaat op de kopse kant. skinExt: { left: inL > EPS ? -inL : a < EPS ? wallLayout.skinExt.left : 0, right: inR > EPS ? -inR : b > W - EPS ? wallLayout.skinExt.right : 0 }, config, frame: WallModel.computeFrame(config) }; }); const raisedList = (raised || []).filter(r => r.u1 - r.u0 >= MIN_PIECE_MM); const ins = wallLayout.endInset || { left: 0, right: 0 }; raisedList.forEach((r, i) => { const { u0: a, u1: b, base } = r; const hA = heightAt(a) - base, hB = heightAt(b) - base; if (Math.min(hA, hB) < MIN_RAISE_MM) return; const openings = fullConfig.openings .filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS) .map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; }); const params = Object.assign({}, wallState.wall, { width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB) }, topsIn(a, b, base)); const config = WallModel.buildConfig(params, openings); pieces.push({ suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''), // opbouw u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2, baseY: Math.round(base), // Naast de haakse wand in de hoek (endInset) bedekken de buitenlagen // ook diens kopse kant, die boven het lage dak bloot ligt. skinExt: { left: ins.left > 0 && a <= ins.left + EPS ? ins.left : 0, right: ins.right > 0 && b >= W - ins.right - EPS ? ins.right : 0 }, config, frame: WallModel.computeFrame(config) }); }); const dropped = fullConfig.openings.filter(op => !used.has(op.id)); return { pieces, dropped, fullConfig }; } // Wandhoogtes koppelen, zodat er altijd een logisch dak op past. De // bewerkte wand (side) bepaalt de dakvorm: // - de tegenoverliggende wand krijgt dezelfde hoogtes gespiegeld (van // buitenaf gezien wisselen links en rechts), incl. nok en knikken; // - de twee haakse wanden worden vlak op de hoogte van het dak boven // hun buitenkant (goothoogte bij een lessenaar- of zadeldak). Staat // de kopgevel tussen de haakse wanden in, dan loopt zijn daklijn // daarvoor door tot de buitenkant van de vloer (gableSpan). // Voorbeeld: west zuid hoger dan noord -> oost ook, noordwand vlak op de // lage, zuidwand vlak op de hoge hoogte. Muteert state. // Welke haakse wand zit aan de linker/rechterkant (van buitenaf gezien). const WALL_ENDS = { voor: { left: 'rechts', right: 'links' }, achter: { left: 'links', right: 'rechts' }, links: { left: 'voor', right: 'achter' }, rechts: { left: 'achter', right: 'voor' } }; const OPPOSITE = { voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' }; // Plaats van een wand t.o.v. de volle vloermaat langs zijn as (van // buitenaf gezien): e0/e1 = afstand van de vloerrand tot het linker-/ // rechteruiteinde, w = wandlengte, span = vloermaat (= e0 + w + e1). function gableSpan(state, side) { const layout = computeLayout(state, {}); const wl = layout.walls.find(x => x.side === side); const s = WALL_SIDES.find(x => x.key === side); const span = s.axis === 'x' ? layout.floorWidth : layout.floorDepth; const c = s.axis === 'x' ? wl.cx : wl.cz; const left = c - s.dir * wl.width / 2, right = c + s.dir * wl.width / 2; const e0 = Math.max(0, Math.abs(left + s.dir * span / 2)), e1 = Math.max(0, Math.abs(s.dir * span / 2 - right)); return { e0, e1, w: wl.width, span, layout }; } // Daklijn van een wand over de volle vloermaat: [{ u, h }] met u vanaf de // linker vloerrand; de eerste en laatste helling lopen door tot de randen. function fullTopLine(wall, g) { const pts = [{ u: g.e0, h: wall.heightLeft }] .concat(WallModel.wallTopPoints(wall, g.w, wall.heightLeft, wall.heightRight).map(q => ({ u: g.e0 + q.x, h: q.h }))) .concat([{ u: g.e0 + g.w, h: wall.heightRight }]); const n = pts.length, s0 = (pts[1].h - pts[0].h) / (pts[1].u - pts[0].u), s1 = (pts[n - 1].h - pts[n - 2].h) / (pts[n - 1].u - pts[n - 2].u); pts[0] = { u: 0, h: pts[0].h - g.e0 * s0 }; pts[n - 1] = { u: g.span, h: pts[n - 1].h + g.e1 * s1 }; return pts; } // Omgekeerd: een daklijn over de volle vloermaat ([{ u, h }]) op de wand // zetten (hoogtes op de uiteinden, knikken als topPts; één knik in het // midden als heightMid). function setWallTopLine(wall, g, line) { const at = (u) => { let i = 0; while (i < line.length - 2 && u > line[i + 1].u) i++; const a = line[i], b = line[i + 1]; return a.h + (u - a.u) / (b.u - a.u) * (b.h - a.h); }; const hL = Math.round(at(g.e0)), hR = Math.round(at(g.e0 + g.w)); const inner = line.slice(1, -1).filter(q => q.u > g.e0 + 1 && q.u < g.e0 + g.w - 1).map(q => ({ f: (q.u - g.e0) / g.w, h: Math.round(q.h) })); const mid = inner.length === 1 && Math.abs(inner[0].f - 0.5) < 1e-3; Object.assign(wall, { heightLeft: hL, heightRight: hR, heightMid: mid ? inner[0].h : null, heightAll: Math.min(hL, hR) }); if (inner.length && !mid) wall.topPts = inner; else delete wall.topPts; } function syncWallHeights(state, side) { const w = state.walls[side].wall; const mid = w.heightMid > 0 ? w.heightMid : null; const pts = Array.isArray(w.topPts) && w.topPts.length ? w.topPts : null; // Haakse wanden: op de onderkant van het dak boven hun buiten- of // binnenkant, de laagste van de twee (zo steekt de wand nooit door het // dak; de wig tussen bovenregel en dak vult een schuine regel op, zie // HouseModel / WallModel topWedge). const g = gableSpan(state, side), full = fullTopLine(w, g), n = full.length; const thick = (key) => (g.layout.walls.find(x => x.side === key) || {}).thickness || 0; const s0 = (full[1].h - full[0].h) / (full[1].u - full[0].u), s1 = (full[n - 1].h - full[n - 2].h) / (full[n - 1].u - full[n - 2].u); const edgeL = Math.round(Math.min(full[0].h, full[0].h + thick(WALL_ENDS[side].left) * s0)); const edgeR = Math.round(Math.min(full[n - 1].h, full[n - 1].h - thick(WALL_ENDS[side].right) * s1)); const set = (key, hL, hR, hM, tp) => { const t = state.walls[key].wall; t.heightLeft = hL; t.heightRight = hR; t.heightMid = hM; if (tp) t.topPts = tp; else delete t.topPts; t.heightAll = hM === null && !tp && hL === hR ? hL : w.heightAll; }; // Gespiegeld (van buitenaf gezien): knikken van rechts naar links. set(OPPOSITE[side], w.heightRight, w.heightLeft, mid, pts && pts.map(q => ({ f: 1 - q.f, h: q.h })).reverse()); set(WALL_ENDS[side].left, edgeL, edgeL, null); set(WALL_ENDS[side].right, edgeR, edgeR, null); } // Hoogste punt van een wand (incl. nok en knikken). function wallTopOf(wallState) { const w = wallState.wall; return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h)); } // ------------------------------------------------------------ // Dakvorm als invoer. Het dak volgt de wanden (RoofModel.computeShape); // hier de omgekeerde weg: één dakvorm per unit, de wanden volgen // (via syncWallHeights op de kopgevel). // { type: 'plat', low } hoogte // { type: 'lessenaar', dir, low, high } dir = hoge zijde (voor/achter/links/rechts) // { type: 'zadel', ridge, low, high } ridge = richting nok: 'z' (noord–zuid) of 'x' (west–oost) // { type: 'mansarde', ridge, low, high, kneeH, kneeIn, kneeRatio } // steil ondervlak van de goot tot de knik (kneeH, kneeIn = afstand // van de knik tot de gevel), daarboven flauw tot de nok // { type: 'wolf', ridge, low, high, cut, cutRatio } // zadeldak waarvan de kopgevels boven `cut` zijn afgeschuind // (wolfseinden, dakhelling tot de nok; rechte nok ertussen) // kneeRatio / cutRatio: plaats van de knik resp. het wolfseind tussen goot // (0) en nok (1). Gaat voor kneeH / cut, zodat ze meeschalen als goot of // nok verandert (roofForm geeft ze altijd terug). // low = goothoogte, high = hoge goot resp. nok (mm vanaf het loopvlak), // over de volle vloermaat: de goot ligt boven de buitenkant van de // gevel, de nok in het midden (ook als de kopgevel tussen de haakse // wanden staat; fullTopLine). kneeIn = afstand van de vloerrand. // Mansarde en wolfsdak: de kopgevel krijgt een daklijn met knikken // (wall.topPts, zie WallModel.wallTopPoints). // ------------------------------------------------------------ // Grenzen van onze woningen (mm, vanaf het loopvlak; maat van de vloer): // nok (hoogste punt onderkant dak) max 5 m, goot / plat dak max 3 m, een // unit max 4,3 x 14 m (de korte zijde max 4,3 m). const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 }; // Vloermaat binnen de grenzen (muteert floor = state.floor.floor). keep: // 'width' of 'depth' = de zojuist gewijzigde maat; bij twee lange zijden // wordt die geklemd (anders de kortste). function clampSize(floor, keep) { floor.width = Math.min(LIMITS.long, floor.width); floor.depth = Math.min(LIMITS.long, floor.depth); if (Math.min(floor.width, floor.depth) > LIMITS.short) { const k = keep || (floor.width <= floor.depth ? 'width' : 'depth'); floor[k] = LIMITS.short; } return floor; } const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' }; const RIDGES = { z: 'Noord–zuid', x: 'West–oost' }; const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf'; // Kopgevel (wand met de schuine daklijn) bij een dakvorm. function gableOf(form) { if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter'; if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links'; return 'achter'; } // Huidige dakvorm, afgelezen uit de wanden (zelfde volgorde als computeShape). function roofForm(unitState) { const W = unitState.walls; for (const side of ['achter', 'voor', 'links', 'rechts']) { const w = W[side].wall; const ridge = side === 'voor' || side === 'achter' ? 'z' : 'x'; const full = fullTopLine(w, gableSpan(unitState, side)); const F = full[full.length - 1].u, inner = full.slice(1, -1); const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h)); if (inner.length === 3) { const high = Math.round(inner[1].h); return { type: 'mansarde', ridge, low, high, kneeH: Math.round(inner[0].h), kneeIn: Math.round(inner[0].u), kneeRatio: (inner[0].h - low) / Math.max(1, high - low) }; } if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) { const slope = (inner[0].h - L.h) / Math.max(1, inner[0].u); const high = Math.round(L.h + slope * F / 2); return { type: 'wolf', ridge, low, high, cut: Math.round(inner[0].h), cutRatio: (inner[0].h - low) / Math.max(1, high - low) }; } if (inner.length === 1) return { type: 'zadel', ridge, low, high: Math.round(inner[0].h) }; if (Math.abs(L.h - R.h) > 0.5) { const dir = R.h > L.h ? WALL_ENDS[side].right : WALL_ENDS[side].left; return { type: 'lessenaar', dir, low, high: Math.round(Math.max(L.h, R.h)) }; } } const h = Math.max(...Object.values(W).map(wallTopOf)); return { type: 'plat', low: h, high: h }; } // Dakvorm toepassen: de kopgevel krijgt de daklijn (over de volle // vloermaat bepaald, zie fullTopLine), syncWallHeights doet de rest. // Zonder hoogteverschil wordt het een plat dak. Muteert state. function applyRoofForm(unitState, form) { const low = Math.round(Math.min(LIMITS.eave, form.low)); const high = Math.round(Math.min(LIMITS.ridge, Math.max(low, form.high || low))); const sloped = form.type !== 'plat' && high > low; const side = sloped ? gableOf(form) : 'achter'; const g = gableSpan(unitState, side), F = g.span, half = F / 2; let line = [{ u: 0, h: low }, { u: F, h: low }]; if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }]; else if (sloped && form.type === 'mansarde') { // Knik binnen de halve gevel; altijd boven de rechte lijn goot–nok // (anders geen mansarde maar een hol dak). // (en op de kopgevel zelf, ook als die tussen de haakse wanden staat) const kIn = Math.max(Math.max(g.e0, g.e1) + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18))); const minK = low + (high - low) * kIn / half + 20; const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH; const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK))); line = kH > minK - 1 && high - kH >= 20 ? [{ u: 0, h: low }, { u: kIn, h: kH }, { u: half, h: high }, { u: F - kIn, h: kH }, { u: F, h: low }] : [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }]; } else if (sloped && form.type === 'wolf') { // Wolfseind: de kopgevel stopt op `cut`, waar de dakhelling die hoogte haalt. // Op de kopgevel zelf (ook als die tussen de haakse wanden staat). const minR = (Math.max(g.e0, g.e1) + 50) / half; const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6; const cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low)))); const uc = (cut - low) / (high - low) * half; line = high - low > 100 ? [{ u: 0, h: low }, { u: uc, h: cut }, { u: F - uc, h: cut }, { u: F, h: low }] : [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }]; } else if (sloped) { const highLeft = WALL_ENDS[side].left === form.dir; line = [{ u: 0, h: highLeft ? high : low }, { u: F, h: highLeft ? low : high }]; } setWallTopLine(unitState.walls[side].wall, g, line); syncWallHeights(unitState, side); } function validateState(design) { if (!design.floor) throw new Error('veld "floor" ontbreekt'); try { FloorModel.validateState(design.floor); } catch (err) { throw new Error('vloer: ' + err.message); } if (!design.walls || typeof design.walls !== 'object') throw new Error('veld "walls" ontbreekt'); WALL_SIDES.forEach(s => { if (!design.walls[s.key]) throw new Error(`walls.${s.key} ontbreekt`); try { WallModel.validateState(design.walls[s.key]); } catch (err) { throw new Error(`${s.label.toLowerCase()}: ${err.message}`); } }); if (design.layout && !CORNER_MODES[design.layout.corners]) { throw new Error(`layout.corners moet ${Object.keys(CORNER_MODES).join(' of ')} zijn`); } } return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, validateState }; })();