/*! * 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; } 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.' }); }; 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) { 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(); mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex); 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, PLATE_COLORS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D }; })();