commit c690e72d138c923684b0559a38d15282851a79e7 Author: Machiel Date: Wed Sep 30 11:38:54 2026 +0200 Tectum v0.4.0 — mijlpaal gevelopbouw Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units, woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen. Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cb53ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Klant-/referentiemateriaal en screenshots niet in de repo +examples/ +*.png +!assets/** +# Lokale exports +*.pdf +.DS_Store diff --git a/LICENTIE.md b/LICENTIE.md new file mode 100644 index 0000000..a320ec2 --- /dev/null +++ b/LICENTIE.md @@ -0,0 +1,65 @@ +# Licentie en auteursrecht + +Copyright © 2026 Machiel Heinen. Alle rechten voorbehouden. + +## 1. Rechthebbende + +De software Tectum, inclusief broncode, objectcode, ontwerp, rekenlogica, +documentatie, afbeeldingen en alle bijbehorende bestanden (hierna: "de Software"), is +auteursrechtelijk beschermd. Alle auteursrechten en overige intellectuele-eigendomsrechten +op de Software berusten uitsluitend bij Machiel Heinen, maker van de Software (hierna: +"de Rechthebbende"). + +De Software is ontwikkeld in opdracht van Van Emous Mantelzorgwoningen, gevestigd te +Putten (KvK 91908671). Deze opdracht houdt geen overdracht van rechten in; gebruik door +Van Emous Mantelzorgwoningen vindt plaats op basis van toestemming van de Rechthebbende. + +## 2. Gebruik uitsluitend met toestemming + +Zonder voorafgaande, uitdrukkelijke en schriftelijke toestemming van de Rechthebbende +is het niet toegestaan de Software geheel of gedeeltelijk te verveelvoudigen, openbaar te +maken, te wijzigen, te bewerken, te vertalen, te decompileren, te verspreiden, te verhuren, +in sublicentie te geven of op enige andere wijze te gebruiken, behoudens voor zover de wet +dit dwingend toestaat. + +## 3. Aard van de toestemming + +Toestemming wordt uitsluitend schriftelijk verleend door de Rechthebbende. Tenzij +schriftelijk anders overeengekomen, is toestemming niet-exclusief, niet-overdraagbaar, +niet sublicentieerbaar, beperkt tot het daarin omschreven doel en de daarin omschreven +duur, en kan zij bij overtreding van de voorwaarden met onmiddellijke ingang worden +ingetrokken. Toestemming houdt nimmer een overdracht van rechten in. + +## 4. Aanpassingen en afgeleide werken + +Alle rechten op aanpassingen, uitbreidingen en afgeleide werken van de Software, ongeacht +door wie deze zijn gemaakt of in wiens opdracht, berusten bij de Rechthebbende. + +## 5. Geen garantie + +De Software wordt ter beschikking gesteld "zoals deze is" (as is), zonder enige garantie, +uitdrukkelijk of stilzwijgend, waaronder begrepen garanties omtrent werking, +foutloosheid, geschiktheid voor een bepaald doel, nauwkeurigheid van berekeningen, +maatvoering of prijzen, en het niet inbreuk maken op rechten van derden. Uitkomsten van +de Software zijn indicatief en vervangen geen constructieve berekening of controle door +een deskundige. + +## 6. Aansprakelijkheid + +Voor zover wettelijk toegestaan, is de Rechthebbende niet aansprakelijk voor enige +directe of indirecte schade voortvloeiend uit of verband houdend met het gebruik van, of +het niet kunnen gebruiken van, de Software, behoudens opzet of bewuste roekeloosheid van +de Rechthebbende. + +## 7. Vermeldingen + +Het is niet toegestaan copyright-, licentie- of eigendomsvermeldingen in of op de +Software te verwijderen, te wijzigen of onleesbaar te maken. + +## 8. Toepasselijk recht en bevoegde rechter + +Op deze licentie en op alle toestemmingen die op grond daarvan worden verleend is +Nederlands recht van toepassing. Geschillen worden uitsluitend voorgelegd aan de +bevoegde rechter van de Rechtbank Gelderland. + +Contact en aanvragen voor toestemming: machiel@start-it.nl diff --git a/README.md b/README.md new file mode 100644 index 0000000..d35c318 --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +# Tectum — configurator Van Emous Mantelzorgwoningen (intern) + +Losse, meerdere-bestanden-opzet van de eerdere single-file wand-tool, nu met +een menubalk om tussen objecttypen (muur/vloer) te schakelen. + +Copyright © 2026 Machiel Heinen. Alle rechten +voorbehouden; gebruik uitsluitend met schriftelijke toestemming. Zie +[LICENTIE.md](LICENTIE.md). Elk bronbestand begint met deze copyright-kop. + +## Draaien + +**Optie 1 — direct openen (geen server nodig):** +Dubbelklik `index.html`. Alles werkt met gewone ` + + + + +
+ +
Tectum — Versie 0.0.0
+
+ +
+ + + + + + +
+ + +
+
+ +
+
+ +
+

Tectum

+
v0.0.0
+
+

Configurator voor mantelzorgwoningen: woningen opgebouwd uit units (vloer, wanden, dak), met binnenwanden, dakrand en buitenverlichting, 3D-weergave en complete productietekeningen (PDF).

+
+ +
+
+ +
+
+

Nieuw

+
+ + + + + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..003f33c --- /dev/null +++ b/js/app.js @@ -0,0 +1,299 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +const APP_VERSION = '0.4.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() { + const has = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function'); + document.getElementById('menu-opslaan-als').classList.toggle('disabled', !has('exportDesign')); + document.getElementById('menu-importeren').classList.toggle('disabled', !has('triggerImport')); + document.getElementById('menu-mirror-h').classList.toggle('disabled', !has('mirrorHorizontal')); + document.getElementById('menu-3d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen3D')); + document.getElementById('menu-2d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen2D')); + document.getElementById('menu-project-props').classList.toggle('disabled', !has('openProjectProperties')); +} + +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 = ''; + 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)) { closeAllMenus(); return; } // 'dak' nog niet beschikbaar + + 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(); + closeAllMenus(); +} + +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(); + 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 closeAllMenus() { + document.querySelectorAll('.menu-dropdown, .submenu').forEach(el => el.classList.remove('open')); + document.querySelectorAll('.menu-btn').forEach(el => el.classList.remove('open')); +} + +function wireTopLevelMenu(btnId, dropdownId) { + const btn = document.getElementById(btnId); + const dropdown = document.getElementById(dropdownId); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const willOpen = !dropdown.classList.contains('open'); + closeAllMenus(); + if (willOpen) { dropdown.classList.add('open'); btn.classList.add('open'); } + }); +} + +function wireMenu() { + wireTopLevelMenu('menu-bestand-btn', 'menu-bestand-dropdown'); + wireTopLevelMenu('menu-bewerken-btn', 'menu-bewerken-dropdown'); + wireTopLevelMenu('menu-beeld-btn', 'menu-beeld-dropdown'); + + document.getElementById('menu-nieuw-item').addEventListener('click', (e) => e.stopPropagation()); + document.querySelectorAll('.menu-nieuw-option').forEach(opt => { + opt.addEventListener('click', () => openNew(opt.dataset.kind, opt.dataset.template)); + }); + + document.getElementById('menu-opslaan-als').addEventListener('click', () => { + if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign(); + closeAllMenus(); + }); + document.getElementById('menu-importeren').addEventListener('click', () => { + if (currentEditor && typeof currentEditor.triggerImport === 'function') currentEditor.triggerImport(); + closeAllMenus(); + }); + document.getElementById('menu-mirror-h').addEventListener('click', () => { + if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal(); + closeAllMenus(); + }); + document.getElementById('menu-fullscreen').addEventListener('click', () => { + toggleBrowserFullscreen(); + closeAllMenus(); + }); + document.getElementById('menu-3d-fullscreen').addEventListener('click', () => { + if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D(); + closeAllMenus(); + }); + document.getElementById('menu-2d-fullscreen').addEventListener('click', () => { + if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D(); + closeAllMenus(); + }); + document.getElementById('menu-project-props').addEventListener('click', () => { + if (currentEditor && typeof currentEditor.openProjectProperties === 'function') currentEditor.openProjectProperties(); + closeAllMenus(); + }); + + document.addEventListener('click', closeAllMenus); +} + +// Minimale markdown-weergave voor de licentietekst (koppen + alinea's). +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('btn-about').addEventListener('click', (e) => { + e.stopPropagation(); + overlay.classList.add('open'); + }); + 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() { + closeAllMenus(); + 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') { + 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; }); + wireMenu(); + wireAbout(); + wireNewModal(); + wireHotkeys(); + wireTabBar(); + openNew('woning', 'L'); + + setTimeout(() => { + const splash = document.getElementById('splash-screen'); + if (splash) splash.remove(); + }, 4300); +}); diff --git a/js/core/camera-memory.js b/js/core/camera-memory.js new file mode 100644 index 0000000..a0d378d --- /dev/null +++ b/js/core/camera-memory.js @@ -0,0 +1,35 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// core/camera-memory.js — onthoudt de 3D-camerastand (positie + draaipunt) +// per weergave, zodat wisselen tussen onderdelen/tabbladen of een her-mount +// na een wijziging niet terugspringt naar de standaardcamera. +// Sleutels komen van de aanroeper (bijv. "tab3:wall:A:voor"). Alleen in het +// geheugen van deze sessie. +// Plain script (geen ES-module). +// ============================================================ + +window.CameraMemory = (() => { + const store = new Map(); + + function save(key, camera, controls) { + if (!key) return; + store.set(key, { position: camera.position.toArray(), target: controls.target.toArray() }); + } + + // true als er een opgeslagen stand was (en die is toegepast). + function restore(key, camera, controls) { + const m = key && store.get(key); + if (!m) return false; + camera.position.fromArray(m.position); + controls.target.fromArray(m.target); + controls.update(); + return true; + } + + return { save, restore }; +})(); diff --git a/js/core/canvas-labels.js b/js/core/canvas-labels.js new file mode 100644 index 0000000..4d7255f --- /dev/null +++ b/js/core/canvas-labels.js @@ -0,0 +1,41 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// core/canvas-labels.js — gedeelde stijl voor maat-labels in de 2D-canvassen +// (blauwe maatlijn + "X: 6000mm"-tekst, zoals het wand-vooraanzicht). Gebruikt +// door wand, vloer en unit, zodat alle labels er hetzelfde uitzien. +// Plain script (geen ES-module). +// ============================================================ + +const CANVAS_LABEL_COLOR = '#4da6ff'; + +// Tekent een maatlijn van (x1,y1) naar (x2,y2) plus tekst op (textX,textY). +// Een verticale lijn (x1 === x2) krijgt de tekst 90° gedraaid. +function drawCanvasDim(ctx, x1, y1, x2, y2, text, textX, textY) { + ctx.save(); + ctx.strokeStyle = CANVAS_LABEL_COLOR; + ctx.fillStyle = CANVAS_LABEL_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + + if (Math.abs(x1 - x2) < 0.5 && Math.abs(y1 - y2) >= 0.5) { + ctx.translate(textX, textY); + ctx.rotate(-Math.PI / 2); + ctx.fillText(text, 0, 0); + } else { + ctx.fillText(text, textX, textY); + } + ctx.restore(); +} diff --git a/js/core/geometry.js b/js/core/geometry.js new file mode 100644 index 0000000..8442909 --- /dev/null +++ b/js/core/geometry.js @@ -0,0 +1,346 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// core/geometry.js — gedeelde, pure math. Geen DOM, geen THREE-afhankelijkheid +// behalve shapeAreaWithHoles (die werkt met elk object dat .getPoints()/.holes +// heeft, dus ook met THREE.Shape). Plain