/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // Deuren en ramen tellen als wand. Wanduiteinden tellen END_TOL langer, zodat een kiertje bij // een getekende binnenwand de ruimte niet laat doorlopen. window.RoomModel = (() => { const EPS = 0.5; const END_TOL = 15; const MIN_AREA_MM2 = 0.05e6; const FACE_OFF = 2; const CEIL_STEP = 300; const CEIL_MIN = 1800, CEIL_MAX = 4500; const TYPES = { woonkamer: { label: 'Woonkamer', color: '#e6c36a' }, keuken: { label: 'Keuken', color: '#f0a05a', floor: 'tegel_grijs' }, woonkeuken: { label: 'Woonkeuken', color: '#e9b35f' }, slaapkamer: { label: 'Slaapkamer', color: '#8fb3e8' }, badkamer: { label: 'Badkamer', color: '#5fc9c0', floor: 'tegel_antraciet', fibo: { decor: 'wit_tegel', height: null }, ceiling: 2600 }, toilet: { label: 'Toilet', color: '#7cc7e3', floor: 'tegel_antraciet', fibo: { decor: 'wit_tegel', height: 1200 }, ceiling: 2600 }, hal: { label: 'Hal / entree', color: '#c9bfae' }, berging: { label: 'Berging', color: '#a99a86' }, techniek: { label: 'Techniek', color: '#9a9aa6' }, wasruimte: { label: 'Wasruimte', color: '#9fd3a4', floor: 'tegel_grijs' }, werkkamer: { label: 'Werkkamer', color: '#c3a6e0' }, overig: { label: 'Overig', color: '#b8b8b8' } }; const FIBO = { wit_tegel: { label: 'Wit, tegel 300×600', color: '#f1f0ec', jointColor: '#cfcdc6', tile: { w: 600, h: 300, joint: 3 } }, grijs_tegel: { label: 'Lichtgrijs, tegel 300×600', color: '#c9c8c3', jointColor: '#a3a29d', tile: { w: 600, h: 300, joint: 3 } }, antraciet_tegel: { label: 'Antraciet, tegel 300×600', color: '#55575a', jointColor: '#3a3b3d', tile: { w: 600, h: 300, joint: 3 } }, wit_metro: { label: 'Wit, metrotegel 100×200', color: '#f3f2ee', jointColor: '#c8c6be', tile: { w: 200, h: 100, joint: 2 } }, beton: { label: 'Betonlook (naadloos)', color: '#a6a39c' } }; const isHex = (v) => /^#[0-9a-f]{6}$/i.test(v || ''); function applyType(room, type) { const T = TYPES[type] || TYPES.overig; room.type = TYPES[type] ? type : 'overig'; room.floor = T.floor || null; room.fibo = T.fibo ? Object.assign({}, T.fibo) : null; room.ceiling = T.ceiling || null; room.paint = null; return room; } function create(state, rect, x, z, type) { const id = (state.rooms || []).reduce((m, r) => Math.max(m, r.id || 0), 0) + 1; return applyType({ id, unitId: rect.id, x: Math.round(x - rect.x0), z: Math.round(z - rect.z0), name: '' }, type); } function normalizeRoom(r, i) { return { id: r.id > 0 ? r.id : i + 1, unitId: String(r.unitId || 'A'), x: Number(r.x) || 0, z: Number(r.z) || 0, type: TYPES[r.type] ? r.type : 'overig', name: typeof r.name === 'string' ? r.name : '', floor: r.floor && FloorModel.FINISHES[r.floor] ? r.floor : null, paint: isHex(r.paint) ? r.paint : null, fibo: r.fibo && FIBO[r.fibo.decor] ? { decor: r.fibo.decor, height: r.fibo.height > 0 ? Math.round(r.fibo.height) : null } : null, ceiling: r.ceiling > 0 ? Math.max(CEIL_MIN, Math.min(CEIL_MAX, Math.round(r.ceiling))) : null }; } function normalize(list, unitIds) { return (Array.isArray(list) ? list : []).map(normalizeRoom).filter(r => !unitIds || unitIds.includes(r.unitId)); } const q = (v) => Math.round(v * 10) / 10; function bsearch(arr, v) { if (!arr.length || v < arr[0] || v > arr[arr.length - 1]) return -1; let lo = 0, hi = arr.length - 1; while (hi - lo > 1) { const m = (lo + hi) >> 1; if (arr[m] <= v) lo = m; else hi = m; } return lo; } function buildGrid(pieces, rects) { const blockers = []; pieces.forEach(p => { if (p.place.baseY) return; // opbouw boven een lager dak staat niet op de vloer const pl = p.place, alongX = Math.abs(Math.sin(pl.rotY)) < 0.5; const hw = pl.width / 2, ht = pl.thickness / 2; const r = alongX ? [pl.cx - hw, pl.cx + hw, pl.cz - ht, pl.cz + ht] : [pl.cx - ht, pl.cx + ht, pl.cz - hw, pl.cz + hw]; const ext = alongX ? [r[0] - END_TOL, r[1] + END_TOL, r[2], r[3]] : [r[0], r[1], r[2] - END_TOL, r[3] + END_TOL]; blockers.push({ piece: p, r: r.map(q), ext: ext.map(q), alongX }); }); const sx = new Set(), sz = new Set(); const addR = (r) => { sx.add(r[0]); sx.add(r[1]); sz.add(r[2]); sz.add(r[3]); }; const unitR = rects.map(r => [r.x0, r.x1, r.z0, r.z1].map(q)); unitR.forEach(addR); blockers.forEach(b => { addR(b.r); addR(b.ext); }); const xs = [...sx].sort((a, b) => a - b), zs = [...sz].sort((a, b) => a - b); const nx = Math.max(0, xs.length - 1), nz = Math.max(0, zs.length - 1); const ix = new Map(xs.map((v, i) => [v, i])), iz = new Map(zs.map((v, i) => [v, i])); const unitOf = new Int16Array(nx * nz).fill(-1); const block = new Int32Array(nx * nz).fill(-1); const mark = (arr, r, val, onlyEmpty) => { const i0 = ix.get(r[0]), i1 = ix.get(r[1]), j0 = iz.get(r[2]), j1 = iz.get(r[3]); for (let j = j0; j < j1; j++) for (let i = i0; i < i1; i++) { const k = j * nx + i; if (!onlyEmpty || arr[k] < 0) arr[k] = val; } }; unitR.forEach((r, i) => mark(unitOf, r, i, true)); blockers.forEach((b, i) => mark(block, b.r, i, true)); blockers.forEach((b, i) => mark(block, b.ext, i, true)); return { xs, zs, nx, nz, unitOf, block, blockers }; } function fill(g, rects) { const { xs, zs, nx, nz, unitOf, block } = g; const region = new Int32Array(nx * nz).fill(-1); const regions = []; for (let k0 = 0; k0 < nx * nz; k0++) { if (unitOf[k0] < 0 || block[k0] >= 0 || region[k0] >= 0) continue; const idx = regions.length, cells = [], units = new Set(); let area = 0, mx = 0, mz = 0; const stack = [k0]; region[k0] = idx; while (stack.length) { const k = stack.pop(), i = k % nx, j = (k - i) / nx; const w = xs[i + 1] - xs[i], d = zs[j + 1] - zs[j], a = w * d; cells.push(k); units.add(rects[unitOf[k]].id); area += a; mx += a * (xs[i] + w / 2); mz += a * (zs[j] + d / 2); const nb = [i > 0 ? k - 1 : -1, i < nx - 1 ? k + 1 : -1, j > 0 ? k - nx : -1, j < nz - 1 ? k + nx : -1]; nb.forEach(m => { if (m < 0 || region[m] >= 0 || unitOf[m] < 0 || block[m] >= 0) return; region[m] = idx; stack.push(m); }); } regions.push({ idx, cells, units: [...units], area, cx: mx / area, cz: mz / area }); } return { region, regions }; } function outline(g, region, reg) { const { xs, zs, nx, nz, block } = g; const inReg = (k) => region[k] === reg.idx; const rows = new Map(); reg.cells.forEach(k => { const i = k % nx, j = (k - i) / nx; (rows.get(j) || rows.set(j, []).get(j)).push(i); }); let open = []; const rects = []; [...rows.keys()].sort((a, b) => a - b).forEach(j => { const is = rows.get(j).sort((a, b) => a - b), runs = []; is.forEach(i => { const last = runs[runs.length - 1]; if (last && last[1] === i) last[1] = i + 1; else runs.push([i, i + 1]); }); const next = []; runs.forEach(([a, b]) => { const r = open.find(o => o.a === a && o.b === b && o.j1 === j); if (r) { r.j1 = j + 1; next.push(r); } else { const n = { a, b, j0: j, j1: j + 1 }; rects.push(n); next.push(n); } }); open = next; }); const out = rects.map(r => ({ x0: xs[r.a], x1: xs[r.b], z0: zs[r.j0], z1: zs[r.j1] })); const raw = []; reg.cells.forEach(k => { const i = k % nx, j = (k - i) / nx; const side = (m, axis, v, s0, s1, n) => { if (m >= 0 && inReg(m)) return; raw.push({ axis, v, s0, s1, n, owner: m >= 0 ? block[m] : -1 }); }; side(i < nx - 1 ? k + 1 : -1, 'x', xs[i + 1], zs[j], zs[j + 1], { x: -1, z: 0 }); side(i > 0 ? k - 1 : -1, 'x', xs[i], zs[j], zs[j + 1], { x: 1, z: 0 }); side(j < nz - 1 ? k + nx : -1, 'z', zs[j + 1], xs[i], xs[i + 1], { x: 0, z: -1 }); side(j > 0 ? k - nx : -1, 'z', zs[j], xs[i], xs[i + 1], { x: 0, z: 1 }); }); const groups = new Map(); raw.forEach(e => { const key = `${e.axis}|${e.v}|${e.n.x}|${e.n.z}|${e.owner}`; (groups.get(key) || groups.set(key, []).get(key)).push(e); }); const edges = []; groups.forEach(list => { list.sort((a, b) => a.s0 - b.s0); list.forEach(e => { const last = edges[edges.length - 1]; if (last && last.key === list && Math.abs(last.s1 - e.s0) < EPS) last.s1 = e.s1; else edges.push(Object.assign({ key: list }, e)); }); }); edges.forEach(e => delete e.key); return { rects: out, edges }; } function labelPoint(g, region, reg, rects) { const i = bsearch(g.xs, reg.cx), j = bsearch(g.zs, reg.cz); if (i >= 0 && j >= 0 && i < g.nx && j < g.nz && region[j * g.nx + i] === reg.idx) return { x: reg.cx, z: reg.cz }; const big = rects.reduce((b, r) => (r.x1 - r.x0) * (r.z1 - r.z0) > (b.x1 - b.x0) * (b.z1 - b.z0) ? r : b, rects[0]); return { x: (big.x0 + big.x1) / 2, z: (big.z0 + big.z1) / 2 }; } function bandOnSide(p, e, lo, H) { const pl = p.place, cfg = p.config, W = cfg.wallWidth, tl = WallModel.topLine(cfg); const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY); const toS = (lx) => e.axis === 'z' ? pl.cx + lx * c : pl.cz - lx * sn; const toLx = (s) => e.axis === 'z' ? (s - pl.cx) / c : -(s - pl.cz) / sn; const la = Math.max(-W / 2, Math.min(toLx(e.s0), toLx(e.s1))), lb = Math.min(W / 2, Math.max(toLx(e.s0), toLx(e.s1))); if (lb - la < 1) return { quads: [], area: 0 }; const hiAt = (lx) => Math.min(tl.at(lx), H); // Schoorsteen met binnenkant 'zoals de wanden': de afwerking van de ruimte loopt eroverheen. const ops = (cfg.openings || []).filter(op => !(op.chimney && op.chimney.inside === 'wand')).map(op => ({ x0: -W / 2 + op.x, x1: -W / 2 + op.x + op.w, y0: op.y, top: (lx) => WallModel.openingTopAt(op, lx + W / 2) })); const bs = new Set([la, lb]); tl.pts.forEach(pt => bs.add(pt.x)); for (let i = 0; i < tl.pts.length - 1; i++) { const a = tl.pts[i], b = tl.pts[i + 1]; if (isFinite(H) && (a.y - H) * (b.y - H) < 0) bs.add(a.x + (H - a.y) / (b.y - a.y) * (b.x - a.x)); } ops.forEach(o => { bs.add(o.x0); bs.add(o.x1); const t0 = o.top(o.x0), t1 = o.top(o.x1); [lo, H].forEach(L => { if (isFinite(L) && (t0 - L) * (t1 - L) < 0) bs.add(o.x0 + (L - t0) / (t1 - t0) * (o.x1 - o.x0)); }); }); const xsB = [...bs].filter(x => x >= la - 0.01 && x <= lb + 0.01).sort((a, b) => a - b); const quads = []; let area = 0; for (let k = 0; k < xsB.length - 1; k++) { const a = xsB[k], b = xsB[k + 1]; if (b - a < 1) continue; const m = (a + b) / 2; if (hiAt(m) <= lo + 1) continue; let bot = () => lo; const ivs = []; ops.filter(o => o.x0 <= m && o.x1 >= m).sort((u, v) => u.y0 - v.y0).forEach(o => { const bf = bot; ivs.push([bf, () => o.y0]); bot = (x) => Math.max(bf(x), o.top(x)); }); ivs.push([bot, hiAt]); ivs.forEach(([bf, tf]) => { const at = (x) => { const y0 = Math.max(lo, bf(x)), y1 = Math.min(hiAt(x), tf(x)); return [y0, Math.max(y0, y1)]; }; const [a0, a1] = at(a), [b0, b1] = at(b); if (a1 - a0 < 0.5 && b1 - b0 < 0.5) return; quads.push([[toS(a), a0], [toS(b), b0], [toS(b), b1], [toS(a), a1]]); area += (b - a) * ((a1 - a0) + (b1 - b0)) / 2; }); } return { quads, area }; } function bandOnEnd(p, e, lo, H) { const tl = WallModel.topLine(p.config), W = p.config.wallWidth; const top = Math.min(H, Math.min(tl.at(-W / 2), tl.at(W / 2))); if (top - lo < 1) return { quads: [], area: 0 }; return { quads: [[[e.s0, lo], [e.s1, lo], [e.s1, top], [e.s0, top]]], area: (e.s1 - e.s0) * (top - lo) }; } function compute(roomStates, units, pieces) { const rects = units.map(u => u.rect); const g = buildGrid(pieces, rects); const { region, regions } = fill(g, rects); const cellAt = (x, z) => { const i = bsearch(g.xs, x), j = bsearch(g.zs, z); return i < 0 || j < 0 || i >= g.nx || j >= g.nz ? -1 : j * g.nx + i; }; const valid = (r) => r && r.area >= MIN_AREA_MM2; const regionAt = (x, z) => { const k = cellAt(x, z); return k >= 0 && region[k] >= 0 && valid(regions[region[k]]) ? regions[region[k]] : null; }; // Zaadpunt in een wand (wand verschoven): het dichtstbijzijnde gebied binnen 400 mm. const seedRegion = (x, z) => { const r0 = regionAt(x, z); if (r0) return r0; for (let d = 50; d <= 400; d += 50) { for (let a = 0; a < 8; a++) { const r = regionAt(x + d * Math.cos(a * Math.PI / 4), z + d * Math.sin(a * Math.PI / 4)); if (r) return r; } } return null; }; const owner = new Map(); const lost = []; (roomStates || []).forEach(rs => { const u = units.find(x => x.id === rs.unitId); const reg = u ? seedRegion(u.rect.x0 + rs.x, u.rect.z0 + rs.z) : null; if (!reg) lost.push({ id: rs.id, state: rs, reason: u ? 'Geen afgesloten gebied op deze plek' : `Unit ${rs.unitId} bestaat niet` }); else if (owner.has(reg.idx)) lost.push({ id: rs.id, state: rs, reason: `Valt samen met een andere ruimte (wand weg?)` }); else owner.set(reg.idx, rs); }); const pieceIdx = new Map(g.blockers.map((b, i) => [i, b])); const unitAt = (x, z) => units.find(u => x >= u.rect.x0 - EPS && x <= u.rect.x1 + EPS && z >= u.rect.z0 - EPS && z <= u.rect.z1 + EPS); const sameType = {}; owner.forEach(rs => { sameType[rs.type] = (sameType[rs.type] || 0) + 1; }); const seenType = {}; const rooms = [], free = []; regions.filter(valid).forEach(reg => { const { rects: rr, edges } = outline(g, region, reg); const lp = labelPoint(g, region, reg, rr); const u0 = units.find(u => u.id === reg.units[0]); const floorTop = u0 ? u0.layout.floorTop : 0; const base = { key: reg.idx, areaM2: reg.area / 1e6, rects: rr, edges, label: lp, units: reg.units, floorTop }; const rs = owner.get(reg.idx); if (!rs) { free.push(base); return; } const T = TYPES[rs.type] || TYPES.overig; seenType[rs.type] = (seenType[rs.type] || 0) + 1; const name = rs.name || (sameType[rs.type] > 1 ? `${T.label} ${seenType[rs.type]}` : T.label); const share = {}; rr.forEach(r => { const u = unitAt((r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2); if (u) share[u.id] = (share[u.id] || 0) + (r.x1 - r.x0) * (r.z1 - r.z0); }); const unitId = Object.keys(share).sort((a, b) => share[b] - share[a])[0] || reg.units[0]; const ceilQuads = []; let ceilMM2 = 0; if (rs.ceiling) { const yAbs = floorTop + rs.ceiling; rr.forEach(r => { const nX = Math.max(1, Math.ceil((r.x1 - r.x0) / CEIL_STEP)), nZ = Math.max(1, Math.ceil((r.z1 - r.z0) / CEIL_STEP)); for (let a = 0; a < nX; a++) for (let b = 0; b < nZ; b++) { const x0 = r.x0 + (r.x1 - r.x0) * a / nX, x1 = r.x0 + (r.x1 - r.x0) * (a + 1) / nX; const z0 = r.z0 + (r.z1 - r.z0) * b / nZ, z1 = r.z0 + (r.z1 - r.z0) * (b + 1) / nZ; const u = unitAt((x0 + x1) / 2, (z0 + z1) / 2); if (!u) continue; const ok = [[x0 + 1, z0 + 1], [x1 - 1, z0 + 1], [x1 - 1, z1 - 1], [x0 + 1, z1 - 1]].every(([x, z]) => u.roof.ceilingAt(x, z) > yAbs + 10); if (!ok) continue; ceilQuads.push({ x0, x1, z0, z1, y: yAbs }); ceilMM2 += (x1 - x0) * (z1 - z0); } }); } const H = rs.ceiling && ceilQuads.length ? rs.ceiling : Infinity; const fiboH = rs.fibo ? (rs.fibo.height ? Math.min(rs.fibo.height, H) : H) : 0; const faces = []; let fiboMM2 = 0, paintMM2 = 0; edges.forEach(e => { const b = pieceIdx.get(e.owner); if (!b) return; const side = b.alongX === (e.axis === 'z'); const band = (lo, hi) => side ? bandOnSide(b.piece, e, lo, hi) : bandOnEnd(b.piece, e, lo, hi); const world = ([s, y]) => e.axis === 'z' ? { x: s, y: b.piece.place.floorTop + y, z: e.v + e.n.z * FACE_OFF, u: s } : { x: e.v + e.n.x * FACE_OFF, y: b.piece.place.floorTop + y, z: s, u: s }; if (rs.fibo) { const r = band(0, fiboH); fiboMM2 += r.area; r.quads.forEach(qd => faces.push({ kind: 'fibo', pts: qd.map(world) })); } if (rs.paint && (!rs.fibo || fiboH < H)) { const r = band(rs.fibo ? fiboH : 0, H); paintMM2 += r.area; r.quads.forEach(qd => faces.push({ kind: 'paint', pts: qd.map(world) })); } }); rooms.push(Object.assign(base, { id: rs.id, state: rs, type: rs.type, typeLabel: T.label, name, color: T.color, unitId, floor: rs.floor, paint: rs.paint, fibo: rs.fibo, ceiling: rs.ceiling, ceilingActive: ceilQuads.length > 0, ceilQuads, ceilM2: ceilMM2 / 1e6, faces, fiboM2: fiboMM2 / 1e6, paintM2: paintMM2 / 1e6 })); }); const roomAt = (x, z) => { const reg = regionAt(x, z); return reg ? rooms.find(r => r.key === reg.idx) || null : null; }; const freeAt = (x, z) => { const reg = regionAt(x, z); return reg ? free.find(r => r.key === reg.idx) || null : null; }; return { rooms, free, lost, roomAt, freeAt, regionAt }; } function lowerSpots(result, spots) { (spots || []).forEach(sp => { const r = result.roomAt(sp.x, sp.z); if (r && r.ceilingActive && sp.floorY + r.ceiling < sp.y) sp.y = sp.floorY + r.ceiling; }); } const matCache = {}; const offset = (m) => Object.assign(m, { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -2, side: THREE.DoubleSide }); function fiboMaterial(decor) { const key = 'fibo:' + decor; if (matCache[key]) return matCache[key]; const f = FIBO[decor] || FIBO.wit_tegel; const tex = f.tile && FloorModel.tileTexture ? FloorModel.tileTexture(f.tile, f.color, f.jointColor) : null; return (matCache[key] = offset(new THREE.MeshStandardMaterial(tex ? { map: tex, roughness: 0.3 } : { color: f.color, roughness: 0.5 }))); } function paintMaterial(hex) { const key = 'paint:' + hex; return matCache[key] || (matCache[key] = offset(new THREE.MeshStandardMaterial({ color: hex, roughness: 0.9 }))); } // Kopie van de vloerafwerking, zodat hij boven de woningvloer wint. function floorMaterial(key) { const f = FloorModel.FINISHES[key] || FloorModel.FINISHES.geen; const base = FloorModel.finishMaterial(key); const ck = 'floor:' + key; if (!matCache[ck]) matCache[ck] = offset(base ? base.clone() : new THREE.MeshStandardMaterial({ color: f.color, roughness: 0.75 })); const m = matCache[ck]; if (base && base.map && m.map !== base.map) { m.map = base.map; m.color.copy(base.color); m.needsUpdate = true; } return m; } let ceilMat = null; const ceilingMaterial = () => ceilMat || (ceilMat = new THREE.MeshStandardMaterial({ color: 0xf4f3ef, roughness: 0.95, side: THREE.DoubleSide })); function quadGeometry(quads, uvOf) { const pos = [], uv = []; quads.forEach(qd => [0, 1, 2, 0, 2, 3].forEach(i => { const p = qd[i]; pos.push(p.x * 0.001, p.y * 0.001, p.z * 0.001); const t = uvOf(p); uv.push(t[0], t[1]); })); const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); geo.computeVertexNormals(); return geo; } function build3D(result, opts) { const o = opts || {}; const group = new THREE.Group(), ceilings = new THREE.Group(); group.add(ceilings); group.userData.ceilings = ceilings; result.rooms.forEach(r => { if (r.floor) { const y = r.floorTop + FACE_OFF; const quads = r.rects.map(c => [{ x: c.x0, z: c.z0 }, { x: c.x1, z: c.z0 }, { x: c.x1, z: c.z1 }, { x: c.x0, z: c.z1 }].map(p => Object.assign(p, { y }))); const m = new THREE.Mesh(quadGeometry(quads, p => [p.x * 0.001, p.z * 0.001]), floorMaterial(r.floor)); m.receiveShadow = true; group.add(m); } const byKind = { fibo: [], paint: [] }; r.faces.forEach(f => byKind[f.kind].push(f.pts)); if (byKind.fibo.length) group.add(new THREE.Mesh(quadGeometry(byKind.fibo, p => [p.u * 0.001, p.y * 0.001]), fiboMaterial(r.fibo.decor))); if (byKind.paint.length) group.add(new THREE.Mesh(quadGeometry(byKind.paint, p => [p.u * 0.001, p.y * 0.001]), paintMaterial(r.paint))); if (r.ceilQuads.length) { const quads = r.ceilQuads.map(c => [{ x: c.x0, z: c.z0 }, { x: c.x1, z: c.z0 }, { x: c.x1, z: c.z1 }, { x: c.x0, z: c.z1 }].map(p => Object.assign(p, { y: c.y }))); ceilings.add(new THREE.Mesh(quadGeometry(quads, p => [p.x * 0.001, p.z * 0.001]), ceilingMaterial())); } }); ceilings.visible = o.ceilings !== false; return group; } function floorLabel(r) { return r.floor ? (FloorModel.FINISHES[r.floor] || {}).label || r.floor : 'Woning'; } function wallLabel(r) { const parts = []; if (r.fibo) parts.push(`FIBO ${(FIBO[r.fibo.decor] || {}).label.toLowerCase()}${r.fibo.height ? ` tot ${r.fibo.height} mm` : ''}`); if (r.paint) parts.push(`verf ${r.paint}`); return parts.join(', ') || 'Woning'; } function ceilingLabel(r) { return r.ceiling ? (r.ceilingActive ? `vals plafond ${r.ceiling}` : `${r.ceiling} (dak lager)`) : 'volgt dak'; } return { TYPES, FIBO, CEIL_MIN, CEIL_MAX, applyType, create, normalize, compute, lowerSpots, build3D, floorLabel, wallLabel, ceilingLabel }; })();