Files

532 lines
31 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Lokaal (mm): x langs de breedte (midden 0), z van de rug (-d/2) naar de voorkant, y vanaf het loopvlak.
// rot in graden met de klok mee, 0 = rug naar het noorden. anchor 'oost': x vanaf de oostrand.
window.FixtureModel = (() => {
const toM = 0.001;
const CATS = { sanitair: 'Sanitair', keuken: 'Keuken', kasten: 'Kasten', wonen: 'Wonen', slapen: 'Slapen', techniek: 'Techniek' };
const mats = {};
const M = (key, make) => mats[key] || (mats[key] = make());
const std = (color, rough, metal, extra) => new THREE.MeshStandardMaterial(Object.assign({ color, roughness: rough, metalness: metal || 0 }, extra || {}));
const MAT = {
keramiek: () => M('keramiek', () => std(0xf7f7f4, 0.18, 0.05)),
chroom: () => M('chroom', () => std(0xd8dadc, 0.15, 0.95)),
zwartMetaal: () => M('zwartMetaal', () => std(0x1d1e20, 0.4, 0.6)),
glas: () => M('glas', () => std(0xb9d4dc, 0.05, 0.1, { transparent: true, opacity: 0.25, depthWrite: false, side: THREE.DoubleSide })),
spiegel: () => M('spiegel', () => std(0xdfe6ea, 0.03, 1)),
voorzet: () => M('voorzet', () => std(0xeceae5, 0.6)),
tegel: () => M('tegel', () => std(0x8d8f91, 0.5)),
rubber: () => M('rubber', () => std(0x1b1b1b, 0.8)),
werkblad: () => M('werkblad', () => std(0x2f3133, 0.35)),
kookplaat: () => M('kookplaat', () => std(0x0e0e10, 0.08, 0.2)),
ring: () => M('ring', () => std(0x5a5c60, 0.3)),
plint: () => M('plint', () => std(0x202122, 0.7)),
stof: () => M('stof', () => std(0x7f8487, 0.95)),
stofDonker: () => M('stofDonker', () => std(0x5c6064, 0.95)),
stofGroen: () => M('stofGroen', () => std(0x6e7d68, 0.95)),
beddengoed: () => M('beddengoed', () => std(0xf2f0ea, 0.9)),
kussen: () => M('kussen', () => std(0xe4e1d8, 0.9)),
hout: () => M('hout', () => std(0x9b7a55, 0.6)),
houtDonker: () => M('houtDonker', () => std(0x4a3a2c, 0.6)),
wit: () => M('wit', () => std(0xf0efea, 0.5)),
scherm: () => M('scherm', () => std(0x0b0c0e, 0.1, 0.3))
};
const FRONTS = {
wit: { label: 'Wit mat', hex: 0xefeeea },
grijs: { label: 'Lichtgrijs', hex: 0xc4c5c2 },
antraciet: { label: 'Antraciet', hex: 0x3b3e42 },
zwart: { label: 'Zwart', hex: 0x1d1e20 },
eiken: { label: 'Eiken', hex: 0xa8865e }
};
const frontMat = (k) => M('front:' + k, () => std((FRONTS[k] || FRONTS.wit).hex, 0.55));
function box(g, mat, x, y, z, w, h, d) {
if (w <= 0 || h <= 0 || d <= 0) return null;
const m = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
m.position.set(x * toM, y * toM, z * toM);
m.castShadow = true; m.receiveShadow = true;
g.add(m);
return m;
}
const boxAt = (g, mat, x0, x1, y0, y1, z0, z1) => box(g, mat, (x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2, x1 - x0, y1 - y0, z1 - z0);
function ecyl(g, mat, x, z, rx, rz, y0, y1, taper) {
const geo = new THREE.CylinderGeometry(1, taper || 1, 1, 40);
const m = new THREE.Mesh(geo, mat);
m.scale.set(rx * toM, (y1 - y0) * toM, rz * toM);
m.position.set(x * toM, (y0 + y1) / 2 * toM, z * toM);
m.castShadow = true; m.receiveShadow = true;
g.add(m);
return m;
}
const rectP = (x0, x1, z0, z1, kind, fill) => ({ pts: [{ x: x0, z: z0 }, { x: x1, z: z0 }, { x: x1, z: z1 }, { x: x0, z: z1 }], closed: true, kind, fill });
const lineP = (x0, z0, x1, z1, kind) => ({ pts: [{ x: x0, z: z0 }, { x: x1, z: z1 }], kind });
function ellP(cx, cz, rx, rz, kind, a0, a1) {
const pts = [], n = 32, s = a0 === undefined ? 0 : a0, e = a1 === undefined ? Math.PI * 2 : a1;
for (let i = 0; i <= n; i++) { const t = s + (e - s) * i / n; pts.push({ x: cx + rx * Math.cos(t), z: cz + rz * Math.sin(t) }); }
return { pts, closed: a0 === undefined, kind };
}
const circP = (x, z, r, kind) => ({ circle: { x, z, r }, kind });
function rrectP(x0, x1, z0, z1, r, kind) {
const pts = [], q = (cx, cz, a) => { for (let i = 0; i <= 6; i++) { const t = a + i / 6 * Math.PI / 2; pts.push({ x: cx + r * Math.cos(t), z: cz + r * Math.sin(t) }); } };
q(x1 - r, z1 - r, 0); q(x0 + r, z1 - r, Math.PI / 2); q(x0 + r, z0 + r, Math.PI); q(x1 - r, z0 + r, Math.PI * 1.5);
return { pts, closed: true, kind };
}
const TYPES = {
toilet: {
label: 'Toilet (zwevend)', cat: 'sanitair', w: 600, d: 690, h: 1200, resize: ['w', 'h'], minW: 450, wall: true, cost: 'toilet',
hint: 'Wandcloset aan een inbouwreservoir in een voorzetwand (150 mm), zitting 420 mm',
build(f, g) {
const D = f.d, zb = -D / 2, zf = zb + 150;
boxAt(g, MAT.voorzet(), -f.w / 2, f.w / 2, 0, f.h, zb, zf); // voorzetwand
boxAt(g, MAT.chroom(), -120, 120, 920, 1080, zf, zf + 8); // bedieningsplaat
boxAt(g, MAT.wit(), -110, 110, 930, 1070, zf + 8, zf + 10);
const cz = zf + 270;
ecyl(g, MAT.keramiek(), 0, cz, 180, 270, 250, 400, 0.72); // pot
boxAt(g, MAT.keramiek(), -150, 150, 300, 400, zf, zf + 120); // aansluiting op de wand
ecyl(g, MAT.keramiek(), 0, cz + 5, 185, 272, 400, 420); // zitting
ecyl(g, MAT.keramiek(), 0, cz + 5, 180, 265, 422, 436); // deksel
},
plan(f) {
const zb = -f.d / 2, zf = zb + 150, cz = zf + 270;
return [rectP(-f.w / 2, f.w / 2, zb, zf, 'solid', true), ellP(0, cz, 180, 270), ellP(0, cz + 30, 115, 190, 'thin'), rectP(-150, 150, zf, zf + 70, 'thin')];
}
},
fontein: {
label: 'Fontein', cat: 'sanitair', w: 400, d: 250, h: 850, resize: ['w', 'd', 'h'], wall: true, cost: 'fontein',
hint: 'Handwasbak met kraan; h = bovenkant',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2, top = f.h;
boxAt(g, MAT.keramiek(), -f.w / 2, f.w / 2, top - 130, top, zb, zf);
boxAt(g, MAT.rubber(), -f.w / 2 + 40, f.w / 2 - 40, top - 4, top + 0.5, zb + 70, zf - 30);
tap(g, f.w / 2 - 70, top, zb + 35, 120);
boxAt(g, MAT.chroom(), -25, 25, 300, top - 130, zb, zb + 50); // sifon
},
plan(f) { const zb = -f.d / 2; return [rectP(-f.w / 2, f.w / 2, zb, f.d / 2), rrectP(-f.w / 2 + 40, f.w / 2 - 40, zb + 70, f.d / 2 - 30, 40, 'thin'), circP(f.w / 2 - 70, zb + 35, 18, 'thin')]; }
},
wastafel: {
label: 'Badmeubel met spiegel', cat: 'sanitair', w: 800, d: 460, h: 850, resize: ['w', 'd', 'h'], wall: true, cost: 'wastafel',
opts: { front: 'eiken', spiegel: true }, hint: 'Zwevend onderkastje met wastafel; h = bovenkant blad',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2, top = f.h;
boxAt(g, frontMat(f.opts.front), -f.w / 2, f.w / 2, top - 520, top - 50, zb, zf - 5);
boxAt(g, MAT.rubber(), -f.w / 2 + 5, f.w / 2 - 5, top - 288, top - 284, zf - 5, zf - 3); // greep-naad
boxAt(g, MAT.keramiek(), -f.w / 2, f.w / 2, top - 50, top, zb, zf);
boxAt(g, MAT.rubber(), -Math.min(250, f.w / 2 - 60), Math.min(250, f.w / 2 - 60), top - 3, top + 0.5, zb + 110, zf - 40);
tap(g, 0, top, zb + 60, 160);
if (f.opts.spiegel) {
boxAt(g, MAT.zwartMetaal(), -f.w / 2, f.w / 2, top + 250, top + 1000, zb, zb + 22);
boxAt(g, MAT.spiegel(), -f.w / 2 + 12, f.w / 2 - 12, top + 262, top + 988, zb + 22, zb + 24);
}
},
plan(f) {
const zb = -f.d / 2, bw = Math.min(250, f.w / 2 - 60);
return [rectP(-f.w / 2, f.w / 2, zb, f.d / 2), ellP(0, (zb + 110 + f.d / 2 - 40) / 2, bw, (f.d - 150) / 2, 'thin'), circP(0, zb + 60, 18, 'thin')];
}
},
douche: {
label: 'Inloopdouche', cat: 'sanitair', w: 900, d: 1200, h: 2100, resize: ['w', 'd', 'h'], wall: true, cost: 'douche',
opts: { glas: 'rechts', glasLengte: 1000 }, hint: 'Betegelde vloer met douchegoot, glaswand opzij, regendouche en thermostaatkraan aan de rugwand',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2;
boxAt(g, MAT.tegel(), -f.w / 2, f.w / 2, 0, 6, zb, zf); // vloer
boxAt(g, MAT.zwartMetaal(), -f.w / 2 + 60, f.w / 2 - 60, 6, 8, zb + 60, zb + 110); // douchegoot
// Thermostaatkraan + glijstang
boxAt(g, MAT.zwartMetaal(), -170, 170, 1050, 1110, zb, zb + 70);
boxAt(g, MAT.zwartMetaal(), 140, 165, 1110, 2050, zb + 30, zb + 55); // stang naar de regendouche
boxAt(g, MAT.zwartMetaal(), 140, 165, 2030, 2055, zb + 30, zb + 330); // arm
ecyl(g, MAT.zwartMetaal(), 152, zb + 330, 150, 150, 2005, 2025); // regendouche Ø300
// Glaswand opzij (zwart profiel aan de wand)
if (f.opts.glas !== 'geen') {
const x = f.opts.glas === 'links' ? -f.w / 2 : f.w / 2, L = Math.min(f.d, f.opts.glasLengte || 1000);
boxAt(g, MAT.glas(), x - 4, x + 4, 30, f.h, zb, zb + L);
boxAt(g, MAT.zwartMetaal(), x - 12, x + 12, 30, f.h, zb, zb + 25);
boxAt(g, MAT.zwartMetaal(), x - 10, x + 10, f.h - 20, f.h, zb, zb + L);
}
},
plan(f) {
const zb = -f.d / 2, out = [rectP(-f.w / 2, f.w / 2, zb, f.d / 2, 'dash'), rectP(-f.w / 2 + 60, f.w / 2 - 60, zb + 60, zb + 110, 'thin'), circP(152, zb + 330, 150, 'thin')];
if (f.opts.glas !== 'geen') {
const x = f.opts.glas === 'links' ? -f.w / 2 : f.w / 2, L = Math.min(f.d, f.opts.glasLengte || 1000);
out.push(rectP(x - 15, x + 15, zb, zb + L, 'solid', true));
}
return out;
}
},
keuken: {
label: 'Keukenblok', cat: 'keuken', w: 2660, d: 600, h: 910, resize: ['w', 'd', 'h'], minW: 600, wall: true, cost: 'keuken',
opts: { front: 'antraciet', spoel: true, spoelbak: 0.6, kookplaat: false, kookplaatPlek: 0.2, hogeKast: 'links', vaatwasser: true, bovenkasten: false },
hint: 'Onderkasten met werkblad, spoelbak en eventueel kookplaat (plek: 0 = links, 1 = rechts, van voren gezien); hoge kast 600 mm met inbouwkoelkast en oven',
build(f, g) { kitchenRun(g, f, false); },
plan(f) { return kitchenPlan(f, false); }
},
kookeiland: {
label: 'Kookeiland', cat: 'keuken', w: 2660, d: 600, h: 910, resize: ['w', 'd', 'h'], minW: 600, wall: false, cost: 'kookeiland',
opts: { front: 'antraciet', kookplaat: true },
hint: 'Vrijstaand, met inductiekookplaat (4 zones) in het midden',
build(f, g) { kitchenRun(g, f, true); },
plan(f) { return kitchenPlan(f, true); }
},
kledingkast: {
label: 'Kledingkast', cat: 'kasten', w: 3020, d: 600, h: 2400, resize: ['w', 'd', 'h'], minW: 400, wall: true, cost: 'kast',
opts: { front: 'wit' }, hint: 'Hang/leg, deuren van ca. 750 mm (twee per vak)',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2, n = Math.max(1, Math.round(f.w / 750));
boxAt(g, MAT.wit(), -f.w / 2, f.w / 2, 0, f.h, zb, zf - 20);
const dw = f.w / n;
for (let i = 0; i < n; i++) {
const x0 = -f.w / 2 + i * dw;
[[x0 + 2, x0 + dw / 2 - 2], [x0 + dw / 2 + 2, x0 + dw - 2]].forEach(([a, b], k) => {
boxAt(g, frontMat(f.opts.front), a, b, 80, f.h - 4, zf - 20, zf);
const hx = k === 0 ? b - 40 : a + 40;
boxAt(g, MAT.zwartMetaal(), hx - 6, hx + 6, 900, 1300, zf, zf + 18);
});
}
boxAt(g, MAT.plint(), -f.w / 2, f.w / 2, 0, 80, zf - 60, zf - 20);
},
plan(f) {
const zb = -f.d / 2, n = Math.max(1, Math.round(f.w / 750)), out = [rectP(-f.w / 2, f.w / 2, zb, f.d / 2)];
for (let i = 1; i < n; i++) out.push(lineP(-f.w / 2 + i * f.w / n, zb, -f.w / 2 + i * f.w / n, f.d / 2, 'thin'));
out.push(lineP(-f.w / 2 + 30, 0, f.w / 2 - 30, 0, 'dash')); // roede
return out;
}
},
bed: {
label: 'Bed', cat: 'slapen', w: 1600, d: 2100, h: 520, resize: ['w', 'd'], minW: 800, wall: true, cost: null,
opts: { nachtkastjes: true }, hint: 'Hoofdbord tegen de wand; 2 kussens vanaf 1200 mm breed',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2;
boxAt(g, MAT.stofDonker(), -f.w / 2, f.w / 2, 0, 1050, zb, zb + 80); // hoofdbord
boxAt(g, MAT.stofDonker(), -f.w / 2, f.w / 2, 60, 330, zb + 80, zf); // boxspring
boxAt(g, MAT.plint(), -f.w / 2 + 40, f.w / 2 - 40, 0, 60, zb + 120, zf - 40);
boxAt(g, MAT.beddengoed(), -f.w / 2 + 10, f.w / 2 - 10, 330, f.h, zb + 85, zf - 5); // matras
boxAt(g, MAT.kussen(), -f.w / 2 + 5, f.w / 2 - 5, f.h - 10, f.h + 40, zb + 650, zf); // dekbed
const nk = f.w >= 1200 ? 2 : 1, kw = (f.w - 60 * (nk + 1)) / nk;
for (let i = 0; i < nk; i++) boxAt(g, MAT.wit(), -f.w / 2 + 60 + i * (kw + 60), -f.w / 2 + 60 + i * (kw + 60) + kw, f.h - 5, f.h + 110, zb + 110, zb + 500);
if (f.opts.nachtkastjes) [-1, 1].forEach(s => {
const x0 = s < 0 ? -f.w / 2 - 470 : f.w / 2 + 20;
boxAt(g, MAT.houtDonker(), x0, x0 + 450, 150, 520, zb + 10, zb + 410);
});
},
plan(f) {
const zb = -f.d / 2, out = [rectP(-f.w / 2, f.w / 2, zb, f.d / 2), lineP(-f.w / 2, zb + 80, f.w / 2, zb + 80, 'thin'), lineP(-f.w / 2, zb + 650, f.w / 2, zb + 650, 'thin')];
const nk = f.w >= 1200 ? 2 : 1, kw = (f.w - 60 * (nk + 1)) / nk;
for (let i = 0; i < nk; i++) out.push(rrectP(-f.w / 2 + 60 + i * (kw + 60), -f.w / 2 + 60 + i * (kw + 60) + kw, zb + 110, zb + 500, 60, 'thin'));
out.push(lineP(-f.w / 2, zb + 650, f.w / 2 - 300, f.d / 2, 'thin'));
if (f.opts.nachtkastjes) [-1, 1].forEach(s => { const x0 = s < 0 ? -f.w / 2 - 470 : f.w / 2 + 20; out.push(rectP(x0, x0 + 450, zb + 10, zb + 410, 'thin')); });
return out;
}
},
eettafel: {
label: 'Eettafel met stoelen', cat: 'wonen', w: 2000, d: 1100, h: 750, resize: ['w', 'd'], minW: 600, wall: false, cost: null,
opts: { vorm: 'ovaal', stoelen: 6 }, hint: 'Ovaal of rechthoekig; stoelen verdeeld over de lange zijden',
build(f, g) {
const ov = f.opts.vorm === 'ovaal';
if (ov) { ecyl(g, MAT.houtDonker(), 0, 0, f.w / 2, f.d / 2, f.h - 30, f.h); ecyl(g, MAT.zwartMetaal(), 0, 0, 60, 60, 20, f.h - 30); ecyl(g, MAT.zwartMetaal(), 0, 0, Math.min(f.w, f.d) * 0.3, Math.min(f.w, f.d) * 0.22, 0, 20); }
else {
boxAt(g, MAT.houtDonker(), -f.w / 2, f.w / 2, f.h - 30, f.h, -f.d / 2, f.d / 2);
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => boxAt(g, MAT.zwartMetaal(), a * (f.w / 2 - 80) - 25, a * (f.w / 2 - 80) + 25, 0, f.h - 30, b * (f.d / 2 - 80) - 25, b * (f.d / 2 - 80) + 25));
}
chairSpots(f).forEach(c => chair(g, c.x, c.z, c.rot));
},
plan(f) {
const out = [f.opts.vorm === 'ovaal' ? ellP(0, 0, f.w / 2, f.d / 2) : rectP(-f.w / 2, f.w / 2, -f.d / 2, f.d / 2)];
chairSpots(f).forEach(c => {
const pts = [[-225, -220], [225, -220], [225, 220], [-225, 220]].map(([x, z]) => rot2(x, z, c.rot)).map(p => ({ x: p.x + c.x, z: p.z + c.z }));
out.push({ pts, closed: true, kind: 'thin' });
const b = [[-225, 190], [225, 190]].map(([x, z]) => rot2(x, z, c.rot)).map(p => ({ x: p.x + c.x, z: p.z + c.z }));
out.push({ pts: b, kind: 'thin' });
});
return out;
}
},
bank: {
label: 'Bank', cat: 'wonen', w: 2600, d: 950, h: 820, resize: ['w', 'd'], minW: 1200, wall: false, cost: null,
opts: { chaise: 'rechts', chaiseLengte: 1700 }, hint: 'Rug aan de achterzijde; chaise longue links/rechts (van voren gezien)',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2, mat = MAT.stof();
boxAt(g, mat, -f.w / 2, f.w / 2, 80, 430, zb, zf); // zitting
boxAt(g, mat, -f.w / 2, f.w / 2, 80, f.h, zb, zb + 220); // rug
const ch = f.opts.chaise, L = Math.max(f.d, f.opts.chaiseLengte || 1700);
if (ch === 'links' || ch === 'rechts') {
const x0 = ch === 'links' ? -f.w / 2 : f.w / 2 - 900, x1 = x0 + 900;
boxAt(g, mat, x0, x1, 80, 430, zf, zb + L);
boxAt(g, MAT.stofDonker(), x0 + 10, x1 - 10, 430, 480, zf, zb + L - 10);
}
[-1, 1].forEach(s => { if (!((s < 0 && ch === 'links') || (s > 0 && ch === 'rechts'))) boxAt(g, mat, s * f.w / 2 - (s > 0 ? 180 : 0), s * f.w / 2 + (s < 0 ? 180 : 0), 80, 620, zb, zf); });
const nz = Math.max(1, Math.round((f.w - 360) / 800)), cw = (f.w - 360) / nz;
for (let i = 0; i < nz; i++) boxAt(g, MAT.stofDonker(), -f.w / 2 + 180 + i * cw + 10, -f.w / 2 + 180 + (i + 1) * cw - 10, 430, 480, zb + 230, zf - 10);
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => boxAt(g, MAT.zwartMetaal(), a * (f.w / 2 - 60) - 15, a * (f.w / 2 - 60) + 15, 0, 80, b * (f.d / 2 - 60) - 15, b * (f.d / 2 - 60) + 15));
},
plan(f) {
const zb = -f.d / 2, zf = f.d / 2, ch = f.opts.chaise, L = Math.max(f.d, f.opts.chaiseLengte || 1700);
let pts = [{ x: -f.w / 2, z: zb }, { x: f.w / 2, z: zb }, { x: f.w / 2, z: zf }, { x: -f.w / 2, z: zf }];
if (ch === 'rechts') pts = [{ x: -f.w / 2, z: zb }, { x: f.w / 2, z: zb }, { x: f.w / 2, z: zb + L }, { x: f.w / 2 - 900, z: zb + L }, { x: f.w / 2 - 900, z: zf }, { x: -f.w / 2, z: zf }];
if (ch === 'links') pts = [{ x: -f.w / 2, z: zb }, { x: f.w / 2, z: zb }, { x: f.w / 2, z: zf }, { x: -f.w / 2 + 900, z: zf }, { x: -f.w / 2 + 900, z: zb + L }, { x: -f.w / 2, z: zb + L }];
const out = [{ pts, closed: true }, lineP(-f.w / 2, zb + 220, f.w / 2, zb + 220, 'thin')];
const nz = Math.max(1, Math.round((f.w - 360) / 800)), cw = (f.w - 360) / nz;
for (let i = 1; i < nz; i++) out.push(lineP(-f.w / 2 + 180 + i * cw, zb + 220, -f.w / 2 + 180 + i * cw, zf, 'thin'));
return out;
}
},
fauteuil: {
label: 'Fauteuil', cat: 'wonen', w: 850, d: 850, h: 800, resize: ['w', 'd'], wall: false, cost: null, opts: {},
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2;
boxAt(g, MAT.stofGroen(), -f.w / 2 + 110, f.w / 2 - 110, 150, 430, zb + 150, zf);
boxAt(g, MAT.stofGroen(), -f.w / 2, f.w / 2, 150, f.h, zb, zb + 160);
[-1, 1].forEach(s => boxAt(g, MAT.stofGroen(), s > 0 ? f.w / 2 - 120 : -f.w / 2, s > 0 ? f.w / 2 : -f.w / 2 + 120, 150, 600, zb, zf));
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => ecyl(g, MAT.houtDonker(), a * (f.w / 2 - 70), b * (f.d / 2 - 70), 18, 18, 0, 150, 0.7));
},
plan(f) { const zb = -f.d / 2; return [rrectP(-f.w / 2, f.w / 2, zb, f.d / 2, 80), lineP(-f.w / 2, zb + 160, f.w / 2, zb + 160, 'thin'), lineP(-f.w / 2 + 120, zb + 160, -f.w / 2 + 120, f.d / 2, 'thin'), lineP(f.w / 2 - 120, zb + 160, f.w / 2 - 120, f.d / 2, 'thin')]; }
},
salontafel: {
label: 'Salontafel', cat: 'wonen', w: 800, d: 800, h: 400, resize: ['w', 'd', 'h'], wall: false, cost: null, opts: { vorm: 'rond' },
build(f, g) {
if (f.opts.vorm === 'rond') { ecyl(g, MAT.hout(), 0, 0, f.w / 2, f.d / 2, f.h - 30, f.h); ecyl(g, MAT.zwartMetaal(), 0, 0, f.w / 6, f.d / 6, 0, f.h - 30, 1.3); }
else { boxAt(g, MAT.hout(), -f.w / 2, f.w / 2, f.h - 30, f.h, -f.d / 2, f.d / 2); boxAt(g, MAT.zwartMetaal(), -f.w / 2 + 40, f.w / 2 - 40, 0, f.h - 30, -f.d / 2 + 40, f.d / 2 - 40); }
},
plan(f) { return [f.opts.vorm === 'rond' ? ellP(0, 0, f.w / 2, f.d / 2) : rectP(-f.w / 2, f.w / 2, -f.d / 2, f.d / 2)]; }
},
tvmeubel: {
label: 'Tv-meubel', cat: 'wonen', w: 1800, d: 400, h: 450, resize: ['w', 'd', 'h'], wall: true, cost: null, opts: { tv: true },
build(f, g) {
const zb = -f.d / 2;
boxAt(g, MAT.houtDonker(), -f.w / 2, f.w / 2, 150, f.h, zb, f.d / 2);
if (f.opts.tv) { const tw = Math.min(1450, f.w - 200); boxAt(g, MAT.scherm(), -tw / 2, tw / 2, 1000, 1000 + tw * 0.5625, zb, zb + 40); }
},
plan(f) { return [rectP(-f.w / 2, f.w / 2, -f.d / 2, f.d / 2), lineP(-f.w / 2, 0, f.w / 2, 0, 'thin')]; }
},
boiler: {
label: 'Elektrische boiler', cat: 'techniek', w: 450, d: 450, h: 800, resize: ['w', 'h'], wall: true, cost: 'boiler',
opts: { liter: 80 }, hint: 'Hangend aan de wand (onderkant 900 mm); w = diameter',
build(f, g) {
const r = f.w / 2;
ecyl(g, MAT.wit(), 0, 0, r, Math.min(r, f.d / 2), 900, 900 + f.h);
boxAt(g, MAT.zwartMetaal(), -40, 40, 860, 900, -20, 20);
[-60, 60].forEach(x => boxAt(g, MAT.chroom(), x - 10, x + 10, 400, 870, -f.d / 2 + 20, -f.d / 2 + 40));
},
plan(f) { return [ellP(0, 0, f.w / 2, f.d / 2), ellP(0, 0, f.w / 2 - 25, f.d / 2 - 25, 'thin'), { text: `EB ${f.opts.liter || 80}L`, at: { x: 0, z: 0 } }]; }
},
wasmachine: {
label: 'Wasmachine', cat: 'techniek', w: 600, d: 600, h: 850, resize: [], wall: true, cost: null, opts: {},
build(f, g) {
boxAt(g, MAT.wit(), -300, 300, 0, 850, -300, 280);
const door = ecyl(g, MAT.zwartMetaal(), 0, 0, 160, 160, 0, 10);
door.rotation.x = Math.PI / 2;
door.position.set(0, 0.45, 0.285);
},
plan() { return [rectP(-300, 300, -300, 300), circP(0, 0, 200, 'thin'), { text: 'WM', at: { x: 0, z: 0 } }]; }
}
};
function tap(g, x, y, z, reach) {
ecyl(g, MAT.chroom(), x, z, 18, 18, y, y + 180);
boxAt(g, MAT.chroom(), x - 10, x + 10, y + 160, y + 180, z, z + reach);
}
function rot2(x, z, deg) {
const a = deg * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
return { x: x * c - z * s, z: x * s + z * c };
}
function chairSpots(f) {
const n = Math.max(0, Math.min(12, Math.round(f.opts.stoelen || 0)));
const out = [];
const perSide = Math.ceil(n / 2);
[[-1, 180], [1, 0]].forEach(([s, r], k) => {
const m = k === 0 ? perSide : n - perSide;
for (let i = 0; i < m; i++) {
const x = -f.w / 2 + f.w * (i + 0.5) / m * 0.86 + f.w * 0.07;
const edge = f.opts.vorm === 'ovaal' ? (f.d / 2) * Math.sqrt(Math.max(0, 1 - (x / (f.w / 2)) ** 2)) : f.d / 2;
out.push({ x, z: s * (edge + 120), rot: r });
}
});
return out;
}
function chair(g, x, z, rotDeg) {
const c = new THREE.Group();
boxAt(c, MAT.stofDonker(), -225, 225, 430, 480, -220, 220);
boxAt(c, MAT.stofDonker(), -225, 225, 480, 880, 170, 220);
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => boxAt(c, MAT.zwartMetaal(), a * 195 - 12, a * 195 + 12, 0, 430, b * 190 - 12, b * 190 + 12));
c.rotation.y = -rotDeg * Math.PI / 180;
c.position.set(x * toM, 0, z * toM);
g.add(c);
}
// Van voren gezien: links = -x.
function kitchenModules(f, island) {
const tall = !island && (f.opts.hogeKast === 'links' || f.opts.hogeKast === 'rechts') && f.w >= 1200 ? f.opts.hogeKast : null;
const x0 = -f.w / 2 + (tall === 'links' ? 600 : 0), x1 = f.w / 2 - (tall === 'rechts' ? 600 : 0);
const n = Math.max(1, Math.round((x1 - x0) / 600));
return { tall, x0, x1, n, mw: (x1 - x0) / n };
}
function kitchenRun(g, f, island) {
const zb = -f.d / 2, zf = f.d / 2, top = f.h, front = frontMat(f.opts.front);
const K = kitchenModules(f, island);
boxAt(g, MAT.plint(), K.x0, K.x1, 0, 100, zb + (island ? 60 : 0), zf - 60);
boxAt(g, front, K.x0, K.x1, 100, top - 40, zb + (island ? 0 : 0), zf - 22);
for (let i = 0; i < K.n; i++) {
const a = K.x0 + i * K.mw + 1.5, b = K.x0 + (i + 1) * K.mw - 1.5;
boxAt(g, front, a, b, 105, top - 45, zf - 22, zf - 2);
if (island) boxAt(g, front, a, b, 105, top - 45, zb + 2, zb + 22);
}
boxAt(g, MAT.werkblad(), K.x0 - (island ? 20 : 0), K.x1 + (island ? 20 : 0), top - 40, top, zb - (island ? 20 : 0), zf + 20);
if (K.tall) {
const a = K.tall === 'links' ? -f.w / 2 : f.w / 2 - 600, b = a + 600;
boxAt(g, front, a, b, 0, 2200, zb, zf - 22);
boxAt(g, front, a + 2, b - 2, 105, 1190, zf - 22, zf - 2); // koelkastdeur
boxAt(g, MAT.scherm(), a + 20, b - 20, 1200, 1780, zf - 22, zf - 2); // oven
boxAt(g, MAT.zwartMetaal(), a + 30, b - 30, 1730, 1750, zf - 2, zf + 15);
boxAt(g, front, a + 2, b - 2, 1790, 2195, zf - 22, zf - 2);
}
if (!island) {
if (f.opts.spoel !== false) {
const sx = spoelX(f, K);
boxAt(g, MAT.chroom(), sx - 380, sx + 380, top - 2, top + 0.5, zb + 90, zf - 70);
boxAt(g, MAT.rubber(), sx - 360, sx + 360, top - 1, top + 1, zb + 105, zf - 85);
ecyl(g, MAT.zwartMetaal(), sx + 200, zb + 55, 22, 22, top, top + 300);
boxAt(g, MAT.zwartMetaal(), sx + 190, sx + 210, top + 280, top + 300, zb + 55, zb + 280);
}
if (f.opts.bovenkasten) boxAt(g, front, K.x0, K.x1, 1450, 2150, zb, zb + 350);
}
if (f.opts.kookplaat) {
const { hx, hw, hd } = hobOf(f, K, island);
boxAt(g, MAT.kookplaat(), hx - hw / 2, hx + hw / 2, top, top + 4, -hd / 2, hd / 2);
hobZones(hw, hd).forEach(z => ecyl(g, MAT.ring(), hx + z.x, z.z, z.r, z.r, top + 4, top + 5));
}
}
const hobZones = (hw, hd) => [[-0.25, -0.25, 0.19], [0.25, -0.25, 0.14], [-0.25, 0.25, 0.14], [0.25, 0.25, 0.19]]
.map(([a, b, r]) => ({ x: a * hw, z: b * hd, r: r * Math.min(hw, hd * 1.4) }));
function plekX(K, v, def) {
const t = Math.max(0, Math.min(1, v === undefined ? def : v));
return Math.max(K.x0 + 400, Math.min(K.x1 - 400, K.x0 + t * (K.x1 - K.x0)));
}
const spoelX = (f, K) => plekX(K, f.opts.spoelbak, 0.6);
// Kookeiland: kookplaat in het midden; keukenblok: op kookplaatPlek.
const hobOf = (f, K, island) => ({
hx: island ? 0 : plekX(K, f.opts.kookplaatPlek, 0.2), hw: Math.min(780, K.x1 - K.x0 - 100), hd: Math.min(520, f.d - 80)
});
function kitchenPlan(f, island) {
const zb = -f.d / 2, zf = f.d / 2, K = kitchenModules(f, island), out = [];
out.push(rectP(K.x0, K.x1, zb, zf));
for (let i = 1; i < K.n; i++) out.push(lineP(K.x0 + i * K.mw, zb, K.x0 + i * K.mw, zf, 'thin'));
if (K.tall) {
const a = K.tall === 'links' ? -f.w / 2 : f.w / 2 - 600;
out.push(rectP(a, a + 600, zb, zf), lineP(a, zb, a + 600, zf, 'thin'), lineP(a, zf, a + 600, zb, 'thin'));
}
if (!island && f.opts.spoel !== false) {
const sx = spoelX(f, K);
out.push(rrectP(sx - 360, sx + 360, zb + 105, zf - 85, 40, 'thin'), circP(sx + 200, zb + 55, 22, 'thin'));
}
if (f.opts.kookplaat) {
const { hx, hw, hd } = hobOf(f, K, island);
out.push(rectP(hx - hw / 2, hx + hw / 2, -hd / 2, hd / 2, 'thin'));
hobZones(hw, hd).forEach(z => out.push(circP(hx + z.x, z.z, z.r, 'thin')));
}
return out;
}
const ROTS = [0, 90, 180, 270];
function create(state, type, unitId, x, z, rot) {
const T = TYPES[type] || TYPES.bed;
const id = (state.fixtures || []).reduce((m, f) => Math.max(m, f.id || 0), 0) + 1;
return normalizeOne({ id, type, unitId, x: Math.round(x), z: Math.round(z), rot: rot || 0, w: T.w, d: T.d, h: T.h, opts: Object.assign({}, T.opts || {}) });
}
function normalizeOne(f, i) {
const T = TYPES[f.type];
if (!T) return null;
const num = (v, def, min) => (isFinite(v) && v > 0 ? Math.max(min || 50, Math.round(v)) : def);
return {
id: f.id > 0 ? f.id : (i || 0) + 1, type: f.type, unitId: String(f.unitId || 'A'),
x: Math.round(Number(f.x) || 0), z: Math.round(Number(f.z) || 0), anchor: f.anchor === 'oost' ? 'oost' : 'west', rot: ((Math.round(Number(f.rot) || 0) % 360) + 360) % 360,
w: T.resize.includes('w') ? num(f.w, T.w, T.minW) : T.w, d: T.resize.includes('d') ? num(f.d, T.d) : T.d, h: T.resize.includes('h') ? num(f.h, T.h) : T.h,
opts: Object.assign({}, T.opts || {}, f.opts || {})
};
}
function normalize(list, unitIds) {
return (Array.isArray(list) ? list : []).map(normalizeOne).filter(f => f && (!unitIds || unitIds.includes(f.unitId)));
}
function toWorld(item, lx, lz) {
const p = rot2(lx, lz, item.rot);
return { x: item.cx + p.x, z: item.cz + p.z };
}
function compute(list, units) {
return (list || []).map(f => {
const u = units.find(q => q.id === f.unitId);
if (!u) return null;
const T = TYPES[f.type];
const item = { id: f.id, type: f.type, label: T.label, cat: T.cat, cost: T.cost, state: f, w: f.w, d: f.d, h: f.h, opts: f.opts, rot: f.rot,
unitId: f.unitId, cx: f.anchor === 'oost' ? u.rect.x1 - f.x : u.rect.x0 + f.x, cz: u.rect.z0 + f.z, floorTop: u.layout.floorTop };
item.corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([a, b]) => toWorld(item, a * f.w / 2, b * f.d / 2));
return item;
}).filter(Boolean);
}
function planShapes(item) {
const T = TYPES[item.type];
const f = { w: item.w, d: item.d, h: item.h, opts: item.opts };
return T.plan(f).map(p => {
if (p.circle) { const c = toWorld(item, p.circle.x, p.circle.z); return Object.assign({}, p, { circle: { x: c.x, z: c.z, r: p.circle.r } }); }
if (p.text) return Object.assign({}, p, { at: toWorld(item, p.at.x, p.at.z) });
return Object.assign({}, p, { pts: p.pts.map(q => toWorld(item, q.x, q.z)) });
});
}
function contains(item, x, z, tol) {
const p = rot2(x - item.cx, z - item.cz, -item.rot), t = tol || 0;
return Math.abs(p.x) <= item.w / 2 + t && Math.abs(p.z) <= item.d / 2 + t;
}
function hit(items, x, z, tol) {
// Kleinste eerst (een fontein op een groter stuk blijft selecteerbaar).
return items.filter(it => contains(it, x, z, tol)).sort((a, b) => a.w * a.d - b.w * b.d)[0] || null;
}
function snap(item, x, z, pieces, reach) {
const T = TYPES[item.type], R = reach || 350;
const grid = (v) => Math.round(v / 10) * 10;
if (!T.wall) return { x: grid(x), z: grid(z), rot: item.rot };
let best = null;
(pieces || []).forEach(p => {
if (p.place.baseY) return;
const pl = p.place, alongX = Math.abs(Math.sin(pl.rotY)) < 0.5;
const half = pl.width / 2, t = pl.thickness / 2;
const s0 = (alongX ? pl.cx : pl.cz) - half, s1 = (alongX ? pl.cx : pl.cz) + half;
const s = alongX ? x : z, n = alongX ? z : x, c = alongX ? pl.cz : pl.cx;
if (s < s0 - 50 || s > s1 + 50) return;
[-1, 1].forEach(side => {
const face = c + side * t, dist = (n - face) * side;
if (dist < -50 || dist > R + item.d / 2) return;
const gap = Math.abs(dist - item.d / 2);
if (best && gap >= best.gap) return;
const rot = alongX ? (side > 0 ? 0 : 180) : (side > 0 ? 270 : 90);
const sC = s1 - s0 > item.w ? Math.max(s0 + item.w / 2, Math.min(s1 - item.w / 2, s)) : (s0 + s1) / 2;
const nC = face + side * item.d / 2;
best = { gap, x: alongX ? sC : nC, z: alongX ? nC : sC, rot };
});
});
return best ? { x: grid(best.x), z: grid(best.z), rot: best.rot } : { x: grid(x), z: grid(z), rot: item.rot };
}
function build3D(items) {
const group = new THREE.Group();
items.forEach(it => {
const g = new THREE.Group();
TYPES[it.type].build({ w: it.w, d: it.d, h: it.h, opts: it.opts }, g);
g.rotation.y = -it.rot * Math.PI / 180;
g.position.set(it.cx * toM, it.floorTop * toM, it.cz * toM);
g.userData.fixture = it.id;
group.add(g);
});
return group;
}
const blocks = (item) => item.type !== 'douche';
return { TYPES, CATS, FRONTS, ROTS, create, normalize, compute, planShapes, contains, hit, snap, build3D, blocks, toWorld };
})();