5 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 a7215b9e62 v0.7.9: woningen van leads en klanten uit de CRM (alleen in de Docker-stack, api/crm/ via Caddy): bij Nieuw te zoeken en te openen, ?woning=<id> opent er direct één; Opslaan in CRM (ook Ctrl+S in zo'n tab) bewaart een nieuwe versie met opmerking, met waarschuwing als er intussen een nieuwere versie is; Downloaden (JSON + PDF) blijft; sluiten met niet-bewaarde wijzigingen vraagt bevestiging
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:40:51 +02:00
MachielandClaude Opus 5.5 0da1769f51 GLB voor AR zonder alles onder het maaiveld (GlbWriter clipBelow, zonder sandwichpanelen), AR op ware grootte; model Van den Berg (dubbel chalet met portiek en overdekte vlonder)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:30:01 +02:00
MachielandClaude Opus 5.5 4bb1548125 Light: optie price: false (of data-price="false"): geen prijs in het paneel en op het voorblad van de PDF; design().info.price blijft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:30:01 +02:00
MachielandClaude Opus 5.5 73248a69c9 v0.7.8: light-versie in te bedden op elke pagina en eigen opslaan: texturen, kleurstalen en kosten/*.csv laden via tectumUrl() (js/core/geometry.js) vanaf de map van de scripts, zodat de engine niet naast de pagina hoeft te staan (modelpagina's van de site met de engine op /configurator/); paden in de data blijven relatief; TectumLight.mount(el, { save: { label, icon, onSave } }) vervangt de PDF-knop, onSave krijgt het ontwerp als house-design-JSON met kerncijfers, prijs en keuzes in woorden (ook el.tectumLight.design())
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:28:02 +02:00
MachielandClaude Opus 5.5 b8cc39275b v0.7.7: daktrim als echt profiel: voorkant 8 mm vóór de gevelbekleding, 30 mm boven het dak en over de bovenste trimHeight van de gevel (standaard 30 mm), opstand 20 mm diep met een flens van 3 mm op het dak over de bovenkant van de bekleding (RoofModel.TRIM); de bekleding loopt bij een daktrim door tot het dak; hoeken met daktrim/felsrand lopen op de buitenkant door, naast een boeideel stopt de trim ertegen; vrije uiteinden van binnenwanden afgewerkt in de binnenwandkleur (endCaps) in plaats van een zichtbare stijl; light-PDF: prijs op het voorblad rustig onder een dunne lijn, bedrag groot en vet, geen oranje vak
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:56 +02:00
17 changed files with 498 additions and 74 deletions
+23 -1
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
@@ -350,7 +351,9 @@ assets/ logo, texturen, skybox
Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via
`light.html?model=<sleutel uit HouseModel.MODELS>`). Inbedden: de stylesheets en scripts uit `light.html`
plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
`TectumLight.mount(el, { model, frames, url, modelViewer })`.
`TectumLight.mount(el, { model, frames, url, modelViewer, save })`. De engine hoeft niet naast de pagina te staan:
texturen, kleurstalen en `kosten/*.csv` laden via `tectumUrl()` (`js/core/geometry.js`) vanaf de map van de scripts
(bijv. `/configurator/` op een modelpagina van de site); paden in de data blijven relatief.
- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont
alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
@@ -393,6 +396,10 @@ plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
kosten) en als laatste het stappenplan van de site ("Hoe verloopt het traject?") met een blok over maatwerk
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
in `LightPdf.COMPANY` en `STEPS`.
- **Eigen opslaan** (`save: { label, icon, onSave }`): vervangt de PDF-knop, bijv. "Ontwerp aanvragen" op de site.
`onSave` krijgt `{ design, label, key, info, choices, url }`: `design` is de house-design-JSON zoals de
configurator die opslaat (de CRM maakt daar het gekozen model van), `info` de kerncijfers met prijs, `choices` de
keuzes in woorden. Zonder `save` (en via `el.tectumLight.design()`) hetzelfde ontwerp.
- **AR / schermvullend:** knop rechtsboven in de weergave. Op een telefoon of tablet: het ontwerp als GLB
(`GlbWriter`, afgewerkte woning zoals in 3D, één primitive per materiaal, texturen als JPEG/PNG tot 1024 px,
lichtbundels weg; maaiveld onder het midden = oorsprong, `HouseModel.groundOrigin`) in model-viewer, over de
@@ -404,6 +411,21 @@ plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
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.6';
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');
+11
View File
@@ -6,6 +6,17 @@
*/
// Plain script, geen ES-module: imports breken bij file:// (CORS).
// Map van de engine (met assets/ en kosten/), afgeleid van de plek van dit script. Texturen, kleurstalen en de
// kosten-CSV's laden via tectumUrl(), zodat ze ook werken als de engine in een andere pagina staat (light-versie op
// een modelpagina van de site). Paden in de data blijven relatief.
const TECTUM_BASE = (() => {
const s = typeof document !== 'undefined' && document.currentScript && document.currentScript.src;
return s ? new URL('../../', s).href : '';
})();
function tectumUrl(path) {
return path && !/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(path) ? TECTUM_BASE + path : path;
}
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
if (spanWidth <= 0) return [];
let numBays = Math.ceil(spanWidth / maxSp);
+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++;
+1 -1
View File
@@ -23,7 +23,7 @@ window.Skybox = (() => {
const cache = {};
function loadTexture(url) {
if (!cache[url]) {
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(url, resolve, undefined, reject));
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(tectumUrl(url), resolve, undefined, reject));
cache[url].catch(() => { delete cache[url]; });
}
return cache[url];
+1 -1
View File
@@ -114,7 +114,7 @@ window.CostCsv = (() => {
}
function loadList(list) {
const url = LISTS[list].url;
return fetch(url, { cache: 'no-store' })
return fetch(tectumUrl(url), { cache: 'no-store' })
.then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
.catch(() => null);
}
+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 };
})();
+2 -2
View File
@@ -293,7 +293,7 @@ window.FloorModel = (() => {
}
function finishThumbUrl(key, px) {
const f = FINISHES[key];
return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null;
return f && f.file ? tectumUrl(f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png'))) : null;
}
const finishMaterials = {};
function finishMaterial(key) {
@@ -307,7 +307,7 @@ window.FloorModel = (() => {
}
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(f.file, (tex) => {
new THREE.TextureLoader().load(tectumUrl(f.file), (tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
tex.anisotropy = 8;
+125 -7
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) {
@@ -1500,10 +1610,10 @@ window.HouseModel = (() => {
});
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels) {
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels, endCaps) {
if (!build) return { group: null, stats: null, specs: null };
const built = WallModel.build3D(config, frame, {
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels,
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels, endCaps,
painted: state.settings.interiorPaint && shows('afwerking'), layers, rabatColor: state.settings.facade.rabatColor
});
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
@@ -1918,14 +2028,22 @@ window.HouseModel = (() => {
// De rand begint op de dakrand, die op of iets vóór het wandvlak kan liggen.
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
if (!sg) return;
const above = sg.fels ? RoofModel.FELS_EDGE.aboveMM : roofEdge.edgeAbove;
p.battenAbove = (p.skinAbove || 0) + above - RoofModel.edgeHeight(roofEdge, sg);
// Daktrim: de bekleding loopt door tot het dak, de trim valt eroverheen.
if (!sg || (!sg.fascia && !sg.fels)) return;
p.battenAbove = (p.skinAbove || 0) + sg.above - RoofModel.edgeHeight(roofEdge, sg);
});
// Vrije uiteinden van binnenwanden: vlak voorbij het eind geen andere wand.
const allPieces = extPieces.concat(interior);
interior.forEach(p => {
if (p.buitenMateriaal !== 'spaanplaat') return;
const W = p.place.width / 2, against = (q) => allPieces.some(o => o !== p && pointInPoly(q,
[[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([i, j]) => UnitModel.localToWorld(o.place, i * o.place.width / 2, j * o.place.thickness / 2))));
p.endCaps = { left: !against(UnitModel.localToWorld(p.place, -W - 15, 0)), right: !against(UnitModel.localToWorld(p.place, W + 15, 0)) };
});
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
extPieces.concat(interior).forEach(p => {
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop, p.fels);
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop, p.fels, p.endCaps);
if (built.group && p.shiftZ) {
const outer = new THREE.Group();
built.group.position.z = p.shiftZ * 0.001;
+1 -1
View File
@@ -1413,7 +1413,7 @@ function mountHouseEditor(container, callbacks) {
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) +
toggle('hs-vent', 'Ventilatierooster', r.vent !== false, { hint: `${RoofModel.VENT.HEIGHT_MM} mm hoog op de gevel, direct onder alle boeidelen` }) +
(r.vent !== false ? pair(sel('hs-vent-color', 'Kleur rooster', TRIM_OPTS, r.ventColor || 'antraciet'), swatchOf(WallModel.trimHex(r.ventColor))) : '') },
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) },
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Over de gevel; steekt 30 mm boven het dak uit (units zonder boeidelen)' }) },
{ label: 'Palen overkapping', html:
toggle('hs-posts', 'Palen', r.posts.enabled, { hint: `Onder een dak dat minstens de min. uitkraging buiten de vloer steekt (veranda); per unit: Overkapping. Nu ${parts ? parts.posts.count : 0} st.` }) +
(r.posts.enabled ? toggle('hs-posts-side', 'Zijwand', r.posts.sideWall, { hint: `Vrije hoek dicht met een gestuukte wand (${PostModel.SIDE_MM} mm) terug tot de gevel, in plaats van een hoekpaal` }) +
+17 -12
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',
@@ -135,20 +135,25 @@ window.LightPdf = (() => {
], 'Kerncijfers');
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
// Prijs
const py = Math.max(y + 4, 196);
doc.setFillColor(...ORANGE);
doc.roundedRect(M, py, 150, 30, 3, 3, 'F');
doc.setTextColor(255, 255, 255);
// Prijs: rustig, als de rest van de pagina; alleen het bedrag groter en vet.
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 + 7, py + 9);
doc.text('Indicatieve prijs', M, py + 9);
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold');
doc.setFontSize(24);
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M + 7, py + 21);
doc.setFontSize(20);
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.text('incl. btw, onder voorbehoud', M + 7, py + 27);
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;
@@ -159,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) {
+30 -8
View File
@@ -8,8 +8,12 @@
// 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 }); 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.
// Knop rechtsboven in de weergave: op een telefoon/tablet het ontwerp als GLB in model-viewer, met AR (WebXR, Scene
// Viewer, Quick Look); op de desktop schermvullend.
window.TectumLight = (() => {
@@ -66,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 }
@@ -499,9 +504,10 @@ 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>
<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</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>'}`;
}
// Keuzes (knoppen en kleuren).
@@ -542,6 +548,7 @@ window.TectumLight = (() => {
const a = e.target.closest('[data-act]');
if (!a || !cur) return;
if (a.dataset.act === 'pdf') savePdf();
else if (a.dataset.act === 'save') o.save.onSave(design());
else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
});
panel.addEventListener('keydown', (e) => {
@@ -608,6 +615,18 @@ window.TectumLight = (() => {
}
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
// Het huidige ontwerp voor buiten de tool (save.onSave, el.tectumLight.design()).
function design() {
const st = clone(cur.st);
st.project.naam = model.label;
st.project.opmerking = 'Ontwerp uit de light-versie';
return {
design: Object.assign({ type: 'house-design', version: 1, exportedAt: new Date().toISOString() }, st),
label: model.label, key: model.key, info: Object.assign({}, cur.info),
choices: LightModel.describe(model, ch, cur.st), url: pageUrl
};
}
function savePdf() {
const st = clone(cur.st);
st.project.naam = model.label;
@@ -615,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);
@@ -652,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;
}
@@ -685,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;
@@ -701,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>
@@ -768,6 +789,7 @@ window.TectumLight = (() => {
root.innerHTML = '';
},
getChoices: () => clone(ch),
design: () => cur && design(),
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
glb: () => cur && makeGlb()
};
+30 -20
View File
@@ -12,6 +12,9 @@ window.RoofModel = (() => {
const DENSITY_DAKLEER_KGM2 = 1.4;
const TRIM_THICK_MM = 25;
const EDGE_ABOVE_MM = 80;
// Daktrim: voorkant 8 mm vóór de gevel, over de bovenste trimHeight van de gevel en 30 mm boven het dak; de opstand
// is 20 mm diep, daarachter ligt een flens van 3 mm op het dak over de bovenkant van de bekleding.
const TRIM = { OUT_MM: 8, ABOVE_MM: 30, DEPTH_MM: 20, FLANGE_MM: 3 };
// Felsplaat op dak én gevel: geen boeideel; de dakplaat zet om over de rand (net vóór de gevelbekleding), zonder
// opstand (de waterkant van een lessenaar), tot over de bovenkant van de gevelplaten.
const FELS_EDGE = { lipMM: 2, aboveMM: 2, heightMM: 120 };
@@ -23,7 +26,7 @@ window.RoofModel = (() => {
fasciaHeight: 300,
spotMin: 800,
spotMax: 1200,
trimHeight: 80,
trimHeight: 30,
fasciaColor: 'antraciet',
vent: true,
ventColor: 'antraciet'
@@ -87,7 +90,7 @@ window.RoofModel = (() => {
}
function coveringThumbUrl(key, px) {
const c = COVERINGS[key];
return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null;
return c && c.file ? tectumUrl(c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png'))) : null;
}
function coveringFor(key, shapeType) {
const c = COVERINGS[key];
@@ -105,7 +108,7 @@ window.RoofModel = (() => {
if (!c.file) return null;
const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 });
if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(c.file, (tex) => {
new THREE.TextureLoader().load(tectumUrl(c.file), (tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM);
tex.anisotropy = 8;
@@ -1007,9 +1010,9 @@ window.RoofModel = (() => {
// Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots.
const nb = others.find(r => onRectEdge(r.rect, mid));
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
const above = fels ? FELS_EDGE.aboveMM : EDGE_ABOVE_MM;
const above = fels ? FELS_EDGE.aboveMM : fascia ? EDGE_ABOVE_MM : TRIM.ABOVE_MM;
return {
a, b, owner: owner.unitId, fascia, fels, covering: owner.covering, normal,
a, b, owner: owner.unitId, fascia, fels, covering: owner.covering, normal, above,
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j],
topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [],
overRoof: !!nb && !dropped, dropped
@@ -1033,13 +1036,13 @@ window.RoofModel = (() => {
// facadeUnder: een eigen gevel onder de rand (niet bij een veranda of een hogere gevel ertegenaan).
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i], facadeUnder: depIn[i] !== null && depIn[i] >= 0 }));
// Rand zonder overstek (daktrim, felsrand): de felsrand ligt net vóór de gevelbekleding en loopt bij een hoek
// door tot de buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand.
const edgeOff = (k) => segs[k].fels ? Math.max(TRIM_THICK_MM, depRaw[k] + FELS_EDGE.lipMM) : TRIM_THICK_MM;
// Rand zonder overstek (daktrim, felsrand): ligt net vóór de gevelbekleding en loopt bij een hoek door tot de
// buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand.
const edgeOff = (k) => segs[k].fels ? Math.max(TRIM_THICK_MM, depRaw[k] + FELS_EDGE.lipMM) : Math.max(0, depRaw[k]) + TRIM.OUT_MM;
const mv = (p, nrm, d) => ({ x: p.x + nrm.x * d, z: p.z + nrm.z * d });
segs.forEach((sg, i) => {
const ends = [['a', 'outerA', (i - 1 + n) % n], ['b', 'outerB', (i + 1) % n]];
if (sg.fels) ends.forEach(([k, ok, j]) => {
if (!sg.fascia) ends.forEach(([k, ok, j]) => {
const nb = segs[j], q = mv(sg[k], sg.normal, edgeOff(i));
sg[ok] = perp(sg.normal, nb.normal) && !nb.fascia && !nb.dropped ? mv(q, nb.normal, edgeOff(j)) : q;
});
@@ -1078,7 +1081,7 @@ window.RoofModel = (() => {
segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor,
vent: s.vent, ventColor: s.ventColor,
top, edgeAbove: EDGE_ABOVE_MM,
top,
fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
ventLengthMM: s.vent ? segments.filter(sg => sg.fascia && sg.facadeUnder).reduce((t, sg) => t + Math.hypot(sg.b.x - sg.a.x, sg.b.z - sg.a.z), 0) : 0,
// Felsrand: zetwerk van de dakplaten, zit in de prijs van de felsplaten.
@@ -1125,7 +1128,7 @@ window.RoofModel = (() => {
}
// Hoogte van de rand vanaf sg.topAt naar beneden: boeideel, felsrand of daktrim (met opstand boven het dak).
function edgeHeight(edge, sg) {
return sg.fascia ? edge.fasciaHeight : sg.fels ? FELS_EDGE.heightMM : edge.trimHeight + edge.edgeAbove;
return sg.fascia ? edge.fasciaHeight : sg.fels ? FELS_EDGE.heightMM : edge.trimHeight + TRIM.ABOVE_MM;
}
// Kleur van de rand in tekeningen (hex-getal).
function edgeColor(edge, sg) {
@@ -1155,20 +1158,27 @@ window.RoofModel = (() => {
});
const h = edgeHeight(edge, sg);
const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial;
polys.forEach(pl => {
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), mat);
const nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z;
const add = (pl, topFn, hh, m, u) => {
const mesh = new THREE.Mesh(prismGeometry(pl, topFn, hh, u), m);
mesh.visible = o.edgeVisible !== false;
group.add(mesh);
};
if (!sg.fascia && !sg.fels) {
// Daktrim: opstand aan de voorkant, flens erachter op het dak.
const inner = nd(sg.outerA) - TRIM.DEPTH_MM;
polys.forEach(pl => {
const up = clipPoly(pl, p => nd(p) - inner), fl = clipPoly(pl, p => inner - nd(p));
if (up.length >= 3) add(up, sg.topAt, h, mat);
if (fl.length >= 3) add(fl, p => sg.topAt(p) - TRIM.ABOVE_MM + TRIM.FLANGE_MM, TRIM.FLANGE_MM, mat);
});
} else polys.forEach(pl => add(pl, sg.topAt, h, mat));
// Rooster: op de gevel (voorkant bekleding), onder de onderplaat van het boeideel.
if (!sg.fascia || !sg.facadeUnder || !edge.vent) return;
const nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z, face = ventFace(sg);
const face = ventFace(sg);
const u = (p) => (alongZ ? p.z : p.x) / VENT.PITCH_MM;
polys.map(pl => clipPoly(clipPoly(pl, p => nd(p) - face), p => face + VENT.THICK_MM - nd(p))).filter(pl => pl.length >= 3).forEach(pl => {
const mesh = new THREE.Mesh(prismGeometry(pl, (p) => sg.topAt(p) - h, VENT.HEIGHT_MM, u), ventMaterial(edge.ventColor));
mesh.visible = o.edgeVisible !== false;
group.add(mesh);
});
polys.map(pl => clipPoly(clipPoly(pl, p => nd(p) - face), p => face + VENT.THICK_MM - nd(p))).filter(pl => pl.length >= 3)
.forEach(pl => add(pl, (p) => sg.topAt(p) - h, VENT.HEIGHT_MM, ventMaterial(edge.ventColor), u));
});
const spotR = 0.035, spotH = 0.012;
@@ -1199,7 +1209,7 @@ window.RoofModel = (() => {
}
return {
DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, TRIM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
FELS_EDGE, VENT, ventFace, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings
+13 -2
View File
@@ -234,8 +234,8 @@ window.WallModel = (() => {
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
};
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg');
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png');
const stoneTextureUrl = (code) => tectumUrl(STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'));
const stoneThumbUrl = (code, px) => tectumUrl(STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'));
// UV's in meters, dus repeat = 1/tegel.
const stoneMaterials = {};
function stoneMaterial(code) {
@@ -1257,6 +1257,17 @@ window.WallModel = (() => {
cap.position.set(x + dir * 0.0003, h / 2, 0);
if (spaanplaatVisible) wallGroup3D.add(cap);
});
// Vrij uiteinde van een binnenwand (endCaps): afgewerkt zoals de wand, geen stijl in zicht.
const endCaps = opts.endCaps || {};
if (buitenMateriaal === 'spaanplaat' && spaanplaatVisible) [[endCaps.left, -halfW, -1], [endCaps.right, halfW, 1]].forEach(([on, x, dir]) => {
if (!on) return;
const h = topYAt(x), depth = maxDepth + 2 * (skinGapM + sThickM);
if (!(h > 0)) return;
const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial);
cap.rotation.y = dir * Math.PI / 2;
cap.position.set(x + dir * 0.0003, h / 2, 0);
wallGroup3D.add(cap);
});
spaanplaatAreaM2 = spaanplaatResult.areaM2;
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
+1 -1
View File
@@ -41,7 +41,7 @@
"fasciaHeight": 300,
"spotMin": 800,
"spotMax": 1200,
"trimHeight": 80,
"trimHeight": 30,
"fasciaColor": "antraciet"
},
"lighting": {