/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ const APP_VERSION = '0.7.9'; // 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') && !activeCrm() }, { label: 'Opslaan in CRM (nieuwe versie)', icon: 'cloud-upload', keys: 'Ctrl+S', run: saveToCrm, enabled: () => !!activeCrm() }, { label: 'Downloaden (JSON + PDF)', icon: 'download', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') && !!activeCrm() }, { 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 (ook uit de CRM)'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF; woning uit de CRM: nieuwe versie in de CRM)']]], ['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 of middelste knop slepen', 'Zijwaarts verschuiven'], ['Shift/Ctrl + slepen', 'Zijwaarts verschuiven'], ['Pijltjestoetsen (muis boven de 3D)', 'Verschuiven (Shift = grotere stap)'], ['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(''); const save = document.getElementById('top-save'); save.disabled = !editorHas('exportDesign'); save.querySelector('span').textContent = activeCrm() ? 'Opslaan in CRM' : 'Opslaan'; save.title = activeCrm() ? 'Opslaan als nieuwe versie in de CRM (Ctrl+S)' : 'Opslaan als JSON + PDF (Ctrl+S)'; } // ---- CRM (js/crm.js): woning uit de CRM in een tab (tab.crm = { id, versie, titel }), terug als nieuwe versie const activeCrm = () => { const t = tabs.find(x => x.id === activeTabId); return t && t.crm ? t : null; }; const crmName = (crm) => `${crm.titel} · v${crm.versie}`; function saveActive() { if (activeCrm()) saveToCrm(); else editorCall('exportDesign'); } async function openCrmWoning(id) { try { const { woning, state } = await TectumCrm.load(id); const open = tabs.find(t => t.crm && t.crm.id === woning.id); if (open && !window.confirm(`${woning.titel} is al geopend. Nog een keer openen (versie ${woning.versie} uit de CRM)?`)) { switchToTab(open.id); return; } const crm = { id: woning.id, versie: woning.versie, titel: woning.titel }; openNew('woning', null, { stateJson: state, name: crmName(crm), crm }); TectumCrm.toast(`${woning.titel} geladen uit de CRM (versie ${woning.versie}${woning.contact ? ', ' + woning.contact.naam : ''}).`, 'ok'); } catch (err) { TectumCrm.toast('Laden uit de CRM mislukt: ' + err.message, 'err'); } } async function saveToCrm() { const tab = activeCrm(); if (!tab || !currentEditor) return; const note = window.prompt(`Opslaan als nieuwe versie van ${tab.crm.titel} in de CRM.\nWat is er veranderd?`, ''); if (note === null) return; const state = currentEditor.getStateJson(); try { const versie = await TectumCrm.saveVersion(tab.crm.id, state, tab.crm.versie, note.trim()); if (versie === null) return; tab.crm.versie = versie; tab.baseJson = JSON.stringify(state); tab.name = crmName(tab.crm); renderTabBar(); TectumCrm.toast(`Opgeslagen in de CRM als versie ${versie}.`, 'ok'); } catch (err) { TectumCrm.toast('Opslaan in de CRM mislukt: ' + err.message, 'err'); } } 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, opts) { 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: (opts && opts.name) || defaultNameFor(kind), savedName: null, stateJson: (opts && opts.stateJson) || null, crm: (opts && opts.crm) || null }; tabs.push(tab); activeTabId = tab.id; mountEditorForTab(tab); // geladen ontwerp (CRM): wijzigingen tellen vanaf hier, niet vanaf het standaardmodel if (tab.stateJson && currentEditor && typeof currentEditor.getStateJson === 'function') tab.baseJson = JSON.stringify(currentEditor.getStateJson()); 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; if (tab.baseJson) return JSON.stringify(state) !== tab.baseJson; 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 (tab.crm && isTabDirty(tab)) { if (!window.confirm(`De wijzigingen aan ${tab.crm.titel} staan nog niet in de CRM (Opslaan in CRM). Toch sluiten?`)) return; } else 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'); }); }); newCrmPicker = TectumCrm.mountPicker(overlay.querySelector('.new-crm'), (id) => { overlay.classList.remove('open'); openCrmWoning(id); }); } let newCrmPicker = null; function openNewModal() { document.getElementById('new-overlay').classList.add('open'); if (newCrmPicker) newCrmPicker.refresh(); } 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: saveActive, 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', saveActive); 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); } // ?woning=: die woning uit de CRM openen (knop "Bewerken in Tectum" in de CRM), anders het standaardmodel const crmId = Number(new URLSearchParams(location.search).get('woning')); if (crmId) openCrmWoning(crmId); else openNew('woning', 'amstel'); setTimeout(() => { const splash = document.getElementById('splash-screen'); if (splash) splash.remove(); }, 4300); });