diff --git a/index.html b/index.html index 180d42e..0765718 100644 --- a/index.html +++ b/index.html @@ -50,7 +50,7 @@
diff --git a/js/app.js b/js/app.js index 4fb0c58..62c8bea 100644 --- a/js/app.js +++ b/js/app.js @@ -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(); } diff --git a/js/core/inspector.js b/js/core/inspector.js index 755fa41..56312a4 100644 --- a/js/core/inspector.js +++ b/js/core/inspector.js @@ -9,11 +9,9 @@ // zodat wire() alleen de eigen knoppen koppelt. window.Inspector = (() => { - const icon = (n) => ``; - const big = (label, iconName, attrs, cls) => - ``; - const small = (label, iconName, attrs, cls) => - ``; + const { icon, esc } = UI; + const big = UI.btnOf('ins-btn ins-big'); + const small = UI.btnOf('ins-btn'); const kv = (label, value, unit) => `
${label}${value}${unit ? `${unit}` : ''}
`; @@ -32,9 +30,9 @@ window.Inspector = (() => { const keepScroll = el._insKey === scrollKey ? el.scrollTop : 0; el._insKey = scrollKey; el.innerHTML = `
- ${view.crumbs && view.crumbs.length ? `
${view.crumbs.map(c => ``).join(icon('chevron-right'))}${icon('chevron-right')}
` : ''} -

${view.icon ? icon(view.icon) : ''}${view.title}

- ${view.sub ? `
${view.sub}
` : ''} + ${view.crumbs && view.crumbs.length ? `
${view.crumbs.map(c => ``).join(icon('chevron-right'))}${icon('chevron-right')}
` : ''} +

${view.icon ? icon(view.icon) : ''}${esc(view.title)}

+ ${view.sub ? `
${esc(view.sub)}
` : ''}
${view.top ? `
${view.top}
` : ''}
`; @@ -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 ? `` : '') + - `
${groupsHtml(sec.groups ? sec.groups() : [], sec.label)}
`; + '
'; 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; } diff --git a/js/core/menubar.js b/js/core/menubar.js index 29edde9..c637a78 100644 --- a/js/core/menubar.js +++ b/js/core/menubar.js @@ -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 }; diff --git a/js/core/ui-kit.js b/js/core/ui-kit.js index 407b2f3..6a1e2fb 100644 --- a/js/core/ui-kit.js +++ b/js/core/ui-kit.js @@ -80,8 +80,10 @@ window.UI = (() => { const info = (text) => `
${icon('info-circle')}${text}
`; const warn = (text) => `
${icon('exclamation-triangle')}${text}
`; const grid = (...cols) => `
${cols.map(c => `
${c}
`).join('')}
`; - const button = (label, iconName, attrs, cls) => - ``; + // Eén knopbouwer; base = de knopsoort (ui-btn, tb-btn, ins-btn …), cls = extra klassen. + const btnOf = (base) => (label, iconName, attrs, cls) => + ``; + const button = btnOf('ui-btn'); const actions = (...btns) => `
${btns.join('')}
`; 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) => - ``; + const tb = btnOf('tb-btn'); const tbSep = () => ''; const viewSeg = (mode) => `
${VIEWS.map(v => tb('', v.icon, `data-view="${v.key}" title="${v.label} (${v.key1})"`, v.key === mode ? 'active' : '')).join('')}
`; - // 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 || ''}
${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 }; })(); diff --git a/js/core/view-layers.js b/js/core/view-layers.js index a626c23..479eb60 100644 --- a/js/core/view-layers.js +++ b/js/core/view-layers.js @@ -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 }; diff --git a/js/cost/cost-settings.js b/js/cost/cost-settings.js index 24e5fe8..d182d71 100644 --- a/js/cost/cost-settings.js +++ b/js/cost/cost-settings.js @@ -29,7 +29,7 @@ window.CostSettings = (() => { - + diff --git a/js/elektra/elektra-model.js b/js/elektra/elektra-model.js index 63c819f..aead6ce 100644 --- a/js/elektra/elektra-model.js +++ b/js/elektra/elektra-model.js @@ -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)) { diff --git a/js/floor/floor.js b/js/floor/floor.js index 7eed262..2d8352a 100644 --- a/js/floor/floor.js +++ b/js/floor/floor.js @@ -159,7 +159,7 @@ const FLOOR_TEMPLATE = ` @@ -620,22 +620,21 @@ function mountFloorEditor(container, callbacks) { let lastResolvedOpenings = []; let lastChassisData = { wheels: [], outOfBounds: false }; - const toolbar = document.getElementById('floor-toolbar'); - toolbar.innerHTML = `
${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 diff --git a/js/house/house-ifc.js b/js/house/house-ifc.js index ead4e42..3978e2e 100644 --- a/js/house/house-ifc.js +++ b/js/house/house-ifc.js @@ -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)); diff --git a/js/house/house-model.js b/js/house/house-model.js index 8720e38..bd8ffbc 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -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. diff --git a/js/house/house-pdf.js b/js/house/house-pdf.js index 7a13143..893d0b5 100644 --- a/js/house/house-pdf.js +++ b/js/house/house-pdf.js @@ -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); diff --git a/js/house/house.js b/js/house/house.js index 693b537..b37a783 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -41,7 +41,7 @@ const HOUSE_TEMPLATE = `
Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.
@@ -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 = `
${TOOLS.map(([k, label, ic, key, hint]) => - UI.tb(label, ic, `data-ho-tool="${k}" title="${label} (${key}): ${hint}"`)).join('')}
- ${UI.tbSep()}${UI.tb('Unit', 'plus-square', 'data-tb="add-unit" title="Unit toevoegen aan de geselecteerde (of laatste) unit"')} -
-
-
-
- ${UI.tb('Doorsnede', 'scissors', 'data-tb="section" title="Horizontale doorsnede (X)"')} - -
- ${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')))}`; + const bar = UI.editorBar(toolbar, document.getElementById('house-overview'), { + left: `
${TOOLS.map(([k, label, ic, key, hint]) => + UI.tb(label, ic, `data-ho-tool="${k}" title="${label} (${key}): ${hint}"`)).join('')}
+ ${UI.tbSep()}${UI.tb('Unit', 'plus-square', 'data-tb="add-unit" title="Unit toevoegen aan de geselecteerde (of laatste) unit"')} +
`, + after: `
+ ${UI.tb('Doorsnede', 'scissors', 'data-tb="section" title="Horizontale doorsnede (X)"')} + +
+ ${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.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,11 +891,12 @@ 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); - selection = null; - refresh(); - } + 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; @@ -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) => `
`; 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), diff --git a/js/wall/wall.js b/js/wall/wall.js index 80f8139..1871d8c 100644 --- a/js/wall/wall.js +++ b/js/wall/wall.js @@ -220,7 +220,7 @@ const WALL_TEMPLATE = ` @@ -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 = `
${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}
-
${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: `
${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}
`, + 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