Files
Tectum/js/house/house.js
T
MachielandClaude Opus 5.5 decbbb4b72 v0.7.4: light-versie voor de modelpagina's (light.html, js/light/, css/light.css; later in de HUGO-site in een Bootstrap col-12): één model in 3D, doorsnede (snijhoogte tot de goot) en 2D-plattegrond, met beperkte keuzes: per unit breedte en diepte (binnen UnitModel.LIMITS, per model/pagina te beperken), dakvorm met richting, goot- en nokhoogte (helling erbij) en wolfseind, dakbedekking, gevelbekleding met kleur en richting, accent van het model, trasraam (geen/steen, hoogte), binnenspots en buitenverlichting; zelfde berekening als de configurator maar alleen prijs (indicatief, incl. btw), maten, m², inhoud en gewicht zichtbaar; LightModel past keuzes toe op een schone kopie van het model (binnenwanden en inrichting schuiven mee, binnenwanden volgen de goot) en weigert keuzes die de indeling breken (ramen/deuren, binnenwanden, inrichting, ruimtes, overlap) met de dichtstbijzijnde maat die wel past; accordeon-paneel, responsief op de breedte van de tool (container queries), scrollwiel scrolt de pagina tenzij je in de 3D klikt of Ctrl houdt; opslaan = PDF (LightPdf): voorblad "Uw woningontwerp" met render, kerncijfers, keuzes, prijs, contact en QR-code naar de modelpagina (qrcode-generator, MIT), aanzichten en plattegrond (HousePdf klantvariant zonder productie-informatie) en het stappenplan met maatwerk en uitnodiging; doorsnede-afdekking naar HouseModel.sectionCaps (ook in de configurator): 1 mm boven de snede en over de wandtop van wanden die lager blijven, geen flikkerend regelwerk meer; OrbitControls met touch (één vinger draaien, twee vingers knijpen/schuiven); model Vecht (U-vorm 13 × 9,5 m, drie units rond een holte)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:48:51 +02:00

