Files
Tectum/js/app.js
T

312 lines
15 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.6';
// 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 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 <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);
});