v0.6.0: ruimtes (verfemmer: functie, netto m², vloerafwerking, FIBO-wandpanelen, eigen verfkleur, vals plafond, ruimtestaat op de PDF), inrichting (sanitair, keuken, kookeiland, kasten, bed, zithoek, boiler; slepen met snappen op het wandvlak, draaien, kosten), kozijnen met deurrichting, glasdeur en schuifdeuren (inbouw/opbouw, deuren open in de loopmodus) plus dagkanten, gevel stucwerk en wandstukken splitsen met eigen gevel, dak over de hele woning met overkapping, geschilderd plafond, model Amstel als nieuw ontwerp, kosten en prijzen uit kosten/intern.csv en kosten/leveranciers.csv (CostCsv); easter egg verwijderd; overbodige comments opgeschoond (alleen copyright, uitzonderingen en valkuilen blijven)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-11 00:34:27 +02:00
co-authored by Claude Opus 5.5
parent b5510d56c7
commit ceda0b554d
45 changed files with 4117 additions and 3700 deletions
+95 -170
View File
@@ -4,38 +4,10 @@
* 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: <vloer-state>, walls: { voor, achter, links, rechts: <wand-state> } }
//
// 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.
// rotY draait lokaal +Z naar buiten; "links" = gezien van buitenaf. normal: plattegrond, x rechts, y omlaag.
// De sleutels voor/achter/links/rechts blijven intern (compatibele JSON); weergave als windrichting.
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 } },
@@ -43,8 +15,6 @@ window.UnitModel = (() => {
{ 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'
@@ -52,9 +22,8 @@ window.UnitModel = (() => {
const DEFAULT_HEIGHT_MM = 2600;
// Kortere wandstukken (restje naast een naad) worden weggelaten.
const MIN_PIECE_MM = 300;
const EPS = 0.5; // mm
const EPS = 0.5;
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
@@ -66,7 +35,6 @@ window.UnitModel = (() => {
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 = [];
@@ -77,17 +45,12 @@ window.UnitModel = (() => {
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.
// 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);
@@ -96,38 +59,31 @@ window.UnitModel = (() => {
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 spans = (tE) => state.layout.corners === 'zijkanten' ? {
links: [-D / 2, D / 2],
rechts: [-D / 2, D / 2],
voor: [-W / 2 + tE('links', -D / 2), W / 2 - tE('rechts', -D / 2)],
achter: [-W / 2 + tE('links', D / 2), W / 2 - tE('rechts', D / 2)]
} : {
voor: [-W / 2, W / 2],
achter: [-W / 2, W / 2],
links: [-D / 2 + tE('voor', -W / 2), D / 2 - tE('achter', -W / 2)],
rechts: [-D / 2 + tE('voor', W / 2), D / 2 - tE('achter', W / 2)]
};
const span = spans(tEff);
const span0 = spans((side) => T[side]);
const walls = WALL_SIDES.map(s => {
const [s0, s1] = span[s.key];
const [s0, s1] = span[s.key], [r0, r1] = span0[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]) => {
@@ -138,10 +94,10 @@ window.UnitModel = (() => {
.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 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 };
const pieces = [];
let cursor = 0, inL = 0;
rawU.forEach(([a, b, e0, e1]) => {
@@ -151,51 +107,82 @@ window.UnitModel = (() => {
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 splitsU = normalizeSplits(state.walls[s.key].splits).map(sp => Object.assign({}, sp, { u: sp.u - skinExt.left }))
.filter(sp => sp.u > MIN_PIECE_MM && sp.u < width - MIN_PIECE_MM);
splitsU.forEach(sp => {
const k = pieces.findIndex(([a, b]) => sp.u >= a + MIN_PIECE_MM - EPS && sp.u <= b - MIN_PIECE_MM + EPS);
if (k < 0) return;
const [a, b, inL, inR] = pieces[k];
pieces.splice(k, 1, [a, sp.u, inL, 0], [sp.u, b, 0, inR]);
});
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
};
const ref = { w: Math.max(MIN_PIECE_MM, Math.round(r1 - r0)), u0: s.dir > 0 ? s0 - r0 : r1 - s1 };
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
thickness: T[s.key], width, cx, cz, cutsU, pieces, skinExt, endInset, ref, splitsU
};
});
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.
function normalizeSplits(list) {
return (Array.isArray(list) ? list : []).map(sp => typeof sp === 'number' ? { u: sp, facade: null } : sp)
.filter(sp => sp && isFinite(sp.u) && sp.u > 0)
.map(sp => ({ u: Math.round(sp.u), facade: sp.facade && sp.facade.type ? Object.assign({}, sp.facade) : null }))
.sort((a, b) => a.u - b.u);
}
function splitFacadeAt(wallLayout, u0, u1) {
const m = (u0 + u1) / 2;
let f = null;
(wallLayout.splitsU || []).forEach(sp => { if (sp.u <= m) f = sp.facade; });
return f;
}
// Openingen eerst op de volle zijde oplossen (ankers gelden voor de hele zijde), dan toewijzen;
// openingen in een naad vervallen.
const MIN_RAISE_MM = 150;
function topLineOn(wallLayout, wall) {
const ref = wallLayout.ref || { w: wallLayout.width, u0: 0 };
const W = wallLayout.width;
const pts = [{ x: 0, h: wall.heightLeft }, ...WallModel.wallTopPoints(wall, ref.w, wall.heightLeft, wall.heightRight), { x: ref.w, h: wall.heightRight }]
.map(q => ({ x: q.x - ref.u0, h: q.h }));
const at = (x) => {
let i = 0;
while (i < pts.length - 2 && x > pts[i + 1].x) i++;
const a = pts[i], b = pts[i + 1];
return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h);
};
return { hL: Math.round(at(0)), hR: Math.round(at(W)), tops: pts.slice(1, -1).filter(q => q.x > 1 && q.x < W - 1) };
}
function topParamsOn(wallLayout, wall) {
const { hL, hR, tops } = topLineOn(wallLayout, wall), W = wallLayout.width;
const mid = tops.length === 1 && Math.abs(tops[0].x - W / 2) < 0.5;
return { heightLeft: hL, heightRight: hR, heightMid: mid ? tops[0].h : null, topPts: tops.length && !mid ? tops.map(q => ({ f: q.x / W, h: q.h })) : undefined };
}
function topParamsFromLayout(wallLayout, wall) {
const ref = wallLayout.ref;
if (!ref || (ref.u0 === 0 && ref.w === wallLayout.width)) return wall;
const back = { width: ref.w, ref: { w: wallLayout.width, u0: -ref.u0 } };
const p = topParamsOn(back, Object.assign({}, wall, { width: wallLayout.width }));
Object.assign(wall, p, { heightAll: Math.min(p.heightLeft, p.heightRight) });
if (!p.topPts) delete wall.topPts;
return wall;
}
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 { hL, hR, tops } = topLineOn(wallLayout, wallState.wall);
const tp = [{ x: 0, h: hL }, ...tops, { x: W, h: hR }];
const heightAt = (u) => {
let i = 0;
@@ -219,11 +206,8 @@ window.UnitModel = (() => {
}, topsIn(a, b, 0));
const config = WallModel.buildConfig(params, openings);
return {
suffix: multi ? String.fromCharCode(97 + i) : '', // a, b, ...
suffix: multi ? String.fromCharCode(97 + i) : '',
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
@@ -245,10 +229,8 @@ window.UnitModel = (() => {
}, topsIn(a, b, base));
const config = WallModel.buildConfig(params, openings);
pieces.push({
suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''), // opbouw
suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''),
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
@@ -260,17 +242,6 @@ window.UnitModel = (() => {
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' },
@@ -278,9 +249,6 @@ window.UnitModel = (() => {
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);
@@ -291,8 +259,6 @@ window.UnitModel = (() => {
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 })))
@@ -302,9 +268,6 @@ window.UnitModel = (() => {
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;
@@ -322,10 +285,7 @@ window.UnitModel = (() => {
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).
// De laagste van de twee: zo steekt de wand nooit door het dak (de wig vult een schuine regel op).
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);
@@ -337,47 +297,17 @@ window.UnitModel = (() => {
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).
// kneeRatio / cutRatio gaan voor kneeH / cut, zodat ze meeschalen als goot of nok verandert.
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);
@@ -390,13 +320,11 @@ window.UnitModel = (() => {
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']) {
@@ -423,22 +351,23 @@ window.UnitModel = (() => {
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 side = form.type !== 'plat' ? gableOf(form) : 'achter';
const g = gableSpan(unitState, side);
const line = formLine(form, g.span, Math.max(g.e0, g.e1), WALL_ENDS[side].left === form.dir);
setWallTopLine(unitState.walls[side].wall, g, line);
syncWallHeights(unitState, side);
}
function formLine(form, F, eMin, highAtStart) {
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;
const 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)));
// Knik altijd boven de rechte lijn goot–nok, anders wordt het een hol dak.
const kIn = Math.max(eMin + 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)));
@@ -446,9 +375,7 @@ window.UnitModel = (() => {
? [{ 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 minR = (eMin + 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;
@@ -456,11 +383,9 @@ window.UnitModel = (() => {
? [{ 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 }];
line = [{ u: 0, h: highAtStart ? high : low }, { u: F, h: highAtStart ? low : high }];
}
setWallTopLine(unitState.walls[side].wall, g, line);
syncWallHeights(unitState, side);
return line;
}
function validateState(design) {
@@ -476,5 +401,5 @@ window.UnitModel = (() => {
}
}
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, validateState };
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, formLine, validateState };
})();