869 lines
48 KiB
JavaScript
869 lines
48 KiB
JavaScript
/*!
|
||
* 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,
|
||
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 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;
|
||
// 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;
|
||
}
|
||
|
||
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)));
|
||
};
|
||
const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; };
|
||
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 });
|
||
if (a.mount === 'vloer' || b.mount === 'vloer') {
|
||
const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH;
|
||
return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true), P({ x: b.x, y: fa, z: a.z }, null, false, true),
|
||
P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')];
|
||
}
|
||
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)];
|
||
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);
|
||
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 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]), []);
|
||
|
||
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);
|
||
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.' }); };
|
||
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);
|
||
add('Vloerdoos met dubbele wcd (randaarde)', 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, 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) {
|
||
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;
|
||
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
|
||
};
|
||
}
|
||
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));
|
||
el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial));
|
||
}
|
||
if (!rough) 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 => {
|
||
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);
|
||
});
|
||
});
|
||
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) {
|
||
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, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D };
|
||
})();
|