v0.7.1: UI-overhaul, ribbon vervangen door de indeling van CAD/BIM-pakketten met elke functie op één plek: bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren, projectgegevens, kosten & tijden) · Beeld · Help, de ontwerpen als tabs en de knop Opslaan; links de structuur met inklapbare groepen (onthouden, groep met de selectie klapt vanzelf open); werkbalk boven de viewports met gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, opties van het gereedschap ernaast, Unit toevoegen) en weergave (één Lagen-menu met bouwfase voor 2D en 3D, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D / 3D met 1/2/3); rechts het eigenschappenpaneel (Inspector: selectie met kruimelpad, of de woning met kerncijfers en inklapbare secties Gevel, Dak, Binnen, Verlichting, Elektra, Constructie); statusbalk met hint en meldingen; wand- en vloer/dak-editor in dezelfde indeling (paneel rechts met tab Resultaat, terug met ← Woning of Esc); sneltoetsenoverzicht (?), Ctrl+O, Ctrl+\ panelen verbergen; rapport als masonry; dubbele functies weg (lagenbalk, Beeld-tab en Weergave-filter, export en kosten op twee plekken, Snel nieuw); oude onderdelen gelijkgetrokken (dialoogknoppen, materiaalknoppen, chassis als schakelaar, eenheden); weergavelagen (ViewLayers) i.p.v. bouwfasen, elk los aan/uit; render-export met Blender-script voor de lampen; elektraleidingen boven een vals plafond en haaks binnen de units bij een L/T-vorm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-12 23:16:43 +02:00
co-authored by Claude Opus 5.5
parent adb3c74f85
commit ab0dca9bb5
22 changed files with 1484 additions and 1027 deletions
-62
View File
@@ -1,62 +0,0 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.BuildStages = (() => {
const LIST = [
{ key: 'regelwerk', label: 'Regelwerk', hint: 'Vloer-, wand- en dakbalken' },
{ key: 'elektra', label: 'Elektra', hint: 'Installatiebuizen door wanden en plafond, dozen en meterkast' },
{ key: 'beplating', label: 'Beplating', hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
{ key: 'doek', label: 'Vochtdoek', hint: 'Vochtdoek op de buitenzijde' },
{ key: 'latwerk', label: 'Latwerk', hint: 'Kruislat en trasraamplaat' },
{ key: 'gevel', label: 'Gevel & dak', hint: 'Rabat, steen, kozijnen, dakbedekking, sandwichpanelen' },
{ key: 'afwerking', label: 'Afgewerkt', hint: 'Dakrand, verlichting, vloerafwerking, verf binnen' }
];
const FINAL = LIST.length - 1;
const INDEX = Object.fromEntries(LIST.map((s, i) => [s.key, i]));
const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL;
const shows = (stage, key) => clamp(stage) >= INDEX[key];
function createBar(pane, stage, onChange) {
const el = document.createElement('div');
el.className = 'stage-bar';
el.innerHTML = `
<div class="stage-row">
<span class="stage-title"><i class="bi bi-layers"></i>Bouwfase</span>
<div class="stage-track">
<input type="range" class="stage-range" min="0" max="${FINAL}" step="1" value="${clamp(stage)}" aria-label="Bouwfase">
<div class="stage-ticks">${LIST.map((s, i) => `<span data-i="${i}" title="${s.label}: ${s.hint}">${i + 1}</span>`).join('')}</div>
</div>
<span class="stage-name"></span>
</div>
<div class="stage-extra"></div>`;
pane.appendChild(el);
const range = el.querySelector('.stage-range'), name = el.querySelector('.stage-name');
const sync = () => {
const i = clamp(Number(range.value));
name.textContent = LIST[i].label;
name.title = LIST[i].hint;
el.querySelectorAll('.stage-ticks span').forEach(t => t.classList.toggle('on', Number(t.dataset.i) <= i));
};
range.addEventListener('input', () => { sync(); onChange(clamp(Number(range.value))); });
el.querySelector('.stage-ticks').addEventListener('click', (e) => {
const t = e.target.closest('[data-i]');
if (!t) return;
range.value = t.dataset.i;
sync();
onChange(clamp(Number(range.value)));
});
// T, R, ... zijn sneltoetsen van de weergave, niet van de schuif.
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
// Na slepen focus loslaten, zodat de pijltoetsen weer naar de weergave (lopen) gaan.
el.addEventListener('pointerup', (e) => { if (e.target.matches('input[type=range]')) setTimeout(() => e.target.blur(), 0); });
sync();
return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } };
}
return { LIST, FINAL, INDEX, clamp, shows, createBar };
})();
+63
View File
@@ -0,0 +1,63 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Eigenschappenpaneel rechts: eigenschappen van de selectie, of van de woning als er niets geselecteerd is.
// Een sectie = { key, label, icon, groups() → [{ label, html }], wire(el) }; elke sectie krijgt een eigen element,
// 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 kv = (label, value, unit) =>
`<div class="ins-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
const PREF_KEY = 'tectum.inspector.open';
let open;
try { open = new Set(JSON.parse(localStorage.getItem(PREF_KEY) || 'null') || ['gevel']); } catch (e) { open = new Set(['gevel']); }
const saveOpen = () => { try { localStorage.setItem(PREF_KEY, JSON.stringify([...open])); } catch (e) { } };
// Een groepskop gelijk aan de sectienaam (bijv. Elektra › Elektra) vervalt.
const groupsHtml = (groups, secLabel) => groups.filter(Boolean).map(g =>
`<div class="ins-group${g.cls ? ' ' + g.cls : ''}">${g.label && g.label.toLowerCase() !== String(secLabel || '').toLowerCase() ? `<h3>${g.label}</h3>` : ''}<div class="ins-group-body">${g.html}</div></div>`).join('');
// view = { crumbs: [{ label, attrs }], title, icon, sub, top: html, sections: [...], collapsible, onBack }
function render(el, view) {
const scrollKey = view.title + '|' + (view.sub || '');
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>` : ''}
</div>
${view.top ? `<div class="ins-top">${view.top}</div>` : ''}
<div class="ins-sections"></div>`;
const host = el.querySelector('.ins-sections');
view.sections.filter(Boolean).forEach(sec => {
const node = document.createElement('section');
const collapsible = view.collapsible;
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>`;
host.appendChild(node);
if (collapsible) node.querySelector('.ins-section-head').addEventListener('click', () => {
const now = !node.classList.contains('open');
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;
}
return { big, small, kv, render };
})();
+61
View File
@@ -0,0 +1,61 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Menubalk: menu = { label, items: [{ label, icon, keys, run, enabled(), checked() } | { sep: true } | { head }] }.
// Beschikbaarheid wordt bij het openen bepaald; is een menu open, dan opent hover het buurmenu.
window.Menubar = (() => {
let root = null, menus = [], openIdx = -1;
function itemHtml(it, mi, ii) {
if (it.sep) return '<div class="mb-sep"></div>';
if (it.head) return `<div class="mb-head">${it.head}</div>`;
const off = it.enabled && !it.enabled();
const on = it.checked && it.checked();
return `<button type="button" class="mb-item${on ? ' checked' : ''}" data-mi="${mi}" data-ii="${ii}"${off ? ' disabled' : ''}>
<span class="mb-ico">${on ? '<i class="bi bi-check2"></i>' : it.icon ? `<i class="bi bi-${it.icon}"></i>` : ''}</span>
<span class="mb-label">${it.label}</span><span class="mb-keys">${it.keys || ''}</span></button>`;
}
function close() {
if (openIdx < 0) return false;
openIdx = -1;
root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open'));
root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; });
return true;
}
function openMenu(i) {
close();
openIdx = i;
const wrap = root.querySelectorAll('.mb-menu')[i];
wrap.classList.add('open');
wrap.querySelector('.mb-drop').innerHTML = menus[i].items.map((it, ii) => itemHtml(it, i, ii)).join('');
}
function init(el, list) {
root = el;
menus = list;
el.innerHTML = menus.map((m, i) => `<div class="mb-menu"><button type="button" class="mb-top" data-menu="${i}">${m.label}</button><div class="mb-drop" role="menu"></div></div>`).join('');
el.addEventListener('click', (e) => {
e.stopPropagation();
const top = e.target.closest('[data-menu]');
if (top) { const i = Number(top.dataset.menu); if (openIdx === i) close(); else openMenu(i); return; }
const item = e.target.closest('.mb-item');
if (!item || item.disabled) return;
const it = menus[Number(item.dataset.mi)].items[Number(item.dataset.ii)];
close();
it.run();
});
el.addEventListener('mouseover', (e) => {
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 };
})();
+66 -1
View File
@@ -129,5 +129,70 @@ window.ObjWriter = (() => {
};
}
return { build };
// OBJ kent geen lampen: een Blender-script dat de OBJ importeert (als die er nog niet is) en de lampen als echte
// spotlights plaatst. sources: LightingModel.lightSources (m, Y omhoog); Blender is Z omhoog: (x, -z, y).
const BLENDER_WATT = { wandlamp: 10, dakrandspot: 8, binnenspot: 15 };
function blenderLights(sources, opts) {
const o = opts || {}, origin = o.origin || { x: 0, y: 0, z: 0 }, name = o.name || 'woning';
const c = new THREE.Color(o.color !== undefined ? o.color : 0xffd9a0);
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
const t = (v) => `(${v.map(f4).join(', ')})`;
const rows = sources.map(s => ` (${JSON.stringify(s.kind)}, ${t([s.x - origin.x, -(s.z - origin.z), s.y - origin.y])}, ${t([s.dir.x, -s.dir.z, s.dir.y])}, ` +
`${f3(Math.min(Math.PI, s.angle * 2))}, ${f3(s.penumbra)}, ${BLENDER_WATT[s.kind] || 10}),\n`).join('');
return `# Tectum${o.app ? ' ' + o.app : ''}: lampen voor ${name}.obj
# Blender: Scripting > Open > dit bestand > Run Script. Importeert ${name}.obj uit dezelfde map
# (IMPORTEER = False als je de OBJ al zelf hebt geïmporteerd) en zet elke lamp als spotlight in de
# collectie "Tectum lampen"; opnieuw uitvoeren vervangt de lampen.
# HELDERHEID schaalt alle lampen; vermogens in watt per soort staan in de lijst (soort, plek, richting,
# kegel, zachte rand, watt).
import bpy, os
from mathutils import Vector
IMPORTEER = True
HELDERHEID = 1.0
KLEUR = (${f3(lin(c.r))}, ${f3(lin(c.g))}, ${f3(lin(c.b))})
OBJ = "${name}.obj"
LAMPEN = [
${rows}]
def map_van_script():
for area in bpy.context.screen.areas if bpy.context.screen else []:
if area.type == 'TEXT_EDITOR' and area.spaces.active.text and area.spaces.active.text.filepath:
return os.path.dirname(bpy.path.abspath(area.spaces.active.text.filepath))
return os.path.dirname(bpy.data.filepath) if bpy.data.filepath else ""
pad = os.path.join(map_van_script(), OBJ)
if IMPORTEER and not any(ob.get("tectum") == OBJ for ob in bpy.data.objects):
if not os.path.exists(pad):
raise Exception(f"{OBJ} niet gevonden naast dit script; zet IMPORTEER = False na een eigen import")
if hasattr(bpy.ops.wm, "obj_import"):
bpy.ops.wm.obj_import(filepath=pad)
else:
bpy.ops.import_scene.obj(filepath=pad)
for ob in bpy.context.selected_objects:
ob["tectum"] = OBJ
oud = bpy.data.collections.get("Tectum lampen")
if oud:
for ob in list(oud.objects):
bpy.data.objects.remove(ob, do_unlink=True)
bpy.data.collections.remove(oud)
col = bpy.data.collections.new("Tectum lampen")
bpy.context.scene.collection.children.link(col)
for i, (soort, plek, richting, kegel, rand, watt) in enumerate(LAMPEN):
licht = bpy.data.lights.new(f"{soort}_{i + 1}", 'SPOT')
licht.energy = watt * HELDERHEID
licht.color = KLEUR
licht.spot_size = kegel
licht.spot_blend = rand
licht.shadow_soft_size = 0.03
ob = bpy.data.objects.new(licht.name, licht)
ob.location = plek
ob.rotation_euler = Vector(richting).to_track_quat('-Z', 'Y').to_euler()
col.objects.link(ob)
print(f"Tectum: {len(LAMPEN)} lampen geplaatst")
`;
}
return { build, blenderLights };
})();
-116
View File
@@ -1,116 +0,0 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.Ribbon = (() => {
const sources = {};
const SOURCE_ORDER = ['app', 'editor'];
let root = null, tabsEl = null, bodyEl = null;
let activeKey = null, lastPlainKey = null;
let auto = true;
let collapsed = false, notified = null;
const listeners = [];
const PREF_KEY = 'tectum.ribbon.collapsed';
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
const big = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
const small = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
const kv = (label, value, unit) =>
`<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
function allTabs() {
const byKey = new Map();
SOURCE_ORDER.concat(Object.keys(sources).filter(n => !SOURCE_ORDER.includes(n))).forEach(name => {
(sources[name] || []).forEach(t => {
if (!byKey.has(t.key)) byKey.set(t.key, { key: t.key, label: t.label, order: t.order || 0, contextual: !!t.contextual, icon: t.icon, parts: [] });
const m = byKey.get(t.key);
if (t.order !== undefined && name !== 'app') m.order = t.order;
if (t.label && name !== 'app') m.label = t.label;
if (t.contextual) m.contextual = true;
m.parts.push(t);
});
});
return Array.from(byKey.values()).sort((a, b) => (a.contextual - b.contextual) || (a.order - b.order));
}
function renderTabs(list) {
tabsEl.innerHTML = list.map(t =>
`<button type="button" class="rb-tab${t.key === activeKey ? ' active' : ''}${t.contextual ? ' rb-ctx' : ''}" data-rb-tab="${t.key}">${t.contextual && t.icon ? icon(t.icon) : ''}<span>${t.label}</span></button>`).join('');
}
function renderBody(list) {
const tab = list.find(t => t.key === activeKey);
if (!tab) { bodyEl.innerHTML = ''; return; }
const groups = [];
tab.parts.forEach(p => (p.groups ? p.groups() : []).forEach(g => g && groups.push(g)));
bodyEl.innerHTML = groups.map(g =>
`<section class="rb-group${g.cls ? ' ' + g.cls : ''}"><div class="rb-group-body">${g.html}</div><div class="rb-group-label">${g.label}</div></section>`).join('') +
`<button type="button" class="rb-collapse" title="Ribbon inklappen (dubbelklik op een tabblad)">${icon('chevron-up')}</button>`;
tab.parts.forEach(p => p.wire && p.wire(bodyEl));
}
function render() {
if (!root) return;
const list = allTabs();
if (!list.some(t => t.key === activeKey) || (auto && !list.some(t => t.key === activeKey && t.contextual))) {
const pick = [lastPlainKey, 'start'].find(k => list.some(t => t.key === k));
activeKey = pick || (list.find(t => !t.contextual) || {}).key || null;
}
renderTabs(list);
renderBody(list);
root.classList.toggle('rb-collapsed', collapsed);
if (notified !== activeKey) { notified = activeKey; listeners.forEach(fn => fn(activeKey)); }
}
function activate(key) {
const list = allTabs();
const tab = list.find(t => t.key === key);
if (!tab) return;
activeKey = key;
auto = false;
if (!tab.contextual) lastPlainKey = key;
if (collapsed) { collapsed = false; savePref(); }
render();
tab.parts.forEach(p => p.onShow && p.onShow());
}
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { } }
function init(el) {
root = el;
try { collapsed = localStorage.getItem(PREF_KEY) === '1'; } catch (e) { collapsed = false; }
tabsEl = el.querySelector('.rb-tabs');
bodyEl = el.querySelector('.rb-body');
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest('[data-rb-tab]');
if (!b) return;
if (b.dataset.rbTab === activeKey && collapsed) { collapsed = false; savePref(); render(); return; }
activate(b.dataset.rbTab);
});
tabsEl.addEventListener('dblclick', (e) => {
if (!e.target.closest('[data-rb-tab]')) return;
collapsed = !collapsed; savePref(); render();
});
bodyEl.addEventListener('click', (e) => {
if (e.target.closest('.rb-collapse')) { collapsed = true; savePref(); render(); }
});
render();
}
function setSource(name, tabs, opts) {
sources[name] = tabs || [];
const ctx = (tabs || []).find(t => t.contextual);
if (opts && opts.focus && ctx) { activeKey = ctx.key; if (collapsed) { collapsed = false; savePref(); } }
render();
if (opts && opts.focus && ctx && ctx.onShow) ctx.onShow();
}
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv };
})();
+9 -15
View File
@@ -161,32 +161,26 @@ window.Skybox = (() => {
function createToggle(toolbar, onChange) {
let m = getMode();
const wrap = document.createElement('div');
wrap.className = 'view-filter';
wrap.innerHTML = `<button type="button" class="btn-3d-toggle vf-btn" title="Omgeving"></button>
<div class="vf-menu">${MODES.map(k => `<div class="vf-item vf-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</div>`).join('')}</div>`;
wrap.className = 'tb-pop-wrap';
wrap.innerHTML = `<button type="button" class="tb-btn" title="Omgeving in 3D"></button>
<div class="tb-pop">${MODES.map(k => `<button type="button" class="pop-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</button>`).join('')}</div>`;
toolbar.appendChild(wrap);
const btn = wrap.querySelector('.vf-btn'), menu = wrap.querySelector('.vf-menu');
const btn = wrap.querySelector('.tb-btn'), menu = wrap.querySelector('.tb-pop');
const close = UI.popover(btn, menu);
const sync = () => {
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i>${LABELS[m][1]}<i class="bi bi-chevron-down"></i>`;
menu.querySelectorAll('.vf-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i><span>${LABELS[m][1]}</span><i class="bi bi-chevron-down tb-caret"></i>`;
menu.querySelectorAll('.pop-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
};
btn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('open'); });
menu.addEventListener('click', (e) => {
e.stopPropagation();
const opt = e.target.closest('.vf-option');
const opt = e.target.closest('.pop-option');
if (!opt) return;
menu.classList.remove('open');
close();
if (opt.dataset.mode === m) return;
m = opt.dataset.mode;
remember(m);
sync();
onChange(m);
});
const closeOnOutside = () => {
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
menu.classList.remove('open');
};
document.addEventListener('click', closeOnOutside);
sync();
return wrap;
}
+50 -10
View File
@@ -128,16 +128,56 @@ window.UI = (() => {
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
}
function cycle3D(el) {
el.classList.remove('fullscreen-2d');
if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d');
else if (!el.classList.contains('render-only')) el.classList.add('render-only');
else el.classList.remove('fullscreen-3d', 'render-only');
}
function toggle2D(el) {
el.classList.remove('fullscreen-3d', 'render-only');
el.classList.toggle('fullscreen-2d');
// Weergave van een editor: klassen fullscreen-2d/-3d op het editor-element (de looptocht bewaart en herstelt className).
const VIEWS = [
{ key: 'split', icon: 'layout-split', label: '2D + 3D', key1: '1' },
{ key: '2d', icon: 'square', label: 'Alleen 2D', key1: '2' },
{ key: '3d', icon: 'box', label: 'Alleen 3D', key1: '3' }
];
const viewOf = (el) => el.classList.contains('fullscreen-3d') ? '3d' : el.classList.contains('fullscreen-2d') ? '2d' : 'split';
function setView(el, mode) {
el.classList.toggle('fullscreen-3d', mode === '3d');
el.classList.toggle('fullscreen-2d', mode === '2d');
el.classList.remove('render-only');
el.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode));
}
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, cycle3D, toggle2D };
const tb = (label, iconName, attrs, cls) =>
`<button type="button" class="tb-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
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.
let openPop = null;
function popover(btn, panel) {
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); if (openPop === close) openPop = null; };
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; }
});
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) {
if (!el) return;
gap = gap === undefined ? 12 : gap;
const cards = el._cards || (el._cards = [...el.children]);
const n = Math.max(1, Math.floor((el.clientWidth + gap) / (minW + gap)));
if (el._cols === n) return;
el._cols = n;
el.classList.add('ui-masonry');
const cols = Array.from({ length: n }, () => { const c = document.createElement('div'); c.className = 'ui-masonry-col'; return c; });
el.replaceChildren(...cols);
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 };
})();
-43
View File
@@ -1,43 +0,0 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
function createViewFilter(toolbar, items, onChange) {
const state = {};
items.forEach(it => { state[it.key] = it.checked !== false; });
const wrap = document.createElement('div');
wrap.className = 'view-filter';
wrap.innerHTML = `
<button type="button" class="btn-3d-toggle vf-btn"><i class="bi bi-eye"></i>Weergave<i class="bi bi-chevron-down"></i></button>
<div class="vf-menu">
${items.map(it => `<label class="vf-item"><input type="checkbox" data-key="${it.key}" ${state[it.key] ? 'checked' : ''}> ${it.label}</label>`).join('')}
</div>`;
toolbar.insertBefore(wrap, toolbar.firstChild);
const menu = wrap.querySelector('.vf-menu');
const btn = wrap.querySelector('.vf-btn');
btn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('open');
btn.classList.toggle('active', menu.classList.contains('open'));
});
menu.addEventListener('click', (e) => e.stopPropagation());
menu.addEventListener('change', (e) => {
const key = e.target.dataset.key;
if (!key) return;
state[key] = e.target.checked;
onChange(Object.assign({}, state));
});
const closeOnOutside = () => {
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
menu.classList.remove('open');
btn.classList.remove('active');
};
document.addEventListener('click', closeOnOutside);
return { state: () => Object.assign({}, state) };
}
+108
View File
@@ -0,0 +1,108 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.ViewLayers = (() => {
// phase: bouwvolgorde voor de snelkeuze "Fase" (en IFC); lagen zonder phase blijven daarbij aan.
const LIST = [
{ key: 'regelwerk', label: 'Regelwerk', icon: 'grid-3x3', phase: 0, hint: 'Vloer-, wand- en dakbalken, stalen nokbalk' },
{ key: 'elektra', label: 'Elektra', icon: 'lightning-charge', phase: 1, hint: 'Meterkast, buizen, dozen, schakelaars en wcd\'s' },
{ key: 'beplating', label: 'Beplating', icon: 'square', phase: 2, hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
{ key: 'doek', label: 'Vochtdoek', icon: 'droplet', phase: 3, hint: 'Vochtdoek op de buitenzijde' },
{ key: 'latwerk', label: 'Latwerk', icon: 'distribute-vertical', phase: 4, hint: 'Kruislat en trasraamplaat' },
{ key: 'gevel', label: 'Gevelbekleding', icon: 'bricks', phase: 5, hint: 'Rabat, steenstrips, stuc, schoorsteen, sandwichpanelen' },
{ key: 'kozijnen', label: 'Kozijnen', icon: 'window', phase: 5, hint: 'Kozijnen, deuren en glas' },
{ key: 'dakbedekking', label: 'Dakbedekking', icon: 'house-up', phase: 5, hint: 'Dakleer of riet' },
{ key: 'afwerking', label: 'Afwerking', icon: 'brush', phase: 6, hint: 'Vloerafwerking, verf, plafond, dakrand, dagkanten, logobord' },
{ 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: 'maten', label: 'Maten', icon: 'rulers', hint: 'Maatvoering in de plattegrond' }
];
const KEYS = LIST.map(l => l.key);
const PHASES = ['Regelwerk', 'Elektra', 'Beplating', 'Vochtdoek', 'Latwerk', 'Gevel & dak', 'Afgewerkt'];
const all = () => Object.fromEntries(KEYS.map(k => [k, true]));
const normalize = (layers) => Object.fromEntries(KEYS.map(k => [k, !layers || layers[k] !== false]));
const shows = (layers, key) => !layers || layers[key] !== false;
// Alles tot en met een bouwfase (0..6), zoals de vroegere bouwfase-schuif.
const upTo = (phase) => Object.fromEntries(LIST.map(l => [l.key, l.phase === undefined || l.phase <= phase]));
const phaseOf = (layers) => PHASES.findIndex((_, p) => LIST.every(l => l.phase === undefined || (l.phase <= p) === shows(layers, l.key)));
// Verbergt meshes met een van de materialen (ook afgeleide met userData.base), bijv. het regelwerk.
function hideByMaterial(group, materials) {
if (!group) return;
const set = new Set(materials.filter(Boolean));
group.traverse(o => {
if (!o.isMesh) return;
const m0 = Array.isArray(o.material) ? o.material[0] : o.material;
if (m0 && (set.has(m0) || set.has(m0.userData && m0.userData.base))) o.visible = false;
});
}
const GROUPS = [
['Casco', ['regelwerk', 'elektra', 'beplating', 'doek', 'latwerk']],
['Buiten', ['gevel', 'kozijnen', 'dakbedekking', 'dak']],
['Binnen', ['afwerking', 'lampen', 'inrichting', 'ruimtes']],
['Plattegrond', ['maten']]
];
// De enige plek voor zichtbaarheid: knop "Lagen" in de werkbalk met een uitklappaneel.
// keys beperkt de lagen (wand-editor); extras zijn editor-eigen schakelaars, bijv. beplating binnen/buiten.
function createMenu(toolbar, o) {
const items = o.keys ? LIST.filter(l => o.keys.includes(l.key)) : LIST;
const extras = o.extras || [];
const extra = Object.fromEntries(extras.map(x => [x.key, x.checked !== false]));
const check = (attr, key, label, hint, ic) =>
`<label class="pop-check" title="${hint || ''}"><input type="checkbox" ${attr}="${key}">${ic ? `<i class="bi bi-${ic}"></i>` : ''}<span>${label}</span></label>`;
const wrap = document.createElement('div');
wrap.className = 'tb-pop-wrap';
wrap.innerHTML = `<button type="button" class="tb-btn" title="Zichtbaarheid van lagen in 2D en 3D"><i class="bi bi-layers"></i><span>Lagen</span><i class="bi bi-chevron-down tb-caret"></i></button>
<div class="tb-pop pop-layers">
${items.length ? `<div class="pop-row">
<select class="pop-phase" title="Alles tot en met een bouwfase; Dak en Maten blijven zoals ze staan">
<option value="">Bouwfase…</option>${PHASES.map((p, i) => `<option value="${i}">${i + 1}. ${p}</option>`).join('')}
</select>
<button type="button" class="ui-btn" data-all>Alles</button>
</div>
<div class="pop-groups">${GROUPS.map(([label, keys]) => {
const g = items.filter(l => keys.includes(l.key));
return g.length ? `<div class="pop-group"><div class="pop-head">${label}</div>${g.map(l => check('data-layer', l.key, l.label, l.hint, l.icon)).join('')}</div>` : '';
}).join('')}</div>` : ''}
${extras.length ? `<div class="pop-group"><div class="pop-head">${o.extrasLabel || 'Weergave'}</div>${extras.map(x => check('data-extra', x.key, x.label, x.hint)).join('')}</div>` : ''}
</div>`;
toolbar.appendChild(wrap);
const btn = wrap.querySelector('.tb-btn'), panel = wrap.querySelector('.tb-pop');
UI.popover(btn, panel);
let cur = normalize(o.layers);
const sel = panel.querySelector('.pop-phase');
const sync = () => {
panel.querySelectorAll('[data-layer]').forEach(c => { c.checked = cur[c.dataset.layer]; });
panel.querySelectorAll('[data-extra]').forEach(c => { c.checked = extra[c.dataset.extra]; });
if (sel) { const p = phaseOf(cur); sel.value = p >= 0 ? String(p) : ''; }
const hidden = items.some(l => !cur[l.key]) || extras.some(x => !extra[x.key]);
btn.classList.toggle('flag', hidden);
};
const emit = () => { sync(); o.onChange && o.onChange(Object.assign({}, cur)); };
panel.addEventListener('change', (e) => {
const t = e.target;
if (t.dataset.layer) { cur = Object.assign({}, cur, { [t.dataset.layer]: t.checked }); emit(); }
else if (t.dataset.extra) { extra[t.dataset.extra] = t.checked; sync(); o.onExtra && o.onExtra(Object.assign({}, extra)); }
else if (t === sel && sel.value !== '') {
const keep = Object.fromEntries(LIST.filter(l => l.phase === undefined).map(l => [l.key, cur[l.key]]));
cur = Object.assign(upTo(Number(sel.value)), keep);
emit();
}
});
const all = panel.querySelector('[data-all]');
if (all) all.addEventListener('click', () => { cur = ViewLayers.all(); emit(); });
sync();
return { el: wrap, set: (l) => { cur = normalize(l); sync(); }, toggle: (key) => { cur = Object.assign({}, cur, { [key]: !cur[key] }); emit(); } };
}
return { LIST, KEYS, PHASES, GROUPS, all, normalize, shows, upTo, phaseOf, hideByMaterial, createMenu };
})();