v0.6.0: ruimtes (verfemmer: functie, netto m², vloerafwerking, FIBO-wandpanelen, eigen verfkleur, vals plafond, ruimtestaat op de PDF), inrichting (sanitair, keuken, kookeiland, kasten, bed, zithoek, boiler; slepen met snappen op het wandvlak, draaien, kosten), kozijnen met deurrichting, glasdeur en schuifdeuren (inbouw/opbouw, deuren open in de loopmodus) plus dagkanten, gevel stucwerk en wandstukken splitsen met eigen gevel, dak over de hele woning met overkapping, geschilderd plafond, model Amstel als nieuw ontwerp, kosten en prijzen uit kosten/intern.csv en kosten/leveranciers.csv (CostCsv); easter egg verwijderd; overbodige comments opgeschoond (alleen copyright, uitzonderingen en valkuilen blijven)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-11 00:34:27 +02:00
co-authored by Claude Opus 5.5
parent b5510d56c7
commit ceda0b554d
45 changed files with 4117 additions and 3700 deletions
+446
View File
@@ -0,0 +1,446 @@
/*!
* 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);
const ops = (cfg.openings || []).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
};
})();