/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ const APP_VERSION = '0.7.3'; // 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 = `
Geen ontwerp geopend.
Bestand › Nieuw (Ctrl+N)
`; } 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 . document.getElementById('keys-body').innerHTML = KEY_HELP.map(([title, rows, mouse]) => `

${title}

${rows.map(([k, d]) => `
${mouse ? `${k}` : k.split(' / ').map(x => `${x}`).join(' / ')}${d}
`).join('')}
`).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 => ` `).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, '&').replace(//g, '>'); return md.trim().split(/\n\s*\n/).map(block => { const b = block.trim(); if (b.startsWith('## ')) return `

${esc(b.slice(3))}

`; if (b.startsWith('# ')) return `

${esc(b.slice(2))}

`; return `

${esc(b.replace(/\s*\n\s*/g, ' '))}

`; }).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]) => ``).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 = 'Tectum is geopend als bestand. Start het via een webserver: ./start.sh of python3 -m http.server, en open http://localhost:8000. De omgeving (Dag/Nacht) werkt anders niet.'; document.body.appendChild(note); } openNew('woning', 'amstel'); setTimeout(() => { const splash = document.getElementById('splash-screen'); if (splash) splash.remove(); }, 4300); });