v0.5.2: elektra (meterkast 3×25 A, schakelaars met letters op hun lampen, wcd, apparaatgroepen incl. perilex, groepenverklaring met fasen/aardlek, leidingen naar de meterkast met strekkende meters per type, PDF-blad + plattegrond, verplaatsbaar door slepen, bouwfase Elektra met installatiebuizen in 3D); ribbon, bouwfasen, kozijnen, kostenmodel en eerdere ongecommitte wijzigingen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-06 18:40:11 +02:00
co-authored by Claude Opus 5.5
parent 8cdfa21f46
commit e1a688728c
30 changed files with 6667 additions and 868 deletions
+201 -27
View File
@@ -192,24 +192,31 @@ window.UnitModel = (() => {
const fullConfig = WallModel.configFromState(wallState);
const W = wallLayout.width;
const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight;
// Middenpunt (nok) midden op de wand; een stuk krijgt de nok alleen als
// die erin valt, anders de daklijn ter plaatse als gewone helling.
const hM = wallState.wall.heightMid > 0 ? Math.max(wallState.wall.heightMid, hL, hR) : null;
const pU = W / 2;
const heightAt = (u) => hM === null ? hL + (u / W) * (hR - hL)
: u <= pU ? hL + (u / pU) * (hM - hL) : hM + ((u - pU) / (W - pU)) * (hR - hM);
// 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, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; });
const hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
.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)),
heightMid: hasPeak ? hM : null, peakX: hasPeak ? pU - a : undefined
});
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, ...
@@ -228,16 +235,14 @@ window.UnitModel = (() => {
const ins = wallLayout.endInset || { left: 0, right: 0 };
raisedList.forEach((r, i) => {
const { u0: a, u1: b, base } = r;
const hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
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, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; });
.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),
heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined
});
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
@@ -258,9 +263,11 @@ window.UnitModel = (() => {
// 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. middenpunt (nok);
// - de twee haakse wanden worden vlak op de hoogte van de hoek waar ze
// op aansluiten (goothoogte bij een lessenaar- of zadeldak).
// 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).
@@ -271,22 +278,189 @@ 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);
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 set = (key, hL, hR, hM) => {
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;
t.heightAll = hM === null && hL === hR ? hL : w.heightAll;
if (tp) t.topPts = tp; else delete t.topPts;
t.heightAll = hM === null && !tp && hL === hR ? hL : w.heightAll;
};
set(OPPOSITE[side], w.heightRight, w.heightLeft, mid);
set(WALL_ENDS[side].left, w.heightLeft, w.heightLeft, null);
set(WALL_ENDS[side].right, w.heightRight, w.heightRight, null);
// 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).
// 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);
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) {
@@ -302,5 +476,5 @@ window.UnitModel = (() => {
}
}
return { WALL_SIDES, CORNER_MODES, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, validateState };
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, validateState };
})();