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

311 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const APP_VERSION = '0.7.4';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = [];
let activeTabId = null;
let tabIdCounter = 1;
let kindCounters = {};
let currentEditor = null;
function editorModuleFor(kind) {
if (kind === 'muur') return window.WallEditor;
if (kind === 'vloer') return window.FloorEditor;
if (kind === 'woning') return window.HouseEditor;
return null;
}
function defaultNameFor(kind) {
kindCounters[kind] = (kindCounters[kind] || 0) + 1;
return `${kind} ${String(kindCounters[kind]).padStart(2, '0')}`;
}
function setWorkspaceEmpty() {
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
<div>Geen ontwerp geopend.</div>
<div style="font-size:12px;">Bestand &rsaquo; Nieuw (Ctrl+N)</div>
</div>`;
}
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function' && (!currentEditor.can || currentEditor.can(fn)));
const editorCall = (fn, ...args) => { if (editorHas(fn)) return currentEditor[fn](...args); };
const viewState = () => editorCall('viewState') || {};
// Menu met de sneltoets per item; KEY_HELP hieronder is het overzicht (Help › Sneltoetsen). Editor-acties werken alleen als de editor ze kent.
const MENUS = [
{ label: 'Bestand', items: [
{ label: 'Nieuw…', icon: 'file-earmark-plus', keys: 'Ctrl+N', run: openNewModal },
{ label: 'Openen…', icon: 'folder2-open', keys: 'Ctrl+O', run: () => editorCall('triggerImport'), enabled: () => editorHas('triggerImport') },
{ label: 'Opslaan (JSON + PDF)', icon: 'save', keys: 'Ctrl+S', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') },
{ sep: true }, { head: 'Exporteren' },
{ label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') },
{ label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') },
{ label: 'Render (OBJ)', icon: 'badge-3d', run: () => editorCall('exportObj'), enabled: () => editorHas('exportObj') },
{ sep: true },
{ label: 'Projectgegevens…', icon: 'card-text', run: () => editorCall('openProjectProperties'), enabled: () => editorHas('openProjectProperties') },
{ label: 'Kosten & tijden…', icon: 'calculator', run: () => CostSettings.open() },
{ sep: true },
{ label: 'Ontwerp sluiten', icon: 'x-lg', run: () => activeTabId !== null && closeTab(activeTabId), enabled: () => activeTabId !== null }
] },
{ label: 'Beeld', items: [
...UI.VIEWS.map(v => ({ label: v.label, icon: v.icon, keys: v.key1, run: () => editorCall('setView', v.key),
enabled: () => editorHas('setView'), checked: () => viewState().view === v.key })),
{ label: 'Rapport', icon: 'clipboard-data', keys: '4', run: () => editorCall('toggleReport'), enabled: () => editorHas('toggleReport'), checked: () => !!viewState().report },
{ sep: true },
{ label: 'Doorsnede', icon: 'scissors', keys: 'X', run: () => editorCall('toggleSection'), enabled: () => editorHas('toggleSection'), checked: () => !!viewState().section },
{ label: 'Plattegrond passend', icon: 'aspect-ratio', keys: 'F', run: () => editorCall('fitView'), enabled: () => editorHas('fitView') },
{ label: 'Lopen', icon: 'person-walking', keys: 'L', run: () => editorCall('toggleWalk'), enabled: () => editorHas('toggleWalk'), checked: () => !!viewState().walk },
{ sep: true },
{ label: 'Panelen verbergen', icon: 'layout-sidebar-inset', keys: 'Ctrl+\\', run: togglePanels, checked: () => document.body.classList.contains('panels-hidden') },
{ label: 'Volledig scherm', icon: 'fullscreen', keys: 'F11', run: toggleBrowserFullscreen, checked: () => !!document.fullscreenElement }
] },
{ label: 'Help', items: [
{ label: 'Sneltoetsen en bediening', icon: 'keyboard', keys: '?', run: openKeys },
{ label: 'Over Tectum', icon: 'info-circle', run: () => document.getElementById('about-overlay').classList.add('open') }
] }
];
const KEY_HELP = [
['Bestand', [['Ctrl+N', 'Nieuw ontwerp'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF)']]],
['Gereedschap (woning)', [['V', 'Selecteren'], ['W', 'Binnenwand tekenen'], ['R', 'Ruimte vullen'], ['I', 'Inrichting plaatsen'],
['Esc', 'Gereedschap stoppen, daarna selectie opheffen'], ['Del', 'Selectie verwijderen'], ['D / Shift+D', 'Inrichting draaien'], ['Dubbelklik', 'Onderdeel bewerken (wand, vloer)']]],
['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'],
['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]],
['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]],
['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts slepen', 'Verschuiven'], ['Scrollwiel', 'Zoomen']], true],
['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true],
['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]],
['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true]
];
function openKeys() {
// Muisacties als tekst, toetsen als <kbd>.
document.getElementById('keys-body').innerHTML = KEY_HELP.map(([title, rows, mouse]) =>
`<section><h3>${title}</h3>${rows.map(([k, d]) => `<div class="keys-row"><span>${mouse ? `<span class="keys-mouse">${k}</span>`
: k.split(' / ').map(x => `<kbd>${x}</kbd>`).join(' / ')}</span><span>${d}</span></div>`).join('')}</section>`).join('');
document.getElementById('keys-overlay').classList.add('open');
}
function togglePanels() {
document.body.classList.toggle('panels-hidden');
window.dispatchEvent(new Event('resize'));
}
function renderTabBar() {
const bar = document.getElementById('doc-tabs');
bar.innerHTML = tabs.map(tab => `
<div class="doc-tab ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}" role="tab" aria-selected="${tab.id === activeTabId}" title="${UI.esc(tab.name)}">
<span class="doc-name">${UI.esc(tab.name)}</span>
<button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">&times;</button>
</div>
`).join('');
document.getElementById('top-save').disabled = !editorHas('exportDesign');
}
function onTabSaved(tab, filenameWithExt) {
const base = filenameWithExt.replace(/\.json$/i, '');
tab.name = base;
tab.savedName = base;
renderTabBar();
}
function mountEditorForTab(tab) {
const ws = document.getElementById('workspace');
ws.innerHTML = '';
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
currentEditor.loadStateJson(tab.stateJson);
}
}
function switchToTab(tabId) {
if (activeTabId === tabId) return;
const oldTab = tabs.find(t => t.id === activeTabId);
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
oldTab.stateJson = currentEditor.getStateJson();
}
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
currentEditor = null;
activeTabId = tabId;
mountEditorForTab(tabs.find(t => t.id === tabId));
renderTabBar();
}
function openNew(kind, template) {
if (!editorModuleFor(kind)) return;
const oldTab = tabs.find(t => t.id === activeTabId);
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
oldTab.stateJson = currentEditor.getStateJson();
}
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
currentEditor = null;
const tab = { id: tabIdCounter++, kind, template: template || null, name: defaultNameFor(kind), savedName: null, stateJson: null };
tabs.push(tab);
activeTabId = tab.id;
mountEditorForTab(tab);
renderTabBar();
}
function isTabDirty(tab) {
const module = editorModuleFor(tab.kind);
if (!module || typeof module.getDefaultState !== 'function') return false;
const state = (tab.id === activeTabId && currentEditor && typeof currentEditor.getStateJson === 'function')
? currentEditor.getStateJson()
: tab.stateJson;
if (!state) return false;
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
}
function closeTab(tabId) {
const tab = tabs.find(t => t.id === tabId);
if (!tab) return;
if (tab.id !== activeTabId) {
if (!isTabDirty(tab)) {
tabs = tabs.filter(t => t.id !== tabId);
renderTabBar();
return;
}
switchToTab(tabId);
}
if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
const saved = currentEditor.exportDesign();
if (!saved) return;
}
tabs = tabs.filter(t => t.id !== tabId);
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
currentEditor = null;
activeTabId = null;
if (tabs.length > 0) {
switchToTab(tabs[tabs.length - 1].id);
} else {
setWorkspaceEmpty();
renderTabBar();
}
}
function wireTabBar() {
document.getElementById('doc-tabs').addEventListener('click', (e) => {
const closeBtn = e.target.closest('.doc-close');
if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; }
const item = e.target.closest('.doc-tab');
if (item) switchToTab(parseInt(item.dataset.tabId, 10));
});
}
function renderLicenseHtml(md) {
const esc = (t) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return md.trim().split(/\n\s*\n/).map(block => {
const b = block.trim();
if (b.startsWith('## ')) return `<h4>${esc(b.slice(3))}</h4>`;
if (b.startsWith('# ')) return `<h3>${esc(b.slice(2))}</h3>`;
return `<p>${esc(b.replace(/\s*\n\s*/g, ' '))}</p>`;
}).join('');
}
function wireAbout() {
const overlay = document.getElementById('about-overlay');
document.getElementById('about-license').innerHTML = renderLicenseHtml(window.TECTUM_LICENSE_MD || '');
document.getElementById('about-close').addEventListener('click', () => overlay.classList.remove('open'));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
}
function wireNewModal() {
const overlay = document.getElementById('new-overlay');
overlay.querySelector('.new-options').insertAdjacentHTML('afterbegin', Object.entries(HouseModel.MODELS).map(([key, m]) =>
`<button class="new-option" data-kind="woning" data-template="${key}"><i class="bi bi-${m.icon}"></i><span>${m.label}</span></button>`).join(''));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => {
opt.addEventListener('click', () => {
openNew(opt.dataset.kind, opt.dataset.template);
overlay.classList.remove('open');
});
});
}
function openNewModal() {
document.getElementById('new-overlay').classList.add('open');
}
function toggleBrowserFullscreen() {
if (document.fullscreenElement) document.exitFullscreen();
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(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();
}
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(); }
return;
}
if (e.key === 'Escape' && closeOverlays()) { e.stopImmediatePropagation(); return; }
if (inField || e.altKey || viewState().walk && !['l', 'escape'].includes(k)) return;
const view = UI.VIEWS.find(v => v.key1 === e.key);
if (view) editorCall('setView', view.key);
else if (e.key === '4') editorCall('toggleReport');
else if (k === 'x') editorCall('toggleSection');
else if (k === 'f') editorCall('fitView');
else if (k === 'l') editorCall('toggleWalk');
else if (k === 'h' && e.shiftKey) editorCall('mirrorHorizontal');
else if (e.key === '?') openKeys();
}, true);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
Menubar.init(document.getElementById('menubar'), MENUS);
document.getElementById('top-save').addEventListener('click', () => editorCall('exportDesign'));
document.getElementById('keys-overlay').addEventListener('click', (e) => {
if (e.target.id === 'keys-overlay' || e.target.closest('[data-close]')) document.getElementById('keys-overlay').classList.remove('open');
});
wireAbout();
wireNewModal();
wireHotkeys();
wireTabBar();
if (location.protocol === 'file:') {
const note = document.createElement('div');
note.className = 'ui-warn file-protocol-warning';
note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>';
document.body.appendChild(note);
}
openNew('woning', 'amstel');
setTimeout(() => {
const splash = document.getElementById('splash-screen');
if (splash) splash.remove();
}, 4300);
});