Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51921650e3 | ||
|
|
89cadbebf7 | ||
|
|
ab0dca9bb5 |
@@ -18,17 +18,19 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
||||
## Structuur
|
||||
|
||||
```
|
||||
index.html app-shell: ribbon, splashscreen
|
||||
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
||||
css/app.css alle stijl
|
||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF
|
||||
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
|
||||
(obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen, weergavefilter,
|
||||
(obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen,
|
||||
skybox, licentie, logo
|
||||
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
||||
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
||||
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
|
||||
PostModel: palen onder een overkapping/veranda (dak per unit te verlengen)
|
||||
PostModel: palen onder een overkapping/veranda (dak per unit te verlengen), of een dichte
|
||||
zijwand op een vrije hoek
|
||||
js/house/chimney-model.js ChimneyModel: schoorsteen tegen een buitenwand (hol, sparing in de wand zoals een raam,
|
||||
ook over een naad, zonder hout erin; sparing in het dak met raveelwerk (stijlen, balken, kreupele
|
||||
balken), de beplating eindigt op het kader; onderbreekt de dakrand; een nokbalk stopt ervoor;
|
||||
@@ -57,9 +59,11 @@ assets/ logo, texturen, skybox
|
||||
- **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
|
||||
links = west, rechts = oost (interne sleutels blijven voor/achter/…).
|
||||
- **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
|
||||
- **Bouwfasen** (`BuildStages`): regelwerk → elektra (buizen, dozen, meterkast) → beplating → vochtdoek →
|
||||
latwerk → gevel & dak → afgewerkt, de volgorde in de hal. De 3D toont de
|
||||
gekozen fase; lagen die daarin aan beide zijden zijn ingesloten worden niet
|
||||
- **Weergavelagen** (`ViewLayers`): regelwerk, elektra, beplating, vochtdoek,
|
||||
latwerk, gevelbekleding, kozijnen, dakbedekking, afwerking, verlichting,
|
||||
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,
|
||||
sporen tussen plafond en dakbedekking). Specificaties en totalen rekenen
|
||||
altijd met alle lagen.
|
||||
@@ -82,7 +86,11 @@ assets/ logo, texturen, skybox
|
||||
en plafond naar de meterkast (strekkende meters per type, draad, buis,
|
||||
steekverbinders op module-naden), materiaallijst en eigen PDF-blad.
|
||||
Buizen minstens 50 mm uit elkaar (gebundeld alleen het laatste stuk naar
|
||||
de meterkast). Vloer-wcd's met leiding door de vloer. Elektra ook op de
|
||||
de meterkast). Nooit door een raam of deur (`makeOpeningTest`): een
|
||||
stijgleiding boven/onder een opening (wcd onder een raam) gaat eerst op
|
||||
dezelfde hoogte opzij tot naast de opening, langs dezelfde wand met een
|
||||
deur of raam ertussen via het plafond, gevellampen om de gevelhoeken heen,
|
||||
en uit elkaar schuiven van buizen eindigt nooit in een opening. Vloer-wcd's met leiding door de vloer. Elektra ook op de
|
||||
PDF-bladen per wand, vloer en dakvlak (plafond). Alles verplaatsbaar door
|
||||
te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per
|
||||
meter, boorgaten (geteld op stijlen, regels en balken), dozen,
|
||||
@@ -150,8 +158,16 @@ assets/ logo, texturen, skybox
|
||||
- **Overkapping:** bij een dak over de hele woning met `cover` loopt het
|
||||
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
|
||||
terras naast een kortere module), met een afgewerkte onderplaat in de
|
||||
kleur van het boeideel (`HouseModel.coverExtensions`).
|
||||
- **Dak over de hele woning:** tabblad Dak › "Over hele woning": één
|
||||
kleur van het boeideel (`HouseModel.coverExtensions`). Palen
|
||||
(Woning › Dak › Palen overkapping) komen onder de vrije dakrand; met
|
||||
"Zijwand" krijgt een vrije hoek geen paal maar een gestuukte wand van
|
||||
200 mm van de dakhoek terug tot de gevel, langs de zijkant van de
|
||||
overkapping (bij een hoek tussen twee vrije randen de kant met de kortste
|
||||
weg naar de gevel); in 3D, plattegrond, PDF, DXF en kosten
|
||||
(`PostModel`, `dak.zijwandM2`/`dak.zijwandMontage`). Ligt de buitenkant
|
||||
in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat,
|
||||
steen) erover door, gelijk met de gevel en tot onder de dakrand.
|
||||
- **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één
|
||||
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle
|
||||
units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden
|
||||
de wolfseinden kilkepers vragen, dan wordt het een zadeldak), bijv. een
|
||||
@@ -185,12 +201,20 @@ assets/ logo, texturen, skybox
|
||||
aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak
|
||||
zonder spots). De rand van het lage dak loopt dan door tot de gevel van
|
||||
het hoge, of langs een zijgevel tot de volle overstek (onder de hoge
|
||||
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit.
|
||||
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit. Hebben
|
||||
dak én gevel onder een rand felsplaten, dan komt er geen boeideel: de
|
||||
dakplaat zet om over de rand, net vóór de gevelbekleding en zonder
|
||||
opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de
|
||||
felsplaten); een boeideel ernaast stopt gelijk met de buitenkant van zo'n
|
||||
rand (ook naast daktrim).
|
||||
- **Gevel:** gevelbekleding per woning, met override per wand en per
|
||||
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
|
||||
getekend als plankentextuur die over naden doorloopt), steen (181
|
||||
texturen) of stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
|
||||
per woning, `WallModel.STUC`). Een zijde kan gesplitst worden (wand ›
|
||||
texturen), stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
|
||||
per woning, `WallModel.STUC`) of felsplaten (staande naad, banen van
|
||||
500 mm, zink/antraciet/zwart; opbouw als verticale rabat, kleuren gedeeld
|
||||
met de felsplaten op het dak, `WallModel.FELS`; kosten `gevel.felsM2` en
|
||||
`gevel.felsMontage`). Een zijde kan gesplitst worden (wand ›
|
||||
Wandstukken › Splitsen op, gemeten vanaf de linker vloerhoek van buitenaf):
|
||||
elk stuk wordt een eigen wand-element en kan een eigen gevel krijgen
|
||||
(bijv. stuc links, hout rechts). Steen is overal een
|
||||
@@ -209,7 +233,7 @@ assets/ logo, texturen, skybox
|
||||
(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
|
||||
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
|
||||
een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee.
|
||||
- **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset
|
||||
@@ -217,8 +241,8 @@ assets/ logo, texturen, skybox
|
||||
(douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met
|
||||
koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat,
|
||||
kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil,
|
||||
salontafel, tv-meubel, boiler en wasmachine. Binnen › Inrichting: kies
|
||||
een stuk en klik in de plattegrond; slepen, bij een wand snapt de rug op
|
||||
salontafel, tv-meubel, boiler en wasmachine. Gereedschap Inrichting (I): kies
|
||||
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
|
||||
en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D
|
||||
(afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep
|
||||
@@ -226,26 +250,38 @@ assets/ logo, texturen, skybox
|
||||
schuift mee.
|
||||
- **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
|
||||
override per wand) en binnenspots in een raster per unit.
|
||||
- **Ribbon** (`core/ribbon.js`, vervangt de menubalk): Bestand · Start ·
|
||||
Gevel · Dak · Binnen · Constructie · Verlichting · Rapport · Beeld · Extra,
|
||||
plus een gekleurd tabblad voor de selectie (unit, wandstuk, binnenwand of
|
||||
naadwand) dat vanzelf opent. Velden staan in groepen naast elkaar, met
|
||||
schuiven voor maten met een logisch bereik. Dubbelklik op een tabblad
|
||||
klapt de ribbon in. Plattegrond en 3D gebruiken de volle breedte; tabblad
|
||||
Rapport toont het rapport (transport per unit, gewicht, afmetingen,
|
||||
kosten, hout, plaatwerk, met staafdiagrammen) in plaats van de plattegrond.
|
||||
- **3D:** schuif Bouwfase linksonder; Beeld › Tonen: Dak/Lampen; Beeld ›
|
||||
Doorsnede (wanden afgesneden op een instelbare hoogte, schuif in de ribbon
|
||||
en onder de 3D, snede wit afgedekt), binnenzijde in een
|
||||
verfkleur (afgewerkte fase), omgeving Ontwerp/Dag/Nacht, Render met lampen
|
||||
als echte lichtbronnen.
|
||||
- **Indeling** (zelfde patroon als CAD/BIM-pakketten; elke functie op één plek):
|
||||
bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren,
|
||||
projectgegevens, kosten & tijden) · Beeld · Help, de geopende ontwerpen als
|
||||
tabs en de knop Opslaan; links de structuur (groepen in-/uitklapbaar en
|
||||
onthouden; de groep met de selectie klapt vanzelf open); in het midden een werkbalk met
|
||||
gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, met de
|
||||
opties van het actieve gereedschap ernaast; Unit toevoegen) en weergave
|
||||
(Lagen, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D /
|
||||
3D met 1/2/3); rechts het eigenschappenpaneel: de selectie (unit,
|
||||
wandstuk, binnenwand, ruimte, inrichting, elektra) met kruimelpad terug,
|
||||
of zonder selectie de woning (kerncijfers en inklapbare secties Gevel,
|
||||
Dak, Binnen, Verlichting, Elektra, Constructie); onderin een statusbalk
|
||||
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
|
||||
gewicht (vloer/wanden/dak) met een indicatieve breedteklasse.
|
||||
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
|
||||
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
|
||||
vloeren, wandstukken, binnenwanden, totalen).
|
||||
- **CAD-export (DXF):** Bestand › Exporteren › CAD (DXF), ook in de ribbon
|
||||
Woning › Uitvoer; los van Opslaan/Ctrl+S. DXF R12 (AC1009), opent in elk
|
||||
- **CAD-export (DXF):** Bestand › Exporteren › CAD (DXF); los van
|
||||
Opslaan/Ctrl+S. DXF R12 (AC1009), opent in elk
|
||||
CAD-pakket (AutoCAD, BricsCAD, DraftSight, LibreCAD, …); DWG maakt het
|
||||
CAD-pakket zelf met Opslaan als (echte DWG schrijven vergt een GPL- of
|
||||
commerciële bibliotheek). Modelruimte 1:1 in mm: plattegrond met noord
|
||||
@@ -281,10 +317,14 @@ assets/ logo, texturen, skybox
|
||||
(`HouseIfc` COLORS). X = oost, Y = noord,
|
||||
Z = 0 op peil; zuidwesthoek van de units = 0,0 (zelfde als de DXF).
|
||||
- **Render-export (OBJ):** Bestand › Exporteren › Render (OBJ), ook in Woning ›
|
||||
Uitvoer. De volledige afgewerkte woning (laatste bouwfase, dak, dakrand,
|
||||
lampen, ruimtes, inrichting; los van de gekozen fase, doorsnede en
|
||||
weergave-opties; zonder logobord, grond en lucht) als ZIP met `.obj`, `.mtl`
|
||||
en `textures/*.png` (rabat, steen, riet, tegels). Meters, Y omhoog (de
|
||||
Uitvoer. Voor een sfeerimpressie: alleen wat in de afgewerkte woning te zien
|
||||
is (dak, dakrand, lampen, ruimtes, inrichting; los van de gekozen lagen,
|
||||
doorsnede en weergave-opties; zonder buizen en dozen, binnenste vloerbalken,
|
||||
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
|
||||
maaiveld onder het midden. Kleuren omgerekend van sRGB naar lineair (de app
|
||||
heeft geen kleurbeheer, Blender wel), PBR via `Pr`/`Pm`/`d`/`Ke` (ruwheid,
|
||||
@@ -340,7 +380,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
|
||||
staat niet in het bestand: in CAD op millimeters zetten (UNITS) als het
|
||||
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
|
||||
op Glass BSDF zetten.
|
||||
- IFC: onderdelen zijn driehoeksnetten (niet parametrisch); geen
|
||||
@@ -376,7 +416,15 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
|
||||
13 × 10 m, hoofdvolume met flauw zadeldak (twee lessenaars die op de naad
|
||||
de nok vormen) en een vleugel met plat dak, steenstrips rondom met een
|
||||
schoorsteen (800 mm, hart op de naad) in de oostgevel, kookeiland, logeerkamer en
|
||||
apart toilet in de vleugel. De standaardmodellen staan in
|
||||
apart toilet in de vleugel. Model **Reest** (`HouseModel.createReest`,
|
||||
naar `examples/ontwerp_reest.pdf`): 12,9 × 7,85 m, hoofdvolume
|
||||
11,5 × 4,8 m onder één lessenaarsdak (hoog aan de noordgevel, twee units
|
||||
van 3,4 en 1,4 m met lessenaars in hetzelfde vlak) dat aan de westkop
|
||||
1,4 m doorloopt als overkapping bij de voordeur, dicht met een witte
|
||||
zijwand; aanbouw met plat dak (toilet, berging, entree met open doorgang
|
||||
naar de woonkamer, daktrim i.p.v. boeidelen); zuidgevel en dak in
|
||||
antraciet felsplaten (zuidrand zonder boeideel, de felsplaten lopen over
|
||||
de zijwand door), verder verticale rabat. De standaardmodellen staan in
|
||||
`HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden
|
||||
daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die
|
||||
`modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft
|
||||
|
||||
+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 {
|
||||
flex: 1;
|
||||
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 + .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 {
|
||||
display: flex;
|
||||
background: var(--van-emous-blue);
|
||||
@@ -148,39 +116,6 @@
|
||||
}
|
||||
.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 {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
@@ -292,28 +227,6 @@
|
||||
.material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); }
|
||||
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-empty {
|
||||
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.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; }
|
||||
#house-nav-tree { display: flex; flex-direction: column; gap: 2px; }
|
||||
.hn-group { margin-top: 6px; }
|
||||
@@ -475,50 +365,20 @@ body { display: flex; flex-direction: column; }
|
||||
.hn-item.disabled:hover { background: none; color: #9aa4b0; }
|
||||
.hn-actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.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); }
|
||||
#house-overview.show-report .ho-report { display: block; }
|
||||
#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 h1 { margin: 0; font-size: 14px; }
|
||||
.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 .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; }
|
||||
.io-btn-danger { border-color: #e3b4b0; color: var(--danger); }
|
||||
.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); }
|
||||
|
||||
.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; }
|
||||
.ui-section { margin-bottom: 14px; }
|
||||
.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.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-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; }
|
||||
@@ -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 .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.active .bi, .hn-item:hover .bi { color: var(--primary); }
|
||||
.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 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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -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-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
|
||||
|
||||
.stage-bar {
|
||||
position: absolute; left: 15px; bottom: 14px; z-index: 10; min-width: 300px; max-width: calc(100% - 30px);
|
||||
background: rgba(0, 0, 0, 0.75); border: 1px solid #444; border-radius: 6px; padding: 7px 12px 6px; color: #eee; font-size: 12px;
|
||||
.plan-zoom { position: absolute; right: 12px; bottom: 12px; z-index: 10; display: flex; flex-direction: column; gap: 4px; }
|
||||
.plan-zoom button {
|
||||
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; }
|
||||
.stage-title { display: inline-flex; align-items: center; gap: 6px; color: var(--primary-on-dark); font-weight: 600; white-space: nowrap; }
|
||||
.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; }
|
||||
.plan-zoom button:hover { border-color: var(--primary-on-dark); color: var(--primary-on-dark); }
|
||||
.plan-zoom-val { text-align: center; font-size: 10px; color: #aaa; }
|
||||
|
||||
.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; }
|
||||
@@ -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-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; }
|
||||
.rb-top { display: flex; align-items: flex-end; height: 34px; padding: 0 8px; }
|
||||
.rb-brand { display: flex; align-items: center; height: 100%; padding: 0 14px 0 4px; font-weight: 700; color: var(--primary); letter-spacing: .5px; }
|
||||
.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); }
|
||||
/* ===== Shell: bovenbalk (menu + documenten), editorindeling, werkbalk, eigenschappen, statusbalk ===== */
|
||||
:root { --panel-w: 340px; --nav-w: 230px; }
|
||||
body { display: flex; flex-direction: column; }
|
||||
|
||||
.rb-pair { display: flex; align-items: center; gap: 6px; }
|
||||
.rb-cols { grid-row: span 3; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 18px; align-items: center; }
|
||||
.rb-cols:empty { display: none; }
|
||||
#topbar {
|
||||
flex: none; height: 40px; display: flex; align-items: stretch; gap: 4px; padding: 0 10px 0 4px;
|
||||
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); }
|
||||
|
||||
+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>
|
||||
|
||||
<header id="ribbon">
|
||||
<div class="rb-top">
|
||||
<div class="rb-brand"><span class="splash-name">Tectum</span></div>
|
||||
<div class="rb-tabs" role="tablist"></div>
|
||||
<div class="rb-right"><span class="app-version-slot"></span></div>
|
||||
<header id="topbar">
|
||||
<div class="tb-brand"><span class="splash-name">Tectum</span></div>
|
||||
<nav id="menubar" aria-label="Menu"></nav>
|
||||
<div id="doc-tabs" role="tablist" aria-label="Geopende ontwerpen"></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 class="rb-body"></div>
|
||||
</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-modal">
|
||||
<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="about-footer">
|
||||
<span>© 2026 Machiel Heinen · ontwikkeld in opdracht van Van Emous Mantelzorgwoningen</span>
|
||||
<button id="about-close" class="about-close">Sluiten</button>
|
||||
<button id="about-close" class="about-close" data-esc>Sluiten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="new-overlay">
|
||||
<div id="new-modal">
|
||||
<h2>Nieuw</h2>
|
||||
<h2>Nieuw ontwerp</h2>
|
||||
<div class="new-options">
|
||||
<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>
|
||||
@@ -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>
|
||||
<span>Los: vloer</span>
|
||||
</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 id="tab-bar"></div>
|
||||
<div id="workspace"></div>
|
||||
|
||||
<script src="js/core/license.js"></script>
|
||||
<script src="js/core/logo-data.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/camera-memory.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/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/vendor/jspdf.umd.min.js"></script>
|
||||
<script src="js/core/pdf-sheet.js"></script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
const APP_VERSION = '0.7.0';
|
||||
const APP_VERSION = '0.7.3';
|
||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||
|
||||
let tabs = [];
|
||||
@@ -28,72 +28,83 @@ function defaultNameFor(kind) {
|
||||
function setWorkspaceEmpty() {
|
||||
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
|
||||
<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>`;
|
||||
}
|
||||
|
||||
function updateEditMenuState() {
|
||||
Ribbon.refresh();
|
||||
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function' && (!currentEditor.can || currentEditor.can(fn)));
|
||||
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');
|
||||
const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); };
|
||||
const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled';
|
||||
|
||||
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 togglePanels() {
|
||||
document.body.classList.toggle('panels-hidden');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
|
||||
function renderTabBar() {
|
||||
const bar = document.getElementById('tab-bar');
|
||||
const bar = document.getElementById('doc-tabs');
|
||||
bar.innerHTML = tabs.map(tab => `
|
||||
<div class="tab-item ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}">
|
||||
<span class="tab-name">${tab.name}</span>
|
||||
<span class="tab-close" data-close-id="${tab.id}">×</span>
|
||||
<div class="doc-tab ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}" role="tab" aria-selected="${tab.id === activeTabId}" title="${UI.esc(tab.name)}">
|
||||
<span class="doc-name">${UI.esc(tab.name)}</span>
|
||||
<button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('top-save').disabled = !editorHas('exportDesign');
|
||||
}
|
||||
|
||||
function onTabSaved(tab, filenameWithExt) {
|
||||
@@ -106,7 +117,6 @@ function onTabSaved(tab, filenameWithExt) {
|
||||
function mountEditorForTab(tab) {
|
||||
const ws = document.getElementById('workspace');
|
||||
ws.innerHTML = '';
|
||||
Ribbon.setSource('editor', []);
|
||||
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') {
|
||||
currentEditor.loadStateJson(tab.stateJson);
|
||||
@@ -124,7 +134,6 @@ function switchToTab(tabId) {
|
||||
|
||||
activeTabId = tabId;
|
||||
mountEditorForTab(tabs.find(t => t.id === tabId));
|
||||
updateEditMenuState();
|
||||
renderTabBar();
|
||||
}
|
||||
|
||||
@@ -142,7 +151,6 @@ function openNew(kind, template) {
|
||||
tabs.push(tab);
|
||||
activeTabId = tab.id;
|
||||
mountEditorForTab(tab);
|
||||
updateEditMenuState();
|
||||
renderTabBar();
|
||||
}
|
||||
|
||||
@@ -183,17 +191,15 @@ function closeTab(tabId) {
|
||||
switchToTab(tabs[tabs.length - 1].id);
|
||||
} else {
|
||||
setWorkspaceEmpty();
|
||||
Ribbon.setSource('editor', []);
|
||||
updateEditMenuState();
|
||||
renderTabBar();
|
||||
}
|
||||
}
|
||||
|
||||
function wireTabBar() {
|
||||
document.getElementById('tab-bar').addEventListener('click', (e) => {
|
||||
const closeBtn = e.target.closest('.tab-close');
|
||||
document.getElementById('doc-tabs').addEventListener('click', (e) => {
|
||||
const closeBtn = e.target.closest('.doc-close');
|
||||
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));
|
||||
});
|
||||
}
|
||||
@@ -237,41 +243,54 @@ function toggleBrowserFullscreen() {
|
||||
else document.documentElement.requestFullscreen();
|
||||
}
|
||||
|
||||
// Alle dialogen; Esc gebruikt hun Annuleren-knop (data-esc), zodat bijv. projectgegevens worden teruggezet.
|
||||
const OVERLAYS = '.dlg-overlay.open, #about-overlay.open, #new-overlay.open, #house-project-overlay.open, #wall-project-overlay.open, #cost-settings-overlay.open';
|
||||
function closeOverlays() {
|
||||
const open = document.querySelector(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() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
|
||||
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
|
||||
const k = e.key.toLowerCase();
|
||||
// Dialoog open: alleen Esc (sluiten); geen sneltoetsen naar de editor erachter. Typen in de dialoog blijft werken.
|
||||
if (document.querySelector(OVERLAYS)) {
|
||||
if (e.key === 'Escape') closeOverlays();
|
||||
e.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault();
|
||||
openNewModal();
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const act = { s: () => editorCall('exportDesign'), n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
|
||||
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
|
||||
return;
|
||||
}
|
||||
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
if (e.key.toLowerCase() === 'f') {
|
||||
toggleBrowserFullscreen();
|
||||
} else if (e.key.toLowerCase() === 'h') {
|
||||
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
|
||||
} else if (e.key.toLowerCase() === 't' && e.shiftKey) {
|
||||
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk();
|
||||
} else if (e.key.toLowerCase() === 't') {
|
||||
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
|
||||
} else if (e.key.toLowerCase() === 'r') {
|
||||
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
|
||||
}
|
||||
});
|
||||
if (e.key === 'Escape' && closeOverlays()) { e.stopImmediatePropagation(); return; }
|
||||
if (inField || e.altKey || viewState().walk && !['l', 'escape'].includes(k)) return;
|
||||
const view = UI.VIEWS.find(v => v.key1 === e.key);
|
||||
if (view) editorCall('setView', view.key);
|
||||
else if (e.key === '4') editorCall('toggleReport');
|
||||
else if (k === 'x') editorCall('toggleSection');
|
||||
else if (k === 'f') editorCall('fitView');
|
||||
else if (k === 'l') editorCall('toggleWalk');
|
||||
else if (k === 'h' && e.shiftKey) editorCall('mirrorHorizontal');
|
||||
else if (e.key === '?') openKeys();
|
||||
}, true);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
||||
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
||||
Ribbon.init(document.getElementById('ribbon'));
|
||||
Ribbon.setSource('app', appRibbonTabs());
|
||||
Menubar.init(document.getElementById('menubar'), MENUS);
|
||||
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();
|
||||
wireNewModal();
|
||||
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) {
|
||||
let m = getMode();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'view-filter';
|
||||
wrap.innerHTML = `<button type="button" class="btn-3d-toggle vf-btn" title="Omgeving"></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>`;
|
||||
wrap.className = 'tb-pop-wrap';
|
||||
wrap.innerHTML = `<button type="button" class="tb-btn" title="Omgeving in 3D"></button>
|
||||
<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);
|
||||
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 = () => {
|
||||
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i>${LABELS[m][1]}<i class="bi bi-chevron-down"></i>`;
|
||||
menu.querySelectorAll('.vf-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
|
||||
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('.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) => {
|
||||
e.stopPropagation();
|
||||
const opt = e.target.closest('.vf-option');
|
||||
const opt = e.target.closest('.pop-option');
|
||||
if (!opt) return;
|
||||
menu.classList.remove('open');
|
||||
close();
|
||||
if (opt.dataset.mode === m) return;
|
||||
m = opt.dataset.mode;
|
||||
remember(m);
|
||||
sync();
|
||||
onChange(m);
|
||||
});
|
||||
const closeOnOutside = () => {
|
||||
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
|
||||
menu.classList.remove('open');
|
||||
};
|
||||
document.addEventListener('click', closeOnOutside);
|
||||
sync();
|
||||
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 warn = (text) => `<div class="ui-warn">${icon('exclamation-triangle')}<span>${text}</span></div>`;
|
||||
const grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`;
|
||||
const button = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
|
||||
// Eén knopbouwer; base = de knopsoort (ui-btn, tb-btn, ins-btn …), cls = extra klassen.
|
||||
const btnOf = (base) => (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="${base}${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
|
||||
const button = btnOf('ui-btn');
|
||||
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
|
||||
|
||||
const activeTabs = {};
|
||||
@@ -128,16 +130,79 @@ window.UI = (() => {
|
||||
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
|
||||
}
|
||||
|
||||
function cycle3D(el) {
|
||||
el.classList.remove('fullscreen-2d');
|
||||
if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d');
|
||||
else if (!el.classList.contains('render-only')) el.classList.add('render-only');
|
||||
else el.classList.remove('fullscreen-3d', 'render-only');
|
||||
}
|
||||
function toggle2D(el) {
|
||||
el.classList.remove('fullscreen-3d', 'render-only');
|
||||
el.classList.toggle('fullscreen-2d');
|
||||
// Weergave van een editor: klassen fullscreen-2d/-3d op het editor-element (de looptocht bewaart en herstelt className).
|
||||
const VIEWS = [
|
||||
{ key: 'split', icon: 'layout-split', label: '2D + 3D', key1: '1' },
|
||||
{ key: '2d', icon: 'square', label: 'Alleen 2D', key1: '2' },
|
||||
{ key: '3d', icon: 'box', label: 'Alleen 3D', key1: '3' }
|
||||
];
|
||||
const viewOf = (el) => el.classList.contains('fullscreen-3d') ? '3d' : el.classList.contains('fullscreen-2d') ? '2d' : 'split';
|
||||
function setView(el, mode) {
|
||||
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 },
|
||||
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 },
|
||||
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
|
||||
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 }
|
||||
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 },
|
||||
felsM2: { label: 'Felsplaten gevel, staande naad (staal, klangen, ondergrond)', unit: '€/m²', value: 70 },
|
||||
felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -123,7 +125,9 @@ window.COST_DEFAULTS = {
|
||||
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 },
|
||||
paalHoutM3: { label: 'Paalhout overkapping (eiken, geschaafd)', unit: '€/m³', value: 2400 },
|
||||
paalvoet: { label: 'Paalvoet verzinkt, verstelbaar (incl. ankers)', unit: '€/st.', value: 40 },
|
||||
paalMontage: { label: 'Paal plaatsen: stellen, aftoppen, vastzetten aan het dak', unit: 's/st.', value: 2700 }
|
||||
paalMontage: { label: 'Paal plaatsen: stellen, aftoppen, vastzetten aan het dak', unit: 's/st.', value: 2700 },
|
||||
zijwandM2: { label: 'Zijwand overkapping (regelwerk, cementgebonden plaat en stuc aan beide kanten, fundering)', unit: '€/m²', value: 140 },
|
||||
zijwandMontage: { label: 'Zijwand overkapping maken en plaatsen', unit: 's/m²', value: 5400 }
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -155,6 +155,8 @@ window.CostModel = (() => {
|
||||
add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM);
|
||||
const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
|
||||
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
|
||||
const fels = p.buitenMateriaal === 'vochtdoek' ? (st.felsAreaM2 || 0) : 0;
|
||||
add(el, 'Gevel', 'Gevel', 'Felsplaten (staande naad)', fels, 'm²', fels * (G.felsM2 || 0), fels * (G.felsMontage || 0));
|
||||
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
|
||||
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
|
||||
const stuc = st.stucAreaM2 || 0;
|
||||
@@ -242,6 +244,11 @@ window.CostModel = (() => {
|
||||
const pp = totals.posts;
|
||||
add(wo, 'Hout', 'Dak', `${pp.wood} palen ${pp.size}×${pp.size} mm (${pp.count} st.)`, pp.lengthM, 'm', pp.m3 * D.paalHoutM3, pp.count * D.paalMontage);
|
||||
add(wo, 'Staal', 'Dak', 'Paalvoeten', pp.count, 'st.', pp.count * D.paalvoet, 0);
|
||||
add(wo, 'Gevel', 'Dak', `Zijwand overkapping (${pp.walls || 0} st.)`, pp.wallM2 || 0, 'm²', (pp.wallM2 || 0) * (D.zijwandM2 || 0), (pp.wallM2 || 0) * (D.zijwandMontage || 0));
|
||||
// Buitenkant zoals de gevel ernaast (stuc zit al in de zijwand).
|
||||
const G = S.gevel, oo = pp.outsideM2 || {};
|
||||
[['fels', 'felsplaten', G.felsM2, G.felsMontage], ['rabat', 'rabat', G.rabatM2, G.rabatMontage], ['steen', 'steenstrips', G.steenM2, G.steenMontage]].forEach(([k, label, m2, sec]) =>
|
||||
add(wo, 'Gevel', 'Gevel', `Zijwand overkapping buitenzijde ${label}`, oo[k] || 0, 'm²', (oo[k] || 0) * (m2 || 0), (oo[k] || 0) * (sec || 0)));
|
||||
}
|
||||
if (parts.sandwich) {
|
||||
const sw = parts.sandwich;
|
||||
|
||||
@@ -24,13 +24,13 @@ window.CostSettings = (() => {
|
||||
<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="modal-footer">
|
||||
<button type="button" class="io-btn" data-cs="reset">Standaard</button>
|
||||
<button type="button" class="io-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="io-btn" data-cs="import">Importeren…</button>
|
||||
<button type="button" class="ui-btn" data-cs="reset">Standaard</button>
|
||||
<button type="button" class="ui-btn" data-cs="export">intern.csv 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="ui-btn" data-cs="import">Importeren…</button>
|
||||
<span style="flex:1"></span>
|
||||
<button type="button" class="io-btn" data-cs="cancel">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" data-cs="cancel" data-esc>Annuleren</button>
|
||||
<button type="button" class="ui-btn ui-btn-primary" data-cs="save">Toepassen</button>
|
||||
</div>
|
||||
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
|
||||
<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;
|
||||
}
|
||||
|
||||
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 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);
|
||||
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);
|
||||
@@ -155,24 +214,159 @@ window.ElektraModel = (() => {
|
||||
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)));
|
||||
};
|
||||
// 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 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) {
|
||||
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') {
|
||||
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),
|
||||
P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')];
|
||||
const A2 = a.mount === 'vloer' ? null : sidestep(a, () => fa), B2 = b.mount === 'vloer' ? null : sidestep(b, () => fb);
|
||||
const a2 = A2 || a, b2 = B2 || b;
|
||||
return [P(a, a.face, false, a.mount === 'vloer')].concat(A2 ? [P(A2, a.face)] : [], [P({ x: a2.x, y: fa, z: a2.z }, a.face, false, true)])
|
||||
.concat(corners(a2, b2).map(c => P({ x: c.x, y: fa, z: c.z }, null, false, true)),
|
||||
[P({ x: b2.x, y: fb, z: b2.z }, b.face, false, true)], B2 ? [P(B2, b.face)] : [], [P(b, b.face, false, b.mount === 'vloer')]);
|
||||
}
|
||||
if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
|
||||
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a), P({ x: b.x, y: a.y, z: a.z }), P(b)];
|
||||
const top = (p) => p.mount === 'plafond' ? p.y : ceilAt(p.x, p.z);
|
||||
const ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1;
|
||||
// Langs dezelfde wand: eerst verticaal of eerst horizontaal; zit er een deur of raam tussen, dan via het plafond.
|
||||
if (a.face && a.face === b.face) {
|
||||
const viaA = [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
|
||||
const viaB = [P(a, a.face), P({ x: b.x, y: a.y, z: b.z }, a.face), P(b, b.face)];
|
||||
if (clear(viaA)) return viaA;
|
||||
if (clear(viaB)) return viaB;
|
||||
}
|
||||
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a)].concat(outside(a, b).map(c => P({ x: c.x, y: a.y, z: c.z })), [P(b)]);
|
||||
// Wandpunt: de plafondhoogte iets de ruimte in meten (de wand zelf hoort bij geen ruimte).
|
||||
const top = (p) => p.mount === 'plafond' ? p.y : p.face && p.face.n ? ceilAt(p.x + p.face.n.x * 50, p.z + p.face.n.z * 50) : ceilAt(p.x, p.z);
|
||||
const A2 = a.mount === 'plafond' ? null : sidestep(a, top), B2 = b.mount === 'plafond' ? null : sidestep(b, top);
|
||||
const a2 = A2 || a, b2 = B2 || b;
|
||||
const ya = top(a2), yb = top(b2), dropB = Math.abs(yb - b.y) > 1;
|
||||
const pts = [P(a, a.face, a.mount === 'plafond')];
|
||||
if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a.x, y: ya, z: a.z }, a.face, true));
|
||||
pts.push(P({ x: b.x, y: ceilAt(b.x, a.z), z: a.z }, null, true));
|
||||
pts.push(P({ x: b.x, y: yb, z: b.z }, dropB ? b.face : null, true));
|
||||
if (dropB) pts.push(P(b, b.face, false));
|
||||
return pts;
|
||||
if (A2) pts.push(P(A2, a.face));
|
||||
if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a2.x, y: ya, z: a2.z }, a.face, true));
|
||||
corners(a2, b2, true).forEach(c => pts.push(P({ x: c.x, y: ceilAt(c.x, c.z), z: c.z }, null, true)));
|
||||
pts.push(P({ x: b2.x, y: yb, z: b2.z }, dropB ? b.face : null, true));
|
||||
if (dropB) {
|
||||
if (B2) pts.push(P(B2, b.face));
|
||||
pts.push(P(b, b.face, false));
|
||||
}
|
||||
return followCeiling(pts);
|
||||
}
|
||||
const length = (pts) => pts.slice(1).reduce((s, q, i) => s + Math.hypot(q.x - pts[i].x, q.y - pts[i].y, q.z - pts[i].z), 0);
|
||||
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: []
|
||||
};
|
||||
if (!s.enabled || !units.length) return out;
|
||||
const R = makeRouter(units);
|
||||
const blocked = makeOpeningTest(units, interior);
|
||||
const R = makeRouter(units, rooms, blocked);
|
||||
const faces = {};
|
||||
units.forEach(u => { faces[u.id] = facesOf(u, interior); });
|
||||
const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1);
|
||||
@@ -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));
|
||||
|
||||
separateRuns(out.runs);
|
||||
separateRuns(out.runs, blocked);
|
||||
const holesOf = makeHoleCounter(units, R);
|
||||
out.runs.forEach(r => {
|
||||
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.
|
||||
function separateRuns(runs) {
|
||||
function separateRuns(runs, blocked) {
|
||||
const add = (p, d, k) => Object.assign({}, p, { x: p.x + d.x * k, y: p.y + d.y * k, z: p.z + d.z * k });
|
||||
const axisOf = (p, q) => {
|
||||
const dx = Math.abs(q.x - p.x), dy = Math.abs(q.y - p.y), dz = Math.abs(q.z - p.z);
|
||||
@@ -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 j1 = seg(r, p, p2, rad), j2 = seg(r, q2, q, rad);
|
||||
if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue;
|
||||
if (blocked && (blocked(p2, q2) || blocked(p, p2) || blocked(q2, q))) continue;
|
||||
pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1));
|
||||
placed.push(s2, j1, j2);
|
||||
i += 2;
|
||||
@@ -735,13 +931,13 @@ window.ElektraModel = (() => {
|
||||
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();
|
||||
if (!el || !el.enabled) return group;
|
||||
const mats = materials || createMaterials();
|
||||
mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex);
|
||||
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key);
|
||||
const rough = show('elektra') && !show('beplating');
|
||||
if (!ViewLayers.shows(layers, 'elektra')) return group;
|
||||
const box = (w, h, d, c, rotY, 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);
|
||||
@@ -750,18 +946,18 @@ window.ElektraModel = (() => {
|
||||
return mesh;
|
||||
};
|
||||
const k = el.meterkast;
|
||||
if (k && show('elektra')) {
|
||||
if (k) {
|
||||
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);
|
||||
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.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.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 shift = (p) => ({
|
||||
@@ -859,7 +1055,7 @@ window.ElektraModel = (() => {
|
||||
} 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;
|
||||
}
|
||||
} 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);
|
||||
if (u) u.roof.planes.forEach(pl => {
|
||||
const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds);
|
||||
|
||||
+36
-54
@@ -6,7 +6,10 @@
|
||||
*/
|
||||
|
||||
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-pane-2d-container">
|
||||
<div class="pane-2d-sub" id="floor-pane-top">
|
||||
@@ -18,7 +21,15 @@ const FLOOR_TEMPLATE = `
|
||||
<canvas id="floor-canvas-section"></canvas>
|
||||
</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">
|
||||
<input type="file" id="f-file-import-json" accept="application/json" style="display:none;">
|
||||
<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" 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-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
|
||||
</div>
|
||||
|
||||
<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><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>
|
||||
@@ -82,9 +80,8 @@ const FLOOR_TEMPLATE = `
|
||||
</div>
|
||||
|
||||
<div id="floor-tab-chassis" class="tab-content">
|
||||
<div class="material-toggle-group">
|
||||
<button type="button" id="f-btn-chassis-toggle" class="material-toggle-btn">Chassis-laag: Uit</button>
|
||||
</div>
|
||||
<div class="control-group"><label for="f-chassis-on">Chassis-laag</label>
|
||||
<div><label class="ui-switch"><input type="checkbox" id="f-chassis-on"><span></span></label></div></div>
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<div class="control-group"><label>Wieldiameter</label>
|
||||
@@ -99,17 +96,9 @@ const FLOOR_TEMPLATE = `
|
||||
</div>
|
||||
<div id="f-chassis-warning" class="global-warning" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</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-tab-specs" class="tab-content">
|
||||
<div id="floor-specs-panel-3d">
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<div>
|
||||
<div class="spec-section">
|
||||
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
||||
@@ -139,6 +128,7 @@ const FLOOR_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wall-project-overlay">
|
||||
<div id="wall-project-modal">
|
||||
@@ -169,8 +159,8 @@ const FLOOR_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="wall-project-cancel" class="io-btn">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-cancel" class="ui-btn" data-esc>Annuleren</button>
|
||||
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,16 +275,14 @@ function mountFloorEditor(container, callbacks) {
|
||||
document.querySelectorAll('#floor-tab-main input').forEach(inp => inp.addEventListener('input', updateAll));
|
||||
|
||||
function syncChassisToggleLabel() {
|
||||
const btn = document.getElementById('f-btn-chassis-toggle');
|
||||
btn.classList.toggle('active', chassis.enabled);
|
||||
btn.textContent = 'Chassis-laag: ' + (chassis.enabled ? 'Aan' : 'Uit');
|
||||
document.getElementById('f-chassis-on').checked = chassis.enabled;
|
||||
}
|
||||
document.getElementById('f-btn-chassis-toggle').addEventListener('click', () => {
|
||||
chassis.enabled = !chassis.enabled;
|
||||
document.getElementById('f-chassis-on').addEventListener('change', (e) => {
|
||||
chassis.enabled = e.target.checked;
|
||||
syncChassisToggleLabel();
|
||||
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.trackWidth = parseFloat(document.getElementById('f-inp-track').value) || 1200;
|
||||
chassis.wheelbase = parseFloat(document.getElementById('f-inp-wheelbase').value) || 1200;
|
||||
@@ -632,19 +620,21 @@ function mountFloorEditor(container, callbacks) {
|
||||
let lastResolvedOpenings = [];
|
||||
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();
|
||||
const skyEnv = Skybox.createEnvironment(scene);
|
||||
const applySky = () => {
|
||||
skyEnv.fitTo(floorGroup3D, 0);
|
||||
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient: floorAmbient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: floorGroup3D });
|
||||
};
|
||||
Skybox.createToggle(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() {
|
||||
if (floorGroup3D) scene.remove(floorGroup3D);
|
||||
@@ -691,14 +681,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
}
|
||||
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() {
|
||||
return {
|
||||
@@ -748,8 +730,8 @@ function mountFloorEditor(container, callbacks) {
|
||||
},
|
||||
exportDesign,
|
||||
triggerImport,
|
||||
toggleFullscreen3D,
|
||||
toggleFullscreen2D,
|
||||
setView: bar.setView,
|
||||
viewState: bar.viewState,
|
||||
openProjectProperties,
|
||||
getStateJson: getPureState,
|
||||
loadStateJson: applyPureState
|
||||
|
||||
@@ -200,6 +200,7 @@ window.HouseDxf = (() => {
|
||||
const s = pp.size / 2;
|
||||
dxf.polyline([{ x: pp.x - s, z: pp.z - s }, { x: pp.x + s, z: pp.z - s }, { x: pp.x + s, z: pp.z + s }, { x: pp.x - s, z: pp.z + s }].map(T), true, 'PL-PALEN');
|
||||
});
|
||||
parts.posts.walls.forEach(w => dxf.polyline([{ x: w.x0, z: w.z0 }, { x: w.x1, z: w.z0 }, { x: w.x1, z: w.z1 }, { x: w.x0, z: w.z1 }].map(T), true, 'PL-PALEN'));
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
dxf.polyline(ch.footprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||
dxf.polyline(ch.flueFootprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||
|
||||
@@ -49,19 +49,20 @@ window.HouseIfc = (() => {
|
||||
};
|
||||
}
|
||||
|
||||
function build(state, 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 parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
|
||||
stage, lampenVisible: false
|
||||
layers, elektraRough: rough
|
||||
});
|
||||
HouseModel.placeGroups(parts);
|
||||
return { parts, classify: classifier(M) };
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const frame = build(state, BuildStages.INDEX.elektra);
|
||||
const sheet = build(state, BuildStages.INDEX.beplating);
|
||||
const frame = build(state, ViewLayers.upTo(1), true);
|
||||
const sheet = build(state, ViewLayers.upTo(2), false);
|
||||
const parts = frame.parts;
|
||||
if (!parts.units.length) throw new Error('geen units in het ontwerp');
|
||||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||||
|
||||
+181
-33
@@ -34,7 +34,7 @@ window.HouseModel = (() => {
|
||||
function createDefaultSettings() {
|
||||
return {
|
||||
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
|
||||
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit' },
|
||||
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit', felsColor: 'antraciet' },
|
||||
cladding: 'horizontaal',
|
||||
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' },
|
||||
interiorPaint: true,
|
||||
@@ -54,7 +54,7 @@ window.HouseModel = (() => {
|
||||
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting(), facade: { walls: {} } };
|
||||
}
|
||||
|
||||
const FACADE_TYPES = { rabat: 'Rabat', steen: 'Steen', stuc: 'Stucwerk' };
|
||||
const FACADE_TYPES = { rabat: 'Rabat', steen: 'Steen', stuc: 'Stucwerk', fels: 'Felsplaten' };
|
||||
function facadeFor(state, u, side, piece) {
|
||||
const f = state.settings.facade;
|
||||
const ow = (u.facade && u.facade.walls && u.facade.walls[side]) || null;
|
||||
@@ -62,15 +62,18 @@ window.HouseModel = (() => {
|
||||
const type = o && FACADE_TYPES[o.type] ? o.type : f.type;
|
||||
const stone = o && o.type === 'steen' && WallModel.isStoneFinish(o.stone) ? o.stone : f.stone;
|
||||
const stuc = o && o.type === 'stuc' && WallModel.isStucColor(o.stuc) ? o.stuc : f.stucColor;
|
||||
return { type, stone, stuc, override: !!o };
|
||||
const fels = o && o.type === 'fels' && WallModel.isFelsColor(o.fels) ? o.fels : f.felsColor;
|
||||
return { type, stone, stuc, fels, override: !!o };
|
||||
}
|
||||
function facadeMaterials(state, fc) {
|
||||
const tr = state.settings.trasraam;
|
||||
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
|
||||
const cladding = state.settings.cladding;
|
||||
if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null };
|
||||
if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null };
|
||||
return { exterior: 'vochtdoek', stone: null, stuc: null, cladding, trasraam, rabatColor: state.settings.facade.rabatColor };
|
||||
if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null, fels: null };
|
||||
if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null, fels: null };
|
||||
// Felsplaten: opbouw als verticale rabat (latten liggend), de banen staan altijd.
|
||||
if (fc.type === 'fels') return { exterior: 'vochtdoek', stone: null, stuc: null, cladding: 'verticaal', trasraam, rabatColor: null, fels: fc.fels };
|
||||
return { exterior: 'vochtdoek', stone: null, stuc: null, cladding, trasraam, rabatColor: state.settings.facade.rabatColor, fels: null };
|
||||
}
|
||||
|
||||
function newModelState(naam) {
|
||||
@@ -154,6 +157,7 @@ window.HouseModel = (() => {
|
||||
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: 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: [] });
|
||||
@@ -243,6 +247,7 @@ window.HouseModel = (() => {
|
||||
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: 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: [] });
|
||||
@@ -309,6 +314,7 @@ window.HouseModel = (() => {
|
||||
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: 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: [] });
|
||||
@@ -383,6 +389,7 @@ window.HouseModel = (() => {
|
||||
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: 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.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: 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: [] });
|
||||
@@ -567,6 +575,7 @@ window.HouseModel = (() => {
|
||||
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: 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,103 @@ window.HouseModel = (() => {
|
||||
return st;
|
||||
}
|
||||
|
||||
// Model Reest (examples/ontwerp_reest.pdf): 12,9 × 7,85 m. Hoofdvolume 11,5 × 4,8 m onder één lessenaarsdak (hoog
|
||||
// aan de noordgevel) dat aan de westkop 1,4 m doorloopt als overkapping bij de voordeur, aan de zuidkant dicht met
|
||||
// een witte zijwand (PostModel sideWall, geen palen). 4,8 m is te diep voor één unit: twee units (3,4 + 1,4 m, de
|
||||
// naad ten zuiden van het glas in de westgevel) met elk een lessenaar in hetzelfde vlak. Aan de noordwestkant een
|
||||
// aanbouw van 3,89 × 3,05 m met plat dak (toilet, berging, entree), die met een open doorgang op de woonkamer
|
||||
// uitkomt; de voordeur zit onder de overkapping. Zuidgevel en dak in felsplaten (daar geen boeideel: de dakplaat
|
||||
// zet om, en de felsplaten lopen over de zijwand door), de rest verticale rabat; de aanbouw heeft daktrim. De
|
||||
// aanbouw is de eerste unit, zodat alle maten buitenwerks vanaf zijn noordwesthoek gelden, zoals de tekening.
|
||||
// De tekening heeft de zuidgevel van de aanbouw 240 mm zuidelijker (3290); in de configurator ligt hij op de naad.
|
||||
function createReest() {
|
||||
const st = newModelState('Reest');
|
||||
const S = st.settings, HA = 2800, HN = 3500, HM = 2820, HS = 2540, DN = 3400, DS = 1400, V = 1400, FELS = 'antraciet';
|
||||
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel', felsColor: FELS });
|
||||
S.cladding = 'verticaal';
|
||||
S.trasraam.enabled = false;
|
||||
Object.assign(S.roof, { fasciaColor: 'wit', overhang: 300, fasciaHeight: 300 });
|
||||
S.roof.posts = PostModel.normalizeSettings({ enabled: true, sideWall: true, sideColor: 'wit', maxSpan: 6000 });
|
||||
S.lighting.mode = 'gevel';
|
||||
S.lighting.direction = 'both';
|
||||
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
||||
if (S.elektra) S.elektra.plateColor = 'antraciet';
|
||||
|
||||
const A = createUnitEntry('A', null, { width: 3890, depth: 3050, height: HA, windowOn: [] });
|
||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: V }, { width: 11500, depth: DN, height: HM, windowOn: [] });
|
||||
const C = createUnitEntry('C', { to: 'B', side: 'achter', align: 'start', offset: 0 }, { width: 11500, depth: DS, height: HS, windowOn: [] });
|
||||
st.units.push(A, B, C);
|
||||
UnitModel.applyRoofForm(B.unit, { type: 'lessenaar', dir: 'voor', low: HM, high: HN });
|
||||
UnitModel.applyRoofForm(C.unit, { type: 'lessenaar', dir: 'voor', low: HS, high: HM });
|
||||
[B, C].forEach(u => { u.roof.covering = 'fels_' + FELS; u.roof.extend = { f: 0, r: 0, b: 0, l: V }; });
|
||||
A.roof.fascia = false;
|
||||
C.facade.walls.achter = { type: 'fels', fels: FELS };
|
||||
[A, B, C].forEach(u => ['voor', 'achter', 'links', 'rechts'].forEach(side => { u.lighting.walls[side] = 'geen'; }));
|
||||
B.lighting.walls.links = 'openingen';
|
||||
|
||||
const { K, binnen, ops } = modelKit(st, HS, 90);
|
||||
// Noord 'right' en zuid 'left' = vanaf de westhoek van de unit; west 'left' = vanaf het noordeind van de wand.
|
||||
ops(A.unit.walls.voor, [['raam', 500, 700, 1400, 'right', 475, K('raam')], ['raam', 800, 1100, 1000, 'right', 2175, K('raam')]]);
|
||||
ops(A.unit.walls.achter, [['voordeur', 1100, 2100, 0, 'left', 230, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
|
||||
ops(B.unit.walls.voor, [
|
||||
['raam', 600, 2100, 0, 'right', 3390, K('raam')],
|
||||
['deur', 900, 2100, 0, 'right', 3990, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
|
||||
['deur', 900, 2100, 0, 'right', 4890, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
|
||||
['raam', 600, 2100, 0, 'right', 5790, K('raam')],
|
||||
['raam', 500, 1400, 700, 'right', 7525, K('raam')],
|
||||
['raam', 500, 1400, 700, 'right', 9975, K('raam')]]);
|
||||
ops(B.unit.walls.links, [['raam', 1800, 2600, 0, 'left', 1480, K('raam')]]);
|
||||
ops(C.unit.walls.achter, [['raam', 1500, 500, 1600, 'left', 2050, K('raam')], ['raam', 1500, 500, 1600, 'left', 7440, K('raam')]]);
|
||||
|
||||
ensureSeamWalls(st, 'A');
|
||||
const seam = st.interiorWalls.find(w => w.seam);
|
||||
if (seam) {
|
||||
// Open doorgang entree → woonkamer (x 1855–3045), gemeten vanaf de binnenkant van de westgevel van B.
|
||||
const tB = WallModel.thicknessOf(WallModel.configFromState(B.unit.walls.links));
|
||||
seam.wall.openings = [{ id: 1, type: 'deur', w: 1190, h: 2300, y: 0, anchor: 'left', offset: Math.round(1855 - V - tB),
|
||||
fill: Object.assign(KozijnModel.defaultFill('deur'), { type: 'geen' }) }];
|
||||
}
|
||||
|
||||
const DH = 2115;
|
||||
// Aanbouw: wanden tot het platte dak. Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
|
||||
const ann = modelKit(st, HA, 90);
|
||||
ann.iw(1335, 190, 1335, 1690); // toilet | berging
|
||||
ann.iw(190, 1735, 3700, 1735, [ // toilet, berging | entree
|
||||
['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'buiten' })],
|
||||
['deur', 930, DH, 'left', 1260, binnen({ hinge: 'links', swing: 'buiten' })]]);
|
||||
// Hoofdvolume: wanden onder het laagste punt van het dak, slaapkamer en badkamer met een vlak plafond.
|
||||
const main = modelKit(st, HS - 40, 90);
|
||||
main.iw(8715, 3240, 8715, 7660, [['deur', 930, DH, 'left', 1890, binnen({ hinge: 'rechts', swing: 'buiten' })]]); // woonkamer | slaapkamer, badkamer
|
||||
main.iw(8760, 6215, 12710, 6215, [['deur', 930, DH, 'left', 1815, // slaapkamer | badkamer
|
||||
Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'binnen', dir: 'links' })]]);
|
||||
|
||||
const { room, fx } = main;
|
||||
const plafond = { ceiling: HS - 40 };
|
||||
room('A', 700, 1000, 'toilet');
|
||||
room('A', 2500, 1000, 'berging', 'Berging');
|
||||
room('A', 1800, 2400, 'hal', 'Entree');
|
||||
room('B', 3000, 1500, 'woonkeuken', 'Woonkamer/keuken');
|
||||
room('B', 9000, 1500, 'slaapkamer', 'Slaapkamer', plafond);
|
||||
room('C', 9500, 600, 'badkamer', '', plafond);
|
||||
|
||||
const xB = (x) => x - V, zB = (z) => z - 3050, zC = (z) => z - 3050 - DN;
|
||||
fx('toilet', 'A', 735, 640, 0, { w: 1100 });
|
||||
fx('boiler', 'A', 3400, 1430, 90);
|
||||
fx('keuken', 'C', xB(3390), zC(7360), 180, { w: 3600 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.29, kookplaat: true, kookplaatPlek: 0.71 });
|
||||
fx('eettafel', 'B', xB(2490), zB(5160), 0, { w: 1000, d: 1000 }, { vorm: 'rond', stoelen: 4 });
|
||||
fx('bank', 'B', xB(5200), zB(4360), 270, { w: 1600, d: 800 }, { chaise: 'geen' });
|
||||
fx('salontafel', 'B', xB(6740), zB(4200), 0, { w: 800, d: 800 }, { vorm: 'rond' });
|
||||
fx('fauteuil', 'B', xB(6940), zB(5600), 90);
|
||||
fx('bed', 'B', xB(10390), zB(4320), 0, { w: 1600, d: 2100 }, { nachtkastjes: false });
|
||||
fx('kledingkast', 'B', xB(12420), zB(4705), 90, { w: 2900, anchor: 'oost' });
|
||||
fx('toilet', 'C', xB(9420), zC(7300), 180, { w: 1000 });
|
||||
fx('wastafel', 'C', xB(10890), zC(7420), 180, { w: 800 });
|
||||
fx('douche', 'C', xB(12260), zC(6960), 180, { w: 900, d: 1400 }, { glas: 'rechts', glasLengte: 800 });
|
||||
|
||||
computeParts(st, { skip3D: true });
|
||||
return st;
|
||||
}
|
||||
|
||||
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
|
||||
const MODELS = {
|
||||
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
|
||||
@@ -661,7 +767,8 @@ window.HouseModel = (() => {
|
||||
ijssel: { label: 'Woning IJssel', icon: 'houses', create: createIJssel },
|
||||
waal: { label: 'Woning Waal', icon: 'house-add', create: createWaal },
|
||||
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
|
||||
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn }
|
||||
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn },
|
||||
reest: { label: 'Woning Reest', icon: 'house-up', create: createReest }
|
||||
};
|
||||
|
||||
function createDefaultState(template) {
|
||||
@@ -1231,8 +1338,9 @@ window.HouseModel = (() => {
|
||||
function computeParts(state, opts) {
|
||||
const o = opts || {};
|
||||
const build = !o.skip3D;
|
||||
const stage = BuildStages.clamp(o.stage);
|
||||
const shows = (key) => BuildStages.shows(stage, key);
|
||||
const layers = ViewLayers.normalize(o.layers);
|
||||
const shows = (key) => layers[key];
|
||||
const hideFrame = (group, mats) => { if (group && mats && !shows('regelwerk')) ViewLayers.hideByMaterial(group, [mats.woodMaterial, mats.steelMaterial]); };
|
||||
applyGlobalSettings(state);
|
||||
const rects = unitRects(state);
|
||||
const rectById = {};
|
||||
@@ -1252,11 +1360,11 @@ window.HouseModel = (() => {
|
||||
});
|
||||
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
|
||||
|
||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop) {
|
||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels) {
|
||||
if (!build) return { group: null, stats: null, specs: null };
|
||||
const built = WallModel.build3D(config, frame, {
|
||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop,
|
||||
painted: state.settings.interiorPaint && shows('afwerking'), stage, rabatColor: state.settings.facade.rabatColor
|
||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, fels,
|
||||
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) };
|
||||
}
|
||||
@@ -1275,11 +1383,12 @@ window.HouseModel = (() => {
|
||||
const cladding = m.cladding || 'horizontaal';
|
||||
const stone = buitenMateriaal === 'cementplaat' ? (m.stone || null) : null;
|
||||
const stuc = buitenMateriaal === 'cementplaat' && !stone ? (m.stuc || null) : null;
|
||||
const fels = buitenMateriaal === 'vochtdoek' ? (m.fels || null) : null;
|
||||
const trasraam = buitenMateriaal === 'vochtdoek' || stone || stuc ? (m.trasraam || null) : null;
|
||||
return Object.assign(p, {
|
||||
element: `${u.id}-W${wl.nr}${p.suffix}`,
|
||||
label: `Unit ${u.id} ${wl.label.toLowerCase()}` + (base ? ` opbouw (vanaf ${base} mm)` : p.suffix ? ` (${p.suffix})` : ''),
|
||||
buitenMateriaal, cladding, trasraam: base ? null : trasraam, stone, stuc, facadeOwn: own,
|
||||
buitenMateriaal, cladding, trasraam: base ? null : trasraam, stone, stuc, fels, facadeOwn: own,
|
||||
place: { cx: rect.cx + c.x, cz: rect.cz + c.z, rotY: wl.rotY, width: p.config.wallWidth, thickness: wl.thickness, normal: wl.normal, floorTop: layout.floorTop + base, baseY: base }
|
||||
}); // 3D volgt na assignFacades (latwerk/doek per gevel)
|
||||
});
|
||||
@@ -1301,9 +1410,10 @@ window.HouseModel = (() => {
|
||||
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
|
||||
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
|
||||
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
|
||||
}) : null;
|
||||
hideFrame(floorGroup, o.floorMaterials);
|
||||
|
||||
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
|
||||
|
||||
@@ -1337,9 +1447,10 @@ window.HouseModel = (() => {
|
||||
const roofGroup = build ? RoofModel.buildUnit3D(ur, {
|
||||
materials: o.roofMaterials,
|
||||
osbVisible: shows('beplating'),
|
||||
dakleerVisible: shows('gevel'),
|
||||
dakleerVisible: shows('dakbedekking'),
|
||||
ceilingMaterial: state.settings.interiorPaint && shows('afwerking') && o.roofMaterials ? o.roofMaterials.ceilingMaterial : null
|
||||
}) : null;
|
||||
hideFrame(roofGroup, o.roofMaterials);
|
||||
const ext = coverExt[u.id];
|
||||
if (roofGroup && ext && shows('afwerking')) {
|
||||
const fz = rect.z0 - ext.f, bz = rect.z1 + ext.b;
|
||||
@@ -1625,10 +1736,11 @@ window.HouseModel = (() => {
|
||||
});
|
||||
}));
|
||||
});
|
||||
const facadeDepth = (a, b, nrm) => {
|
||||
// Gevelstuk onder een dakrand (grootste overlap langs de rand); dot < 0 = gevel van een hoger deel ertegenover.
|
||||
const edgePiece = (a, b, nrm) => {
|
||||
const horiz = Math.abs(nrm.z) > 0.5;
|
||||
const line = horiz ? a.z : a.x, s0 = Math.min(horiz ? a.x : a.z, horiz ? b.x : b.z), s1 = Math.max(horiz ? a.x : a.z, horiz ? b.x : b.z);
|
||||
let best = 0, bestOv = 1;
|
||||
let best = null, bestOv = 1;
|
||||
extPieces.forEach(p => {
|
||||
const pn = { x: p.place.normal.x, z: p.place.normal.y };
|
||||
const dot = pn.x * nrm.x + pn.z * nrm.z;
|
||||
@@ -1638,15 +1750,20 @@ window.HouseModel = (() => {
|
||||
const ov = Math.min(s1, Math.max(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z)) - Math.max(s0, Math.min(horiz ? q0.x : q0.z, horiz ? q1.x : q1.z));
|
||||
if (ov <= bestOv) return;
|
||||
bestOv = ov;
|
||||
best = Math.sign(dot) * WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
best = { p, dot };
|
||||
});
|
||||
return best;
|
||||
};
|
||||
const facadeDepth = (a, b, nrm) => {
|
||||
const e = edgePiece(a, b, nrm);
|
||||
return e ? Math.sign(e.dot) * WallModel.claddingOffset(e.p.buitenMateriaal, e.p.stone, e.p.stuc) : 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,
|
||||
units.map(u => ({
|
||||
unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false,
|
||||
topAt: u.roof.topAt, ridges: u.roof.ridges
|
||||
})), facadeDepth) : null;
|
||||
covering: u.roof.covering, topAt: u.roof.topAt, ridges: u.roof.ridges
|
||||
})), facadeDepth, facadeFels) : null;
|
||||
const pointInPoly = (q, poly) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
@@ -1662,13 +1779,13 @@ window.HouseModel = (() => {
|
||||
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
|
||||
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
|
||||
if (!sg) return;
|
||||
const h = sg.fascia ? roofEdge.fasciaHeight : roofEdge.trimHeight + roofEdge.edgeAbove;
|
||||
p.battenAbove = (p.skinAbove || 0) + roofEdge.edgeAbove - h;
|
||||
const above = sg.fels ? RoofModel.FELS_EDGE.aboveMM : roofEdge.edgeAbove;
|
||||
p.battenAbove = (p.skinAbove || 0) + above - RoofModel.edgeHeight(roofEdge, sg);
|
||||
});
|
||||
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
||||
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
||||
extPieces.concat(interior).forEach(p => {
|
||||
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop);
|
||||
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop, p.fels);
|
||||
if (built.group && p.shiftZ) {
|
||||
const outer = new THREE.Group();
|
||||
built.group.position.z = p.shiftZ * 0.001;
|
||||
@@ -1683,18 +1800,32 @@ window.HouseModel = (() => {
|
||||
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));
|
||||
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;
|
||||
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
|
||||
LightingModel.roomSpots(state.settings.lighting, lighting.indoor, rooms, units);
|
||||
RoomModel.lowerSpots(rooms, lighting.indoor);
|
||||
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 elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
|
||||
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
|
||||
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, layers, o.finishedOnly ? false : o.elektraRough) : null;
|
||||
if (elektraGroup) elektraGroup.visible = shows('elektra');
|
||||
|
||||
const posts = PostModel.compute(state.settings.roof.posts, units, groundY);
|
||||
// Zijwand van een overkapping in het vlak van een gevel: die bekleding loopt erover door.
|
||||
const postOutside = (w, out) => {
|
||||
const line = out.x > 0 ? w.x1 : out.x < 0 ? w.x0 : out.z > 0 ? w.z1 : w.z0;
|
||||
const p = extPieces.find(pc => {
|
||||
const n = { x: pc.place.normal.x, z: pc.place.normal.y }, q = UnitModel.localToWorld(pc.place, 0, pc.place.thickness / 2);
|
||||
return n.x * out.x + n.z * out.z > 0.5 && Math.abs((out.x ? q.x : q.z) - line) < 2 && !pc.place.baseY;
|
||||
});
|
||||
if (!p || (p.buitenMateriaal === 'cementplaat' && !p.stone && !p.stuc)) return null;
|
||||
const kind = p.fels ? 'fels' : p.stone ? 'steen' : p.stuc ? 'stuc' : 'rabat';
|
||||
return {
|
||||
kind, depth: WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc),
|
||||
finish: { fels: p.fels, stone: p.stone, stuc: p.stuc, rabatColor: kind === 'rabat' ? state.settings.facade.rabatColor : null, cladding: p.cladding }
|
||||
};
|
||||
};
|
||||
const posts = PostModel.compute(state.settings.roof.posts, units, groundY, postOutside);
|
||||
const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null;
|
||||
const chimneysGroup = build && chimneys.length ? ChimneyModel.build3D(chimneys, {
|
||||
materials: o.chimneyMaterials, wallMaterial: o.wallMaterials && o.wallMaterials.spaanplaatMaterial
|
||||
@@ -1708,12 +1839,23 @@ window.HouseModel = (() => {
|
||||
}) : 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 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;
|
||||
}
|
||||
|
||||
// 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten.
|
||||
@@ -1937,6 +2079,10 @@ window.HouseModel = (() => {
|
||||
{
|
||||
naam: `Rabat ${WallModel.CLADDING_THICK_MM} mm (gevelbekleding)`, m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.rabatAreaM2 || 0), 0),
|
||||
m3: null, kg: 0 // gewicht volgt
|
||||
},
|
||||
{
|
||||
naam: 'Felsplaten gevel (staande naad)', m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.felsAreaM2 || 0), 0),
|
||||
m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + (p.stats.felsAreaM2 || 0), 0) * RoofModel.FELS.kgM2
|
||||
}
|
||||
],
|
||||
weights,
|
||||
@@ -1946,9 +2092,10 @@ window.HouseModel = (() => {
|
||||
return { unit: u.id, name: rb.profile.name, kgM: rb.profile.kgM, lengthM: rb.length / 1000, kg: rb.steelKg, fillM3: rb.fillM3, fillKg: rb.fillKg,
|
||||
util: rb.util, defl: rb.defl, deflMax: rb.deflMax, ok: rb.ok, below: rb.below, loadKNm: rb.loadKNm };
|
||||
}),
|
||||
posts: parts.posts.count ? {
|
||||
posts: parts.posts.count || parts.posts.walls.length ? {
|
||||
count: parts.posts.count, size: parts.posts.size, wood: (PostModel.WOODS[parts.posts.wood] || {}).label || '',
|
||||
lengthM: parts.posts.lengthMM / 1000, m3: parts.posts.m3, kg: parts.posts.kg
|
||||
lengthM: parts.posts.lengthMM / 1000, m3: parts.posts.m3, kg: parts.posts.kg,
|
||||
walls: parts.posts.walls.length, wallM2: parts.posts.wallM2, outsideM2: parts.posts.outsideM2
|
||||
} : null,
|
||||
roofEdge: parts.roofEdge ? {
|
||||
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
|
||||
@@ -2017,6 +2164,7 @@ window.HouseModel = (() => {
|
||||
if (!WallModel.isStoneFinish(state.settings.facade.stone)) state.settings.facade.stone = def.facade.stone;
|
||||
if (!WallModel.RABAT_COLORS[state.settings.facade.rabatColor]) state.settings.facade.rabatColor = def.facade.rabatColor;
|
||||
if (!WallModel.isStucColor(state.settings.facade.stucColor)) state.settings.facade.stucColor = def.facade.stucColor;
|
||||
if (!WallModel.isFelsColor(state.settings.facade.felsColor)) state.settings.facade.felsColor = def.facade.felsColor;
|
||||
state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {});
|
||||
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
||||
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
|
||||
|
||||
+12
-7
@@ -139,6 +139,8 @@ window.HousePdf = (() => {
|
||||
doc.setLineWidth(0.2);
|
||||
doc.setFillColor(160, 124, 84);
|
||||
parts.posts.posts.forEach(pp => doc.rect(px(pp.x - pp.size / 2), py(pp.z - pp.size / 2), pp.size * scale, pp.size * scale, 'FD'));
|
||||
doc.setFillColor(235, 235, 232);
|
||||
parts.posts.walls.forEach(w => doc.rect(px(w.x0), py(w.z0), (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale, 'FD'));
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
doc.setFillColor(150, 150, 150);
|
||||
poly(ch.footprint, 'FD');
|
||||
@@ -257,7 +259,8 @@ window.HousePdf = (() => {
|
||||
const addWall = (p, y0) => {
|
||||
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
||||
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : WallModel.rabatHex(state.settings.facade.rabatColor);
|
||||
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : p.fels ? WallModel.felsHex(p.fels)
|
||||
: WallModel.rabatHex(state.settings.facade.rabatColor);
|
||||
faces.push({ kind: 'wand', pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
||||
const tr = p.trasraam;
|
||||
if (tr && tr.top > y0) {
|
||||
@@ -312,10 +315,9 @@ window.HousePdf = (() => {
|
||||
|
||||
const edge = parts.roofEdge;
|
||||
if (edge) {
|
||||
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
|
||||
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, z: a.z + (b.z - a.z) * t });
|
||||
edge.segments.forEach(sg => {
|
||||
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
||||
const h = RoofModel.edgeHeight(edge, sg);
|
||||
const n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100));
|
||||
const top = [], bottom = [];
|
||||
for (let k = 0; k <= n; k++) {
|
||||
@@ -324,7 +326,7 @@ window.HousePdf = (() => {
|
||||
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
|
||||
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
|
||||
}
|
||||
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
||||
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: RoofModel.edgeColor(edge, sg), bias: 3 });
|
||||
});
|
||||
}
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
@@ -351,14 +353,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)));
|
||||
}));
|
||||
facades.forEach(fc => {
|
||||
const key = fc.type === 'steen' ? 'steen:' + fc.stone : fc.type === 'stuc' ? 'stuc:' + fc.stuc : 'rabat';
|
||||
const key = fc.type === 'steen' ? 'steen:' + fc.stone : fc.type === 'stuc' ? 'stuc:' + fc.stuc : fc.type === 'fels' ? 'fels:' + fc.fels : 'rabat';
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
items.push(fc.type === 'steen'
|
||||
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
||||
: fc.type === 'stuc'
|
||||
? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) }
|
||||
: { 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) {
|
||||
const tf = st.trasraam.finish;
|
||||
@@ -762,7 +766,8 @@ window.HousePdf = (() => {
|
||||
|
||||
function generate(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);
|
||||
|
||||
+445
-325
File diff suppressed because it is too large
Load Diff
@@ -324,32 +324,41 @@ window.LightingModel = (() => {
|
||||
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 DAY_OUTDOOR_FACTOR = 0.35;
|
||||
function buildLights3D(lamps, spots, spotY, indoor, o) {
|
||||
const toM = 0.001;
|
||||
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
|
||||
const group = new THREE.Group();
|
||||
const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
|
||||
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay);
|
||||
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);
|
||||
// Lichtbronnen in meters: positie, richting (genormaliseerd), halve kegelhoek; gedeeld door 3D en render-export.
|
||||
function lightSources(lamps, spots, spotY, indoor) {
|
||||
const toM = 0.001, out = [];
|
||||
const add = (kind, x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
|
||||
const len = Math.hypot(tx, ty, tz) || 1;
|
||||
out.push({ kind, x, y, z, dir: { x: tx / len, y: ty / len, z: tz / len }, intensity, angle, distance, decay, penumbra, ref });
|
||||
};
|
||||
const tilt = Math.tan(OUTDOOR.angle);
|
||||
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 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 !== '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 !== '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('wandlamp', x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
||||
});
|
||||
(spots || []).forEach(p => {
|
||||
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 => {
|
||||
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));
|
||||
return group;
|
||||
@@ -450,5 +459,5 @@ window.LightingModel = (() => {
|
||||
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 de dakrand. Hoekpalen staan met hun buitenkant gelijk met de dakhoek; een eind dat op een gevel
|
||||
// uitkomt rust op die wand. Daartussen zo weinig palen dat de h.o.h.-afstand onder maxSpan blijft.
|
||||
// sideWall: een vrije hoek krijgt geen paal maar een dichte zijwand (gestuukt) van de dakhoek terug tot de gevel,
|
||||
// met de buitenkant gelijk met de zijkant van het dak. Ligt die buitenkant in het vlak van een gevel, dan loopt de
|
||||
// bekleding van die gevel erover door (outsideOf, bijv. felsplaten) en staat de wand gelijk met de bekleding.
|
||||
|
||||
window.PostModel = (() => {
|
||||
const EPS = 0.5;
|
||||
@@ -15,12 +18,16 @@ window.PostModel = (() => {
|
||||
eiken: { label: 'Eiken', hex: 0xa07c54, kgM3: 750 },
|
||||
douglas: { label: 'Douglas', hex: 0xb98a5e, kgM3: 530 }
|
||||
};
|
||||
const DEFAULT_SETTINGS = { enabled: true, size: 200, wood: 'eiken', maxSpan: 3600, minExt: 1000 };
|
||||
const DEFAULT_SETTINGS = { enabled: true, size: 200, wood: 'eiken', maxSpan: 3600, minExt: 1000, sideWall: false, sideColor: 'wit' };
|
||||
const SIDE_MM = 200; // dikte zijwand (regelwerk, plaat en stuc aan beide kanten)
|
||||
const SIDE_TOP_BELOW_MM = 40; // doorlopende gevelbekleding: tot zo ver onder de bovenkant van het dak
|
||||
|
||||
function normalizeSettings(settings) {
|
||||
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
||||
s.enabled = s.enabled !== false;
|
||||
s.sideWall = !!s.sideWall;
|
||||
if (!WOODS[s.wood]) s.wood = DEFAULT_SETTINGS.wood;
|
||||
if (!WallModel.isStucColor(s.sideColor)) s.sideColor = DEFAULT_SETTINGS.sideColor;
|
||||
const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def);
|
||||
s.size = num(s.size, DEFAULT_SETTINGS.size, 100, 400);
|
||||
s.maxSpan = num(s.maxSpan, DEFAULT_SETTINGS.maxSpan, 1000, 8000);
|
||||
@@ -29,9 +36,10 @@ window.PostModel = (() => {
|
||||
}
|
||||
|
||||
// units: [{ rect (vloer), roof: { rect, ceilingAt } }] in wereld-mm; groundY = maaiveld (absoluut).
|
||||
function compute(settings, units, groundY) {
|
||||
// outsideOf(wall, out): { finish, kind, depth } van de gevel in het vlak van de buitenkant, of null.
|
||||
function compute(settings, units, groundY, outsideOf) {
|
||||
const s = normalizeSettings(settings);
|
||||
const out = { posts: [], count: 0, size: s.size, wood: s.wood, lengthMM: 0, m3: 0, kg: 0 };
|
||||
const out = { posts: [], count: 0, size: s.size, wood: s.wood, lengthMM: 0, m3: 0, kg: 0, walls: [], wallM2: 0, sideColor: s.sideColor };
|
||||
if (!s.enabled || !units.length) return out;
|
||||
const floors = units.map(u => u.rect);
|
||||
const onFloor = (x, z, m) => floors.some(f => x > f.x0 - m && x < f.x1 + m && z > f.z0 - m && z < f.z1 + m);
|
||||
@@ -79,11 +87,56 @@ window.PostModel = (() => {
|
||||
|
||||
const half = s.size / 2;
|
||||
const pts = [];
|
||||
const roofAt = (x, z) => units.map(u => u.roof).find(rf => x > rf.rect.x0 - EPS && x < rf.rect.x1 + EPS && z > rf.rect.z0 - EPS && z < rf.rect.z1 + EPS);
|
||||
const endFreeOf = (r, t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); };
|
||||
// Zijwand op het vrije eind t van run r (dir +1: wand van t de run in, -1: andersom), terug tot de gevel waar de
|
||||
// run naar kijkt. Delen twee runs een vrije hoek (veranda in een binnenhoek), dan is de run met de kortste weg
|
||||
// naar de gevel de voorkant en komt de wand langs de zijkant; het andere eind rust op die wand.
|
||||
const sideCands = [];
|
||||
if (s.sideWall) merged.forEach(r => [[r.a, 1], [r.b, -1]].forEach(([t, dir]) => {
|
||||
if (!endFreeOf(r, t)) return;
|
||||
const lo = Math.min(t, t + dir * SIDE_MM), hi = Math.max(t, t + dir * SIDE_MM);
|
||||
let reach = Infinity;
|
||||
floors.forEach(f => {
|
||||
const [s0, s1, c0, c1] = r.axis === 'z' ? [f.z0, f.z1, f.x0, f.x1] : [f.x0, f.x1, f.z0, f.z1];
|
||||
if (s0 > hi - EPS || s1 < lo + EPS) return;
|
||||
const d = r.n > 0 ? r.at - c1 : c0 - r.at;
|
||||
if (d > -EPS && d < reach) reach = d;
|
||||
});
|
||||
if (!isFinite(reach) || reach < s.size) return;
|
||||
const a = Math.min(r.at, r.at - r.n * reach), b = Math.max(r.at, r.at - r.n * reach);
|
||||
const corner = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at };
|
||||
const outward = r.axis === 'z' ? { x: 0, z: -dir } : { x: -dir, z: 0 };
|
||||
sideCands.push({ r, t, corner, reach, out: outward, w: r.axis === 'z' ? { x0: a, x1: b, z0: lo, z1: hi } : { x0: lo, x1: hi, z0: a, z1: b } });
|
||||
}));
|
||||
const sameCorner = (p, q) => Math.abs(p.x - q.x) < EPS && Math.abs(p.z - q.z) < EPS;
|
||||
const sideAt = (r, t) => { const c = sideCands.find(d => d.r === r && d.t === t); return !!c && sideCands.some(d => d.built && sameCorner(d.corner, c.corner)); };
|
||||
sideCands.forEach(c => {
|
||||
if (sideCands.some(d => d !== c && sameCorner(d.corner, c.corner) && (d.reach < c.reach - EPS || (Math.abs(d.reach - c.reach) < EPS && sideCands.indexOf(d) < sideCands.indexOf(c))))) return;
|
||||
const w = c.w;
|
||||
const tops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]]
|
||||
.map(([x, z]) => { const rf = roofAt(x, z); return rf ? rf.ceilingAt(x, z) : null; }).filter(v => v !== null);
|
||||
if (!tops.length || !(Math.max(...tops) > groundY)) return;
|
||||
const wall = Object.assign({}, w, { y0: groundY, y1: Math.round(Math.max(...tops)), color: s.sideColor, out: c.out, outside: null });
|
||||
const o = outsideOf ? outsideOf(wall, c.out) : null;
|
||||
if (o) {
|
||||
if (c.out.x > 0) wall.x1 += o.depth; else if (c.out.x < 0) wall.x0 -= o.depth;
|
||||
if (c.out.z > 0) wall.z1 += o.depth; else if (c.out.z < 0) wall.z0 -= o.depth;
|
||||
Object.assign(wall, { outside: o.finish, outsideKind: o.kind });
|
||||
// Zoals de gevel: de bekleding loopt tot onder de dakrand door (over de kant van het dakpakket).
|
||||
const roofTops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]]
|
||||
.map(([x, z]) => { const rf = roofAt(x, z); return rf && rf.topAt ? rf.topAt(x, z) : null; }).filter(v => v !== null);
|
||||
if (roofTops.length) wall.y1 = Math.max(wall.y1, Math.round(Math.min(...roofTops) - SIDE_TOP_BELOW_MM));
|
||||
}
|
||||
out.walls.push(wall);
|
||||
c.built = true;
|
||||
});
|
||||
merged.forEach(r => {
|
||||
const pos = (t) => (r.axis === 'z' ? { x: r.at - r.n * half, z: t } : { x: t, z: r.at - r.n * half });
|
||||
// Een eind dat op of tegen een vloer uitkomt rust op die gevel.
|
||||
const endFree = (t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); };
|
||||
const fa = endFree(r.a), fb = endFree(r.b);
|
||||
// Een dichte zijwand neemt de plaats van de hoekpaal in; het eind rust dan op die wand.
|
||||
const fa = endFree(r.a) && !sideAt(r, r.a), fb = endFree(r.b) && !sideAt(r, r.b);
|
||||
const pa = fa ? r.a + half : r.a, pb = fb ? r.b - half : r.b;
|
||||
if (pb - pa < EPS) return;
|
||||
const n = Math.max(1, Math.ceil((pb - pa) / s.maxSpan - 1e-9));
|
||||
@@ -94,7 +147,8 @@ window.PostModel = (() => {
|
||||
});
|
||||
pts.forEach(p => {
|
||||
if (out.posts.some(q => Math.abs(q.x - p.x) < s.size && Math.abs(q.z - p.z) < s.size)) return;
|
||||
const roof = units.map(u => u.roof).find(rf => p.x > rf.rect.x0 - EPS && p.x < rf.rect.x1 + EPS && p.z > rf.rect.z0 - EPS && p.z < rf.rect.z1 + EPS);
|
||||
if (out.walls.some(w => p.x > w.x0 - half && p.x < w.x1 + half && p.z > w.z0 - half && p.z < w.z1 + half)) return;
|
||||
const roof = roofAt(p.x, p.z);
|
||||
if (!roof) return;
|
||||
// Hoogste hoek: bij een hellend dak verdwijnt de paal aan de lage kant in het dakpakket, zonder kier.
|
||||
const top = Math.max(...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([a, b]) => roof.ceilingAt(p.x + a * (half - 1), p.z + b * (half - 1))));
|
||||
@@ -105,6 +159,10 @@ window.PostModel = (() => {
|
||||
out.lengthMM = out.posts.reduce((t, p) => t + p.y1 - p.y0 - SHOE_MM, 0);
|
||||
out.m3 = out.lengthMM * s.size * s.size / 1e9;
|
||||
out.kg = out.m3 * WOODS[s.wood].kgM3;
|
||||
const wallArea = (w) => Math.max(w.x1 - w.x0, w.z1 - w.z0) * (w.y1 - w.y0) / 1e6;
|
||||
out.wallM2 = out.walls.reduce((t, w) => t + wallArea(w), 0);
|
||||
out.outsideM2 = {};
|
||||
out.walls.filter(w => w.outsideKind).forEach(w => { out.outsideM2[w.outsideKind] = (out.outsideM2[w.outsideKind] || 0) + wallArea(w); });
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -117,7 +175,7 @@ window.PostModel = (() => {
|
||||
|
||||
function build3D(data, materials) {
|
||||
const toM = 0.001, group = new THREE.Group();
|
||||
if (!data || !data.posts.length) return group;
|
||||
if (!data || !(data.posts.length || (data.walls || []).length)) return group;
|
||||
const m = materials || createMaterials();
|
||||
const woodMat = m.wood[data.wood] || m.wood.eiken;
|
||||
data.posts.forEach(p => {
|
||||
@@ -132,8 +190,28 @@ window.PostModel = (() => {
|
||||
stem.position.set(p.x * toM, (p.y0 + 10 + (SHOE_MM - 10) / 2) * toM, p.z * toM);
|
||||
group.add(stem);
|
||||
});
|
||||
(data.walls || []).forEach(w => {
|
||||
const c = [(w.x0 + w.x1) / 2 * toM, (w.y0 + w.y1) / 2 * toM, (w.z0 + w.z1) / 2 * toM];
|
||||
const geo = new THREE.BoxGeometry((w.x1 - w.x0) * toM, (w.y1 - w.y0) * toM, (w.z1 - w.z0) * toM);
|
||||
// UV in wereldmeters, zoals de gevels: texturen op dezelfde schaal.
|
||||
const pos = geo.attributes.position, nrm = geo.attributes.normal, uv = geo.attributes.uv;
|
||||
for (let k = 0; k < pos.count; k++) {
|
||||
const x = pos.getX(k) + c[0], y = pos.getY(k) + c[1], z = pos.getZ(k) + c[2];
|
||||
if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y);
|
||||
}
|
||||
uv.needsUpdate = true;
|
||||
// Volgorde van de vlakken in BoxGeometry: +x, -x, +y, -y, +z, -z.
|
||||
const stuc = WallModel.stucMaterial(w.color), mats = [stuc, stuc, stuc, stuc, stuc, stuc];
|
||||
const outMat = w.outside && w.out ? WallModel.claddingMaterial(w.outside) : null;
|
||||
if (outMat) mats[w.out.x > 0 ? 0 : w.out.x < 0 ? 1 : w.out.z > 0 ? 4 : 5] = outMat;
|
||||
const box = new THREE.Mesh(geo, mats);
|
||||
box.position.set(...c);
|
||||
box.castShadow = true;
|
||||
box.receiveShadow = true;
|
||||
group.add(box);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
return { WOODS, SHOE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
|
||||
return { WOODS, SHOE_MM, SIDE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
|
||||
})();
|
||||
|
||||
+49
-9
@@ -12,6 +12,9 @@ window.RoofModel = (() => {
|
||||
const DENSITY_DAKLEER_KGM2 = 1.4;
|
||||
const TRIM_THICK_MM = 25;
|
||||
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 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: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer hier toevoegen).
|
||||
const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } };
|
||||
const FELS = { thickMM: 25, kgM2: 6.5, seamMM: 500, colors: { zink: ['Felsplaat zink', '#8e9497'], antraciet: ['Felsplaat antraciet', '#3d4146'], zwart: ['Felsplaat zwart', '#1f2022'] } };
|
||||
// Kleuren en naadafstand gedeeld met felsplaten op de gevel (WallModel.FELS).
|
||||
const FELS = { thickMM: 25, kgM2: 6.5, seamMM: WallModel.FELS.SEAM_MM,
|
||||
colors: Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, ['Felsplaat ' + c.label.toLowerCase(), c.hex]])) };
|
||||
const COVERINGS = Object.assign({
|
||||
dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 }
|
||||
}, ...RIET.nrs.map(nr => ({
|
||||
@@ -923,7 +928,8 @@ window.RoofModel = (() => {
|
||||
}
|
||||
|
||||
// facadeDepth negatief = gevel van een hoger deel die naar dit dak kijkt: de rand stopt tegen die gevel.
|
||||
function computeEdge(settings, roofs, facadeDepth) {
|
||||
// facadeFels(a, b, normal): de gevel onder deze rand heeft felsplaten (met een felsdak: geen boeideel).
|
||||
function computeEdge(settings, roofs, facadeDepth, facadeFels) {
|
||||
const s = normalizeSettings(settings);
|
||||
const rects = roofs.map(r => r.rect);
|
||||
if (!roofs.length) return null;
|
||||
@@ -967,15 +973,18 @@ window.RoofModel = (() => {
|
||||
const j = (i + 1) % n, b = base[j];
|
||||
const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 };
|
||||
const owner = members.find(r => onRectEdge(r.rect, mid)) || members[0];
|
||||
const fascia = owner.fascia !== false;
|
||||
const normal = rectilinearNormal(a, b);
|
||||
const fels = coverKind(owner.covering) === 'fels' && !!facadeFels && !!facadeFels(a, b, normal);
|
||||
const fascia = owner.fascia !== false && !fels;
|
||||
const off = fascia ? offBoei : offTrim;
|
||||
// Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots.
|
||||
const nb = others.find(r => onRectEdge(r.rect, mid));
|
||||
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
|
||||
const above = fels ? FELS_EDGE.aboveMM : EDGE_ABOVE_MM;
|
||||
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],
|
||||
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
|
||||
};
|
||||
});
|
||||
@@ -995,6 +1004,23 @@ window.RoofModel = (() => {
|
||||
});
|
||||
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);
|
||||
segs.forEach((sg, i) => {
|
||||
if (sg.dropped) return;
|
||||
@@ -1024,7 +1050,9 @@ window.RoofModel = (() => {
|
||||
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor,
|
||||
top, edgeAbove: EDGE_ABOVE_MM,
|
||||
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,
|
||||
spots: []
|
||||
};
|
||||
@@ -1056,6 +1084,17 @@ window.RoofModel = (() => {
|
||||
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) {
|
||||
const o = opts || {};
|
||||
const toM = 0.001;
|
||||
@@ -1075,9 +1114,10 @@ window.RoofModel = (() => {
|
||||
const t = (p) => alongZ ? p.z : p.x;
|
||||
polys = polys.flatMap(pl => [clipPoly(pl, p => g0 - t(p)), clipPoly(pl, p => t(p) - g1)]).filter(pl => pl.length >= 3);
|
||||
});
|
||||
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
||||
const h = edgeHeight(edge, sg);
|
||||
const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial;
|
||||
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;
|
||||
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,
|
||||
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
||||
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
|
||||
computeEdge, buildEdge3D, normalizeSettings
|
||||
FELS_EDGE, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings
|
||||
};
|
||||
})();
|
||||
|
||||
+63
-8
@@ -72,6 +72,50 @@ window.WallModel = (() => {
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Felsplaten (staande naad) op de gevel: zelfde opbouw als verticale rabat (doek, latten, bekleding), alleen het
|
||||
// materiaal verschilt. Kleuren ook voor het dak (RoofModel.FELS). In 3D de naden als textuur over het blok.
|
||||
const FELS = {
|
||||
SEAM_MM: 500, TILE_SEAMS: 6,
|
||||
COLORS: {
|
||||
zink: { label: 'Zink', hex: '#8e9497' },
|
||||
antraciet: { label: 'Antraciet', hex: '#3d4146' },
|
||||
zwart: { label: 'Zwart', hex: '#1f2022' }
|
||||
}
|
||||
};
|
||||
const isFelsColor = (key) => !!FELS.COLORS[key];
|
||||
const felsHex = (key) => (FELS.COLORS[key] || FELS.COLORS.antraciet).hex;
|
||||
const felsMaterials = {};
|
||||
function felsMaterial(key) {
|
||||
const k = isFelsColor(key) ? key : 'antraciet';
|
||||
if (felsMaterials[k]) return felsMaterials[k];
|
||||
let mat;
|
||||
const canvas = typeof document !== 'undefined' && document.createElement('canvas');
|
||||
const ctx = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!ctx) mat = new THREE.MeshStandardMaterial({ color: felsHex(k), roughness: 0.4, metalness: 0.55 });
|
||||
else {
|
||||
const B = 100, N = FELS.TILE_SEAMS; // 5 mm/px
|
||||
canvas.width = B * N;
|
||||
canvas.height = 16;
|
||||
const base = new THREE.Color(felsHex(k));
|
||||
const shade = (f) => '#' + base.clone().multiplyScalar(f).getHexString();
|
||||
const col = (a, b, color) => { ctx.fillStyle = color; ctx.fillRect(a, 0, b - a, canvas.height); };
|
||||
for (let i = 0; i < N; i++) {
|
||||
const a = i * B, f = 0.97 + ((i * 29) % 7) / 100; // per baan net iets anders
|
||||
col(a, a + B, shade(f));
|
||||
col(a + 6, a + 14, shade(f * 1.04)); // lichte zetting naast de naad
|
||||
col(a, a + 2, shade(f * 0.6)); // schaduw van de naad
|
||||
col(a + 2, a + 5, shade(f * 1.35)); // staande naad (licht)
|
||||
}
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(1000 / (FELS.SEAM_MM * N), 1);
|
||||
tex.anisotropy = 8;
|
||||
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.4, metalness: 0.55 });
|
||||
}
|
||||
felsMaterials[k] = mat;
|
||||
return mat;
|
||||
}
|
||||
|
||||
function claddingOffset(buitenMateriaal, stone, stuc) {
|
||||
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
|
||||
if (buitenMateriaal !== 'cementplaat') return 0;
|
||||
@@ -121,6 +165,14 @@ window.WallModel = (() => {
|
||||
return mat;
|
||||
}
|
||||
|
||||
// Materiaal van een gevelbekleding (UV in meters): felsplaten, steen, stuc of rabat; null = kale plaat.
|
||||
function claddingMaterial(f) {
|
||||
if (isFelsColor(f.fels)) return felsMaterial(f.fels);
|
||||
if (isStoneFinish(f.stone)) return stoneMaterial(f.stone);
|
||||
if (isStucColor(f.stuc)) return stucMaterial(f.stuc);
|
||||
return f.rabatColor ? rabatMaterial(f.rabatColor, f.cladding) : null;
|
||||
}
|
||||
|
||||
// Hoogte vanaf het loopvlak (= onderkant van de wand).
|
||||
const TRASRAAM_DEFAULT_MM = 350;
|
||||
function trasraamFor(heightMM, finish) {
|
||||
@@ -751,8 +803,8 @@ window.WallModel = (() => {
|
||||
function build3D(CONFIG, calcData, opts) {
|
||||
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
|
||||
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
|
||||
const stage = BuildStages.clamp(opts.stage);
|
||||
const show = (key) => BuildStages.shows(stage, key);
|
||||
const layers = ViewLayers.normalize(opts.layers);
|
||||
const show = (key) => layers[key];
|
||||
const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating');
|
||||
const buitenVisible = opts.buitenVisible !== false;
|
||||
const cladding = opts.cladding || 'horizontaal';
|
||||
@@ -763,6 +815,7 @@ window.WallModel = (() => {
|
||||
const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0;
|
||||
const painted = !!opts.painted;
|
||||
const stone = buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone) ? opts.stone : null;
|
||||
const felsKey = buitenMateriaal === 'vochtdoek' && isFelsColor(opts.fels) ? opts.fels : null;
|
||||
// spaanplaatBelow: naadwand geheel in één unit; onder het dak van de andere unit is het een binnenwand.
|
||||
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
|
||||
const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel');
|
||||
@@ -792,7 +845,7 @@ window.WallModel = (() => {
|
||||
let spaanplaatBuitenAreaM2 = 0;
|
||||
let spaanplaatBuitenVolumeM3 = 0;
|
||||
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0;
|
||||
let rabatAreaM2 = 0;
|
||||
let rabatAreaM2 = 0, felsAreaM2 = 0;
|
||||
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
|
||||
let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
|
||||
let stucAreaM2 = 0;
|
||||
@@ -1294,6 +1347,7 @@ window.WallModel = (() => {
|
||||
return shape;
|
||||
}) : [];
|
||||
rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
||||
if (felsKey) { felsAreaM2 = rabatAreaM2; rabatAreaM2 = 0; }
|
||||
if (rabatOn && rabatShapes.length) {
|
||||
const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false });
|
||||
const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM;
|
||||
@@ -1303,7 +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);
|
||||
}
|
||||
uv.needsUpdate = true;
|
||||
const mesh = new THREE.Mesh(geo, rabatMaterial(opts.rabatColor, cladding));
|
||||
const mesh = new THREE.Mesh(geo, felsKey ? felsMaterial(felsKey) : rabatMaterial(opts.rabatColor, cladding));
|
||||
mesh.position.set(0, 0, zDoek);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
@@ -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 exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat';
|
||||
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 {
|
||||
group: wallGroup3D,
|
||||
stats: {
|
||||
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
|
||||
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
|
||||
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2
|
||||
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1619,8 +1674,8 @@ window.WallModel = (() => {
|
||||
}
|
||||
|
||||
return {
|
||||
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
||||
stoneMaterial, stucMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
||||
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, FELS, isFelsColor, felsHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
||||
stoneMaterial, stucMaterial, felsMaterial, claddingMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
||||
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
||||
createMaterials, build3D, computeSpecs
|
||||
};
|
||||
|
||||
+39
-43
@@ -6,7 +6,10 @@
|
||||
*/
|
||||
|
||||
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="pane-2d-container">
|
||||
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
|
||||
@@ -24,8 +27,16 @@ const WALL_TEMPLATE = `
|
||||
</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">
|
||||
<input type="file" id="file-import-json" accept="application/json" style="display:none;">
|
||||
<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" 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-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
@@ -138,17 +146,10 @@ const WALL_TEMPLATE = `
|
||||
</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 class="controls-grid">
|
||||
<div>
|
||||
<div>
|
||||
<div class="spec-section">
|
||||
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
||||
@@ -188,6 +189,7 @@ const WALL_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wall-project-overlay">
|
||||
<div id="wall-project-modal">
|
||||
@@ -218,8 +220,8 @@ const WALL_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" id="wall-project-cancel" class="io-btn">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-cancel" class="ui-btn" data-esc>Annuleren</button>
|
||||
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,16 +335,6 @@ function mountWallEditor(container, callbacks) {
|
||||
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',
|
||||
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
|
||||
let projectPropsSnapshot = null;
|
||||
@@ -608,7 +600,7 @@ function mountWallEditor(container, callbacks) {
|
||||
function getPureState() {
|
||||
return {
|
||||
wall: collectWallParams(),
|
||||
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor },
|
||||
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels },
|
||||
openings: openings.map(rawOpening)
|
||||
};
|
||||
}
|
||||
@@ -637,6 +629,7 @@ function mountWallEditor(container, callbacks) {
|
||||
stone = (state.materials && state.materials.stone) || null;
|
||||
stuc = (state.materials && state.materials.stuc) || null;
|
||||
rabatColor = (state.materials && state.materials.rabatColor) || null;
|
||||
fels = (state.materials && state.materials.fels) || null;
|
||||
syncMaterialButtons();
|
||||
|
||||
openings = state.openings.map(rawOpening);
|
||||
@@ -1279,14 +1272,14 @@ function mountWallEditor(container, callbacks) {
|
||||
|
||||
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 cladding = 'horizontaal';
|
||||
let trasraam = null;
|
||||
let stone = null;
|
||||
let stuc = null;
|
||||
let rabatColor = null;
|
||||
let stage = BuildStages.FINAL;
|
||||
let fels = null;
|
||||
let layers = ViewLayers.all();
|
||||
function syncMaterialButtons() {
|
||||
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
||||
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 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();
|
||||
const skyEnv = Skybox.createEnvironment(scene);
|
||||
const applySky = () => {
|
||||
skyEnv.fitTo(wallGroup3D, 0);
|
||||
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
|
||||
};
|
||||
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
|
||||
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
|
||||
const toolbar = document.getElementById('wall-toolbar');
|
||||
const bar = UI.editorBar(toolbar, document.getElementById('app-container'), {
|
||||
left: `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>`,
|
||||
onResize: onWindowResize,
|
||||
layers: {
|
||||
layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'],
|
||||
onChange: (l) => { layers = l; update3DScene(); },
|
||||
extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }],
|
||||
onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); }
|
||||
},
|
||||
onSky: (m) => { skyMode = m; applySky(); }
|
||||
});
|
||||
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
|
||||
|
||||
let lastWallStats = null;
|
||||
|
||||
function update3DScene() {
|
||||
if (wallGroup3D) scene.remove(wallGroup3D);
|
||||
const result = WallModel.build3D(CONFIG, calcData, {
|
||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, stage
|
||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels, spaanplaatVisible, buitenVisible, layers
|
||||
});
|
||||
wallGroup3D = result.group;
|
||||
lastWallStats = result.stats;
|
||||
@@ -1419,8 +1415,8 @@ function mountWallEditor(container, callbacks) {
|
||||
exportDesign,
|
||||
triggerImport,
|
||||
mirrorHorizontal,
|
||||
toggleFullscreen3D,
|
||||
toggleFullscreen2D,
|
||||
setView: bar.setView,
|
||||
viewState: bar.viewState,
|
||||
openProjectProperties,
|
||||
getStateJson: getPureState,
|
||||
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.steenMontage;Montage steenstrips;s/m²;1800
|
||||
gevel.stucMontage;Stucwerk aanbrengen (wapenen, pleisteren);s/m²;1500
|
||||
gevel.felsMontage;Montage felsplaten gevel (ook dagkanten en hoeken);s/m²;2100
|
||||
dak.dakleerMontage;Montage dakleer;s/m²;600
|
||||
dak.rietMontage;Montage riet;s/m²;3600
|
||||
dak.beschotMontage;Montage dakbeschot;s/m²;150
|
||||
@@ -41,6 +42,7 @@ dak.nokPlaatsen;Stalen nokbalk plaatsen, stellen en vastzetten;s;2700
|
||||
dak.nokVulhoutMontage;Vulhout in de holling van de nokbalk aanbrengen (beide zijden);s/m;240
|
||||
dak.nokPersoneel;Personeel bij plaatsen nokbalk (i.p.v. de bezetting);man;3
|
||||
dak.paalMontage;Paal plaatsen: stellen, aftoppen, vastzetten aan het dak;s/st.;2700
|
||||
dak.zijwandMontage;Zijwand overkapping maken en plaatsen;s/m²;5400
|
||||
vloer.afwerkingMontage;Montage vloerafwerking;s/m²;1200
|
||||
vloer.sandwichMontage;Montage per sandwichpaneel;s;600
|
||||
afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800
|
||||
|
||||
|
@@ -12,6 +12,7 @@ gevel.vochtdoekM2;Vochtdoek;€/m²;1,5;;;
|
||||
gevel.rabatM2;Rabat (gevelbekleding);€/m²;35;;;
|
||||
gevel.steenM2;Steenstrips (gevel + trasraam);€/m²;60;;;
|
||||
gevel.stucM2;Stucwerk gevel (wapening + sierpleister op cementgebonden plaat);€/m²;45;;;
|
||||
gevel.felsM2;Felsplaten gevel, staande naad (staal, klangen, ondergrond);€/m²;70;;;
|
||||
dak.dakleerM2;Dakleer (EPDM);€/m²;18;;;
|
||||
dak.rietM2;Riet;€/m²;90;;;
|
||||
dak.beschotM2;Dakbeschot OSB 18 mm (bovenop de sporen);€/m²;14;;;
|
||||
@@ -23,6 +24,7 @@ dak.staalKg;Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond);€/kg;2
|
||||
dak.nokOplegging;Oplegging nokbalk (stalen schoen + bouten), 2 per balk;€/st.;45;;;
|
||||
dak.paalHoutM3;Paalhout overkapping (eiken, geschaafd);€/m³;2400;;;
|
||||
dak.paalvoet;Paalvoet verzinkt, verstelbaar (incl. ankers);€/st.;40;;;
|
||||
dak.zijwandM2;Zijwand overkapping (regelwerk, cementgebonden plaat en stuc aan beide kanten, fundering);€/m²;140;;;
|
||||
vloer.afwerkingM2;Vloerafwerking;€/m²;35;;;
|
||||
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
|
||||
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
|
||||
|
||||
|
Reference in New Issue
Block a user