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

717 lines
42 KiB
JavaScript
Raw 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.
*/
// ============================================================
// elektra/elektra-model.js — ElektraModel: ENIGE bron van de elektra-
// installatie van een woning (eerste versie, automatisch). Geen DOM.
//
// Woning-breed (settings.elektra). Alles wordt afgeleid uit de woning:
// - Meterkast (3×25 A, 3-fase) als kast aan de binnenzijde van een wand
// in de unit met de voordeur, zo dicht mogelijk bij de voordeur, vrij van
// openingen (incl. raamwerk) en van de schakelaars.
// - Schakelaarcombinatie naast de voordeur (binnenzijde, 1500 mm), aan de
// slotzijde (deur draait links, KozijnModel; anders de scharnierzijde):
// A = binnenlicht van die unit, B = buitenlicht (boeideel-spots en
// gevellampen). Elke andere unit krijgt een schakelaar naast de deur
// waardoor je hem binnenkomt (naadwand, anders een buitendeur), C, D, ...
// Elke lamp/spot krijgt de letter van zijn schakelaar (p.letter).
// - Wandcontactdozen (dubbel, 400 mm): wcdPerUnit per unit, zo ver mogelijk
// uit elkaar langs de binnenzijde van de wanden.
// - Apparaten (settings.apparaten, APPLIANCES) met elk een eigen groep:
// kookgroep (perilex, 2-fase, 5×2,5), wasmachine, droger, warmtepomp,
// optioneel oven en vaatwasser. Keukenapparaten bij elkaar in de grootste
// andere unit, de rest naast de meterkast (nog geen keuken in het model).
// - Handmatig verplaatsen (settings.place[key] = { element, side, u, y }):
// key 'mk' (meterkast), 'sw:<unit>' (schakelaars), 'wcd:<unit>:<nr>',
// 'app:<apparaat>'. element = wandstuk, side = -1 (kamerzijde; bij een
// binnenwand ±1), u = hart langs de wand (wand-x), y = hoogte (optioneel).
// Handmatige plekken gaan voor: de automatische plaatsing wijkt uit.
// Wandvlakken: binnenzijde van buitenwanden en naadwanddelen, beide
// zijden van binnenwanden (in de unit waar hun midden ligt).
// - Groepen: per unit een lichtgroep (B10, 1,5 mm²) en een wcd-groep
// (B16, 2,5 mm²); het buitenlicht op een eigen groep (B10, XMvK 3×1,5),
// op dezelfde aardlek als het binnenlicht van de voordeur-unit (zelfde
// schakeldoos). Fasen L1/L2/L3 om en om; aardlekschakelaars (30 mA,
// max. 4 groepen elk, minstens 2) om en om, zodat licht en wcd van één
// unit niet op dezelfde aardlek zitten.
//
// Leidingen (HSB): binnen VD-draad in flexibele installatiebuis, via de
// stijlen (zelfde wand) of via het plafond (andere wand/plafondpunt): eerst
// verticaal in de wand naar het plafond, dan haaks (x, dan z) naar het
// volgende punt. Per schakeldoos en groep een luchtdichte verdeeldoos in het
// plafond erboven (groepen niet gemengd in één verdeeldoos); vandaar de schakeldraden omlaag en de lampen doorgelust volgens
// een minimale opspannende boom (kortste totale lengte). Buitenverlichting in
// XMvK. Een leiding over een naad tussen units krijgt een steekverbinder
// (modules worden los gebouwd en op de bouwplaats gekoppeld).
// Elke leiding: + reserve (lus in de doos, meterkast extra) en + slack %.
//
// Plain script, geladen na lighting-model.js en unit-model.js.
// ============================================================
window.ElektraModel = (() => {
const DEFAULT_SETTINGS = {
enabled: true,
meterkast: { w: 600, h: 900, d: 250, y: 1000 }, // mm; y = onderkant boven vloer
switchHeight: 1500, // hart schakelaar boven vloer
wcdHeight: 400, // hart wcd boven vloer
wcdPerUnit: 2, // dubbele wcd per unit
slack: 10, // % extra op elke leiding
reserve: 300, // mm per leiding (lussen in de dozen)
meterkastReserve: 500, // mm extra per leiding vanuit de meterkast
apparaten: { koken: true, wasmachine: true, droger: true, warmtepomp: true, oven: false, vaatwasser: false },
place: {} // handmatige plekken (zie boven)
};
// Apparaten met een eigen groep. code = label op de plattegrond (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 BOX_PITCH = 71; // h.o.h. inbouwdozen in een combinatie
const PLATE = 80; // afdekraam (vierkant)
const WCD_W = 150; // afdekraam dubbele wcd
const DOOR_GAP = 100; // kozijnstijl -> hart eerste doos (bovenop de stijl)
const VD_OFFSET = 150; // verdeeldoos: zoveel de kamer in vanaf het wandvlak
// Leidingtypen. cores: aders (kleur via CORE_COLORS); conduit: buis-Ø (mm),
// 0 = kabel zonder buis.
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 }
};
// Schakeldraden verdeeldoos -> schakeldoos: fase + één per schakelaar.
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.enabled = s.enabled !== false;
// 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;
}
// ------------------------------------------------------------
// Wandvlakken (binnenzijde) waarop iets gemonteerd kan worden.
// ------------------------------------------------------------
// Zijde van een wandstuk (side -1 = lokaal -Z: bij buitenwand en
// naadwanddeel de kamerkant; binnenwand: beide zijden), begrensd tot de
// binnenruimte van de unit.
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) }, // naar de kamer
side,
sw: p.config.studWidth || 45,
reserved: [] // [{ a, b, y0, y1 }] al bezet (meterkast, schakelaars, wcd)
};
}
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);
}
// Punt op een wandvlak: u langs de wand (0..W), y boven de vloer, 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;
// Vrije stukken van een vlak tussen hoogte y0..y1: openingen (incl.
// raamwerk + margin) en wat al bezet is eraf, edge vrij van de uiteinden.
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);
}
// Dichtstbijzijnde vrije plek (breedte w) op een van de vlakken bij target
// (wereld {x, z}). Geeft { f, u, d } of null.
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;
}
// Schakelaarcombinatie van n dozen naast opening op op vlak f: eerst de
// slotzijde (rechts van de opening in wand-x), dan de scharnierzijde.
// Geeft de doos-centra (u, dichtst bij de deur eerst) of null.
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;
}
// ------------------------------------------------------------
// Routes. Knooppunt: { x, y, z, mount: 'wand'|'plafond'|'buiten', face?, mk? }.
// ------------------------------------------------------------
function makeRouter(units) {
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;
const ceilAt = (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)));
};
// Pad als 3D-punten (mm). Per punt: wall = wandvlak waar het in de wand
// ligt, ceil = boven het plafond (voor de buizen in de 3D, build3D).
function path(a, b) {
const P = (p, wall, ceil) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil });
if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)]; // door de stijlen
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a), P({ x: b.x, y: a.y, z: a.z }), P(b)];
const top = (p) => p.mount === 'plafond' ? p.y : ceilAt(p.x, p.z);
const ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1;
const pts = [P(a, a.face, a.mount === 'plafond')];
if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a.x, y: ya, z: a.z }, a.face, true));
pts.push(P({ x: b.x, y: ceilAt(b.x, a.z), z: a.z }, null, true));
pts.push(P({ x: b.x, y: yb, z: b.z }, dropB ? b.face : null, true));
if (dropB) pts.push(P(b, b.face, false));
return 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);
// Snelle schatting (voor de boom): haaks + hoogteverschil.
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
// Aantal keer dat het pad (in plattegrond) van de ene unit naar een andere gaat.
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, path, length, cost, crossings };
}
// ------------------------------------------------------------
// Alles voor de hele woning. units/interior = HouseModel-parts,
// lighting = parts.lighting (lamps, indoor), roofEdge = parts.roofEdge.
// Zet p.letter op elke lamp/spot. Geeft het elektra-resultaat (zie onder).
// ------------------------------------------------------------
function compute(settings, units, interior, lighting, roofEdge) {
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: [], boxes: [],
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
};
if (!s.enabled || !units.length) return out;
const R = makeRouter(units);
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]), []);
// 1. Voordeur (anders de eerste buitendeur, anders de eerste unit).
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;
// Handmatige plekken eerst vastleggen (de automatische wijken uit).
const comboWidth = (n) => PLATE + (n - 1) * BOX_PITCH;
const widthOf = (key) => {
if (key === 'mk') return mk.w;
let m = /^sw:(.+)$/.exec(key);
if (m) return units.some(u => u.id === m[1]) ? comboWidth(m[1] === mainUnit.id ? mainFuncs.length : 1) : 0;
m = /^wcd:(.+):(\d+)$/.exec(key);
if (m) return units.some(u => u.id === m[1]) && Number(m[2]) <= s.wcdPerUnit ? 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);
// 2. Schakelaars bij de voordeur (A binnen, B buiten).
const addCombo = (unit, f, us, funcs, y, manual) => {
const yy = y === null || y === undefined ? s.switchHeight : y;
const combo = { id: out.combos.length + 1, 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 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];
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) {
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 };
// 3. Meterkast: zo dicht mogelijk bij de voordeur, eerst in die unit.
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.');
// 4. Schakelaar per andere unit, naast de deur waardoor je hem binnenkomt.
units.forEach(u => {
if (u === mainUnit) 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) }); // naadwand-deur eerst
}));
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 : '-'}.`);
});
// 5. Lampen aan schakelaars koppelen (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];
indoor.forEach(p => { const sw = 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 }); } });
// 6. Wandcontactdozen per unit (zo ver mogelijk uit elkaar).
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 = [];
for (let i = 1; i <= s.wcdPerUnit; i++) {
const key = `wcd:${u.id}:${i}`, fx = fixed[key];
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 < s.wcdPerUnit) out.warnings.push(`Unit ${u.id}: ruimte voor ${chosen.length} van ${s.wcdPerUnit} wcd's.`);
chosen.sort((a, b) => a.key < b.key ? -1 : 1).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)
}));
});
// 6b. Apparaten: keuken bij elkaar in de grootste andere unit, de rest
// naast de meterkast.
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) });
});
// 7. Groepen: per unit licht (B10) + wcd (B16); fasen en aardlek om en om.
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.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;
// Max. 4 groepen per aardlek: anders een andere groep (niet die twee)
// naar de aardlek met de minste groepen.
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.apps.forEach(a => { const g = out.groups.find(x => x.kind === 'app' && x.app === a.app); a.group = g ? g.nr : null; });
// 8. Leidingen.
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);
const lengthMM = (raw + s.reserve + (a.mk || b.mk ? s.meterkastReserve : 0)) * (1 + s.slack / 100);
out.runs.push({ type, group, letter: letter || null, from: a.name || '', to: b.name || '', path: pts, rawMM: raw, lengthMM, crossings: R.crossings(pts), outdoor: a.mount === 'buiten' || b.mount === 'buiten' });
};
// Minimale opspannende boom vanaf root (Prim, op de snelle schatting).
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);
}
};
// Per schakeldoos één verdeeldoos per groep (naast elkaar langs de wand).
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); // voeding binnen: VD in buis
const swNode = Object.assign({}, combo.pos, { 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}` }));
if (mkLow && nodes.length) tree(mkLow, nodes, () => 'wcd', g.nr);
});
// Apparaten: elk een eigen leiding vanuit de meterkast.
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));
// 9. Totalen: per leidingtype, draad per doorsnede/kleur, buis, groep.
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).`);
});
// 10. Materiaal (aantallen).
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.' }); };
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)', out.switches.length);
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig`, frames[n]));
add('Wandcontactdoos dubbel met randaarde', out.wcds.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, 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]));
// 11. Verplaatsbare onderdelen (plattegrond: slepen, selecteren).
// marker = plek van het symbool in de plattegrond (iets de kamer in).
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));
return out;
}
// Dichtstbijzijnde wandvlak (kamerzijde) bij plattegrondpunt (x, z), voor
// het slepen: { element, side, u, x, z, n } of null.
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; // aan de andere kant van de wand
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;
}
// ------------------------------------------------------------
// 3D per bouwfase (BuildStages):
// elektra meterkast (blijft), installatiebuizen (zeskant, kleur per
// leidingtype) in de wanden en boven het plafond, luchtdichte
// inbouwdozen, verdeeldozen en spotdozen. Vanaf "beplating"
// zitten buizen en dozen achter de platen: niet meer gebouwd.
// afwerking afdekramen van schakelaars, wcd's en apparaten.
// Kabel zonder buis (XMvK buiten) en losse draad worden niet getekend.
// ------------------------------------------------------------
const WALL_IN = 35; // buis: zoveel achter het wandvlak (in de stijlruimte)
const CEIL_UP = 35; // buis: zoveel boven de onderkant plafond (op de plafondplaat)
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
};
}
function build3D(el, materials, stage) {
const toM = 0.001, group = new THREE.Group();
if (!el || !el.enabled) return group;
const mats = materials || createMaterials();
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key);
const rough = show('elektra') && !show('beplating');
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 && show('elektra')) {
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 (show('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));
}
if (!rough) return group;
// Installatiebuizen: zeskantige cilinders per padstuk + bolletje op elke knik.
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 => {
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));
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);
group.add(j);
});
});
// Luchtdichte inbouwdozen (schakelaars, wcd, apparaten): in de wand.
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));
// Verdeeldozen en spotdozen boven het plafond.
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;
}
return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, cableInfo, normalizeSettings, compute, snapToWall, createMaterials, build3D };
})();