Files
Tectum/js/unit/unit-model.js
T

307 lines
15 KiB
JavaScript

/*!
* 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: <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.
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;
// 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);
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;
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
});
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 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 }; });
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
});
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. middenpunt (nok);
// - de twee haakse wanden worden vlak op de hoogte van de hoek waar ze
// op aansluiten (goothoogte bij een lessenaar- of zadeldak).
// 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' };
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 t = state.walls[key].wall;
t.heightLeft = hL; t.heightRight = hR; t.heightMid = hM;
t.heightAll = hM === null && 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);
}
// Hoogste punt van een wand (incl. nok).
function wallTopOf(wallState) {
const w = wallState.wall;
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0);
}
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, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, validateState };
})();