Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
decbbb4b72 | ||
|
|
51921650e3 | ||
|
|
89cadbebf7 | ||
|
|
ab0dca9bb5 |
@@ -18,17 +18,19 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
|||||||
## Structuur
|
## Structuur
|
||||||
|
|
||||||
```
|
```
|
||||||
index.html app-shell: ribbon, splashscreen
|
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
||||||
css/app.css alle stijl
|
css/app.css alle stijl
|
||||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF
|
js/vendor/ three.js r128, eigen OrbitControls, jsPDF, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF)
|
||||||
js/core/ gedeelde helpers: geometrie, bouwfasen, ribbon, UI-kit, PDF-bladen,
|
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
|
DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver
|
||||||
(obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen, weergavefilter,
|
(obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen,
|
||||||
skybox, licentie, logo
|
skybox, licentie, logo
|
||||||
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
||||||
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
||||||
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
|
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,
|
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
|
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;
|
balken), de beplating eindigt op het kader; onderbreekt de dakrand; een nokbalk stopt ervoor;
|
||||||
@@ -43,6 +45,9 @@ js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
|
|||||||
js/house/ HouseModel (woning) + woning-PDF + CAD-export (house-dxf.js) + BIM-export (house-ifc.js) + woning-editor
|
js/house/ HouseModel (woning) + woning-PDF + CAD-export (house-dxf.js) + BIM-export (house-ifc.js) + woning-editor
|
||||||
js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
|
js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
|
||||||
js/app.js menubalk, versienummer, actieve editor
|
js/app.js menubalk, versienummer, actieve editor
|
||||||
|
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)
|
json/defaults/ referentie-JSON's (niet runtime ingeladen)
|
||||||
kosten/ intern.csv (eigen waarden) + leveranciers.csv (live prijzen), bij het starten ingeladen
|
kosten/ intern.csv (eigen waarden) + leveranciers.csv (live prijzen), bij het starten ingeladen
|
||||||
assets/ logo, texturen, skybox
|
assets/ logo, texturen, skybox
|
||||||
@@ -57,9 +62,11 @@ assets/ logo, texturen, skybox
|
|||||||
- **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
|
- **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
|
||||||
links = west, rechts = oost (interne sleutels blijven voor/achter/…).
|
links = west, rechts = oost (interne sleutels blijven voor/achter/…).
|
||||||
- **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
|
- **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
|
||||||
- **Bouwfasen** (`BuildStages`): regelwerk → elektra (buizen, dozen, meterkast) → beplating → vochtdoek →
|
- **Weergavelagen** (`ViewLayers`): regelwerk, elektra, beplating, vochtdoek,
|
||||||
latwerk → gevel & dak → afgewerkt, de volgorde in de hal. De 3D toont de
|
latwerk, gevelbekleding, kozijnen, dakbedekking, afwerking, verlichting,
|
||||||
gekozen fase; lagen die daarin aan beide zijden zijn ingesloten worden niet
|
inrichting, ruimtes, dak en maten, elk los aan/uit; de snelkeuze Fase zet
|
||||||
|
alles tot en met een bouwfase aan (de volgorde in de hal). Lagen die aan
|
||||||
|
beide zijden zijn ingesloten worden niet
|
||||||
gebouwd (stijlen tussen spaanplaat en doek, latten en doek achter de rabat,
|
gebouwd (stijlen tussen spaanplaat en doek, latten en doek achter de rabat,
|
||||||
sporen tussen plafond en dakbedekking). Specificaties en totalen rekenen
|
sporen tussen plafond en dakbedekking). Specificaties en totalen rekenen
|
||||||
altijd met alle lagen.
|
altijd met alle lagen.
|
||||||
@@ -82,7 +89,11 @@ assets/ logo, texturen, skybox
|
|||||||
en plafond naar de meterkast (strekkende meters per type, draad, buis,
|
en plafond naar de meterkast (strekkende meters per type, draad, buis,
|
||||||
steekverbinders op module-naden), materiaallijst en eigen PDF-blad.
|
steekverbinders op module-naden), materiaallijst en eigen PDF-blad.
|
||||||
Buizen minstens 50 mm uit elkaar (gebundeld alleen het laatste stuk naar
|
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
|
PDF-bladen per wand, vloer en dakvlak (plafond). Alles verplaatsbaar door
|
||||||
te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per
|
te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per
|
||||||
meter, boorgaten (geteld op stijlen, regels en balken), dozen,
|
meter, boorgaten (geteld op stijlen, regels en balken), dozen,
|
||||||
@@ -150,8 +161,16 @@ assets/ logo, texturen, skybox
|
|||||||
- **Overkapping:** bij een dak over de hele woning met `cover` loopt het
|
- **Overkapping:** bij een dak over de hele woning met `cover` loopt het
|
||||||
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
|
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
|
||||||
terras naast een kortere module), met een afgewerkte onderplaat in de
|
terras naast een kortere module), met een afgewerkte onderplaat in de
|
||||||
kleur van het boeideel (`HouseModel.coverExtensions`).
|
kleur van het boeideel (`HouseModel.coverExtensions`). Palen
|
||||||
- **Dak over de hele woning:** tabblad Dak › "Over hele woning": één
|
(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.
|
||||||
|
- **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één
|
||||||
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle
|
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle
|
||||||
units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden
|
units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden
|
||||||
de wolfseinden kilkepers vragen, dan wordt het een zadeldak), bijv. een
|
de wolfseinden kilkepers vragen, dan wordt het een zadeldak), bijv. een
|
||||||
@@ -185,12 +204,20 @@ assets/ logo, texturen, skybox
|
|||||||
aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak
|
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
|
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
|
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).
|
||||||
- **Gevel:** gevelbekleding per woning, met override per wand en per
|
- **Gevel:** gevelbekleding per woning, met override per wand en per
|
||||||
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
|
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
|
||||||
getekend als plankentextuur die over naden doorloopt), steen (181
|
getekend als plankentextuur die over naden doorloopt), steen (181
|
||||||
texturen) of stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
|
texturen), stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
|
||||||
per woning, `WallModel.STUC`). Een zijde kan gesplitst worden (wand ›
|
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):
|
Wandstukken › Splitsen op, gemeten vanaf de linker vloerhoek van buitenaf):
|
||||||
elk stuk wordt een eigen wand-element en kan een eigen gevel krijgen
|
elk stuk wordt een eigen wand-element en kan een eigen gevel krijgen
|
||||||
(bijv. stuc links, hout rechts). Steen is overal een
|
(bijv. stuc links, hout rechts). Steen is overal een
|
||||||
@@ -209,7 +236,7 @@ assets/ logo, texturen, skybox
|
|||||||
(o.a. tegels), FIBO-wandpanelen (decor, tot een hoogte of tot het plafond),
|
(o.a. tegels), FIBO-wandpanelen (decor, tot een hoogte of tot het plafond),
|
||||||
eigen verfkleur en een vals plafond op een vaste hoogte (alleen waar het
|
eigen verfkleur en een vals plafond op een vaste hoogte (alleen waar het
|
||||||
dak hoger ligt; wandafwerking en binnenspots volgen). Laag in de
|
dak hoger ligt; wandafwerking en binnenspots volgen). Laag in de
|
||||||
plattegrond (Beeld › Tonen: Ruimtes), label met netto m², in de structuur,
|
plattegrond (Lagen › Ruimtes), label met netto m², in de structuur,
|
||||||
het rapport, de kosten (vals plafond, FIBO) en op de PDF-plattegrond met
|
het rapport, de kosten (vals plafond, FIBO) en op de PDF-plattegrond met
|
||||||
een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee.
|
een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee.
|
||||||
- **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset
|
- **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset
|
||||||
@@ -217,8 +244,8 @@ assets/ logo, texturen, skybox
|
|||||||
(douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met
|
(douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met
|
||||||
koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat,
|
koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat,
|
||||||
kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil,
|
kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil,
|
||||||
salontafel, tv-meubel, boiler en wasmachine. Binnen › Inrichting: kies
|
salontafel, tv-meubel, boiler en wasmachine. Gereedschap Inrichting (I): kies
|
||||||
een stuk en klik in de plattegrond; slepen, bij een wand snapt de rug op
|
een stuk in de werkbalk en klik in de plattegrond; slepen, bij een wand snapt de rug op
|
||||||
het wandvlak (Alt = vrij), D = draaien, Del = verwijderen. Per stuk maten
|
het wandvlak (Alt = vrij), D = draaien, Del = verwijderen. Per stuk maten
|
||||||
en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D
|
en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D
|
||||||
(afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep
|
(afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep
|
||||||
@@ -226,26 +253,38 @@ assets/ logo, texturen, skybox
|
|||||||
schuift mee.
|
schuift mee.
|
||||||
- **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
|
- **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
|
||||||
override per wand) en binnenspots in een raster per unit.
|
override per wand) en binnenspots in een raster per unit.
|
||||||
- **Ribbon** (`core/ribbon.js`, vervangt de menubalk): Bestand · Start ·
|
- **Indeling** (zelfde patroon als CAD/BIM-pakketten; elke functie op één plek):
|
||||||
Gevel · Dak · Binnen · Constructie · Verlichting · Rapport · Beeld · Extra,
|
bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren,
|
||||||
plus een gekleurd tabblad voor de selectie (unit, wandstuk, binnenwand of
|
projectgegevens, kosten & tijden) · Beeld · Help, de geopende ontwerpen als
|
||||||
naadwand) dat vanzelf opent. Velden staan in groepen naast elkaar, met
|
tabs en de knop Opslaan; links de structuur (groepen in-/uitklapbaar en
|
||||||
schuiven voor maten met een logisch bereik. Dubbelklik op een tabblad
|
onthouden; de groep met de selectie klapt vanzelf open); in het midden een werkbalk met
|
||||||
klapt de ribbon in. Plattegrond en 3D gebruiken de volle breedte; tabblad
|
gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, met de
|
||||||
Rapport toont het rapport (transport per unit, gewicht, afmetingen,
|
opties van het actieve gereedschap ernaast; Unit toevoegen) en weergave
|
||||||
kosten, hout, plaatwerk, met staafdiagrammen) in plaats van de plattegrond.
|
(Lagen, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D /
|
||||||
- **3D:** schuif Bouwfase linksonder; Beeld › Tonen: Dak/Lampen; Beeld ›
|
3D met 1/2/3); rechts het eigenschappenpaneel: de selectie (unit,
|
||||||
Doorsnede (wanden afgesneden op een instelbare hoogte, schuif in de ribbon
|
wandstuk, binnenwand, ruimte, inrichting, elektra) met kruimelpad terug,
|
||||||
en onder de 3D, snede wit afgedekt), binnenzijde in een
|
of zonder selectie de woning (kerncijfers en inklapbare secties Gevel,
|
||||||
verfkleur (afgewerkte fase), omgeving Ontwerp/Dag/Nacht, Render met lampen
|
Dak, Binnen, Verlichting, Elektra, Constructie); onderin een statusbalk
|
||||||
als echte lichtbronnen.
|
met de hint van het gereedschap en meldingen. Wand- en vloer/dak-editor
|
||||||
|
hebben dezelfde indeling (werkbalk boven, paneel met tabbladen en
|
||||||
|
Resultaat rechts; binnen de woning met "← Woning" of Esc terug). Help ›
|
||||||
|
Sneltoetsen (?) toont alle toetsen. Rapport toont transport per unit,
|
||||||
|
gewicht, afmetingen, kosten, hout en plaatwerk in plaats van de
|
||||||
|
plattegrond, als masonry (kaart in de kortste kolom, leesvolgorde blijft).
|
||||||
|
- **3D en plattegrond:** één lagenmenu (Lagen in de werkbalk, met bouwfase
|
||||||
|
als snelkeuze) voor 3D en plattegrond; plattegrond zoomt met het
|
||||||
|
scrollwiel en de knoppen rechtsonder (F = passend), verschuiven met
|
||||||
|
slepen; Doorsnede (wanden afgesneden op een instelbare hoogte, schuif in
|
||||||
|
de werkbalk, snede wit afgedekt), binnenzijde in een verfkleur (afgewerkte
|
||||||
|
fase), omgeving Ontwerp/Dag/Nacht, Render met lampen als echte
|
||||||
|
lichtbronnen.
|
||||||
- **Transport:** per unit buitenmaat (incl. gevelafwerking), hoogte en
|
- **Transport:** per unit buitenmaat (incl. gevelafwerking), hoogte en
|
||||||
gewicht (vloer/wanden/dak) met een indicatieve breedteklasse.
|
gewicht (vloer/wanden/dak) met een indicatieve breedteklasse.
|
||||||
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
|
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
|
||||||
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
|
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
|
||||||
vloeren, wandstukken, binnenwanden, totalen).
|
vloeren, wandstukken, binnenwanden, totalen).
|
||||||
- **CAD-export (DXF):** Bestand › Exporteren › CAD (DXF), ook in de ribbon
|
- **CAD-export (DXF):** Bestand › Exporteren › CAD (DXF); los van
|
||||||
Woning › Uitvoer; los van Opslaan/Ctrl+S. DXF R12 (AC1009), opent in elk
|
Opslaan/Ctrl+S. DXF R12 (AC1009), opent in elk
|
||||||
CAD-pakket (AutoCAD, BricsCAD, DraftSight, LibreCAD, …); DWG maakt het
|
CAD-pakket (AutoCAD, BricsCAD, DraftSight, LibreCAD, …); DWG maakt het
|
||||||
CAD-pakket zelf met Opslaan als (echte DWG schrijven vergt een GPL- of
|
CAD-pakket zelf met Opslaan als (echte DWG schrijven vergt een GPL- of
|
||||||
commerciële bibliotheek). Modelruimte 1:1 in mm: plattegrond met noord
|
commerciële bibliotheek). Modelruimte 1:1 in mm: plattegrond met noord
|
||||||
@@ -281,16 +320,66 @@ assets/ logo, texturen, skybox
|
|||||||
(`HouseIfc` COLORS). X = oost, Y = noord,
|
(`HouseIfc` COLORS). X = oost, Y = noord,
|
||||||
Z = 0 op peil; zuidwesthoek van de units = 0,0 (zelfde als de DXF).
|
Z = 0 op peil; zuidwesthoek van de units = 0,0 (zelfde als de DXF).
|
||||||
- **Render-export (OBJ):** Bestand › Exporteren › Render (OBJ), ook in Woning ›
|
- **Render-export (OBJ):** Bestand › Exporteren › Render (OBJ), ook in Woning ›
|
||||||
Uitvoer. De volledige afgewerkte woning (laatste bouwfase, dak, dakrand,
|
Uitvoer. Voor een sfeerimpressie: alleen wat in de afgewerkte woning te zien
|
||||||
lampen, ruimtes, inrichting; los van de gekozen fase, doorsnede en
|
is (dak, dakrand, lampen, ruimtes, inrichting; los van de gekozen lagen,
|
||||||
weergave-opties; zonder logobord, grond en lucht) als ZIP met `.obj`, `.mtl`
|
doorsnede en weergave-opties; zonder buizen en dozen, binnenste vloerbalken,
|
||||||
en `textures/*.png` (rabat, steen, riet, tegels). Meters, Y omhoog (de
|
latten en isolatie achter de gevel, lichtkegels, logobord, grond en lucht)
|
||||||
|
als ZIP met `.obj`, `.mtl`, `textures/*.png` (rabat, steen, riet, tegels) en
|
||||||
|
`<naam>_lampen.py`: in Blender uitvoeren importeert de OBJ en zet elke lamp
|
||||||
|
(gevellamp, dakrandspot, binnenspot) als echte spotlight met dezelfde plek,
|
||||||
|
richting en kegel als in de app (`LightingModel.lightSources`). Meters, Y omhoog (de
|
||||||
standaard OBJ-import van Blender zet dat goed: noord = +Y), oorsprong op
|
standaard OBJ-import van Blender zet dat goed: noord = +Y), oorsprong op
|
||||||
maaiveld onder het midden. Kleuren omgerekend van sRGB naar lineair (de app
|
maaiveld onder het midden. Kleuren omgerekend van sRGB naar lineair (de app
|
||||||
heeft geen kleurbeheer, Blender wel), PBR via `Pr`/`Pm`/`d`/`Ke` (ruwheid,
|
heeft geen kleurbeheer, Blender wel), PBR via `Pr`/`Pm`/`d`/`Ke` (ruwheid,
|
||||||
metaal, doorzichtigheid glas, gloed lampen); textuurherhaling zit in de
|
metaal, doorzichtigheid glas, gloed lampen); textuurherhaling zit in de
|
||||||
UV's (`ObjWriter`).
|
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 })`.
|
||||||
|
|
||||||
|
- **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), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele
|
||||||
|
woning alleen als de units samen een rechthoek vormen (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. 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`.
|
||||||
|
|
||||||
## Kosten en prijzen (CSV)
|
## Kosten en prijzen (CSV)
|
||||||
|
|
||||||
Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die
|
Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die
|
||||||
@@ -340,7 +429,7 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
|
|||||||
pendiktes via de plotstijl. Geen doorsnede en geen dakaanzicht. Eenheid
|
pendiktes via de plotstijl. Geen doorsnede en geen dakaanzicht. Eenheid
|
||||||
staat niet in het bestand: in CAD op millimeters zetten (UNITS) als het
|
staat niet in het bestand: in CAD op millimeters zetten (UNITS) als het
|
||||||
pakket erom vraagt.
|
pakket erom vraagt.
|
||||||
- OBJ: geen lichtbronnen (lampen alleen als gloeiend materiaal), geen
|
- OBJ: lichtbronnen alleen via het meegeleverde Blender-script, geen
|
||||||
reliëf-/normaalkaarten; glas als doorzichtig materiaal, in Blender eventueel
|
reliëf-/normaalkaarten; glas als doorzichtig materiaal, in Blender eventueel
|
||||||
op Glass BSDF zetten.
|
op Glass BSDF zetten.
|
||||||
- IFC: onderdelen zijn driehoeksnetten (niet parametrisch); geen
|
- IFC: onderdelen zijn driehoeksnetten (niet parametrisch); geen
|
||||||
@@ -376,7 +465,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
|
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
|
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
|
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
|
`HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden
|
||||||
daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die
|
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
|
`modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft
|
||||||
|
|||||||
+318
-228
@@ -39,30 +39,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#app-container { display: flex; width: 100%; height: 100%; }
|
|
||||||
|
|
||||||
#left-column {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
#right-column {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app-container.fullscreen-3d #left-column { display: none; }
|
|
||||||
#app-container.fullscreen-2d #right-column { display: none; }
|
|
||||||
|
|
||||||
#pane-2d-container {
|
#pane-2d-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -85,14 +61,6 @@
|
|||||||
.pane-2d-half { flex: 1; position: relative; min-width: 0; min-height: 0; background: var(--viewport-bg); }
|
.pane-2d-half { flex: 1; position: relative; min-width: 0; min-height: 0; background: var(--viewport-bg); }
|
||||||
.pane-2d-half + .pane-2d-half { border-left: 1px solid var(--border); }
|
.pane-2d-half + .pane-2d-half { border-left: 1px solid var(--border); }
|
||||||
|
|
||||||
#control-panel {
|
|
||||||
height: 380px;
|
|
||||||
background: var(--van-emous-ultra-light);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--van-emous-blue);
|
background: var(--van-emous-blue);
|
||||||
@@ -148,39 +116,6 @@
|
|||||||
}
|
}
|
||||||
.tab-content.active { display: block; }
|
.tab-content.active { display: block; }
|
||||||
|
|
||||||
#pane-3d { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); }
|
|
||||||
|
|
||||||
#specs-panel-3d {
|
|
||||||
height: 380px;
|
|
||||||
background: var(--van-emous-ultra-light);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 15px 20px;
|
|
||||||
}
|
|
||||||
#specs-panel-3d .controls-grid { grid-template-columns: 1fr 1fr; }
|
|
||||||
|
|
||||||
.toolbar-3d {
|
|
||||||
position: absolute;
|
|
||||||
top: 15px;
|
|
||||||
right: 15px;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.btn-3d-toggle {
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
border: 1px solid var(--primary-on-dark);
|
|
||||||
color: var(--primary-on-dark);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-3d-toggle.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
|
||||||
|
|
||||||
.pane-label {
|
.pane-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 15px;
|
top: 15px;
|
||||||
@@ -292,28 +227,6 @@
|
|||||||
.material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); }
|
.material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); }
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
|
|
||||||
#tab-bar {
|
|
||||||
height: 34px;
|
|
||||||
background: var(--van-emous-ultra-light);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.tab-item {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
padding: 0 12px; font-size: 12px; color: var(--text-muted);
|
|
||||||
border-right: 1px solid var(--border-soft); border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.tab-item:hover { color: var(--text); }
|
|
||||||
.tab-item.active { background: var(--surface); color: var(--text); border-bottom: 2px solid var(--primary); }
|
|
||||||
.tab-item .tab-close {
|
|
||||||
color: var(--text-faint); font-size: 14px; line-height: 1; padding: 2px;
|
|
||||||
}
|
|
||||||
.tab-item .tab-close:hover { color: var(--danger); }
|
|
||||||
|
|
||||||
body { display: flex; flex-direction: column; }
|
|
||||||
#tab-bar { flex: none; }
|
|
||||||
#workspace { width: 100vw; flex: 1; min-height: 0; position: relative; }
|
#workspace { width: 100vw; flex: 1; min-height: 0; position: relative; }
|
||||||
#workspace-empty {
|
#workspace-empty {
|
||||||
height: 100%; display: flex; align-items: center; justify-content: center;
|
height: 100%; display: flex; align-items: center; justify-content: center;
|
||||||
@@ -433,29 +346,6 @@ body { display: flex; flex-direction: column; }
|
|||||||
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
|
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
|
||||||
.new-option.disabled { opacity: .4; cursor: not-allowed; }
|
.new-option.disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
#floor-app { display: flex; width: 100%; height: 100%; }
|
|
||||||
#floor-left-column {
|
|
||||||
flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
#floor-right-column { flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; }
|
|
||||||
#floor-app.fullscreen-3d #floor-left-column { display: none; }
|
|
||||||
#floor-app.fullscreen-2d #floor-right-column { display: none; }
|
|
||||||
#floor-pane-2d-container { flex: 1; display: flex; flex-direction: column; min-height: 0; width: 100%; }
|
|
||||||
#floor-control-panel { height: 380px; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); display: flex; flex-direction: column; }
|
|
||||||
#floor-control-panel .tab-content { padding: 15px 20px; overflow-y: auto; flex: 1; display: none; }
|
|
||||||
#floor-control-panel .tab-content.active { display: block; }
|
|
||||||
#floor-pane-3d { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); }
|
|
||||||
#floor-specs-panel-3d {
|
|
||||||
height: 380px; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-y: auto; padding: 15px 20px;
|
|
||||||
}
|
|
||||||
#floor-specs-panel-3d .controls-grid { grid-template-columns: 1fr 1fr; }
|
|
||||||
|
|
||||||
#house-app { display: flex; width: 100%; height: 100%; }
|
|
||||||
#house-nav {
|
|
||||||
width: 240px; flex: none; display: flex; flex-direction: column; gap: 6px;
|
|
||||||
background: var(--van-emous-blue); border-right: 1px solid var(--border); padding: 10px 8px; overflow-y: auto;
|
|
||||||
}
|
|
||||||
.hn-title { font-size: 12px; font-weight: bold; color: var(--primary); text-transform: uppercase; letter-spacing: .8px; padding: 2px 6px 6px; }
|
.hn-title { font-size: 12px; font-weight: bold; color: var(--primary); text-transform: uppercase; letter-spacing: .8px; padding: 2px 6px 6px; }
|
||||||
#house-nav-tree { display: flex; flex-direction: column; gap: 2px; }
|
#house-nav-tree { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.hn-group { margin-top: 6px; }
|
.hn-group { margin-top: 6px; }
|
||||||
@@ -475,50 +365,20 @@ body { display: flex; flex-direction: column; }
|
|||||||
.hn-item.disabled:hover { background: none; color: #9aa4b0; }
|
.hn-item.disabled:hover { background: none; color: #9aa4b0; }
|
||||||
.hn-actions { display: flex; gap: 6px; margin-top: 10px; }
|
.hn-actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||||
.hn-actions .io-btn { flex: 1; }
|
.hn-actions .io-btn { flex: 1; }
|
||||||
#house-nav .io-status { margin: auto 0 0; padding: 6px; }
|
|
||||||
|
|
||||||
#house-part-host { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
||||||
#house-part-host > #app-container,
|
|
||||||
#house-part-host > #floor-app,
|
|
||||||
#house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; }
|
|
||||||
|
|
||||||
#house-overview { display: flex; width: 100%; }
|
|
||||||
.ho-views { flex: 1; display: flex; min-width: 0; min-height: 0; }
|
|
||||||
.ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); }
|
|
||||||
#ho-pane-plan { border-right: 1px solid var(--border); }
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.ho-views { flex-direction: column; }
|
|
||||||
#ho-pane-plan, .ho-report { border-right: none; border-bottom: 1px solid var(--border); }
|
|
||||||
}
|
|
||||||
#house-overview.fullscreen-3d #ho-pane-plan, #house-overview.fullscreen-3d .ho-report { display: none !important; }
|
|
||||||
#house-overview.fullscreen-2d #ho-pane-3d { display: none; }
|
|
||||||
.ho-report { display: none; flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 14px 18px 20px; background: var(--van-emous-ultra-light); border-right: 1px solid var(--border); }
|
.ho-report { display: none; flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 14px 18px 20px; background: var(--van-emous-ultra-light); border-right: 1px solid var(--border); }
|
||||||
#house-overview.show-report .ho-report { display: block; }
|
#house-overview.show-report .ho-report { display: block; }
|
||||||
#house-overview.show-report #ho-pane-plan { display: none; }
|
#house-overview.show-report #ho-pane-plan { display: none; }
|
||||||
.ho-report .ui-spec-grid { grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); align-items: start; }
|
|
||||||
.ho-report-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
|
.ho-report-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
|
||||||
.ho-report-head h1 { margin: 0; font-size: 14px; }
|
.ho-report-head h1 { margin: 0; font-size: 14px; }
|
||||||
.ho-report-head span { font-size: 12px; color: var(--text-muted); }
|
.ho-report-head span { font-size: 12px; color: var(--text-muted); }
|
||||||
.ho-warn { position: absolute; top: 52px; left: 15px; right: 15px; z-index: 9; pointer-events: none; }
|
.ho-warn { position: absolute; top: 52px; left: 15px; right: 15px; z-index: 9; pointer-events: none; }
|
||||||
.ho-warn .ui-warn { margin: 0 0 6px; max-width: 560px; }
|
.ho-warn .ui-warn { margin: 0 0 6px; max-width: 560px; }
|
||||||
.ho-hint {
|
|
||||||
position: absolute; left: 15px; bottom: 10px; font-size: 11px; color: #bbb;
|
|
||||||
background: rgba(0, 0, 0, .6); padding: 3px 8px; border-radius: 4px; pointer-events: none;
|
|
||||||
}
|
|
||||||
.ho-prop-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
.ho-prop-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||||
.io-btn-danger { border-color: #e3b4b0; color: var(--danger); }
|
.io-btn-danger { border-color: #e3b4b0; color: var(--danger); }
|
||||||
.io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
|
.io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
|
||||||
.control-group input:disabled { opacity: .5; cursor: not-allowed; background: var(--border-soft); }
|
.control-group input:disabled { opacity: .5; cursor: not-allowed; background: var(--border-soft); }
|
||||||
|
|
||||||
.view-filter { position: relative; }
|
|
||||||
.vf-menu {
|
|
||||||
position: absolute; right: 0; top: 100%; margin-top: 6px; min-width: 210px; display: none;
|
|
||||||
background: rgba(0, 0, 0, 0.88); border: 1px solid #444; border-radius: 4px; padding: 8px 12px; z-index: 20;
|
|
||||||
}
|
|
||||||
.vf-menu.open { display: block; }
|
|
||||||
.vf-item { display: flex; align-items: center; gap: 8px; color: #eee; font-size: 13px; padding: 4px 0; cursor: pointer; user-select: none; }
|
|
||||||
.vf-item input { accent-color: var(--primary-on-dark); width: 15px; height: 15px; cursor: pointer; }
|
|
||||||
|
|
||||||
.bi { line-height: 1; vertical-align: -0.125em; }
|
.bi { line-height: 1; vertical-align: -0.125em; }
|
||||||
.ui-section { margin-bottom: 14px; }
|
.ui-section { margin-bottom: 14px; }
|
||||||
.ui-section h2, .ui-spec-card h2 {
|
.ui-section h2, .ui-spec-card h2 {
|
||||||
@@ -574,7 +434,6 @@ body { display: flex; flex-direction: column; }
|
|||||||
.ui-tab-page { display: none; }
|
.ui-tab-page { display: none; }
|
||||||
.ui-tab-page.active { display: block; }
|
.ui-tab-page.active { display: block; }
|
||||||
|
|
||||||
.ui-spec-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
|
|
||||||
.ui-spec-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 6px; padding: 10px 12px; }
|
.ui-spec-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 6px; padding: 10px 12px; }
|
||||||
.ui-spec-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); }
|
.ui-spec-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); }
|
||||||
.ui-spec-row:last-child { border-bottom: none; }
|
.ui-spec-row:last-child { border-bottom: none; }
|
||||||
@@ -583,12 +442,10 @@ body { display: flex; flex-direction: column; }
|
|||||||
.ui-spec-total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 2px; }
|
.ui-spec-total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 2px; }
|
||||||
.ui-spec-total .ui-spec-val { color: var(--primary); }
|
.ui-spec-total .ui-spec-val { color: var(--primary); }
|
||||||
|
|
||||||
.btn-3d-toggle { display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.hn-item .bi { margin-right: 6px; color: var(--text-muted); }
|
.hn-item .bi { margin-right: 6px; color: var(--text-muted); }
|
||||||
.hn-item.active .bi, .hn-item:hover .bi { color: var(--primary); }
|
.hn-item.active .bi, .hn-item:hover .bi { color: var(--primary); }
|
||||||
.hn-label { display: inline-flex; align-items: center; }
|
.hn-label { display: inline-flex; align-items: center; }
|
||||||
|
|
||||||
.render-only #specs-panel-3d, .render-only #floor-specs-panel-3d { display: none; }
|
|
||||||
|
|
||||||
.control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; }
|
.control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; }
|
||||||
.control-group label { font-size: 12.5px; color: var(--text); }
|
.control-group label { font-size: 12.5px; color: var(--text); }
|
||||||
@@ -606,9 +463,6 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
|||||||
.hn-title { display: flex; align-items: center; gap: 6px; }
|
.hn-title { display: flex; align-items: center; gap: 6px; }
|
||||||
.file-protocol-warning { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 9000; max-width: 640px; box-shadow: 0 8px 20px rgba(33,33,33,.2); }
|
.file-protocol-warning { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 9000; max-width: 640px; box-shadow: 0 8px 20px rgba(33,33,33,.2); }
|
||||||
.file-protocol-warning code { background: rgba(0,0,0,.06); padding: 0 4px; border-radius: 3px; }
|
.file-protocol-warning code { background: rgba(0,0,0,.06); padding: 0 4px; border-radius: 3px; }
|
||||||
.vf-option { gap: 9px; }
|
|
||||||
.vf-option.active { color: var(--primary-on-dark); font-weight: 600; }
|
|
||||||
.vf-menu .vf-option:hover { color: var(--primary-on-dark); }
|
|
||||||
|
|
||||||
.hs-stone-preview { display: block; width: 128px; height: auto; margin: 4px 0 8px; border: 1px solid var(--border, #ccc); border-radius: 3px; }
|
.hs-stone-preview { display: block; width: 128px; height: auto; margin: 4px 0 8px; border: 1px solid var(--border, #ccc); border-radius: 3px; }
|
||||||
|
|
||||||
@@ -635,21 +489,13 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
|||||||
.ui-range input[type=number] { width: 84px; }
|
.ui-range input[type=number] { width: 84px; }
|
||||||
.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
|
.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
|
||||||
|
|
||||||
.stage-bar {
|
.plan-zoom { position: absolute; right: 12px; bottom: 12px; z-index: 10; display: flex; flex-direction: column; gap: 4px; }
|
||||||
position: absolute; left: 15px; bottom: 14px; z-index: 10; min-width: 300px; max-width: calc(100% - 30px);
|
.plan-zoom button {
|
||||||
background: rgba(0, 0, 0, 0.75); border: 1px solid #444; border-radius: 6px; padding: 7px 12px 6px; color: #eee; font-size: 12px;
|
width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||||
|
background: rgba(0, 0, 0, 0.75); border: 1px solid #555; border-radius: 4px; color: #eee; font-size: 14px;
|
||||||
}
|
}
|
||||||
.stage-row, .stage-extra { display: flex; align-items: center; gap: 10px; }
|
.plan-zoom button:hover { border-color: var(--primary-on-dark); color: var(--primary-on-dark); }
|
||||||
.stage-title { display: inline-flex; align-items: center; gap: 6px; color: var(--primary-on-dark); font-weight: 600; white-space: nowrap; }
|
.plan-zoom-val { text-align: center; font-size: 10px; color: #aaa; }
|
||||||
.stage-range { flex: 1; min-width: 90px; accent-color: var(--primary-on-dark); }
|
|
||||||
.stage-name { min-width: 84px; font-weight: 600; white-space: nowrap; }
|
|
||||||
.stage-track { flex: 1; display: flex; flex-direction: column; min-width: 120px; }
|
|
||||||
.stage-track .stage-range { width: 100%; margin: 0; }
|
|
||||||
.stage-ticks { display: flex; justify-content: space-between; padding: 0 3px; font-size: 10px; color: #888; }
|
|
||||||
.stage-ticks span { cursor: pointer; padding: 0 3px; }
|
|
||||||
.stage-ticks span.on { color: var(--primary-on-dark); }
|
|
||||||
.stage-extra { display: none; margin-top: 6px; padding-top: 6px; border-top: 1px solid #444; }
|
|
||||||
.stage-extra.show { display: flex; }
|
|
||||||
|
|
||||||
.viz { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; }
|
.viz { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; }
|
||||||
.viz-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; }
|
.viz-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; }
|
||||||
@@ -671,72 +517,316 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
|||||||
.viz-val { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text); text-align: right; white-space: nowrap; }
|
.viz-val { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text); text-align: right; white-space: nowrap; }
|
||||||
.viz-note { font-size: 11px; color: var(--text-faint); line-height: 1.45; margin-top: 6px; }
|
.viz-note { font-size: 11px; color: var(--text-faint); line-height: 1.45; margin-top: 6px; }
|
||||||
|
|
||||||
#ribbon { flex: none; background: var(--van-emous-blue); border-bottom: 1px solid var(--border); position: relative; z-index: 100; user-select: none; }
|
/* ===== Shell: bovenbalk (menu + documenten), editorindeling, werkbalk, eigenschappen, statusbalk ===== */
|
||||||
.rb-top { display: flex; align-items: flex-end; height: 34px; padding: 0 8px; }
|
:root { --panel-w: 340px; --nav-w: 230px; }
|
||||||
.rb-brand { display: flex; align-items: center; height: 100%; padding: 0 14px 0 4px; font-weight: 700; color: var(--primary); letter-spacing: .5px; }
|
body { display: flex; flex-direction: column; }
|
||||||
.rb-tabs { display: flex; gap: 2px; height: 100%; align-items: flex-end; overflow-x: auto; scrollbar-width: none; }
|
|
||||||
.rb-tab {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 14px; border: 1px solid transparent; border-bottom: none;
|
|
||||||
border-radius: 5px 5px 0 0; background: none; font: inherit; font-size: 12.5px; color: var(--nav-link-color); cursor: pointer; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.rb-tab:hover { background: rgba(255, 255, 255, .55); color: var(--primary); }
|
|
||||||
.rb-tab.active { background: var(--van-emous-ultra-light); border-color: var(--border); color: var(--primary); font-weight: 600; margin-bottom: -1px; height: 29px; }
|
|
||||||
.rb-tab.rb-ctx { color: #fff; background: var(--primary); margin-left: 10px; }
|
|
||||||
.rb-tab.rb-ctx:hover { background: var(--primary-hover); color: #fff; }
|
|
||||||
.rb-tab.rb-ctx.active { background: var(--van-emous-ultra-light); color: var(--primary); box-shadow: inset 0 3px 0 var(--primary); }
|
|
||||||
.rb-right { margin-left: auto; display: flex; align-items: center; height: 100%; gap: 10px; font-size: 11px; color: var(--text-muted); padding-left: 12px; }
|
|
||||||
.rb-body {
|
|
||||||
position: relative; display: flex; align-items: stretch; height: 104px; padding: 6px 40px 4px 6px;
|
|
||||||
background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-x: auto; overflow-y: hidden;
|
|
||||||
}
|
|
||||||
#ribbon.rb-collapsed .rb-body { display: none; }
|
|
||||||
.rb-collapse { position: absolute; right: 8px; bottom: 6px; border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 2px 4px; }
|
|
||||||
.rb-collapse:hover { color: var(--primary); }
|
|
||||||
.rb-group { display: flex; flex-direction: column; flex: none; padding: 0 12px; border-right: 1px solid var(--border-soft); }
|
|
||||||
.rb-group:last-of-type { border-right: none; }
|
|
||||||
.rb-group-body { flex: 1; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 16px; align-items: center; justify-content: start; }
|
|
||||||
.rb-group-label { text-align: center; font-size: 10.5px; color: var(--text-faint); padding-top: 2px; white-space: nowrap; }
|
|
||||||
.rb-btn {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px; border: 1px solid transparent; background: none; border-radius: 4px;
|
|
||||||
font: inherit; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.rb-btn:hover:not(:disabled) { background: var(--primary-soft); border-color: rgba(185, 89, 19, .25); color: var(--primary); }
|
|
||||||
.rb-btn:disabled { color: #a9b2bd; cursor: default; }
|
|
||||||
.rb-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
|
|
||||||
.rb-btn.rb-danger:hover:not(:disabled) { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
|
|
||||||
.rb-small { height: 24px; padding: 0 7px; }
|
|
||||||
.rb-small .bi { font-size: 14px; color: var(--primary); }
|
|
||||||
.rb-btn:disabled .bi { color: inherit; }
|
|
||||||
.rb-big { grid-row: span 3; flex-direction: column; justify-content: center; gap: 4px; min-width: 64px; height: 76px; padding: 4px 8px; text-align: center; }
|
|
||||||
.rb-big .bi { font-size: 24px; color: var(--primary); }
|
|
||||||
.rb-big span { white-space: normal; max-width: 86px; line-height: 1.2; }
|
|
||||||
.rb-kv { display: flex; justify-content: space-between; gap: 12px; font-size: 11.5px; color: var(--text-muted); min-width: 150px; }
|
|
||||||
.rb-kv b { color: var(--text); font-variant-numeric: tabular-nums; }
|
|
||||||
.rb-kv small { font-weight: normal; color: var(--text-faint); margin-left: 2px; }
|
|
||||||
.rb-note { grid-row: span 3; max-width: 260px; font-size: 11px; line-height: 1.4; color: var(--text-muted); white-space: normal; align-self: center; }
|
|
||||||
.rb-note.rb-warn { color: var(--danger); }
|
|
||||||
.rb-group .ui-field { min-height: 0; height: 26px; gap: 8px; }
|
|
||||||
.rb-group .ui-field label { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
|
|
||||||
.rb-group .ui-control input[type=number] { width: 70px; padding: 2px 5px; font-size: 12px; }
|
|
||||||
.rb-group .ui-control select, .rb-group .ui-control input[type=text] { width: 132px; padding: 2px 4px; font-size: 12px; }
|
|
||||||
.rb-group .ui-control input[type=color] { width: 46px; height: 22px; padding: 0; border: 1px solid var(--border); border-radius: 3px; background: none; }
|
|
||||||
.rb-group .ui-unit { width: 20px; margin-left: 3px; font-size: 10.5px; }
|
|
||||||
.rb-group .ui-switch { margin-right: 0; }
|
|
||||||
.rb-group .ui-field-range { flex-wrap: nowrap; padding: 0; }
|
|
||||||
.rb-group .ui-field-range label { flex: none; }
|
|
||||||
.rb-group .ui-range { flex: none; gap: 6px; }
|
|
||||||
.rb-group .ui-range input[type=range] { width: 96px; flex: none; }
|
|
||||||
.rb-group .ui-range input[type=number] { width: 62px; }
|
|
||||||
.rb-thumb { width: 26px; height: 26px; border-radius: 3px; border: 1px solid var(--border); object-fit: cover; display: block; }
|
|
||||||
.rb-swatch { width: 26px; height: 22px; border-radius: 3px; border: 1px solid var(--border); }
|
|
||||||
.rb-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; height: 24px; }
|
|
||||||
.rb-seg .rb-btn { border-radius: 0; border: none; border-right: 1px solid var(--border-soft); height: 22px; padding: 0 8px; }
|
|
||||||
.rb-seg .rb-btn:last-child { border-right: none; }
|
|
||||||
.rb-seg .rb-btn.active { background: var(--primary); color: #fff; }
|
|
||||||
.rb-seg .rb-btn.active .bi { color: #fff; }
|
|
||||||
.rb-check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap; }
|
|
||||||
.rb-check input { accent-color: var(--primary); }
|
|
||||||
|
|
||||||
.rb-pair { display: flex; align-items: center; gap: 6px; }
|
#topbar {
|
||||||
.rb-cols { grid-row: span 3; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 18px; align-items: center; }
|
flex: none; height: 40px; display: flex; align-items: stretch; gap: 4px; padding: 0 10px 0 4px;
|
||||||
.rb-cols:empty { display: none; }
|
background: var(--van-emous-blue); border-bottom: 1px solid var(--border); position: relative; z-index: 200; user-select: none;
|
||||||
|
}
|
||||||
|
.tb-brand { display: flex; align-items: center; padding: 0 12px 0 8px; font-weight: 700; color: var(--primary); letter-spacing: .5px; }
|
||||||
|
#menubar { display: flex; align-items: center; }
|
||||||
|
.mb-menu { position: relative; height: 100%; display: flex; align-items: center; }
|
||||||
|
.mb-top { height: 28px; padding: 0 10px; border: none; background: none; border-radius: 4px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer; }
|
||||||
|
.mb-top:hover, .mb-menu.open .mb-top { background: rgba(255, 255, 255, .7); color: var(--primary); }
|
||||||
|
.mb-drop {
|
||||||
|
display: none; position: absolute; top: calc(100% - 3px); left: 0; min-width: 260px; padding: 4px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 10px 28px rgba(33, 33, 33, .18); z-index: 300;
|
||||||
|
}
|
||||||
|
.mb-menu.open .mb-drop { display: block; }
|
||||||
|
.mb-item {
|
||||||
|
display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 6px; width: 100%; padding: 6px 8px;
|
||||||
|
border: none; background: none; border-radius: 4px; font: inherit; font-size: 13px; color: var(--text); text-align: left; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mb-item:hover:not(:disabled) { background: var(--primary-soft); color: var(--primary); }
|
||||||
|
.mb-item:disabled { color: var(--text-faint); cursor: default; }
|
||||||
|
.mb-ico .bi { color: var(--text-muted); }
|
||||||
|
.mb-item:hover:not(:disabled) .mb-ico .bi, .mb-item.checked .mb-ico .bi { color: var(--primary); }
|
||||||
|
.mb-item:disabled .mb-ico .bi { color: inherit; }
|
||||||
|
.mb-keys { font-size: 11.5px; color: var(--text-faint); padding-left: 18px; white-space: nowrap; }
|
||||||
|
.mb-sep { height: 1px; background: var(--border-soft); margin: 4px 6px; }
|
||||||
|
.mb-head { font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); padding: 6px 8px 2px 36px; }
|
||||||
|
|
||||||
|
#doc-tabs { display: flex; align-items: flex-end; gap: 2px; flex: 1; min-width: 0; margin-left: 14px; overflow: hidden; }
|
||||||
|
.doc-tab {
|
||||||
|
display: flex; align-items: center; gap: 4px; flex: 0 1 auto; min-width: 70px; max-width: 220px; height: 32px; padding: 0 4px 0 12px;
|
||||||
|
border: 1px solid transparent; border-bottom: none; border-radius: 6px 6px 0 0; font-size: 12.5px; color: var(--text-muted); cursor: pointer;
|
||||||
|
}
|
||||||
|
.doc-tab:hover { background: rgba(255, 255, 255, .5); color: var(--text); }
|
||||||
|
.doc-tab.active { background: var(--surface); border-color: var(--border); color: var(--text); font-weight: 600; height: 33px; margin-bottom: -1px; }
|
||||||
|
.doc-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.doc-close { width: 20px; height: 20px; flex: none; border: none; background: none; border-radius: 4px; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; }
|
||||||
|
.doc-close:hover { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.top-right { display: flex; align-items: center; gap: 12px; margin-left: auto; padding-left: 12px; font-size: 11px; color: var(--text-muted); white-space: nowrap; }
|
||||||
|
.top-save {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border: 1px solid var(--primary); border-radius: 4px;
|
||||||
|
background: var(--primary); color: #fff; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.top-save:hover:not(:disabled) { background: var(--primary-hover); }
|
||||||
|
.top-save:disabled { opacity: .45; cursor: default; }
|
||||||
|
|
||||||
|
/* Editoren: [werkbalk + viewports] | paneel rechts. Dezelfde indeling voor woning, wand en vloer/dak. */
|
||||||
|
.ed-app, #house-overview { display: flex; width: 100%; height: 100%; min-height: 0; }
|
||||||
|
.ed-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
.ed-views, .ho-views { flex: 1; min-height: 0; min-width: 0; display: flex; }
|
||||||
|
#left-column, #right-column, #floor-left-column, #floor-right-column { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
#left-column, #floor-left-column { border-right: 1px solid var(--border); }
|
||||||
|
#floor-pane-2d-container { flex: 1; display: flex; flex-direction: column; min-height: 0; width: 100%; }
|
||||||
|
#pane-3d, #floor-pane-3d { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); }
|
||||||
|
.ed-app.fullscreen-3d #left-column, .ed-app.fullscreen-3d #floor-left-column { display: none; }
|
||||||
|
.ed-app.fullscreen-2d #right-column, .ed-app.fullscreen-2d #floor-right-column { display: none; }
|
||||||
|
.ed-panel { width: var(--panel-w); flex: none; min-height: 0; display: flex; flex-direction: column; background: var(--van-emous-ultra-light); border-left: 1px solid var(--border); }
|
||||||
|
.ed-panel .tab-content { padding: 12px 14px; }
|
||||||
|
.ed-panel .controls-grid { grid-template-columns: 1fr; gap: 4px; }
|
||||||
|
.ed-panel .tab-btn { padding: 9px 4px; font-size: 12px; gap: 5px; }
|
||||||
|
.ed-panel .io-toolbar:has(.io-status:empty) { display: none; }
|
||||||
|
#specs-panel-3d, #floor-specs-panel-3d { padding: 0; }
|
||||||
|
body.panels-hidden .ed-panel, body.panels-hidden #house-nav, body.panels-hidden .statusbar { display: none; }
|
||||||
|
|
||||||
|
#house-app { display: flex; flex-direction: column; width: 100%; height: 100%; }
|
||||||
|
.house-body { flex: 1; min-height: 0; display: flex; }
|
||||||
|
#house-nav {
|
||||||
|
width: var(--nav-w); flex: none; display: flex; flex-direction: column; gap: 6px; padding: 10px 8px; overflow-y: auto;
|
||||||
|
background: var(--van-emous-blue); border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
#house-part-host { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
#house-part-host > .ed-app, #house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; }
|
||||||
|
.ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); }
|
||||||
|
#ho-pane-plan { border-right: 1px solid var(--border); }
|
||||||
|
#house-overview.fullscreen-3d #ho-pane-plan, #house-overview.fullscreen-3d .ho-report { display: none !important; }
|
||||||
|
#house-overview.fullscreen-2d #ho-pane-3d { display: none; }
|
||||||
|
@media (max-width: 1250px) {
|
||||||
|
:root { --panel-w: 300px; --nav-w: 190px; }
|
||||||
|
.ho-views, .ed-views { flex-direction: column; }
|
||||||
|
#ho-pane-plan, .ho-report, #left-column, #floor-left-column { border-right: none; border-bottom: 1px solid var(--border); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
flex: none; height: 24px; display: flex; align-items: center; gap: 16px; padding: 0 12px;
|
||||||
|
background: var(--van-emous-blue); border-top: 1px solid var(--border); font-size: 11.5px; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.sb-hint { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.statusbar .io-status { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Werkbalk boven de viewports */
|
||||||
|
.ed-toolbar {
|
||||||
|
flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-height: 40px; padding: 4px 8px;
|
||||||
|
background: var(--surface); border-bottom: 1px solid var(--border); position: relative; z-index: 20; container-type: inline-size;
|
||||||
|
}
|
||||||
|
.tb-group { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.tb-spacer { flex: 1; }
|
||||||
|
.tb-sep { width: 1px; height: 22px; background: var(--border-soft); margin: 0 6px; }
|
||||||
|
.tb-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 9px; border: 1px solid transparent; border-radius: 5px;
|
||||||
|
background: none; font: inherit; font-size: 12.5px; color: var(--text); cursor: pointer; white-space: nowrap; position: relative;
|
||||||
|
}
|
||||||
|
.tb-btn .bi { font-size: 15px; color: var(--text-muted); }
|
||||||
|
.tb-btn .tb-caret { font-size: 10px; }
|
||||||
|
.tb-btn:hover { background: var(--primary-soft); color: var(--primary); }
|
||||||
|
.tb-btn:hover .bi, .tb-btn.active .bi, .tb-btn.open .bi { color: var(--primary); }
|
||||||
|
.tb-btn.active, .tb-btn.open { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
|
||||||
|
.tb-btn.flag::after { content: ''; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--primary); }
|
||||||
|
.tb-title { font-size: 12.5px; font-weight: 600; padding: 0 6px; white-space: nowrap; }
|
||||||
|
.tb-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
|
||||||
|
.tb-seg .tb-btn { height: 28px; padding: 0 8px; border: none; border-radius: 0; }
|
||||||
|
.tb-seg .tb-btn + .tb-btn { border-left: 1px solid var(--border-soft); }
|
||||||
|
.tb-seg .tb-btn.active { background: var(--primary); color: #fff; }
|
||||||
|
.tb-seg .tb-btn.active .bi { color: #fff; }
|
||||||
|
.tb-options:empty { display: none; }
|
||||||
|
.tb-opt { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; padding-left: 10px; border-left: 1px solid var(--border-soft); font-size: 12px; color: var(--text-muted); }
|
||||||
|
.tb-opt select { height: 28px; max-width: 220px; padding: 0 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; color: var(--text); }
|
||||||
|
.tb-range { display: none; align-items: center; gap: 6px; margin-left: 2px; font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.tb-section.on .tb-range { display: inline-flex; }
|
||||||
|
.tb-range input { width: 110px; accent-color: var(--primary); }
|
||||||
|
#ho-section-val { min-width: 54px; }
|
||||||
|
/* Smalle werkbalk: eerst de weergaveknoppen zonder tekst, daarna ook het gereedschap (titel blijft als tooltip). */
|
||||||
|
@container (max-width: 1240px) { .tb-view .tb-btn > span, .tb-section .tb-btn > span, [data-tb="report"] > span { display: none; } }
|
||||||
|
@container (max-width: 860px) { .tb-btn > span, .tb-opt > span { display: none; } }
|
||||||
|
|
||||||
|
.tb-pop-wrap { position: relative; }
|
||||||
|
.tb-pop {
|
||||||
|
display: none; position: absolute; top: calc(100% + 6px); right: 0; min-width: 170px; padding: 6px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 10px 28px rgba(33, 33, 33, .18); z-index: 50;
|
||||||
|
}
|
||||||
|
.tb-pop.open { display: block; }
|
||||||
|
.pop-option {
|
||||||
|
display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border: none; border-radius: 4px; background: none;
|
||||||
|
font: inherit; font-size: 12.5px; color: var(--text); text-align: left; cursor: pointer;
|
||||||
|
}
|
||||||
|
.pop-option:hover { background: var(--primary-soft); color: var(--primary); }
|
||||||
|
.pop-option.active { color: var(--primary); font-weight: 600; }
|
||||||
|
.pop-layers { width: max-content; max-width: min(440px, 90vw); padding: 10px 12px; }
|
||||||
|
.pop-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.pop-phase { flex: 1; height: 28px; padding: 0 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; }
|
||||||
|
.pop-groups { display: grid; grid-template-columns: repeat(2, minmax(170px, 1fr)); gap: 0 18px; }
|
||||||
|
.pop-head { margin: 6px 0 2px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); }
|
||||||
|
.pop-check { display: flex; align-items: center; gap: 7px; padding: 3px 2px; font-size: 12.5px; cursor: pointer; white-space: nowrap; }
|
||||||
|
.pop-check:hover { color: var(--primary); }
|
||||||
|
.pop-check input { margin: 0; accent-color: var(--primary); }
|
||||||
|
.pop-check .bi { width: 14px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Eigenschappenpaneel */
|
||||||
|
.inspector { overflow-y: auto; background: var(--surface); }
|
||||||
|
.ins-head { position: sticky; top: 0; z-index: 2; padding: 12px 14px 10px; background: var(--surface); border-bottom: 1px solid var(--border-soft); }
|
||||||
|
.ins-crumbs { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; font-size: 11.5px; color: var(--text-faint); }
|
||||||
|
.ins-crumbs .bi { font-size: 9px; }
|
||||||
|
.ins-crumb { padding: 0; border: none; background: none; font: inherit; color: var(--primary); cursor: pointer; }
|
||||||
|
.ins-crumb:hover { text-decoration: underline; }
|
||||||
|
.ins-head h2 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; font-weight: 600; color: var(--text); }
|
||||||
|
.ins-head h2 .bi { color: var(--primary); }
|
||||||
|
.ins-sub { margin-top: 2px; font-size: 11.5px; color: var(--text-muted); }
|
||||||
|
.ins-top { padding: 8px 14px 10px; border-bottom: 1px solid var(--border-soft); }
|
||||||
|
.ins-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||||
|
.ins-summary .ins-wide { grid-column: 1 / -1; }
|
||||||
|
.ins-kv { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); font-size: 12px; color: var(--text-muted); }
|
||||||
|
.ins-kv b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
|
.ins-kv small { margin-left: 2px; font-weight: normal; color: var(--text-faint); }
|
||||||
|
.ins-section { border-bottom: 1px solid var(--border-soft); }
|
||||||
|
.ins-section-head {
|
||||||
|
display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 14px; border: none; background: none;
|
||||||
|
font: inherit; font-size: 13px; font-weight: 600; color: var(--text); text-align: left; cursor: pointer;
|
||||||
|
}
|
||||||
|
.ins-section-head:hover { background: var(--van-emous-ultra-light); color: var(--primary); }
|
||||||
|
.ins-section-head > .bi:first-child { width: 16px; color: var(--primary); }
|
||||||
|
.ins-section-head .bi-chevron-down { margin-left: auto; font-size: 11px; color: var(--text-faint); transition: transform .15s; }
|
||||||
|
.ins-section.open .ins-section-head .bi-chevron-down { transform: rotate(180deg); }
|
||||||
|
.ins-collapsible:not(.open) .ins-section-body { display: none; }
|
||||||
|
.ins-section-body { padding: 2px 14px 12px; }
|
||||||
|
.ins-group + .ins-group { margin-top: 8px; }
|
||||||
|
.ins-group h3 { margin: 8px 0 4px; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); }
|
||||||
|
.ins-group-body { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.ins-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; align-self: flex-start; margin: 3px 0; padding: 5px 11px;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.ins-btn .bi { color: var(--primary); }
|
||||||
|
.ins-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.ins-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
|
||||||
|
.ins-btn:disabled { opacity: .5; cursor: default; }
|
||||||
|
.ins-btn.ins-danger { color: var(--danger); border-color: #e3b4b0; }
|
||||||
|
.ins-btn.ins-danger .bi { color: var(--danger); }
|
||||||
|
.ins-btn.ins-danger:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
|
||||||
|
.ins-group-body > .ins-btn + .ins-btn { margin-top: 0; }
|
||||||
|
.ins-note { margin: 4px 0; font-size: 11.5px; line-height: 1.45; color: var(--text-muted); }
|
||||||
|
.ins-note.ins-warn { padding: 6px 8px; border-radius: 4px; background: var(--danger-soft); color: var(--danger); }
|
||||||
|
/* Staal of foto in de eenheid-kolom, zodat het veld uitlijnt met de rest. */
|
||||||
|
.ins-pair { position: relative; }
|
||||||
|
.ins-pair > .ins-thumb, .ins-pair > .ins-swatch { position: absolute; right: 0; top: 50%; transform: translateY(-50%); }
|
||||||
|
.ins-thumb { width: 26px; height: 26px; display: block; border: 1px solid var(--border); border-radius: 3px; object-fit: cover; }
|
||||||
|
.ins-swatch { width: 26px; height: 22px; border: 1px solid var(--border); border-radius: 3px; }
|
||||||
|
.ins-check { display: flex; align-items: center; gap: 7px; padding: 3px 0; font-size: 12.5px; cursor: pointer; }
|
||||||
|
.ins-check input { margin: 0; accent-color: var(--primary); }
|
||||||
|
.ins-cols { display: flex; flex-direction: column; }
|
||||||
|
.ins-cols:empty { display: none; }
|
||||||
|
.inspector .ui-field { min-height: 30px; }
|
||||||
|
.inspector .ui-control input[type=number] { width: 96px; }
|
||||||
|
.inspector .ui-control select, .inspector .ui-control input[type=text] { width: 150px; }
|
||||||
|
.inspector .ui-control input[type=color] { width: 46px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 3px; background: none; }
|
||||||
|
.inspector .ui-switch { margin-right: 0; }
|
||||||
|
.inspector .ui-range input[type=number] { width: 80px; }
|
||||||
|
|
||||||
|
/* Dialogen */
|
||||||
|
.dlg-overlay { position: fixed; inset: 0; z-index: 500; display: none; align-items: center; justify-content: center; background: rgba(33, 33, 33, .45); }
|
||||||
|
.dlg-overlay.open { display: flex; }
|
||||||
|
.dlg {
|
||||||
|
display: flex; flex-direction: column; width: 780px; max-width: 94%; max-height: 88vh;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 12px 30px rgba(33, 33, 33, .2);
|
||||||
|
}
|
||||||
|
.dlg-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px 8px; }
|
||||||
|
.dlg-head h2 { margin: 0; font-size: 15px; color: var(--primary); }
|
||||||
|
.dlg-x { border: none; background: none; font-size: 20px; line-height: 1; color: var(--text-faint); cursor: pointer; }
|
||||||
|
.dlg-x:hover { color: var(--danger); }
|
||||||
|
.keys-body { overflow-y: auto; padding: 4px 20px 18px; columns: 2 300px; column-gap: 28px; }
|
||||||
|
.keys-body section { break-inside: avoid; margin-bottom: 14px; }
|
||||||
|
.keys-body h3 { margin: 0 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); }
|
||||||
|
.keys-row { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); font-size: 12.5px; }
|
||||||
|
.keys-row span:last-child { color: var(--text-muted); text-align: right; }
|
||||||
|
kbd {
|
||||||
|
display: inline-block; min-width: 20px; padding: 1px 6px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px;
|
||||||
|
background: var(--van-emous-ultra-light); font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); text-align: center;
|
||||||
|
}
|
||||||
|
#new-modal { max-width: 92vw; }
|
||||||
|
.new-options { flex-wrap: wrap; max-width: 640px; }
|
||||||
|
.keys-mouse { font-weight: 600; color: var(--text); }
|
||||||
|
#house-nav { overflow-x: hidden; }
|
||||||
|
.hn-item { min-width: 0; }
|
||||||
|
.hn-label { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.hn-meta { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
/* Structuur: inklapbare groepen */
|
||||||
|
.hn-item { align-items: center; }
|
||||||
|
.hn-item .hn-label { flex: 1; }
|
||||||
|
.hn-group:not(.open) .hn-children { display: none; }
|
||||||
|
.hn-head { cursor: pointer; }
|
||||||
|
.hn-head:hover { color: var(--primary); background: rgba(255, 255, 255, .6); }
|
||||||
|
.hn-toggle {
|
||||||
|
flex: none; width: 18px; height: 18px; margin: 0 2px 0 -4px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: none; border-radius: 3px; background: none; color: var(--text-faint); cursor: pointer;
|
||||||
|
}
|
||||||
|
.hn-toggle .bi { margin: 0 !important; font-size: 10px; color: inherit !important; transition: transform .15s; }
|
||||||
|
.hn-group.open > .hn-item .hn-toggle .bi { transform: rotate(90deg); }
|
||||||
|
.hn-toggle:hover { background: rgba(255, 255, 255, .8); color: var(--primary); }
|
||||||
|
.hn-sub { padding-left: 30px; }
|
||||||
|
|
||||||
|
/* Masonry: kaarten in de kortste kolom (leesvolgorde blijft links → rechts) */
|
||||||
|
.ui-masonry { display: flex; align-items: flex-start; gap: 12px; }
|
||||||
|
.ui-masonry-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
/* ===== Gelijktrekken oude onderdelen met de nieuwe stijl ===== */
|
||||||
|
.io-btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; font-family: inherit; font-size: 12px; }
|
||||||
|
.ui-btn.ui-btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
|
||||||
|
|
||||||
|
/* Viewportlabels: herkenbaar maar niet dominant */
|
||||||
|
.pane-label {
|
||||||
|
top: 10px; left: 10px; padding: 3px 8px; border: none; border-radius: 4px; background: rgba(0, 0, 0, .55);
|
||||||
|
font-size: 11px; font-weight: 600; letter-spacing: .8px; color: #c9ced6;
|
||||||
|
}
|
||||||
|
.ho-warn { top: 40px; left: 10px; right: 10px; }
|
||||||
|
|
||||||
|
/* Eigenschappenpaneel: getal naast schuif lijnt uit met gewone getalvelden; bredere keuzelijsten */
|
||||||
|
.inspector .ui-range { gap: 0; }
|
||||||
|
.inspector .ui-range input[type=range] { margin-right: 10px; }
|
||||||
|
.inspector .ui-range input[type=number], .inspector .ui-control input[type=number] { width: 96px; }
|
||||||
|
.inspector .ui-control select, .inspector .ui-control input[type=text] { width: 170px; }
|
||||||
|
.inspector .ui-field label { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* Rapport- en resultaatkaarten: lange waarde schuift onder het label i.p.v. het label te pletten */
|
||||||
|
.ui-spec-row { flex-wrap: wrap; }
|
||||||
|
.ui-spec-val { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Wand-/vloerpaneel: koppen, velden en knoppen zoals het eigenschappenpaneel */
|
||||||
|
.ed-panel h1 { margin: 12px 0 4px; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); }
|
||||||
|
.ed-panel h1 .bi { display: none; }
|
||||||
|
.ed-panel .tab-content > :first-child h1:first-child, .ed-panel .controls-grid > div:first-child > h1:first-child { margin-top: 2px; }
|
||||||
|
.ed-panel .spec-section h1 { margin: 0 0 6px; font-size: 12px; color: var(--primary); letter-spacing: .6px; }
|
||||||
|
.ed-panel .spec-section h1 .bi { display: inline; }
|
||||||
|
.ed-panel .divider { border: none; margin: 4px 0; }
|
||||||
|
.ed-panel .control-group label { flex: 1; min-width: 0; line-height: 1.3; }
|
||||||
|
.ed-panel .control-group > div { display: flex; align-items: center; flex: none; white-space: nowrap; }
|
||||||
|
.ed-panel .control-group .unit { display: inline-block; width: 22px; margin-left: 3px; }
|
||||||
|
.ed-panel .control-group > div > select:last-child, .ed-panel .control-group > div > input:last-child { margin-right: 25px; }
|
||||||
|
.ed-panel .control-group .ui-switch { margin-right: 25px; }
|
||||||
|
.ed-panel .control-group input[type=number], .ed-panel .control-group input[type=text], .ed-panel .control-group select { width: 110px; }
|
||||||
|
.ed-panel .material-info { font-size: 11.5px; }
|
||||||
|
|
||||||
|
.material-toggle-group { flex-wrap: wrap; }
|
||||||
|
.material-toggle-btn {
|
||||||
|
flex: 1 1 auto; min-width: 0; padding: 6px 10px; border-radius: 4px; text-transform: none; letter-spacing: 0;
|
||||||
|
font-family: inherit; font-size: 12.5px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.material-toggle-btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.add-opening-btn {
|
||||||
|
width: 100%; justify-content: center; padding: 7px 10px; background: var(--surface); border: 1px dashed var(--border);
|
||||||
|
color: var(--text); font-weight: 500; font-family: inherit;
|
||||||
|
}
|
||||||
|
.add-opening-btn .bi { color: var(--primary); }
|
||||||
|
.add-opening-btn:hover { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
|
||||||
|
.opening-card-title { text-transform: none; letter-spacing: 0; font-size: 13px; color: var(--text); }
|
||||||
|
.opening-remove-btn { border-color: transparent; color: var(--text-faint); }
|
||||||
|
.opening-remove-btn:hover { border-color: var(--danger); color: var(--danger); }
|
||||||
|
#cost-settings-modal .cs-row input, #cost-settings-modal .cs-row select {
|
||||||
|
padding: 4px 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; color: var(--text);
|
||||||
|
}
|
||||||
|
#cost-settings-modal .cs-row input:focus, #cost-settings-modal .cs-row select:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px var(--primary-soft); }
|
||||||
|
#cost-settings-modal .cs-row input:disabled, #cost-settings-modal .cs-row input[readonly] { background: var(--border-soft); }
|
||||||
|
|||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
/*!
|
||||||
|
* 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; }
|
||||||
|
.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 .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; }
|
||||||
|
}
|
||||||
+19
-16
@@ -22,15 +22,23 @@
|
|||||||
<div class="splash-version"><span class="splash-name">Tectum</span> — Versie <span class="app-version-number">0.0.0</span></div>
|
<div class="splash-version"><span class="splash-name">Tectum</span> — Versie <span class="app-version-number">0.0.0</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header id="ribbon">
|
<header id="topbar">
|
||||||
<div class="rb-top">
|
<div class="tb-brand"><span class="splash-name">Tectum</span></div>
|
||||||
<div class="rb-brand"><span class="splash-name">Tectum</span></div>
|
<nav id="menubar" aria-label="Menu"></nav>
|
||||||
<div class="rb-tabs" role="tablist"></div>
|
<div id="doc-tabs" role="tablist" aria-label="Geopende ontwerpen"></div>
|
||||||
<div class="rb-right"><span class="app-version-slot"></span></div>
|
<div class="top-right">
|
||||||
|
<button type="button" id="top-save" class="top-save" title="Opslaan als JSON + PDF (Ctrl+S)"><i class="bi bi-save"></i><span>Opslaan</span></button>
|
||||||
|
<span class="app-version-slot"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rb-body"></div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="keys-overlay" class="dlg-overlay">
|
||||||
|
<div class="dlg" role="dialog" aria-labelledby="keys-title">
|
||||||
|
<div class="dlg-head"><h2 id="keys-title">Sneltoetsen en bediening</h2><button type="button" class="dlg-x" data-close>×</button></div>
|
||||||
|
<div class="keys-body" id="keys-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="about-overlay">
|
<div id="about-overlay">
|
||||||
<div id="about-modal">
|
<div id="about-modal">
|
||||||
<img class="about-logo" src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
<img class="about-logo" src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
||||||
@@ -42,14 +50,14 @@
|
|||||||
<div class="license-box" id="about-license"></div>
|
<div class="license-box" id="about-license"></div>
|
||||||
<div class="about-footer">
|
<div class="about-footer">
|
||||||
<span>© 2026 Machiel Heinen · ontwikkeld in opdracht van Van Emous Mantelzorgwoningen</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="new-overlay">
|
<div id="new-overlay">
|
||||||
<div id="new-modal">
|
<div id="new-modal">
|
||||||
<h2>Nieuw</h2>
|
<h2>Nieuw ontwerp</h2>
|
||||||
<div class="new-options">
|
<div class="new-options">
|
||||||
<button class="new-option" data-kind="woning" data-template="single">
|
<button class="new-option" data-kind="woning" data-template="single">
|
||||||
<svg viewBox="0 0 48 48"><rect x="8" y="14" width="32" height="20" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="24" x2="40" y2="24" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/></svg>
|
<svg viewBox="0 0 48 48"><rect x="8" y="14" width="32" height="20" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="24" x2="40" y2="24" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/></svg>
|
||||||
@@ -67,27 +75,22 @@
|
|||||||
<svg viewBox="0 0 48 48"><rect x="6" y="6" width="36" height="36" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="18" x2="42" y2="18" stroke="currentColor" stroke-width="2"/><line x1="6" y1="30" x2="42" y2="30" stroke="currentColor" stroke-width="2"/><line x1="18" y1="6" x2="18" y2="42" stroke="currentColor" stroke-width="2"/><line x1="30" y1="6" x2="30" y2="42" stroke="currentColor" stroke-width="2"/></svg>
|
<svg viewBox="0 0 48 48"><rect x="6" y="6" width="36" height="36" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="18" x2="42" y2="18" stroke="currentColor" stroke-width="2"/><line x1="6" y1="30" x2="42" y2="30" stroke="currentColor" stroke-width="2"/><line x1="18" y1="6" x2="18" y2="42" stroke="currentColor" stroke-width="2"/><line x1="30" y1="6" x2="30" y2="42" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
<span>Los: vloer</span>
|
<span>Los: vloer</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="new-option disabled" data-kind="dak" disabled title="Nog niet beschikbaar">
|
|
||||||
<svg viewBox="0 0 48 48"><path d="M4 30 L24 8 L44 30" fill="none" stroke="currentColor" stroke-width="2"/><line x1="10" y1="30" x2="38" y2="30" stroke="currentColor" stroke-width="2"/></svg>
|
|
||||||
<span>Dak</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-bar"></div>
|
|
||||||
<div id="workspace"></div>
|
<div id="workspace"></div>
|
||||||
|
|
||||||
<script src="js/core/license.js"></script>
|
<script src="js/core/license.js"></script>
|
||||||
<script src="js/core/logo-data.js"></script>
|
<script src="js/core/logo-data.js"></script>
|
||||||
<script src="js/core/geometry.js"></script>
|
<script src="js/core/geometry.js"></script>
|
||||||
<script src="js/core/build-stages.js"></script>
|
<script src="js/core/view-layers.js"></script>
|
||||||
<script src="js/core/canvas-labels.js"></script>
|
<script src="js/core/canvas-labels.js"></script>
|
||||||
<script src="js/core/camera-memory.js"></script>
|
<script src="js/core/camera-memory.js"></script>
|
||||||
<script src="js/core/walk-controls.js"></script>
|
<script src="js/core/walk-controls.js"></script>
|
||||||
<script src="js/core/view-filter.js"></script>
|
|
||||||
<script src="js/core/ui-kit.js"></script>
|
<script src="js/core/ui-kit.js"></script>
|
||||||
<script src="js/core/ribbon.js"></script>
|
<script src="js/core/menubar.js"></script>
|
||||||
|
<script src="js/core/inspector.js"></script>
|
||||||
<script src="js/core/skybox.js"></script>
|
<script src="js/core/skybox.js"></script>
|
||||||
<script src="js/vendor/jspdf.umd.min.js"></script>
|
<script src="js/vendor/jspdf.umd.min.js"></script>
|
||||||
<script src="js/core/pdf-sheet.js"></script>
|
<script src="js/core/pdf-sheet.js"></script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
* Zie LICENTIE.md.
|
* Zie LICENTIE.md.
|
||||||
*/
|
*/
|
||||||
const APP_VERSION = '0.7.0';
|
const APP_VERSION = '0.7.4';
|
||||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||||
|
|
||||||
let tabs = [];
|
let tabs = [];
|
||||||
@@ -28,72 +28,83 @@ function defaultNameFor(kind) {
|
|||||||
function setWorkspaceEmpty() {
|
function setWorkspaceEmpty() {
|
||||||
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
|
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
|
||||||
<div>Geen ontwerp geopend.</div>
|
<div>Geen ontwerp geopend.</div>
|
||||||
<div style="font-size:11px;">Bestand → Nieuw → kies een objecttype</div>
|
<div style="font-size:12px;">Bestand › Nieuw (Ctrl+N)</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEditMenuState() {
|
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function' && (!currentEditor.can || currentEditor.can(fn)));
|
||||||
Ribbon.refresh();
|
const editorCall = (fn, ...args) => { if (editorHas(fn)) return currentEditor[fn](...args); };
|
||||||
|
const viewState = () => editorCall('viewState') || {};
|
||||||
|
|
||||||
|
// Menu met de sneltoets per item; KEY_HELP hieronder is het overzicht (Help › Sneltoetsen). Editor-acties werken alleen als de editor ze kent.
|
||||||
|
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') },
|
||||||
|
{ 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') },
|
||||||
|
{ label: 'Render (OBJ)', icon: 'badge-3d', run: () => editorCall('exportObj'), enabled: () => editorHas('exportObj') },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Projectgegevens…', icon: 'card-text', run: () => editorCall('openProjectProperties'), enabled: () => editorHas('openProjectProperties') },
|
||||||
|
{ label: 'Kosten & tijden…', icon: 'calculator', run: () => CostSettings.open() },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Ontwerp sluiten', icon: 'x-lg', run: () => activeTabId !== null && closeTab(activeTabId), enabled: () => activeTabId !== null }
|
||||||
|
] },
|
||||||
|
{ label: 'Beeld', items: [
|
||||||
|
...UI.VIEWS.map(v => ({ label: v.label, icon: v.icon, keys: v.key1, run: () => editorCall('setView', v.key),
|
||||||
|
enabled: () => editorHas('setView'), checked: () => viewState().view === v.key })),
|
||||||
|
{ label: 'Rapport', icon: 'clipboard-data', keys: '4', run: () => editorCall('toggleReport'), enabled: () => editorHas('toggleReport'), checked: () => !!viewState().report },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Doorsnede', icon: 'scissors', keys: 'X', run: () => editorCall('toggleSection'), enabled: () => editorHas('toggleSection'), checked: () => !!viewState().section },
|
||||||
|
{ label: 'Plattegrond passend', icon: 'aspect-ratio', keys: 'F', run: () => editorCall('fitView'), enabled: () => editorHas('fitView') },
|
||||||
|
{ label: 'Lopen', icon: 'person-walking', keys: 'L', run: () => editorCall('toggleWalk'), enabled: () => editorHas('toggleWalk'), checked: () => !!viewState().walk },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Panelen verbergen', icon: 'layout-sidebar-inset', keys: 'Ctrl+\\', run: togglePanels, checked: () => document.body.classList.contains('panels-hidden') },
|
||||||
|
{ label: 'Volledig scherm', icon: 'fullscreen', keys: 'F11', run: toggleBrowserFullscreen, checked: () => !!document.fullscreenElement }
|
||||||
|
] },
|
||||||
|
{ label: 'Help', items: [
|
||||||
|
{ label: 'Sneltoetsen en bediening', icon: 'keyboard', keys: '?', run: openKeys },
|
||||||
|
{ label: 'Over Tectum', icon: 'info-circle', run: () => document.getElementById('about-overlay').classList.add('open') }
|
||||||
|
] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const KEY_HELP = [
|
||||||
|
['Bestand', [['Ctrl+N', 'Nieuw ontwerp'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF)']]],
|
||||||
|
['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],
|
||||||
|
['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]
|
||||||
|
];
|
||||||
|
|
||||||
|
function openKeys() {
|
||||||
|
// Muisacties als tekst, toetsen als <kbd>.
|
||||||
|
document.getElementById('keys-body').innerHTML = KEY_HELP.map(([title, rows, mouse]) =>
|
||||||
|
`<section><h3>${title}</h3>${rows.map(([k, d]) => `<div class="keys-row"><span>${mouse ? `<span class="keys-mouse">${k}</span>`
|
||||||
|
: k.split(' / ').map(x => `<kbd>${x}</kbd>`).join(' / ')}</span><span>${d}</span></div>`).join('')}</section>`).join('');
|
||||||
|
document.getElementById('keys-overlay').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
|
function togglePanels() {
|
||||||
const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); };
|
document.body.classList.toggle('panels-hidden');
|
||||||
const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled';
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
|
||||||
function appRibbonTabs() {
|
|
||||||
const { big, small } = Ribbon;
|
|
||||||
const wireClicks = (body) => body.querySelectorAll('[data-app]').forEach(b => b.addEventListener('click', () => {
|
|
||||||
const a = b.dataset.app;
|
|
||||||
if (a === 'new') openNewModal();
|
|
||||||
else if (a === 'new-kind') openNew(b.dataset.kind, b.dataset.template);
|
|
||||||
else if (a === 'fullscreen') toggleBrowserFullscreen();
|
|
||||||
else if (a === 'costs') CostSettings.open();
|
|
||||||
else if (a === 'about') document.getElementById('about-overlay').classList.add('open');
|
|
||||||
else editorCall(a);
|
|
||||||
}));
|
|
||||||
return [
|
|
||||||
{ key: 'bestand', label: 'Bestand', order: 0, wire: wireClicks, groups: () => [
|
|
||||||
{ label: 'Document', html:
|
|
||||||
big('Nieuw', 'file-earmark-plus', 'data-app="new" title="Nieuw ontwerp (Ctrl+N)"') +
|
|
||||||
big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) +
|
|
||||||
big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) },
|
|
||||||
{ label: 'Exporteren', html:
|
|
||||||
big('CAD (DXF)', 'vector-pen', `data-app="exportDxf" title="Plattegrond en gevels als DXF voor CAD (AutoCAD, BricsCAD, …), 1:1 in mm; daar op te slaan als DWG" ${disabledUnless('exportDxf')}`) +
|
|
||||||
big('BIM (IFC)', 'boxes', `data-app="exportIfc" title="Bouwconstructie als IFC4 (balken, platen, elektra) per element, zonder afwerking en inrichting" ${disabledUnless('exportIfc')}`) +
|
|
||||||
big('Render (OBJ)', 'badge-3d', `data-app="exportObj" title="Volledige afgewerkte woning als OBJ + MTL + texturen in een ZIP, voor renders in Blender" ${disabledUnless('exportObj')}`) },
|
|
||||||
{ label: 'Snel nieuw', html:
|
|
||||||
Object.entries(HouseModel.MODELS).map(([key, m]) => small(m.label, m.icon, `data-app="new-kind" data-kind="woning" data-template="${key}"`)).join('') +
|
|
||||||
small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') +
|
|
||||||
small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') +
|
|
||||||
small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') +
|
|
||||||
small('Los: vloer', 'grid-3x3', 'data-app="new-kind" data-kind="vloer"') },
|
|
||||||
{ label: 'Project', html: big('Eigenschappen', 'card-text', `data-app="openProjectProperties" title="Projectgegevens voor de PDF" ${disabledUnless('openProjectProperties')}`) }
|
|
||||||
] },
|
|
||||||
{ key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [
|
|
||||||
{ label: 'Venster', html:
|
|
||||||
small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') +
|
|
||||||
small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) +
|
|
||||||
small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) },
|
|
||||||
{ label: 'Camera', html:
|
|
||||||
big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) +
|
|
||||||
small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') }
|
|
||||||
] },
|
|
||||||
{ key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [
|
|
||||||
{ label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) },
|
|
||||||
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') },
|
|
||||||
{ label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') }
|
|
||||||
] }
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTabBar() {
|
function renderTabBar() {
|
||||||
const bar = document.getElementById('tab-bar');
|
const bar = document.getElementById('doc-tabs');
|
||||||
bar.innerHTML = tabs.map(tab => `
|
bar.innerHTML = tabs.map(tab => `
|
||||||
<div class="tab-item ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}">
|
<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="tab-name">${tab.name}</span>
|
<span class="doc-name">${UI.esc(tab.name)}</span>
|
||||||
<span class="tab-close" data-close-id="${tab.id}">×</span>
|
<button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">×</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
document.getElementById('top-save').disabled = !editorHas('exportDesign');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabSaved(tab, filenameWithExt) {
|
function onTabSaved(tab, filenameWithExt) {
|
||||||
@@ -106,7 +117,6 @@ function onTabSaved(tab, filenameWithExt) {
|
|||||||
function mountEditorForTab(tab) {
|
function mountEditorForTab(tab) {
|
||||||
const ws = document.getElementById('workspace');
|
const ws = document.getElementById('workspace');
|
||||||
ws.innerHTML = '';
|
ws.innerHTML = '';
|
||||||
Ribbon.setSource('editor', []);
|
|
||||||
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
|
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
|
||||||
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
|
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
|
||||||
currentEditor.loadStateJson(tab.stateJson);
|
currentEditor.loadStateJson(tab.stateJson);
|
||||||
@@ -124,7 +134,6 @@ function switchToTab(tabId) {
|
|||||||
|
|
||||||
activeTabId = tabId;
|
activeTabId = tabId;
|
||||||
mountEditorForTab(tabs.find(t => t.id === tabId));
|
mountEditorForTab(tabs.find(t => t.id === tabId));
|
||||||
updateEditMenuState();
|
|
||||||
renderTabBar();
|
renderTabBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +151,6 @@ function openNew(kind, template) {
|
|||||||
tabs.push(tab);
|
tabs.push(tab);
|
||||||
activeTabId = tab.id;
|
activeTabId = tab.id;
|
||||||
mountEditorForTab(tab);
|
mountEditorForTab(tab);
|
||||||
updateEditMenuState();
|
|
||||||
renderTabBar();
|
renderTabBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,17 +191,15 @@ function closeTab(tabId) {
|
|||||||
switchToTab(tabs[tabs.length - 1].id);
|
switchToTab(tabs[tabs.length - 1].id);
|
||||||
} else {
|
} else {
|
||||||
setWorkspaceEmpty();
|
setWorkspaceEmpty();
|
||||||
Ribbon.setSource('editor', []);
|
|
||||||
updateEditMenuState();
|
|
||||||
renderTabBar();
|
renderTabBar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireTabBar() {
|
function wireTabBar() {
|
||||||
document.getElementById('tab-bar').addEventListener('click', (e) => {
|
document.getElementById('doc-tabs').addEventListener('click', (e) => {
|
||||||
const closeBtn = e.target.closest('.tab-close');
|
const closeBtn = e.target.closest('.doc-close');
|
||||||
if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; }
|
if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; }
|
||||||
const item = e.target.closest('.tab-item');
|
const item = e.target.closest('.doc-tab');
|
||||||
if (item) switchToTab(parseInt(item.dataset.tabId, 10));
|
if (item) switchToTab(parseInt(item.dataset.tabId, 10));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -237,41 +243,54 @@ function toggleBrowserFullscreen() {
|
|||||||
else document.documentElement.requestFullscreen();
|
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(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();
|
||||||
|
}
|
||||||
|
|
||||||
function wireHotkeys() {
|
function wireHotkeys() {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
|
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
// Dialoog open: alleen Esc (sluiten); geen sneltoetsen naar de editor erachter. Typen in de dialoog blijft werken.
|
||||||
e.preventDefault();
|
if (document.querySelector(OVERLAYS)) {
|
||||||
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
|
if (e.key === 'Escape') closeOverlays();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
const act = { s: () => editorCall('exportDesign'), n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
|
||||||
openNewModal();
|
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
|
if (e.key === 'Escape' && closeOverlays()) { e.stopImmediatePropagation(); return; }
|
||||||
|
if (inField || e.altKey || viewState().walk && !['l', 'escape'].includes(k)) return;
|
||||||
if (e.key.toLowerCase() === 'f') {
|
const view = UI.VIEWS.find(v => v.key1 === e.key);
|
||||||
toggleBrowserFullscreen();
|
if (view) editorCall('setView', view.key);
|
||||||
} else if (e.key.toLowerCase() === 'h') {
|
else if (e.key === '4') editorCall('toggleReport');
|
||||||
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
|
else if (k === 'x') editorCall('toggleSection');
|
||||||
} else if (e.key.toLowerCase() === 't' && e.shiftKey) {
|
else if (k === 'f') editorCall('fitView');
|
||||||
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk();
|
else if (k === 'l') editorCall('toggleWalk');
|
||||||
} else if (e.key.toLowerCase() === 't') {
|
else if (k === 'h' && e.shiftKey) editorCall('mirrorHorizontal');
|
||||||
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
|
else if (e.key === '?') openKeys();
|
||||||
} else if (e.key.toLowerCase() === 'r') {
|
}, true);
|
||||||
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
||||||
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
||||||
Ribbon.init(document.getElementById('ribbon'));
|
Menubar.init(document.getElementById('menubar'), MENUS);
|
||||||
Ribbon.setSource('app', appRibbonTabs());
|
document.getElementById('top-save').addEventListener('click', () => editorCall('exportDesign'));
|
||||||
|
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');
|
||||||
|
});
|
||||||
wireAbout();
|
wireAbout();
|
||||||
wireNewModal();
|
wireNewModal();
|
||||||
wireHotkeys();
|
wireHotkeys();
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Tectum
|
|
||||||
* Copyright © 2026 Machiel Heinen.
|
|
||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
||||||
* Zie LICENTIE.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
window.BuildStages = (() => {
|
|
||||||
const LIST = [
|
|
||||||
{ key: 'regelwerk', label: 'Regelwerk', hint: 'Vloer-, wand- en dakbalken' },
|
|
||||||
{ key: 'elektra', label: 'Elektra', hint: 'Installatiebuizen door wanden en plafond, dozen en meterkast' },
|
|
||||||
{ key: 'beplating', label: 'Beplating', hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
|
|
||||||
{ key: 'doek', label: 'Vochtdoek', hint: 'Vochtdoek op de buitenzijde' },
|
|
||||||
{ key: 'latwerk', label: 'Latwerk', hint: 'Kruislat en trasraamplaat' },
|
|
||||||
{ key: 'gevel', label: 'Gevel & dak', hint: 'Rabat, steen, kozijnen, dakbedekking, sandwichpanelen' },
|
|
||||||
{ key: 'afwerking', label: 'Afgewerkt', hint: 'Dakrand, verlichting, vloerafwerking, verf binnen' }
|
|
||||||
];
|
|
||||||
const FINAL = LIST.length - 1;
|
|
||||||
const INDEX = Object.fromEntries(LIST.map((s, i) => [s.key, i]));
|
|
||||||
|
|
||||||
const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL;
|
|
||||||
const shows = (stage, key) => clamp(stage) >= INDEX[key];
|
|
||||||
|
|
||||||
function createBar(pane, stage, onChange) {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'stage-bar';
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="stage-row">
|
|
||||||
<span class="stage-title"><i class="bi bi-layers"></i>Bouwfase</span>
|
|
||||||
<div class="stage-track">
|
|
||||||
<input type="range" class="stage-range" min="0" max="${FINAL}" step="1" value="${clamp(stage)}" aria-label="Bouwfase">
|
|
||||||
<div class="stage-ticks">${LIST.map((s, i) => `<span data-i="${i}" title="${s.label}: ${s.hint}">${i + 1}</span>`).join('')}</div>
|
|
||||||
</div>
|
|
||||||
<span class="stage-name"></span>
|
|
||||||
</div>
|
|
||||||
<div class="stage-extra"></div>`;
|
|
||||||
pane.appendChild(el);
|
|
||||||
const range = el.querySelector('.stage-range'), name = el.querySelector('.stage-name');
|
|
||||||
const sync = () => {
|
|
||||||
const i = clamp(Number(range.value));
|
|
||||||
name.textContent = LIST[i].label;
|
|
||||||
name.title = LIST[i].hint;
|
|
||||||
el.querySelectorAll('.stage-ticks span').forEach(t => t.classList.toggle('on', Number(t.dataset.i) <= i));
|
|
||||||
};
|
|
||||||
range.addEventListener('input', () => { sync(); onChange(clamp(Number(range.value))); });
|
|
||||||
el.querySelector('.stage-ticks').addEventListener('click', (e) => {
|
|
||||||
const t = e.target.closest('[data-i]');
|
|
||||||
if (!t) return;
|
|
||||||
range.value = t.dataset.i;
|
|
||||||
sync();
|
|
||||||
onChange(clamp(Number(range.value)));
|
|
||||||
});
|
|
||||||
// T, R, ... zijn sneltoetsen van de weergave, niet van de schuif.
|
|
||||||
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
|
|
||||||
// Na slepen focus loslaten, zodat de pijltoetsen weer naar de weergave (lopen) gaan.
|
|
||||||
el.addEventListener('pointerup', (e) => { if (e.target.matches('input[type=range]')) setTimeout(() => e.target.blur(), 0); });
|
|
||||||
sync();
|
|
||||||
return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { LIST, FINAL, INDEX, clamp, shows, createBar };
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// Eigenschappenpaneel rechts: eigenschappen van de selectie, of van de woning als er niets geselecteerd is.
|
||||||
|
// Een sectie = { key, label, icon, groups() → [{ label, html }], wire(el) }; elke sectie krijgt een eigen element,
|
||||||
|
// zodat wire() alleen de eigen knoppen koppelt.
|
||||||
|
|
||||||
|
window.Inspector = (() => {
|
||||||
|
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>`;
|
||||||
|
|
||||||
|
const PREF_KEY = 'tectum.inspector.open';
|
||||||
|
let open;
|
||||||
|
try { open = new Set(JSON.parse(localStorage.getItem(PREF_KEY) || 'null') || ['gevel']); } catch (e) { open = new Set(['gevel']); }
|
||||||
|
const saveOpen = () => { try { localStorage.setItem(PREF_KEY, JSON.stringify([...open])); } catch (e) { } };
|
||||||
|
|
||||||
|
// Een groepskop gelijk aan de sectienaam (bijv. Elektra › Elektra) vervalt.
|
||||||
|
const groupsHtml = (groups, secLabel) => groups.filter(Boolean).map(g =>
|
||||||
|
`<div class="ins-group${g.cls ? ' ' + g.cls : ''}">${g.label && g.label.toLowerCase() !== String(secLabel || '').toLowerCase() ? `<h3>${g.label}</h3>` : ''}<div class="ins-group-body">${g.html}</div></div>`).join('');
|
||||||
|
|
||||||
|
// view = { crumbs: [{ label, attrs }], title, icon, sub, top: html, sections: [...], collapsible, onBack }
|
||||||
|
function render(el, view) {
|
||||||
|
const scrollKey = view.title + '|' + (view.sub || '');
|
||||||
|
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 || ''}>${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>`;
|
||||||
|
const host = el.querySelector('.ins-sections');
|
||||||
|
view.sections.filter(Boolean).forEach(sec => {
|
||||||
|
const node = document.createElement('section');
|
||||||
|
const collapsible = view.collapsible;
|
||||||
|
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"></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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
el.scrollTop = keepScroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { big, small, kv, render };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// Menubalk: menu = { label, items: [{ label, icon, keys, run, enabled(), checked() } | { sep: true } | { head }] }.
|
||||||
|
// Beschikbaarheid wordt bij het openen bepaald; is een menu open, dan opent hover het buurmenu.
|
||||||
|
|
||||||
|
window.Menubar = (() => {
|
||||||
|
let root = null, menus = [], openIdx = -1;
|
||||||
|
|
||||||
|
function itemHtml(it, mi, ii) {
|
||||||
|
if (it.sep) return '<div class="mb-sep"></div>';
|
||||||
|
if (it.head) return `<div class="mb-head">${it.head}</div>`;
|
||||||
|
const off = it.enabled && !it.enabled();
|
||||||
|
const on = it.checked && it.checked();
|
||||||
|
return `<button type="button" class="mb-item${on ? ' checked' : ''}" data-mi="${mi}" data-ii="${ii}"${off ? ' disabled' : ''}>
|
||||||
|
<span class="mb-ico">${on ? '<i class="bi bi-check2"></i>' : it.icon ? `<i class="bi bi-${it.icon}"></i>` : ''}</span>
|
||||||
|
<span class="mb-label">${it.label}</span><span class="mb-keys">${it.keys || ''}</span></button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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');
|
||||||
|
wrap.querySelector('.mb-drop').innerHTML = menus[i].items.map((it, ii) => itemHtml(it, i, ii)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(el, list) {
|
||||||
|
root = el;
|
||||||
|
menus = list;
|
||||||
|
el.innerHTML = menus.map((m, i) => `<div class="mb-menu"><button type="button" class="mb-top" data-menu="${i}">${m.label}</button><div class="mb-drop" role="menu"></div></div>`).join('');
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const top = e.target.closest('[data-menu]');
|
||||||
|
if (top) { const i = Number(top.dataset.menu); if (openIdx === i) close(); else openMenu(i); return; }
|
||||||
|
const item = e.target.closest('.mb-item');
|
||||||
|
if (!item || item.disabled) return;
|
||||||
|
const it = menus[Number(item.dataset.mi)].items[Number(item.dataset.ii)];
|
||||||
|
close();
|
||||||
|
it.run();
|
||||||
|
});
|
||||||
|
el.addEventListener('mouseover', (e) => {
|
||||||
|
const top = e.target.closest('[data-menu]');
|
||||||
|
if (top && openIdx >= 0 && Number(top.dataset.menu) !== openIdx) openMenu(Number(top.dataset.menu));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, close };
|
||||||
|
})();
|
||||||
+66
-1
@@ -129,5 +129,70 @@ window.ObjWriter = (() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { build };
|
// OBJ kent geen lampen: een Blender-script dat de OBJ importeert (als die er nog niet is) en de lampen als echte
|
||||||
|
// spotlights plaatst. sources: LightingModel.lightSources (m, Y omhoog); Blender is Z omhoog: (x, -z, y).
|
||||||
|
const BLENDER_WATT = { wandlamp: 10, dakrandspot: 8, binnenspot: 15 };
|
||||||
|
function blenderLights(sources, opts) {
|
||||||
|
const o = opts || {}, origin = o.origin || { x: 0, y: 0, z: 0 }, name = o.name || 'woning';
|
||||||
|
const c = new THREE.Color(o.color !== undefined ? o.color : 0xffd9a0);
|
||||||
|
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||||
|
const t = (v) => `(${v.map(f4).join(', ')})`;
|
||||||
|
const rows = sources.map(s => ` (${JSON.stringify(s.kind)}, ${t([s.x - origin.x, -(s.z - origin.z), s.y - origin.y])}, ${t([s.dir.x, -s.dir.z, s.dir.y])}, ` +
|
||||||
|
`${f3(Math.min(Math.PI, s.angle * 2))}, ${f3(s.penumbra)}, ${BLENDER_WATT[s.kind] || 10}),\n`).join('');
|
||||||
|
return `# Tectum${o.app ? ' ' + o.app : ''}: lampen voor ${name}.obj
|
||||||
|
# Blender: Scripting > Open > dit bestand > Run Script. Importeert ${name}.obj uit dezelfde map
|
||||||
|
# (IMPORTEER = False als je de OBJ al zelf hebt geïmporteerd) en zet elke lamp als spotlight in de
|
||||||
|
# collectie "Tectum lampen"; opnieuw uitvoeren vervangt de lampen.
|
||||||
|
# HELDERHEID schaalt alle lampen; vermogens in watt per soort staan in de lijst (soort, plek, richting,
|
||||||
|
# kegel, zachte rand, watt).
|
||||||
|
import bpy, os
|
||||||
|
from mathutils import Vector
|
||||||
|
|
||||||
|
IMPORTEER = True
|
||||||
|
HELDERHEID = 1.0
|
||||||
|
KLEUR = (${f3(lin(c.r))}, ${f3(lin(c.g))}, ${f3(lin(c.b))})
|
||||||
|
OBJ = "${name}.obj"
|
||||||
|
LAMPEN = [
|
||||||
|
${rows}]
|
||||||
|
|
||||||
|
def map_van_script():
|
||||||
|
for area in bpy.context.screen.areas if bpy.context.screen else []:
|
||||||
|
if area.type == 'TEXT_EDITOR' and area.spaces.active.text and area.spaces.active.text.filepath:
|
||||||
|
return os.path.dirname(bpy.path.abspath(area.spaces.active.text.filepath))
|
||||||
|
return os.path.dirname(bpy.data.filepath) if bpy.data.filepath else ""
|
||||||
|
|
||||||
|
pad = os.path.join(map_van_script(), OBJ)
|
||||||
|
if IMPORTEER and not any(ob.get("tectum") == OBJ for ob in bpy.data.objects):
|
||||||
|
if not os.path.exists(pad):
|
||||||
|
raise Exception(f"{OBJ} niet gevonden naast dit script; zet IMPORTEER = False na een eigen import")
|
||||||
|
if hasattr(bpy.ops.wm, "obj_import"):
|
||||||
|
bpy.ops.wm.obj_import(filepath=pad)
|
||||||
|
else:
|
||||||
|
bpy.ops.import_scene.obj(filepath=pad)
|
||||||
|
for ob in bpy.context.selected_objects:
|
||||||
|
ob["tectum"] = OBJ
|
||||||
|
|
||||||
|
oud = bpy.data.collections.get("Tectum lampen")
|
||||||
|
if oud:
|
||||||
|
for ob in list(oud.objects):
|
||||||
|
bpy.data.objects.remove(ob, do_unlink=True)
|
||||||
|
bpy.data.collections.remove(oud)
|
||||||
|
col = bpy.data.collections.new("Tectum lampen")
|
||||||
|
bpy.context.scene.collection.children.link(col)
|
||||||
|
for i, (soort, plek, richting, kegel, rand, watt) in enumerate(LAMPEN):
|
||||||
|
licht = bpy.data.lights.new(f"{soort}_{i + 1}", 'SPOT')
|
||||||
|
licht.energy = watt * HELDERHEID
|
||||||
|
licht.color = KLEUR
|
||||||
|
licht.spot_size = kegel
|
||||||
|
licht.spot_blend = rand
|
||||||
|
licht.shadow_soft_size = 0.03
|
||||||
|
ob = bpy.data.objects.new(licht.name, licht)
|
||||||
|
ob.location = plek
|
||||||
|
ob.rotation_euler = Vector(richting).to_track_quat('-Z', 'Y').to_euler()
|
||||||
|
col.objects.link(ob)
|
||||||
|
print(f"Tectum: {len(LAMPEN)} lampen geplaatst")
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { build, blenderLights };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Tectum
|
|
||||||
* Copyright © 2026 Machiel Heinen.
|
|
||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
||||||
* Zie LICENTIE.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
window.Ribbon = (() => {
|
|
||||||
const sources = {};
|
|
||||||
const SOURCE_ORDER = ['app', 'editor'];
|
|
||||||
let root = null, tabsEl = null, bodyEl = null;
|
|
||||||
let activeKey = null, lastPlainKey = null;
|
|
||||||
let auto = true;
|
|
||||||
let collapsed = false, notified = null;
|
|
||||||
const listeners = [];
|
|
||||||
const PREF_KEY = 'tectum.ribbon.collapsed';
|
|
||||||
|
|
||||||
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
|
|
||||||
const big = (label, iconName, attrs, cls) =>
|
|
||||||
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
|
||||||
const small = (label, iconName, attrs, cls) =>
|
|
||||||
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
|
||||||
const kv = (label, value, unit) =>
|
|
||||||
`<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
|
|
||||||
|
|
||||||
function allTabs() {
|
|
||||||
const byKey = new Map();
|
|
||||||
SOURCE_ORDER.concat(Object.keys(sources).filter(n => !SOURCE_ORDER.includes(n))).forEach(name => {
|
|
||||||
(sources[name] || []).forEach(t => {
|
|
||||||
if (!byKey.has(t.key)) byKey.set(t.key, { key: t.key, label: t.label, order: t.order || 0, contextual: !!t.contextual, icon: t.icon, parts: [] });
|
|
||||||
const m = byKey.get(t.key);
|
|
||||||
if (t.order !== undefined && name !== 'app') m.order = t.order;
|
|
||||||
if (t.label && name !== 'app') m.label = t.label;
|
|
||||||
if (t.contextual) m.contextual = true;
|
|
||||||
m.parts.push(t);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return Array.from(byKey.values()).sort((a, b) => (a.contextual - b.contextual) || (a.order - b.order));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTabs(list) {
|
|
||||||
tabsEl.innerHTML = list.map(t =>
|
|
||||||
`<button type="button" class="rb-tab${t.key === activeKey ? ' active' : ''}${t.contextual ? ' rb-ctx' : ''}" data-rb-tab="${t.key}">${t.contextual && t.icon ? icon(t.icon) : ''}<span>${t.label}</span></button>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBody(list) {
|
|
||||||
const tab = list.find(t => t.key === activeKey);
|
|
||||||
if (!tab) { bodyEl.innerHTML = ''; return; }
|
|
||||||
const groups = [];
|
|
||||||
tab.parts.forEach(p => (p.groups ? p.groups() : []).forEach(g => g && groups.push(g)));
|
|
||||||
bodyEl.innerHTML = groups.map(g =>
|
|
||||||
`<section class="rb-group${g.cls ? ' ' + g.cls : ''}"><div class="rb-group-body">${g.html}</div><div class="rb-group-label">${g.label}</div></section>`).join('') +
|
|
||||||
`<button type="button" class="rb-collapse" title="Ribbon inklappen (dubbelklik op een tabblad)">${icon('chevron-up')}</button>`;
|
|
||||||
tab.parts.forEach(p => p.wire && p.wire(bodyEl));
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
if (!root) return;
|
|
||||||
const list = allTabs();
|
|
||||||
if (!list.some(t => t.key === activeKey) || (auto && !list.some(t => t.key === activeKey && t.contextual))) {
|
|
||||||
const pick = [lastPlainKey, 'start'].find(k => list.some(t => t.key === k));
|
|
||||||
activeKey = pick || (list.find(t => !t.contextual) || {}).key || null;
|
|
||||||
}
|
|
||||||
renderTabs(list);
|
|
||||||
renderBody(list);
|
|
||||||
root.classList.toggle('rb-collapsed', collapsed);
|
|
||||||
if (notified !== activeKey) { notified = activeKey; listeners.forEach(fn => fn(activeKey)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function activate(key) {
|
|
||||||
const list = allTabs();
|
|
||||||
const tab = list.find(t => t.key === key);
|
|
||||||
if (!tab) return;
|
|
||||||
activeKey = key;
|
|
||||||
auto = false;
|
|
||||||
if (!tab.contextual) lastPlainKey = key;
|
|
||||||
if (collapsed) { collapsed = false; savePref(); }
|
|
||||||
render();
|
|
||||||
tab.parts.forEach(p => p.onShow && p.onShow());
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { } }
|
|
||||||
|
|
||||||
function init(el) {
|
|
||||||
root = el;
|
|
||||||
try { collapsed = localStorage.getItem(PREF_KEY) === '1'; } catch (e) { collapsed = false; }
|
|
||||||
tabsEl = el.querySelector('.rb-tabs');
|
|
||||||
bodyEl = el.querySelector('.rb-body');
|
|
||||||
tabsEl.addEventListener('click', (e) => {
|
|
||||||
const b = e.target.closest('[data-rb-tab]');
|
|
||||||
if (!b) return;
|
|
||||||
if (b.dataset.rbTab === activeKey && collapsed) { collapsed = false; savePref(); render(); return; }
|
|
||||||
activate(b.dataset.rbTab);
|
|
||||||
});
|
|
||||||
tabsEl.addEventListener('dblclick', (e) => {
|
|
||||||
if (!e.target.closest('[data-rb-tab]')) return;
|
|
||||||
collapsed = !collapsed; savePref(); render();
|
|
||||||
});
|
|
||||||
bodyEl.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.rb-collapse')) { collapsed = true; savePref(); render(); }
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSource(name, tabs, opts) {
|
|
||||||
sources[name] = tabs || [];
|
|
||||||
const ctx = (tabs || []).find(t => t.contextual);
|
|
||||||
if (opts && opts.focus && ctx) { activeKey = ctx.key; if (collapsed) { collapsed = false; savePref(); } }
|
|
||||||
render();
|
|
||||||
if (opts && opts.focus && ctx && ctx.onShow) ctx.onShow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
|
|
||||||
|
|
||||||
return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv };
|
|
||||||
})();
|
|
||||||
+9
-15
@@ -161,32 +161,26 @@ window.Skybox = (() => {
|
|||||||
function createToggle(toolbar, onChange) {
|
function createToggle(toolbar, onChange) {
|
||||||
let m = getMode();
|
let m = getMode();
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'view-filter';
|
wrap.className = 'tb-pop-wrap';
|
||||||
wrap.innerHTML = `<button type="button" class="btn-3d-toggle vf-btn" title="Omgeving"></button>
|
wrap.innerHTML = `<button type="button" class="tb-btn" title="Omgeving in 3D"></button>
|
||||||
<div class="vf-menu">${MODES.map(k => `<div class="vf-item vf-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</div>`).join('')}</div>`;
|
<div class="tb-pop">${MODES.map(k => `<button type="button" class="pop-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</button>`).join('')}</div>`;
|
||||||
toolbar.appendChild(wrap);
|
toolbar.appendChild(wrap);
|
||||||
const btn = wrap.querySelector('.vf-btn'), menu = wrap.querySelector('.vf-menu');
|
const btn = wrap.querySelector('.tb-btn'), menu = wrap.querySelector('.tb-pop');
|
||||||
|
const close = UI.popover(btn, menu);
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i>${LABELS[m][1]}<i class="bi bi-chevron-down"></i>`;
|
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i><span>${LABELS[m][1]}</span><i class="bi bi-chevron-down tb-caret"></i>`;
|
||||||
menu.querySelectorAll('.vf-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
|
menu.querySelectorAll('.pop-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
|
||||||
};
|
};
|
||||||
btn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('open'); });
|
|
||||||
menu.addEventListener('click', (e) => {
|
menu.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
const opt = e.target.closest('.pop-option');
|
||||||
const opt = e.target.closest('.vf-option');
|
|
||||||
if (!opt) return;
|
if (!opt) return;
|
||||||
menu.classList.remove('open');
|
close();
|
||||||
if (opt.dataset.mode === m) return;
|
if (opt.dataset.mode === m) return;
|
||||||
m = opt.dataset.mode;
|
m = opt.dataset.mode;
|
||||||
remember(m);
|
remember(m);
|
||||||
sync();
|
sync();
|
||||||
onChange(m);
|
onChange(m);
|
||||||
});
|
});
|
||||||
const closeOnOutside = () => {
|
|
||||||
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
|
|
||||||
menu.classList.remove('open');
|
|
||||||
};
|
|
||||||
document.addEventListener('click', closeOnOutside);
|
|
||||||
sync();
|
sync();
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-12
@@ -80,8 +80,10 @@ window.UI = (() => {
|
|||||||
const info = (text) => `<div class="ui-info">${icon('info-circle')}<span>${text}</span></div>`;
|
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 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 grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`;
|
||||||
const button = (label, iconName, attrs, cls) =>
|
// Eén knopbouwer; base = de knopsoort (ui-btn, tb-btn, ins-btn …), cls = extra klassen.
|
||||||
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
|
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 actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
|
||||||
|
|
||||||
const activeTabs = {};
|
const activeTabs = {};
|
||||||
@@ -128,16 +130,79 @@ window.UI = (() => {
|
|||||||
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
|
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycle3D(el) {
|
// Weergave van een editor: klassen fullscreen-2d/-3d op het editor-element (de looptocht bewaart en herstelt className).
|
||||||
el.classList.remove('fullscreen-2d');
|
const VIEWS = [
|
||||||
if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d');
|
{ key: 'split', icon: 'layout-split', label: '2D + 3D', key1: '1' },
|
||||||
else if (!el.classList.contains('render-only')) el.classList.add('render-only');
|
{ key: '2d', icon: 'square', label: 'Alleen 2D', key1: '2' },
|
||||||
else el.classList.remove('fullscreen-3d', 'render-only');
|
{ key: '3d', icon: 'box', label: 'Alleen 3D', key1: '3' }
|
||||||
}
|
];
|
||||||
function toggle2D(el) {
|
const viewOf = (el) => el.classList.contains('fullscreen-3d') ? '3d' : el.classList.contains('fullscreen-2d') ? '2d' : 'split';
|
||||||
el.classList.remove('fullscreen-3d', 'render-only');
|
function setView(el, mode) {
|
||||||
el.classList.toggle('fullscreen-2d');
|
el.classList.toggle('fullscreen-3d', mode === '3d');
|
||||||
|
el.classList.toggle('fullscreen-2d', mode === '2d');
|
||||||
|
el.classList.remove('render-only');
|
||||||
|
el.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
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, cycle3D, toggle2D };
|
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>`;
|
||||||
|
|
||||||
|
// 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'); release(close); };
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kaarten verdelen over kolommen van min. minW breed; telkens in de kortste kolom. Opnieuw aanroepen bij een andere breedte.
|
||||||
|
function masonry(el, minW, gap) {
|
||||||
|
if (!el) return;
|
||||||
|
gap = gap === undefined ? 12 : gap;
|
||||||
|
const cards = el._cards || (el._cards = [...el.children]);
|
||||||
|
const n = Math.max(1, Math.floor((el.clientWidth + gap) / (minW + gap)));
|
||||||
|
if (el._cols === n) return;
|
||||||
|
el._cols = n;
|
||||||
|
el.classList.add('ui-masonry');
|
||||||
|
const cols = Array.from({ length: n }, () => { const c = document.createElement('div'); c.className = 'ui-masonry-col'; return c; });
|
||||||
|
el.replaceChildren(...cols);
|
||||||
|
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, btnOf,
|
||||||
|
VIEWS, viewOf, setView, tb, tbSep, viewSeg, editorBar, popover, claim, release, closePopovers, masonry };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Tectum
|
|
||||||
* Copyright © 2026 Machiel Heinen.
|
|
||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
||||||
* Zie LICENTIE.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function createViewFilter(toolbar, items, onChange) {
|
|
||||||
const state = {};
|
|
||||||
items.forEach(it => { state[it.key] = it.checked !== false; });
|
|
||||||
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
wrap.className = 'view-filter';
|
|
||||||
wrap.innerHTML = `
|
|
||||||
<button type="button" class="btn-3d-toggle vf-btn"><i class="bi bi-eye"></i>Weergave<i class="bi bi-chevron-down"></i></button>
|
|
||||||
<div class="vf-menu">
|
|
||||||
${items.map(it => `<label class="vf-item"><input type="checkbox" data-key="${it.key}" ${state[it.key] ? 'checked' : ''}> ${it.label}</label>`).join('')}
|
|
||||||
</div>`;
|
|
||||||
toolbar.insertBefore(wrap, toolbar.firstChild);
|
|
||||||
|
|
||||||
const menu = wrap.querySelector('.vf-menu');
|
|
||||||
const btn = wrap.querySelector('.vf-btn');
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
menu.classList.toggle('open');
|
|
||||||
btn.classList.toggle('active', menu.classList.contains('open'));
|
|
||||||
});
|
|
||||||
menu.addEventListener('click', (e) => e.stopPropagation());
|
|
||||||
menu.addEventListener('change', (e) => {
|
|
||||||
const key = e.target.dataset.key;
|
|
||||||
if (!key) return;
|
|
||||||
state[key] = e.target.checked;
|
|
||||||
onChange(Object.assign({}, state));
|
|
||||||
});
|
|
||||||
const closeOnOutside = () => {
|
|
||||||
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
|
|
||||||
menu.classList.remove('open');
|
|
||||||
btn.classList.remove('active');
|
|
||||||
};
|
|
||||||
document.addEventListener('click', closeOnOutside);
|
|
||||||
|
|
||||||
return { state: () => Object.assign({}, state) };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
window.ViewLayers = (() => {
|
||||||
|
// phase: bouwvolgorde voor de snelkeuze "Fase" (en IFC); lagen zonder phase blijven daarbij aan.
|
||||||
|
const LIST = [
|
||||||
|
{ key: 'regelwerk', label: 'Regelwerk', icon: 'grid-3x3', phase: 0, hint: 'Vloer-, wand- en dakbalken, stalen nokbalk' },
|
||||||
|
{ key: 'elektra', label: 'Elektra', icon: 'lightning-charge', phase: 1, hint: 'Meterkast, buizen, dozen, schakelaars en wcd\'s' },
|
||||||
|
{ 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, 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 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);
|
||||||
|
const PHASES = ['Regelwerk', 'Elektra', 'Beplating', 'Vochtdoek', 'Latwerk', 'Gevel & dak', 'Afgewerkt'];
|
||||||
|
|
||||||
|
const all = () => Object.fromEntries(KEYS.map(k => [k, true]));
|
||||||
|
const normalize = (layers) => Object.fromEntries(KEYS.map(k => [k, !layers || layers[k] !== false]));
|
||||||
|
const shows = (layers, key) => !layers || layers[key] !== false;
|
||||||
|
// Alles tot en met een bouwfase (0..6), zoals de vroegere bouwfase-schuif.
|
||||||
|
const upTo = (phase) => Object.fromEntries(LIST.map(l => [l.key, l.phase === undefined || l.phase <= phase]));
|
||||||
|
const phaseOf = (layers) => PHASES.findIndex((_, p) => LIST.every(l => l.phase === undefined || (l.phase <= p) === shows(layers, l.key)));
|
||||||
|
|
||||||
|
// Verbergt meshes met een van de materialen (ook afgeleide met userData.base), bijv. het regelwerk.
|
||||||
|
function hideByMaterial(group, materials) {
|
||||||
|
if (!group) return;
|
||||||
|
const set = new Set(materials.filter(Boolean));
|
||||||
|
group.traverse(o => {
|
||||||
|
if (!o.isMesh) return;
|
||||||
|
const m0 = Array.isArray(o.material) ? o.material[0] : o.material;
|
||||||
|
if (m0 && (set.has(m0) || set.has(m0.userData && m0.userData.base))) o.visible = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS = [
|
||||||
|
['Casco', ['regelwerk', 'elektra', 'beplating', 'doek', 'latwerk']],
|
||||||
|
['Buiten', ['gevel', 'kozijnen', 'dakbedekking', 'dak']],
|
||||||
|
['Binnen', ['afwerking', 'lampen', 'inrichting', 'ruimtes']],
|
||||||
|
['Plattegrond', ['maten']]
|
||||||
|
];
|
||||||
|
|
||||||
|
// De enige plek voor zichtbaarheid: knop "Lagen" in de werkbalk met een uitklappaneel.
|
||||||
|
// keys beperkt de lagen (wand-editor); extras zijn editor-eigen schakelaars, bijv. beplating binnen/buiten.
|
||||||
|
function createMenu(toolbar, o) {
|
||||||
|
const items = o.keys ? LIST.filter(l => o.keys.includes(l.key)) : LIST;
|
||||||
|
const extras = o.extras || [];
|
||||||
|
const extra = Object.fromEntries(extras.map(x => [x.key, x.checked !== false]));
|
||||||
|
const check = (attr, key, label, hint, ic) =>
|
||||||
|
`<label class="pop-check" title="${hint || ''}"><input type="checkbox" ${attr}="${key}">${ic ? `<i class="bi bi-${ic}"></i>` : ''}<span>${label}</span></label>`;
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'tb-pop-wrap';
|
||||||
|
wrap.innerHTML = `<button type="button" class="tb-btn" title="Zichtbaarheid van lagen in 2D en 3D"><i class="bi bi-layers"></i><span>Lagen</span><i class="bi bi-chevron-down tb-caret"></i></button>
|
||||||
|
<div class="tb-pop pop-layers">
|
||||||
|
${items.length ? `<div class="pop-row">
|
||||||
|
<select class="pop-phase" title="Alles tot en met een bouwfase; Dak en Maten blijven zoals ze staan">
|
||||||
|
<option value="">Bouwfase…</option>${PHASES.map((p, i) => `<option value="${i}">${i + 1}. ${p}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="ui-btn" data-all>Alles</button>
|
||||||
|
</div>
|
||||||
|
<div class="pop-groups">${GROUPS.map(([label, keys]) => {
|
||||||
|
const g = items.filter(l => keys.includes(l.key));
|
||||||
|
return g.length ? `<div class="pop-group"><div class="pop-head">${label}</div>${g.map(l => check('data-layer', l.key, l.label, l.hint, l.icon)).join('')}</div>` : '';
|
||||||
|
}).join('')}</div>` : ''}
|
||||||
|
${extras.length ? `<div class="pop-group"><div class="pop-head">${o.extrasLabel || 'Weergave'}</div>${extras.map(x => check('data-extra', x.key, x.label, x.hint)).join('')}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
toolbar.appendChild(wrap);
|
||||||
|
const btn = wrap.querySelector('.tb-btn'), panel = wrap.querySelector('.tb-pop');
|
||||||
|
UI.popover(btn, panel);
|
||||||
|
let cur = normalize(o.layers);
|
||||||
|
const sel = panel.querySelector('.pop-phase');
|
||||||
|
const sync = () => {
|
||||||
|
panel.querySelectorAll('[data-layer]').forEach(c => { c.checked = cur[c.dataset.layer]; });
|
||||||
|
panel.querySelectorAll('[data-extra]').forEach(c => { c.checked = extra[c.dataset.extra]; });
|
||||||
|
if (sel) { const p = phaseOf(cur); sel.value = p >= 0 ? String(p) : ''; }
|
||||||
|
const hidden = items.some(l => !cur[l.key]) || extras.some(x => !extra[x.key]);
|
||||||
|
btn.classList.toggle('flag', hidden);
|
||||||
|
};
|
||||||
|
const emit = () => { sync(); o.onChange && o.onChange(Object.assign({}, cur)); };
|
||||||
|
panel.addEventListener('change', (e) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (t.dataset.layer) { cur = Object.assign({}, cur, { [t.dataset.layer]: t.checked }); emit(); }
|
||||||
|
else if (t.dataset.extra) { extra[t.dataset.extra] = t.checked; sync(); o.onExtra && o.onExtra(Object.assign({}, extra)); }
|
||||||
|
else if (t === sel && sel.value !== '') {
|
||||||
|
const keep = Object.fromEntries(LIST.filter(l => l.phase === undefined).map(l => [l.key, cur[l.key]]));
|
||||||
|
cur = Object.assign(upTo(Number(sel.value)), keep);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Alles = ook de editor-eigen schakelaars weer aan.
|
||||||
|
const all = panel.querySelector('[data-all]');
|
||||||
|
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(); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { LIST, KEYS, PHASES, GROUPS, all, normalize, shows, upTo, phaseOf, hideByMaterial, createMenu };
|
||||||
|
})();
|
||||||
@@ -95,7 +95,9 @@ window.COST_DEFAULTS = {
|
|||||||
steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 },
|
steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 },
|
||||||
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 },
|
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 },
|
||||||
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
|
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
|
||||||
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 }
|
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 },
|
||||||
|
felsM2: { label: 'Felsplaten gevel, staande naad (staal, klangen, ondergrond)', unit: '€/m²', value: 70 },
|
||||||
|
felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -123,7 +125,9 @@ window.COST_DEFAULTS = {
|
|||||||
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 },
|
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 },
|
paalHoutM3: { label: 'Paalhout overkapping (eiken, geschaafd)', unit: '€/m³', value: 2400 },
|
||||||
paalvoet: { label: 'Paalvoet verzinkt, verstelbaar (incl. ankers)', unit: '€/st.', value: 40 },
|
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,6 +155,8 @@ window.CostModel = (() => {
|
|||||||
add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM);
|
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;
|
const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
|
||||||
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
|
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);
|
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
|
||||||
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
|
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
|
||||||
const stuc = st.stucAreaM2 || 0;
|
const stuc = st.stucAreaM2 || 0;
|
||||||
@@ -242,6 +244,11 @@ window.CostModel = (() => {
|
|||||||
const pp = 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, '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, '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) {
|
if (parts.sandwich) {
|
||||||
const sw = parts.sandwich;
|
const sw = parts.sandwich;
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ window.CostSettings = (() => {
|
|||||||
<div class="cs-body"></div>
|
<div class="cs-body"></div>
|
||||||
<div class="material-info">Bedragen excl. btw. De CSV's in kosten/ zijn leidend: Toepassen geldt tot herladen; om een wijziging te houden "intern.csv downloaden" en het bestand in kosten/ vervangen, of met "Wijzigingen downloaden" alleen de aangepaste velden (met de standaardwaarde ernaast) opslaan. Prijzen pas je aan in leveranciers.csv (CSV = daaruit, anders noodwaarde). Een veld dat afwijkt van intern.csv is gemarkeerd.</div>
|
<div class="material-info">Bedragen excl. btw. De CSV's in kosten/ zijn leidend: Toepassen geldt tot herladen; om een wijziging te houden "intern.csv downloaden" en het bestand in kosten/ vervangen, of met "Wijzigingen downloaden" alleen de aangepaste velden (met de standaardwaarde ernaast) opslaan. Prijzen pas je aan in leveranciers.csv (CSV = daaruit, anders noodwaarde). Een veld dat afwijkt van intern.csv is gemarkeerd.</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="io-btn" data-cs="reset">Standaard</button>
|
<button type="button" class="ui-btn" data-cs="reset">Standaard</button>
|
||||||
<button type="button" class="io-btn" data-cs="export">intern.csv downloaden</button>
|
<button type="button" class="ui-btn" data-cs="export">intern.csv downloaden</button>
|
||||||
<button type="button" class="io-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="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
|
||||||
<button type="button" class="io-btn" data-cs="import">Importeren…</button>
|
<button type="button" class="ui-btn" data-cs="import">Importeren…</button>
|
||||||
<span style="flex:1"></span>
|
<span style="flex:1"></span>
|
||||||
<button type="button" class="io-btn" data-cs="cancel">Annuleren</button>
|
<button type="button" class="ui-btn" data-cs="cancel" data-esc>Annuleren</button>
|
||||||
<button type="button" class="material-toggle-btn active" data-cs="save" style="flex:none; padding:6px 18px;">Toepassen</button>
|
<button type="button" class="ui-btn ui-btn-primary" data-cs="save">Toepassen</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
|
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
|
||||||
<input type="file" data-file="leveranciers" accept=".csv,text/csv,text/plain" style="display:none">
|
<input type="file" data-file="leveranciers" accept=".csv,text/csv,text/plain" style="display:none">
|
||||||
|
|||||||
+220
-24
@@ -143,9 +143,68 @@ window.ElektraModel = (() => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRouter(units) {
|
// 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.
|
||||||
|
// 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;
|
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;
|
||||||
const ceilAt = (x, z) => {
|
// 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);
|
||||||
|
if (!u) return null;
|
||||||
|
let v = roomPlenum(x, z);
|
||||||
|
if (v === undefined) {
|
||||||
|
v = null;
|
||||||
|
[[1, 0], [-1, 0], [0, 1], [0, -1]].forEach(([dx, dz]) => {
|
||||||
|
for (const d of [60, 120, 200, 300]) {
|
||||||
|
const o = roomPlenum(x + dx * d, z + dz * d);
|
||||||
|
if (o === undefined) continue;
|
||||||
|
if (o !== null) v = v === null ? o : Math.min(v, o);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return v === null ? null : u.layout.floorTop + v;
|
||||||
|
};
|
||||||
|
const roofCeilAt = (x, z) => {
|
||||||
let u = unitAt(x, z);
|
let u = unitAt(x, z);
|
||||||
if (!u) u = units.reduce((best, c) => {
|
if (!u) u = units.reduce((best, c) => {
|
||||||
const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1);
|
const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1);
|
||||||
@@ -155,24 +214,159 @@ window.ElektraModel = (() => {
|
|||||||
const r = u.rect;
|
const r = u.rect;
|
||||||
return u.roof.ceilingAt(Math.max(r.x0 + 1, Math.min(r.x1 - 1, x)), Math.max(r.z0 + 1, Math.min(r.z1 - 1, z)));
|
return u.roof.ceilingAt(Math.max(r.x0 + 1, Math.min(r.x1 - 1, x)), Math.max(r.z0 + 1, Math.min(r.z1 - 1, z)));
|
||||||
};
|
};
|
||||||
|
// Leidingvlak: het valse plafond waar dat lager ligt dan het dak, anders de onderkant van het dak.
|
||||||
|
const plenumOn = (x, z) => { const v = plenumAt(x, z); return v !== null && v < roofCeilAt(x, z) - 1 ? v : null; };
|
||||||
|
const ceilAt = (x, z) => { const v = plenumOn(x, z); return v !== null ? v : roofCeilAt(x, z); };
|
||||||
|
const inPlenum = (x, z, y) => y < roofCeilAt(x, z) - 50;
|
||||||
|
// Over het plafond het vlak volgen: knikken in het dak (nok, kil) als tussenpunt, de overgang vals plafond/dak
|
||||||
|
// verticaal op de grens (in de wand), zodat de buis nooit los door de ruimte of buiten het dak loopt.
|
||||||
|
function followCeiling(pts) {
|
||||||
|
const same = (u, v) => (u === null && v === null) || (u !== null && v !== null && Math.abs(u - v) < 1);
|
||||||
|
const out = [pts[0]];
|
||||||
|
pts.slice(1).forEach((q, i) => {
|
||||||
|
const p = pts[i], len = Math.hypot(q.x - p.x, q.z - p.z);
|
||||||
|
if (p.ceil && q.ceil && !p.floor && !q.floor && len > 1) {
|
||||||
|
const at = (t) => ({ x: p.x + (q.x - p.x) * t, z: p.z + (q.z - p.z) * t });
|
||||||
|
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));
|
||||||
|
// 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)) {
|
||||||
|
let lo = A.t, hi = B.t;
|
||||||
|
for (let it = 0; it < 10; it++) { const m = (lo + hi) / 2, pm = at(m); if (same(plenumOn(pm.x, pm.z), A.st)) lo = m; else hi = m; }
|
||||||
|
const pb = at((lo + hi) / 2);
|
||||||
|
out.push(mk(pb, hOf(pb, A.st)), mk(pb, hOf(pb, B.st)));
|
||||||
|
} else if (k < n && same(B.st, S[k + 1].st) && Math.abs(A.y - 2 * B.y + S[k + 1].y) > 2) out.push(mk(B.pt, B.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(q);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; };
|
const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; };
|
||||||
|
const clampTo = (v, lo, hi) => lo > hi ? (lo + hi) / 2 : Math.max(lo, Math.min(hi, v));
|
||||||
|
const segInside = (p, q) => {
|
||||||
|
const n = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 50));
|
||||||
|
for (let k = 0; k <= n; k++) if (!unitAt(p.x + (q.x - p.x) * k / n, p.z + (q.z - p.z) * k / n)) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// Punt buiten de woning (gevellamp, dakrandspot): eerst loodrecht naar de dichtstbijzijnde unit.
|
||||||
|
const clampIn = (p) => {
|
||||||
|
if (unitAt(p.x, p.z)) return null;
|
||||||
|
let best = null;
|
||||||
|
units.forEach(u => {
|
||||||
|
const r = u.rect, x = clampTo(p.x, r.x0 + 1, r.x1 - 1), z = clampTo(p.z, r.z0 + 1, r.z1 - 1), d = Math.hypot(x - p.x, z - p.z);
|
||||||
|
if (!best || d < best.d) best = { x, z, d };
|
||||||
|
});
|
||||||
|
return best && { x: best.x, z: best.z };
|
||||||
|
};
|
||||||
|
// Knikpunten in de plattegrond tussen a en b (exclusief), haaks en binnen de units: bij een L- of T-vorm mag de
|
||||||
|
// knik niet in de lege hoek buiten de woning vallen. Eerst x dan z (zoals altijd), anders z dan x, anders via een unit.
|
||||||
|
function corners(a, b, viaCeiling) {
|
||||||
|
const A = clampIn(a), B = clampIn(b), pa = A || a, pb = B || b;
|
||||||
|
const cands = [[{ x: pb.x, z: pa.z }], [{ x: pa.x, z: pb.z }]];
|
||||||
|
units.forEach(u => {
|
||||||
|
const r = u.rect, xm = clampTo((pa.x + pb.x) / 2, r.x0 + 150, r.x1 - 150), zm = clampTo((pa.z + pb.z) / 2, r.z0 + 150, r.z1 - 150);
|
||||||
|
cands.push([{ x: xm, z: pa.z }, { x: xm, z: pb.z }], [{ x: pa.x, z: zm }, { x: pb.x, z: zm }]);
|
||||||
|
});
|
||||||
|
let best = null;
|
||||||
|
cands.forEach(c => {
|
||||||
|
const pts = [pa].concat(c, [pb]);
|
||||||
|
if (!pts.slice(1).every((q, i) => segInside(pts[i], q))) return;
|
||||||
|
let len = pts.slice(1).reduce((t, q, i) => t + Math.abs(q.x - pts[i].x) + Math.abs(q.z - pts[i].z), 0);
|
||||||
|
// Over het plafond telt op- en neergaan mee: liever door een laag vals plafond dan over de nok.
|
||||||
|
if (viaCeiling) pts.slice(1).forEach((q, i) => {
|
||||||
|
const p = pts[i], m = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 200));
|
||||||
|
let prev = ceilAt(p.x, p.z);
|
||||||
|
for (let k = 1; k <= m; k++) { const y = ceilAt(p.x + (q.x - p.x) * k / m, p.z + (q.z - p.z) * k / m); len += Math.abs(y - prev); prev = y; }
|
||||||
|
});
|
||||||
|
if (!best || len < best.len - 1) best = { len, c };
|
||||||
|
});
|
||||||
|
return [A].concat(best ? best.c : cands[0], [B]).filter(Boolean)
|
||||||
|
.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) {
|
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 });
|
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') {
|
if (a.mount === 'vloer' || b.mount === 'vloer') {
|
||||||
const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH;
|
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), P({ x: b.x, y: fa, z: a.z }, null, false, true),
|
const A2 = a.mount === 'vloer' ? null : sidestep(a, () => fa), B2 = b.mount === 'vloer' ? null : sidestep(b, () => fb);
|
||||||
P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')];
|
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)];
|
// Langs dezelfde wand: eerst verticaal of eerst horizontaal; zit er een deur of raam tussen, dan via het plafond.
|
||||||
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a), P({ x: b.x, y: a.y, z: a.z }), P(b)];
|
if (a.face && a.face === b.face) {
|
||||||
const top = (p) => p.mount === 'plafond' ? p.y : ceilAt(p.x, p.z);
|
const viaA = [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
|
||||||
const ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1;
|
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 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')];
|
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));
|
if (A2) pts.push(P(A2, a.face));
|
||||||
pts.push(P({ x: b.x, y: ceilAt(b.x, a.z), z: a.z }, null, true));
|
if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a2.x, y: ya, z: a2.z }, a.face, true));
|
||||||
pts.push(P({ x: b.x, y: yb, z: b.z }, dropB ? b.face : null, true));
|
corners(a2, b2, true).forEach(c => pts.push(P({ x: c.x, y: ceilAt(c.x, c.z), z: c.z }, null, true)));
|
||||||
if (dropB) pts.push(P(b, b.face, false));
|
pts.push(P({ x: b2.x, y: yb, z: b2.z }, dropB ? b.face : null, true));
|
||||||
return pts;
|
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);
|
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);
|
||||||
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
|
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
|
||||||
@@ -201,7 +395,8 @@ window.ElektraModel = (() => {
|
|||||||
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
|
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
|
||||||
};
|
};
|
||||||
if (!s.enabled || !units.length) return out;
|
if (!s.enabled || !units.length) return out;
|
||||||
const R = makeRouter(units);
|
const blocked = makeOpeningTest(units, interior);
|
||||||
|
const R = makeRouter(units, rooms, blocked);
|
||||||
const faces = {};
|
const faces = {};
|
||||||
units.forEach(u => { faces[u.id] = facesOf(u, interior); });
|
units.forEach(u => { faces[u.id] = facesOf(u, interior); });
|
||||||
const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1);
|
const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1);
|
||||||
@@ -522,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));
|
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);
|
const holesOf = makeHoleCounter(units, R);
|
||||||
out.runs.forEach(r => {
|
out.runs.forEach(r => {
|
||||||
r.rawMM = R.length(r.path);
|
r.rawMM = R.length(r.path);
|
||||||
@@ -611,7 +806,7 @@ window.ElektraModel = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Uitzondering: het laatste stuk naar de meterkast; daar komen de buizen tegen elkaar aan de kast binnen.
|
// 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 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 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);
|
const dx = Math.abs(q.x - p.x), dy = Math.abs(q.y - p.y), dz = Math.abs(q.z - p.z);
|
||||||
@@ -682,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 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);
|
const j1 = seg(r, p, p2, rad), j2 = seg(r, q2, q, rad);
|
||||||
if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue;
|
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));
|
pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1));
|
||||||
placed.push(s2, j1, j2);
|
placed.push(s2, j1, j2);
|
||||||
i += 2;
|
i += 2;
|
||||||
@@ -735,13 +931,13 @@ window.ElektraModel = (() => {
|
|||||||
conduit
|
conduit
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function build3D(el, materials, stage) {
|
// rough: buizen en inbouwdozen (in de wand en boven het plafond); uit bij de render-export.
|
||||||
|
function build3D(el, materials, layers, rough) {
|
||||||
const toM = 0.001, group = new THREE.Group();
|
const toM = 0.001, group = new THREE.Group();
|
||||||
if (!el || !el.enabled) return group;
|
if (!el || !el.enabled) return group;
|
||||||
const mats = materials || createMaterials();
|
const mats = materials || createMaterials();
|
||||||
mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex);
|
mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex);
|
||||||
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key);
|
if (!ViewLayers.shows(layers, 'elektra')) return group;
|
||||||
const rough = show('elektra') && !show('beplating');
|
|
||||||
const box = (w, h, d, c, rotY, mat) => {
|
const box = (w, h, d, c, rotY, mat) => {
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
|
||||||
mesh.position.set(c.x * toM, c.y * toM, c.z * toM);
|
mesh.position.set(c.x * toM, c.y * toM, c.z * toM);
|
||||||
@@ -750,18 +946,18 @@ window.ElektraModel = (() => {
|
|||||||
return mesh;
|
return mesh;
|
||||||
};
|
};
|
||||||
const k = el.meterkast;
|
const k = el.meterkast;
|
||||||
if (k && show('elektra')) {
|
if (k) {
|
||||||
box(k.w, k.h, k.d, k.center, k.rotY, mats.kastMaterial);
|
box(k.w, k.h, k.d, k.center, k.rotY, mats.kastMaterial);
|
||||||
const front = facePoint(k.face, k.u, k.y + k.h / 2, k.d + 2);
|
const front = facePoint(k.face, k.u, k.y + k.h / 2, k.d + 2);
|
||||||
box(k.w - 30, k.h - 30, 4, front, k.rotY, mats.deurMaterial);
|
box(k.w - 30, k.h - 30, 4, front, k.rotY, mats.deurMaterial);
|
||||||
}
|
}
|
||||||
if (show('afwerking')) {
|
if (ViewLayers.shows(layers, 'afwerking')) {
|
||||||
el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial));
|
el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial));
|
||||||
el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial));
|
el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial));
|
||||||
el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial));
|
el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial));
|
||||||
el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial));
|
el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial));
|
||||||
}
|
}
|
||||||
if (!rough) return group;
|
if (rough === false) return group;
|
||||||
|
|
||||||
const up = new THREE.Vector3(0, 1, 0);
|
const up = new THREE.Vector3(0, 1, 0);
|
||||||
const shift = (p) => ({
|
const shift = (p) => ({
|
||||||
@@ -859,7 +1055,7 @@ window.ElektraModel = (() => {
|
|||||||
} else {
|
} else {
|
||||||
n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length;
|
n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length;
|
||||||
}
|
}
|
||||||
} else if (!vertical && p.ceil && q.ceil) {
|
} else if (!vertical && p.ceil && q.ceil && !(p.plenum && q.plenum)) {
|
||||||
const mid = { x: (p.x + q.x) / 2, z: (p.z + q.z) / 2 }, u = R.unitAt(mid.x, mid.z);
|
const mid = { x: (p.x + q.x) / 2, z: (p.z + q.z) / 2 }, u = R.unitAt(mid.x, mid.z);
|
||||||
if (u) u.roof.planes.forEach(pl => {
|
if (u) u.roof.planes.forEach(pl => {
|
||||||
const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds);
|
const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds);
|
||||||
|
|||||||
+36
-54
@@ -6,7 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const FLOOR_TEMPLATE = `
|
const FLOOR_TEMPLATE = `
|
||||||
<div id="floor-app">
|
<div id="floor-app" class="ed-app">
|
||||||
|
<div class="ed-main">
|
||||||
|
<div class="ed-toolbar" id="floor-toolbar"></div>
|
||||||
|
<div class="ed-views">
|
||||||
<div id="floor-left-column">
|
<div id="floor-left-column">
|
||||||
<div id="floor-pane-2d-container">
|
<div id="floor-pane-2d-container">
|
||||||
<div class="pane-2d-sub" id="floor-pane-top">
|
<div class="pane-2d-sub" id="floor-pane-top">
|
||||||
@@ -18,7 +21,15 @@ const FLOOR_TEMPLATE = `
|
|||||||
<canvas id="floor-canvas-section"></canvas>
|
<canvas id="floor-canvas-section"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="floor-control-panel">
|
</div>
|
||||||
|
<div id="floor-right-column">
|
||||||
|
<div id="floor-pane-3d">
|
||||||
|
<div class="pane-label">3D</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="floor-control-panel" class="ed-panel">
|
||||||
<div class="io-toolbar">
|
<div class="io-toolbar">
|
||||||
<input type="file" id="f-file-import-json" accept="application/json" style="display:none;">
|
<input type="file" id="f-file-import-json" accept="application/json" style="display:none;">
|
||||||
<span id="f-io-status" class="io-status"></span>
|
<span id="f-io-status" class="io-status"></span>
|
||||||
@@ -27,6 +38,7 @@ const FLOOR_TEMPLATE = `
|
|||||||
<button class="tab-btn active" data-tab="floor-tab-main"><i class="bi bi-rulers"></i> Opbouw</button>
|
<button class="tab-btn active" data-tab="floor-tab-main"><i class="bi bi-rulers"></i> Opbouw</button>
|
||||||
<button class="tab-btn" data-tab="floor-tab-openings"><i class="bi bi-bullseye"></i> Doorvoeren</button>
|
<button class="tab-btn" data-tab="floor-tab-openings"><i class="bi bi-bullseye"></i> Doorvoeren</button>
|
||||||
<button class="tab-btn" data-tab="floor-tab-chassis"><i class="bi bi-truck"></i> Chassis</button>
|
<button class="tab-btn" data-tab="floor-tab-chassis"><i class="bi bi-truck"></i> Chassis</button>
|
||||||
|
<button class="tab-btn" data-tab="floor-tab-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="floor-tab-main" class="tab-content active">
|
<div id="floor-tab-main" class="tab-content active">
|
||||||
@@ -57,20 +69,6 @@ const FLOOR_TEMPLATE = `
|
|||||||
<div class="control-group"><label>Afstand max.</label>
|
<div class="control-group"><label>Afstand max.</label>
|
||||||
<div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div>
|
<div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
<div class="result-row"><span>Vloerbalken</span><span data-spec="joist-count" class="highlight">-</span></div>
|
|
||||||
<div class="result-row"><span>Vloeroppervlak</span><span data-spec="floor-area" class="highlight">-</span></div>
|
|
||||||
<div class="result-row"><span>Volume balkhout</span><span data-spec="joist-m3" class="highlight">-</span></div>
|
|
||||||
<div class="result-row"><span>Gewicht balkhout</span><span data-spec="joist-kg" class="highlight">-</span></div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
<div class="result-row"><span>OSB oppervlak</span><span data-spec="osb-m2" class="highlight">-</span></div>
|
|
||||||
<div class="result-row"><span>OSB volume</span><span data-spec="osb-m3" class="highlight">-</span></div>
|
|
||||||
<div class="result-row"><span>OSB gewicht</span><span data-spec="osb-kg" class="highlight">-</span></div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
<div class="result-row"><span>Isolatie</span><span data-spec="void-m3" class="highlight">-</span></div>
|
|
||||||
<div class="result-row"><span>Totaal gewicht</span><span data-spec="total-kg" class="highlight">-</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,9 +80,8 @@ const FLOOR_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="floor-tab-chassis" class="tab-content">
|
<div id="floor-tab-chassis" class="tab-content">
|
||||||
<div class="material-toggle-group">
|
<div class="control-group"><label for="f-chassis-on">Chassis-laag</label>
|
||||||
<button type="button" id="f-btn-chassis-toggle" class="material-toggle-btn">Chassis-laag: Uit</button>
|
<div><label class="ui-switch"><input type="checkbox" id="f-chassis-on"><span></span></label></div></div>
|
||||||
</div>
|
|
||||||
<div class="controls-grid">
|
<div class="controls-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="control-group"><label>Wieldiameter</label>
|
<div class="control-group"><label>Wieldiameter</label>
|
||||||
@@ -99,17 +96,9 @@ const FLOOR_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
<div id="f-chassis-warning" class="global-warning" style="display:none;"></div>
|
<div id="f-chassis-warning" class="global-warning" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div id="floor-tab-specs" class="tab-content">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="floor-right-column">
|
|
||||||
<div id="floor-pane-3d">
|
|
||||||
<div class="pane-label">3D</div>
|
|
||||||
<div class="toolbar-3d" id="floor-toolbar-3d"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="floor-specs-panel-3d">
|
<div id="floor-specs-panel-3d">
|
||||||
<div class="controls-grid">
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div class="spec-section">
|
<div class="spec-section">
|
||||||
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
||||||
@@ -139,6 +128,7 @@ const FLOOR_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="wall-project-overlay">
|
<div id="wall-project-overlay">
|
||||||
<div id="wall-project-modal">
|
<div id="wall-project-modal">
|
||||||
@@ -169,8 +159,8 @@ const FLOOR_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" id="wall-project-cancel" class="io-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="material-toggle-btn active" style="flex:none; padding:6px 18px;">Opslaan</button>
|
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,16 +275,14 @@ function mountFloorEditor(container, callbacks) {
|
|||||||
document.querySelectorAll('#floor-tab-main input').forEach(inp => inp.addEventListener('input', updateAll));
|
document.querySelectorAll('#floor-tab-main input').forEach(inp => inp.addEventListener('input', updateAll));
|
||||||
|
|
||||||
function syncChassisToggleLabel() {
|
function syncChassisToggleLabel() {
|
||||||
const btn = document.getElementById('f-btn-chassis-toggle');
|
document.getElementById('f-chassis-on').checked = chassis.enabled;
|
||||||
btn.classList.toggle('active', chassis.enabled);
|
|
||||||
btn.textContent = 'Chassis-laag: ' + (chassis.enabled ? 'Aan' : 'Uit');
|
|
||||||
}
|
}
|
||||||
document.getElementById('f-btn-chassis-toggle').addEventListener('click', () => {
|
document.getElementById('f-chassis-on').addEventListener('change', (e) => {
|
||||||
chassis.enabled = !chassis.enabled;
|
chassis.enabled = e.target.checked;
|
||||||
syncChassisToggleLabel();
|
syncChassisToggleLabel();
|
||||||
updateAll();
|
updateAll();
|
||||||
});
|
});
|
||||||
document.querySelectorAll('#floor-tab-chassis input').forEach(inp => inp.addEventListener('input', () => {
|
document.querySelectorAll('#floor-tab-chassis input[type=number]').forEach(inp => inp.addEventListener('input', () => {
|
||||||
chassis.wheelDiameter = parseFloat(document.getElementById('f-inp-wheel-d').value) || 400;
|
chassis.wheelDiameter = parseFloat(document.getElementById('f-inp-wheel-d').value) || 400;
|
||||||
chassis.trackWidth = parseFloat(document.getElementById('f-inp-track').value) || 1200;
|
chassis.trackWidth = parseFloat(document.getElementById('f-inp-track').value) || 1200;
|
||||||
chassis.wheelbase = parseFloat(document.getElementById('f-inp-wheelbase').value) || 1200;
|
chassis.wheelbase = parseFloat(document.getElementById('f-inp-wheelbase').value) || 1200;
|
||||||
@@ -632,19 +620,21 @@ function mountFloorEditor(container, callbacks) {
|
|||||||
let lastResolvedOpenings = [];
|
let lastResolvedOpenings = [];
|
||||||
let lastChassisData = { wheels: [], outOfBounds: false };
|
let lastChassisData = { wheels: [], outOfBounds: false };
|
||||||
|
|
||||||
createViewFilter(document.getElementById('floor-toolbar-3d'), [
|
|
||||||
{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }
|
|
||||||
], (v) => {
|
|
||||||
osbVisible = v.beplating;
|
|
||||||
update3DScene();
|
|
||||||
});
|
|
||||||
let skyMode = Skybox.getMode();
|
let skyMode = Skybox.getMode();
|
||||||
const skyEnv = Skybox.createEnvironment(scene);
|
const skyEnv = Skybox.createEnvironment(scene);
|
||||||
const applySky = () => {
|
const applySky = () => {
|
||||||
skyEnv.fitTo(floorGroup3D, 0);
|
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.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient: floorAmbient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: floorGroup3D });
|
||||||
};
|
};
|
||||||
Skybox.createToggle(document.getElementById('floor-toolbar-3d'), (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() {
|
function update3DScene() {
|
||||||
if (floorGroup3D) scene.remove(floorGroup3D);
|
if (floorGroup3D) scene.remove(floorGroup3D);
|
||||||
@@ -691,14 +681,6 @@ function mountFloorEditor(container, callbacks) {
|
|||||||
}
|
}
|
||||||
window.addEventListener('resize', onWindowResize);
|
window.addEventListener('resize', onWindowResize);
|
||||||
|
|
||||||
function toggleFullscreen3D() {
|
|
||||||
UI.cycle3D(document.getElementById('floor-app'));
|
|
||||||
requestAnimationFrame(onWindowResize);
|
|
||||||
}
|
|
||||||
function toggleFullscreen2D() {
|
|
||||||
UI.toggle2D(document.getElementById('floor-app'));
|
|
||||||
requestAnimationFrame(onWindowResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPureState() {
|
function getPureState() {
|
||||||
return {
|
return {
|
||||||
@@ -748,8 +730,8 @@ function mountFloorEditor(container, callbacks) {
|
|||||||
},
|
},
|
||||||
exportDesign,
|
exportDesign,
|
||||||
triggerImport,
|
triggerImport,
|
||||||
toggleFullscreen3D,
|
setView: bar.setView,
|
||||||
toggleFullscreen2D,
|
viewState: bar.viewState,
|
||||||
openProjectProperties,
|
openProjectProperties,
|
||||||
getStateJson: getPureState,
|
getStateJson: getPureState,
|
||||||
loadStateJson: applyPureState
|
loadStateJson: applyPureState
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ window.HouseDxf = (() => {
|
|||||||
const s = pp.size / 2;
|
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');
|
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 => {
|
(parts.chimneys || []).forEach(ch => {
|
||||||
dxf.polyline(ch.footprint.map(T), true, 'PL-SCHOORSTEEN');
|
dxf.polyline(ch.footprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||||
dxf.polyline(ch.flueFootprint.map(T), true, 'PL-SCHOORSTEEN');
|
dxf.polyline(ch.flueFootprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||||
|
|||||||
@@ -49,19 +49,20 @@ window.HouseIfc = (() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function build(state, stage) {
|
// rough: buizen en dozen bouwen; de plaatfase verzamelt alleen platen en heeft ze niet nodig.
|
||||||
|
function build(state, layers, rough) {
|
||||||
const M = materialsSet();
|
const M = materialsSet();
|
||||||
const parts = HouseModel.computeParts(state, {
|
const parts = HouseModel.computeParts(state, {
|
||||||
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
|
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
|
||||||
stage, lampenVisible: false
|
layers, elektraRough: rough
|
||||||
});
|
});
|
||||||
HouseModel.placeGroups(parts);
|
HouseModel.placeGroups(parts);
|
||||||
return { parts, classify: classifier(M) };
|
return { parts, classify: classifier(M) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function generate(state) {
|
function generate(state) {
|
||||||
const frame = build(state, BuildStages.INDEX.elektra);
|
const frame = build(state, ViewLayers.upTo(1), true);
|
||||||
const sheet = build(state, BuildStages.INDEX.beplating);
|
const sheet = build(state, ViewLayers.upTo(2), false);
|
||||||
const parts = frame.parts;
|
const parts = frame.parts;
|
||||||
if (!parts.units.length) throw new Error('geen units in het ontwerp');
|
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));
|
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||||||
|
|||||||
+355
-34
@@ -34,7 +34,7 @@ window.HouseModel = (() => {
|
|||||||
function createDefaultSettings() {
|
function createDefaultSettings() {
|
||||||
return {
|
return {
|
||||||
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
|
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
|
||||||
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit' },
|
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit', felsColor: 'antraciet' },
|
||||||
cladding: 'horizontaal',
|
cladding: 'horizontaal',
|
||||||
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' },
|
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' },
|
||||||
interiorPaint: true,
|
interiorPaint: true,
|
||||||
@@ -54,7 +54,7 @@ window.HouseModel = (() => {
|
|||||||
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting(), facade: { walls: {} } };
|
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) {
|
function facadeFor(state, u, side, piece) {
|
||||||
const f = state.settings.facade;
|
const f = state.settings.facade;
|
||||||
const ow = (u.facade && u.facade.walls && u.facade.walls[side]) || null;
|
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 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 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;
|
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) {
|
function facadeMaterials(state, fc) {
|
||||||
const tr = state.settings.trasraam;
|
const tr = state.settings.trasraam;
|
||||||
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
|
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
|
||||||
const cladding = state.settings.cladding;
|
const cladding = state.settings.cladding;
|
||||||
if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null };
|
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 };
|
if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null, fels: null };
|
||||||
return { exterior: 'vochtdoek', stone: null, stuc: null, cladding, trasraam, rabatColor: state.settings.facade.rabatColor };
|
// 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) {
|
function newModelState(naam) {
|
||||||
@@ -154,6 +157,7 @@ window.HouseModel = (() => {
|
|||||||
S.lighting.mode = 'gevel';
|
S.lighting.mode = 'gevel';
|
||||||
S.lighting.direction = 'both';
|
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.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: 11240, depth: 3600, height: H, windowOn: [] });
|
const A = createUnitEntry('A', null, { width: 11240, depth: 3600, height: H, windowOn: [] });
|
||||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10600, depth: 3600, height: H, windowOn: [] });
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10600, depth: 3600, height: H, windowOn: [] });
|
||||||
@@ -243,6 +247,7 @@ window.HouseModel = (() => {
|
|||||||
S.lighting.mode = 'gevel';
|
S.lighting.mode = 'gevel';
|
||||||
S.lighting.direction = 'both';
|
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.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: W, depth: DA, height: H, windowOn: [] });
|
const A = createUnitEntry('A', null, { width: W, depth: DA, height: H, windowOn: [] });
|
||||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: DB, height: H, windowOn: [] });
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: DB, height: H, windowOn: [] });
|
||||||
@@ -309,6 +314,7 @@ window.HouseModel = (() => {
|
|||||||
S.lighting.mode = 'spots';
|
S.lighting.mode = 'spots';
|
||||||
S.lighting.direction = 'both';
|
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.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: 14000, depth: D, height: LO, windowOn: [] });
|
const A = createUnitEntry('A', null, { width: 14000, depth: D, height: LO, windowOn: [] });
|
||||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 4600, depth: 3000, height: LO, windowOn: [] });
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 4600, depth: 3000, height: LO, windowOn: [] });
|
||||||
@@ -383,6 +389,7 @@ window.HouseModel = (() => {
|
|||||||
S.lighting.mode = 'spots';
|
S.lighting.mode = 'spots';
|
||||||
S.lighting.direction = 'both';
|
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.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: W, depth: D, height: LO, windowOn: [] });
|
const A = createUnitEntry('A', null, { width: W, depth: D, height: LO, windowOn: [] });
|
||||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: D, height: LO, windowOn: [] });
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: D, height: LO, windowOn: [] });
|
||||||
@@ -473,6 +480,7 @@ window.HouseModel = (() => {
|
|||||||
S.lighting.mode = 'spots';
|
S.lighting.mode = 'spots';
|
||||||
S.lighting.direction = 'both';
|
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.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: 9200 - V, depth: D, height: LO, windowOn: [] });
|
const A = createUnitEntry('A', null, { width: 9200 - V, depth: D, height: LO, windowOn: [] });
|
||||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 9200, depth: D, height: LO, windowOn: [] });
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 9200, depth: D, height: LO, windowOn: [] });
|
||||||
@@ -567,6 +575,7 @@ window.HouseModel = (() => {
|
|||||||
S.lighting.mode = 'spots';
|
S.lighting.mode = 'spots';
|
||||||
S.lighting.direction = 'both';
|
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.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: 13000, depth: D, height: LO, windowOn: [] });
|
const A = createUnitEntry('A', null, { width: 13000, depth: D, height: LO, windowOn: [] });
|
||||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 13000, depth: D, height: LO, windowOn: [] });
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 13000, depth: D, height: LO, windowOn: [] });
|
||||||
@@ -654,6 +663,219 @@ window.HouseModel = (() => {
|
|||||||
return st;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
|
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
|
||||||
const MODELS = {
|
const MODELS = {
|
||||||
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
|
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
|
||||||
@@ -661,7 +883,9 @@ window.HouseModel = (() => {
|
|||||||
ijssel: { label: 'Woning IJssel', icon: 'houses', create: createIJssel },
|
ijssel: { label: 'Woning IJssel', icon: 'houses', create: createIJssel },
|
||||||
waal: { label: 'Woning Waal', icon: 'house-add', create: createWaal },
|
waal: { label: 'Woning Waal', icon: 'house-add', create: createWaal },
|
||||||
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
|
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
|
||||||
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn }
|
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
|
||||||
|
reest: { label: 'Woning Reest', icon: 'house-up', create: createReest },
|
||||||
|
vecht: { label: 'Woning Vecht', icon: 'house-down', create: createVecht }
|
||||||
};
|
};
|
||||||
|
|
||||||
function createDefaultState(template) {
|
function createDefaultState(template) {
|
||||||
@@ -1231,8 +1455,9 @@ window.HouseModel = (() => {
|
|||||||
function computeParts(state, opts) {
|
function computeParts(state, opts) {
|
||||||
const o = opts || {};
|
const o = opts || {};
|
||||||
const build = !o.skip3D;
|
const build = !o.skip3D;
|
||||||
const stage = BuildStages.clamp(o.stage);
|
const layers = ViewLayers.normalize(o.layers);
|
||||||
const shows = (key) => BuildStages.shows(stage, key);
|
const shows = (key) => layers[key];
|
||||||
|
const hideFrame = (group, mats) => { if (group && mats && !shows('regelwerk')) ViewLayers.hideByMaterial(group, [mats.woodMaterial, mats.steelMaterial]); };
|
||||||
applyGlobalSettings(state);
|
applyGlobalSettings(state);
|
||||||
const rects = unitRects(state);
|
const rects = unitRects(state);
|
||||||
const rectById = {};
|
const rectById = {};
|
||||||
@@ -1252,11 +1477,11 @@ window.HouseModel = (() => {
|
|||||||
});
|
});
|
||||||
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
|
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
|
||||||
|
|
||||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop) {
|
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels) {
|
||||||
if (!build) return { group: null, stats: null, specs: null };
|
if (!build) return { group: null, stats: null, specs: null };
|
||||||
const built = WallModel.build3D(config, frame, {
|
const built = WallModel.build3D(config, frame, {
|
||||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop,
|
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels,
|
||||||
painted: state.settings.interiorPaint && shows('afwerking'), stage, rabatColor: state.settings.facade.rabatColor
|
painted: state.settings.interiorPaint && shows('afwerking'), layers, rabatColor: state.settings.facade.rabatColor
|
||||||
});
|
});
|
||||||
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
||||||
}
|
}
|
||||||
@@ -1275,11 +1500,12 @@ window.HouseModel = (() => {
|
|||||||
const cladding = m.cladding || 'horizontaal';
|
const cladding = m.cladding || 'horizontaal';
|
||||||
const stone = buitenMateriaal === 'cementplaat' ? (m.stone || null) : null;
|
const stone = buitenMateriaal === 'cementplaat' ? (m.stone || null) : null;
|
||||||
const stuc = buitenMateriaal === 'cementplaat' && !stone ? (m.stuc || 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;
|
const trasraam = buitenMateriaal === 'vochtdoek' || stone || stuc ? (m.trasraam || null) : null;
|
||||||
return Object.assign(p, {
|
return Object.assign(p, {
|
||||||
element: `${u.id}-W${wl.nr}${p.suffix}`,
|
element: `${u.id}-W${wl.nr}${p.suffix}`,
|
||||||
label: `Unit ${u.id} ${wl.label.toLowerCase()}` + (base ? ` opbouw (vanaf ${base} mm)` : p.suffix ? ` (${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 }
|
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)
|
}); // 3D volgt na assignFacades (latwerk/doek per gevel)
|
||||||
});
|
});
|
||||||
@@ -1301,9 +1527,10 @@ window.HouseModel = (() => {
|
|||||||
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
|
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
|
||||||
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
|
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
|
||||||
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
|
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
|
||||||
materials: o.floorMaterials, osbVisible: shows('beplating'),
|
materials: o.floorMaterials, osbVisible: shows('beplating'), outerJoistsOnly: !!o.finishedOnly && shows('beplating'),
|
||||||
finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null
|
finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null
|
||||||
}) : null;
|
}) : null;
|
||||||
|
hideFrame(floorGroup, o.floorMaterials);
|
||||||
|
|
||||||
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
|
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
|
||||||
|
|
||||||
@@ -1337,9 +1564,10 @@ window.HouseModel = (() => {
|
|||||||
const roofGroup = build ? RoofModel.buildUnit3D(ur, {
|
const roofGroup = build ? RoofModel.buildUnit3D(ur, {
|
||||||
materials: o.roofMaterials,
|
materials: o.roofMaterials,
|
||||||
osbVisible: shows('beplating'),
|
osbVisible: shows('beplating'),
|
||||||
dakleerVisible: shows('gevel'),
|
dakleerVisible: shows('dakbedekking'),
|
||||||
ceilingMaterial: state.settings.interiorPaint && shows('afwerking') && o.roofMaterials ? o.roofMaterials.ceilingMaterial : null
|
ceilingMaterial: state.settings.interiorPaint && shows('afwerking') && o.roofMaterials ? o.roofMaterials.ceilingMaterial : null
|
||||||
}) : null;
|
}) : null;
|
||||||
|
hideFrame(roofGroup, o.roofMaterials);
|
||||||
const ext = coverExt[u.id];
|
const ext = coverExt[u.id];
|
||||||
if (roofGroup && ext && shows('afwerking')) {
|
if (roofGroup && ext && shows('afwerking')) {
|
||||||
const fz = rect.z0 - ext.f, bz = rect.z1 + ext.b;
|
const fz = rect.z0 - ext.f, bz = rect.z1 + ext.b;
|
||||||
@@ -1625,10 +1853,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 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);
|
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 => {
|
extPieces.forEach(p => {
|
||||||
const pn = { x: p.place.normal.x, z: p.place.normal.y };
|
const pn = { x: p.place.normal.x, z: p.place.normal.y };
|
||||||
const dot = pn.x * nrm.x + pn.z * nrm.z;
|
const dot = pn.x * nrm.x + pn.z * nrm.z;
|
||||||
@@ -1638,15 +1867,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));
|
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;
|
if (ov <= bestOv) return;
|
||||||
bestOv = ov;
|
bestOv = ov;
|
||||||
best = Math.sign(dot) * WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
best = { p, dot };
|
||||||
});
|
});
|
||||||
return best;
|
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) : 0;
|
||||||
|
};
|
||||||
|
const facadeFels = (a, b, nrm) => { const e = edgePiece(a, b, nrm); return !!(e && e.dot > 0 && e.p.fels); };
|
||||||
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
|
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
|
||||||
units.map(u => ({
|
units.map(u => ({
|
||||||
unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false,
|
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
|
covering: u.roof.covering, topAt: u.roof.topAt, ridges: u.roof.ridges
|
||||||
})), facadeDepth) : null;
|
})), facadeDepth, facadeFels) : null;
|
||||||
const pointInPoly = (q, poly) => {
|
const pointInPoly = (q, poly) => {
|
||||||
let inside = false;
|
let inside = false;
|
||||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
@@ -1662,13 +1896,13 @@ window.HouseModel = (() => {
|
|||||||
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
|
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
|
||||||
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
|
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
|
||||||
if (!sg) return;
|
if (!sg) return;
|
||||||
const h = sg.fascia ? roofEdge.fasciaHeight : roofEdge.trimHeight + roofEdge.edgeAbove;
|
const above = sg.fels ? RoofModel.FELS_EDGE.aboveMM : roofEdge.edgeAbove;
|
||||||
p.battenAbove = (p.skinAbove || 0) + roofEdge.edgeAbove - h;
|
p.battenAbove = (p.skinAbove || 0) + above - RoofModel.edgeHeight(roofEdge, sg);
|
||||||
});
|
});
|
||||||
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
||||||
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
||||||
extPieces.concat(interior).forEach(p => {
|
extPieces.concat(interior).forEach(p => {
|
||||||
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop);
|
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);
|
||||||
if (built.group && p.shiftZ) {
|
if (built.group && p.shiftZ) {
|
||||||
const outer = new THREE.Group();
|
const outer = new THREE.Group();
|
||||||
built.group.position.z = p.shiftZ * 0.001;
|
built.group.position.z = p.shiftZ * 0.001;
|
||||||
@@ -1683,18 +1917,32 @@ window.HouseModel = (() => {
|
|||||||
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
|
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
|
||||||
if (roofEdge && chimneys.length) roofEdge.spots = roofEdge.spots.filter(sp => !ChimneyModel.covers(chimneys, sp.x, sp.z, 60));
|
if (roofEdge && chimneys.length) roofEdge.spots = roofEdge.spots.filter(sp => !ChimneyModel.covers(chimneys, sp.x, sp.z, 60));
|
||||||
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
|
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
|
||||||
edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking')
|
edgeVisible: shows('afwerking'), spotsVisible: shows('lampen')
|
||||||
}) : null;
|
}) : null;
|
||||||
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
|
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
|
||||||
LightingModel.roomSpots(state.settings.lighting, lighting.indoor, rooms, units);
|
LightingModel.roomSpots(state.settings.lighting, lighting.indoor, rooms, units);
|
||||||
RoomModel.lowerSpots(rooms, lighting.indoor);
|
RoomModel.lowerSpots(rooms, lighting.indoor);
|
||||||
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
|
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
|
||||||
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false && shows('afwerking');
|
if (lightingGroup) lightingGroup.visible = shows('lampen');
|
||||||
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge, rooms);
|
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge, rooms);
|
||||||
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
|
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, layers, o.finishedOnly ? false : o.elektraRough) : null;
|
||||||
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
|
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 postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null;
|
||||||
const chimneysGroup = build && chimneys.length ? ChimneyModel.build3D(chimneys, {
|
const chimneysGroup = build && chimneys.length ? ChimneyModel.build3D(chimneys, {
|
||||||
materials: o.chimneyMaterials, wallMaterial: o.wallMaterials && o.wallMaterials.spaanplaatMaterial
|
materials: o.chimneyMaterials, wallMaterial: o.wallMaterials && o.wallMaterials.spaanplaatMaterial
|
||||||
@@ -1708,12 +1956,79 @@ window.HouseModel = (() => {
|
|||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
const roomsGroup = build ? RoomModel.build3D(rooms) : null;
|
const roomsGroup = build ? RoomModel.build3D(rooms) : null;
|
||||||
if (roomsGroup) roomsGroup.visible = shows('afwerking');
|
if (roomsGroup) roomsGroup.visible = shows('ruimtes') && shows('afwerking');
|
||||||
const fixtures = FixtureModel.compute(state.fixtures || [], units);
|
const fixtures = FixtureModel.compute(state.fixtures || [], units);
|
||||||
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
|
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
|
||||||
if (fixturesGroup) fixturesGroup.visible = shows('afwerking');
|
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.
|
// 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten.
|
||||||
@@ -1937,6 +2252,10 @@ window.HouseModel = (() => {
|
|||||||
{
|
{
|
||||||
naam: `Rabat ${WallModel.CLADDING_THICK_MM} mm (gevelbekleding)`, m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.rabatAreaM2 || 0), 0),
|
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
|
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,
|
weights,
|
||||||
@@ -1946,9 +2265,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,
|
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 };
|
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 || '',
|
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,
|
} : null,
|
||||||
roofEdge: parts.roofEdge ? {
|
roofEdge: parts.roofEdge ? {
|
||||||
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
|
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
|
||||||
@@ -2017,6 +2337,7 @@ window.HouseModel = (() => {
|
|||||||
if (!WallModel.isStoneFinish(state.settings.facade.stone)) state.settings.facade.stone = def.facade.stone;
|
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.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.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 = Object.assign(def.trasraam, state.settings.trasraam || {});
|
||||||
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
||||||
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
|
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
|
||||||
@@ -2058,6 +2379,6 @@ window.HouseModel = (() => {
|
|||||||
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
||||||
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
||||||
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
|
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
|
||||||
computeParts, placeGroups, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
computeParts, placeGroups, sectionCaps, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+38
-23
@@ -42,11 +42,13 @@ window.HousePdf = (() => {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// customer (light-versie): zonder onderdelenlijst, elementcodes en elektra; de tekening schuift dan naar links.
|
||||||
function drawPlanSheet(doc, parts, sheets, sheetOpts) {
|
function drawPlanSheet(doc, parts, sheets, sheetOpts) {
|
||||||
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
||||||
|
const customer = !!sheetOpts.customer;
|
||||||
doc.setFont('helvetica', 'normal');
|
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),
|
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 });
|
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
|
||||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||||
@@ -54,7 +56,7 @@ window.HousePdf = (() => {
|
|||||||
const rects = parts.rects;
|
const rects = parts.rects;
|
||||||
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
|
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 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 margin = 32;
|
||||||
const scale = Math.min((drawX1 - drawX0 - margin * 2) / (bx1 - bx0), (drawY1 - drawY0 - margin * 2) / (bz1 - bz0));
|
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;
|
const ox = (drawX0 + drawX1) / 2 - ((bx0 + bx1) / 2) * scale;
|
||||||
@@ -139,6 +141,8 @@ window.HousePdf = (() => {
|
|||||||
doc.setLineWidth(0.2);
|
doc.setLineWidth(0.2);
|
||||||
doc.setFillColor(160, 124, 84);
|
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'));
|
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 => {
|
(parts.chimneys || []).forEach(ch => {
|
||||||
doc.setFillColor(150, 150, 150);
|
doc.setFillColor(150, 150, 150);
|
||||||
poly(ch.footprint, 'FD');
|
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');
|
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.setDrawColor(230, 130, 30);
|
||||||
doc.setLineWidth(0.4);
|
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 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;
|
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);
|
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);
|
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 });
|
if (n.y === 0) doc.text(text, tx, ty, { align: 'center', angle: 90 });
|
||||||
else doc.text(text, tx, ty, { align: 'center' });
|
else doc.text(text, tx, ty, { align: 'center' });
|
||||||
})));
|
})));
|
||||||
doc.setTextColor(...BLACK);
|
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 });
|
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.setFontSize(6);
|
||||||
doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' +
|
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);
|
'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]; };
|
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 addWall = (p, y0) => {
|
||||||
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
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 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 });
|
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;
|
const tr = p.trasraam;
|
||||||
if (tr && tr.top > y0) {
|
if (tr && tr.top > y0) {
|
||||||
@@ -312,10 +317,9 @@ window.HousePdf = (() => {
|
|||||||
|
|
||||||
const edge = parts.roofEdge;
|
const edge = parts.roofEdge;
|
||||||
if (edge) {
|
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 });
|
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 => {
|
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 n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100));
|
||||||
const top = [], bottom = [];
|
const top = [], bottom = [];
|
||||||
for (let k = 0; k <= n; k++) {
|
for (let k = 0; k <= n; k++) {
|
||||||
@@ -324,7 +328,7 @@ window.HousePdf = (() => {
|
|||||||
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
|
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
|
||||||
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
|
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
|
||||||
}
|
}
|
||||||
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 });
|
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 => {
|
(parts.chimneys || []).forEach(ch => {
|
||||||
@@ -351,14 +355,16 @@ window.HousePdf = (() => {
|
|||||||
UnitModel.normalizeSplits(u.unit.walls[side].splits).filter(sp => sp.facade).forEach(sp => facades.push(HouseModel.facadeFor(state, u, side, sp.facade)));
|
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 => {
|
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;
|
if (seen.has(key)) return;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
items.push(fc.type === 'steen'
|
items.push(fc.type === 'steen'
|
||||||
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
||||||
: fc.type === 'stuc'
|
: fc.type === 'stuc'
|
||||||
? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) }
|
? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) }
|
||||||
: { 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) });
|
: 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) {
|
if (st.trasraam.enabled) {
|
||||||
const tf = st.trasraam.finish;
|
const tf = st.trasraam.finish;
|
||||||
@@ -760,28 +766,37 @@ window.HousePdf = (() => {
|
|||||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
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, {
|
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).filter(s => !customer || s.kind === 'views' || s.kind === 'plan');
|
||||||
const sheets = sheetList(parts);
|
const totals = customer ? null : HouseModel.computeTotals(parts);
|
||||||
const cost = CostModel.compute(parts, totals);
|
const cost = customer ? null : CostModel.compute(parts, totals);
|
||||||
const costOf = (element) => cost.elements.find(el => el.element === element);
|
const costOf = (element) => cost.elements.find(el => el.element === element);
|
||||||
const drawWallWithCost = (sheet, opts) => {
|
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 || [],
|
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));
|
overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts));
|
||||||
drawElementCost(doc, costOf(sheet.element));
|
drawElementCost(doc, costOf(sheet.element));
|
||||||
};
|
};
|
||||||
const detailPages = paginateCostRows(costDetailRows(cost));
|
if (!customer) {
|
||||||
sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' });
|
const detailPages = paginateCostRows(costDetailRows(cost));
|
||||||
detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols }));
|
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 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) => {
|
sheets.forEach((sheet, i) => {
|
||||||
if (i > 0) doc.addPage('a3', 'landscape');
|
if (i > 0 || o.doc) doc.addPage('a3', 'landscape');
|
||||||
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
|
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${offset + i + 1} / ${total}`, customer };
|
||||||
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
|
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
|
||||||
else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
|
else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
|
||||||
else if (sheet.kind === 'floor') {
|
else if (sheet.kind === 'floor') {
|
||||||
|
|||||||
+446
-361
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
|||||||
|
/*!
|
||||||
|
* 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 }
|
||||||
|
};
|
||||||
|
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 });
|
||||||
|
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']);
|
||||||
|
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 });
|
||||||
|
|
||||||
|
return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, create, build, check, fit, summary, describe, colorName };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/*!
|
||||||
|
* 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, 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
|
||||||
|
const py = Math.max(y + 4, 196);
|
||||||
|
doc.setFillColor(...ORANGE);
|
||||||
|
doc.roundedRect(M, py, 150, 30, 3, 3, 'F');
|
||||||
|
doc.setTextColor(255, 255, 255);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.text('Indicatieve prijs', M + 7, py + 9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(24);
|
||||||
|
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M + 7, py + 21);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.text('incl. btw, onder voorbehoud', M + 7, py + 27);
|
||||||
|
|
||||||
|
// 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 en de prijs zijn 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,658 @@
|
|||||||
|
/*!
|
||||||
|
* 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 }); url = adres voor de QR-code in de PDF (standaard de modelpagina).
|
||||||
|
window.TectumLight = (() => {
|
||||||
|
const esc = UI.esc;
|
||||||
|
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 slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · 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>
|
||||||
|
<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 });
|
||||||
|
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 = (p) => p.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(p).map(x => x.group), p.units.map(u => u.roof.group),
|
||||||
|
[p.roofEdgeGroup, p.postsGroup, p.chimneysGroup, p.sandwichGroup, p.lightingGroup, p.elektraGroup, p.roomsGroup, p.fixturesGroup]).filter(Boolean);
|
||||||
|
|
||||||
|
// Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
// 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 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', UnitModel.ROOF_TYPES, 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'));
|
||||||
|
|
||||||
|
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') : '');
|
||||||
|
|
||||||
|
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"><span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span></div>
|
||||||
|
<button type="button" class="tl-btn tl-icon" data-act="reset" title="Alle keuzes terug naar het model" aria-label="Terug naar het model"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||||
|
<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 === '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 === '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, prev = view, pr = renderer.getPixelRatio();
|
||||||
|
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||||
|
if (view !== '3d') {
|
||||||
|
view = '3d';
|
||||||
|
applyView();
|
||||||
|
if (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);
|
||||||
|
const url = renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||||||
|
renderer.setPixelRatio(pr);
|
||||||
|
if (prev !== '3d') {
|
||||||
|
view = prev;
|
||||||
|
camera.position.copy(keep.pos);
|
||||||
|
controls.target.copy(keep.target);
|
||||||
|
controls.update();
|
||||||
|
applyView();
|
||||||
|
}
|
||||||
|
resize();
|
||||||
|
return { url, width: W, height: H };
|
||||||
|
}
|
||||||
|
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
|
||||||
|
|
||||||
|
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 })
|
||||||
|
.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; 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') return;
|
||||||
|
e.stopPropagation();
|
||||||
|
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
||||||
|
}, { capture: true });
|
||||||
|
io.observe(stage);
|
||||||
|
(function loop() {
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
if (visible && 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();
|
||||||
|
if (group) disposeGeo(group);
|
||||||
|
renderer.dispose();
|
||||||
|
root.innerHTML = '';
|
||||||
|
},
|
||||||
|
getChoices: () => clone(ch)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoMount() {
|
||||||
|
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
|
||||||
|
if (el.dataset.mounted) return;
|
||||||
|
el.dataset.mounted = '1';
|
||||||
|
try { 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 };
|
||||||
|
})();
|
||||||
@@ -324,32 +324,41 @@ window.LightingModel = (() => {
|
|||||||
const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
|
const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
|
||||||
const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 };
|
const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 };
|
||||||
const DAY_OUTDOOR_FACTOR = 0.35;
|
const DAY_OUTDOOR_FACTOR = 0.35;
|
||||||
function buildLights3D(lamps, spots, spotY, indoor, o) {
|
// Lichtbronnen in meters: positie, richting (genormaliseerd), halve kegelhoek; gedeeld door 3D en render-export.
|
||||||
const toM = 0.001;
|
function lightSources(lamps, spots, spotY, indoor) {
|
||||||
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
|
const toM = 0.001, out = [];
|
||||||
const group = new THREE.Group();
|
const add = (kind, x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
|
||||||
const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
|
const len = Math.hypot(tx, ty, tz) || 1;
|
||||||
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay);
|
out.push({ kind, x, y, z, dir: { x: tx / len, y: ty / len, z: tz / len }, intensity, angle, distance, decay, penumbra, ref });
|
||||||
light.userData.ref = ref;
|
|
||||||
light.position.set(x, y, z);
|
|
||||||
light.target.position.set(x + tx, y + ty, z + tz);
|
|
||||||
group.add(light);
|
|
||||||
group.add(light.target);
|
|
||||||
};
|
};
|
||||||
const tilt = Math.tan(OUTDOOR.angle);
|
const tilt = Math.tan(OUTDOOR.angle);
|
||||||
lamps.forEach(l => {
|
lamps.forEach(l => {
|
||||||
const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM;
|
const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM;
|
||||||
const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
|
const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
|
||||||
if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
if (l.direction !== 'up') add('wandlamp', x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
||||||
if (l.direction !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
if (l.direction !== 'down') add('wandlamp', x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
||||||
});
|
});
|
||||||
(spots || []).forEach(p => {
|
(spots || []).forEach(p => {
|
||||||
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
|
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
|
||||||
add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra, p);
|
add('dakrandspot', p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra, p);
|
||||||
});
|
});
|
||||||
(indoor || []).forEach(p => {
|
(indoor || []).forEach(p => {
|
||||||
const drop = (p.y - p.floorY) * toM;
|
const drop = (p.y - p.floorY) * toM;
|
||||||
add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra, p);
|
add('binnenspot', p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra, p);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLights3D(lamps, spots, spotY, indoor, o) {
|
||||||
|
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
|
||||||
|
const group = new THREE.Group();
|
||||||
|
lightSources(lamps, spots, spotY, indoor).forEach(s => {
|
||||||
|
const light = new THREE.SpotLight(0xffd9a0, s.kind === 'binnenspot' ? s.intensity : s.intensity * fo, s.distance, s.angle, s.penumbra, s.decay);
|
||||||
|
light.userData.ref = s.ref;
|
||||||
|
light.position.set(s.x, s.y, s.z);
|
||||||
|
light.target.position.set(s.x + s.dir.x, s.y + s.dir.y, s.z + s.dir.z);
|
||||||
|
group.add(light);
|
||||||
|
group.add(light.target);
|
||||||
});
|
});
|
||||||
if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo));
|
if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo));
|
||||||
return group;
|
return group;
|
||||||
@@ -450,5 +459,5 @@ window.LightingModel = (() => {
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D };
|
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, unitInnerRect, freeIntervals, createMaterials, build3D, lightSources, buildLights3D };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+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 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
|
// 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.
|
// 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 = (() => {
|
window.PostModel = (() => {
|
||||||
const EPS = 0.5;
|
const EPS = 0.5;
|
||||||
@@ -15,12 +18,16 @@ window.PostModel = (() => {
|
|||||||
eiken: { label: 'Eiken', hex: 0xa07c54, kgM3: 750 },
|
eiken: { label: 'Eiken', hex: 0xa07c54, kgM3: 750 },
|
||||||
douglas: { label: 'Douglas', hex: 0xb98a5e, kgM3: 530 }
|
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) {
|
function normalizeSettings(settings) {
|
||||||
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
||||||
s.enabled = s.enabled !== false;
|
s.enabled = s.enabled !== false;
|
||||||
|
s.sideWall = !!s.sideWall;
|
||||||
if (!WOODS[s.wood]) s.wood = DEFAULT_SETTINGS.wood;
|
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);
|
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.size = num(s.size, DEFAULT_SETTINGS.size, 100, 400);
|
||||||
s.maxSpan = num(s.maxSpan, DEFAULT_SETTINGS.maxSpan, 1000, 8000);
|
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).
|
// 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 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;
|
if (!s.enabled || !units.length) return out;
|
||||||
const floors = units.map(u => u.rect);
|
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);
|
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 half = s.size / 2;
|
||||||
const pts = [];
|
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 => {
|
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 });
|
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.
|
// 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 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;
|
const pa = fa ? r.a + half : r.a, pb = fb ? r.b - half : r.b;
|
||||||
if (pb - pa < EPS) return;
|
if (pb - pa < EPS) return;
|
||||||
const n = Math.max(1, Math.ceil((pb - pa) / s.maxSpan - 1e-9));
|
const n = Math.max(1, Math.ceil((pb - pa) / s.maxSpan - 1e-9));
|
||||||
@@ -94,7 +147,8 @@ window.PostModel = (() => {
|
|||||||
});
|
});
|
||||||
pts.forEach(p => {
|
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;
|
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;
|
if (!roof) return;
|
||||||
// Hoogste hoek: bij een hellend dak verdwijnt de paal aan de lage kant in het dakpakket, zonder kier.
|
// 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))));
|
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.lengthMM = out.posts.reduce((t, p) => t + p.y1 - p.y0 - SHOE_MM, 0);
|
||||||
out.m3 = out.lengthMM * s.size * s.size / 1e9;
|
out.m3 = out.lengthMM * s.size * s.size / 1e9;
|
||||||
out.kg = out.m3 * WOODS[s.wood].kgM3;
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +175,7 @@ window.PostModel = (() => {
|
|||||||
|
|
||||||
function build3D(data, materials) {
|
function build3D(data, materials) {
|
||||||
const toM = 0.001, group = new THREE.Group();
|
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 m = materials || createMaterials();
|
||||||
const woodMat = m.wood[data.wood] || m.wood.eiken;
|
const woodMat = m.wood[data.wood] || m.wood.eiken;
|
||||||
data.posts.forEach(p => {
|
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);
|
stem.position.set(p.x * toM, (p.y0 + 10 + (SHOE_MM - 10) / 2) * toM, p.z * toM);
|
||||||
group.add(stem);
|
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 group;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { WOODS, SHOE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
|
return { WOODS, SHOE_MM, SIDE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+49
-9
@@ -12,6 +12,9 @@ window.RoofModel = (() => {
|
|||||||
const DENSITY_DAKLEER_KGM2 = 1.4;
|
const DENSITY_DAKLEER_KGM2 = 1.4;
|
||||||
const TRIM_THICK_MM = 25;
|
const TRIM_THICK_MM = 25;
|
||||||
const EDGE_ABOVE_MM = 80;
|
const EDGE_ABOVE_MM = 80;
|
||||||
|
// 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 SPOT_RATIO = 0.4;
|
||||||
const OVERHANG_MIN = 200, OVERHANG_MAX = 400;
|
const OVERHANG_MIN = 200, OVERHANG_MAX = 400;
|
||||||
|
|
||||||
@@ -35,7 +38,9 @@ window.RoofModel = (() => {
|
|||||||
// Riet e.d. alleen bij een hellend dak; plat valt terug op dakleer.
|
// 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).
|
// 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 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({
|
const COVERINGS = Object.assign({
|
||||||
dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 }
|
dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 }
|
||||||
}, ...RIET.nrs.map(nr => ({
|
}, ...RIET.nrs.map(nr => ({
|
||||||
@@ -923,7 +928,8 @@ window.RoofModel = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// facadeDepth negatief = gevel van een hoger deel die naar dit dak kijkt: de rand stopt tegen die gevel.
|
// 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 s = normalizeSettings(settings);
|
||||||
const rects = roofs.map(r => r.rect);
|
const rects = roofs.map(r => r.rect);
|
||||||
if (!roofs.length) return null;
|
if (!roofs.length) return null;
|
||||||
@@ -967,15 +973,18 @@ window.RoofModel = (() => {
|
|||||||
const j = (i + 1) % n, b = base[j];
|
const j = (i + 1) % n, b = base[j];
|
||||||
const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 };
|
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 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;
|
const off = fascia ? offBoei : offTrim;
|
||||||
// Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots.
|
// Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots.
|
||||||
const nb = others.find(r => onRectEdge(r.rect, mid));
|
const nb = others.find(r => onRectEdge(r.rect, mid));
|
||||||
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
|
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
|
||||||
|
const above = fels ? FELS_EDGE.aboveMM : EDGE_ABOVE_MM;
|
||||||
return {
|
return {
|
||||||
a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b),
|
a, b, owner: owner.unitId, fascia, fels, covering: owner.covering, normal,
|
||||||
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j],
|
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j],
|
||||||
topAt: (p) => owner.topAt(p.x, p.z) + EDGE_ABOVE_MM, ridges: owner.ridges || [],
|
topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [],
|
||||||
overRoof: !!nb && !dropped, dropped
|
overRoof: !!nb && !dropped, dropped
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -995,6 +1004,23 @@ window.RoofModel = (() => {
|
|||||||
});
|
});
|
||||||
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] }));
|
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] }));
|
||||||
|
|
||||||
|
// Rand zonder overstek (daktrim, felsrand): de felsrand ligt net vóór de gevelbekleding en loopt bij een hoek
|
||||||
|
// door tot de buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand.
|
||||||
|
const edgeOff = (k) => segs[k].fels ? Math.max(TRIM_THICK_MM, depRaw[k] + FELS_EDGE.lipMM) : TRIM_THICK_MM;
|
||||||
|
const mv = (p, nrm, d) => ({ x: p.x + nrm.x * d, z: p.z + nrm.z * d });
|
||||||
|
segs.forEach((sg, i) => {
|
||||||
|
const ends = [['a', 'outerA', (i - 1 + n) % n], ['b', 'outerB', (i + 1) % n]];
|
||||||
|
if (sg.fels) ends.forEach(([k, ok, j]) => {
|
||||||
|
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);
|
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) => {
|
segs.forEach((sg, i) => {
|
||||||
if (sg.dropped) return;
|
if (sg.dropped) return;
|
||||||
@@ -1024,7 +1050,9 @@ window.RoofModel = (() => {
|
|||||||
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor,
|
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor,
|
||||||
top, edgeAbove: EDGE_ABOVE_MM,
|
top, edgeAbove: EDGE_ABOVE_MM,
|
||||||
fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
|
fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
|
||||||
trimLengthMM: segments.filter(sg => !sg.fascia).reduce((t, sg) => t + segLen(sg), 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,
|
soffitM2: segments.filter(sg => sg.fascia).reduce((t, sg) => t + quadArea([sg.a, sg.b, sg.outerB, sg.outerA]), 0) / 1e6,
|
||||||
spots: []
|
spots: []
|
||||||
};
|
};
|
||||||
@@ -1056,6 +1084,17 @@ window.RoofModel = (() => {
|
|||||||
return geo;
|
return geo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hoogte van de rand vanaf sg.topAt naar beneden: boeideel, felsrand of daktrim (met opstand boven het dak).
|
||||||
|
function edgeHeight(edge, sg) {
|
||||||
|
return sg.fascia ? edge.fasciaHeight : sg.fels ? FELS_EDGE.heightMM : edge.trimHeight + edge.edgeAbove;
|
||||||
|
}
|
||||||
|
// 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) {
|
function buildEdge3D(edge, materials, opts) {
|
||||||
const o = opts || {};
|
const o = opts || {};
|
||||||
const toM = 0.001;
|
const toM = 0.001;
|
||||||
@@ -1075,9 +1114,10 @@ window.RoofModel = (() => {
|
|||||||
const t = (p) => alongZ ? p.z : p.x;
|
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);
|
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;
|
const h = edgeHeight(edge, sg);
|
||||||
|
const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial;
|
||||||
polys.forEach(pl => {
|
polys.forEach(pl => {
|
||||||
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial);
|
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), mat);
|
||||||
mesh.visible = o.edgeVisible !== false;
|
mesh.visible = o.edgeVisible !== false;
|
||||||
group.add(mesh);
|
group.add(mesh);
|
||||||
});
|
});
|
||||||
@@ -1112,6 +1152,6 @@ window.RoofModel = (() => {
|
|||||||
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, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
|
||||||
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
||||||
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
|
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
|
||||||
computeEdge, buildEdge3D, normalizeSettings
|
FELS_EDGE, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
Vendored
+54
-20
@@ -4,7 +4,8 @@
|
|||||||
// hebben om het origineel te vendoren; dekt precies de subset die wall.js
|
// hebben om het origineel te vendoren; dekt precies de subset die wall.js
|
||||||
// gebruikt (`new THREE.OrbitControls(camera, domElement)`,
|
// gebruikt (`new THREE.OrbitControls(camera, domElement)`,
|
||||||
// `controls.target.set(...)`, `controls.update()`), plus interactie:
|
// `controls.target.set(...)`, `controls.update()`), plus interactie:
|
||||||
// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen.
|
// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen;
|
||||||
|
// touch: één vinger = roteren, twee vingers = knijpen (zoomen) en samen schuiven (pannen).
|
||||||
//
|
//
|
||||||
// Moet NA three.min.js geladen worden (leunt op window.THREE).
|
// Moet NA three.min.js geladen worden (leunt op window.THREE).
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -60,13 +61,7 @@
|
|||||||
lastY = event.clientY;
|
lastY = event.clientY;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(event) {
|
function rotate(dx, dy) {
|
||||||
if (isRotating) {
|
|
||||||
const dx = event.clientX - lastX;
|
|
||||||
const dy = event.clientY - lastY;
|
|
||||||
lastX = event.clientX;
|
|
||||||
lastY = event.clientY;
|
|
||||||
|
|
||||||
const w = scope.domElement.clientWidth || 1;
|
const w = scope.domElement.clientWidth || 1;
|
||||||
const h = scope.domElement.clientHeight || 1;
|
const h = scope.domElement.clientHeight || 1;
|
||||||
|
|
||||||
@@ -79,12 +74,8 @@
|
|||||||
offset.setFromSpherical(spherical);
|
offset.setFromSpherical(spherical);
|
||||||
scope.object.position.copy(scope.target).add(offset);
|
scope.object.position.copy(scope.target).add(offset);
|
||||||
scope.object.lookAt(scope.target);
|
scope.object.lookAt(scope.target);
|
||||||
} else if (isPanning) {
|
}
|
||||||
const dx = event.clientX - lastX;
|
function pan(dx, dy) {
|
||||||
const dy = event.clientY - lastY;
|
|
||||||
lastX = event.clientX;
|
|
||||||
lastY = event.clientY;
|
|
||||||
|
|
||||||
const distance = scope.object.position.distanceTo(scope.target);
|
const distance = scope.object.position.distanceTo(scope.target);
|
||||||
const fov = (scope.object.fov || 50) * Math.PI / 180;
|
const fov = (scope.object.fov || 50) * Math.PI / 180;
|
||||||
const h = scope.domElement.clientHeight || 1;
|
const h = scope.domElement.clientHeight || 1;
|
||||||
@@ -95,7 +86,22 @@
|
|||||||
const yAxis = new THREE.Vector3(te[4], te[5], te[6]).multiplyScalar(dy * factor * scope.panSpeed);
|
const yAxis = new THREE.Vector3(te[4], te[5], te[6]).multiplyScalar(dy * factor * scope.panSpeed);
|
||||||
scope.target.add(xAxis).add(yAxis);
|
scope.target.add(xAxis).add(yAxis);
|
||||||
scope.object.position.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; }
|
function onPointerUp() { isRotating = false; isPanning = false; }
|
||||||
@@ -103,13 +109,33 @@
|
|||||||
function onWheel(event) {
|
function onWheel(event) {
|
||||||
if (!scope.enableZoom) return;
|
if (!scope.enableZoom) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const scale = event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale();
|
zoom(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(); }
|
function onContextMenu(event) { event.preventDefault(); }
|
||||||
|
|
||||||
this.domElement.addEventListener('mousedown', onPointerDown);
|
this.domElement.addEventListener('mousedown', onPointerDown);
|
||||||
@@ -117,6 +143,10 @@
|
|||||||
window.addEventListener('mouseup', onPointerUp);
|
window.addEventListener('mouseup', onPointerUp);
|
||||||
this.domElement.addEventListener('wheel', onWheel, { passive: false });
|
this.domElement.addEventListener('wheel', onWheel, { passive: false });
|
||||||
this.domElement.addEventListener('contextmenu', onContextMenu);
|
this.domElement.addEventListener('contextmenu', onContextMenu);
|
||||||
|
this.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
|
||||||
|
this.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
|
this.domElement.addEventListener('touchend', onTouchEnd);
|
||||||
|
this.domElement.addEventListener('touchcancel', onTouchEnd);
|
||||||
|
|
||||||
this.dispose = function () {
|
this.dispose = function () {
|
||||||
scope.domElement.removeEventListener('mousedown', onPointerDown);
|
scope.domElement.removeEventListener('mousedown', onPointerDown);
|
||||||
@@ -124,6 +154,10 @@
|
|||||||
window.removeEventListener('mouseup', onPointerUp);
|
window.removeEventListener('mouseup', onPointerUp);
|
||||||
scope.domElement.removeEventListener('wheel', onWheel);
|
scope.domElement.removeEventListener('wheel', onWheel);
|
||||||
scope.domElement.removeEventListener('contextmenu', onContextMenu);
|
scope.domElement.removeEventListener('contextmenu', onContextMenu);
|
||||||
|
scope.domElement.removeEventListener('touchstart', onTouchStart);
|
||||||
|
scope.domElement.removeEventListener('touchmove', onTouchMove);
|
||||||
|
scope.domElement.removeEventListener('touchend', onTouchEnd);
|
||||||
|
scope.domElement.removeEventListener('touchcancel', onTouchEnd);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+2297
File diff suppressed because it is too large
Load Diff
+63
-8
@@ -72,6 +72,50 @@ window.WallModel = (() => {
|
|||||||
return mat;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function claddingOffset(buitenMateriaal, stone, stuc) {
|
function claddingOffset(buitenMateriaal, stone, stuc) {
|
||||||
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
|
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
|
||||||
if (buitenMateriaal !== 'cementplaat') return 0;
|
if (buitenMateriaal !== 'cementplaat') return 0;
|
||||||
@@ -121,6 +165,14 @@ window.WallModel = (() => {
|
|||||||
return mat;
|
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).
|
// Hoogte vanaf het loopvlak (= onderkant van de wand).
|
||||||
const TRASRAAM_DEFAULT_MM = 350;
|
const TRASRAAM_DEFAULT_MM = 350;
|
||||||
function trasraamFor(heightMM, finish) {
|
function trasraamFor(heightMM, finish) {
|
||||||
@@ -751,8 +803,8 @@ window.WallModel = (() => {
|
|||||||
function build3D(CONFIG, calcData, opts) {
|
function build3D(CONFIG, calcData, opts) {
|
||||||
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
|
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
|
||||||
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
|
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
|
||||||
const stage = BuildStages.clamp(opts.stage);
|
const layers = ViewLayers.normalize(opts.layers);
|
||||||
const show = (key) => BuildStages.shows(stage, key);
|
const show = (key) => layers[key];
|
||||||
const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating');
|
const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating');
|
||||||
const buitenVisible = opts.buitenVisible !== false;
|
const buitenVisible = opts.buitenVisible !== false;
|
||||||
const cladding = opts.cladding || 'horizontaal';
|
const cladding = opts.cladding || 'horizontaal';
|
||||||
@@ -763,6 +815,7 @@ window.WallModel = (() => {
|
|||||||
const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0;
|
const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0;
|
||||||
const painted = !!opts.painted;
|
const painted = !!opts.painted;
|
||||||
const stone = buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone) ? opts.stone : null;
|
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.
|
// 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 spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
|
||||||
const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel');
|
const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel');
|
||||||
@@ -792,7 +845,7 @@ window.WallModel = (() => {
|
|||||||
let spaanplaatBuitenAreaM2 = 0;
|
let spaanplaatBuitenAreaM2 = 0;
|
||||||
let spaanplaatBuitenVolumeM3 = 0;
|
let spaanplaatBuitenVolumeM3 = 0;
|
||||||
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0;
|
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0;
|
||||||
let rabatAreaM2 = 0;
|
let rabatAreaM2 = 0, felsAreaM2 = 0;
|
||||||
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
|
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
|
||||||
let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
|
let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
|
||||||
let stucAreaM2 = 0;
|
let stucAreaM2 = 0;
|
||||||
@@ -1294,6 +1347,7 @@ window.WallModel = (() => {
|
|||||||
return shape;
|
return shape;
|
||||||
}) : [];
|
}) : [];
|
||||||
rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
||||||
|
if (felsKey) { felsAreaM2 = rabatAreaM2; rabatAreaM2 = 0; }
|
||||||
if (rabatOn && rabatShapes.length) {
|
if (rabatOn && rabatShapes.length) {
|
||||||
const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false });
|
const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false });
|
||||||
const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM;
|
const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM;
|
||||||
@@ -1303,7 +1357,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);
|
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;
|
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.position.set(0, 0, zDoek);
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
mesh.receiveShadow = true;
|
mesh.receiveShadow = true;
|
||||||
@@ -1311,7 +1365,7 @@ window.WallModel = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (show('gevel') && window.KozijnModel) {
|
if (show('kozijnen') && window.KozijnModel) {
|
||||||
const sh = CONFIG.sheathingThick, half = CONFIG.studDepth / 2;
|
const sh = CONFIG.sheathingThick, half = CONFIG.studDepth / 2;
|
||||||
const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat';
|
const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat';
|
||||||
const place = exterior ? { zOut: half + sh, depth: KozijnModel.PROFILE.depth, exterior: true }
|
const place = exterior ? { zOut: half + sh, depth: KozijnModel.PROFILE.depth, exterior: true }
|
||||||
@@ -1384,13 +1438,14 @@ window.WallModel = (() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!show('regelwerk')) ViewLayers.hideByMaterial(wallGroup3D, [woodMaterial]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
group: wallGroup3D,
|
group: wallGroup3D,
|
||||||
stats: {
|
stats: {
|
||||||
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
|
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
|
||||||
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
|
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
|
||||||
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2
|
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1619,8 +1674,8 @@ window.WallModel = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, FELS, isFelsColor, felsHex, 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,
|
stoneMaterial, stucMaterial, felsMaterial, claddingMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
||||||
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
||||||
createMaterials, build3D, computeSpecs
|
createMaterials, build3D, computeSpecs
|
||||||
};
|
};
|
||||||
|
|||||||
+39
-43
@@ -6,7 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const WALL_TEMPLATE = `
|
const WALL_TEMPLATE = `
|
||||||
<div id="app-container">
|
<div id="app-container" class="ed-app">
|
||||||
|
<div class="ed-main">
|
||||||
|
<div class="ed-toolbar" id="wall-toolbar"></div>
|
||||||
|
<div class="ed-views">
|
||||||
<div id="left-column">
|
<div id="left-column">
|
||||||
<div id="pane-2d-container">
|
<div id="pane-2d-container">
|
||||||
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
|
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
|
||||||
@@ -24,8 +27,16 @@ const WALL_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="right-column">
|
||||||
|
<div id="pane-3d">
|
||||||
|
<div class="pane-label">3D</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="control-panel">
|
<div id="control-panel" class="ed-panel">
|
||||||
<div class="io-toolbar">
|
<div class="io-toolbar">
|
||||||
<input type="file" id="file-import-json" accept="application/json" style="display:none;">
|
<input type="file" id="file-import-json" accept="application/json" style="display:none;">
|
||||||
<span id="io-status" class="io-status"></span>
|
<span id="io-status" class="io-status"></span>
|
||||||
@@ -34,6 +45,7 @@ const WALL_TEMPLATE = `
|
|||||||
<button class="tab-btn active" data-tab="tab-wall"><i class="bi bi-rulers"></i> Opbouw</button>
|
<button class="tab-btn active" data-tab="tab-wall"><i class="bi bi-rulers"></i> Opbouw</button>
|
||||||
<button class="tab-btn" data-tab="tab-opening"><i class="bi bi-door-open"></i> Openingen</button>
|
<button class="tab-btn" data-tab="tab-opening"><i class="bi bi-door-open"></i> Openingen</button>
|
||||||
<button class="tab-btn" data-tab="tab-materials"><i class="bi bi-layers"></i> Materiaal</button>
|
<button class="tab-btn" data-tab="tab-materials"><i class="bi bi-layers"></i> Materiaal</button>
|
||||||
|
<button class="tab-btn" data-tab="tab-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TAB 1: WAND -->
|
<!-- TAB 1: WAND -->
|
||||||
@@ -104,10 +116,6 @@ const WALL_TEMPLATE = `
|
|||||||
<div><input type="number" id="inp-max-sp" value="480" step="10" min="150" max="1500"><span class="unit">mm</span></div>
|
<div><input type="number" id="inp-max-sp" value="480" step="10" min="150" max="1500"><span class="unit">mm</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
<div class="result-row"><span>Verticale balken</span><span data-spec="vert-beams" class="highlight">-</span></div>
|
|
||||||
<div class="result-row"><span>Horizontale balken</span><span data-spec="horiz-beams" class="highlight">-</span></div>
|
|
||||||
<div class="result-row"><span>Dakhelling</span><span data-spec="roof-angle" class="highlight">-</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,17 +146,10 @@ const WALL_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="right-column">
|
|
||||||
<div id="pane-3d">
|
|
||||||
<div class="pane-label">3D</div>
|
|
||||||
<div class="toolbar-3d" id="wall-toolbar-3d"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div id="tab-specs" class="tab-content">
|
||||||
<div id="specs-panel-3d">
|
<div id="specs-panel-3d">
|
||||||
<div class="controls-grid">
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div class="spec-section">
|
<div class="spec-section">
|
||||||
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
||||||
@@ -188,6 +189,7 @@ const WALL_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="wall-project-overlay">
|
<div id="wall-project-overlay">
|
||||||
<div id="wall-project-modal">
|
<div id="wall-project-modal">
|
||||||
@@ -218,8 +220,8 @@ const WALL_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" id="wall-project-cancel" class="io-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="material-toggle-btn active" style="flex:none; padding:6px 18px;">Opslaan</button>
|
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,16 +335,6 @@ function mountWallEditor(container, callbacks) {
|
|||||||
updateAll();
|
updateAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFullscreen3D() {
|
|
||||||
UI.cycle3D(document.getElementById('app-container'));
|
|
||||||
requestAnimationFrame(onWindowResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFullscreen2D() {
|
|
||||||
UI.toggle2D(document.getElementById('app-container'));
|
|
||||||
requestAnimationFrame(onWindowResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
|
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'];
|
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
|
||||||
let projectPropsSnapshot = null;
|
let projectPropsSnapshot = null;
|
||||||
@@ -608,7 +600,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
function getPureState() {
|
function getPureState() {
|
||||||
return {
|
return {
|
||||||
wall: collectWallParams(),
|
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)
|
openings: openings.map(rawOpening)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -637,6 +629,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
stone = (state.materials && state.materials.stone) || null;
|
stone = (state.materials && state.materials.stone) || null;
|
||||||
stuc = (state.materials && state.materials.stuc) || null;
|
stuc = (state.materials && state.materials.stuc) || null;
|
||||||
rabatColor = (state.materials && state.materials.rabatColor) || null;
|
rabatColor = (state.materials && state.materials.rabatColor) || null;
|
||||||
|
fels = (state.materials && state.materials.fels) || null;
|
||||||
syncMaterialButtons();
|
syncMaterialButtons();
|
||||||
|
|
||||||
openings = state.openings.map(rawOpening);
|
openings = state.openings.map(rawOpening);
|
||||||
@@ -1279,14 +1272,14 @@ function mountWallEditor(container, callbacks) {
|
|||||||
|
|
||||||
const wallMaterials = WallModel.createMaterials();
|
const wallMaterials = WallModel.createMaterials();
|
||||||
|
|
||||||
// Alleen knoppen met data-material/-cladding: de "Opslaan"-knop van Project eigenschappen heeft ook de klasse material-toggle-btn.
|
|
||||||
let buitenMateriaal = 'vochtdoek';
|
let buitenMateriaal = 'vochtdoek';
|
||||||
let cladding = 'horizontaal';
|
let cladding = 'horizontaal';
|
||||||
let trasraam = null;
|
let trasraam = null;
|
||||||
let stone = null;
|
let stone = null;
|
||||||
let stuc = null;
|
let stuc = null;
|
||||||
let rabatColor = null;
|
let rabatColor = null;
|
||||||
let stage = BuildStages.FINAL;
|
let fels = null;
|
||||||
|
let layers = ViewLayers.all();
|
||||||
function syncMaterialButtons() {
|
function syncMaterialButtons() {
|
||||||
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
||||||
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
|
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
|
||||||
@@ -1303,29 +1296,32 @@ function mountWallEditor(container, callbacks) {
|
|||||||
|
|
||||||
let spaanplaatVisible = true;
|
let spaanplaatVisible = true;
|
||||||
let buitenVisible = true;
|
let buitenVisible = true;
|
||||||
createViewFilter(document.getElementById('wall-toolbar-3d'), [
|
|
||||||
{ key: 'binnen', label: 'Beplating binnenzijde' },
|
|
||||||
{ key: 'buiten', label: 'Beplating buitenzijde' }
|
|
||||||
], (v) => {
|
|
||||||
spaanplaatVisible = v.binnen;
|
|
||||||
buitenVisible = v.buiten;
|
|
||||||
update3DScene();
|
|
||||||
});
|
|
||||||
let skyMode = Skybox.getMode();
|
let skyMode = Skybox.getMode();
|
||||||
const skyEnv = Skybox.createEnvironment(scene);
|
const skyEnv = Skybox.createEnvironment(scene);
|
||||||
const applySky = () => {
|
const applySky = () => {
|
||||||
skyEnv.fitTo(wallGroup3D, 0);
|
skyEnv.fitTo(wallGroup3D, 0);
|
||||||
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
|
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
|
||||||
};
|
};
|
||||||
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
|
const toolbar = document.getElementById('wall-toolbar');
|
||||||
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
|
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;
|
let lastWallStats = null;
|
||||||
|
|
||||||
function update3DScene() {
|
function update3DScene() {
|
||||||
if (wallGroup3D) scene.remove(wallGroup3D);
|
if (wallGroup3D) scene.remove(wallGroup3D);
|
||||||
const result = WallModel.build3D(CONFIG, calcData, {
|
const result = WallModel.build3D(CONFIG, calcData, {
|
||||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, stage
|
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels, spaanplaatVisible, buitenVisible, layers
|
||||||
});
|
});
|
||||||
wallGroup3D = result.group;
|
wallGroup3D = result.group;
|
||||||
lastWallStats = result.stats;
|
lastWallStats = result.stats;
|
||||||
@@ -1419,8 +1415,8 @@ function mountWallEditor(container, callbacks) {
|
|||||||
exportDesign,
|
exportDesign,
|
||||||
triggerImport,
|
triggerImport,
|
||||||
mirrorHorizontal,
|
mirrorHorizontal,
|
||||||
toggleFullscreen3D,
|
setView: bar.setView,
|
||||||
toggleFullscreen2D,
|
viewState: bar.viewState,
|
||||||
openProjectProperties,
|
openProjectProperties,
|
||||||
getStateJson: getPureState,
|
getStateJson: getPureState,
|
||||||
loadStateJson: applyPureState
|
loadStateJson: applyPureState
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ gevel.vochtdoekMontageM;Montage vochtdoek per strekkende meter wand;s/m;600
|
|||||||
gevel.rabatMontage;Montage rabat;s/m²;600
|
gevel.rabatMontage;Montage rabat;s/m²;600
|
||||||
gevel.steenMontage;Montage steenstrips;s/m²;1800
|
gevel.steenMontage;Montage steenstrips;s/m²;1800
|
||||||
gevel.stucMontage;Stucwerk aanbrengen (wapenen, pleisteren);s/m²;1500
|
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.dakleerMontage;Montage dakleer;s/m²;600
|
||||||
dak.rietMontage;Montage riet;s/m²;3600
|
dak.rietMontage;Montage riet;s/m²;3600
|
||||||
dak.beschotMontage;Montage dakbeschot;s/m²;150
|
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.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.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.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.afwerkingMontage;Montage vloerafwerking;s/m²;1200
|
||||||
vloer.sandwichMontage;Montage per sandwichpaneel;s;600
|
vloer.sandwichMontage;Montage per sandwichpaneel;s;600
|
||||||
afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800
|
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.rabatM2;Rabat (gevelbekleding);€/m²;35;;;
|
||||||
gevel.steenM2;Steenstrips (gevel + trasraam);€/m²;60;;;
|
gevel.steenM2;Steenstrips (gevel + trasraam);€/m²;60;;;
|
||||||
gevel.stucM2;Stucwerk gevel (wapening + sierpleister op cementgebonden plaat);€/m²;45;;;
|
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.dakleerM2;Dakleer (EPDM);€/m²;18;;;
|
||||||
dak.rietM2;Riet;€/m²;90;;;
|
dak.rietM2;Riet;€/m²;90;;;
|
||||||
dak.beschotM2;Dakbeschot OSB 18 mm (bovenop de sporen);€/m²;14;;;
|
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.nokOplegging;Oplegging nokbalk (stalen schoen + bouten), 2 per balk;€/st.;45;;;
|
||||||
dak.paalHoutM3;Paalhout overkapping (eiken, geschaafd);€/m³;2400;;;
|
dak.paalHoutM3;Paalhout overkapping (eiken, geschaafd);€/m³;2400;;;
|
||||||
dak.paalvoet;Paalvoet verzinkt, verstelbaar (incl. ankers);€/st.;40;;;
|
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.afwerkingM2;Vloerafwerking;€/m²;35;;;
|
||||||
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
|
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
|
||||||
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
|
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
|
||||||
|
|||||||
|
+83
@@ -0,0 +1,83 @@
|
|||||||
|
<!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 { 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. -->
|
||||||
|
<select id="model-pick" aria-label="Model"></select>
|
||||||
|
</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/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; });
|
||||||
|
document.getElementById('configurator').dataset.model = model;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="js/light/light.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user