Files
Tectum/js/house/house.js
T

1243 lines
68 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"><i class="bi bi-diagram-3"></i> Woning</div>
<div id="house-nav-tree"></div>
<div class="hn-actions">
<button type="button" id="hn-add-unit" class="ui-btn"><i class="bi bi-plus-lg"></i><span>Unit</span></button>
<button type="button" id="hn-add-iw" class="ui-btn"><i class="bi bi-plus-lg"></i><span>Binnenwand</span></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">PLATTEGROND</div>
<div class="toolbar-3d">
<button type="button" id="ho-tool-select" class="btn-3d-toggle active" title="Selecteren"><i class="bi bi-cursor"></i>Selecteren</button>
<button type="button" id="ho-tool-iw" class="btn-3d-toggle" title="Binnenwand tekenen"><i class="bi bi-pencil"></i>Binnenwand</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</div>
<div class="toolbar-3d" id="ho-toolbar-3d">
<button type="button" id="ho-btn-render" class="btn-3d-toggle" title="Render: elke lamp en spot is een echte lichtbron"><i class="bi bi-camera"></i>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)) {
// Hoogtes gewijzigd: tegenoverliggende wand spiegelen, haakse wanden
// vlak op de hoekhoogtes (UnitModel.syncWallHeights).
const unit = unitById(a).unit, before = unit.walls[b].wall;
const next = activeEditor.getStateJson();
unit.walls[b] = next;
const h = (w) => [w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : null].join('|');
if (h(before) !== h(next.wall)) UnitModel.syncWallHeights(unit, b);
}
else if (kind === 'roof' && unitById(a)) unitById(a).roof.deck = activeEditor.getStateJson();
else if (kind === 'iw' && iwById(Number(a))) {
const iw = iwById(Number(a));
iw.wall = activeEditor.getStateJson();
if (iw.seam) iw.wall.materials = Object.assign({}, iw.wall.materials, { exterior: 'geen' }); // naadzijde blijft zonder plaat
}
}
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.',
lockExterior: 'De gevelbekleding volgt de woning (Overzicht › Gevel); per wand aan te passen via de wand in de plattegrond.',
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);
if (iwById(Number(a)).seam) HouseModel.computeParts(state, { skip3D: true }); // breedte naadwand-deel
else 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) =>
`<div class="hn-item ${cls || ''} ${activePart === part ? 'active' : ''}" data-part="${part}">
<span class="hn-label">${iconName ? UI.icon(iconName) : ''}${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`;
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 += `<div class="hn-group">`;
html += `<div class="hn-item hn-unit ${sel ? 'active' : ''}" data-select-unit="${u.id}"><span class="hn-label">${UI.icon('box')}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', '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 += `</div>`;
});
if (parts.interior.length) {
html += `<div class="hn-group"><div class="hn-item hn-unit hn-static"><span class="hn-label">${UI.icon('distribute-horizontal')}Binnenwanden</span></div>`;
const seen = new Set();
parts.interior.forEach(p => {
if (seen.has(p.id)) return; // naadwand: twee delen, één item
seen.add(p.id);
if (p.seam) {
const els = parts.interior.filter(x => x.id === p.id).map(x => x.element).join(', ');
html += item(`iw:${p.id}`, `Naadwand ${p.seam.a}|${p.seam.b}`, els, 'hn-sub', 'layout-split');
} else html += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub', 'distribute-horizontal');
});
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: [] }));
HouseModel.ensureSeamWalls(state, id); // tussenwand met deur op de nieuwe naad
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 SPAANPLAAT_COLOR = wallMaterials.spaanplaatMaterial.color.getHex(); // kaal (niet geschilderd)
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, lampenVisible = true;
// Vloerplan: alle wanden afgesneden op VLOERPLAN_CUT_MM boven het loopvlak
// (clipping-vlak, doorsnede blijft open); dak en lampen uit.
const VLOERPLAN_CUT_MM = 500;
let vloerplan = false;
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
renderer.localClippingEnabled = true;
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
// Egaal witte afdekking van de snede: per wandstuk een vlak op de
// snijhoogte over de volle dikte (incl. doek/latwerk of steen aan de
// buitenzijde en de hoekdoorloop), met de openingen die door de snede
// lopen uitgespaard. cutAbs = snijhoogte (mm, absoluut).
function buildPlanCaps(cutAbs) {
const toM = 0.001, g = new THREE.Group();
const S = WallModel.STONE;
HouseModel.allWallPieces(parts).forEach(p => {
const y = cutAbs - p.place.floorTop; // snijhoogte in het stuk
if (y <= 0 || y >= WallModel.topLine(p.config).maxY) return;
const W = p.place.width, T = p.place.thickness;
// Buitenlagen op de snijhoogte (alleen buitenwanden: skinExt/normal).
let out = 0;
if (p.place.normal && p.buitenMateriaal !== 'geen') {
const tr = p.trasraam;
out = tr && y < tr.top ? (WallModel.isStoneFinish(tr.finish) ? S.THICK_MM : 0) : WallModel.claddingOffset(p.buitenMateriaal, p.stone);
}
const e = p.skinExt || {};
const xL = -W / 2 - (e.left > 0 ? e.left + out : 0), xR = W / 2 + (e.right > 0 ? e.right + out : 0);
const gaps = p.config.openings.filter(op => op.y < y && op.y + op.h > y)
.map(op => [-W / 2 + op.x, -W / 2 + op.x + op.w]).sort((a, b) => a[0] - b[0]);
const spans = [];
let cur = xL;
gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); });
if (xR > cur) spans.push([cur, xR]);
const z0 = -T / 2, z1 = T / 2 + out;
spans.filter(([a, b]) => b - a > 1).forEach(([a, b]) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry((b - a) * toM, (z1 - z0) * toM), capMaterial);
m.rotation.x = -Math.PI / 2;
m.position.set((a + b) / 2 * toM, (y - 0.5) * toM, (z0 + z1) / 2 * toM);
const holder = new THREE.Group();
holder.rotation.y = p.place.rotY;
holder.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
holder.add(m);
g.add(holder);
});
});
return g;
}
let fullRender = false;
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
// 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: 'dak', label: 'Dak' },
{ key: 'lampen', label: 'Lampen' },
{ key: 'vloerplan', label: 'Vloerplan', checked: false }
], (v) => {
beplatingVisible = v.beplating;
dakVisible = v.dak;
lampenVisible = v.lampen;
vloerplan = v.vloerplan;
refresh({ keepProps: true });
});
// Omgeving (core/skybox.js: geen/dag/nacht) en Render. Render = lampen en
// spots als echte lichtbronnen, in het licht van de gekozen omgeving;
// Nacht = omgevingslicht op 15% en de lampen altijd aan (100%). De
// tegelvloer ligt op het maaiveld, dus het ingegraven deel van vloer en
// sandwichpanelen verdwijnt eronder.
let lightsGroup = null;
function applyRenderMode() {
const lampsOn = fullRender || skyMode === 'night';
scene.background = new THREE.Color(0x1a1a1a);
Skybox.apply(skyMode, { env: skyEnv, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: houseGroup });
ground.visible = fullRender && skyMode === 'none'; // zonder omgeving: vlak waarop het lamplicht valt
grid.visible = skyMode === 'none' && !fullRender;
if (lightsGroup) { scene.remove(lightsGroup); lightsGroup = null; }
// De nep-lichtbundels (kegels) alleen zonder echte lampen.
if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !lampsOn; });
if (lampsOn && parts && lampenVisible && !vloerplan) {
const edge = dakVisible ? parts.roofEdge : null;
lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, parts.lighting.indoor, {
outdoorFactor: skyMode === 'night' ? 1 : LightingModel.DAY_OUTDOOR_FACTOR
});
scene.add(lightsGroup);
}
}
Skybox.createToggle(document.getElementById('ho-toolbar-3d'), (m) => { skyMode = m; 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();
// Binnenspots (plafond): open cirkeltje
ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5;
(parts.lighting.indoor || []).forEach(sp => {
const q = toPx(sp.x, sp.z);
ctx.beginPath(); ctx.arc(q.x, q.y, 3.5, 0, Math.PI * 2); ctx.stroke();
});
// 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) =>
`<div class="ui-panel-head"><h1>${UI.icon(iconName)}${title}</h1>${sub ? `<span class="ui-sub">${sub}</span>` : ''}</div>`;
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);
// Dakbedekking per unit; riet alleen bij een hellend dak.
const pu = parts && parts.units.find(x => x.id === u.id);
const shapeType = pu ? pu.roof.shape.type : 'plat';
const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS)
.filter(([, c]) => !c.sloped || shapeType !== 'plat').map(([k, c]) => [k, c.label]));
const covInfo = (k) => { const c = RoofModel.COVERINGS[k]; return `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²`; };
const bedekking = sel('hp-roof-cover', 'Bedekking', covOpts, RoofModel.coveringFor(u.roof.covering, shapeType), {
hint: shapeType === 'plat' ? 'Riet kan alleen bij een hellend dak (lessenaar/zadel)' : covInfo(RoofModel.coveringFor(u.roof.covering, shapeType)) + '; boeideel schuift mee omhoog'
});
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' }) +
bedekking;
propsEl.innerHTML = warn + head('box', `Unit ${u.id}`, `${f.width} &times; ${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.onSel('hp-roof-cover', v => { u.roof.covering = 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".');
const su = unitById(pu.id);
const fc = HouseModel.facadeFor(state, su, wl.side);
const ov = su.facade.walls[wl.side] || null;
const f0 = state.settings.facade;
const woningLabel = `Woning (${HouseModel.FACADE_TYPES[f0.type]}${f0.type === 'steen' ? ' ' + f0.stone : ''})`;
const gevel = sel('hw-facade', 'Type', Object.assign({ woning: woningLabel }, HouseModel.FACADE_TYPES), ov ? ov.type : 'woning',
{ hint: 'Geldt voor de hele zijde van deze unit' }) +
(ov && ov.type === 'steen' ? stonePicker('hw-facade-stone', 'Steen', fc.stone, `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik`) : '');
propsEl.innerHTML = warn + head('bricks', piece.element, piece.label) +
cols(UI.specCard('Wandstuk', 'rulers', rows), section('Gevelbekleding', 'grip-horizontal', gevel)) +
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);
UI.onSel('hw-facade', v => {
if (v === 'woning') delete su.facade.walls[wl.side];
else su.facade.walls[wl.side] = v === 'steen' ? { type: v, stone: fc.stone } : { type: v };
}, rerender);
UI.onSel('hw-facade-stone', v => { su.facade.walls[wl.side].stone = v; }, rerender);
}
function renderIwProps(iw, warn) {
const p = parts.interior.find(x => x.id === iw.id);
if (iw.seam) return renderSeamProps(iw, warn);
if (!p) { selection = null; return renderProps(); }
propsEl.innerHTML = warn + head('distribute-horizontal', p.element, `Binnenwand &middot; ${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);
}
// Naadwand: midden op de naad (twee delen, één per unit) of geheel in
// unit A/B (één wand). Eén set openingen en maten; de hoogte volgt per
// deel de daklijn van zijn unit.
function renderSeamProps(iw, warn) {
const leaves = parts.interior.filter(x => x.id === iw.id);
const title = `Naadwand ${iw.seam.a} | ${iw.seam.b}`;
const exposed = leaves.filter(l => l.skinFrom !== undefined);
const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
const plaatsen = { midden: 'Midden op de naad (2 delen)', a: `Geheel in unit ${iw.seam.a}`, b: `Geheel in unit ${iw.seam.b}` };
const sheet = iw.wall.wall.sheathingThick;
const dikte = inUnit
? num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall) + sheet, { step: 5, hint: 'Regelwerk + beplating aan beide zijden' })
: num('hi-thick', 'Dikte per deel', HouseModel.thicknessOf(iw.wall), { step: 5, hint: 'Regelwerk + beplating aan de kamerzijde; naadzijde zonder plaat' });
const uitleg = inUnit
? `Binnenwand geheel in unit ${inUnit}, tegen de naad aan, met beplating aan beide zijden. De hoogte volgt de daklijn van unit ${inUnit}.`
: 'Tussenwand op de naad, per unit een eigen deel dat tegen het andere aan staat. Openingen gelden voor beide delen (de deur loopt door). De hoogte volgt per deel de daklijn van zijn unit.';
propsEl.innerHTML = warn + head('layout-split', title, leaves.length ? (leaves.length === 1 ? '1 deel' : `${leaves.length} delen`) : 'vervalt') +
(leaves.length ? '' : UI.warn(`Unit ${iw.seam.a} en ${iw.seam.b} staan niet (meer) tegen elkaar; deze naadwand wordt niet gebouwd.`)) +
cols(
section('Opbouw', 'layers', sel('hi-seam-in', 'Plaats', plaatsen, iw.seam.in || 'midden') + dikte),
UI.specCard('Delen', 'layout-split', leaves.map(l => [`${l.element} (unit ${l.seamUnit})`, l.place.width, 'mm']))) +
info(uitleg +
exposed.map(l => ` ${l.element} steekt vanaf ${l.skinFrom} mm boven het dak van de andere unit uit; dat stuk krijgt buitenafwerking die met de gevel meeloopt.`).join('')) +
actions(
button('Openingen bewerken', 'pencil-square', `data-go="iw:${iw.id}"`),
button('Verwijderen', 'trash', `data-remove-iw="${iw.id}"`, 'ui-btn-danger'));
UI.onSel('hi-seam-in', v => { if (v === 'midden') delete iw.seam.in; else iw.seam.in = v; }, rerender);
UI.onNum('hi-thick', v => { const t = inUnit ? v - sheet : v; if (t >= 40) HouseModel.setThickness(iw.wall, t); }, after);
}
// Woning-instellingen (niets geselecteerd): alles wat voor de hele woning
// geldt, in tabbladen.
// Keuzes voor de vormgeving van het trasraam: plaat of een steen.
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
// Steenkeuze met voorvertoning.
const stonePicker = (id, label, value, hint) => sel(id, label, stoneOptions, value, { hint }) +
`<img class="hs-stone-preview" src="${WallModel.stoneThumbUrl(value, 128)}" alt="${value}">`;
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) &middot; ${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-facade', 'Type', HouseModel.FACADE_TYPES, st.facade.type, { hint: 'Voor alle buitenwanden; per wand aan te passen (klik een wand in de plattegrond)' }) +
(st.facade.type === 'steen'
? stonePicker('hs-facade-stone', 'Steen', st.facade.stone, `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik`)
: sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'Rabat op vochtdoek en kruislat' }))) +
section('Trasraam', 'bricks', toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Strook onderaan de gevel, onder rabat én steen (eigen steen mogelijk)' }) +
(st.trasraam.enabled ? num('hs-trasraam-h', 'Hoogte', st.trasraam.height, { step: 10, hint: 'Vanaf bovenkant vloer' }) +
sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish, {
hint: WallModel.isStoneFinish(st.trasraam.finish)
? `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik`
: 'Cement-gebonden plaat'
}) +
(WallModel.isStoneFinish(st.trasraam.finish)
? `<img class="hs-stone-preview" src="${WallModel.stoneThumbUrl(st.trasraam.finish, 128)}" alt="${st.trasraam.finish}">` : '') : '')),
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 naar beneden` })) +
section('Maaiveld', 'tree', num('hs-ground', 'Onder bovenkant vloer', st.groundLevel, { step: 10, hint: 'Hoogte van de tegelvloer in de 3D-weergave bij Dag/Nacht' }))) },
{ key: 'binnen', label: 'Binnen', icon: 'palette', html: cols(
section('Wanden', 'palette', toggle('hs-int-paint', 'Geschilderd', st.interiorPaint, { hint: 'Alle wanden aan de kamerzijde; egaal, zonder plaatnaden' }) +
(st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning' }) : '')),
section('Vloer', 'grid-3x3', sel('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, f]) => [k, f.label])), st.floorFinish, { hint: 'Alle vloeren (voorlopig één keuze)' }) +
info('Wanden uit: kale spaanplaat met plaatnaden.'))) },
{ 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 }) +
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor, { hint: 'Egale kleur' })),
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(section('Buiten', 'sun', sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode)),
section('Binnenspots', 'circle', toggle('hs-indoor', 'Spots in plafond', lt.indoor, { hint: 'Per unit verdeeld in het plafond' }) +
(lt.indoor ? num('hs-indoor-min', 'Afstand min.', lt.indoorMin, { hint: 'Hart-op-hart, in beide richtingen' }) +
num('hs-indoor-max', 'Afstand max.', lt.indoorMax, { hint: 'Hart-op-hart, in beide richtingen' }) +
num('hs-indoor-off', 'Afstand tot wand', lt.indoorOffset, { hint: 'Tot de binnenzijde van een wand, of tot de unitrand bij een naad' }) : ''))) +
verlichting }
]);
UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender);
UI.onSel('hs-facade', v => { st.facade.type = v; }, rerender);
UI.onColor('hs-int-color', v => { st.interiorColor = v; }, after);
UI.onToggle('hs-int-paint', v => { st.interiorPaint = v; }, rerender);
UI.onSel('hs-floor-finish', v => { st.floorFinish = v; }, rerender);
UI.onSel('hs-facade-stone', v => { st.facade.stone = 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.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender);
UI.onNum('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
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.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender);
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);
UI.onToggle('hs-indoor', v => { lt.indoor = v; }, rerender);
UI.onNum('hs-indoor-min', v => { lt.indoorMin = v; }, after, pos);
UI.onNum('hs-indoor-max', v => { lt.indoorMax = v; }, after, pos);
UI.onNum('hs-indoor-off', v => { lt.indoorOffset = v; }, after);
}
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.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
}
document.getElementById('ho-specs').innerHTML = `<div class="ui-spec-grid">${cards.join('')}</div>`;
}
// --- Alles opnieuw doorrekenen ---
function refresh(opts) {
// Binnenzijde geschilderd: de (gedeelde) spaanplaat krijgt de verfkleur.
wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorPaint ? state.settings.interiorColor : SPAANPLAAT_COLOR);
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, 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);
});
// Vloerplan: snijvlak op het (hoogste) loopvlak + VLOERPLAN_CUT_MM. De
// wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit).
cutPlane.constant = (Math.max(...parts.units.map(u => u.layout.floorTop)) + VLOERPLAN_CUT_MM) * toM;
const clip = vloerplan ? [cutPlane] : [];
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);
p.group.traverse(o => {
if (!o.material) return;
(Array.isArray(o.material) ? o.material : [o.material]).forEach(m => {
if (m.clippingPlanes && m.clippingPlanes.length === clip.length) return;
m.clippingPlanes = clip;
m.needsUpdate = true;
});
});
houseGroup.add(p.group);
});
if (vloerplan) houseGroup.add(buildPlanCaps(cutPlane.constant / toM));
if (dakVisible && !vloerplan) {
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 && !vloerplan) houseGroup.add(parts.lightingGroup);
scene.add(houseGroup);
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);
// Maaiveld: settings.groundLevel onder het loopvlak (bovenkant vloer).
// 2 mm extra lager: bij maaiveld 0 niet in hetzelfde vlak als de OSB (flikkeren).
const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel - 2) * toM;
skyEnv.fitTo(houseGroup, groundY);
ground.position.set(cx, groundY - 0.002, cz);
applyRenderMode();
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')
};