Compare commits
4
Commits
b8cc39275b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7215b9e62 | ||
|
|
0da1769f51 | ||
|
|
4bb1548125 | ||
|
|
73248a69c9 |
@@ -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/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/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
|
||||||
js/app.js menubalk, versienummer, actieve editor
|
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
|
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
|
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
|
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
|
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`
|
`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
|
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
|
- **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²,
|
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
|
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
|
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
|
||||||
in `LightPdf.COMPANY` en `STEPS`.
|
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
|
- **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,
|
(`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
|
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
|
Per ontwerp wordt de GLB één keer gemaakt; `el.tectumLight.glb()` geeft `{ blob, url, stats }` (op
|
||||||
`light.html` de testknop GLB).
|
`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)
|
## Kosten en prijzen (CSV)
|
||||||
|
|
||||||
Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die
|
Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die
|
||||||
|
|||||||
+17
@@ -345,6 +345,23 @@ html, body { height: 100%; }
|
|||||||
.new-option span { font-size: 13px; }
|
.new-option span { font-size: 13px; }
|
||||||
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
|
.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-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; }
|
.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; }
|
#house-nav-tree { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
<span>Los: vloer</span>
|
<span>Los: vloer</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="new-crm" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,6 +125,7 @@
|
|||||||
<script src="js/house/house-dxf.js"></script>
|
<script src="js/house/house-dxf.js"></script>
|
||||||
<script src="js/house/house-ifc.js"></script>
|
<script src="js/house/house-ifc.js"></script>
|
||||||
<script src="js/house/house.js"></script>
|
<script src="js/house/house.js"></script>
|
||||||
|
<script src="js/crm.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
* Zie LICENTIE.md.
|
* Zie LICENTIE.md.
|
||||||
*/
|
*/
|
||||||
const APP_VERSION = '0.7.7';
|
const APP_VERSION = '0.7.9';
|
||||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||||
|
|
||||||
let tabs = [];
|
let tabs = [];
|
||||||
@@ -41,7 +41,9 @@ const MENUS = [
|
|||||||
{ label: 'Bestand', items: [
|
{ label: 'Bestand', items: [
|
||||||
{ label: 'Nieuw…', icon: 'file-earmark-plus', keys: 'Ctrl+N', run: openNewModal },
|
{ 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: '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' },
|
{ sep: true }, { head: 'Exporteren' },
|
||||||
{ label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') },
|
{ label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') },
|
||||||
{ label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') },
|
{ label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') },
|
||||||
@@ -71,7 +73,7 @@ const MENUS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const KEY_HELP = [
|
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'],
|
['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)']]],
|
['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'],
|
['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');
|
const bar = document.getElementById('doc-tabs');
|
||||||
bar.innerHTML = tabs.map(tab => `
|
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)}">
|
<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">×</button>
|
<button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">×</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).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) {
|
function onTabSaved(tab, filenameWithExt) {
|
||||||
@@ -138,7 +186,7 @@ function switchToTab(tabId) {
|
|||||||
renderTabBar();
|
renderTabBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew(kind, template) {
|
function openNew(kind, template, opts) {
|
||||||
if (!editorModuleFor(kind)) return;
|
if (!editorModuleFor(kind)) return;
|
||||||
|
|
||||||
const oldTab = tabs.find(t => t.id === activeTabId);
|
const oldTab = tabs.find(t => t.id === activeTabId);
|
||||||
@@ -148,10 +196,13 @@ function openNew(kind, template) {
|
|||||||
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
|
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
|
||||||
currentEditor = null;
|
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);
|
tabs.push(tab);
|
||||||
activeTabId = tab.id;
|
activeTabId = tab.id;
|
||||||
mountEditorForTab(tab);
|
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();
|
renderTabBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +213,7 @@ function isTabDirty(tab) {
|
|||||||
? currentEditor.getStateJson()
|
? currentEditor.getStateJson()
|
||||||
: tab.stateJson;
|
: tab.stateJson;
|
||||||
if (!state) return false;
|
if (!state) return false;
|
||||||
|
if (tab.baseJson) return JSON.stringify(state) !== tab.baseJson;
|
||||||
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
|
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +230,9 @@ function closeTab(tabId) {
|
|||||||
switchToTab(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();
|
const saved = currentEditor.exportDesign();
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
}
|
}
|
||||||
@@ -233,10 +287,16 @@ function wireNewModal() {
|
|||||||
overlay.classList.remove('open');
|
overlay.classList.remove('open');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
newCrmPicker = TectumCrm.mountPicker(overlay.querySelector('.new-crm'), (id) => {
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
openCrmWoning(id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let newCrmPicker = null;
|
||||||
function openNewModal() {
|
function openNewModal() {
|
||||||
document.getElementById('new-overlay').classList.add('open');
|
document.getElementById('new-overlay').classList.add('open');
|
||||||
|
if (newCrmPicker) newCrmPicker.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleBrowserFullscreen() {
|
function toggleBrowserFullscreen() {
|
||||||
@@ -267,7 +327,7 @@ function wireHotkeys() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.ctrlKey || e.metaKey) {
|
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(); }
|
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -288,7 +348,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
||||||
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
||||||
Menubar.init(document.getElementById('menubar'), MENUS);
|
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) => {
|
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');
|
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>';
|
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);
|
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(() => {
|
setTimeout(() => {
|
||||||
const splash = document.getElementById('splash-screen');
|
const splash = document.getElementById('splash-screen');
|
||||||
|
|||||||
@@ -6,6 +6,17 @@
|
|||||||
*/
|
*/
|
||||||
// Plain script, geen ES-module: imports breken bij file:// (CORS).
|
// 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) {
|
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
|
||||||
if (spanWidth <= 0) return [];
|
if (spanWidth <= 0) return [];
|
||||||
let numBays = Math.ceil(spanWidth / maxSp);
|
let numBays = Math.ceil(spanWidth / maxSp);
|
||||||
|
|||||||
+43
-1
@@ -34,10 +34,14 @@ window.GlbWriter = (() => {
|
|||||||
} catch (e) { return null; }
|
} 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) {
|
function build(objects, opts) {
|
||||||
const o = opts || {};
|
const o = opts || {};
|
||||||
const origin = o.origin || { x: 0, y: 0, z: 0 };
|
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 buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
|
||||||
const images = new Map(), samplers = new Map(), textures = new Map();
|
const images = new Map(), samplers = new Map(), textures = new Map();
|
||||||
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
|
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
|
||||||
@@ -115,6 +119,25 @@ window.GlbWriter = (() => {
|
|||||||
}
|
}
|
||||||
return (remap[ci] = b.n++);
|
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);
|
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
|
||||||
for (let k = gr.start; k + 2 < end; k += 3) {
|
for (let k = gr.start; k + 2 < end; k += 3) {
|
||||||
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
|
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
|
||||||
@@ -126,6 +149,25 @@ window.GlbWriter = (() => {
|
|||||||
const len = face.length();
|
const len = face.length();
|
||||||
if (len < 2e-7) continue;
|
if (len < 2e-7) continue;
|
||||||
face.multiplyScalar(flip ? -1 / len : 1 / len);
|
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);
|
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]);
|
if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
|
||||||
triCount++;
|
triCount++;
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ window.Skybox = (() => {
|
|||||||
const cache = {};
|
const cache = {};
|
||||||
function loadTexture(url) {
|
function loadTexture(url) {
|
||||||
if (!cache[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]; });
|
cache[url].catch(() => { delete cache[url]; });
|
||||||
}
|
}
|
||||||
return cache[url];
|
return cache[url];
|
||||||
|
|||||||
+1
-1
@@ -114,7 +114,7 @@ window.CostCsv = (() => {
|
|||||||
}
|
}
|
||||||
function loadList(list) {
|
function loadList(list) {
|
||||||
const url = LISTS[list].url;
|
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)
|
.then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -293,7 +293,7 @@ window.FloorModel = (() => {
|
|||||||
}
|
}
|
||||||
function finishThumbUrl(key, px) {
|
function finishThumbUrl(key, px) {
|
||||||
const f = FINISHES[key];
|
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 = {};
|
const finishMaterials = {};
|
||||||
function finishMaterial(key) {
|
function finishMaterial(key) {
|
||||||
@@ -307,7 +307,7 @@ window.FloorModel = (() => {
|
|||||||
}
|
}
|
||||||
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
|
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
|
||||||
if (typeof document !== 'undefined' && THREE.TextureLoader) {
|
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.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||||
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
|
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
|
||||||
tex.anisotropy = 8;
|
tex.anisotropy = 8;
|
||||||
|
|||||||
+111
-1
@@ -876,6 +876,115 @@ window.HouseModel = (() => {
|
|||||||
return st;
|
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.
|
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
|
||||||
const MODELS = {
|
const MODELS = {
|
||||||
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
|
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
|
||||||
@@ -885,7 +994,8 @@ window.HouseModel = (() => {
|
|||||||
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
|
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
|
||||||
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
|
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
|
||||||
reest: { label: 'Woning Reest', icon: 'house-up', create: createReest },
|
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) {
|
function createDefaultState(template) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* Zie LICENTIE.md.
|
* Zie LICENTIE.md.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs, contact en
|
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs tenzij
|
||||||
// QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
|
// d.price === false, contact en QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
|
||||||
window.LightPdf = (() => {
|
window.LightPdf = (() => {
|
||||||
const COMPANY = {
|
const COMPANY = {
|
||||||
naam: 'Van Emous Mantelzorgwoningen',
|
naam: 'Van Emous Mantelzorgwoningen',
|
||||||
@@ -136,7 +136,8 @@ window.LightPdf = (() => {
|
|||||||
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
|
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.
|
// Prijs: rustig, als de rest van de pagina; alleen het bedrag groter en vet.
|
||||||
const py = Math.max(y + 4, 196);
|
const py = Math.max(y + 4, 196), price = d.price !== false;
|
||||||
|
if (price) {
|
||||||
doc.setDrawColor(...LINE);
|
doc.setDrawColor(...LINE);
|
||||||
doc.setLineWidth(0.3);
|
doc.setLineWidth(0.3);
|
||||||
doc.line(M, py, M + 150, py);
|
doc.line(M, py, M + 150, py);
|
||||||
@@ -152,6 +153,7 @@ window.LightPdf = (() => {
|
|||||||
doc.setFontSize(9);
|
doc.setFontSize(9);
|
||||||
doc.setTextColor(...MUTED);
|
doc.setTextColor(...MUTED);
|
||||||
doc.text('incl. btw, onder voorbehoud', M, py + 25);
|
doc.text('incl. btw, onder voorbehoud', M, py + 25);
|
||||||
|
}
|
||||||
|
|
||||||
// Beeld van de woning
|
// Beeld van de woning
|
||||||
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
|
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`);
|
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
|
||||||
doc.setFontSize(7);
|
doc.setFontSize(7);
|
||||||
doc.setTextColor(...MUTED);
|
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) {
|
function process(doc, d) {
|
||||||
|
|||||||
+30
-8
@@ -8,8 +8,12 @@
|
|||||||
// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met
|
// 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).
|
// 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
|
// 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
|
// TectumLight.mount(el, { model, frames, url, modelViewer, save, price }); url = adres voor de QR-code in de PDF (standaard
|
||||||
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script).
|
// 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
|
// 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.
|
// Viewer, Quick Look); op de desktop schermvullend.
|
||||||
window.TectumLight = (() => {
|
window.TectumLight = (() => {
|
||||||
@@ -66,6 +70,7 @@ window.TectumLight = (() => {
|
|||||||
function mount(root, opts) {
|
function mount(root, opts) {
|
||||||
const o = opts || {};
|
const o = opts || {};
|
||||||
const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames });
|
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 ch = clone(model.initial);
|
||||||
let unitSel = model.base.units[0].id;
|
let unitSel = model.base.units[0].id;
|
||||||
let cur = null; // { st, parts, info }
|
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>`;
|
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²`) +
|
$('.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`);
|
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-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).
|
// Keuzes (knoppen en kleuren).
|
||||||
@@ -542,6 +548,7 @@ window.TectumLight = (() => {
|
|||||||
const a = e.target.closest('[data-act]');
|
const a = e.target.closest('[data-act]');
|
||||||
if (!a || !cur) return;
|
if (!a || !cur) return;
|
||||||
if (a.dataset.act === 'pdf') savePdf();
|
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));
|
else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
|
||||||
});
|
});
|
||||||
panel.addEventListener('keydown', (e) => {
|
panel.addEventListener('keydown', (e) => {
|
||||||
@@ -608,6 +615,18 @@ window.TectumLight = (() => {
|
|||||||
}
|
}
|
||||||
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
|
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() {
|
function savePdf() {
|
||||||
const st = clone(cur.st);
|
const st = clone(cur.st);
|
||||||
st.project.naam = model.label;
|
st.project.naam = model.label;
|
||||||
@@ -615,7 +634,7 @@ window.TectumLight = (() => {
|
|||||||
try {
|
try {
|
||||||
let image = null;
|
let image = null;
|
||||||
try { image = snapshot(); } catch (err) { console.warn(err); }
|
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`);
|
.save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -652,11 +671,13 @@ window.TectumLight = (() => {
|
|||||||
io.observe(stage);
|
io.observe(stage);
|
||||||
|
|
||||||
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
|
// 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;
|
let glb = null;
|
||||||
function makeGlb() {
|
function makeGlb() {
|
||||||
if (glb && glb.parts === cur.parts) return glb;
|
if (glb && glb.parts === cur.parts) return glb;
|
||||||
if (glb) URL.revokeObjectURL(glb.url);
|
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 };
|
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
|
||||||
return glb;
|
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.
|
// 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;
|
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
|
||||||
let arView = null, arOpen = false;
|
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() {
|
function closeAr() {
|
||||||
if (!arOpen) return;
|
if (!arOpen) return;
|
||||||
arOpen = false;
|
arOpen = false;
|
||||||
@@ -701,7 +722,7 @@ window.TectumLight = (() => {
|
|||||||
arView.setAttribute('role', 'dialog');
|
arView.setAttribute('role', 'dialog');
|
||||||
arView.setAttribute('aria-modal', 'true');
|
arView.setAttribute('aria-modal', 'true');
|
||||||
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
|
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>
|
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
|
||||||
</model-viewer>
|
</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>
|
<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 = '';
|
root.innerHTML = '';
|
||||||
},
|
},
|
||||||
getChoices: () => clone(ch),
|
getChoices: () => clone(ch),
|
||||||
|
design: () => cur && design(),
|
||||||
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
|
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
|
||||||
glb: () => cur && makeGlb()
|
glb: () => cur && makeGlb()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ window.RoofModel = (() => {
|
|||||||
}
|
}
|
||||||
function coveringThumbUrl(key, px) {
|
function coveringThumbUrl(key, px) {
|
||||||
const c = COVERINGS[key];
|
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) {
|
function coveringFor(key, shapeType) {
|
||||||
const c = COVERINGS[key];
|
const c = COVERINGS[key];
|
||||||
@@ -108,7 +108,7 @@ window.RoofModel = (() => {
|
|||||||
if (!c.file) return null;
|
if (!c.file) return null;
|
||||||
const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 });
|
const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 });
|
||||||
if (typeof document !== 'undefined' && THREE.TextureLoader) {
|
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.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||||
tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM);
|
tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM);
|
||||||
tex.anisotropy = 8;
|
tex.anisotropy = 8;
|
||||||
|
|||||||
@@ -234,8 +234,8 @@ window.WallModel = (() => {
|
|||||||
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
|
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
|
||||||
};
|
};
|
||||||
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
|
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
|
||||||
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg');
|
const stoneTextureUrl = (code) => tectumUrl(STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'));
|
||||||
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png');
|
const stoneThumbUrl = (code, px) => tectumUrl(STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'));
|
||||||
// UV's in meters, dus repeat = 1/tegel.
|
// UV's in meters, dus repeat = 1/tegel.
|
||||||
const stoneMaterials = {};
|
const stoneMaterials = {};
|
||||||
function stoneMaterial(code) {
|
function stoneMaterial(code) {
|
||||||
|
|||||||
Reference in New Issue
Block a user