3 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 b8cc39275b v0.7.7: daktrim als echt profiel: voorkant 8 mm vóór de gevelbekleding, 30 mm boven het dak en over de bovenste trimHeight van de gevel (standaard 30 mm), opstand 20 mm diep met een flens van 3 mm op het dak over de bovenkant van de bekleding (RoofModel.TRIM); de bekleding loopt bij een daktrim door tot het dak; hoeken met daktrim/felsrand lopen op de buitenkant door, naast een boeideel stopt de trim ertegen; vrije uiteinden van binnenwanden afgewerkt in de binnenwandkleur (endCaps) in plaats van een zichtbare stijl; light-PDF: prijs op het voorblad rustig onder een dunne lijn, bedrag groot en vet, geen oranje vak
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:56 +02:00
MachielandClaude Opus 5.5 118ccf9098 v0.7.6: geen wolfsdak bij een overkapping (veranda, inpandig of uitpandig: het dak van een unit steekt buiten zijn vloer, HouseModel.hasCover): de keuze verdwijnt in configurator en light, een bestaand wolfsdak wordt een zadeldak met dezelfde goot en nok; afdekker op een stenen trasraam onder rabat, felsplaten of stuc (WallModel.TRAS_CAP): zetwerk achter de bekleding, 5° aflopend, neus 25 mm over de steen, onderbroken bij openingen, bij een buitenhoek door tot voorbij de buursteen, zwart/antraciet/wit (WallModel.TRIM_COLORS), in 3D, PDF-aanzichten, materialen, rapport en kosten; ventilatierooster van 40 mm op de gevel direct onder de onderplaat van alle boeidelen (RoofModel.VENT, niet boven een veranda of tegen een hogere gevel), sleuven in de textuur, aan/uit en kleur bij Dak › Boeideel, in 3D, PDF, rapport en kosten; light-versie: boeidelen of daktrim met kleur boeidelen (Dak) en één zetwerk-kleur voor afdekker en rooster (Gevel); 3D zijwaarts schuiven ook met de middelste muisknop, Shift/Ctrl/⌘ + slepen en de pijltjestoetsen met de muis boven de weergave (configurator en light, hints en sneltoetsenoverzicht bijgewerkt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:45:26 +02:00
MachielandClaude Opus 5.5 432e0f494a v0.7.5: AR en schermvullend in de light-versie: knop rechtsboven in de weergave; op een telefoon/tablet het ontwerp als GLB in model-viewer over de hele pagina met "Bekijk in je tuin" (ware grootte, WebXR/Scene Viewer/Quick Look, op iOS maakt model-viewer de USDZ; model-viewer 4.3.1, Apache-2.0, UMD met eigen three.js, pas geladen bij de eerste keer AR), op de desktop de weergave schermvullend (scrollwiel zoomt dan direct); GlbWriter (js/core/glb-writer.js): glTF 2.0 binair uit three.js-objecten, één primitive per materiaal, PBR met lineaire kleuren, texturen als JPEG/PNG op een macht van 2 tot 1024 px met de textuurmatrix in de UV's, lichtbundels en -vegen weg, nul-driehoeken eruit en lege normalen naar de vlaknormaal (valideert zonder fouten); GLB één keer per ontwerp, el.tectumLight.glb() en een testknop GLB op light.html; HouseModel.rootGroups en groundOrigin gedeeld met de render-export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:06:09 +02:00
19 changed files with 2105 additions and 110 deletions
+32 -8
View File
@@ -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
@@ -170,6 +171,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 +214,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 +231,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 +350,7 @@ 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 })`.
- **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 +362,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 +383,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 +393,16 @@ 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`.
- **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).
## Kosten en prijzen (CSV) ## Kosten en prijzen (CSV)
+42
View File
@@ -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;
}
+3 -2
View File
@@ -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.7';
// 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 = [];
@@ -77,7 +77,8 @@ const KEY_HELP = [
['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]
+211
View File
@@ -0,0 +1,211 @@
/*!
* 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. Geeft { blob, stats }.
function build(objects, opts) {
const o = opts || {};
const origin = o.origin || { x: 0, y: 0, z: 0 };
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++);
};
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);
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 };
})();
+5 -1
View File
@@ -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 },
+3
View File
@@ -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;
+61 -11
View File
@@ -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 };
@@ -1121,10 +1121,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 +1500,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 +1896,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 +1918,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 +2082,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 +2250,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 +2324,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 +2391,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 +2428,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
}; };
})(); })();
+23
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
})(); })();
+11 -8
View File
@@ -135,20 +135,23 @@ 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);
doc.setFillColor(...ORANGE); doc.setDrawColor(...LINE);
doc.roundedRect(M, py, 150, 30, 3, 3, 'F'); doc.setLineWidth(0.3);
doc.setTextColor(255, 255, 255); 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;
+151 -22
View File
@@ -8,16 +8,34 @@
// 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 }); url = adres voor de QR-code in de PDF (standaard de
// modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script).
// 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 +51,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>
@@ -81,8 +100,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 +127,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 +436,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 +464,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')) +
@@ -495,6 +526,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;
@@ -551,27 +585,23 @@ 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 };
@@ -609,20 +639,111 @@ 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.
let glb = null;
function makeGlb() {
if (glb && glb.parts === cur.parts) return glb;
if (glb) URL.revokeObjectURL(glb.url);
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts), { name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts) }));
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 verkleinen of draaien';
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="auto" ar-placement="floor">
<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 +757,19 @@ 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),
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
glb: () => cur && makeGlb()
}; };
} }
@@ -648,7 +777,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);
+80 -20
View File
@@ -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 },
@@ -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
}; };
})(); })();
+26 -2
View File
@@ -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);
+1117
View File
File diff suppressed because one or more lines are too long
+202
View File
@@ -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.
+70 -5
View File
@@ -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);
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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>