Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7215b9e62 | ||
|
|
0da1769f51 | ||
|
|
4bb1548125 | ||
|
|
73248a69c9 | ||
|
|
b8cc39275b | ||
|
|
118ccf9098 | ||
|
|
432e0f494a |
@@ -20,11 +20,12 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
|||||||
```
|
```
|
||||||
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
||||||
css/app.css alle stijl
|
css/app.css alle stijl
|
||||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF)
|
js/vendor/ three.js r128, eigen OrbitControls, jsPDF, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF),
|
||||||
|
model-viewer 4.3.1 (Apache-2.0, Google; UMD-bundel met eigen three.js, AR in de light-versie)
|
||||||
js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel
|
js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel
|
||||||
(inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen,
|
(inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen,
|
||||||
DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver
|
DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver
|
||||||
(obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen,
|
(obj-writer.js), GLB-schrijver (glb-writer.js), ZIP (zip-writer.js), labels, camerageheugen,
|
||||||
skybox, licentie, logo
|
skybox, licentie, logo
|
||||||
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
||||||
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
||||||
@@ -45,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
|
||||||
@@ -170,6 +172,10 @@ assets/ logo, texturen, skybox
|
|||||||
(`PostModel`, `dak.zijwandM2`/`dak.zijwandMontage`). Ligt de buitenkant
|
(`PostModel`, `dak.zijwandM2`/`dak.zijwandMontage`). Ligt de buitenkant
|
||||||
in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat,
|
in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat,
|
||||||
steen) erover door, gelijk met de gevel en tot onder de dakrand.
|
steen) erover door, gelijk met de gevel en tot onder de dakrand.
|
||||||
|
Bij een overkapping (veranda, inpandig of uitpandig: het dak van een unit
|
||||||
|
steekt buiten zijn vloer, `HouseModel.hasCover`) is er geen wolfsdak: de
|
||||||
|
keuze verdwijnt en een bestaand wolfsdak wordt een zadeldak met dezelfde
|
||||||
|
goot en nok.
|
||||||
- **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één
|
- **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één
|
||||||
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle
|
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle
|
||||||
units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden
|
units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden
|
||||||
@@ -209,7 +215,10 @@ assets/ logo, texturen, skybox
|
|||||||
dakplaat zet om over de rand, net vóór de gevelbekleding en zonder
|
dakplaat zet om over de rand, net vóór de gevelbekleding en zonder
|
||||||
opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de
|
opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de
|
||||||
felsplaten); een boeideel ernaast stopt gelijk met de buitenkant van zo'n
|
felsplaten); een boeideel ernaast stopt gelijk met de buitenkant van zo'n
|
||||||
rand (ook naast daktrim).
|
rand (ook naast daktrim). Onder de boeidelen zit op de gevel een
|
||||||
|
ventilatierooster van 40 mm (direct onder de onderplaat, op de
|
||||||
|
gevelbekleding; niet boven een veranda), zwart/antraciet/wit
|
||||||
|
(`RoofModel.VENT`, Woning › Dak › Boeideel; kosten `dak.ventRoosterM`).
|
||||||
- **Gevel:** gevelbekleding per woning, met override per wand en per
|
- **Gevel:** gevelbekleding per woning, met override per wand en per
|
||||||
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
|
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
|
||||||
getekend als plankentextuur die over naden doorloopt), steen (181
|
getekend als plankentextuur die over naden doorloopt), steen (181
|
||||||
@@ -223,7 +232,10 @@ assets/ logo, texturen, skybox
|
|||||||
(bijv. stuc links, hout rechts). Steen is overal een
|
(bijv. stuc links, hout rechts). Steen is overal een
|
||||||
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
|
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
|
||||||
Het trasraam (cementplaat of een eigen steen) komt onder beide, dus
|
Het trasraam (cementplaat of een eigen steen) komt onder beide, dus
|
||||||
twee stenen kan. Latwerk, doek en metselwerk lopen per gevel door over
|
twee stenen kan. Op een stenen trasraam onder rabat, felsplaten of stuc
|
||||||
|
(minder ver uitstekend) komt een afdekker: zetwerk 5° aflopend met een
|
||||||
|
neus van 25 mm over de steen, onderbroken bij openingen, zwart/antraciet/wit
|
||||||
|
(`WallModel.TRAS_CAP`, `TRIM_COLORS`; kosten `gevel.afdekkerM`). Latwerk, doek en metselwerk lopen per gevel door over
|
||||||
unit-naden. Sandwichpanelen rondom, 600 mm de grond in.
|
unit-naden. Sandwichpanelen rondom, 600 mm de grond in.
|
||||||
- **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is
|
- **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is
|
||||||
een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door
|
een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door
|
||||||
@@ -339,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 })`.
|
`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²,
|
||||||
@@ -351,8 +365,10 @@ plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
|
|||||||
west/oost; zadel- en wolfsdak: nok noord–zuid of west–oost); bij een hellend dak de nokhoogte (lessenaar:
|
west/oost; zadel- en wolfsdak: nok noord–zuid of west–oost); bij een hellend dak de nokhoogte (lessenaar:
|
||||||
hoge kant, met de helling eronder; de goot omhoog neemt de nok mee), bij een wolfsdak ook de hoogte van het
|
hoge kant, met de helling eronder; de goot omhoog neemt de nok mee), bij een wolfsdak ook de hoogte van het
|
||||||
wolfseind (bij een dak over de hele woning één keer), dakbedekking, gevelbekleding (hout met richting, felsplaten, stuc, steenstrips; kleur),
|
wolfseind (bij een dak over de hele woning één keer), dakbedekking, gevelbekleding (hout met richting, felsplaten, stuc, steenstrips; kleur),
|
||||||
het accent van het model (de afwijkende gevel; of geen), trasraam (geen of steen, met hoogte; een cementplaat-trasraam van het model wordt geen), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele
|
het accent van het model (de afwijkende gevel; of geen), trasraam (geen of steen, met hoogte; een cementplaat-trasraam van het model wordt geen), boeidelen of daktrim
|
||||||
woning alleen als de units samen een rechthoek vormen (zoals de configurator). Geen openingen,
|
(met kleur boeidelen; een model met beide houdt zijn indeling tot je kiest), zetwerk-kleur (afdekker trasraam en
|
||||||
|
ventilatierooster), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele
|
||||||
|
woning alleen als de units samen een rechthoek vormen en nooit bij een overkapping (zoals de configurator). Geen openingen,
|
||||||
binnenwanden, inrichting of elektra; die volgen het model.
|
binnenwanden, inrichting of elektra; die volgen het model.
|
||||||
- **Keuzes op een schone kopie:** `LightModel.build` past de keuzes elke keer toe op het model zelf; wat niet
|
- **Keuzes op een schone kopie:** `LightModel.build` past de keuzes elke keer toe op het model zelf; wat niet
|
||||||
afwijkt blijft precies zoals in het model. Bij een andere maat schuiven binnenwanden mee met hun unit en
|
afwijkt blijft precies zoals in het model. Bij een andere maat schuiven binnenwanden mee met hun unit en
|
||||||
@@ -370,7 +386,8 @@ plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
|
|||||||
site in een Bootstrap col-12): vanaf 720 px naast elkaar, smaller 3D boven en de keuzes eronder met prijs en
|
site in een Bootstrap col-12): vanaf 720 px naast elkaar, smaller 3D boven en de keuzes eronder met prijs en
|
||||||
PDF vast onderin beeld. Stijl alles onder `.tectum-light` (Bootstrap-reboot geneutraliseerd); Bootstrap Icons
|
PDF vast onderin beeld. Stijl alles onder `.tectum-light` (Bootstrap-reboot geneutraliseerd); Bootstrap Icons
|
||||||
nodig (`css/vendor/bootstrap-icons.min.css`). Het scrollwiel scrolt de pagina, tenzij je in de 3D hebt
|
nodig (`css/vendor/bootstrap-icons.min.css`). Het scrollwiel scrolt de pagina, tenzij je in de 3D hebt
|
||||||
geklikt of Ctrl/⌘ ingedrukt houdt. Touch: één vinger draaien, twee vingers knijpen/schuiven (eigen
|
geklikt of Ctrl/⌘ ingedrukt houdt. Zijwaarts schuiven: rechts of middelste knop slepen, Shift/Ctrl/⌘ +
|
||||||
|
slepen, of de pijltjestoetsen met de muis boven de 3D. Touch: één vinger draaien, twee vingers knijpen/schuiven (eigen
|
||||||
OrbitControls, geldt ook voor de configurator).
|
OrbitControls, geldt ook voor de configurator).
|
||||||
- **Opslaan = PDF** (`LightPdf`): voorblad "Uw woningontwerp" (beeld uit de 3D-weergave, kerncijfers, keuzes
|
- **Opslaan = PDF** (`LightPdf`): voorblad "Uw woningontwerp" (beeld uit de 3D-weergave, kerncijfers, keuzes
|
||||||
in woorden via `LightModel.describe`, indicatieve prijs, contactgegevens en een QR-code naar de modelpagina
|
in woorden via `LightModel.describe`, indicatieve prijs, contactgegevens en een QR-code naar de modelpagina
|
||||||
@@ -379,6 +396,35 @@ 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
|
||||||
|
(`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
|
||||||
|
hele pagina, met de knop "Bekijk in je tuin" (ware grootte; `ar-modes="webxr scene-viewer quick-look"`, op iOS
|
||||||
|
maakt model-viewer zelf de USDZ). model-viewer wordt pas bij de eerste keer geladen
|
||||||
|
(`js/vendor/model-viewer-umd.min.js` naast `js/light/`, of `modelViewer`/`data-model-viewer`). AR vraagt
|
||||||
|
https; Scene Viewer (Android zonder WebXR) heeft bovendien een publieke URL van de GLB nodig, een blob-URL
|
||||||
|
werkt daar niet. Op de desktop: de weergave schermvullend (Fullscreen API; het scrollwiel zoomt dan direct).
|
||||||
|
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)
|
## Kosten en prijzen (CSV)
|
||||||
|
|
||||||
|
|||||||
+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; }
|
||||||
|
|||||||
@@ -65,6 +65,15 @@
|
|||||||
.tectum-light.view-cut .tl-cutbar { display: flex; }
|
.tectum-light.view-cut .tl-cutbar { display: flex; }
|
||||||
.tectum-light .tl-cutbar input { width: 150px; accent-color: var(--tl-primary); }
|
.tectum-light .tl-cutbar input { width: 150px; accent-color: var(--tl-primary); }
|
||||||
.tectum-light .tl-cutbar output { min-width: 4.2em; font-weight: 600; font-variant-numeric: tabular-nums; }
|
.tectum-light .tl-cutbar output { min-width: 4.2em; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
/* Rechtsboven: AR (telefoon/tablet) of schermvullend (desktop). */
|
||||||
|
.tectum-light .tl-xbtn {
|
||||||
|
position: absolute; top: 10px; right: 10px; width: 35px; height: 35px; padding: 0; border: 0; border-radius: 8px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer;
|
||||||
|
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); color: var(--tl-text);
|
||||||
|
}
|
||||||
|
.tectum-light .tl-xbtn:hover { color: var(--tl-primary); }
|
||||||
|
.tectum-light .tl-xbtn[hidden] { display: none; }
|
||||||
|
.tectum-light.has-xbtn .tl-busy { right: 53px; }
|
||||||
.tectum-light .tl-hint { left: 10px; bottom: 10px; font-size: 12px; color: var(--tl-muted); padding: 3px 8px; box-shadow: none; pointer-events: none; }
|
.tectum-light .tl-hint { left: 10px; bottom: 10px; font-size: 12px; color: var(--tl-muted); padding: 3px 8px; box-shadow: none; pointer-events: none; }
|
||||||
.tectum-light .tl-busy { top: 10px; right: 10px; display: none; align-items: center; gap: 8px; padding: 6px 12px; }
|
.tectum-light .tl-busy { top: 10px; right: 10px; display: none; align-items: center; gap: 8px; padding: 6px 12px; }
|
||||||
.tectum-light.is-busy .tl-busy { display: inline-flex; }
|
.tectum-light.is-busy .tl-busy { display: inline-flex; }
|
||||||
@@ -174,6 +183,8 @@
|
|||||||
.tectum-light .tl-hint { display: none; }
|
.tectum-light .tl-hint { display: none; }
|
||||||
.tectum-light .tl-viewbar { left: 8px; right: 8px; top: 8px; }
|
.tectum-light .tl-viewbar { left: 8px; right: 8px; top: 8px; }
|
||||||
.tectum-light .tl-viewbar button { flex: 1; justify-content: center; }
|
.tectum-light .tl-viewbar button { flex: 1; justify-content: center; }
|
||||||
|
.tectum-light.has-xbtn .tl-viewbar { right: 51px; }
|
||||||
|
.tectum-light .tl-xbtn { top: 8px; right: 8px; width: 37px; height: 37px; }
|
||||||
.tectum-light .tl-cutbar { left: 8px; right: 8px; top: 50px; }
|
.tectum-light .tl-cutbar { left: 8px; right: 8px; top: 50px; }
|
||||||
.tectum-light .tl-cutbar input { flex: 1; width: auto; }
|
.tectum-light .tl-cutbar input { flex: 1; width: auto; }
|
||||||
.tectum-light .tl-busy { top: auto; bottom: 10px; right: 10px; }
|
.tectum-light .tl-busy { top: auto; bottom: 10px; right: 10px; }
|
||||||
@@ -187,3 +198,34 @@
|
|||||||
.tectum-light .tl-viewbar button .bi { display: none; }
|
.tectum-light .tl-viewbar button .bi { display: none; }
|
||||||
.tectum-light .tl-facts { grid-template-columns: 1fr; }
|
.tectum-light .tl-facts { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AR-weergave (model-viewer) over de hele pagina. Staat buiten .tectum-light: die is een container en zou het kader van
|
||||||
|
position: fixed worden. Kleur van de knop = --tl-primary. */
|
||||||
|
html.tl-noscroll, html.tl-noscroll body { overflow: hidden; }
|
||||||
|
.tl-arview {
|
||||||
|
position: fixed; inset: 0; z-index: 2147483000; display: none; background: #dfe6ec; color: #212121;
|
||||||
|
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.tl-arview.on { display: block; }
|
||||||
|
.tl-arview *, .tl-arview *::before, .tl-arview *::after { box-sizing: border-box; }
|
||||||
|
.tl-arview model-viewer { display: block; width: 100%; height: 100%; background: #dfe6ec; --poster-color: transparent; }
|
||||||
|
.tl-arview .tl-ar-top {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
padding: max(10px, env(safe-area-inset-top)) 12px 10px 16px; pointer-events: none;
|
||||||
|
}
|
||||||
|
.tl-arview .tl-ar-title { font-size: 17px; font-weight: 700; }
|
||||||
|
.tl-arview .tl-ar-close {
|
||||||
|
pointer-events: auto; width: 44px; height: 44px; margin: 0; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; color: inherit;
|
||||||
|
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.tl-arview .tl-ar-go {
|
||||||
|
position: absolute; left: 50%; bottom: calc(44px + env(safe-area-inset-bottom)); transform: translateX(-50%);
|
||||||
|
display: inline-flex; align-items: center; gap: 9px; height: 50px; margin: 0; padding: 0 24px; border: 0; border-radius: 25px;
|
||||||
|
background: #b95913; color: #fff; font-family: inherit; font-size: 16px; font-weight: 600; line-height: 1; white-space: nowrap; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.tl-arview .tl-ar-go .bi { font-size: 22px; }
|
||||||
|
.tl-arview .tl-ar-note {
|
||||||
|
position: absolute; left: 16px; right: 16px; bottom: calc(14px + env(safe-area-inset-bottom));
|
||||||
|
text-align: center; font-size: 12px; color: #5b6673; pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.4';
|
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,13 +73,14 @@ 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'],
|
||||||
['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]],
|
['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]],
|
||||||
['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]],
|
['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]],
|
||||||
['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts slepen', 'Verschuiven'], ['Scrollwiel', 'Zoomen']], true],
|
['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts of middelste knop slepen', 'Zijwaarts verschuiven'], ['Shift/Ctrl + slepen', 'Zijwaarts verschuiven'],
|
||||||
|
['Pijltjestoetsen (muis boven de 3D)', 'Verschuiven (Shift = grotere stap)'], ['Scrollwiel', 'Zoomen']], true],
|
||||||
['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true],
|
['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true],
|
||||||
['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]],
|
['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]],
|
||||||
['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true]
|
['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true]
|
||||||
@@ -100,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) {
|
||||||
@@ -137,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);
|
||||||
@@ -147,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,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;
|
||||||
}
|
}
|
||||||
@@ -232,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() {
|
||||||
@@ -266,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;
|
||||||
}
|
}
|
||||||
@@ -287,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');
|
||||||
});
|
});
|
||||||
@@ -301,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);
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// glTF 2.0 binair (GLB) uit three.js-objecten, in meters, Y omhoog; bedoeld voor AR (model-viewer: WebXR, Scene Viewer,
|
||||||
|
// Quick Look) en andere viewers. Alles in wereldcoördinaten samengevoegd tot één mesh met één primitive per materiaal
|
||||||
|
// (weinig draw calls op een telefoon). Texturen: herhaling/verschuiving (texture.matrix) zit in de UV's, beelden als
|
||||||
|
// JPEG (of PNG bij doorzichtig), maximaal MAX_TEX px. Lichteffecten (bundels, vegen: onbelicht en doorschijnend zonder
|
||||||
|
// dieptebuffer, of additief) blijven weg; die horen bij de eigen belichting, niet in een model.
|
||||||
|
|
||||||
|
window.GlbWriter = (() => {
|
||||||
|
const MAX_TEX = 1024;
|
||||||
|
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||||
|
const WRAP = { 1000: 10497, 1001: 33071, 1002: 33648 }; // three Repeat/Clamp/Mirrored -> glTF
|
||||||
|
|
||||||
|
const isEffect = (m) => m.blending !== THREE.NormalBlending || (m.isMeshBasicMaterial && m.transparent && m.depthWrite === false);
|
||||||
|
|
||||||
|
// Beeld als JPEG/PNG-bytes, afmetingen een macht van 2 (mipmaps en herhalen ook op oudere toestellen), hoogstens
|
||||||
|
// MAX_TEX; null als het (nog) niet te tekenen is. UV's zijn 0..1, dus het rekken verandert niets aan het beeld.
|
||||||
|
const pot = (n) => Math.min(MAX_TEX, Math.pow(2, Math.max(0, Math.round(Math.log2(n)))));
|
||||||
|
function imageBytes(image, png) {
|
||||||
|
try {
|
||||||
|
const w0 = image.naturalWidth || image.videoWidth || image.width, h0 = image.naturalHeight || image.videoHeight || image.height;
|
||||||
|
if (!w0 || !h0) return null;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = pot(w0); c.height = pot(h0);
|
||||||
|
c.getContext('2d').drawImage(image, 0, 0, c.width, c.height);
|
||||||
|
const bin = atob(c.toDataURL(png ? 'image/png' : 'image/jpeg', 0.85).split(',')[1]);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return { data: out, mime: png ? 'image/png' : 'image/jpeg' };
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = [];
|
||||||
|
const bin = [];
|
||||||
|
let binLength = 0, triCount = 0;
|
||||||
|
function addView(data, target) {
|
||||||
|
const pad = (4 - binLength % 4) % 4;
|
||||||
|
if (pad) { bin.push(new Uint8Array(pad)); binLength += pad; }
|
||||||
|
const view = { buffer: 0, byteOffset: binLength, byteLength: data.byteLength };
|
||||||
|
if (target) view.target = target;
|
||||||
|
bin.push(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||||
|
binLength += data.byteLength;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
const views = [];
|
||||||
|
|
||||||
|
function textureIndex(tex, png) {
|
||||||
|
if (!tex || !tex.image) return null;
|
||||||
|
if (!images.has(tex.image)) {
|
||||||
|
const img = imageBytes(tex.image, png);
|
||||||
|
if (img) { views.push(addView(img.data)); gltfImages.push({ bufferView: views.length - 1, mimeType: img.mime }); }
|
||||||
|
images.set(tex.image, img ? gltfImages.length - 1 : null);
|
||||||
|
}
|
||||||
|
const source = images.get(tex.image);
|
||||||
|
if (source === null) return null;
|
||||||
|
const sKey = `${tex.wrapS}/${tex.wrapT}`;
|
||||||
|
if (!samplers.has(sKey)) {
|
||||||
|
gltfSamplers.push({ magFilter: 9729, minFilter: 9987, wrapS: WRAP[tex.wrapS] || 10497, wrapT: WRAP[tex.wrapT] || 10497 });
|
||||||
|
samplers.set(sKey, gltfSamplers.length - 1);
|
||||||
|
}
|
||||||
|
const tKey = `${source}/${samplers.get(sKey)}`;
|
||||||
|
if (!textures.has(tKey)) { gltfTextures.push({ source, sampler: samplers.get(sKey) }); textures.set(tKey, gltfTextures.length - 1); }
|
||||||
|
return textures.get(tKey);
|
||||||
|
}
|
||||||
|
function bucketOf(m) {
|
||||||
|
if (!buckets.has(m)) {
|
||||||
|
const map = m.map && m.map.image ? m.map : null;
|
||||||
|
const tex = map ? textureIndex(map, !!m.transparent) : null;
|
||||||
|
buckets.set(m, { mat: m, map: tex !== null ? map : null, tex, pos: [], nrm: [], uv: [], idx: [], n: 0 });
|
||||||
|
}
|
||||||
|
return buckets.get(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = new THREE.Vector3(), nm = new THREE.Matrix3(), uv = new THREE.Vector2();
|
||||||
|
const pa = new THREE.Vector3(), pb = new THREE.Vector3(), pc = new THREE.Vector3(), face = new THREE.Vector3();
|
||||||
|
const visit = (root) => root && (root.updateMatrixWorld(true), root.traverseVisible(mesh => {
|
||||||
|
if (!mesh.isMesh || !mesh.geometry || !mesh.geometry.attributes.position) return;
|
||||||
|
let g = mesh.geometry;
|
||||||
|
if (!g.attributes.normal) { g = g.clone(); g.computeVertexNormals(); }
|
||||||
|
const pos = g.attributes.position, nrm = g.attributes.normal, uvA = g.attributes.uv, idx = g.index;
|
||||||
|
nm.getNormalMatrix(mesh.matrixWorld);
|
||||||
|
const flip = mesh.matrixWorld.determinant() < 0;
|
||||||
|
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||||
|
const groups = g.groups && g.groups.length ? g.groups : [{ start: 0, count: idx ? idx.count : pos.count, materialIndex: 0 }];
|
||||||
|
const remaps = new Map(); // bucket -> bronhoekpunt -> index in de bucket
|
||||||
|
groups.forEach(gr => {
|
||||||
|
const m = list[gr.materialIndex] || list[0];
|
||||||
|
if (!m || m.visible === false || isEffect(m)) return;
|
||||||
|
const b = bucketOf(m);
|
||||||
|
if (!remaps.has(b)) remaps.set(b, new Int32Array(pos.count).fill(-1));
|
||||||
|
const remap = remaps.get(b);
|
||||||
|
if (b.map) b.map.updateMatrix();
|
||||||
|
const add = (ci) => {
|
||||||
|
if (remap[ci] >= 0) return remap[ci];
|
||||||
|
v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld);
|
||||||
|
b.pos.push(v.x - origin.x, v.y - origin.y, v.z - origin.z);
|
||||||
|
// Een lege normaal (bron met nul-driehoeken) wordt de vlaknormaal: glTF eist lengte 1.
|
||||||
|
v.fromBufferAttribute(nrm, ci).applyMatrix3(nm);
|
||||||
|
if (v.lengthSq() < 1e-12) v.copy(face); else v.normalize();
|
||||||
|
b.nrm.push(v.x, v.y, v.z);
|
||||||
|
if (b.map) {
|
||||||
|
if (uvA) uv.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix); else uv.set(0, 0);
|
||||||
|
// three (flipY) heeft v = 0 onderaan het beeld, glTF bovenaan.
|
||||||
|
b.uv.push(uv.x, b.map.flipY ? 1 - uv.y : uv.y);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
// Driehoeken zonder oppervlakte (< 0,1 mm²) overslaan.
|
||||||
|
pa.fromBufferAttribute(pos, s[0]).applyMatrix4(mesh.matrixWorld);
|
||||||
|
pb.fromBufferAttribute(pos, s[1]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||||
|
pc.fromBufferAttribute(pos, s[2]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||||
|
face.crossVectors(pb, pc);
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
objects.forEach(visit);
|
||||||
|
|
||||||
|
// Accessors en materialen per bucket.
|
||||||
|
const accessors = [], materials = [], primitives = [];
|
||||||
|
let unlit = false;
|
||||||
|
const accessor = (arr, type, comps, ctype, target, minMax) => {
|
||||||
|
views.push(addView(arr, target));
|
||||||
|
const a = { bufferView: views.length - 1, componentType: ctype, count: arr.length / comps, type };
|
||||||
|
if (minMax) Object.assign(a, minMax);
|
||||||
|
accessors.push(a);
|
||||||
|
return accessors.length - 1;
|
||||||
|
};
|
||||||
|
buckets.forEach(b => {
|
||||||
|
if (!b.idx.length) return;
|
||||||
|
const m = b.mat, pos = new Float32Array(b.pos);
|
||||||
|
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
||||||
|
for (let i = 0; i < pos.length; i++) { const a = i % 3; if (pos[i] < min[a]) min[a] = pos[i]; if (pos[i] > max[a]) max[a] = pos[i]; }
|
||||||
|
const attributes = {
|
||||||
|
POSITION: accessor(pos, 'VEC3', 3, 5126, 34962, { min, max }),
|
||||||
|
NORMAL: accessor(new Float32Array(b.nrm), 'VEC3', 3, 5126, 34962)
|
||||||
|
};
|
||||||
|
if (b.map) attributes.TEXCOORD_0 = accessor(new Float32Array(b.uv), 'VEC2', 2, 5126, 34962);
|
||||||
|
const big = b.n >= 65535; // 65535 zelf mag niet in Uint16 (glTF)
|
||||||
|
const indices = accessor(big ? new Uint32Array(b.idx) : new Uint16Array(b.idx), 'SCALAR', 1, big ? 5125 : 5123, 34963);
|
||||||
|
|
||||||
|
// De app toont kleurwaarden zoals ze zijn (geen kleurbeheer); glTF-factoren zijn lineair, beelden sRGB.
|
||||||
|
const c = m.color || { r: 0.6, g: 0.6, b: 0.6 };
|
||||||
|
const alpha = m.transparent ? (m.opacity !== undefined ? m.opacity : 1) : 1;
|
||||||
|
const pbr = {
|
||||||
|
baseColorFactor: [lin(c.r), lin(c.g), lin(c.b), alpha],
|
||||||
|
metallicFactor: m.metalness !== undefined ? m.metalness : 0,
|
||||||
|
roughnessFactor: m.roughness !== undefined ? m.roughness : 0.85
|
||||||
|
};
|
||||||
|
if (b.tex !== null) pbr.baseColorTexture = { index: b.tex };
|
||||||
|
const mat = { name: m.name || m.type, pbrMetallicRoughness: pbr };
|
||||||
|
if (m.emissive && (m.emissive.r || m.emissive.g || m.emissive.b)) {
|
||||||
|
const k = m.emissiveIntensity !== undefined ? m.emissiveIntensity : 1;
|
||||||
|
mat.emissiveFactor = [m.emissive.r, m.emissive.g, m.emissive.b].map(x => Math.min(1, lin(x) * k));
|
||||||
|
}
|
||||||
|
if (m.transparent && (alpha < 1 || b.tex !== null)) mat.alphaMode = 'BLEND';
|
||||||
|
if (m.side !== THREE.FrontSide) mat.doubleSided = true;
|
||||||
|
if (m.isMeshBasicMaterial) { mat.extensions = { KHR_materials_unlit: {} }; unlit = true; }
|
||||||
|
materials.push(mat);
|
||||||
|
primitives.push({ attributes, indices, material: materials.length - 1, mode: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
asset: { version: '2.0', generator: `Tectum${o.app ? ' ' + o.app : ''}` },
|
||||||
|
scene: 0,
|
||||||
|
scenes: [{ name: o.name || 'woning', nodes: [0] }],
|
||||||
|
nodes: [{ name: o.name || 'woning', mesh: 0 }],
|
||||||
|
meshes: [{ name: o.name || 'woning', primitives }],
|
||||||
|
materials, accessors, bufferViews: views,
|
||||||
|
buffers: [{ byteLength: 0 }]
|
||||||
|
};
|
||||||
|
if (gltfImages.length) Object.assign(json, { images: gltfImages, samplers: gltfSamplers, textures: gltfTextures });
|
||||||
|
if (unlit) json.extensionsUsed = ['KHR_materials_unlit'];
|
||||||
|
const binPad = (4 - binLength % 4) % 4;
|
||||||
|
if (binPad) { bin.push(new Uint8Array(binPad)); binLength += binPad; }
|
||||||
|
json.buffers[0].byteLength = binLength;
|
||||||
|
|
||||||
|
// GLB: kop (12 bytes), JSON-blok (opgevuld met spaties), BIN-blok (opgevuld met nullen).
|
||||||
|
let jsonBytes = new TextEncoder().encode(JSON.stringify(json));
|
||||||
|
const jsonPad = (4 - jsonBytes.length % 4) % 4;
|
||||||
|
if (jsonPad) { const p = new Uint8Array(jsonBytes.length + jsonPad).fill(0x20); p.set(jsonBytes); jsonBytes = p; }
|
||||||
|
const total = 12 + 8 + jsonBytes.length + 8 + binLength;
|
||||||
|
const head = new DataView(new ArrayBuffer(20));
|
||||||
|
head.setUint32(0, 0x46546C67, true); head.setUint32(4, 2, true); head.setUint32(8, total, true);
|
||||||
|
head.setUint32(12, jsonBytes.length, true); head.setUint32(16, 0x4E4F534A, true);
|
||||||
|
const binHead = new DataView(new ArrayBuffer(8));
|
||||||
|
binHead.setUint32(0, binLength, true); binHead.setUint32(4, 0x004E4942, true);
|
||||||
|
const blob = new Blob([head, jsonBytes, binHead].concat(bin), { type: 'model/gltf-binary' });
|
||||||
|
return { blob, stats: { triangles: triCount, materials: materials.length, textures: gltfImages.length, bytes: total } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { build };
|
||||||
|
})();
|
||||||
+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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ window.COST_DEFAULTS = {
|
|||||||
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
|
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
|
||||||
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 },
|
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 },
|
||||||
felsM2: { label: 'Felsplaten gevel, staande naad (staal, klangen, ondergrond)', unit: '€/m²', value: 70 },
|
felsM2: { label: 'Felsplaten gevel, staande naad (staal, klangen, ondergrond)', unit: '€/m²', value: 70 },
|
||||||
felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 }
|
felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 },
|
||||||
|
afdekkerM: { label: 'Afdekker trasraam (aluminium zetwerk, gepoedercoat)', unit: '€/m', value: 14 },
|
||||||
|
afdekkerMontage: { label: 'Montage afdekker trasraam', unit: 's/m', value: 300 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -118,6 +120,8 @@ window.COST_DEFAULTS = {
|
|||||||
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
|
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
|
||||||
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
|
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
|
||||||
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 },
|
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 },
|
||||||
|
ventRoosterM: { label: 'Ventilatierooster onder boeideel (40 mm, aluminium)', unit: '€/m', value: 6 },
|
||||||
|
ventRoosterMontage: { label: 'Montage ventilatierooster', unit: 's/m', value: 120 },
|
||||||
staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 },
|
staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 },
|
||||||
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
|
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
|
||||||
nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 },
|
nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 },
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ window.CostModel = (() => {
|
|||||||
add(el, 'Gevel', 'Gevel', 'Felsplaten (staande naad)', fels, 'm²', fels * (G.felsM2 || 0), fels * (G.felsMontage || 0));
|
add(el, 'Gevel', 'Gevel', 'Felsplaten (staande naad)', fels, 'm²', fels * (G.felsM2 || 0), fels * (G.felsMontage || 0));
|
||||||
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
|
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
|
||||||
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
|
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
|
||||||
|
const cap = (st.trasraamCapM || 0);
|
||||||
|
add(el, 'Gevel', 'Gevel', 'Afdekker trasraam', cap, 'm', cap * (G.afdekkerM || 0), cap * (G.afdekkerMontage || 0));
|
||||||
const stuc = st.stucAreaM2 || 0;
|
const stuc = st.stucAreaM2 || 0;
|
||||||
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0));
|
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0));
|
||||||
const latM = st.battenLengthM || 0;
|
const latM = st.battenLengthM || 0;
|
||||||
@@ -239,6 +241,7 @@ window.CostModel = (() => {
|
|||||||
add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage);
|
add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage);
|
||||||
add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
|
add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
|
||||||
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
|
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
|
||||||
|
add(wo, 'Dak', 'Dak', 'Ventilatierooster onder boeideel', e.ventLengthM || 0, 'm', (e.ventLengthM || 0) * (D.ventRoosterM || 0), (e.ventLengthM || 0) * (D.ventRoosterMontage || 0));
|
||||||
}
|
}
|
||||||
if (totals.posts) {
|
if (totals.posts) {
|
||||||
const pp = totals.posts;
|
const pp = totals.posts;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+172
-12
@@ -36,7 +36,7 @@ window.HouseModel = (() => {
|
|||||||
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
|
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
|
||||||
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit', felsColor: 'antraciet' },
|
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit', felsColor: 'antraciet' },
|
||||||
cladding: 'horizontaal',
|
cladding: 'horizontaal',
|
||||||
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' },
|
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat', capColor: 'antraciet' },
|
||||||
interiorPaint: true,
|
interiorPaint: true,
|
||||||
interiorColor: '#f2efe9',
|
interiorColor: '#f2efe9',
|
||||||
ceilingColor: '#f4f3ef',
|
ceilingColor: '#f4f3ef',
|
||||||
@@ -67,7 +67,7 @@ window.HouseModel = (() => {
|
|||||||
}
|
}
|
||||||
function facadeMaterials(state, fc) {
|
function facadeMaterials(state, fc) {
|
||||||
const tr = state.settings.trasraam;
|
const tr = state.settings.trasraam;
|
||||||
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
|
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish, tr.capColor) : null;
|
||||||
const cladding = state.settings.cladding;
|
const cladding = state.settings.cladding;
|
||||||
if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null, fels: null };
|
if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null, fels: null };
|
||||||
if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null, fels: null };
|
if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null, fels: null };
|
||||||
@@ -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) {
|
||||||
@@ -1121,10 +1231,33 @@ window.HouseModel = (() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
|
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
|
||||||
|
// Geen wolfsdak boven een overkapping: wordt een zadeldak met dezelfde goot en nok.
|
||||||
|
if (houseRoofOn(state)) {
|
||||||
|
if (state.settings.roof.house.form.type === 'wolf' && state.units.some(u => roofCovers(u, cover))) state.settings.roof.house.form.type = 'zadel';
|
||||||
|
} else state.units.forEach(u => {
|
||||||
|
const f = UnitModel.roofForm(u.unit);
|
||||||
|
if (f.type === 'wolf' && roofCovers(u, cover)) UnitModel.applyRoofForm(u.unit, Object.assign(f, { type: 'zadel' }));
|
||||||
|
});
|
||||||
applyHouseRoof(state);
|
applyHouseRoof(state);
|
||||||
applyRoofJoins(state);
|
applyRoofJoins(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overkapping (veranda, inpandig of uitpandig): het dak van de unit steekt buiten zijn vloer. Een vrije dakmaat
|
||||||
|
// telt pas vanaf de kleinste paal-uitkraging; daaronder is het overstek.
|
||||||
|
function roofCovers(u, cover) {
|
||||||
|
if (!u.roof.followFloor) {
|
||||||
|
const d = u.roof.deck.floor, f = u.unit.floor.floor, m = 400;
|
||||||
|
return (d.width - f.width) / 2 + Math.abs(u.roof.offsetX || 0) > m || (d.depth - f.depth) / 2 + Math.abs(u.roof.offsetZ || 0) > m;
|
||||||
|
}
|
||||||
|
const c = cover[u.id] || {}, x = u.roof.extend || {};
|
||||||
|
return ['l', 'r', 'f', 'b'].some(k => (c[k] || 0) + (x[k] || 0) > EPS);
|
||||||
|
}
|
||||||
|
// Overkapping onder het dak van de unit (unitId) of ergens onder de woning (geen unitId): dan geen wolfsdak.
|
||||||
|
function hasCover(state, unitId) {
|
||||||
|
const cover = coverExtensions(state);
|
||||||
|
return state.units.some(u => (!unitId || u.id === unitId) && roofCovers(u, cover));
|
||||||
|
}
|
||||||
|
|
||||||
const EXTEND_MAX = 4000;
|
const EXTEND_MAX = 4000;
|
||||||
function normalizeExtend(e) {
|
function normalizeExtend(e) {
|
||||||
return Object.fromEntries(['f', 'r', 'b', 'l'].map(k => [k, Math.max(0, Math.min(EXTEND_MAX, Math.round(Number((e || {})[k]) || 0)))]));
|
return Object.fromEntries(['f', 'r', 'b', 'l'].map(k => [k, Math.max(0, Math.min(EXTEND_MAX, Math.round(Number((e || {})[k]) || 0)))]));
|
||||||
@@ -1477,10 +1610,10 @@ window.HouseModel = (() => {
|
|||||||
});
|
});
|
||||||
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
|
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 };
|
if (!build) return { group: null, stats: null, specs: null };
|
||||||
const built = WallModel.build3D(config, frame, {
|
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
|
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) };
|
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
||||||
@@ -1873,7 +2006,7 @@ window.HouseModel = (() => {
|
|||||||
};
|
};
|
||||||
const facadeDepth = (a, b, nrm) => {
|
const facadeDepth = (a, b, nrm) => {
|
||||||
const e = edgePiece(a, b, nrm);
|
const e = edgePiece(a, b, nrm);
|
||||||
return e ? Math.sign(e.dot) * WallModel.claddingOffset(e.p.buitenMateriaal, e.p.stone, e.p.stuc) : 0;
|
return e ? Math.sign(e.dot) * WallModel.claddingOffset(e.p.buitenMateriaal, e.p.stone, e.p.stuc) : null; // null: geen gevel (veranda)
|
||||||
};
|
};
|
||||||
const facadeFels = (a, b, nrm) => { const e = edgePiece(a, b, nrm); return !!(e && e.dot > 0 && e.p.fels); };
|
const facadeFels = (a, b, nrm) => { const e = edgePiece(a, b, nrm); return !!(e && e.dot > 0 && e.p.fels); };
|
||||||
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
|
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
|
||||||
@@ -1895,14 +2028,22 @@ window.HouseModel = (() => {
|
|||||||
// De rand begint op de dakrand, die op of iets vóór het wandvlak kan liggen.
|
// 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 }))
|
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);
|
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
|
||||||
if (!sg) return;
|
// Daktrim: de bekleding loopt door tot het dak, de trim valt eroverheen.
|
||||||
const above = sg.fels ? RoofModel.FELS_EDGE.aboveMM : roofEdge.edgeAbove;
|
if (!sg || (!sg.fascia && !sg.fels)) return;
|
||||||
p.battenAbove = (p.skinAbove || 0) + above - RoofModel.edgeHeight(roofEdge, sg);
|
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)));
|
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
||||||
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
||||||
extPieces.concat(interior).forEach(p => {
|
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) {
|
if (built.group && p.shiftZ) {
|
||||||
const outer = new THREE.Group();
|
const outer = new THREE.Group();
|
||||||
built.group.position.z = p.shiftZ * 0.001;
|
built.group.position.z = p.shiftZ * 0.001;
|
||||||
@@ -2051,6 +2192,21 @@ window.HouseModel = (() => {
|
|||||||
return ext.concat(parts.interior);
|
return ext.concat(parts.interior);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alle 3D-groepen van de woning (na placeGroups), bijv. voor een scène of een export.
|
||||||
|
function rootGroups(parts) {
|
||||||
|
return parts.units.map(u => u.floorGroup).concat(allWallPieces(parts).map(p => p.group), parts.units.map(u => u.roof.group),
|
||||||
|
[parts.roofEdgeGroup, parts.postsGroup, parts.chimneysGroup, parts.sandwichGroup, parts.lightingGroup, parts.elektraGroup, parts.roomsGroup, parts.fixturesGroup]).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oorsprong voor exports (m): maaiveld onder het midden van de units.
|
||||||
|
function groundOrigin(st, parts) {
|
||||||
|
const rs = parts.rects;
|
||||||
|
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
|
||||||
|
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
|
||||||
|
const ground = Math.max(...parts.units.map(u => u.layout.floorTop)) - (st.settings.groundLevel || 0);
|
||||||
|
return { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 };
|
||||||
|
}
|
||||||
|
|
||||||
function computeDimensions(parts, groundLevel) {
|
function computeDimensions(parts, groundLevel) {
|
||||||
const m2 = (mm2) => mm2 / 1e6;
|
const m2 = (mm2) => mm2 / 1e6;
|
||||||
const rects = parts.rects;
|
const rects = parts.rects;
|
||||||
@@ -2204,6 +2360,7 @@ window.HouseModel = (() => {
|
|||||||
woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3),
|
woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3),
|
||||||
woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg),
|
woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg),
|
||||||
battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) },
|
battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) },
|
||||||
|
trasraamCap: { lengthM: sumP(p => p.stats.trasraamCapM || 0), color: (WallModel.TRIM_COLORS[(pieces.find(p => p.stats.trasraamCapM > 0 && p.trasraam) || { trasraam: {} }).trasraam.capColor] || {}).label || '' },
|
||||||
isolatie,
|
isolatie,
|
||||||
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
|
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
|
||||||
plaatwerk: [
|
plaatwerk: [
|
||||||
@@ -2277,6 +2434,8 @@ window.HouseModel = (() => {
|
|||||||
overhang: parts.roofEdge.overhang,
|
overhang: parts.roofEdge.overhang,
|
||||||
soffitM2: parts.roofEdge.soffitM2,
|
soffitM2: parts.roofEdge.soffitM2,
|
||||||
trimLengthM: parts.roofEdge.trimLengthMM / 1000,
|
trimLengthM: parts.roofEdge.trimLengthMM / 1000,
|
||||||
|
ventLengthM: (parts.roofEdge.ventLengthMM || 0) / 1000,
|
||||||
|
ventColor: parts.roofEdge.vent ? (WallModel.TRIM_COLORS[parts.roofEdge.ventColor] || {}).label || '' : '',
|
||||||
trimHeight: parts.roofEdge.trimHeight,
|
trimHeight: parts.roofEdge.trimHeight,
|
||||||
spots: parts.roofEdge.spots.length
|
spots: parts.roofEdge.spots.length
|
||||||
} : null,
|
} : null,
|
||||||
@@ -2342,6 +2501,7 @@ window.HouseModel = (() => {
|
|||||||
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
||||||
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
|
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
|
||||||
if (!WallModel.isStoneFinish(state.settings.trasraam.finish)) state.settings.trasraam.finish = 'cementplaat';
|
if (!WallModel.isStoneFinish(state.settings.trasraam.finish)) state.settings.trasraam.finish = 'cementplaat';
|
||||||
|
if (!WallModel.isTrimColor(state.settings.trasraam.capColor)) state.settings.trasraam.capColor = 'antraciet';
|
||||||
state.settings.sandwich = state.settings.sandwich !== false;
|
state.settings.sandwich = state.settings.sandwich !== false;
|
||||||
if (!/^#[0-9a-f]{6}$/i.test(state.settings.interiorColor || '')) state.settings.interiorColor = def.interiorColor;
|
if (!/^#[0-9a-f]{6}$/i.test(state.settings.interiorColor || '')) state.settings.interiorColor = def.interiorColor;
|
||||||
if (!/^#[0-9a-f]{6}$/i.test(state.settings.ceilingColor || '')) state.settings.ceilingColor = def.ceilingColor;
|
if (!/^#[0-9a-f]{6}$/i.test(state.settings.ceilingColor || '')) state.settings.ceilingColor = def.ceilingColor;
|
||||||
@@ -2378,7 +2538,7 @@ window.HouseModel = (() => {
|
|||||||
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
|
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
|
||||||
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
||||||
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
||||||
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
|
houseRoofShape, houseRoofFits, hasCover, setHouseRoof, coverExtensions, EXTEND_MAX,
|
||||||
computeParts, placeGroups, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
computeParts, placeGroups, rootGroups, groundOrigin, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -271,6 +271,12 @@ window.HousePdf = (() => {
|
|||||||
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
|
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
|
||||||
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
|
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
|
||||||
faces.push({ kind: 'trasraam', pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
|
faces.push({ kind: 'trasraam', pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
|
||||||
|
// Afdekker: neus over de bovenkant van de steen (alleen in 3D onderbroken bij openingen).
|
||||||
|
if (p.stats && p.stats.trasraamCapM > 0) {
|
||||||
|
const C = WallModel.TRAS_CAP, yLo = tr.top - C.DROP_MM, yHi = tr.top + C.THICK_MM;
|
||||||
|
faces.push({ kind: 'trasraam', pts: [[-W / 2, yLo], [W / 2, yLo], [W / 2, yHi], [-W / 2, yHi]].map(([lx, ly]) => toWorld(p, lx, ly, zT + C.GAP_MM + C.THICK_MM)),
|
||||||
|
n, color: WallModel.trimHex(tr.capColor), bias: 1.5 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
|
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||||
if (WallModel.openingTopMax(op) <= y0) return;
|
if (WallModel.openingTopMax(op) <= y0) return;
|
||||||
@@ -328,6 +334,17 @@ window.HousePdf = (() => {
|
|||||||
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
|
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
|
||||||
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
|
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
|
||||||
}
|
}
|
||||||
|
// Rooster op de gevel, onder de onderplaat.
|
||||||
|
if (sg.fascia && sg.facadeUnder && edge.vent) {
|
||||||
|
const V = RoofModel.VENT, d = RoofModel.ventFace(sg) + V.THICK_MM - (sg.a.x * sg.normal.x + sg.a.z * sg.normal.z);
|
||||||
|
const vTop = [];
|
||||||
|
for (let k = 0; k <= n; k++) {
|
||||||
|
const I = lerp(sg.a, sg.b, k / n), q = { x: I.x + sg.normal.x * d, z: I.z + sg.normal.z * d };
|
||||||
|
vTop.push({ x: q.x, y: sg.topAt(q) - h, z: q.z });
|
||||||
|
}
|
||||||
|
faces.push({ kind: 'dakrand', pts: vTop.concat(vTop.map(q => ({ x: q.x, y: q.y - V.HEIGHT_MM, z: q.z })).reverse()),
|
||||||
|
n: { x: sg.normal.x, z: sg.normal.z }, color: WallModel.trimHex(edge.ventColor), bias: 2.5 });
|
||||||
|
}
|
||||||
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: RoofModel.edgeColor(edge, sg), bias: 3 });
|
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: RoofModel.edgeColor(edge, sg), bias: 3 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -371,7 +388,11 @@ window.HousePdf = (() => {
|
|||||||
items.push(WallModel.isStoneFinish(tf)
|
items.push(WallModel.isStoneFinish(tf)
|
||||||
? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] }
|
? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] }
|
||||||
: { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat });
|
: { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat });
|
||||||
|
const tc = WallModel.TRIM_COLORS[st.trasraam.capColor];
|
||||||
|
if (parts && tc && HouseModel.allWallPieces(parts).some(p => p.stats && p.stats.trasraamCapM > 0)) items.push({ label: 'Afdekker trasraam', value: tc.label, color: tc.hex });
|
||||||
}
|
}
|
||||||
|
const vc = parts && parts.roofEdge && parts.roofEdge.vent && parts.roofEdge.ventLengthMM > 0 && WallModel.TRIM_COLORS[parts.roofEdge.ventColor];
|
||||||
|
if (vc) items.push({ label: 'Ventilatierooster', value: vc.label, color: vc.hex });
|
||||||
(parts ? parts.units : state.units).forEach(u => {
|
(parts ? parts.units : state.units).forEach(u => {
|
||||||
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
|
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
|
||||||
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
|
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
|
||||||
@@ -618,6 +639,8 @@ window.HousePdf = (() => {
|
|||||||
rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']);
|
rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']);
|
||||||
}
|
}
|
||||||
if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]);
|
if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]);
|
||||||
|
if (e.ventLengthM > 0) rows.push(['Ventilatierooster', `${fmt(e.ventLengthM, 2)} m op de gevel onder de boeidelen, ${RoofModel.VENT.HEIGHT_MM} mm hoog, ${e.ventColor.toLowerCase()}`]);
|
||||||
|
if (totals.trasraamCap && totals.trasraamCap.lengthM > 0) rows.push(['Afdekker trasraam', `${fmt(totals.trasraamCap.lengthM, 2)} m, ${totals.trasraamCap.color.toLowerCase()}`]);
|
||||||
rows.push(['Boeideel-spots', e.spots + ' stuks']);
|
rows.push(['Boeideel-spots', e.spots + ' stuks']);
|
||||||
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
|
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
|
||||||
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
|
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
|
||||||
|
|||||||
+28
-18
@@ -1312,6 +1312,8 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ',');
|
const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ',');
|
||||||
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
|
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
|
||||||
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
|
const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions);
|
||||||
|
const TRIM_OPTS = Object.fromEntries(Object.entries(WallModel.TRIM_COLORS).map(([k, c]) => [k, c.label]));
|
||||||
|
const NO_WOLF = Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf'));
|
||||||
const stucColors = Object.fromEntries(Object.entries(WallModel.STUC_COLORS).map(([k, c]) => [k, c.label]));
|
const stucColors = Object.fromEntries(Object.entries(WallModel.STUC_COLORS).map(([k, c]) => [k, c.label]));
|
||||||
const felsColors = Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, c.label]));
|
const felsColors = Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, c.label]));
|
||||||
const swatchOf = (hex) => `<div class="ins-swatch" style="background:${hex}"></div>`;
|
const swatchOf = (hex) => `<div class="ins-swatch" style="background:${hex}"></div>`;
|
||||||
@@ -1352,7 +1354,10 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
(st.trasraam.enabled
|
(st.trasraam.enabled
|
||||||
? UI.range('hs-trasraam-h', 'Hoogte', st.trasraam.height, { min: 100, max: 1000, step: 10, hint: 'Vanaf bovenkant vloer' }) +
|
? UI.range('hs-trasraam-h', 'Hoogte', st.trasraam.height, { min: 100, max: 1000, step: 10, hint: 'Vanaf bovenkant vloer' }) +
|
||||||
pair(sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish),
|
pair(sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish),
|
||||||
WallModel.isStoneFinish(st.trasraam.finish) ? thumb(WallModel.stoneThumbUrl(st.trasraam.finish, 64), st.trasraam.finish) : '')
|
WallModel.isStoneFinish(st.trasraam.finish) ? thumb(WallModel.stoneThumbUrl(st.trasraam.finish, 64), st.trasraam.finish) : '') +
|
||||||
|
(WallModel.isStoneFinish(st.trasraam.finish)
|
||||||
|
? pair(sel('hs-trasraam-cap', 'Afdekker', TRIM_OPTS, st.trasraam.capColor, { hint: 'Zetwerk op de steen waar de gevel erboven minder ver uitsteekt (rabat, felsplaat, stuc): 5° aflopend, neus 25 mm over de steen' }),
|
||||||
|
swatchOf(WallModel.trimHex(st.trasraam.capColor))) : '')
|
||||||
: '') },
|
: '') },
|
||||||
{ label: 'Onderzijde', html: toggle('hs-sandwich', 'Sandwichpanelen', st.sandwich,
|
{ label: 'Onderzijde', html: toggle('hs-sandwich', 'Sandwichpanelen', st.sandwich,
|
||||||
{ hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm rondom, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` }) +
|
{ hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm rondom, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` }) +
|
||||||
@@ -1371,6 +1376,7 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender);
|
UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender);
|
||||||
UI.onRange('hs-trasraam-h', v => { if (v > 0) st.trasraam.height = v; }, after);
|
UI.onRange('hs-trasraam-h', v => { if (v > 0) st.trasraam.height = v; }, after);
|
||||||
UI.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender);
|
UI.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender);
|
||||||
|
UI.onSel('hs-trasraam-cap', v => { st.trasraam.capColor = v; }, rerender);
|
||||||
UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
|
UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
|
||||||
UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
|
UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
|
||||||
}
|
}
|
||||||
@@ -1381,9 +1387,9 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
key: 'dak', label: 'Dak', order: 30,
|
key: 'dak', label: 'Dak', order: 30,
|
||||||
groups: () => {
|
groups: () => {
|
||||||
const r = state.settings.roof, hr = r.house;
|
const r = state.settings.roof, hr = r.house;
|
||||||
const wolfOk = HouseModel.houseRoofFits(state);
|
const wolfOk = HouseModel.houseRoofFits(state) && !HouseModel.hasCover(state);
|
||||||
houseRoofUi = hr.enabled && hr.form ? roofFormUi('hs', hr.form, {
|
houseRoofUi = hr.enabled && hr.form ? roofFormUi('hs', hr.form, {
|
||||||
types: wolfOk || hr.form.type === 'wolf' ? null : Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')),
|
types: wolfOk || hr.form.type === 'wolf' ? null : NO_WOLF,
|
||||||
notice: !wolfOk && hr.form.type === 'wolf' ? 'Wolfsdak alleen als de units samen een rechthoek vormen; bij deze L-/T-vorm wordt het een zadeldak.' : null,
|
notice: !wolfOk && hr.form.type === 'wolf' ? 'Wolfsdak alleen als de units samen een rechthoek vormen; bij deze L-/T-vorm wordt het een zadeldak.' : null,
|
||||||
span: () => { const sh = HouseModel.houseRoofShape(state); return sh ? sh.span : 1000; },
|
span: () => { const sh = HouseModel.houseRoofShape(state); return sh ? sh.span : 1000; },
|
||||||
apply: () => HouseModel.applyGlobalSettings(state),
|
apply: () => HouseModel.applyGlobalSettings(state),
|
||||||
@@ -1404,8 +1410,10 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
{ label: 'Boeideel', html:
|
{ label: 'Boeideel', html:
|
||||||
UI.range('hs-overhang', 'Overstek', r.overhang, { min: RoofModel.OVERHANG_MIN, max: RoofModel.OVERHANG_MAX, step: 10, hint: 'Vanaf de gevel' }) +
|
UI.range('hs-overhang', 'Overstek', r.overhang, { min: RoofModel.OVERHANG_MIN, max: RoofModel.OVERHANG_MAX, step: 10, hint: 'Vanaf de gevel' }) +
|
||||||
UI.range('hs-fascia-h', 'Hoogte', r.fasciaHeight, { min: 150, max: 500, step: 10 }) +
|
UI.range('hs-fascia-h', 'Hoogte', r.fasciaHeight, { min: 150, max: 500, step: 10 }) +
|
||||||
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) },
|
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) +
|
||||||
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) },
|
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: 'Over de gevel; steekt 30 mm boven het dak uit (units zonder boeidelen)' }) },
|
||||||
{ label: 'Palen overkapping', html:
|
{ 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.` }) +
|
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` }) +
|
(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` }) +
|
||||||
@@ -1426,6 +1434,8 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
UI.onRange('hs-overhang', v => { if (v > 0) r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after);
|
UI.onRange('hs-overhang', v => { if (v > 0) r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after);
|
||||||
UI.onRange('hs-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after);
|
UI.onRange('hs-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after);
|
||||||
UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender);
|
UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender);
|
||||||
|
UI.onToggle('hs-vent', v => { r.vent = v; }, rerender);
|
||||||
|
UI.onSel('hs-vent-color', v => { r.ventColor = v; }, rerender);
|
||||||
UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos);
|
UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos);
|
||||||
UI.onToggle('hs-posts', v => { r.posts.enabled = v; }, rerender);
|
UI.onToggle('hs-posts', v => { r.posts.enabled = v; }, rerender);
|
||||||
UI.onToggle('hs-posts-side', v => { r.posts.sideWall = v; }, rerender);
|
UI.onToggle('hs-posts-side', v => { r.posts.sideWall = v; }, rerender);
|
||||||
@@ -1889,7 +1899,7 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
const f = (k) => `${id}-roof-${k}`;
|
const f = (k) => `${id}-roof-${k}`;
|
||||||
const groups = () => {
|
const groups = () => {
|
||||||
const g = [
|
const g = [
|
||||||
{ label: 'Dakvorm', html: sel(f('type'), 'Vorm', o.types || UnitModel.ROOF_TYPES, form.type) + (o.notice ? note(o.notice) : '') +
|
{ label: 'Dakvorm', html: sel(f('type'), 'Vorm', (typeof o.types === 'function' ? o.types() : o.types) || UnitModel.ROOF_TYPES, form.type) + (o.notice ? note(o.notice) : '') +
|
||||||
(form.type === 'lessenaar' ? sel(f('dir'), 'Hoge zijde', SIDE_SHORT, form.dir) : '') +
|
(form.type === 'lessenaar' ? sel(f('dir'), 'Hoge zijde', SIDE_SHORT, form.dir) : '') +
|
||||||
(UnitModel.hasRidge(form.type) ? sel(f('ridge'), 'Nok', UnitModel.RIDGES, form.ridge) : '') +
|
(UnitModel.hasRidge(form.type) ? sel(f('ridge'), 'Nok', UnitModel.RIDGES, form.ridge) : '') +
|
||||||
UI.range(f('low'), sloped() ? 'Goot' : 'Hoogte', form.low, { min: 2100, max: LIM.eave, step: 10, hint: `Vanaf bovenkant vloer tot onderkant dak (max ${LIM.eave} mm)` }) }
|
UI.range(f('low'), sloped() ? 'Goot' : 'Hoogte', form.low, { min: 2100, max: LIM.eave, step: 10, hint: `Vanaf bovenkant vloer tot onderkant dak (max ${LIM.eave} mm)` }) }
|
||||||
@@ -1959,6 +1969,7 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
const form = UnitModel.roofForm(u.unit);
|
const form = UnitModel.roofForm(u.unit);
|
||||||
const roofUi = roofFormUi('hp', form, {
|
const roofUi = roofFormUi('hp', form, {
|
||||||
gable: true,
|
gable: true,
|
||||||
|
types: () => HouseModel.hasCover(state, u.id) ? NO_WOLF : null,
|
||||||
span: () => {
|
span: () => {
|
||||||
const pu = partsOf();
|
const pu = partsOf();
|
||||||
const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form));
|
const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form));
|
||||||
@@ -2030,13 +2041,15 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
|
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
|
||||||
UI.onToggle('hp-steel-ridge', v => { u.roof.steelRidge = v; }, rerender);
|
UI.onToggle('hp-steel-ridge', v => { u.roof.steelRidge = v; }, rerender);
|
||||||
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
|
UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
|
||||||
|
// Een overkapping maakt van een wolfsdak een zadeldak: dan het paneel opnieuw opbouwen.
|
||||||
|
const afterCover = () => (form.type === 'wolf' ? rerender : after)();
|
||||||
['f', 'r', 'b', 'l'].forEach(k => UI.onNum('hp-ext-' + k, v => {
|
['f', 'r', 'b', 'l'].forEach(k => UI.onNum('hp-ext-' + k, v => {
|
||||||
u.roof.extend = Object.assign({ f: 0, r: 0, b: 0, l: 0 }, u.roof.extend, { [k]: Math.max(0, Math.min(HouseModel.EXTEND_MAX, v)) });
|
u.roof.extend = Object.assign({ f: 0, r: 0, b: 0, l: 0 }, u.roof.extend, { [k]: Math.max(0, Math.min(HouseModel.EXTEND_MAX, v)) });
|
||||||
}, after));
|
}, afterCover));
|
||||||
UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after);
|
UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, afterCover);
|
||||||
UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after);
|
UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, afterCover);
|
||||||
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after);
|
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, afterCover);
|
||||||
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after);
|
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, afterCover);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2334,6 +2347,8 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
const e = totals.roofEdge, rows = [];
|
const e = totals.roofEdge, rows = [];
|
||||||
if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']);
|
if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']);
|
||||||
if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']);
|
if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']);
|
||||||
|
if (e.ventLengthM > 0) rows.push([`Ventilatierooster · ${e.ventColor.toLowerCase()}`, f(e.ventLengthM, 2), 'm']);
|
||||||
|
if (totals.trasraamCap.lengthM > 0) rows.push([`Afdekker trasraam · ${totals.trasraamCap.color.toLowerCase()}`, f(totals.trasraamCap.lengthM, 2), 'm']);
|
||||||
rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
|
rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
|
||||||
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
|
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
|
||||||
}
|
}
|
||||||
@@ -2846,14 +2861,9 @@ function mountHouseEditor(container, callbacks) {
|
|||||||
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true
|
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true
|
||||||
});
|
});
|
||||||
HouseModel.placeGroups(full);
|
HouseModel.placeGroups(full);
|
||||||
const roots = full.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(full).map(p => p.group), full.units.map(u => u.roof.group),
|
|
||||||
[full.roofEdgeGroup, full.postsGroup, full.chimneysGroup, full.sandwichGroup, full.lightingGroup, full.elektraGroup, full.roomsGroup, full.fixturesGroup]).filter(Boolean);
|
|
||||||
const bx0 = Math.min(...full.rects.map(r => r.x0)), bx1 = Math.max(...full.rects.map(r => r.x1));
|
|
||||||
const bz0 = Math.min(...full.rects.map(r => r.z0)), bz1 = Math.max(...full.rects.map(r => r.z1));
|
|
||||||
const ground = Math.max(...full.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
|
|
||||||
const name = filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning';
|
const name = filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning';
|
||||||
const origin = { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 };
|
const origin = HouseModel.groundOrigin(state, full);
|
||||||
const out = ObjWriter.build(roots, { name, app: APP_VERSION, origin });
|
const out = ObjWriter.build(HouseModel.rootGroups(full), { name, app: APP_VERSION, origin });
|
||||||
const edge = full.roofEdge;
|
const edge = full.roofEdge;
|
||||||
const lights = LightingModel.lightSources(full.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, full.lighting.indoor);
|
const lights = LightingModel.lightSources(full.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, full.lighting.indoor);
|
||||||
out.files.push({ name: `${name}_lampen.py`, data: new TextEncoder().encode(ObjWriter.blenderLights(lights, { name, app: APP_VERSION, origin })) });
|
out.files.push({ name: `${name}_lampen.py`, data: new TextEncoder().encode(ObjWriter.blenderLights(lights, { name, app: APP_VERSION, origin })) });
|
||||||
|
|||||||
+14
-2
@@ -77,7 +77,11 @@ window.LightModel = (() => {
|
|||||||
const t = base.settings.trasraam, stone = WallModel.isStoneFinish(t.finish) && t.finish !== 'cementplaat';
|
const t = base.settings.trasraam, stone = WallModel.isStoneFinish(t.finish) && t.finish !== 'cementplaat';
|
||||||
return { on: !!t.enabled && stone, finish: stone ? t.finish : null, height: t.height };
|
return { on: !!t.enabled && stone, finish: stone ? t.finish : null, height: t.height };
|
||||||
})(),
|
})(),
|
||||||
lighting: { mode: base.settings.lighting.mode, direction: base.settings.lighting.direction, indoor: base.settings.lighting.indoor !== false }
|
lighting: { mode: base.settings.lighting.mode, direction: base.settings.lighting.direction, indoor: base.settings.lighting.indoor !== false },
|
||||||
|
// Boeidelen (aan = boeidelen, uit = daktrim) met kleur; een model met beide houdt zijn eigen indeling tot je kiest.
|
||||||
|
fascia: { on: base.units.some(u => u.roof.fascia !== false), color: RoofModel.FASCIA_COLORS[base.settings.roof.fasciaColor] ? base.settings.roof.fasciaColor : 'antraciet' },
|
||||||
|
// Zetwerk: één kleur voor de afdekker op het trasraam en het ventilatierooster onder de boeidelen.
|
||||||
|
trim: WallModel.isTrimColor(base.settings.roof.ventColor) ? base.settings.roof.ventColor : 'antraciet'
|
||||||
};
|
};
|
||||||
const model = { key, label: def.label, base, forms, hasAccent: acc.length > 0, frame: frameOf(key, opts && opts.frames), initial: clone(choices) };
|
const model = { key, label: def.label, base, forms, hasAccent: acc.length > 0, frame: frameOf(key, opts && opts.frames), initial: clone(choices) };
|
||||||
const st = build(model, choices);
|
const st = build(model, choices);
|
||||||
@@ -222,6 +226,9 @@ window.LightModel = (() => {
|
|||||||
accents(st).forEach(o => o.set(a ? Object.assign({ type: a.type }, a.type === 'steen' ? { stone: a.color } : a.type === 'stuc' ? { stuc: a.color } : a.type === 'fels' ? { fels: a.color } : {}) : null));
|
accents(st).forEach(o => o.set(a ? Object.assign({ type: a.type }, a.type === 'steen' ? { stone: a.color } : a.type === 'stuc' ? { stuc: a.color } : a.type === 'fels' ? { fels: a.color } : {}) : null));
|
||||||
}
|
}
|
||||||
Object.assign(st.settings.lighting, { mode: ch.lighting.mode, direction: ch.lighting.direction, indoor: ch.lighting.indoor });
|
Object.assign(st.settings.lighting, { mode: ch.lighting.mode, direction: ch.lighting.direction, indoor: ch.lighting.indoor });
|
||||||
|
st.settings.trasraam.capColor = st.settings.roof.ventColor = ch.trim;
|
||||||
|
if (ch.fascia.on !== model.initial.fascia.on) st.units.forEach(u => { u.roof.fascia = ch.fascia.on; });
|
||||||
|
st.settings.roof.fasciaColor = ch.fascia.color;
|
||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,12 +333,17 @@ window.LightModel = (() => {
|
|||||||
];
|
];
|
||||||
const tr = ch.trasraam;
|
const tr = ch.trasraam;
|
||||||
rows.push(['Trasraam', tr.on ? `Steen ${tr.finish}, ${nl(tr.height / 1000)} m hoog` : 'Geen']);
|
rows.push(['Trasraam', tr.on ? `Steen ${tr.finish}, ${nl(tr.height / 1000)} m hoog` : 'Geen']);
|
||||||
|
rows.push(['Dakrand', ch.fascia.on ? `Boeidelen ${RoofModel.FASCIA_COLORS[ch.fascia.color].label.toLowerCase()}` : 'Daktrim']);
|
||||||
|
const trimUse = trimUses(ch);
|
||||||
|
if (trimUse) rows.push(['Zetwerk', `${WallModel.TRIM_COLORS[ch.trim].label} (${trimUse})`]);
|
||||||
if (model.hasAccent) rows.push(['Accentgevel', ch.accent ? cap(facade(ch.accent.type, ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : 'Geen']);
|
if (model.hasAccent) rows.push(['Accentgevel', ch.accent ? cap(facade(ch.accent.type, ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : 'Geen']);
|
||||||
rows.push(['Buitenverlichting', LIGHTING[ch.lighting.mode] + (ch.lighting.mode === 'gevel' ? ` (${LightingModel.DIRECTIONS[ch.lighting.direction].toLowerCase()})` : '')]);
|
rows.push(['Buitenverlichting', LIGHTING[ch.lighting.mode] + (ch.lighting.mode === 'gevel' ? ` (${LightingModel.DIRECTIONS[ch.lighting.direction].toLowerCase()})` : '')]);
|
||||||
rows.push(['Binnenverlichting', ch.lighting.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
|
rows.push(['Binnenverlichting', ch.lighting.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
// Waar de zetwerk-kleur voor geldt (leeg: nergens).
|
||||||
|
const trimUses = (ch) => [ch.trasraam.on ? 'afdekker trasraam' : null, ch.fascia.on ? 'ventilatierooster' : null].filter(Boolean).join(' en ');
|
||||||
|
|
||||||
return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, create, build, check, fit, summary, describe, colorName };
|
return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, trimUses, create, build, check, fit, summary, describe, colorName };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+17
-12
@@ -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',
|
||||||
@@ -135,20 +135,25 @@ window.LightPdf = (() => {
|
|||||||
], 'Kerncijfers');
|
], 'Kerncijfers');
|
||||||
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
|
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
|
||||||
|
|
||||||
// Prijs
|
// 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;
|
||||||
doc.setFillColor(...ORANGE);
|
if (price) {
|
||||||
doc.roundedRect(M, py, 150, 30, 3, 3, 'F');
|
doc.setDrawColor(...LINE);
|
||||||
doc.setTextColor(255, 255, 255);
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(M, py, M + 150, py);
|
||||||
|
doc.setTextColor(...MUTED);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.setFontSize(10);
|
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.setFont('helvetica', 'bold');
|
||||||
doc.setFontSize(24);
|
doc.setFontSize(20);
|
||||||
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M + 7, py + 21);
|
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.setFontSize(9);
|
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
|
// 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;
|
||||||
@@ -159,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) {
|
||||||
|
|||||||
+176
-25
@@ -8,16 +8,38 @@
|
|||||||
// 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 }); url = adres voor de QR-code in de PDF (standaard de modelpagina).
|
// 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 = (() => {
|
window.TectumLight = (() => {
|
||||||
const esc = UI.esc;
|
const esc = UI.esc;
|
||||||
|
// model-viewer (Apache-2.0, eigen three.js ingebakken) pas laden bij de eerste keer AR.
|
||||||
|
const SCRIPT_SRC = (document.currentScript && document.currentScript.src) || location.href;
|
||||||
|
let mvLoading = null;
|
||||||
|
function loadModelViewer(src) {
|
||||||
|
if (window.customElements.get('model-viewer')) return Promise.resolve();
|
||||||
|
if (!mvLoading) mvLoading = new Promise((ok, fail) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = src;
|
||||||
|
s.onload = ok;
|
||||||
|
s.onerror = () => { mvLoading = null; fail(new Error(`model-viewer niet geladen (${src})`)); };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
return mvLoading;
|
||||||
|
}
|
||||||
|
const AR_DEVICE = /android|iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
||||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||||
|
|
||||||
// Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator.
|
// Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator.
|
||||||
const SECTION = { start: 1500, min: 300, max: 3000 };
|
const SECTION = { start: 1500, min: 300, max: 3000 };
|
||||||
const HINTS = { '3d': 'Slepen = draaien · rechts slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' };
|
const HINTS = { '3d': 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' };
|
||||||
|
|
||||||
// tl-wrap is het raster; .tectum-light zelf is de container voor de container queries (breedte van de tool, niet
|
// tl-wrap is het raster; .tectum-light zelf is de container voor de container queries (breedte van de tool, niet
|
||||||
// van het scherm: in de HUGO-site staat hij in een Bootstrap col-12).
|
// van het scherm: in de HUGO-site staat hij in een Bootstrap col-12).
|
||||||
@@ -33,6 +55,7 @@ window.TectumLight = (() => {
|
|||||||
<label class="tl-cutbar"><span>Snijhoogte</span>
|
<label class="tl-cutbar"><span>Snijhoogte</span>
|
||||||
<input type="range" min="${SECTION.min}" max="${SECTION.max}" step="50" value="${SECTION.start}" aria-label="Snijhoogte boven de vloer">
|
<input type="range" min="${SECTION.min}" max="${SECTION.max}" step="50" value="${SECTION.start}" aria-label="Snijhoogte boven de vloer">
|
||||||
<output>${meters(SECTION.start)}</output></label>
|
<output>${meters(SECTION.start)}</output></label>
|
||||||
|
<button type="button" class="tl-xbtn" hidden></button>
|
||||||
<div class="tl-hint"></div>
|
<div class="tl-hint"></div>
|
||||||
<div class="tl-busy" aria-live="polite"><span class="tl-spinner"></span>Berekenen…</div>
|
<div class="tl-busy" aria-live="polite"><span class="tl-spinner"></span>Berekenen…</div>
|
||||||
<div class="tl-toast" role="status" aria-live="polite"></div>
|
<div class="tl-toast" role="status" aria-live="polite"></div>
|
||||||
@@ -47,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 }
|
||||||
@@ -81,8 +105,7 @@ window.TectumLight = (() => {
|
|||||||
mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef');
|
mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef');
|
||||||
const compute = (st) => HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
|
const compute = (st) => HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
|
||||||
const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); });
|
const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); });
|
||||||
const rootsOf = (p) => p.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(p).map(x => x.group), p.units.map(u => u.roof.group),
|
const rootsOf = HouseModel.rootGroups;
|
||||||
[p.roofEdgeGroup, p.postsGroup, p.chimneysGroup, p.sandwichGroup, p.lightingGroup, p.elektraGroup, p.roomsGroup, p.fixturesGroup]).filter(Boolean);
|
|
||||||
|
|
||||||
// Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond.
|
// Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond.
|
||||||
let group = null, caps = null, view = '3d', fitted = false, center = { x: 0, z: 0, size: 10 }, sectionMM = SECTION.start;
|
let group = null, caps = null, view = '3d', fitted = false, center = { x: 0, z: 0, size: 10 }, sectionMM = SECTION.start;
|
||||||
@@ -109,6 +132,12 @@ window.TectumLight = (() => {
|
|||||||
placeCut();
|
placeCut();
|
||||||
if (view === 'plan') drawPlan();
|
if (view === 'plan') drawPlan();
|
||||||
}
|
}
|
||||||
|
// fn in de gewone 3D-weergave (dak erop, niets afgesneden), daarna terug naar de gekozen weergave.
|
||||||
|
function as3d(fn) {
|
||||||
|
const prev = view;
|
||||||
|
if (prev !== '3d') { view = '3d'; applyView(); }
|
||||||
|
try { return fn(); } finally { if (prev !== '3d') { view = prev; applyView(); } }
|
||||||
|
}
|
||||||
// Snijvlak en witte afdekking op de snijhoogte; alleen dit bij het schuiven.
|
// Snijvlak en witte afdekking op de snijhoogte; alleen dit bij het schuiven.
|
||||||
function placeCut() {
|
function placeCut() {
|
||||||
const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop));
|
const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop));
|
||||||
@@ -412,17 +441,22 @@ window.TectumLight = (() => {
|
|||||||
|
|
||||||
// Dak: per unit (of één keer bij een dak over de hele woning) vorm, richting en hoogtes; de bedekking geldt overal.
|
// Dak: per unit (of één keer bij een dak over de hele woning) vorm, richting en hoogtes; de bedekking geldt overal.
|
||||||
const wolfOk = !houseRoof || HouseModel.houseRoofFits(cur.st);
|
const wolfOk = !houseRoof || HouseModel.houseRoofFits(cur.st);
|
||||||
|
const roofTypes = HouseModel.hasCover(cur.st, houseRoof ? null : unitSel)
|
||||||
|
? Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')) : UnitModel.ROOF_TYPES;
|
||||||
const K = houseRoof ? 'house' : 'unit', c = houseRoof ? ch.house : u, f = NUM[K + 'High'].form(ch);
|
const K = houseRoof ? 'house' : 'unit', c = houseRoof ? ch.house : u, f = NUM[K + 'High'].form(ch);
|
||||||
const covItems = Object.entries(RoofModel.COVERINGS).map(([key, cv]) => ({ key, label: cv.label, hex: cv.color, img: RoofModel.coveringThumbUrl(key, 64) }));
|
const covItems = Object.entries(RoofModel.COVERINGS).map(([key, cv]) => ({ key, label: cv.label, hex: cv.color, img: RoofModel.coveringThumbUrl(key, 64) }));
|
||||||
const roofHtml = (multi && !houseRoof ? field('Unit', seg('unit', Object.fromEntries(model.base.units.map(x => [x.id, x.id])), unitSel), null, 'tl-units') : '') +
|
const roofHtml = (multi && !houseRoof ? field('Unit', seg('unit', Object.fromEntries(model.base.units.map(x => [x.id, x.id])), unitSel), null, 'tl-units') : '') +
|
||||||
field('Dakvorm', seg(K + 'Type', UnitModel.ROOF_TYPES, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) +
|
field('Dakvorm', seg(K + 'Type', roofTypes, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) +
|
||||||
(c.type === 'lessenaar' ? field('Hoge kant', seg(K + 'Dir', LightModel.SIDES, f.dir)) : '') +
|
(c.type === 'lessenaar' ? field('Hoge kant', seg(K + 'Dir', LightModel.SIDES, f.dir)) : '') +
|
||||||
(UnitModel.hasRidge(c.type) ? field('Nok', seg(K + 'Ridge', UnitModel.RIDGES, f.ridge)) : '') +
|
(UnitModel.hasRidge(c.type) ? field('Nok', seg(K + 'Ridge', UnitModel.RIDGES, f.ridge)) : '') +
|
||||||
slider(K + 'Low', c.type === 'plat' ? 'Hoogte' : 'Goothoogte', c.low, model.frame.height, 'Vanaf de vloer tot onderkant dak, aan de lage kant') +
|
slider(K + 'Low', c.type === 'plat' ? 'Hoogte' : 'Goothoogte', c.low, model.frame.height, 'Vanaf de vloer tot onderkant dak, aan de lage kant') +
|
||||||
(c.type !== 'plat' ? slider(K + 'High', c.type === 'lessenaar' ? 'Hoge kant' : 'Nokhoogte', f.high,
|
(c.type !== 'plat' ? slider(K + 'High', c.type === 'lessenaar' ? 'Hoge kant' : 'Nokhoogte', f.high,
|
||||||
[Math.ceil((f.low + 300) / LightModel.STEP) * LightModel.STEP, UnitModel.LIMITS.ridge], 'Vanaf de vloer tot onderkant dak', ` · ${nl(f.pitch, 0)}°`) : '') +
|
[Math.ceil((f.low + 300) / LightModel.STEP) * LightModel.STEP, UnitModel.LIMITS.ridge], 'Vanaf de vloer tot onderkant dak', ` · ${nl(f.pitch, 0)}°`) : '') +
|
||||||
(c.type === 'wolf' ? wolfSlider(K + 'Cut', f) : '') +
|
(c.type === 'wolf' ? wolfSlider(K + 'Cut', f) : '') +
|
||||||
swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer'));
|
swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer')) +
|
||||||
|
field('Dakrand', seg('fascia', { aan: 'Boeidelen', uit: 'Daktrim' }, ch.fascia.on ? 'aan' : 'uit')) +
|
||||||
|
(ch.fascia.on ? swatchField('Kleur boeidelen', swatches('fasciaColor',
|
||||||
|
Object.entries(RoofModel.FASCIA_COLORS).map(([key, c]) => ({ key, label: c.label, hex: '#' + c.hex.toString(16).padStart(6, '0') })), ch.fascia.color)) : '');
|
||||||
|
|
||||||
const fc = ch.facade;
|
const fc = ch.facade;
|
||||||
const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) +
|
const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) +
|
||||||
@@ -435,7 +469,9 @@ window.TectumLight = (() => {
|
|||||||
const tr = ch.trasraam;
|
const tr = ch.trasraam;
|
||||||
const trasraamHtml = field('Trasraam <small>stenen strook onderaan de gevel</small>', seg('trasraamType', { geen: 'Geen', steen: 'Steen' }, tr.on ? 'steen' : 'geen')) +
|
const trasraamHtml = field('Trasraam <small>stenen strook onderaan de gevel</small>', seg('trasraamType', { geen: 'Geen', steen: 'Steen' }, tr.on ? 'steen' : 'geen')) +
|
||||||
(tr.on ? swatchField('Steen trasraam', swatches('trasraamStone', facadeColors('steen', tr.finish), tr.finish)) : '') +
|
(tr.on ? swatchField('Steen trasraam', swatches('trasraamStone', facadeColors('steen', tr.finish), tr.finish)) : '') +
|
||||||
(tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : '');
|
(tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : '') +
|
||||||
|
(LightModel.trimUses(ch) ? swatchField(`Zetwerk <small>${LightModel.trimUses(ch)}</small>`,
|
||||||
|
swatches('trim', Object.entries(WallModel.TRIM_COLORS).map(([key, c]) => ({ key, label: c.label, hex: c.hex })), ch.trim)) : '');
|
||||||
|
|
||||||
const L = ch.lighting;
|
const L = ch.lighting;
|
||||||
const lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) +
|
const lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) +
|
||||||
@@ -468,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).
|
||||||
@@ -495,6 +532,9 @@ window.TectumLight = (() => {
|
|||||||
t.on = val === 'steen';
|
t.on = val === 'steen';
|
||||||
if (t.on && !t.finish) t.finish = fc.type === 'steen' ? fc.stone : LightModel.STONES[0];
|
if (t.on && !t.finish) t.finish = fc.type === 'steen' ? fc.stone : LightModel.STONES[0];
|
||||||
} else if (key === 'trasraamStone') next.trasraam.finish = val;
|
} else if (key === 'trasraamStone') next.trasraam.finish = val;
|
||||||
|
else if (key === 'trim') next.trim = val;
|
||||||
|
else if (key === 'fascia') next.fascia.on = val === 'aan';
|
||||||
|
else if (key === 'fasciaColor') next.fascia.color = val;
|
||||||
else if (key === 'lightMode') next.lighting.mode = val;
|
else if (key === 'lightMode') next.lighting.mode = val;
|
||||||
else if (key === 'lightDir') next.lighting.direction = val;
|
else if (key === 'lightDir') next.lighting.direction = val;
|
||||||
else return;
|
else return;
|
||||||
@@ -508,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) => {
|
||||||
@@ -551,33 +592,41 @@ window.TectumLight = (() => {
|
|||||||
|
|
||||||
// Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie.
|
// Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie.
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const W = 1600, H = 1500, prev = view, pr = renderer.getPixelRatio();
|
const W = 1600, H = 1500, other = view !== '3d', pr = renderer.getPixelRatio();
|
||||||
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
|
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||||
if (view !== '3d') {
|
const url = as3d(() => {
|
||||||
view = '3d';
|
if (other && poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
||||||
applyView();
|
|
||||||
if (poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
|
||||||
}
|
|
||||||
renderer.setPixelRatio(1);
|
renderer.setPixelRatio(1);
|
||||||
renderer.setSize(W, H, false);
|
renderer.setSize(W, H, false);
|
||||||
camera.aspect = W / H;
|
camera.aspect = W / H;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
controls.update();
|
controls.update();
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
const url = renderer.domElement.toDataURL('image/jpeg', 0.88);
|
return renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||||||
|
});
|
||||||
renderer.setPixelRatio(pr);
|
renderer.setPixelRatio(pr);
|
||||||
if (prev !== '3d') {
|
if (other) {
|
||||||
view = prev;
|
|
||||||
camera.position.copy(keep.pos);
|
camera.position.copy(keep.pos);
|
||||||
controls.target.copy(keep.target);
|
controls.target.copy(keep.target);
|
||||||
controls.update();
|
controls.update();
|
||||||
applyView();
|
|
||||||
}
|
}
|
||||||
resize();
|
resize();
|
||||||
return { url, width: W, height: H };
|
return { url, width: W, height: H };
|
||||||
}
|
}
|
||||||
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;
|
||||||
@@ -585,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);
|
||||||
@@ -609,20 +658,113 @@ window.TectumLight = (() => {
|
|||||||
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
|
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
|
||||||
const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); });
|
const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); });
|
||||||
// In een pagina: het scrollwiel scrolt de pagina, tenzij je in de 3D hebt geklikt (tot je de muis eruit haalt) of
|
// In een pagina: het scrollwiel scrolt de pagina, tenzij je in de 3D hebt geklikt (tot je de muis eruit haalt) of
|
||||||
// Ctrl/⌘ ingedrukt houdt; dan zoomt het.
|
// Ctrl/⌘ ingedrukt houdt (of schermvullend); dan zoomt het.
|
||||||
let engaged = false;
|
let engaged = false;
|
||||||
const stageEl = $('.tl-stage');
|
const stageEl = $('.tl-stage');
|
||||||
stageEl.addEventListener('pointerdown', () => { engaged = true; });
|
stageEl.addEventListener('pointerdown', () => { engaged = true; });
|
||||||
stageEl.addEventListener('pointerleave', () => { engaged = false; });
|
stageEl.addEventListener('pointerleave', () => { engaged = false; });
|
||||||
stageEl.addEventListener('wheel', (e) => {
|
stageEl.addEventListener('wheel', (e) => {
|
||||||
if (engaged || e.ctrlKey || e.metaKey || view === 'plan') return;
|
if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() === stageEl) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
||||||
}, { capture: true });
|
}, { capture: true });
|
||||||
io.observe(stage);
|
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).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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knop rechtsboven: AR op een telefoon/tablet, schermvullend op de desktop.
|
||||||
|
const xbtn = $('.tl-xbtn');
|
||||||
|
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
|
||||||
|
const canFull = !!(stageEl.requestFullscreen || stageEl.webkitRequestFullscreen);
|
||||||
|
function setXbtn() {
|
||||||
|
const full = fsEl() === stageEl;
|
||||||
|
xbtn.innerHTML = `<i class="bi bi-${AR_DEVICE ? 'badge-ar' : full ? 'fullscreen-exit' : 'fullscreen'}"></i>`;
|
||||||
|
xbtn.title = AR_DEVICE ? 'Bekijk het ontwerp in je eigen tuin (AR)' : full ? 'Schermvullend uit (Esc)' : 'Schermvullend';
|
||||||
|
xbtn.setAttribute('aria-label', xbtn.title);
|
||||||
|
}
|
||||||
|
if (AR_DEVICE || canFull) { xbtn.hidden = false; root.classList.add('has-xbtn'); setXbtn(); }
|
||||||
|
function toggleFull() {
|
||||||
|
const r = fsEl() === stageEl ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
|
||||||
|
: (stageEl.requestFullscreen || stageEl.webkitRequestFullscreen).call(stageEl);
|
||||||
|
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
|
||||||
|
}
|
||||||
|
const onFull = () => { root.classList.toggle('is-full', fsEl() === stageEl); setXbtn(); };
|
||||||
|
document.addEventListener('fullscreenchange', onFull);
|
||||||
|
document.addEventListener('webkitfullscreenchange', onFull);
|
||||||
|
|
||||||
|
// AR: model-viewer in een eigen laag over de hele pagina (buiten de tool: die is een container en dus het
|
||||||
|
// 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 draaien, met één vinger verplaatsen';
|
||||||
|
function closeAr() {
|
||||||
|
if (!arOpen) return;
|
||||||
|
arOpen = false;
|
||||||
|
arView.classList.remove('on');
|
||||||
|
document.documentElement.classList.remove('tl-noscroll');
|
||||||
|
}
|
||||||
|
const arKey = (e) => { if (arOpen && e.key === 'Escape') closeAr(); };
|
||||||
|
document.addEventListener('keydown', arKey);
|
||||||
|
function showAr(url) {
|
||||||
|
if (!arView) {
|
||||||
|
arView = document.createElement('div');
|
||||||
|
arView.className = 'tl-arview';
|
||||||
|
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="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>
|
||||||
|
<div class="tl-ar-note"></div>`;
|
||||||
|
document.body.appendChild(arView);
|
||||||
|
const mv = arView.querySelector('model-viewer'), note = arView.querySelector('.tl-ar-note');
|
||||||
|
arView.querySelector('.tl-ar-close').addEventListener('click', closeAr);
|
||||||
|
mv.addEventListener('load', () => {
|
||||||
|
note.textContent = mv.canActivateAR ? AR_NOTE : 'AR werkt niet op dit toestel of in deze browser; draaien en zoomen kan wel.';
|
||||||
|
});
|
||||||
|
mv.addEventListener('ar-status', (e) => { if (e.detail.status === 'failed') note.textContent = 'AR kon niet starten op dit toestel.'; });
|
||||||
|
}
|
||||||
|
const mv = arView.querySelector('model-viewer');
|
||||||
|
arView.setAttribute('aria-label', `${model.label} in 3D en AR`);
|
||||||
|
arView.querySelector('.tl-ar-title').textContent = model.label;
|
||||||
|
mv.alt = `3D-model van ${model.label}`;
|
||||||
|
if (mv.getAttribute('src') !== url) { arView.querySelector('.tl-ar-note').textContent = ''; mv.setAttribute('src', url); }
|
||||||
|
arView.classList.add('on');
|
||||||
|
document.documentElement.classList.add('tl-noscroll');
|
||||||
|
arOpen = true;
|
||||||
|
}
|
||||||
|
async function openAr() {
|
||||||
|
if (!cur || busy) return;
|
||||||
|
busy = true;
|
||||||
|
root.classList.add('is-busy');
|
||||||
|
try {
|
||||||
|
await new Promise(r => setTimeout(r, 30)); // eerst de spinner tekenen
|
||||||
|
const g = makeGlb();
|
||||||
|
await loadModelViewer(mvSrc);
|
||||||
|
showAr(g.url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast('De AR-weergave kon niet worden geopend.');
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
root.classList.remove('is-busy');
|
||||||
|
}
|
||||||
|
xbtn.addEventListener('click', () => { if (AR_DEVICE) openAr(); else toggleFull(); });
|
||||||
|
|
||||||
(function loop() {
|
(function loop() {
|
||||||
raf = requestAnimationFrame(loop);
|
raf = requestAnimationFrame(loop);
|
||||||
if (visible && view !== 'plan') renderer.render(scene, camera);
|
if (visible && !arOpen && view !== 'plan') renderer.render(scene, camera);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
root.classList.add('is-busy');
|
root.classList.add('is-busy');
|
||||||
@@ -636,11 +778,20 @@ window.TectumLight = (() => {
|
|||||||
unmount() {
|
unmount() {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
ro.disconnect(); io.disconnect(); offCosts(); controls.dispose();
|
ro.disconnect(); io.disconnect(); offCosts(); controls.dispose();
|
||||||
|
document.removeEventListener('keydown', arKey);
|
||||||
|
document.removeEventListener('fullscreenchange', onFull);
|
||||||
|
document.removeEventListener('webkitfullscreenchange', onFull);
|
||||||
|
closeAr();
|
||||||
|
if (arView) arView.remove();
|
||||||
|
if (glb) URL.revokeObjectURL(glb.url);
|
||||||
if (group) disposeGeo(group);
|
if (group) disposeGeo(group);
|
||||||
renderer.dispose();
|
renderer.dispose();
|
||||||
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: () => cur && makeGlb()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,7 +799,7 @@ window.TectumLight = (() => {
|
|||||||
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
|
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
|
||||||
if (el.dataset.mounted) return;
|
if (el.dataset.mounted) return;
|
||||||
el.dataset.mounted = '1';
|
el.dataset.mounted = '1';
|
||||||
try { mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; }
|
try { el.tectumLight = mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount);
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount);
|
||||||
|
|||||||
+82
-22
@@ -12,6 +12,9 @@ window.RoofModel = (() => {
|
|||||||
const DENSITY_DAKLEER_KGM2 = 1.4;
|
const DENSITY_DAKLEER_KGM2 = 1.4;
|
||||||
const TRIM_THICK_MM = 25;
|
const TRIM_THICK_MM = 25;
|
||||||
const EDGE_ABOVE_MM = 80;
|
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
|
// 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.
|
// opstand (de waterkant van een lessenaar), tot over de bovenkant van de gevelplaten.
|
||||||
const FELS_EDGE = { lipMM: 2, aboveMM: 2, heightMM: 120 };
|
const FELS_EDGE = { lipMM: 2, aboveMM: 2, heightMM: 120 };
|
||||||
@@ -23,9 +26,36 @@ window.RoofModel = (() => {
|
|||||||
fasciaHeight: 300,
|
fasciaHeight: 300,
|
||||||
spotMin: 800,
|
spotMin: 800,
|
||||||
spotMax: 1200,
|
spotMax: 1200,
|
||||||
trimHeight: 80,
|
trimHeight: 30,
|
||||||
fasciaColor: 'antraciet'
|
fasciaColor: 'antraciet',
|
||||||
|
vent: true,
|
||||||
|
ventColor: 'antraciet'
|
||||||
};
|
};
|
||||||
|
// Ventilatierooster onder de boeidelen: strook op de gevel, direct onder de onderplaat; sleuven in de textuur.
|
||||||
|
// Kleuren als de afdekker van het trasraam (WallModel.TRIM_COLORS).
|
||||||
|
const VENT = { HEIGHT_MM: 40, THICK_MM: 12, PITCH_MM: 12 };
|
||||||
|
const ventMaterials = {};
|
||||||
|
function ventMaterial(key) {
|
||||||
|
const k = WallModel.isTrimColor(key) ? key : 'antraciet';
|
||||||
|
if (ventMaterials[k]) return ventMaterials[k];
|
||||||
|
const base = new THREE.Color(WallModel.trimHex(k));
|
||||||
|
const canvas = typeof document !== 'undefined' && document.createElement('canvas');
|
||||||
|
const ctx = canvas && canvas.getContext && canvas.getContext('2d');
|
||||||
|
let mat;
|
||||||
|
if (!ctx) mat = new THREE.MeshStandardMaterial({ color: base, roughness: 0.5, metalness: 0.3 });
|
||||||
|
else {
|
||||||
|
canvas.width = 32; canvas.height = 32;
|
||||||
|
ctx.fillStyle = '#' + base.getHexString();
|
||||||
|
ctx.fillRect(0, 0, 32, 32);
|
||||||
|
ctx.fillStyle = '#' + base.clone().multiplyScalar(0.18).getHexString();
|
||||||
|
ctx.fillRect(11, 6, 11, 20); // sleuf, 4 mm breed en 25 mm hoog
|
||||||
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
|
tex.wrapS = THREE.RepeatWrapping;
|
||||||
|
tex.anisotropy = 8;
|
||||||
|
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5, metalness: 0.3 });
|
||||||
|
}
|
||||||
|
return (ventMaterials[k] = mat);
|
||||||
|
}
|
||||||
|
|
||||||
const FASCIA_COLORS = {
|
const FASCIA_COLORS = {
|
||||||
wit: { label: 'Wit', hex: 0xf2f2f0 },
|
wit: { label: 'Wit', hex: 0xf2f2f0 },
|
||||||
@@ -60,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];
|
||||||
@@ -78,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;
|
||||||
@@ -980,15 +1010,16 @@ window.RoofModel = (() => {
|
|||||||
// Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots.
|
// 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 nb = others.find(r => onRectEdge(r.rect, mid));
|
||||||
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
|
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 {
|
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],
|
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j],
|
||||||
topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [],
|
topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [],
|
||||||
overRoof: !!nb && !dropped, dropped
|
overRoof: !!nb && !dropped, dropped
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const depRaw = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) || 0 : 0));
|
const depIn = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) : 0));
|
||||||
|
const depRaw = depIn.map(d => d || 0);
|
||||||
const dep = depRaw.map(d => Math.min(0, d));
|
const dep = depRaw.map(d => Math.min(0, d));
|
||||||
const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO);
|
const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO);
|
||||||
const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5;
|
const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5;
|
||||||
@@ -1002,15 +1033,16 @@ window.RoofModel = (() => {
|
|||||||
spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni])
|
spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni])
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] }));
|
// 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
|
// Rand zonder overstek (daktrim, felsrand): ligt net vóór de gevelbekleding en loopt bij een hoek door tot de
|
||||||
// door tot de buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand.
|
// 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;
|
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 });
|
const mv = (p, nrm, d) => ({ x: p.x + nrm.x * d, z: p.z + nrm.z * d });
|
||||||
segs.forEach((sg, i) => {
|
segs.forEach((sg, i) => {
|
||||||
const ends = [['a', 'outerA', (i - 1 + n) % n], ['b', 'outerB', (i + 1) % n]];
|
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));
|
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;
|
sg[ok] = perp(sg.normal, nb.normal) && !nb.fascia && !nb.dropped ? mv(q, nb.normal, edgeOff(j)) : q;
|
||||||
});
|
});
|
||||||
@@ -1048,8 +1080,10 @@ window.RoofModel = (() => {
|
|||||||
return {
|
return {
|
||||||
segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
|
segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
|
||||||
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor,
|
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor,
|
||||||
top, edgeAbove: EDGE_ABOVE_MM,
|
vent: s.vent, ventColor: s.ventColor,
|
||||||
|
top,
|
||||||
fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
|
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.
|
// Felsrand: zetwerk van de dakplaten, zit in de prijs van de felsplaten.
|
||||||
trimLengthMM: segments.filter(sg => !sg.fascia && !sg.fels).reduce((t, sg) => t + segLen(sg), 0),
|
trimLengthMM: segments.filter(sg => !sg.fascia && !sg.fels).reduce((t, sg) => t + segLen(sg), 0),
|
||||||
felsEdgeLengthMM: segments.filter(sg => sg.fels).reduce((t, sg) => t + segLen(sg), 0),
|
felsEdgeLengthMM: segments.filter(sg => sg.fels).reduce((t, sg) => t + segLen(sg), 0),
|
||||||
@@ -1071,22 +1105,30 @@ window.RoofModel = (() => {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prismGeometry(poly, topFn, h) {
|
// uAt(p): met textuur, u langs de rand; v = 0 onder, 1 boven.
|
||||||
const toM = 0.001, n = poly.length, pos = [];
|
function prismGeometry(poly, topFn, h, uAt) {
|
||||||
|
const toM = 0.001, n = poly.length, pos = [], uvs = [];
|
||||||
const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]);
|
const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]);
|
||||||
const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]);
|
const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]);
|
||||||
|
const U = uAt ? poly.map(uAt) : null;
|
||||||
const tri = (a, b, c) => pos.push(...a, ...b, ...c);
|
const tri = (a, b, c) => pos.push(...a, ...b, ...c);
|
||||||
for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); tri(B[0], B[k + 1], B[k]); }
|
const uv = (...q) => { if (U) q.forEach(([k, v]) => uvs.push(U[k], v)); };
|
||||||
for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); tri(T[k], B[j], T[j]); }
|
for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); uv([0, 1], [k, 1], [k + 1, 1]); tri(B[0], B[k + 1], B[k]); uv([0, 0], [k + 1, 0], [k, 0]); }
|
||||||
|
for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); uv([k, 1], [k, 0], [j, 0]); tri(T[k], B[j], T[j]); uv([k, 1], [j, 0], [j, 1]); }
|
||||||
const geo = new THREE.BufferGeometry();
|
const geo = new THREE.BufferGeometry();
|
||||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||||
|
if (U) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
||||||
geo.computeVertexNormals();
|
geo.computeVertexNormals();
|
||||||
return geo;
|
return geo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gevelvlak onder een rand (afstand langs de normaal): de dakrand plus de bekleding die erbuiten steekt.
|
||||||
|
function ventFace(sg) {
|
||||||
|
return sg.a.x * sg.normal.x + sg.a.z * sg.normal.z + Math.max(0, sg.facadeDepth || 0);
|
||||||
|
}
|
||||||
// Hoogte van de rand vanaf sg.topAt naar beneden: boeideel, felsrand of daktrim (met opstand boven het dak).
|
// Hoogte van de rand vanaf sg.topAt naar beneden: boeideel, felsrand of daktrim (met opstand boven het dak).
|
||||||
function edgeHeight(edge, sg) {
|
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).
|
// Kleur van de rand in tekeningen (hex-getal).
|
||||||
function edgeColor(edge, sg) {
|
function edgeColor(edge, sg) {
|
||||||
@@ -1116,11 +1158,27 @@ window.RoofModel = (() => {
|
|||||||
});
|
});
|
||||||
const h = edgeHeight(edge, sg);
|
const h = edgeHeight(edge, sg);
|
||||||
const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial;
|
const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial;
|
||||||
polys.forEach(pl => {
|
const nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z;
|
||||||
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), mat);
|
const add = (pl, topFn, hh, m, u) => {
|
||||||
|
const mesh = new THREE.Mesh(prismGeometry(pl, topFn, hh, u), m);
|
||||||
mesh.visible = o.edgeVisible !== false;
|
mesh.visible = o.edgeVisible !== false;
|
||||||
group.add(mesh);
|
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 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 => add(pl, (p) => sg.topAt(p) - h, VENT.HEIGHT_MM, ventMaterial(edge.ventColor), u));
|
||||||
});
|
});
|
||||||
|
|
||||||
const spotR = 0.035, spotH = 0.012;
|
const spotR = 0.035, spotH = 0.012;
|
||||||
@@ -1145,13 +1203,15 @@ window.RoofModel = (() => {
|
|||||||
s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover };
|
s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover };
|
||||||
s.posts = PostModel.normalizeSettings(s.posts);
|
s.posts = PostModel.normalizeSettings(s.posts);
|
||||||
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
|
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
|
||||||
|
s.vent = s.vent !== false;
|
||||||
|
if (!WallModel.isTrimColor(s.ventColor)) s.ventColor = DEFAULT_SETTINGS.ventColor;
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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,
|
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
||||||
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
|
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
|
||||||
FELS_EDGE, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings
|
FELS_EDGE, VENT, ventFace, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
Vendored
+26
-2
@@ -4,7 +4,8 @@
|
|||||||
// hebben om het origineel te vendoren; dekt precies de subset die wall.js
|
// hebben om het origineel te vendoren; dekt precies de subset die wall.js
|
||||||
// gebruikt (`new THREE.OrbitControls(camera, domElement)`,
|
// gebruikt (`new THREE.OrbitControls(camera, domElement)`,
|
||||||
// `controls.target.set(...)`, `controls.update()`), plus interactie:
|
// `controls.target.set(...)`, `controls.update()`), plus interactie:
|
||||||
// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen;
|
// links-slepen = roteren, scrollwiel = zoomen; zijwaarts schuiven (pannen) met rechts- of middelste-knop-slepen,
|
||||||
|
// Shift/Ctrl/⌘ + links-slepen, of de pijltjestoetsen terwijl de muis boven de weergave staat;
|
||||||
// touch: één vinger = roteren, twee vingers = knijpen (zoomen) en samen schuiven (pannen).
|
// touch: één vinger = roteren, twee vingers = knijpen (zoomen) en samen schuiven (pannen).
|
||||||
//
|
//
|
||||||
// Moet NA three.min.js geladen worden (leunt op window.THREE).
|
// Moet NA three.min.js geladen worden (leunt op window.THREE).
|
||||||
@@ -50,7 +51,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
function onPointerDown(event) {
|
function onPointerDown(event) {
|
||||||
if (event.button === 2) {
|
const panButton = event.button === 2 || event.button === 1 || (event.button === 0 && (event.shiftKey || event.ctrlKey || event.metaKey));
|
||||||
|
if (event.button === 1) event.preventDefault(); // geen autoscroll
|
||||||
|
if (panButton) {
|
||||||
if (!scope.enablePan) return;
|
if (!scope.enablePan) return;
|
||||||
isPanning = true;
|
isPanning = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -138,11 +141,29 @@
|
|||||||
|
|
||||||
function onContextMenu(event) { event.preventDefault(); }
|
function onContextMenu(event) { event.preventDefault(); }
|
||||||
|
|
||||||
|
// Pijltjestoetsen: schuiven terwijl de muis boven de weergave staat (niet in een invoerveld).
|
||||||
|
let hover = false;
|
||||||
|
const onEnter = () => { hover = true; };
|
||||||
|
const onLeave = () => { hover = false; };
|
||||||
|
const KEY_PAN = { ArrowLeft: [1, 0], ArrowRight: [-1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
|
||||||
|
function onKeyDown(event) {
|
||||||
|
const d = KEY_PAN[event.key];
|
||||||
|
if (!d || !hover || !scope.enablePan || event.altKey || event.ctrlKey || event.metaKey) return;
|
||||||
|
const t = event.target, tag = t && t.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (t && t.isContentEditable)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const step = (scope.domElement.clientHeight || 400) * (event.shiftKey ? 0.25 : 0.08);
|
||||||
|
pan(d[0] * step, d[1] * step);
|
||||||
|
}
|
||||||
|
|
||||||
this.domElement.addEventListener('mousedown', onPointerDown);
|
this.domElement.addEventListener('mousedown', onPointerDown);
|
||||||
window.addEventListener('mousemove', onPointerMove);
|
window.addEventListener('mousemove', onPointerMove);
|
||||||
window.addEventListener('mouseup', onPointerUp);
|
window.addEventListener('mouseup', onPointerUp);
|
||||||
this.domElement.addEventListener('wheel', onWheel, { passive: false });
|
this.domElement.addEventListener('wheel', onWheel, { passive: false });
|
||||||
this.domElement.addEventListener('contextmenu', onContextMenu);
|
this.domElement.addEventListener('contextmenu', onContextMenu);
|
||||||
|
this.domElement.addEventListener('mouseenter', onEnter);
|
||||||
|
this.domElement.addEventListener('mouseleave', onLeave);
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
this.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
|
this.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
|
||||||
this.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
|
this.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
this.domElement.addEventListener('touchend', onTouchEnd);
|
this.domElement.addEventListener('touchend', onTouchEnd);
|
||||||
@@ -154,6 +175,9 @@
|
|||||||
window.removeEventListener('mouseup', onPointerUp);
|
window.removeEventListener('mouseup', onPointerUp);
|
||||||
scope.domElement.removeEventListener('wheel', onWheel);
|
scope.domElement.removeEventListener('wheel', onWheel);
|
||||||
scope.domElement.removeEventListener('contextmenu', onContextMenu);
|
scope.domElement.removeEventListener('contextmenu', onContextMenu);
|
||||||
|
scope.domElement.removeEventListener('mouseenter', onEnter);
|
||||||
|
scope.domElement.removeEventListener('mouseleave', onLeave);
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
scope.domElement.removeEventListener('touchstart', onTouchStart);
|
scope.domElement.removeEventListener('touchstart', onTouchStart);
|
||||||
scope.domElement.removeEventListener('touchmove', onTouchMove);
|
scope.domElement.removeEventListener('touchmove', onTouchMove);
|
||||||
scope.domElement.removeEventListener('touchend', onTouchEnd);
|
scope.domElement.removeEventListener('touchend', onTouchEnd);
|
||||||
|
|||||||
Vendored
+1117
File diff suppressed because one or more lines are too long
Vendored
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+72
-7
@@ -116,6 +116,23 @@ window.WallModel = (() => {
|
|||||||
return mat;
|
return mat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Afwerking in zetwerk (aluminium, gepoedercoat): afdekker op een stenen trasraam en het ventilatierooster onder
|
||||||
|
// de boeidelen.
|
||||||
|
const TRIM_COLORS = {
|
||||||
|
zwart: { label: 'Zwart', hex: '#1f2022' },
|
||||||
|
antraciet: { label: 'Antraciet', hex: '#3d4146' },
|
||||||
|
wit: { label: 'Wit', hex: '#f3f2ee' }
|
||||||
|
};
|
||||||
|
const isTrimColor = (key) => !!TRIM_COLORS[key];
|
||||||
|
const trimHex = (key) => (TRIM_COLORS[key] || TRIM_COLORS.antraciet).hex;
|
||||||
|
const trimMaterials = {};
|
||||||
|
function trimMaterial(key) {
|
||||||
|
const k = isTrimColor(key) ? key : 'antraciet';
|
||||||
|
return trimMaterials[k] || (trimMaterials[k] = new THREE.MeshStandardMaterial({ color: trimHex(k), roughness: 0.45, metalness: 0.3 }));
|
||||||
|
}
|
||||||
|
// Afdekker trasraam: bovenkant 5° aflopend (85° met de neus), neus 25 mm over de steen, 3 mm ervoor.
|
||||||
|
const TRAS_CAP = { SLOPE_DEG: 5, DROP_MM: 25, GAP_MM: 3, THICK_MM: 2 };
|
||||||
|
|
||||||
function claddingOffset(buitenMateriaal, stone, stuc) {
|
function claddingOffset(buitenMateriaal, stone, stuc) {
|
||||||
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
|
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
|
||||||
if (buitenMateriaal !== 'cementplaat') return 0;
|
if (buitenMateriaal !== 'cementplaat') return 0;
|
||||||
@@ -175,9 +192,9 @@ window.WallModel = (() => {
|
|||||||
|
|
||||||
// Hoogte vanaf het loopvlak (= onderkant van de wand).
|
// Hoogte vanaf het loopvlak (= onderkant van de wand).
|
||||||
const TRASRAAM_DEFAULT_MM = 350;
|
const TRASRAAM_DEFAULT_MM = 350;
|
||||||
function trasraamFor(heightMM, finish) {
|
function trasraamFor(heightMM, finish, capColor) {
|
||||||
if (!(heightMM > 0)) return null;
|
if (!(heightMM > 0)) return null;
|
||||||
return { bottom: 0, top: heightMM, finish: finish || 'cementplaat' };
|
return { bottom: 0, top: heightMM, finish: finish || 'cementplaat', capColor: isTrimColor(capColor) ? capColor : 'antraciet' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Textuur 2062 × 1125 px op 2 mm/px. JOINT_MM: hart onderste voeg boven de onderrand (gemeten 1-2 px);
|
// Textuur 2062 × 1125 px op 2 mm/px. JOINT_MM: hart onderste voeg boven de onderrand (gemeten 1-2 px);
|
||||||
@@ -217,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) {
|
||||||
@@ -849,7 +866,7 @@ window.WallModel = (() => {
|
|||||||
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
|
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
|
||||||
let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
|
let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
|
||||||
let stucAreaM2 = 0;
|
let stucAreaM2 = 0;
|
||||||
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0;
|
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0, trasraamCapM = 0;
|
||||||
let horizBeamCount = 0;
|
let horizBeamCount = 0;
|
||||||
|
|
||||||
function createExtrudedBeam(shape, depth, positionZ) {
|
function createExtrudedBeam(shape, depth, positionZ) {
|
||||||
@@ -1108,6 +1125,41 @@ window.WallModel = (() => {
|
|||||||
return { areaM2: area(mid) + area(caps), isoM3: area(mid) * tIso };
|
return { areaM2: area(mid) + area(caps), isoM3: area(mid) * tIso };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Afdekker op een stenen trasraam waar de gevel erboven minder ver uitsteekt (rabat, felsplaat, stuc): zetwerk
|
||||||
|
// over de bovenkant van de steen, achter de bekleding beginnend, aan de voorkant een neus over de steen. Onderbroken
|
||||||
|
// waar een opening door de bovenkant van het trasraam gaat; bij een buitenhoek tot voorbij de steen van de buurwand.
|
||||||
|
function stoneCap(tr) {
|
||||||
|
const C = TRAS_CAP, T = tr.top * toM;
|
||||||
|
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM, zF = zB + STONE.THICK_MM * toM;
|
||||||
|
const zIn = zF + C.GAP_MM * toM, zOut = zIn + C.THICK_MM * toM, lip = zOut - zF;
|
||||||
|
const yFront = T + C.THICK_MM * toM, yBack = yFront + Math.tan(C.SLOPE_DEG * Math.PI / 180) * (zOut - zB);
|
||||||
|
const ext = extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM + STONE.THICK_MM);
|
||||||
|
const a = -halfW - ext.left - (ext.left > 0 ? lip : 0), b = halfW + ext.right + (ext.right > 0 ? lip : 0);
|
||||||
|
const yLo = T - C.DROP_MM * toM;
|
||||||
|
const cuts = outerHoles.filter(h => h.hy0 < yBack && h.hy1 > yLo).map(h => [h.hx0, h.hx1]).sort((p, q) => p[0] - q[0]);
|
||||||
|
const runs = [];
|
||||||
|
let x = a;
|
||||||
|
cuts.forEach(([c0, c1]) => { if (c0 > x) runs.push([x, Math.min(b, c0)]); x = Math.max(x, c1); });
|
||||||
|
if (x < b) runs.push([x, b]);
|
||||||
|
const shape = new THREE.Shape([
|
||||||
|
[zB, T + 0.0005], [zIn, T + 0.0005], [zIn, yLo], [zOut, yLo], [zOut, yFront], [zB, yBack]
|
||||||
|
].map(([pz, py]) => new THREE.Vector2(pz, py)));
|
||||||
|
let lengthM = 0;
|
||||||
|
runs.forEach(([r0, r1]) => {
|
||||||
|
if (r1 - r0 <= edgeEps) return;
|
||||||
|
lengthM += r1 - r0;
|
||||||
|
if (!buitenVisible || !show('gevel')) return;
|
||||||
|
// Profiel in (z, y), langs de wand uitgetrokken: -90° om Y legt de extrusie langs -X.
|
||||||
|
const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: r1 - r0, bevelEnabled: false }), trimMaterial(tr.capColor));
|
||||||
|
mesh.rotation.y = -Math.PI / 2;
|
||||||
|
mesh.position.x = r1;
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
wallGroup3D.add(mesh);
|
||||||
|
});
|
||||||
|
return lengthM;
|
||||||
|
}
|
||||||
|
|
||||||
function stucBlock(colorKey, y0) {
|
function stucBlock(colorKey, y0) {
|
||||||
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM;
|
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM;
|
||||||
const t = STUC.THICK_MM * toM;
|
const t = STUC.THICK_MM * toM;
|
||||||
@@ -1205,6 +1257,17 @@ window.WallModel = (() => {
|
|||||||
cap.position.set(x + dir * 0.0003, h / 2, 0);
|
cap.position.set(x + dir * 0.0003, h / 2, 0);
|
||||||
if (spaanplaatVisible) wallGroup3D.add(cap);
|
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;
|
spaanplaatAreaM2 = spaanplaatResult.areaM2;
|
||||||
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
|
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
|
||||||
|
|
||||||
@@ -1229,6 +1292,7 @@ window.WallModel = (() => {
|
|||||||
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
|
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
|
||||||
trasraamStoneAreaM2 = r.areaM2;
|
trasraamStoneAreaM2 = r.areaM2;
|
||||||
trasraamIsolatieM3 = r.isoM3;
|
trasraamIsolatieM3 = r.isoM3;
|
||||||
|
if (stucKey) trasraamCapM = stoneCap(trasraam);
|
||||||
y0 = trasraam.top * toM;
|
y0 = trasraam.top * toM;
|
||||||
} else if (trasraam) {
|
} else if (trasraam) {
|
||||||
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
|
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
|
||||||
@@ -1306,6 +1370,7 @@ window.WallModel = (() => {
|
|||||||
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
|
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
|
||||||
trasraamStoneAreaM2 = r.areaM2;
|
trasraamStoneAreaM2 = r.areaM2;
|
||||||
trasraamIsolatieM3 = r.isoM3;
|
trasraamIsolatieM3 = r.isoM3;
|
||||||
|
trasraamCapM = stoneCap(trasraam);
|
||||||
} else if (trasraam) {
|
} else if (trasraam) {
|
||||||
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
|
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
|
||||||
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM
|
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM
|
||||||
@@ -1445,7 +1510,7 @@ window.WallModel = (() => {
|
|||||||
stats: {
|
stats: {
|
||||||
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
|
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
|
||||||
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
|
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
|
||||||
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2
|
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, trasraamCapM, stoneAreaM2, stoneIsolatieM3, stucAreaM2
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1674,7 +1739,7 @@ window.WallModel = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, FELS, isFelsColor, felsHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, FELS, isFelsColor, felsHex, TRIM_COLORS, isTrimColor, trimHex, trimMaterial, TRAS_CAP, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
||||||
stoneMaterial, stucMaterial, felsMaterial, claddingMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
stoneMaterial, stucMaterial, felsMaterial, claddingMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
||||||
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
||||||
createMaterials, build3D, computeSpecs
|
createMaterials, build3D, computeSpecs
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"fasciaHeight": 300,
|
"fasciaHeight": 300,
|
||||||
"spotMin": 800,
|
"spotMin": 800,
|
||||||
"spotMax": 1200,
|
"spotMax": 1200,
|
||||||
"trimHeight": 80,
|
"trimHeight": 30,
|
||||||
"fasciaColor": "antraciet"
|
"fasciaColor": "antraciet"
|
||||||
},
|
},
|
||||||
"lighting": {
|
"lighting": {
|
||||||
|
|||||||
+17
-2
@@ -19,7 +19,7 @@
|
|||||||
.page { max-width: 1400px; margin: 0 auto; }
|
.page { max-width: 1400px; margin: 0 auto; }
|
||||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
.page-head img { height: 34px; }
|
.page-head img { height: 34px; }
|
||||||
.page-head select { font: inherit; padding: 6px 10px; border: 1px solid #c5ced9; border-radius: 8px; background: #fff; }
|
.page-head select, .page-head button { font: inherit; padding: 6px 10px; border: 1px solid #c5ced9; border-radius: 8px; background: #fff; }
|
||||||
@media (max-width: 820px) { body { padding: 12px; } }
|
@media (max-width: 820px) { body { padding: 12px; } }
|
||||||
</style>
|
</style>
|
||||||
<script src="js/vendor/three.min.js"></script>
|
<script src="js/vendor/three.min.js"></script>
|
||||||
@@ -30,7 +30,11 @@
|
|||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<img src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
<img src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
||||||
<!-- Alleen voor het losse testen: op een modelpagina staat het model vast. -->
|
<!-- Alleen voor het losse testen: op een modelpagina staat het model vast. -->
|
||||||
|
<div>
|
||||||
<select id="model-pick" aria-label="Model"></select>
|
<select id="model-pick" aria-label="Model"></select>
|
||||||
|
<!-- Alleen voor het testen: de GLB die op een telefoon in AR wordt getoond (bijv. in Blender of een glTF-viewer). -->
|
||||||
|
<button type="button" id="glb-dl" title="De GLB die AR gebruikt downloaden">GLB</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="configurator" class="tectum-light"></div>
|
<div id="configurator" class="tectum-light"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,6 +44,7 @@
|
|||||||
<script src="js/core/view-layers.js"></script>
|
<script src="js/core/view-layers.js"></script>
|
||||||
<script src="js/core/ui-kit.js"></script>
|
<script src="js/core/ui-kit.js"></script>
|
||||||
<script src="js/core/skybox.js"></script>
|
<script src="js/core/skybox.js"></script>
|
||||||
|
<script src="js/core/glb-writer.js"></script>
|
||||||
<script src="js/vendor/jspdf.umd.min.js"></script>
|
<script src="js/vendor/jspdf.umd.min.js"></script>
|
||||||
<script src="js/vendor/qrcode.js"></script>
|
<script src="js/vendor/qrcode.js"></script>
|
||||||
<script src="js/core/pdf-sheet.js"></script>
|
<script src="js/core/pdf-sheet.js"></script>
|
||||||
@@ -75,7 +80,17 @@
|
|||||||
const pick = document.getElementById('model-pick');
|
const pick = document.getElementById('model-pick');
|
||||||
pick.innerHTML = Object.entries(HouseModel.MODELS).map(([k, m]) => `<option value="${k}"${k === model ? ' selected' : ''}>${m.label}</option>`).join('');
|
pick.innerHTML = Object.entries(HouseModel.MODELS).map(([k, m]) => `<option value="${k}"${k === model ? ' selected' : ''}>${m.label}</option>`).join('');
|
||||||
pick.addEventListener('change', () => { location.search = '?model=' + pick.value; });
|
pick.addEventListener('change', () => { location.search = '?model=' + pick.value; });
|
||||||
document.getElementById('configurator').dataset.model = model;
|
const el = document.getElementById('configurator'), btn = document.getElementById('glb-dl');
|
||||||
|
el.dataset.model = model;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const g = el.tectumLight && el.tectumLight.glb();
|
||||||
|
if (!g) return;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = g.url;
|
||||||
|
a.download = `${model}.glb`;
|
||||||
|
a.click();
|
||||||
|
btn.textContent = `GLB · ${(g.stats.bytes / 1e6).toLocaleString('nl-NL', { maximumFractionDigits: 1 })} MB · ${g.stats.triangles.toLocaleString('nl-NL')} driehoeken`;
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script src="js/light/light.js"></script>
|
<script src="js/light/light.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user