/*! * 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', spoelbak: 0.6, hogeKast: 'links', vaatwasser: true, bovenkasten: false }, hint: 'Onderkasten met werkblad en spoelbak (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) { // Spoelbak + kraan 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); } else if (f.opts.kookplaat) { const hw = Math.min(780, K.x1 - K.x0 - 100), hd = Math.min(520, f.d - 80); boxAt(g, MAT.kookplaat(), -hw / 2, hw / 2, top, top + 4, -hd / 2, hd / 2); hobZones(hw, hd).forEach(z => ecyl(g, MAT.ring(), 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 spoelX(f, K) { const t = Math.max(0, Math.min(1, f.opts.spoelbak === undefined ? 0.6 : f.opts.spoelbak)); return Math.max(K.x0 + 400, Math.min(K.x1 - 400, K.x0 + t * (K.x1 - K.x0))); } 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) { 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')); } else if (f.opts.kookplaat) { const hw = Math.min(780, K.x1 - K.x0 - 100), hd = Math.min(520, f.d - 80); out.push(rectP(-hw / 2, hw / 2, -hd / 2, hd / 2, 'thin')); hobZones(hw, hd).forEach(z => out.push(circP(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 }; })();