Files
Tectum/js/elektra/elektra-model.js

1076 lines
60 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.ElektraModel = (() => {
const DEFAULT_SETTINGS = {
enabled: true,
meterkast: { w: 600, h: 900, d: 250, y: 1000 }, // y = onderkant boven vloer
switchHeight: 1500,
wcdHeight: 400,
wcdPerUnit: 2,
plateColor: 'wit', // schakelaars, wcd's, apparaat-wcd's en vloerdozen
slack: 10, // %
reserve: 300,
meterkastReserve: 500,
apparaten: { koken: true, wasmachine: true, droger: true, warmtepomp: true, oven: false, vaatwasser: false },
place: {},
vloerWcd: [],
wcdExtra: {},
removed: []
};
const CLEAR = 50;
const FLOOR_DEPTH = 100;
// Codes van twee letters: geen verwarring met de schakelaarletters.
const APPLIANCES = {
koken: { label: 'Kookgroep (perilex)', code: 'KK', breaker: '2× B16', poles: 2, cable: 'koken', height: 600, zone: 'keuken', material: 'Perilex-wandcontactdoos (2-fase)' },
oven: { label: 'Oven', code: 'OV', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' },
vaatwasser: { label: 'Vaatwasser', code: 'VW', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' },
wasmachine: { label: 'Wasmachine', code: 'WM', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' }
};
const PLATE_COLORS = {
wit: { label: 'Wit', hex: 0xf6f6f2 },
antraciet: { label: 'Antraciet', hex: 0x3a3d41 },
zwart: { label: 'Zwart', hex: 0x161718 }
};
const BOX_PITCH = 71;
const PLATE = 80;
const WCD_W = 150;
const DOOR_GAP = 100;
const VD_OFFSET = 150;
const CORE_COLORS = { L: 'bruin', L2: 'zwart', L3: 'grijs', N: 'blauw', PE: 'geel/groen', S: 'zwart' };
const CABLES = {
licht: { label: 'VD 3×1,5 mm² in buis Ø16', use: 'Verlichting: voeding en doorlus', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 16 },
wcd: { label: 'VD 3×2,5 mm² in buis Ø19', use: 'Wcd en apparaten', mm2: 2.5, cores: ['L', 'N', 'PE'], conduit: 19 },
koken: { label: 'VD 5×2,5 mm² in buis Ø25', use: 'Kookgroep (perilex, 2-fase)', mm2: 2.5, cores: ['L', 'L2', 'L3', 'N', 'PE'], conduit: 25 },
buiten: { label: 'XMvK 3×1,5 mm²', use: 'Buitenverlichting (boeideel/gevel)', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 0 }
};
function cableInfo(key) {
if (CABLES[key]) return CABLES[key];
const m = /^schakel(\d)$/.exec(key);
const n = m ? Number(m[1]) : 2;
return { label: `VD ${n}×1,5 mm² in buis Ø16`, use: `Schakeldraden (${n - 1} schakelaar${n > 2 ? 's' : ''})`, mm2: 1.5, cores: ['L'].concat(Array(n - 1).fill('S')), conduit: 16 };
}
function normalizeSettings(settings) {
const i = settings || {};
const s = Object.assign({}, DEFAULT_SETTINGS, i);
s.meterkast = Object.assign({}, DEFAULT_SETTINGS.meterkast, i.meterkast || {});
s.apparaten = Object.assign({}, DEFAULT_SETTINGS.apparaten, i.apparaten || {});
s.place = Object.assign({}, i.place || {});
s.wcdExtra = Object.assign({}, i.wcdExtra || {});
s.removed = Array.isArray(i.removed) ? i.removed.slice() : [];
s.vloerWcd = Array.isArray(i.vloerWcd) ? i.vloerWcd.filter(o => o && o.unitId).map(o => Object.assign({}, o)) : [];
s.enabled = s.enabled !== false;
if (!PLATE_COLORS[s.plateColor]) s.plateColor = DEFAULT_SETTINGS.plateColor;
// v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard).
if (!i.v) {
if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight;
if (i.wcdHeight === 300) s.wcdHeight = DEFAULT_SETTINGS.wcdHeight;
}
s.v = 2;
return s;
}
// side -1 = lokaal -Z = kamerkant (buitenwand, naadwanddeel); binnenwand: beide zijden.
function faceOf(p, inner, u, side) {
const pl = p.place, W = pl.width, lz = side * pl.thickness / 2;
const a = UnitModel.localToWorld(pl, -W / 2, lz), b = UnitModel.localToWorld(pl, W / 2, lz);
const axis = Math.abs(b.x - a.x) > Math.abs(b.z - a.z) ? 'x' : 'z';
const uOf = (v) => (v - a[axis]) / (b[axis] - a[axis]) * W;
const ua = uOf(axis === 'x' ? inner.x0 : inner.z0), ub = uOf(axis === 'x' ? inner.x1 : inner.z1);
return {
piece: p, unit: u, a, b, axis, W, lz,
u0: Math.max(0, Math.min(ua, ub)), u1: Math.min(W, Math.max(ua, ub)),
n: { x: side * Math.sin(pl.rotY), z: side * Math.cos(pl.rotY) },
side,
sw: p.config.studWidth || 45,
reserved: []
};
}
function facesOf(u, interior) {
const inner = LightingModel.unitInnerRect(u, interior);
const pieces = [];
u.walls.forEach(w => w.pieces.forEach(p => { if (!p.baseY) pieces.push(p); }));
interior.filter(p => p.seamUnit === u.id).forEach(p => pieces.push(p));
const list = pieces.map(p => faceOf(p, inner, u, -1));
const r = u.rect;
interior.filter(p => !p.seam && p.place.cx >= r.x0 && p.place.cx <= r.x1 && p.place.cz >= r.z0 && p.place.cz <= r.z1)
.forEach(p => list.push(faceOf(p, inner, u, -1), faceOf(p, inner, u, 1)));
return list.filter(f => f.u1 - f.u0 > 200);
}
// off: de kamer in (negatief = de wand in).
function facePoint(f, u, y, off) {
const q = UnitModel.localToWorld(f.piece.place, u - f.W / 2, f.lz + f.side * (off || 0));
return { x: q.x, y: f.piece.place.floorTop + y, z: q.z };
}
const uAt = (f, q) => (q[f.axis] - f.a[f.axis]) / (f.b[f.axis] - f.a[f.axis]) * f.W;
function freeOn(f, y0, y1, margin, edge) {
const blocked = [];
f.piece.config.openings.forEach(op => {
if (op.y < y1 && WallModel.openingTopMax(op) > y0) blocked.push([op.x - f.sw - margin, op.x + op.w + f.sw + margin]);
});
f.reserved.forEach(r => { if (r.y0 < y1 && r.y1 > y0) blocked.push([r.a, r.b]); });
return LightingModel.freeIntervals(f.u0 + edge, f.u1 - edge, blocked);
}
function nearestFree(faces, target, w, y0, y1, margin, edge) {
let best = null;
faces.forEach(f => freeOn(f, y0, y1, margin, edge).forEach(([a, b]) => {
if (b - a < w) return;
const u = Math.max(a + w / 2, Math.min(b - w / 2, uAt(f, target)));
const q = facePoint(f, u, 0, 0);
const d = Math.hypot(q.x - target.x, q.z - target.z);
if (!best || d < best.d) best = { f, u, d };
}));
return best;
}
// Eerst de slotzijde (rechts van de opening in wand-x), dan de scharnierzijde.
function besideDoor(f, op, n, height) {
const width = PLATE + (n - 1) * BOX_PITCH;
const free = freeOn(f, height - PLATE / 2, height + PLATE / 2, 0, f.sw);
const fits = (lo, hi) => free.some(([a, b]) => lo >= a - 0.5 && hi <= b + 0.5);
const r0 = op.x + op.w + f.sw + DOOR_GAP;
if (fits(r0 - PLATE / 2, r0 - PLATE / 2 + width)) return Array.from({ length: n }, (_, i) => r0 + i * BOX_PITCH);
const l0 = op.x - f.sw - DOOR_GAP;
if (fits(l0 + PLATE / 2 - width, l0 + PLATE / 2)) return Array.from({ length: n }, (_, i) => l0 - i * BOX_PITCH);
return null;
}
// Gaat het stuk p→q (wereld, mm) door een raam of deur? Een wand telt over zijn dikte plus 5 mm, de opening 1 mm
// kleiner: een buis naast de opening of erboven/eronder mag.
function makeOpeningTest(units, interior) {
const pieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { if (p.config.openings.length) pieces.push(p); })));
interior.forEach(p => { if (p.config.openings.length) pieces.push(p); });
const clip = (r, a, b, lo, hi) => {
const d = b - a;
if (Math.abs(d) < 1e-9) return a > lo && a < hi ? r : null;
let t0 = (lo - a) / d, t1 = (hi - a) / d;
if (t0 > t1) [t0, t1] = [t1, t0];
return Math.max(r[0], t0) < Math.min(r[1], t1) ? [Math.max(r[0], t0), Math.min(r[1], t1)] : null;
};
return (p, q) => pieces.some(pc => {
const pl = pc.place, c = Math.cos(pl.rotY), s = Math.sin(pl.rotY), half = pl.thickness / 2 + 5;
const loc = (v) => { const dx = v.x - pl.cx, dz = v.z - pl.cz; return { u: dx * c - dz * s + pl.width / 2, n: dx * s + dz * c, y: v.y - pl.floorTop }; };
const A = loc(p), B = loc(q);
const inWall = clip([0, 1], A.n, B.n, -half, half);
return !!inWall && pc.config.openings.some(op => {
let r = clip(inWall, A.u, B.u, op.x + 1, op.x + op.w - 1);
r = r && clip(r, A.y, B.y, op.y + 1, WallModel.openingTopMax(op) - 1);
if (!r) return false;
// Schuine bovenkant: alleen onder de daklijn van de opening.
return [0, 0.25, 0.5, 0.75, 1].some(k => {
const t = r[0] + (r[1] - r[0]) * k, u = A.u + (B.u - A.u) * t, y = A.y + (B.y - A.y) * t;
return y < WallModel.openingTopAt(op, u) - 1;
});
});
});
}
// rooms: leidingen lopen boven een vals plafond, niet tegen de onderkant van een hoog dak.
// blocked(p, q): het stuk gaat door een wandopening (makeOpeningTest).
function makeRouter(units, rooms, blocked) {
const unitAt = (x, z) => units.find(u => x >= u.rect.x0 - 1 && x <= u.rect.x1 + 1 && z >= u.rect.z0 - 1 && z <= u.rect.z1 + 1) || null;
// Vals plafond (mm boven vloer) van de ruimte op (x,z): undefined = in een wand of naad (geen gebied), null = geen
// vals plafond (ook een gebied zonder functie: niet het plafond van de buurruimte door de wand heen overnemen).
const roomPlenum = (x, z) => {
if (!rooms || !rooms.roomAt) return undefined;
const r = rooms.roomAt(x, z);
if (r) return r.ceilingActive ? r.ceiling : null;
return rooms.regionAt && rooms.regionAt(x, z) ? null : undefined;
};
// Absoluut; in een binnenwand het laagste valse plafond ernaast: de leiding gaat daar door de wand, niet omhoog.
const plenumAt = (x, z) => {
const u = unitAt(x, z);
if (!u) return null;
let v = roomPlenum(x, z);
if (v === undefined) {
v = null;
[[1, 0], [-1, 0], [0, 1], [0, -1]].forEach(([dx, dz]) => {
for (const d of [60, 120, 200, 300]) {
const o = roomPlenum(x + dx * d, z + dz * d);
if (o === undefined) continue;
if (o !== null) v = v === null ? o : Math.min(v, o);
break;
}
});
}
return v === null ? null : u.layout.floorTop + v;
};
const roofCeilAt = (x, z) => {
let u = unitAt(x, z);
if (!u) u = units.reduce((best, c) => {
const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1);
const d = Math.hypot(dx, dz);
return !best || d < best.d ? { u: c, d } : best;
}, null).u;
const r = u.rect;
return u.roof.ceilingAt(Math.max(r.x0 + 1, Math.min(r.x1 - 1, x)), Math.max(r.z0 + 1, Math.min(r.z1 - 1, z)));
};
// Leidingvlak: het valse plafond waar dat lager ligt dan het dak, anders de onderkant van het dak.
const plenumOn = (x, z) => { const v = plenumAt(x, z); return v !== null && v < roofCeilAt(x, z) - 1 ? v : null; };
const ceilAt = (x, z) => { const v = plenumOn(x, z); return v !== null ? v : roofCeilAt(x, z); };
const inPlenum = (x, z, y) => y < roofCeilAt(x, z) - 50;
// Over het plafond het vlak volgen: knikken in het dak (nok, kil) als tussenpunt, de overgang vals plafond/dak
// verticaal op de grens (in de wand), zodat de buis nooit los door de ruimte of buiten het dak loopt.
function followCeiling(pts) {
const same = (u, v) => (u === null && v === null) || (u !== null && v !== null && Math.abs(u - v) < 1);
const out = [pts[0]];
pts.slice(1).forEach((q, i) => {
const p = pts[i], len = Math.hypot(q.x - p.x, q.z - p.z);
if (p.ceil && q.ceil && !p.floor && !q.floor && len > 1) {
const at = (t) => ({ x: p.x + (q.x - p.x) * t, z: p.z + (q.z - p.z) * t });
const hOf = (pt, st) => st !== null ? st : roofCeilAt(pt.x, pt.z);
const mk = (pt, y) => Object.assign({}, p, pt, { y, wall: null, ceil: true, floor: false, plenum: inPlenum(pt.x, pt.z, y) });
const n = Math.max(2, Math.ceil(len / 100));
// Begin/eind op een wandvlak 50 mm de ruimte in meten, net als top(): anders telt de buurruimte achter de wand mee.
const probe = (pt, e) => e.wall && e.wall.n ? { x: pt.x + e.wall.n.x * 50, z: pt.z + e.wall.n.z * 50 } : pt;
const S = Array.from({ length: n + 1 }, (_, k) => {
const pt = at(k / n), pr = k === 0 ? probe(pt, p) : k === n ? probe(pt, q) : pt, st = plenumOn(pr.x, pr.z);
return { t: k / n, pt, st, y: hOf(pt, st) };
});
for (let k = 1; k <= n; k++) {
const A = S[k - 1], B = S[k];
if (!same(A.st, B.st)) {
let lo = A.t, hi = B.t;
for (let it = 0; it < 10; it++) { const m = (lo + hi) / 2, pm = at(m); if (same(plenumOn(pm.x, pm.z), A.st)) lo = m; else hi = m; }
const pb = at((lo + hi) / 2);
out.push(mk(pb, hOf(pb, A.st)), mk(pb, hOf(pb, B.st)));
} else if (k < n && same(B.st, S[k + 1].st) && Math.abs(A.y - 2 * B.y + S[k + 1].y) > 2) out.push(mk(B.pt, B.y));
}
}
out.push(q);
});
return out;
}
const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; };
const clampTo = (v, lo, hi) => lo > hi ? (lo + hi) / 2 : Math.max(lo, Math.min(hi, v));
const segInside = (p, q) => {
const n = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 50));
for (let k = 0; k <= n; k++) if (!unitAt(p.x + (q.x - p.x) * k / n, p.z + (q.z - p.z) * k / n)) return false;
return true;
};
// Punt buiten de woning (gevellamp, dakrandspot): eerst loodrecht naar de dichtstbijzijnde unit.
const clampIn = (p) => {
if (unitAt(p.x, p.z)) return null;
let best = null;
units.forEach(u => {
const r = u.rect, x = clampTo(p.x, r.x0 + 1, r.x1 - 1), z = clampTo(p.z, r.z0 + 1, r.z1 - 1), d = Math.hypot(x - p.x, z - p.z);
if (!best || d < best.d) best = { x, z, d };
});
return best && { x: best.x, z: best.z };
};
// Knikpunten in de plattegrond tussen a en b (exclusief), haaks en binnen de units: bij een L- of T-vorm mag de
// knik niet in de lege hoek buiten de woning vallen. Eerst x dan z (zoals altijd), anders z dan x, anders via een unit.
function corners(a, b, viaCeiling) {
const A = clampIn(a), B = clampIn(b), pa = A || a, pb = B || b;
const cands = [[{ x: pb.x, z: pa.z }], [{ x: pa.x, z: pb.z }]];
units.forEach(u => {
const r = u.rect, xm = clampTo((pa.x + pb.x) / 2, r.x0 + 150, r.x1 - 150), zm = clampTo((pa.z + pb.z) / 2, r.z0 + 150, r.z1 - 150);
cands.push([{ x: xm, z: pa.z }, { x: xm, z: pb.z }], [{ x: pa.x, z: zm }, { x: pb.x, z: zm }]);
});
let best = null;
cands.forEach(c => {
const pts = [pa].concat(c, [pb]);
if (!pts.slice(1).every((q, i) => segInside(pts[i], q))) return;
let len = pts.slice(1).reduce((t, q, i) => t + Math.abs(q.x - pts[i].x) + Math.abs(q.z - pts[i].z), 0);
// Over het plafond telt op- en neergaan mee: liever door een laag vals plafond dan over de nok.
if (viaCeiling) pts.slice(1).forEach((q, i) => {
const p = pts[i], m = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 200));
let prev = ceilAt(p.x, p.z);
for (let k = 1; k <= m; k++) { const y = ceilAt(p.x + (q.x - p.x) * k / m, p.z + (q.z - p.z) * k / m); len += Math.abs(y - prev); prev = y; }
});
if (!best || len < best.len - 1) best = { len, c };
});
return [A].concat(best ? best.c : cands[0], [B]).filter(Boolean)
.filter(q => Math.hypot(q.x - a.x, q.z - a.z) > 0.5 && Math.hypot(q.x - b.x, q.z - b.z) > 0.5);
}
const strictlyInside = (x, z) => units.some(u => x > u.rect.x0 + 5 && x < u.rect.x1 - 5 && z > u.rect.z0 + 5 && z < u.rect.z1 - 5);
const clear = (pts) => !blocked || pts.slice(1).every((q, i) => !blocked(pts[i], q));
// Wandpunt dat recht omhoog/omlaag door een raam of deur zou gaan (wcd onder een raam, meterkast onder een
// bovenlicht): eerst op dezelfde hoogte opzij tot naast de opening (stijl + 60 mm), de dichtstbijzijnde kant
// die vrij is. yTo(punt) = hoogte waar de leiding heen gaat. null = recht kan, of nergens plek.
function sidestep(p, yTo) {
const f = p.face;
if (!f || !blocked || !blocked(p, { x: p.x, y: yTo(p), z: p.z })) return null;
const y = p.y - f.piece.place.floorTop, u0 = uAt(f, p), lo = Math.min(y, yTo(p) - f.piece.place.floorTop);
const cands = [];
f.piece.config.openings.forEach(op => {
if (WallModel.openingTopMax(op) > lo) cands.push(op.x - f.sw - 60, op.x + op.w + f.sw + 60);
});
const pick = cands.filter(u => u > f.u0 + 50 && u < f.u1 - 50).sort((m, n) => Math.abs(m - u0) - Math.abs(n - u0)).find(u => {
const q = Object.assign({}, p, facePoint(f, u, y, 0));
return !blocked(p, q) && !blocked(q, { x: q.x, y: yTo(q), z: q.z });
});
return pick === undefined ? null : Object.assign({}, p, facePoint(f, pick, y, 0));
}
// Gevellamp naar gevellamp: langs de gevel (om de hoeken heen), nooit dwars door de woning.
function outside(a, b) {
const gap = (p) => Math.min(...units.map(u => Math.max(u.rect.x0 - p.x, 0, p.x - u.rect.x1) + Math.max(u.rect.z0 - p.z, 0, p.z - u.rect.z1)));
const d = Math.max(60, gap(a), gap(b));
const cands = [[{ x: b.x, z: a.z }], [{ x: a.x, z: b.z }]];
units.forEach(u => {
[u.rect.x0 - d, u.rect.x1 + d].forEach(x => cands.push([{ x, z: a.z }, { x, z: b.z }]));
[u.rect.z0 - d, u.rect.z1 + d].forEach(z => cands.push([{ x: a.x, z }, { x: b.x, z }]));
});
const free = (p, q) => {
const n = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 50));
for (let k = 1; k < n; k++) if (strictlyInside(p.x + (q.x - p.x) * k / n, p.z + (q.z - p.z) * k / n)) return false;
return true;
};
let best = null;
cands.forEach(c => {
const pts = [a].concat(c, [b]);
if (!pts.slice(1).every((q, i) => free(pts[i], q))) return;
const len = pts.slice(1).reduce((t, q, i) => t + Math.abs(q.x - pts[i].x) + Math.abs(q.z - pts[i].z), 0);
if (!best || len < best.len - 1) best = { len, c };
});
return best ? best.c : [strictlyInside(b.x, a.z) ? { x: a.x, z: b.z } : { x: b.x, z: a.z }];
}
function path(a, b) {
const P = (p, wall, ceil, floor) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil, floor: !!floor, plenum: !!ceil && inPlenum(p.x, p.z, p.y) });
if (a.mount === 'vloer' || b.mount === 'vloer') {
const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH;
const A2 = a.mount === 'vloer' ? null : sidestep(a, () => fa), B2 = b.mount === 'vloer' ? null : sidestep(b, () => fb);
const a2 = A2 || a, b2 = B2 || b;
return [P(a, a.face, false, a.mount === 'vloer')].concat(A2 ? [P(A2, a.face)] : [], [P({ x: a2.x, y: fa, z: a2.z }, a.face, false, true)])
.concat(corners(a2, b2).map(c => P({ x: c.x, y: fa, z: c.z }, null, false, true)),
[P({ x: b2.x, y: fb, z: b2.z }, b.face, false, true)], B2 ? [P(B2, b.face)] : [], [P(b, b.face, false, b.mount === 'vloer')]);
}
// Langs dezelfde wand: eerst verticaal of eerst horizontaal; zit er een deur of raam tussen, dan via het plafond.
if (a.face && a.face === b.face) {
const viaA = [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
const viaB = [P(a, a.face), P({ x: b.x, y: a.y, z: b.z }, a.face), P(b, b.face)];
if (clear(viaA)) return viaA;
if (clear(viaB)) return viaB;
}
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a)].concat(outside(a, b).map(c => P({ x: c.x, y: a.y, z: c.z })), [P(b)]);
// Wandpunt: de plafondhoogte iets de ruimte in meten (de wand zelf hoort bij geen ruimte).
const top = (p) => p.mount === 'plafond' ? p.y : p.face && p.face.n ? ceilAt(p.x + p.face.n.x * 50, p.z + p.face.n.z * 50) : ceilAt(p.x, p.z);
const A2 = a.mount === 'plafond' ? null : sidestep(a, top), B2 = b.mount === 'plafond' ? null : sidestep(b, top);
const a2 = A2 || a, b2 = B2 || b;
const ya = top(a2), yb = top(b2), dropB = Math.abs(yb - b.y) > 1;
const pts = [P(a, a.face, a.mount === 'plafond')];
if (A2) pts.push(P(A2, a.face));
if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a2.x, y: ya, z: a2.z }, a.face, true));
corners(a2, b2, true).forEach(c => pts.push(P({ x: c.x, y: ceilAt(c.x, c.z), z: c.z }, null, true)));
pts.push(P({ x: b2.x, y: yb, z: b2.z }, dropB ? b.face : null, true));
if (dropB) {
if (B2) pts.push(P(B2, b.face));
pts.push(P(b, b.face, false));
}
return followCeiling(pts);
}
const length = (pts) => pts.slice(1).reduce((s, q, i) => s + Math.hypot(q.x - pts[i].x, q.y - pts[i].y, q.z - pts[i].z), 0);
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
function crossings(pts) {
let last = null, n = 0;
pts.slice(1).forEach((q, i) => {
const p = pts[i], len = Math.hypot(q.x - p.x, q.z - p.z), steps = Math.max(1, Math.ceil(len / 100));
for (let k = 0; k <= steps; k++) {
const u = unitAt(p.x + (q.x - p.x) * k / steps, p.z + (q.z - p.z) * k / steps);
if (!u) continue;
if (last && u.id !== last) n++;
last = u.id;
}
});
return n;
}
return { unitAt, ceilAt, floorAt, path, length, cost, crossings };
}
function compute(settings, units, interior, lighting, roofEdge, rooms) {
const s = normalizeSettings(settings);
const indoor = lighting.indoor || [], gevel = lighting.lamps || [], spots = (roofEdge && roofEdge.spots) || [];
indoor.concat(gevel, spots).forEach(p => { delete p.letter; });
const out = {
enabled: s.enabled, settings: s, meterkast: null, combos: [], switches: [], wcds: [], apps: [], floorWcds: [], boxes: [],
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
};
if (!s.enabled || !units.length) return out;
const blocked = makeOpeningTest(units, interior);
const R = makeRouter(units, rooms, blocked);
const faces = {};
units.forEach(u => { faces[u.id] = facesOf(u, interior); });
const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1);
const allFaces = out.faces = units.reduce((a, u) => a.concat(faces[u.id]), []);
let door = null;
['voordeur', 'deur'].some(type => units.some(u => u.walls.some(w => w.pieces.some(p => {
if (p.baseY) return false;
const op = p.config.openings.find(o => o.type === type);
const f = op && faceOfPiece(u, p);
if (f) door = { unit: u, face: f, op };
return !!door;
}))));
if (!door) out.warnings.push('Geen voordeur of buitendeur gevonden: schakelaars en meterkast vrij geplaatst.');
const mainUnit = door ? door.unit : units[0];
const hasOutdoor = gevel.length + spots.length > 0;
let letterCode = 65;
const nextLetter = () => String.fromCharCode(letterCode++);
const mainFuncs = hasOutdoor ? ['binnen', 'buiten'] : ['binnen'];
const mk = s.meterkast;
const apps = Object.keys(APPLIANCES).filter(k => s.apparaten[k]);
const num = (v, def) => typeof v === 'number' && isFinite(v) ? v : def;
const removed = (key) => s.removed.includes(key);
const wcdCount = (uid) => s.wcdPerUnit + Math.max(0, Math.round(num(s.wcdExtra[uid], 0)));
const comboWidth = (n) => PLATE + (n - 1) * BOX_PITCH;
const widthOf = (key) => {
if (key === 'mk') return mk.w;
let m = /^sw:(.+)$/.exec(key);
if (m) {
if (removed(key)) return 0;
if (m[1] === mainUnit.id) return comboWidth(mainFuncs.length);
return units.some(u => u.id === m[1]) ? (rooms ? 0 : comboWidth(1)) : comboWidth(1);
}
m = /^wcd:(.+):(\d+)$/.exec(key);
if (m) return units.some(u => u.id === m[1]) && Number(m[2]) <= wcdCount(m[1]) && !removed(key) ? WCD_W : 0;
m = /^app:(.+)$/.exec(key);
return m && apps.includes(m[1]) ? PLATE : 0;
};
const fixed = {};
Object.keys(s.place).forEach(key => {
const o = s.place[key], w = widthOf(key);
if (!o || !w) return;
const f = allFaces.find(x => x.piece.element === o.element && x.side === (o.side || -1));
if (!f) return;
const u = Math.max(f.u0 + w / 2, Math.min(f.u1 - w / 2, num(o.u, (f.u0 + f.u1) / 2)));
fixed[key] = { f, u, y: num(o.y, null) };
f.reserved.push({ a: u - w / 2 - 50, b: u + w / 2 + 50, y0: 0, y1: 1e6 });
});
const centersAt = (u, n) => Array.from({ length: n }, (_, i) => u - comboWidth(n) / 2 + PLATE / 2 + i * BOX_PITCH);
const addCombo = (unit, f, us, funcs, y, manual, key, name) => {
const yy = y === null || y === undefined ? s.switchHeight : y;
const combo = { id: out.combos.length + 1, key: key || 'sw:' + unit.id, manual: !!manual, unitId: unit.id, face: f, us, y: yy, rotY: f.piece.place.rotY, switches: [] };
funcs.forEach((func, i) => {
const sw = { letter: nextLetter(), func, unitId: unit.id, combo, u: us[i], lamps: [], label: func === 'buiten' ? 'Buitenlicht' : `Binnenlicht ${name || 'unit ' + unit.id}` };
sw.pos = facePoint(f, us[i], yy, 0);
combo.switches.push(sw);
out.switches.push(sw);
});
const lo = Math.min(...us) - PLATE / 2, hi = Math.max(...us) + PLATE / 2;
if (!manual) f.reserved.push({ a: lo - 60, b: hi + 60, y0: 0, y1: 1e6 });
combo.u = (lo + hi) / 2;
combo.pos = facePoint(f, combo.u, yy, 0);
combo.width = hi - lo;
combo.center = facePoint(f, combo.u, yy, 5);
out.combos.push(combo);
return combo;
};
const fxMain = fixed['sw:' + mainUnit.id], mainOff = removed('sw:' + mainUnit.id);
if (mainOff) { } else if (fxMain) addCombo(mainUnit, fxMain.f, centersAt(fxMain.u, mainFuncs.length), mainFuncs, fxMain.y, true);
else if (door) {
const us = besideDoor(door.face, door.op, mainFuncs.length, s.switchHeight);
if (us) addCombo(mainUnit, door.face, us, mainFuncs);
else {
const doorPt = facePoint(door.face, door.op.x + door.op.w / 2, 0, 0);
const w = PLATE + (mainFuncs.length - 1) * BOX_PITCH;
const nf = nearestFree(faces[mainUnit.id], doorPt, w, s.switchHeight - PLATE / 2, s.switchHeight + PLATE / 2, 0, 100);
if (nf) addCombo(mainUnit, nf.f, mainFuncs.map((_, i) => nf.u - w / 2 + PLATE / 2 + i * BOX_PITCH), mainFuncs);
}
}
if (!out.combos.length && !mainOff) {
const f = faces[mainUnit.id][0];
if (f) addCombo(mainUnit, f, mainFuncs.map((_, i) => (f.u0 + f.u1) / 2 + i * BOX_PITCH), mainFuncs);
}
const entry = out.combos[0] ? out.combos[0].pos : { x: mainUnit.rect.cx, z: mainUnit.rect.cz };
const target = door ? facePoint(door.face, door.op.x + door.op.w / 2, 0, 0) : entry;
const fxMk = fixed.mk;
const mkY = fxMk && fxMk.y !== null ? fxMk.y : mk.y;
let spot = fxMk ? { f: fxMk.f, u: fxMk.u } : nearestFree(faces[mainUnit.id], target, mk.w, mkY, mkY + mk.h, 50, 50);
if (!spot) units.some(u => u !== mainUnit && (spot = nearestFree(faces[u.id], target, mk.w, mkY, mkY + mk.h, 50, 50)));
if (spot) {
const f = spot.f;
if (!fxMk) f.reserved.push({ a: spot.u - mk.w / 2 - 50, b: spot.u + mk.w / 2 + 50, y0: 0, y1: 1e6 });
out.meterkast = {
key: 'mk', manual: !!fxMk, unitId: f.unit.id, element: f.piece.element, face: f, u: spot.u, w: mk.w, h: mk.h, d: mk.d, y: mkY, rotY: f.piece.place.rotY,
center: facePoint(f, spot.u, mkY + mk.h / 2, mk.d / 2),
corners: [[-1, 0], [1, 0], [1, mk.d], [-1, mk.d]].map(([i, d]) => facePoint(f, spot.u + i * mk.w / 2, 0, d))
};
} else out.warnings.push('Geen vrije wand voor de meterkast gevonden.');
const isDoorOp = (op) => op.type === 'deur' || op.type === 'voordeur' || (op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur'));
const regionOf = (pt) => rooms ? rooms.regionAt(pt.x, pt.z) : null;
const roomSw = new Map();
if (rooms) {
const entryPt = door ? facePoint(door.face, door.op.x + door.op.w / 2, 0, 150) : entry;
const entryReg = regionOf(entryPt);
const mainSw = out.switches.find(x => x.func === 'binnen');
if (entryReg && mainSw) {
roomSw.set(entryReg.idx, mainSw);
const r0 = rooms.rooms.find(r => r.key === entryReg.idx);
if (r0) mainSw.label = `Binnenlicht ${r0.name.toLowerCase()}`;
}
const list = rooms.rooms.concat(rooms.free).filter(r => r.areaM2 >= 0.6 && !roomSw.has(r.key))
.sort((a, b) => Math.hypot(a.label.x - entryPt.x, a.label.z - entryPt.z) - Math.hypot(b.label.x - entryPt.x, b.label.z - entryPt.z));
list.forEach(r => {
const key = r.id !== undefined ? 'sw:r' + r.id : `sw:g${Math.round(r.label.x / 100)}_${Math.round(r.label.z / 100)}`;
const name = r.name ? r.name.toLowerCase() : 'ruimte';
if (removed(key)) return;
const inR = (f, uu) => { const g = regionOf(facePoint(f, uu, 0, 150)); return g && g.idx === r.key; };
const fx = fixed[key];
let combo = null;
if (fx) combo = addCombo(fx.f.unit, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true, key, name);
else {
const cands = [];
allFaces.forEach(f => f.piece.config.openings.forEach(op => {
if (!isDoorOp(op) || !inR(f, op.x + op.w / 2)) return;
const q = facePoint(f, op.x + op.w / 2, 0, 0);
cands.push({ f, op, d: Math.hypot(q.x - entryPt.x, q.z - entryPt.z) });
}));
cands.sort((a, b) => a.d - b.d);
for (const c of cands) {
const us = besideDoor(c.f, c.op, 1, s.switchHeight);
if (us) { combo = addCombo(c.f.unit, c.f, us, ['binnen'], null, false, key, name); break; }
}
if (!combo) {
const own = allFaces.filter(f => inR(f, (f.u0 + f.u1) / 2));
const nf = nearestFree(own, r.label, PLATE, s.switchHeight - PLATE / 2, s.switchHeight + PLATE / 2, 0, 100);
if (nf && inR(nf.f, nf.u)) combo = addCombo(nf.f.unit, nf.f, [nf.u], ['binnen'], null, false, key, name);
}
}
if (combo) roomSw.set(r.key, combo.switches[0]);
else out.warnings.push(`${r.name || 'Ruimte'}: geen plek voor een schakelaar; licht op een andere schakelaar.`);
});
}
if (!rooms) units.forEach(u => {
if (u === mainUnit || removed('sw:' + u.id)) return;
const fx = fixed['sw:' + u.id];
if (fx) { addCombo(u, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true); return; }
const cands = [];
faces[u.id].forEach(f => f.piece.config.openings.forEach(op => {
if (op.type !== 'deur' && op.type !== 'voordeur') return;
const q = facePoint(f, op.x + op.w / 2, 0, 0);
cands.push({ f, op, d: Math.hypot(q.x - entry.x, q.z - entry.z) + (f.piece.seamUnit ? 0 : 5000) });
}));
cands.sort((a, b) => a.d - b.d);
for (const c of cands) {
const us = besideDoor(c.f, c.op, 1, s.switchHeight);
if (us) { addCombo(u, c.f, us, ['binnen']); return; }
}
out.warnings.push(`Unit ${u.id}: geen deur met ruimte voor een schakelaar; binnenlicht op schakelaar ${out.switches[0] ? out.switches[0].letter : '-'}.`);
});
const swOf = (unitId, func) => out.switches.find(x => x.unitId === unitId && x.func === func) || out.switches.find(x => x.func === func) || out.switches[0];
const roomSwOf = (p) => { const g = regionOf(p); return g ? roomSw.get(g.idx) : null; };
indoor.forEach(p => { const sw = roomSwOf(p) || swOf(p.unitId, 'binnen'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x, y: p.y, z: p.z, mount: 'plafond', kind: 'binnenspot', ref: p }); } });
gevel.forEach(p => { const sw = swOf(mainUnit.id, 'buiten'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x + p.normal.x * 60, y: p.y, z: p.z + p.normal.z * 60, mount: 'buiten', kind: 'gevellamp', ref: p }); } });
spots.forEach(p => { const sw = swOf(mainUnit.id, 'buiten'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x, y: p.y, z: p.z, mount: 'buiten', kind: 'boeideelspot', ref: p }); } });
units.forEach(u => {
const y0 = s.wcdHeight - 60, y1 = s.wcdHeight + 60, cands = [];
faces[u.id].forEach(f => freeOn(f, y0, y1, 100, 150).forEach(([a, b]) => {
if (b - a < WCD_W + 20) return;
(b - a > 1200 ? [a + 300, (a + b) / 2, b - 300] : [(a + b) / 2]).forEach(uu => cands.push({ f, u: uu, len: b - a, p: facePoint(f, uu, s.wcdHeight, 0) }));
}));
const chosen = [], freeKeys = [], nWcd = wcdCount(u.id);
let want = 0;
for (let i = 1; i <= nWcd; i++) {
const key = `wcd:${u.id}:${i}`, fx = fixed[key];
if (removed(key)) continue;
want++;
if (!fx) { freeKeys.push(key); continue; }
const y = fx.y !== null ? fx.y : s.wcdHeight;
chosen.push({ f: fx.f, u: fx.u, y, p: facePoint(fx.f, fx.u, y, 0), key, manual: true });
}
while (freeKeys.length) {
let best = null, bestScore = -1;
cands.forEach(c => {
if (chosen.some(x => x.f === c.f && Math.abs(x.u - c.u) < WCD_W + 150)) return;
const score = chosen.length ? Math.min(...chosen.map(x => Math.hypot(x.p.x - c.p.x, x.p.z - c.p.z))) : c.len;
if (score > bestScore) { bestScore = score; best = c; }
});
if (!best) break;
chosen.push(Object.assign({}, best, { key: freeKeys.shift(), y: s.wcdHeight }));
best.f.reserved.push({ a: best.u - WCD_W / 2 - 75, b: best.u + WCD_W / 2 + 75, y0: 0, y1: s.wcdHeight + 200 });
}
if (chosen.length < want) out.warnings.push(`Unit ${u.id}: ruimte voor ${chosen.length} van ${want} wcd's.`);
chosen.sort((a, b) => Number(a.key.split(':')[2]) - Number(b.key.split(':')[2])).forEach(c => out.wcds.push({
key: c.key, manual: !!c.manual, unitId: u.id, face: c.f, u: c.u, y: c.y, rotY: c.f.piece.place.rotY, pos: c.p, center: facePoint(c.f, c.u, c.y, 6)
}));
});
const others = units.filter(u => u !== mainUnit);
const kitchen = (others.length ? others : units).reduce((a, b) => a.rect.W * a.rect.D >= b.rect.W * b.rect.D ? a : b);
const mkUnit = out.meterkast ? units.find(u => u.id === out.meterkast.unitId) : mainUnit;
const zones = {
keuken: { target: { x: kitchen.rect.cx, z: kitchen.rect.cz }, order: [kitchen].concat(units.filter(u => u !== kitchen)) },
techniek: { target: out.meterkast ? out.meterkast.center : entry, order: [mkUnit].concat(units.filter(u => u !== mkUnit)) }
};
apps.forEach(k => {
const A = APPLIANCES[k], key = 'app:' + k, fx = fixed[key];
let f, uu, y = A.height;
if (fx) { f = fx.f; uu = fx.u; if (fx.y !== null) y = fx.y; }
else {
const z = zones[A.zone];
let nf = null;
z.order.some(u => (nf = nearestFree(faces[u.id], z.target, PLATE + 40, y - 60, y + 60, 100, 150)));
if (!nf) { out.warnings.push(`${A.label}: geen vrije wand gevonden.`); return; }
f = nf.f; uu = nf.u;
f.reserved.push({ a: uu - PLATE / 2 - 80, b: uu + PLATE / 2 + 80, y0: 0, y1: 1e6 });
}
out.apps.push({ key, manual: !!fx, app: k, label: A.label, code: A.code, unitId: f.unit.id, face: f, u: uu, y, rotY: f.piece.place.rotY, pos: facePoint(f, uu, y, 0), center: facePoint(f, uu, y, 6) });
});
s.vloerWcd.forEach(o => {
const u = units.find(x => x.id === o.unitId);
if (!u) return;
const x = u.rect.x0 + Math.max(100, Math.min(u.rect.W - 100, num(o.x, u.rect.W / 2)));
const z = u.rect.z0 + Math.max(100, Math.min(u.rect.D - 100, num(o.z, u.rect.D / 2)));
out.floorWcds.push({ key: 'vwcd:' + o.id, id: o.id, unitId: u.id, x, z, y: u.layout.floorTop });
});
units.forEach(u => {
const sws = out.switches.filter(x => x.unitId === u.id && x.func === 'binnen');
if (sws.length) out.groups.push({ kind: 'licht', unitId: u.id, label: `Verlichting unit ${u.id}`, breaker: 'B10', cable: 'licht', switches: sws.map(x => x.letter) });
if (out.wcds.some(w => w.unitId === u.id) || out.floorWcds.some(w => w.unitId === u.id)) out.groups.push({ kind: 'wcd', unitId: u.id, label: `Wcd unit ${u.id}`, breaker: 'B16', cable: 'wcd', switches: [] });
const outSw = out.switches.filter(x => x.unitId === u.id && x.func === 'buiten');
if (outSw.length) out.groups.push({ kind: 'buiten', unitId: u.id, label: 'Buitenverlichting', breaker: 'B10', cable: 'buiten', switches: outSw.map(x => x.letter) });
});
out.apps.forEach(a => {
const A = APPLIANCES[a.app];
out.groups.push({ kind: 'app', app: a.app, unitId: a.unitId, label: A.label, breaker: A.breaker, cable: A.cable, poles: A.poles || 1, switches: [] });
});
const nRcd = Math.max(2, Math.ceil(out.groups.length / 4));
let ph = 0;
out.groups.forEach((g, i) => {
const n = g.poles || 1;
g.nr = i + 1; g.rcd = i % nRcd + 1;
g.phase = Array.from({ length: n }, (_, j) => 'L' + ((ph + j) % 3 + 1)).join('+');
ph += n;
});
out.rcdCount = out.groups.length ? nRcd : 0;
const groupOf = (kind, unitId) => out.groups.find(g => g.kind === kind && g.unitId === unitId);
// Buitenlicht zit in de schakeldoos van het binnenlicht: zelfde aardlek.
const gOut = out.groups.find(g => g.kind === 'buiten'), gIn = gOut && groupOf('licht', gOut.unitId);
if (gOut && gIn) gOut.rcd = gIn.rcd;
for (let guard = 0; guard < 20; guard++) {
const cnt = Array.from({ length: nRcd }, (_, i) => out.groups.filter(g => g.rcd === i + 1).length);
const over = cnt.findIndex(c => c > 4);
if (over < 0) break;
const mv = out.groups.slice().reverse().find(g => g.rcd === over + 1 && g !== gOut && g !== gIn);
if (!mv) break;
mv.rcd = cnt.indexOf(Math.min(...cnt)) + 1;
}
out.switches.forEach(sw => { const g = groupOf(sw.func === 'buiten' ? 'buiten' : 'licht', sw.unitId); sw.group = g ? g.nr : null; });
out.wcds.forEach(w => { const g = groupOf('wcd', w.unitId); w.group = g ? g.nr : null; });
out.floorWcds.forEach(w => { const g = groupOf('wcd', w.unitId); w.group = g ? g.nr : null; });
out.apps.forEach(a => { const g = out.groups.find(x => x.kind === 'app' && x.app === a.app); a.group = g ? g.nr : null; });
const mkNode = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY + mk.h, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null;
const mkLow = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null;
const addRun = (type, a, b, group, letter) => {
const pts = R.path(a, b);
const raw = R.length(pts);
out.runs.push({ type, group, letter: letter || null, from: a.name || '', to: b.name || '', path: pts, mk: !!(a.mk || b.mk), outdoor: a.mount === 'buiten' || b.mount === 'buiten' });
};
const tree = (root, nodes, typeOf, group, letter) => {
const done = [root], rest = nodes.slice();
while (rest.length) {
let best = null;
rest.forEach((n, i) => done.forEach(t => { const c = R.cost(t, n); if (!best || c < best.c) best = { c, i, t }; }));
const n = rest.splice(best.i, 1)[0];
addRun(typeOf(best.t, n), best.t, n, group, letter);
done.push(n);
}
};
for (const combo of out.combos) {
const byGroup = [];
combo.switches.forEach(sw => {
let e = byGroup.find(x => x.group === sw.group);
if (!e) byGroup.push(e = { group: sw.group, switches: [] });
e.switches.push(sw);
});
combo.vds = [];
const along = { x: combo.face.b.x - combo.face.a.x, z: combo.face.b.z - combo.face.a.z }, al = Math.hypot(along.x, along.z) || 1;
byGroup.forEach((e, i) => {
const shift = (i - (byGroup.length - 1) / 2) * VD_OFFSET;
const vd = {
x: combo.pos.x + combo.face.n.x * VD_OFFSET + along.x / al * shift, z: combo.pos.z + combo.face.n.z * VD_OFFSET + along.z / al * shift,
mount: 'plafond', name: `VD${combo.id}${byGroup.length > 1 ? String.fromCharCode(97 + i) : ''}`
};
vd.y = R.ceilAt(vd.x, vd.z);
combo.vds.push(vd);
out.boxes.push({ kind: 'verdeeldoos', combo: combo.id, group: e.group, x: vd.x, y: vd.y, z: vd.z, unitId: combo.unitId });
if (mkNode) addRun('licht', mkNode, vd, e.group);
const uSw = e.switches.reduce((t, x) => t + x.u, 0) / e.switches.length;
const swNode = Object.assign(facePoint(combo.face, uSw, combo.y, 0), { mount: 'wand', face: combo.face, name: e.switches.map(x => x.letter).join('') });
addRun('schakel' + (e.switches.length + 1), vd, swNode, e.group);
e.switches.forEach(sw => tree(vd, sw.lamps.map((l, k) => Object.assign(l, { name: `${sw.letter}${k + 1}` })),
(a, b) => a.mount === 'buiten' || b.mount === 'buiten' ? 'buiten' : 'licht', e.group, sw.letter));
});
}
out.groups.filter(g => g.kind === 'wcd').forEach(g => {
const nodes = out.wcds.filter(w => w.group === g.nr).map((w, i) => Object.assign({}, w.pos, { mount: 'wand', face: w.face, name: `WCD${g.nr}.${i + 1}` }))
.concat(out.floorWcds.filter(w => w.group === g.nr).map((w, i) => ({ x: w.x, y: w.y, z: w.z, mount: 'vloer', name: `VWCD${g.nr}.${i + 1}` })));
if (mkLow && nodes.length) tree(mkLow, nodes, () => 'wcd', g.nr);
});
if (mkLow) out.apps.forEach(a => addRun(APPLIANCES[a.app].cable, mkLow, Object.assign({}, a.pos, { mount: 'wand', face: a.face, name: a.code }), a.group));
separateRuns(out.runs, blocked);
const holesOf = makeHoleCounter(units, R);
out.runs.forEach(r => {
r.rawMM = R.length(r.path);
r.lengthMM = (r.rawMM + s.reserve + (r.mk ? s.meterkastReserve : 0)) * (1 + s.slack / 100);
r.crossings = R.crossings(r.path);
r.holes = holesOf(r);
});
out.holes = out.runs.reduce((a, r) => a + r.holes, 0);
const byType = {};
out.runs.forEach(r => { byType[r.type] = (byType[r.type] || 0) + r.lengthMM; });
out.cables = Object.keys(byType).sort().map(k => ({ key: k, label: cableInfo(k).label, use: cableInfo(k).use, meters: byType[k] / 1000 }));
const wires = {}, conduits = {};
out.runs.forEach(r => {
const c = cableInfo(r.type);
if (!c.conduit) return;
conduits[c.conduit] = (conduits[c.conduit] || 0) + r.lengthMM;
c.cores.forEach(core => {
const k = `${String(c.mm2).replace('.', ',')} mm² ${CORE_COLORS[core]}`;
wires[k] = (wires[k] || 0) + r.lengthMM;
});
});
out.wires = Object.keys(wires).sort().map(k => ({ label: `VD-draad ${k}`, meters: wires[k] / 1000 }));
out.conduits = Object.keys(conduits).sort((a, b) => a - b).map(k => ({ label: `Flexibele installatiebuis Ø${k} mm`, meters: conduits[k] / 1000 }));
out.groups.forEach(g => {
const rs = out.runs.filter(r => r.group === g.nr);
g.meters = rs.reduce((a, r) => a + r.lengthMM, 0) / 1000;
g.points = g.kind === 'wcd' ? out.wcds.filter(w => w.group === g.nr).length
: g.kind === 'app' ? 1
: out.switches.filter(x => x.group === g.nr).reduce((a, x) => a + x.lamps.length, 0);
});
out.groups.forEach(g => {
if ((g.kind === 'licht' || g.kind === 'buiten') && g.points > 50) out.warnings.push(`Groep ${g.nr} (${g.label}): ${g.points} lichtpunten; controleer het vermogen (LED).`);
});
const crossBy = {};
out.runs.forEach(r => { if (r.crossings) { const n = cableInfo(r.type).cores.length; crossBy[n] = (crossBy[n] || 0) + r.crossings; } });
const nIndoor = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'binnenspot').length, 0);
const nSpots = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'boeideelspot').length, 0);
const nGevel = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'gevellamp').length, 0);
const nLicht = out.groups.filter(g => g.kind === 'licht').length, nWcdG = out.groups.filter(g => g.kind === 'wcd').length;
const frames = {};
out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; });
const m = out.materials;
const add = (naam, aantal, eenheid) => { if (aantal > 0) m.push({ naam, aantal, eenheid: eenheid || 'st.' }); };
const kleur = ', ' + PLATE_COLORS[s.plateColor].label.toLowerCase();
if (out.meterkast) {
add('Groepenkast 3-fase (aansluiting 3×25 A)', 1);
add('Hoofdschakelaar 4-polig 40 A', 1);
}
add('Aardlekschakelaar 2-polig 40 A / 30 mA', out.rcdCount);
add('Installatieautomaat B10 1P+N (verlichting)', nLicht);
add('Installatieautomaat B10 1P+N (buitenverlichting)', out.groups.filter(g => g.kind === 'buiten').length);
add('Installatieautomaat B16 1P+N (wcd)', nWcdG);
add('Installatieautomaat B16 1P+N (apparaten)', out.apps.filter(a => (APPLIANCES[a.app].poles || 1) === 1).length);
add('Installatieautomaat 2× B16, 2-fase (kookgroep)', out.apps.filter(a => APPLIANCES[a.app].poles === 2).length);
add('Schakelaar enkelpolig (inbouw)' + kleur, out.switches.length);
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig` + kleur, frames[n]));
add('Wandcontactdoos dubbel met randaarde' + kleur, out.wcds.length);
add('Vloerdoos met dubbele wcd (randaarde)' + kleur, out.floorWcds.length);
const appMat = {};
out.apps.forEach(a => { const n = APPLIANCES[a.app].material; appMat[n] = (appMat[n] || 0) + 1; });
Object.keys(appMat).forEach(n => add(n + kleur, appMat[n]));
add('Luchtdichte hollewanddoos (schakelaar/wcd/apparaat)', out.switches.length + 2 * out.wcds.length + out.apps.length);
add('Luchtdichte verdeeldoos (plafond)', out.boxes.length);
add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length);
add('LED-inbouwspot binnen + luchtdichte spotdoos', nIndoor);
add('Buitenspot boeideel (IP44)', nSpots);
add('Gevellamp (IP44)', nGevel);
add('Luchtdichte kabeldoorvoer naar buiten', out.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && r.to !== '' && !r.to.startsWith('VD')).length);
Object.keys(crossBy).forEach(n => add(`Steekverbinder module-naad ${n}-polig (set m/v)`, crossBy[n]));
const item = (key, kind, label, f, u, y, manual, off, unitId) => out.items.push({
key, kind, label, unitId: unitId || f.unit.id, element: f.piece.element, side: f.side, u, y, manual: !!manual, marker: facePoint(f, u, 0, off)
});
const k = out.meterkast;
if (k) item('mk', 'mk', 'Meterkast', k.face, k.u, k.y, k.manual, k.d / 2);
out.combos.forEach(c => item(c.key, 'sw', 'Schakelaar ' + c.switches.map(x => x.letter).join(' '), c.face, c.u, c.y, c.manual, 260, c.unitId));
out.wcds.forEach(w => item(w.key, 'wcd', `Wcd ${w.key.split(':')[2]} unit ${w.unitId} (groep ${w.group})`, w.face, w.u, w.y, w.manual, 120, w.unitId));
out.apps.forEach(a => item(a.key, 'app', `${a.label} (groep ${a.group})`, a.face, a.u, a.y, a.manual, 150));
out.floorWcds.forEach(w => {
const u = units.find(x => x.id === w.unitId);
out.items.push({ key: w.key, kind: 'vwcd', label: `Vloer-wcd unit ${w.unitId} (groep ${w.group})`, unitId: w.unitId, x: w.x - u.rect.x0, z: w.z - u.rect.z0, manual: true, marker: { x: w.x, z: w.z } });
});
return out;
}
// Uitzondering: het laatste stuk naar de meterkast; daar komen de buizen tegen elkaar aan de kast binnen.
function separateRuns(runs, blocked) {
const add = (p, d, k) => Object.assign({}, p, { x: p.x + d.x * k, y: p.y + d.y * k, z: p.z + d.z * k });
const axisOf = (p, q) => {
const dx = Math.abs(q.x - p.x), dy = Math.abs(q.y - p.y), dz = Math.abs(q.z - p.z);
return dx >= dy && dx >= dz ? 'x' : dz >= dy ? 'z' : 'y';
};
const lenOf = (p, q) => Math.hypot(q.x - p.x, q.y - p.y, q.z - p.z);
const seg = (run, p, q, r) => {
const axis = axisOf(p, q), perp = {};
['x', 'y', 'z'].filter(k => k !== axis).forEach(k => { perp[k] = (p[k] + q[k]) / 2; });
return { run, axis, lo: Math.min(p[axis], q[axis]), hi: Math.max(p[axis], q[axis]), perp, r };
};
const placed = [];
const conflict = (sg) => placed.some(t => t.run !== sg.run && t.axis === sg.axis && Math.min(t.hi, sg.hi) - Math.max(t.lo, sg.lo) > 1 &&
Math.hypot(...Object.keys(sg.perp).map(k => sg.perp[k] - t.perp[k])) < CLEAR + sg.r + t.r - 0.5);
const faceDir = (f) => { const L = Math.hypot(f.b.x - f.a.x, f.b.z - f.a.z) || 1; return { x: (f.b.x - f.a.x) / L, y: 0, z: (f.b.z - f.a.z) / L }; };
const shiftDir = (p, q, axis) => {
const plane = (p.ceil || p.floor) && (q.ceil || q.floor);
if (axis === 'y') { const f = p.wall || q.wall; return f ? faceDir(f) : { x: 1, y: 0, z: 0 }; }
if (!plane && (p.wall || q.wall)) return { x: 0, y: 1, z: 0 };
return axis === 'x' ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
};
const conduit = runs.filter(r => cableInfo(r.type).conduit > 0);
const radius = (r) => cableInfo(r.type).conduit / 2;
const bundles = {};
conduit.filter(r => r.mk && r.path[0].wall).forEach(r => {
const k = Math.round(r.path[0].y);
(bundles[k] = bundles[k] || []).push(r);
});
Object.values(bundles).forEach(list => {
const d = faceDir(list[0].path[0].wall);
const along = (r) => { const q = r.path[Math.min(2, r.path.length - 1)]; return (q.x - r.path[0].x) * d.x + (q.z - r.path[0].z) * d.z; };
list.sort((a, b) => along(a) - along(b));
const total = list.reduce((a, r) => a + 2 * radius(r), 0);
let off = -total / 2;
list.forEach(r => {
const o = off + radius(r);
off += 2 * radius(r);
const orig = r.path, dAx = Math.abs(d.x) > Math.abs(d.z) ? 'x' : 'z';
const out = orig.map(p => p);
out[0] = add(orig[0], d, o);
for (let k = 0; k < orig.length - 1; k++) {
const len = lenOf(orig[k], orig[k + 1]);
if (len >= 1 && axisOf(orig[k], orig[k + 1]) === dAx) break;
if (k + 1 === orig.length - 1) { out.splice(k + 1, 0, add(orig[k + 1], d, o)); break; }
out[k + 1] = add(orig[k + 1], d, o);
}
r.path = out;
placed.push(seg(r, out[0], out[1], radius(r)));
r.fixed = 1;
});
});
conduit.forEach(r => {
const rad = radius(r), step = CLEAR + 2 * rad;
let pts = r.path;
for (let i = r.fixed || 0; i < pts.length - 1; i++) {
const p = pts[i], q = pts[i + 1];
if (lenOf(p, q) < 1) continue;
const sg = seg(r, p, q, rad);
if (!conflict(sg)) { placed.push(sg); continue; }
const dir = shiftDir(p, q, sg.axis);
// Knikken tellen mee, behalve direct aan een doos/lamp en aan het eind van de meterkastbundel.
const freeStart = i === 0 || (r.fixed && i === r.fixed), freeEnd = i + 1 === pts.length - 1;
let done = false;
for (let k = 1; k <= 12 && !done; k++) {
for (const sign of [1, -1]) {
const kk = sign * k * step, p2 = add(p, dir, kk), q2 = add(q, dir, kk), s2 = seg(r, p2, q2, rad);
const j1 = seg(r, p, p2, rad), j2 = seg(r, q2, q, rad);
if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue;
if (blocked && (blocked(p2, q2) || blocked(p, p2) || blocked(q2, q))) continue;
pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1));
placed.push(s2, j1, j2);
i += 2;
done = true;
break;
}
}
if (!done) placed.push(sg);
}
r.path = simplifyPath(pts);
});
runs.forEach(r => { delete r.fixed; });
}
function simplifyPath(pts) {
const same = (a, b) => Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.z - b.z) < 0.5;
let out = pts.filter((p, i) => i === 0 || !same(p, pts[i - 1]));
let changed = true;
while (changed) {
changed = false;
for (let i = 1; i < out.length - 1; i++) {
if (i + 1 < out.length && same(out[i - 1], out[i + 1])) { out.splice(i, 2); changed = true; break; }
}
}
return out;
}
function snapToWall(el, x, z) {
let best = null;
((el && el.faces) || []).forEach(f => {
const u = Math.max(f.u0, Math.min(f.u1, uAt(f, { x, z })));
const q = facePoint(f, u, 0, 0);
if ((x - q.x) * f.n.x + (z - q.z) * f.n.z < -(f.piece.place.thickness + 50)) return;
const d = Math.hypot(q.x - x, q.z - z);
if (!best || d < best.d) best = { d, element: f.piece.element, side: f.side, u: Math.round(u / 10) * 10, x: q.x, z: q.z, n: f.n };
});
return best;
}
// Kabel zonder buis (XMvK buiten) en losse draad worden niet getekend.
const WALL_IN = 35;
const CEIL_UP = 35;
const CONDUIT_COLORS = { licht: 0xe0b020, wcd: 0xcc4a30, koken: 0x8a3a9a, schakel: 0x9a6a20 };
function createMaterials() {
const conduit = {};
Object.keys(CONDUIT_COLORS).forEach(k => { conduit[k] = new THREE.MeshStandardMaterial({ color: CONDUIT_COLORS[k], roughness: 0.55 }); });
return {
kastMaterial: new THREE.MeshStandardMaterial({ color: 0xd8d8d2, roughness: 0.6 }),
deurMaterial: new THREE.MeshStandardMaterial({ color: 0xc4c4bd, roughness: 0.5 }),
plateMaterial: new THREE.MeshStandardMaterial({ color: 0xf6f6f2, roughness: 0.4 }),
boxMaterial: new THREE.MeshStandardMaterial({ color: 0xe8742a, roughness: 0.6 }),
conduit
};
}
// rough: buizen en inbouwdozen (in de wand en boven het plafond); uit bij de render-export.
function build3D(el, materials, layers, rough) {
const toM = 0.001, group = new THREE.Group();
if (!el || !el.enabled) return group;
const mats = materials || createMaterials();
mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex);
if (!ViewLayers.shows(layers, 'elektra')) return group;
const box = (w, h, d, c, rotY, mat) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
mesh.position.set(c.x * toM, c.y * toM, c.z * toM);
mesh.rotation.y = rotY;
group.add(mesh);
return mesh;
};
const k = el.meterkast;
if (k) {
box(k.w, k.h, k.d, k.center, k.rotY, mats.kastMaterial);
const front = facePoint(k.face, k.u, k.y + k.h / 2, k.d + 2);
box(k.w - 30, k.h - 30, 4, front, k.rotY, mats.deurMaterial);
}
if (ViewLayers.shows(layers, 'afwerking')) {
el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial));
el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial));
el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial));
el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial));
}
if (rough === false) return group;
const up = new THREE.Vector3(0, 1, 0);
const shift = (p) => ({
x: p.x - (p.wall ? p.wall.n.x * WALL_IN : 0), y: p.y + (p.ceil ? CEIL_UP : 0), z: p.z - (p.wall ? p.wall.n.z * WALL_IN : 0)
});
el.runs.forEach((r, ri) => {
const c = cableInfo(r.type);
if (!c.conduit) return;
const rad = c.conduit / 2 * toM;
const mat = mats.conduit[r.type] || mats.conduit.schakel;
const pts = r.path.map(shift);
pts.slice(1).forEach((q, i) => {
const p = pts[i], dx = q.x - p.x, dy = q.y - p.y, dz = q.z - p.z, len = Math.hypot(dx, dy, dz);
if (len < 1) return;
const m = new THREE.Mesh(new THREE.CylinderGeometry(rad, rad, len * toM, 6), mat);
m.position.set((p.x + q.x) / 2 * toM, (p.y + q.y) / 2 * toM, (p.z + q.z) / 2 * toM);
m.quaternion.setFromUnitVectors(up, new THREE.Vector3(dx / len, dy / len, dz / len));
m.userData.run = ri;
group.add(m);
});
pts.slice(1, -1).forEach(p => {
const j = new THREE.Mesh(new THREE.IcosahedronGeometry(rad * 1.15, 0), mat);
j.position.set(p.x * toM, p.y * toM, p.z * toM);
j.userData.run = ri;
group.add(j);
});
});
const wallBox = (f, u, y) => {
const c = facePoint(f, u, y, -22);
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.034, 0.034, 0.045, 12), mats.boxMaterial);
m.position.set(c.x * toM, c.y * toM, c.z * toM);
m.quaternion.setFromUnitVectors(up, new THREE.Vector3(f.n.x, 0, f.n.z));
group.add(m);
};
el.switches.forEach(sw => wallBox(sw.combo.face, sw.u, sw.combo.y));
el.wcds.forEach(w => { wallBox(w.face, w.u - 36, w.y); wallBox(w.face, w.u + 36, w.y); });
el.apps.forEach(a => wallBox(a.face, a.u, a.y));
el.floorWcds.forEach(w => box(100, 80, 100, { x: w.x, y: w.y - 40, z: w.z }, 0, mats.boxMaterial));
el.boxes.forEach(b => box(90, 50, 90, { x: b.x, y: b.y + CEIL_UP + 10, z: b.z }, 0, mats.boxMaterial));
el.switches.forEach(sw => sw.lamps.filter(l => l.kind === 'binnenspot').forEach(l => {
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.07, 12), mats.boxMaterial);
m.position.set(l.x * toM, (l.y + 18 + 35) * toM, l.z * toM);
group.add(m);
}));
return group;
}
const wallCoords = (f, p) => ({ u: uAt(f, p), y: p.y - f.piece.place.floorTop });
function clipPlan(p, q, b) {
let t0 = 0, t1 = 1;
for (const [k, lo, hi] of [['x', b.x0, b.x1], ['z', b.z0, b.z1]]) {
const d = q[k] - p[k];
if (Math.abs(d) < 1e-9) { if (p[k] < lo - 0.5 || p[k] > hi + 0.5) return null; continue; }
let a = (lo - p[k]) / d, c = (hi - p[k]) / d;
if (a > c) [a, c] = [c, a];
t0 = Math.max(t0, a); t1 = Math.min(t1, c);
if (t0 > t1) return null;
}
const at = (t) => Object.assign({}, p, { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t, z: p.z + (q.z - p.z) * t });
return [at(t0), at(t1)];
}
// Zelfde omrekening als de doorvoeren in RoofModel.computeUnitRoof: gelijk houden.
function roofDeckMap(unit, pl) {
const alongX = Math.abs(Math.sin(pl.yaw)) > 0.5, rr = unit.roof.rect, r = unit.rect;
const qOf = (p) => alongX ? p.x - r.cx : p.z - r.cz;
return {
bounds: alongX
? { x0: Math.max(r.x0, r.cx + pl.p0), x1: Math.min(r.x1, r.cx + pl.p1), z0: r.z0, z1: r.z1 }
: { x0: r.x0, x1: r.x1, z0: Math.max(r.z0, r.cz + pl.p0), z1: Math.min(r.z1, r.cz + pl.p1) },
map: (p) => ({ sx: alongX ? rr.z1 - p.z : p.x - rr.x0, sy: (qOf(p) - pl.p0) / pl.cos })
};
}
const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl'];
function makeHoleCounter(units, R) {
const boms = new Map();
const bomOf = (piece) => {
if (!boms.has(piece)) boms.set(piece, piece.frame ? WallModel.computeBOM(piece.config, piece.frame) : []);
return boms.get(piece);
};
const between = (v, a, b) => v > Math.min(a, b) + 1 && v < Math.max(a, b) - 1;
const joistsBetween = (cd, a, b) => cd.joists.filter(j => between(j + cd.width / 2, a, b)).length;
return (run) => {
if (!cableInfo(run.type).conduit) return run.type === 'buiten' && run.from.startsWith('VD') ? 1 : 0;
let n = 0;
run.path.slice(1).forEach((q, i) => {
const p = run.path[i];
const vertical = Math.abs(q.y - p.y) > Math.hypot(q.x - p.x, q.z - p.z);
if (p.wall && q.wall && p.wall.piece === q.wall.piece && !(p.ceil && q.ceil)) {
const f = p.wall, a = wallCoords(f, p), b = wallCoords(f, q), bom = bomOf(f.piece);
if (vertical) {
const x = a.u - f.W / 2;
n += bom.filter(bp => !VERTICAL_KINDS.includes(bp.kind) && Math.abs(x - bp.x) <= bp.length / 2 && between(bp.y, a.y, b.y)).length;
if (p.floor !== q.floor) n += 1;
} else {
n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length;
}
} else if (!vertical && p.ceil && q.ceil && !(p.plenum && q.plenum)) {
const mid = { x: (p.x + q.x) / 2, z: (p.z + q.z) / 2 }, u = R.unitAt(mid.x, mid.z);
if (u) u.roof.planes.forEach(pl => {
const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds);
if (c) n += joistsBetween(pl.data.calcData, dm.map(c[0]).sx, dm.map(c[1]).sx);
});
} else if (!vertical && p.floor && q.floor) {
const u = R.unitAt((p.x + q.x) / 2, (p.z + q.z) / 2);
const c = u && clipPlan(p, q, u.rect);
if (c) n += joistsBetween(u.floorData.calcData, c[0].x - u.rect.x0, c[1].x - u.rect.x0);
} else if (vertical && (p.floor || q.floor) && !(p.wall || q.wall)) n += 1;
});
return n;
};
}
return { DEFAULT_SETTINGS, PLATE_COLORS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D };
})();