v0.7.2: fixes uit de code-review van v0.7.1: elektraleidingen nemen niet langer het valse plafond van de buurruimte door de wand heen over (gebied zonder functie = geen vals plafond) en meten begin/eind 50 mm de ruimte in, zodat de zigzag bij een overgang vals plafond/dak verdwijnt (Amstel 249,9 → 246,8 m, 16 van 68 leidingen strakker); elektra en schoorsteen snijden mee met de doorsnede (geen zwevende buizen); PDF en de plaatfase van de IFC bouwen geen buizen meer; schoorsteen in 2D en 3D op de laag Gevelbekleding; Esc sluit elke dialoog via Annuleren en sneltoetsen gaan niet meer naar de editor erachter; toets 3 sluit het rapport; menu en popovers sluiten elkaar (één uitklapper tegelijk); paneelkop ge-escaped; na slepen/pannen buiten de plattegrond wordt de volgende klik niet meer ingeslikt en blijft de cursor van het gereedschap; "Alles" in Lagen zet ook beplating binnen/buiten terug; laag uit met een selectie erin werkt het paneel bij; opgeruimd: gedeelde werkbalk (UI.editorBar) voor woning, wand en vloer, één knopbouwer (UI.btnOf), dichte paneelsecties pas opgebouwd bij openen, dode knopacties weg, binnenwand verwijderen op één plek, doorgestuurde acties uit één lijst, render-export "alleen afgewerkt" volledig in HouseModel (finishedOnly), structuurboom springt alleen bij een andere selectie

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-13 00:01:13 +02:00
co-authored by Claude Opus 5.5
parent ab0dca9bb5
commit 89cadbebf7
14 changed files with 217 additions and 175 deletions
+1 -1
View File
@@ -50,7 +50,7 @@
<div class="license-box" id="about-license"></div>
<div class="about-footer">
<span>&copy; 2026 Machiel Heinen &middot; ontwikkeld in opdracht van Van Emous Mantelzorgwoningen</span>
<button id="about-close" class="about-close">Sluiten</button>
<button id="about-close" class="about-close" data-esc>Sluiten</button>
</div>
</div>
</div>
+15 -3
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const APP_VERSION = '0.7.1';
const APP_VERSION = '0.7.2';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = [];
@@ -243,9 +243,15 @@ function toggleBrowserFullscreen() {
else document.documentElement.requestFullscreen();
}
// Alle dialogen; Esc gebruikt hun Annuleren-knop (data-esc), zodat bijv. projectgegevens worden teruggezet.
const OVERLAYS = '.dlg-overlay.open, #about-overlay.open, #new-overlay.open, #house-project-overlay.open, #wall-project-overlay.open, #cost-settings-overlay.open';
function closeOverlays() {
const open = document.querySelector('.dlg-overlay.open, #about-overlay.open, #new-overlay.open');
if (open) { open.classList.remove('open'); return true; }
const open = document.querySelector(OVERLAYS);
if (open) {
const cancel = open.querySelector('[data-esc]');
if (cancel) cancel.click(); else open.classList.remove('open');
return true;
}
return Menubar.close() || UI.closePopovers();
}
@@ -253,6 +259,12 @@ function wireHotkeys() {
document.addEventListener('keydown', (e) => {
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
const k = e.key.toLowerCase();
// Dialoog open: alleen Esc (sluiten); geen sneltoetsen naar de editor erachter. Typen in de dialoog blijft werken.
if (document.querySelector(OVERLAYS)) {
if (e.key === 'Escape') closeOverlays();
e.stopImmediatePropagation();
return;
}
if (e.ctrlKey || e.metaKey) {
const act = { s: () => editorCall('exportDesign'), n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
+17 -10
View File
@@ -9,11 +9,9 @@
// zodat wire() alleen de eigen knoppen koppelt.
window.Inspector = (() => {
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
const big = (label, iconName, attrs, cls) =>
`<button type="button" class="ins-btn ins-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
const small = (label, iconName, attrs, cls) =>
`<button type="button" class="ins-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
const { icon, esc } = UI;
const big = UI.btnOf('ins-btn ins-big');
const small = UI.btnOf('ins-btn');
const kv = (label, value, unit) =>
`<div class="ins-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
@@ -32,9 +30,9 @@ window.Inspector = (() => {
const keepScroll = el._insKey === scrollKey ? el.scrollTop : 0;
el._insKey = scrollKey;
el.innerHTML = `<div class="ins-head">
${view.crumbs && view.crumbs.length ? `<div class="ins-crumbs">${view.crumbs.map(c => `<button type="button" class="ins-crumb" ${c.attrs || ''}>${c.label}</button>`).join(icon('chevron-right'))}${icon('chevron-right')}</div>` : ''}
<h2>${view.icon ? icon(view.icon) : ''}<span>${view.title}</span></h2>
${view.sub ? `<div class="ins-sub">${view.sub}</div>` : ''}
${view.crumbs && view.crumbs.length ? `<div class="ins-crumbs">${view.crumbs.map(c => `<button type="button" class="ins-crumb" ${c.attrs || ''}>${esc(c.label)}</button>`).join(icon('chevron-right'))}${icon('chevron-right')}</div>` : ''}
<h2>${view.icon ? icon(view.icon) : ''}<span>${esc(view.title)}</span></h2>
${view.sub ? `<div class="ins-sub">${esc(view.sub)}</div>` : ''}
</div>
${view.top ? `<div class="ins-top">${view.top}</div>` : ''}
<div class="ins-sections"></div>`;
@@ -45,16 +43,25 @@ window.Inspector = (() => {
const isOpen = !collapsible || open.has(sec.key);
node.className = 'ins-section' + (collapsible ? ' ins-collapsible' : '') + (isOpen ? ' open' : '');
node.innerHTML = (collapsible ? `<button type="button" class="ins-section-head" aria-expanded="${isOpen}">${sec.icon ? icon(sec.icon) : ''}<span>${sec.label}</span>${icon('chevron-down')}</button>` : '') +
`<div class="ins-section-body">${groupsHtml(sec.groups ? sec.groups() : [], sec.label)}</div>`;
'<div class="ins-section-body"></div>';
host.appendChild(node);
// Een dichte sectie wordt pas opgebouwd (en gekoppeld) als hij opengaat.
let built = false;
const build = () => {
if (built) return;
built = true;
node.querySelector('.ins-section-body').innerHTML = groupsHtml(sec.groups ? sec.groups() : [], sec.label);
if (sec.wire) sec.wire(node);
};
if (isOpen) build();
if (collapsible) node.querySelector('.ins-section-head').addEventListener('click', () => {
const now = !node.classList.contains('open');
if (now) build();
node.classList.toggle('open', now);
node.querySelector('.ins-section-head').setAttribute('aria-expanded', now);
if (now) open.add(sec.key); else open.delete(sec.key);
saveOpen();
});
if (sec.wire) sec.wire(node);
});
el.scrollTop = keepScroll;
}
+3 -2
View File
@@ -23,13 +23,15 @@ window.Menubar = (() => {
function close() {
if (openIdx < 0) return false;
openIdx = -1;
UI.release(close);
root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open'));
root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; });
return true;
}
function openMenu(i) {
close();
if (openIdx < 0) UI.claim(close);
else { root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open')); root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; }); }
openIdx = i;
const wrap = root.querySelectorAll('.mb-menu')[i];
wrap.classList.add('open');
@@ -54,7 +56,6 @@ window.Menubar = (() => {
const top = e.target.closest('[data-menu]');
if (top && openIdx >= 0 && Number(top.dataset.menu) !== openIdx) openMenu(Number(top.dataset.menu));
});
document.addEventListener('click', close);
}
return { init, close };
+38 -13
View File
@@ -80,8 +80,10 @@ window.UI = (() => {
const info = (text) => `<div class="ui-info">${icon('info-circle')}<span>${text}</span></div>`;
const warn = (text) => `<div class="ui-warn">${icon('exclamation-triangle')}<span>${text}</span></div>`;
const grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`;
const button = (label, iconName, attrs, cls) =>
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
// Eén knopbouwer; base = de knopsoort (ui-btn, tb-btn, ins-btn …), cls = extra klassen.
const btnOf = (base) => (label, iconName, attrs, cls) =>
`<button type="button" class="${base}${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
const button = btnOf('ui-btn');
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
const activeTabs = {};
@@ -142,27 +144,50 @@ window.UI = (() => {
el.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode));
}
const tb = (label, iconName, attrs, cls) =>
`<button type="button" class="tb-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
const tb = btnOf('tb-btn');
const tbSep = () => '<span class="tb-sep"></span>';
const viewSeg = (mode) => `<div class="tb-seg" role="group" aria-label="Weergave">${VIEWS.map(v =>
tb('', v.icon, `data-view="${v.key}" title="${v.label} (${v.key1})"`, v.key === mode ? 'active' : '')).join('')}</div>`;
// Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere popover.
// Werkbalk van een editor: [left] … [Lagen, Omgeving] [after] | [2D + 3D, 2D, 3D]. Gedeeld door woning, wand en vloer.
// o: left/after (html), onResize, layers (opties voor ViewLayers.createMenu), onSky, beforeView(mode).
function editorBar(toolbar, root, o) {
toolbar.innerHTML = `${o.left || ''}<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${o.after || ''}${tbSep()}${viewSeg(viewOf(root))}`;
const group = toolbar.querySelector('.tb-view');
if (o.layers) ViewLayers.createMenu(group, o.layers);
if (o.onSky) Skybox.createToggle(group, o.onSky);
const bar = {
setView(mode) {
if (o.beforeView) o.beforeView(mode);
setView(root, mode);
requestAnimationFrame(o.onResize);
},
viewState: () => ({ view: viewOf(root) })
};
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => bar.setView(b.dataset.view)));
return bar;
}
// Eén uitklapper tegelijk (popover of menu): wie opent, sluit de vorige. claim bij openen, release bij sluiten.
let openPop = null;
function claim(close) { if (openPop && openPop !== close) openPop(); openPop = close; }
function release(close) { if (openPop === close) openPop = null; }
const closePopovers = () => { if (openPop) { openPop(); return true; } return false; };
document.addEventListener('click', () => closePopovers());
// Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere uitklapper.
function popover(btn, panel) {
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); if (openPop === close) openPop = null; };
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); release(close); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
const was = panel.classList.contains('open');
if (openPop) openPop();
if (!was) { panel.classList.add('open'); btn.classList.add('open'); openPop = close; }
if (panel.classList.contains('open')) { close(); return; }
claim(close);
panel.classList.add('open');
btn.classList.add('open');
});
panel.addEventListener('click', (e) => e.stopPropagation());
return close;
}
const closePopovers = () => { if (openPop) { openPop(); return true; } return false; };
document.addEventListener('click', () => closePopovers());
// Kaarten verdelen over kolommen van min. minW breed; telkens in de kortste kolom. Opnieuw aanroepen bij een andere breedte.
function masonry(el, minW, gap) {
@@ -178,6 +203,6 @@ window.UI = (() => {
cards.forEach(card => cols.reduce((a, b) => b.offsetHeight < a.offsetHeight ? b : a).appendChild(card));
}
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle,
VIEWS, viewOf, setView, tb, tbSep, viewSeg, popover, closePopovers, masonry };
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, btnOf,
VIEWS, viewOf, setView, tb, tbSep, viewSeg, editorBar, popover, claim, release, closePopovers, masonry };
})();
+10 -3
View File
@@ -20,7 +20,7 @@ window.ViewLayers = (() => {
{ key: 'lampen', label: 'Verlichting', icon: 'lightbulb', phase: 6, hint: 'Gevellampen, spots en binnenverlichting' },
{ key: 'inrichting', label: 'Inrichting', icon: 'lamp', phase: 6, hint: 'Sanitair, keuken, kasten, bed, zithoek' },
{ key: 'ruimtes', label: 'Ruimtes', icon: 'bounding-box', phase: 6, hint: 'Ruimtes met functie, vloer en wandpanelen' },
{ key: 'dak', label: 'Dak', icon: 'house', hint: 'Het hele dak met dakrand, overkapping, palen en schoorsteen; uit om van boven in de woning te kijken' },
{ key: 'dak', label: 'Dak', icon: 'house', hint: 'Het hele dak met dakrand, overkapping en palen; uit om van boven in de woning te kijken' },
{ key: 'maten', label: 'Maten', icon: 'rulers', hint: 'Maatvoering in de plattegrond' }
];
const KEYS = LIST.map(l => l.key);
@@ -98,10 +98,17 @@ window.ViewLayers = (() => {
emit();
}
});
// Alles = ook de editor-eigen schakelaars weer aan.
const all = panel.querySelector('[data-all]');
if (all) all.addEventListener('click', () => { cur = ViewLayers.all(); emit(); });
if (all) all.addEventListener('click', () => {
const extraOff = extras.some(x => !extra[x.key]);
extras.forEach(x => { extra[x.key] = true; });
cur = ViewLayers.all();
emit();
if (extraOff && o.onExtra) o.onExtra(Object.assign({}, extra));
});
sync();
return { el: wrap, set: (l) => { cur = normalize(l); sync(); }, toggle: (key) => { cur = Object.assign({}, cur, { [key]: !cur[key] }); emit(); } };
return { el: wrap, set: (l) => { cur = normalize(l); sync(); } };
}
return { LIST, KEYS, PHASES, GROUPS, all, normalize, shows, upTo, phaseOf, hideByMaterial, createMenu };
+1 -1
View File
@@ -29,7 +29,7 @@ window.CostSettings = (() => {
<button type="button" class="ui-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
<button type="button" class="ui-btn" data-cs="import">Importeren&hellip;</button>
<span style="flex:1"></span>
<button type="button" class="ui-btn" data-cs="cancel">Annuleren</button>
<button type="button" class="ui-btn" data-cs="cancel" data-esc>Annuleren</button>
<button type="button" class="ui-btn ui-btn-primary" data-cs="save">Toepassen</button>
</div>
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
+14 -3
View File
@@ -146,8 +146,14 @@ window.ElektraModel = (() => {
// rooms: leidingen lopen boven een vals plafond, niet tegen de onderkant van een hoog dak.
function makeRouter(units, rooms) {
const unitAt = (x, z) => units.find(u => x >= u.rect.x0 - 1 && x <= u.rect.x1 + 1 && z >= u.rect.z0 - 1 && z <= u.rect.z1 + 1) || null;
// Vals plafond (mm boven vloer) van de ruimte op (x,z): undefined = geen ruimte (binnenwand, naad), null = geen vals plafond.
const roomPlenum = (x, z) => { const r = rooms && rooms.roomAt ? rooms.roomAt(x, z) : null; return r ? (r.ceilingActive ? r.ceiling : null) : undefined; };
// Vals plafond (mm boven vloer) van de ruimte op (x,z): undefined = in een wand of naad (geen gebied), null = geen
// vals plafond (ook een gebied zonder functie: niet het plafond van de buurruimte door de wand heen overnemen).
const roomPlenum = (x, z) => {
if (!rooms || !rooms.roomAt) return undefined;
const r = rooms.roomAt(x, z);
if (r) return r.ceilingActive ? r.ceiling : null;
return rooms.regionAt && rooms.regionAt(x, z) ? null : undefined;
};
// Absoluut; in een binnenwand het laagste valse plafond ernaast: de leiding gaat daar door de wand, niet omhoog.
const plenumAt = (x, z) => {
const u = unitAt(x, z);
@@ -192,7 +198,12 @@ window.ElektraModel = (() => {
const hOf = (pt, st) => st !== null ? st : roofCeilAt(pt.x, pt.z);
const mk = (pt, y) => Object.assign({}, p, pt, { y, wall: null, ceil: true, floor: false, plenum: inPlenum(pt.x, pt.z, y) });
const n = Math.max(2, Math.ceil(len / 100));
const S = Array.from({ length: n + 1 }, (_, k) => { const pt = at(k / n), st = plenumOn(pt.x, pt.z); return { t: k / n, pt, st, y: hOf(pt, st) }; });
// Begin/eind op een wandvlak 50 mm de ruimte in meten, net als top(): anders telt de buurruimte achter de wand mee.
const probe = (pt, e) => e.wall && e.wall.n ? { x: pt.x + e.wall.n.x * 50, z: pt.z + e.wall.n.z * 50 } : pt;
const S = Array.from({ length: n + 1 }, (_, k) => {
const pt = at(k / n), pr = k === 0 ? probe(pt, p) : k === n ? probe(pt, q) : pt, st = plenumOn(pr.x, pr.z);
return { t: k / n, pt, st, y: hOf(pt, st) };
});
for (let k = 1; k <= n; k++) {
const A = S[k - 1], B = S[k];
if (!same(A.st, B.st)) {
+12 -17
View File
@@ -159,7 +159,7 @@ const FLOOR_TEMPLATE = `
</div>
</div>
<div class="modal-footer">
<button type="button" id="wall-project-cancel" class="ui-btn">Annuleren</button>
<button type="button" id="wall-project-cancel" class="ui-btn" data-esc>Annuleren</button>
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
</div>
</div>
@@ -620,22 +620,21 @@ function mountFloorEditor(container, callbacks) {
let lastResolvedOpenings = [];
let lastChassisData = { wheels: [], outOfBounds: false };
const toolbar = document.getElementById('floor-toolbar');
toolbar.innerHTML = `<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${UI.tbSep()}${UI.viewSeg('split')}`;
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
const viewGroup = toolbar.querySelector('.tb-view');
ViewLayers.createMenu(viewGroup, {
layers: {}, keys: [],
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
onExtra: (v) => { osbVisible = v.beplating; update3DScene(); }
});
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => {
skyEnv.fitTo(floorGroup3D, 0);
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient: floorAmbient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: floorGroup3D });
};
Skybox.createToggle(viewGroup, (m) => { skyMode = m; applySky(); });
const bar = UI.editorBar(document.getElementById('floor-toolbar'), document.getElementById('floor-app'), {
onResize: () => onWindowResize(),
layers: {
layers: {}, keys: [],
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
onExtra: (v) => { osbVisible = v.beplating; update3DScene(); }
},
onSky: (m) => { skyMode = m; applySky(); }
});
function update3DScene() {
if (floorGroup3D) scene.remove(floorGroup3D);
@@ -682,10 +681,6 @@ function mountFloorEditor(container, callbacks) {
}
window.addEventListener('resize', onWindowResize);
function setView(mode) {
UI.setView(document.getElementById('floor-app'), mode);
requestAnimationFrame(onWindowResize);
}
function getPureState() {
return {
@@ -735,8 +730,8 @@ function mountFloorEditor(container, callbacks) {
},
exportDesign,
triggerImport,
setView,
viewState: () => ({ view: UI.viewOf(document.getElementById('floor-app')) }),
setView: bar.setView,
viewState: bar.viewState,
openProjectProperties,
getStateJson: getPureState,
loadStateJson: applyPureState
+5 -4
View File
@@ -49,19 +49,20 @@ window.HouseIfc = (() => {
};
}
function build(state, layers) {
// rough: buizen en dozen bouwen; de plaatfase verzamelt alleen platen en heeft ze niet nodig.
function build(state, layers, rough) {
const M = materialsSet();
const parts = HouseModel.computeParts(state, {
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
layers: Object.assign(layers, { lampen: false })
layers, elektraRough: rough
});
HouseModel.placeGroups(parts);
return { parts, classify: classifier(M) };
}
function generate(state) {
const frame = build(state, ViewLayers.upTo(1));
const sheet = build(state, ViewLayers.upTo(2));
const frame = build(state, ViewLayers.upTo(1), true);
const sheet = build(state, ViewLayers.upTo(2), false);
const parts = frame.parts;
if (!parts.units.length) throw new Error('geen units in het ontwerp');
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
+13 -2
View File
@@ -1700,7 +1700,7 @@ window.HouseModel = (() => {
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
if (lightingGroup) lightingGroup.visible = shows('lampen');
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge, rooms);
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, layers, o.elektraRough) : null;
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, layers, o.finishedOnly ? false : o.elektraRough) : null;
if (elektraGroup) elektraGroup.visible = shows('elektra');
const posts = PostModel.compute(state.settings.roof.posts, units, groundY);
@@ -1722,7 +1722,18 @@ window.HouseModel = (() => {
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
if (fixturesGroup) fixturesGroup.visible = shows('inrichting');
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup };
const parts = { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup };
if (build && o.finishedOnly) keepFinished(parts, o.wallMaterials);
return parts;
}
// finishedOnly (render-export): alleen wat in de afgewerkte woning te zien is. Naast de buitenste vloerbalken en geen
// elektraleidingen (hierboven): geen isolatie, latten en vochtdoek achter de gevel, geen lichtkegels, valse plafonds altijd.
function keepFinished(parts, wallMaterials) {
if (wallMaterials) allWallPieces(parts).forEach(p => ViewLayers.hideByMaterial(p.group,
[wallMaterials.isolatieMaterial, wallMaterials.battenMaterial, wallMaterials.doekMaterial]));
if (parts.lightingGroup) parts.lightingGroup.traverse(x => { if (x.userData.beam) x.visible = false; });
if (parts.roomsGroup && parts.roomsGroup.userData.ceilings) parts.roomsGroup.userData.ceilings.visible = true;
}
// 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten.
+2 -1
View File
@@ -762,7 +762,8 @@ window.HousePdf = (() => {
function generate(state) {
const parts = HouseModel.computeParts(state, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
elektraRough: false
});
const totals = HouseModel.computeTotals(parts);
const sheets = sheetList(parts);
+60 -84
View File
@@ -41,7 +41,7 @@ const HOUSE_TEMPLATE = `
</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">Annuleren</button>
<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>
@@ -198,6 +198,7 @@ function mountHouseEditor(container, callbacks) {
// 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) {
@@ -262,8 +263,11 @@ function mountHouseEditor(container, callbacks) {
}
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) act.scrollIntoView({ block: 'nearest' });
if (act && key !== navActiveKey) act.scrollIntoView({ block: 'nearest' });
navActiveKey = key;
}
document.getElementById('house-nav-tree').addEventListener('click', (e) => {
@@ -418,23 +422,25 @@ function mountHouseEditor(container, callbacks) {
['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)']
];
toolbar.innerHTML = `<div class="tb-group tb-tools" role="group" aria-label="Gereedschap">${TOOLS.map(([k, label, ic, key, hint]) =>
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>
<div class="tb-spacer"></div>
<div class="tb-group tb-view" id="ho-view-group"></div>
<div class="tb-group tb-section" id="ho-section-group">
<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"')}
${UI.viewSeg(UI.viewOf(document.getElementById('house-overview')))}`;
${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.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
toolbar.addEventListener('click', (e) => {
const b = e.target.closest('[data-tb]');
if (!b) return;
@@ -446,9 +452,6 @@ function mountHouseEditor(container, callbacks) {
else if (a === 'report') toggleReport();
b.blur();
});
const viewGroup = document.getElementById('ho-view-group');
const layerMenu = ViewLayers.createMenu(viewGroup, { layers: view3D.layers, onChange: setLayers });
Skybox.createToggle(viewGroup, (m) => { skyMode = m; applyRenderMode(); });
function syncToolbar() {
const q = (a) => toolbar.querySelector(`[data-tb="${a}"]`);
q('section').classList.toggle('active', view3D.section);
@@ -464,10 +467,10 @@ function mountHouseEditor(container, callbacks) {
function setLayers(l) {
view3D.layers = ViewLayers.normalize(l);
const L = view3D.layers;
if (selection && ((!L.ruimtes && (selection.type === 'room' || selection.type === 'region')) ||
(!L.inrichting && selection.type === 'fixture') || (!L.elektra && selection.type === 'elektra'))) selection = null;
layerMenu.set(L);
scheduleRefresh({ keepProps: true });
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() {
@@ -667,7 +670,7 @@ function mountHouseEditor(container, callbacks) {
tool = t;
drawStart = null;
document.querySelectorAll('[data-ho-tool]').forEach(b => b.classList.toggle('active', b.dataset.hoTool === t));
canvas.style.cursor = t === 'iw' || t === 'fixture' ? 'crosshair' : t === 'room' ? 'cell' : 'default';
canvas.style.cursor = toolCursor();
roomHover = null;
fxHover = null;
renderToolOptions();
@@ -675,6 +678,7 @@ function mountHouseEditor(container, callbacks) {
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.')
@@ -728,13 +732,13 @@ function mountHouseEditor(container, callbacks) {
: ElektraModel.snapToWall(parts.elektra, w.x, w.z);
drawPlan();
});
function onElektraDrop() {
function onElektraDrop(e) {
if (!elDrag) return;
const d = elDrag;
elDrag = null;
canvas.style.cursor = tool === 'iw' ? 'crosshair' : tool === 'room' ? 'cell' : 'default';
canvas.style.cursor = toolCursor();
if (!d.moved || !d.snap) return;
suppressClick = true;
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);
@@ -769,13 +773,13 @@ function mountHouseEditor(container, callbacks) {
if (it) fxDrag.pose = fxPose(it, w.x + fxDrag.dx, w.z + fxDrag.dz, e.altKey);
drawPlan();
});
function onFixtureDrop() {
function onFixtureDrop(e) {
if (!fxDrag) return;
const d = fxDrag;
fxDrag = null;
canvas.style.cursor = 'default';
canvas.style.cursor = toolCursor();
if (!d.moved || !d.pose) return;
suppressClick = true;
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 };
@@ -801,13 +805,13 @@ function mountHouseEditor(container, callbacks) {
if (ch) chDrag.snap = ChimneyModel.snapToFacade(parts.rects, w.x, w.z, ch.w);
drawPlan();
});
window.addEventListener('mouseup', () => {
window.addEventListener('mouseup', (e) => {
if (!chDrag) return;
const d = chDrag;
chDrag = null;
canvas.style.cursor = 'default';
canvas.style.cursor = toolCursor();
if (!d.moved || !d.snap) return;
suppressClick = true;
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);
@@ -887,12 +891,13 @@ function mountHouseEditor(container, callbacks) {
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') {
state.interiorWalls = state.interiorWalls.filter(w => w.id !== selection.id);
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;
@@ -962,9 +967,10 @@ function mountHouseEditor(container, callbacks) {
plan2D.cz = (canvas.height / 2 - (pan.oy + e.clientY - pan.sy)) / view.scale;
drawPlan();
}
function onPanEnd() {
// 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 = 'default'; if (!pan.mid) suppressClick = true; }
if (pan.moved) { canvas.style.cursor = toolCursor(); if (!pan.mid && e.target === canvas) suppressClick = true; }
pan = null;
}
window.addEventListener('mousemove', onPanMove);
@@ -1339,24 +1345,12 @@ function mountHouseEditor(container, callbacks) {
const stucColors = Object.fromEntries(Object.entries(WallModel.STUC_COLORS).map(([k, c]) => [k, c.label]));
const swatchOf = (hex) => `<div class="ins-swatch" style="background:${hex}"></div>`;
function wireButtons(body) {
body.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool)));
body.querySelectorAll('[data-go]').forEach(b => b.addEventListener('click', () => showPart(b.dataset.go)));
body.querySelectorAll('[data-ho-act]').forEach(b => b.addEventListener('click', () => {
const a = b.dataset.hoAct;
if (a === 'add-unit') addUnit();
else if (a === 'project') openProjectProperties();
else if (a === 'save') exportDesign();
else if (a === 'dxf') exportDxf();
else if (a === 'ifc') exportIfc();
else if (a === 'obj') exportObj();
else if (a === 'costs') CostSettings.open();
else if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
else if (a === 'remove-unit') removeUnit(b.dataset.unit);
else if (a === 'remove-iw') {
state.interiorWalls = state.interiorWalls.filter(w => w.id !== Number(b.dataset.iw));
selection = null;
refresh();
}
else if (a === 'remove-iw') removeInteriorWall(Number(b.dataset.iw));
}));
}
@@ -2295,12 +2289,6 @@ function mountHouseEditor(container, callbacks) {
syncToolbar();
requestAnimationFrame(onResize);
}
function setView(mode) {
setWalk(false);
UI.setView(overviewEl, view3D.mode = mode);
requestAnimationFrame(onResize);
}
const esc = UI.esc;
function stackedBars(title, iconName, series, rows, fmtVal, note) {
const max = Math.max(1, ...rows.map(r => r.total));
@@ -2442,28 +2430,24 @@ function mountHouseEditor(container, callbacks) {
parts.units.forEach(u => houseGroup.add(u.roof.group));
if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup);
if (parts.postsGroup) houseGroup.add(parts.postsGroup);
if (parts.chimneysGroup) houseGroup.add(parts.chimneysGroup);
}
// 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);
if (parts.elektraGroup) houseGroup.add(parts.elektraGroup);
if (parts.roomsGroup) {
parts.roomsGroup.userData.ceilings.visible = !section;
parts.roomsGroup.traverse(o => {
if (!o.material || (o.material.clippingPlanes || []).length === clip.length) return;
o.material.clippingPlanes = clip;
o.material.needsUpdate = true;
});
houseGroup.add(parts.roomsGroup);
}
if (parts.fixturesGroup) {
parts.fixturesGroup.traverse(o => {
if (!o.material || (o.material.clippingPlanes || []).length === clip.length) return;
o.material.clippingPlanes = clip;
o.material.needsUpdate = true;
});
houseGroup.add(parts.fixturesGroup);
}
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));
@@ -2777,7 +2761,7 @@ function mountHouseEditor(container, callbacks) {
refresh,
select,
setTool,
setView,
setView: bar.setView,
toggleReport: () => toggleReport(),
toggleSection,
fitView: () => fitPlan(),
@@ -2863,7 +2847,7 @@ function mountHouseEditor(container, callbacks) {
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; geen buizen, dozen, binnenste vloerbalken, latten en isolatie achter de gevel, lichtkegels.
// 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) => {
@@ -2873,13 +2857,9 @@ function mountHouseEditor(container, callbacks) {
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(), elektraRough: false, finishedOnly: true
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true
});
HouseModel.placeGroups(full);
HouseModel.allWallPieces(full).forEach(p => ViewLayers.hideByMaterial(p.group,
[wallMaterials.isolatieMaterial, wallMaterials.battenMaterial, wallMaterials.doekMaterial]));
if (full.lightingGroup) full.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = false; });
if (full.roomsGroup && full.roomsGroup.userData.ceilings) full.roomsGroup.userData.ceilings.visible = true;
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));
@@ -2932,6 +2912,7 @@ function mountHouseEditor(container, callbacks) {
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) {
@@ -2953,12 +2934,7 @@ function mountHouseEditor(container, callbacks) {
exportIfc,
exportObj,
triggerImport,
mirrorHorizontal: forward('mirrorHorizontal'),
setView: forward('setView'),
toggleReport: forward('toggleReport'),
toggleSection: forward('toggleSection'),
fitView: forward('fitView'),
toggleWalk: forward('toggleWalk'),
...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),
+16 -21
View File
@@ -220,7 +220,7 @@ const WALL_TEMPLATE = `
</div>
</div>
<div class="modal-footer">
<button type="button" id="wall-project-cancel" class="ui-btn">Annuleren</button>
<button type="button" id="wall-project-cancel" class="ui-btn" data-esc>Annuleren</button>
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
</div>
</div>
@@ -335,11 +335,6 @@ function mountWallEditor(container, callbacks) {
updateAll();
}
function setView(mode) {
UI.setView(document.getElementById('app-container'), mode);
requestAnimationFrame(onWindowResize);
}
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
let projectPropsSnapshot = null;
@@ -1299,25 +1294,25 @@ function mountWallEditor(container, callbacks) {
let spaanplaatVisible = true;
let buitenVisible = true;
const toolbar = document.getElementById('wall-toolbar');
toolbar.innerHTML = `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>
<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${UI.tbSep()}${UI.viewSeg('split')}`;
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
const viewGroup = toolbar.querySelector('.tb-view');
ViewLayers.createMenu(viewGroup, {
layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'],
onChange: (l) => { layers = l; update3DScene(); },
extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }],
onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); }
});
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => {
skyEnv.fitTo(wallGroup3D, 0);
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
};
Skybox.createToggle(viewGroup, (m) => { skyMode = m; applySky(); });
const toolbar = document.getElementById('wall-toolbar');
const bar = UI.editorBar(toolbar, document.getElementById('app-container'), {
left: `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>`,
onResize: onWindowResize,
layers: {
layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'],
onChange: (l) => { layers = l; update3DScene(); },
extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }],
onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); }
},
onSky: (m) => { skyMode = m; applySky(); }
});
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
let lastWallStats = null;
@@ -1418,8 +1413,8 @@ function mountWallEditor(container, callbacks) {
exportDesign,
triggerImport,
mirrorHorizontal,
setView,
viewState: () => ({ view: UI.viewOf(document.getElementById('app-container')) }),
setView: bar.setView,
viewState: bar.viewState,
openProjectProperties,
getStateJson: getPureState,
loadStateJson: applyPureState