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

406 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.UnitModel = (() => {
// 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 } },
{ 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 } }
];
const CORNER_MODES = {
'voor-achter': 'Noord/zuid doorlopend',
'zijkanten': 'West/oost doorlopend'
};
const DEFAULT_HEIGHT_MM = 2600;
const MIN_PIECE_MM = 300;
const EPS = 0.5;
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();
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;
}
// 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 };
}
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])); });
const cutAt = (side, p) => (c[side] || []).some(([a, b]) => a <= p + EPS && b >= p - EPS);
const tEff = (side, p) => cutAt(side, p) ? 0 : T[side];
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], [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;
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]);
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]) => {
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]);
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;
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, ref, splitsU
};
});
return { floorWidth: W, floorDepth: D, floorTop: FloorModel.topHeight(floorFrame), walls };
}
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, hR, tops } = topLineOn(wallLayout, wallState.wall);
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) : '',
u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2,
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 : ''),
u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2, baseY: Math.round(base),
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 };
}
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 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 };
}
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;
}
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;
// 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);
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;
};
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);
}
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));
}
// kneeRatio / cutRatio gaan voor kneeH / cut, zodat ze meeschalen als goot of nok verandert.
const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 };
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';
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';
}
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 };
}
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 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 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)));
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') {
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;
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) {
line = [{ u: 0, h: highAtStart ? high : low }, { u: F, h: highAtStart ? low : high }];
}
return line;
}
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, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, formLine, validateState };
})();