/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ const APP_VERSION = '0.6.0'; // 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 → kies een objecttype
`; } function updateEditMenuState() { Ribbon.refresh(); } const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function'); const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); }; const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled'; function appRibbonTabs() { const { big, small } = Ribbon; const wireClicks = (body) => body.querySelectorAll('[data-app]').forEach(b => b.addEventListener('click', () => { const a = b.dataset.app; if (a === 'new') openNewModal(); else if (a === 'new-kind') openNew(b.dataset.kind, b.dataset.template); else if (a === 'fullscreen') toggleBrowserFullscreen(); else if (a === 'costs') CostSettings.open(); else if (a === 'about') document.getElementById('about-overlay').classList.add('open'); else editorCall(a); })); return [ { key: 'bestand', label: 'Bestand', order: 0, wire: wireClicks, groups: () => [ { label: 'Document', html: big('Nieuw', 'file-earmark-plus', 'data-app="new" title="Nieuw ontwerp (Ctrl+N)"') + big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) + big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) }, { label: 'Snel nieuw', html: small('Woning Amstel', 'house-heart', 'data-app="new-kind" data-kind="woning" data-template="amstel"') + small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') + small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') + small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') + small('Los: vloer', 'grid-3x3', 'data-app="new-kind" data-kind="vloer"') }, { label: 'Project', html: big('Eigenschappen', 'card-text', `data-app="openProjectProperties" title="Projectgegevens voor de PDF" ${disabledUnless('openProjectProperties')}`) } ] }, { key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [ { label: 'Venster', html: small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') + small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) + small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) }, { label: 'Camera', html: big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) + small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') } ] }, { key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [ { label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) }, { label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') }, { label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') } ] } ]; } function renderTabBar() { const bar = document.getElementById('tab-bar'); bar.innerHTML = tabs.map(tab => `
${tab.name} ×
`).join(''); } 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 = ''; Ribbon.setSource('editor', []); 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)); updateEditMenuState(); 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); updateEditMenuState(); 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(); Ribbon.setSource('editor', []); updateEditMenuState(); renderTabBar(); } } function wireTabBar() { document.getElementById('tab-bar').addEventListener('click', (e) => { const closeBtn = e.target.closest('.tab-close'); if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; } const item = e.target.closest('.tab-item'); 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.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(); } function wireHotkeys() { document.addEventListener('keydown', (e) => { const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName); if (e.ctrlKey && e.key.toLowerCase() === 's') { e.preventDefault(); if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign(); return; } if (e.ctrlKey && e.key.toLowerCase() === 'n') { e.preventDefault(); openNewModal(); return; } if (inField || e.ctrlKey || e.metaKey || e.altKey) return; if (e.key.toLowerCase() === 'f') { toggleBrowserFullscreen(); } else if (e.key.toLowerCase() === 'h') { if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal(); } else if (e.key.toLowerCase() === 't' && e.shiftKey) { if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk(); } else if (e.key.toLowerCase() === 't') { if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D(); } else if (e.key.toLowerCase() === 'r') { if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D(); } }); } 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; }); Ribbon.init(document.getElementById('ribbon')); Ribbon.setSource('app', appRibbonTabs()); 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); });