399 lines
20 KiB
JavaScript
399 lines
20 KiB
JavaScript
/*!
|
||
* 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, chimney: op.chimney, koppel: op.koppel, 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, chimney: op.chimney, koppel: op.koppel, 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));
|
||
}
|
||
|
||
// cutRatio gaat voor cut, zodat ze meeschalen als goot of nok verandert.
|
||
const LIMITS = { ridge: 5000, eave: 3000, short: 4700, 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', wolf: 'Wolfsdak' };
|
||
const RIDGES = { z: 'Noord–zuid', x: 'West–oost' };
|
||
const hasRidge = (type) => type === 'zadel' || 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));
|
||
// v0.6.2: mansarde vervallen; een opgeslagen mansarde wordt een zadeldak met dezelfde nok.
|
||
if (inner.length === 3) return { type: 'zadel', ridge, low, high: Math.round(inner[1].h) };
|
||
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 dropMansarde(unitState) {
|
||
const f = roofForm(unitState), side = f.type === 'zadel' ? gableOf(f) : null;
|
||
if (side && fullTopLine(unitState.walls[side].wall, gableSpan(unitState, side)).length === 5) applyRoofForm(unitState, f);
|
||
}
|
||
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 === '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, dropMansarde, applyRoofForm, formLine, validateState };
|
||
})();
|