Files
Tectum/js/house/house.js
T

2049 lines
114 KiB
JavaScript
Raw Permalink 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 + 3D
// met bouwfase, inspector met Eigenschappen | Rapport), 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> Structuur</div>
<div id="house-nav-tree"></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>
`;
// Overzicht: plattegrond en 3D over de volle breedte; alle bediening zit in
// de ribbon (core/ribbon.js). Tabblad Rapport toont het rapport in plaats
// van de plattegrond.
const HOUSE_OVERVIEW_TEMPLATE = `
<div id="house-overview">
<div class="ho-views">
<div id="ho-report" class="ho-report"></div>
<div id="ho-pane-plan" class="ho-pane">
<div class="pane-label">PLATTEGROND</div>
<div id="ho-warn" class="ho-warn"></div>
<canvas id="ho-canvas"></canvas>
<div id="ho-hint" class="ho-hint"></div>
</div>
<div id="ho-pane-3d" class="ho-pane">
<div class="pane-label">3D</div>
</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
// Weergave van het overzicht (blijft bewaard bij wisselen van onderdeel):
// bouwfase (BuildStages) en de hoogte van de doorsnede (mm boven het loopvlak).
const view3D = { stage: BuildStages.FINAL, section: false, sectionMM: 1000, dak: true, lampen: true, elektra: true };
// 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, JSON.stringify(w.topPts || 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(); // rendert de boom en de ribbon zelf
return;
}
setPartRibbon(kind, a, b);
renderNav();
}
// Ribbon terwijl een onderdeel-editor open is: terug naar de woning en de
// woning-acties; de editor zelf heeft zijn eigen panelen.
function setPartRibbon(kind, a, b) {
const { big } = Ribbon;
const what = kind === 'floor' ? `Vloer unit ${a}` : kind === 'roof' ? `Dak unit ${a}` : kind === 'wall'
? `${(UnitModel.WALL_SIDES.find(sd => sd.key === b) || {}).label || 'Wand'} unit ${a}`
: (iwById(Number(a)) && iwById(Number(a)).seam ? 'Naadwand' : 'Binnenwand');
Ribbon.setSource('editor', [{
key: 'start', label: 'Start', order: 10,
groups: () => [
{ label: 'Navigatie', html: big('Terug naar woning', 'arrow-left-circle', 'data-hp="back" title="Overzicht van de woning"') },
{ label: 'Onderdeel', html: `<div class="rb-note"><b>${what}</b><br>Wijzigingen gelden direct voor de woning. Hoogtes van een wand bepalen de dakvorm van de unit (de overstaande wand spiegelt mee).</div>` },
{ label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-hp="add-unit"') + big('Binnenwand tekenen', 'pencil', 'data-hp="draw-iw"') }
],
wire: (body) => body.querySelectorAll('[data-hp]').forEach(btn => btn.addEventListener('click', () => {
if (btn.dataset.hp === 'back') showPart('overzicht');
else if (btn.dataset.hp === 'add-unit') addUnit();
else drawInteriorWall();
}))
}]);
Ribbon.activate('start');
}
// ------------------------------------------------------------
// Navigatieboom
// ------------------------------------------------------------
// parts (optioneel): al doorgerekende onderdelen (overzicht), anders zelf rekenen.
function renderNav(parts) {
parts = parts || HouseModel.computeParts(state, { skip3D: true });
const item = (part, label, meta, cls, iconName, active) =>
`<div class="hn-item ${cls || ''} ${(active !== undefined ? active : activePart === part) ? 'active' : ''}" data-part="${part}">
<span class="hn-label">${iconName ? UI.icon(iconName) : ''}${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`;
// "Woning" = overzicht zonder selectie: de instellingen van de hele woning.
let html = item('overzicht', 'Woning', `${parts.units.length} unit${parts.units.length === 1 ? '' : 's'}`, 'hn-top', 'house-door',
activePart === 'overzicht' && !selection);
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.select(selection);
return;
}
const row = e.target.closest('[data-part]');
if (!row) return;
if (row.dataset.part === 'overzicht') {
selection = null;
if (activePart !== 'overzicht') showPart('overzicht');
else activeEditor.select(null);
} else if (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');
}
// Binnenwand tekenen (ribbon): vanuit een onderdeel eerst terug naar het overzicht.
function drawInteriorWall() {
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');
// --- 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();
const elektraMaterials = ElektraModel.createMaterials();
let houseGroup = null;
// Opgeslagen camerastand gaat voor; anders eenmalig passend maken.
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
// Doorsnede: alle wanden afgesneden op view3D.sectionMM boven het
// loopvlak (clipping-vlak, snede wit afgedekt); dak en lampen uit.
let planCaps = null;
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;
// Bouwfase: buitenlagen (latwerk is open, dus pas vanaf de gevel) en beplating alleen als ze er staan.
const gevel = BuildStages.shows(view3D.stage, 'gevel'), beplating = BuildStages.shows(view3D.stage, 'beplating');
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 (gevel && 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.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean)
.map(([a, b]) => [-W / 2 + a, -W / 2 + b]).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 sh = beplating ? 0 : p.config.sheathingThick;
const z0 = -T / 2 + sh, z1 = T / 2 + out - (p.buitenMateriaal !== 'geen' ? sh : 0);
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);
// Bouwfase (core/build-stages.js) + hoogte van de doorsnede.
const stageBar = BuildStages.createBar(pane3D, view3D.stage, (st) => {
view3D.stage = st;
scheduleRefresh({ keepProps: true });
});
stageBar.extra.innerHTML = `<span class="stage-title"><i class="bi bi-scissors"></i>Doorsnede</span>
<input type="range" class="stage-range" id="ho-section" min="100" max="4000" step="50" value="${view3D.sectionMM}" aria-label="Hoogte doorsnede">
<span class="stage-name" id="ho-section-val"></span>`;
const sectionInput = document.getElementById('ho-section');
function syncSectionRow() {
stageBar.extra.classList.toggle('show', view3D.section);
document.getElementById('ho-section-val').textContent = `${view3D.sectionMM} mm`;
}
// Snijhoogte (schuif hier en in Beeld › Doorsnede). Alleen het snijvlak
// en de afdekking verplaatsen, niet alles herbouwen.
function setSectionMM(v) {
if (!isFinite(v)) return;
view3D.sectionMM = Math.max(50, Math.min(8000, Math.round(v)));
sectionInput.value = view3D.sectionMM;
UI.setRange('hv-section-h', view3D.sectionMM);
syncSectionRow();
if (!parts || !view3D.section) return;
placeSection();
}
sectionInput.addEventListener('input', () => setSectionMM(Number(sectionInput.value)));
sectionInput.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) sectionInput.blur(); });
syncSectionRow();
function placeSection() {
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
cutPlane.constant = (floorTop + view3D.sectionMM) * 0.001;
if (planCaps) { houseGroup.remove(planCaps); disposeGroup(planCaps); }
planCaps = view3D.section ? buildPlanCaps(floorTop + view3D.sectionMM) : null;
if (planCaps) houseGroup.add(planCaps);
}
// 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;
// buildLights3D maakt per lamp eigen wash-materialen; de textuur is gedeeld (material.dispose laat die staan).
function disposeLights() {
if (!lightsGroup) return;
scene.remove(lightsGroup);
lightsGroup.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); if (o.isLight && o.dispose) o.dispose(); });
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;
disposeLights();
// 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 && view3D.lampen && !view3D.section && BuildStages.shows(view3D.stage, 'afwerking')) {
const edge = view3D.dak ? 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,
// Nep-lichtvlekken op de gevel (wall-wash), tot het maaiveld.
walls: parts.units.flatMap(u => u.walls.flatMap(w => w.pieces)),
groundY: Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel
});
scene.add(lightsGroup);
}
}
// (Omgeving, Render en Tonen: ribbon Beeld, zie ribbonTabs.)
// --- 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;
}
// Verplaatsbaar elektra-onderdeel (ElektraModel items) onder de cursor.
function elektraHit(x, z) {
const el = parts && parts.elektra;
if (!el || !el.enabled || !view3D.elektra) return null;
const tol = 10 / view.scale;
let best = null;
el.items.forEach(it => {
const d = Math.hypot(it.marker.x - x, it.marker.z - z);
if (d < tol && (!best || d < best.d)) best = { d, it };
});
return best ? best.it : null;
}
const elektraItem = (key) => parts && parts.elektra && parts.elektra.items.find(it => it.key === key);
function hitTest(x, z) {
const eh = elektraHit(x, z);
if (eh) return { type: 'elektra', id: eh.key };
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);
// Selectie wijzigen (plattegrond of boom): inspector naar Eigenschappen.
function select(sel) {
selection = sel;
syncRibbon(true);
drawPlan();
renderNav(parts);
}
// --- 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.querySelectorAll('[data-ho-tool]').forEach(b => b.classList.toggle('active', b.dataset.hoTool === t));
canvas.style.cursor = t === 'iw' ? 'crosshair' : 'default';
updateHint();
drawPlan();
}
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();
}
// Elektra slepen: langs de wanden (ElektraModel.snapToWall); bij loslaten
// vastleggen als handmatige plek (settings.elektra.place).
let elDrag = null, suppressClick = false;
canvas.addEventListener('mousedown', (e) => {
if (tool !== 'select' || !parts || e.button !== 0) return;
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
const it = elektraHit(w.x, w.z);
if (it) elDrag = { key: it.key, sx: e.clientX, sy: e.clientY, moved: false, snap: null };
});
canvas.addEventListener('mousemove', (e) => {
if (!elDrag) return;
if (!elDrag.moved && Math.hypot(e.clientX - elDrag.sx, e.clientY - elDrag.sy) < 4) return;
elDrag.moved = true;
canvas.style.cursor = 'grabbing';
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
elDrag.snap = ElektraModel.snapToWall(parts.elektra, w.x, w.z);
drawPlan();
});
function onElektraDrop() {
if (!elDrag) return;
const d = elDrag;
elDrag = null;
canvas.style.cursor = tool === 'iw' ? 'crosshair' : 'default';
if (!d.moved || !d.snap) return;
suppressClick = true;
const pl = state.settings.elektra.place, prev = pl[d.key] || {};
pl[d.key] = { element: d.snap.element, side: d.snap.side, u: d.snap.u, y: prev.y };
selection = { type: 'elektra', id: d.key };
refresh();
}
window.addEventListener('mouseup', onElektraDrop);
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;
}
if (suppressClick) { suppressClick = false; return; }
const hit = hitTest(w.x, w.z);
if (!sameSel(hit, selection)) select(hit);
});
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);
});
// Elektra (ElektraModel): leidingen, meterkast, schakelaars met letter,
// wcd met groepnummer en bij elke lamp de letter van zijn schakelaar.
const el = parts.elektra;
if (el && el.enabled && view3D.elektra) {
const RC = ElektraPdf.RUN_COLORS, rgb = (c, a) => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`;
const inward = (q, n, d) => toPx(q.x + n.x * d, q.z + n.z * d);
ctx.save();
ctx.lineWidth = 1.2; ctx.setLineDash([4, 3]);
el.runs.forEach(r => {
ctx.strokeStyle = rgb(RC[r.type] || RC.schakel, 0.8);
ctx.beginPath();
r.path.forEach((q, i) => { const p = toPx(q.x, q.z); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); });
ctx.stroke();
});
ctx.restore();
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
if (el.meterkast) {
const k = el.meterkast;
polyPath(k.corners);
ctx.fillStyle = '#5a5a5a'; ctx.fill();
ctx.strokeStyle = '#eee'; ctx.lineWidth = 1; ctx.stroke();
const c = inward({ x: (k.corners[0].x + k.corners[2].x) / 2, z: (k.corners[0].z + k.corners[2].z) / 2 }, k.face.n, 380);
ctx.fillStyle = '#eee'; ctx.font = 'bold 10px sans-serif';
ctx.fillText('MK', c.x, c.y);
}
ctx.strokeStyle = rgb(RC.schakel, 1); ctx.lineWidth = 1;
el.boxes.forEach(b => { const q = toPx(b.x, b.z); ctx.strokeRect(q.x - 3, q.y - 3, 6, 6); });
ctx.font = 'bold 10px sans-serif';
el.combos.forEach(c => {
const q = inward(c.pos, c.face.n, 260), text = c.switches.map(x => x.letter).join(' '), w = 8 + text.length * 6;
ctx.fillStyle = '#fff'; ctx.fillRect(q.x - w / 2, q.y - 7, w, 14);
ctx.strokeStyle = '#1a1a1a'; ctx.strokeRect(q.x - w / 2, q.y - 7, w, 14);
ctx.fillStyle = '#1a1a1a'; ctx.fillText(text, q.x, q.y + 0.5);
});
ctx.font = '9px sans-serif';
el.wcds.forEach(w => {
const q = inward(w.pos, w.face.n, 120), t = inward(w.pos, w.face.n, 420);
ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(q.x, q.y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = rgb(RC.wcd, 1); ctx.fillText(String(w.group), t.x, t.y);
});
ctx.font = 'bold 8px sans-serif';
el.apps.forEach(a => {
const it = el.items.find(x => x.key === a.key), q = toPx(it.marker.x, it.marker.z), t = inward(a.pos, a.face.n, 480);
ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5;
ctx.fillRect(q.x - 9, q.y - 6, 18, 12); ctx.strokeRect(q.x - 9, q.y - 6, 18, 12);
ctx.fillStyle = rgb(RC.wcd, 1); ctx.fillText(a.code, q.x, q.y + 0.5);
ctx.font = '9px sans-serif'; ctx.fillText(String(a.group), t.x, t.y); ctx.font = 'bold 8px sans-serif';
});
ctx.fillStyle = '#ffe9a8'; ctx.font = '9px sans-serif';
el.switches.forEach(sw => sw.lamps.forEach(l => { const q = toPx(l.x, l.z); ctx.fillText(sw.letter, q.x + 7, q.y - 6); }));
// Geselecteerd onderdeel (ring; gestippeld = handmatig geplaatst) en sleep-voorbeeld.
const selIt = selection && selection.type === 'elektra' && el.items.find(x => x.key === selection.id);
if (selIt) {
const q = toPx(selIt.marker.x, selIt.marker.z);
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(q.x, q.y, 13, 0, Math.PI * 2); ctx.stroke();
}
el.items.filter(x => x.manual).forEach(x => {
const q = toPx(x.marker.x, x.marker.z);
ctx.fillStyle = '#4da6ff'; ctx.beginPath(); ctx.arc(q.x + 10, q.y - 9, 2.5, 0, Math.PI * 2); ctx.fill();
});
if (elDrag && elDrag.snap) {
const sn = elDrag.snap, a = toPx(sn.x, sn.z), b = toPx(sn.x + sn.n.x * 200, sn.z + sn.n.z * 200);
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.beginPath(); ctx.arc(b.x, b.y, 7, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = '#4da6ff'; ctx.textAlign = 'left';
ctx.fillText(`${sn.element} · ${sn.u} mm`, b.x + 10, b.y - 8);
ctx.textAlign = 'center';
}
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
}
// 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();
}
}
// ------------------------------------------------------------
// RIBBON (core/ribbon.js): alle bediening van het overzicht. Vaste
// tabbladen voor de woning, plus een contextueel tabblad voor de
// selectie (unit, wandstuk, binnenwand/naadwand).
// ------------------------------------------------------------
const { num, sel, toggle } = UI;
const { big, small, kv } = Ribbon;
const after = () => scheduleRefresh({ keepProps: true }); // getal/schuif: ribbon blijft staan
const rerender = () => refresh(); // keuze: velden kunnen wijzigen
// Afgeleide waarden in de ribbon die na een verversing meelopen
// (bijv. de dakhelling), zonder het tabblad opnieuw op te bouwen.
let live = [];
function updateLive() { live.forEach(fn => fn()); }
const note = (text, isWarn) => `<div class="rb-note${isWarn ? ' rb-warn' : ''}">${text}</div>`;
const pair = (...items) => `<div class="rb-pair">${items.join('')}</div>`;
const thumb = (url, alt) => url ? `<img class="rb-thumb" src="${url}" alt="${alt}" title="${alt}">` : '';
const check = (id, label, checked) => `<label class="rb-check"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}>${label}</label>`;
const SIDE_SHORT = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' };
const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ',');
// 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);
// Kliks op knoppen met data-ho-act / data-go / data-ho-tool (per tekening opnieuw).
function wireButtons(body) {
body.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool)));
body.querySelectorAll('[data-go]').forEach(b => b.addEventListener('click', () => showPart(b.dataset.go)));
body.querySelectorAll('[data-ho-act]').forEach(b => b.addEventListener('click', () => {
const a = b.dataset.hoAct;
if (a === 'add-unit') addUnit();
else if (a === 'project') openProjectProperties();
else if (a === 'save') exportDesign();
else if (a === 'costs') CostSettings.open();
else if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
else if (a === 'remove-unit') removeUnit(b.dataset.unit);
else if (a === 'remove-iw') {
state.interiorWalls = state.interiorWalls.filter(w => w.id !== Number(b.dataset.iw));
selection = null;
refresh();
}
}));
}
// --- Vaste tabbladen ---
const startTab = {
key: 'start', label: 'Start', order: 10,
groups: () => [
{ label: 'Gereedschap', html:
big('Selecteren', 'cursor', `data-ho-tool="select" title="Klik = selecteren, dubbelklik = bewerken (Esc)"`, tool === 'select' ? 'active' : '') +
big('Binnenwand tekenen', 'pencil', `data-ho-tool="iw" title="Twee klikken in de plattegrond; snapt op 50 mm, haaks en op wandvlakken"`, tool === 'iw' ? 'active' : '') },
{ label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-ho-act="add-unit" title="Aan de geselecteerde (of laatste) unit"') + big('Project', 'card-text', 'data-ho-act="project"') },
{ label: 'Overzicht', html: `<div data-live="start-dims" class="rb-cols"></div>` },
{ label: 'Werkwijze', html: note('Selecteer een unit, wand of binnenwand in de plattegrond of de structuur: het tabblad van die selectie verschijnt rechts. Dubbelklik om te bewerken. Oranje gestippeld = naad tussen units.') }
],
wire: (body) => {
live = [];
wireButtons(body);
const dimsLive = () => {
const el = body.querySelector('[data-live="start-dims"]');
if (!el || !parts) return;
const d = HouseModel.computeDimensions(parts, state.settings.groundLevel), f = houseFmt;
el.innerHTML = kv('Buitenmaten', `${f(d.lengthX / 1000, 2)} × ${f(d.lengthZ / 1000, 2)}`, 'm') + kv('Bruto vloer', f(d.brutoM2, 1), 'm²') +
kv('Bouwhoogte', f(d.height / 1000, 2), 'm') + kv('Leefbaar', f(d.leefbaarM2, 1), 'm²') +
kv('Gevel netto', f(d.gevelNettoM2, 1), 'm²') + kv('Inhoud', f(d.inhoudM3, 0), 'm³');
};
live.push(dimsLive);
dimsLive();
}
};
const gevelTab = {
key: 'gevel', label: 'Gevel', order: 20,
groups: () => {
const st = state.settings;
const rabatColors = Object.fromEntries(Object.entries(WallModel.RABAT_COLORS).map(([k, c]) => [k, c.label]));
return [
{ label: 'Bekleding', html: sel('hs-facade', 'Type', HouseModel.FACADE_TYPES, st.facade.type, { hint: 'Voor alle buitenwanden; per wand aan te passen (selecteer de wand)' }) +
(st.facade.type === 'steen'
? pair(sel('hs-facade-stone', 'Steen', stoneOptions, st.facade.stone, { hint: `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik` }), thumb(WallModel.stoneThumbUrl(st.facade.stone, 64), st.facade.stone))
: sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'Rabat op vochtdoek en kruislat' }) +
pair(sel('hs-rabat-color', 'Kleur', rabatColors, st.facade.rabatColor, { hint: 'Ook voor rabat-overrides per wand' }),
`<div class="rb-swatch" style="background:${WallModel.rabatHex(st.facade.rabatColor)}"></div>`)) },
{ label: 'Trasraam', html: toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Strook onderaan de gevel, onder rabat én steen' }) +
(st.trasraam.enabled
? UI.range('hs-trasraam-h', 'Hoogte', st.trasraam.height, { min: 100, max: 1000, step: 10, hint: 'Vanaf bovenkant vloer' }) +
pair(sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish),
WallModel.isStoneFinish(st.trasraam.finish) ? thumb(WallModel.stoneThumbUrl(st.trasraam.finish, 64), st.trasraam.finish) : '')
: '') },
{ label: 'Onderzijde', html: toggle('hs-sandwich', 'Sandwichpanelen', st.sandwich,
{ hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm rondom, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` }) +
UI.range('hs-ground', 'Maaiveld', st.groundLevel, { min: 0, max: 800, step: 10, hint: 'Onder de bovenkant vloer; tegelvloer in de 3D bij Dag/Nacht' }) },
{ label: 'Per wand', html: note('Een eigen gevel voor één zijde van een unit: selecteer die wand in de plattegrond.') }
];
},
wire: (body) => {
live = [];
const st = state.settings;
UI.onSel('hs-facade', v => { st.facade.type = v; }, rerender);
UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender);
UI.onSel('hs-rabat-color', v => { st.facade.rabatColor = v; }, rerender);
UI.onSel('hs-facade-stone', v => { st.facade.stone = v; }, rerender);
UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender);
UI.onRange('hs-trasraam-h', v => { if (v > 0) st.trasraam.height = v; }, after);
UI.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender);
UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
}
};
const dakTab = {
key: 'dak', label: 'Dak', order: 30,
groups: () => {
const r = state.settings.roof;
return [
{ label: 'Boeideel', html:
UI.range('hs-overhang', 'Overstek', r.overhang, { min: RoofModel.OVERHANG_MIN, max: RoofModel.OVERHANG_MAX, step: 10, hint: 'Vanaf de gevel' }) +
UI.range('hs-fascia-h', 'Hoogte', r.fasciaHeight, { min: 150, max: 500, step: 10 }) +
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) },
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) },
{ label: 'Daken per unit', html: `<div data-live="roof-list" class="rb-cols"></div>` },
{ label: 'Dakvorm', html: note('Vorm, hoogtes, helling en bedekking zijn per unit: selecteer een unit.') }
];
},
wire: (body) => {
live = [];
const r = state.settings.roof, pos = { positive: true };
UI.onRange('hs-overhang', v => { if (v > 0) r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after);
UI.onRange('hs-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after);
UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender);
UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos);
const list = () => {
const el = body.querySelector('[data-live="roof-list"]');
if (!el || !parts) return;
el.innerHTML = parts.units.map(u => {
const pl = u.roof.planes[0], deg = Math.abs(pl ? pl.theta : 0) * 180 / Math.PI;
const cov = (RoofModel.COVERINGS[u.roof.covering] || {}).label || '';
return kv(`Unit ${u.id}`, `${(UnitModel.ROOF_TYPES[u.roof.shape.type] || u.roof.shape.type).toLowerCase()}${deg > 0.05 ? ' ' + fmtDeg(deg) + '°' : ''} · ${cov.toLowerCase()}`);
}).join('');
};
live.push(list);
list();
}
};
const binnenTab = {
key: 'binnen', label: 'Binnen', order: 40,
groups: () => {
const st = state.settings;
return [
{ label: 'Wanden', html: toggle('hs-int-paint', 'Geschilderd', st.interiorPaint, { hint: 'Alle wanden aan de kamerzijde; egaal, zonder plaatnaden. Uit: kale spaanplaat.' }) +
(st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning' }) : '') },
{ label: 'Vloer', html: pair(sel('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, fi]) => [k, fi.label])), st.floorFinish),
thumb(FloorModel.finishThumbUrl(st.floorFinish, 64), st.floorFinish)) },
{ label: 'Binnenwanden', html: big('Binnenwand tekenen', 'pencil', 'data-ho-tool="iw"') + note(`${state.interiorWalls.length} binnen-/naadwand(en). Selecteer er een voor positie, dikte en openingen.`) }
];
},
wire: (body) => {
live = [];
wireButtons(body);
const st = state.settings;
UI.onToggle('hs-int-paint', v => { st.interiorPaint = v; }, rerender);
UI.onColor('hs-int-color', v => { st.interiorColor = v; }, after);
UI.onSel('hs-floor-finish', v => { st.floorFinish = v; }, rerender);
}
};
const constructieTab = {
key: 'constructie', label: 'Constructie', order: 45,
groups: () => {
const sp = state.settings.spacing;
const bad = sp.exterior.max < 2 * sp.exterior.min || sp.interior.max < 2 * sp.interior.min;
return [
{ label: 'Stijlen buitenwanden', html: num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart' }) },
{ label: 'Stijlen binnenwanden', html: num('hs-int-min', 'Afstand min.', sp.interior.min, { step: 10, hint: 'Hart-op-hart' }) + num('hs-int-max', 'Afstand max.', sp.interior.max, { step: 10, hint: 'Hart-op-hart' }) },
{ label: 'Verdeling', html: note(bad
? 'Max. is kleiner dan 2 × min.: niet elk vak is dan te verdelen; daar wijkt de verdeling af van min. of max.'
: 'Elk vak wordt gelijk verdeeld met een h.o.h. tussen min. en max. (max. ≥ 2 × min.: altijd mogelijk).', bad) },
{ label: 'Balken', html: kv('Wandbalk', `${WallModel.STANDARD_BEAM.width} × ${WallModel.STANDARD_BEAM.depth}`, 'mm') +
kv('Handelslengtes', WallModel.STOCK_LENGTHS_MM.map(l => l / 1000).join(' · ').replace(/\./g, ','), 'm') +
kv('Latwerk', `${WallModel.BATTEN.thick} × ${WallModel.BATTEN.width}, ${WallModel.BATTEN.spacing} h.o.h.`, 'mm') }
];
},
wire: () => {
live = [];
const sp = state.settings.spacing, pos = { positive: true };
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);
}
};
const lichtTab = {
key: 'licht', label: 'Verlichting', order: 50,
groups: () => {
const r = state.settings.roof, lt = state.settings.lighting;
const groups = [{ label: 'Buiten', html: sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode) +
(lt.mode === 'gevel' ? sel('hs-light-dir', 'Richting', LightingModel.DIRECTIONS, lt.direction) : '') }];
if (lt.mode === 'gevel') {
groups.push({ label: 'Gevellampen', html: UI.range('hs-lamp-h', 'Hoogte', lt.lampHeight, { min: 1200, max: 3000, step: 50, 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' }) });
groups.push({ label: 'Plaatsing', html: num('hs-lamp-w', 'Min. gevel', lt.minWallWidth, { hint: 'Smallere vrije stukken gevel krijgen geen lamp' }) +
num('hs-lamp-end', 'Tot hoek', lt.endOffset) + num('hs-lamp-open', 'Tot opening', lt.openingOffset, { hint: 'Voor wanden met de instelling "Naast openingen" (selecteer de wand)' }) });
} else if (lt.mode === 'spots') {
groups.push({ label: 'Boeideel-spots', html: num('hs-spot-min', 'Afstand min.', r.spotMin, { hint: 'Hart-op-hart' }) + num('hs-spot-max', 'Afstand max.', r.spotMax, { hint: 'Hart-op-hart' }) +
note('Alleen onder units met boeidelen.') });
}
groups.push({ label: 'Binnenspots', html: toggle('hs-indoor', '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', 'Tot wand', lt.indoorOffset, { hint: 'Tot de binnenzijde van een wand, of tot de unitrand bij een naad' }) : '') });
groups.push({ label: 'Aantallen', html: `<div data-live="light-count" class="rb-cols"></div>` });
return groups;
},
wire: (body) => {
live = [];
const r = state.settings.roof, lt = state.settings.lighting, pos = { positive: true };
UI.onSel('hs-light-mode', v => { lt.mode = v; }, rerender);
UI.onSel('hs-light-dir', v => { lt.direction = v; }, rerender);
UI.onRange('hs-lamp-h', v => { if (v > 0) lt.lampHeight = v; }, after);
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.onNum('hs-spot-min', v => { r.spotMin = v; }, after, pos);
UI.onNum('hs-spot-max', v => { r.spotMax = 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);
const count = () => {
const el = body.querySelector('[data-live="light-count"]');
if (!el || !parts) return;
el.innerHTML = kv('Gevellampen', parts.lighting.lamps.length, 'st.') +
kv('Boeideel-spots', parts.roofEdge ? parts.roofEdge.spots.length : 0, 'st.') + kv('Binnenspots', (parts.lighting.indoor || []).length, 'st.');
};
live.push(count);
count();
}
};
const elektraTab = {
key: 'elektra', label: 'Elektra', order: 55,
groups: () => {
const e = state.settings.elektra;
const groups = [{ label: 'Elektra', html: toggle('he-on', 'Installatie', e.enabled, { hint: 'Meterkast, schakelaars, wcd en leidingen (automatisch, eerste versie)' }) }];
if (e.enabled) {
groups.push({ label: 'Meterkast', html: num('he-mk-w', 'Breedte', e.meterkast.w, { step: 10 }) + num('he-mk-h', 'Hoogte', e.meterkast.h, { step: 10 }) +
num('he-mk-d', 'Diepte', e.meterkast.d, { step: 10 }) + num('he-mk-y', 'Onderkant', e.meterkast.y, { step: 10, hint: 'Vanaf bovenkant vloer; zo dicht mogelijk bij de voordeur' }) });
groups.push({ label: 'Punten', html: num('he-sw-h', 'Schakelaar', e.switchHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) +
num('he-wcd-h', 'Wcd', e.wcdHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + num('he-wcd-n', 'Wcd per unit', e.wcdPerUnit, { step: 1, hint: 'Dubbele wcd' }) });
groups.push({ label: 'Apparaten', html: Object.entries(ElektraModel.APPLIANCES).map(([k, A]) =>
check(`he-app-${k}`, `${A.label} (${A.code})`, !!e.apparaten[k])).join('') });
const nManual = Object.keys(e.place).length;
groups.push({ label: 'Plaatsing', html: note('Sleep meterkast, schakelaars, wcd en apparaten in de plattegrond; selecteer er een voor positie en hoogte.') +
(nManual ? big('Alles automatisch', 'arrow-counterclockwise', `data-he-reset title="${nManual} handmatige plek(ken) wissen"`) : '') });
groups.push({ label: 'Leidingen', html: num('he-slack', 'Marge %', e.slack, { step: 1 }) + num('he-res', 'Reserve', e.reserve, { step: 50, hint: 'Per leiding (lussen in de dozen), mm' }) +
num('he-mk-res', 'Extra MK', e.meterkastReserve, { step: 50, hint: 'Per leiding vanuit de meterkast, mm' }) });
groups.push({ label: 'Strekkende meters', html: `<div data-live="el-cables" class="rb-cols"></div>` });
groups.push({ label: 'Groepen', html: `<div data-live="el-groups" class="rb-cols"></div>` });
}
return groups;
},
wire: (body) => {
live = [];
const e = state.settings.elektra, pos = { positive: true };
UI.onToggle('he-on', v => { e.enabled = v; }, rerender);
UI.onNum('he-mk-w', v => { e.meterkast.w = v; }, after, pos);
UI.onNum('he-mk-h', v => { e.meterkast.h = v; }, after, pos);
UI.onNum('he-mk-d', v => { e.meterkast.d = v; }, after, pos);
UI.onNum('he-mk-y', v => { e.meterkast.y = v; }, after);
UI.onNum('he-sw-h', v => { e.switchHeight = v; }, after, pos);
UI.onNum('he-wcd-h', v => { e.wcdHeight = v; }, after, pos);
UI.onNum('he-wcd-n', v => { e.wcdPerUnit = Math.max(0, Math.round(v)); }, after);
UI.onNum('he-slack', v => { e.slack = v; }, after);
UI.onNum('he-res', v => { e.reserve = v; }, after);
UI.onNum('he-mk-res', v => { e.meterkastReserve = v; }, after);
Object.keys(ElektraModel.APPLIANCES).forEach(k => {
const cb = document.getElementById(`he-app-${k}`);
if (cb) cb.addEventListener('change', () => { e.apparaten[k] = cb.checked; refresh(); });
});
const rs = body.querySelector('[data-he-reset]');
if (rs) rs.addEventListener('click', () => { e.place = {}; refresh(); });
const show = () => {
const el = parts && parts.elektra;
const c = body.querySelector('[data-live="el-cables"]'), g = body.querySelector('[data-live="el-groups"]');
if (!el) return;
if (c) c.innerHTML = el.cables.map(x => kv(x.use, houseFmt(x.meters, 1), 'm')).join('') || note('Geen leidingen.');
if (g) g.innerHTML = el.groups.map(x => kv(`${x.nr} ${x.phase} AL${x.rcd}`, `${x.label} (${x.breaker})`)).join('') + el.warnings.map(w => note(w, true)).join('');
};
live.push(show);
show();
}
};
const rapportTab = {
key: 'rapport', label: 'Rapport', order: 60,
groups: () => [
{ label: 'Rapport', html: note('Transport per unit, gewicht, afmetingen, kosten, hout en plaatwerk. Staat in plaats van de plattegrond zolang dit tabblad open is en loopt mee met elke wijziging.') },
{ label: 'Uitvoer', html: big('Opslaan + PDF', 'file-earmark-pdf', 'data-ho-act="save" title="JSON en de volledige PDF (Ctrl+S)"') },
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-ho-act="costs"') }
],
wire: (body) => { live = []; wireButtons(body); }
};
const beeldTab = {
key: 'beeld', label: 'Beeld', order: 70,
groups: () => [
{ label: 'Tonen', html: check('hv-dak', 'Dak', view3D.dak) + check('hv-lampen', 'Lampen', view3D.lampen) + check('hv-elektra', 'Elektra', view3D.elektra) },
{ label: 'Doorsnede', html: check('hv-section', 'Doorsnede', view3D.section) +
UI.range('hv-section-h', 'Hoogte', view3D.sectionMM, { min: 100, max: 4000, step: 50, hint: 'Snijhoogte boven de bovenkant vloer' }) },
{ label: 'Omgeving', html: `<div class="rb-seg">${Skybox.MODES.map(m => small(Skybox.LABELS[m][1], Skybox.LABELS[m][0], `data-sky="${m}"`, m === skyMode ? 'active' : '')).join('')}</div>` +
small('Render', 'camera', 'data-render title="Elke lamp en spot als echte lichtbron"', fullRender ? 'active' : '') }
],
wire: (body) => {
live = [];
const onCheck = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('change', () => { fn(el.checked); scheduleRefresh({ keepProps: true }); }); };
onCheck('hv-dak', v => { view3D.dak = v; });
onCheck('hv-lampen', v => { view3D.lampen = v; });
onCheck('hv-elektra', v => { view3D.elektra = v; });
onCheck('hv-section', v => { view3D.section = v; syncSectionRow(); });
UI.onRange('hv-section-h', setSectionMM);
body.querySelectorAll('[data-sky]').forEach(b => b.addEventListener('click', () => {
skyMode = b.dataset.sky;
Skybox.setMode(skyMode);
body.querySelectorAll('[data-sky]').forEach(x => x.classList.toggle('active', x === b));
applyRenderMode();
}));
const rb = body.querySelector('[data-render]');
if (rb) rb.addEventListener('click', () => { fullRender = !fullRender; rb.classList.toggle('active', fullRender); applyRenderMode(); });
}
};
// --- Contextueel tabblad: de selectie ---
function contextTab() {
if (!selection) return null;
if (selection.type === 'unit' && unitById(selection.id)) return unitTab(unitById(selection.id));
if (selection.type === 'wall') return wallTab();
if (selection.type === 'iw' && iwById(selection.id)) return iwById(selection.id).seam ? seamTab(iwById(selection.id)) : iwTab(iwById(selection.id));
if (selection.type === 'elektra' && elektraItem(selection.id)) return elektraItemTab(elektraItem(selection.id));
return null;
}
// Geselecteerd elektra-onderdeel: plek langs de wand en hoogte; slepen in
// de plattegrond of hier invullen maakt de plek handmatig.
function elektraItemTab(it) {
const e = state.settings.elektra;
const setPlace = (patch) => {
const cur = e.place[it.key] || { element: it.element, side: it.side, u: Math.round(it.u) };
e.place[it.key] = Object.assign({}, cur, patch);
};
return {
key: 'sel', label: it.label, icon: 'plug', contextual: true, order: 100,
groups: () => [
{ label: 'Onderdeel', html: kv('Wand', it.element + (it.side > 0 ? ' (andere zijde)' : '')) + kv('Unit', it.unitId) + kv('Plaatsing', it.manual ? 'handmatig' : 'automatisch') },
{ label: 'Plaats', html: num('he-it-u', 'Langs wand', Math.round(it.u), { step: 10, hint: 'Hart, vanaf de linkerkant van de wand (buitenaanzicht)' }) +
num('he-it-y', it.kind === 'mk' ? 'Onderkant' : 'Hoogte', Math.round(it.y), { step: 10, hint: it.kind === 'mk' ? 'Vanaf bovenkant vloer' : 'Hart, vanaf bovenkant vloer' }) },
{ label: 'Verplaatsen', html: note('Sleep het onderdeel in de plattegrond naar een andere wand of plek.') +
(it.manual ? big('Automatisch', 'arrow-counterclockwise', 'data-he-auto title="Weer automatisch plaatsen"') : '') }
],
wire: (body) => {
live = [];
UI.onNum('he-it-u', v => setPlace({ u: v }), after);
UI.onNum('he-it-y', v => setPlace({ y: v }), after);
const b = body.querySelector('[data-he-auto]');
if (b) b.addEventListener('click', () => { delete e.place[it.key]; refresh(); });
}
};
}
function unitTab(u) {
const isRoot = state.units[0].id === u.id;
const f = u.unit.floor.floor;
// Dakvorm (UnitModel.roofForm): de invoer; de wanden volgen.
const form = UnitModel.roofForm(u.unit);
const sloped = () => form.type !== 'plat';
const LIM = UnitModel.LIMITS;
// Actuele parts opzoeken: na Breedte/Diepte (keepProps) wordt de tab niet opnieuw opgebouwd.
const partsOf = () => parts && parts.units.find(x => x.id === u.id);
// Horizontale lengte van de helling: over de volle vloermaat (goot boven de buitenkant gevel).
const runOf = () => {
const pu = partsOf();
const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form));
const L = pu ? (s.axis === 'x' ? pu.layout.floorWidth : pu.layout.floorDepth) : 1000;
return UnitModel.hasRidge(form.type) ? L / 2 : L;
};
const degOf = () => sloped() ? Math.atan(Math.max(0, form.high - form.low) / runOf()) * 180 / Math.PI : 0;
// Mansarde: hellingen van het onder- en bovenvlak.
const mansardeDeg = () => {
const kIn = Math.max(1, form.kneeIn || 1), rest = Math.max(1, runOf() - kIn);
return [Math.atan(Math.max(0, form.kneeH - form.low) / kIn), Math.atan(Math.max(0, form.high - form.kneeH) / rest)].map(r => r * 180 / Math.PI);
};
return {
key: 'sel', label: `Unit ${u.id}`, icon: 'box', contextual: true, order: 100,
groups: () => {
const a = u.attach, axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z';
const pu = partsOf(), shapeNow = pu ? pu.roof.shape.type : form.type;
const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS).filter(([, c]) => !c.sloped || shapeNow !== 'plat').map(([k, c]) => [k, c.label]));
const covNow = RoofModel.coveringFor(u.roof.covering, shapeNow);
const c = RoofModel.COVERINGS[covNow];
const missing = HouseModel.missingSeamWalls(state, u.id);
const groups = [
{ label: 'Afmetingen', html:
UI.range('hp-width', 'Breedte', f.width, { min: 2000, max: LIM.long, step: 50, hint: `West–oost (unit max ${LIM.short / 1000} x ${LIM.long / 1000} m)`.replace('.', ',') }) +
UI.range('hp-depth', 'Diepte', f.depth, { min: 2000, max: LIM.long, step: 50, hint: `Noord–zuid (unit max ${LIM.short / 1000} x ${LIM.long / 1000} m)`.replace('.', ',') }) +
sel('hp-corners', 'Hoeken', UnitModel.CORNER_MODES, u.unit.layout.corners, { hint: 'Welke wanden doorlopen over de volle vloermaat' }) },
{ label: 'Dakvorm', html: sel('hp-roof-type', 'Vorm', UnitModel.ROOF_TYPES, form.type) +
(form.type === 'lessenaar' ? sel('hp-roof-dir', 'Hoge zijde', SIDE_SHORT, form.dir) : '') +
(UnitModel.hasRidge(form.type) ? sel('hp-roof-ridge', 'Nok', UnitModel.RIDGES, form.ridge) : '') +
UI.range('hp-roof-low', sloped() ? 'Goot' : 'Hoogte', form.low, { min: 2100, max: LIM.eave, step: 10, hint: `Vanaf bovenkant vloer tot onderkant dak (max ${LIM.eave} mm)` }) }
];
if (sloped()) groups.push({ label: 'Helling', html:
UI.range('hp-roof-high', UnitModel.hasRidge(form.type) ? 'Nok' : 'Hoog', form.high, { min: 2100, max: LIM.ridge, step: 10, hint: `Max ${LIM.ridge} mm` }) +
(form.type === 'mansarde' ? '' : UI.range('hp-roof-deg', 'Helling', Math.round(degOf() * 10) / 10, { min: 1, max: 60, step: 0.5, unit: '°', hint: 'Zet de hoge zijde/nok bij de huidige goothoogte' })) +
`<div data-live="roof-gable">${kv('Kopgevel', `wand ${SIDE_SHORT[UnitModel.gableOf(form)].toLowerCase()} + overstaande`)}</div>` });
if (form.type === 'mansarde') groups.push({ label: 'Mansarde', html:
UI.range('hp-roof-knee-h', 'Knik', form.kneeH, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte van de knik tussen het steile en het flauwe dakvlak' }) +
UI.range('hp-roof-knee-in', 'Knik in', form.kneeIn, { min: 100, max: 3000, step: 10, hint: 'Horizontale afstand van de gevel tot de knik' }) +
`<div data-live="roof-mansarde">${kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '))}</div>` });
if (form.type === 'wolf') groups.push({ label: 'Wolfsdak', html:
UI.range('hp-roof-cut', 'Wolfseind', form.cut, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte waarop de kopgevels stoppen; daarboven driehoekige wolfseinden met de dakhelling tot de nok' }) });
groups.push({ label: 'Dak', html:
pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet kan alleen bij een hellend dak' : `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²` }),
thumb(RoofModel.coveringThumbUrl(covNow, 64), c.label)) +
toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim' }) +
toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) });
if (!u.roof.followFloor) groups.push({ label: 'Dakmaat', html:
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', 'Versch. oost', u.roof.offsetX || 0) + num('hp-roof-oz', 'Versch. zuid', u.roof.offsetZ || 0) });
groups.push({ label: 'Koppeling', html: isRoot
? note('Basis-unit: de noordwesthoek ligt op (0, 0). Andere units koppelen hieraan.')
: sel('hp-att-to', '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) });
groups.push({ label: 'Bewerken', html:
big('Vloer', 'grid-3x3', `data-go="floor:${u.id}" title="Vloer-editor: balken, doorvoeren"`) +
big('Dak', 'house', `data-go="roof:${u.id}" title="Dak-editor: dek, doorvoeren"`) +
(missing.length ? small('Naadwand toevoegen', 'plus-lg', `data-ho-act="add-seams" data-unit="${u.id}" title="Zonder naadwand: ${missing.map(m => `${m.a}|${m.b}`).join(', ')}"`) : '') +
(isRoot ? '' : small('Unit verwijderen', 'trash', `data-ho-act="remove-unit" data-unit="${u.id}"`, 'rb-danger')) });
return groups;
},
wire: (body) => {
live = [];
wireButtons(body);
// Unitmaat binnen de grenzen (de andere maat geklemd als beide te groot zijn).
const size = (k, v) => {
if (!(v >= 1000)) return;
f[k] = v;
UnitModel.clampSize(f, k === 'width' ? 'depth' : 'width');
UI.setRange('hp-width', f.width); UI.setRange('hp-depth', f.depth);
};
UI.onRange('hp-width', v => size('width', v), after);
UI.onRange('hp-depth', v => size('depth', v), after);
UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender);
UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after);
// Andere koppeling: naadwanden van deze unit gaan mee.
UI.onSel('hp-att-to', v => { u.attach.to = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
UI.onSel('hp-att-side', v => { u.attach.side = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
UI.onSel('hp-att-align', v => { u.attach.align = v; HouseModel.syncSeamWalls(state, u.id); }, rerender);
// Dakvorm: elke wijziging past de wanden aan (applyRoofForm).
const apply = () => {
form.low = Math.min(form.low, LIM.eave);
form.high = Math.min(form.high, LIM.ridge);
UnitModel.applyRoofForm(u.unit, form);
};
UI.onSel('hp-roof-type', v => {
form.type = v;
if (v !== 'plat' && !(form.high > form.low)) form.high = form.low + 700;
if (v === 'lessenaar' && !form.dir) form.dir = 'voor';
if (UnitModel.hasRidge(v) && !form.ridge) form.ridge = 'z';
if (v === 'mansarde') {
// Standaard: steil onderdeel (~65°) over ruim 1/3 van de halve gevel, flauw (~27°) bovendeel.
const run = runOf();
form.kneeIn = Math.round(run * 0.35);
form.kneeH = Math.round(form.low + form.kneeIn * 2.1);
form.high = Math.min(LIM.ridge, Math.max(form.high, Math.round(form.kneeH + (run - form.kneeIn) * 0.5)));
form.kneeRatio = Math.min(0.9, (form.kneeH - form.low) / (form.high - form.low));
}
if (v === 'wolf') {
if (form.high - form.low < 1000) form.high = form.low + 1500;
form.cutRatio = 0.6;
}
apply();
}, rerender);
UI.onSel('hp-roof-dir', v => { form.dir = v; apply(); }, rerender);
UI.onSel('hp-roof-ridge', v => { form.ridge = v; apply(); }, rerender);
// Na toepassen: de werkelijke (geklemde) maten in de velden; de
// verhoudingen (kneeRatio, cutRatio) blijven zoals ingesteld.
const syncDeg = () => {
const now = UnitModel.roofForm(u.unit);
if (now.type === form.type) ['kneeH', 'kneeIn', 'cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; });
UI.setRange('hp-roof-deg', Math.round(degOf() * 10) / 10);
UI.setRange('hp-roof-knee-h', form.kneeH);
UI.setRange('hp-roof-knee-in', form.kneeIn);
UI.setRange('hp-roof-cut', form.cut);
const md = body.querySelector('[data-live="roof-mansarde"]');
if (md && form.type === 'mansarde') md.innerHTML = kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '));
};
UI.onRange('hp-roof-low', v => {
if (!(v >= 500)) return;
form.low = v;
if (sloped() && form.high < v) { form.high = v; UI.setRange('hp-roof-high', v); }
apply(); syncDeg();
}, after);
UI.onRange('hp-roof-high', v => {
if (!(v >= 500)) return;
form.high = Math.max(v, form.low);
apply(); syncDeg();
}, after);
UI.onRange('hp-roof-deg', v => {
if (!(v >= 0.5) || v > 75) return;
form.high = Math.round(form.low + Math.tan(v * Math.PI / 180) * runOf());
UI.setRange('hp-roof-high', form.high);
apply(); syncDeg();
}, after);
const ratioOf = (v) => Math.max(0.05, Math.min(0.95, (v - form.low) / Math.max(1, form.high - form.low)));
UI.onRange('hp-roof-knee-h', v => { if (v >= 500) { form.kneeRatio = ratioOf(v); apply(); syncDeg(); } }, after);
UI.onRange('hp-roof-knee-in', v => { if (v >= 50) { form.kneeIn = v; apply(); syncDeg(); } }, after);
UI.onRange('hp-roof-cut', v => { if (v >= 500) { form.cutRatio = ratioOf(v); apply(); syncDeg(); } }, after);
UI.onSel('hp-roof-cover', v => { u.roof.covering = v; }, rerender);
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = 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 wallTab() {
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) return null;
const su = unitById(pu.id);
return {
key: 'sel', label: `Wand ${piece.element}`, icon: 'bricks', contextual: true, order: 100,
groups: () => {
const lt = state.settings.lighting;
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 : ''})`;
return [
{ label: piece.label, html:
kv('Lengte zijde', wl.width, 'mm') + kv('Wandstuk', piece.place.width, 'mm') + kv('Positie', `${Math.round(piece.u0)} – ${Math.round(piece.u1)}`, 'mm') +
kv('Openingen', piece.config.openings.length) + kv('Naad', wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)}–${Math.round(b)}`).join(', ') : 'geen', wl.cutsU.length ? 'mm' : '') +
kv('Hoogte', `${piece.config.wallHeightLeft} / ${piece.config.wallHeightRight}`, 'mm') },
{ label: 'Gevel (hele zijde)', html:
sel('hw-facade', 'Type', Object.assign({ woning: woningLabel }, HouseModel.FACADE_TYPES), ov ? ov.type : 'woning') +
(ov && ov.type === 'steen' ? pair(sel('hw-facade-stone', 'Steen', stoneOptions, fc.stone), thumb(WallModel.stoneThumbUrl(fc.stone, 64), fc.stone)) : '') },
{ label: 'Gevellampen', html: lt.mode === 'gevel'
? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, su.lighting.walls[wl.side] || 'auto',
{ hint: `Automatisch: ${lt.endOffset} mm uit de hoek, op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm ernaast.` }) +
kv('Op dit wandstuk', piece.lamps.length, 'st.')
: note('Gevellampen staan uit (tabblad Verlichting).') },
wl.dropped.length ? { label: 'Let op', html: note(`${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.`, true) } : null,
{ label: 'Bewerken', html: big('Wand bewerken', 'pencil-square', `data-go="wall:${pu.id}:${wl.side}" title="Wand-editor: openingen, hoogtes, regelwerk"`) }
];
},
wire: (body) => {
live = [];
wireButtons(body);
const fc = HouseModel.facadeFor(state, su, wl.side);
UI.onSel('hw-light', v => { su.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 iwTab(iw) {
const p = parts.interior.find(x => x.id === iw.id);
if (!p) return null;
return {
key: 'sel', label: p.element, icon: 'distribute-horizontal', contextual: true, order: 100,
groups: () => [
{ label: 'Positie', html: 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) },
{ label: 'Opbouw', html: num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), { step: 5 }) + num('hi-height', 'Hoogte', iw.wall.wall.heightAll) + kv('Lengte', p.place.width, 'mm') },
{ label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}" title="Delete"`, 'rb-danger') }
],
wire: (body) => {
live = [];
wireButtons(body);
['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 seamTab(iw) {
const leaves = parts.interior.filter(x => x.id === iw.id);
const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
const sheet = iw.wall.wall.sheathingThick;
return {
key: 'sel', label: `Naadwand ${iw.seam.a}|${iw.seam.b}`, icon: 'layout-split', contextual: true, order: 100,
groups: () => {
const plaatsen = { midden: 'Midden (2 delen)', a: `Geheel in unit ${iw.seam.a}`, b: `Geheel in unit ${iw.seam.b}` };
const exposed = leaves.filter(l => l.skinFrom !== undefined);
return [
{ label: 'Opbouw', html: sel('hi-seam-in', 'Plaats', plaatsen, iw.seam.in || 'midden') +
(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' })) },
{ label: 'Delen', html: leaves.length
? leaves.map(l => kv(`${l.element}`, l.place.width, 'mm')).join('') + exposed.map(l => kv(`${l.element} buiten vanaf`, l.skinFrom, 'mm')).join('')
: note(`Unit ${iw.seam.a} en ${iw.seam.b} staan niet (meer) tegen elkaar; deze naadwand wordt niet gebouwd.`, true) },
{ label: 'Uitleg', html: note(inUnit
? `Binnenwand geheel in unit ${inUnit}, tegen de naad, beplating aan beide zijden; hoogte volgt de daklijn van unit ${inUnit}.`
: 'Per unit een eigen deel tegen het andere aan; openingen gelden voor beide delen (de deur loopt door). Hoogte volgt per deel de daklijn.') },
{ label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}"`, 'rb-danger') }
];
},
wire: (body) => {
live = [];
wireButtons(body);
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);
}
};
}
// Ribbon bijwerken (na een verversing of een andere selectie). focus =
// het selectie-tabblad openen.
function syncRibbon(focus) {
const tabs = [startTab, gevelTab, dakTab, binnenTab, constructieTab, lichtTab, elektraTab, rapportTab, beeldTab];
const ctx = contextTab();
if (ctx) tabs.push(ctx);
Ribbon.setSource('editor', tabs, { focus: focus && !!ctx });
}
// Waarschuwingen (overlappende units, ongeldige koppeling) op de plattegrond.
function renderWarnings() {
const msgs = (parts.overlaps.length ? [`Units overlappen elkaar: ${parts.overlaps.map(p => p.join(' + ')).join(', ')}.`] : [])
.concat(parts.rects.filter(r => r.error).map(r => `Unit ${r.id}: ${r.error}`));
document.getElementById('ho-warn').innerHTML = msgs.map(m => UI.warn(m)).join('');
}
// --- Rapport: in plaats van de plattegrond zolang tabblad Rapport open is ---
let reportOpen = false, reportDirty = true;
const overviewEl = document.getElementById('house-overview');
const offRibbon = Ribbon.onChange((key) => {
const open = key === 'rapport';
if (open === reportOpen) return;
reportOpen = open;
overviewEl.classList.toggle('show-report', open);
if (open && reportDirty && parts) renderReport();
requestAnimationFrame(onResize);
});
// Grafieken (HTML, geen bibliotheek). Kleuren: vaste volgorde, zie css
// (--series-1..3); waarden staan er altijd als tekst bij, de tabel
// eronder is de toegankelijke weergave.
const esc = UI.esc;
function stackedBars(title, iconName, series, rows, fmtVal, note) {
const max = Math.max(1, ...rows.map(r => r.total));
const legend = series.length > 1
? `<div class="viz-legend">${series.map((sr, i) => `<span><i class="viz-key s${i + 1}"></i>${sr.label}</span>`).join('')}</div>` : '';
const body = rows.map(r => `<div class="viz-row">
<div class="viz-label">${esc(r.label)}${r.sub ? `<small>${r.sub}</small>` : ''}</div>
<div class="viz-track"><div class="viz-bar" style="width:${(r.total / max * 100).toFixed(2)}%">${series.map((sr, i) => {
const v = r.values[sr.key] || 0;
return v > 0 ? `<span class="viz-seg s${series.length > 1 ? i + 1 : 1}" style="flex:${v}" title="${esc(`${r.label} · ${sr.label}: ${fmtVal(v)}`)}"></span>` : '';
}).join('')}</div></div>
<div class="viz-val">${fmtVal(r.total)}</div>
</div>`).join('');
return `<div class="ui-spec-card viz"><h2>${UI.icon(iconName)}${title}</h2>${legend}${body}${note ? `<div class="viz-note">${note}</div>` : ''}</div>`;
}
function renderReport() {
reportDirty = false;
const f = houseFmt;
const totals = HouseModel.computeTotals(parts);
const dims = HouseModel.computeDimensions(parts, state.settings.groundLevel);
const transport = HouseModel.computeTransport(parts);
const cost = CostModel.compute(parts, totals);
const kg = (v) => `${f(v, 0)} kg`;
const m = (mm) => f(mm / 1000, 2);
const cards = [
stackedBars('Transport per unit', 'truck', [
{ key: 'vloer', label: 'Vloer' }, { key: 'wanden', label: 'Wanden' }, { key: 'dak', label: 'Dak' }
], transport.map(t => ({
label: `Unit ${t.id}`, total: t.totalKg, values: t.kg,
sub: `${m(t.length)} × ${m(t.width)} × ${m(t.height)} m · ${HouseModel.TRANSPORT_CLASSES[t.cls].toLowerCase()}`
})), kg, `Buitenmaat l × b × h incl. gevelafwerking, zonder dakrand. Breedte tot ${HouseModel.TRANSPORT.widthRegular} mm regulier, tot ${HouseModel.TRANSPORT.widthPermit} mm met ontheffing (indicatief). Gewicht zonder kozijnen, isolatie en steen.`),
UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']),
UI.specCard('Afmetingen', 'rulers', HouseModel.dimensionRows(dims, f)),
stackedBars('Kostprijs per unit', 'cash-coin', [{ key: 'v', label: 'Kostprijs' }],
cost.units.map(u => ({ label: u.id ? `Unit ${u.id}` : 'Woning-breed', total: u.kostprijs, values: { v: u.kostprijs } })), (v) => CostModel.eur(v),
'Woning-breed: dakrand, fundering, verlichting, kraan.'),
...costCards(cost),
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(pw => pw.m2 > 0).map(pw => [pw.naam, f(pw.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));
}
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
const sw = totals.saw, cs = cost.saw;
cards.push(UI.specCard('Zaagmachine (tijdelijk)', 'scissors', [
['Zaagmeters', f(sw.meters, 1), 'm'],
['Zaagtijd robot', CostModel.uur(cs.robotSec / 3600), 'uur'],
[`Zaagtijd handmatig (×${cost.settings.zagen.handFactor})`, CostModel.uur(cs.handSec / 3600), 'uur'],
['Zaagsneden', sw.cuts, 'st.'],
['Verschillende hoeken', sw.angles.length, '']
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0°)' : `Schuin ${f(a.deg, 1)}°`, a.count, 'sneden']))));
document.getElementById('ho-report').innerHTML = `<div class="ho-report-head"><h1>${UI.icon('clipboard-data')}Rapport</h1><span>${UI.esc(state.project.naam || 'Woning')} · ${state.units.length} unit(s)</span></div><div class="ui-spec-grid">${cards.join('')}</div>`;
}
// Kostenraming (CostModel; kosten/tijden via Instellingen > Kosten & tijden).
function costCards(c) {
const s = c.summary, S = c.settings;
const eur = (n) => CostModel.eur(n);
return [
UI.specCard('Kostenraming (indicatief)', 'cash-coin',
s.rows.map(r => [r.sub ? `<b>${r.label}</b>` : r.label, r.sub ? `<b>${eur(r.amount)}</b>` : eur(r.amount), '']),
['Totaal incl. btw', eur(s.inclBtw), '']),
stackedBars('Materiaal per groep', 'box-seam', [{ key: 'v', label: 'Materiaal' }],
c.categories.filter(r => r.mat > 0).map(r => ({ label: r.name, total: r.mat, values: { v: r.mat } })), eur),
UI.specCard('Arbeid per werkzaamheid', 'tools', c.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600), 'manuur'])
.concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect), 'manuur'], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa), 'manuur']]), ['Totaal', CostModel.uur(s.hours), 'manuur'])
];
}
const offCosts = CostModel.onChange(() => { if (parts && overviewEl.isConnected) { reportDirty = true; if (reportOpen) renderReport(); } });
// --- Alles opnieuw doorrekenen ---
// Verversen gebundeld per beeldje (schuiven en typen geven veel events).
let pending = null, firstSync = true;
function scheduleRefresh(opts) {
const keep = !!(opts && opts.keepProps);
if (pending) { pending.keepProps = pending.keepProps && keep; return; }
pending = { keepProps: keep };
requestAnimationFrame(() => { const o = pending; pending = null; if (canvas.isConnected) refresh(o); });
}
// Oude 3D vrijgeven (geometrie wordt per verversing nieuw opgebouwd;
// materialen worden gedeeld en blijven).
const disposeGroup = (g) => g && g.traverse(o => { if (o.geometry) o.geometry.dispose(); });
function refresh(opts) {
// Voorvertoningen van de gekozen materialen alvast laden voor de PDF
// (legenda op het aanzichtenblad; HousePdf.materialThumbs).
HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage);
// Binnenzijde geschilderd (laatste bouwfase): de gedeelde spaanplaat krijgt de verfkleur.
const painted = state.settings.interiorPaint && BuildStages.shows(view3D.stage, 'afwerking');
wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR);
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra });
reportDirty = true;
if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); }
houseGroup = new THREE.Group();
planCaps = null;
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);
});
// Doorsnede: de wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit).
const section = view3D.section;
const clip = section ? [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);
});
placeSection();
if (view3D.dak && !section) {
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 && !section) houseGroup.add(parts.lightingGroup);
if (parts.elektraGroup) houseGroup.add(parts.elektraGroup);
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;
signPos = null;
if (!section && BuildStages.shows(view3D.stage, 'afwerking')) houseGroup.add(buildLogoSign(groundY));
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;
}
// Selectie die na de herberekening niet meer bestaat (wandstuk hernoemd of vervallen): loslaten.
const stale = !!selection && !contextTab();
if (stale) selection = null;
if (stale || !(opts && opts.keepProps)) syncRibbon(firstSync && !!selection);
else updateLive();
firstSync = false;
renderWarnings();
if (reportOpen) renderReport();
drawPlan();
renderNav(parts);
}
// --- Bord met logo: paal + plaat naast het pad, enkele meters voor de
// voordeur, met de plaat naar buiten gericht (leesbaar vanaf de straat).
const SIGN = { ahead: 4000, aside: 1800, poleH: 2200, poleW: 80, plateW: 1500, plateD: 30 };
let signPos = null; // mm, voor botsen tijdens lopen
let signMaterial = null;
// Paal en rand: één keer aanmaken (buildLogoSign draait bij elke refresh).
const signPoleMat = new THREE.MeshStandardMaterial({ color: 0x2b2b2b, roughness: 0.6, metalness: 0.3 });
const signEdgeMat = new THREE.MeshStandardMaterial({ color: 0x212121, roughness: 0.6 });
function getSignMaterial() {
if (signMaterial) return signMaterial;
const canvas = document.createElement('canvas');
canvas.width = 1024; canvas.height = 340;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const tex = new THREE.CanvasTexture(canvas);
tex.anisotropy = 8;
if (window.TECTUM_LOGO) {
const img = new Image();
img.onload = () => {
const w = canvas.width * 0.86, h = w / window.TECTUM_LOGO.aspect;
ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
tex.needsUpdate = true;
};
img.src = window.TECTUM_LOGO.png;
}
signMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5 });
return signMaterial;
}
function buildLogoSign(groundY) {
const { d, n } = frontDoor();
// Opzij langs de gevel (rechts gezien vanaf de straat), zodat het pad vrij blijft.
const t = { x: n.z, z: -n.x };
signPos = { x: d.x + n.x * SIGN.ahead + t.x * SIGN.aside, z: d.z + n.z * SIGN.ahead + t.z * SIGN.aside };
const toM = 0.001, plateH = SIGN.plateW / 3; // canvas 1024 x 340
const g = new THREE.Group();
const pole = new THREE.Mesh(new THREE.BoxGeometry(SIGN.poleW * toM, SIGN.poleH * toM, SIGN.poleW * toM), signPoleMat);
pole.position.y = SIGN.poleH / 2 * toM;
g.add(pole);
const edge = signEdgeMat;
const face = getSignMaterial();
// BoxGeometry-materialvolgorde: +x, -x, +y, -y, +z (voorkant), -z (achterkant).
const plate = new THREE.Mesh(new THREE.BoxGeometry(SIGN.plateW * toM, plateH * toM, SIGN.plateD * toM),
[edge, edge, edge, edge, face, face]);
plate.position.set(0, (SIGN.poleH - plateH / 2 - 100) * toM, (SIGN.poleW + SIGN.plateD) / 2 * toM);
g.add(plate);
g.traverse(o => { if (o.isMesh) o.receiveShadow = true; });
g.position.set(signPos.x * toM, groundY, signPos.z * toM);
g.rotation.y = Math.atan2(n.x, n.z); // lokale +z = gevelnormaal (naar buiten)
return g;
}
// --- Lopen (first person), eigen sneltoets Shift+T ---
// Start 3 m voor de voordeur, buiten; botsen tegen wanden (deuren zijn
// doorgangen), ondergrond = vloer binnen een unit, anders het maaiveld.
const WALK_RADIUS = 250; // mm, "lichaam"
function wallBlocked(xm, zm) {
if (!parts) return false;
const x = xm * 1000, z = zm * 1000;
if (signPos && Math.hypot(x - signPos.x, z - signPos.z) < SIGN.poleW / 2 + WALK_RADIUS) return true;
return HouseModel.allWallPieces(parts).some(p => {
if (p.place.baseY) return false; // opbouw boven een lager dak
const c = Math.cos(p.place.rotY), s = Math.sin(p.place.rotY);
const dx = x - p.place.cx, dz = z - p.place.cz;
const lx = dx * c - dz * s, lz = dx * s + dz * c;
const W = p.place.width, T = p.place.thickness;
const out = p.place.normal ? WallModel.claddingOffset(p.buitenMateriaal, p.stone) : 0;
if (lz < -T / 2 - WALK_RADIUS || lz > T / 2 + out + WALK_RADIUS || Math.abs(lx) > W / 2 + WALK_RADIUS) return false;
// Door een deur(opening) mag je lopen.
return !(p.config.openings || []).some(op => op.y < 100 && op.y + Math.min(op.h, op.hR) > 1800 &&
lx > -W / 2 + op.x + WALK_RADIUS * 0.6 && lx < -W / 2 + op.x + op.w - WALK_RADIUS * 0.6);
});
}
function groundAt(xm, zm) {
const x = xm * 1000, z = zm * 1000;
const u = parts && parts.units.find(q => x >= q.rect.x0 && x <= q.rect.x1 && z >= q.rect.z0 && z <= q.rect.z1);
const top = parts ? Math.max(...parts.units.map(q => q.layout.floorTop)) : 0;
return (u ? u.layout.floorTop : top - (state.settings.groundLevel || 0)) / 1000;
}
const walk = createWalkControls(camera, renderer.domElement, { blocked: wallBlocked, floorAt: groundAt });
let orbitSaved = null;
// Voordeur (anders een buitendeur, anders midden van de noordgevel):
// midden van de opening aan de buitenzijde (mm) + gevelnormaal naar buiten.
function frontDoor() {
const ext = [];
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
let best = null;
['voordeur', 'deur'].some(type => ext.some(p => (p.config.openings || []).some(op => {
if (op.type !== type) return false;
best = { p, lx: -p.place.width / 2 + op.x + op.w / 2 };
return true;
})));
if (!best) {
const p = ext.find(q => q.place.normal && q.place.normal.y < -0.5) || ext[0];
best = { p, lx: 0 };
}
const d = UnitModel.localToWorld(best.p.place, best.lx, best.p.place.thickness / 2);
return { d, n: { x: best.p.place.normal.x, z: best.p.place.normal.y } };
}
// Startpositie: 3 m voor de voordeur, kijkend naar de deur.
function walkStartPose() {
const { d, n } = frontDoor();
const x = (d.x + n.x * 3000) / 1000, z = (d.z + n.z * 3000) / 1000;
return { x, z, yaw: Math.atan2(-n.x, -n.z) };
}
const walkHint = document.createElement('div');
walkHint.className = 'walk-hint';
walkHint.textContent = 'Lopen: W/S of pijltjes = vooruit/achteruit · A/D = opzij · pijl links/rechts = draaien · Shift = rennen · slepen = rondkijken · Shift+T = terug';
pane3D.appendChild(walkHint);
function setWalk(on) {
if (on === walk.active || !parts) return;
const view = document.getElementById('house-overview');
if (on) {
// Lopen gebeurt in de volle 3D (alleen render); vorige stand onthouden.
orbitSaved = { pos: camera.position.clone(), target: controls.target.clone(), fov: camera.fov, cls: view.className };
view.classList.remove('fullscreen-2d');
view.classList.add('fullscreen-3d', 'render-only');
requestAnimationFrame(onResize);
controls.enableRotate = controls.enablePan = controls.enableZoom = false;
camera.fov = 65;
camera.updateProjectionMatrix();
walk.start(walkStartPose());
} else {
walk.stop();
controls.enableRotate = controls.enablePan = controls.enableZoom = true;
if (orbitSaved) {
view.className = orbitSaved.cls;
requestAnimationFrame(onResize);
camera.fov = orbitSaved.fov;
camera.updateProjectionMatrix();
camera.position.copy(orbitSaved.pos);
controls.target.copy(orbitSaved.target);
controls.update();
}
}
walkHint.classList.toggle('show', on && !shooter.active);
pane3D.classList.toggle('walking', on);
if (!on && shooter.active) shooter.stop();
}
// --- Easter egg: schieten (Ctrl+Shift+Alt+L), bovenop het lopen ---
const shooter = createShooter({ scene, camera, domElement: renderer.domElement, pane: pane3D, walk,
floorAt: groundAt, blocked: wallBlocked, targets: () => [houseGroup, ground] });
function toggleShooter() {
if (shooter.active) { setWalk(false); return; }
if (!parts) return;
setWalk(true);
walk.speedFactor = 1.3; // 30% sneller in de shooter-modus
shooter.start();
walkHint.classList.remove('show');
}
let rafId = null, lastT = performance.now();
function animate() {
rafId = requestAnimationFrame(animate);
const now = performance.now();
walk.update((now - lastT) / 1000);
shooter.update((now - lastT) / 1000);
lastT = now;
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) {
const el = document.getElementById('house-overview');
setWalk(false); // weergave wisselen beëindigt het lopen
fn(el);
requestAnimationFrame(onResize);
}
refresh();
animate();
setTool(pendingTool || 'select');
pendingTool = null;
return {
unmount() {
window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKey);
window.removeEventListener('mouseup', onElektraDrop);
if (rafId !== null) cancelAnimationFrame(rafId);
setWalk(false); // orbitcamera terug vóór het opslaan
walk.dispose();
shooter.dispose();
CameraMemory.save(overviewCamKey, camera, controls);
disposeGroup(houseGroup);
disposeLights();
signPoleMat.dispose(); signEdgeMat.dispose();
offRibbon();
offCosts();
Ribbon.setSource('editor', []);
renderer.dispose();
},
refresh,
select,
setTool,
toggleFullscreen3D: () => toggleView(UI.cycle3D),
toggleWalk: () => setWalk(!walk.active),
toggleShooter,
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'),
toggleWalk: forward('toggleWalk'),
toggleShooter: forward('toggleShooter'),
toggleFullscreen2D: forward('toggleFullscreen2D'),
openProjectProperties,
getStateJson,
loadStateJson
};
}
window.HouseEditor = {
mount: mountHouseEditor,
getDefaultState: (template) => HouseModel.createDefaultState(template || 'single')
};