2937 lines
171 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const HOUSE_TEMPLATE = `
<div id="house-app">
<div class="house-body">
<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;">
</aside>
<div id="house-part-host"></div>
</div>
<footer class="statusbar">
<span id="ho-hint" class="sb-hint"></span>
<span id="h-io-status" class="io-status"></span>
</footer>
</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="ui-btn" data-esc>Annuleren</button>
<button type="button" id="house-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
</div>
</div>
</div>
`;
const HOUSE_OVERVIEW_TEMPLATE = `
<div id="house-overview">
<div class="ed-main">
<div class="ed-toolbar" id="ho-toolbar"></div>
<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 class="plan-zoom">
<button type="button" data-zoom="in" title="Inzoomen (scrollwiel)"><i class="bi bi-plus-lg"></i></button>
<button type="button" data-zoom="out" title="Uitzoomen (scrollwiel)"><i class="bi bi-dash-lg"></i></button>
<button type="button" data-zoom="fit" title="Passend (F); slepen op een lege plek of met de middelste muisknop verschuift"><i class="bi bi-aspect-ratio"></i></button>
<div class="plan-zoom-val" id="ho-zoom-val">100%</div>
</div>
</div>
<div id="ho-pane-3d" class="ho-pane">
<div class="pane-label">3D</div>
</div>
</div>
</div>
<aside id="ho-inspector" class="ed-panel inspector" aria-label="Eigenschappen"></aside>
</div>
`;
const SPACING_LOCK_MSG = 'De h.o.h. is globaal ingesteld voor alle wanden van de woning (eigenschappen Woning › Constructie, 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;
let activeEditor = null;
let activeWallLayout = null;
let selection = null;
let pendingTool = null;
const view3D = { layers: ViewLayers.all(), section: false, sectionMM: 1000, mode: 'split', report: false };
let fillType = 'woonkamer';
let fixtureType = 'toilet';
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);
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)) {
const unit = unitById(a).unit, before = unit.walls[b].wall;
const next = activeEditor.getStateJson();
if (activeWallLayout) UnitModel.topParamsFromLayout(activeWallLayout, next.wall);
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' });
}
}
function showPart(part) {
flushActivePart();
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
activeEditor = null;
activeWallLayout = 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: eigenschappen Woning › Gevel, niets geselecteerd.',
lockExterior: 'De gevelbekleding volgt de woning (eigenschappen Woning › 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 }))))
});
const ws = HouseModel.clone(unitById(a).unit.walls[b]);
Object.assign(ws.wall, UnitModel.topParamsOn(wl, ws.wall), { width: wl.width });
if (!ws.wall.topPts) delete ws.wall.topPts;
activeWallLayout = wl;
activeEditor.loadStateJson(ws);
} else if (kind === 'roof' && unitById(a)) {
HouseModel.applyGlobalSettings(state);
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 (woning, unit selecteren) 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 });
else HouseModel.interiorLayout(iwById(Number(a)));
activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'De lengte van een binnenwand volgt uit de tekening in de plattegrond van de woning.', lockSpacing: SPACING_LOCK_MSG });
activeEditor.loadStateJson(HouseModel.clone(iwById(Number(a)).wall));
} else {
activePart = 'overzicht';
activeEditor = mountOverview();
return;
}
setPartToolbar(kind, a, b);
renderNav();
}
// Onderdeel-editor binnen de woning: terug-knop en naam vooraan in de werkbalk van die editor.
function setPartToolbar(kind, a, b) {
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');
const bar = host.querySelector('.ed-toolbar');
if (bar) {
bar.insertAdjacentHTML('afterbegin', `<div class="tb-group">${UI.tb('Woning', 'arrow-left', 'data-hp="back" title="Terug naar de woning (Esc)"')}<span class="tb-title">${what}</span></div>${UI.tbSep()}`);
bar.querySelector('[data-hp="back"]').addEventListener('click', () => showPart('overzicht'));
}
setHint('Wijzigingen gelden direct voor de woning. Hoogtes van een wand bepalen de dakvorm van de unit (de overstaande wand spiegelt mee).');
}
function setHint(text) { document.getElementById('ho-hint').textContent = text; }
// Structuur: groepen in-/uitklapbaar (onthouden); een groep met het actieve onderdeel of de selectie klapt vanzelf open.
const NAV_PREF = 'tectum.nav.closed';
let navActiveKey = null;
let navClosed;
try { navClosed = new Set(JSON.parse(localStorage.getItem(NAV_PREF) || 'null') || ['iw', 'rooms', 'fixtures']); } catch (e) { navClosed = new Set(['iw', 'rooms', 'fixtures']); }
function renderNav(parts) {
parts = parts || HouseModel.computeParts(state, { skip3D: true });
const inOverview = activePart === 'overzicht';
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>`;
const chevron = (key) => `<button type="button" class="hn-toggle" data-nav-toggle="${key}" title="In-/uitklappen" aria-label="In-/uitklappen">${UI.icon('chevron-right')}</button>`;
const group = (key, headHtml, body, hasActive) =>
`<div class="hn-group${hasActive || !navClosed.has(key) ? ' open' : ''}" data-nav-group="${key}">${headHtml}<div class="hn-children">${body}</div></div>`;
const head = (key, iconName, label, meta) =>
`<div class="hn-item hn-unit hn-head" data-nav-toggle="${key}">${chevron(key)}<span class="hn-label">${UI.icon(iconName)}${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`;
const [pk, pa] = (activePart || '').split(':');
let html = item('overzicht', 'Woning', `${parts.units.length} unit${parts.units.length === 1 ? '' : 's'}`, 'hn-top', 'house-door', inOverview && !selection);
parts.units.forEach(u => {
const sel = selection && selection.type === 'unit' && selection.id === u.id && inOverview;
let body = item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub', 'grid-3x3') + 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(', ');
body += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : ''), 'bricks');
});
const wallSel = inOverview && selection && selection.type === 'wall' && selection.unit === u.id;
html += group('unit:' + u.id,
`<div class="hn-item hn-unit ${sel ? 'active' : ''}" data-select-unit="${u.id}">${chevron('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>`,
body, (['floor', 'roof', 'wall'].includes(pk) && pa === u.id) || wallSel);
});
if (parts.interior.length) {
let body = '';
const seen = new Set();
parts.interior.forEach(p => {
if (seen.has(p.id)) return;
seen.add(p.id);
if (p.seam) {
const els = parts.interior.filter(x => x.id === p.id).map(x => x.element).join(', ');
body += item(`iw:${p.id}`, `Naadwand ${p.seam.a}|${p.seam.b}`, els, 'hn-sub', 'layout-split');
} else body += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub', 'distribute-horizontal');
});
html += group('iw', head('iw', 'distribute-horizontal', 'Binnenwanden', seen.size), body,
pk === 'iw' || (inOverview && selection && selection.type === 'iw'));
}
const R = parts.rooms;
if (R && (R.rooms.length || R.free.length)) {
const total = R.rooms.concat(R.free).reduce((a, r) => a + r.areaM2, 0);
let body = '';
R.rooms.forEach(r => {
const act = selection && selection.type === 'room' && selection.id === r.id && inOverview;
body += `<div class="hn-item hn-sub ${act ? 'active' : ''}" data-select-room="${r.id}"><span class="hn-label">${UI.icon('square-half')}${UI.esc(r.name)}</span><span class="hn-meta">${houseFmt(r.areaM2, 2)} m²</span></div>`;
});
if (R.free.length) body += `<div class="hn-item hn-sub hn-static hn-removed"><span class="hn-label">${R.free.length} zonder functie</span><span class="hn-meta">${houseFmt(R.free.reduce((a, r) => a + r.areaM2, 0), 1)} m²</span></div>`;
html += group('rooms', head('rooms', 'bounding-box', 'Ruimtes', `${houseFmt(total, 1)} m²`), body,
inOverview && selection && selection.type === 'room');
}
if (parts.fixtures && parts.fixtures.length) {
let body = '';
parts.fixtures.forEach(f => {
const act = selection && selection.type === 'fixture' && selection.id === f.id && inOverview;
body += `<div class="hn-item hn-sub ${act ? 'active' : ''}" data-select-fixture="${f.id}"><span class="hn-label">${UI.icon('dot')}${UI.esc(f.label)}</span><span class="hn-meta">${f.unitId}</span></div>`;
});
html += group('fixtures', head('fixtures', 'lamp', 'Inrichting', parts.fixtures.length), body,
inOverview && selection && selection.type === 'fixture');
}
const tree = document.getElementById('house-nav-tree');
tree.innerHTML = html;
// Alleen in beeld schuiven als de selectie of het onderdeel verandert, niet bij elke herberekening.
const key = activePart + '|' + JSON.stringify(selection);
const act = tree.querySelector('.hn-item.active');
if (act && key !== navActiveKey) act.scrollIntoView({ block: 'nearest' });
navActiveKey = key;
}
document.getElementById('house-nav-tree').addEventListener('click', (e) => {
const tg = e.target.closest('[data-nav-toggle]');
if (tg && (tg.classList.contains('hn-toggle') || tg.classList.contains('hn-head'))) {
const key = tg.dataset.navToggle, g = tg.closest('.hn-group');
const open = !g.classList.contains('open');
g.classList.toggle('open', open);
if (open) navClosed.delete(key); else navClosed.add(key);
try { localStorage.setItem(NAV_PREF, JSON.stringify([...navClosed])); } catch (err) { }
return;
}
const roomRow = e.target.closest('[data-select-room]');
const unitRow = e.target.closest('[data-select-unit]');
const fxRow = e.target.closest('[data-select-fixture]');
if (roomRow || unitRow || fxRow) {
selection = fxRow ? { type: 'fixture', id: Number(fxRow.dataset.selectFixture) }
: roomRow ? { type: 'room', id: Number(roomRow.dataset.selectRoom) } : { 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);
});
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);
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');
}
function drawInteriorWall() {
pendingTool = 'iw';
if (activePart !== 'overzicht') showPart('overzicht');
else activeEditor.setTool('iw');
}
function mountOverview() {
host.innerHTML = HOUSE_OVERVIEW_TEMPLATE;
UI.setView(document.getElementById('house-overview'), view3D.mode);
const canvas = document.getElementById('ho-canvas');
const ctx = canvas.getContext('2d');
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);
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();
const floorMaterials = FloorModel.createMaterials();
const roofMaterials = RoofModel.createMaterials();
const lightingMaterials = LightingModel.createMaterials();
const sandwichMaterials = SandwichModel.createMaterials();
const postMaterials = PostModel.createMaterials();
const chimneyMaterials = ChimneyModel.createMaterials();
const elektraMaterials = ElektraModel.createMaterials();
let houseGroup = null;
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
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 });
// Snede wit afgedekt; latwerk is open, pas vanaf de gevel afdekken (HouseModel.sectionCaps).
const buildPlanCaps = (cutAbs) => HouseModel.sectionCaps(parts, cutAbs, capMaterial, view3D.layers);
let fullRender = false;
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
// Werkbalk: gereedschap links (met opties van het actieve gereedschap), weergave rechts.
const toolbar = document.getElementById('ho-toolbar');
const TOOLS = [
['select', 'Selecteren', 'cursor', 'V', 'Klik = selecteren, dubbelklik = bewerken'],
['iw', 'Binnenwand', 'pencil', 'W', 'Twee klikken in de plattegrond; snapt op 50 mm, haaks en op wandvlakken'],
['room', 'Ruimte', 'paint-bucket', 'R', 'Functie kiezen en in een ruimte klikken; de ruimte loopt tot de wanden'],
['fixture', 'Inrichting', 'lamp', 'I', 'Stuk kiezen en in de plattegrond klikken (D = draaien)']
];
const bar = UI.editorBar(toolbar, document.getElementById('house-overview'), {
left: `<div class="tb-group tb-tools" role="group" aria-label="Gereedschap">${TOOLS.map(([k, label, ic, key, hint]) =>
UI.tb(label, ic, `data-ho-tool="${k}" title="${label} (${key}): ${hint}"`)).join('')}</div>
${UI.tbSep()}${UI.tb('Unit', 'plus-square', 'data-tb="add-unit" title="Unit toevoegen aan de geselecteerde (of laatste) unit"')}
<div class="tb-group tb-options" id="ho-tool-options"></div>`,
after: `<div class="tb-group tb-section" id="ho-section-group">
${UI.tb('Doorsnede', 'scissors', 'data-tb="section" title="Horizontale doorsnede (X)"')}
<span class="tb-range"><input type="range" id="ho-section" min="100" max="4000" step="50" value="${view3D.sectionMM}" aria-label="Hoogte doorsnede"><span id="ho-section-val"></span></span>
</div>
${UI.tb('', 'camera', 'data-tb="render" title="Render: elke lamp en spot als echte lichtbron"')}
${UI.tb('', 'person-walking', 'data-tb="walk" title="Lopen (L): W/S vooruit/achteruit, A/D opzij, slepen = rondkijken, E = deur/schakelaar"')}
${UI.tbSep()}
${UI.tb('Rapport', 'clipboard-data', 'data-tb="report" title="Rapport in plaats van de plattegrond (4): transport, gewicht, kosten, hout en plaatwerk"')}`,
onResize,
layers: { layers: view3D.layers, onChange: setLayers },
onSky: (m) => { skyMode = m; applyRenderMode(); },
beforeView: (mode) => { setWalk(false); if (mode === '3d') toggleReport(false); view3D.mode = mode; }
});
toolbar.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool)));
toolbar.addEventListener('click', (e) => {
const b = e.target.closest('[data-tb]');
if (!b) return;
const a = b.dataset.tb;
if (a === 'add-unit') addUnit();
else if (a === 'section') toggleSection();
else if (a === 'render') { fullRender = !fullRender; syncToolbar(); applyRenderMode(); }
else if (a === 'walk') setWalk(!walk.active);
else if (a === 'report') toggleReport();
b.blur();
});
function syncToolbar() {
const q = (a) => toolbar.querySelector(`[data-tb="${a}"]`);
q('section').classList.toggle('active', view3D.section);
q('render').classList.toggle('active', fullRender);
q('walk').classList.toggle('active', !!(walk && walk.active));
q('report').classList.toggle('active', reportOpen);
document.getElementById('ho-section-group').classList.toggle('on', view3D.section);
const mode = UI.viewOf(document.getElementById('house-overview'));
toolbar.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode));
}
// Eén set lagen voor 3D en plattegrond.
function setLayers(l) {
view3D.layers = ViewLayers.normalize(l);
const L = view3D.layers;
const hidden = selection && ((!L.ruimtes && (selection.type === 'room' || selection.type === 'region')) ||
(!L.inrichting && selection.type === 'fixture') || (!L.elektra && selection.type === 'elektra'));
if (hidden) selection = null;
scheduleRefresh({ keepProps: !hidden }); // vervalt de selectie, dan ook het paneel bijwerken
}
const sectionInput = document.getElementById('ho-section');
function syncSectionRow() {
document.getElementById('ho-section-val').textContent = `${view3D.sectionMM} mm`;
syncToolbar();
}
function setSectionMM(v) {
if (!isFinite(v)) return;
view3D.sectionMM = Math.max(50, Math.min(8000, Math.round(v)));
sectionInput.value = view3D.sectionMM;
syncSectionRow();
if (!parts || !view3D.section) return;
placeSection();
}
function toggleSection() {
view3D.section = !view3D.section;
syncSectionRow();
scheduleRefresh({ keepProps: true });
}
sectionInput.addEventListener('input', () => setSectionMM(Number(sectionInput.value)));
sectionInput.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) sectionInput.blur(); });
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);
}
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';
grid.visible = skyMode === 'none' && !fullRender;
disposeLights();
if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !lampsOn; });
if (lampsOn && parts && view3D.layers.lampen && !view3D.section) {
const edge = view3D.layers.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,
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);
}
applyLightSwitches();
}
let parts = null;
let view = { scale: 1, ox: 0, oy: 0 };
// Zoom ten opzichte van passend; cx/cz = midden van het beeld (mm), null = midden van de woning.
const plan2D = { zoom: 1, cx: null, cz: null };
const toPx = (x, z) => ({ x: view.ox + x * view.scale, y: view.oy + z * view.scale });
const toWorld = (mx, my) => ({ x: (mx - view.ox) / view.scale, z: (my - view.oy) / view.scale });
function pieceHit(place, x, z, tolPx) {
const c = Math.cos(place.rotY), s = Math.sin(place.rotY);
const dx = x - place.cx, dz = z - place.cz;
const lx = dx * c - dz * s, lz = dx * s + dz * c;
const tol = tolPx / view.scale;
return Math.abs(lx) <= place.width / 2 + tol && Math.abs(lz) <= place.thickness / 2 + tol;
}
function elektraHit(x, z) {
const el = parts && parts.elektra;
if (!el || !el.enabled || !view3D.layers.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);
// Klik op een schoorsteen: de wand waar hij aan hangt (daar staat het schoorsteenpaneel).
function chimneyWallHit(ch) {
const u = parts.units.find(q => q.id === ch.unitId), w = u && u.walls.find(q => q.side === ch.side);
const pc = w && w.pieces.find(p => !p.baseY);
return pc ? { type: 'wall', unit: u.id, side: ch.side, element: pc.element } : null;
}
function hitTest(x, z) {
const eh = elektraHit(x, z);
if (eh) return { type: 'elektra', id: eh.key };
const ch = view3D.layers.gevel && ChimneyModel.hit(parts.chimneys || [], x, z, 0);
if (ch && chimneyWallHit(ch)) return chimneyWallHit(ch);
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 fx = fixtureHit(x, z);
if (fx) return { type: 'fixture', id: fx.id };
if (view3D.layers.ruimtes && parts.rooms) {
const rm = parts.rooms.roomAt(x, z);
if (rm) return { type: 'room', id: rm.id };
if (parts.rooms.freeAt(x, z)) return { type: 'region', x: Math.round(x), z: Math.round(z) };
}
return unitHit(x, z);
}
function fixtureHit(x, z) {
if (!parts || !view3D.layers.inrichting || !parts.fixtures) return null;
return FixtureModel.hit(parts.fixtures, x, z, 3 / view.scale);
}
const fixtureState = (id) => (state.fixtures || []).find(f => f.id === id);
function placeFixture(fs, cx, cz, rot) {
const rc = parts.rects.find(r => cx >= r.x0 && cx <= r.x1 && cz >= r.z0 && cz <= r.z1) || parts.rects.find(r => r.id === fs.unitId) || parts.rects[0];
fs.unitId = rc.id;
fs.x = Math.round(fs.anchor === 'oost' ? rc.x1 - cx : cx - rc.x0);
fs.z = Math.round(cz - rc.z0);
if (rot !== undefined) fs.rot = ((Math.round(rot) % 360) + 360) % 360;
}
function removeFixture(id) {
state.fixtures = (state.fixtures || []).filter(f => f.id !== id);
selection = null;
refresh();
}
function rotateFixture(id, deg) {
const fs = fixtureState(id);
if (!fs) return;
fs.rot = ((fs.rot + deg) % 360 + 360) % 360;
refresh({ keepProps: false });
}
function duplicateFixture(id) {
const fs = fixtureState(id);
if (!fs) return;
const copy = JSON.parse(JSON.stringify(fs));
copy.id = state.fixtures.reduce((m, f) => Math.max(m, f.id), 0) + 1;
copy.x += 300; copy.z += 300;
state.fixtures.push(copy);
selection = { type: 'fixture', id: copy.id };
refresh();
}
function unitHit(x, z) {
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;
}
function sameArea(a, b) {
if (!a || !b || a.type !== b.type) return false;
if (a.type === 'room') return a.id === b.id;
if (a.type === 'region') { const fa = parts.rooms.freeAt(a.x, a.z); return !!fa && fa === parts.rooms.freeAt(b.x, b.z); }
return false;
}
function assignRoom(x, z, type) {
const R = parts.rooms;
const existing = R.roomAt(x, z);
if (existing) {
RoomModel.applyType(existing.state, type);
selection = { type: 'room', id: existing.id };
} else {
const rect = R.freeAt(x, z) && parts.rects.find(r => x >= r.x0 && x <= r.x1 && z >= r.z0 && z <= r.z1);
if (!rect) { setIoStatus('Geen afgesloten ruimte op deze plek.', 'err'); return; }
const rs = RoomModel.create(state, rect, x, z, type);
state.rooms.push(rs);
selection = { type: 'room', id: rs.id };
}
refresh();
}
function removeRoom(id) {
state.rooms = state.rooms.filter(r => r.id !== id);
selection = null;
refresh();
}
const sameSel = (a, b) => a && b && a.type === b.type && (a.id === b.id) && (a.element === b.element);
function select(sel) {
selection = sel;
renderInspector();
drawPlan();
renderNav(parts);
}
let tool = 'select';
let drawStart = null;
let hover = null;
let roomHover = null;
let fxHover = null;
let fxDrag = null;
let chDrag = null;
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 = toolCursor();
roomHover = null;
fxHover = null;
renderToolOptions();
updateHint();
drawPlan();
}
const toolCursor = () => tool === 'iw' || tool === 'fixture' ? 'crosshair' : tool === 'room' ? 'cell' : 'default';
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.')
: tool === 'room' ? `Klik in een ruimte: functie ${RoomModel.TYPES[fillType].label.toLowerCase()} (Esc = stoppen). Deuren en ramen tellen als wand.`
: tool === 'fixture' ? `Klik om ${FixtureModel.TYPES[fixtureType].label.toLowerCase()} te plaatsen (D = draaien); bij een wand snapt de rug op het wandvlak (Alt = vrij, Esc = stoppen).`
: 'Klik = selecteren (in een ruimte: nog eens = de unit) · dubbelklik = bewerken';
}
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();
}
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 = elDrag.key.startsWith('vwcd:')
? (parts.rects.some(rc => w.x > rc.x0 && w.x < rc.x1 && w.z > rc.z0 && w.z < rc.z1) ? { floor: true, x: Math.round(w.x / 10) * 10, z: Math.round(w.z / 10) * 10 } : null)
: ElektraModel.snapToWall(parts.elektra, w.x, w.z);
drawPlan();
});
function onElektraDrop(e) {
if (!elDrag) return;
const d = elDrag;
elDrag = null;
canvas.style.cursor = toolCursor();
if (!d.moved || !d.snap) return;
if (e.target === canvas) suppressClick = true;
if (d.snap.floor) {
const rc = parts.rects.find(q => d.snap.x > q.x0 && d.snap.x < q.x1 && d.snap.z > q.z0 && d.snap.z < q.z1);
const vw = state.settings.elektra.vloerWcd.find(o => 'vwcd:' + o.id === d.key);
if (rc && vw) Object.assign(vw, { unitId: rc.id, x: Math.round(d.snap.x - rc.x0), z: Math.round(d.snap.z - rc.z0) });
} else {
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);
const fixtureItem = (id) => parts && parts.fixtures.find(f => f.id === id);
const fxPose = (item, x, z, free) => free ? { x: Math.round(x / 10) * 10, z: Math.round(z / 10) * 10, rot: item.rot }
: FixtureModel.snap(item, x, z, HouseModel.allWallPieces(parts));
canvas.addEventListener('mousedown', (e) => {
if (tool !== 'select' || !parts || e.button !== 0 || elDrag) return;
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
if (elektraHit(w.x, w.z)) return;
const it = fixtureHit(w.x, w.z);
if (it) fxDrag = { id: it.id, sx: e.clientX, sy: e.clientY, dx: it.cx - w.x, dz: it.cz - w.z, moved: false, pose: null };
});
canvas.addEventListener('mousemove', (e) => {
if (!fxDrag) return;
if (!fxDrag.moved && Math.hypot(e.clientX - fxDrag.sx, e.clientY - fxDrag.sy) < 4) return;
fxDrag.moved = true;
canvas.style.cursor = 'grabbing';
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
const it = fixtureItem(fxDrag.id);
if (it) fxDrag.pose = fxPose(it, w.x + fxDrag.dx, w.z + fxDrag.dz, e.altKey);
drawPlan();
});
function onFixtureDrop(e) {
if (!fxDrag) return;
const d = fxDrag;
fxDrag = null;
canvas.style.cursor = toolCursor();
if (!d.moved || !d.pose) return;
if (e.target === canvas) suppressClick = true;
const fs = fixtureState(d.id);
if (fs) placeFixture(fs, d.pose.x, d.pose.z, d.pose.rot);
selection = { type: 'fixture', id: d.id };
refresh();
}
window.addEventListener('mouseup', onFixtureDrop);
// Schoorsteen slepen: alleen langs de buitengevel.
canvas.addEventListener('mousedown', (e) => {
if (tool !== 'select' || !parts || e.button !== 0 || elDrag || fxDrag) return;
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
const ch = view3D.layers.gevel && ChimneyModel.hit(parts.chimneys || [], w.x, w.z, 0);
if (ch) chDrag = { id: ch.id, sx: e.clientX, sy: e.clientY, moved: false, snap: null };
});
canvas.addEventListener('mousemove', (e) => {
if (!chDrag) return;
if (!chDrag.moved && Math.hypot(e.clientX - chDrag.sx, e.clientY - chDrag.sy) < 4) return;
chDrag.moved = true;
canvas.style.cursor = 'grabbing';
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
const ch = (parts.chimneys || []).find(c => c.id === chDrag.id);
if (ch) chDrag.snap = ChimneyModel.snapToFacade(parts.rects, w.x, w.z, ch.w);
drawPlan();
});
window.addEventListener('mouseup', (e) => {
if (!chDrag) return;
const d = chDrag;
chDrag = null;
canvas.style.cursor = toolCursor();
if (!d.moved || !d.snap) return;
if (e.target === canvas) suppressClick = true;
const cs = (state.chimneys || []).find(c => c.id === d.id);
if (cs) Object.assign(cs, { unitId: d.snap.unitId, side: d.snap.side, u: d.snap.u });
const u = parts.units.find(q => q.id === d.snap.unitId), wl = u && u.walls.find(q => q.side === d.snap.side), pc = wl && wl.pieces.find(p => !p.baseY);
if (pc) selection = { type: 'wall', unit: u.id, side: d.snap.side, element: pc.element };
refresh();
});
const newFixtureItem = (cx, cz, rot) => {
const T = FixtureModel.TYPES[fixtureType];
return { type: fixtureType, w: T.w, d: T.d, h: T.h, opts: Object.assign({}, T.opts || {}), rot: rot || 0, cx, cz };
};
canvas.addEventListener('mousemove', (e) => {
if (tool !== 'fixture' || !parts) return;
const r = canvas.getBoundingClientRect();
const w = toWorld(e.clientX - r.left, e.clientY - r.top);
const p = fxPose(newFixtureItem(w.x, w.z, fxHover ? fxHover.rot : 0), w.x, w.z, e.altKey);
fxHover = { cx: p.x, cz: p.z, rot: p.rot };
drawPlan();
});
canvas.addEventListener('mousemove', (e) => {
if (tool === 'room' && parts) {
const r = canvas.getBoundingClientRect();
roomHover = toWorld(e.clientX - r.left, e.clientY - r.top);
drawPlan();
return;
}
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 (tool === 'room') { assignRoom(w.x, w.z, fillType); return; }
if (tool === 'fixture') {
const rc = parts.rects.find(q => w.x >= q.x0 && w.x <= q.x1 && w.z >= q.z0 && w.z <= q.z1);
if (!rc) { setIoStatus('Plaats inrichting binnen een unit.', 'err'); return; }
const p = fxHover || { cx: w.x, cz: w.z, rot: 0 };
state.fixtures = state.fixtures || [];
const fs = FixtureModel.create(state, fixtureType, rc.id, 0, 0, p.rot);
placeFixture(fs, p.cx, p.cz, p.rot);
state.fixtures.push(fs);
selection = { type: 'fixture', id: fs.id };
setTool('select');
refresh();
return;
}
if (suppressClick) { suppressClick = false; return; }
let hit = hitTest(w.x, w.z);
if (hit && sameArea(hit, selection)) hit = unitHit(w.x, w.z);
if (!sameSel(hit, selection) || (hit && hit.type === 'region')) 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);
let hit = hitTest(w.x, w.z);
if (hit && (hit.type === 'room' || hit.type === 'region')) hit = unitHit(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}`);
});
const TOOL_KEYS = { v: 'select', w: 'iw', r: 'room', i: 'fixture' };
function deleteSelection() {
if (!selection) return;
if (selection.type === 'fixture') removeFixture(selection.id);
else if (selection.type === 'room') removeRoom(selection.id);
else if (selection.type === 'elektra' && selection.id !== 'mk') removeSelectedElektra();
else if (selection.type === 'iw') removeInteriorWall(selection.id);
}
function removeInteriorWall(id) {
state.interiorWalls = state.interiorWalls.filter(w => w.id !== id);
selection = null;
refresh();
}
function onKey(e) {
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
const k = e.key.toLowerCase(), plain = !e.ctrlKey && !e.altKey && !e.metaKey;
if (e.key === 'Escape') {
if (walk.active) setWalk(false);
else if (drawStart) { drawStart = null; updateHint(); drawPlan(); }
else if (tool !== 'select') setTool('select');
else if (selection) select(null);
} else if (walk.active) {
if (k === 'e' && plain) interact();
} else if (plain && !e.shiftKey && TOOL_KEYS[k]) {
setTool(TOOL_KEYS[k]);
} else if (k === 'd' && plain && (tool === 'fixture' || (selection && selection.type === 'fixture'))) {
const deg = e.shiftKey ? -90 : 90;
if (tool === 'fixture') { if (fxHover) { fxHover.rot = ((fxHover.rot + deg) % 360 + 360) % 360; drawPlan(); } }
else rotateFixture(selection.id, deg);
} else if ((e.key === 'Delete' || e.key === 'Backspace') && plain && selection) {
e.preventDefault();
deleteSelection();
}
}
window.addEventListener('keydown', onKey);
const zoomVal = document.getElementById('ho-zoom-val');
function zoomPlanAt(px, py, factor) {
const k = Math.max(0.5, Math.min(25, plan2D.zoom * factor));
if (!parts || k === plan2D.zoom) return;
const p = toWorld(px, py), s1 = view.scale * k / plan2D.zoom;
plan2D.zoom = k;
plan2D.cx = (canvas.width / 2 - (px - p.x * s1)) / s1;
plan2D.cz = (canvas.height / 2 - (py - p.z * s1)) / s1;
zoomVal.textContent = `${Math.round(k * 100)}%`;
drawPlan();
}
function fitPlan() {
Object.assign(plan2D, { zoom: 1, cx: null, cz: null });
zoomVal.textContent = '100%';
drawPlan();
}
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const r = canvas.getBoundingClientRect();
zoomPlanAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-Math.max(-300, Math.min(300, e.deltaY * (e.deltaMode === 1 ? 33 : 1))) * 0.002));
}, { passive: false });
host.querySelector('.plan-zoom').addEventListener('click', (e) => {
const b = e.target.closest('[data-zoom]');
if (!b) return;
b.blur();
if (b.dataset.zoom === 'fit') fitPlan();
else zoomPlanAt(canvas.width / 2, canvas.height / 2, b.dataset.zoom === 'in' ? 1.4 : 1 / 1.4);
});
// Verschuiven: middelste muisknop overal, linksslepen op een plek zonder sleepbaar onderdeel.
let pan = null;
canvas.addEventListener('mousedown', (e) => {
if (!parts) return;
const mid = e.button === 1;
if (!mid && (e.button !== 0 || tool !== 'select' || elDrag || fxDrag || chDrag)) return;
if (mid) e.preventDefault();
pan = { sx: e.clientX, sy: e.clientY, ox: view.ox, oy: view.oy, moved: false, mid };
});
function onPanMove(e) {
if (!pan) return;
if (!pan.moved && Math.hypot(e.clientX - pan.sx, e.clientY - pan.sy) < 4) return;
pan.moved = true;
canvas.style.cursor = 'grabbing';
plan2D.cx = (canvas.width / 2 - (pan.ox + e.clientX - pan.sx)) / view.scale;
plan2D.cz = (canvas.height / 2 - (pan.oy + e.clientY - pan.sy)) / view.scale;
drawPlan();
}
// Na een sleep de klik op de plattegrond overslaan, maar alleen als die klik er ook komt (loslaten op de plattegrond).
function onPanEnd(e) {
if (!pan) return;
if (pan.moved) { canvas.style.cursor = toolCursor(); if (!pan.mid && e.target === canvas) suppressClick = true; }
pan = null;
}
window.addEventListener('mousemove', onPanMove);
window.addEventListener('mouseup', onPanEnd);
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)) * plan2D.zoom;
const mx = plan2D.cx === null ? (bx0 + bx1) / 2 : plan2D.cx, mz = plan2D.cz === null ? (bz0 + bz1) / 2 : plan2D.cz;
view = { scale, ox: w / 2 - mx * scale, oy: h / 2 - mz * scale };
const L = view3D.layers;
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 R = view3D.layers.ruimtes ? parts.rooms : null;
const rectsPath = (rr) => { ctx.beginPath(); rr.forEach(c => { const a = toPx(c.x0, c.z0); ctx.rect(a.x, a.y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); };
const hexA = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`;
const selRoom = R && selection && selection.type === 'room' ? R.rooms.find(r => r.id === selection.id) : null;
const selFree = R && selection && selection.type === 'region' ? R.freeAt(selection.x, selection.z) : null;
const hoverArea = R && tool === 'room' && roomHover ? (R.roomAt(roomHover.x, roomHover.z) || R.freeAt(roomHover.x, roomHover.z)) : null;
if (R) {
R.rooms.forEach(r => { rectsPath(r.rects); ctx.fillStyle = hexA(r.color, r === selRoom ? 0.5 : 0.3); ctx.fill(); });
R.free.forEach(r => { rectsPath(r.rects); ctx.fillStyle = r === selFree ? 'rgba(77, 166, 255, 0.25)' : 'rgba(255, 255, 255, 0.04)'; ctx.fill(); });
if (hoverArea) { rectsPath(hoverArea.rects); ctx.fillStyle = hexA(RoomModel.TYPES[fillType].color, 0.55); ctx.fill(); }
[selRoom, selFree].filter(Boolean).forEach(r => {
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2;
ctx.beginPath();
r.edges.forEach(e => {
const a = e.axis === 'x' ? toPx(e.v, e.s0) : toPx(e.s0, e.v), b = e.axis === 'x' ? toPx(e.v, e.s1) : toPx(e.s1, e.v);
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
});
ctx.stroke();
});
}
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.filter(op => op.type !== 'schoorsteen').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();
if (op.fill && L.kozijnen) drawOpeningSymbol(p, op);
});
};
function drawOpeningSymbol(p, op) {
const W = p.place.width, T = p.place.thickness;
KozijnModel.planSymbol(op, op.fill, { zIn: -T / 2, zOut: T / 2 }).forEach(sh => {
ctx.save();
ctx.strokeStyle = sh.kind === 'leaf' ? '#e8e8e8' : 'rgba(232, 232, 232, 0.75)';
ctx.lineWidth = sh.kind === 'leaf' ? 1.4 : 0.9;
ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []);
ctx.beginPath();
sh.pts.forEach((pt, i) => {
const w = UnitModel.localToWorld(p.place, -W / 2 + op.x + pt.x, pt.z), q = toPx(w.x, w.z);
if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y);
});
if (sh.closed) ctx.closePath();
ctx.stroke();
ctx.restore();
});
}
const drawFixture = (item, selected, ghost) => {
ctx.save();
polyPath(item.corners || [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([a, b]) => FixtureModel.toWorld(item, a * item.w / 2, b * item.d / 2)));
ctx.fillStyle = selected ? 'rgba(77, 166, 255, 0.22)' : ghost ? 'rgba(77, 166, 255, 0.15)' : 'rgba(28, 28, 28, 0.45)';
ctx.fill();
const col = selected || ghost ? '#4da6ff' : 'rgba(228, 228, 228, 0.9)';
FixtureModel.planShapes(item).forEach(sh => {
ctx.strokeStyle = col;
ctx.lineWidth = sh.kind === 'thin' ? 0.8 : 1.3;
ctx.setLineDash(sh.kind === 'dash' ? [4, 3] : []);
if (sh.circle) {
const c = toPx(sh.circle.x, sh.circle.z);
ctx.beginPath(); ctx.arc(c.x, c.y, Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke();
return;
}
if (sh.text) {
const c = toPx(sh.at.x, sh.at.z);
ctx.fillStyle = col; ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(sh.text, c.x, c.y);
ctx.textBaseline = 'alphabetic';
return;
}
ctx.beginPath();
sh.pts.forEach((pt, i) => { const q = toPx(pt.x, pt.z); if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y); });
if (sh.closed) ctx.closePath();
if (sh.fill) { ctx.fillStyle = selected || ghost ? 'rgba(77, 166, 255, 0.4)' : 'rgba(205, 205, 205, 0.45)'; ctx.fill(); }
ctx.stroke();
});
ctx.restore();
};
if (view3D.layers.inrichting && parts.fixtures) {
parts.fixtures.forEach(it => {
const dragging = fxDrag && fxDrag.moved && fxDrag.pose && fxDrag.id === it.id;
const sel = selection && selection.type === 'fixture' && selection.id === it.id;
if (dragging) drawFixture(Object.assign({}, it, { cx: fxDrag.pose.x, cz: fxDrag.pose.z, rot: fxDrag.pose.rot, corners: null }), true, true);
else drawFixture(it, sel, false);
});
}
if (tool === 'fixture' && fxHover) drawFixture(newFixtureItem(fxHover.cx, fxHover.cz, fxHover.rot), false, true);
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'));
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();
if (L.maten) 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));
})));
if (L.maten) 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();
});
if (R) {
ctx.save();
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
const label = (r, lines) => {
const c = toPx(r.label.x, r.label.z);
lines.forEach(([t, font, col], i) => {
const y = c.y + (i - (lines.length - 1) / 2) * 14;
ctx.font = font;
ctx.strokeStyle = 'rgba(20, 20, 20, 0.75)'; ctx.lineWidth = 3; ctx.strokeText(t, c.x, y);
ctx.fillStyle = col; ctx.fillText(t, c.x, y);
});
};
R.rooms.forEach(r => label(r, [[r.name, 'bold 12px sans-serif', '#fff'], [`${houseFmt(r.areaM2, 2)} m²`, '11px sans-serif', '#e8e8e8']]
.concat(r.ceilingActive ? [[`plafond ${r.ceiling}`, '10px sans-serif', '#cfd8e0']] : [])));
R.free.forEach(r => label(r, [['Ruimte', 'italic 11px sans-serif', '#aaa'], [`${houseFmt(r.areaM2, 2)} m²`, '10px sans-serif', '#999']]));
ctx.restore();
}
ctx.save();
ctx.setLineDash([3, 4]);
ctx.strokeStyle = 'rgba(200, 200, 200, 0.55)'; ctx.lineWidth = 1;
if (L.dak) 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 && L.dak) {
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';
if (L.lampen) 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.setLineDash([]);
ctx.fillStyle = '#a07c54'; ctx.strokeStyle = '#e8d3b0'; ctx.lineWidth = 1;
if (L.dak) parts.posts.posts.forEach(pp => {
const a = toPx(pp.x - pp.size / 2, pp.z - pp.size / 2), sz = pp.size * scale;
ctx.fillRect(a.x, a.y, sz, sz); ctx.strokeRect(a.x, a.y, sz, sz);
});
if (L.dak) parts.posts.walls.forEach(w => {
const a = toPx(w.x0, w.z0);
ctx.fillStyle = WallModel.stucHex(w.color); ctx.strokeStyle = '#8a8a8a';
ctx.fillRect(a.x, a.y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); ctx.strokeRect(a.x, a.y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale);
});
(L.gevel ? parts.chimneys || [] : []).forEach(ch => {
const path = (pts) => { ctx.beginPath(); pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y); }); ctx.closePath(); };
const sn = chDrag && chDrag.moved && chDrag.snap && chDrag.id === ch.id ? chDrag.snap : null;
if (sn) {
// Voorbeeld tijdens het slepen: dezelfde diepte, op de nieuwe plek.
const S = ChimneyModel.SIDES[sn.side], W = (t, s) => S.axis === 'z' ? { x: sn.plane + S.n.x * s, z: t } : { x: t, z: sn.plane + S.n.z * s };
path([W(sn.t - ch.w / 2, ch.sIn), W(sn.t + ch.w / 2, ch.sIn), W(sn.t + ch.w / 2, ch.sFront), W(sn.t - ch.w / 2, ch.sFront)]);
ctx.fillStyle = 'rgba(77, 166, 255, 0.45)'; ctx.fill(); ctx.strokeStyle = '#4da6ff'; ctx.stroke();
return;
}
path(ch.footprint);
ctx.fillStyle = ch.outside.type === 'steen' ? (WallModel.STONE_COLORS[ch.outside.stone] || '#8a6a5c') : WallModel.stucHex(ch.outside.stuc);
ctx.fill(); ctx.strokeStyle = '#2b2b2b'; ctx.stroke();
path(ch.flueFootprint);
ctx.fillStyle = '#1b1a19'; ctx.fill();
});
ctx.restore();
ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5;
(L.lampen ? 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();
});
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1;
(L.lampen ? 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);
});
const el = parts.elektra;
if (el && el.enabled && view3D.layers.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); }));
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();
});
el.floorWcds.forEach(w => {
const q = toPx(w.x, w.z);
ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5;
ctx.fillRect(q.x - 7, q.y - 7, 14, 14); ctx.strokeRect(q.x - 7, q.y - 7, 14, 14);
ctx.fillStyle = rgb(RC.wcd, 1); ctx.font = 'bold 8px sans-serif'; ctx.fillText('V' + w.group, q.x, q.y + 0.5);
});
if (elDrag && elDrag.snap && elDrag.snap.floor) {
const q = toPx(elDrag.snap.x, elDrag.snap.z);
ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2; ctx.strokeRect(q.x - 9, q.y - 9, 18, 18);
} else 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';
}
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();
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();
}
}
const { num, sel, toggle } = UI;
const { big, small, kv } = Inspector;
const after = () => scheduleRefresh({ keepProps: true });
const rerender = () => refresh();
let live = [];
function updateLive() { live.forEach(fn => fn()); }
const note = (text, isWarn) => `<div class="ins-note${isWarn ? ' ins-warn' : ''}">${text}</div>`;
const pair = (...items) => `<div class="ins-pair">${items.join('')}</div>`;
const thumb = (url, alt) => url ? `<img class="ins-thumb" src="${url}" alt="${alt}" title="${alt}">` : '';
const check = (id, label, checked) => `<label class="ins-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('.', ',');
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
const stucColors = Object.fromEntries(Object.entries(WallModel.STUC_COLORS).map(([k, c]) => [k, c.label]));
const felsColors = Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, c.label]));
const swatchOf = (hex) => `<div class="ins-swatch" style="background:${hex}"></div>`;
function wireButtons(body) {
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-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
else if (a === 'remove-unit') removeUnit(b.dataset.unit);
else if (a === 'remove-iw') removeInteriorWall(Number(b.dataset.iw));
}));
}
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]));
const rabatUi = 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="ins-swatch" style="background:${WallModel.rabatHex(st.facade.rabatColor)}"></div>`);
const uses = (type) => state.units.some(u => Object.keys(u.unit.walls).some(side => HouseModel.facadeFor(state, u, side).type === type ||
UnitModel.normalizeSplits(u.unit.walls[side].splits).some(sp => sp.facade && sp.facade.type === type)));
const usesRabat = uses('rabat');
const felsUi = pair(sel('hs-fels-color', 'Kleur', felsColors, st.facade.felsColor,
{ hint: `Staande naad, banen van ${WallModel.FELS.SEAM_MM} mm, op latten zoals verticale rabat; ook voor fels-overrides per wand` }),
swatchOf(WallModel.felsHex(st.facade.felsColor)));
return [
{ label: 'Bekleding', html: sel('hs-facade', 'Type', HouseModel.FACADE_TYPES, st.facade.type, { hint: 'Voor alle buitenwanden; per wand of wandstuk 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))
: st.facade.type === 'stuc'
? pair(sel('hs-stuc-color', 'Kleur', stucColors, st.facade.stucColor, { hint: `Pleisterlaag ${WallModel.STUC.THICK_MM} mm op cement-gebonden plaat` }), swatchOf(WallModel.stucHex(st.facade.stucColor)))
: st.facade.type === 'fels' ? felsUi : rabatUi) },
st.facade.type !== 'rabat' && usesRabat ? { label: 'Rabat (overrides)', html: rabatUi } : null,
st.facade.type !== 'fels' && uses('fels') ? { label: 'Felsplaten (overrides)', html: felsUi } : null,
{ 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) => {
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.onSel('hs-stuc-color', v => { st.facade.stucColor = v; }, rerender);
UI.onSel('hs-fels-color', v => { st.facade.felsColor = 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);
}
};
let houseRoofUi = null;
const dakTab = {
key: 'dak', label: 'Dak', order: 30,
groups: () => {
const r = state.settings.roof, hr = r.house;
const wolfOk = HouseModel.houseRoofFits(state);
houseRoofUi = hr.enabled && hr.form ? roofFormUi('hs', hr.form, {
types: wolfOk || hr.form.type === 'wolf' ? null : Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')),
notice: !wolfOk && hr.form.type === 'wolf' ? 'Wolfsdak alleen als de units samen een rechthoek vormen; bij deze L-/T-vorm wordt het een zadeldak.' : null,
span: () => { const sh = HouseModel.houseRoofShape(state); return sh ? sh.span : 1000; },
apply: () => HouseModel.applyGlobalSettings(state),
readBack: () => hr.form
}) : null;
const shapeType = hr.enabled && hr.form ? (HouseModel.houseRoofShape(state) || {}).type : null;
const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS).filter(([, c]) => !c.sloped || shapeType !== 'plat').map(([k, c]) => [k, c.label]));
const groups = [
{ label: 'Dak', html:
toggle('hs-roof-house', 'Over hele woning', hr.enabled, { hint: 'Eén dakvorm over alle units (bijv. een grote wolfskap); uit = dak per unit. De daken per unit blijven bewaard.' }) +
(houseRoofUi ? sel('hs-roof-cover', 'Bedekking', covOpts, RoofModel.coveringFor(state.units[0].roof.covering, shapeType), { hint: 'Voor alle units (per unit aan te passen)' }) : '') +
toggle('hs-steel-ridge', 'Stalen nokbalk', state.units.some(u => u.roof.steelRidge !== false), { hint: 'IPE onder de nok van zadel- en wolfsdaken en van een dubbel lessenaardak (gelijke goot en nok, samen een zadel); alle units; uit = verwijderen' }) }
];
if (houseRoofUi) groups.push(...houseRoofUi.groups());
else groups.push({ label: 'Dakvorm', html: note('Vorm, hoogtes, helling en bedekking zijn per unit: selecteer een unit.') });
groups.push(
{ 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: 'Palen overkapping', html:
toggle('hs-posts', 'Palen', r.posts.enabled, { hint: `Onder een dak dat minstens de min. uitkraging buiten de vloer steekt (veranda); per unit: Overkapping. Nu ${parts ? parts.posts.count : 0} st.` }) +
(r.posts.enabled ? toggle('hs-posts-side', 'Zijwand', r.posts.sideWall, { hint: `Vrije hoek dicht met een gestuukte wand (${PostModel.SIDE_MM} mm) terug tot de gevel, in plaats van een hoekpaal` }) +
(r.posts.sideWall ? pair(sel('hs-posts-side-color', 'Kleur zijwand', stucColors, r.posts.sideColor), swatchOf(WallModel.stucHex(r.posts.sideColor))) : '') +
sel('hs-posts-wood', 'Hout', Object.fromEntries(Object.entries(PostModel.WOODS).map(([k, w]) => [k, w.label])), r.posts.wood) +
UI.range('hs-posts-size', 'Maat', r.posts.size, { min: 100, max: 300, step: 10, hint: 'Vierkant, mm' }) +
num('hs-posts-span', 'Max. h.o.h.', r.posts.maxSpan, { step: 100, hint: 'Grootste afstand tussen palen (of paal en gevel)' }) +
num('hs-posts-min', 'Min. uitkraging', r.posts.minExt, { step: 100, hint: 'Kleinere overstekken krijgen geen palen' }) : '') },
{ label: 'Daken per unit', html: `<div data-live="roof-list" class="ins-cols"></div>` });
return groups;
},
wire: (body) => {
const r = state.settings.roof, pos = { positive: true };
UI.onToggle('hs-roof-house', v => { HouseModel.setHouseRoof(state, v); }, rerender);
UI.onSel('hs-roof-cover', v => { state.units.forEach(u => { u.roof.covering = v; }); }, rerender);
UI.onToggle('hs-steel-ridge', v => { state.units.forEach(u => { u.roof.steelRidge = v; }); }, rerender);
if (houseRoofUi) houseRoofUi.wire(body);
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);
UI.onToggle('hs-posts', v => { r.posts.enabled = v; }, rerender);
UI.onToggle('hs-posts-side', v => { r.posts.sideWall = v; }, rerender);
UI.onSel('hs-posts-side-color', v => { r.posts.sideColor = v; }, rerender);
UI.onSel('hs-posts-wood', v => { r.posts.wood = v; }, rerender);
UI.onRange('hs-posts-size', v => { if (v > 0) r.posts.size = v; }, after);
UI.onNum('hs-posts-span', v => { if (v >= 1000) r.posts.maxSpan = v; }, after, pos);
UI.onNum('hs-posts-min', v => { if (v >= 400) r.posts.minExt = 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], cap = u.roof.unitRoof.cap;
const deg = (pl ? Math.abs(pl.theta) : cap ? Math.atan(cap.slope) : 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, de dagkanten en het plafond; egaal, zonder plaatnaden. Uit: kale spaanplaat en OSB.' }) +
(st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning; ook de dagkanten rond ramen en deuren' }) +
UI.color('hs-ceil-color', 'Plafond', st.ceilingColor, { hint: 'Onderkant dak (plafondplaat), afgewerkte fase' }) : '') },
{ 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: 'Ruimtes', html: `<div data-live="room-list" class="ins-cols"></div>` }
];
},
wire: (body) => {
const list = () => {
const el = body.querySelector('[data-live="room-list"]');
if (!el || !parts) return;
const R = parts.rooms;
el.innerHTML = R.rooms.map(r => kv(UI.esc(r.name), houseFmt(r.areaM2, 2), 'm²')).join('') +
(R.free.length ? kv('Zonder functie', `${R.free.length} × · ${houseFmt(R.free.reduce((a, r) => a + r.areaM2, 0), 2)}`, 'm²') : '') ||
note('Nog geen ruimtes: teken binnenwanden of vul een unit met de verfemmer.');
};
live.push(list);
list();
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.onColor('hs-ceil-color', v => { st.ceilingColor = 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: () => {
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="ins-cols"></div>` });
return groups;
},
wire: (body) => {
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, unit: 'st.', hint: 'Dubbele wcd' }) +
sel('he-plate', 'Kleur', Object.fromEntries(Object.entries(ElektraModel.PLATE_COLORS).map(([k, c]) => [k, c.label])), e.plateColor || 'wit', { hint: 'Schakelaars, wcd\'s en vloerdozen' }) });
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;
const nExtra = Object.values(e.wcdExtra || {}).reduce((t, n) => t + n, 0);
groups.push({ label: 'Toevoegen', html: big('Wcd', 'plus-square', 'data-he-add-wcd title="Dubbele wandcontactdoos in de geselecteerde (of eerste) unit; daarna slepen naar de plek"') +
big('Vloer-wcd', 'plus-square', 'data-he-add-vw title="Vloerdoos met dubbele wcd in de geselecteerde (of eerste) unit"') +
note(`${nExtra} losse wcd('s), ${e.vloerWcd.length} vloer-wcd('s); leiding vloer-wcd door de vloer.`) });
const nRemoved = (e.removed || []).length;
groups.push({ label: 'Plaatsing', html: note('Sleep meterkast, schakelaars, wcd en apparaten in de plattegrond; selecteer er een voor positie, hoogte of verwijderen (Del).') +
(nManual ? big('Alles automatisch', 'arrow-counterclockwise', `data-he-reset title="${nManual} handmatige plek(ken) wissen"`) : '') +
(nRemoved ? big('Verwijderde terug', 'arrow-counterclockwise', `data-he-unremove title="${nRemoved} verwijderde wcd/schakelaar(s) terugzetten"`) : '') });
groups.push({ label: 'Leidingen', html: num('he-slack', 'Marge', e.slack, { step: 1, unit: '%', hint: 'Extra lengte op elke leiding' }) + 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="ins-cols"></div>` });
groups.push({ label: 'Groepen', html: `<div data-live="el-groups" class="ins-cols"></div>` });
}
return groups;
},
wire: (body) => {
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.onSel('he-plate', v => { e.plateColor = v; }, rerender);
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 av = body.querySelector('[data-he-add-vw]');
if (av) av.addEventListener('click', () => {
const uid = selection && selection.type === 'unit' ? selection.id : state.units[0].id;
const rc = parts && parts.rects.find(q => q.id === uid);
const id = e.vloerWcd.reduce((m, o) => Math.max(m, o.id || 0), 0) + 1;
e.vloerWcd.push({ id, unitId: uid, x: rc ? Math.round(rc.W / 2) : 1000, z: rc ? Math.round(rc.D / 2) : 1000 });
selection = { type: 'elektra', id: 'vwcd:' + id };
refresh();
});
const aw = body.querySelector('[data-he-add-wcd]');
if (aw) aw.addEventListener('click', () => {
const uid = selection && selection.type === 'unit' ? selection.id : state.units[0].id;
e.wcdExtra = e.wcdExtra || {};
e.wcdExtra[uid] = (e.wcdExtra[uid] || 0) + 1;
selection = { type: 'elektra', id: `wcd:${uid}:${e.wcdPerUnit + e.wcdExtra[uid]}` };
refresh();
});
const rs = body.querySelector('[data-he-reset]');
if (rs) rs.addEventListener('click', () => { e.place = {}; refresh(); });
const ur = body.querySelector('[data-he-unremove]');
if (ur) ur.addEventListener('click', () => { e.removed = []; 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();
}
};
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));
if (selection.type === 'room' && state.rooms.find(r => r.id === selection.id)) return roomTab(state.rooms.find(r => r.id === selection.id));
if (selection.type === 'region') return regionTab();
if (selection.type === 'fixture' && fixtureState(selection.id)) return fixtureTab(fixtureState(selection.id));
return null;
}
function fixtureTypeOpts() {
const out = {};
Object.keys(FixtureModel.CATS).forEach(cat => Object.entries(FixtureModel.TYPES).filter(([, T]) => T.cat === cat)
.forEach(([k, T]) => { out[k] = `${FixtureModel.CATS[cat]}: ${T.label}`; }));
return out;
}
const FX_OPT_UI = {
front: { label: 'Front', kind: 'sel', options: () => Object.fromEntries(Object.entries(FixtureModel.FRONTS).map(([k, f]) => [k, f.label])) },
spoel: { label: 'Spoelbak', kind: 'toggle' },
spoelbak: { label: 'Plek spoelbak', kind: 'range', min: 0, max: 1, step: 0.01, unit: '', hint: '0 = links, 1 = rechts (van voren gezien)' },
hogeKast: { label: 'Hoge kast', kind: 'sel', options: () => ({ geen: 'Geen', links: 'Links', rechts: 'Rechts' }), hint: 'Inbouwkoelkast + oven, 600 mm' },
vaatwasser: { label: 'Vaatwasser', kind: 'toggle' },
bovenkasten: { label: 'Bovenkasten', kind: 'toggle' },
kookplaat: { label: 'Kookplaat', kind: 'toggle', hint: 'Inductie, 4 zones' },
kookplaatPlek: { label: 'Plek kookplaat', kind: 'range', min: 0, max: 1, step: 0.01, unit: '', hint: '0 = links, 1 = rechts (van voren gezien)' },
glas: { label: 'Glaswand', kind: 'sel', options: () => ({ links: 'Links', rechts: 'Rechts', geen: 'Geen' }), hint: 'Van voren gezien' },
glasLengte: { label: 'Lengte glas', kind: 'num', step: 50 },
spiegel: { label: 'Spiegel', kind: 'toggle' },
nachtkastjes: { label: 'Nachtkastjes', kind: 'toggle' },
vorm: { label: 'Vorm', kind: 'sel', options: (t) => t === 'eettafel' ? { ovaal: 'Ovaal', rechthoek: 'Rechthoekig' } : { rond: 'Rond', rechthoek: 'Rechthoekig' } },
stoelen: { label: 'Stoelen', kind: 'num', step: 1, unit: 'st.' },
chaise: { label: 'Chaise longue', kind: 'sel', options: () => ({ geen: 'Geen', links: 'Links', rechts: 'Rechts' }), hint: 'Van voren gezien' },
chaiseLengte: { label: 'Lengte chaise', kind: 'num', step: 50 },
tv: { label: 'Tv aan de wand', kind: 'toggle' },
liter: { label: 'Inhoud', kind: 'num', step: 10, unit: 'l' }
};
const ROT_LABELS = { 0: 'Rug noord', 90: 'Rug oost', 180: 'Rug zuid', 270: 'Rug west' };
function fixtureTab(fs) {
const T = FixtureModel.TYPES[fs.type];
const item = () => parts.fixtures.find(x => x.id === fs.id);
return {
key: 'sel', label: T.label, icon: 'lamp', contextual: true, order: 100,
groups: () => {
const it = item();
const rotOpts = Object.assign({}, ROT_LABELS);
if (!rotOpts[fs.rot]) rotOpts[fs.rot] = `${fs.rot}°`;
const dim = (k, label, hint) => T.resize.includes(k) ? num('hf-' + k, label, fs[k], { step: 10, hint }) : kv(label, fs[k], 'mm');
const optKeys = Object.keys(T.opts || {}).filter(k => FX_OPT_UI[k]);
const optHtml = optKeys.map(k => {
const o = FX_OPT_UI[k], id = 'hfo-' + k, v = fs.opts[k];
if (o.kind === 'sel') return sel(id, o.label, o.options(fs.type), String(v), { hint: o.hint });
if (o.kind === 'toggle') return toggle(id, o.label, !!v, { hint: o.hint });
if (o.kind === 'range') return UI.range(id, o.label, v, { min: o.min, max: o.max, step: o.step, unit: o.unit, hint: o.hint });
return num(id, o.label, v, { step: o.step, unit: o.unit, hint: o.hint });
}).join('');
const groups = [
{ label: 'Stuk', html: sel('hf-type', 'Type', fixtureTypeOpts(), fs.type, { hint: T.hint || 'Ander type: zelfde plek, standaardmaat van dat type' }) + kv('Unit', fs.unitId) +
sel('hf-anchor', 'Gemaat vanaf', { west: 'Westgevel', oost: 'Oostgevel' }, fs.anchor || 'west', { hint: 'Wordt de unit langer (west–oost), dan blijft het stuk op zijn afstand tot deze gevel' }) },
{ label: 'Maten', html: dim('w', 'Breedte', 'Langs de rug') + dim('d', 'Diepte', 'Van rug tot voorkant') + dim('h', 'Hoogte') },
{ label: 'Plaats', html: num('hf-x', 'X (midden)', it ? Math.round(it.cx) : 0, { step: 10, hint: 'Woning-mm, west → oost' }) +
num('hf-z', 'Y (midden)', it ? Math.round(it.cz) : 0, { step: 10, hint: 'Woning-mm, noord → zuid' }) +
sel('hf-rot', 'Draaiing', rotOpts, String(fs.rot), { hint: 'Waar de rug naartoe wijst' }) }
];
if (optHtml) groups.push({ label: 'Opties', html: optHtml });
groups.push({ label: 'Bewerken', html: small('Draaien 90°', 'arrow-clockwise', 'data-hf="rot" title="D (Shift+D = andersom)"') +
small('Draaien 15°', 'arrow-clockwise', 'data-hf="rot15"') + small('Dupliceren', 'files', 'data-hf="dup"') +
small('Verwijderen', 'trash', 'data-hf="del" title="Delete"', 'ins-danger') +
note('Sleep het stuk in de plattegrond; bij een wand snapt de rug op het wandvlak, Alt = vrij.') });
return groups;
},
wire: (body) => {
const place = (x, z) => { placeFixture(fs, x, z); };
UI.onSel('hf-type', v => {
const N = FixtureModel.TYPES[v];
if (!N) return;
const it = item();
Object.assign(fs, { type: v, w: N.w, d: N.d, h: N.h, opts: Object.assign({}, N.opts || {}) });
if (it) place(it.cx, it.cz);
}, rerender);
['w', 'd', 'h'].forEach(k => UI.onNum('hf-' + k, v => { if (v >= 50) fs[k] = Math.round(v); }, after));
UI.onNum('hf-x', v => { const it = item(); if (it) place(v, it.cz); }, after);
UI.onNum('hf-z', v => { const it = item(); if (it) place(it.cx, v); }, after);
UI.onSel('hf-rot', v => { fs.rot = Number(v) || 0; }, after);
UI.onSel('hf-anchor', v => { const it = item(); fs.anchor = v; if (it) place(it.cx, it.cz); }, after);
Object.keys(T.opts || {}).filter(k => FX_OPT_UI[k]).forEach(k => {
const o = FX_OPT_UI[k], id = 'hfo-' + k;
if (o.kind === 'sel') UI.onSel(id, v => { fs.opts[k] = v; }, after);
else if (o.kind === 'toggle') UI.onToggle(id, v => { fs.opts[k] = v; }, after);
else if (o.kind === 'range') UI.onRange(id, v => { if (isFinite(v)) fs.opts[k] = v; }, after);
else UI.onNum(id, v => { if (v >= 0) fs.opts[k] = v; }, after);
});
body.querySelectorAll('[data-hf]').forEach(b => b.addEventListener('click', () => {
const a = b.dataset.hf;
if (a === 'rot') rotateFixture(fs.id, 90);
else if (a === 'rot15') rotateFixture(fs.id, 15);
else if (a === 'dup') duplicateFixture(fs.id);
else if (a === 'del') removeFixture(fs.id);
}));
}
};
}
const roomTypeOpts = Object.fromEntries(Object.entries(RoomModel.TYPES).map(([k, t]) => [k, t.label]));
// Opties van het actieve gereedschap, rechts naast het gereedschap in de werkbalk.
function renderToolOptions() {
const el = document.getElementById('ho-tool-options');
if (!el) return;
const opt = (id, label, opts, val) => `<label class="tb-opt"><span>${label}</span><select id="${id}">${Object.entries(opts).map(([k, v]) =>
`<option value="${k}"${k === val ? ' selected' : ''}>${v}</option>`).join('')}</select></label>`;
el.innerHTML = tool === 'room' ? opt('hr-fill-type', 'Functie', roomTypeOpts, fillType)
: tool === 'fixture' ? opt('hf-new-type', 'Stuk', fixtureTypeOpts(), fixtureType) : '';
UI.onSel('hr-fill-type', v => { fillType = v; updateHint(); document.activeElement.blur(); });
UI.onSel('hf-new-type', v => { fixtureType = v; updateHint(); document.activeElement.blur(); });
}
const swatch = (hex) => `<div class="ins-swatch" style="background:${hex}"></div>`;
function roomTab(rs) {
const pr = () => parts.rooms.rooms.find(r => r.id === rs.id);
const lost = parts.rooms.lost.find(l => l.id === rs.id);
const r0 = pr();
return {
key: 'sel', label: r0 ? r0.name : `Ruimte (${RoomModel.TYPES[rs.type].label})`, icon: 'bounding-box', contextual: true, order: 100,
groups: () => {
const floorOpts = Object.assign({ woning: `Woning (${((FloorModel.FINISHES[state.settings.floorFinish] || {}).label || '').toLowerCase()})` },
Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, fi]) => [k, fi.label])));
const fk = rs.floor || state.settings.floorFinish, ff = FloorModel.FINISHES[fk] || FloorModel.FINISHES.geen;
const fiboOpts = Object.assign({ geen: 'Geen' }, Object.fromEntries(Object.entries(RoomModel.FIBO).map(([k, f]) => [k, f.label])));
return [
{ label: 'Ruimte', html: sel('hr-type', 'Functie', roomTypeOpts, rs.type, { hint: 'Een andere functie zet de standaard afwerking van die functie' }) +
UI.text('hr-name', 'Naam', rs.name, { placeholder: RoomModel.TYPES[rs.type].label, hint: 'Leeg = de functie (genummerd bij meer van dezelfde)' }) },
{ label: 'Maten', html: lost ? note(lost.reason + '. Verwijder de ruimte of herstel de wand.', true) : `<div data-live="room-dims" class="ins-cols"></div>` },
{ label: 'Plafond', html: toggle('hr-ceil-on', 'Vals plafond', !!rs.ceiling, { hint: 'Verlaagd plafond op een vaste hoogte, ongeacht het dak; wanden en spots volgen' }) +
(rs.ceiling ? UI.range('hr-ceil', 'Hoogte', rs.ceiling, { min: 2000, max: 3400, step: 10, hint: 'Onderkant plafond vanaf bovenkant vloer' }) : '') +
`<div data-live="room-ceil"></div>` },
{ label: 'Vloer', html: pair(sel('hr-floor', 'Afwerking', floorOpts, rs.floor || 'woning'),
FloorModel.finishThumbUrl(fk, 64) ? thumb(FloorModel.finishThumbUrl(fk, 64), ff.label) : swatch(ff.color)) },
{ label: 'Wanden', html: pair(sel('hr-fibo', 'FIBO', fiboOpts, rs.fibo ? rs.fibo.decor : 'geen', { hint: 'Waterdichte wandpanelen (natte ruimtes)' }),
rs.fibo ? swatch(RoomModel.FIBO[rs.fibo.decor].color) : '') +
(rs.fibo ? toggle('hr-fibo-full', 'Tot plafond', !rs.fibo.height) +
(rs.fibo.height ? UI.range('hr-fibo-h', 'Hoogte', rs.fibo.height, { min: 300, max: 3000, step: 50, hint: 'Vanaf bovenkant vloer' }) : '') : '') },
{ label: 'Verf', html: toggle('hr-paint-on', 'Eigen kleur', !!rs.paint, { hint: rs.fibo ? 'Boven de FIBO; anders de verfkleur van de woning' : 'Anders de verfkleur van de woning' }) +
(rs.paint ? UI.color('hr-paint', 'Kleur', rs.paint) : '') },
{ label: 'Bewerken', html: small('Verwijderen', 'trash', 'data-hr-del title="Ruimte (functie en afwerking) verwijderen (Delete)"', 'ins-danger') }
];
},
wire: (body) => {
UI.onSel('hr-type', v => { RoomModel.applyType(rs, v); }, rerender);
UI.onText('hr-name', v => { rs.name = v; }, after);
UI.onToggle('hr-ceil-on', v => { rs.ceiling = v ? (RoomModel.TYPES[rs.type].ceiling || 2600) : null; }, rerender);
UI.onRange('hr-ceil', v => { if (v >= RoomModel.CEIL_MIN && v <= RoomModel.CEIL_MAX) rs.ceiling = Math.round(v); }, after);
UI.onSel('hr-floor', v => { rs.floor = v === 'woning' ? null : v; }, rerender);
UI.onSel('hr-fibo', v => { rs.fibo = v === 'geen' ? null : { decor: v, height: rs.fibo ? rs.fibo.height : null }; }, rerender);
UI.onToggle('hr-fibo-full', v => { if (rs.fibo) rs.fibo.height = v ? null : 1200; }, rerender);
UI.onRange('hr-fibo-h', v => { if (rs.fibo && v > 0) rs.fibo.height = Math.round(v); }, after);
UI.onToggle('hr-paint-on', v => { rs.paint = v ? state.settings.interiorColor : null; }, rerender);
UI.onColor('hr-paint', v => { rs.paint = v; }, after);
const del = body.querySelector('[data-hr-del]');
if (del) del.addEventListener('click', () => removeRoom(rs.id));
const dims = () => {
const r = pr(), el = body.querySelector('[data-live="room-dims"]'), ce = body.querySelector('[data-live="room-ceil"]');
if (el && r) el.innerHTML = kv('Netto vloer', houseFmt(r.areaM2, 2), 'm²') + kv('Unit', r.units.join(' + ')) +
(r.fiboM2 ? kv('FIBO', houseFmt(r.fiboM2, 2), 'm²') : '') + (r.paintM2 ? kv('Verf', houseFmt(r.paintM2, 2), 'm²') : '');
if (ce) ce.innerHTML = r && r.ceiling ? (r.ceilingActive ? kv('Plafond', houseFmt(r.ceilM2, 2), 'm²') : note('Het dak ligt hier lager: geen vals plafond.', true)) : '';
};
live.push(dims);
dims();
}
};
}
function regionTab() {
const fr = parts.rooms.freeAt(selection.x, selection.z);
if (!fr) return null;
return {
key: 'sel', label: 'Ruimte (geen functie)', icon: 'bounding-box', contextual: true, order: 100,
groups: () => [
{ label: 'Ruimte', html: sel('hr-new-type', 'Functie', Object.assign({ '': '— kies —' }, roomTypeOpts), '') },
{ label: 'Maten', html: kv('Netto vloer', houseFmt(fr.areaM2, 2), 'm²') + kv('Unit', fr.units.join(' + ')) },
{ label: 'Uitleg', html: note('De ruimte loopt tot de wanden; deuren en ramen tellen als wand. Klik nog eens voor de unit.') }
],
wire: () => {
const sx = selection.x, sz = selection.z;
UI.onSel('hr-new-type', v => { if (v) assignRoom(sx, sz, v); }, null);
}
};
}
function removeSelectedElektra() {
const e = state.settings.elektra, key = String(selection.id);
if (key === 'mk') return;
if (key.startsWith('vwcd:')) e.vloerWcd = e.vloerWcd.filter(o => 'vwcd:' + o.id !== key);
else if (key.startsWith('app:')) e.apparaten[key.slice(4)] = false;
else if (!(e.removed = e.removed || []).includes(key)) e.removed.push(key);
delete e.place[key];
selection = null;
refresh();
}
function elektraItemTab(it) {
const e = state.settings.elektra;
if (it.kind === 'vwcd') {
const vw = e.vloerWcd.find(o => 'vwcd:' + o.id === it.key);
return {
key: 'sel', label: it.label, icon: 'plug', contextual: true, order: 100,
groups: () => [
{ label: 'Vloer-wcd', html: kv('Unit', it.unitId) + note('Vloerdoos met dubbele wcd; leiding door de vloer naar de meterkast.') },
{ label: 'Plaats', html: num('he-vw-x', 'Vanaf west', Math.round(it.x), { step: 10, hint: 'Hart, vanaf de westrand van de unit' }) +
num('he-vw-z', 'Vanaf noord', Math.round(it.z), { step: 10, hint: 'Hart, vanaf de noordrand van de unit' }) },
{ label: 'Bewerken', html: note('Sleep de vloer-wcd in de plattegrond (ook naar een andere unit).') + big('Verwijderen', 'trash', 'data-he-vw-del title="Vloer-wcd verwijderen (Del)"') }
],
wire: (body) => {
UI.onNum('he-vw-x', v => { if (vw) vw.x = v; }, after);
UI.onNum('he-vw-z', v => { if (vw) vw.z = v; }, after);
const b = body.querySelector('[data-he-vw-del]');
if (b) b.addEventListener('click', removeSelectedElektra);
}
};
}
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"') : '') },
it.kind === 'mk' ? { label: 'Bewerken', html: note('De meterkast kan niet weg: alle groepen hangen eraan.') }
: { label: 'Bewerken', html: big('Verwijderen', 'trash', 'data-he-it-del title="Onderdeel verwijderen (Del)"') }
],
wire: (body) => {
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(); });
const d = body.querySelector('[data-he-it-del]');
if (d) d.addEventListener('click', removeSelectedElektra);
}
};
}
function roofFormUi(id, form, o) {
const sloped = () => form.type !== 'plat';
const LIM = UnitModel.LIMITS;
const runOf = () => UnitModel.hasRidge(form.type) ? o.span() / 2 : o.span();
const degOf = () => sloped() ? Math.atan(Math.max(0, form.high - form.low) / runOf()) * 180 / Math.PI : 0;
const f = (k) => `${id}-roof-${k}`;
const groups = () => {
const g = [
{ label: 'Dakvorm', html: sel(f('type'), 'Vorm', o.types || UnitModel.ROOF_TYPES, form.type) + (o.notice ? note(o.notice) : '') +
(form.type === 'lessenaar' ? sel(f('dir'), 'Hoge zijde', SIDE_SHORT, form.dir) : '') +
(UnitModel.hasRidge(form.type) ? sel(f('ridge'), 'Nok', UnitModel.RIDGES, form.ridge) : '') +
UI.range(f('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()) g.push({ label: 'Helling', html:
UI.range(f('high'), UnitModel.hasRidge(form.type) ? 'Nok' : 'Hoog', form.high, { min: 2100, max: LIM.ridge, step: 10, hint: `Max ${LIM.ridge} mm` }) +
UI.range(f('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' }) +
(o.gable ? `<div data-live="roof-gable">${kv('Kopgevel', `wand ${SIDE_SHORT[UnitModel.gableOf(form)].toLowerCase()} + overstaande`)}</div>` : '') });
if (form.type === 'wolf') g.push({ label: 'Wolfsdak', html:
UI.range(f('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' }) });
return g;
};
const wire = (body) => {
const apply = () => {
form.low = Math.min(form.low, LIM.eave);
form.high = Math.min(form.high, LIM.ridge);
o.apply();
};
UI.onSel(f('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 === 'wolf') {
if (form.high - form.low < 1000) form.high = form.low + 1500;
form.cutRatio = 0.6;
}
apply();
}, rerender);
UI.onSel(f('dir'), v => { form.dir = v; apply(); }, rerender);
UI.onSel(f('ridge'), v => { form.ridge = v; apply(); }, rerender);
const syncDeg = () => {
const now = o.readBack();
if (now && now.type === form.type) ['cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; });
UI.setRange(f('deg'), Math.round(degOf() * 10) / 10);
UI.setRange(f('cut'), form.cut);
};
UI.onRange(f('low'), v => {
if (!(v >= 500)) return;
form.low = v;
if (sloped() && form.high < v) { form.high = v; UI.setRange(f('high'), v); }
apply(); syncDeg();
}, after);
UI.onRange(f('high'), v => {
if (!(v >= 500)) return;
form.high = Math.max(v, form.low);
apply(); syncDeg();
}, after);
UI.onRange(f('deg'), v => {
if (!(v >= 0.5) || v > 75) return;
form.high = Math.round(form.low + Math.tan(v * Math.PI / 180) * runOf());
UI.setRange(f('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(f('cut'), v => { if (v >= 500) { form.cutRatio = ratioOf(v); apply(); syncDeg(); } }, after);
};
return { groups, wire };
}
function unitTab(u) {
const isRoot = state.units[0].id === u.id;
const f = u.unit.floor.floor;
const LIM = UnitModel.LIMITS;
const partsOf = () => parts && parts.units.find(x => x.id === u.id);
const houseRoof = state.settings.roof.house.enabled;
const form = UnitModel.roofForm(u.unit);
const roofUi = roofFormUi('hp', form, {
gable: true,
span: () => {
const pu = partsOf();
const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form));
return pu ? (s.axis === 'x' ? pu.layout.floorWidth : pu.layout.floorDepth) : 1000;
},
apply: () => UnitModel.applyRoofForm(u.unit, form),
readBack: () => UnitModel.roofForm(u.unit)
});
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' }) },
];
if (houseRoof) groups.push({ label: 'Dakvorm', html: note(`Dak over de hele woning (${(UnitModel.ROOF_TYPES[shapeNow] || shapeNow).toLowerCase()}): vorm en hoogtes bij Woning › Dak (niets geselecteerd).`) });
else groups.push(...roofUi.groups());
groups.push({ label: 'Dak', html:
pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet en felsplaten 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' }) +
(pu && pu.roof.unitRoof.ridgeBeam || u.roof.steelRidge === false && ['zadel', 'wolf', 'lessenaar'].includes(shapeNow)
? toggle('hp-steel-ridge', 'Stalen nokbalk', u.roof.steelRidge !== false, { hint: pu && pu.roof.unitRoof.ridgeBeam ? `${pu.roof.unitRoof.ridgeBeam.profile.name}, ${Math.round(pu.roof.unitRoof.ridgeBeam.steelKg)} kg; uit = verwijderen` : 'Uit = verwijderd' }) : '') +
toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) });
if (u.roof.followFloor) groups.push({ label: 'Overkapping', html:
['f', 'r', 'b', 'l'].map(k => num('hp-ext-' + k, { f: 'Noord', r: 'Oost', b: 'Zuid', l: 'West' }[k], (u.roof.extend || {})[k] || 0,
{ hint: k === 'f' ? `Dak verlengen voorbij de gevel (veranda), max. ${HouseModel.EXTEND_MAX} mm; palen volgen vanzelf` : '' })).join('') });
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}"`, 'ins-danger')) });
return groups;
},
wire: (body) => {
wireButtons(body);
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);
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);
if (!houseRoof) roofUi.wire(body);
UI.onSel('hp-roof-cover', v => { u.roof.covering = v; }, rerender);
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
UI.onToggle('hp-steel-ridge', v => { u.roof.steelRidge = v; }, rerender);
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
['f', 'r', 'b', 'l'].forEach(k => UI.onNum('hp-ext-' + k, v => {
u.roof.extend = Object.assign({ f: 0, r: 0, b: 0, l: 0 }, u.roof.extend, { [k]: Math.max(0, Math.min(HouseModel.EXTEND_MAX, v)) });
}, after));
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);
}
};
}
// Schoorstenen in het vlak van deze wand (ook als ze aan een andere unit hangen, bijv. over een naad).
function chimneysOnWall(pu, side) {
const r = pu.rect, plane = { voor: r.z0, achter: r.z1, links: r.x0, rechts: r.x1 }[side];
const ids = (parts.chimneys || []).filter(c => c.side === side && Math.abs(c.plane - plane) < 1).map(c => c.id);
return (state.chimneys || []).filter(c => ids.includes(c.id));
}
function chimneyGroup(pu, wl, piece, e0) {
const list = chimneysOnWall(pu, wl.side);
const fin = (pre, label, f, types) => sel(pre, label, types, f.type) +
(f.type === 'steen' ? pair(sel(pre + '-stone', 'Steen', stoneOptions, f.stone), thumb(WallModel.stoneThumbUrl(f.stone, 64), f.stone)) : '') +
(f.type === 'stuc' ? pair(sel(pre + '-stuc', 'Kleur', stucColors, f.stuc), swatchOf(WallModel.stucHex(f.stuc))) : '');
const html = list.map(c => {
const r = (parts.chimneys || []).find(x => x.id === c.id);
const id = 'hw-ch' + c.id;
return kv('Schoorsteen', `${c.id} (unit ${c.unitId})`) +
num(id + '-u', 'Positie', c.u, { step: 10, hint: `Hart vanaf de linker vloerhoek van unit ${c.unitId}, van buitenaf gezien` }) +
num(id + '-w', 'Breedte', c.w, { step: 10, hint: 'Buitenwerks; de wand krijgt een sparing zoals een raam' }) +
num(id + '-out', 'Voor de gevel', c.out, { step: 10, hint: r ? `Minstens tot voorbij de dakoverstek: nu ${r.protrudes} mm voor de gevel` : '' }) +
num(id + '-above', 'Boven het dak', c.above, { step: 50, hint: r ? `Bovenkant ${r.yTop - r.yFloor} mm boven de vloer (hoogste dakpunt + dit)` : '' }) +
fin(id + '-out-type', 'Buiten', c.outside, ChimneyModel.OUTSIDE_TYPES) +
fin(id + '-in-type', 'Binnen', c.inside, ChimneyModel.INSIDE_TYPES) +
small('Schoorsteen weg', 'trash', `data-hw-ch="del" data-ch="${c.id}"`, 'ins-danger');
}).join('');
return { label: 'Schoorsteen', html: html + small('Schoorsteen toevoegen', 'plus-lg', `data-hw-ch="add" data-u="${Math.round(e0 + (piece.u0 + piece.u1) / 2)}"`) };
}
function wireChimneys(body, pu, wl) {
chimneysOnWall(pu, wl.side).forEach(c => {
const id = 'hw-ch' + c.id;
UI.onNum(id + '-u', v => { c.u = Math.round(v); }, after);
UI.onNum(id + '-w', v => { if (v >= 400) c.w = Math.round(v); }, after);
UI.onNum(id + '-out', v => { if (v >= 0) c.out = Math.round(v); }, after);
UI.onNum(id + '-above', v => { if (v >= 0) c.above = Math.round(v); }, after);
[['out', c.outside], ['in', c.inside]].forEach(([k, f]) => {
UI.onSel(`${id}-${k}-type`, v => { f.type = v; }, rerender);
UI.onSel(`${id}-${k}-type-stone`, v => { f.stone = v; }, rerender);
UI.onSel(`${id}-${k}-type-stuc`, v => { f.stuc = v; }, rerender);
});
});
body.querySelectorAll('[data-hw-ch]').forEach(b => b.addEventListener('click', () => {
state.chimneys = state.chimneys || [];
if (b.dataset.hwCh === 'add') state.chimneys.push(ChimneyModel.create(state.chimneys, pu.id, wl.side, Number(b.dataset.u)));
else state.chimneys = state.chimneys.filter(c => c.id !== Number(b.dataset.ch));
refresh();
}));
}
function wallTab() {
const pu = parts.units.find(x => x.id === selection.unit);
const wl = pu && pu.walls.find(x => x.side === selection.side);
// Na splitsen hernoemen de stukken: dan het stuk op positie selection.u.
const piece = wl && (wl.pieces.find(p => p.element === selection.element) ||
(selection.u !== undefined && wl.pieces.find(p => !p.baseY && selection.u >= p.u0 - 1 && selection.u <= p.u1 + 1)));
if (!piece) return null;
selection.element = piece.element;
delete selection.u;
const su = unitById(pu.id);
const ws = su.unit.walls[wl.side];
ws.splits = UnitModel.normalizeSplits(ws.splits);
const e0 = (wl.skinExt && wl.skinExt.left) || 0;
const own = ws.splits.find(sp => Math.abs(sp.u - e0 - piece.u0) < 1);
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)) : '') +
(ov && ov.type === 'stuc' ? pair(sel('hw-facade-stuc', 'Kleur', stucColors, fc.stuc), swatchOf(WallModel.stucHex(fc.stuc))) : '') +
(ov && ov.type === 'fels' ? pair(sel('hw-facade-fels', 'Kleur', felsColors, fc.fels), swatchOf(WallModel.felsHex(fc.fels))) : '') },
{ label: 'Wandstukken', html:
num('hw-split-at', 'Splitsen op', Math.round(e0 + (piece.u0 + piece.u1) / 2), { step: 10, hint: 'Afstand vanaf de linker vloerhoek, van buitenaf gezien. Elk stuk wordt een eigen wand-element (eigen regelwerk) en kan een eigen gevel krijgen.' }) +
small('Splitsen', 'scissors', 'data-hw="split"') +
kv('Splitsingen', ws.splits.length ? ws.splits.map(sp => sp.u).join(' · ') : 'geen', ws.splits.length ? 'mm' : '') +
(own ? sel('hw-piece-facade', 'Gevel dit stuk', Object.assign({ zijde: 'Zoals de zijde' }, HouseModel.FACADE_TYPES), own.facade ? own.facade.type : 'zijde', { hint: 'Gevel van dit wandstuk (rechts van de splitsing)' }) +
(own.facade && own.facade.type === 'steen' ? sel('hw-piece-stone', 'Steen', stoneOptions, own.facade.stone || fc.stone) : '') +
(own.facade && own.facade.type === 'stuc' ? sel('hw-piece-stuc', 'Kleur', stucColors, own.facade.stuc || fc.stuc) : '') +
(own.facade && own.facade.type === 'fels' ? sel('hw-piece-fels', 'Kleur', felsColors, own.facade.fels || fc.fels) : '') +
small('Splitsing weg', 'x-lg', 'data-hw="unsplit"') : '') },
chimneyGroup(pu, wl, piece, e0),
{ 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 (Woning › 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) => {
wireButtons(body);
const fc = HouseModel.facadeFor(state, su, wl.side);
UI.onSel('hw-light', v => { su.lighting.walls[wl.side] = v; }, rerender);
wireChimneys(body, pu, wl);
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 } : v === 'stuc' ? { type: v, stuc: fc.stuc } : v === 'fels' ? { type: v, fels: fc.fels } : { type: v };
}, rerender);
UI.onSel('hw-facade-stone', v => { su.facade.walls[wl.side].stone = v; }, rerender);
UI.onSel('hw-facade-stuc', v => { su.facade.walls[wl.side].stuc = v; }, rerender);
UI.onSel('hw-facade-fels', v => { su.facade.walls[wl.side].fels = v; }, rerender);
UI.onSel('hw-piece-facade', v => {
if (!own) return;
own.facade = v === 'zijde' ? null : v === 'steen' ? { type: v, stone: fc.stone } : v === 'stuc' ? { type: v, stuc: fc.stuc } : v === 'fels' ? { type: v, fels: fc.fels } : { type: v };
}, rerender);
UI.onSel('hw-piece-stone', v => { if (own && own.facade) own.facade.stone = v; }, rerender);
UI.onSel('hw-piece-stuc', v => { if (own && own.facade) own.facade.stuc = v; }, rerender);
UI.onSel('hw-piece-fels', v => { if (own && own.facade) own.facade.fels = v; }, rerender);
body.querySelectorAll('[data-hw]').forEach(b => b.addEventListener('click', () => {
if (b.dataset.hw === 'split') {
const v = Number(document.getElementById('hw-split-at').value);
if (!(v > 0)) return;
ws.splits = UnitModel.normalizeSplits(ws.splits.concat([{ u: v, facade: null }]));
selection.u = v - e0 + 1;
} else if (b.dataset.hw === 'unsplit' && own) {
ws.splits = ws.splits.filter(sp => sp !== own);
selection.u = piece.u0 + 1;
}
refresh();
}));
}
};
}
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"`, 'ins-danger') }
],
wire: (body) => {
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);
}
};
}
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}"`, 'ins-danger') }
];
},
wire: (body) => {
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);
}
};
}
const WONING_SECTIONS = () => [
Object.assign({ icon: 'bricks' }, gevelTab), Object.assign({ icon: 'house' }, dakTab), Object.assign({ icon: 'door-open' }, binnenTab),
Object.assign({ icon: 'lightbulb' }, lichtTab), Object.assign({ icon: 'lightning-charge' }, elektraTab), Object.assign({ icon: 'grid-3x3' }, constructieTab)
];
// Eigenschappenpaneel: de selectie, of zonder selectie de instellingen van de hele woning.
function renderInspector() {
const el = document.getElementById('ho-inspector');
if (!el) return;
live = [];
const ctx = contextTab();
if (ctx) {
Inspector.render(el, { crumbs: [{ label: 'Woning', attrs: 'data-deselect title="Selectie opheffen (Esc)"' }], title: ctx.label, icon: ctx.icon || 'cursor',
sections: [{ key: 'sel', groups: ctx.groups, wire: ctx.wire }] });
} else {
const n = state.units.length;
Inspector.render(el, { title: 'Woning', icon: 'house-door', sub: `${n} unit${n === 1 ? '' : 's'} · niets geselecteerd`, collapsible: true,
top: '<div data-live="woning-dims" class="ins-summary"></div>',
sections: WONING_SECTIONS() });
const dims = () => {
const d0 = el.querySelector('[data-live="woning-dims"]');
if (!d0 || !parts) return;
const d = HouseModel.computeDimensions(parts, state.settings.groundLevel), f = houseFmt;
const wide = (h) => h.replace('class="ins-kv"', 'class="ins-kv ins-wide"');
d0.innerHTML = wide(kv('Buitenmaten', `${f(d.lengthX / 1000, 2)} × ${f(d.lengthZ / 1000, 2)}`, 'm')) +
kv('Bruto vloer', f(d.brutoM2, 1), 'm²') + kv('Leefbaar', f(d.leefbaarM2, 1), 'm²') +
kv('Bouwhoogte', f(d.height / 1000, 2), 'm') + kv('Inhoud', f(d.inhoudM3, 0), 'm³') + wide(kv('Gevel netto', f(d.gevelNettoM2, 1), 'm²'));
};
live.push(dims);
dims();
}
const back = el.querySelector('[data-deselect]');
if (back) back.addEventListener('click', () => select(null));
}
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('');
}
let reportOpen = false, reportDirty = true;
const overviewEl = document.getElementById('house-overview');
function toggleReport(force) {
const open = force === undefined ? !reportOpen : !!force;
if (open === reportOpen) return;
reportOpen = view3D.report = open;
if (open && UI.viewOf(overviewEl) === '3d') UI.setView(overviewEl, view3D.mode = 'split');
overviewEl.classList.toggle('show-report', open);
if (open && reportDirty && parts) renderReport();
syncToolbar();
requestAnimationFrame(onResize);
}
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)),
UI.specCard('Ruimtes (netto vloer)', 'bounding-box', parts.rooms.rooms.map(r => [`${esc(r.name)}${r.ceilingActive ? ` <small>plafond ${r.ceiling}</small>` : ''}`, f(r.areaM2, 2), 'm²'])
.concat(parts.rooms.free.length ? [[`Zonder functie (${parts.rooms.free.length})`, f(parts.rooms.free.reduce((a, r) => a + r.areaM2, 0), 2), 'm²']] : []),
['Totaal netto', f(parts.rooms.rooms.concat(parts.rooms.free).reduce((a, r) => a + r.areaM2, 0), 2), 'm²']),
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.nokbalken.length) cards.push(UI.specCard('Stalen nokbalk', 'house', totals.nokbalken.flatMap(n => [
[`Unit ${n.unit}: ${n.name} S235${n.ok ? '' : ' (te licht!)'}`, `${f(n.lengthM, 2)} m · ${f(n.kg)}`, 'kg'],
['Benutting sterkte / doorbuiging', `${f(n.util * 100)} % · ${f(n.defl, 1)} / ${f(n.deflMax, 1)}`, 'mm'],
['Vulhout in de holling', `${f(n.fillM3, 3)} m³${n.below ? ` · ${n.below} mm onder plafond` : ''}`, '']
])));
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));
}
if (totals.posts) {
const pp = totals.posts;
cards.push(UI.specCard('Overkapping', 'columns', (pp.count ? [
[`${pp.wood} palen ${pp.size}×${pp.size} mm`, pp.count, 'st.'],
['Lengte totaal', f(pp.lengthM, 2), 'm'], ['Hout', f(pp.m3, 3), 'm³'], ['Gewicht', f(pp.kg), 'kg'], ['Paalvoeten', pp.count, 'st.']] : [])
.concat(pp.walls ? [[`Zijwand (${pp.walls} st.)`, f(pp.wallM2, 2), 'm²']] : [])));
}
// 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="ho-report-cards">${cards.join('')}</div>`;
layoutReport();
}
const layoutReport = () => { if (reportOpen) UI.masonry(document.querySelector('#ho-report .ho-report-cards'), 300); };
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(); } });
let pending = null;
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); });
}
// Alleen geometrie vrijgeven: materialen zijn gedeeld.
const disposeGroup = (g) => g && g.traverse(o => { if (o.geometry) o.geometry.dispose(); });
function refresh(opts) {
HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage);
const painted = state.settings.interiorPaint && view3D.layers.afwerking;
wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR);
roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef');
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, postMaterials, chimneyMaterials, elektraMaterials, layers: view3D.layers });
reportDirty = true;
if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); }
houseGroup = new THREE.Group();
planCaps = null;
const toM = 0.001;
HouseModel.placeGroups(parts);
parts.units.forEach(u => 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.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.layers.dak && !section) {
parts.units.forEach(u => houseGroup.add(u.roof.group));
if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup);
if (parts.postsGroup) houseGroup.add(parts.postsGroup);
}
// Alles wat binnen de wanden of onder het plafond zit, snijdt mee met de doorsnede (anders zweeft het boven de snede).
const clipGroup = (g) => {
if (!g) return;
g.traverse(o => {
if (!o.material || (o.material.clippingPlanes || []).length === clip.length) return;
o.material.clippingPlanes = clip;
o.material.needsUpdate = true;
});
houseGroup.add(g);
};
if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup);
if (parts.lightingGroup && !section) houseGroup.add(parts.lightingGroup);
clipGroup(parts.elektraGroup);
clipGroup(parts.chimneysGroup); // laag Gevelbekleding, net als in de plattegrond
if (parts.roomsGroup) parts.roomsGroup.userData.ceilings.visible = !section;
clipGroup(parts.roomsGroup);
clipGroup(parts.fixturesGroup);
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);
// 2 mm extra lager: bij maaiveld 0 anders 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 && view3D.layers.afwerking) houseGroup.add(buildLogoSign(groundY));
skyEnv.fitTo(houseGroup, groundY);
ground.position.set(cx, groundY - 0.002, cz);
applyRenderMode();
collectDoors();
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);
controls.update();
cameraFitted = true;
}
const stale = !!selection && !contextTab();
if (stale) selection = null;
if (stale || !(opts && opts.keepProps)) renderInspector();
else updateLive();
renderWarnings();
if (reportOpen) renderReport();
drawPlan();
renderNav(parts);
}
const SIGN = { ahead: 4000, aside: 1800, clear: 1200, poleH: 2200, poleW: 80, plateW: 1500, plateD: 30 };
let signPos = null;
let signMaterial = null;
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();
const t = { x: n.z, z: -n.x };
// Voordeur in een binnenhoek: opzij kan het bord onder een andere unit vallen, dan verder opzij of de andere kant op.
const at = (a) => ({ x: d.x + n.x * SIGN.ahead + t.x * a, z: d.z + n.z * SIGN.ahead + t.z * a });
const free = (q) => !parts.rects.some(r => !r.error && q.x > r.x0 - SIGN.clear && q.x < r.x1 + SIGN.clear && q.z > r.z0 - SIGN.clear && q.z < r.z1 + SIGN.clear);
signPos = at(SIGN.aside);
for (let a = SIGN.aside; a <= 30000; a += 500) {
const q = [at(a), at(-a)].find(free);
if (q) { signPos = q; break; }
}
const toM = 0.001, plateH = SIGN.plateW / 3;
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);
return g;
}
const WALK_RADIUS = 250;
const isWalkDoor = (op) => op.type !== 'schoorsteen' && (op.type === 'deur' || op.type === 'voordeur' ||
(op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur')) || op.y < 50);
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;
if ((parts.chimneys || []).length && ChimneyModel.covers(parts.chimneys, x, z, 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, p.stuc) : 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 mag je altijd lopen, ook als de opening smal of laag is.
return !(p.config.openings || []).some(op => isWalkDoor(op) &&
lx > -W / 2 + op.x - WALK_RADIUS * 0.2 && lx < -W / 2 + op.x + op.w + WALK_RADIUS * 0.2);
}) || (view3D.layers.inrichting && (parts.fixtures || [])
.some(f => FixtureModel.blocks(f) && FixtureModel.contains(f, x, z, 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;
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 } };
}
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 · E = deur/schakelaar · L of Esc = terug';
pane3D.appendChild(walkHint);
const walkPrompt = document.createElement('div');
walkPrompt.className = 'walk-prompt';
pane3D.appendChild(walkPrompt);
const INTERACT = { reach: 2.2, doorAngle: 0.6, switchAngle: 0.3, swingSpeed: 2.4, slideSpeed: 1.2 };
const doorState = new Map();
const lightsOff = new Set();
let doors = [];
function applyDoor(o) {
const d = o.userData.door;
const dir = d.dir === undefined ? (d.kind === 'swing' ? 1 : -1) : d.dir;
if (d.kind === 'swing') o.rotation.y = dir * d.open;
else o.position.x = d.base + dir * d.open;
}
function collectDoors() {
doors = [];
if (!houseGroup) return;
houseGroup.updateMatrixWorld(true);
houseGroup.traverse(o => {
const d = o.userData.door;
if (!d) return;
d.world = o.parent.localToWorld(d.center.clone());
d.key = `${Math.round(d.world.x * 100)}|${Math.round(d.world.z * 100)}`;
if (doorState.has(d.key)) d.open = d.target = doorState.get(d.key);
applyDoor(o);
doors.push(o);
});
}
function animateDoors(dt) {
doors.forEach(o => {
const d = o.userData.door;
if (d.open === d.target) return;
const step = (d.kind === 'swing' ? INTERACT.swingSpeed : INTERACT.slideSpeed) * Math.min(dt, 0.1);
d.open = d.target > d.open ? Math.min(d.target, d.open + step) : Math.max(d.target, d.open - step);
applyDoor(o);
});
}
function applyLightSwitches() {
const off = (ref) => !!ref.letter && lightsOff.has(ref.letter);
if (parts) { LightingModel.applySwitches(parts.lightingGroup, off); LightingModel.applySwitches(parts.roofEdgeGroup, off); }
LightingModel.applySwitches(lightsGroup, off);
}
const tmpDir = new THREE.Vector3();
function interactTarget() {
if (!walk.active || !parts) return null;
camera.getWorldDirection(tmpDir);
const cam = camera.position, hd = Math.hypot(tmpDir.x, tmpDir.z) || 1;
let best = null;
const consider = (score, t) => { if (!best || score < best.score) best = Object.assign(t, { score }); };
doors.forEach(o => {
const d = o.userData.door, w = d.world;
if (!o.parent.visible) return;
const dx = w.x - cam.x, dz = w.z - cam.z, dist = Math.hypot(dx, dz);
if (dist > INTERACT.reach || dist < 1e-3) return;
const ang = Math.acos(Math.max(-1, Math.min(1, (dx * tmpDir.x + dz * tmpDir.z) / (dist * hd))));
if (ang > INTERACT.doorAngle) return;
consider(ang + dist * 0.1, { kind: 'door', label: d.target > 0 ? 'Deur sluiten' : 'Deur openen', act: () => {
const open = !(d.target > 0);
doors.filter(x => x === o || x.parent === o.parent).forEach(x => {
const dx2 = x.userData.door;
dx2.target = open ? dx2.max : 0;
doorState.set(dx2.key, dx2.target);
});
} });
});
const el = parts.elektra;
if (el && el.enabled) el.switches.forEach(sw => {
const p = sw.pos, x = p.x / 1000, y = p.y / 1000, z = p.z / 1000;
const dx = x - cam.x, dy = y - cam.y, dz = z - cam.z, dist = Math.hypot(dx, dy, dz);
if (dist > INTERACT.reach || dist < 1e-3) return;
const ang = Math.acos(Math.max(-1, Math.min(1, (dx * tmpDir.x + dy * tmpDir.y + dz * tmpDir.z) / dist)));
if (ang > INTERACT.switchAngle) return;
const off = lightsOff.has(sw.letter);
consider(ang + dist * 0.1 - 0.05, { kind: 'switch', label: `Schakelaar ${sw.letter}: ${sw.label.toLowerCase()} ${off ? 'aan' : 'uit'}`, act: () => {
if (off) lightsOff.delete(sw.letter); else lightsOff.add(sw.letter);
applyRenderMode();
} });
});
return best;
}
function interact() {
const t = interactTarget();
if (t) t.act();
}
let promptText = '';
function updatePrompt() {
const t = walk.active ? interactTarget() : null;
const text = t ? `E · ${t.label}` : '';
if (text === promptText) return;
promptText = text;
walkPrompt.textContent = text;
walkPrompt.classList.toggle('show', !!text);
}
function setWalk(on) {
if (on === walk.active || !parts) return;
const view = document.getElementById('house-overview');
if (on) {
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);
if (!on) updatePrompt();
pane3D.classList.toggle('walking', on);
syncToolbar();
}
let rafId = null, lastT = performance.now();
function animate() {
rafId = requestAnimationFrame(animate);
const now = performance.now();
walk.update((now - lastT) / 1000);
animateDoors((now - lastT) / 1000);
if (walk.active) updatePrompt();
lastT = now;
renderer.render(scene, camera);
}
function onResize() {
camera.aspect = pane3D.clientWidth / pane3D.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
drawPlan();
layoutReport();
}
window.addEventListener('resize', onResize);
refresh();
syncSectionRow();
if (view3D.report) toggleReport(true);
animate();
setTool(pendingTool || 'select');
pendingTool = null;
return {
unmount() {
window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKey);
window.removeEventListener('mouseup', onElektraDrop);
window.removeEventListener('mouseup', onFixtureDrop);
window.removeEventListener('mousemove', onPanMove);
window.removeEventListener('mouseup', onPanEnd);
if (rafId !== null) cancelAnimationFrame(rafId);
setWalk(false); // orbitcamera terug vóór het opslaan
walk.dispose();
CameraMemory.save(overviewCamKey, camera, controls);
disposeGroup(houseGroup);
disposeLights();
signPoleMat.dispose(); signEdgeMat.dispose();
offCosts();
renderer.dispose();
},
refresh,
select,
setTool,
setView: bar.setView,
toggleReport: () => toggleReport(),
toggleSection,
fitView: () => fitPlan(),
toggleWalk: () => setWalk(!walk.active),
viewState: () => ({ view: UI.viewOf(overviewEl), report: reportOpen, section: view3D.section, walk: walk.active })
};
}
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);
});
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 exportAs(ext, title, run, done) {
flushActivePart();
const naam = String((state.project && state.project.naam) || '').replace(/[\\/:*?"<>|]+/g, '-').trim();
const suggested = (naam || 'woning') + '-' + new Date().toISOString().slice(0, 10);
let filename = window.prompt(`Bestandsnaam voor de ${title}:`, suggested);
if (filename === null) { setIoStatus('Export geannuleerd.', ''); return null; }
filename = filename.trim() || suggested;
if (!new RegExp(`\\.${ext}$`, 'i').test(filename)) filename += '.' + ext;
try {
const res = run(filename);
setIoStatus(`Geëxporteerd als "${filename}" (${typeof done === 'function' ? done(res) : done}).`, 'ok');
} catch (err) {
setIoStatus(`${ext.toUpperCase()}-export mislukt: ${err.message}`, 'err');
}
return filename;
}
const exportDxf = () => exportAs('dxf', 'CAD-export (DXF)', (f) => HouseDxf.generate(state).save(f), 'DXF, in CAD op te slaan als DWG');
const exportIfc = () => exportAs('ifc', 'BIM-export (IFC)', (f) => HouseIfc.generate(state).save(f), 'IFC4: balken, platen en elektra');
// Render-export (sfeerimpressie): alleen wat in de afgewerkte woning te zien is, los van de gekozen lagen, doorsnede
// en weergave-opties (HouseModel.computeParts met finishedOnly).
// Lampen als echte lichtbronnen via een Blender-script in de ZIP.
// Eigen materiaalsets (steen, riet, rabat en tegels komen uit gedeelde caches); maaiveld onder het midden = oorsprong.
const exportObj = () => exportAs('zip', 'render-export (OBJ + texturen + lampen, ZIP)', (filename) => {
const wallMaterials = WallModel.createMaterials(), roofMaterials = RoofModel.createMaterials();
if (state.settings.interiorPaint) wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorColor);
roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef');
const full = HouseModel.computeParts(state, {
wallMaterials, floorMaterials: FloorModel.createMaterials(), roofMaterials, lightingMaterials: LightingModel.createMaterials(),
sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(), chimneyMaterials: ChimneyModel.createMaterials(),
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true
});
HouseModel.placeGroups(full);
const roots = full.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(full).map(p => p.group), full.units.map(u => u.roof.group),
[full.roofEdgeGroup, full.postsGroup, full.chimneysGroup, full.sandwichGroup, full.lightingGroup, full.elektraGroup, full.roomsGroup, full.fixturesGroup]).filter(Boolean);
const bx0 = Math.min(...full.rects.map(r => r.x0)), bx1 = Math.max(...full.rects.map(r => r.x1));
const bz0 = Math.min(...full.rects.map(r => r.z0)), bz1 = Math.max(...full.rects.map(r => r.z1));
const ground = Math.max(...full.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
const name = filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning';
const origin = { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 };
const out = ObjWriter.build(roots, { name, app: APP_VERSION, origin });
const edge = full.roofEdge;
const lights = LightingModel.lightSources(full.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, full.lighting.indoor);
out.files.push({ name: `${name}_lampen.py`, data: new TextEncoder().encode(ObjWriter.blenderLights(lights, { name, app: APP_VERSION, origin })) });
ZipWriter.save(out.files, filename);
return Object.assign(out.stats, { lights: lights.length, script: `${name}_lampen.py` });
}, (s) => `${s.triangles.toLocaleString('nl-NL')} driehoeken, ${s.materials} materialen, ${s.textures} texturen, ${s.lights} lampen; uitpakken en in Blender ${s.script} uitvoeren (importeert de OBJ en plaatst de lampen)`);
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 (...args) => { if (activeEditor && typeof activeEditor[fn] === 'function') return activeEditor[fn](...args); };
}
const has = (fn) => !!(activeEditor && typeof activeEditor[fn] === 'function');
// Acties die naar de actieve onderdeel-editor gaan; de API hieronder wordt uit deze lijst opgebouwd.
const FORWARDED = ['mirrorHorizontal', 'setView', 'toggleReport', 'toggleSection', 'fitView', 'toggleWalk'];
function onPartKey(e) {
if (e.key !== 'Escape' || activePart === 'overzicht' || ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
showPart('overzicht');
}
window.addEventListener('keydown', onPartKey);
showPart('overzicht');
return {
unmount() {
window.removeEventListener('keydown', onPartKey);
if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount();
activeEditor = null;
},
exportDesign,
exportDxf,
exportIfc,
exportObj,
triggerImport,
...Object.fromEntries(FORWARDED.map(fn => [fn, forward(fn)])),
viewState: () => forward('viewState')() || {},
// Doorgestuurde acties bestaan alleen als de actieve onderdeel-editor ze kent (bijv. Rapport alleen in de woning).
can: (fn) => !FORWARDED.includes(fn) || has(fn),
openProjectProperties,
getStateJson,
loadStateJson
};
}
window.HouseEditor = {
mount: mountHouseEditor,
getDefaultState: (template) => HouseModel.createDefaultState(template || 'single')
};