3 Commits
9 changed files with 396 additions and 39 deletions
+16
View File
@@ -46,6 +46,7 @@ js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
js/house/ HouseModel (woning) + woning-PDF + CAD-export (house-dxf.js) + BIM-export (house-ifc.js) + woning-editor
js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
js/app.js menubalk, versienummer, actieve editor
js/crm.js koppeling met de Van Emous CRM (alleen in de Docker-stack, zie "CRM")
light.html light-versie (modelpagina's), los te openen: light.html?model=amstel
js/light/ LightModel (keuzes op een model toepassen, indelingscontrole, kerncijfers), LightPdf (klant-PDF) + light-UI
css/light.css stijl light-versie, alles onder .tectum-light
@@ -410,6 +411,21 @@ texturen, kleurstalen en `kosten/*.csv` laden via `tectumUrl()` (`js/core/geomet
Per ontwerp wordt de GLB één keer gemaakt; `el.tectumLight.glb()` geeft `{ blob, url, stats }` (op
`light.html` de testknop GLB).
## CRM (woningen van leads en klanten)
Alleen in de Docker-stack (`../docker`), waar Tectum op `tectum.<domein>` achter het wachtwoord staat. Caddy zet
`api/crm/*` daar door naar de CRM (`/api/tectum/*`) met de sleutel `TECTUM_CRM_KEY`; de browser ziet die nooit.
- **Nieuw** toont onder de standaardmodellen de woningen uit de CRM (zoeken op woning, naam, relatie of plaats);
kiezen opent de nieuwste versie in een eigen tab (wolkje in de tab, naam "woning · vN").
- **`?woning=<id>`** opent die woning direct (de knop "Bewerken in Tectum" bij een woning in de CRM).
- **Opslaan in CRM** (bovenbalk, Bestand, Ctrl+S in een CRM-tab) vraagt wat er veranderd is en zet het ontwerp als
nieuwe versie bij de woning in de CRM. Staat er intussen een nieuwere versie (iemand anders), dan vraagt Tectum
eerst of het toch moet. **Downloaden (JSON + PDF)** doet in een CRM-tab wat Opslaan anders doet.
- Sluiten van een CRM-tab met wijzigingen die nog niet in de CRM staan vraagt om bevestiging.
Zonder stack (`start.sh`) bestaat `api/crm/` niet en is van de CRM niets te zien.
## Kosten en prijzen (CSV)
Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die
+17
View File
@@ -345,6 +345,23 @@ html, body { height: 100%; }
.new-option span { font-size: 13px; }
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
.new-option.disabled { opacity: .4; cursor: not-allowed; }
.new-crm { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-soft); max-width: 640px; }
.new-crm h3 { margin: 0 0 10px; font-size: 13px; color: var(--primary); }
.new-crm-q { width: 100%; box-sizing: border-box; font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; }
.new-crm-list { list-style: none; margin: 8px 0 0; padding: 0; max-height: 260px; overflow-y: auto; }
.new-crm-item { display: flex; gap: 10px; align-items: center; width: 100%; text-align: left; background: none; border: 0; border-radius: 6px; padding: 6px 8px; cursor: pointer; color: var(--text); font: inherit; }
.new-crm-item:hover, .new-crm-item:focus-visible { background: var(--primary-soft); color: var(--primary); }
.new-crm-item .bi { font-size: 18px; }
.new-crm-item strong { display: block; font-size: 13px; }
.new-crm-item small { display: block; font-size: 12px; color: var(--text-muted); }
.new-crm-empty { font-size: 12px; color: var(--text-muted); padding: 6px 8px; }
.doc-tab .bi-cloud { margin-right: 4px; color: var(--primary); }
#crm-toast { position: fixed; left: 50%; bottom: 24px; transform: translate(-50%, 20px); opacity: 0; pointer-events: none; z-index: 900;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; font-size: 13px;
box-shadow: 0 12px 30px rgba(33, 33, 33, .2); transition: opacity .2s, transform .2s; max-width: 90vw; }
#crm-toast.open { opacity: 1; transform: translate(-50%, 0); }
#crm-toast.ok { border-color: var(--ok); background: var(--ok-soft); }
#crm-toast.err { border-color: var(--danger); background: var(--danger-soft); }
.hn-title { font-size: 12px; font-weight: bold; color: var(--primary); text-transform: uppercase; letter-spacing: .8px; padding: 2px 6px 6px; }
#house-nav-tree { display: flex; flex-direction: column; gap: 2px; }
+2
View File
@@ -76,6 +76,7 @@
<span>Los: vloer</span>
</button>
</div>
<div class="new-crm" hidden></div>
</div>
</div>
@@ -124,6 +125,7 @@
<script src="js/house/house-dxf.js"></script>
<script src="js/house/house-ifc.js"></script>
<script src="js/house/house.js"></script>
<script src="js/crm.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+73 -11
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const APP_VERSION = '0.7.8';
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 = [];
@@ -41,7 +41,9 @@ 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') },
{ 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') },
@@ -71,7 +73,7 @@ const MENUS = [
];
const KEY_HELP = [
['Bestand', [['Ctrl+N', 'Nieuw ontwerp'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF)']]],
['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'],
@@ -101,11 +103,57 @@ 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>
${tab.crm ? '<i class="bi bi-cloud" title="Woning uit de CRM"></i>' : ''}<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');
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) {
@@ -138,7 +186,7 @@ function switchToTab(tabId) {
renderTabBar();
}
function openNew(kind, template) {
function openNew(kind, template, opts) {
if (!editorModuleFor(kind)) return;
const oldTab = tabs.find(t => t.id === activeTabId);
@@ -148,10 +196,13 @@ function openNew(kind, template) {
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 };
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();
}
@@ -162,6 +213,7 @@ function isTabDirty(tab) {
? 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));
}
@@ -178,7 +230,9 @@ function closeTab(tabId) {
switchToTab(tabId);
}
if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
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;
}
@@ -233,10 +287,16 @@ function wireNewModal() {
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() {
@@ -267,7 +327,7 @@ function wireHotkeys() {
return;
}
if (e.ctrlKey || e.metaKey) {
const act = { s: () => editorCall('exportDesign'), n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
const act = { s: saveActive, n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
return;
}
@@ -288,7 +348,7 @@ 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('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');
});
@@ -302,7 +362,9 @@ document.addEventListener('DOMContentLoaded', () => {
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');
// ?woning=<id>: 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');
+43 -1
View File
@@ -34,10 +34,14 @@ window.GlbWriter = (() => {
} catch (e) { return null; }
}
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters. Geeft { blob, stats }.
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; clipBelow: alles onder deze hoogte (m, na
// de verschuiving) valt weg, driehoeken die de snede kruisen worden afgesneden (AR zet de onderkant van het model op de
// grond: met clipBelow 0 op het maaiveld valt die samen met de oorsprong). Geeft { blob, stats }.
function build(objects, opts) {
const o = opts || {};
const origin = o.origin || { x: 0, y: 0, z: 0 };
const clip = typeof o.clipBelow === 'number', clipY = o.clipBelow, EPS = 1e-6;
const originV = new THREE.Vector3(origin.x, origin.y, origin.z);
const buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
const images = new Map(), samplers = new Map(), textures = new Map();
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
@@ -115,6 +119,25 @@ window.GlbWriter = (() => {
}
return (remap[ci] = b.n++);
};
// Afgesneden driehoeken: losse hoekpunten (geïnterpoleerd), niet gedeeld via de remap.
const raw = (ci) => {
const p = new THREE.Vector3().fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).sub(originV);
const n = new THREE.Vector3().fromBufferAttribute(nrm, ci).applyMatrix3(nm);
if (n.lengthSq() < 1e-12) n.copy(face); else n.normalize();
const t = new THREE.Vector2();
if (b.map && uvA) t.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix);
return { p, n, t };
};
const lerp = (A, B, f) => ({
p: new THREE.Vector3().lerpVectors(A.p, B.p, f), n: new THREE.Vector3().lerpVectors(A.n, B.n, f).normalize(),
t: new THREE.Vector2().lerpVectors(A.t, B.t, f)
});
const push = (q) => {
b.pos.push(q.p.x, q.p.y, q.p.z);
b.nrm.push(q.n.x, q.n.y, q.n.z);
if (b.map) b.uv.push(q.t.x, b.map.flipY ? 1 - q.t.y : q.t.y);
return b.n++;
};
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
for (let k = gr.start; k + 2 < end; k += 3) {
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
@@ -126,6 +149,25 @@ window.GlbWriter = (() => {
const len = face.length();
if (len < 2e-7) continue;
face.multiplyScalar(flip ? -1 / len : 1 / len);
if (clip) {
const ys = s.map(ci => v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).y - origin.y);
const inside = ys.map(y => y >= clipY - EPS), nIn = inside.filter(Boolean).length;
if (!nIn) continue;
if (nIn < 3) {
const q = s.map(raw), poly = [];
for (let j = 0; j < 3; j++) {
const k2 = (j + 1) % 3;
if (inside[j]) poly.push(q[j]);
if (inside[j] !== inside[k2]) poly.push(lerp(q[j], q[k2], (clipY - ys[j]) / (ys[k2] - ys[j])));
}
const c = poly.map(push);
for (let j = 1; j + 1 < c.length; j++) {
if (flip) b.idx.push(c[0], c[j + 1], c[j]); else b.idx.push(c[0], c[j], c[j + 1]);
triCount++;
}
continue;
}
}
const c = s.map(add);
if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
triCount++;
+102
View File
@@ -0,0 +1,102 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Koppeling met de Van Emous CRM (alleen in de Docker-stack): woningen van leads en klanten laden (Nieuw › Uit de
// CRM, of ?woning=<id> in de URL) en een gewijzigde woning als nieuwe versie terugzetten (Opslaan in CRM).
// De API staat op api/crm/ naast deze pagina: Caddy stuurt dat na het Tectum-wachtwoord door naar de CRM, met de
// sleutel erbij. Zonder stack (start.sh) geeft api/crm/ een 404 en blijft alles van de CRM verborgen.
window.TectumCrm = (function () {
const API = 'api/crm/woningen';
let available = null;
async function call(url, opts) {
const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json', ...(opts && opts.body ? { 'Content-Type': 'application/json' } : {}) }, ...opts });
let data = null;
try { data = await res.json(); } catch (e) { /* geen JSON: geen CRM */ }
if (!data) throw new Error(`CRM niet bereikbaar (HTTP ${res.status})`);
return { status: res.status, data };
}
// Eén keer kijken of de CRM bereikbaar is (de lijst ophalen); onthoudt de uitkomst
function isAvailable() {
if (!available) available = list('').then(() => true, () => false);
return available;
}
async function list(q) {
const { data } = await call(`${API}?q=${encodeURIComponent(q || '')}`);
if (!data.ok) throw new Error(data.error || 'CRM-fout');
return data.woningen;
}
// Ontwerp van een woning (nieuwste versie, of `versie`), klaar voor de editor: gecontroleerd en zonder de
// bestandsvelden (type, version, exportedAt), zoals bij Openen.
async function load(id, versie) {
const { data } = await call(`${API}/${encodeURIComponent(id)}${versie ? `?versie=${versie}` : ''}`);
if (!data.ok) throw new Error(data.error || 'Woning niet gevonden');
HouseModel.validateState(data.ontwerp);
const { type, version, exportedAt, ...state } = data.ontwerp;
return { woning: data.woning, state };
}
// Nieuwe versie bewaren. basis = de versie die geladen was; is er intussen een nieuwere, dan vraagt dit eerst.
async function saveVersion(id, state, basis, opmerking) {
const ontwerp = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), configurator: APP_VERSION, ...state };
const send = (forceer) => call(`${API}/${encodeURIComponent(id)}/versies`, { method: 'POST', body: JSON.stringify({ ontwerp, opmerking, basis, forceer }) });
let { status, data } = await send(false);
if (status === 409) {
if (!window.confirm(`In de CRM staat intussen versie ${data.versie} van deze woning (u bewerkt versie ${basis}). Toch opslaan als nieuwe versie?`)) return null;
({ data } = await send(true));
}
if (!data.ok) throw new Error(data.error || 'Opslaan mislukt');
return data.versie;
}
function toast(msg, cls) {
let el = document.getElementById('crm-toast');
if (!el) {
el = document.createElement('div');
el.id = 'crm-toast';
el.setAttribute('role', 'status');
document.body.appendChild(el);
}
el.textContent = msg;
el.className = 'open ' + (cls || '');
clearTimeout(toast._t);
toast._t = setTimeout(() => { el.className = ''; }, 5000);
}
const date = (s) => (s || '').slice(0, 10).split('-').reverse().join('-');
// Sectie "Woningen uit de CRM" in het venster Nieuw: zoeken en kiezen; verborgen zolang de CRM er niet is
function mountPicker(host, onPick) {
host.innerHTML = `<h3>Woningen van leads en klanten (CRM)</h3>
<input type="search" class="new-crm-q" placeholder="Zoek op woning, naam of plaats" aria-label="Zoek in de CRM">
<ul class="new-crm-list" role="list"></ul>`;
const q = host.querySelector('.new-crm-q'), ul = host.querySelector('.new-crm-list');
let req = 0, timer = null;
const render = (rows) => {
ul.innerHTML = rows.length ? rows.map(w => `<li><button type="button" class="new-crm-item" data-id="${w.id}">
<i class="bi bi-house"></i><span><strong>${UI.esc(w.titel)}</strong>
<small>${UI.esc([w.contact && w.contact.naam, w.relatie, w.contact && w.contact.plaats].filter(Boolean).join(' · '))}
· versie ${w.versie} · ${date(w.datum)}</small></span></button></li>`).join('')
: '<li class="new-crm-empty">Geen woningen gevonden.</li>';
};
const search = () => {
const id = ++req;
list(q.value.trim()).then(rows => { if (id === req) render(rows); }, err => { if (id === req) ul.innerHTML = `<li class="new-crm-empty">${UI.esc(err.message)}</li>`; });
};
q.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(search, 250); });
ul.addEventListener('click', (e) => {
const b = e.target.closest('.new-crm-item');
if (b) onPick(Number(b.dataset.id));
});
isAvailable().then(ok => { host.hidden = !ok; if (ok) search(); });
return { refresh: () => { if (!host.hidden) search(); } };
}
return { isAvailable, list, load, saveVersion, mountPicker, toast };
})();
+111 -1
View File
@@ -876,6 +876,115 @@ window.HouseModel = (() => {
return st;
}
// Model Van den Berg (examples/van den berg.pdf): dubbel chalet 6,94 × 12,25 m. West een smal, laag chalet van
// 2,97 × 12,25 m (wandhoogte 2330: garderobe, twee slaapkamers, toilet, badkamer, cv-kast, hal), oost een breed,
// hoog chalet van 3,97 × 9,5 m (wandhoogte 3080: woonkamer met keuken), allebei een plat dak (EPDM) met boeidelen.
// In de noordwesthoek een overdekt portiek van 1,36 × 1,75 m voor de voordeur: de garderobe is een eigen unit
// (B) waarvan het dak naar het westen doorloopt. Ten zuiden van het oostchalet een overdekte vlonder van 3,97 × 2,75 m
// (het dak loopt 2,75 m door, palen op de hoeken). Gevel: het lage chalet donker (Eternit potdeksel, hier
// antraciet felsplaten), het hoge chalet eiken rabat horizontaal; het deel van de oostwand boven het lage dak
// krijgt dezelfde rabat. De twee bovenlichten van 200 × 30 in die wand zijn weggelaten: het stuk boven het lage
// dak is 488 mm hoog en een opening is minimaal 400 mm. Maten buitenwerks, x vanaf de westgevel en z vanaf de
// noordgevel van de tekening. Het lage chalet is de eerste unit A (noordwesthoek op z 1750), het hoge chalet C.
function createVandenBerg() {
const st = newModelState('Van den Berg');
const S = st.settings, HL = 2330, HH = 3080, WA = 2970, WB = 3970, P = 1750, PW = 1360, FELS = 'antraciet';
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel', felsColor: FELS });
S.cladding = 'horizontaal';
S.trasraam.enabled = false;
Object.assign(S.roof, { fasciaColor: 'antraciet', overhang: RoofModel.OVERHANG_MIN, fasciaHeight: 250 });
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 120, maxSpan: 4500 });
S.lighting.mode = 'spots';
S.lighting.direction = 'both';
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: false, oven: true, vaatwasser: true });
if (S.elektra) S.elektra.plateColor = 'wit';
const A = createUnitEntry('A', null, { width: WA, depth: 12250 - P, height: HL, windowOn: [] });
const B = createUnitEntry('B', { to: 'A', side: 'voor', align: 'end', offset: 0 }, { width: WA - PW, depth: P, height: HL, windowOn: [] });
const C = createUnitEntry('C', { to: 'B', side: 'rechts', align: 'start', offset: 0 }, { width: WB, depth: 9500, height: HH, windowOn: [] });
st.units.push(A, B, C);
B.roof.extend = { f: 0, r: 0, b: 0, l: PW }; // portiek
C.roof.extend = { f: 0, r: 0, b: 2750, l: 0 }; // vlonder
[A, B].forEach(u => ['voor', 'achter', 'links', 'rechts'].forEach(side => { u.facade.walls[side] = { type: 'fels', fels: FELS }; }));
const tE = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.voor));
const { binnen, ops, iw, room, fx } = modelKit(st, HL, 90);
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), extra || {});
// Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' en oost 'right' = vanaf de
// binnenkant van de noordgevel. z in de tekening; zA = z in unit A.
const zA = (z) => z - P;
ops(A.unit.walls.links, [
['raam', 700, 1100, 950, 'left', zA(3897) - tE, K('raam', { rooster: true })], // slaapkamer 2
['raam', 600, 700, 1350, 'left', zA(5550) - tE, K('raam')], // toilet
['raam', 600, 700, 1350, 'left', zA(6590) - tE, K('raam')]]); // badkamer
ops(A.unit.walls.achter, [['raam', 1200, 1100, 950, 'left', 827, K('raam', { rooster: true })]]); // slaapkamer 1
ops(B.unit.walls.links, [['voordeur', 900, 2110, 0, 'left', 787 - tE, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
ops(C.unit.walls.voor, [['raam', 1400, 900, 1200, 'right', 1651, K('raam', { rooster: true })]]); // boven de spoelbak
// Oostgevel: kozijnen van 180 × 300, het noordelijke met openslaande deuren (alleen van binnen te bedienen).
ops(C.unit.walls.rechts, [
['deur', 900, 2100, 0, 'right', 2267 - tE, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
['deur', 900, 2100, 0, 'right', 3167 - tE, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
['raam', 1800, 650, 2200, 'right', 2267 - tE, K('raam')],
['raam', 1800, 2100, 0, 'right', 5946 - tE, K('raam')],
['raam', 1800, 650, 2200, 'right', 5946 - tE, K('raam')]]);
C.unit.walls.rechts.openings[1].koppel = true;
ops(C.unit.walls.achter, [['deur', 2400, 2100, 0, 'left', 792, // schuifpui naar de vlonder
K('deur', { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true })]]);
const DH = 2115, DW = 730;
// Binnenwanden in wereldcoördinaten (t.o.v. de noordwesthoek van A). Noord–zuid: 'buiten' = west, 'left' =
// vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
const xE = WA - 100; // tegen de naadwand
const w = (x1, z1, x2, z2, openings) => iw(x1, zA(z1), x2, zA(z2), openings);
w(tE, 5321, xE, 5321, [['deur', DW, DH, 'left', 1879 - tE, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // slaapkamer 2 | toilet, hal
w(1376, 5366, 1376, 6368, [['deur', DW, DH, 'left', 200, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet | hal
w(tE, 6413, 1891, 6413); // toilet | badkamer
w(1846, 6458, 1846, 8651, [ // badkamer, cv-kast | hal
['deur', DW, DH, 'left', 20, binnen({ hinge: 'links', swing: 'binnen' })],
['deur', DW, DH, 'left', 1330, binnen({ hinge: 'links', swing: 'binnen' })]]);
w(1155, 7700, 1801, 7700); // badkamer | cv-kast
w(1200, 7745, 1200, 8651); // douche | cv-kast
w(tE, 8696, xE, 8696, [['deur', DW, DH, 'left', 1985 - tE, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal | slaapkamer 1
ensureSeamWalls(st);
const seamOf = (a, b) => st.interiorWalls.find(iw => iw.seam && sameSeam(iw.seam, a, b));
const toog = (wd) => ({ id: 1, type: 'deur', w: wd, h: 2100, y: 0, anchor: 'left', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) });
const sAC = seamOf('A', 'C'), sBC = seamOf('B', 'C'), sAB = seamOf('A', 'B');
// Naadwanden noord–zuid: 'left' = vanaf de zuidkant (A: binnenkant zuidgevel van C, z 9244; B: de naad met A, z 1750).
if (sAC) sAC.wall.openings = [Object.assign(toog(1000), { offset: 2854 })]; // hal → woonkamer (z 5390–6390)
if (sBC) sBC.wall.openings = [Object.assign(toog(900), { offset: 350 })]; // garderobe → woonkamer (z 500–1400)
if (sAB) sAB.wall.openings = [];
room('A', 1400, zA(3000), 'slaapkamer', 'Slaapkamer 2');
room('A', 800, zA(5900), 'toilet');
room('A', 1500, zA(7000), 'badkamer');
room('A', 1500, zA(8200), 'techniek', 'Cv-kast');
room('A', 2400, zA(7000), 'hal', 'Hal');
room('A', 1400, zA(11000), 'slaapkamer', 'Slaapkamer 1');
room('B', 800, 900, 'hal', 'Garderobe');
room('C', 2000, 4500, 'woonkeuken', 'Woonkamer/keuken');
fx('bed', 'A', 670, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
fx('bed', 'A', 2290, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
fx('kledingkast', 'A', 1056, zA(4976), 180, { w: 1600 });
fx('toilet', 'A', 601, zA(5950), 270);
fx('fontein', 'A', 1108, zA(5491), 0);
fx('douche', 'A', 706, zA(8051), 180, { w: 900, d: 1200 }, { glas: 'rechts', glasLengte: 900 });
fx('wastafel', 'A', 1478, zA(7405), 180, { w: 600 });
fx('boiler', 'A', 1470, zA(8350), 270);
fx('kledingkast', 'A', 1056, zA(9041), 0, { w: 1600 });
fx('bed', 'A', tE + 1000, zA(10744), 270, { w: 1600, d: 2000 }, { nachtkastjes: false });
const xC = (x) => x - WA;
fx('keuken', 'C', xC(4930), tE + 300, 0, { w: 3500 }, { front: 'wit', hogeKast: 'rechts', spoelbak: 0.6, kookplaat: true, kookplaatPlek: 0.26 });
fx('eettafel', 'C', xC(4912), 3172, 0, { w: 1400, d: 900 }, { vorm: 'rechthoek', stoelen: 6 });
fx('bank', 'C', 560, 7700, 270, { w: 2200, d: 900 }, { chaise: 'geen' });
fx('salontafel', 'C', 1700, 7700, 0, { w: 800, d: 800 }, { vorm: 'rond' });
fx('fauteuil', 'C', 2900, 7200, 90);
computeParts(st, { skip3D: true });
return st;
}
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
const MODELS = {
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
@@ -885,7 +994,8 @@ window.HouseModel = (() => {
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
reest: { label: 'Woning Reest', icon: 'house-up', create: createReest },
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht }
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht },
vandenberg: { label: 'Woning Van den Berg', icon: 'house-dash', create: createVandenBerg }
};
function createDefaultState(template) {
+21 -19
View File
@@ -5,8 +5,8 @@
* Zie LICENTIE.md.
*/
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs, contact en
// QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs tenzij
// d.price === false, contact en QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
window.LightPdf = (() => {
const COMPANY = {
naam: 'Van Emous Mantelzorgwoningen',
@@ -136,22 +136,24 @@ window.LightPdf = (() => {
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
// Prijs: rustig, als de rest van de pagina; alleen het bedrag groter en vet.
const py = Math.max(y + 4, 196);
doc.setDrawColor(...LINE);
doc.setLineWidth(0.3);
doc.line(M, py, M + 150, py);
doc.setTextColor(...MUTED);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text('Indicatieve prijs', M, py + 9);
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold');
doc.setFontSize(20);
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(...MUTED);
doc.text('incl. btw, onder voorbehoud', M, py + 25);
const py = Math.max(y + 4, 196), price = d.price !== false;
if (price) {
doc.setDrawColor(...LINE);
doc.setLineWidth(0.3);
doc.line(M, py, M + 150, py);
doc.setTextColor(...MUTED);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text('Indicatieve prijs', M, py + 9);
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold');
doc.setFontSize(20);
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(...MUTED);
doc.text('incl. btw, onder voorbehoud', M, py + 25);
}
// Beeld van de woning
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
@@ -162,7 +164,7 @@ window.LightPdf = (() => {
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
doc.setFontSize(7);
doc.setTextColor(...MUTED);
doc.text('Dit ontwerp en de prijs zijn indicatief; hieraan kunnen geen rechten worden ontleend.', M, PH - 6);
doc.text(`Dit ontwerp${price ? ' en de prijs zijn' : ' is'} indicatief; hieraan kunnen geen rechten worden ontleend.`, M, PH - 6);
}
function process(doc, d) {
+11 -7
View File
@@ -8,8 +8,9 @@
// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met
// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen).
// Inbedden: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
// TectumLight.mount(el, { model, frames, url, modelViewer, save }); url = adres voor de QR-code in de PDF (standaard de
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script).
// TectumLight.mount(el, { model, frames, url, modelViewer, save, price }); url = adres voor de QR-code in de PDF (standaard
// de modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script), price: false (of
// data-price="false") = geen prijs in beeld en in de PDF; design().info.price blijft er wel in.
// save = { label, icon, onSave(ontwerp) } vervangt de PDF-knop (bijv. ontwerp aanvragen op de site); onSave krijgt
// { design, label, key, info, choices, url }: design = house-design-JSON zoals de configurator die opslaat, choices = de
// keuzes in woorden (zoals op het voorblad van de PDF). Zonder save blijft het de PDF.
@@ -69,6 +70,7 @@ window.TectumLight = (() => {
function mount(root, opts) {
const o = opts || {};
const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames });
const showPrice = o.price !== false && root.dataset.price !== 'false';
let ch = clone(model.initial);
let unitSel = model.base.units[0].id;
let cur = null; // { st, parts, info }
@@ -502,7 +504,7 @@ window.TectumLight = (() => {
const kv = (k, v) => `<div class="tl-kv"><span>${k}</span><b>${v}</b></div>`;
$('.tl-facts').innerHTML = kv('Vloer bruto', `${nl(i.brutoM2, 1)} m²`) + kv('Leefbaar', `${nl(i.leefbaarM2, 1)} m²`) +
kv('Inhoud', `${nl(i.inhoudM3, 0)} m³`) + kv('Gewicht', `${nl(Math.round(i.kg / 10) * 10, 0)} kg`);
$('.tl-buy').innerHTML = `<div class="tl-price"><span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span></div>
$('.tl-buy').innerHTML = `<div class="tl-price">${showPrice ? `<span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span>` : ''}</div>
<button type="button" class="tl-btn tl-icon" data-act="reset" title="Alle keuzes terug naar het model" aria-label="Terug naar het model"><i class="bi bi-arrow-counterclockwise"></i></button>
${o.save ? `<button type="button" class="tl-btn tl-primary" data-act="save"><i class="bi bi-${esc(o.save.icon || 'send')}"></i>${esc(o.save.label || 'Opslaan')}</button>`
: '<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>'}`;
@@ -632,7 +634,7 @@ window.TectumLight = (() => {
try {
let image = null;
try { image = snapshot(); } catch (err) { console.warn(err); }
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image })
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image, price: showPrice })
.save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
} catch (err) {
console.error(err);
@@ -669,11 +671,13 @@ window.TectumLight = (() => {
io.observe(stage);
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
// Alles onder het maaiveld (sandwichpanelen, vloerbalken) valt weg: AR zet het laagste punt op de grond.
let glb = null;
function makeGlb() {
if (glb && glb.parts === cur.parts) return glb;
if (glb) URL.revokeObjectURL(glb.url);
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts), { name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts) }));
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts).filter(g => g !== cur.parts.sandwichGroup),
{ name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts), clipBelow: 0 }));
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
return glb;
}
@@ -702,7 +706,7 @@ window.TectumLight = (() => {
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan.
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
let arView = null, arOpen = false;
const AR_NOTE = 'Ware grootte · met twee vingers verkleinen of draaien';
const AR_NOTE = 'Ware grootte · met twee vingers draaien, met één vinger verplaatsen';
function closeAr() {
if (!arOpen) return;
arOpen = false;
@@ -718,7 +722,7 @@ window.TectumLight = (() => {
arView.setAttribute('role', 'dialog');
arView.setAttribute('aria-modal', 'true');
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
ar ar-modes="webxr scene-viewer quick-look" ar-scale="auto" ar-placement="floor">
ar ar-modes="webxr scene-viewer quick-look" ar-scale="fixed" ar-placement="floor" xr-environment>
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
</model-viewer>
<div class="tl-ar-top"><b class="tl-ar-title"></b><button type="button" class="tl-ar-close" aria-label="Sluiten"><i class="bi bi-x-lg"></i></button></div>