/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // ============================================================ // house/house.js — Woning-editor (UI). Een woning = units die aan elkaar // gekoppeld zijn + binnenwanden (later ook dak, chassis voor transport, ...). // // Opbouw van het scherm: // - Links een navigatieboom: Overzicht, per unit de vloer en 4 wanden, // binnenwanden, en acties (+ Unit, + Binnenwand). // - Rechts het actieve onderdeel: het woning-overzicht (plattegrond + // eigenschappen + 3D + totalen), of de ECHTE WallEditor/FloorEditor voor // één onderdeel. // // Geen eigen wand-/vloer-/unitlogica: alles via HouseModel/UnitModel/ // WallModel/FloorModel en HousePdf. // Plain script, geladen na house-model.js en house-pdf.js. // ============================================================ const HOUSE_TEMPLATE = `
Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.
`; const HOUSE_OVERVIEW_TEMPLATE = `
PLATTEGROND
3D
`; const SPACING_LOCK_MSG = 'De h.o.h. is globaal ingesteld voor alle wanden van de woning (Overzicht, niets geselecteerd).'; const HOUSE_PROJECT_KEYS = ['naam', 'plaats', 'straat', 'nummer', 'tekenaar', 'opmerking']; const HOUSE_SIDES = ['voor', 'achter', 'links', 'rechts']; function houseFmt(n, decimals = 1) { return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } function mountHouseEditor(container, callbacks) { const onSaved = callbacks && callbacks.onSaved; container.innerHTML = HOUSE_TEMPLATE; const host = document.getElementById('house-part-host'); let state = HouseModel.createDefaultState((callbacks && callbacks.template) || 'single'); let activePart = null; // 'overzicht' | 'floor:A' | 'wall:A:voor' | 'iw:3' let activeEditor = null; // WallEditor/FloorEditor-instantie of het overzicht // Selectie in het overzicht (blijft bewaard bij wisselen van onderdeel): // null | { type: 'unit', id } | { type: 'wall', unit, side, element } | { type: 'iw', id } let selection = null; let pendingTool = null; // 'iw' = direct in teken-modus bij openen overzicht // Camerastand per onderdeel onthouden (CameraMemory), per tabblad. const camKey = (part) => `${(callbacks && callbacks.cameraKey) || 'woning'}:${part}`; function setIoStatus(msg, cls) { const el = document.getElementById('h-io-status'); el.textContent = msg; el.className = 'io-status ' + (cls || ''); clearTimeout(setIoStatus._t); setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000); } const unitById = (id) => state.units.find(u => u.id === id); const iwById = (id) => state.interiorWalls.find(w => w.id === id); // ------------------------------------------------------------ // Onderdelen mounten // ------------------------------------------------------------ function flushActivePart() { if (!activeEditor || typeof activeEditor.getStateJson !== 'function' || !activePart) return; const [kind, a, b] = activePart.split(':'); if (kind === 'floor' && unitById(a)) unitById(a).unit.floor = activeEditor.getStateJson(); else if (kind === 'wall' && unitById(a)) unitById(a).unit.walls[b] = activeEditor.getStateJson(); else if (kind === 'roof' && unitById(a)) unitById(a).roof.deck = activeEditor.getStateJson(); else if (kind === 'iw' && iwById(Number(a))) iwById(Number(a)).wall = activeEditor.getStateJson(); } function showPart(part) { flushActivePart(); if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount(); activeEditor = null; host.innerHTML = ''; activePart = part; const [kind, a, b] = part.split(':'); if (kind === 'floor' && unitById(a)) { activeEditor = FloorEditor.mount(host, { cameraKey: camKey(part) }); activeEditor.loadStateJson(HouseModel.clone(unitById(a).unit.floor)); } else if (kind === 'wall' && unitById(a)) { const parts = HouseModel.computeParts(state, { skip3D: true }); const wl = parts.units.find(u => u.id === a).walls.find(w => w.side === b); activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'Binnen een woning volgt de wandbreedte uit de vloer van de unit.', lockSpacing: SPACING_LOCK_MSG, lockCladding: 'De richting van de gevelbekleding is (voorlopig) globaal: Overzicht, niets geselecteerd.', seamCuts: wl.cutsU, lamps: [].concat(...wl.pieces.map(p => p.lamps.map(l => ({ u: p.u0 + l.u, y: l.y })))) }); activeEditor.loadStateJson(HouseModel.clone(unitById(a).unit.walls[b])); } else if (kind === 'roof' && unitById(a)) { HouseModel.applyGlobalSettings(state); // dakmaat volgt evt. de vloer const u = unitById(a); activeEditor = FloorEditor.mount(host, { mode: 'roof', cameraKey: camKey(part), lockSize: u.roof.followFloor ? 'Dit dak volgt de vloermaat. Zet "Dak volgt vloer" uit bij de unit (overzicht) om de maat aan te passen.' : null }); activeEditor.loadStateJson(HouseModel.clone(u.roof.deck)); } else if (kind === 'iw' && iwById(Number(a))) { HouseModel.applyGlobalSettings(state); HouseModel.interiorLayout(iwById(Number(a))); // breedte bijwerken activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'De lengte van een binnenwand volgt uit de tekening (overzicht).', lockSpacing: SPACING_LOCK_MSG }); activeEditor.loadStateJson(HouseModel.clone(iwById(Number(a)).wall)); } else { activePart = 'overzicht'; activeEditor = mountOverview(); } renderNav(); } // ------------------------------------------------------------ // Navigatieboom // ------------------------------------------------------------ function renderNav() { const parts = HouseModel.computeParts(state, { skip3D: true }); const item = (part, label, meta, cls, iconName) => `
${iconName ? UI.icon(iconName) : ''}${label}${meta ? `${meta}` : ''}
`; let html = item('overzicht', 'Overzicht', '', 'hn-top', 'grid-1x2'); parts.units.forEach(u => { const sel = selection && selection.type === 'unit' && selection.id === u.id && activePart === 'overzicht'; html += `
`; html += `
${UI.icon('box')}Unit ${u.id}${u.rect.W} × ${u.rect.D}
`; html += item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub', 'grid-3x3'); html += item(`roof:${u.id}`, 'Dak', u.roof.element, 'hn-sub', 'house'); u.walls.forEach(w => { const meta = w.removed ? 'vervalt' : w.pieces.map(p => p.element).join(', '); html += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : ''), 'bricks'); }); html += `
`; }); if (parts.interior.length) { html += `
${UI.icon('distribute-horizontal')}Binnenwanden
`; parts.interior.forEach(p => { html += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub', 'distribute-horizontal'); }); html += `
`; } document.getElementById('house-nav-tree').innerHTML = html; } document.getElementById('house-nav-tree').addEventListener('click', (e) => { const unitRow = e.target.closest('[data-select-unit]'); if (unitRow) { selection = { type: 'unit', id: unitRow.dataset.selectUnit }; if (activePart !== 'overzicht') showPart('overzicht'); else { activeEditor.refresh(); renderNav(); } return; } const row = e.target.closest('[data-part]'); if (row && row.dataset.part !== activePart) showPart(row.dataset.part); }); // ------------------------------------------------------------ // Acties: unit toevoegen/verwijderen, binnenwand // ------------------------------------------------------------ function addUnit() { flushActivePart(); const parent = (selection && selection.type === 'unit' && unitById(selection.id)) || state.units[state.units.length - 1]; const taken = new Set(state.units.filter(u => u.attach && u.attach.to === parent.id).map(u => u.attach.side)); if (parent.attach) taken.add({ voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' }[parent.attach.side]); const side = ['rechts', 'achter', 'links', 'voor'].find(s => !taken.has(s)) || 'rechts'; const id = HouseModel.nextUnitId(state); state.units.push(HouseModel.createUnitEntry(id, { to: parent.id, side, align: 'start', offset: 0 }, { width: 3000, depth: 3000, windowOn: [] })); selection = { type: 'unit', id }; showPart('overzicht'); } function removeUnit(id) { if (state.units[0].id === id) return; if (state.units.some(u => u.attach && u.attach.to === id)) { setIoStatus(`Unit ${id} kan niet weg: er zijn units aan gekoppeld.`, 'err'); return; } state.units = state.units.filter(u => u.id !== id); selection = null; showPart('overzicht'); } document.getElementById('hn-add-unit').addEventListener('click', addUnit); document.getElementById('hn-add-iw').addEventListener('click', () => { pendingTool = 'iw'; if (activePart !== 'overzicht') showPart('overzicht'); else activeEditor.setTool('iw'); }); // ------------------------------------------------------------ // OVERZICHT // ------------------------------------------------------------ function mountOverview() { host.innerHTML = HOUSE_OVERVIEW_TEMPLATE; const canvas = document.getElementById('ho-canvas'); const ctx = canvas.getContext('2d'); const propsEl = document.getElementById('ho-props'); // --- 3D --- const pane3D = document.getElementById('ho-pane-3d'); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); const camera = new THREE.PerspectiveCamera(45, pane3D.clientWidth / pane3D.clientHeight, 0.1, 400); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(pane3D.clientWidth, pane3D.clientHeight); renderer.setPixelRatio(window.devicePixelRatio); renderer.shadowMap.enabled = true; pane3D.appendChild(renderer.domElement); const controls = new THREE.OrbitControls(camera, renderer.domElement); const overviewCamKey = camKey('overzicht'); const ambient = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambient); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 8, 5); scene.add(dirLight); const grid = new THREE.GridHelper(40, 80, 0x444444, 0x222222); scene.add(grid); // Maaiveld: alleen in de volle render, zodat het licht op de grond valt. const ground = new THREE.Mesh(new THREE.PlaneGeometry(80, 80), new THREE.MeshStandardMaterial({ color: 0x3a3d40, roughness: 1 })); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.002; ground.visible = false; scene.add(ground); const wallMaterials = WallModel.createMaterials(); const floorMaterials = FloorModel.createMaterials(); const roofMaterials = RoofModel.createMaterials(); const lightingMaterials = LightingModel.createMaterials(); const sandwichMaterials = SandwichModel.createMaterials(); let houseGroup = null; let dakVisible = true; // Opgeslagen camerastand gaat voor; anders eenmalig passend maken. let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls); let beplatingVisible = true, isolatieVisible = true, lampenVisible = true; let fullRender = false; let skyOn = Skybox.isOn(); // Weergave-filter (core/view-filter.js). Beplating = wandplaten, vloer- // en plafondplaten, dakleer en boeideel/daktrim. createViewFilter(document.getElementById('ho-toolbar-3d'), [ { key: 'beplating', label: 'Beplating' }, { key: 'isolatie', label: 'Isolatie' }, { key: 'dak', label: 'Dak' }, { key: 'lampen', label: 'Lampen' } ], (v) => { beplatingVisible = v.beplating; isolatieVisible = v.isolatie; dakVisible = v.dak; lampenVisible = v.lampen; refresh({ keepProps: true }); }); // Volle render: avondscène, lampen en spots als echte lichtbronnen. let lightsGroup = null; // Lucht (core/skybox.js): alleen in de gewone weergave (de volle render is // een avondscène). De sandwichpanelen zitten in de grond: met lucht aan // zijn ze niet zichtbaar. function applyRenderMode() { if (fullRender) scene.background = new THREE.Color(0x0b0f18); else Skybox.apply(scene, skyOn, 0x1a1a1a, () => skyOn && !fullRender); if (parts && parts.sandwichGroup) parts.sandwichGroup.visible = beplatingVisible && !(skyOn && !fullRender); ambient.intensity = fullRender ? 0.1 : 0.6; dirLight.intensity = fullRender ? 0.12 : 0.8; ground.visible = fullRender; grid.visible = !fullRender && !skyOn; if (lightsGroup) { scene.remove(lightsGroup); lightsGroup = null; } // De nep-lichtbundels (kegels) alleen in de snelle weergave; in de // volle render schijnt er echt licht. if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !fullRender; }); if (fullRender && parts && lampenVisible) { const edge = dakVisible ? parts.roofEdge : null; lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0); scene.add(lightsGroup); } } Skybox.createToggle(document.getElementById('ho-toolbar-3d'), (on) => { skyOn = on; applyRenderMode(); }); document.getElementById('ho-btn-render').addEventListener('click', (e) => { fullRender = !fullRender; e.currentTarget.classList.toggle('active', fullRender); applyRenderMode(); }); // --- Plattegrond: view-transform + hit-tests --- let parts = null; let view = { scale: 1, ox: 0, oy: 0 }; const toPx = (x, z) => ({ x: view.ox + x * view.scale, y: view.oy + z * view.scale }); const toWorld = (mx, my) => ({ x: (mx - view.ox) / view.scale, z: (my - view.oy) / view.scale }); function pieceHit(place, x, z, tolPx) { const c = Math.cos(place.rotY), s = Math.sin(place.rotY); const dx = x - place.cx, dz = z - place.cz; const lx = dx * c - dz * s, lz = dx * s + dz * c; const tol = tolPx / view.scale; return Math.abs(lx) <= place.width / 2 + tol && Math.abs(lz) <= place.thickness / 2 + tol; } function hitTest(x, z) { const iw = parts.interior.find(p => pieceHit(p.place, x, z, 4)); if (iw) return { type: 'iw', id: iw.id }; for (const u of parts.units) { for (const w of u.walls) { const p = w.pieces.find(pc => pieceHit(pc.place, x, z, 3)); if (p) return { type: 'wall', unit: u.id, side: w.side, element: p.element }; } } const r = parts.rects.find(rc => x >= rc.x0 && x <= rc.x1 && z >= rc.z0 && z <= rc.z1); return r ? { type: 'unit', id: r.id } : null; } const sameSel = (a, b) => a && b && a.type === b.type && (a.id === b.id) && (a.element === b.element); // --- Tekenen van binnenwanden --- let tool = 'select'; let drawStart = null; // { x, z } eerste klik let hover = null; // { x, z } gesnapt cursorpunt function setTool(t) { tool = t; drawStart = null; document.getElementById('ho-tool-select').classList.toggle('active', t === 'select'); document.getElementById('ho-tool-iw').classList.toggle('active', t === 'iw'); canvas.style.cursor = t === 'iw' ? 'crosshair' : 'default'; updateHint(); drawPlan(); } document.getElementById('ho-tool-select').addEventListener('click', () => setTool('select')); document.getElementById('ho-tool-iw').addEventListener('click', () => setTool('iw')); function updateHint() { document.getElementById('ho-hint').textContent = tool === 'iw' ? (drawStart ? 'Klik het eindpunt (Esc = annuleren). Snapt op 50 mm, haaks, en op wandvlakken.' : 'Klik het beginpunt van de binnenwand.') : 'Klik = selecteren · dubbelklik = bewerken'; } // Snappen: 50 mm raster, haaks op het beginpunt, en op wandvlakken // (binnen/buitenzijde van wanden) binnen 12 px. function snapPoint(x, z, from) { let sx = Math.round(x / 50) * 50, sz = Math.round(z / 50) * 50; if (from) { if (Math.abs(x - from.x) >= Math.abs(z - from.z)) sz = from.z; else sx = from.x; } const tol = 12 / view.scale; const facesX = [], facesZ = []; HouseModel.allWallPieces(parts).forEach(p => { const alongX = Math.abs(Math.sin(p.place.rotY)) < 0.5; (alongX ? facesZ : facesX).push((alongX ? p.place.cz : p.place.cx) - p.place.thickness / 2, (alongX ? p.place.cz : p.place.cx) + p.place.thickness / 2); }); const nearest = (v, list) => list.reduce((best, f) => Math.abs(f - v) < Math.abs(best - v) ? f : best, Infinity); const fx = nearest(x, facesX), fz = nearest(z, facesZ); if (!(from && sx === from.x) && Math.abs(fx - x) < tol) sx = fx; if (!(from && sz === from.z) && Math.abs(fz - z) < tol) sz = fz; return { x: Math.round(sx), z: Math.round(sz) }; } function createInteriorWall(a, b) { if (Math.hypot(b.x - a.x, b.z - a.z) < 300) { setIoStatus('Binnenwand te kort (min. 300 mm).', 'err'); return; } const id = state.interiorWalls.reduce((m, w) => Math.max(m, w.id), 0) + 1; const height = Math.min(...state.units.map(u => Math.min(u.unit.walls.voor.wall.heightLeft, u.unit.walls.voor.wall.heightRight))); state.interiorWalls.push({ id, x1: a.x, z1: a.z, x2: b.x, z2: b.z, wall: HouseModel.interiorWallDefaults(HouseModel.DEFAULT_INTERIOR_THICKNESS, height) }); selection = { type: 'iw', id }; refresh(); } canvas.addEventListener('mousemove', (e) => { if (tool !== 'iw' || !parts) return; const r = canvas.getBoundingClientRect(); const w = toWorld(e.clientX - r.left, e.clientY - r.top); hover = snapPoint(w.x, w.z, drawStart); drawPlan(); }); canvas.addEventListener('click', (e) => { if (!parts) return; const r = canvas.getBoundingClientRect(); const w = toWorld(e.clientX - r.left, e.clientY - r.top); if (tool === 'iw') { const p = snapPoint(w.x, w.z, drawStart); if (!drawStart) drawStart = p; else { const a = drawStart; drawStart = null; createInteriorWall(a, p); } updateHint(); drawPlan(); return; } const hit = hitTest(w.x, w.z); if (!sameSel(hit, selection)) { selection = hit; renderProps(); drawPlan(); renderNav(); } }); canvas.addEventListener('dblclick', (e) => { if (tool !== 'select' || !parts) return; const r = canvas.getBoundingClientRect(); const w = toWorld(e.clientX - r.left, e.clientY - r.top); const hit = hitTest(w.x, w.z); if (!hit) return; if (hit.type === 'wall') showPart(`wall:${hit.unit}:${hit.side}`); else if (hit.type === 'iw') showPart(`iw:${hit.id}`); else if (hit.type === 'unit') showPart(`floor:${hit.id}`); }); function onKey(e) { if (['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; if (e.key === 'Escape') { if (drawStart) { drawStart = null; updateHint(); drawPlan(); } else if (tool === 'iw') setTool('select'); } else if (e.key === 'Delete' && selection && selection.type === 'iw') { state.interiorWalls = state.interiorWalls.filter(w => w.id !== selection.id); selection = null; refresh(); } } window.addEventListener('keydown', onKey); // --- Plattegrond tekenen --- function polyPath(pts) { ctx.beginPath(); pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i === 0) ctx.moveTo(q.x, q.y); else ctx.lineTo(q.x, q.y); }); ctx.closePath(); } function pieceCorners(place, lx0, lx1) { const t = place.thickness / 2; return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([lx, lz]) => UnitModel.localToWorld(place, lx, lz)); } function drawPlan() { if (!parts) return; const rect = canvas.parentElement.getBoundingClientRect(); canvas.width = rect.width; canvas.height = rect.height; const w = canvas.width, h = canvas.height; ctx.clearRect(0, 0, w, h); const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1)); const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1)); const scale = Math.min(w / ((bx1 - bx0) * 1.45), h / ((bz1 - bz0) * 1.45)); view = { scale, ox: w / 2 - ((bx0 + bx1) / 2) * scale, oy: h / 2 - ((bz0 + bz1) / 2) * scale }; ctx.strokeStyle = '#222'; ctx.lineWidth = 1; const g = 500 * scale, gx = view.ox % g, gy = view.oy % g; for (let x = gx; x < w; x += g) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } for (let y = gy; y < h; y += g) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } const overlapIds = new Set(parts.overlaps.flat()); parts.rects.forEach(r => { const p0 = toPx(r.x0, r.z0); const sel = selection && selection.type === 'unit' && selection.id === r.id; ctx.fillStyle = sel ? 'rgba(77, 166, 255, 0.18)' : 'rgba(205, 168, 106, 0.12)'; ctx.fillRect(p0.x, p0.y, r.W * scale, r.D * scale); ctx.strokeStyle = overlapIds.has(r.id) ? '#ff5544' : sel ? '#4da6ff' : '#666'; ctx.lineWidth = sel || overlapIds.has(r.id) ? 2 : 1; ctx.strokeRect(p0.x, p0.y, r.W * scale, r.D * scale); const c = toPx(r.cx, r.cz); ctx.fillStyle = 'rgba(255, 255, 255, 0.18)'; ctx.font = `bold ${Math.max(14, Math.min(48, r.D * scale * 0.3))}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(r.id, c.x, c.y); ctx.textBaseline = 'alphabetic'; }); const drawPiece = (p, selected, color) => { polyPath(pieceCorners(p.place, -p.place.width / 2, p.place.width / 2)); ctx.fillStyle = selected ? '#4da6ff' : color; ctx.fill(); ctx.strokeStyle = '#8a6836'; ctx.lineWidth = 1; ctx.stroke(); p.config.openings.forEach(op => { polyPath(pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w)); ctx.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.85)' : (op.y < p.config.plateThick ? '#1d5f99' : '#9fd0ff'); ctx.fill(); }); }; parts.units.forEach(u => u.walls.forEach(wl => wl.pieces.forEach(p => { drawPiece(p, selection && selection.type === 'wall' && selection.element === p.element, '#c29b62'); }))); parts.interior.forEach(p => drawPiece(p, selection && selection.type === 'iw' && selection.id === p.id, '#d8c49a')); // Naden ctx.save(); ctx.strokeStyle = '#ff9f43'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); parts.seams.forEach(s => { const a = toPx(s.x0, s.z0), b = toPx(s.x1, s.z1); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); }); ctx.restore(); // Maatlabels buiten elk buiten-wandstuk (stijl: core/canvas-labels.js) parts.units.forEach(u => u.walls.forEach(wl => wl.pieces.forEach(p => { const n = p.place.normal, off = 14; const a = toPx(...Object.values(UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2))); const b = toPx(...Object.values(UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2))); const x0 = a.x + n.x * off, y0 = a.y + n.y * off, x1 = b.x + n.x * off, y1 = b.y + n.y * off; drawCanvasDim(ctx, x0, y0, x1, y1, `${p.element}: ${p.place.width}mm`, (x0 + x1) / 2 + (n.x < 0 ? -4 : n.x > 0 ? 12 : 0), (y0 + y1) / 2 + (n.y < 0 ? -4 : n.y > 0 ? 11 : 0)); }))); parts.interior.forEach(p => { const c = toPx(p.place.cx, p.place.cz); ctx.save(); ctx.translate(c.x, c.y); if (p.place.rotY !== 0) ctx.rotate(-Math.PI / 2); ctx.fillStyle = '#4da6ff'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`${p.element}: ${p.place.width}mm`, 0, -p.place.thickness / 2 * scale - 4); ctx.restore(); }); // Dak: dakranden (gestippeld) + boeideel/daktrim-buitenrand + spots ctx.save(); ctx.setLineDash([3, 4]); ctx.strokeStyle = 'rgba(200, 200, 200, 0.55)'; ctx.lineWidth = 1; parts.units.forEach(u => { const a = toPx(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(a.x, a.y, u.roof.rect.W * scale, u.roof.rect.D * scale); }); if (parts.roofEdge) { parts.roofEdge.segments.forEach(sg => { ctx.strokeStyle = sg.fascia ? 'rgba(232, 136, 61, 0.9)' : 'rgba(190, 195, 200, 0.9)'; const a = toPx(sg.outerA.x, sg.outerA.z), b = toPx(sg.outerB.x, sg.outerB.z); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); }); ctx.setLineDash([]); ctx.fillStyle = '#ffe9a8'; parts.roofEdge.spots.forEach(sp => { const q = toPx(sp.x, sp.z); ctx.beginPath(); ctx.arc(q.x, q.y, 3, 0, Math.PI * 2); ctx.fill(); }); } ctx.restore(); // Gevellampen: blokje op het gevelvlak ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1; parts.lighting.lamps.forEach(l => { const q = toPx(l.x + l.normal.x * 60, l.z + l.normal.z * 60); ctx.fillRect(q.x - 3.5, q.y - 3.5, 7, 7); ctx.strokeRect(q.x - 3.5, q.y - 3.5, 7, 7); }); // Noordpijl (boven = noord; wanden/zijden heten naar windrichting) ctx.save(); ctx.translate(w - 34, 78); ctx.fillStyle = '#4da6ff'; ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(0, -18); ctx.lineTo(7, 6); ctx.lineTo(0, 1); ctx.lineTo(-7, 6); ctx.closePath(); ctx.fill(); ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('N', 0, 20); ctx.restore(); // Teken-preview binnenwand if (tool === 'iw' && hover) { const t = HouseModel.DEFAULT_INTERIOR_THICKNESS / 2; if (drawStart) { const alongX = Math.abs(hover.x - drawStart.x) >= Math.abs(hover.z - drawStart.z); const pts = alongX ? [{ x: drawStart.x, z: drawStart.z - t }, { x: hover.x, z: drawStart.z - t }, { x: hover.x, z: drawStart.z + t }, { x: drawStart.x, z: drawStart.z + t }] : [{ x: drawStart.x - t, z: drawStart.z }, { x: drawStart.x + t, z: drawStart.z }, { x: drawStart.x + t, z: hover.z }, { x: drawStart.x - t, z: hover.z }]; polyPath(pts); ctx.fillStyle = 'rgba(77, 166, 255, 0.35)'; ctx.fill(); ctx.strokeStyle = '#4da6ff'; ctx.stroke(); const len = Math.round(alongX ? Math.abs(hover.x - drawStart.x) : Math.abs(hover.z - drawStart.z)); const hp = toPx(hover.x, hover.z); ctx.fillStyle = '#fff'; ctx.font = '11px sans-serif'; ctx.textAlign = 'left'; ctx.fillText(len + ' mm', hp.x + 10, hp.y - 8); } const hp = toPx(hover.x, hover.z); ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(hp.x - 7, hp.y); ctx.lineTo(hp.x + 7, hp.y); ctx.moveTo(hp.x, hp.y - 7); ctx.lineTo(hp.x, hp.y + 7); ctx.stroke(); } } // --- Eigenschappenpaneel (contextafhankelijk, opgebouwd met UI-kit) --- const { num, sel, toggle, section, info, button, actions } = UI; const cols = UI.grid; // (grid = de 3D-rasterhulp) UI.wireTabs(propsEl); const head = (iconName, title, sub) => `

${UI.icon(iconName)}${title}

${sub ? `${sub}` : ''}
`; const after = () => refresh({ keepProps: true }); // getal: panelen blijven staan const rerender = () => refresh(); // keuze: velden kunnen wijzigen function renderProps() { const warn = (parts.overlaps.length ? UI.warn(`Units overlappen elkaar: ${parts.overlaps.map(p => p.join(' + ')).join(', ')}.`) : '') + parts.rects.filter(r => r.error).map(r => UI.warn(`Unit ${r.id}: ${r.error}`)).join(''); if (selection && selection.type === 'unit' && unitById(selection.id)) renderUnitProps(unitById(selection.id), warn); else if (selection && selection.type === 'wall') renderWallProps(warn); else if (selection && selection.type === 'iw' && iwById(selection.id)) renderIwProps(iwById(selection.id), warn); else renderHouseProps(warn); } function renderUnitProps(u, warn) { const isRoot = state.units[0].id === u.id; const a = u.attach; const axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z'; const f = u.unit.floor.floor; const koppeling = isRoot ? info('Basis-unit: de noordwesthoek ligt op (0, 0). Andere units koppelen hieraan.') : sel('hp-att-to', 'Gekoppeld aan', Object.fromEntries(state.units.filter(o => o.id !== u.id).map(o => [o.id, 'Unit ' + o.id])), a.to) + sel('hp-att-side', 'Zijde', HouseModel.SIDE_LABELS, a.side) + sel('hp-att-align', 'Uitlijning', HouseModel.ALIGN_LABELS[axis], a.align) + num('hp-att-offset', 'Verschuiving', a.offset || 0); const dak = toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) + (u.roof.followFloor ? '' : num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) + num('hp-roof-d', 'Diepte', u.roof.deck.floor.depth, { hint: 'Noord–zuid' }) + num('hp-roof-ox', 'Verschuiving oost', u.roof.offsetX || 0) + num('hp-roof-oz', 'Verschuiving zuid', u.roof.offsetZ || 0)) + toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim van 80 mm' }); propsEl.innerHTML = warn + head('box', `Unit ${u.id}`, `${f.width} × ${f.depth} mm`) + UI.tabs('unit', [ { key: 'maat', label: 'Afmetingen', icon: 'rulers', html: cols( num('hp-width', 'Breedte', f.width, { hint: 'West–oost' }) + num('hp-depth', 'Diepte', f.depth, { hint: 'Noord–zuid' }), sel('hp-corners', 'Hoekaansluiting', UnitModel.CORNER_MODES, u.unit.layout.corners)) }, { key: 'koppeling', label: 'Koppeling', icon: 'link-45deg', html: cols(koppeling, '') }, { key: 'dak', label: 'Dak', icon: 'house', html: cols(dak, u.roof.followFloor ? info('Het dak heeft de maat van de vloer. Zet "Maat volgt vloer" uit voor een eigen maat of verschuiving.') : '') } ]) + actions( button('Vloer bewerken', 'grid-3x3', `data-go="floor:${u.id}"`), button('Dak bewerken', 'house', `data-go="roof:${u.id}"`), isRoot ? '' : button('Unit verwijderen', 'trash', `data-remove-unit="${u.id}"`, 'ui-btn-danger')); UI.onNum('hp-width', v => { if (v >= 1000) f.width = v; }, after); UI.onNum('hp-depth', v => { if (v >= 1000) f.depth = v; }, after); UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after); UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender); UI.onSel('hp-att-to', v => { u.attach.to = v; }, rerender); UI.onSel('hp-att-side', v => { u.attach.side = v; }, rerender); UI.onSel('hp-att-align', v => { u.attach.align = v; }, rerender); UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender); UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender); UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after); UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after); UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after); UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after); } function renderWallProps(warn) { const pu = parts.units.find(x => x.id === selection.unit); const wl = pu && pu.walls.find(x => x.side === selection.side); const piece = wl && wl.pieces.find(p => p.element === selection.element); if (!piece) { selection = null; return renderProps(); } const lt = state.settings.lighting; const rows = [ ['Lengte zijde', wl.width, 'mm'], ['Lengte wandstuk', piece.place.width, 'mm'], ['Positie', `${Math.round(piece.u0)} – ${Math.round(piece.u1)}`, 'mm'], ['Naad', wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)} – ${Math.round(b)}`).join(', ') : 'geen', wl.cutsU.length ? 'mm' : ''], ['Openingen', piece.config.openings.length, ''] ]; const lampen = lt.mode === 'gevel' ? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, unitById(pu.id).lighting.walls[wl.side] || 'auto', { hint: `Automatisch: ${lt.endOffset} mm uit de hoek, alleen op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm naast elke opening.` }) + UI.specCard('Op dit wandstuk', 'lightbulb', [['Gevellampen', piece.lamps.length, 'st.']]) : info('Gevellampen staan uit. Kies bij Woning › Verlichting het type "Gevellampen".'); propsEl.innerHTML = warn + head('bricks', piece.element, piece.label) + cols(UI.specCard('Wandstuk', 'rulers', rows), section('Gevellampen', 'lightbulb', lampen)) + (wl.dropped.length ? UI.warn(`${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.`) : '') + actions(button('Wand bewerken', 'pencil-square', `data-go="wall:${pu.id}:${wl.side}"`)); UI.onSel('hw-light', v => { unitById(pu.id).lighting.walls[wl.side] = v; }, rerender); } function renderIwProps(iw, warn) { const p = parts.interior.find(x => x.id === iw.id); propsEl.innerHTML = warn + head('distribute-horizontal', p.element, `Binnenwand · ${p.place.width} mm`) + cols( section('Positie', 'crosshair', num('hi-x1', 'Begin X', iw.x1) + num('hi-z1', 'Begin Y', iw.z1) + num('hi-x2', 'Eind X', iw.x2) + num('hi-z2', 'Eind Y', iw.z2)), section('Opbouw', 'layers', num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), { step: 5 }) + num('hi-height', 'Hoogte', iw.wall.wall.heightAll))) + actions( button('Openingen bewerken', 'pencil-square', `data-go="iw:${iw.id}"`), button('Verwijderen', 'trash', `data-remove-iw="${iw.id}"`, 'ui-btn-danger')); ['x1', 'z1', 'x2', 'z2'].forEach(k => UI.onNum('hi-' + k, v => { iw[k] = v; }, after)); UI.onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); }, after); UI.onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; }, after); } // Woning-instellingen (niets geselecteerd): alles wat voor de hele woning // geldt, in tabbladen. function renderHouseProps(warn) { const st = state.settings, sp = st.spacing, r = st.roof, lt = st.lighting; const pos = { positive: true }; const verlichting = lt.mode === 'gevel' ? cols( section('Gevellampen', 'lightbulb', sel('hs-light-dir', 'Lichtrichting', LightingModel.DIRECTIONS, lt.direction) + num('hs-lamp-h', 'Hoogte', lt.lampHeight, { hint: 'Vanaf bovenkant vloer' }) + num('hs-lamp-min', 'Afstand min.', lt.lampMin, { hint: 'Hart-op-hart' }) + num('hs-lamp-max', 'Afstand max.', lt.lampMax, { hint: 'Hart-op-hart' })), section('Plaatsing', 'distribute-horizontal', num('hs-lamp-w', 'Min. gevelbreedte', lt.minWallWidth, { hint: 'Smallere vrije stukken gevel krijgen geen lamp' }) + num('hs-lamp-end', 'Afstand tot hoek', lt.endOffset) + num('hs-lamp-open', 'Afstand tot opening', lt.openingOffset, { hint: 'Voor wanden met de instelling "Naast openingen"' }))) : lt.mode === 'spots' ? cols(section('Boeideel-spots', 'lightbulb', num('hs-spot-min', 'Afstand min.', r.spotMin, { hint: 'Hart-op-hart' }) + num('hs-spot-max', 'Afstand max.', r.spotMax, { hint: 'Hart-op-hart' })), info('Spots komen alleen onder units met boeidelen.')) : ''; propsEl.innerHTML = warn + head('house-door', 'Woning', `${state.units.length} unit(s) · ${state.interiorWalls.length} binnenwand(en)`) + UI.tabs('woning', [ { key: 'algemeen', label: 'Algemeen', icon: 'house-door', html: info('Klik in de plattegrond op een unit, wand of binnenwand om te selecteren; dubbelklik om te bewerken. Waar units tegen elkaar staan (oranje gestippeld) vervalt de wand.') + actions(button('Unit toevoegen', 'plus-lg', 'data-action="add-unit"'), button('Binnenwand tekenen', 'pencil', 'data-action="draw-iw"'), button('Projectgegevens', 'card-text', 'data-action="project"')) }, { key: 'gevel', label: 'Gevel', icon: 'bricks', html: cols( section('Gevelbekleding', 'grip-horizontal', sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'WEO/Novalit, voorlopig voor alle buitenwanden' })) + section('Trasraam', 'bricks', toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Cement-gebonden plaat onderaan de gevel' }) + (st.trasraam.enabled ? num('hs-trasraam-h', 'Hoogte', st.trasraam.height, { step: 10, hint: 'Vanaf bovenkant vloer' }) : '')), section('Sandwichpanelen', 'bounding-box', toggle('hs-sandwich', 'Rondom woning', st.sandwich, { hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm de grond in` }))) }, { key: 'constructie', label: 'Regelwerk', icon: 'columns-gap', html: cols( section('Buitenwanden', 'bricks', num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart stijlen' })), section('Binnenwanden', 'distribute-horizontal', num('hs-int-min', 'Afstand min.', sp.interior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-int-max', 'Afstand max.', sp.interior.max, { step: 10, hint: 'Hart-op-hart stijlen' }))) }, { key: 'dak', label: 'Dakrand', icon: 'house', html: cols( section('Boeideel', 'border-outer', num('hs-overhang', 'Overstek', r.overhang, { step: 10, hint: `${RoofModel.OVERHANG_MIN} – ${RoofModel.OVERHANG_MAX} mm` }) + num('hs-fascia-h', 'Hoogte', r.fasciaHeight, { step: 10 })), section('Daktrim', 'border-top', num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }))) }, { key: 'verlichting', label: 'Verlichting', icon: 'lightbulb', html: cols(sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode), '') + verlichting } ]); UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender); UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender); UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender); UI.onNum('hs-trasraam-h', v => { st.trasraam.height = v; }, after, pos); UI.onNum('hs-ext-min', v => { sp.exterior.min = v; }, after, pos); UI.onNum('hs-ext-max', v => { sp.exterior.max = v; }, after, pos); UI.onNum('hs-int-min', v => { sp.interior.min = v; }, after, pos); UI.onNum('hs-int-max', v => { sp.interior.max = v; }, after, pos); UI.onNum('hs-overhang', v => { r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after, pos); UI.onNum('hs-fascia-h', v => { r.fasciaHeight = v; }, after, pos); UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos); UI.onNum('hs-spot-min', v => { r.spotMin = v; }, after, pos); UI.onNum('hs-spot-max', v => { r.spotMax = v; }, after, pos); UI.onSel('hs-light-mode', v => { lt.mode = v; }, rerender); UI.onSel('hs-light-dir', v => { lt.direction = v; }, rerender); UI.onNum('hs-lamp-h', v => { lt.lampHeight = v; }, after, pos); UI.onNum('hs-lamp-min', v => { lt.lampMin = v; }, after, pos); UI.onNum('hs-lamp-max', v => { lt.lampMax = v; }, after, pos); UI.onNum('hs-lamp-w', v => { lt.minWallWidth = v; }, after, pos); UI.onNum('hs-lamp-end', v => { lt.endOffset = v; }, after, pos); UI.onNum('hs-lamp-open', v => { lt.openingOffset = v; }, after, pos); } propsEl.addEventListener('click', (e) => { const go = e.target.closest('[data-go]'); if (go) { showPart(go.dataset.go); return; } const ru = e.target.closest('[data-remove-unit]'); if (ru) { removeUnit(ru.dataset.removeUnit); return; } const ri = e.target.closest('[data-remove-iw]'); if (ri) { state.interiorWalls = state.interiorWalls.filter(w => w.id !== Number(ri.dataset.removeIw)); selection = null; refresh(); return; } const act = e.target.closest('[data-action]'); if (!act) return; if (act.dataset.action === 'add-unit') addUnit(); else if (act.dataset.action === 'draw-iw') setTool('iw'); else if (act.dataset.action === 'project') openProjectProperties(); }); // --- Specificaties/totalen (onder de 3D) --- function renderSpecs(totals) { const f = houseFmt; const cards = [ UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']), UI.specCard('Hout & isolatie', 'tree', [ ['Balken', totals.beamCount, 'st.'], ['Volume hout', f(totals.woodM3, 3), 'm³'], ['Inkoop wandbalken', WallModel.purchaseText(totals.purchase), ''], ['Snijverlies', `${f(totals.purchaseWasteMM / 1000, 2)} m · ${f(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0)}`, '%'], [`Latwerk ${WallModel.BATTEN.thick}×${WallModel.BATTEN.width}`, `${totals.battens.count} × ${WallModel.BATTEN.length}`, 'mm'], ['Glaswol', f(totals.isolatieM3, 3), 'm³'] ]), UI.specCard('Plaatwerk', 'layers', totals.plaatwerk.filter(p => p.m2 > 0).map(p => [p.naam, f(p.m2, 2), 'm²'])) ]; if (totals.roofEdge) { const e = totals.roofEdge, rows = []; if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']); if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']); rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.']); cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows)); } document.getElementById('ho-specs').innerHTML = `
${cards.join('')}
`; } // --- Alles opnieuw doorrekenen --- function refresh(opts) { parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, isolatieVisible, lampenVisible }); const totals = HouseModel.computeTotals(parts); if (houseGroup) scene.remove(houseGroup); houseGroup = new THREE.Group(); const toM = 0.001; parts.units.forEach(u => { u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM); houseGroup.add(u.floorGroup); }); HouseModel.allWallPieces(parts).forEach(p => { p.group.rotation.y = p.place.rotY; p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM); houseGroup.add(p.group); }); if (dakVisible) { parts.units.forEach(u => { u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM); houseGroup.add(u.roof.group); }); if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup); } if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup); if (parts.lightingGroup) houseGroup.add(parts.lightingGroup); scene.add(houseGroup); applyRenderMode(); const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1)); const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1)); const cx = (bx0 + bx1) / 2 * toM, cz = (bz0 + bz1) / 2 * toM; grid.position.set(cx, 0, cz); if (!cameraFitted) { const size = Math.max(bx1 - bx0, bz1 - bz0) * toM; controls.target.set(cx, 1.2, cz); camera.position.set(cx - size * 0.9, size * 0.8, cz - size * 1.3); // kijkt tegen de voorzijde aan controls.update(); cameraFitted = true; } if (!(opts && opts.keepProps)) renderProps(); renderSpecs(totals); drawPlan(); renderNav(); } let rafId = null; function animate() { rafId = requestAnimationFrame(animate); renderer.render(scene, camera); } function onResize() { camera.aspect = pane3D.clientWidth / pane3D.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(pane3D.clientWidth, pane3D.clientHeight); drawPlan(); } window.addEventListener('resize', onResize); function toggleView(fn) { fn(document.getElementById('house-overview')); requestAnimationFrame(onResize); } refresh(); animate(); setTool(pendingTool || 'select'); pendingTool = null; return { unmount() { window.removeEventListener('resize', onResize); window.removeEventListener('keydown', onKey); if (rafId !== null) cancelAnimationFrame(rafId); CameraMemory.save(overviewCamKey, camera, controls); renderer.dispose(); }, refresh, setTool, toggleFullscreen3D: () => toggleView(UI.cycle3D), toggleFullscreen2D: () => toggleView(UI.toggle2D) }; } // ------------------------------------------------------------ // Project eigenschappen (woning-niveau) // ------------------------------------------------------------ function openProjectProperties() { HOUSE_PROJECT_KEYS.forEach(k => { document.getElementById('h-proj-' + k).value = state.project[k] || ''; }); document.getElementById('house-project-overlay').classList.add('open'); } function closeProjectProperties(save) { if (save) HOUSE_PROJECT_KEYS.forEach(k => { state.project[k] = document.getElementById('h-proj-' + k).value; }); document.getElementById('house-project-overlay').classList.remove('open'); } document.getElementById('house-project-save').addEventListener('click', () => closeProjectProperties(true)); document.getElementById('house-project-cancel').addEventListener('click', () => closeProjectProperties(false)); document.getElementById('house-project-close-x').addEventListener('click', () => closeProjectProperties(false)); document.getElementById('house-project-overlay').addEventListener('click', (e) => { if (e.target.id === 'house-project-overlay') closeProjectProperties(false); }); // ------------------------------------------------------------ // JSON + PDF // ------------------------------------------------------------ function getStateJson() { flushActivePart(); return HouseModel.clone(state); } function loadStateJson(newState) { if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount(); activeEditor = null; activePart = null; state = HouseModel.normalize(HouseModel.clone(newState)); selection = null; showPart('overzicht'); } function exportDesign() { const suggested = 'woning-ontwerp-' + new Date().toISOString().slice(0, 10); let filename = window.prompt('Bestandsnaam voor het opgeslagen ontwerp:', suggested); if (filename === null) { setIoStatus('Opslaan geannuleerd.', ''); return null; } filename = filename.trim() || suggested; if (!filename.toLowerCase().endsWith('.json')) filename += '.json'; const design = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), ...getStateJson() }; const blob = new Blob([JSON.stringify(design, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); try { HousePdf.generate(state).save(filename.replace(/\.json$/i, '') + '.pdf'); setIoStatus(`Opgeslagen als "${filename}" + PDF.`, 'ok'); } catch (err) { setIoStatus(`Opgeslagen als "${filename}" (PDF mislukt: ${err.message}).`, 'err'); } if (typeof onSaved === 'function') onSaved(filename); return filename; } function triggerImport() { document.getElementById('h-file-import-json').click(); } document.getElementById('h-file-import-json').addEventListener('change', (e) => { const file = e.target.files[0]; e.target.value = ''; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { let design = JSON.parse(ev.target.result); if (design && design.type === 'unit-design') { UnitModel.validateState(design); design = HouseModel.fromUnitDesign(design); } else if (!design || design.type !== 'house-design') { throw new Error('geen geldig woning- of unit-ontwerp (type ontbreekt of onjuist)'); } else { HouseModel.validateState(design); const { type, version, exportedAt, ...pure } = design; design = pure; } loadStateJson(design); setIoStatus('Woning geïmporteerd.', 'ok'); if (typeof onSaved === 'function') onSaved(file.name); } catch (err) { setIoStatus('Import mislukt: ' + err.message, 'err'); } }; reader.onerror = () => setIoStatus('Kon bestand niet lezen.', 'err'); reader.readAsText(file); }); function forward(fn) { return () => { if (activeEditor && typeof activeEditor[fn] === 'function') activeEditor[fn](); }; } showPart('overzicht'); return { unmount() { if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount(); activeEditor = null; }, exportDesign, triggerImport, mirrorHorizontal: forward('mirrorHorizontal'), toggleFullscreen3D: forward('toggleFullscreen3D'), toggleFullscreen2D: forward('toggleFullscreen2D'), openProjectProperties, getStateJson, loadStateJson }; } window.HouseEditor = { mount: mountHouseEditor, getDefaultState: (template) => HouseModel.createDefaultState(template || 'single') };