Files
Tectum/js/house/house.js
T
MachielandClaude Opus 5.5 c690e72d13 Tectum v0.4.0 — mijlpaal gevelopbouw
Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units,
woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op
vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:38:54 +02:00

1104 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 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 = `
<div id="house-app">
<aside id="house-nav">
<div class="hn-title">Woning</div>
<div id="house-nav-tree"></div>
<div class="hn-actions">
<button type="button" id="hn-add-unit" class="io-btn">+ Unit</button>
<button type="button" id="hn-add-iw" class="io-btn">+ Binnenwand</button>
</div>
<div class="hn-soon">
<div class="hn-item disabled" title="Nog niet beschikbaar">Schuine daken</div>
<div class="hn-item disabled" title="Nog niet beschikbaar">Chassis (transport)</div>
</div>
<input type="file" id="h-file-import-json" accept="application/json" style="display:none;">
<div id="h-io-status" class="io-status"></div>
</aside>
<div id="house-part-host"></div>
</div>
<div id="house-project-overlay">
<div id="house-project-modal">
<div class="modal-header">
<h2>Project eigenschappen (woning)</h2>
<button type="button" id="house-project-close-x" class="modal-close-x">&times;</button>
</div>
<div class="controls-grid">
<div>
<div class="control-group"><label for="h-proj-naam">Projectnaam:</label><div><input type="text" id="h-proj-naam"></div></div>
<div class="control-group"><label for="h-proj-plaats">Plaats:</label><div><input type="text" id="h-proj-plaats"></div></div>
<div class="control-group"><label for="h-proj-straat">Straat:</label><div><input type="text" id="h-proj-straat"></div></div>
</div>
<div>
<div class="control-group"><label for="h-proj-nummer">Projectnummer:</label><div><input type="text" id="h-proj-nummer"></div></div>
<div class="control-group"><label for="h-proj-tekenaar">Tekenaar:</label><div><input type="text" id="h-proj-tekenaar"></div></div>
<div class="control-group"><label for="h-proj-opmerking">Opmerking:</label><div><input type="text" id="h-proj-opmerking"></div></div>
</div>
</div>
<div class="material-info">Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.</div>
<div class="modal-footer">
<button type="button" id="house-project-cancel" class="io-btn">Annuleren</button>
<button type="button" id="house-project-save" class="material-toggle-btn active" style="flex:none; padding:6px 18px;">Opslaan</button>
</div>
</div>
</div>
`;
const HOUSE_OVERVIEW_TEMPLATE = `
<div id="house-overview">
<div class="ho-col ho-left">
<div id="ho-pane-plan" class="ho-pane">
<div class="pane-label">2D PLATTEGROND</div>
<div class="toolbar-3d">
<button type="button" id="ho-tool-select" class="btn-3d-toggle active">Selecteren</button>
<button type="button" id="ho-tool-iw" class="btn-3d-toggle">Binnenwand tekenen</button>
</div>
<canvas id="ho-canvas"></canvas>
<div id="ho-hint" class="ho-hint"></div>
</div>
<div id="ho-props"></div>
</div>
<div class="ho-col ho-right">
<div id="ho-pane-3d" class="ho-pane">
<div class="pane-label">3D PERSPECTIEF</div>
<div class="toolbar-3d" id="ho-toolbar-3d">
<button type="button" id="ho-btn-render" class="btn-3d-toggle" title="Avondscène: elke lamp en spot is een echte lichtbron">Volle render</button>
</div>
</div>
<div id="ho-specs"></div>
</div>
</div>
`;
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) =>
`<div class="hn-item ${cls || ''} ${activePart === part ? 'active' : ''}" data-part="${part}">
<span>${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`;
let html = item('overzicht', 'Overzicht', '', 'hn-top');
parts.units.forEach(u => {
const sel = selection && selection.type === 'unit' && selection.id === u.id && activePart === 'overzicht';
html += `<div class="hn-group">`;
html += `<div class="hn-item hn-unit ${sel ? 'active' : ''}" data-select-unit="${u.id}"><span>Unit ${u.id}</span><span class="hn-meta">${u.rect.W} &times; ${u.rect.D}</span></div>`;
html += item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub');
html += item(`roof:${u.id}`, 'Dak (plat)', u.roof.element, 'hn-sub');
u.walls.forEach(w => {
const meta = w.removed ? 'vervalt (naad)' : w.cutsU.length ? `deels naad &middot; ${w.pieces.map(p => p.element).join(', ')}` : w.pieces[0].element;
html += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : ''));
});
html += `</div>`;
});
if (parts.interior.length) {
html += `<div class="hn-group"><div class="hn-item hn-unit hn-static"><span>Binnenwanden</span></div>`;
parts.interior.forEach(p => { html += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub'); });
html += `</div>`;
}
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;
// 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;
function applyRenderMode() {
scene.background = new THREE.Color(fullRender ? 0x0b0f18 : 0x1a1a1a);
ambient.intensity = fullRender ? 0.1 : 0.6;
dirLight.intensity = fullRender ? 0.12 : 0.8;
ground.visible = fullRender;
grid.visible = !fullRender;
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);
}
}
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) ---
const num = (id, label, value, step) =>
`<div class="control-group"><label for="${id}">${label}:</label><div><input type="number" id="${id}" value="${value}" step="${step || 50}"><span class="unit">mm</span></div></div>`;
const sel = (id, label, options, value) =>
`<div class="control-group"><label for="${id}">${label}:</label><div><select id="${id}">${Object.entries(options).map(([k, v]) => `<option value="${k}" ${k === value ? 'selected' : ''}>${v}</option>`).join('')}</select></div></div>`;
function renderProps() {
const warn = parts.overlaps.length
? `<div class="global-warning">Let op: units overlappen elkaar (${parts.overlaps.map(p => p.join(' + ')).join(', ')}).</div>` : '';
const errs = parts.rects.filter(r => r.error).map(r => `<div class="global-warning">Unit ${r.id}: ${r.error}</div>`).join('');
if (selection && selection.type === 'unit' && unitById(selection.id)) {
const u = unitById(selection.id);
const isRoot = state.units[0].id === u.id;
const a = u.attach;
const axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z';
propsEl.innerHTML = `${warn}${errs}
<div class="controls-grid">
<div>
<h1>Unit ${u.id}</h1>
${num('hp-width', 'Breedte (west-oost)', u.unit.floor.floor.width)}
${num('hp-depth', 'Diepte (noord-zuid)', u.unit.floor.floor.depth)}
${sel('hp-corners', 'Hoeken', UnitModel.CORNER_MODES, u.unit.layout.corners)}
</div>
<div>
<h1>Koppeling</h1>
${isRoot ? '<div class="material-info">Basis-unit: noordwest-hoek op (0, 0). Andere units koppelen hieraan.</div>' : `
${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', 'Aan 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)}`}
</div>
</div>
<div class="divider"></div>
<div class="controls-grid">
<div>
<h1>Dak (plat)</h1>
${sel('hp-roof-follow', 'Dak volgt vloer', { ja: 'Ja (standaard)', nee: 'Nee, eigen maat' }, u.roof.followFloor ? 'ja' : 'nee')}
${u.roof.followFloor ? '' : `
${num('hp-roof-w', 'Dakbreedte (west-oost)', u.roof.deck.floor.width)}
${num('hp-roof-d', 'Dakdiepte (noord-zuid)', u.roof.deck.floor.depth)}`}
</div>
<div>
${u.roof.followFloor ? '<div class="material-info">Het dak heeft dezelfde maat als de vloer. Kies "Nee, eigen maat" om het dak langer/breder te maken of te verschuiven.</div>' : `
<h1>Positie dak</h1>
${num('hp-roof-ox', 'Verschuiving oost (+)', u.roof.offsetX || 0)}
${num('hp-roof-oz', 'Verschuiving zuid (+)', u.roof.offsetZ || 0)}`}
</div>
</div>
<div class="divider"></div>
<div class="controls-grid">
<div>
<h1>Dakrand</h1>
${sel('hp-fascia', 'Boeidelen', { aan: 'Aan', uit: 'Uit (daktrim 80 mm)' }, u.roof.fascia !== false ? 'aan' : 'uit')}
</div>
<div>
<div class="material-info">Buitenverlichting (type, richting, afstanden) is woning-breed: Overzicht, niets geselecteerd.</div>
</div>
</div>
<div class="ho-prop-actions">
<button type="button" class="io-btn" data-go="floor:${u.id}">Vloer bewerken</button>
<button type="button" class="io-btn" data-go="roof:${u.id}">Dak bewerken</button>
${isRoot ? '' : `<button type="button" class="io-btn io-btn-danger" data-remove-unit="${u.id}">Unit verwijderen</button>`}
</div>`;
const onNum = (id, fn) => document.getElementById(id) && document.getElementById(id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (isFinite(v)) { fn(v); refresh({ keepProps: true }); }
});
onNum('hp-width', v => { if (v >= 1000) u.unit.floor.floor.width = v; });
onNum('hp-depth', v => { if (v >= 1000) u.unit.floor.floor.depth = v; });
onNum('hp-att-offset', v => { u.attach.offset = v; });
const onSel = (id, fn) => document.getElementById(id) && document.getElementById(id).addEventListener('input', (e) => { fn(e.target.value); refresh(); });
onSel('hp-corners', v => { u.unit.layout.corners = v; });
onSel('hp-att-to', v => { u.attach.to = v; });
onSel('hp-att-side', v => { u.attach.side = v; });
onSel('hp-att-align', v => { u.attach.align = v; });
onSel('hp-roof-follow', v => { u.roof.followFloor = v === 'ja'; });
onSel('hp-fascia', v => { u.roof.fascia = v === 'aan'; });
onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; });
onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; });
onNum('hp-roof-ox', v => { u.roof.offsetX = v; });
onNum('hp-roof-oz', v => { u.roof.offsetZ = v; });
} else if (selection && selection.type === 'wall') {
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(); }
propsEl.innerHTML = `${warn}
<h1>${piece.element} &mdash; ${piece.label}</h1>
<div class="result-row"><span>Lengte volle zijde:</span><span class="highlight">${wl.width} mm</span></div>
<div class="result-row"><span>Dit wandstuk:</span><span class="highlight">${piece.place.width} mm (${Math.round(piece.u0)}&ndash;${Math.round(piece.u1)} vanaf links, van buitenaf gezien)</span></div>
<div class="result-row"><span>Naad met andere unit:</span><span class="highlight">${wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)}&ndash;${Math.round(b)}`).join(', ') + ' mm vervalt' : 'nee'}</span></div>
<div class="result-row"><span>Openingen in dit stuk:</span><span class="highlight">${piece.config.openings.length}</span></div>
${state.settings.lighting.mode === 'gevel' ? `
<div class="divider"></div>
<h1>Gevellampen op deze wand</h1>
${sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, unitById(pu.id).lighting.walls[wl.side] || 'auto')}
<div class="result-row"><span>Lampen op dit wandstuk:</span><span class="highlight">${piece.lamps.length}</span></div>
<div class="material-info">Automatisch: vanaf ${state.settings.lighting.endOffset} mm van hoek/uiteinde, alleen op vrije stukken gevel &ge; ${state.settings.lighting.minWallWidth} mm op ${state.settings.lighting.lampHeight} mm hoogte (smallere stukken krijgen geen lampen). Override: ${state.settings.lighting.openingOffset} mm naast elke opening.</div>`
: '<div class="material-info">Gevellampen: kies in de woning-instellingen "Buitenverlichting: Gevellampen".</div>'}
${wl.dropped.length ? `<div class="global-warning">${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.</div>` : ''}
<div class="ho-prop-actions"><button type="button" class="io-btn" data-go="wall:${pu.id}:${wl.side}">Wand bewerken</button></div>`;
const hw = document.getElementById('hw-light');
if (hw) hw.addEventListener('input', (e) => { unitById(pu.id).lighting.walls[wl.side] = e.target.value; refresh(); });
} else if (selection && selection.type === 'iw' && iwById(selection.id)) {
const iw = iwById(selection.id);
const p = parts.interior.find(x => x.id === iw.id);
propsEl.innerHTML = `${warn}
<div class="controls-grid">
<div>
<h1>${p.element} &mdash; binnenwand</h1>
${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)}
</div>
<div>
<h1>Opbouw</h1>
${num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), 5)}
${num('hi-height', 'Hoogte', iw.wall.wall.heightAll)}
<div class="result-row"><span>Lengte:</span><span class="highlight">${p.place.width} mm</span></div>
</div>
</div>
<div class="ho-prop-actions">
<button type="button" class="io-btn" data-go="iw:${iw.id}">Wand bewerken (openingen)</button>
<button type="button" class="io-btn io-btn-danger" data-remove-iw="${iw.id}">Verwijderen</button>
</div>`;
const onNum = (id, fn) => document.getElementById(id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (isFinite(v)) { fn(v); refresh({ keepProps: true }); }
});
['x1', 'z1', 'x2', 'z2'].forEach(f => onNum('hi-' + f, v => { iw[f] = v; }));
onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); });
onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; });
} else {
propsEl.innerHTML = `${warn}${errs}
<h1>Woning</h1>
<div class="material-info">${state.units.length} unit(s), ${state.interiorWalls.length} binnenwand(en).
Klik in de plattegrond op een unit, wand of binnenwand om die te selecteren; dubbelklik om te bewerken.
Waar units tegen elkaar staan (oranje gestippeld) vervalt de wand.</div>
<div class="ho-prop-actions">
<button type="button" class="io-btn" data-action="add-unit">+ Unit toevoegen</button>
<button type="button" class="io-btn" data-action="draw-iw">Binnenwand tekenen</button>
<button type="button" class="io-btn" data-action="project">Project eigenschappen</button>
</div>
<div class="divider"></div>
${renderSettingsHtml()}`;
wireSettings();
}
}
// Woning-instellingen: h.o.h. voor ALLE buiten-/binnenwanden + dakrand.
function renderSettingsHtml() {
const sp = state.settings.spacing, r = state.settings.roof, lt = state.settings.lighting;
return `
<div class="controls-grid">
<div>
<h1>Gevelbekleding (test, alle buitenwanden)</h1>
${sel('hs-cladding', 'Richting WEO/Novalit', WallModel.CLADDING_DIRECTIONS, state.settings.cladding)}
${sel('hs-trasraam', 'Trasraam (cement-gebonden plaat)', { aan: 'Aan', uit: 'Uit' }, state.settings.trasraam.enabled ? 'aan' : 'uit')}
${state.settings.trasraam.enabled ? num('hs-trasraam-h', 'Hoogte trasraam (vanaf bovenkant vloer)', state.settings.trasraam.height, 10) : ''}
${sel('hs-sandwich', `Sandwichpanelen rondom (${SandwichModel.DEPTH_MM} mm de grond in)`, { aan: 'Aan', uit: 'Uit' }, state.settings.sandwich ? 'aan' : 'uit')}
<div class="divider"></div>
<h1>Regelwerk h.o.h. (alle wanden)</h1>
${num('hs-ext-min', 'Buitenwanden min.', sp.exterior.min, 10)}
${num('hs-ext-max', 'Buitenwanden max.', sp.exterior.max, 10)}
${num('hs-int-min', 'Binnenwanden min.', sp.interior.min, 10)}
${num('hs-int-max', 'Binnenwanden max.', sp.interior.max, 10)}
</div>
<div>
<h1>Dakrand (maten, hele woning)</h1>
${num('hs-overhang', `Overstek boeideel (${RoofModel.OVERHANG_MIN}–${RoofModel.OVERHANG_MAX})`, r.overhang, 10)}
${num('hs-fascia-h', 'Hoogte boeideel', r.fasciaHeight, 10)}
${num('hs-trim-h', 'Hoogte daktrim', r.trimHeight, 5)}
${num('hs-spot-min', 'Spots min. h.o.h.', r.spotMin, 50)}
${num('hs-spot-max', 'Spots max. h.o.h.', r.spotMax, 50)}
</div>
</div>
<div class="divider"></div>
<div class="controls-grid">
<div>
<h1>Buitenverlichting (hele woning)</h1>
${sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode)}
${lt.mode === 'gevel' ? sel('hs-light-dir', 'Lichtrichting', LightingModel.DIRECTIONS, lt.direction) : ''}
${lt.mode === 'spots' ? '<div class="material-info">Boeideel-spots komen alleen bij units met boeidelen.</div>' : ''}
${num('hs-lamp-h', 'Lamphoogte', lt.lampHeight, 50)}
${num('hs-lamp-min', 'Lampen min. h.o.h.', lt.lampMin, 50)}
${num('hs-lamp-max', 'Lampen max. h.o.h.', lt.lampMax, 50)}
</div>
<div>
<h1>&nbsp;</h1>
${num('hs-lamp-w', 'Min. vrije gevelbreedte', lt.minWallWidth, 50)}
${num('hs-lamp-end', 'Vanaf hoek/uiteinde', lt.endOffset, 50)}
${num('hs-lamp-open', 'Override: naast opening', lt.openingOffset, 50)}
</div>
</div>`;
}
function wireSettings() {
const sp = state.settings.spacing, r = state.settings.roof;
const onNum = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('input', (e) => { const v = parseFloat(e.target.value); if (isFinite(v) && v > 0) { fn(v); refresh({ keepProps: true }); } }); };
const onSel = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('input', (e) => { fn(e.target.value); refresh(); }); };
onSel('hs-cladding', v => { state.settings.cladding = v; });
onSel('hs-trasraam', v => { state.settings.trasraam.enabled = v === 'aan'; });
onSel('hs-sandwich', v => { state.settings.sandwich = v === 'aan'; });
onNum('hs-trasraam-h', v => { state.settings.trasraam.height = v; });
onNum('hs-ext-min', v => { sp.exterior.min = v; });
onNum('hs-ext-max', v => { sp.exterior.max = v; });
onNum('hs-int-min', v => { sp.interior.min = v; });
onNum('hs-int-max', v => { sp.interior.max = v; });
const lt = state.settings.lighting;
onNum('hs-overhang', v => { r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); });
onNum('hs-fascia-h', v => { r.fasciaHeight = v; });
onNum('hs-spot-min', v => { r.spotMin = v; });
onSel('hs-light-mode', v => { lt.mode = v; });
onSel('hs-light-dir', v => { lt.direction = v; });
onNum('hs-lamp-h', v => { lt.lampHeight = v; });
onNum('hs-lamp-min', v => { lt.lampMin = v; });
onNum('hs-lamp-max', v => { lt.lampMax = v; });
onNum('hs-lamp-w', v => { lt.minWallWidth = v; });
onNum('hs-lamp-end', v => { lt.endOffset = v; });
onNum('hs-lamp-open', v => { lt.openingOffset = v; });
onNum('hs-spot-max', v => { r.spotMax = v; });
onNum('hs-trim-h', v => { r.trimHeight = v; });
}
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 ---
function renderSpecs(totals) {
const row = (label, value) => `<div class="result-row"><span>${label}:</span><span class="highlight">${value}</span></div>`;
document.getElementById('ho-specs').innerHTML = `
<div class="controls-grid">
<div>
<div class="spec-section"><h1>Gewicht</h1>
${totals.weights.map(r => row(r.label, houseFmt(r.kg) + ' kg')).join('')}
<div class="divider"></div>${row('Totaal woning', houseFmt(totals.totalKg) + ' kg')}
</div>
</div>
<div>
<div class="spec-section"><h1>Houtwerk &amp; isolatie</h1>
${row('Aantal balken', totals.beamCount + ' stuks')}
${row('Volume hout', houseFmt(totals.woodM3, 3) + ' m3')}
${row('Latwerk 20x30 (kruislat)', totals.battens.count + ' st. &agrave; ' + WallModel.BATTEN.length + ' mm')}
${row('Inkoop wandbalken', WallModel.purchaseText(totals.purchase))}
${row('Snijverlies', houseFmt(totals.purchaseWasteMM / 1000, 2) + ' m (' + houseFmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%)')}
${row('Glaswol (isolatie)', houseFmt(totals.isolatieM3, 3) + ' m3')}
</div>
<div class="spec-section"><h1>Plaatwerk</h1>
${totals.plaatwerk.filter(p => p.m2 > 0).map(p => row(p.naam, houseFmt(p.m2, 2) + ' m2')).join('')}
</div>
${totals.roofEdge ? `<div class="spec-section"><h1>Dakrand &amp; buitenverlichting</h1>
${totals.roofEdge.fasciaLengthM > 0 ? row('Boeidelen', houseFmt(totals.roofEdge.fasciaLengthM, 2) + ' m &middot; ' + totals.roofEdge.fasciaHeight + ' mm hoog') + row('Onderplaat boeideel', houseFmt(totals.roofEdge.soffitM2, 2) + ' m2') : ''}
${totals.roofEdge.trimLengthM > 0 ? row('Daktrim', houseFmt(totals.roofEdge.trimLengthM, 2) + ' m &middot; ' + totals.roofEdge.trimHeight + ' mm hoog') : ''}
${row('Boeideel-spots', totals.roofEdge.spots + ' stuks')}
${row('Gevellampen', totals.lamps.total + ' stuks')}
</div>` : ''}
</div>
</div>`;
}
// --- 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 toggleFullscreen(cls, other) {
const el = document.getElementById('house-overview');
el.classList.remove(other);
el.classList.toggle(cls);
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: () => toggleFullscreen('fullscreen-3d', 'fullscreen-2d'),
toggleFullscreen2D: () => toggleFullscreen('fullscreen-2d', 'fullscreen-3d')
};
}
// ------------------------------------------------------------
// 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')
};