Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7215b9e62 | ||
|
|
0da1769f51 | ||
|
|
4bb1548125 | ||
|
|
73248a69c9 | ||
|
|
b8cc39275b | ||
|
|
118ccf9098 | ||
|
|
432e0f494a | ||
|
|
decbbb4b72 | ||
|
|
51921650e3 | ||
|
|
89cadbebf7 |
@@ -20,16 +20,18 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
||||
```
|
||||
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
||||
css/app.css alle stijl
|
||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF
|
||||
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
|
||||
(inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen,
|
||||
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
|
||||
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
||||
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
||||
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
|
||||
PostModel: palen onder een overkapping/veranda (dak per unit te verlengen)
|
||||
PostModel: palen onder een overkapping/veranda (dak per unit te verlengen), of een dichte
|
||||
zijwand op een vrije hoek
|
||||
js/house/chimney-model.js ChimneyModel: schoorsteen tegen een buitenwand (hol, sparing in de wand zoals een raam,
|
||||
ook over een naad, zonder hout erin; sparing in het dak met raveelwerk (stijlen, balken, kreupele
|
||||
balken), de beplating eindigt op het kader; onderbreekt de dakrand; een nokbalk stopt ervoor;
|
||||
@@ -44,6 +46,10 @@ js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
|
||||
js/house/ HouseModel (woning) + woning-PDF + CAD-export (house-dxf.js) + BIM-export (house-ifc.js) + woning-editor
|
||||
js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
|
||||
js/app.js menubalk, versienummer, actieve editor
|
||||
js/crm.js koppeling met de Van Emous CRM (alleen in de Docker-stack, zie "CRM")
|
||||
light.html light-versie (modelpagina's), los te openen: light.html?model=amstel
|
||||
js/light/ LightModel (keuzes op een model toepassen, indelingscontrole, kerncijfers), LightPdf (klant-PDF) + light-UI
|
||||
css/light.css stijl light-versie, alles onder .tectum-light
|
||||
json/defaults/ referentie-JSON's (niet runtime ingeladen)
|
||||
kosten/ intern.csv (eigen waarden) + leveranciers.csv (live prijzen), bij het starten ingeladen
|
||||
assets/ logo, texturen, skybox
|
||||
@@ -85,7 +91,11 @@ assets/ logo, texturen, skybox
|
||||
en plafond naar de meterkast (strekkende meters per type, draad, buis,
|
||||
steekverbinders op module-naden), materiaallijst en eigen PDF-blad.
|
||||
Buizen minstens 50 mm uit elkaar (gebundeld alleen het laatste stuk naar
|
||||
de meterkast). Vloer-wcd's met leiding door de vloer. Elektra ook op de
|
||||
de meterkast). Nooit door een raam of deur (`makeOpeningTest`): een
|
||||
stijgleiding boven/onder een opening (wcd onder een raam) gaat eerst op
|
||||
dezelfde hoogte opzij tot naast de opening, langs dezelfde wand met een
|
||||
deur of raam ertussen via het plafond, gevellampen om de gevelhoeken heen,
|
||||
en uit elkaar schuiven van buizen eindigt nooit in een opening. Vloer-wcd's met leiding door de vloer. Elektra ook op de
|
||||
PDF-bladen per wand, vloer en dakvlak (plafond). Alles verplaatsbaar door
|
||||
te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per
|
||||
meter, boorgaten (geteld op stijlen, regels en balken), dozen,
|
||||
@@ -153,7 +163,19 @@ assets/ logo, texturen, skybox
|
||||
- **Overkapping:** bij een dak over de hele woning met `cover` loopt het
|
||||
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
|
||||
terras naast een kortere module), met een afgewerkte onderplaat in de
|
||||
kleur van het boeideel (`HouseModel.coverExtensions`).
|
||||
kleur van het boeideel (`HouseModel.coverExtensions`). Palen
|
||||
(Woning › Dak › Palen overkapping) komen onder de vrije dakrand; met
|
||||
"Zijwand" krijgt een vrije hoek geen paal maar een gestuukte wand van
|
||||
200 mm van de dakhoek terug tot de gevel, langs de zijkant van de
|
||||
overkapping (bij een hoek tussen twee vrije randen de kant met de kortste
|
||||
weg naar de gevel); in 3D, plattegrond, PDF, DXF en kosten
|
||||
(`PostModel`, `dak.zijwandM2`/`dak.zijwandMontage`). Ligt de buitenkant
|
||||
in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat,
|
||||
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
|
||||
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
|
||||
@@ -188,18 +210,32 @@ assets/ logo, texturen, skybox
|
||||
aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak
|
||||
zonder spots). De rand van het lage dak loopt dan door tot de gevel van
|
||||
het hoge, of langs een zijgevel tot de volle overstek (onder de hoge
|
||||
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit.
|
||||
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit. Hebben
|
||||
dak én gevel onder een rand felsplaten, dan komt er geen boeideel: de
|
||||
dakplaat zet om over de rand, net vóór de gevelbekleding en zonder
|
||||
opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de
|
||||
felsplaten); een boeideel ernaast stopt gelijk met de buitenkant van zo'n
|
||||
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
|
||||
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
|
||||
getekend als plankentextuur die over naden doorloopt), steen (181
|
||||
texturen) of stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
|
||||
per woning, `WallModel.STUC`). Een zijde kan gesplitst worden (wand ›
|
||||
texturen), stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
|
||||
per woning, `WallModel.STUC`) of felsplaten (staande naad, banen van
|
||||
500 mm, zink/antraciet/zwart; opbouw als verticale rabat, kleuren gedeeld
|
||||
met de felsplaten op het dak, `WallModel.FELS`; kosten `gevel.felsM2` en
|
||||
`gevel.felsMontage`). Een zijde kan gesplitst worden (wand ›
|
||||
Wandstukken › Splitsen op, gemeten vanaf de linker vloerhoek van buitenaf):
|
||||
elk stuk wordt een eigen wand-element en kan een eigen gevel krijgen
|
||||
(bijv. stuc links, hout rechts). Steen is overal een
|
||||
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
|
||||
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.
|
||||
- **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is
|
||||
een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door
|
||||
@@ -310,6 +346,86 @@ assets/ logo, texturen, skybox
|
||||
metaal, doorzichtigheid glas, gloed lampen); textuurherhaling zit in de
|
||||
UV's (`ObjWriter`).
|
||||
|
||||
## Light-versie (modelpagina's)
|
||||
|
||||
Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via
|
||||
`light.html?model=<sleutel uit HouseModel.MODELS>`). Inbedden: de stylesheets en scripts uit `light.html`
|
||||
plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
|
||||
`TectumLight.mount(el, { model, frames, url, modelViewer, save })`. De engine hoeft niet naast de pagina te staan:
|
||||
texturen, kleurstalen en `kosten/*.csv` laden via `tectumUrl()` (`js/core/geometry.js`) vanaf de map van de scripts
|
||||
(bijv. `/configurator/` op een modelpagina van de site); paden in de data blijven relatief.
|
||||
|
||||
- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont
|
||||
alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
|
||||
inhoud en gewicht. Geen uren, zaaglijst, inkoop of kostenopbouw.
|
||||
- **Weergave:** 3D (afgewerkt, dag-omgeving), Doorsnede (wanden op een instelbare hoogte, standaard 1,5 m en hoogstens de laagste goot of de hoogte van een plat dak, afgesneden en wit afgedekt
|
||||
met `HouseModel.sectionCaps`, dezelfde als de configurator; dak, dakrand, palen en lampen weg) en een
|
||||
2D-plattegrond (ruimtes met netto m², wanden, kozijnen met draairichting, inrichting, dakrand, buitenmaten).
|
||||
- **Keuzes:** per unit breedte, diepte, goothoogte en dakvorm met richting (lessenaar: hoge kant noord/zuid/
|
||||
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
|
||||
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), boeidelen of daktrim
|
||||
(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.
|
||||
- **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
|
||||
schuift wat tegen de oost- of zuidgevel staat (binnenwand-eindpunt, inrichting) mee met die gevel (`BAND`).
|
||||
Bij een andere hoogte gaan de binnenwanden van die unit evenveel mee. Een andere dakvorm dan die van het
|
||||
model krijgt de helling uit `LightModel.PITCH` (lessenaar 10°, zadel 30°, wolfsdak 35°).
|
||||
- **Kaders:** standaard `UnitModel.LIMITS` (maat 2,4–14 m, max 4,7 m in één richting; hoogte 2,4–3 m), per
|
||||
model te beperken in `LightModel.FRAMES` of per pagina met `frames` (zelfde vorm; `null` = maat staat vast).
|
||||
- **Indelingscontrole** (`LightModel.check`): een keuze waarbij ramen of deuren niet meer passen
|
||||
(verschoven, ingeklemd, te hoog of over een naad), een binnenwand buiten de units valt, inrichting door
|
||||
een gevel steekt, ruimtes verdwijnen of units overlappen, wordt geweigerd; vergeleken met het model zelf.
|
||||
Bij een maat wordt de dichtstbijzijnde maat gezocht die nog past.
|
||||
- **Indeling:** paneel als accordeon (Afmetingen, Dak, Gevel, Verlichting; één open, de kop vat de keuze samen),
|
||||
kerncijfers en prijs met PDF-knop onderaan. Responsief op de breedte van de tool zelf (container queries; in de
|
||||
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
|
||||
nodig (`css/vendor/bootstrap-icons.min.css`). Het scrollwiel scrolt de pagina, tenzij je in de 3D hebt
|
||||
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).
|
||||
- **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
|
||||
op de site, of `data-url`/`url` van de pagina), dan aanzichten en plattegrond
|
||||
(`HousePdf.generate(state, { customer: true, doc })`: zonder onderdelenlijst, elementcodes, elektra en
|
||||
kosten) en als laatste het stappenplan van de site ("Hoe verloopt het traject?") met een blok over maatwerk
|
||||
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
|
||||
in `LightPdf.COMPANY` en `STEPS`.
|
||||
- **Eigen opslaan** (`save: { label, icon, onSave }`): vervangt de PDF-knop, bijv. "Ontwerp aanvragen" op de site.
|
||||
`onSave` krijgt `{ design, label, key, info, choices, url }`: `design` is de house-design-JSON zoals de
|
||||
configurator die opslaat (de CRM maakt daar het gekozen model van), `info` de kerncijfers met prijs, `choices` de
|
||||
keuzes in woorden. Zonder `save` (en via `el.tectumLight.design()`) hetzelfde ontwerp.
|
||||
- **AR / schermvullend:** knop rechtsboven in de weergave. Op een telefoon of tablet: het ontwerp als GLB
|
||||
(`GlbWriter`, afgewerkte woning zoals in 3D, één primitive per materiaal, texturen als JPEG/PNG tot 1024 px,
|
||||
lichtbundels weg; maaiveld onder het midden = oorsprong, `HouseModel.groundOrigin`) in model-viewer, over de
|
||||
hele pagina, met de knop "Bekijk in je tuin" (ware grootte; `ar-modes="webxr scene-viewer quick-look"`, op iOS
|
||||
maakt model-viewer zelf de USDZ). model-viewer wordt pas bij de eerste keer geladen
|
||||
(`js/vendor/model-viewer-umd.min.js` naast `js/light/`, of `modelViewer`/`data-model-viewer`). AR vraagt
|
||||
https; Scene Viewer (Android zonder WebXR) heeft bovendien een publieke URL van de GLB nodig, een blob-URL
|
||||
werkt daar niet. Op de desktop: de weergave schermvullend (Fullscreen API; het scrollwiel zoomt dan direct).
|
||||
Per ontwerp wordt de GLB één keer gemaakt; `el.tectumLight.glb()` geeft `{ blob, url, stats }` (op
|
||||
`light.html` de testknop GLB).
|
||||
|
||||
## CRM (woningen van leads en klanten)
|
||||
|
||||
Alleen in de Docker-stack (`../docker`), waar Tectum op `tectum.<domein>` achter het wachtwoord staat. Caddy zet
|
||||
`api/crm/*` daar door naar de CRM (`/api/tectum/*`) met de sleutel `TECTUM_CRM_KEY`; de browser ziet die nooit.
|
||||
|
||||
- **Nieuw** toont onder de standaardmodellen de woningen uit de CRM (zoeken op woning, naam, relatie of plaats);
|
||||
kiezen opent de nieuwste versie in een eigen tab (wolkje in de tab, naam "woning · vN").
|
||||
- **`?woning=<id>`** opent die woning direct (de knop "Bewerken in Tectum" bij een woning in de CRM).
|
||||
- **Opslaan in CRM** (bovenbalk, Bestand, Ctrl+S in een CRM-tab) vraagt wat er veranderd is en zet het ontwerp als
|
||||
nieuwe versie bij de woning in de CRM. Staat er intussen een nieuwere versie (iemand anders), dan vraagt Tectum
|
||||
eerst of het toch moet. **Downloaden (JSON + PDF)** doet in een CRM-tab wat Opslaan anders doet.
|
||||
- Sluiten van een CRM-tab met wijzigingen die nog niet in de CRM staan vraagt om bevestiging.
|
||||
|
||||
Zonder stack (`start.sh`) bestaat `api/crm/` niet en is van de CRM niets te zien.
|
||||
|
||||
## Kosten en prijzen (CSV)
|
||||
|
||||
Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die
|
||||
@@ -395,7 +511,19 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
|
||||
13 × 10 m, hoofdvolume met flauw zadeldak (twee lessenaars die op de naad
|
||||
de nok vormen) en een vleugel met plat dak, steenstrips rondom met een
|
||||
schoorsteen (800 mm, hart op de naad) in de oostgevel, kookeiland, logeerkamer en
|
||||
apart toilet in de vleugel. De standaardmodellen staan in
|
||||
apart toilet in de vleugel. Model **Reest** (`HouseModel.createReest`,
|
||||
naar `examples/ontwerp_reest.pdf`): 12,9 × 7,85 m, hoofdvolume
|
||||
11,5 × 4,8 m onder één lessenaarsdak (hoog aan de noordgevel, twee units
|
||||
van 3,4 en 1,4 m met lessenaars in hetzelfde vlak) dat aan de westkop
|
||||
1,4 m doorloopt als overkapping bij de voordeur, dicht met een witte
|
||||
zijwand; aanbouw met plat dak (toilet, berging, entree met open doorgang
|
||||
naar de woonkamer, daktrim i.p.v. boeidelen); zuidgevel en dak in
|
||||
antraciet felsplaten (zuidrand zonder boeideel, de felsplaten lopen over
|
||||
de zijwand door), verder verticale rabat. Model **Vecht** (`HouseModel.createVecht`): U-vorm
|
||||
13 × 9,5 m, drie units (slaapvleugel west, tussenlid met entree, leefvleugel oost) met de holte open naar
|
||||
het noorden; de vleugels lessenaars die naar de holte aflopen, het tussenlid plat op dezelfde goot, de
|
||||
overkapping van het tussenlid dekt de holte (palen niet nodig); zwarte verticale rabat, wit stucwerk aan de
|
||||
holte. De standaardmodellen staan in
|
||||
`HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden
|
||||
daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die
|
||||
`modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft
|
||||
|
||||
+17
@@ -345,6 +345,23 @@ html, body { height: 100%; }
|
||||
.new-option span { font-size: 13px; }
|
||||
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
|
||||
.new-option.disabled { opacity: .4; cursor: not-allowed; }
|
||||
.new-crm { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-soft); max-width: 640px; }
|
||||
.new-crm h3 { margin: 0 0 10px; font-size: 13px; color: var(--primary); }
|
||||
.new-crm-q { width: 100%; box-sizing: border-box; font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.new-crm-list { list-style: none; margin: 8px 0 0; padding: 0; max-height: 260px; overflow-y: auto; }
|
||||
.new-crm-item { display: flex; gap: 10px; align-items: center; width: 100%; text-align: left; background: none; border: 0; border-radius: 6px; padding: 6px 8px; cursor: pointer; color: var(--text); font: inherit; }
|
||||
.new-crm-item:hover, .new-crm-item:focus-visible { background: var(--primary-soft); color: var(--primary); }
|
||||
.new-crm-item .bi { font-size: 18px; }
|
||||
.new-crm-item strong { display: block; font-size: 13px; }
|
||||
.new-crm-item small { display: block; font-size: 12px; color: var(--text-muted); }
|
||||
.new-crm-empty { font-size: 12px; color: var(--text-muted); padding: 6px 8px; }
|
||||
.doc-tab .bi-cloud { margin-right: 4px; color: var(--primary); }
|
||||
#crm-toast { position: fixed; left: 50%; bottom: 24px; transform: translate(-50%, 20px); opacity: 0; pointer-events: none; z-index: 900;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; font-size: 13px;
|
||||
box-shadow: 0 12px 30px rgba(33, 33, 33, .2); transition: opacity .2s, transform .2s; max-width: 90vw; }
|
||||
#crm-toast.open { opacity: 1; transform: translate(-50%, 0); }
|
||||
#crm-toast.ok { border-color: var(--ok); background: var(--ok-soft); }
|
||||
#crm-toast.err { border-color: var(--danger); background: var(--danger-soft); }
|
||||
|
||||
.hn-title { font-size: 12px; font-weight: bold; color: var(--primary); text-transform: uppercase; letter-spacing: .8px; padding: 2px 6px 6px; }
|
||||
#house-nav-tree { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
/* Light-versie (modelpagina's). Alles onder .tectum-light, zodat de stijl van de site eromheen (Bootstrap) er niet
|
||||
mee botst; koppen, labels en knoppen krijgen daarom expliciet marge, gewicht en regelhoogte. De indeling volgt de
|
||||
breedte van de tool zelf (container queries), niet die van het scherm: in de site staat hij in een col-12. */
|
||||
.tectum-light {
|
||||
--tl-primary: #b95913;
|
||||
--tl-primary-hover: #9c4a0f;
|
||||
--tl-primary-soft: rgba(185, 89, 19, 0.10);
|
||||
--tl-text: #212121;
|
||||
--tl-muted: #5b6673;
|
||||
--tl-faint: #8a94a0;
|
||||
--tl-surface: #ffffff;
|
||||
--tl-bg: #f9faf5;
|
||||
--tl-border: #c5ced9;
|
||||
--tl-border-soft: #e2e7ee;
|
||||
--tl-radius: 10px;
|
||||
container: tl / inline-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--tl-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
}
|
||||
.tectum-light *, .tectum-light *::before, .tectum-light *::after { box-sizing: border-box; }
|
||||
.tectum-light button { font: inherit; color: inherit; line-height: 1.2; margin: 0; text-transform: none; }
|
||||
.tectum-light h2, .tectum-light h3 { margin: 0; font-weight: 700; line-height: 1.2; }
|
||||
.tectum-light label { display: block; margin: 0; }
|
||||
.tectum-light small { font-size: 11px; color: var(--tl-faint); font-weight: 400; }
|
||||
|
||||
.tectum-light .tl-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
height: clamp(560px, 80vh, 800px);
|
||||
border: 1px solid var(--tl-border-soft);
|
||||
border-radius: var(--tl-radius);
|
||||
overflow: hidden;
|
||||
background: var(--tl-surface);
|
||||
}
|
||||
|
||||
/* 3D / plattegrond */
|
||||
.tectum-light .tl-stage { position: relative; min-width: 0; min-height: 0; background: #dfe6ec; }
|
||||
.tectum-light .tl-3d { position: absolute; inset: 0; }
|
||||
.tectum-light .tl-3d canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||
.tectum-light .tl-plan { position: absolute; inset: 0; width: 100%; height: 100%; display: none; background: #fff; }
|
||||
.tectum-light.view-plan .tl-plan { display: block; }
|
||||
.tectum-light.view-plan .tl-3d { visibility: hidden; }
|
||||
.tectum-light .tl-viewbar, .tectum-light .tl-cutbar, .tectum-light .tl-busy, .tectum-light .tl-hint {
|
||||
position: absolute; background: rgba(255, 255, 255, 0.94); border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.tectum-light .tl-viewbar { top: 10px; left: 10px; display: flex; gap: 2px; padding: 3px; }
|
||||
.tectum-light .tl-viewbar button {
|
||||
display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 6px 10px;
|
||||
border-radius: 6px; cursor: pointer; color: var(--tl-muted); white-space: nowrap;
|
||||
}
|
||||
.tectum-light .tl-viewbar button:hover { color: var(--tl-text); }
|
||||
.tectum-light .tl-viewbar button.on { background: var(--tl-primary-soft); color: var(--tl-primary); font-weight: 600; }
|
||||
.tectum-light .tl-cutbar { top: 52px; left: 10px; display: none; align-items: center; gap: 10px; padding: 6px 12px; font-size: 13px; }
|
||||
.tectum-light.view-cut .tl-cutbar { display: flex; }
|
||||
.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; }
|
||||
/* 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-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-panel { pointer-events: none; }
|
||||
.tectum-light .tl-spinner {
|
||||
width: 14px; height: 14px; border: 2px solid var(--tl-border); border-top-color: var(--tl-primary);
|
||||
border-radius: 50%; animation: tl-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes tl-spin { to { transform: rotate(360deg); } }
|
||||
.tectum-light .tl-toast {
|
||||
position: absolute; left: 50%; bottom: 42px; transform: translate(-50%, 8px); width: max-content; max-width: calc(100% - 24px);
|
||||
background: #2b2f33; color: #fff; padding: 9px 14px; border-radius: 8px; font-size: 13px;
|
||||
opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none;
|
||||
}
|
||||
.tectum-light .tl-toast.on { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
/* Paneel */
|
||||
.tectum-light .tl-panel { display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--tl-border-soft); background: var(--tl-surface); }
|
||||
.tectum-light .tl-head { padding: 14px 16px 10px; }
|
||||
.tectum-light .tl-title { font-size: 18px; }
|
||||
.tectum-light .tl-sub { margin-top: 3px; color: var(--tl-muted); font-size: 12px; }
|
||||
.tectum-light .tl-sections { flex: 1; min-height: 0; overflow-y: auto; border-top: 1px solid var(--tl-border-soft); }
|
||||
|
||||
/* Accordeon */
|
||||
.tectum-light .tl-sec { border-bottom: 1px solid var(--tl-border-soft); }
|
||||
.tectum-light .tl-sec-head {
|
||||
display: flex; align-items: center; gap: 9px; width: 100%; padding: 11px 16px; border: 0; background: none; cursor: pointer; text-align: left;
|
||||
}
|
||||
.tectum-light .tl-sec-head:hover { background: var(--tl-bg); }
|
||||
.tectum-light .tl-sec-head > .bi:first-child { color: var(--tl-primary); }
|
||||
.tectum-light .tl-sec-title { font-weight: 600; }
|
||||
.tectum-light .tl-sec-sum { flex: 1; min-width: 0; text-align: right; font-size: 12px; color: var(--tl-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tectum-light .tl-chev { font-size: 12px; color: var(--tl-faint); transition: transform 0.15s; }
|
||||
.tectum-light .tl-sec.open .tl-chev { transform: rotate(180deg); }
|
||||
.tectum-light .tl-sec.open .tl-sec-sum { visibility: hidden; }
|
||||
.tectum-light .tl-sec-body { padding: 0 16px 12px; }
|
||||
|
||||
/* Velden */
|
||||
.tectum-light .tl-field { margin: 8px 0; }
|
||||
.tectum-light .tl-label { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--tl-muted); margin-bottom: 4px; }
|
||||
.tectum-light .tl-val { color: var(--tl-text); font-weight: 600; text-align: right; }
|
||||
.tectum-light .tl-note { font-size: 11px; color: var(--tl-faint); }
|
||||
.tectum-light .tl-seg { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tectum-light .tl-seg button {
|
||||
flex: 1 1 auto; border: 1px solid var(--tl-border); background: var(--tl-surface); border-radius: 7px;
|
||||
padding: 6px 8px; cursor: pointer; white-space: nowrap; font-size: 13px;
|
||||
}
|
||||
.tectum-light .tl-seg button:hover:not(:disabled) { border-color: var(--tl-primary); }
|
||||
.tectum-light .tl-seg button.on { border-color: var(--tl-primary); background: var(--tl-primary-soft); color: var(--tl-primary); font-weight: 600; }
|
||||
.tectum-light .tl-units .tl-seg button { flex: 0 0 auto; min-width: 40px; }
|
||||
.tectum-light button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.tectum-light button:focus-visible, .tectum-light input:focus-visible, .tectum-light .tl-map-u:focus-visible { outline: 2px solid var(--tl-primary); outline-offset: 2px; }
|
||||
|
||||
.tectum-light .tl-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tectum-light .tl-sw {
|
||||
width: 30px; height: 30px; padding: 0; border-radius: 50%; border: 1px solid rgba(0, 0, 0, 0.18);
|
||||
background: var(--sw); cursor: pointer; overflow: hidden; box-shadow: 0 0 0 2px var(--tl-surface);
|
||||
}
|
||||
.tectum-light .tl-sw img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.tectum-light .tl-sw.on { box-shadow: 0 0 0 2px var(--tl-surface), 0 0 0 4px var(--tl-primary); }
|
||||
|
||||
.tectum-light .tl-slider { margin: 8px 0; }
|
||||
.tectum-light .tl-sl-head { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; }
|
||||
.tectum-light .tl-sl-head output { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.tectum-light .tl-slider input { display: block; width: 100%; margin: 4px 0 0; accent-color: var(--tl-primary); }
|
||||
|
||||
.tectum-light .tl-mapbox { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
|
||||
.tectum-light .tl-map { display: block; flex: 1; min-width: 0; max-height: 96px; }
|
||||
.tectum-light .tl-map-u { cursor: pointer; outline: none; }
|
||||
.tectum-light .tl-map-u rect { fill: var(--tl-bg); stroke: var(--tl-border); stroke-width: 40; }
|
||||
.tectum-light .tl-map-u text { fill: var(--tl-muted); font-weight: 600; pointer-events: none; }
|
||||
.tectum-light .tl-map-u:hover rect { stroke: var(--tl-primary); }
|
||||
.tectum-light .tl-map-u.on rect { fill: var(--tl-primary-soft); stroke: var(--tl-primary); stroke-width: 60; }
|
||||
.tectum-light .tl-map-u.on text { fill: var(--tl-primary); }
|
||||
|
||||
/* Kerncijfers en prijs */
|
||||
.tectum-light .tl-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 14px; padding: 10px 16px; font-size: 12px; border-top: 1px solid var(--tl-border-soft); background: var(--tl-bg); }
|
||||
.tectum-light .tl-kv { display: flex; justify-content: space-between; gap: 6px; }
|
||||
.tectum-light .tl-kv span { color: var(--tl-muted); }
|
||||
.tectum-light .tl-kv b { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.tectum-light .tl-buy { display: flex; align-items: center; gap: 8px; padding: 10px 16px 12px; background: var(--tl-bg); }
|
||||
.tectum-light .tl-price { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.tectum-light .tl-price-val { font-size: 21px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.tectum-light .tl-price-note { font-size: 11px; color: var(--tl-muted); }
|
||||
.tectum-light .tl-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 40px; padding: 0 14px;
|
||||
border: 1px solid var(--tl-border); border-radius: 8px; background: var(--tl-surface); cursor: pointer;
|
||||
}
|
||||
.tectum-light .tl-btn:hover { border-color: var(--tl-primary); }
|
||||
.tectum-light .tl-btn.tl-icon { width: 40px; padding: 0; }
|
||||
.tectum-light .tl-btn.tl-primary { background: var(--tl-primary); border-color: var(--tl-primary); color: #fff; font-weight: 600; }
|
||||
.tectum-light .tl-btn.tl-primary:hover { background: var(--tl-primary-hover); }
|
||||
|
||||
/* Middel (bijv. tablet, of een smallere kolom): paneel iets smaller. */
|
||||
@container tl (max-width: 980px) {
|
||||
.tectum-light .tl-wrap { grid-template-columns: minmax(0, 1fr) 300px; }
|
||||
.tectum-light .tl-viewbar button { padding: 6px 8px; }
|
||||
}
|
||||
|
||||
/* Smal (telefoon): 3D boven, keuzes eronder; prijs en PDF blijven onderin beeld zolang de keuzes in beeld zijn. */
|
||||
@container tl (max-width: 720px) {
|
||||
.tectum-light .tl-wrap { grid-template-columns: 1fr; height: auto; overflow: visible; }
|
||||
.tectum-light .tl-stage { height: min(62vh, 115cqw); border-radius: var(--tl-radius) var(--tl-radius) 0 0; overflow: hidden; }
|
||||
.tectum-light .tl-panel { border-left: 0; border-top: 1px solid var(--tl-border-soft); border-radius: 0 0 var(--tl-radius) var(--tl-radius); }
|
||||
.tectum-light .tl-sections { overflow: visible; }
|
||||
.tectum-light .tl-buy { position: sticky; bottom: 0; z-index: 2; border-top: 1px solid var(--tl-border-soft); box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06); border-radius: 0 0 var(--tl-radius) var(--tl-radius); }
|
||||
.tectum-light .tl-hint { display: none; }
|
||||
.tectum-light .tl-viewbar { left: 8px; right: 8px; top: 8px; }
|
||||
.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 input { flex: 1; width: auto; }
|
||||
.tectum-light .tl-busy { top: auto; bottom: 10px; right: 10px; }
|
||||
/* Grotere tikvlakken */
|
||||
.tectum-light .tl-seg button { padding: 9px 8px; }
|
||||
.tectum-light .tl-sw { width: 36px; height: 36px; }
|
||||
.tectum-light .tl-sec-head { padding: 13px 16px; }
|
||||
.tectum-light .tl-slider input { height: 28px; }
|
||||
}
|
||||
@container tl (max-width: 380px) {
|
||||
.tectum-light .tl-viewbar button .bi { display: none; }
|
||||
.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
-1
@@ -50,7 +50,7 @@
|
||||
<div class="license-box" id="about-license"></div>
|
||||
<div class="about-footer">
|
||||
<span>© 2026 Machiel Heinen · ontwikkeld in opdracht van Van Emous Mantelzorgwoningen</span>
|
||||
<button id="about-close" class="about-close">Sluiten</button>
|
||||
<button id="about-close" class="about-close" data-esc>Sluiten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,6 +76,7 @@
|
||||
<span>Los: vloer</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="new-crm" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +125,7 @@
|
||||
<script src="js/house/house-dxf.js"></script>
|
||||
<script src="js/house/house-ifc.js"></script>
|
||||
<script src="js/house/house.js"></script>
|
||||
<script src="js/crm.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
const APP_VERSION = '0.7.1';
|
||||
const APP_VERSION = '0.7.9';
|
||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||
|
||||
let tabs = [];
|
||||
@@ -41,7 +41,9 @@ const MENUS = [
|
||||
{ label: 'Bestand', items: [
|
||||
{ label: 'Nieuw…', icon: 'file-earmark-plus', keys: 'Ctrl+N', run: openNewModal },
|
||||
{ label: 'Openen…', icon: 'folder2-open', keys: 'Ctrl+O', run: () => editorCall('triggerImport'), enabled: () => editorHas('triggerImport') },
|
||||
{ label: 'Opslaan (JSON + PDF)', icon: 'save', keys: 'Ctrl+S', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') },
|
||||
{ label: 'Opslaan (JSON + PDF)', icon: 'save', keys: 'Ctrl+S', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') && !activeCrm() },
|
||||
{ label: 'Opslaan in CRM (nieuwe versie)', icon: 'cloud-upload', keys: 'Ctrl+S', run: saveToCrm, enabled: () => !!activeCrm() },
|
||||
{ label: 'Downloaden (JSON + PDF)', icon: 'download', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') && !!activeCrm() },
|
||||
{ sep: true }, { head: 'Exporteren' },
|
||||
{ label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') },
|
||||
{ label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') },
|
||||
@@ -71,13 +73,14 @@ const MENUS = [
|
||||
];
|
||||
|
||||
const KEY_HELP = [
|
||||
['Bestand', [['Ctrl+N', 'Nieuw ontwerp'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF)']]],
|
||||
['Bestand', [['Ctrl+N', 'Nieuw ontwerp (ook uit de CRM)'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF; woning uit de CRM: nieuwe versie in de CRM)']]],
|
||||
['Gereedschap (woning)', [['V', 'Selecteren'], ['W', 'Binnenwand tekenen'], ['R', 'Ruimte vullen'], ['I', 'Inrichting plaatsen'],
|
||||
['Esc', 'Gereedschap stoppen, daarna selectie opheffen'], ['Del', 'Selectie verwijderen'], ['D / Shift+D', 'Inrichting draaien'], ['Dubbelklik', 'Onderdeel bewerken (wand, vloer)']]],
|
||||
['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'],
|
||||
['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]],
|
||||
['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],
|
||||
['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]
|
||||
@@ -100,11 +103,57 @@ function renderTabBar() {
|
||||
const bar = document.getElementById('doc-tabs');
|
||||
bar.innerHTML = tabs.map(tab => `
|
||||
<div class="doc-tab ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}" role="tab" aria-selected="${tab.id === activeTabId}" title="${UI.esc(tab.name)}">
|
||||
<span class="doc-name">${UI.esc(tab.name)}</span>
|
||||
${tab.crm ? '<i class="bi bi-cloud" title="Woning uit de CRM"></i>' : ''}<span class="doc-name">${UI.esc(tab.name)}</span>
|
||||
<button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('top-save').disabled = !editorHas('exportDesign');
|
||||
const save = document.getElementById('top-save');
|
||||
save.disabled = !editorHas('exportDesign');
|
||||
save.querySelector('span').textContent = activeCrm() ? 'Opslaan in CRM' : 'Opslaan';
|
||||
save.title = activeCrm() ? 'Opslaan als nieuwe versie in de CRM (Ctrl+S)' : 'Opslaan als JSON + PDF (Ctrl+S)';
|
||||
}
|
||||
|
||||
// ---- CRM (js/crm.js): woning uit de CRM in een tab (tab.crm = { id, versie, titel }), terug als nieuwe versie
|
||||
const activeCrm = () => { const t = tabs.find(x => x.id === activeTabId); return t && t.crm ? t : null; };
|
||||
const crmName = (crm) => `${crm.titel} · v${crm.versie}`;
|
||||
|
||||
function saveActive() {
|
||||
if (activeCrm()) saveToCrm(); else editorCall('exportDesign');
|
||||
}
|
||||
|
||||
async function openCrmWoning(id) {
|
||||
try {
|
||||
const { woning, state } = await TectumCrm.load(id);
|
||||
const open = tabs.find(t => t.crm && t.crm.id === woning.id);
|
||||
if (open && !window.confirm(`${woning.titel} is al geopend. Nog een keer openen (versie ${woning.versie} uit de CRM)?`)) {
|
||||
switchToTab(open.id);
|
||||
return;
|
||||
}
|
||||
const crm = { id: woning.id, versie: woning.versie, titel: woning.titel };
|
||||
openNew('woning', null, { stateJson: state, name: crmName(crm), crm });
|
||||
TectumCrm.toast(`${woning.titel} geladen uit de CRM (versie ${woning.versie}${woning.contact ? ', ' + woning.contact.naam : ''}).`, 'ok');
|
||||
} catch (err) {
|
||||
TectumCrm.toast('Laden uit de CRM mislukt: ' + err.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToCrm() {
|
||||
const tab = activeCrm();
|
||||
if (!tab || !currentEditor) return;
|
||||
const note = window.prompt(`Opslaan als nieuwe versie van ${tab.crm.titel} in de CRM.\nWat is er veranderd?`, '');
|
||||
if (note === null) return;
|
||||
const state = currentEditor.getStateJson();
|
||||
try {
|
||||
const versie = await TectumCrm.saveVersion(tab.crm.id, state, tab.crm.versie, note.trim());
|
||||
if (versie === null) return;
|
||||
tab.crm.versie = versie;
|
||||
tab.baseJson = JSON.stringify(state);
|
||||
tab.name = crmName(tab.crm);
|
||||
renderTabBar();
|
||||
TectumCrm.toast(`Opgeslagen in de CRM als versie ${versie}.`, 'ok');
|
||||
} catch (err) {
|
||||
TectumCrm.toast('Opslaan in de CRM mislukt: ' + err.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function onTabSaved(tab, filenameWithExt) {
|
||||
@@ -137,7 +186,7 @@ function switchToTab(tabId) {
|
||||
renderTabBar();
|
||||
}
|
||||
|
||||
function openNew(kind, template) {
|
||||
function openNew(kind, template, opts) {
|
||||
if (!editorModuleFor(kind)) return;
|
||||
|
||||
const oldTab = tabs.find(t => t.id === activeTabId);
|
||||
@@ -147,10 +196,13 @@ function openNew(kind, template) {
|
||||
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
|
||||
currentEditor = null;
|
||||
|
||||
const tab = { id: tabIdCounter++, kind, template: template || null, name: defaultNameFor(kind), savedName: null, stateJson: null };
|
||||
const tab = { id: tabIdCounter++, kind, template: template || null, name: (opts && opts.name) || defaultNameFor(kind), savedName: null,
|
||||
stateJson: (opts && opts.stateJson) || null, crm: (opts && opts.crm) || null };
|
||||
tabs.push(tab);
|
||||
activeTabId = tab.id;
|
||||
mountEditorForTab(tab);
|
||||
// geladen ontwerp (CRM): wijzigingen tellen vanaf hier, niet vanaf het standaardmodel
|
||||
if (tab.stateJson && currentEditor && typeof currentEditor.getStateJson === 'function') tab.baseJson = JSON.stringify(currentEditor.getStateJson());
|
||||
renderTabBar();
|
||||
}
|
||||
|
||||
@@ -161,6 +213,7 @@ function isTabDirty(tab) {
|
||||
? currentEditor.getStateJson()
|
||||
: tab.stateJson;
|
||||
if (!state) return false;
|
||||
if (tab.baseJson) return JSON.stringify(state) !== tab.baseJson;
|
||||
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
|
||||
}
|
||||
|
||||
@@ -177,7 +230,9 @@ function closeTab(tabId) {
|
||||
switchToTab(tabId);
|
||||
}
|
||||
|
||||
if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
|
||||
if (tab.crm && isTabDirty(tab)) {
|
||||
if (!window.confirm(`De wijzigingen aan ${tab.crm.titel} staan nog niet in de CRM (Opslaan in CRM). Toch sluiten?`)) return;
|
||||
} else if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
|
||||
const saved = currentEditor.exportDesign();
|
||||
if (!saved) return;
|
||||
}
|
||||
@@ -232,10 +287,16 @@ function wireNewModal() {
|
||||
overlay.classList.remove('open');
|
||||
});
|
||||
});
|
||||
newCrmPicker = TectumCrm.mountPicker(overlay.querySelector('.new-crm'), (id) => {
|
||||
overlay.classList.remove('open');
|
||||
openCrmWoning(id);
|
||||
});
|
||||
}
|
||||
|
||||
let newCrmPicker = null;
|
||||
function openNewModal() {
|
||||
document.getElementById('new-overlay').classList.add('open');
|
||||
if (newCrmPicker) newCrmPicker.refresh();
|
||||
}
|
||||
|
||||
function toggleBrowserFullscreen() {
|
||||
@@ -243,9 +304,15 @@ function toggleBrowserFullscreen() {
|
||||
else document.documentElement.requestFullscreen();
|
||||
}
|
||||
|
||||
// Alle dialogen; Esc gebruikt hun Annuleren-knop (data-esc), zodat bijv. projectgegevens worden teruggezet.
|
||||
const OVERLAYS = '.dlg-overlay.open, #about-overlay.open, #new-overlay.open, #house-project-overlay.open, #wall-project-overlay.open, #cost-settings-overlay.open';
|
||||
function closeOverlays() {
|
||||
const open = document.querySelector('.dlg-overlay.open, #about-overlay.open, #new-overlay.open');
|
||||
if (open) { open.classList.remove('open'); return true; }
|
||||
const open = document.querySelector(OVERLAYS);
|
||||
if (open) {
|
||||
const cancel = open.querySelector('[data-esc]');
|
||||
if (cancel) cancel.click(); else open.classList.remove('open');
|
||||
return true;
|
||||
}
|
||||
return Menubar.close() || UI.closePopovers();
|
||||
}
|
||||
|
||||
@@ -253,8 +320,14 @@ function wireHotkeys() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
|
||||
const k = e.key.toLowerCase();
|
||||
// Dialoog open: alleen Esc (sluiten); geen sneltoetsen naar de editor erachter. Typen in de dialoog blijft werken.
|
||||
if (document.querySelector(OVERLAYS)) {
|
||||
if (e.key === 'Escape') closeOverlays();
|
||||
e.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const act = { s: () => editorCall('exportDesign'), n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
|
||||
const act = { s: saveActive, n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
|
||||
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
|
||||
return;
|
||||
}
|
||||
@@ -275,7 +348,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
||||
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
||||
Menubar.init(document.getElementById('menubar'), MENUS);
|
||||
document.getElementById('top-save').addEventListener('click', () => editorCall('exportDesign'));
|
||||
document.getElementById('top-save').addEventListener('click', saveActive);
|
||||
document.getElementById('keys-overlay').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'keys-overlay' || e.target.closest('[data-close]')) document.getElementById('keys-overlay').classList.remove('open');
|
||||
});
|
||||
@@ -289,7 +362,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>';
|
||||
document.body.appendChild(note);
|
||||
}
|
||||
openNew('woning', 'amstel');
|
||||
// ?woning=<id>: die woning uit de CRM openen (knop "Bewerken in Tectum" in de CRM), anders het standaardmodel
|
||||
const crmId = Number(new URLSearchParams(location.search).get('woning'));
|
||||
if (crmId) openCrmWoning(crmId); else openNew('woning', 'amstel');
|
||||
|
||||
setTimeout(() => {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
*/
|
||||
// Plain script, geen ES-module: imports breken bij file:// (CORS).
|
||||
|
||||
// Map van de engine (met assets/ en kosten/), afgeleid van de plek van dit script. Texturen, kleurstalen en de
|
||||
// kosten-CSV's laden via tectumUrl(), zodat ze ook werken als de engine in een andere pagina staat (light-versie op
|
||||
// een modelpagina van de site). Paden in de data blijven relatief.
|
||||
const TECTUM_BASE = (() => {
|
||||
const s = typeof document !== 'undefined' && document.currentScript && document.currentScript.src;
|
||||
return s ? new URL('../../', s).href : '';
|
||||
})();
|
||||
function tectumUrl(path) {
|
||||
return path && !/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(path) ? TECTUM_BASE + path : path;
|
||||
}
|
||||
|
||||
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
|
||||
if (spanWidth <= 0) return [];
|
||||
let numBays = Math.ceil(spanWidth / maxSp);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// glTF 2.0 binair (GLB) uit three.js-objecten, in meters, Y omhoog; bedoeld voor AR (model-viewer: WebXR, Scene Viewer,
|
||||
// Quick Look) en andere viewers. Alles in wereldcoördinaten samengevoegd tot één mesh met één primitive per materiaal
|
||||
// (weinig draw calls op een telefoon). Texturen: herhaling/verschuiving (texture.matrix) zit in de UV's, beelden als
|
||||
// JPEG (of PNG bij doorzichtig), maximaal MAX_TEX px. Lichteffecten (bundels, vegen: onbelicht en doorschijnend zonder
|
||||
// dieptebuffer, of additief) blijven weg; die horen bij de eigen belichting, niet in een model.
|
||||
|
||||
window.GlbWriter = (() => {
|
||||
const MAX_TEX = 1024;
|
||||
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||
const WRAP = { 1000: 10497, 1001: 33071, 1002: 33648 }; // three Repeat/Clamp/Mirrored -> glTF
|
||||
|
||||
const isEffect = (m) => m.blending !== THREE.NormalBlending || (m.isMeshBasicMaterial && m.transparent && m.depthWrite === false);
|
||||
|
||||
// Beeld als JPEG/PNG-bytes, afmetingen een macht van 2 (mipmaps en herhalen ook op oudere toestellen), hoogstens
|
||||
// MAX_TEX; null als het (nog) niet te tekenen is. UV's zijn 0..1, dus het rekken verandert niets aan het beeld.
|
||||
const pot = (n) => Math.min(MAX_TEX, Math.pow(2, Math.max(0, Math.round(Math.log2(n)))));
|
||||
function imageBytes(image, png) {
|
||||
try {
|
||||
const w0 = image.naturalWidth || image.videoWidth || image.width, h0 = image.naturalHeight || image.videoHeight || image.height;
|
||||
if (!w0 || !h0) return null;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = pot(w0); c.height = pot(h0);
|
||||
c.getContext('2d').drawImage(image, 0, 0, c.width, c.height);
|
||||
const bin = atob(c.toDataURL(png ? 'image/png' : 'image/jpeg', 0.85).split(',')[1]);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return { data: out, mime: png ? 'image/png' : 'image/jpeg' };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; clipBelow: alles onder deze hoogte (m, na
|
||||
// de verschuiving) valt weg, driehoeken die de snede kruisen worden afgesneden (AR zet de onderkant van het model op de
|
||||
// grond: met clipBelow 0 op het maaiveld valt die samen met de oorsprong). Geeft { blob, stats }.
|
||||
function build(objects, opts) {
|
||||
const o = opts || {};
|
||||
const origin = o.origin || { x: 0, y: 0, z: 0 };
|
||||
const clip = typeof o.clipBelow === 'number', clipY = o.clipBelow, EPS = 1e-6;
|
||||
const originV = new THREE.Vector3(origin.x, origin.y, origin.z);
|
||||
const buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
|
||||
const images = new Map(), samplers = new Map(), textures = new Map();
|
||||
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
|
||||
const bin = [];
|
||||
let binLength = 0, triCount = 0;
|
||||
function addView(data, target) {
|
||||
const pad = (4 - binLength % 4) % 4;
|
||||
if (pad) { bin.push(new Uint8Array(pad)); binLength += pad; }
|
||||
const view = { buffer: 0, byteOffset: binLength, byteLength: data.byteLength };
|
||||
if (target) view.target = target;
|
||||
bin.push(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
binLength += data.byteLength;
|
||||
return view;
|
||||
}
|
||||
const views = [];
|
||||
|
||||
function textureIndex(tex, png) {
|
||||
if (!tex || !tex.image) return null;
|
||||
if (!images.has(tex.image)) {
|
||||
const img = imageBytes(tex.image, png);
|
||||
if (img) { views.push(addView(img.data)); gltfImages.push({ bufferView: views.length - 1, mimeType: img.mime }); }
|
||||
images.set(tex.image, img ? gltfImages.length - 1 : null);
|
||||
}
|
||||
const source = images.get(tex.image);
|
||||
if (source === null) return null;
|
||||
const sKey = `${tex.wrapS}/${tex.wrapT}`;
|
||||
if (!samplers.has(sKey)) {
|
||||
gltfSamplers.push({ magFilter: 9729, minFilter: 9987, wrapS: WRAP[tex.wrapS] || 10497, wrapT: WRAP[tex.wrapT] || 10497 });
|
||||
samplers.set(sKey, gltfSamplers.length - 1);
|
||||
}
|
||||
const tKey = `${source}/${samplers.get(sKey)}`;
|
||||
if (!textures.has(tKey)) { gltfTextures.push({ source, sampler: samplers.get(sKey) }); textures.set(tKey, gltfTextures.length - 1); }
|
||||
return textures.get(tKey);
|
||||
}
|
||||
function bucketOf(m) {
|
||||
if (!buckets.has(m)) {
|
||||
const map = m.map && m.map.image ? m.map : null;
|
||||
const tex = map ? textureIndex(map, !!m.transparent) : null;
|
||||
buckets.set(m, { mat: m, map: tex !== null ? map : null, tex, pos: [], nrm: [], uv: [], idx: [], n: 0 });
|
||||
}
|
||||
return buckets.get(m);
|
||||
}
|
||||
|
||||
const v = new THREE.Vector3(), nm = new THREE.Matrix3(), uv = new THREE.Vector2();
|
||||
const pa = new THREE.Vector3(), pb = new THREE.Vector3(), pc = new THREE.Vector3(), face = new THREE.Vector3();
|
||||
const visit = (root) => root && (root.updateMatrixWorld(true), root.traverseVisible(mesh => {
|
||||
if (!mesh.isMesh || !mesh.geometry || !mesh.geometry.attributes.position) return;
|
||||
let g = mesh.geometry;
|
||||
if (!g.attributes.normal) { g = g.clone(); g.computeVertexNormals(); }
|
||||
const pos = g.attributes.position, nrm = g.attributes.normal, uvA = g.attributes.uv, idx = g.index;
|
||||
nm.getNormalMatrix(mesh.matrixWorld);
|
||||
const flip = mesh.matrixWorld.determinant() < 0;
|
||||
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
const groups = g.groups && g.groups.length ? g.groups : [{ start: 0, count: idx ? idx.count : pos.count, materialIndex: 0 }];
|
||||
const remaps = new Map(); // bucket -> bronhoekpunt -> index in de bucket
|
||||
groups.forEach(gr => {
|
||||
const m = list[gr.materialIndex] || list[0];
|
||||
if (!m || m.visible === false || isEffect(m)) return;
|
||||
const b = bucketOf(m);
|
||||
if (!remaps.has(b)) remaps.set(b, new Int32Array(pos.count).fill(-1));
|
||||
const remap = remaps.get(b);
|
||||
if (b.map) b.map.updateMatrix();
|
||||
const add = (ci) => {
|
||||
if (remap[ci] >= 0) return remap[ci];
|
||||
v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld);
|
||||
b.pos.push(v.x - origin.x, v.y - origin.y, v.z - origin.z);
|
||||
// Een lege normaal (bron met nul-driehoeken) wordt de vlaknormaal: glTF eist lengte 1.
|
||||
v.fromBufferAttribute(nrm, ci).applyMatrix3(nm);
|
||||
if (v.lengthSq() < 1e-12) v.copy(face); else v.normalize();
|
||||
b.nrm.push(v.x, v.y, v.z);
|
||||
if (b.map) {
|
||||
if (uvA) uv.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix); else uv.set(0, 0);
|
||||
// three (flipY) heeft v = 0 onderaan het beeld, glTF bovenaan.
|
||||
b.uv.push(uv.x, b.map.flipY ? 1 - uv.y : uv.y);
|
||||
}
|
||||
return (remap[ci] = b.n++);
|
||||
};
|
||||
// Afgesneden driehoeken: losse hoekpunten (geïnterpoleerd), niet gedeeld via de remap.
|
||||
const raw = (ci) => {
|
||||
const p = new THREE.Vector3().fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).sub(originV);
|
||||
const n = new THREE.Vector3().fromBufferAttribute(nrm, ci).applyMatrix3(nm);
|
||||
if (n.lengthSq() < 1e-12) n.copy(face); else n.normalize();
|
||||
const t = new THREE.Vector2();
|
||||
if (b.map && uvA) t.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix);
|
||||
return { p, n, t };
|
||||
};
|
||||
const lerp = (A, B, f) => ({
|
||||
p: new THREE.Vector3().lerpVectors(A.p, B.p, f), n: new THREE.Vector3().lerpVectors(A.n, B.n, f).normalize(),
|
||||
t: new THREE.Vector2().lerpVectors(A.t, B.t, f)
|
||||
});
|
||||
const push = (q) => {
|
||||
b.pos.push(q.p.x, q.p.y, q.p.z);
|
||||
b.nrm.push(q.n.x, q.n.y, q.n.z);
|
||||
if (b.map) b.uv.push(q.t.x, b.map.flipY ? 1 - q.t.y : q.t.y);
|
||||
return b.n++;
|
||||
};
|
||||
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
|
||||
for (let k = gr.start; k + 2 < end; k += 3) {
|
||||
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
|
||||
// Driehoeken zonder oppervlakte (< 0,1 mm²) overslaan.
|
||||
pa.fromBufferAttribute(pos, s[0]).applyMatrix4(mesh.matrixWorld);
|
||||
pb.fromBufferAttribute(pos, s[1]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||
pc.fromBufferAttribute(pos, s[2]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||
face.crossVectors(pb, pc);
|
||||
const len = face.length();
|
||||
if (len < 2e-7) continue;
|
||||
face.multiplyScalar(flip ? -1 / len : 1 / len);
|
||||
if (clip) {
|
||||
const ys = s.map(ci => v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).y - origin.y);
|
||||
const inside = ys.map(y => y >= clipY - EPS), nIn = inside.filter(Boolean).length;
|
||||
if (!nIn) continue;
|
||||
if (nIn < 3) {
|
||||
const q = s.map(raw), poly = [];
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const k2 = (j + 1) % 3;
|
||||
if (inside[j]) poly.push(q[j]);
|
||||
if (inside[j] !== inside[k2]) poly.push(lerp(q[j], q[k2], (clipY - ys[j]) / (ys[k2] - ys[j])));
|
||||
}
|
||||
const c = poly.map(push);
|
||||
for (let j = 1; j + 1 < c.length; j++) {
|
||||
if (flip) b.idx.push(c[0], c[j + 1], c[j]); else b.idx.push(c[0], c[j], c[j + 1]);
|
||||
triCount++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const c = s.map(add);
|
||||
if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
|
||||
triCount++;
|
||||
}
|
||||
});
|
||||
}));
|
||||
objects.forEach(visit);
|
||||
|
||||
// Accessors en materialen per bucket.
|
||||
const accessors = [], materials = [], primitives = [];
|
||||
let unlit = false;
|
||||
const accessor = (arr, type, comps, ctype, target, minMax) => {
|
||||
views.push(addView(arr, target));
|
||||
const a = { bufferView: views.length - 1, componentType: ctype, count: arr.length / comps, type };
|
||||
if (minMax) Object.assign(a, minMax);
|
||||
accessors.push(a);
|
||||
return accessors.length - 1;
|
||||
};
|
||||
buckets.forEach(b => {
|
||||
if (!b.idx.length) return;
|
||||
const m = b.mat, pos = new Float32Array(b.pos);
|
||||
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
||||
for (let i = 0; i < pos.length; i++) { const a = i % 3; if (pos[i] < min[a]) min[a] = pos[i]; if (pos[i] > max[a]) max[a] = pos[i]; }
|
||||
const attributes = {
|
||||
POSITION: accessor(pos, 'VEC3', 3, 5126, 34962, { min, max }),
|
||||
NORMAL: accessor(new Float32Array(b.nrm), 'VEC3', 3, 5126, 34962)
|
||||
};
|
||||
if (b.map) attributes.TEXCOORD_0 = accessor(new Float32Array(b.uv), 'VEC2', 2, 5126, 34962);
|
||||
const big = b.n >= 65535; // 65535 zelf mag niet in Uint16 (glTF)
|
||||
const indices = accessor(big ? new Uint32Array(b.idx) : new Uint16Array(b.idx), 'SCALAR', 1, big ? 5125 : 5123, 34963);
|
||||
|
||||
// De app toont kleurwaarden zoals ze zijn (geen kleurbeheer); glTF-factoren zijn lineair, beelden sRGB.
|
||||
const c = m.color || { r: 0.6, g: 0.6, b: 0.6 };
|
||||
const alpha = m.transparent ? (m.opacity !== undefined ? m.opacity : 1) : 1;
|
||||
const pbr = {
|
||||
baseColorFactor: [lin(c.r), lin(c.g), lin(c.b), alpha],
|
||||
metallicFactor: m.metalness !== undefined ? m.metalness : 0,
|
||||
roughnessFactor: m.roughness !== undefined ? m.roughness : 0.85
|
||||
};
|
||||
if (b.tex !== null) pbr.baseColorTexture = { index: b.tex };
|
||||
const mat = { name: m.name || m.type, pbrMetallicRoughness: pbr };
|
||||
if (m.emissive && (m.emissive.r || m.emissive.g || m.emissive.b)) {
|
||||
const k = m.emissiveIntensity !== undefined ? m.emissiveIntensity : 1;
|
||||
mat.emissiveFactor = [m.emissive.r, m.emissive.g, m.emissive.b].map(x => Math.min(1, lin(x) * k));
|
||||
}
|
||||
if (m.transparent && (alpha < 1 || b.tex !== null)) mat.alphaMode = 'BLEND';
|
||||
if (m.side !== THREE.FrontSide) mat.doubleSided = true;
|
||||
if (m.isMeshBasicMaterial) { mat.extensions = { KHR_materials_unlit: {} }; unlit = true; }
|
||||
materials.push(mat);
|
||||
primitives.push({ attributes, indices, material: materials.length - 1, mode: 4 });
|
||||
});
|
||||
|
||||
const json = {
|
||||
asset: { version: '2.0', generator: `Tectum${o.app ? ' ' + o.app : ''}` },
|
||||
scene: 0,
|
||||
scenes: [{ name: o.name || 'woning', nodes: [0] }],
|
||||
nodes: [{ name: o.name || 'woning', mesh: 0 }],
|
||||
meshes: [{ name: o.name || 'woning', primitives }],
|
||||
materials, accessors, bufferViews: views,
|
||||
buffers: [{ byteLength: 0 }]
|
||||
};
|
||||
if (gltfImages.length) Object.assign(json, { images: gltfImages, samplers: gltfSamplers, textures: gltfTextures });
|
||||
if (unlit) json.extensionsUsed = ['KHR_materials_unlit'];
|
||||
const binPad = (4 - binLength % 4) % 4;
|
||||
if (binPad) { bin.push(new Uint8Array(binPad)); binLength += binPad; }
|
||||
json.buffers[0].byteLength = binLength;
|
||||
|
||||
// GLB: kop (12 bytes), JSON-blok (opgevuld met spaties), BIN-blok (opgevuld met nullen).
|
||||
let jsonBytes = new TextEncoder().encode(JSON.stringify(json));
|
||||
const jsonPad = (4 - jsonBytes.length % 4) % 4;
|
||||
if (jsonPad) { const p = new Uint8Array(jsonBytes.length + jsonPad).fill(0x20); p.set(jsonBytes); jsonBytes = p; }
|
||||
const total = 12 + 8 + jsonBytes.length + 8 + binLength;
|
||||
const head = new DataView(new ArrayBuffer(20));
|
||||
head.setUint32(0, 0x46546C67, true); head.setUint32(4, 2, true); head.setUint32(8, total, true);
|
||||
head.setUint32(12, jsonBytes.length, true); head.setUint32(16, 0x4E4F534A, true);
|
||||
const binHead = new DataView(new ArrayBuffer(8));
|
||||
binHead.setUint32(0, binLength, true); binHead.setUint32(4, 0x004E4942, true);
|
||||
const blob = new Blob([head, jsonBytes, binHead].concat(bin), { type: 'model/gltf-binary' });
|
||||
return { blob, stats: { triangles: triCount, materials: materials.length, textures: gltfImages.length, bytes: total } };
|
||||
}
|
||||
|
||||
return { build };
|
||||
})();
|
||||
+17
-10
@@ -9,11 +9,9 @@
|
||||
// zodat wire() alleen de eigen knoppen koppelt.
|
||||
|
||||
window.Inspector = (() => {
|
||||
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
|
||||
const big = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="ins-btn ins-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||
const small = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="ins-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||
const { icon, esc } = UI;
|
||||
const big = UI.btnOf('ins-btn ins-big');
|
||||
const small = UI.btnOf('ins-btn');
|
||||
const kv = (label, value, unit) =>
|
||||
`<div class="ins-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
|
||||
|
||||
@@ -32,9 +30,9 @@ window.Inspector = (() => {
|
||||
const keepScroll = el._insKey === scrollKey ? el.scrollTop : 0;
|
||||
el._insKey = scrollKey;
|
||||
el.innerHTML = `<div class="ins-head">
|
||||
${view.crumbs && view.crumbs.length ? `<div class="ins-crumbs">${view.crumbs.map(c => `<button type="button" class="ins-crumb" ${c.attrs || ''}>${c.label}</button>`).join(icon('chevron-right'))}${icon('chevron-right')}</div>` : ''}
|
||||
<h2>${view.icon ? icon(view.icon) : ''}<span>${view.title}</span></h2>
|
||||
${view.sub ? `<div class="ins-sub">${view.sub}</div>` : ''}
|
||||
${view.crumbs && view.crumbs.length ? `<div class="ins-crumbs">${view.crumbs.map(c => `<button type="button" class="ins-crumb" ${c.attrs || ''}>${esc(c.label)}</button>`).join(icon('chevron-right'))}${icon('chevron-right')}</div>` : ''}
|
||||
<h2>${view.icon ? icon(view.icon) : ''}<span>${esc(view.title)}</span></h2>
|
||||
${view.sub ? `<div class="ins-sub">${esc(view.sub)}</div>` : ''}
|
||||
</div>
|
||||
${view.top ? `<div class="ins-top">${view.top}</div>` : ''}
|
||||
<div class="ins-sections"></div>`;
|
||||
@@ -45,16 +43,25 @@ window.Inspector = (() => {
|
||||
const isOpen = !collapsible || open.has(sec.key);
|
||||
node.className = 'ins-section' + (collapsible ? ' ins-collapsible' : '') + (isOpen ? ' open' : '');
|
||||
node.innerHTML = (collapsible ? `<button type="button" class="ins-section-head" aria-expanded="${isOpen}">${sec.icon ? icon(sec.icon) : ''}<span>${sec.label}</span>${icon('chevron-down')}</button>` : '') +
|
||||
`<div class="ins-section-body">${groupsHtml(sec.groups ? sec.groups() : [], sec.label)}</div>`;
|
||||
'<div class="ins-section-body"></div>';
|
||||
host.appendChild(node);
|
||||
// Een dichte sectie wordt pas opgebouwd (en gekoppeld) als hij opengaat.
|
||||
let built = false;
|
||||
const build = () => {
|
||||
if (built) return;
|
||||
built = true;
|
||||
node.querySelector('.ins-section-body').innerHTML = groupsHtml(sec.groups ? sec.groups() : [], sec.label);
|
||||
if (sec.wire) sec.wire(node);
|
||||
};
|
||||
if (isOpen) build();
|
||||
if (collapsible) node.querySelector('.ins-section-head').addEventListener('click', () => {
|
||||
const now = !node.classList.contains('open');
|
||||
if (now) build();
|
||||
node.classList.toggle('open', now);
|
||||
node.querySelector('.ins-section-head').setAttribute('aria-expanded', now);
|
||||
if (now) open.add(sec.key); else open.delete(sec.key);
|
||||
saveOpen();
|
||||
});
|
||||
if (sec.wire) sec.wire(node);
|
||||
});
|
||||
el.scrollTop = keepScroll;
|
||||
}
|
||||
|
||||
+3
-2
@@ -23,13 +23,15 @@ window.Menubar = (() => {
|
||||
function close() {
|
||||
if (openIdx < 0) return false;
|
||||
openIdx = -1;
|
||||
UI.release(close);
|
||||
root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open'));
|
||||
root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; });
|
||||
return true;
|
||||
}
|
||||
|
||||
function openMenu(i) {
|
||||
close();
|
||||
if (openIdx < 0) UI.claim(close);
|
||||
else { root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open')); root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; }); }
|
||||
openIdx = i;
|
||||
const wrap = root.querySelectorAll('.mb-menu')[i];
|
||||
wrap.classList.add('open');
|
||||
@@ -54,7 +56,6 @@ window.Menubar = (() => {
|
||||
const top = e.target.closest('[data-menu]');
|
||||
if (top && openIdx >= 0 && Number(top.dataset.menu) !== openIdx) openMenu(Number(top.dataset.menu));
|
||||
});
|
||||
document.addEventListener('click', close);
|
||||
}
|
||||
|
||||
return { init, close };
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ window.Skybox = (() => {
|
||||
const cache = {};
|
||||
function loadTexture(url) {
|
||||
if (!cache[url]) {
|
||||
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(url, resolve, undefined, reject));
|
||||
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(tectumUrl(url), resolve, undefined, reject));
|
||||
cache[url].catch(() => { delete cache[url]; });
|
||||
}
|
||||
return cache[url];
|
||||
|
||||
+38
-13
@@ -80,8 +80,10 @@ window.UI = (() => {
|
||||
const info = (text) => `<div class="ui-info">${icon('info-circle')}<span>${text}</span></div>`;
|
||||
const warn = (text) => `<div class="ui-warn">${icon('exclamation-triangle')}<span>${text}</span></div>`;
|
||||
const grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`;
|
||||
const button = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
|
||||
// Eén knopbouwer; base = de knopsoort (ui-btn, tb-btn, ins-btn …), cls = extra klassen.
|
||||
const btnOf = (base) => (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="${base}${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
|
||||
const button = btnOf('ui-btn');
|
||||
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
|
||||
|
||||
const activeTabs = {};
|
||||
@@ -142,27 +144,50 @@ window.UI = (() => {
|
||||
el.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode));
|
||||
}
|
||||
|
||||
const tb = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="tb-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
|
||||
const tb = btnOf('tb-btn');
|
||||
const tbSep = () => '<span class="tb-sep"></span>';
|
||||
const viewSeg = (mode) => `<div class="tb-seg" role="group" aria-label="Weergave">${VIEWS.map(v =>
|
||||
tb('', v.icon, `data-view="${v.key}" title="${v.label} (${v.key1})"`, v.key === mode ? 'active' : '')).join('')}</div>`;
|
||||
|
||||
// Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere popover.
|
||||
// Werkbalk van een editor: [left] … [Lagen, Omgeving] [after] | [2D + 3D, 2D, 3D]. Gedeeld door woning, wand en vloer.
|
||||
// o: left/after (html), onResize, layers (opties voor ViewLayers.createMenu), onSky, beforeView(mode).
|
||||
function editorBar(toolbar, root, o) {
|
||||
toolbar.innerHTML = `${o.left || ''}<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${o.after || ''}${tbSep()}${viewSeg(viewOf(root))}`;
|
||||
const group = toolbar.querySelector('.tb-view');
|
||||
if (o.layers) ViewLayers.createMenu(group, o.layers);
|
||||
if (o.onSky) Skybox.createToggle(group, o.onSky);
|
||||
const bar = {
|
||||
setView(mode) {
|
||||
if (o.beforeView) o.beforeView(mode);
|
||||
setView(root, mode);
|
||||
requestAnimationFrame(o.onResize);
|
||||
},
|
||||
viewState: () => ({ view: viewOf(root) })
|
||||
};
|
||||
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => bar.setView(b.dataset.view)));
|
||||
return bar;
|
||||
}
|
||||
|
||||
// Eén uitklapper tegelijk (popover of menu): wie opent, sluit de vorige. claim bij openen, release bij sluiten.
|
||||
let openPop = null;
|
||||
function claim(close) { if (openPop && openPop !== close) openPop(); openPop = close; }
|
||||
function release(close) { if (openPop === close) openPop = null; }
|
||||
const closePopovers = () => { if (openPop) { openPop(); return true; } return false; };
|
||||
document.addEventListener('click', () => closePopovers());
|
||||
|
||||
// Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere uitklapper.
|
||||
function popover(btn, panel) {
|
||||
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); if (openPop === close) openPop = null; };
|
||||
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); release(close); };
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const was = panel.classList.contains('open');
|
||||
if (openPop) openPop();
|
||||
if (!was) { panel.classList.add('open'); btn.classList.add('open'); openPop = close; }
|
||||
if (panel.classList.contains('open')) { close(); return; }
|
||||
claim(close);
|
||||
panel.classList.add('open');
|
||||
btn.classList.add('open');
|
||||
});
|
||||
panel.addEventListener('click', (e) => e.stopPropagation());
|
||||
return close;
|
||||
}
|
||||
const closePopovers = () => { if (openPop) { openPop(); return true; } return false; };
|
||||
document.addEventListener('click', () => closePopovers());
|
||||
|
||||
// Kaarten verdelen over kolommen van min. minW breed; telkens in de kortste kolom. Opnieuw aanroepen bij een andere breedte.
|
||||
function masonry(el, minW, gap) {
|
||||
@@ -178,6 +203,6 @@ window.UI = (() => {
|
||||
cards.forEach(card => cols.reduce((a, b) => b.offsetHeight < a.offsetHeight ? b : a).appendChild(card));
|
||||
}
|
||||
|
||||
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle,
|
||||
VIEWS, viewOf, setView, tb, tbSep, viewSeg, popover, closePopovers, masonry };
|
||||
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, btnOf,
|
||||
VIEWS, viewOf, setView, tb, tbSep, viewSeg, editorBar, popover, claim, release, closePopovers, masonry };
|
||||
})();
|
||||
|
||||
+11
-4
@@ -13,14 +13,14 @@ window.ViewLayers = (() => {
|
||||
{ key: 'beplating', label: 'Beplating', icon: 'square', phase: 2, hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
|
||||
{ key: 'doek', label: 'Vochtdoek', icon: 'droplet', phase: 3, hint: 'Vochtdoek op de buitenzijde' },
|
||||
{ key: 'latwerk', label: 'Latwerk', icon: 'distribute-vertical', phase: 4, hint: 'Kruislat en trasraamplaat' },
|
||||
{ key: 'gevel', label: 'Gevelbekleding', icon: 'bricks', phase: 5, hint: 'Rabat, steenstrips, stuc, schoorsteen, sandwichpanelen' },
|
||||
{ key: 'gevel', label: 'Gevelbekleding', icon: 'bricks', phase: 5, hint: 'Rabat, felsplaten, steenstrips, stuc, schoorsteen, sandwichpanelen' },
|
||||
{ key: 'kozijnen', label: 'Kozijnen', icon: 'window', phase: 5, hint: 'Kozijnen, deuren en glas' },
|
||||
{ key: 'dakbedekking', label: 'Dakbedekking', icon: 'house-up', phase: 5, hint: 'Dakleer of riet' },
|
||||
{ key: 'afwerking', label: 'Afwerking', icon: 'brush', phase: 6, hint: 'Vloerafwerking, verf, plafond, dakrand, dagkanten, logobord' },
|
||||
{ key: 'lampen', label: 'Verlichting', icon: 'lightbulb', phase: 6, hint: 'Gevellampen, spots en binnenverlichting' },
|
||||
{ key: 'inrichting', label: 'Inrichting', icon: 'lamp', phase: 6, hint: 'Sanitair, keuken, kasten, bed, zithoek' },
|
||||
{ key: 'ruimtes', label: 'Ruimtes', icon: 'bounding-box', phase: 6, hint: 'Ruimtes met functie, vloer en wandpanelen' },
|
||||
{ key: 'dak', label: 'Dak', icon: 'house', hint: 'Het hele dak met dakrand, overkapping, palen en schoorsteen; uit om van boven in de woning te kijken' },
|
||||
{ key: 'dak', label: 'Dak', icon: 'house', hint: 'Het hele dak met dakrand, overkapping en palen; uit om van boven in de woning te kijken' },
|
||||
{ key: 'maten', label: 'Maten', icon: 'rulers', hint: 'Maatvoering in de plattegrond' }
|
||||
];
|
||||
const KEYS = LIST.map(l => l.key);
|
||||
@@ -98,10 +98,17 @@ window.ViewLayers = (() => {
|
||||
emit();
|
||||
}
|
||||
});
|
||||
// Alles = ook de editor-eigen schakelaars weer aan.
|
||||
const all = panel.querySelector('[data-all]');
|
||||
if (all) all.addEventListener('click', () => { cur = ViewLayers.all(); emit(); });
|
||||
if (all) all.addEventListener('click', () => {
|
||||
const extraOff = extras.some(x => !extra[x.key]);
|
||||
extras.forEach(x => { extra[x.key] = true; });
|
||||
cur = ViewLayers.all();
|
||||
emit();
|
||||
if (extraOff && o.onExtra) o.onExtra(Object.assign({}, extra));
|
||||
});
|
||||
sync();
|
||||
return { el: wrap, set: (l) => { cur = normalize(l); sync(); }, toggle: (key) => { cur = Object.assign({}, cur, { [key]: !cur[key] }); emit(); } };
|
||||
return { el: wrap, set: (l) => { cur = normalize(l); sync(); } };
|
||||
}
|
||||
|
||||
return { LIST, KEYS, PHASES, GROUPS, all, normalize, shows, upTo, phaseOf, hideByMaterial, createMenu };
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ window.CostCsv = (() => {
|
||||
}
|
||||
function loadList(list) {
|
||||
const url = LISTS[list].url;
|
||||
return fetch(url, { cache: 'no-store' })
|
||||
return fetch(tectumUrl(url), { cache: 'no-store' })
|
||||
.then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,11 @@ window.COST_DEFAULTS = {
|
||||
steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 },
|
||||
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 },
|
||||
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 },
|
||||
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 }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -116,6 +120,8 @@ window.COST_DEFAULTS = {
|
||||
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
|
||||
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
|
||||
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 },
|
||||
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 },
|
||||
@@ -123,7 +129,9 @@ window.COST_DEFAULTS = {
|
||||
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 },
|
||||
paalHoutM3: { label: 'Paalhout overkapping (eiken, geschaafd)', unit: '€/m³', value: 2400 },
|
||||
paalvoet: { label: 'Paalvoet verzinkt, verstelbaar (incl. ankers)', unit: '€/st.', value: 40 },
|
||||
paalMontage: { label: 'Paal plaatsen: stellen, aftoppen, vastzetten aan het dak', unit: 's/st.', value: 2700 }
|
||||
paalMontage: { label: 'Paal plaatsen: stellen, aftoppen, vastzetten aan het dak', unit: 's/st.', value: 2700 },
|
||||
zijwandM2: { label: 'Zijwand overkapping (regelwerk, cementgebonden plaat en stuc aan beide kanten, fundering)', unit: '€/m²', value: 140 },
|
||||
zijwandMontage: { label: 'Zijwand overkapping maken en plaatsen', unit: 's/m²', value: 5400 }
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -155,8 +155,12 @@ window.CostModel = (() => {
|
||||
add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM);
|
||||
const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
|
||||
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
|
||||
const fels = p.buitenMateriaal === 'vochtdoek' ? (st.felsAreaM2 || 0) : 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);
|
||||
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;
|
||||
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0));
|
||||
const latM = st.battenLengthM || 0;
|
||||
@@ -237,11 +241,17 @@ 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', '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', 'Ventilatierooster onder boeideel', e.ventLengthM || 0, 'm', (e.ventLengthM || 0) * (D.ventRoosterM || 0), (e.ventLengthM || 0) * (D.ventRoosterMontage || 0));
|
||||
}
|
||||
if (totals.posts) {
|
||||
const pp = totals.posts;
|
||||
add(wo, 'Hout', 'Dak', `${pp.wood} palen ${pp.size}×${pp.size} mm (${pp.count} st.)`, pp.lengthM, 'm', pp.m3 * D.paalHoutM3, pp.count * D.paalMontage);
|
||||
add(wo, 'Staal', 'Dak', 'Paalvoeten', pp.count, 'st.', pp.count * D.paalvoet, 0);
|
||||
add(wo, 'Gevel', 'Dak', `Zijwand overkapping (${pp.walls || 0} st.)`, pp.wallM2 || 0, 'm²', (pp.wallM2 || 0) * (D.zijwandM2 || 0), (pp.wallM2 || 0) * (D.zijwandMontage || 0));
|
||||
// Buitenkant zoals de gevel ernaast (stuc zit al in de zijwand).
|
||||
const G = S.gevel, oo = pp.outsideM2 || {};
|
||||
[['fels', 'felsplaten', G.felsM2, G.felsMontage], ['rabat', 'rabat', G.rabatM2, G.rabatMontage], ['steen', 'steenstrips', G.steenM2, G.steenMontage]].forEach(([k, label, m2, sec]) =>
|
||||
add(wo, 'Gevel', 'Gevel', `Zijwand overkapping buitenzijde ${label}`, oo[k] || 0, 'm²', (oo[k] || 0) * (m2 || 0), (oo[k] || 0) * (sec || 0)));
|
||||
}
|
||||
if (parts.sandwich) {
|
||||
const sw = parts.sandwich;
|
||||
|
||||
@@ -29,7 +29,7 @@ window.CostSettings = (() => {
|
||||
<button type="button" class="ui-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
|
||||
<button type="button" class="ui-btn" data-cs="import">Importeren…</button>
|
||||
<span style="flex:1"></span>
|
||||
<button type="button" class="ui-btn" data-cs="cancel">Annuleren</button>
|
||||
<button type="button" class="ui-btn" data-cs="cancel" data-esc>Annuleren</button>
|
||||
<button type="button" class="ui-btn ui-btn-primary" data-cs="save">Toepassen</button>
|
||||
</div>
|
||||
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Koppeling met de Van Emous CRM (alleen in de Docker-stack): woningen van leads en klanten laden (Nieuw › Uit de
|
||||
// CRM, of ?woning=<id> in de URL) en een gewijzigde woning als nieuwe versie terugzetten (Opslaan in CRM).
|
||||
// De API staat op api/crm/ naast deze pagina: Caddy stuurt dat na het Tectum-wachtwoord door naar de CRM, met de
|
||||
// sleutel erbij. Zonder stack (start.sh) geeft api/crm/ een 404 en blijft alles van de CRM verborgen.
|
||||
window.TectumCrm = (function () {
|
||||
const API = 'api/crm/woningen';
|
||||
let available = null;
|
||||
|
||||
async function call(url, opts) {
|
||||
const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json', ...(opts && opts.body ? { 'Content-Type': 'application/json' } : {}) }, ...opts });
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { /* geen JSON: geen CRM */ }
|
||||
if (!data) throw new Error(`CRM niet bereikbaar (HTTP ${res.status})`);
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
// Eén keer kijken of de CRM bereikbaar is (de lijst ophalen); onthoudt de uitkomst
|
||||
function isAvailable() {
|
||||
if (!available) available = list('').then(() => true, () => false);
|
||||
return available;
|
||||
}
|
||||
|
||||
async function list(q) {
|
||||
const { data } = await call(`${API}?q=${encodeURIComponent(q || '')}`);
|
||||
if (!data.ok) throw new Error(data.error || 'CRM-fout');
|
||||
return data.woningen;
|
||||
}
|
||||
|
||||
// Ontwerp van een woning (nieuwste versie, of `versie`), klaar voor de editor: gecontroleerd en zonder de
|
||||
// bestandsvelden (type, version, exportedAt), zoals bij Openen.
|
||||
async function load(id, versie) {
|
||||
const { data } = await call(`${API}/${encodeURIComponent(id)}${versie ? `?versie=${versie}` : ''}`);
|
||||
if (!data.ok) throw new Error(data.error || 'Woning niet gevonden');
|
||||
HouseModel.validateState(data.ontwerp);
|
||||
const { type, version, exportedAt, ...state } = data.ontwerp;
|
||||
return { woning: data.woning, state };
|
||||
}
|
||||
|
||||
// Nieuwe versie bewaren. basis = de versie die geladen was; is er intussen een nieuwere, dan vraagt dit eerst.
|
||||
async function saveVersion(id, state, basis, opmerking) {
|
||||
const ontwerp = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), configurator: APP_VERSION, ...state };
|
||||
const send = (forceer) => call(`${API}/${encodeURIComponent(id)}/versies`, { method: 'POST', body: JSON.stringify({ ontwerp, opmerking, basis, forceer }) });
|
||||
let { status, data } = await send(false);
|
||||
if (status === 409) {
|
||||
if (!window.confirm(`In de CRM staat intussen versie ${data.versie} van deze woning (u bewerkt versie ${basis}). Toch opslaan als nieuwe versie?`)) return null;
|
||||
({ data } = await send(true));
|
||||
}
|
||||
if (!data.ok) throw new Error(data.error || 'Opslaan mislukt');
|
||||
return data.versie;
|
||||
}
|
||||
|
||||
function toast(msg, cls) {
|
||||
let el = document.getElementById('crm-toast');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'crm-toast';
|
||||
el.setAttribute('role', 'status');
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent = msg;
|
||||
el.className = 'open ' + (cls || '');
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => { el.className = ''; }, 5000);
|
||||
}
|
||||
|
||||
const date = (s) => (s || '').slice(0, 10).split('-').reverse().join('-');
|
||||
|
||||
// Sectie "Woningen uit de CRM" in het venster Nieuw: zoeken en kiezen; verborgen zolang de CRM er niet is
|
||||
function mountPicker(host, onPick) {
|
||||
host.innerHTML = `<h3>Woningen van leads en klanten (CRM)</h3>
|
||||
<input type="search" class="new-crm-q" placeholder="Zoek op woning, naam of plaats" aria-label="Zoek in de CRM">
|
||||
<ul class="new-crm-list" role="list"></ul>`;
|
||||
const q = host.querySelector('.new-crm-q'), ul = host.querySelector('.new-crm-list');
|
||||
let req = 0, timer = null;
|
||||
const render = (rows) => {
|
||||
ul.innerHTML = rows.length ? rows.map(w => `<li><button type="button" class="new-crm-item" data-id="${w.id}">
|
||||
<i class="bi bi-house"></i><span><strong>${UI.esc(w.titel)}</strong>
|
||||
<small>${UI.esc([w.contact && w.contact.naam, w.relatie, w.contact && w.contact.plaats].filter(Boolean).join(' · '))}
|
||||
· versie ${w.versie} · ${date(w.datum)}</small></span></button></li>`).join('')
|
||||
: '<li class="new-crm-empty">Geen woningen gevonden.</li>';
|
||||
};
|
||||
const search = () => {
|
||||
const id = ++req;
|
||||
list(q.value.trim()).then(rows => { if (id === req) render(rows); }, err => { if (id === req) ul.innerHTML = `<li class="new-crm-empty">${UI.esc(err.message)}</li>`; });
|
||||
};
|
||||
q.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(search, 250); });
|
||||
ul.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('.new-crm-item');
|
||||
if (b) onPick(Number(b.dataset.id));
|
||||
});
|
||||
isAvailable().then(ok => { host.hidden = !ok; if (ok) search(); });
|
||||
return { refresh: () => { if (!host.hidden) search(); } };
|
||||
}
|
||||
|
||||
return { isAvailable, list, load, saveVersion, mountPicker, toast };
|
||||
})();
|
||||
+116
-20
@@ -143,11 +143,49 @@ window.ElektraModel = (() => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gaat het stuk p→q (wereld, mm) door een raam of deur? Een wand telt over zijn dikte plus 5 mm, de opening 1 mm
|
||||
// kleiner: een buis naast de opening of erboven/eronder mag.
|
||||
function makeOpeningTest(units, interior) {
|
||||
const pieces = [];
|
||||
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { if (p.config.openings.length) pieces.push(p); })));
|
||||
interior.forEach(p => { if (p.config.openings.length) pieces.push(p); });
|
||||
const clip = (r, a, b, lo, hi) => {
|
||||
const d = b - a;
|
||||
if (Math.abs(d) < 1e-9) return a > lo && a < hi ? r : null;
|
||||
let t0 = (lo - a) / d, t1 = (hi - a) / d;
|
||||
if (t0 > t1) [t0, t1] = [t1, t0];
|
||||
return Math.max(r[0], t0) < Math.min(r[1], t1) ? [Math.max(r[0], t0), Math.min(r[1], t1)] : null;
|
||||
};
|
||||
return (p, q) => pieces.some(pc => {
|
||||
const pl = pc.place, c = Math.cos(pl.rotY), s = Math.sin(pl.rotY), half = pl.thickness / 2 + 5;
|
||||
const loc = (v) => { const dx = v.x - pl.cx, dz = v.z - pl.cz; return { u: dx * c - dz * s + pl.width / 2, n: dx * s + dz * c, y: v.y - pl.floorTop }; };
|
||||
const A = loc(p), B = loc(q);
|
||||
const inWall = clip([0, 1], A.n, B.n, -half, half);
|
||||
return !!inWall && pc.config.openings.some(op => {
|
||||
let r = clip(inWall, A.u, B.u, op.x + 1, op.x + op.w - 1);
|
||||
r = r && clip(r, A.y, B.y, op.y + 1, WallModel.openingTopMax(op) - 1);
|
||||
if (!r) return false;
|
||||
// Schuine bovenkant: alleen onder de daklijn van de opening.
|
||||
return [0, 0.25, 0.5, 0.75, 1].some(k => {
|
||||
const t = r[0] + (r[1] - r[0]) * k, u = A.u + (B.u - A.u) * t, y = A.y + (B.y - A.y) * t;
|
||||
return y < WallModel.openingTopAt(op, u) - 1;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// rooms: leidingen lopen boven een vals plafond, niet tegen de onderkant van een hoog dak.
|
||||
function makeRouter(units, rooms) {
|
||||
// blocked(p, q): het stuk gaat door een wandopening (makeOpeningTest).
|
||||
function makeRouter(units, rooms, blocked) {
|
||||
const unitAt = (x, z) => units.find(u => x >= u.rect.x0 - 1 && x <= u.rect.x1 + 1 && z >= u.rect.z0 - 1 && z <= u.rect.z1 + 1) || null;
|
||||
// Vals plafond (mm boven vloer) van de ruimte op (x,z): undefined = geen ruimte (binnenwand, naad), null = geen vals plafond.
|
||||
const roomPlenum = (x, z) => { const r = rooms && rooms.roomAt ? rooms.roomAt(x, z) : null; return r ? (r.ceilingActive ? r.ceiling : null) : undefined; };
|
||||
// Vals plafond (mm boven vloer) van de ruimte op (x,z): undefined = in een wand of naad (geen gebied), null = geen
|
||||
// vals plafond (ook een gebied zonder functie: niet het plafond van de buurruimte door de wand heen overnemen).
|
||||
const roomPlenum = (x, z) => {
|
||||
if (!rooms || !rooms.roomAt) return undefined;
|
||||
const r = rooms.roomAt(x, z);
|
||||
if (r) return r.ceilingActive ? r.ceiling : null;
|
||||
return rooms.regionAt && rooms.regionAt(x, z) ? null : undefined;
|
||||
};
|
||||
// Absoluut; in een binnenwand het laagste valse plafond ernaast: de leiding gaat daar door de wand, niet omhoog.
|
||||
const plenumAt = (x, z) => {
|
||||
const u = unitAt(x, z);
|
||||
@@ -192,7 +230,12 @@ window.ElektraModel = (() => {
|
||||
const hOf = (pt, st) => st !== null ? st : roofCeilAt(pt.x, pt.z);
|
||||
const mk = (pt, y) => Object.assign({}, p, pt, { y, wall: null, ceil: true, floor: false, plenum: inPlenum(pt.x, pt.z, y) });
|
||||
const n = Math.max(2, Math.ceil(len / 100));
|
||||
const S = Array.from({ length: n + 1 }, (_, k) => { const pt = at(k / n), st = plenumOn(pt.x, pt.z); return { t: k / n, pt, st, y: hOf(pt, st) }; });
|
||||
// Begin/eind op een wandvlak 50 mm de ruimte in meten, net als top(): anders telt de buurruimte achter de wand mee.
|
||||
const probe = (pt, e) => e.wall && e.wall.n ? { x: pt.x + e.wall.n.x * 50, z: pt.z + e.wall.n.z * 50 } : pt;
|
||||
const S = Array.from({ length: n + 1 }, (_, k) => {
|
||||
const pt = at(k / n), pr = k === 0 ? probe(pt, p) : k === n ? probe(pt, q) : pt, st = plenumOn(pr.x, pr.z);
|
||||
return { t: k / n, pt, st, y: hOf(pt, st) };
|
||||
});
|
||||
for (let k = 1; k <= n; k++) {
|
||||
const A = S[k - 1], B = S[k];
|
||||
if (!same(A.st, B.st)) {
|
||||
@@ -250,28 +293,79 @@ window.ElektraModel = (() => {
|
||||
.filter(q => Math.hypot(q.x - a.x, q.z - a.z) > 0.5 && Math.hypot(q.x - b.x, q.z - b.z) > 0.5);
|
||||
}
|
||||
const strictlyInside = (x, z) => units.some(u => x > u.rect.x0 + 5 && x < u.rect.x1 - 5 && z > u.rect.z0 + 5 && z < u.rect.z1 - 5);
|
||||
const clear = (pts) => !blocked || pts.slice(1).every((q, i) => !blocked(pts[i], q));
|
||||
// Wandpunt dat recht omhoog/omlaag door een raam of deur zou gaan (wcd onder een raam, meterkast onder een
|
||||
// bovenlicht): eerst op dezelfde hoogte opzij tot naast de opening (stijl + 60 mm), de dichtstbijzijnde kant
|
||||
// die vrij is. yTo(punt) = hoogte waar de leiding heen gaat. null = recht kan, of nergens plek.
|
||||
function sidestep(p, yTo) {
|
||||
const f = p.face;
|
||||
if (!f || !blocked || !blocked(p, { x: p.x, y: yTo(p), z: p.z })) return null;
|
||||
const y = p.y - f.piece.place.floorTop, u0 = uAt(f, p), lo = Math.min(y, yTo(p) - f.piece.place.floorTop);
|
||||
const cands = [];
|
||||
f.piece.config.openings.forEach(op => {
|
||||
if (WallModel.openingTopMax(op) > lo) cands.push(op.x - f.sw - 60, op.x + op.w + f.sw + 60);
|
||||
});
|
||||
const pick = cands.filter(u => u > f.u0 + 50 && u < f.u1 - 50).sort((m, n) => Math.abs(m - u0) - Math.abs(n - u0)).find(u => {
|
||||
const q = Object.assign({}, p, facePoint(f, u, y, 0));
|
||||
return !blocked(p, q) && !blocked(q, { x: q.x, y: yTo(q), z: q.z });
|
||||
});
|
||||
return pick === undefined ? null : Object.assign({}, p, facePoint(f, pick, y, 0));
|
||||
}
|
||||
// Gevellamp naar gevellamp: langs de gevel (om de hoeken heen), nooit dwars door de woning.
|
||||
function outside(a, b) {
|
||||
const gap = (p) => Math.min(...units.map(u => Math.max(u.rect.x0 - p.x, 0, p.x - u.rect.x1) + Math.max(u.rect.z0 - p.z, 0, p.z - u.rect.z1)));
|
||||
const d = Math.max(60, gap(a), gap(b));
|
||||
const cands = [[{ x: b.x, z: a.z }], [{ x: a.x, z: b.z }]];
|
||||
units.forEach(u => {
|
||||
[u.rect.x0 - d, u.rect.x1 + d].forEach(x => cands.push([{ x, z: a.z }, { x, z: b.z }]));
|
||||
[u.rect.z0 - d, u.rect.z1 + d].forEach(z => cands.push([{ x: a.x, z }, { x: b.x, z }]));
|
||||
});
|
||||
const free = (p, q) => {
|
||||
const n = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 50));
|
||||
for (let k = 1; k < n; k++) if (strictlyInside(p.x + (q.x - p.x) * k / n, p.z + (q.z - p.z) * k / n)) return false;
|
||||
return true;
|
||||
};
|
||||
let best = null;
|
||||
cands.forEach(c => {
|
||||
const pts = [a].concat(c, [b]);
|
||||
if (!pts.slice(1).every((q, i) => free(pts[i], q))) return;
|
||||
const len = pts.slice(1).reduce((t, q, i) => t + Math.abs(q.x - pts[i].x) + Math.abs(q.z - pts[i].z), 0);
|
||||
if (!best || len < best.len - 1) best = { len, c };
|
||||
});
|
||||
return best ? best.c : [strictlyInside(b.x, a.z) ? { x: a.x, z: b.z } : { x: b.x, z: a.z }];
|
||||
}
|
||||
function path(a, b) {
|
||||
const P = (p, wall, ceil, floor) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil, floor: !!floor, plenum: !!ceil && inPlenum(p.x, p.z, p.y) });
|
||||
if (a.mount === 'vloer' || b.mount === 'vloer') {
|
||||
const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH;
|
||||
return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true)]
|
||||
.concat(corners(a, b).map(c => P({ x: c.x, y: fa, z: c.z }, null, false, true)),
|
||||
[P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')]);
|
||||
const A2 = a.mount === 'vloer' ? null : sidestep(a, () => fa), B2 = b.mount === 'vloer' ? null : sidestep(b, () => fb);
|
||||
const a2 = A2 || a, b2 = B2 || b;
|
||||
return [P(a, a.face, false, a.mount === 'vloer')].concat(A2 ? [P(A2, a.face)] : [], [P({ x: a2.x, y: fa, z: a2.z }, a.face, false, true)])
|
||||
.concat(corners(a2, b2).map(c => P({ x: c.x, y: fa, z: c.z }, null, false, true)),
|
||||
[P({ x: b2.x, y: fb, z: b2.z }, b.face, false, true)], B2 ? [P(B2, b.face)] : [], [P(b, b.face, false, b.mount === 'vloer')]);
|
||||
}
|
||||
if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
|
||||
// Gevellamp naar gevellamp: langs de gevel, bij een binnenhoek niet dwars door de woning.
|
||||
if (a.mount === 'buiten' && b.mount === 'buiten') {
|
||||
const c = strictlyInside(b.x, a.z) ? { x: a.x, z: b.z } : { x: b.x, z: a.z };
|
||||
return [P(a), P({ x: c.x, y: a.y, z: c.z }), P(b)];
|
||||
// Langs dezelfde wand: eerst verticaal of eerst horizontaal; zit er een deur of raam tussen, dan via het plafond.
|
||||
if (a.face && a.face === b.face) {
|
||||
const viaA = [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
|
||||
const viaB = [P(a, a.face), P({ x: b.x, y: a.y, z: b.z }, a.face), P(b, b.face)];
|
||||
if (clear(viaA)) return viaA;
|
||||
if (clear(viaB)) return viaB;
|
||||
}
|
||||
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a)].concat(outside(a, b).map(c => P({ x: c.x, y: a.y, z: c.z })), [P(b)]);
|
||||
// Wandpunt: de plafondhoogte iets de ruimte in meten (de wand zelf hoort bij geen ruimte).
|
||||
const top = (p) => p.mount === 'plafond' ? p.y : p.face && p.face.n ? ceilAt(p.x + p.face.n.x * 50, p.z + p.face.n.z * 50) : ceilAt(p.x, p.z);
|
||||
const ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1;
|
||||
const A2 = a.mount === 'plafond' ? null : sidestep(a, top), B2 = b.mount === 'plafond' ? null : sidestep(b, top);
|
||||
const a2 = A2 || a, b2 = B2 || b;
|
||||
const ya = top(a2), yb = top(b2), dropB = Math.abs(yb - b.y) > 1;
|
||||
const pts = [P(a, a.face, a.mount === 'plafond')];
|
||||
if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a.x, y: ya, z: a.z }, a.face, true));
|
||||
corners(a, b, true).forEach(c => pts.push(P({ x: c.x, y: ceilAt(c.x, c.z), z: c.z }, null, true)));
|
||||
pts.push(P({ x: b.x, y: yb, z: b.z }, dropB ? b.face : null, true));
|
||||
if (dropB) pts.push(P(b, b.face, false));
|
||||
if (A2) pts.push(P(A2, a.face));
|
||||
if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a2.x, y: ya, z: a2.z }, a.face, true));
|
||||
corners(a2, b2, true).forEach(c => pts.push(P({ x: c.x, y: ceilAt(c.x, c.z), z: c.z }, null, true)));
|
||||
pts.push(P({ x: b2.x, y: yb, z: b2.z }, dropB ? b.face : null, true));
|
||||
if (dropB) {
|
||||
if (B2) pts.push(P(B2, b.face));
|
||||
pts.push(P(b, b.face, false));
|
||||
}
|
||||
return followCeiling(pts);
|
||||
}
|
||||
const length = (pts) => pts.slice(1).reduce((s, q, i) => s + Math.hypot(q.x - pts[i].x, q.y - pts[i].y, q.z - pts[i].z), 0);
|
||||
@@ -301,7 +395,8 @@ window.ElektraModel = (() => {
|
||||
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
|
||||
};
|
||||
if (!s.enabled || !units.length) return out;
|
||||
const R = makeRouter(units, rooms);
|
||||
const blocked = makeOpeningTest(units, interior);
|
||||
const R = makeRouter(units, rooms, blocked);
|
||||
const faces = {};
|
||||
units.forEach(u => { faces[u.id] = facesOf(u, interior); });
|
||||
const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1);
|
||||
@@ -622,7 +717,7 @@ window.ElektraModel = (() => {
|
||||
});
|
||||
if (mkLow) out.apps.forEach(a => addRun(APPLIANCES[a.app].cable, mkLow, Object.assign({}, a.pos, { mount: 'wand', face: a.face, name: a.code }), a.group));
|
||||
|
||||
separateRuns(out.runs);
|
||||
separateRuns(out.runs, blocked);
|
||||
const holesOf = makeHoleCounter(units, R);
|
||||
out.runs.forEach(r => {
|
||||
r.rawMM = R.length(r.path);
|
||||
@@ -711,7 +806,7 @@ window.ElektraModel = (() => {
|
||||
}
|
||||
|
||||
// Uitzondering: het laatste stuk naar de meterkast; daar komen de buizen tegen elkaar aan de kast binnen.
|
||||
function separateRuns(runs) {
|
||||
function separateRuns(runs, blocked) {
|
||||
const add = (p, d, k) => Object.assign({}, p, { x: p.x + d.x * k, y: p.y + d.y * k, z: p.z + d.z * k });
|
||||
const axisOf = (p, q) => {
|
||||
const dx = Math.abs(q.x - p.x), dy = Math.abs(q.y - p.y), dz = Math.abs(q.z - p.z);
|
||||
@@ -782,6 +877,7 @@ window.ElektraModel = (() => {
|
||||
const kk = sign * k * step, p2 = add(p, dir, kk), q2 = add(q, dir, kk), s2 = seg(r, p2, q2, rad);
|
||||
const j1 = seg(r, p, p2, rad), j2 = seg(r, q2, q, rad);
|
||||
if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue;
|
||||
if (blocked && (blocked(p2, q2) || blocked(p, p2) || blocked(q2, q))) continue;
|
||||
pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1));
|
||||
placed.push(s2, j1, j2);
|
||||
i += 2;
|
||||
|
||||
@@ -293,7 +293,7 @@ window.FloorModel = (() => {
|
||||
}
|
||||
function finishThumbUrl(key, px) {
|
||||
const f = FINISHES[key];
|
||||
return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null;
|
||||
return f && f.file ? tectumUrl(f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png'))) : null;
|
||||
}
|
||||
const finishMaterials = {};
|
||||
function finishMaterial(key) {
|
||||
@@ -307,7 +307,7 @@ window.FloorModel = (() => {
|
||||
}
|
||||
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
|
||||
if (typeof document !== 'undefined' && THREE.TextureLoader) {
|
||||
new THREE.TextureLoader().load(f.file, (tex) => {
|
||||
new THREE.TextureLoader().load(tectumUrl(f.file), (tex) => {
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
|
||||
tex.anisotropy = 8;
|
||||
|
||||
+12
-17
@@ -159,7 +159,7 @@ const FLOOR_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="wall-project-cancel" class="ui-btn">Annuleren</button>
|
||||
<button type="button" id="wall-project-cancel" class="ui-btn" data-esc>Annuleren</button>
|
||||
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -620,22 +620,21 @@ function mountFloorEditor(container, callbacks) {
|
||||
let lastResolvedOpenings = [];
|
||||
let lastChassisData = { wheels: [], outOfBounds: false };
|
||||
|
||||
const toolbar = document.getElementById('floor-toolbar');
|
||||
toolbar.innerHTML = `<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${UI.tbSep()}${UI.viewSeg('split')}`;
|
||||
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||
const viewGroup = toolbar.querySelector('.tb-view');
|
||||
ViewLayers.createMenu(viewGroup, {
|
||||
layers: {}, keys: [],
|
||||
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
|
||||
onExtra: (v) => { osbVisible = v.beplating; update3DScene(); }
|
||||
});
|
||||
let skyMode = Skybox.getMode();
|
||||
const skyEnv = Skybox.createEnvironment(scene);
|
||||
const applySky = () => {
|
||||
skyEnv.fitTo(floorGroup3D, 0);
|
||||
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient: floorAmbient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: floorGroup3D });
|
||||
};
|
||||
Skybox.createToggle(viewGroup, (m) => { skyMode = m; applySky(); });
|
||||
const bar = UI.editorBar(document.getElementById('floor-toolbar'), document.getElementById('floor-app'), {
|
||||
onResize: () => onWindowResize(),
|
||||
layers: {
|
||||
layers: {}, keys: [],
|
||||
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
|
||||
onExtra: (v) => { osbVisible = v.beplating; update3DScene(); }
|
||||
},
|
||||
onSky: (m) => { skyMode = m; applySky(); }
|
||||
});
|
||||
|
||||
function update3DScene() {
|
||||
if (floorGroup3D) scene.remove(floorGroup3D);
|
||||
@@ -682,10 +681,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
}
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
function setView(mode) {
|
||||
UI.setView(document.getElementById('floor-app'), mode);
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
|
||||
function getPureState() {
|
||||
return {
|
||||
@@ -735,8 +730,8 @@ function mountFloorEditor(container, callbacks) {
|
||||
},
|
||||
exportDesign,
|
||||
triggerImport,
|
||||
setView,
|
||||
viewState: () => ({ view: UI.viewOf(document.getElementById('floor-app')) }),
|
||||
setView: bar.setView,
|
||||
viewState: bar.viewState,
|
||||
openProjectProperties,
|
||||
getStateJson: getPureState,
|
||||
loadStateJson: applyPureState
|
||||
|
||||
@@ -200,6 +200,7 @@ window.HouseDxf = (() => {
|
||||
const s = pp.size / 2;
|
||||
dxf.polyline([{ x: pp.x - s, z: pp.z - s }, { x: pp.x + s, z: pp.z - s }, { x: pp.x + s, z: pp.z + s }, { x: pp.x - s, z: pp.z + s }].map(T), true, 'PL-PALEN');
|
||||
});
|
||||
parts.posts.walls.forEach(w => dxf.polyline([{ x: w.x0, z: w.z0 }, { x: w.x1, z: w.z0 }, { x: w.x1, z: w.z1 }, { x: w.x0, z: w.z1 }].map(T), true, 'PL-PALEN'));
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
dxf.polyline(ch.footprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||
dxf.polyline(ch.flueFootprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||
|
||||
@@ -49,19 +49,20 @@ window.HouseIfc = (() => {
|
||||
};
|
||||
}
|
||||
|
||||
function build(state, layers) {
|
||||
// rough: buizen en dozen bouwen; de plaatfase verzamelt alleen platen en heeft ze niet nodig.
|
||||
function build(state, layers, rough) {
|
||||
const M = materialsSet();
|
||||
const parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
|
||||
layers: Object.assign(layers, { lampen: false })
|
||||
layers, elektraRough: rough
|
||||
});
|
||||
HouseModel.placeGroups(parts);
|
||||
return { parts, classify: classifier(M) };
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const frame = build(state, ViewLayers.upTo(1));
|
||||
const sheet = build(state, ViewLayers.upTo(2));
|
||||
const frame = build(state, ViewLayers.upTo(1), true);
|
||||
const sheet = build(state, ViewLayers.upTo(2), false);
|
||||
const parts = frame.parts;
|
||||
if (!parts.units.length) throw new Error('geen units in het ontwerp');
|
||||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||||
|
||||
+500
-28
@@ -34,9 +34,9 @@ window.HouseModel = (() => {
|
||||
function createDefaultSettings() {
|
||||
return {
|
||||
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
|
||||
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit' },
|
||||
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit', felsColor: 'antraciet' },
|
||||
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,
|
||||
interiorColor: '#f2efe9',
|
||||
ceilingColor: '#f4f3ef',
|
||||
@@ -54,7 +54,7 @@ window.HouseModel = (() => {
|
||||
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting(), facade: { walls: {} } };
|
||||
}
|
||||
|
||||
const FACADE_TYPES = { rabat: 'Rabat', steen: 'Steen', stuc: 'Stucwerk' };
|
||||
const FACADE_TYPES = { rabat: 'Rabat', steen: 'Steen', stuc: 'Stucwerk', fels: 'Felsplaten' };
|
||||
function facadeFor(state, u, side, piece) {
|
||||
const f = state.settings.facade;
|
||||
const ow = (u.facade && u.facade.walls && u.facade.walls[side]) || null;
|
||||
@@ -62,15 +62,18 @@ window.HouseModel = (() => {
|
||||
const type = o && FACADE_TYPES[o.type] ? o.type : f.type;
|
||||
const stone = o && o.type === 'steen' && WallModel.isStoneFinish(o.stone) ? o.stone : f.stone;
|
||||
const stuc = o && o.type === 'stuc' && WallModel.isStucColor(o.stuc) ? o.stuc : f.stucColor;
|
||||
return { type, stone, stuc, override: !!o };
|
||||
const fels = o && o.type === 'fels' && WallModel.isFelsColor(o.fels) ? o.fels : f.felsColor;
|
||||
return { type, stone, stuc, fels, override: !!o };
|
||||
}
|
||||
function facadeMaterials(state, fc) {
|
||||
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;
|
||||
if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null };
|
||||
if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null };
|
||||
return { exterior: 'vochtdoek', stone: null, stuc: null, cladding, trasraam, rabatColor: state.settings.facade.rabatColor };
|
||||
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 };
|
||||
// Felsplaten: opbouw als verticale rabat (latten liggend), de banen staan altijd.
|
||||
if (fc.type === 'fels') return { exterior: 'vochtdoek', stone: null, stuc: null, cladding: 'verticaal', trasraam, rabatColor: null, fels: fc.fels };
|
||||
return { exterior: 'vochtdoek', stone: null, stuc: null, cladding, trasraam, rabatColor: state.settings.facade.rabatColor, fels: null };
|
||||
}
|
||||
|
||||
function newModelState(naam) {
|
||||
@@ -660,6 +663,328 @@ window.HouseModel = (() => {
|
||||
return st;
|
||||
}
|
||||
|
||||
// Model Reest (examples/ontwerp_reest.pdf): 12,9 × 7,85 m. Hoofdvolume 11,5 × 4,8 m onder één lessenaarsdak (hoog
|
||||
// aan de noordgevel) dat aan de westkop 1,4 m doorloopt als overkapping bij de voordeur, aan de zuidkant dicht met
|
||||
// een witte zijwand (PostModel sideWall, geen palen). 4,8 m is te diep voor één unit: twee units (3,4 + 1,4 m, de
|
||||
// naad ten zuiden van het glas in de westgevel) met elk een lessenaar in hetzelfde vlak. Aan de noordwestkant een
|
||||
// aanbouw van 3,89 × 3,05 m met plat dak (toilet, berging, entree), die met een open doorgang op de woonkamer
|
||||
// uitkomt; de voordeur zit onder de overkapping. Zuidgevel en dak in felsplaten (daar geen boeideel: de dakplaat
|
||||
// zet om, en de felsplaten lopen over de zijwand door), de rest verticale rabat; de aanbouw heeft daktrim. De
|
||||
// aanbouw is de eerste unit, zodat alle maten buitenwerks vanaf zijn noordwesthoek gelden, zoals de tekening.
|
||||
// De tekening heeft de zuidgevel van de aanbouw 240 mm zuidelijker (3290); in de configurator ligt hij op de naad.
|
||||
function createReest() {
|
||||
const st = newModelState('Reest');
|
||||
const S = st.settings, HA = 2800, HN = 3500, HM = 2820, HS = 2540, DN = 3400, DS = 1400, V = 1400, FELS = 'antraciet';
|
||||
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel', felsColor: FELS });
|
||||
S.cladding = 'verticaal';
|
||||
S.trasraam.enabled = false;
|
||||
Object.assign(S.roof, { fasciaColor: 'wit', overhang: 300, fasciaHeight: 300 });
|
||||
S.roof.posts = PostModel.normalizeSettings({ enabled: true, sideWall: true, sideColor: 'wit', maxSpan: 6000 });
|
||||
S.lighting.mode = 'gevel';
|
||||
S.lighting.direction = 'both';
|
||||
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
||||
if (S.elektra) S.elektra.plateColor = 'antraciet';
|
||||
|
||||
const A = createUnitEntry('A', null, { width: 3890, depth: 3050, height: HA, windowOn: [] });
|
||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: V }, { width: 11500, depth: DN, height: HM, windowOn: [] });
|
||||
const C = createUnitEntry('C', { to: 'B', side: 'achter', align: 'start', offset: 0 }, { width: 11500, depth: DS, height: HS, windowOn: [] });
|
||||
st.units.push(A, B, C);
|
||||
UnitModel.applyRoofForm(B.unit, { type: 'lessenaar', dir: 'voor', low: HM, high: HN });
|
||||
UnitModel.applyRoofForm(C.unit, { type: 'lessenaar', dir: 'voor', low: HS, high: HM });
|
||||
[B, C].forEach(u => { u.roof.covering = 'fels_' + FELS; u.roof.extend = { f: 0, r: 0, b: 0, l: V }; });
|
||||
A.roof.fascia = false;
|
||||
C.facade.walls.achter = { type: 'fels', fels: FELS };
|
||||
[A, B, C].forEach(u => ['voor', 'achter', 'links', 'rechts'].forEach(side => { u.lighting.walls[side] = 'geen'; }));
|
||||
B.lighting.walls.links = 'openingen';
|
||||
|
||||
const { K, binnen, ops } = modelKit(st, HS, 90);
|
||||
// Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' = vanaf het noordeind van de wand.
|
||||
ops(A.unit.walls.voor, [['raam', 500, 700, 1400, 'right', 475, K('raam')], ['raam', 800, 1100, 1000, 'right', 2175, K('raam')]]);
|
||||
ops(A.unit.walls.achter, [['voordeur', 1100, 2100, 0, 'left', 230, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
|
||||
ops(B.unit.walls.voor, [
|
||||
['raam', 600, 2100, 0, 'right', 3390, K('raam')],
|
||||
['deur', 900, 2100, 0, 'right', 3990, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
|
||||
['deur', 900, 2100, 0, 'right', 4890, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
|
||||
['raam', 600, 2100, 0, 'right', 5790, K('raam')],
|
||||
['raam', 500, 1400, 700, 'right', 7525, K('raam')],
|
||||
['raam', 500, 1400, 700, 'right', 9975, K('raam')]]);
|
||||
ops(B.unit.walls.links, [['raam', 1800, 2600, 0, 'left', 1480, K('raam')]]);
|
||||
ops(C.unit.walls.achter, [['raam', 1500, 500, 1600, 'left', 2050, K('raam')], ['raam', 1500, 500, 1600, 'left', 7440, K('raam')]]);
|
||||
|
||||
ensureSeamWalls(st, 'A');
|
||||
const seam = st.interiorWalls.find(w => w.seam);
|
||||
if (seam) {
|
||||
// Open doorgang entree → woonkamer (x 1855–3045), gemeten vanaf de binnenkant van de westgevel van B.
|
||||
const tB = WallModel.thicknessOf(WallModel.configFromState(B.unit.walls.links));
|
||||
seam.wall.openings = [{ id: 1, type: 'deur', w: 1190, h: 2300, y: 0, anchor: 'left', offset: Math.round(1855 - V - tB),
|
||||
fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) }];
|
||||
}
|
||||
|
||||
const DH = 2115;
|
||||
// Aanbouw: wanden tot het platte dak. Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
|
||||
const ann = modelKit(st, HA, 90);
|
||||
ann.iw(1335, 190, 1335, 1690); // toilet | berging
|
||||
ann.iw(190, 1735, 3700, 1735, [ // toilet, berging | entree
|
||||
['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'buiten' })],
|
||||
['deur', 930, DH, 'left', 1260, binnen({ hinge: 'links', swing: 'buiten' })]]);
|
||||
// Hoofdvolume: wanden onder het laagste punt van het dak, slaapkamer en badkamer met een vlak plafond.
|
||||
const main = modelKit(st, HS - 40, 90);
|
||||
main.iw(8715, 3240, 8715, 7660, [['deur', 930, DH, 'left', 1890, binnen({ hinge: 'rechts', swing: 'buiten' })]]); // woonkamer | slaapkamer, badkamer
|
||||
main.iw(8760, 6215, 12710, 6215, [['deur', 930, DH, 'left', 1815, // slaapkamer | badkamer
|
||||
Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'binnen', dir: 'links' })]]);
|
||||
|
||||
const { room, fx } = main;
|
||||
const plafond = { ceiling: HS - 40 };
|
||||
room('A', 700, 1000, 'toilet');
|
||||
room('A', 2500, 1000, 'berging', 'Berging');
|
||||
room('A', 1800, 2400, 'hal', 'Entree');
|
||||
room('B', 3000, 1500, 'woonkeuken', 'Woonkamer/keuken');
|
||||
room('B', 9000, 1500, 'slaapkamer', 'Slaapkamer', plafond);
|
||||
room('C', 9500, 600, 'badkamer', '', plafond);
|
||||
|
||||
const xB = (x) => x - V, zB = (z) => z - 3050, zC = (z) => z - 3050 - DN;
|
||||
fx('toilet', 'A', 735, 640, 0, { w: 1100 });
|
||||
fx('boiler', 'A', 3400, 1430, 90);
|
||||
fx('keuken', 'C', xB(3390), zC(7360), 180, { w: 3600 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.29, kookplaat: true, kookplaatPlek: 0.71 });
|
||||
fx('eettafel', 'B', xB(2490), zB(5160), 0, { w: 1000, d: 1000 }, { vorm: 'rond', stoelen: 4 });
|
||||
fx('bank', 'B', xB(5200), zB(4360), 270, { w: 1600, d: 800 }, { chaise: 'geen' });
|
||||
fx('salontafel', 'B', xB(6740), zB(4200), 0, { w: 800, d: 800 }, { vorm: 'rond' });
|
||||
fx('fauteuil', 'B', xB(6940), zB(5600), 90);
|
||||
fx('bed', 'B', xB(10390), zB(4320), 0, { w: 1600, d: 2100 }, { nachtkastjes: false });
|
||||
fx('kledingkast', 'B', xB(12420), zB(4705), 90, { w: 2900, anchor: 'oost' });
|
||||
fx('toilet', 'C', xB(9420), zC(7300), 180, { w: 1000 });
|
||||
fx('wastafel', 'C', xB(10890), zC(7420), 180, { w: 800 });
|
||||
fx('douche', 'C', xB(12260), zC(6960), 180, { w: 900, d: 1400 }, { glas: 'rechts', glasLengte: 800 });
|
||||
|
||||
computeParts(st, { skip3D: true });
|
||||
return st;
|
||||
}
|
||||
|
||||
// Model Vecht: U-vorm 13 × 9,5 m (91,5 m²), de holte (4 m breed) open naar het noorden, de entree aan de
|
||||
// zuidkant (de onderkant van de U). Drie units: slaapvleugel west (4,6 × 9,5 m), tussenlid met entree en
|
||||
// toilet (4 × 3,6 m) en leefvleugel oost (4,4 × 7,6 m); de vleugels verschillen 1,9 m in lengte. De vleugels
|
||||
// hebben een lessenaar die naar de holte afloopt (laag 2800, buiten 3600), het tussenlid een plat dak op
|
||||
// dezelfde 2800: rond de holte één goothoogte. Het dak over de holte is de overkapping van het tussenlid
|
||||
// (Unit B › Overkapping › Noord): 0 = open holte, 2400 (standaard) = over de verandadeuren, 4000 = tot het eind
|
||||
// van de leefvleugel. De rand rust aan beide kanten op een vleugel; h.o.h. 4500, dus zonder palen. Buiten
|
||||
// zwarte verticale rabat, de gevels aan de holte wit stucwerk. Alles buitenwerks vanaf de noordwesthoek.
|
||||
function createVecht() {
|
||||
const st = newModelState('Vecht');
|
||||
const S = st.settings, LO = 2800, HI = 3600, FELS = 'fels_antraciet';
|
||||
Object.assign(S.facade, { type: 'rabat', rabatColor: 'zwart', stucColor: 'wit' });
|
||||
S.cladding = 'verticaal';
|
||||
S.trasraam.enabled = false;
|
||||
Object.assign(S.roof, { fasciaColor: 'zwart', overhang: 300, fasciaHeight: 300 });
|
||||
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 150, wood: 'eiken', maxSpan: 4500 });
|
||||
S.lighting.mode = 'spots';
|
||||
S.lighting.direction = 'both';
|
||||
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
||||
if (S.elektra) S.elektra.plateColor = 'antraciet';
|
||||
|
||||
const A = createUnitEntry('A', null, { width: 4600, depth: 9500, height: LO, windowOn: [] });
|
||||
const B = createUnitEntry('B', { to: 'A', side: 'rechts', align: 'end', offset: 0 }, { width: 4000, depth: 3600, height: LO, windowOn: [] });
|
||||
const C = createUnitEntry('C', { to: 'B', side: 'rechts', align: 'end', offset: 0 }, { width: 4400, depth: 7600, height: LO, windowOn: [] });
|
||||
st.units.push(A, B, C);
|
||||
UnitModel.applyRoofForm(A.unit, { type: 'lessenaar', dir: 'links', low: LO, high: HI });
|
||||
UnitModel.applyRoofForm(C.unit, { type: 'lessenaar', dir: 'rechts', low: LO, high: HI });
|
||||
A.roof.covering = C.roof.covering = FELS;
|
||||
B.roof.extend = { f: 2400, r: 0, b: 0, l: 0 };
|
||||
const hof = { type: 'stuc', stuc: 'wit' };
|
||||
A.facade.walls.rechts = Object.assign({}, hof);
|
||||
B.facade.walls.voor = Object.assign({}, hof);
|
||||
C.facade.walls.links = Object.assign({}, hof);
|
||||
|
||||
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
|
||||
// Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' en oost 'right' = vanaf de
|
||||
// binnenkant van de noordgevel (z 256 in de unit). Wanden 256 mm, binnenwanden 91 mm.
|
||||
ops(A.unit.walls.voor, [['raam', 1600, 1500, 600, 'right', 2600, K('raam')]]);
|
||||
ops(A.unit.walls.links, [
|
||||
['raam', 1600, 500, 1700, 'left', 600, K('raam')], // boven het bed
|
||||
['raam', 800, 600, 1500, 'left', 4644, K('raam')], // badkamer
|
||||
['raam', 800, 1400, 700, 'left', 7344, K('raam')]]); // slaapkamer 2
|
||||
ops(A.unit.walls.rechts, [
|
||||
['raam', 1400, 2100, 300, 'right', 944, K('raam')], // slaapkamer → holte
|
||||
['raam', 1500, 2400, 0, 'right', 3994, K('raam')]]); // gang
|
||||
ops(A.unit.walls.achter, [['raam', 1200, 1400, 700, 'left', 900, K('raam')]]);
|
||||
ops(B.unit.walls.voor, [['raam', 2000, 2400, 0, 'right', 400, K('raam')]]); // zicht vanaf de voordeur op de holte
|
||||
ops(B.unit.walls.achter, [
|
||||
['voordeur', 1100, 2100, 0, 'left', 800, K('deur', { hinge: 'links', swing: 'buiten', glas: true })],
|
||||
['raam', 400, 2100, 0, 'left', 2000, K('raam')],
|
||||
['raam', 500, 700, 1400, 'left', 3100, K('raam')]]);
|
||||
ops(C.unit.walls.voor, [['raam', 2400, 2100, 300, 'right', 1000, K('raam')]]);
|
||||
// Verandadeuren naar de holte, onder de overkapping.
|
||||
ops(C.unit.walls.links, [
|
||||
['deur', 900, 2100, 0, 'left', 1744, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
|
||||
['deur', 900, 2100, 0, 'left', 2644, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })]]);
|
||||
ops(C.unit.walls.rechts, [
|
||||
['raam', 2000, 900, 1900, 'right', 500, K('raam')], // boven de bank
|
||||
['raam', 1000, 2100, 300, 'right', 2844, K('raam')],
|
||||
['raam', 1200, 1100, 1000, 'right', 4744, K('raam')]]);
|
||||
ops(C.unit.walls.achter, [['raam', 1400, 1000, 1100, 'left', 1400, K('raam')]]); // boven de spoelbak
|
||||
|
||||
const DH = 2115;
|
||||
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
|
||||
iw(256, 3902, 4344, 3902, [ // slaapkamer | badkamer, gang
|
||||
['deur', 930, DH, 'left', 1944, binnen({ hinge: 'rechts', swing: 'buiten' })],
|
||||
['deur', 930, DH, 'left', 3124, binnen({ hinge: 'rechts', swing: 'binnen' })]]);
|
||||
iw(3300, 3948, 3300, 9244, [['deur', 930, DH, 'left', 2452, binnen({ hinge: 'links', swing: 'buiten' })]]); // badkamer, slaapkamer 2 | gang, techniek
|
||||
iw(256, 6293, 3254, 6293, [['deur', 930, DH, 'left', 1944, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // badkamer | slaapkamer 2
|
||||
iw(3346, 7405, 4500, 7405, [['deur', 800, DH, 'middle', 0, binnen({ hinge: 'links', swing: 'buiten' })]]); // gang | techniek
|
||||
iw(7260, 7555, 8500, 7555); // hal | toilet
|
||||
iw(7305, 7601, 7305, 9244, [['deur', 850, DH, 'left', 100, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet → hal
|
||||
|
||||
ensureSeamWalls(st);
|
||||
const seamOf = (a, b) => st.interiorWalls.find(w => w.seam && sameSeam(w.seam, a, b));
|
||||
// Noord–zuid-naadwand: 'left' = vanaf de binnenkant van de zuidgevel (z 9244). Openingen bij de noordkant van de hal.
|
||||
const sAB = seamOf('A', 'B'), sBC = seamOf('B', 'C');
|
||||
if (sAB) sAB.wall.openings = [{ id: 1, type: 'deur', w: 1000, h: 2300, y: 0, anchor: 'left', offset: 1994, // open doorgang hal → gang (z 6250–7250)
|
||||
fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) }];
|
||||
if (sBC) sBC.wall.openings = [{ id: 1, type: 'deur', w: 930, h: DH, y: 0, anchor: 'left', offset: 2014, // hal → keuken (z 6300–7230)
|
||||
fill: binnen({ hinge: 'rechts', swing: 'binnen', glas: true }) }];
|
||||
|
||||
const plafond = { ceiling: LO };
|
||||
room('A', 2000, 1500, 'slaapkamer', 'Slaapkamer', plafond);
|
||||
room('A', 2300, 5100, 'badkamer');
|
||||
room('A', 1500, 8800, 'slaapkamer', 'Slaapkamer 2', plafond);
|
||||
room('A', 3900, 5000, 'hal', 'Gang', plafond);
|
||||
room('A', 3900, 8000, 'wasruimte', 'Techniek/was', plafond);
|
||||
room('B', 1200, 1500, 'hal', 'Entree');
|
||||
room('B', 3325, 2400, 'toilet');
|
||||
room('C', 2000, 3500, 'woonkeuken', 'Woonkamer/keuken');
|
||||
|
||||
const xB = (x) => x - 4600, zB = (z) => z - 5900, xC = (x) => x - 8600, zC = (z) => z - 1900;
|
||||
fx('bed', 'A', 1306, 1656, 270, { w: 1600, d: 2100 }, { nachtkastjes: false });
|
||||
fx('kledingkast', 'A', 1206, 3556, 180, { w: 1800 });
|
||||
fx('douche', 'A', 856, 4398, 0, { w: 1200, d: 900 }, { glas: 'rechts', glasLengte: 900 });
|
||||
fx('toilet', 'A', 601, 5250, 270);
|
||||
fx('wastafel', 'A', 900, 6017, 180, { w: 1000 });
|
||||
fx('bed', 'A', 1300, 7339, 0, { w: 1400, d: 2000 }, { nachtkastjes: false });
|
||||
fx('kledingkast', 'A', 2954, 8300, 90, { w: 1800 });
|
||||
fx('wasmachine', 'A', 3700, 8944, 180);
|
||||
fx('boiler', 'A', 4200, 9019, 180);
|
||||
fx('toilet', 'B', xB(7925), zB(8899), 180);
|
||||
fx('fontein', 'B', xB(7925), zB(7726), 0);
|
||||
fx('keuken', 'C', xC(10944), zC(8944), 180, { w: 3600 }, { front: 'antraciet', hogeKast: 'links', spoelbak: 0.57, kookplaat: false });
|
||||
fx('kookeiland', 'C', xC(10800), zC(7094), 0, { w: 2400, d: 900 }, { front: 'antraciet', kookplaat: true });
|
||||
fx('eettafel', 'C', xC(10500), zC(5300), 0, { w: 1800, d: 1000 }, { vorm: 'ovaal', stoelen: 6 });
|
||||
fx('bank', 'C', xC(12294), zC(3656), 90, { w: 2400, d: 900 }, { chaise: 'geen' });
|
||||
fx('salontafel', 'C', xC(11200), zC(3500), 0, { w: 800, d: 800 }, { vorm: 'rond' });
|
||||
fx('fauteuil', 'C', xC(10300), zC(2850), 45);
|
||||
fx('tvmeubel', 'C', xC(9056), zC(3100), 270, { w: 1400 });
|
||||
|
||||
computeParts(st, { skip3D: true });
|
||||
return st;
|
||||
}
|
||||
|
||||
// Model Van den Berg (examples/van den berg.pdf): dubbel chalet 6,94 × 12,25 m. West een smal, laag chalet van
|
||||
// 2,97 × 12,25 m (wandhoogte 2330: garderobe, twee slaapkamers, toilet, badkamer, cv-kast, hal), oost een breed,
|
||||
// hoog chalet van 3,97 × 9,5 m (wandhoogte 3080: woonkamer met keuken), allebei een plat dak (EPDM) met boeidelen.
|
||||
// In de noordwesthoek een overdekt portiek van 1,36 × 1,75 m voor de voordeur: de garderobe is een eigen unit
|
||||
// (B) waarvan het dak naar het westen doorloopt. Ten zuiden van het oostchalet een overdekte vlonder van 3,97 × 2,75 m
|
||||
// (het dak loopt 2,75 m door, palen op de hoeken). Gevel: het lage chalet donker (Eternit potdeksel, hier
|
||||
// antraciet felsplaten), het hoge chalet eiken rabat horizontaal; het deel van de oostwand boven het lage dak
|
||||
// krijgt dezelfde rabat. De twee bovenlichten van 200 × 30 in die wand zijn weggelaten: het stuk boven het lage
|
||||
// dak is 488 mm hoog en een opening is minimaal 400 mm. Maten buitenwerks, x vanaf de westgevel en z vanaf de
|
||||
// noordgevel van de tekening. Het lage chalet is de eerste unit A (noordwesthoek op z 1750), het hoge chalet C.
|
||||
function createVandenBerg() {
|
||||
const st = newModelState('Van den Berg');
|
||||
const S = st.settings, HL = 2330, HH = 3080, WA = 2970, WB = 3970, P = 1750, PW = 1360, FELS = 'antraciet';
|
||||
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel', felsColor: FELS });
|
||||
S.cladding = 'horizontaal';
|
||||
S.trasraam.enabled = false;
|
||||
Object.assign(S.roof, { fasciaColor: 'antraciet', overhang: RoofModel.OVERHANG_MIN, fasciaHeight: 250 });
|
||||
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 120, maxSpan: 4500 });
|
||||
S.lighting.mode = 'spots';
|
||||
S.lighting.direction = 'both';
|
||||
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: false, oven: true, vaatwasser: true });
|
||||
if (S.elektra) S.elektra.plateColor = 'wit';
|
||||
|
||||
const A = createUnitEntry('A', null, { width: WA, depth: 12250 - P, height: HL, windowOn: [] });
|
||||
const B = createUnitEntry('B', { to: 'A', side: 'voor', align: 'end', offset: 0 }, { width: WA - PW, depth: P, height: HL, windowOn: [] });
|
||||
const C = createUnitEntry('C', { to: 'B', side: 'rechts', align: 'start', offset: 0 }, { width: WB, depth: 9500, height: HH, windowOn: [] });
|
||||
st.units.push(A, B, C);
|
||||
B.roof.extend = { f: 0, r: 0, b: 0, l: PW }; // portiek
|
||||
C.roof.extend = { f: 0, r: 0, b: 2750, l: 0 }; // vlonder
|
||||
[A, B].forEach(u => ['voor', 'achter', 'links', 'rechts'].forEach(side => { u.facade.walls[side] = { type: 'fels', fels: FELS }; }));
|
||||
|
||||
const tE = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.voor));
|
||||
const { binnen, ops, iw, room, fx } = modelKit(st, HL, 90);
|
||||
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), extra || {});
|
||||
// Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' en oost 'right' = vanaf de
|
||||
// binnenkant van de noordgevel. z in de tekening; zA = z in unit A.
|
||||
const zA = (z) => z - P;
|
||||
ops(A.unit.walls.links, [
|
||||
['raam', 700, 1100, 950, 'left', zA(3897) - tE, K('raam', { rooster: true })], // slaapkamer 2
|
||||
['raam', 600, 700, 1350, 'left', zA(5550) - tE, K('raam')], // toilet
|
||||
['raam', 600, 700, 1350, 'left', zA(6590) - tE, K('raam')]]); // badkamer
|
||||
ops(A.unit.walls.achter, [['raam', 1200, 1100, 950, 'left', 827, K('raam', { rooster: true })]]); // slaapkamer 1
|
||||
ops(B.unit.walls.links, [['voordeur', 900, 2110, 0, 'left', 787 - tE, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
|
||||
ops(C.unit.walls.voor, [['raam', 1400, 900, 1200, 'right', 1651, K('raam', { rooster: true })]]); // boven de spoelbak
|
||||
// Oostgevel: kozijnen van 180 × 300, het noordelijke met openslaande deuren (alleen van binnen te bedienen).
|
||||
ops(C.unit.walls.rechts, [
|
||||
['deur', 900, 2100, 0, 'right', 2267 - tE, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
|
||||
['deur', 900, 2100, 0, 'right', 3167 - tE, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
|
||||
['raam', 1800, 650, 2200, 'right', 2267 - tE, K('raam')],
|
||||
['raam', 1800, 2100, 0, 'right', 5946 - tE, K('raam')],
|
||||
['raam', 1800, 650, 2200, 'right', 5946 - tE, K('raam')]]);
|
||||
C.unit.walls.rechts.openings[1].koppel = true;
|
||||
ops(C.unit.walls.achter, [['deur', 2400, 2100, 0, 'left', 792, // schuifpui naar de vlonder
|
||||
K('deur', { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true })]]);
|
||||
|
||||
const DH = 2115, DW = 730;
|
||||
// Binnenwanden in wereldcoördinaten (t.o.v. de noordwesthoek van A). Noord–zuid: 'buiten' = west, 'left' =
|
||||
// vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
|
||||
const xE = WA - 100; // tegen de naadwand
|
||||
const w = (x1, z1, x2, z2, openings) => iw(x1, zA(z1), x2, zA(z2), openings);
|
||||
w(tE, 5321, xE, 5321, [['deur', DW, DH, 'left', 1879 - tE, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // slaapkamer 2 | toilet, hal
|
||||
w(1376, 5366, 1376, 6368, [['deur', DW, DH, 'left', 200, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet | hal
|
||||
w(tE, 6413, 1891, 6413); // toilet | badkamer
|
||||
w(1846, 6458, 1846, 8651, [ // badkamer, cv-kast | hal
|
||||
['deur', DW, DH, 'left', 20, binnen({ hinge: 'links', swing: 'binnen' })],
|
||||
['deur', DW, DH, 'left', 1330, binnen({ hinge: 'links', swing: 'binnen' })]]);
|
||||
w(1155, 7700, 1801, 7700); // badkamer | cv-kast
|
||||
w(1200, 7745, 1200, 8651); // douche | cv-kast
|
||||
w(tE, 8696, xE, 8696, [['deur', DW, DH, 'left', 1985 - tE, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal | slaapkamer 1
|
||||
|
||||
ensureSeamWalls(st);
|
||||
const seamOf = (a, b) => st.interiorWalls.find(iw => iw.seam && sameSeam(iw.seam, a, b));
|
||||
const toog = (wd) => ({ id: 1, type: 'deur', w: wd, h: 2100, y: 0, anchor: 'left', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) });
|
||||
const sAC = seamOf('A', 'C'), sBC = seamOf('B', 'C'), sAB = seamOf('A', 'B');
|
||||
// Naadwanden noord–zuid: 'left' = vanaf de zuidkant (A: binnenkant zuidgevel van C, z 9244; B: de naad met A, z 1750).
|
||||
if (sAC) sAC.wall.openings = [Object.assign(toog(1000), { offset: 2854 })]; // hal → woonkamer (z 5390–6390)
|
||||
if (sBC) sBC.wall.openings = [Object.assign(toog(900), { offset: 350 })]; // garderobe → woonkamer (z 500–1400)
|
||||
if (sAB) sAB.wall.openings = [];
|
||||
|
||||
room('A', 1400, zA(3000), 'slaapkamer', 'Slaapkamer 2');
|
||||
room('A', 800, zA(5900), 'toilet');
|
||||
room('A', 1500, zA(7000), 'badkamer');
|
||||
room('A', 1500, zA(8200), 'techniek', 'Cv-kast');
|
||||
room('A', 2400, zA(7000), 'hal', 'Hal');
|
||||
room('A', 1400, zA(11000), 'slaapkamer', 'Slaapkamer 1');
|
||||
room('B', 800, 900, 'hal', 'Garderobe');
|
||||
room('C', 2000, 4500, 'woonkeuken', 'Woonkamer/keuken');
|
||||
|
||||
fx('bed', 'A', 670, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
|
||||
fx('bed', 'A', 2290, tE + 1020, 0, { w: 800, d: 2000 }, { nachtkastjes: false });
|
||||
fx('kledingkast', 'A', 1056, zA(4976), 180, { w: 1600 });
|
||||
fx('toilet', 'A', 601, zA(5950), 270);
|
||||
fx('fontein', 'A', 1108, zA(5491), 0);
|
||||
fx('douche', 'A', 706, zA(8051), 180, { w: 900, d: 1200 }, { glas: 'rechts', glasLengte: 900 });
|
||||
fx('wastafel', 'A', 1478, zA(7405), 180, { w: 600 });
|
||||
fx('boiler', 'A', 1470, zA(8350), 270);
|
||||
fx('kledingkast', 'A', 1056, zA(9041), 0, { w: 1600 });
|
||||
fx('bed', 'A', tE + 1000, zA(10744), 270, { w: 1600, d: 2000 }, { nachtkastjes: false });
|
||||
const xC = (x) => x - WA;
|
||||
fx('keuken', 'C', xC(4930), tE + 300, 0, { w: 3500 }, { front: 'wit', hogeKast: 'rechts', spoelbak: 0.6, kookplaat: true, kookplaatPlek: 0.26 });
|
||||
fx('eettafel', 'C', xC(4912), 3172, 0, { w: 1400, d: 900 }, { vorm: 'rechthoek', stoelen: 6 });
|
||||
fx('bank', 'C', 560, 7700, 270, { w: 2200, d: 900 }, { chaise: 'geen' });
|
||||
fx('salontafel', 'C', 1700, 7700, 0, { w: 800, d: 800 }, { vorm: 'rond' });
|
||||
fx('fauteuil', 'C', 2900, 7200, 90);
|
||||
|
||||
computeParts(st, { skip3D: true });
|
||||
return st;
|
||||
}
|
||||
|
||||
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
|
||||
const MODELS = {
|
||||
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
|
||||
@@ -667,7 +992,10 @@ window.HouseModel = (() => {
|
||||
ijssel: { label: 'Woning IJssel', icon: 'houses', create: createIJssel },
|
||||
waal: { label: 'Woning Waal', icon: 'house-add', create: createWaal },
|
||||
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
|
||||
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn }
|
||||
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
|
||||
reest: { label: 'Woning Reest', icon: 'house-up', create: createReest },
|
||||
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht },
|
||||
vandenberg: { label: 'Woning Van den Berg', icon: 'house-dash', create: createVandenBerg }
|
||||
};
|
||||
|
||||
function createDefaultState(template) {
|
||||
@@ -903,10 +1231,33 @@ window.HouseModel = (() => {
|
||||
}
|
||||
});
|
||||
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);
|
||||
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;
|
||||
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)))]));
|
||||
@@ -1259,10 +1610,10 @@ window.HouseModel = (() => {
|
||||
});
|
||||
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
|
||||
|
||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop) {
|
||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels, endCaps) {
|
||||
if (!build) return { group: null, stats: null, specs: null };
|
||||
const built = WallModel.build3D(config, frame, {
|
||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop,
|
||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels, endCaps,
|
||||
painted: state.settings.interiorPaint && shows('afwerking'), layers, rabatColor: state.settings.facade.rabatColor
|
||||
});
|
||||
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
||||
@@ -1282,11 +1633,12 @@ window.HouseModel = (() => {
|
||||
const cladding = m.cladding || 'horizontaal';
|
||||
const stone = buitenMateriaal === 'cementplaat' ? (m.stone || null) : null;
|
||||
const stuc = buitenMateriaal === 'cementplaat' && !stone ? (m.stuc || null) : null;
|
||||
const fels = buitenMateriaal === 'vochtdoek' ? (m.fels || null) : null;
|
||||
const trasraam = buitenMateriaal === 'vochtdoek' || stone || stuc ? (m.trasraam || null) : null;
|
||||
return Object.assign(p, {
|
||||
element: `${u.id}-W${wl.nr}${p.suffix}`,
|
||||
label: `Unit ${u.id} ${wl.label.toLowerCase()}` + (base ? ` opbouw (vanaf ${base} mm)` : p.suffix ? ` (${p.suffix})` : ''),
|
||||
buitenMateriaal, cladding, trasraam: base ? null : trasraam, stone, stuc, facadeOwn: own,
|
||||
buitenMateriaal, cladding, trasraam: base ? null : trasraam, stone, stuc, fels, facadeOwn: own,
|
||||
place: { cx: rect.cx + c.x, cz: rect.cz + c.z, rotY: wl.rotY, width: p.config.wallWidth, thickness: wl.thickness, normal: wl.normal, floorTop: layout.floorTop + base, baseY: base }
|
||||
}); // 3D volgt na assignFacades (latwerk/doek per gevel)
|
||||
});
|
||||
@@ -1634,10 +1986,11 @@ window.HouseModel = (() => {
|
||||
});
|
||||
}));
|
||||
});
|
||||
const facadeDepth = (a, b, nrm) => {
|
||||
// Gevelstuk onder een dakrand (grootste overlap langs de rand); dot < 0 = gevel van een hoger deel ertegenover.
|
||||
const edgePiece = (a, b, nrm) => {
|
||||
const horiz = Math.abs(nrm.z) > 0.5;
|
||||
const line = horiz ? a.z : a.x, s0 = Math.min(horiz ? a.x : a.z, horiz ? b.x : b.z), s1 = Math.max(horiz ? a.x : a.z, horiz ? b.x : b.z);
|
||||
let best = 0, bestOv = 1;
|
||||
let best = null, bestOv = 1;
|
||||
extPieces.forEach(p => {
|
||||
const pn = { x: p.place.normal.x, z: p.place.normal.y };
|
||||
const dot = pn.x * nrm.x + pn.z * nrm.z;
|
||||
@@ -1647,15 +2000,20 @@ window.HouseModel = (() => {
|
||||
const ov = Math.min(s1, Math.max(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z)) - Math.max(s0, Math.min(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z));
|
||||
if (ov <= bestOv) return;
|
||||
bestOv = ov;
|
||||
best = Math.sign(dot) * WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
best = { p, dot };
|
||||
});
|
||||
return best;
|
||||
};
|
||||
const facadeDepth = (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) : 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 roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
|
||||
units.map(u => ({
|
||||
unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false,
|
||||
topAt: u.roof.topAt, ridges: u.roof.ridges
|
||||
})), facadeDepth) : null;
|
||||
covering: u.roof.covering, topAt: u.roof.topAt, ridges: u.roof.ridges
|
||||
})), facadeDepth, facadeFels) : null;
|
||||
const pointInPoly = (q, poly) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
@@ -1670,14 +2028,22 @@ window.HouseModel = (() => {
|
||||
// De rand begint op de dakrand, die op of iets vóór het wandvlak kan liggen.
|
||||
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
|
||||
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
|
||||
if (!sg) return;
|
||||
const h = sg.fascia ? roofEdge.fasciaHeight : roofEdge.trimHeight + roofEdge.edgeAbove;
|
||||
p.battenAbove = (p.skinAbove || 0) + roofEdge.edgeAbove - h;
|
||||
// Daktrim: de bekleding loopt door tot het dak, de trim valt eroverheen.
|
||||
if (!sg || (!sg.fascia && !sg.fels)) return;
|
||||
p.battenAbove = (p.skinAbove || 0) + sg.above - RoofModel.edgeHeight(roofEdge, sg);
|
||||
});
|
||||
// Vrije uiteinden van binnenwanden: vlak voorbij het eind geen andere wand.
|
||||
const allPieces = extPieces.concat(interior);
|
||||
interior.forEach(p => {
|
||||
if (p.buitenMateriaal !== 'spaanplaat') return;
|
||||
const W = p.place.width / 2, against = (q) => allPieces.some(o => o !== p && pointInPoly(q,
|
||||
[[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([i, j]) => UnitModel.localToWorld(o.place, i * o.place.width / 2, j * o.place.thickness / 2))));
|
||||
p.endCaps = { left: !against(UnitModel.localToWorld(p.place, -W - 15, 0)), right: !against(UnitModel.localToWorld(p.place, W + 15, 0)) };
|
||||
});
|
||||
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
||||
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
||||
extPieces.concat(interior).forEach(p => {
|
||||
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop);
|
||||
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop, p.fels, p.endCaps);
|
||||
if (built.group && p.shiftZ) {
|
||||
const outer = new THREE.Group();
|
||||
built.group.position.z = p.shiftZ * 0.001;
|
||||
@@ -1700,10 +2066,24 @@ window.HouseModel = (() => {
|
||||
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
|
||||
if (lightingGroup) lightingGroup.visible = shows('lampen');
|
||||
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge, rooms);
|
||||
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, layers, o.elektraRough) : null;
|
||||
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, layers, o.finishedOnly ? false : o.elektraRough) : null;
|
||||
if (elektraGroup) elektraGroup.visible = shows('elektra');
|
||||
|
||||
const posts = PostModel.compute(state.settings.roof.posts, units, groundY);
|
||||
// Zijwand van een overkapping in het vlak van een gevel: die bekleding loopt erover door.
|
||||
const postOutside = (w, out) => {
|
||||
const line = out.x > 0 ? w.x1 : out.x < 0 ? w.x0 : out.z > 0 ? w.z1 : w.z0;
|
||||
const p = extPieces.find(pc => {
|
||||
const n = { x: pc.place.normal.x, z: pc.place.normal.y }, q = UnitModel.localToWorld(pc.place, 0, pc.place.thickness / 2);
|
||||
return n.x * out.x + n.z * out.z > 0.5 && Math.abs((out.x ? q.x : q.z) - line) < 2 && !pc.place.baseY;
|
||||
});
|
||||
if (!p || (p.buitenMateriaal === 'cementplaat' && !p.stone && !p.stuc)) return null;
|
||||
const kind = p.fels ? 'fels' : p.stone ? 'steen' : p.stuc ? 'stuc' : 'rabat';
|
||||
return {
|
||||
kind, depth: WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc),
|
||||
finish: { fels: p.fels, stone: p.stone, stuc: p.stuc, rabatColor: kind === 'rabat' ? state.settings.facade.rabatColor : null, cladding: p.cladding }
|
||||
};
|
||||
};
|
||||
const posts = PostModel.compute(state.settings.roof.posts, units, groundY, postOutside);
|
||||
const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null;
|
||||
const chimneysGroup = build && chimneys.length ? ChimneyModel.build3D(chimneys, {
|
||||
materials: o.chimneyMaterials, wallMaterial: o.wallMaterials && o.wallMaterials.spaanplaatMaterial
|
||||
@@ -1722,7 +2102,74 @@ window.HouseModel = (() => {
|
||||
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
|
||||
if (fixturesGroup) fixturesGroup.visible = shows('inrichting');
|
||||
|
||||
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup };
|
||||
const parts = { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup };
|
||||
if (build && o.finishedOnly) keepFinished(parts, o.wallMaterials);
|
||||
return parts;
|
||||
}
|
||||
|
||||
// finishedOnly (render-export): alleen wat in de afgewerkte woning te zien is. Naast de buitenste vloerbalken en geen
|
||||
// elektraleidingen (hierboven): geen isolatie, latten en vochtdoek achter de gevel, geen lichtkegels, valse plafonds altijd.
|
||||
function keepFinished(parts, wallMaterials) {
|
||||
if (wallMaterials) allWallPieces(parts).forEach(p => ViewLayers.hideByMaterial(p.group,
|
||||
[wallMaterials.isolatieMaterial, wallMaterials.battenMaterial, wallMaterials.doekMaterial]));
|
||||
if (parts.lightingGroup) parts.lightingGroup.traverse(x => { if (x.userData.beam) x.visible = false; });
|
||||
if (parts.roomsGroup && parts.roomsGroup.userData.ceilings) parts.roomsGroup.userData.ceilings.visible = true;
|
||||
}
|
||||
|
||||
// Doorsnede: witte afdekking van de wanden. Hij volgt de bovenkant van elke wand, afgetopt op de snijhoogte cutAbs
|
||||
// (mm, absoluut): op de snede vlak en onderbroken door de openingen, daaronder over de wandtop (ook schuin), zodat
|
||||
// een wand die niet tot de snede reikt ook wit afgedekt is. Met gevelbekleding vanaf de buitenkant daarvan (latwerk
|
||||
// is open); zonder beplating tussen de platen. De afdekking ligt CAP_LIFT_MM boven wand of snede: alles wat daar
|
||||
// eindigt (regelwerk, platen) valt eronder en flikkert niet mee (dieptebuffer op afstand).
|
||||
const CAP_LIFT_MM = 1;
|
||||
function sectionCaps(parts, cutAbs, material, layers) {
|
||||
const toM = 0.001, g = new THREE.Group();
|
||||
const S = WallModel.STONE;
|
||||
const gevel = ViewLayers.shows(layers, 'gevel'), beplating = ViewLayers.shows(layers, 'beplating');
|
||||
allWallPieces(parts).forEach(p => {
|
||||
const y = cutAbs - p.place.floorTop;
|
||||
if (y <= 0) return;
|
||||
const W = p.place.width, T = p.place.thickness, tl = WallModel.topLine(p.config);
|
||||
const topAt = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x)));
|
||||
const hAt = (x) => Math.min(y, topAt(x));
|
||||
const outAt = (h) => {
|
||||
if (!gevel || !p.place.normal || p.buitenMateriaal === 'geen') return 0;
|
||||
const tr = p.trasraam;
|
||||
return tr && h < tr.top ? (WallModel.isStoneFinish(tr.finish) ? S.THICK_MM : 0) : WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
};
|
||||
const sh = beplating ? 0 : p.config.sheathingThick;
|
||||
const e = p.skinExt || {}, out0 = outAt(hAt(0));
|
||||
const xL = -W / 2 - (e.left > 0 ? e.left + out0 : 0), xR = W / 2 + (e.right > 0 ? e.right + out0 : 0);
|
||||
// Knikpunten: de wandtop en waar die door de snijhoogte gaat; op de snede de openingen eruit.
|
||||
const xs = [...new Set([xL, xR, ...tl.pts.map(q => q.x), ...tl.crossings(y)].filter(x => x >= xL && x <= xR))].sort((a, b) => a - b);
|
||||
const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean)
|
||||
.map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]);
|
||||
const spans = [];
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const a = xs[i], b = xs[i + 1];
|
||||
if (b - a <= 1) continue;
|
||||
const onCut = topAt((a + b) / 2) >= y - 0.5;
|
||||
if (!onCut) { spans.push([a, b]); continue; }
|
||||
let cur = a;
|
||||
gaps.forEach(([g0, g1]) => { if (g1 <= cur || g0 >= b) return; if (g0 > cur) spans.push([cur, g0]); cur = Math.max(cur, g1); });
|
||||
if (b > cur) spans.push([cur, b]);
|
||||
}
|
||||
const pos = [];
|
||||
spans.filter(([a, b]) => b - a > 1).forEach(([a, b]) => {
|
||||
const ha = hAt(a) + CAP_LIFT_MM, hb = hAt(b) + CAP_LIFT_MM, out = outAt((ha + hb) / 2);
|
||||
const z0 = -T / 2 + sh, z1 = T / 2 + out - (p.buitenMateriaal !== 'geen' ? sh : 0);
|
||||
// Twee driehoeken, normaal omhoog.
|
||||
pos.push(a, ha, z0, a, ha, z1, b, hb, z1, a, ha, z0, b, hb, z1, b, hb, z0);
|
||||
});
|
||||
if (!pos.length) return;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos.map(v => v * toM), 3));
|
||||
const m = new THREE.Mesh(geo, material);
|
||||
m.rotation.y = p.place.rotY;
|
||||
m.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
|
||||
g.add(m);
|
||||
});
|
||||
return g;
|
||||
}
|
||||
|
||||
// 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten.
|
||||
@@ -1745,6 +2192,21 @@ window.HouseModel = (() => {
|
||||
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) {
|
||||
const m2 = (mm2) => mm2 / 1e6;
|
||||
const rects = parts.rects;
|
||||
@@ -1898,6 +2360,7 @@ window.HouseModel = (() => {
|
||||
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),
|
||||
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,
|
||||
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
|
||||
plaatwerk: [
|
||||
@@ -1946,6 +2409,10 @@ window.HouseModel = (() => {
|
||||
{
|
||||
naam: `Rabat ${WallModel.CLADDING_THICK_MM} mm (gevelbekleding)`, m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.rabatAreaM2 || 0), 0),
|
||||
m3: null, kg: 0 // gewicht volgt
|
||||
},
|
||||
{
|
||||
naam: 'Felsplaten gevel (staande naad)', m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.felsAreaM2 || 0), 0),
|
||||
m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + (p.stats.felsAreaM2 || 0), 0) * RoofModel.FELS.kgM2
|
||||
}
|
||||
],
|
||||
weights,
|
||||
@@ -1955,9 +2422,10 @@ window.HouseModel = (() => {
|
||||
return { unit: u.id, name: rb.profile.name, kgM: rb.profile.kgM, lengthM: rb.length / 1000, kg: rb.steelKg, fillM3: rb.fillM3, fillKg: rb.fillKg,
|
||||
util: rb.util, defl: rb.defl, deflMax: rb.deflMax, ok: rb.ok, below: rb.below, loadKNm: rb.loadKNm };
|
||||
}),
|
||||
posts: parts.posts.count ? {
|
||||
posts: parts.posts.count || parts.posts.walls.length ? {
|
||||
count: parts.posts.count, size: parts.posts.size, wood: (PostModel.WOODS[parts.posts.wood] || {}).label || '',
|
||||
lengthM: parts.posts.lengthMM / 1000, m3: parts.posts.m3, kg: parts.posts.kg
|
||||
lengthM: parts.posts.lengthMM / 1000, m3: parts.posts.m3, kg: parts.posts.kg,
|
||||
walls: parts.posts.walls.length, wallM2: parts.posts.wallM2, outsideM2: parts.posts.outsideM2
|
||||
} : null,
|
||||
roofEdge: parts.roofEdge ? {
|
||||
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
|
||||
@@ -1966,6 +2434,8 @@ window.HouseModel = (() => {
|
||||
overhang: parts.roofEdge.overhang,
|
||||
soffitM2: parts.roofEdge.soffitM2,
|
||||
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,
|
||||
spots: parts.roofEdge.spots.length
|
||||
} : null,
|
||||
@@ -2026,10 +2496,12 @@ window.HouseModel = (() => {
|
||||
if (!WallModel.isStoneFinish(state.settings.facade.stone)) state.settings.facade.stone = def.facade.stone;
|
||||
if (!WallModel.RABAT_COLORS[state.settings.facade.rabatColor]) state.settings.facade.rabatColor = def.facade.rabatColor;
|
||||
if (!WallModel.isStucColor(state.settings.facade.stucColor)) state.settings.facade.stucColor = def.facade.stucColor;
|
||||
if (!WallModel.isFelsColor(state.settings.facade.felsColor)) state.settings.facade.felsColor = def.facade.felsColor;
|
||||
state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {});
|
||||
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
||||
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.isTrimColor(state.settings.trasraam.capColor)) state.settings.trasraam.capColor = 'antraciet';
|
||||
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.ceilingColor || '')) state.settings.ceilingColor = def.ceilingColor;
|
||||
@@ -2066,7 +2538,7 @@ window.HouseModel = (() => {
|
||||
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
|
||||
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
||||
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
||||
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
|
||||
computeParts, placeGroups, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||||
houseRoofShape, houseRoofFits, hasCover, setHouseRoof, coverExtensions, EXTEND_MAX,
|
||||
computeParts, placeGroups, rootGroups, groundOrigin, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||||
};
|
||||
})();
|
||||
|
||||
+57
-19
@@ -42,11 +42,13 @@ window.HousePdf = (() => {
|
||||
return list;
|
||||
}
|
||||
|
||||
// customer (light-versie): zonder onderdelenlijst, elementcodes en elektra; de tekening schuift dan naar links.
|
||||
function drawPlanSheet(doc, parts, sheets, sheetOpts) {
|
||||
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
||||
const customer = !!sheetOpts.customer;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
|
||||
if (!customer) PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
|
||||
sheets.map((s, i) => [s.element, s.label, String(i + 1)]).filter(r => r[0] !== PLAN_ELEMENT),
|
||||
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
@@ -54,7 +56,7 @@ window.HousePdf = (() => {
|
||||
const rects = parts.rects;
|
||||
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
|
||||
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
|
||||
const drawX0 = 105, drawX1 = 405, drawY0 = 20, drawY1 = 228;
|
||||
const drawX0 = customer ? 15 : 105, drawX1 = 405, drawY0 = 20, drawY1 = 228;
|
||||
const margin = 32;
|
||||
const scale = Math.min((drawX1 - drawX0 - margin * 2) / (bx1 - bx0), (drawY1 - drawY0 - margin * 2) / (bz1 - bz0));
|
||||
const ox = (drawX0 + drawX1) / 2 - ((bx0 + bx1) / 2) * scale;
|
||||
@@ -139,6 +141,8 @@ window.HousePdf = (() => {
|
||||
doc.setLineWidth(0.2);
|
||||
doc.setFillColor(160, 124, 84);
|
||||
parts.posts.posts.forEach(pp => doc.rect(px(pp.x - pp.size / 2), py(pp.z - pp.size / 2), pp.size * scale, pp.size * scale, 'FD'));
|
||||
doc.setFillColor(235, 235, 232);
|
||||
parts.posts.walls.forEach(w => doc.rect(px(w.x0), py(w.z0), (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale, 'FD'));
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
doc.setFillColor(150, 150, 150);
|
||||
poly(ch.footprint, 'FD');
|
||||
@@ -153,7 +157,7 @@ window.HousePdf = (() => {
|
||||
doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD');
|
||||
});
|
||||
|
||||
ElektraPdf.drawPlan(doc, parts.elektra, px, py);
|
||||
if (!customer) ElektraPdf.drawPlan(doc, parts.elektra, px, py);
|
||||
|
||||
doc.setDrawColor(230, 130, 30);
|
||||
doc.setLineWidth(0.4);
|
||||
@@ -171,13 +175,13 @@ window.HousePdf = (() => {
|
||||
const b = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
|
||||
const x0 = px(a.x) + n.x * off, y0 = py(a.z) + n.y * off, x1 = px(b.x) + n.x * off, y1 = py(b.z) + n.y * off;
|
||||
PdfSheet.dimLine(doc, x0, y0, x1, y1);
|
||||
const text = `${p.element}: ${p.place.width}`;
|
||||
const text = customer ? String(p.place.width) : `${p.element}: ${p.place.width}`;
|
||||
const tx = (x0 + x1) / 2 + n.x * 2.5, ty = (y0 + y1) / 2 + n.y * 2.5 + (n.y > 0 ? 1.5 : 0);
|
||||
if (n.y === 0) doc.text(text, tx, ty, { align: 'center', angle: 90 });
|
||||
else doc.text(text, tx, ty, { align: 'center' });
|
||||
})));
|
||||
doc.setTextColor(...BLACK);
|
||||
parts.interior.forEach(p => {
|
||||
if (!customer) parts.interior.forEach(p => {
|
||||
doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: Math.abs(Math.sin(p.place.rotY)) < 0.5 ? 0 : 90 });
|
||||
});
|
||||
|
||||
@@ -224,7 +228,7 @@ window.HousePdf = (() => {
|
||||
doc.setFontSize(6);
|
||||
doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' +
|
||||
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp, kleurvlak = ruimte (netto m²)', drawX0, drawY1 + 4);
|
||||
if (parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5);
|
||||
if (!customer && parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5);
|
||||
}
|
||||
|
||||
const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
|
||||
@@ -257,7 +261,8 @@ window.HousePdf = (() => {
|
||||
const addWall = (p, y0) => {
|
||||
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
||||
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : WallModel.rabatHex(state.settings.facade.rabatColor);
|
||||
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : p.fels ? WallModel.felsHex(p.fels)
|
||||
: WallModel.rabatHex(state.settings.facade.rabatColor);
|
||||
faces.push({ kind: 'wand', pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
||||
const tr = p.trasraam;
|
||||
if (tr && tr.top > y0) {
|
||||
@@ -266,6 +271,12 @@ window.HousePdf = (() => {
|
||||
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);
|
||||
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 => {
|
||||
if (WallModel.openingTopMax(op) <= y0) return;
|
||||
@@ -312,10 +323,9 @@ window.HousePdf = (() => {
|
||||
|
||||
const edge = parts.roofEdge;
|
||||
if (edge) {
|
||||
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
|
||||
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, z: a.z + (b.z - a.z) * t });
|
||||
edge.segments.forEach(sg => {
|
||||
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
||||
const h = RoofModel.edgeHeight(edge, sg);
|
||||
const n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100));
|
||||
const top = [], bottom = [];
|
||||
for (let k = 0; k <= n; k++) {
|
||||
@@ -324,7 +334,18 @@ window.HousePdf = (() => {
|
||||
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 });
|
||||
}
|
||||
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
||||
// 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 });
|
||||
});
|
||||
}
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
@@ -351,13 +372,15 @@ window.HousePdf = (() => {
|
||||
UnitModel.normalizeSplits(u.unit.walls[side].splits).filter(sp => sp.facade).forEach(sp => facades.push(HouseModel.facadeFor(state, u, side, sp.facade)));
|
||||
}));
|
||||
facades.forEach(fc => {
|
||||
const key = fc.type === 'steen' ? 'steen:' + fc.stone : fc.type === 'stuc' ? 'stuc:' + fc.stuc : 'rabat';
|
||||
const key = fc.type === 'steen' ? 'steen:' + fc.stone : fc.type === 'stuc' ? 'stuc:' + fc.stuc : fc.type === 'fels' ? 'fels:' + fc.fels : 'rabat';
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
items.push(fc.type === 'steen'
|
||||
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
||||
: fc.type === 'stuc'
|
||||
? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) }
|
||||
: fc.type === 'fels'
|
||||
? { label: 'Gevel', value: `Felsplaten ${((WallModel.FELS.COLORS[fc.fels] || {}).label || '').toLowerCase()} (staande naad)`, color: WallModel.felsHex(fc.fels) }
|
||||
: { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) });
|
||||
});
|
||||
if (st.trasraam.enabled) {
|
||||
@@ -365,7 +388,11 @@ window.HousePdf = (() => {
|
||||
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: '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 => {
|
||||
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
|
||||
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
|
||||
@@ -612,6 +639,8 @@ window.HousePdf = (() => {
|
||||
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.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(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
|
||||
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
|
||||
@@ -760,28 +789,37 @@ window.HousePdf = (() => {
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
// opts.customer (light-versie): alleen aanzichten en plattegrond, zonder productie-informatie en kosten.
|
||||
// opts.doc: verder in een bestaand document (nieuwe pagina's erachter); opts.pageOffset/pageTotal: bladnummering
|
||||
// als er pagina's omheen komen (voorblad e.d.).
|
||||
function generate(state, opts) {
|
||||
const o = opts || {};
|
||||
const customer = !!o.customer;
|
||||
const parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
|
||||
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
|
||||
elektraRough: false
|
||||
});
|
||||
const totals = HouseModel.computeTotals(parts);
|
||||
const sheets = sheetList(parts);
|
||||
const cost = CostModel.compute(parts, totals);
|
||||
const sheets = sheetList(parts).filter(s => !customer || s.kind === 'views' || s.kind === 'plan');
|
||||
const totals = customer ? null : HouseModel.computeTotals(parts);
|
||||
const cost = customer ? null : CostModel.compute(parts, totals);
|
||||
const costOf = (element) => cost.elements.find(el => el.element === element);
|
||||
const drawWallWithCost = (sheet, opts) => {
|
||||
WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [],
|
||||
overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts));
|
||||
drawElementCost(doc, costOf(sheet.element));
|
||||
};
|
||||
if (!customer) {
|
||||
const detailPages = paginateCostRows(costDetailRows(cost));
|
||||
sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' });
|
||||
detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols }));
|
||||
}
|
||||
const project = state.project;
|
||||
const doc = PdfSheet.createDoc();
|
||||
const doc = o.doc || PdfSheet.createDoc();
|
||||
const offset = o.pageOffset || 0, total = o.pageTotal || sheets.length;
|
||||
|
||||
sheets.forEach((sheet, i) => {
|
||||
if (i > 0) doc.addPage('a3', 'landscape');
|
||||
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
|
||||
if (i > 0 || o.doc) doc.addPage('a3', 'landscape');
|
||||
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${offset + i + 1} / ${total}`, customer };
|
||||
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
|
||||
else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
|
||||
else if (sheet.kind === 'floor') {
|
||||
|
||||
+120
-148
@@ -41,7 +41,7 @@ const HOUSE_TEMPLATE = `
|
||||
</div>
|
||||
<div class="material-info">Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="house-project-cancel" class="ui-btn">Annuleren</button>
|
||||
<button type="button" id="house-project-cancel" class="ui-btn" data-esc>Annuleren</button>
|
||||
<button type="button" id="house-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,6 +198,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
|
||||
// Structuur: groepen in-/uitklapbaar (onthouden); een groep met het actieve onderdeel of de selectie klapt vanzelf open.
|
||||
const NAV_PREF = 'tectum.nav.closed';
|
||||
let navActiveKey = null;
|
||||
let navClosed;
|
||||
try { navClosed = new Set(JSON.parse(localStorage.getItem(NAV_PREF) || 'null') || ['iw', 'rooms', 'fixtures']); } catch (e) { navClosed = new Set(['iw', 'rooms', 'fixtures']); }
|
||||
function renderNav(parts) {
|
||||
@@ -262,8 +263,11 @@ function mountHouseEditor(container, callbacks) {
|
||||
}
|
||||
const tree = document.getElementById('house-nav-tree');
|
||||
tree.innerHTML = html;
|
||||
// Alleen in beeld schuiven als de selectie of het onderdeel verandert, niet bij elke herberekening.
|
||||
const key = activePart + '|' + JSON.stringify(selection);
|
||||
const act = tree.querySelector('.hn-item.active');
|
||||
if (act) act.scrollIntoView({ block: 'nearest' });
|
||||
if (act && key !== navActiveKey) act.scrollIntoView({ block: 'nearest' });
|
||||
navActiveKey = key;
|
||||
}
|
||||
|
||||
document.getElementById('house-nav-tree').addEventListener('click', (e) => {
|
||||
@@ -369,43 +373,8 @@ function mountHouseEditor(container, callbacks) {
|
||||
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||||
renderer.localClippingEnabled = true;
|
||||
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
function buildPlanCaps(cutAbs) {
|
||||
const toM = 0.001, g = new THREE.Group();
|
||||
const S = WallModel.STONE;
|
||||
// Latwerk is open: pas vanaf de gevel afdekken.
|
||||
const gevel = view3D.layers.gevel, beplating = view3D.layers.beplating;
|
||||
HouseModel.allWallPieces(parts).forEach(p => {
|
||||
const y = cutAbs - p.place.floorTop;
|
||||
if (y <= 0 || y >= WallModel.topLine(p.config).maxY) return;
|
||||
const W = p.place.width, T = p.place.thickness;
|
||||
let out = 0;
|
||||
if (gevel && p.place.normal && p.buitenMateriaal !== 'geen') {
|
||||
const tr = p.trasraam;
|
||||
out = tr && y < tr.top ? (WallModel.isStoneFinish(tr.finish) ? S.THICK_MM : 0) : WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
}
|
||||
const e = p.skinExt || {};
|
||||
const xL = -W / 2 - (e.left > 0 ? e.left + out : 0), xR = W / 2 + (e.right > 0 ? e.right + out : 0);
|
||||
const gaps = p.config.openings.map(op => WallModel.openingSpanAtY(op, y)).filter(Boolean)
|
||||
.map(([a, b]) => [-W / 2 + a, -W / 2 + b]).sort((a, b) => a[0] - b[0]);
|
||||
const spans = [];
|
||||
let cur = xL;
|
||||
gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); });
|
||||
if (xR > cur) spans.push([cur, xR]);
|
||||
const sh = beplating ? 0 : p.config.sheathingThick;
|
||||
const z0 = -T / 2 + sh, z1 = T / 2 + out - (p.buitenMateriaal !== 'geen' ? sh : 0);
|
||||
spans.filter(([a, b]) => b - a > 1).forEach(([a, b]) => {
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry((b - a) * toM, (z1 - z0) * toM), capMaterial);
|
||||
m.rotation.x = -Math.PI / 2;
|
||||
m.position.set((a + b) / 2 * toM, (y - 0.5) * toM, (z0 + z1) / 2 * toM);
|
||||
const holder = new THREE.Group();
|
||||
holder.rotation.y = p.place.rotY;
|
||||
holder.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
|
||||
holder.add(m);
|
||||
g.add(holder);
|
||||
});
|
||||
});
|
||||
return g;
|
||||
}
|
||||
// Snede wit afgedekt; latwerk is open, pas vanaf de gevel afdekken (HouseModel.sectionCaps).
|
||||
const buildPlanCaps = (cutAbs) => HouseModel.sectionCaps(parts, cutAbs, capMaterial, view3D.layers);
|
||||
let fullRender = false;
|
||||
let skyMode = Skybox.getMode();
|
||||
const skyEnv = Skybox.createEnvironment(scene);
|
||||
@@ -418,23 +387,25 @@ function mountHouseEditor(container, callbacks) {
|
||||
['room', 'Ruimte', 'paint-bucket', 'R', 'Functie kiezen en in een ruimte klikken; de ruimte loopt tot de wanden'],
|
||||
['fixture', 'Inrichting', 'lamp', 'I', 'Stuk kiezen en in de plattegrond klikken (D = draaien)']
|
||||
];
|
||||
toolbar.innerHTML = `<div class="tb-group tb-tools" role="group" aria-label="Gereedschap">${TOOLS.map(([k, label, ic, key, hint]) =>
|
||||
const bar = UI.editorBar(toolbar, document.getElementById('house-overview'), {
|
||||
left: `<div class="tb-group tb-tools" role="group" aria-label="Gereedschap">${TOOLS.map(([k, label, ic, key, hint]) =>
|
||||
UI.tb(label, ic, `data-ho-tool="${k}" title="${label} (${key}): ${hint}"`)).join('')}</div>
|
||||
${UI.tbSep()}${UI.tb('Unit', 'plus-square', 'data-tb="add-unit" title="Unit toevoegen aan de geselecteerde (of laatste) unit"')}
|
||||
<div class="tb-group tb-options" id="ho-tool-options"></div>
|
||||
<div class="tb-spacer"></div>
|
||||
<div class="tb-group tb-view" id="ho-view-group"></div>
|
||||
<div class="tb-group tb-section" id="ho-section-group">
|
||||
<div class="tb-group tb-options" id="ho-tool-options"></div>`,
|
||||
after: `<div class="tb-group tb-section" id="ho-section-group">
|
||||
${UI.tb('Doorsnede', 'scissors', 'data-tb="section" title="Horizontale doorsnede (X)"')}
|
||||
<span class="tb-range"><input type="range" id="ho-section" min="100" max="4000" step="50" value="${view3D.sectionMM}" aria-label="Hoogte doorsnede"><span id="ho-section-val"></span></span>
|
||||
</div>
|
||||
${UI.tb('', 'camera', 'data-tb="render" title="Render: elke lamp en spot als echte lichtbron"')}
|
||||
${UI.tb('', 'person-walking', 'data-tb="walk" title="Lopen (L): W/S vooruit/achteruit, A/D opzij, slepen = rondkijken, E = deur/schakelaar"')}
|
||||
${UI.tbSep()}
|
||||
${UI.tb('Rapport', 'clipboard-data', 'data-tb="report" title="Rapport in plaats van de plattegrond (4): transport, gewicht, kosten, hout en plaatwerk"')}
|
||||
${UI.viewSeg(UI.viewOf(document.getElementById('house-overview')))}`;
|
||||
${UI.tb('Rapport', 'clipboard-data', 'data-tb="report" title="Rapport in plaats van de plattegrond (4): transport, gewicht, kosten, hout en plaatwerk"')}`,
|
||||
onResize,
|
||||
layers: { layers: view3D.layers, onChange: setLayers },
|
||||
onSky: (m) => { skyMode = m; applyRenderMode(); },
|
||||
beforeView: (mode) => { setWalk(false); if (mode === '3d') toggleReport(false); view3D.mode = mode; }
|
||||
});
|
||||
toolbar.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool)));
|
||||
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||
toolbar.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-tb]');
|
||||
if (!b) return;
|
||||
@@ -446,9 +417,6 @@ function mountHouseEditor(container, callbacks) {
|
||||
else if (a === 'report') toggleReport();
|
||||
b.blur();
|
||||
});
|
||||
const viewGroup = document.getElementById('ho-view-group');
|
||||
const layerMenu = ViewLayers.createMenu(viewGroup, { layers: view3D.layers, onChange: setLayers });
|
||||
Skybox.createToggle(viewGroup, (m) => { skyMode = m; applyRenderMode(); });
|
||||
function syncToolbar() {
|
||||
const q = (a) => toolbar.querySelector(`[data-tb="${a}"]`);
|
||||
q('section').classList.toggle('active', view3D.section);
|
||||
@@ -464,10 +432,10 @@ function mountHouseEditor(container, callbacks) {
|
||||
function setLayers(l) {
|
||||
view3D.layers = ViewLayers.normalize(l);
|
||||
const L = view3D.layers;
|
||||
if (selection && ((!L.ruimtes && (selection.type === 'room' || selection.type === 'region')) ||
|
||||
(!L.inrichting && selection.type === 'fixture') || (!L.elektra && selection.type === 'elektra'))) selection = null;
|
||||
layerMenu.set(L);
|
||||
scheduleRefresh({ keepProps: true });
|
||||
const hidden = selection && ((!L.ruimtes && (selection.type === 'room' || selection.type === 'region')) ||
|
||||
(!L.inrichting && selection.type === 'fixture') || (!L.elektra && selection.type === 'elektra'));
|
||||
if (hidden) selection = null;
|
||||
scheduleRefresh({ keepProps: !hidden }); // vervalt de selectie, dan ook het paneel bijwerken
|
||||
}
|
||||
const sectionInput = document.getElementById('ho-section');
|
||||
function syncSectionRow() {
|
||||
@@ -667,7 +635,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
tool = t;
|
||||
drawStart = null;
|
||||
document.querySelectorAll('[data-ho-tool]').forEach(b => b.classList.toggle('active', b.dataset.hoTool === t));
|
||||
canvas.style.cursor = t === 'iw' || t === 'fixture' ? 'crosshair' : t === 'room' ? 'cell' : 'default';
|
||||
canvas.style.cursor = toolCursor();
|
||||
roomHover = null;
|
||||
fxHover = null;
|
||||
renderToolOptions();
|
||||
@@ -675,6 +643,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
drawPlan();
|
||||
}
|
||||
|
||||
const toolCursor = () => tool === 'iw' || tool === 'fixture' ? 'crosshair' : tool === 'room' ? 'cell' : 'default';
|
||||
function updateHint() {
|
||||
document.getElementById('ho-hint').textContent = tool === 'iw'
|
||||
? (drawStart ? 'Klik het eindpunt (Esc = annuleren). Snapt op 50 mm, haaks, en op wandvlakken.' : 'Klik het beginpunt van de binnenwand.')
|
||||
@@ -728,13 +697,13 @@ function mountHouseEditor(container, callbacks) {
|
||||
: ElektraModel.snapToWall(parts.elektra, w.x, w.z);
|
||||
drawPlan();
|
||||
});
|
||||
function onElektraDrop() {
|
||||
function onElektraDrop(e) {
|
||||
if (!elDrag) return;
|
||||
const d = elDrag;
|
||||
elDrag = null;
|
||||
canvas.style.cursor = tool === 'iw' ? 'crosshair' : tool === 'room' ? 'cell' : 'default';
|
||||
canvas.style.cursor = toolCursor();
|
||||
if (!d.moved || !d.snap) return;
|
||||
suppressClick = true;
|
||||
if (e.target === canvas) suppressClick = true;
|
||||
if (d.snap.floor) {
|
||||
const rc = parts.rects.find(q => d.snap.x > q.x0 && d.snap.x < q.x1 && d.snap.z > q.z0 && d.snap.z < q.z1);
|
||||
const vw = state.settings.elektra.vloerWcd.find(o => 'vwcd:' + o.id === d.key);
|
||||
@@ -769,13 +738,13 @@ function mountHouseEditor(container, callbacks) {
|
||||
if (it) fxDrag.pose = fxPose(it, w.x + fxDrag.dx, w.z + fxDrag.dz, e.altKey);
|
||||
drawPlan();
|
||||
});
|
||||
function onFixtureDrop() {
|
||||
function onFixtureDrop(e) {
|
||||
if (!fxDrag) return;
|
||||
const d = fxDrag;
|
||||
fxDrag = null;
|
||||
canvas.style.cursor = 'default';
|
||||
canvas.style.cursor = toolCursor();
|
||||
if (!d.moved || !d.pose) return;
|
||||
suppressClick = true;
|
||||
if (e.target === canvas) suppressClick = true;
|
||||
const fs = fixtureState(d.id);
|
||||
if (fs) placeFixture(fs, d.pose.x, d.pose.z, d.pose.rot);
|
||||
selection = { type: 'fixture', id: d.id };
|
||||
@@ -801,13 +770,13 @@ function mountHouseEditor(container, callbacks) {
|
||||
if (ch) chDrag.snap = ChimneyModel.snapToFacade(parts.rects, w.x, w.z, ch.w);
|
||||
drawPlan();
|
||||
});
|
||||
window.addEventListener('mouseup', () => {
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (!chDrag) return;
|
||||
const d = chDrag;
|
||||
chDrag = null;
|
||||
canvas.style.cursor = 'default';
|
||||
canvas.style.cursor = toolCursor();
|
||||
if (!d.moved || !d.snap) return;
|
||||
suppressClick = true;
|
||||
if (e.target === canvas) suppressClick = true;
|
||||
const cs = (state.chimneys || []).find(c => c.id === d.id);
|
||||
if (cs) Object.assign(cs, { unitId: d.snap.unitId, side: d.snap.side, u: d.snap.u });
|
||||
const u = parts.units.find(q => q.id === d.snap.unitId), wl = u && u.walls.find(q => q.side === d.snap.side), pc = wl && wl.pieces.find(p => !p.baseY);
|
||||
@@ -887,12 +856,13 @@ function mountHouseEditor(container, callbacks) {
|
||||
if (selection.type === 'fixture') removeFixture(selection.id);
|
||||
else if (selection.type === 'room') removeRoom(selection.id);
|
||||
else if (selection.type === 'elektra' && selection.id !== 'mk') removeSelectedElektra();
|
||||
else if (selection.type === 'iw') {
|
||||
state.interiorWalls = state.interiorWalls.filter(w => w.id !== selection.id);
|
||||
else if (selection.type === 'iw') removeInteriorWall(selection.id);
|
||||
}
|
||||
function removeInteriorWall(id) {
|
||||
state.interiorWalls = state.interiorWalls.filter(w => w.id !== id);
|
||||
selection = null;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
function onKey(e) {
|
||||
if (['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
|
||||
const k = e.key.toLowerCase(), plain = !e.ctrlKey && !e.altKey && !e.metaKey;
|
||||
@@ -962,9 +932,10 @@ function mountHouseEditor(container, callbacks) {
|
||||
plan2D.cz = (canvas.height / 2 - (pan.oy + e.clientY - pan.sy)) / view.scale;
|
||||
drawPlan();
|
||||
}
|
||||
function onPanEnd() {
|
||||
// Na een sleep de klik op de plattegrond overslaan, maar alleen als die klik er ook komt (loslaten op de plattegrond).
|
||||
function onPanEnd(e) {
|
||||
if (!pan) return;
|
||||
if (pan.moved) { canvas.style.cursor = 'default'; if (!pan.mid) suppressClick = true; }
|
||||
if (pan.moved) { canvas.style.cursor = toolCursor(); if (!pan.mid && e.target === canvas) suppressClick = true; }
|
||||
pan = null;
|
||||
}
|
||||
window.addEventListener('mousemove', onPanMove);
|
||||
@@ -1182,6 +1153,11 @@ function mountHouseEditor(container, callbacks) {
|
||||
const a = toPx(pp.x - pp.size / 2, pp.z - pp.size / 2), sz = pp.size * scale;
|
||||
ctx.fillRect(a.x, a.y, sz, sz); ctx.strokeRect(a.x, a.y, sz, sz);
|
||||
});
|
||||
if (L.dak) parts.posts.walls.forEach(w => {
|
||||
const a = toPx(w.x0, w.z0);
|
||||
ctx.fillStyle = WallModel.stucHex(w.color); ctx.strokeStyle = '#8a8a8a';
|
||||
ctx.fillRect(a.x, a.y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); ctx.strokeRect(a.x, a.y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale);
|
||||
});
|
||||
(L.gevel ? parts.chimneys || [] : []).forEach(ch => {
|
||||
const path = (pts) => { ctx.beginPath(); pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y); }); ctx.closePath(); };
|
||||
const sn = chDrag && chDrag.moved && chDrag.snap && chDrag.id === ch.id ? chDrag.snap : null;
|
||||
@@ -1336,27 +1312,18 @@ function mountHouseEditor(container, callbacks) {
|
||||
const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ',');
|
||||
const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` })));
|
||||
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 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>`;
|
||||
function wireButtons(body) {
|
||||
body.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool)));
|
||||
body.querySelectorAll('[data-go]').forEach(b => b.addEventListener('click', () => showPart(b.dataset.go)));
|
||||
body.querySelectorAll('[data-ho-act]').forEach(b => b.addEventListener('click', () => {
|
||||
const a = b.dataset.hoAct;
|
||||
if (a === 'add-unit') addUnit();
|
||||
else if (a === 'project') openProjectProperties();
|
||||
else if (a === 'save') exportDesign();
|
||||
else if (a === 'dxf') exportDxf();
|
||||
else if (a === 'ifc') exportIfc();
|
||||
else if (a === 'obj') exportObj();
|
||||
else if (a === 'costs') CostSettings.open();
|
||||
else if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
|
||||
if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); }
|
||||
else if (a === 'remove-unit') removeUnit(b.dataset.unit);
|
||||
else if (a === 'remove-iw') {
|
||||
state.interiorWalls = state.interiorWalls.filter(w => w.id !== Number(b.dataset.iw));
|
||||
selection = null;
|
||||
refresh();
|
||||
}
|
||||
else if (a === 'remove-iw') removeInteriorWall(Number(b.dataset.iw));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1368,21 +1335,29 @@ function mountHouseEditor(container, callbacks) {
|
||||
const rabatUi = sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'Rabat op vochtdoek en kruislat' }) +
|
||||
pair(sel('hs-rabat-color', 'Kleur', rabatColors, st.facade.rabatColor, { hint: 'Ook voor rabat-overrides per wand' }),
|
||||
`<div class="ins-swatch" style="background:${WallModel.rabatHex(st.facade.rabatColor)}"></div>`);
|
||||
const usesRabat = state.units.some(u => Object.keys(u.unit.walls).some(side => HouseModel.facadeFor(state, u, side).type === 'rabat' ||
|
||||
UnitModel.normalizeSplits(u.unit.walls[side].splits).some(sp => sp.facade && sp.facade.type === 'rabat')));
|
||||
const uses = (type) => state.units.some(u => Object.keys(u.unit.walls).some(side => HouseModel.facadeFor(state, u, side).type === type ||
|
||||
UnitModel.normalizeSplits(u.unit.walls[side].splits).some(sp => sp.facade && sp.facade.type === type)));
|
||||
const usesRabat = uses('rabat');
|
||||
const felsUi = pair(sel('hs-fels-color', 'Kleur', felsColors, st.facade.felsColor,
|
||||
{ hint: `Staande naad, banen van ${WallModel.FELS.SEAM_MM} mm, op latten zoals verticale rabat; ook voor fels-overrides per wand` }),
|
||||
swatchOf(WallModel.felsHex(st.facade.felsColor)));
|
||||
return [
|
||||
{ label: 'Bekleding', html: sel('hs-facade', 'Type', HouseModel.FACADE_TYPES, st.facade.type, { hint: 'Voor alle buitenwanden; per wand of wandstuk aan te passen (selecteer de wand)' }) +
|
||||
(st.facade.type === 'steen'
|
||||
? pair(sel('hs-facade-stone', 'Steen', stoneOptions, st.facade.stone, { hint: `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik` }), thumb(WallModel.stoneThumbUrl(st.facade.stone, 64), st.facade.stone))
|
||||
: st.facade.type === 'stuc'
|
||||
? pair(sel('hs-stuc-color', 'Kleur', stucColors, st.facade.stucColor, { hint: `Pleisterlaag ${WallModel.STUC.THICK_MM} mm op cement-gebonden plaat` }), swatchOf(WallModel.stucHex(st.facade.stucColor)))
|
||||
: rabatUi) },
|
||||
: st.facade.type === 'fels' ? felsUi : rabatUi) },
|
||||
st.facade.type !== 'rabat' && usesRabat ? { label: 'Rabat (overrides)', html: rabatUi } : null,
|
||||
st.facade.type !== 'fels' && uses('fels') ? { label: 'Felsplaten (overrides)', html: felsUi } : null,
|
||||
{ label: 'Trasraam', html: toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Strook onderaan de gevel, onder rabat én steen' }) +
|
||||
(st.trasraam.enabled
|
||||
? 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),
|
||||
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,
|
||||
{ hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm rondom, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` }) +
|
||||
@@ -1397,9 +1372,11 @@ function mountHouseEditor(container, callbacks) {
|
||||
UI.onSel('hs-rabat-color', v => { st.facade.rabatColor = v; }, rerender);
|
||||
UI.onSel('hs-facade-stone', v => { st.facade.stone = v; }, rerender);
|
||||
UI.onSel('hs-stuc-color', v => { st.facade.stucColor = v; }, rerender);
|
||||
UI.onSel('hs-fels-color', v => { st.facade.felsColor = 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.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.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
|
||||
}
|
||||
@@ -1410,9 +1387,9 @@ function mountHouseEditor(container, callbacks) {
|
||||
key: 'dak', label: 'Dak', order: 30,
|
||||
groups: () => {
|
||||
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, {
|
||||
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,
|
||||
span: () => { const sh = HouseModel.houseRoofShape(state); return sh ? sh.span : 1000; },
|
||||
apply: () => HouseModel.applyGlobalSettings(state),
|
||||
@@ -1433,11 +1410,15 @@ function mountHouseEditor(container, callbacks) {
|
||||
{ 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-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) },
|
||||
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) },
|
||||
sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) +
|
||||
toggle('hs-vent', 'Ventilatierooster', r.vent !== false, { hint: `${RoofModel.VENT.HEIGHT_MM} mm hoog op de gevel, direct onder alle boeidelen` }) +
|
||||
(r.vent !== false ? pair(sel('hs-vent-color', 'Kleur rooster', TRIM_OPTS, r.ventColor || 'antraciet'), swatchOf(WallModel.trimHex(r.ventColor))) : '') },
|
||||
{ label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Over de gevel; steekt 30 mm boven het dak uit (units zonder boeidelen)' }) },
|
||||
{ label: 'Palen overkapping', html:
|
||||
toggle('hs-posts', 'Palen', r.posts.enabled, { hint: `Onder een dak dat minstens de min. uitkraging buiten de vloer steekt (veranda); per unit: Overkapping. Nu ${parts ? parts.posts.count : 0} st.` }) +
|
||||
(r.posts.enabled ? sel('hs-posts-wood', 'Hout', Object.fromEntries(Object.entries(PostModel.WOODS).map(([k, w]) => [k, w.label])), r.posts.wood) +
|
||||
(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.sideWall ? pair(sel('hs-posts-side-color', 'Kleur zijwand', stucColors, r.posts.sideColor), swatchOf(WallModel.stucHex(r.posts.sideColor))) : '') +
|
||||
sel('hs-posts-wood', 'Hout', Object.fromEntries(Object.entries(PostModel.WOODS).map(([k, w]) => [k, w.label])), r.posts.wood) +
|
||||
UI.range('hs-posts-size', 'Maat', r.posts.size, { min: 100, max: 300, step: 10, hint: 'Vierkant, mm' }) +
|
||||
num('hs-posts-span', 'Max. h.o.h.', r.posts.maxSpan, { step: 100, hint: 'Grootste afstand tussen palen (of paal en gevel)' }) +
|
||||
num('hs-posts-min', 'Min. uitkraging', r.posts.minExt, { step: 100, hint: 'Kleinere overstekken krijgen geen palen' }) : '') },
|
||||
@@ -1453,8 +1434,12 @@ 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-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after);
|
||||
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.onToggle('hs-posts', v => { r.posts.enabled = v; }, rerender);
|
||||
UI.onToggle('hs-posts-side', v => { r.posts.sideWall = v; }, rerender);
|
||||
UI.onSel('hs-posts-side-color', v => { r.posts.sideColor = v; }, rerender);
|
||||
UI.onSel('hs-posts-wood', v => { r.posts.wood = v; }, rerender);
|
||||
UI.onRange('hs-posts-size', v => { if (v > 0) r.posts.size = v; }, after);
|
||||
UI.onNum('hs-posts-span', v => { if (v >= 1000) r.posts.maxSpan = v; }, after, pos);
|
||||
@@ -1914,7 +1899,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
const f = (k) => `${id}-roof-${k}`;
|
||||
const groups = () => {
|
||||
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) : '') +
|
||||
(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)` }) }
|
||||
@@ -1984,6 +1969,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
const form = UnitModel.roofForm(u.unit);
|
||||
const roofUi = roofFormUi('hp', form, {
|
||||
gable: true,
|
||||
types: () => HouseModel.hasCover(state, u.id) ? NO_WOLF : null,
|
||||
span: () => {
|
||||
const pu = partsOf();
|
||||
const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form));
|
||||
@@ -2055,13 +2041,15 @@ function mountHouseEditor(container, callbacks) {
|
||||
UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
|
||||
UI.onToggle('hp-steel-ridge', v => { u.roof.steelRidge = 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 => {
|
||||
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));
|
||||
UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after);
|
||||
UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after);
|
||||
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after);
|
||||
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after);
|
||||
}, afterCover));
|
||||
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; }, afterCover);
|
||||
UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, afterCover);
|
||||
UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, afterCover);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2142,7 +2130,8 @@ function mountHouseEditor(container, callbacks) {
|
||||
{ label: 'Gevel (hele zijde)', html:
|
||||
sel('hw-facade', 'Type', Object.assign({ woning: woningLabel }, HouseModel.FACADE_TYPES), ov ? ov.type : 'woning') +
|
||||
(ov && ov.type === 'steen' ? pair(sel('hw-facade-stone', 'Steen', stoneOptions, fc.stone), thumb(WallModel.stoneThumbUrl(fc.stone, 64), fc.stone)) : '') +
|
||||
(ov && ov.type === 'stuc' ? pair(sel('hw-facade-stuc', 'Kleur', stucColors, fc.stuc), swatchOf(WallModel.stucHex(fc.stuc))) : '') },
|
||||
(ov && ov.type === 'stuc' ? pair(sel('hw-facade-stuc', 'Kleur', stucColors, fc.stuc), swatchOf(WallModel.stucHex(fc.stuc))) : '') +
|
||||
(ov && ov.type === 'fels' ? pair(sel('hw-facade-fels', 'Kleur', felsColors, fc.fels), swatchOf(WallModel.felsHex(fc.fels))) : '') },
|
||||
{ label: 'Wandstukken', html:
|
||||
num('hw-split-at', 'Splitsen op', Math.round(e0 + (piece.u0 + piece.u1) / 2), { step: 10, hint: 'Afstand vanaf de linker vloerhoek, van buitenaf gezien. Elk stuk wordt een eigen wand-element (eigen regelwerk) en kan een eigen gevel krijgen.' }) +
|
||||
small('Splitsen', 'scissors', 'data-hw="split"') +
|
||||
@@ -2150,6 +2139,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
(own ? sel('hw-piece-facade', 'Gevel dit stuk', Object.assign({ zijde: 'Zoals de zijde' }, HouseModel.FACADE_TYPES), own.facade ? own.facade.type : 'zijde', { hint: 'Gevel van dit wandstuk (rechts van de splitsing)' }) +
|
||||
(own.facade && own.facade.type === 'steen' ? sel('hw-piece-stone', 'Steen', stoneOptions, own.facade.stone || fc.stone) : '') +
|
||||
(own.facade && own.facade.type === 'stuc' ? sel('hw-piece-stuc', 'Kleur', stucColors, own.facade.stuc || fc.stuc) : '') +
|
||||
(own.facade && own.facade.type === 'fels' ? sel('hw-piece-fels', 'Kleur', felsColors, own.facade.fels || fc.fels) : '') +
|
||||
small('Splitsing weg', 'x-lg', 'data-hw="unsplit"') : '') },
|
||||
chimneyGroup(pu, wl, piece, e0),
|
||||
{ label: 'Gevellampen', html: lt.mode === 'gevel'
|
||||
@@ -2168,16 +2158,18 @@ function mountHouseEditor(container, callbacks) {
|
||||
wireChimneys(body, pu, wl);
|
||||
UI.onSel('hw-facade', v => {
|
||||
if (v === 'woning') delete su.facade.walls[wl.side];
|
||||
else su.facade.walls[wl.side] = v === 'steen' ? { type: v, stone: fc.stone } : v === 'stuc' ? { type: v, stuc: fc.stuc } : { type: v };
|
||||
else su.facade.walls[wl.side] = v === 'steen' ? { type: v, stone: fc.stone } : v === 'stuc' ? { type: v, stuc: fc.stuc } : v === 'fels' ? { type: v, fels: fc.fels } : { type: v };
|
||||
}, rerender);
|
||||
UI.onSel('hw-facade-stone', v => { su.facade.walls[wl.side].stone = v; }, rerender);
|
||||
UI.onSel('hw-facade-stuc', v => { su.facade.walls[wl.side].stuc = v; }, rerender);
|
||||
UI.onSel('hw-facade-fels', v => { su.facade.walls[wl.side].fels = v; }, rerender);
|
||||
UI.onSel('hw-piece-facade', v => {
|
||||
if (!own) return;
|
||||
own.facade = v === 'zijde' ? null : v === 'steen' ? { type: v, stone: fc.stone } : v === 'stuc' ? { type: v, stuc: fc.stuc } : { type: v };
|
||||
own.facade = v === 'zijde' ? null : v === 'steen' ? { type: v, stone: fc.stone } : v === 'stuc' ? { type: v, stuc: fc.stuc } : v === 'fels' ? { type: v, fels: fc.fels } : { type: v };
|
||||
}, rerender);
|
||||
UI.onSel('hw-piece-stone', v => { if (own && own.facade) own.facade.stone = v; }, rerender);
|
||||
UI.onSel('hw-piece-stuc', v => { if (own && own.facade) own.facade.stuc = v; }, rerender);
|
||||
UI.onSel('hw-piece-fels', v => { if (own && own.facade) own.facade.fels = v; }, rerender);
|
||||
body.querySelectorAll('[data-hw]').forEach(b => b.addEventListener('click', () => {
|
||||
if (b.dataset.hw === 'split') {
|
||||
const v = Number(document.getElementById('hw-split-at').value);
|
||||
@@ -2295,12 +2287,6 @@ function mountHouseEditor(container, callbacks) {
|
||||
syncToolbar();
|
||||
requestAnimationFrame(onResize);
|
||||
}
|
||||
function setView(mode) {
|
||||
setWalk(false);
|
||||
UI.setView(overviewEl, view3D.mode = mode);
|
||||
requestAnimationFrame(onResize);
|
||||
}
|
||||
|
||||
const esc = UI.esc;
|
||||
function stackedBars(title, iconName, series, rows, fmtVal, note) {
|
||||
const max = Math.max(1, ...rows.map(r => r.total));
|
||||
@@ -2361,14 +2347,17 @@ function mountHouseEditor(container, callbacks) {
|
||||
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.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.']);
|
||||
cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
|
||||
}
|
||||
if (totals.posts) {
|
||||
const pp = totals.posts;
|
||||
cards.push(UI.specCard('Overkapping', 'columns', [
|
||||
cards.push(UI.specCard('Overkapping', 'columns', (pp.count ? [
|
||||
[`${pp.wood} palen ${pp.size}×${pp.size} mm`, pp.count, 'st.'],
|
||||
['Lengte totaal', f(pp.lengthM, 2), 'm'], ['Hout', f(pp.m3, 3), 'm³'], ['Gewicht', f(pp.kg), 'kg'], ['Paalvoeten', pp.count, 'st.']]));
|
||||
['Lengte totaal', f(pp.lengthM, 2), 'm'], ['Hout', f(pp.m3, 3), 'm³'], ['Gewicht', f(pp.kg), 'kg'], ['Paalvoeten', pp.count, 'st.']] : [])
|
||||
.concat(pp.walls ? [[`Zijwand (${pp.walls} st.)`, f(pp.wallM2, 2), 'm²']] : [])));
|
||||
}
|
||||
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
||||
const sw = totals.saw, cs = cost.saw;
|
||||
@@ -2442,28 +2431,24 @@ function mountHouseEditor(container, callbacks) {
|
||||
parts.units.forEach(u => houseGroup.add(u.roof.group));
|
||||
if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup);
|
||||
if (parts.postsGroup) houseGroup.add(parts.postsGroup);
|
||||
if (parts.chimneysGroup) houseGroup.add(parts.chimneysGroup);
|
||||
}
|
||||
// Alles wat binnen de wanden of onder het plafond zit, snijdt mee met de doorsnede (anders zweeft het boven de snede).
|
||||
const clipGroup = (g) => {
|
||||
if (!g) return;
|
||||
g.traverse(o => {
|
||||
if (!o.material || (o.material.clippingPlanes || []).length === clip.length) return;
|
||||
o.material.clippingPlanes = clip;
|
||||
o.material.needsUpdate = true;
|
||||
});
|
||||
houseGroup.add(g);
|
||||
};
|
||||
if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup);
|
||||
if (parts.lightingGroup && !section) houseGroup.add(parts.lightingGroup);
|
||||
if (parts.elektraGroup) houseGroup.add(parts.elektraGroup);
|
||||
if (parts.roomsGroup) {
|
||||
parts.roomsGroup.userData.ceilings.visible = !section;
|
||||
parts.roomsGroup.traverse(o => {
|
||||
if (!o.material || (o.material.clippingPlanes || []).length === clip.length) return;
|
||||
o.material.clippingPlanes = clip;
|
||||
o.material.needsUpdate = true;
|
||||
});
|
||||
houseGroup.add(parts.roomsGroup);
|
||||
}
|
||||
if (parts.fixturesGroup) {
|
||||
parts.fixturesGroup.traverse(o => {
|
||||
if (!o.material || (o.material.clippingPlanes || []).length === clip.length) return;
|
||||
o.material.clippingPlanes = clip;
|
||||
o.material.needsUpdate = true;
|
||||
});
|
||||
houseGroup.add(parts.fixturesGroup);
|
||||
}
|
||||
clipGroup(parts.elektraGroup);
|
||||
clipGroup(parts.chimneysGroup); // laag Gevelbekleding, net als in de plattegrond
|
||||
if (parts.roomsGroup) parts.roomsGroup.userData.ceilings.visible = !section;
|
||||
clipGroup(parts.roomsGroup);
|
||||
clipGroup(parts.fixturesGroup);
|
||||
scene.add(houseGroup);
|
||||
|
||||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1));
|
||||
@@ -2777,7 +2762,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
refresh,
|
||||
select,
|
||||
setTool,
|
||||
setView,
|
||||
setView: bar.setView,
|
||||
toggleReport: () => toggleReport(),
|
||||
toggleSection,
|
||||
fitView: () => fitPlan(),
|
||||
@@ -2863,7 +2848,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
const exportIfc = () => exportAs('ifc', 'BIM-export (IFC)', (f) => HouseIfc.generate(state).save(f), 'IFC4: balken, platen en elektra');
|
||||
|
||||
// Render-export (sfeerimpressie): alleen wat in de afgewerkte woning te zien is, los van de gekozen lagen, doorsnede
|
||||
// en weergave-opties; geen buizen, dozen, binnenste vloerbalken, latten en isolatie achter de gevel, lichtkegels.
|
||||
// en weergave-opties (HouseModel.computeParts met finishedOnly).
|
||||
// Lampen als echte lichtbronnen via een Blender-script in de ZIP.
|
||||
// Eigen materiaalsets (steen, riet, rabat en tegels komen uit gedeelde caches); maaiveld onder het midden = oorsprong.
|
||||
const exportObj = () => exportAs('zip', 'render-export (OBJ + texturen + lampen, ZIP)', (filename) => {
|
||||
@@ -2873,21 +2858,12 @@ function mountHouseEditor(container, callbacks) {
|
||||
const full = HouseModel.computeParts(state, {
|
||||
wallMaterials, floorMaterials: FloorModel.createMaterials(), roofMaterials, lightingMaterials: LightingModel.createMaterials(),
|
||||
sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(), chimneyMaterials: ChimneyModel.createMaterials(),
|
||||
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), elektraRough: false, finishedOnly: true
|
||||
elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), finishedOnly: true
|
||||
});
|
||||
HouseModel.placeGroups(full);
|
||||
HouseModel.allWallPieces(full).forEach(p => ViewLayers.hideByMaterial(p.group,
|
||||
[wallMaterials.isolatieMaterial, wallMaterials.battenMaterial, wallMaterials.doekMaterial]));
|
||||
if (full.lightingGroup) full.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = false; });
|
||||
if (full.roomsGroup && full.roomsGroup.userData.ceilings) full.roomsGroup.userData.ceilings.visible = true;
|
||||
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 origin = { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 };
|
||||
const out = ObjWriter.build(roots, { name, app: APP_VERSION, origin });
|
||||
const origin = HouseModel.groundOrigin(state, full);
|
||||
const out = ObjWriter.build(HouseModel.rootGroups(full), { name, app: APP_VERSION, origin });
|
||||
const edge = full.roofEdge;
|
||||
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 })) });
|
||||
@@ -2932,6 +2908,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
return (...args) => { if (activeEditor && typeof activeEditor[fn] === 'function') return activeEditor[fn](...args); };
|
||||
}
|
||||
const has = (fn) => !!(activeEditor && typeof activeEditor[fn] === 'function');
|
||||
// Acties die naar de actieve onderdeel-editor gaan; de API hieronder wordt uit deze lijst opgebouwd.
|
||||
const FORWARDED = ['mirrorHorizontal', 'setView', 'toggleReport', 'toggleSection', 'fitView', 'toggleWalk'];
|
||||
|
||||
function onPartKey(e) {
|
||||
@@ -2953,12 +2930,7 @@ function mountHouseEditor(container, callbacks) {
|
||||
exportIfc,
|
||||
exportObj,
|
||||
triggerImport,
|
||||
mirrorHorizontal: forward('mirrorHorizontal'),
|
||||
setView: forward('setView'),
|
||||
toggleReport: forward('toggleReport'),
|
||||
toggleSection: forward('toggleSection'),
|
||||
fitView: forward('fitView'),
|
||||
toggleWalk: forward('toggleWalk'),
|
||||
...Object.fromEntries(FORWARDED.map(fn => [fn, forward(fn)])),
|
||||
viewState: () => forward('viewState')() || {},
|
||||
// Doorgestuurde acties bestaan alleen als de actieve onderdeel-editor ze kent (bijv. Rapport alleen in de woning).
|
||||
can: (fn) => !FORWARDED.includes(fn) || has(fn),
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
// Light-versie (modelpagina's): hetzelfde model en dezelfde berekeningen als de configurator, met een beperkte set
|
||||
// keuzes. Keuzes worden altijd opnieuw toegepast op een schone kopie van het model, zodat terug naar de
|
||||
// oorspronkelijke keuze precies het model geeft. Een keuze die de indeling breekt (ramen en deuren die niet meer
|
||||
// passen, binnenwanden of inrichting buiten de unit, ruimtes die verdwijnen) wordt geweigerd (check).
|
||||
window.LightModel = (() => {
|
||||
const LIM = UnitModel.LIMITS;
|
||||
// Kaders (mm) als het model of de pagina niets opgeeft; altijd binnen UnitModel.LIMITS. Per model te
|
||||
// beperken met FRAMES, per pagina met mount-optie frames (zelfde vorm).
|
||||
const DEFAULT_FRAME = { width: [2400, LIM.long], depth: [2400, LIM.long], height: [2400, LIM.eave] };
|
||||
const FRAMES = {
|
||||
// amstel: { units: { A: { width: [9000, 13000], depth: null } }, height: [2600, 3000] }
|
||||
};
|
||||
const STEP = 50;
|
||||
// Helling (graden) bij een andere dakvorm dan die van het model.
|
||||
const PITCH = { lessenaar: 10, zadel: 30, wolf: 35 };
|
||||
const FACADES = { rabat: 'Hout', fels: 'Felsplaten', stuc: 'Stucwerk', steen: 'Steenstrips' };
|
||||
const STONES = ['K490', 'K915', 'K911', 'K764', 'K518', 'K706'];
|
||||
const LIGHTING = { geen: 'Geen', spots: 'Spots in de dakrand', gevel: 'Gevellampen' };
|
||||
// Punten tot zo ver van de oost- of zuidrand van een unit horen bij die gevel en schuiven mee.
|
||||
const BAND = 450;
|
||||
|
||||
const clone = HouseModel.clone;
|
||||
const rectMap = (st) => Object.fromEntries(HouseModel.unitRects(st).map(r => [r.id, r]));
|
||||
const houseRoof = (st) => !!(st.settings.roof.house.enabled && st.settings.roof.house.form);
|
||||
|
||||
// Alle gevel-overrides van het model (per wand en per wandstuk): het "accent".
|
||||
function accents(st) {
|
||||
const out = [];
|
||||
st.units.forEach(u => Object.entries(u.unit.walls).forEach(([side, ws]) => {
|
||||
if (u.facade.walls[side]) out.push({ get: () => u.facade.walls[side], set: (v) => { if (v) u.facade.walls[side] = v; else delete u.facade.walls[side]; } });
|
||||
(ws.splits || []).forEach(sp => { if (sp && sp.facade) out.push({ get: () => sp.facade, set: (v) => { sp.facade = v || null; } }); });
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
const colorOf = (o) => o.type === 'steen' ? o.stone : o.type === 'stuc' ? o.stuc : o.type === 'fels' ? o.fels : null;
|
||||
|
||||
function frameOf(key, override) {
|
||||
const f = Object.assign({}, FRAMES[key] || {}, override || {});
|
||||
const clampR = (r, lim) => r && [Math.max(1000, Math.min(lim, r[0])), Math.min(lim, r[1])];
|
||||
return {
|
||||
unit: (id) => {
|
||||
const u = (f.units || {})[id] || {};
|
||||
const pick = (k, lim) => u[k] === null ? null : clampR(u[k] || DEFAULT_FRAME[k], lim);
|
||||
return { width: pick('width', LIM.long), depth: pick('depth', LIM.long) };
|
||||
},
|
||||
height: clampR(f.height || DEFAULT_FRAME.height, LIM.eave)
|
||||
};
|
||||
}
|
||||
|
||||
// Model inladen: schone basis, oorspronkelijke dakvormen en de keuzes zoals het model ze heeft.
|
||||
function create(key, opts) {
|
||||
const def = HouseModel.MODELS[key];
|
||||
if (!def) throw new Error(`onbekend model "${key}"`);
|
||||
const base = def.create();
|
||||
const forms = Object.fromEntries(base.units.map(u => [u.id, UnitModel.roofForm(u.unit)]));
|
||||
if (houseRoof(base)) forms.house = Object.assign({}, base.settings.roof.house.form);
|
||||
const f = base.settings.facade, acc = accents(base).map(a => a.get());
|
||||
const covering = (base.units.find(u => RoofModel.COVERINGS[u.roof.covering] && RoofModel.COVERINGS[u.roof.covering].sloped) || base.units[0]).roof.covering;
|
||||
const choices = {
|
||||
units: Object.fromEntries(base.units.map(u => {
|
||||
const fl = u.unit.floor.floor;
|
||||
return [u.id, { width: fl.width, depth: fl.depth, low: forms[u.id].low, high: highOf(forms[u.id]), type: forms[u.id].type, cut: cutOf(forms[u.id]), dir: forms[u.id].dir || null, ridge: forms[u.id].ridge || null }];
|
||||
})),
|
||||
house: forms.house ? { low: forms.house.low, high: highOf(forms.house), type: forms.house.type, cut: cutOf(forms.house), dir: forms.house.dir || null, ridge: forms.house.ridge || null } : null,
|
||||
covering: RoofModel.COVERINGS[covering] ? covering : 'dakleer',
|
||||
facade: { type: f.type, rabat: f.rabatColor, cladding: base.settings.cladding, stuc: f.stucColor, fels: f.felsColor, stone: f.stone },
|
||||
accent: acc.length ? { type: acc[0].type, color: colorOf(acc[0]) } : null,
|
||||
// Trasraam: alleen geen of steen; een cementplaat-trasraam van het model wordt geen.
|
||||
trasraam: (() => {
|
||||
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 };
|
||||
})(),
|
||||
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 st = build(model, choices);
|
||||
model.reference = issuesOf(st, HouseModel.computeParts(st, { skip3D: true }));
|
||||
return model;
|
||||
}
|
||||
|
||||
// Wolfseind als verhouding tussen goot (0) en nok (1), zoals de configurator: schaalt mee met goot en nok.
|
||||
const WOLF_CUT = 0.6;
|
||||
const cutOf = (form) => form.type !== 'wolf' ? null
|
||||
: form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - form.low) / Math.max(1, form.high - form.low) : WOLF_CUT;
|
||||
|
||||
// Nok (of hoge kant) als keuze; null = afgeleid (plat dak, of een andere dakvorm dan die van het model).
|
||||
const RISE_MIN = 300;
|
||||
const highOf = (form) => form.type === 'plat' ? null : form.high;
|
||||
|
||||
// Dakvorm bij een keuze (c: type, low, high, cut, dir, ridge): dezelfde vorm als het model houdt richting en
|
||||
// dakhoogte boven de goot, een andere krijgt de helling uit PITCH over de korte kant (lessenaar hoog aan de noord-
|
||||
// of westgevel, nok in de lengte). Een gekozen richting (c.dir, c.ridge) en nok (c.high) gaan voor.
|
||||
function formFor(orig, c, W, D) {
|
||||
const { type, low } = c;
|
||||
const dir = (f) => {
|
||||
if (type === 'lessenaar') f.dir = c.dir || orig.dir || (W >= D ? 'voor' : 'links');
|
||||
if (UnitModel.hasRidge(type)) f.ridge = c.ridge || orig.ridge || (W >= D ? 'x' : 'z');
|
||||
return f;
|
||||
};
|
||||
let f;
|
||||
if (type === orig.type) {
|
||||
f = dir(Object.assign({}, orig, { low }));
|
||||
f.high = type === 'plat' ? low : Math.min(LIM.ridge, orig.high + (low - orig.low));
|
||||
} else {
|
||||
f = { type, low, high: low };
|
||||
if (type === 'plat') return f;
|
||||
dir(f);
|
||||
const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D;
|
||||
const run = UnitModel.hasRidge(type) ? span / 2 : span;
|
||||
f.high = Math.round(Math.min(LIM.ridge, low + Math.tan(PITCH[type] * Math.PI / 180) * run));
|
||||
}
|
||||
if (type !== 'plat' && c.high > 0) f.high = Math.round(Math.min(LIM.ridge, Math.max(low + RISE_MIN, c.high)));
|
||||
if (type === 'wolf') { f.cutRatio = c.cut > 0 ? c.cut : cutOf(orig) || WOLF_CUT; delete f.cut; }
|
||||
return f;
|
||||
}
|
||||
// Helling (graden) van een dakvorm over zijn overspanning (W/D: maten van de unit of de woning).
|
||||
function pitchOf(f, W, D) {
|
||||
if (f.type === 'plat') return 0;
|
||||
const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D;
|
||||
return Math.atan((f.high - f.low) / ((UnitModel.hasRidge(f.type) ? span / 2 : span) || 1)) * 180 / Math.PI;
|
||||
}
|
||||
function houseSize(st) {
|
||||
const xs = HouseModel.unitRects(st);
|
||||
return [Math.max(...xs.map(r => r.x1)) - Math.min(...xs.map(r => r.x0)), Math.max(...xs.map(r => r.z1)) - Math.min(...xs.map(r => r.z0))];
|
||||
}
|
||||
// Dakvorm zoals build hem toepast: per unit (id), of over de hele woning (id null; maten uit de gebouwde st).
|
||||
// Met pitch: de helling in graden.
|
||||
function formOf(model, ch, id, st) {
|
||||
const [W, D] = id ? [ch.units[id].width, ch.units[id].depth] : houseSize(st);
|
||||
const f = formFor(id ? model.forms[id] : model.forms.house, id ? ch.units[id] : ch.house, W, D);
|
||||
return Object.assign(f, { pitch: pitchOf(f, W, D) });
|
||||
}
|
||||
|
||||
// Unit van een punt (woningcoördinaten): de rechthoek waar het punt het diepst in ligt.
|
||||
function ownerOf(rects, x, z) {
|
||||
let best = null, depth = -1;
|
||||
Object.values(rects).forEach(r => {
|
||||
const d = Math.min(x - r.x0, r.x1 - x, z - r.z0, r.z1 - z);
|
||||
if (d > -1 && d > depth) { best = r.id; depth = d; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
// Na een maatwijziging: binnenwanden (woningcoördinaten) schuiven mee met hun unit; een eindpunt tegen de oost- of
|
||||
// zuidgevel van een gewijzigde unit schuift mee met die gevel (de wand rekt mee). Inrichting staat al t.o.v. de
|
||||
// noordwesthoek (of de oostgevel) van zijn unit; staat een stuk tegen de oost- of zuidgevel, dan schuift het mee.
|
||||
function follow(st, before, after) {
|
||||
const shift = (id, x, z) => {
|
||||
const b = before[id], a = after[id];
|
||||
let dx = a.x0 - b.x0, dz = a.z0 - b.z0;
|
||||
if (a.W !== b.W && x >= b.x1 - BAND) dx = a.x1 - b.x1;
|
||||
if (a.D !== b.D && z >= b.z1 - BAND) dz = a.z1 - b.z1;
|
||||
return { x: Math.round(x + dx), z: Math.round(z + dz) };
|
||||
};
|
||||
st.interiorWalls.forEach(iw => {
|
||||
if (iw.seam) return;
|
||||
[['x1', 'z1'], ['x2', 'z2']].forEach(([kx, kz]) => {
|
||||
const id = ownerOf(before, iw[kx], iw[kz]);
|
||||
if (!id) return;
|
||||
const p = shift(id, iw[kx], iw[kz]);
|
||||
iw[kx] = p.x; iw[kz] = p.z;
|
||||
});
|
||||
});
|
||||
st.fixtures.forEach(f => {
|
||||
const b = before[f.unitId], a = after[f.unitId];
|
||||
if (!b || !a) return;
|
||||
const r = f.rot * Math.PI / 180, c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
|
||||
const ex = c * f.w / 2 + s * f.d / 2, ez = s * f.w / 2 + c * f.d / 2;
|
||||
if (f.anchor !== 'oost' && a.W !== b.W && f.x + ex >= b.W - BAND) f.x += a.W - b.W;
|
||||
if (a.D !== b.D && f.z + ez >= b.D - BAND) f.z += a.D - b.D;
|
||||
});
|
||||
}
|
||||
|
||||
// Keuzes toepassen op een schone kopie van het model.
|
||||
function build(model, ch) {
|
||||
const st = clone(model.base);
|
||||
const before = rectMap(st);
|
||||
st.units.forEach(u => {
|
||||
const c = ch.units[u.id], fl = u.unit.floor.floor;
|
||||
fl.width = c.width; fl.depth = c.depth;
|
||||
UnitModel.clampSize(fl);
|
||||
});
|
||||
const after = rectMap(st);
|
||||
follow(st, before, after);
|
||||
|
||||
// Dakvorm en hoogte; binnenwanden gaan evenveel mee omhoog of omlaag als de goot.
|
||||
const lift = {};
|
||||
if (model.forms.house) {
|
||||
st.settings.roof.house.form = formOf(model, ch, null, st);
|
||||
st.units.forEach(u => { lift[u.id] = ch.house.low - model.forms.house.low; });
|
||||
} else {
|
||||
// Alleen opnieuw opbouwen wat afwijkt van het model; de rest blijft precies zoals het model het heeft.
|
||||
st.units.forEach(u => {
|
||||
const c = ch.units[u.id], c0 = model.initial.units[u.id], fl = u.unit.floor.floor;
|
||||
lift[u.id] = c.low - model.forms[u.id].low;
|
||||
if (['type', 'low', 'high', 'cut', 'dir', 'ridge', 'width', 'depth'].every(k => c[k] === c0[k])) return;
|
||||
UnitModel.applyRoofForm(u.unit, formFor(model.forms[u.id], c, fl.width, fl.depth));
|
||||
});
|
||||
}
|
||||
st.interiorWalls.forEach(iw => {
|
||||
if (iw.seam) return;
|
||||
const id = ownerOf(after, (iw.x1 + iw.x2) / 2, (iw.z1 + iw.z2) / 2), dh = id ? lift[id] : 0;
|
||||
if (!dh) return;
|
||||
const w = iw.wall.wall;
|
||||
['heightAll', 'heightLeft', 'heightRight'].forEach(k => { if (w[k] > 0) w[k] += dh; });
|
||||
});
|
||||
|
||||
if (ch.covering !== model.initial.covering) st.units.forEach(u => { u.roof.covering = ch.covering; });
|
||||
const fc = ch.facade, F = st.settings.facade;
|
||||
Object.assign(F, { type: fc.type, rabatColor: fc.rabat, stucColor: fc.stuc, felsColor: fc.fels, stone: fc.stone });
|
||||
st.settings.cladding = fc.cladding;
|
||||
Object.assign(st.settings.trasraam, { enabled: ch.trasraam.on }, ch.trasraam.on ? { finish: ch.trasraam.finish, height: ch.trasraam.height } : {});
|
||||
if (model.hasAccent && JSON.stringify(ch.accent) !== JSON.stringify(model.initial.accent)) {
|
||||
const a = ch.accent;
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
|
||||
// Wat er aan de indeling schort: per opening (verschoven, ingeklemd, te hoog voor de wand of in geen enkel
|
||||
// wandstuk meer), binnenwand buiten de units, inrichting door een gevel, verdwenen ruimtes en overlappende units.
|
||||
// check vergelijkt met het model zelf, zodat wat daar al zo is geen keuze blokkeert.
|
||||
function issuesOf(st, parts) {
|
||||
const out = new Set();
|
||||
parts.overlaps.forEach(p => out.add('units ' + p.join('+')));
|
||||
parts.rects.forEach(r => { if (r.error) out.add('units ' + r.id); });
|
||||
const ops = (key, config, placed) => config.openings.forEach(o => {
|
||||
if (o.type === 'schoorsteen') return;
|
||||
const W = config.wallWidth;
|
||||
const want = o.anchor === 'right' ? W - o.offset - o.w : o.anchor === 'middle' ? (W - o.w) / 2 + o.offset : o.offset;
|
||||
if (o.overflowing || Math.abs(o.x - want) > 2) out.add(`opening ${key}.${o.id} plek`);
|
||||
if (o.h < o.rawH - 2 || o.hR < o.rawHR - 2 || o.y < o.rawY - 2) out.add(`opening ${key}.${o.id} hoogte`);
|
||||
if (placed && !placed.has(o.id)) out.add(`opening ${key}.${o.id} kwijt`);
|
||||
});
|
||||
// Gevelopeningen staan t.o.v. de hele wand en worden daarna over de wandstukken verdeeld (UnitModel.wallPieces).
|
||||
st.units.forEach(su => {
|
||||
const pu = parts.units.find(u => u.id === su.id);
|
||||
Object.entries(su.unit.walls).forEach(([side, ws]) => {
|
||||
const w = pu && pu.walls.find(x => x.side === side);
|
||||
ops(`${su.id}-${side}`, WallModel.configFromState(ws), new Set(w ? w.pieces.flatMap(p => p.config.openings.map(o => o.id)) : []));
|
||||
});
|
||||
});
|
||||
parts.interior.forEach(p => ops(`bw${p.id}${p.seamUnit || ''}`, p.config));
|
||||
const inside = (r, x, z, m) => r && x >= r.x0 + m && x <= r.x1 - m && z >= r.z0 + m && z <= r.z1 - m;
|
||||
st.interiorWalls.filter(iw => !iw.seam).forEach(iw => {
|
||||
if (![[iw.x1, iw.z1], [iw.x2, iw.z2]].every(([x, z]) => parts.rects.some(r => inside(r, x, z, -1)))) out.add(`indeling bw${iw.id}`);
|
||||
});
|
||||
parts.fixtures.forEach(it => {
|
||||
const r = parts.rects.find(q => q.id === it.unitId);
|
||||
if (it.corners.some(c => !inside(r, c.x, c.z, 100))) out.add(`indeling fx${it.id}`);
|
||||
});
|
||||
return { keys: out, rooms: parts.rooms.rooms.length };
|
||||
}
|
||||
|
||||
// null = in orde, anders een melding voor de klant.
|
||||
function check(model, st, parts) {
|
||||
const now = issuesOf(st, parts), ref = model.reference;
|
||||
const added = [...now.keys].filter(k => !ref.keys.has(k));
|
||||
if (now.rooms < ref.rooms) added.push('indeling ruimtes');
|
||||
if (!added.length) return null;
|
||||
if (added.some(k => k.startsWith('units'))) return 'De units komen dan over elkaar heen.';
|
||||
if (added.some(k => k.startsWith('opening'))) return 'De ramen en deuren van dit model passen dan niet meer.';
|
||||
return 'De indeling van dit model past dan niet meer.';
|
||||
}
|
||||
|
||||
// Grootste of kleinste maat tussen een geldige (ok) en een gevraagde waarde (want) die nog past; rekent zonder 3D.
|
||||
function fit(model, ch, setter, ok, want) {
|
||||
let good = ok, bad = want;
|
||||
while (Math.abs(bad - good) > STEP) {
|
||||
const mid = Math.round((good + bad) / 2 / STEP) * STEP;
|
||||
if (mid === good || mid === bad) break;
|
||||
const c = clone(ch);
|
||||
setter(c, mid);
|
||||
const st = build(model, c);
|
||||
if (check(model, st, HouseModel.computeParts(st, { skip3D: true }))) bad = mid; else good = mid;
|
||||
}
|
||||
return good;
|
||||
}
|
||||
|
||||
// Kerncijfers voor de klant: geen uren, zaagstukken of inkoop.
|
||||
function summary(st, parts) {
|
||||
const totals = HouseModel.computeTotals(parts);
|
||||
const cost = CostModel.compute(parts, totals);
|
||||
const d = HouseModel.computeDimensions(parts, st.settings.groundLevel);
|
||||
return {
|
||||
price: cost.summary.inclBtw, kg: totals.totalKg, units: st.units.length,
|
||||
lengthX: d.lengthX, lengthZ: d.lengthZ, height: d.height, brutoM2: d.brutoM2, leefbaarM2: d.leefbaarM2, inhoudM3: d.inhoudM3
|
||||
};
|
||||
}
|
||||
|
||||
// De keuzes in woorden (voorblad van de PDF): [onderwerp, tekst].
|
||||
// Lessenaar: hoge kant; zadel/wolf: richting van de nok (UnitModel.RIDGES).
|
||||
const SIDES = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' };
|
||||
const SIDE_NAMES = { voor: 'noord', achter: 'zuid', links: 'west', rechts: 'oost' };
|
||||
const ROOF_NAMES = { plat: 'plat dak', lessenaar: 'lessenaardak', zadel: 'zadeldak', wolf: 'wolfsdak' };
|
||||
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
||||
function colorName(type, key) {
|
||||
if (type === 'steen') return `steen ${key}`;
|
||||
const list = type === 'rabat' ? WallModel.RABAT_COLORS : type === 'stuc' ? WallModel.STUC_COLORS : WallModel.FELS.COLORS;
|
||||
return list[key] ? list[key].label.toLowerCase() : '';
|
||||
}
|
||||
function describe(model, ch, st) {
|
||||
const fc = ch.facade, ids = Object.keys(ch.units), multi = ids.length > 1;
|
||||
const facade = (type, color) => [FACADES[type].toLowerCase(), type === 'rabat' ? fc.cladding : null, colorName(type, color)].filter(Boolean).join(', ');
|
||||
const perUnit = (fn) => {
|
||||
const vals = ids.map(id => fn(ch.units[id], id));
|
||||
return !multi || vals.every(v => v === vals[0]) ? vals[0] : ids.map((id, i) => `unit ${id}: ${vals[i]}`).join(', ');
|
||||
};
|
||||
const sloped = ch.house ? ch.house.type !== 'plat' : ids.some(id => ch.units[id].type !== 'plat');
|
||||
const nok = (f) => f.type === 'plat' ? '' : `, ${f.type === 'lessenaar' ? 'hoge kant' : 'nok'} ${nl(f.high / 1000)} m`;
|
||||
const way = (f) => f.type === 'lessenaar' ? ` (hoog aan de ${SIDE_NAMES[f.dir]}kant)` : UnitModel.hasRidge(f.type) ? ` (nok ${UnitModel.RIDGES[f.ridge].toLowerCase()})` : '';
|
||||
const rows = [
|
||||
['Dakvorm', cap(ch.house ? `${ROOF_NAMES[ch.house.type]}${way(formOf(model, ch, null, st))} over de hele woning`
|
||||
: perUnit((u, id) => ROOF_NAMES[u.type] + way(formOf(model, ch, id))))],
|
||||
['Hoogte', cap(ch.house ? `goot ${nl(ch.house.low / 1000)} m${nok(formOf(model, ch, null, st))}` : perUnit((u, id) => `goot ${nl(u.low / 1000)} m${nok(formOf(model, ch, id))}`))],
|
||||
['Dakbedekking', (RoofModel.COVERINGS[sloped ? ch.covering : 'dakleer'] || RoofModel.COVERINGS.dakleer).label],
|
||||
['Gevel', cap(facade(fc.type, ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type]))]
|
||||
];
|
||||
const tr = ch.trasraam;
|
||||
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']);
|
||||
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']);
|
||||
return rows;
|
||||
}
|
||||
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, trimUses, create, build, check, fit, summary, describe, colorName };
|
||||
})();
|
||||
@@ -0,0 +1,232 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs tenzij
|
||||
// d.price === false, contact en QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
|
||||
window.LightPdf = (() => {
|
||||
const COMPANY = {
|
||||
naam: 'Van Emous Mantelzorgwoningen',
|
||||
adres: ['Keizerswoert 19', '3881 LE Putten'],
|
||||
telefoon: '0341 - 275 147',
|
||||
email: 'info@vanemousmantelzorgwoningen.nl',
|
||||
web: 'https://www.vanemousmantelzorgwoningen.nl',
|
||||
open: 'Ma t/m vr 08:00 - 17:00 uur, za en zo op afspraak'
|
||||
};
|
||||
// Modellen met een eigen pagina op de site; andere verwijzen naar het overzicht.
|
||||
const SITE_MODELS = ['amstel', 'ijssel', 'maas', 'reest', 'rijn', 'waal', 'wetering', 'eem'];
|
||||
const modelUrl = (key) => SITE_MODELS.includes(key) ? `${COMPANY.web}/modellen/model-${key}/` : `${COMPANY.web}/modellen/`;
|
||||
|
||||
// Werkwijze, naar de site (werkwijze/stappenplan).
|
||||
const STEPS = [
|
||||
['Mag u plaatsen?', 'Wij toetsen vrijblijvend uw perceel aan de regels van uw gemeente en geven een eerste indicatie van de mogelijkheden. Voor zekerheid helpen wij u graag bij het overleg met de gemeente.'],
|
||||
['Uw ontwerp uitwerken', 'U start vanuit een van onze standaardmodellen of vanuit uw eigen ontwerp. Onze specialisten werken uw wensen uit tot een productietekening. Omdat wij in onze eigen fabriek bouwen, is vrijwel elk ontwerp technisch haalbaar.'],
|
||||
['Wij gaan bouwen', 'Zodra het ontwerp definitief is en de omgevingsvergunning er is, start de productie in onze fabriek. Tijdens openingstijden bent u welkom om de voortgang te komen bekijken.'],
|
||||
['Transport en plaatsing', 'De afgebouwde modules worden naar uw perceel gebracht, op de fundering geplaatst en aangesloten op de nutsvoorzieningen. Na de laatste afwerking ontvangt u de sleutel.'],
|
||||
['Oplevering en service', 'Samen lopen we de woning na, zodat alles klopt met het ontwerp en uw wensen. Ook na de oplevering blijven wij beschikbaar voor service en onderhoud.']
|
||||
];
|
||||
|
||||
const MAATWERK = ['Dit is pas het begin: alles kan op maat',
|
||||
'Dit ontwerp is gemaakt met een vereenvoudigde versie van onze configurator. Met onze eigen ontwerpsoftware werken wij uw woning ' +
|
||||
'samen met u tot in detail uit: een keuken op maat, een andere indeling met extra binnenwanden, of ramen en deuren op een andere ' +
|
||||
'plek en in een ander formaat. U bent van harte welkom voor een vrijblijvend adviesgesprek, waarin we samen uw wensen en ' +
|
||||
'mogelijkheden doornemen. Ook staat onze fabriek in Putten open voor bezoek: kom kijken hoe onze woningen gebouwd worden en ervaar ' +
|
||||
'de kwaliteit met eigen ogen. Maak gerust een afspraak; wij denken graag met u mee.'];
|
||||
|
||||
const ORANGE = [185, 89, 19], INK = [33, 33, 33], MUTED = [91, 102, 115], SOFT = [249, 246, 240], LINE = [226, 231, 238];
|
||||
const PW = 420, PH = 297, M = 20;
|
||||
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
||||
|
||||
// QR-code als vlakken (scherp bij elke vergroting), met witte rand.
|
||||
function drawQr(doc, text, x, y, size) {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(text);
|
||||
qr.make();
|
||||
const n = qr.getModuleCount(), quiet = 2, s = size / (n + 2 * quiet);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(x, y, size, size, 'F');
|
||||
doc.setFillColor(0, 0, 0);
|
||||
for (let r = 0; r < n; r++) {
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (!qr.isDark(r, c)) continue;
|
||||
let e = c;
|
||||
while (e + 1 < n && qr.isDark(r, e + 1)) e++;
|
||||
doc.rect(x + (quiet + c) * s, y + (quiet + r) * s, (e - c + 1) * s + 0.02, s + 0.02, 'F');
|
||||
c = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logo(doc, x, y, h) {
|
||||
const L = window.TECTUM_LOGO;
|
||||
if (L) doc.addImage(L.png, 'PNG', x, y, h * L.aspect, h, 'tectum-logo', 'FAST');
|
||||
else { doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(...INK); doc.text(COMPANY.naam, x, y + h * 0.7); }
|
||||
}
|
||||
|
||||
// Contactblok met QR-code; y = bovenkant.
|
||||
function contactBand(doc, y, url, label) {
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(M, y, PW - M, y);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.text(COMPANY.naam, M, y + 9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
const col = (x, lines) => lines.forEach((t, i) => doc.text(t, x, y + 16 + i * 5.2));
|
||||
col(M, COMPANY.adres.concat([COMPANY.open]));
|
||||
col(M + 95, [`Telefoon ${COMPANY.telefoon}`, COMPANY.email, COMPANY.web.replace(/^https:\/\//, '')]);
|
||||
const q = 34;
|
||||
drawQr(doc, url, PW - M - q, y + 4, q);
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(...INK);
|
||||
doc.text(doc.splitTextToSize(label, 70), PW - M - q - 4, y + 12, { align: 'right' });
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFontSize(8);
|
||||
doc.text(url.replace(/^https:\/\//, ''), PW - M - q - 4, y + 30, { align: 'right' });
|
||||
}
|
||||
|
||||
function keyValues(doc, x, y, w, rows, title) {
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(...INK);
|
||||
doc.text(title, x, y);
|
||||
let yy = y + 8;
|
||||
rows.forEach(([k, v]) => {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(k, x, yy);
|
||||
doc.setTextColor(...INK);
|
||||
const lines = doc.splitTextToSize(String(v), w - 42);
|
||||
doc.text(lines, x + 42, yy);
|
||||
yy += 5.4 * lines.length + 0.8;
|
||||
});
|
||||
return yy;
|
||||
}
|
||||
|
||||
function cover(doc, d) {
|
||||
doc.setFillColor(...SOFT);
|
||||
doc.rect(0, 0, PW, PH, 'F');
|
||||
logo(doc, M, 18, 14);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(34);
|
||||
doc.text('Uw woningontwerp', M, 62);
|
||||
doc.setTextColor(...ORANGE);
|
||||
doc.setFontSize(20);
|
||||
doc.text(d.label, M, 74);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(`Samengesteld op ${new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })}`, M, 82);
|
||||
|
||||
const i = d.info;
|
||||
let y = keyValues(doc, M, 98, 150, [
|
||||
['Buitenmaten', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`], ['Bouwhoogte', meters(i.height)],
|
||||
['Vloeroppervlak', `${nl(i.brutoM2, 1)} m² bruto, ${nl(i.leefbaarM2, 1)} m² leefbaar`], ['Inhoud', `${nl(i.inhoudM3, 0)} m³`],
|
||||
['Modules', String(i.units)]
|
||||
], 'Kerncijfers');
|
||||
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
|
||||
|
||||
// Prijs: rustig, als de rest van de pagina; alleen het bedrag groter en vet.
|
||||
const py = Math.max(y + 4, 196), price = d.price !== false;
|
||||
if (price) {
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(M, py, M + 150, py);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.text('Indicatieve prijs', M, py + 9);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(20);
|
||||
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text('incl. btw, onder voorbehoud', M, py + 25);
|
||||
}
|
||||
|
||||
// Beeld van de woning
|
||||
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
|
||||
if (d.image) {
|
||||
const ar = d.image.width / d.image.height, w = Math.min(bw, bh * ar), h = w / ar;
|
||||
doc.addImage(d.image.url, 'JPEG', bx + (bw - w) / 2, by + (bh - h) / 2, w, h);
|
||||
}
|
||||
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(`Dit ontwerp${price ? ' en de prijs zijn' : ' is'} indicatief; hieraan kunnen geen rechten worden ontleend.`, M, PH - 6);
|
||||
}
|
||||
|
||||
function process(doc, d) {
|
||||
doc.setFillColor(...SOFT);
|
||||
doc.rect(0, 0, PW, PH, 'F');
|
||||
logo(doc, M, 18, 14);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(28);
|
||||
doc.text('Hoe verloopt het traject?', M, 62);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(doc.splitTextToSize('Van het eerste idee tot de sleuteloverdracht: wij begeleiden u in duidelijke stappen naar een mantelzorgwoning die bij u past. Dit ontwerp is de start van stap 2.', 260), M, 74);
|
||||
|
||||
const gap = 8, w = (PW - 2 * M - gap * (STEPS.length - 1)) / STEPS.length, y = 92, h = 98;
|
||||
STEPS.forEach(([title, text], k) => {
|
||||
const x = M + k * (w + gap);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.roundedRect(x, y, w, h, 3, 3, 'FD');
|
||||
doc.setFillColor(...ORANGE);
|
||||
doc.circle(x + 12, y + 14, 6.5, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(13);
|
||||
doc.text(String(k + 1), x + 12, y + 14, { align: 'center', baseline: 'middle' });
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFontSize(13);
|
||||
doc.text(doc.splitTextToSize(title, w - 12), x + 6, y + 32);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(doc.splitTextToSize(text, w - 12), x + 6, y + 44, { lineHeightFactor: 1.4 });
|
||||
});
|
||||
// Maatwerk: dit ontwerp is een begin, de eigen software kan veel verder.
|
||||
const by = 195, bh = 40;
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.roundedRect(M, by, PW - 2 * M, bh, 3, 3, 'FD');
|
||||
doc.setFillColor(...ORANGE);
|
||||
doc.rect(M, by + 3, 2.5, bh - 6, 'F');
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(13);
|
||||
doc.setTextColor(...INK);
|
||||
doc.text(MAATWERK[0], M + 9, by + 10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10.5);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(doc.splitTextToSize(MAATWERK[1], PW - 2 * M - 18), M + 9, by + 17, { lineHeightFactor: 1.35 });
|
||||
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
|
||||
}
|
||||
|
||||
// d: { st, label, info, choices: [[label, tekst]], url, image: { url, width, height } | null }
|
||||
function generate(d) {
|
||||
const doc = PdfSheet.createDoc();
|
||||
cover(doc, d);
|
||||
HousePdf.generate(d.st, { customer: true, doc, pageOffset: 1, pageTotal: 4 });
|
||||
doc.addPage('a3', 'landscape');
|
||||
process(doc, d);
|
||||
return doc;
|
||||
}
|
||||
|
||||
return { COMPANY, modelUrl, generate };
|
||||
})();
|
||||
@@ -0,0 +1,809 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met
|
||||
// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen).
|
||||
// Inbedden: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
|
||||
// TectumLight.mount(el, { model, frames, url, modelViewer, save, price }); url = adres voor de QR-code in de PDF (standaard
|
||||
// de modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script), price: false (of
|
||||
// data-price="false") = geen prijs in beeld en in de PDF; design().info.price blijft er wel in.
|
||||
// save = { label, icon, onSave(ontwerp) } vervangt de PDF-knop (bijv. ontwerp aanvragen op de site); onSave krijgt
|
||||
// { design, label, key, info, choices, url }: design = house-design-JSON zoals de configurator die opslaat, choices = de
|
||||
// keuzes in woorden (zoals op het voorblad van de PDF). Zonder save blijft het de PDF.
|
||||
// Knop rechtsboven in de weergave: op een telefoon/tablet het ontwerp als GLB in model-viewer, met AR (WebXR, Scene
|
||||
// Viewer, Quick Look); op de desktop schermvullend.
|
||||
window.TectumLight = (() => {
|
||||
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 meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
// Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator.
|
||||
const SECTION = { start: 1500, min: 300, max: 3000 };
|
||||
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
|
||||
// van het scherm: in de HUGO-site staat hij in een Bootstrap col-12).
|
||||
const TEMPLATE = `<div class="tl-wrap">
|
||||
<div class="tl-stage">
|
||||
<div class="tl-3d"></div>
|
||||
<canvas class="tl-plan" aria-label="Plattegrond, noord boven"></canvas>
|
||||
<div class="tl-viewbar" role="group" aria-label="Weergave">
|
||||
<button type="button" data-view="3d" class="on"><i class="bi bi-badge-3d"></i>3D</button>
|
||||
<button type="button" data-view="cut" title="Wanden afgesneden op een hoogte boven de vloer"><i class="bi bi-scissors"></i>Doorsnede</button>
|
||||
<button type="button" data-view="plan"><i class="bi bi-grid-1x2"></i>Plattegrond</button>
|
||||
</div>
|
||||
<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">
|
||||
<output>${meters(SECTION.start)}</output></label>
|
||||
<button type="button" class="tl-xbtn" hidden></button>
|
||||
<div class="tl-hint"></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>
|
||||
<aside class="tl-panel">
|
||||
<header class="tl-head"><h2 class="tl-title"></h2><div class="tl-sub"></div></header>
|
||||
<div class="tl-sections"></div>
|
||||
<div class="tl-facts"></div>
|
||||
<div class="tl-buy"></div>
|
||||
</aside></div>`;
|
||||
|
||||
function mount(root, opts) {
|
||||
const o = opts || {};
|
||||
const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames });
|
||||
const showPrice = o.price !== false && root.dataset.price !== 'false';
|
||||
let ch = clone(model.initial);
|
||||
let unitSel = model.base.units[0].id;
|
||||
let cur = null; // { st, parts, info }
|
||||
root.classList.add('tectum-light');
|
||||
root.innerHTML = TEMPLATE;
|
||||
const $ = (sel) => root.querySelector(sel);
|
||||
$('.tl-title').textContent = model.label;
|
||||
|
||||
// 3D: afgewerkte woning (zoals de render-export), dag-omgeving.
|
||||
const stage = $('.tl-3d');
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xdfe6ec);
|
||||
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.localClippingEnabled = true;
|
||||
stage.appendChild(renderer.domElement);
|
||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
sun.position.set(5, 8, 5);
|
||||
scene.add(ambient, sun);
|
||||
const sky = Skybox.createEnvironment(scene);
|
||||
const mats = {
|
||||
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
|
||||
lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(),
|
||||
chimneyMaterials: ChimneyModel.createMaterials(), elektraMaterials: ElektraModel.createMaterials()
|
||||
};
|
||||
const S0 = model.base.settings;
|
||||
if (S0.interiorPaint) mats.wallMaterials.spaanplaatMaterial.color.set(S0.interiorColor);
|
||||
mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef');
|
||||
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 rootsOf = HouseModel.rootGroups;
|
||||
|
||||
// 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;
|
||||
const poses = {};
|
||||
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||||
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
function clip(g, planes) {
|
||||
if (g) g.traverse(x => (Array.isArray(x.material) ? x.material : x.material ? [x.material] : []).forEach(m => {
|
||||
if ((m.clippingPlanes || []).length === planes.length) return;
|
||||
m.clippingPlanes = planes;
|
||||
m.needsUpdate = true;
|
||||
}));
|
||||
}
|
||||
function applyView() {
|
||||
if (!cur) return;
|
||||
const p = cur.parts, cut = view === 'cut';
|
||||
p.units.forEach(u => { if (u.roof.group) u.roof.group.visible = !cut; });
|
||||
[p.roofEdgeGroup, p.postsGroup].forEach(g => { if (g) g.visible = !cut; });
|
||||
if (p.roomsGroup && p.roomsGroup.userData.ceilings) p.roomsGroup.userData.ceilings.visible = !cut;
|
||||
const planes = cut ? [cutPlane] : [];
|
||||
HouseModel.allWallPieces(p).forEach(x => clip(x.group, planes));
|
||||
// Lampen snijden mee: binnenspots onder de snijhoogte blijven zichtbaar, niets zweeft.
|
||||
[p.elektraGroup, p.chimneysGroup, p.roomsGroup, p.fixturesGroup, p.lightingGroup].forEach(g => clip(g, planes));
|
||||
placeCut();
|
||||
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.
|
||||
function placeCut() {
|
||||
const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop));
|
||||
cutPlane.constant = (floorTop + sectionMM) * 0.001;
|
||||
if (caps) { group.remove(caps); disposeGeo(caps); caps = null; }
|
||||
if (view === 'cut') { caps = HouseModel.sectionCaps(p, floorTop + sectionMM, capMaterial, ViewLayers.all()); group.add(caps); }
|
||||
}
|
||||
const cutInput = $('.tl-cutbar input');
|
||||
// Niet hoger dan de goot (plat dak: de dakhoogte); bij units met een andere goot de laagste, zodat de snede altijd
|
||||
// door alle gevels gaat. Loopt mee met de gekozen hoogte.
|
||||
function fitCutRange() {
|
||||
const lows = ch.house ? [ch.house.low] : Object.values(ch.units).map(u => u.low);
|
||||
const max = Math.max(SECTION.min, Math.floor(Math.min(...lows) / 50) * 50);
|
||||
cutInput.max = max;
|
||||
if (sectionMM > max) sectionMM = max;
|
||||
cutInput.value = sectionMM;
|
||||
$('.tl-cutbar output').textContent = meters(sectionMM);
|
||||
}
|
||||
cutInput.addEventListener('input', () => {
|
||||
sectionMM = Number(cutInput.value);
|
||||
$('.tl-cutbar output').textContent = meters(sectionMM);
|
||||
if (cur) placeCut();
|
||||
});
|
||||
// Elke weergave onthoudt zijn eigen camerastand; de doorsnede begint schuin van boven.
|
||||
function setView(v) {
|
||||
if (v === view) return;
|
||||
if (view !== 'plan') poses[view] = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||
view = v;
|
||||
if (v !== 'plan') {
|
||||
const pose = poses[v];
|
||||
if (pose) { camera.position.copy(pose.pos); controls.target.copy(pose.target); }
|
||||
else {
|
||||
controls.target.set(center.x, 0.6, center.z);
|
||||
camera.position.set(center.x - center.size * 0.45, center.size * 1.05 + 2, center.z + center.size * 0.75);
|
||||
}
|
||||
controls.update();
|
||||
}
|
||||
root.classList.toggle('view-plan', v === 'plan');
|
||||
root.classList.toggle('view-cut', v === 'cut');
|
||||
root.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === v));
|
||||
$('.tl-hint').textContent = HINTS[v];
|
||||
applyView();
|
||||
}
|
||||
$('.tl-hint').textContent = HINTS['3d'];
|
||||
root.querySelector('.tl-viewbar').addEventListener('click', (e) => { const b = e.target.closest('[data-view]'); if (b) setView(b.dataset.view); });
|
||||
|
||||
function show(st, parts) {
|
||||
if (group) { scene.remove(group); disposeGeo(group); caps = null; }
|
||||
fitCutRange();
|
||||
HouseModel.placeGroups(parts);
|
||||
group = new THREE.Group();
|
||||
rootsOf(parts).forEach(g => group.add(g));
|
||||
scene.add(group);
|
||||
cur = { st, parts, info: LightModel.summary(st, parts) };
|
||||
const rs = parts.rects, toM = 0.001;
|
||||
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));
|
||||
center = { x: (bx0 + bx1) / 2 * toM, z: (bz0 + bz1) / 2 * toM, size: Math.max(bx1 - bx0, bz1 - bz0) * toM };
|
||||
const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - st.settings.groundLevel - 2) * toM;
|
||||
sky.fitTo(group, groundY);
|
||||
Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group });
|
||||
if (!fitted) {
|
||||
controls.target.set(center.x, 1.2, center.z);
|
||||
camera.position.set(center.x - center.size * 0.9, center.size * 0.7, center.z - center.size * 1.3);
|
||||
controls.update();
|
||||
fitted = true;
|
||||
}
|
||||
applyView();
|
||||
HousePdf.materialThumbUrls(st).forEach(PdfSheet.preloadImage);
|
||||
renderPanel();
|
||||
renderSummary();
|
||||
}
|
||||
|
||||
// Plattegrond (noord boven): ruimtes met naam en netto m², wanden met kozijnen en draairichting, inrichting,
|
||||
// dakrand, palen en de buitenmaten. Zelfde gegevens als de PDF-plattegrond.
|
||||
function drawPlan() {
|
||||
const cv = $('.tl-plan'), W = cv.clientWidth, H = cv.clientHeight;
|
||||
if (!cur || !W || !H) return;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
const p = cur.parts, rs = p.rects;
|
||||
const ext = rs.concat(p.units.map(u => u.roof.rect));
|
||||
const bx0 = Math.min(...ext.map(r => r.x0)), bx1 = Math.max(...ext.map(r => r.x1));
|
||||
const bz0 = Math.min(...ext.map(r => r.z0)), bz1 = Math.max(...ext.map(r => r.z1));
|
||||
const top = 64, side = 56;
|
||||
const scale = Math.min((W - 2 * side) / (bx1 - bx0), (H - top - side) / (bz1 - bz0));
|
||||
const ox = W / 2 - (bx0 + bx1) / 2 * scale, oy = top + (H - top - side) / 2 - (bz0 + bz1) / 2 * scale;
|
||||
const P = (x, z) => [ox + x * scale, oy + z * scale];
|
||||
const path = (pts, closed) => { ctx.beginPath(); pts.forEach((q, i) => ctx[i ? 'lineTo' : 'moveTo'](...P(q.x, q.z))); if (closed) ctx.closePath(); };
|
||||
const corners = (pl, lx0, lx1) => { const t = pl.thickness / 2; return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([a, b]) => UnitModel.localToWorld(pl, a, b)); };
|
||||
const rgba = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`;
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Dakrand
|
||||
ctx.setLineDash([5, 4]); ctx.strokeStyle = '#9aa3ad'; ctx.lineWidth = 1;
|
||||
if (p.roofEdge) p.roofEdge.segments.forEach(sg => { path([sg.outerA, sg.outerB]); ctx.stroke(); });
|
||||
else p.units.forEach(u => { const [x, y] = P(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(x, y, u.roof.rect.W * scale, u.roof.rect.D * scale); });
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Ruimtes
|
||||
const R = p.rooms;
|
||||
R.rooms.forEach(r => { ctx.fillStyle = rgba(r.color, 0.22); r.rects.forEach(c => { const [x, y] = P(c.x0, c.z0); ctx.fillRect(x, y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); });
|
||||
|
||||
// Inrichting
|
||||
(p.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
|
||||
ctx.strokeStyle = '#8b939b';
|
||||
ctx.lineWidth = sh.kind === 'thin' ? 0.6 : 1;
|
||||
ctx.setLineDash(sh.kind === 'dash' ? [3, 3] : []);
|
||||
if (sh.circle) { ctx.beginPath(); ctx.arc(...P(sh.circle.x, sh.circle.z), Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke(); return; }
|
||||
if (sh.text) return;
|
||||
path(sh.pts, sh.closed);
|
||||
if (sh.fill) { ctx.fillStyle = 'rgba(139, 147, 155, 0.18)'; ctx.fill(); }
|
||||
ctx.stroke();
|
||||
}));
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Wanden met openingen en kozijnsymbolen
|
||||
const piece = (x, fill) => {
|
||||
const pl = x.place, Wp = pl.width;
|
||||
path(corners(pl, -Wp / 2, Wp / 2), true);
|
||||
ctx.fillStyle = fill; ctx.fill();
|
||||
x.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||
path(corners(pl, -Wp / 2 + op.x, -Wp / 2 + op.x + op.w), true);
|
||||
ctx.fillStyle = '#ffffff'; ctx.fill();
|
||||
ctx.strokeStyle = '#33383d'; ctx.lineWidth = 0.8; ctx.stroke();
|
||||
if (!op.fill) return;
|
||||
KozijnModel.planSymbol(op, op.fill, { zIn: -pl.thickness / 2, zOut: pl.thickness / 2 }).forEach(sh => {
|
||||
ctx.lineWidth = sh.kind === 'leaf' ? 1.3 : 0.8;
|
||||
ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []);
|
||||
path(sh.pts.map(q => UnitModel.localToWorld(pl, -Wp / 2 + op.x + q.x, q.z)), !!sh.closed);
|
||||
ctx.stroke();
|
||||
});
|
||||
ctx.setLineDash([]);
|
||||
});
|
||||
};
|
||||
p.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(x => piece(x, '#3d4247'))));
|
||||
p.interior.forEach(x => piece(x, '#7a828a'));
|
||||
ctx.fillStyle = '#a07c54';
|
||||
p.posts.posts.forEach(pp => { const [x, y] = P(pp.x - pp.size / 2, pp.z - pp.size / 2); ctx.fillRect(x, y, pp.size * scale, pp.size * scale); });
|
||||
p.posts.walls.forEach(w => { const [x, y] = P(w.x0, w.z0); ctx.fillStyle = '#3d4247'; ctx.fillRect(x, y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); });
|
||||
(p.chimneys || []).forEach(c => { path(c.footprint, true); ctx.fillStyle = '#8b8f93'; ctx.fill(); });
|
||||
// Binnenspots
|
||||
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#a07a1e'; ctx.lineWidth = 0.8;
|
||||
(p.lighting.indoor || []).forEach(sp => { ctx.beginPath(); ctx.arc(...P(sp.x, sp.z), Math.max(2.5, 90 * scale), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); });
|
||||
|
||||
// Ruimtenamen
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
const fs = Math.max(9, Math.min(13, scale * 260));
|
||||
R.rooms.forEach(r => {
|
||||
const [x, y] = P(r.label.x, r.label.z);
|
||||
ctx.fillStyle = '#212121'; ctx.font = `600 ${fs}px sans-serif`;
|
||||
ctx.fillText(r.name, x, y - fs * 0.6);
|
||||
ctx.fillStyle = '#5b6673'; ctx.font = `${fs * 0.9}px sans-serif`;
|
||||
ctx.fillText(`${nl(r.areaM2, 1)} m²`, x, y + fs * 0.6);
|
||||
});
|
||||
|
||||
// Buitenmaten (units) en noordpijl
|
||||
const ux0 = Math.min(...rs.map(r => r.x0)), ux1 = Math.max(...rs.map(r => r.x1));
|
||||
const uz0 = Math.min(...rs.map(r => r.z0)), uz1 = Math.max(...rs.map(r => r.z1));
|
||||
ctx.strokeStyle = '#1d5f99'; ctx.fillStyle = '#1d5f99'; ctx.lineWidth = 1; ctx.font = '12px sans-serif';
|
||||
const dim = (a, b, horiz, at) => {
|
||||
const [x0, y0] = horiz ? P(a, 0) : P(0, a), [x1, y1] = horiz ? P(b, 0) : P(0, b);
|
||||
const A = horiz ? [x0, at] : [at, y0], B = horiz ? [x1, at] : [at, y1];
|
||||
ctx.beginPath(); ctx.moveTo(...A); ctx.lineTo(...B);
|
||||
[A, B].forEach(([x, y]) => { ctx.moveTo(x - 4, y + 4); ctx.lineTo(x + 4, y - 4); ctx.moveTo(...(horiz ? [x, y - 6] : [x - 6, y])); ctx.lineTo(...(horiz ? [x, y + 6] : [x + 6, y])); });
|
||||
ctx.stroke();
|
||||
ctx.save();
|
||||
ctx.translate((A[0] + B[0]) / 2, (A[1] + B[1]) / 2);
|
||||
if (!horiz) ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText(nl(Math.round(b - a)), 0, -9);
|
||||
ctx.restore();
|
||||
};
|
||||
dim(ux0, ux1, true, P(0, bz0)[1] - 22);
|
||||
dim(uz0, uz1, false, P(bx0, 0)[0] - 22);
|
||||
const nx = W - 28, ny = top + 6;
|
||||
ctx.beginPath(); ctx.moveTo(nx, ny); ctx.lineTo(nx - 7, ny + 18); ctx.lineTo(nx + 7, ny + 18); ctx.closePath(); ctx.fill();
|
||||
ctx.fillText('N', nx, ny + 30);
|
||||
}
|
||||
|
||||
// Keuze doorrekenen; past de indeling niet, dan blijft de vorige keuze staan. Bij een maat (fit) eerst de
|
||||
// dichtstbijzijnde maat zoeken die nog wel past.
|
||||
let toastTimer = null;
|
||||
function toast(msg) {
|
||||
const t = $('.tl-toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('on');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.remove('on'), 5000);
|
||||
}
|
||||
let busy = false;
|
||||
function tryApply(next, fit) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
root.classList.add('is-busy');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
let st = LightModel.build(model, next), parts = compute(st);
|
||||
let msg = LightModel.check(model, st, parts), note = null;
|
||||
if (msg && fit) {
|
||||
rootsOf(parts).forEach(disposeGeo);
|
||||
const v = LightModel.fit(model, next, fit.set, fit.ok, fit.want);
|
||||
if (v !== fit.ok) {
|
||||
fit.set(next, v);
|
||||
st = LightModel.build(model, next);
|
||||
parts = compute(st);
|
||||
note = `${msg} Dichtstbijzijnde maat die past: ${meters(v)}.`;
|
||||
msg = LightModel.check(model, st, parts);
|
||||
} else parts = null;
|
||||
}
|
||||
if (msg) {
|
||||
if (parts) rootsOf(parts).forEach(disposeGeo);
|
||||
toast(msg);
|
||||
renderPanel();
|
||||
} else {
|
||||
ch = next;
|
||||
show(st, parts);
|
||||
if (note) toast(note);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast('Dit kon niet worden doorgerekend.');
|
||||
renderPanel();
|
||||
}
|
||||
busy = false;
|
||||
root.classList.remove('is-busy');
|
||||
}, 30);
|
||||
}
|
||||
|
||||
// Paneel: alleen keuzes, geen vrije invoer.
|
||||
const field = (label, html, extra, cls) => `<div class="tl-field${cls ? ' ' + cls : ''}"><div class="tl-label"><span>${label}</span>${extra ? `<span class="tl-val">${extra}</span>` : ''}</div>${html}</div>`;
|
||||
// disabled(k) geeft false of de reden (tooltip).
|
||||
const seg = (key, options, value, disabled) => `<div class="tl-seg" role="radiogroup">${Object.entries(options).map(([k, label]) => {
|
||||
const why = disabled && disabled(k);
|
||||
return `<button type="button" role="radio" aria-checked="${k === value}" class="${k === value ? 'on' : ''}" data-set="${key}" data-val="${esc(k)}"${why ? ` disabled title="${esc(why)}"` : ''}>${esc(label)}</button>`;
|
||||
}).join('')}</div>`;
|
||||
// Kleurstalen; de naam van de gekozen kleur komt in de labelregel (swatchField).
|
||||
const swatches = (key, items, value, disabled) => {
|
||||
const sel = items.find(it => it.key === value);
|
||||
return `<div class="tl-swatches" role="radiogroup" data-name="${sel ? esc(sel.label) : ''}">${items.map(it => {
|
||||
const why = disabled && disabled(it.key);
|
||||
return `<button type="button" role="radio" aria-checked="${it.key === value}" class="tl-sw${it.key === value ? ' on' : ''}" data-set="${key}" data-val="${esc(it.key)}" title="${esc(why ? `${it.label}: ${why}` : it.label)}" aria-label="${esc(it.label)}" style="--sw:${it.hex}"${why ? ' disabled' : ''}>${it.img ? `<img src="${esc(it.img)}" alt="" loading="lazy">` : ''}</button>`;
|
||||
}).join('')}</div>`;
|
||||
};
|
||||
const swatchField = (label, html) => field(label, html, (html.match(/data-name="([^"]*)"/) || [])[1]);
|
||||
// Het bereik omvat altijd de maat van het model zelf; extra staat achter de waarde (bijv. de helling).
|
||||
const slider = (key, label, value, range, hint, extra) => {
|
||||
const lo = Math.min(range[0], value), hi = Math.max(range[1], value);
|
||||
return `<label class="tl-slider" title="${esc(hint || '')}">
|
||||
<span class="tl-sl-head"><span>${label}</span><output>${meters(value)}${extra || ''}</output></span>
|
||||
<input type="range" data-num="${key}" min="${lo}" max="${hi}" step="${LightModel.STEP}" value="${value}" aria-label="${esc(label.replace(/<[^>]+>/g, ''))}">
|
||||
</label>`;
|
||||
};
|
||||
// Wolfseind: hoogte waarop de kopgevels stoppen (tussen goot en nok); daarboven de schuine wolfseinden.
|
||||
const wolfSlider = (key, f) => {
|
||||
const at = (r) => Math.round((f.low + r * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP;
|
||||
return slider(key, 'Wolfseind', at(f.cutRatio), [at(0.05), at(0.95)], 'Hoogte waarop de kopgevels stoppen; daarboven de schuine wolfseinden tot de nok');
|
||||
};
|
||||
const colorItems = (obj) => Object.entries(obj).map(([key, c]) => ({ key, label: c.label, hex: c.hex }));
|
||||
const facadeColors = (type, current) => {
|
||||
if (type === 'rabat') return colorItems(WallModel.RABAT_COLORS);
|
||||
if (type === 'stuc') return colorItems(WallModel.STUC_COLORS);
|
||||
if (type === 'fels') return colorItems(WallModel.FELS.COLORS);
|
||||
const codes = LightModel.STONES.includes(current) || !current ? LightModel.STONES : [current].concat(LightModel.STONES);
|
||||
return codes.map(code => ({ key: code, label: `Steenstrip ${code}`, hex: WallModel.STONE_COLORS[code] || '#a08070', img: WallModel.stoneThumbUrl(code, 64) }));
|
||||
};
|
||||
const facadeColor = (fc) => ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type];
|
||||
const roofTypes = UnitModel.ROOF_TYPES;
|
||||
|
||||
// Plattegrondje om een unit te kiezen (noord boven).
|
||||
function unitMap() {
|
||||
const rs = cur.parts.rects, pad = 300;
|
||||
const x0 = Math.min(...rs.map(r => r.x0)) - pad, x1 = Math.max(...rs.map(r => r.x1)) + pad;
|
||||
const z0 = Math.min(...rs.map(r => r.z0)) - pad, z1 = Math.max(...rs.map(r => r.z1)) + pad;
|
||||
const fs = Math.min(x1 - x0, z1 - z0) * 0.16;
|
||||
return `<svg class="tl-map" viewBox="${x0} ${z0} ${x1 - x0} ${z1 - z0}" role="group" aria-label="Units, noord boven">${rs.map(r =>
|
||||
`<g class="tl-map-u${r.id === unitSel ? ' on' : ''}" data-set="unit" data-val="${esc(r.id)}" role="button" tabindex="0" aria-label="Unit ${esc(r.id)}">
|
||||
<rect x="${r.x0 + 40}" y="${r.z0 + 40}" width="${r.W - 80}" height="${r.D - 80}" rx="${fs * 0.2}"/>
|
||||
<text x="${r.cx}" y="${r.cz}" font-size="${fs}" dy="0.35em" text-anchor="middle">${esc(r.id)}</text></g>`).join('')}</svg>`;
|
||||
}
|
||||
|
||||
// Accordeon: één sectie open, in de kop een samenvatting van de keuze.
|
||||
let openSec = 'maten';
|
||||
const roofName = (t) => ({ plat: 'Plat dak', lessenaar: 'Lessenaardak', zadel: 'Zadeldak', wolf: 'Wolfsdak' })[t];
|
||||
function renderPanel() {
|
||||
if (!cur) return;
|
||||
const multi = model.base.units.length > 1, houseRoof = !!ch.house;
|
||||
const u = ch.units[unitSel], fr = model.frame.unit(unitSel);
|
||||
const sloped = cur.parts.units.some(x => x.roof.shape.type !== 'plat');
|
||||
const short = UnitModel.LIMITS.short;
|
||||
// Een unit mag maar in één richting langer zijn dan de transportmaat.
|
||||
const range = (r, other) => r && [r[0], other > short ? Math.min(r[1], short) : r[1]];
|
||||
const wR = range(fr.width, u.depth), dR = range(fr.depth, u.width);
|
||||
|
||||
const sizeHtml = (multi ? `<div class="tl-mapbox">${unitMap()}<span class="tl-note">Klik op een unit</span></div>` : '') +
|
||||
(wR ? slider('width', 'Breedte <small>west–oost</small>', u.width, wR) : '') +
|
||||
(dR ? slider('depth', 'Diepte <small>noord–zuid</small>', u.depth, dR) : '');
|
||||
|
||||
// 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 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 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') : '') +
|
||||
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)) : '') +
|
||||
(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') +
|
||||
(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)}°`) : '') +
|
||||
(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')) +
|
||||
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 facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) +
|
||||
(fc.type === 'rabat' ? field('Richting', seg('cladding', WallModel.CLADDING_DIRECTIONS, fc.cladding)) : '') +
|
||||
swatchField('Kleur', swatches('facadeColor', facadeColors(fc.type, fc.stone), facadeColor(fc))) +
|
||||
(model.hasAccent ? field('Accent <small>afwijkende gevel van het model</small>', seg('accentType', Object.assign({ geen: 'Geen' }, LightModel.FACADES), ch.accent ? ch.accent.type : 'geen')) +
|
||||
(ch.accent ? swatchField('Kleur accent', swatches('accentColor', facadeColors(ch.accent.type, ch.accent.color), ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : '') : '');
|
||||
|
||||
// Trasraam: strook onderaan de gevel (onder rabat én steen).
|
||||
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')) +
|
||||
(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') : '') +
|
||||
(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 lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) +
|
||||
field('Buiten', seg('lightMode', LightModel.LIGHTING, L.mode)) +
|
||||
(L.mode === 'gevel' ? field('Lichtrichting', seg('lightDir', LightingModel.DIRECTIONS, L.direction)) : '');
|
||||
|
||||
const i = cur.info;
|
||||
const types = houseRoof ? [ch.house.type] : [...new Set(Object.values(ch.units).map(x => x.type))];
|
||||
const covLabel = sloped ? RoofModel.COVERINGS[ch.covering].label : 'Dakleer';
|
||||
const facadeSum = `${LightModel.FACADES[fc.type]} · ${LightModel.colorName(fc.type, facadeColor(fc))}${tr.on ? ' · trasraam' : ''}`;
|
||||
const lightSum = [L.indoor ? 'Spots binnen' : null, L.mode === 'geen' ? 'geen buitenlampen' : LightModel.LIGHTING[L.mode].toLowerCase()].filter(Boolean).join(' · ');
|
||||
const sec = (key, title, icon, sum, html) => {
|
||||
const open = openSec === key;
|
||||
return `<section class="tl-sec${open ? ' open' : ''}">
|
||||
<button type="button" class="tl-sec-head" data-sec="${key}" aria-expanded="${open}"><i class="bi bi-${icon}"></i><span class="tl-sec-title">${title}</span>
|
||||
<span class="tl-sec-sum">${esc(sum)}</span><i class="bi bi-chevron-down tl-chev"></i></button>
|
||||
${open ? `<div class="tl-sec-body">${html}</div>` : ''}</section>`;
|
||||
};
|
||||
const el = $('.tl-sections'), scroll = el.scrollTop;
|
||||
el.innerHTML = sec('maten', 'Afmetingen', 'rulers', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`, sizeHtml) +
|
||||
sec('dak', 'Dak', 'house', `${types.length > 1 ? 'Gemengd' : roofName(types[0])} · ${covLabel}`, roofHtml) +
|
||||
sec('gevel', 'Gevel', 'bricks', facadeSum, facadeHtml + trasraamHtml) +
|
||||
sec('licht', 'Verlichting', 'lightbulb', lightSum, lightHtml);
|
||||
el.scrollTop = scroll;
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const i = cur.info;
|
||||
$('.tl-sub').textContent = `${meters(i.lengthX)} × ${meters(i.lengthZ)} · ${nl(i.brutoM2, 1)} m² · ${meters(i.height)} hoog`;
|
||||
const kv = (k, v) => `<div class="tl-kv"><span>${k}</span><b>${v}</b></div>`;
|
||||
$('.tl-facts').innerHTML = kv('Vloer bruto', `${nl(i.brutoM2, 1)} m²`) + kv('Leefbaar', `${nl(i.leefbaarM2, 1)} m²`) +
|
||||
kv('Inhoud', `${nl(i.inhoudM3, 0)} m³`) + kv('Gewicht', `${nl(Math.round(i.kg / 10) * 10, 0)} kg`);
|
||||
$('.tl-buy').innerHTML = `<div class="tl-price">${showPrice ? `<span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span>` : ''}</div>
|
||||
<button type="button" class="tl-btn tl-icon" data-act="reset" title="Alle keuzes terug naar het model" aria-label="Terug naar het model"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||
${o.save ? `<button type="button" class="tl-btn tl-primary" data-act="save"><i class="bi bi-${esc(o.save.icon || 'send')}"></i>${esc(o.save.label || 'Opslaan')}</button>`
|
||||
: '<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>'}`;
|
||||
}
|
||||
|
||||
// Keuzes (knoppen en kleuren).
|
||||
const panel = $('.tl-panel');
|
||||
function choose(key, val) {
|
||||
if (key === 'unit') { unitSel = val; renderPanel(); return; }
|
||||
const R = (k) => k.startsWith('house') ? next.house : next.units[unitSel];
|
||||
const next = clone(ch), fc = next.facade;
|
||||
// Andere dakvorm: de nok volgt weer uit de helling (of uit het model, bij zijn eigen dakvorm).
|
||||
if (key === 'unitType' || key === 'houseType') Object.assign(R(key), { type: val, high: null });
|
||||
else if (key === 'unitDir' || key === 'houseDir') R(key).dir = val;
|
||||
else if (key === 'unitRidge' || key === 'houseRidge') R(key).ridge = val;
|
||||
else if (key === 'indoor') next.lighting.indoor = val === 'aan';
|
||||
else if (key === 'covering') next.covering = val;
|
||||
else if (key === 'facadeType') fc.type = val;
|
||||
else if (key === 'cladding') fc.cladding = val;
|
||||
else if (key === 'facadeColor') fc[{ rabat: 'rabat', stuc: 'stuc', fels: 'fels', steen: 'stone' }[fc.type]] = val;
|
||||
else if (key === 'accentType') next.accent = val === 'geen' ? null : { type: val, color: val === 'rabat' ? null : facadeColor(Object.assign({}, fc, { type: val })) };
|
||||
else if (key === 'accentColor') { if (next.accent.type === 'rabat') fc.rabat = val; else next.accent.color = val; }
|
||||
else if (key === 'trasraamType') {
|
||||
const t = next.trasraam;
|
||||
t.on = val === 'steen';
|
||||
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 === '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 === 'lightDir') next.lighting.direction = val;
|
||||
else return;
|
||||
tryApply(next);
|
||||
}
|
||||
panel.addEventListener('click', (e) => {
|
||||
const h = e.target.closest('[data-sec]');
|
||||
if (h) { openSec = openSec === h.dataset.sec ? null : h.dataset.sec; renderPanel(); return; }
|
||||
const b = e.target.closest('[data-set]');
|
||||
if (b && !b.disabled) { choose(b.dataset.set, b.dataset.val); return; }
|
||||
const a = e.target.closest('[data-act]');
|
||||
if (!a || !cur) return;
|
||||
if (a.dataset.act === 'pdf') savePdf();
|
||||
else if (a.dataset.act === 'save') o.save.onSave(design());
|
||||
else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
|
||||
});
|
||||
panel.addEventListener('keydown', (e) => {
|
||||
const g = e.target.closest('g[data-set]');
|
||||
if (g && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); choose(g.dataset.set, g.dataset.val); }
|
||||
});
|
||||
// Maten: label volgt het schuiven, doorrekenen bij loslaten.
|
||||
const NUM = {
|
||||
width: { get: (c) => c.units[unitSel].width, set: (c, v) => { c.units[unitSel].width = v; } },
|
||||
depth: { get: (c) => c.units[unitSel].depth, set: (c, v) => { c.units[unitSel].depth = v; } },
|
||||
// De goot omhoog of omlaag neemt een gekozen nok mee (zelfde dak, hoger of lager).
|
||||
unitLow: { get: (c) => c.units[unitSel].low, set: (c, v) => lift(c.units[unitSel], v) },
|
||||
houseLow: { get: (c) => c.house.low, set: (c, v) => lift(c.house, v) },
|
||||
trasraamHigh: { get: (c) => c.trasraam.height, set: (c, v) => { c.trasraam.height = v; } }
|
||||
};
|
||||
function lift(x, v) { if (x.high > 0) x.high += v - x.low; x.low = v; }
|
||||
// Wolfseind: de keuze is een verhouding (goot 0, nok 1), de schuif een hoogte.
|
||||
const unitForm = (c) => LightModel.formOf(model, c, unitSel), houseForm = (c) => LightModel.formOf(model, c, null, cur.st);
|
||||
NUM.unitHigh = { form: unitForm, get: (c) => unitForm(c).high, set: (c, v) => { c.units[unitSel].high = v; } };
|
||||
NUM.houseHigh = { form: houseForm, get: (c) => houseForm(c).high, set: (c, v) => { c.house.high = v; } };
|
||||
[['unitCut', (c) => c.units[unitSel], unitForm], ['houseCut', (c) => c.house, houseForm]].forEach(([k, of, form]) => {
|
||||
NUM[k] = {
|
||||
form,
|
||||
get: (c) => { const f = form(c); return Math.round((f.low + f.cutRatio * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP; },
|
||||
set: (c, v) => { const f = form(c); of(c).cut = Math.max(0.05, Math.min(0.95, (v - f.low) / Math.max(1, f.high - f.low))); }
|
||||
};
|
||||
});
|
||||
panel.addEventListener('input', (e) => {
|
||||
const r = e.target.closest('[data-num]');
|
||||
if (r) r.closest('.tl-slider').querySelector('output').textContent = meters(Number(r.value));
|
||||
});
|
||||
panel.addEventListener('change', (e) => {
|
||||
const r = e.target.closest('[data-num]');
|
||||
if (!r) return;
|
||||
const n = NUM[r.dataset.num], v = Number(r.value), old = n.get(ch);
|
||||
if (v === old) return;
|
||||
const next = clone(ch);
|
||||
n.set(next, v);
|
||||
tryApply(next, { set: n.set, ok: old, want: v });
|
||||
});
|
||||
|
||||
// Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie.
|
||||
function snapshot() {
|
||||
const W = 1600, H = 1500, other = view !== '3d', pr = renderer.getPixelRatio();
|
||||
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||
const url = as3d(() => {
|
||||
if (other && poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(W, H, false);
|
||||
camera.aspect = W / H;
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
return renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||||
});
|
||||
renderer.setPixelRatio(pr);
|
||||
if (other) {
|
||||
camera.position.copy(keep.pos);
|
||||
controls.target.copy(keep.target);
|
||||
controls.update();
|
||||
}
|
||||
resize();
|
||||
return { url, width: W, height: H };
|
||||
}
|
||||
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
|
||||
|
||||
// Het huidige ontwerp voor buiten de tool (save.onSave, el.tectumLight.design()).
|
||||
function design() {
|
||||
const st = clone(cur.st);
|
||||
st.project.naam = model.label;
|
||||
st.project.opmerking = 'Ontwerp uit de light-versie';
|
||||
return {
|
||||
design: Object.assign({ type: 'house-design', version: 1, exportedAt: new Date().toISOString() }, st),
|
||||
label: model.label, key: model.key, info: Object.assign({}, cur.info),
|
||||
choices: LightModel.describe(model, ch, cur.st), url: pageUrl
|
||||
};
|
||||
}
|
||||
|
||||
function savePdf() {
|
||||
const st = clone(cur.st);
|
||||
st.project.naam = model.label;
|
||||
st.project.opmerking = 'Indicatief ontwerp';
|
||||
try {
|
||||
let image = null;
|
||||
try { image = snapshot(); } catch (err) { console.warn(err); }
|
||||
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image, price: showPrice })
|
||||
.save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast('De PDF kon niet worden gemaakt.');
|
||||
}
|
||||
}
|
||||
|
||||
// Prijzen komen uit de CSV's (async geladen): bijwerken zodra ze er zijn.
|
||||
const offCosts = CostModel.onChange(() => { if (cur && cur.parts) { cur.info = LightModel.summary(cur.st, cur.parts); renderSummary(); } });
|
||||
|
||||
let visible = true, raf = 0;
|
||||
function resize() {
|
||||
const w = stage.clientWidth, h = stage.clientHeight;
|
||||
if (!w || !h) return;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
}
|
||||
const ro = new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); });
|
||||
ro.observe(stage);
|
||||
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
|
||||
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
|
||||
// Ctrl/⌘ ingedrukt houdt (of schermvullend); dan zoomt het.
|
||||
let engaged = false;
|
||||
const stageEl = $('.tl-stage');
|
||||
stageEl.addEventListener('pointerdown', () => { engaged = true; });
|
||||
stageEl.addEventListener('pointerleave', () => { engaged = false; });
|
||||
stageEl.addEventListener('wheel', (e) => {
|
||||
if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() === stageEl) return;
|
||||
e.stopPropagation();
|
||||
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
||||
}, { capture: true });
|
||||
io.observe(stage);
|
||||
|
||||
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
|
||||
// Alles onder het maaiveld (sandwichpanelen, vloerbalken) valt weg: AR zet het laagste punt op de grond.
|
||||
let glb = null;
|
||||
function makeGlb() {
|
||||
if (glb && glb.parts === cur.parts) return glb;
|
||||
if (glb) URL.revokeObjectURL(glb.url);
|
||||
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts).filter(g => g !== cur.parts.sandwichGroup),
|
||||
{ name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts), clipBelow: 0 }));
|
||||
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
|
||||
return glb;
|
||||
}
|
||||
|
||||
// Knop rechtsboven: AR op een telefoon/tablet, schermvullend op de desktop.
|
||||
const xbtn = $('.tl-xbtn');
|
||||
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
|
||||
const canFull = !!(stageEl.requestFullscreen || stageEl.webkitRequestFullscreen);
|
||||
function setXbtn() {
|
||||
const full = fsEl() === stageEl;
|
||||
xbtn.innerHTML = `<i class="bi bi-${AR_DEVICE ? 'badge-ar' : full ? 'fullscreen-exit' : 'fullscreen'}"></i>`;
|
||||
xbtn.title = AR_DEVICE ? 'Bekijk het ontwerp in je eigen tuin (AR)' : full ? 'Schermvullend uit (Esc)' : 'Schermvullend';
|
||||
xbtn.setAttribute('aria-label', xbtn.title);
|
||||
}
|
||||
if (AR_DEVICE || canFull) { xbtn.hidden = false; root.classList.add('has-xbtn'); setXbtn(); }
|
||||
function toggleFull() {
|
||||
const r = fsEl() === stageEl ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
|
||||
: (stageEl.requestFullscreen || stageEl.webkitRequestFullscreen).call(stageEl);
|
||||
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
|
||||
}
|
||||
const onFull = () => { root.classList.toggle('is-full', fsEl() === stageEl); setXbtn(); };
|
||||
document.addEventListener('fullscreenchange', onFull);
|
||||
document.addEventListener('webkitfullscreenchange', onFull);
|
||||
|
||||
// AR: model-viewer in een eigen laag over de hele pagina (buiten de tool: die is een container en dus het
|
||||
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan.
|
||||
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
|
||||
let arView = null, arOpen = false;
|
||||
const AR_NOTE = 'Ware grootte · met twee vingers draaien, met één vinger verplaatsen';
|
||||
function closeAr() {
|
||||
if (!arOpen) return;
|
||||
arOpen = false;
|
||||
arView.classList.remove('on');
|
||||
document.documentElement.classList.remove('tl-noscroll');
|
||||
}
|
||||
const arKey = (e) => { if (arOpen && e.key === 'Escape') closeAr(); };
|
||||
document.addEventListener('keydown', arKey);
|
||||
function showAr(url) {
|
||||
if (!arView) {
|
||||
arView = document.createElement('div');
|
||||
arView.className = 'tl-arview';
|
||||
arView.setAttribute('role', 'dialog');
|
||||
arView.setAttribute('aria-modal', 'true');
|
||||
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
|
||||
ar ar-modes="webxr scene-viewer quick-look" ar-scale="fixed" ar-placement="floor" xr-environment>
|
||||
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
|
||||
</model-viewer>
|
||||
<div class="tl-ar-top"><b class="tl-ar-title"></b><button type="button" class="tl-ar-close" aria-label="Sluiten"><i class="bi bi-x-lg"></i></button></div>
|
||||
<div class="tl-ar-note"></div>`;
|
||||
document.body.appendChild(arView);
|
||||
const mv = arView.querySelector('model-viewer'), note = arView.querySelector('.tl-ar-note');
|
||||
arView.querySelector('.tl-ar-close').addEventListener('click', closeAr);
|
||||
mv.addEventListener('load', () => {
|
||||
note.textContent = mv.canActivateAR ? AR_NOTE : 'AR werkt niet op dit toestel of in deze browser; draaien en zoomen kan wel.';
|
||||
});
|
||||
mv.addEventListener('ar-status', (e) => { if (e.detail.status === 'failed') note.textContent = 'AR kon niet starten op dit toestel.'; });
|
||||
}
|
||||
const mv = arView.querySelector('model-viewer');
|
||||
arView.setAttribute('aria-label', `${model.label} in 3D en AR`);
|
||||
arView.querySelector('.tl-ar-title').textContent = model.label;
|
||||
mv.alt = `3D-model van ${model.label}`;
|
||||
if (mv.getAttribute('src') !== url) { arView.querySelector('.tl-ar-note').textContent = ''; mv.setAttribute('src', url); }
|
||||
arView.classList.add('on');
|
||||
document.documentElement.classList.add('tl-noscroll');
|
||||
arOpen = true;
|
||||
}
|
||||
async function openAr() {
|
||||
if (!cur || busy) return;
|
||||
busy = true;
|
||||
root.classList.add('is-busy');
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 30)); // eerst de spinner tekenen
|
||||
const g = makeGlb();
|
||||
await loadModelViewer(mvSrc);
|
||||
showAr(g.url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast('De AR-weergave kon niet worden geopend.');
|
||||
}
|
||||
busy = false;
|
||||
root.classList.remove('is-busy');
|
||||
}
|
||||
xbtn.addEventListener('click', () => { if (AR_DEVICE) openAr(); else toggleFull(); });
|
||||
|
||||
(function loop() {
|
||||
raf = requestAnimationFrame(loop);
|
||||
if (visible && !arOpen && view !== 'plan') renderer.render(scene, camera);
|
||||
})();
|
||||
|
||||
root.classList.add('is-busy');
|
||||
setTimeout(() => {
|
||||
const st = LightModel.build(model, ch);
|
||||
show(st, compute(st));
|
||||
root.classList.remove('is-busy');
|
||||
}, 30);
|
||||
|
||||
return {
|
||||
unmount() {
|
||||
cancelAnimationFrame(raf);
|
||||
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);
|
||||
renderer.dispose();
|
||||
root.innerHTML = '';
|
||||
},
|
||||
getChoices: () => clone(ch),
|
||||
design: () => cur && design(),
|
||||
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
|
||||
glb: () => cur && makeGlb()
|
||||
};
|
||||
}
|
||||
|
||||
function autoMount() {
|
||||
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
|
||||
if (el.dataset.mounted) return;
|
||||
el.dataset.mounted = '1';
|
||||
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);
|
||||
else autoMount();
|
||||
|
||||
return { mount, autoMount };
|
||||
})();
|
||||
+85
-7
@@ -7,6 +7,9 @@
|
||||
// Palen onder een overkapping: steekt een dak minstens minExt buiten de vloeren uit (veranda), dan komen er
|
||||
// palen onder de dakrand. Hoekpalen staan met hun buitenkant gelijk met de dakhoek; een eind dat op een gevel
|
||||
// uitkomt rust op die wand. Daartussen zo weinig palen dat de h.o.h.-afstand onder maxSpan blijft.
|
||||
// sideWall: een vrije hoek krijgt geen paal maar een dichte zijwand (gestuukt) van de dakhoek terug tot de gevel,
|
||||
// met de buitenkant gelijk met de zijkant van het dak. Ligt die buitenkant in het vlak van een gevel, dan loopt de
|
||||
// bekleding van die gevel erover door (outsideOf, bijv. felsplaten) en staat de wand gelijk met de bekleding.
|
||||
|
||||
window.PostModel = (() => {
|
||||
const EPS = 0.5;
|
||||
@@ -15,12 +18,16 @@ window.PostModel = (() => {
|
||||
eiken: { label: 'Eiken', hex: 0xa07c54, kgM3: 750 },
|
||||
douglas: { label: 'Douglas', hex: 0xb98a5e, kgM3: 530 }
|
||||
};
|
||||
const DEFAULT_SETTINGS = { enabled: true, size: 200, wood: 'eiken', maxSpan: 3600, minExt: 1000 };
|
||||
const DEFAULT_SETTINGS = { enabled: true, size: 200, wood: 'eiken', maxSpan: 3600, minExt: 1000, sideWall: false, sideColor: 'wit' };
|
||||
const SIDE_MM = 200; // dikte zijwand (regelwerk, plaat en stuc aan beide kanten)
|
||||
const SIDE_TOP_BELOW_MM = 40; // doorlopende gevelbekleding: tot zo ver onder de bovenkant van het dak
|
||||
|
||||
function normalizeSettings(settings) {
|
||||
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
||||
s.enabled = s.enabled !== false;
|
||||
s.sideWall = !!s.sideWall;
|
||||
if (!WOODS[s.wood]) s.wood = DEFAULT_SETTINGS.wood;
|
||||
if (!WallModel.isStucColor(s.sideColor)) s.sideColor = DEFAULT_SETTINGS.sideColor;
|
||||
const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def);
|
||||
s.size = num(s.size, DEFAULT_SETTINGS.size, 100, 400);
|
||||
s.maxSpan = num(s.maxSpan, DEFAULT_SETTINGS.maxSpan, 1000, 8000);
|
||||
@@ -29,9 +36,10 @@ window.PostModel = (() => {
|
||||
}
|
||||
|
||||
// units: [{ rect (vloer), roof: { rect, ceilingAt } }] in wereld-mm; groundY = maaiveld (absoluut).
|
||||
function compute(settings, units, groundY) {
|
||||
// outsideOf(wall, out): { finish, kind, depth } van de gevel in het vlak van de buitenkant, of null.
|
||||
function compute(settings, units, groundY, outsideOf) {
|
||||
const s = normalizeSettings(settings);
|
||||
const out = { posts: [], count: 0, size: s.size, wood: s.wood, lengthMM: 0, m3: 0, kg: 0 };
|
||||
const out = { posts: [], count: 0, size: s.size, wood: s.wood, lengthMM: 0, m3: 0, kg: 0, walls: [], wallM2: 0, sideColor: s.sideColor };
|
||||
if (!s.enabled || !units.length) return out;
|
||||
const floors = units.map(u => u.rect);
|
||||
const onFloor = (x, z, m) => floors.some(f => x > f.x0 - m && x < f.x1 + m && z > f.z0 - m && z < f.z1 + m);
|
||||
@@ -79,11 +87,56 @@ window.PostModel = (() => {
|
||||
|
||||
const half = s.size / 2;
|
||||
const pts = [];
|
||||
const roofAt = (x, z) => units.map(u => u.roof).find(rf => x > rf.rect.x0 - EPS && x < rf.rect.x1 + EPS && z > rf.rect.z0 - EPS && z < rf.rect.z1 + EPS);
|
||||
const endFreeOf = (r, t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); };
|
||||
// Zijwand op het vrije eind t van run r (dir +1: wand van t de run in, -1: andersom), terug tot de gevel waar de
|
||||
// run naar kijkt. Delen twee runs een vrije hoek (veranda in een binnenhoek), dan is de run met de kortste weg
|
||||
// naar de gevel de voorkant en komt de wand langs de zijkant; het andere eind rust op die wand.
|
||||
const sideCands = [];
|
||||
if (s.sideWall) merged.forEach(r => [[r.a, 1], [r.b, -1]].forEach(([t, dir]) => {
|
||||
if (!endFreeOf(r, t)) return;
|
||||
const lo = Math.min(t, t + dir * SIDE_MM), hi = Math.max(t, t + dir * SIDE_MM);
|
||||
let reach = Infinity;
|
||||
floors.forEach(f => {
|
||||
const [s0, s1, c0, c1] = r.axis === 'z' ? [f.z0, f.z1, f.x0, f.x1] : [f.x0, f.x1, f.z0, f.z1];
|
||||
if (s0 > hi - EPS || s1 < lo + EPS) return;
|
||||
const d = r.n > 0 ? r.at - c1 : c0 - r.at;
|
||||
if (d > -EPS && d < reach) reach = d;
|
||||
});
|
||||
if (!isFinite(reach) || reach < s.size) return;
|
||||
const a = Math.min(r.at, r.at - r.n * reach), b = Math.max(r.at, r.at - r.n * reach);
|
||||
const corner = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at };
|
||||
const outward = r.axis === 'z' ? { x: 0, z: -dir } : { x: -dir, z: 0 };
|
||||
sideCands.push({ r, t, corner, reach, out: outward, w: r.axis === 'z' ? { x0: a, x1: b, z0: lo, z1: hi } : { x0: lo, x1: hi, z0: a, z1: b } });
|
||||
}));
|
||||
const sameCorner = (p, q) => Math.abs(p.x - q.x) < EPS && Math.abs(p.z - q.z) < EPS;
|
||||
const sideAt = (r, t) => { const c = sideCands.find(d => d.r === r && d.t === t); return !!c && sideCands.some(d => d.built && sameCorner(d.corner, c.corner)); };
|
||||
sideCands.forEach(c => {
|
||||
if (sideCands.some(d => d !== c && sameCorner(d.corner, c.corner) && (d.reach < c.reach - EPS || (Math.abs(d.reach - c.reach) < EPS && sideCands.indexOf(d) < sideCands.indexOf(c))))) return;
|
||||
const w = c.w;
|
||||
const tops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]]
|
||||
.map(([x, z]) => { const rf = roofAt(x, z); return rf ? rf.ceilingAt(x, z) : null; }).filter(v => v !== null);
|
||||
if (!tops.length || !(Math.max(...tops) > groundY)) return;
|
||||
const wall = Object.assign({}, w, { y0: groundY, y1: Math.round(Math.max(...tops)), color: s.sideColor, out: c.out, outside: null });
|
||||
const o = outsideOf ? outsideOf(wall, c.out) : null;
|
||||
if (o) {
|
||||
if (c.out.x > 0) wall.x1 += o.depth; else if (c.out.x < 0) wall.x0 -= o.depth;
|
||||
if (c.out.z > 0) wall.z1 += o.depth; else if (c.out.z < 0) wall.z0 -= o.depth;
|
||||
Object.assign(wall, { outside: o.finish, outsideKind: o.kind });
|
||||
// Zoals de gevel: de bekleding loopt tot onder de dakrand door (over de kant van het dakpakket).
|
||||
const roofTops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]]
|
||||
.map(([x, z]) => { const rf = roofAt(x, z); return rf && rf.topAt ? rf.topAt(x, z) : null; }).filter(v => v !== null);
|
||||
if (roofTops.length) wall.y1 = Math.max(wall.y1, Math.round(Math.min(...roofTops) - SIDE_TOP_BELOW_MM));
|
||||
}
|
||||
out.walls.push(wall);
|
||||
c.built = true;
|
||||
});
|
||||
merged.forEach(r => {
|
||||
const pos = (t) => (r.axis === 'z' ? { x: r.at - r.n * half, z: t } : { x: t, z: r.at - r.n * half });
|
||||
// Een eind dat op of tegen een vloer uitkomt rust op die gevel.
|
||||
const endFree = (t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); };
|
||||
const fa = endFree(r.a), fb = endFree(r.b);
|
||||
// Een dichte zijwand neemt de plaats van de hoekpaal in; het eind rust dan op die wand.
|
||||
const fa = endFree(r.a) && !sideAt(r, r.a), fb = endFree(r.b) && !sideAt(r, r.b);
|
||||
const pa = fa ? r.a + half : r.a, pb = fb ? r.b - half : r.b;
|
||||
if (pb - pa < EPS) return;
|
||||
const n = Math.max(1, Math.ceil((pb - pa) / s.maxSpan - 1e-9));
|
||||
@@ -94,7 +147,8 @@ window.PostModel = (() => {
|
||||
});
|
||||
pts.forEach(p => {
|
||||
if (out.posts.some(q => Math.abs(q.x - p.x) < s.size && Math.abs(q.z - p.z) < s.size)) return;
|
||||
const roof = units.map(u => u.roof).find(rf => p.x > rf.rect.x0 - EPS && p.x < rf.rect.x1 + EPS && p.z > rf.rect.z0 - EPS && p.z < rf.rect.z1 + EPS);
|
||||
if (out.walls.some(w => p.x > w.x0 - half && p.x < w.x1 + half && p.z > w.z0 - half && p.z < w.z1 + half)) return;
|
||||
const roof = roofAt(p.x, p.z);
|
||||
if (!roof) return;
|
||||
// Hoogste hoek: bij een hellend dak verdwijnt de paal aan de lage kant in het dakpakket, zonder kier.
|
||||
const top = Math.max(...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([a, b]) => roof.ceilingAt(p.x + a * (half - 1), p.z + b * (half - 1))));
|
||||
@@ -105,6 +159,10 @@ window.PostModel = (() => {
|
||||
out.lengthMM = out.posts.reduce((t, p) => t + p.y1 - p.y0 - SHOE_MM, 0);
|
||||
out.m3 = out.lengthMM * s.size * s.size / 1e9;
|
||||
out.kg = out.m3 * WOODS[s.wood].kgM3;
|
||||
const wallArea = (w) => Math.max(w.x1 - w.x0, w.z1 - w.z0) * (w.y1 - w.y0) / 1e6;
|
||||
out.wallM2 = out.walls.reduce((t, w) => t + wallArea(w), 0);
|
||||
out.outsideM2 = {};
|
||||
out.walls.filter(w => w.outsideKind).forEach(w => { out.outsideM2[w.outsideKind] = (out.outsideM2[w.outsideKind] || 0) + wallArea(w); });
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -117,7 +175,7 @@ window.PostModel = (() => {
|
||||
|
||||
function build3D(data, materials) {
|
||||
const toM = 0.001, group = new THREE.Group();
|
||||
if (!data || !data.posts.length) return group;
|
||||
if (!data || !(data.posts.length || (data.walls || []).length)) return group;
|
||||
const m = materials || createMaterials();
|
||||
const woodMat = m.wood[data.wood] || m.wood.eiken;
|
||||
data.posts.forEach(p => {
|
||||
@@ -132,8 +190,28 @@ window.PostModel = (() => {
|
||||
stem.position.set(p.x * toM, (p.y0 + 10 + (SHOE_MM - 10) / 2) * toM, p.z * toM);
|
||||
group.add(stem);
|
||||
});
|
||||
(data.walls || []).forEach(w => {
|
||||
const c = [(w.x0 + w.x1) / 2 * toM, (w.y0 + w.y1) / 2 * toM, (w.z0 + w.z1) / 2 * toM];
|
||||
const geo = new THREE.BoxGeometry((w.x1 - w.x0) * toM, (w.y1 - w.y0) * toM, (w.z1 - w.z0) * toM);
|
||||
// UV in wereldmeters, zoals de gevels: texturen op dezelfde schaal.
|
||||
const pos = geo.attributes.position, nrm = geo.attributes.normal, uv = geo.attributes.uv;
|
||||
for (let k = 0; k < pos.count; k++) {
|
||||
const x = pos.getX(k) + c[0], y = pos.getY(k) + c[1], z = pos.getZ(k) + c[2];
|
||||
if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y);
|
||||
}
|
||||
uv.needsUpdate = true;
|
||||
// Volgorde van de vlakken in BoxGeometry: +x, -x, +y, -y, +z, -z.
|
||||
const stuc = WallModel.stucMaterial(w.color), mats = [stuc, stuc, stuc, stuc, stuc, stuc];
|
||||
const outMat = w.outside && w.out ? WallModel.claddingMaterial(w.outside) : null;
|
||||
if (outMat) mats[w.out.x > 0 ? 0 : w.out.x < 0 ? 1 : w.out.z > 0 ? 4 : 5] = outMat;
|
||||
const box = new THREE.Mesh(geo, mats);
|
||||
box.position.set(...c);
|
||||
box.castShadow = true;
|
||||
box.receiveShadow = true;
|
||||
group.add(box);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
return { WOODS, SHOE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
|
||||
return { WOODS, SHOE_MM, SIDE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
|
||||
})();
|
||||
|
||||
+122
-22
@@ -12,6 +12,12 @@ window.RoofModel = (() => {
|
||||
const DENSITY_DAKLEER_KGM2 = 1.4;
|
||||
const TRIM_THICK_MM = 25;
|
||||
const EDGE_ABOVE_MM = 80;
|
||||
// Daktrim: voorkant 8 mm vóór de gevel, over de bovenste trimHeight van de gevel en 30 mm boven het dak; de opstand
|
||||
// is 20 mm diep, daarachter ligt een flens van 3 mm op het dak over de bovenkant van de bekleding.
|
||||
const TRIM = { OUT_MM: 8, ABOVE_MM: 30, DEPTH_MM: 20, FLANGE_MM: 3 };
|
||||
// Felsplaat op dak én gevel: geen boeideel; de dakplaat zet om over de rand (net vóór de gevelbekleding), zonder
|
||||
// opstand (de waterkant van een lessenaar), tot over de bovenkant van de gevelplaten.
|
||||
const FELS_EDGE = { lipMM: 2, aboveMM: 2, heightMM: 120 };
|
||||
const SPOT_RATIO = 0.4;
|
||||
const OVERHANG_MIN = 200, OVERHANG_MAX = 400;
|
||||
|
||||
@@ -20,9 +26,36 @@ window.RoofModel = (() => {
|
||||
fasciaHeight: 300,
|
||||
spotMin: 800,
|
||||
spotMax: 1200,
|
||||
trimHeight: 80,
|
||||
fasciaColor: 'antraciet'
|
||||
trimHeight: 30,
|
||||
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 = {
|
||||
wit: { label: 'Wit', hex: 0xf2f2f0 },
|
||||
@@ -35,7 +68,9 @@ window.RoofModel = (() => {
|
||||
// Riet e.d. alleen bij een hellend dak; plat valt terug op dakleer.
|
||||
// Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer hier toevoegen).
|
||||
const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } };
|
||||
const FELS = { thickMM: 25, kgM2: 6.5, seamMM: 500, colors: { zink: ['Felsplaat zink', '#8e9497'], antraciet: ['Felsplaat antraciet', '#3d4146'], zwart: ['Felsplaat zwart', '#1f2022'] } };
|
||||
// Kleuren en naadafstand gedeeld met felsplaten op de gevel (WallModel.FELS).
|
||||
const FELS = { thickMM: 25, kgM2: 6.5, seamMM: WallModel.FELS.SEAM_MM,
|
||||
colors: Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, ['Felsplaat ' + c.label.toLowerCase(), c.hex]])) };
|
||||
const COVERINGS = Object.assign({
|
||||
dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 }
|
||||
}, ...RIET.nrs.map(nr => ({
|
||||
@@ -55,7 +90,7 @@ window.RoofModel = (() => {
|
||||
}
|
||||
function coveringThumbUrl(key, px) {
|
||||
const c = COVERINGS[key];
|
||||
return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null;
|
||||
return c && c.file ? tectumUrl(c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png'))) : null;
|
||||
}
|
||||
function coveringFor(key, shapeType) {
|
||||
const c = COVERINGS[key];
|
||||
@@ -73,7 +108,7 @@ window.RoofModel = (() => {
|
||||
if (!c.file) return null;
|
||||
const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 });
|
||||
if (typeof document !== 'undefined' && THREE.TextureLoader) {
|
||||
new THREE.TextureLoader().load(c.file, (tex) => {
|
||||
new THREE.TextureLoader().load(tectumUrl(c.file), (tex) => {
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM);
|
||||
tex.anisotropy = 8;
|
||||
@@ -923,7 +958,8 @@ window.RoofModel = (() => {
|
||||
}
|
||||
|
||||
// facadeDepth negatief = gevel van een hoger deel die naar dit dak kijkt: de rand stopt tegen die gevel.
|
||||
function computeEdge(settings, roofs, facadeDepth) {
|
||||
// facadeFels(a, b, normal): de gevel onder deze rand heeft felsplaten (met een felsdak: geen boeideel).
|
||||
function computeEdge(settings, roofs, facadeDepth, facadeFels) {
|
||||
const s = normalizeSettings(settings);
|
||||
const rects = roofs.map(r => r.rect);
|
||||
if (!roofs.length) return null;
|
||||
@@ -967,19 +1003,23 @@ window.RoofModel = (() => {
|
||||
const j = (i + 1) % n, b = base[j];
|
||||
const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 };
|
||||
const owner = members.find(r => onRectEdge(r.rect, mid)) || members[0];
|
||||
const fascia = owner.fascia !== false;
|
||||
const normal = rectilinearNormal(a, b);
|
||||
const fels = coverKind(owner.covering) === 'fels' && !!facadeFels && !!facadeFels(a, b, normal);
|
||||
const fascia = owner.fascia !== false && !fels;
|
||||
const off = fascia ? offBoei : offTrim;
|
||||
// Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots.
|
||||
const nb = others.find(r => onRectEdge(r.rect, mid));
|
||||
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
|
||||
const above = fels ? FELS_EDGE.aboveMM : fascia ? EDGE_ABOVE_MM : TRIM.ABOVE_MM;
|
||||
return {
|
||||
a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b),
|
||||
a, b, owner: owner.unitId, fascia, fels, covering: owner.covering, normal, above,
|
||||
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j],
|
||||
topAt: (p) => owner.topAt(p.x, p.z) + EDGE_ABOVE_MM, ridges: owner.ridges || [],
|
||||
topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [],
|
||||
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 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;
|
||||
@@ -993,7 +1033,25 @@ 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])
|
||||
};
|
||||
});
|
||||
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): ligt net vóór de gevelbekleding en loopt bij een hoek door tot de
|
||||
// buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand.
|
||||
const edgeOff = (k) => segs[k].fels ? Math.max(TRIM_THICK_MM, depRaw[k] + FELS_EDGE.lipMM) : Math.max(0, depRaw[k]) + TRIM.OUT_MM;
|
||||
const mv = (p, nrm, d) => ({ x: p.x + nrm.x * d, z: p.z + nrm.z * d });
|
||||
segs.forEach((sg, i) => {
|
||||
const ends = [['a', 'outerA', (i - 1 + n) % n], ['b', 'outerB', (i + 1) % n]];
|
||||
if (!sg.fascia) ends.forEach(([k, ok, j]) => {
|
||||
const nb = segs[j], q = mv(sg[k], sg.normal, edgeOff(i));
|
||||
sg[ok] = perp(sg.normal, nb.normal) && !nb.fascia && !nb.dropped ? mv(q, nb.normal, edgeOff(j)) : q;
|
||||
});
|
||||
else if (sg.fascia) ends.forEach(([k, ok, j]) => {
|
||||
const nb = segs[j];
|
||||
if (nb.fascia || nb.dropped || !perp(sg.normal, nb.normal)) return;
|
||||
sg[ok] = mv(mv(sg[k], sg.normal, s.overhang), nb.normal, edgeOff(j));
|
||||
});
|
||||
});
|
||||
|
||||
const insideOther = (p) => others.some(r => p.x > r.rect.x0 + 0.5 && p.x < r.rect.x1 - 0.5 && p.z > r.rect.z0 + 0.5 && p.z < r.rect.z1 - 0.5);
|
||||
segs.forEach((sg, i) => {
|
||||
@@ -1022,9 +1080,13 @@ window.RoofModel = (() => {
|
||||
return {
|
||||
segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
|
||||
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),
|
||||
trimLengthMM: 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.
|
||||
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),
|
||||
soffitM2: segments.filter(sg => sg.fascia).reduce((t, sg) => t + quadArea([sg.a, sg.b, sg.outerB, sg.outerA]), 0) / 1e6,
|
||||
spots: []
|
||||
};
|
||||
@@ -1043,19 +1105,38 @@ window.RoofModel = (() => {
|
||||
return out;
|
||||
}
|
||||
|
||||
function prismGeometry(poly, topFn, h) {
|
||||
const toM = 0.001, n = poly.length, pos = [];
|
||||
// uAt(p): met textuur, u langs de rand; v = 0 onder, 1 boven.
|
||||
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 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);
|
||||
for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); tri(B[0], B[k + 1], B[k]); }
|
||||
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]); }
|
||||
const uv = (...q) => { if (U) q.forEach(([k, v]) => uvs.push(U[k], v)); };
|
||||
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();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
if (U) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
||||
geo.computeVertexNormals();
|
||||
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).
|
||||
function edgeHeight(edge, sg) {
|
||||
return sg.fascia ? edge.fasciaHeight : sg.fels ? FELS_EDGE.heightMM : edge.trimHeight + TRIM.ABOVE_MM;
|
||||
}
|
||||
// Kleur van de rand in tekeningen (hex-getal).
|
||||
function edgeColor(edge, sg) {
|
||||
if (sg.fascia) return (FASCIA_COLORS[edge.fasciaColor] || FASCIA_COLORS.antraciet).hex;
|
||||
const c = sg.fels && COVERINGS[sg.covering];
|
||||
return c ? parseInt(c.color.slice(1), 16) : 0xb8bcc0;
|
||||
}
|
||||
|
||||
function buildEdge3D(edge, materials, opts) {
|
||||
const o = opts || {};
|
||||
const toM = 0.001;
|
||||
@@ -1075,12 +1156,29 @@ window.RoofModel = (() => {
|
||||
const t = (p) => alongZ ? p.z : p.x;
|
||||
polys = polys.flatMap(pl => [clipPoly(pl, p => g0 - t(p)), clipPoly(pl, p => t(p) - g1)]).filter(pl => pl.length >= 3);
|
||||
});
|
||||
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
||||
polys.forEach(pl => {
|
||||
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial);
|
||||
const h = edgeHeight(edge, sg);
|
||||
const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial;
|
||||
const nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z;
|
||||
const add = (pl, topFn, hh, m, u) => {
|
||||
const mesh = new THREE.Mesh(prismGeometry(pl, topFn, hh, u), m);
|
||||
mesh.visible = o.edgeVisible !== false;
|
||||
group.add(mesh);
|
||||
};
|
||||
if (!sg.fascia && !sg.fels) {
|
||||
// Daktrim: opstand aan de voorkant, flens erachter op het dak.
|
||||
const inner = nd(sg.outerA) - TRIM.DEPTH_MM;
|
||||
polys.forEach(pl => {
|
||||
const up = clipPoly(pl, p => nd(p) - inner), fl = clipPoly(pl, p => inner - nd(p));
|
||||
if (up.length >= 3) add(up, sg.topAt, h, mat);
|
||||
if (fl.length >= 3) add(fl, p => sg.topAt(p) - TRIM.ABOVE_MM + TRIM.FLANGE_MM, TRIM.FLANGE_MM, mat);
|
||||
});
|
||||
} else polys.forEach(pl => add(pl, sg.topAt, h, mat));
|
||||
// Rooster: op de gevel (voorkant bekleding), onder de onderplaat van het boeideel.
|
||||
if (!sg.fascia || !sg.facadeUnder || !edge.vent) return;
|
||||
const 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;
|
||||
@@ -1105,13 +1203,15 @@ window.RoofModel = (() => {
|
||||
s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover };
|
||||
s.posts = PostModel.normalizeSettings(s.posts);
|
||||
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 {
|
||||
DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
|
||||
DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, TRIM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
|
||||
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
||||
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
|
||||
computeEdge, buildEdge3D, normalizeSettings
|
||||
FELS_EDGE, VENT, ventFace, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings
|
||||
};
|
||||
})();
|
||||
|
||||
Vendored
+78
-20
@@ -4,7 +4,9 @@
|
||||
// hebben om het origineel te vendoren; dekt precies de subset die wall.js
|
||||
// gebruikt (`new THREE.OrbitControls(camera, domElement)`,
|
||||
// `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).
|
||||
//
|
||||
// Moet NA three.min.js geladen worden (leunt op window.THREE).
|
||||
// ============================================================
|
||||
@@ -49,7 +51,9 @@
|
||||
};
|
||||
|
||||
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;
|
||||
isPanning = true;
|
||||
} else {
|
||||
@@ -60,13 +64,7 @@
|
||||
lastY = event.clientY;
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (isRotating) {
|
||||
const dx = event.clientX - lastX;
|
||||
const dy = event.clientY - lastY;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
|
||||
function rotate(dx, dy) {
|
||||
const w = scope.domElement.clientWidth || 1;
|
||||
const h = scope.domElement.clientHeight || 1;
|
||||
|
||||
@@ -79,12 +77,8 @@
|
||||
offset.setFromSpherical(spherical);
|
||||
scope.object.position.copy(scope.target).add(offset);
|
||||
scope.object.lookAt(scope.target);
|
||||
} else if (isPanning) {
|
||||
const dx = event.clientX - lastX;
|
||||
const dy = event.clientY - lastY;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
|
||||
}
|
||||
function pan(dx, dy) {
|
||||
const distance = scope.object.position.distanceTo(scope.target);
|
||||
const fov = (scope.object.fov || 50) * Math.PI / 180;
|
||||
const h = scope.domElement.clientHeight || 1;
|
||||
@@ -96,6 +90,21 @@
|
||||
scope.target.add(xAxis).add(yAxis);
|
||||
scope.object.position.add(xAxis).add(yAxis);
|
||||
}
|
||||
function zoom(scale) {
|
||||
offset.copy(scope.object.position).sub(scope.target);
|
||||
const distance = Math.max(scope.minDistance, Math.min(scope.maxDistance, offset.length() * scale));
|
||||
offset.setLength(distance);
|
||||
scope.object.position.copy(scope.target).add(offset);
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!isRotating && !isPanning) return;
|
||||
const dx = event.clientX - lastX;
|
||||
const dy = event.clientY - lastY;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
if (isRotating) rotate(dx, dy);
|
||||
else pan(dx, dy);
|
||||
}
|
||||
|
||||
function onPointerUp() { isRotating = false; isPanning = false; }
|
||||
@@ -103,20 +112,62 @@
|
||||
function onWheel(event) {
|
||||
if (!scope.enableZoom) return;
|
||||
event.preventDefault();
|
||||
const scale = event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale();
|
||||
offset.copy(scope.object.position).sub(scope.target);
|
||||
const distance = Math.max(scope.minDistance, Math.min(scope.maxDistance, offset.length() * scale));
|
||||
offset.setLength(distance);
|
||||
scope.object.position.copy(scope.target).add(offset);
|
||||
zoom(event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale());
|
||||
}
|
||||
|
||||
// Touch: midden en afstand van de vingers; bij een wissel van het aantal vingers opnieuw beginnen.
|
||||
let touch = null;
|
||||
function touchState(list) {
|
||||
const a = list[0], b = list[1] || list[0];
|
||||
return { n: list.length, x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2, d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) };
|
||||
}
|
||||
function onTouchStart(event) {
|
||||
event.preventDefault();
|
||||
touch = touchState(event.touches);
|
||||
}
|
||||
function onTouchMove(event) {
|
||||
if (!touch) return;
|
||||
event.preventDefault();
|
||||
const t = touchState(event.touches);
|
||||
if (t.n !== touch.n) { touch = t; return; }
|
||||
if (t.n === 1) { if (scope.enableRotate) rotate(t.x - touch.x, t.y - touch.y); }
|
||||
else {
|
||||
if (scope.enablePan) pan(t.x - touch.x, t.y - touch.y);
|
||||
if (scope.enableZoom && t.d > 0 && touch.d > 0) zoom(touch.d / t.d);
|
||||
}
|
||||
touch = t;
|
||||
}
|
||||
function onTouchEnd(event) { touch = event.touches.length ? touchState(event.touches) : null; }
|
||||
|
||||
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);
|
||||
window.addEventListener('mousemove', onPointerMove);
|
||||
window.addEventListener('mouseup', onPointerUp);
|
||||
this.domElement.addEventListener('wheel', onWheel, { passive: false });
|
||||
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('touchmove', onTouchMove, { passive: false });
|
||||
this.domElement.addEventListener('touchend', onTouchEnd);
|
||||
this.domElement.addEventListener('touchcancel', onTouchEnd);
|
||||
|
||||
this.dispose = function () {
|
||||
scope.domElement.removeEventListener('mousedown', onPointerDown);
|
||||
@@ -124,6 +175,13 @@
|
||||
window.removeEventListener('mouseup', onPointerUp);
|
||||
scope.domElement.removeEventListener('wheel', onWheel);
|
||||
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('touchmove', onTouchMove);
|
||||
scope.domElement.removeEventListener('touchend', onTouchEnd);
|
||||
scope.domElement.removeEventListener('touchcancel', onTouchEnd);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1117
File diff suppressed because one or more lines are too long
Vendored
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Vendored
+2297
File diff suppressed because it is too large
Load Diff
+129
-10
@@ -72,6 +72,67 @@ window.WallModel = (() => {
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Felsplaten (staande naad) op de gevel: zelfde opbouw als verticale rabat (doek, latten, bekleding), alleen het
|
||||
// materiaal verschilt. Kleuren ook voor het dak (RoofModel.FELS). In 3D de naden als textuur over het blok.
|
||||
const FELS = {
|
||||
SEAM_MM: 500, TILE_SEAMS: 6,
|
||||
COLORS: {
|
||||
zink: { label: 'Zink', hex: '#8e9497' },
|
||||
antraciet: { label: 'Antraciet', hex: '#3d4146' },
|
||||
zwart: { label: 'Zwart', hex: '#1f2022' }
|
||||
}
|
||||
};
|
||||
const isFelsColor = (key) => !!FELS.COLORS[key];
|
||||
const felsHex = (key) => (FELS.COLORS[key] || FELS.COLORS.antraciet).hex;
|
||||
const felsMaterials = {};
|
||||
function felsMaterial(key) {
|
||||
const k = isFelsColor(key) ? key : 'antraciet';
|
||||
if (felsMaterials[k]) return felsMaterials[k];
|
||||
let mat;
|
||||
const canvas = typeof document !== 'undefined' && document.createElement('canvas');
|
||||
const ctx = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!ctx) mat = new THREE.MeshStandardMaterial({ color: felsHex(k), roughness: 0.4, metalness: 0.55 });
|
||||
else {
|
||||
const B = 100, N = FELS.TILE_SEAMS; // 5 mm/px
|
||||
canvas.width = B * N;
|
||||
canvas.height = 16;
|
||||
const base = new THREE.Color(felsHex(k));
|
||||
const shade = (f) => '#' + base.clone().multiplyScalar(f).getHexString();
|
||||
const col = (a, b, color) => { ctx.fillStyle = color; ctx.fillRect(a, 0, b - a, canvas.height); };
|
||||
for (let i = 0; i < N; i++) {
|
||||
const a = i * B, f = 0.97 + ((i * 29) % 7) / 100; // per baan net iets anders
|
||||
col(a, a + B, shade(f));
|
||||
col(a + 6, a + 14, shade(f * 1.04)); // lichte zetting naast de naad
|
||||
col(a, a + 2, shade(f * 0.6)); // schaduw van de naad
|
||||
col(a + 2, a + 5, shade(f * 1.35)); // staande naad (licht)
|
||||
}
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(1000 / (FELS.SEAM_MM * N), 1);
|
||||
tex.anisotropy = 8;
|
||||
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.4, metalness: 0.55 });
|
||||
}
|
||||
felsMaterials[k] = 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) {
|
||||
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
|
||||
if (buitenMateriaal !== 'cementplaat') return 0;
|
||||
@@ -121,11 +182,19 @@ window.WallModel = (() => {
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Materiaal van een gevelbekleding (UV in meters): felsplaten, steen, stuc of rabat; null = kale plaat.
|
||||
function claddingMaterial(f) {
|
||||
if (isFelsColor(f.fels)) return felsMaterial(f.fels);
|
||||
if (isStoneFinish(f.stone)) return stoneMaterial(f.stone);
|
||||
if (isStucColor(f.stuc)) return stucMaterial(f.stuc);
|
||||
return f.rabatColor ? rabatMaterial(f.rabatColor, f.cladding) : null;
|
||||
}
|
||||
|
||||
// Hoogte vanaf het loopvlak (= onderkant van de wand).
|
||||
const TRASRAAM_DEFAULT_MM = 350;
|
||||
function trasraamFor(heightMM, finish) {
|
||||
function trasraamFor(heightMM, finish, capColor) {
|
||||
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);
|
||||
@@ -165,8 +234,8 @@ window.WallModel = (() => {
|
||||
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
|
||||
};
|
||||
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
|
||||
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg');
|
||||
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png');
|
||||
const stoneTextureUrl = (code) => tectumUrl(STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'));
|
||||
const stoneThumbUrl = (code, px) => tectumUrl(STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'));
|
||||
// UV's in meters, dus repeat = 1/tegel.
|
||||
const stoneMaterials = {};
|
||||
function stoneMaterial(code) {
|
||||
@@ -763,6 +832,7 @@ window.WallModel = (() => {
|
||||
const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0;
|
||||
const painted = !!opts.painted;
|
||||
const stone = buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone) ? opts.stone : null;
|
||||
const felsKey = buitenMateriaal === 'vochtdoek' && isFelsColor(opts.fels) ? opts.fels : null;
|
||||
// spaanplaatBelow: naadwand geheel in één unit; onder het dak van de andere unit is het een binnenwand.
|
||||
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
|
||||
const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel');
|
||||
@@ -792,11 +862,11 @@ window.WallModel = (() => {
|
||||
let spaanplaatBuitenAreaM2 = 0;
|
||||
let spaanplaatBuitenVolumeM3 = 0;
|
||||
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0;
|
||||
let rabatAreaM2 = 0;
|
||||
let rabatAreaM2 = 0, felsAreaM2 = 0;
|
||||
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
|
||||
let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
|
||||
let stucAreaM2 = 0;
|
||||
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0;
|
||||
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0, trasraamCapM = 0;
|
||||
let horizBeamCount = 0;
|
||||
|
||||
function createExtrudedBeam(shape, depth, positionZ) {
|
||||
@@ -1055,6 +1125,41 @@ window.WallModel = (() => {
|
||||
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) {
|
||||
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM;
|
||||
const t = STUC.THICK_MM * toM;
|
||||
@@ -1152,6 +1257,17 @@ window.WallModel = (() => {
|
||||
cap.position.set(x + dir * 0.0003, h / 2, 0);
|
||||
if (spaanplaatVisible) wallGroup3D.add(cap);
|
||||
});
|
||||
// Vrij uiteinde van een binnenwand (endCaps): afgewerkt zoals de wand, geen stijl in zicht.
|
||||
const endCaps = opts.endCaps || {};
|
||||
if (buitenMateriaal === 'spaanplaat' && spaanplaatVisible) [[endCaps.left, -halfW, -1], [endCaps.right, halfW, 1]].forEach(([on, x, dir]) => {
|
||||
if (!on) return;
|
||||
const h = topYAt(x), depth = maxDepth + 2 * (skinGapM + sThickM);
|
||||
if (!(h > 0)) return;
|
||||
const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial);
|
||||
cap.rotation.y = dir * Math.PI / 2;
|
||||
cap.position.set(x + dir * 0.0003, h / 2, 0);
|
||||
wallGroup3D.add(cap);
|
||||
});
|
||||
spaanplaatAreaM2 = spaanplaatResult.areaM2;
|
||||
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
|
||||
|
||||
@@ -1176,6 +1292,7 @@ window.WallModel = (() => {
|
||||
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
|
||||
trasraamStoneAreaM2 = r.areaM2;
|
||||
trasraamIsolatieM3 = r.isoM3;
|
||||
if (stucKey) trasraamCapM = stoneCap(trasraam);
|
||||
y0 = trasraam.top * toM;
|
||||
} else if (trasraam) {
|
||||
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
|
||||
@@ -1253,6 +1370,7 @@ window.WallModel = (() => {
|
||||
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
|
||||
trasraamStoneAreaM2 = r.areaM2;
|
||||
trasraamIsolatieM3 = r.isoM3;
|
||||
trasraamCapM = stoneCap(trasraam);
|
||||
} else if (trasraam) {
|
||||
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
|
||||
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM
|
||||
@@ -1294,6 +1412,7 @@ window.WallModel = (() => {
|
||||
return shape;
|
||||
}) : [];
|
||||
rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
||||
if (felsKey) { felsAreaM2 = rabatAreaM2; rabatAreaM2 = 0; }
|
||||
if (rabatOn && rabatShapes.length) {
|
||||
const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false });
|
||||
const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM;
|
||||
@@ -1303,7 +1422,7 @@ window.WallModel = (() => {
|
||||
if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y);
|
||||
}
|
||||
uv.needsUpdate = true;
|
||||
const mesh = new THREE.Mesh(geo, rabatMaterial(opts.rabatColor, cladding));
|
||||
const mesh = new THREE.Mesh(geo, felsKey ? felsMaterial(felsKey) : rabatMaterial(opts.rabatColor, cladding));
|
||||
mesh.position.set(0, 0, zDoek);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
@@ -1391,7 +1510,7 @@ window.WallModel = (() => {
|
||||
stats: {
|
||||
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
|
||||
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
|
||||
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2
|
||||
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, trasraamCapM, stoneAreaM2, stoneIsolatieM3, stucAreaM2
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1620,8 +1739,8 @@ window.WallModel = (() => {
|
||||
}
|
||||
|
||||
return {
|
||||
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
||||
stoneMaterial, stucMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
||||
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,
|
||||
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
||||
createMaterials, build3D, computeSpecs
|
||||
};
|
||||
|
||||
+20
-23
@@ -220,7 +220,7 @@ const WALL_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="wall-project-cancel" class="ui-btn">Annuleren</button>
|
||||
<button type="button" id="wall-project-cancel" class="ui-btn" data-esc>Annuleren</button>
|
||||
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,11 +335,6 @@ function mountWallEditor(container, callbacks) {
|
||||
updateAll();
|
||||
}
|
||||
|
||||
function setView(mode) {
|
||||
UI.setView(document.getElementById('app-container'), mode);
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
|
||||
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
|
||||
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
|
||||
let projectPropsSnapshot = null;
|
||||
@@ -605,7 +600,7 @@ function mountWallEditor(container, callbacks) {
|
||||
function getPureState() {
|
||||
return {
|
||||
wall: collectWallParams(),
|
||||
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor },
|
||||
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels },
|
||||
openings: openings.map(rawOpening)
|
||||
};
|
||||
}
|
||||
@@ -634,6 +629,7 @@ function mountWallEditor(container, callbacks) {
|
||||
stone = (state.materials && state.materials.stone) || null;
|
||||
stuc = (state.materials && state.materials.stuc) || null;
|
||||
rabatColor = (state.materials && state.materials.rabatColor) || null;
|
||||
fels = (state.materials && state.materials.fels) || null;
|
||||
syncMaterialButtons();
|
||||
|
||||
openings = state.openings.map(rawOpening);
|
||||
@@ -1282,6 +1278,7 @@ function mountWallEditor(container, callbacks) {
|
||||
let stone = null;
|
||||
let stuc = null;
|
||||
let rabatColor = null;
|
||||
let fels = null;
|
||||
let layers = ViewLayers.all();
|
||||
function syncMaterialButtons() {
|
||||
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
||||
@@ -1299,32 +1296,32 @@ function mountWallEditor(container, callbacks) {
|
||||
|
||||
let spaanplaatVisible = true;
|
||||
let buitenVisible = true;
|
||||
const toolbar = document.getElementById('wall-toolbar');
|
||||
toolbar.innerHTML = `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>
|
||||
<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${UI.tbSep()}${UI.viewSeg('split')}`;
|
||||
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
|
||||
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||
const viewGroup = toolbar.querySelector('.tb-view');
|
||||
ViewLayers.createMenu(viewGroup, {
|
||||
layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'],
|
||||
onChange: (l) => { layers = l; update3DScene(); },
|
||||
extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }],
|
||||
onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); }
|
||||
});
|
||||
let skyMode = Skybox.getMode();
|
||||
const skyEnv = Skybox.createEnvironment(scene);
|
||||
const applySky = () => {
|
||||
skyEnv.fitTo(wallGroup3D, 0);
|
||||
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
|
||||
};
|
||||
Skybox.createToggle(viewGroup, (m) => { skyMode = m; applySky(); });
|
||||
const toolbar = document.getElementById('wall-toolbar');
|
||||
const bar = UI.editorBar(toolbar, document.getElementById('app-container'), {
|
||||
left: `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>`,
|
||||
onResize: onWindowResize,
|
||||
layers: {
|
||||
layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'],
|
||||
onChange: (l) => { layers = l; update3DScene(); },
|
||||
extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }],
|
||||
onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); }
|
||||
},
|
||||
onSky: (m) => { skyMode = m; applySky(); }
|
||||
});
|
||||
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
|
||||
|
||||
let lastWallStats = null;
|
||||
|
||||
function update3DScene() {
|
||||
if (wallGroup3D) scene.remove(wallGroup3D);
|
||||
const result = WallModel.build3D(CONFIG, calcData, {
|
||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, layers
|
||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels, spaanplaatVisible, buitenVisible, layers
|
||||
});
|
||||
wallGroup3D = result.group;
|
||||
lastWallStats = result.stats;
|
||||
@@ -1418,8 +1415,8 @@ function mountWallEditor(container, callbacks) {
|
||||
exportDesign,
|
||||
triggerImport,
|
||||
mirrorHorizontal,
|
||||
setView,
|
||||
viewState: () => ({ view: UI.viewOf(document.getElementById('app-container')) }),
|
||||
setView: bar.setView,
|
||||
viewState: bar.viewState,
|
||||
openProjectProperties,
|
||||
getStateJson: getPureState,
|
||||
loadStateJson: applyPureState
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"fasciaHeight": 300,
|
||||
"spotMin": 800,
|
||||
"spotMax": 1200,
|
||||
"trimHeight": 80,
|
||||
"trimHeight": 30,
|
||||
"fasciaColor": "antraciet"
|
||||
},
|
||||
"lighting": {
|
||||
|
||||
@@ -30,6 +30,7 @@ gevel.vochtdoekMontageM;Montage vochtdoek per strekkende meter wand;s/m;600
|
||||
gevel.rabatMontage;Montage rabat;s/m²;600
|
||||
gevel.steenMontage;Montage steenstrips;s/m²;1800
|
||||
gevel.stucMontage;Stucwerk aanbrengen (wapenen, pleisteren);s/m²;1500
|
||||
gevel.felsMontage;Montage felsplaten gevel (ook dagkanten en hoeken);s/m²;2100
|
||||
dak.dakleerMontage;Montage dakleer;s/m²;600
|
||||
dak.rietMontage;Montage riet;s/m²;3600
|
||||
dak.beschotMontage;Montage dakbeschot;s/m²;150
|
||||
@@ -41,6 +42,7 @@ dak.nokPlaatsen;Stalen nokbalk plaatsen, stellen en vastzetten;s;2700
|
||||
dak.nokVulhoutMontage;Vulhout in de holling van de nokbalk aanbrengen (beide zijden);s/m;240
|
||||
dak.nokPersoneel;Personeel bij plaatsen nokbalk (i.p.v. de bezetting);man;3
|
||||
dak.paalMontage;Paal plaatsen: stellen, aftoppen, vastzetten aan het dak;s/st.;2700
|
||||
dak.zijwandMontage;Zijwand overkapping maken en plaatsen;s/m²;5400
|
||||
vloer.afwerkingMontage;Montage vloerafwerking;s/m²;1200
|
||||
vloer.sandwichMontage;Montage per sandwichpaneel;s;600
|
||||
afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800
|
||||
|
||||
|
@@ -12,6 +12,7 @@ gevel.vochtdoekM2;Vochtdoek;€/m²;1,5;;;
|
||||
gevel.rabatM2;Rabat (gevelbekleding);€/m²;35;;;
|
||||
gevel.steenM2;Steenstrips (gevel + trasraam);€/m²;60;;;
|
||||
gevel.stucM2;Stucwerk gevel (wapening + sierpleister op cementgebonden plaat);€/m²;45;;;
|
||||
gevel.felsM2;Felsplaten gevel, staande naad (staal, klangen, ondergrond);€/m²;70;;;
|
||||
dak.dakleerM2;Dakleer (EPDM);€/m²;18;;;
|
||||
dak.rietM2;Riet;€/m²;90;;;
|
||||
dak.beschotM2;Dakbeschot OSB 18 mm (bovenop de sporen);€/m²;14;;;
|
||||
@@ -23,6 +24,7 @@ dak.staalKg;Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond);€/kg;2
|
||||
dak.nokOplegging;Oplegging nokbalk (stalen schoen + bouten), 2 per balk;€/st.;45;;;
|
||||
dak.paalHoutM3;Paalhout overkapping (eiken, geschaafd);€/m³;2400;;;
|
||||
dak.paalvoet;Paalvoet verzinkt, verstelbaar (incl. ankers);€/st.;40;;;
|
||||
dak.zijwandM2;Zijwand overkapping (regelwerk, cementgebonden plaat en stuc aan beide kanten, fundering);€/m²;140;;;
|
||||
vloer.afwerkingM2;Vloerafwerking;€/m²;35;;;
|
||||
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
|
||||
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
|
||||
|
||||
|
+98
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Tectum
|
||||
Copyright © 2026 Machiel Heinen.
|
||||
Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
Zie LICENTIE.md.
|
||||
-->
|
||||
<!-- Light-versie (modelpagina's), voorlopig los: light.html?model=amstel. Op een modelpagina is alleen het
|
||||
<div class="tectum-light" data-model="…"> plus de stylesheets en scripts hieronder nodig. -->
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Woning samenstellen — Van Emous Mantelzorgwoningen</title>
|
||||
<link rel="stylesheet" href="css/vendor/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="css/light.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 24px; background: #f9faf5; color: #212121; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
.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 img { height: 34px; }
|
||||
.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; } }
|
||||
</style>
|
||||
<script src="js/vendor/three.min.js"></script>
|
||||
<script src="js/vendor/OrbitControls.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<img src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
||||
<!-- Alleen voor het losse testen: op een modelpagina staat het model vast. -->
|
||||
<div>
|
||||
<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 id="configurator" class="tectum-light"></div>
|
||||
</div>
|
||||
|
||||
<script src="js/core/logo-data.js"></script>
|
||||
<script src="js/core/geometry.js"></script>
|
||||
<script src="js/core/view-layers.js"></script>
|
||||
<script src="js/core/ui-kit.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/qrcode.js"></script>
|
||||
<script src="js/core/pdf-sheet.js"></script>
|
||||
<script src="js/wall/kozijn-model.js"></script>
|
||||
<script src="js/wall/wall-model.js"></script>
|
||||
<script src="js/wall/wall-pdf.js"></script>
|
||||
<script src="js/floor/floor-model.js"></script>
|
||||
<script src="js/floor/floor-pdf.js"></script>
|
||||
<script src="js/roof/roof-model.js"></script>
|
||||
<script src="js/roof/post-model.js"></script>
|
||||
<script src="js/lighting/lighting-model.js"></script>
|
||||
<script src="js/elektra/elektra-model.js"></script>
|
||||
<script src="js/sandwich/sandwich-model.js"></script>
|
||||
<script src="js/wall/wall.js"></script>
|
||||
<script src="js/floor/floor.js"></script>
|
||||
<script src="js/unit/unit-model.js"></script>
|
||||
<script src="js/room/room-model.js"></script>
|
||||
<script src="js/interior/fixture-model.js"></script>
|
||||
<script src="js/house/chimney-model.js"></script>
|
||||
<script src="js/house/house-model.js"></script>
|
||||
<script src="js/cost/cost-defaults.js"></script>
|
||||
<script src="js/cost/cost-csv.js"></script>
|
||||
<script src="js/cost/cost-model.js"></script>
|
||||
<script src="js/elektra/elektra-pdf.js"></script>
|
||||
<script src="js/house/house-pdf.js"></script>
|
||||
<script src="js/light/light-model.js"></script>
|
||||
<script src="js/light/light-pdf.js"></script>
|
||||
<script>
|
||||
// Model uit de URL (?model=amstel); de keuzelijst herlaadt de pagina met een ander model.
|
||||
(function () {
|
||||
const key = new URLSearchParams(location.search).get('model');
|
||||
const model = HouseModel.MODELS[key] ? key : Object.keys(HouseModel.MODELS)[0];
|
||||
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.addEventListener('change', () => { location.search = '?model=' + pick.value; });
|
||||
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 src="js/light/light.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user