Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1a688728c |
@@ -18,15 +18,16 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
|||||||
## Structuur
|
## Structuur
|
||||||
|
|
||||||
```
|
```
|
||||||
index.html app-shell: menubalk, splashscreen
|
index.html app-shell: ribbon, splashscreen
|
||||||
css/app.css alle stijl
|
css/app.css alle stijl
|
||||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF
|
js/vendor/ three.js r128, eigen OrbitControls, jsPDF
|
||||||
js/core/ gedeelde helpers: geometrie, UI-kit, PDF-bladen, labels,
|
js/core/ gedeelde helpers: geometrie, bouwfasen, ribbon, UI-kit, PDF-bladen,
|
||||||
camerageheugen, weergavefilter, skybox, licentie, logo
|
labels, camerageheugen, weergavefilter, skybox, licentie, logo
|
||||||
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
||||||
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
||||||
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
|
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
|
||||||
js/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots
|
js/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots
|
||||||
|
js/elektra/ ElektraModel + ElektraPdf: meterkast, schakelaars (letters), wcd, groepen, leidingen
|
||||||
js/sandwich/ SandwichModel: sandwichpanelen rondom
|
js/sandwich/ SandwichModel: sandwichpanelen rondom
|
||||||
js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
|
js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
|
||||||
js/house/ HouseModel (woning) + woning-PDF + woning-editor
|
js/house/ HouseModel (woning) + woning-PDF + woning-editor
|
||||||
@@ -44,6 +45,15 @@ assets/ logo, texturen, skybox
|
|||||||
- **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
|
- **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
|
||||||
links = west, rechts = oost (interne sleutels blijven voor/achter/…).
|
links = west, rechts = oost (interne sleutels blijven voor/achter/…).
|
||||||
- **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
|
- **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
|
||||||
|
- **Bouwfasen** (`BuildStages`): regelwerk → elektra (buizen, dozen, meterkast) → beplating → vochtdoek →
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
- **Invoer volgt de logica:** de dakvorm is een eigenschap van de unit
|
||||||
|
(plat/lessenaar/zadel, richting, goot- en nokhoogte of helling); de
|
||||||
|
wanden volgen.
|
||||||
|
|
||||||
## Functionaliteit
|
## Functionaliteit
|
||||||
|
|
||||||
@@ -52,9 +62,37 @@ assets/ logo, texturen, skybox
|
|||||||
38x235 met dubbele hoekstijlen en een dubbele stijl naast deuren, zaagplan
|
38x235 met dubbele hoekstijlen en een dubbele stijl naast deuren, zaagplan
|
||||||
op handelslengtes, spaanplaat binnen, vochtdoek (met kruislat en trasraam)
|
op handelslengtes, spaanplaat binnen, vochtdoek (met kruislat en trasraam)
|
||||||
of cementplaat buiten.
|
of cementplaat buiten.
|
||||||
- **Wandhoogte:** schuine bovenrand of middenpunt (nok) met goothoogtes. In
|
- **Elektra** (`elektra/`): meterkast (3×25 A) bij de voordeur, schakelaars
|
||||||
een unit bepaalt de laatst gewijzigde wand de dakvorm: de tegenoverliggende
|
met letter (A binnen, B buiten, per unit een eigen; dezelfde letter bij de
|
||||||
wand spiegelt mee, de haakse wanden worden vlak op de hoekhoogtes.
|
lampen erop), dubbele wcd's per unit, apparaten met eigen groep (kookgroep
|
||||||
|
perilex, wasmachine, droger, warmtepomp, optioneel oven/vaatwasser).
|
||||||
|
Groepenverklaring met fasen en aardlekschakelaars, leidingen via stijlen
|
||||||
|
en plafond naar de meterkast (strekkende meters per type, draad, buis,
|
||||||
|
steekverbinders op module-naden), materiaallijst en eigen PDF-blad.
|
||||||
|
Alles verplaatsbaar door te slepen in de plattegrond.
|
||||||
|
- **Kozijnen** (`wall/kozijn-model.js`): per opening een vulling (geen, raam
|
||||||
|
met vast glas en optioneel ventilatierooster, of deur met dorpel en
|
||||||
|
optionele glasstrook; zij- en bovenlicht vullen de rest van een grote
|
||||||
|
opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de
|
||||||
|
maat van de opening, ook een schuine bovenzijde.
|
||||||
|
- **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat,
|
||||||
|
lessenaar, zadel, mansarde of wolfsdak, met schuiven en hellingshoek).
|
||||||
|
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde
|
||||||
|
met twee knikken, bij een wolfsdak afgeknot op het wolfseind, met
|
||||||
|
daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een
|
||||||
|
rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het
|
||||||
|
bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers,
|
||||||
|
nokbalk en kopbalken (ook in zaaglijst, specificaties en kosten).
|
||||||
|
Goot en nok gelden over de volle vloermaat (goot boven de buitenkant van
|
||||||
|
de gevel); staat de kopgevel tussen de haakse wanden, dan loopt zijn
|
||||||
|
daklijn daar precies op aan. Knik en wolfseind schalen mee met goot en
|
||||||
|
nok (verhouding). Bij een knik (mansarde) sluiten de dakvlakken in
|
||||||
|
verstek aan. De PDF heeft voor een wolfsdak een eigen blad met het
|
||||||
|
bovenste deel (bovenaanzicht houtwerk + stuklijst).
|
||||||
|
Grenzen (`UnitModel.LIMITS`): nok max 5 m, goot / plat dak max 3 m (vanaf
|
||||||
|
bovenkant vloer tot onderkant dak), unit max 4,3 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen
|
||||||
|
boeideel (versprongen, het lage loopt door tot onder het hoge). Wijzig je een
|
||||||
|
wandhoogte in de wand-editor, dan geldt dezelfde regel vanuit die wand.
|
||||||
- **Vloer:** balken, cirkelvormige doorvoeren, OSB, optioneel chassis;
|
- **Vloer:** balken, cirkelvormige doorvoeren, OSB, optioneel chassis;
|
||||||
in de woning een vloerafwerking (textuur, bijv. visgraat).
|
in de woning een vloerafwerking (textuur, bijv. visgraat).
|
||||||
- **Woning:** units koppelen aan een zijde (uitlijning + verschuiving); op
|
- **Woning:** units koppelen aan een zijde (uitlijning + verschuiving); op
|
||||||
@@ -80,16 +118,29 @@ assets/ logo, texturen, skybox
|
|||||||
het hoge, of langs een zijgevel tot de volle overstek (onder de hoge
|
het hoge, of langs een zijgevel tot de volle overstek (onder de hoge
|
||||||
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit.
|
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit.
|
||||||
- **Gevel:** gevelbekleding per woning, met override per wand: rabat
|
- **Gevel:** gevelbekleding per woning, met override per wand: rabat
|
||||||
(vochtdoek + kruislat) of steen (181 texturen). Steen is overal een
|
(vochtdoek + kruislat + rabat 12 mm, kleur per woning, getekend als
|
||||||
|
plankentextuur die over naden doorloopt) of steen (181 texturen). Steen is overal een
|
||||||
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
|
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
|
||||||
Het trasraam (cementplaat of een eigen steen) komt onder beide, dus
|
Het trasraam (cementplaat of een eigen steen) komt onder beide, dus
|
||||||
twee stenen kan. Latwerk, doek en metselwerk lopen per gevel door over
|
twee stenen kan. Latwerk, doek en metselwerk lopen per gevel door over
|
||||||
unit-naden. Sandwichpanelen rondom, 600 mm de grond in.
|
unit-naden. Sandwichpanelen rondom, 600 mm de grond in.
|
||||||
- **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
|
- **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
|
||||||
override per wand) en binnenspots in een raster per unit.
|
override per wand) en binnenspots in een raster per unit.
|
||||||
- **3D:** weergavefilters (o.a. Vloerplan: wanden afgesneden op 500 mm,
|
- **Ribbon** (`core/ribbon.js`, vervangt de menubalk): Bestand · Start ·
|
||||||
snede wit afgedekt), binnenzijde van de wanden in een verfkleur (globaal),
|
Gevel · Dak · Binnen · Constructie · Verlichting · Rapport · Beeld · Extra,
|
||||||
omgeving Ontwerp/Dag/Nacht, Render met lampen als echte lichtbronnen.
|
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.
|
||||||
|
- **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
|
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
|
||||||
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
|
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
|
||||||
vloeren, wandstukken, binnenwanden, totalen).
|
vloeren, wandstukken, binnenwanden, totalen).
|
||||||
@@ -98,8 +149,10 @@ assets/ logo, texturen, skybox
|
|||||||
|
|
||||||
- Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij
|
- Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij
|
||||||
2400 m/u, zaagsneden, hoeken) in de app en op het totalenblad (`SAW_M_PER_H`).
|
2400 m/u, zaagsneden, hoeken) in de app en op het totalenblad (`SAW_M_PER_H`).
|
||||||
- Tijdelijk: een nieuwe woning is het testontwerp (L-vorm, lessenaar met
|
- Tijdelijk: een nieuwe woning is het voorbeeldontwerp "Vlinderwoning"
|
||||||
riet); uit te zetten met `TEST_DEFAULT` in `js/house/house-model.js`.
|
(92 m², drie units, vlinderdak met riet + platte tuinkamer, ook als
|
||||||
|
`json/premade/vlinderwoning.json`); uit te zetten met `TEST_DEFAULT` in
|
||||||
|
`js/house/house-model.js`.
|
||||||
- Units kunnen niet gedraaid worden.
|
- Units kunnen niet gedraaid worden.
|
||||||
- Getekende binnenwanden volgen de dakvorm (nog) niet.
|
- Getekende binnenwanden volgen de dakvorm (nog) niet.
|
||||||
- De dak-editor toont het dek in de plattegrond (niet per schuin dakvlak).
|
- De dak-editor toont het dek in de plattegrond (niet per schuin dakvlak).
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ Assets voor de 3D-weergave en de PDF:
|
|||||||
naadloos), voorvertoning in thumb/32|64|128/*.png (WallModel.TRASRAAM_STONES)
|
naadloos), voorvertoning in thumb/32|64|128/*.png (WallModel.TRASRAAM_STONES)
|
||||||
- textures/vloer/ vloerafwerking (FloorModel.FINISHES), bijv. visgraat_001.jpg (1500x1500, tegel 3 m); thumb/32|64|128/*.png
|
- textures/vloer/ vloerafwerking (FloorModel.FINISHES), bijv. visgraat_001.jpg (1500x1500, tegel 3 m); thumb/32|64|128/*.png
|
||||||
- textures/dak/ dakbedekking: riet_<nr>.jpg (1024x1024, tegel 1 m); nummers in RoofModel RIET.nrs; thumb/32|64|128/*.png
|
- textures/dak/ dakbedekking: riet_<nr>.jpg (1024x1024, tegel 1 m); nummers in RoofModel RIET.nrs; thumb/32|64|128/*.png
|
||||||
|
- audio/five-armies.mp3 soundtrack easter egg (Ctrl+Shift+Alt+L shooter; core/granny-kit.js):
|
||||||
|
"Five Armies" Kevin MacLeod (incompetech.com), licensed under
|
||||||
|
Creative Commons: By Attribution 4.0 — https://creativecommons.org/licenses/by/4.0/
|
||||||
|
|||||||
Binary file not shown.
+198
-63
@@ -236,6 +236,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.opening-card.opening-selected { background: var(--primary-soft); border-color: var(--primary); }
|
.opening-card.opening-selected { background: var(--primary-soft); border-color: var(--primary); }
|
||||||
|
.opening-fill { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border); }
|
||||||
.opening-card.opening-warn {
|
.opening-card.opening-warn {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
box-shadow: 0 0 0 1px var(--danger) inset;
|
box-shadow: 0 0 0 1px var(--danger) inset;
|
||||||
@@ -298,56 +299,10 @@
|
|||||||
.material-toggle-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
.material-toggle-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
.material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); }
|
.material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); }
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
APP SHELL: menubalk, workspace-wrapper, splashscreen, about-modal
|
APP SHELL: ribbon, workspace-wrapper, splashscreen, about-modal
|
||||||
============================================================ */
|
============================================================ */
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
|
|
||||||
#app-bar {
|
|
||||||
height: 40px;
|
|
||||||
background: var(--van-emous-blue);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 4px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 100;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
#app-bar .menu-group { position: relative; }
|
|
||||||
#app-bar .menu-btn {
|
|
||||||
background: none; border: none; color: var(--nav-link-color); font-size: 13px;
|
|
||||||
padding: 8px 14px; cursor: pointer; border-radius: 4px;
|
|
||||||
}
|
|
||||||
#app-bar .menu-btn:hover, #app-bar .menu-btn.open { background: var(--surface); color: var(--primary); }
|
|
||||||
#app-bar .menu-dropdown {
|
|
||||||
position: absolute; top: 100%; left: 0; background: var(--surface); border: 1px solid var(--border);
|
|
||||||
border-radius: 0 0 6px 6px; min-width: 220px; display: none; box-shadow: 0 8px 20px rgba(33, 33, 33, .15);
|
|
||||||
}
|
|
||||||
#app-bar .menu-dropdown.open { display: block; }
|
|
||||||
#app-bar .menu-item {
|
|
||||||
padding: 9px 16px; font-size: 13px; color: var(--nav-link-color); cursor: pointer; position: relative;
|
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
|
||||||
}
|
|
||||||
#app-bar .menu-item:hover { background: var(--primary-soft); color: var(--primary); }
|
|
||||||
#app-bar .menu-hotkey { color: var(--text-faint); font-size: 11px; margin-left: 16px; }
|
|
||||||
#app-bar .menu-item.disabled { color: #b0b8c2; cursor: not-allowed; pointer-events: none; }
|
|
||||||
#app-bar .menu-item.disabled:hover { background: none; color: #b0b8c2; }
|
|
||||||
#app-bar .submenu {
|
|
||||||
position: absolute; left: 100%; top: -1px; background: var(--surface); border: 1px solid var(--border);
|
|
||||||
border-radius: 6px; min-width: 200px; display: none; box-shadow: 0 8px 20px rgba(33, 33, 33, .15);
|
|
||||||
}
|
|
||||||
#app-bar .menu-item:hover .submenu, #app-bar .submenu.open { display: block; }
|
|
||||||
#app-bar .submenu .menu-item { color: var(--nav-link-color); }
|
|
||||||
#app-bar .submenu .menu-item:hover { background: var(--primary-soft); color: var(--primary); }
|
|
||||||
|
|
||||||
#app-bar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; padding-right: 6px; }
|
|
||||||
#app-version-tag { font-size: 11px; color: var(--text-muted); }
|
|
||||||
#btn-about {
|
|
||||||
background: var(--surface); border: 1px solid var(--border); color: var(--nav-link-color); font-size: 12px;
|
|
||||||
padding: 5px 10px; border-radius: 4px; cursor: pointer;
|
|
||||||
}
|
|
||||||
#btn-about:hover { border-color: var(--primary); color: var(--primary); }
|
|
||||||
|
|
||||||
#tab-bar {
|
#tab-bar {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
background: var(--van-emous-ultra-light);
|
background: var(--van-emous-ultra-light);
|
||||||
@@ -368,7 +323,9 @@ html, body { height: 100%; }
|
|||||||
}
|
}
|
||||||
.tab-item .tab-close:hover { color: var(--danger); }
|
.tab-item .tab-close:hover { color: var(--danger); }
|
||||||
|
|
||||||
#workspace { width: 100vw; height: calc(100vh - 40px - 34px); position: relative; }
|
body { display: flex; flex-direction: column; }
|
||||||
|
#tab-bar { flex: none; }
|
||||||
|
#workspace { width: 100vw; flex: 1; min-height: 0; position: relative; }
|
||||||
#workspace-empty {
|
#workspace-empty {
|
||||||
height: 100%; display: flex; align-items: center; justify-content: center;
|
height: 100%; display: flex; align-items: center; justify-content: center;
|
||||||
color: var(--text-faint); font-size: 14px; flex-direction: column; gap: 10px;
|
color: var(--text-faint); font-size: 14px; flex-direction: column; gap: 10px;
|
||||||
@@ -435,6 +392,31 @@ html, body { height: 100%; }
|
|||||||
#wall-project-modal .modal-footer, #house-project-modal .modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
|
#wall-project-modal .modal-footer, #house-project-modal .modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
|
||||||
#wall-project-modal .control-group input, #house-project-modal .control-group input { width: 150px; text-align: left; }
|
#wall-project-modal .control-group input, #house-project-modal .control-group input { width: 150px; text-align: left; }
|
||||||
|
|
||||||
|
/* --- Kosten & tijden (CostSettings) --- */
|
||||||
|
#cost-settings-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
|
||||||
|
display: none; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
#cost-settings-overlay.open { display: flex; }
|
||||||
|
#cost-settings-modal {
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px;
|
||||||
|
width: 760px; max-width: 94%; max-height: 90vh; display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 12px 30px rgba(33, 33, 33, .2);
|
||||||
|
}
|
||||||
|
#cost-settings-modal .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
#cost-settings-modal .modal-header h2 { margin: 0; font-size: 15px; color: var(--primary); }
|
||||||
|
#cost-settings-modal .modal-close-x { background: none; border: none; color: var(--text-faint); font-size: 19px; line-height: 1; cursor: pointer; padding: 0 4px; }
|
||||||
|
#cost-settings-modal .modal-close-x:hover { color: var(--danger); }
|
||||||
|
#cost-settings-modal .modal-footer { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
#cost-settings-modal .cs-body { overflow-y: auto; min-height: 0; flex: 1; columns: 2 340px; column-gap: 24px; }
|
||||||
|
#cost-settings-modal .cs-group { break-inside: avoid; margin-bottom: 14px; }
|
||||||
|
#cost-settings-modal .cs-group h3 { font-size: 12px; color: var(--primary); text-transform: uppercase; letter-spacing: .5px; margin: 0 0 6px; }
|
||||||
|
#cost-settings-modal .cs-row { display: grid; grid-template-columns: 1fr 84px 48px; gap: 6px; align-items: center; font-size: 12px; padding: 2px 0; }
|
||||||
|
#cost-settings-modal .cs-row input, #cost-settings-modal .cs-row select { width: 100%; text-align: right; }
|
||||||
|
#cost-settings-modal .cs-row select { text-align: left; }
|
||||||
|
#cost-settings-modal .cs-unit { color: var(--text-faint); font-size: 11px; }
|
||||||
|
#cost-settings-modal .cs-row.changed .cs-label { color: var(--primary); font-weight: bold; }
|
||||||
|
|
||||||
#new-overlay {
|
#new-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
|
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
|
||||||
display: none; align-items: center; justify-content: center;
|
display: none; align-items: center; justify-content: center;
|
||||||
@@ -508,7 +490,6 @@ html, body { height: 100%; }
|
|||||||
.hn-item.disabled:hover { background: none; color: #9aa4b0; }
|
.hn-item.disabled:hover { background: none; color: #9aa4b0; }
|
||||||
.hn-actions { display: flex; gap: 6px; margin-top: 10px; }
|
.hn-actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||||
.hn-actions .io-btn { flex: 1; }
|
.hn-actions .io-btn { flex: 1; }
|
||||||
.hn-soon { margin-top: 10px; border-top: 1px solid var(--border); padding-top: 6px; }
|
|
||||||
#house-nav .io-status { margin: auto 0 0; padding: 6px; }
|
#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 { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||||
@@ -516,20 +497,31 @@ html, body { height: 100%; }
|
|||||||
#house-part-host > #floor-app,
|
#house-part-host > #floor-app,
|
||||||
#house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; }
|
#house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; }
|
||||||
|
|
||||||
|
/* Overzicht: plattegrond en 3D over de volle breedte (smal: onder elkaar);
|
||||||
|
bediening in de ribbon. Tabblad Rapport: rapport i.p.v. plattegrond. */
|
||||||
#house-overview { display: flex; width: 100%; }
|
#house-overview { display: flex; width: 100%; }
|
||||||
.ho-col { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
.ho-views { flex: 1; display: flex; min-width: 0; min-height: 0; }
|
||||||
.ho-left { border-right: 1px solid var(--border); }
|
.ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); }
|
||||||
#house-overview.fullscreen-3d .ho-left { display: none; }
|
#ho-pane-plan { border-right: 1px solid var(--border); }
|
||||||
#house-overview.fullscreen-2d .ho-right { display: none; }
|
@media (max-width: 1100px) {
|
||||||
.ho-pane { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); }
|
.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 {
|
.ho-hint {
|
||||||
position: absolute; left: 15px; bottom: 10px; font-size: 11px; color: #bbb;
|
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;
|
background: rgba(0, 0, 0, .6); padding: 3px 8px; border-radius: 4px; pointer-events: none;
|
||||||
}
|
}
|
||||||
#ho-props, #ho-specs {
|
|
||||||
height: 340px; flex: none; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-y: auto; padding: 15px 20px;
|
|
||||||
}
|
|
||||||
#ho-specs .controls-grid { grid-template-columns: 1fr 1fr; }
|
|
||||||
.ho-prop-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
.ho-prop-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||||
.io-btn-danger { border-color: #e3b4b0; color: var(--danger); }
|
.io-btn-danger { border-color: #e3b4b0; color: var(--danger); }
|
||||||
.io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
|
.io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
|
||||||
@@ -619,7 +611,7 @@ html, body { height: 100%; }
|
|||||||
.hn-label { display: inline-flex; align-items: center; }
|
.hn-label { display: inline-flex; align-items: center; }
|
||||||
|
|
||||||
/* Weergave-cyclus (T): render-only = ook het info-deel onder de 3D weg. */
|
/* Weergave-cyclus (T): render-only = ook het info-deel onder de 3D weg. */
|
||||||
.render-only #ho-specs, .render-only #specs-panel-3d, .render-only #floor-specs-panel-3d { display: none; }
|
.render-only #specs-panel-3d, .render-only #floor-specs-panel-3d { display: none; }
|
||||||
|
|
||||||
/* Bestaande editor-panelen (wand/vloer) in dezelfde stijl als de UI-kit. */
|
/* Bestaande editor-panelen (wand/vloer) in dezelfde stijl als de UI-kit. */
|
||||||
.control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; }
|
.control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; }
|
||||||
@@ -650,10 +642,27 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
|||||||
background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; pointer-events: none; white-space: nowrap; z-index: 5; }
|
background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; pointer-events: none; white-space: nowrap; z-index: 5; }
|
||||||
.walk-hint.show { display: block; }
|
.walk-hint.show { display: block; }
|
||||||
|
|
||||||
/* Beeld > Camera: uitleg (geen actie) */
|
/* Easter egg: schieten (Ctrl+L) */
|
||||||
#app-bar .submenu .menu-info { padding: 6px 12px 2px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted, #8a8f96); }
|
.shooter-hud { display: none; position: absolute; inset: 0; pointer-events: none; z-index: 6; font-family: monospace; color: #ffd27a; }
|
||||||
#app-bar .submenu .menu-help { cursor: default; opacity: .85; }
|
.shooter-hud.show { display: block; }
|
||||||
#app-bar .submenu .menu-help:hover { background: transparent; color: var(--nav-link-color); }
|
.shooter-cross { position: absolute; left: 50%; top: 50%; width: 18px; height: 18px; margin: -9px 0 0 -9px;
|
||||||
|
background: linear-gradient(#ffd27a, #ffd27a) center/2px 100% no-repeat, linear-gradient(#ffd27a, #ffd27a) center/100% 2px no-repeat; opacity: .85; }
|
||||||
|
.shooter-weapon { position: absolute; right: 18px; bottom: 16px; font-size: 18px; font-weight: 700; text-shadow: 0 0 4px #000; }
|
||||||
|
.shooter-credit { position: absolute; left: 14px; top: 12px; font-size: 11px; color: #fff; opacity: .7; text-shadow: 0 0 3px #000; }
|
||||||
|
.shooter-kills { position: absolute; right: 18px; top: 12px; font-size: 18px; font-weight: 700; text-shadow: 0 0 4px #000; }
|
||||||
|
.shooter-shout { position: absolute; left: 50%; top: 22%; transform: translateX(-50%); font-size: 20px; font-weight: 700; color: #fff;
|
||||||
|
text-shadow: 0 0 4px #000, 2px 2px 0 #8040a0; opacity: 0; transition: opacity .2s; white-space: nowrap; }
|
||||||
|
.shooter-shout.show { opacity: 1; }
|
||||||
|
.shooter-ouch { position: absolute; inset: 0; background: rgba(255, 0, 0, 0.45); opacity: 0; }
|
||||||
|
.shooter-ouch.show { animation: shooter-ouch .4s ease-out; }
|
||||||
|
@keyframes shooter-ouch { from { opacity: 1; } to { opacity: 0; } }
|
||||||
|
.shooter-announce { position: absolute; left: 50%; top: 34%; transform: translateX(-50%); font: italic 900 42px Impact, 'Arial Black', sans-serif;
|
||||||
|
color: #ff2a1a; text-shadow: 0 0 8px #000, 3px 3px 0 #400; letter-spacing: 2px; white-space: nowrap; opacity: 0; }
|
||||||
|
.shooter-announce.show { opacity: 1; animation: shooter-announce .35s ease-out; }
|
||||||
|
@keyframes shooter-announce { from { transform: translateX(-50%) scale(2.2); opacity: 0; } to { transform: translateX(-50%) scale(1); opacity: 1; } }
|
||||||
|
.shooter-help { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); padding: 6px 12px; border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; white-space: nowrap; }
|
||||||
|
|
||||||
|
|
||||||
/* Openingen: "Volg dakhelling" (schuine bovenzijde) */
|
/* Openingen: "Volg dakhelling" (schuine bovenzijde) */
|
||||||
.opening-roof-btn {
|
.opening-roof-btn {
|
||||||
@@ -661,3 +670,129 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
|||||||
background: var(--surface); border: 1px solid var(--border); color: var(--primary); border-radius: 4px; cursor: pointer;
|
background: var(--surface); border: 1px solid var(--border); color: var(--primary); border-radius: 4px; cursor: pointer;
|
||||||
}
|
}
|
||||||
.opening-roof-btn:hover { background: var(--primary-soft); border-color: var(--primary); }
|
.opening-roof-btn:hover { background: var(--primary-soft); border-color: var(--primary); }
|
||||||
|
|
||||||
|
/* Inspector: kruimelpad, schuiven, kleurstaal. */
|
||||||
|
.ui-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 11px; color: var(--text-faint); margin-bottom: 4px; }
|
||||||
|
.ui-crumbs .bi { font-size: 9px; }
|
||||||
|
.ui-crumbs span:last-child { color: var(--text-muted); }
|
||||||
|
.ui-field-range { flex-wrap: wrap; row-gap: 2px; padding: 2px 0 4px; }
|
||||||
|
.ui-field-range label { flex: 1 1 100%; }
|
||||||
|
.ui-range { flex: 1 1 100%; gap: 8px; }
|
||||||
|
.ui-range input[type=range] { flex: 1; min-width: 0; accent-color: var(--primary); }
|
||||||
|
.ui-range input[type=number] { width: 84px; }
|
||||||
|
.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
|
||||||
|
|
||||||
|
/* Bouwfase (core/build-stages.js): schuif linksonder in de 3D. */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
.walking .stage-bar { display: none; }
|
||||||
|
|
||||||
|
/* Grafieken in het rapport (staafdiagrammen, HTML). Categorische kleuren
|
||||||
|
in vaste volgorde: vloer, wanden, dak. */
|
||||||
|
.viz { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; }
|
||||||
|
.viz-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; }
|
||||||
|
.viz-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
.viz-key { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||||
|
.viz-key.s1, .viz-seg.s1 { background: var(--series-1); }
|
||||||
|
.viz-key.s2, .viz-seg.s2 { background: var(--series-2); }
|
||||||
|
.viz-key.s3, .viz-seg.s3 { background: var(--series-3); }
|
||||||
|
.viz-row { display: grid; grid-template-columns: 96px 1fr auto; align-items: center; gap: 8px; padding: 4px 0; }
|
||||||
|
.viz-label { font-size: 12px; color: var(--text); line-height: 1.25; }
|
||||||
|
.viz-label small { display: block; font-size: 10px; color: var(--text-faint); }
|
||||||
|
.viz-row:has(.viz-label small) { grid-template-columns: 1fr auto; }
|
||||||
|
.viz-row:has(.viz-label small) .viz-label { grid-column: 1 / -1; }
|
||||||
|
.viz-track { height: 12px; }
|
||||||
|
.viz-bar { display: flex; gap: 2px; height: 100%; min-width: 2px; }
|
||||||
|
.viz-seg { height: 100%; min-width: 2px; }
|
||||||
|
.viz-seg:last-child { border-radius: 0 4px 4px 0; }
|
||||||
|
.viz-seg:hover { filter: brightness(1.12); }
|
||||||
|
.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 (core/ribbon.js): tabbladen + groepen van 3 rijen hoog.
|
||||||
|
============================================================ */
|
||||||
|
#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); }
|
||||||
|
/* Velden (UI-kit) compact in de ribbon: label links, invoer rechts, één rij. */
|
||||||
|
.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 { 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); }
|
||||||
|
|
||||||
|
/* Ribbon: hulpklassen voor de woning-tabbladen. */
|
||||||
|
.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; }
|
||||||
|
|||||||
+17
-57
@@ -22,64 +22,14 @@
|
|||||||
<div class="splash-version"><span class="splash-name">Tectum</span> — Versie <span class="app-version-number">0.0.0</span></div>
|
<div class="splash-version"><span class="splash-name">Tectum</span> — Versie <span class="app-version-number">0.0.0</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="app-bar">
|
<header id="ribbon">
|
||||||
<div class="menu-group">
|
<div class="rb-top">
|
||||||
<button id="menu-bestand-btn" class="menu-btn">Bestand</button>
|
<div class="rb-brand"><span class="splash-name">Tectum</span></div>
|
||||||
<div id="menu-bestand-dropdown" class="menu-dropdown">
|
<div class="rb-tabs" role="tablist"></div>
|
||||||
<div class="menu-item" id="menu-nieuw-item">
|
<div class="rb-right"><span class="app-version-slot"></span></div>
|
||||||
<span>Nieuw</span><span class="menu-hotkey">Ctrl+N</span>
|
|
||||||
<div class="submenu">
|
|
||||||
<div class="menu-item menu-nieuw-option" data-kind="woning" data-template="single">Woning (1 unit)</div>
|
|
||||||
<div class="menu-item menu-nieuw-option" data-kind="woning" data-template="L">Woning L-vorm (2 units)</div>
|
|
||||||
<div class="menu-item menu-nieuw-option" data-kind="muur">Los element: muur</div>
|
|
||||||
<div class="menu-item menu-nieuw-option" data-kind="vloer">Los element: vloer</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="menu-item" id="menu-opslaan-als"><span>Opslaan als…</span><span class="menu-hotkey">Ctrl+S</span></div>
|
|
||||||
<div class="menu-item" id="menu-importeren">Importeren…</div>
|
|
||||||
<div class="menu-item" id="menu-project-props">Project eigenschappen…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rb-body"></div>
|
||||||
<div class="menu-group">
|
</header>
|
||||||
<button id="menu-bewerken-btn" class="menu-btn">Bewerken</button>
|
|
||||||
<div id="menu-bewerken-dropdown" class="menu-dropdown">
|
|
||||||
<div class="menu-item" id="menu-mirror-h"><span>Horizontaal spiegelen</span><span class="menu-hotkey">H</span></div>
|
|
||||||
<div class="menu-item disabled" id="menu-mirror-v" title="Nog niet beschikbaar: een wand mag aan de onderzijde niet scheef worden.">Verticaal spiegelen</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="menu-group">
|
|
||||||
<button id="menu-beeld-btn" class="menu-btn">Beeld</button>
|
|
||||||
<div id="menu-beeld-dropdown" class="menu-dropdown">
|
|
||||||
<div class="menu-item" id="menu-fullscreen"><span>Volledig scherm</span><span class="menu-hotkey">F</span></div>
|
|
||||||
<div class="menu-item" id="menu-3d-fullscreen"><span>3D groot / alleen render</span><span class="menu-hotkey">T</span></div>
|
|
||||||
<div class="menu-item" id="menu-2d-fullscreen"><span>2D groot</span><span class="menu-hotkey">R</span></div>
|
|
||||||
<div class="menu-item" id="menu-camera-item">
|
|
||||||
<span>Camera</span><span class="menu-hotkey">›</span>
|
|
||||||
<div class="submenu">
|
|
||||||
<div class="menu-item" id="menu-walk"><span>Lopen (first person)</span><span class="menu-hotkey">Shift+T</span></div>
|
|
||||||
<div class="menu-info">Rondom (standaard)</div>
|
|
||||||
<div class="menu-item menu-help"><span>Draaien</span><span class="menu-hotkey">Slepen</span></div>
|
|
||||||
<div class="menu-item menu-help"><span>Verschuiven</span><span class="menu-hotkey">Rechts slepen</span></div>
|
|
||||||
<div class="menu-item menu-help"><span>Zoomen</span><span class="menu-hotkey">Scrollwiel</span></div>
|
|
||||||
<div class="menu-info">Lopen</div>
|
|
||||||
<div class="menu-item menu-help"><span>Vooruit / achteruit</span><span class="menu-hotkey">W / S, ↑ / ↓</span></div>
|
|
||||||
<div class="menu-item menu-help"><span>Opzij stappen</span><span class="menu-hotkey">A / D</span></div>
|
|
||||||
<div class="menu-item menu-help"><span>Draaien</span><span class="menu-hotkey">← / →</span></div>
|
|
||||||
<div class="menu-item menu-help"><span>Rondkijken</span><span class="menu-hotkey">Slepen</span></div>
|
|
||||||
<div class="menu-item menu-help"><span>Rennen</span><span class="menu-hotkey">Shift</span></div>
|
|
||||||
<div class="menu-item menu-help"><span>Terug</span><span class="menu-hotkey">Shift+T</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="app-bar-right">
|
|
||||||
<span id="app-version-tag" class="app-version-slot"></span>
|
|
||||||
<button id="btn-about">Over deze software</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="about-overlay">
|
<div id="about-overlay">
|
||||||
<div id="about-modal">
|
<div id="about-modal">
|
||||||
@@ -131,25 +81,35 @@
|
|||||||
<script src="js/core/license.js"></script>
|
<script src="js/core/license.js"></script>
|
||||||
<script src="js/core/logo-data.js"></script>
|
<script src="js/core/logo-data.js"></script>
|
||||||
<script src="js/core/geometry.js"></script>
|
<script src="js/core/geometry.js"></script>
|
||||||
|
<script src="js/core/build-stages.js"></script>
|
||||||
<script src="js/core/canvas-labels.js"></script>
|
<script src="js/core/canvas-labels.js"></script>
|
||||||
<script src="js/core/camera-memory.js"></script>
|
<script src="js/core/camera-memory.js"></script>
|
||||||
<script src="js/core/walk-controls.js"></script>
|
<script src="js/core/walk-controls.js"></script>
|
||||||
|
<script src="js/core/granny-kit.js"></script>
|
||||||
|
<script src="js/core/shooter.js"></script>
|
||||||
<script src="js/core/view-filter.js"></script>
|
<script src="js/core/view-filter.js"></script>
|
||||||
<script src="js/core/ui-kit.js"></script>
|
<script src="js/core/ui-kit.js"></script>
|
||||||
|
<script src="js/core/ribbon.js"></script>
|
||||||
<script src="js/core/skybox.js"></script>
|
<script src="js/core/skybox.js"></script>
|
||||||
<script src="js/vendor/jspdf.umd.min.js"></script>
|
<script src="js/vendor/jspdf.umd.min.js"></script>
|
||||||
<script src="js/core/pdf-sheet.js"></script>
|
<script src="js/core/pdf-sheet.js"></script>
|
||||||
|
<script src="js/wall/kozijn-model.js"></script>
|
||||||
<script src="js/wall/wall-model.js"></script>
|
<script src="js/wall/wall-model.js"></script>
|
||||||
<script src="js/wall/wall-pdf.js"></script>
|
<script src="js/wall/wall-pdf.js"></script>
|
||||||
<script src="js/floor/floor-model.js"></script>
|
<script src="js/floor/floor-model.js"></script>
|
||||||
<script src="js/floor/floor-pdf.js"></script>
|
<script src="js/floor/floor-pdf.js"></script>
|
||||||
<script src="js/roof/roof-model.js"></script>
|
<script src="js/roof/roof-model.js"></script>
|
||||||
<script src="js/lighting/lighting-model.js"></script>
|
<script src="js/lighting/lighting-model.js"></script>
|
||||||
|
<script src="js/elektra/elektra-model.js"></script>
|
||||||
<script src="js/sandwich/sandwich-model.js"></script>
|
<script src="js/sandwich/sandwich-model.js"></script>
|
||||||
<script src="js/wall/wall.js"></script>
|
<script src="js/wall/wall.js"></script>
|
||||||
<script src="js/floor/floor.js"></script>
|
<script src="js/floor/floor.js"></script>
|
||||||
<script src="js/unit/unit-model.js"></script>
|
<script src="js/unit/unit-model.js"></script>
|
||||||
<script src="js/house/house-model.js"></script>
|
<script src="js/house/house-model.js"></script>
|
||||||
|
<script src="js/cost/cost-defaults.js"></script>
|
||||||
|
<script src="js/cost/cost-model.js"></script>
|
||||||
|
<script src="js/cost/cost-settings.js"></script>
|
||||||
|
<script src="js/elektra/elektra-pdf.js"></script>
|
||||||
<script src="js/house/house-pdf.js"></script>
|
<script src="js/house/house-pdf.js"></script>
|
||||||
<script src="js/house/house.js"></script>
|
<script src="js/house/house.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
* Zie LICENTIE.md.
|
* Zie LICENTIE.md.
|
||||||
*/
|
*/
|
||||||
const APP_VERSION = '0.4.17';
|
const APP_VERSION = '0.5.2';
|
||||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||||
|
|
||||||
let tabs = [];
|
let tabs = [];
|
||||||
@@ -32,15 +32,56 @@ function setWorkspaceEmpty() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ribbon (core/ribbon.js): app-tabbladen Bestand, Beeld en Extra; de
|
||||||
|
// editor voegt er zijn eigen tabbladen aan toe. Knoppen die de huidige
|
||||||
|
// editor niet ondersteunt staan uit.
|
||||||
function updateEditMenuState() {
|
function updateEditMenuState() {
|
||||||
const has = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
|
Ribbon.refresh();
|
||||||
document.getElementById('menu-opslaan-als').classList.toggle('disabled', !has('exportDesign'));
|
}
|
||||||
document.getElementById('menu-importeren').classList.toggle('disabled', !has('triggerImport'));
|
|
||||||
document.getElementById('menu-mirror-h').classList.toggle('disabled', !has('mirrorHorizontal'));
|
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
|
||||||
document.getElementById('menu-3d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen3D'));
|
const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); };
|
||||||
document.getElementById('menu-2d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen2D'));
|
const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled';
|
||||||
document.getElementById('menu-walk').classList.toggle('disabled', !has('toggleWalk'));
|
|
||||||
document.getElementById('menu-project-props').classList.toggle('disabled', !has('openProjectProperties'));
|
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: 'Snel nieuw', html:
|
||||||
|
small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') +
|
||||||
|
small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') +
|
||||||
|
small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') +
|
||||||
|
small('Los: vloer', 'grid-3x3', 'data-app="new-kind" data-kind="vloer"') },
|
||||||
|
{ label: 'Project', html: big('Eigenschappen', 'card-text', `data-app="openProjectProperties" title="Projectgegevens voor de PDF" ${disabledUnless('openProjectProperties')}`) }
|
||||||
|
] },
|
||||||
|
{ key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [
|
||||||
|
{ label: 'Venster', html:
|
||||||
|
small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') +
|
||||||
|
small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) +
|
||||||
|
small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) },
|
||||||
|
{ label: 'Camera', html:
|
||||||
|
big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) +
|
||||||
|
small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') }
|
||||||
|
] },
|
||||||
|
{ key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [
|
||||||
|
{ label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) },
|
||||||
|
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') },
|
||||||
|
{ label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') }
|
||||||
|
] }
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTabBar() {
|
function renderTabBar() {
|
||||||
@@ -63,6 +104,7 @@ function onTabSaved(tab, filenameWithExt) {
|
|||||||
function mountEditorForTab(tab) {
|
function mountEditorForTab(tab) {
|
||||||
const ws = document.getElementById('workspace');
|
const ws = document.getElementById('workspace');
|
||||||
ws.innerHTML = '';
|
ws.innerHTML = '';
|
||||||
|
Ribbon.setSource('editor', []);
|
||||||
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
|
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
|
||||||
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
|
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
|
||||||
currentEditor.loadStateJson(tab.stateJson);
|
currentEditor.loadStateJson(tab.stateJson);
|
||||||
@@ -85,7 +127,7 @@ function switchToTab(tabId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openNew(kind, template) {
|
function openNew(kind, template) {
|
||||||
if (!editorModuleFor(kind)) { closeAllMenus(); return; } // 'dak' nog niet beschikbaar
|
if (!editorModuleFor(kind)) return; // 'dak' nog niet beschikbaar
|
||||||
|
|
||||||
const oldTab = tabs.find(t => t.id === activeTabId);
|
const oldTab = tabs.find(t => t.id === activeTabId);
|
||||||
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
|
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
|
||||||
@@ -100,7 +142,6 @@ function openNew(kind, template) {
|
|||||||
mountEditorForTab(tab);
|
mountEditorForTab(tab);
|
||||||
updateEditMenuState();
|
updateEditMenuState();
|
||||||
renderTabBar();
|
renderTabBar();
|
||||||
closeAllMenus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTabDirty(tab) {
|
function isTabDirty(tab) {
|
||||||
@@ -140,6 +181,7 @@ function closeTab(tabId) {
|
|||||||
switchToTab(tabs[tabs.length - 1].id);
|
switchToTab(tabs[tabs.length - 1].id);
|
||||||
} else {
|
} else {
|
||||||
setWorkspaceEmpty();
|
setWorkspaceEmpty();
|
||||||
|
Ribbon.setSource('editor', []);
|
||||||
updateEditMenuState();
|
updateEditMenuState();
|
||||||
renderTabBar();
|
renderTabBar();
|
||||||
}
|
}
|
||||||
@@ -154,69 +196,6 @@ function wireTabBar() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAllMenus() {
|
|
||||||
document.querySelectorAll('.menu-dropdown, .submenu').forEach(el => el.classList.remove('open'));
|
|
||||||
document.querySelectorAll('.menu-btn').forEach(el => el.classList.remove('open'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireTopLevelMenu(btnId, dropdownId) {
|
|
||||||
const btn = document.getElementById(btnId);
|
|
||||||
const dropdown = document.getElementById(dropdownId);
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const willOpen = !dropdown.classList.contains('open');
|
|
||||||
closeAllMenus();
|
|
||||||
if (willOpen) { dropdown.classList.add('open'); btn.classList.add('open'); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireMenu() {
|
|
||||||
wireTopLevelMenu('menu-bestand-btn', 'menu-bestand-dropdown');
|
|
||||||
wireTopLevelMenu('menu-bewerken-btn', 'menu-bewerken-dropdown');
|
|
||||||
wireTopLevelMenu('menu-beeld-btn', 'menu-beeld-dropdown');
|
|
||||||
|
|
||||||
document.getElementById('menu-nieuw-item').addEventListener('click', (e) => e.stopPropagation());
|
|
||||||
document.querySelectorAll('.menu-nieuw-option').forEach(opt => {
|
|
||||||
opt.addEventListener('click', () => openNew(opt.dataset.kind, opt.dataset.template));
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('menu-opslaan-als').addEventListener('click', () => {
|
|
||||||
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
|
|
||||||
closeAllMenus();
|
|
||||||
});
|
|
||||||
document.getElementById('menu-importeren').addEventListener('click', () => {
|
|
||||||
if (currentEditor && typeof currentEditor.triggerImport === 'function') currentEditor.triggerImport();
|
|
||||||
closeAllMenus();
|
|
||||||
});
|
|
||||||
document.getElementById('menu-mirror-h').addEventListener('click', () => {
|
|
||||||
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
|
|
||||||
closeAllMenus();
|
|
||||||
});
|
|
||||||
document.getElementById('menu-fullscreen').addEventListener('click', () => {
|
|
||||||
toggleBrowserFullscreen();
|
|
||||||
closeAllMenus();
|
|
||||||
});
|
|
||||||
document.getElementById('menu-3d-fullscreen').addEventListener('click', () => {
|
|
||||||
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
|
|
||||||
closeAllMenus();
|
|
||||||
});
|
|
||||||
document.getElementById('menu-camera-item').addEventListener('click', (e) => e.stopPropagation());
|
|
||||||
document.getElementById('menu-walk').addEventListener('click', () => {
|
|
||||||
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk();
|
|
||||||
closeAllMenus();
|
|
||||||
});
|
|
||||||
document.getElementById('menu-2d-fullscreen').addEventListener('click', () => {
|
|
||||||
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
|
|
||||||
closeAllMenus();
|
|
||||||
});
|
|
||||||
document.getElementById('menu-project-props').addEventListener('click', () => {
|
|
||||||
if (currentEditor && typeof currentEditor.openProjectProperties === 'function') currentEditor.openProjectProperties();
|
|
||||||
closeAllMenus();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', closeAllMenus);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimale markdown-weergave voor de licentietekst (koppen + alinea's).
|
// Minimale markdown-weergave voor de licentietekst (koppen + alinea's).
|
||||||
function renderLicenseHtml(md) {
|
function renderLicenseHtml(md) {
|
||||||
const esc = (t) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
const esc = (t) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
@@ -231,10 +210,6 @@ function renderLicenseHtml(md) {
|
|||||||
function wireAbout() {
|
function wireAbout() {
|
||||||
const overlay = document.getElementById('about-overlay');
|
const overlay = document.getElementById('about-overlay');
|
||||||
document.getElementById('about-license').innerHTML = renderLicenseHtml(window.TECTUM_LICENSE_MD || '');
|
document.getElementById('about-license').innerHTML = renderLicenseHtml(window.TECTUM_LICENSE_MD || '');
|
||||||
document.getElementById('btn-about').addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
overlay.classList.add('open');
|
|
||||||
});
|
|
||||||
document.getElementById('about-close').addEventListener('click', () => overlay.classList.remove('open'));
|
document.getElementById('about-close').addEventListener('click', () => overlay.classList.remove('open'));
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
|
||||||
}
|
}
|
||||||
@@ -251,7 +226,6 @@ function wireNewModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openNewModal() {
|
function openNewModal() {
|
||||||
closeAllMenus();
|
|
||||||
document.getElementById('new-overlay').classList.add('open');
|
document.getElementById('new-overlay').classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +248,12 @@ function wireHotkeys() {
|
|||||||
openNewModal();
|
openNewModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Easter egg (shooter); e.code omdat Alt/Shift e.key per toetsenbord verandert.
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyL') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentEditor && typeof currentEditor.toggleShooter === 'function') currentEditor.toggleShooter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
|
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
|
||||||
if (e.key.toLowerCase() === 'f') {
|
if (e.key.toLowerCase() === 'f') {
|
||||||
@@ -293,7 +273,8 @@ function wireHotkeys() {
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
||||||
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
||||||
wireMenu();
|
Ribbon.init(document.getElementById('ribbon'));
|
||||||
|
Ribbon.setSource('app', appRibbonTabs());
|
||||||
wireAbout();
|
wireAbout();
|
||||||
wireNewModal();
|
wireNewModal();
|
||||||
wireHotkeys();
|
wireHotkeys();
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// core/build-stages.js — Bouwfasen (window.BuildStages): de volgorde waarin
|
||||||
|
// een unit in de hal wordt opgebouwd. Eén begrip voor zowel de weergave
|
||||||
|
// (schuif "Bouwfase" in de 3D) als het weglaten van verborgen delen:
|
||||||
|
//
|
||||||
|
// 0 regelwerk vloer-, wand- en dakbalken
|
||||||
|
// 1 elektra installatiebuizen, dozen en meterkast (ElektraModel)
|
||||||
|
// 2 beplating spaanplaat binnen, OSB vloer/plafond, cementplaat buiten
|
||||||
|
// 3 doek vochtdoek
|
||||||
|
// 4 latwerk kruislat, trasraamplaat
|
||||||
|
// 5 gevel rabat, steen, kozijnen, dakbedekking, sandwichpanelen
|
||||||
|
// 6 afwerking dakrand, verlichting, vloerafwerking, verf binnen
|
||||||
|
//
|
||||||
|
// Fasen zijn cumulatief: fase n toont alles t/m n. Een laag die in de
|
||||||
|
// gekozen fase aan beide zijden is ingesloten door latere lagen wordt niet
|
||||||
|
// gebouwd (bijv. stijlen tussen spaanplaat en doek, latten achter de
|
||||||
|
// rabat). De specificaties rekenen altijd met alle lagen.
|
||||||
|
//
|
||||||
|
// Plain script (geen ES-module); het model-deel heeft geen DOM nodig.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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 en plafond, 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]));
|
||||||
|
|
||||||
|
// Fase (getal of undefined = afgewerkt) normaliseren.
|
||||||
|
const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL;
|
||||||
|
// Is laag `key` zichtbaar in fase `stage`?
|
||||||
|
const shows = (stage, key) => clamp(stage) >= INDEX[key];
|
||||||
|
|
||||||
|
// Schuif onderin een 3D-weergave (DOM). pane = de weergave (position:
|
||||||
|
// relative); onChange(stage) bij elke stap. Geeft { set(stage), el,
|
||||||
|
// extra } terug; extra = vrije rij onder de schuif (bijv. doorsnede).
|
||||||
|
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)));
|
||||||
|
});
|
||||||
|
// Toetsen in de weergave mogen de schuif niet bedienen (T, R, ...).
|
||||||
|
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
|
||||||
|
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,174 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// core/granny-kit.js — onderdelen van de easter egg (core/shooter.js):
|
||||||
|
// oma-sprite, gibs, kreten,
|
||||||
|
// geluidseffecten, soundtrack en de omroeper à la Unreal Tournament.
|
||||||
|
// Plain script (geen ES-module).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const GrannyKit = (() => {
|
||||||
|
// Oud vrouwtje met rollator, 16x25 pixels (twee loopbeelden).
|
||||||
|
const LADY = [
|
||||||
|
'......hHHh......',
|
||||||
|
'.....HHHHHH.....',
|
||||||
|
'....HHHHHHHH....',
|
||||||
|
'....HSSSSSSH....',
|
||||||
|
'....SGEsSGES....',
|
||||||
|
'....SSSssSSS....',
|
||||||
|
'.....SsrrsS.....',
|
||||||
|
'......SSSS......',
|
||||||
|
'.....WWWWWW.....',
|
||||||
|
'...DDDDWWDDDD...',
|
||||||
|
'..DDDDDDDDDDDD..',
|
||||||
|
'..DdDDDDDDDDdD..',
|
||||||
|
'..DdDDDDDDDDdD..',
|
||||||
|
'MSSMMMMMMMMMMSSM',
|
||||||
|
'.M.DDDDDDDDDD.M.',
|
||||||
|
'.MRRRRRRRRRRRRM.',
|
||||||
|
'.MRrRrRrRrRrRRM.',
|
||||||
|
'.MRRRRRRRRRRRRM.',
|
||||||
|
'.M..dDDDDDDd..M.',
|
||||||
|
'.M...DDDDDD...M.',
|
||||||
|
'.M....LL.LL...M.',
|
||||||
|
'.M....LL..LL..M.',
|
||||||
|
'.M....BB..BB..M.',
|
||||||
|
'KKK..........KKK',
|
||||||
|
'KkK..........KkK'
|
||||||
|
];
|
||||||
|
const LADY_B = LADY.slice(0, 20).concat([
|
||||||
|
'.M...LL..LL...M.',
|
||||||
|
'.M...LL...LL..M.',
|
||||||
|
'.M...BB...BB..M.',
|
||||||
|
'KkK..........KkK',
|
||||||
|
'KKK..........KKK'
|
||||||
|
]);
|
||||||
|
const PAL = { h: '#9a9aa8', H: '#d0d0da', S: '#f0c8a0', s: '#d09878', E: '#101010', G: '#707080', r: '#a04040',
|
||||||
|
W: '#f4f4f4', D: '#8040a0', d: '#5a2a78', M: '#b8c0c8', R: '#c03030', L: '#c8a888', B: '#402018', K: '#202020', k: '#606060' };
|
||||||
|
|
||||||
|
// Gibs: 8-bit stukjes oma (vlees, botjes, gebit, bril, handtas, breibol, rollatorwiel).
|
||||||
|
const GIBS = [
|
||||||
|
['..rr....', '.rRRr...', 'rRRRRr..', 'rRrRRRr.', '.rRRRRr.', '..rRRr..', '...rr...'],
|
||||||
|
['.rr.rr..', 'rRRrRRr.', 'rRRRRRr.', '.rRRRr..', '..rrr...'],
|
||||||
|
['ww....ww', 'wWw..wWw', '.wWwwWw.', '..wWWw..', '.wWwwWw.', 'wWw..wWw', 'ww....ww'],
|
||||||
|
['.pppppp.', 'pWWWWWWp', 'pWkWkWkp', 'pWWWWWWp', '.pppppp.'],
|
||||||
|
['GG..GG..', 'GbG.GbG.', 'GGGGGGG.', '........'],
|
||||||
|
['..nn....', '.n..n...', 'nNNNNNn.', 'NNNNNNN.', 'NNyNNNN.', 'NNNNNNN.'],
|
||||||
|
['.YYYY...', 'YyYYyY..', 'YYyYYy..', 'YyYYyY..', '.YYYY...', '.....Y..', '......Y.'],
|
||||||
|
['.KKKK...', 'KkkkkK..', 'KkMMkK..', 'KkMMkK..', 'KkkkkK..', '.KKKK...']
|
||||||
|
];
|
||||||
|
const GIB_PAL = { r: '#6a0808', R: '#c01818', w: '#a09880', W: '#f0ecdc', p: '#e07888', k: '#303030', G: '#606070', b: '#a0c8f0',
|
||||||
|
n: '#402018', N: '#7a4a22', y: '#d0b040', Y: '#c050a0', K: '#202020', M: '#b8c0c8' };
|
||||||
|
|
||||||
|
const SHOUTS = ['"Jongeman! Voeten vegen!"', '"In mijn tijd hadden we geen raketten!"', '"Ik bel je moeder!"',
|
||||||
|
'"Waar is de bingo?"', '"Dat dak lekt, hoor!"', '"Is dit wel vergund?"', '"Ik heb een abonnement op de bus!"'];
|
||||||
|
const DEATH_SHOUTS = ['"Oeeehh, mijn heup!"', '"Dat zeg ik tegen de wijkagent!"', '"Aaargh, de bingo..."'];
|
||||||
|
const HIT_SHOUTS = ['*BAM* met de handtas!', '*klets* met de rollator!', '"Neem een koekje, en dan wegwezen!"'];
|
||||||
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
|
||||||
|
// Pixelraster -> canvas (1 px per teken, optioneel met rand en één tintkleur;
|
||||||
|
// bottom = figuur op de onderrand, voor sprites die op de grond staan).
|
||||||
|
function pixelCanvas(rows, pal, opts) {
|
||||||
|
const o = Object.assign({ pad: 0, size: 0, tint: null, bottom: false }, opts || {});
|
||||||
|
const w = Math.max(...rows.map(r => r.length)), h = rows.length;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = o.size || w + o.pad * 2;
|
||||||
|
c.height = o.size || h + o.pad * 2;
|
||||||
|
const g = c.getContext('2d');
|
||||||
|
const ox = Math.floor((c.width - w) / 2), oy = o.bottom ? c.height - h : Math.floor((c.height - h) / 2);
|
||||||
|
rows.forEach((row, y) => [...row].forEach((ch, x) => {
|
||||||
|
if (!pal[ch]) return;
|
||||||
|
g.fillStyle = o.tint || pal[ch];
|
||||||
|
g.fillRect(ox + x, oy + y, 1, 1);
|
||||||
|
}));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geluidseffecten: gesynthetiseerde ruis via WebAudio.
|
||||||
|
function createSfx() {
|
||||||
|
let audio = null;
|
||||||
|
return {
|
||||||
|
bang(len, freq, vol) {
|
||||||
|
try {
|
||||||
|
audio = audio || new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const n = Math.floor(audio.sampleRate * len), buf = audio.createBuffer(1, n, audio.sampleRate), d = buf.getChannelData(0);
|
||||||
|
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 3);
|
||||||
|
const src = audio.createBufferSource(), f = audio.createBiquadFilter(), g = audio.createGain();
|
||||||
|
src.buffer = buf; f.type = 'lowpass'; f.frequency.value = freq; g.gain.value = vol;
|
||||||
|
src.connect(f).connect(g).connect(audio.destination);
|
||||||
|
src.start();
|
||||||
|
} catch (e) { /* geen geluid, jammer */ }
|
||||||
|
},
|
||||||
|
close() { if (audio) audio.close(); audio = null; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soundtrack: "Five Armies" Kevin MacLeod (incompetech.com), CC BY 4.0.
|
||||||
|
const MUSIC_CREDIT = '♪ "Five Armies" Kevin MacLeod (incompetech.com) · CC BY 4.0';
|
||||||
|
function createMusic() {
|
||||||
|
const music = new Audio('assets/audio/five-armies.mp3');
|
||||||
|
music.loop = true;
|
||||||
|
music.volume = 0.5;
|
||||||
|
return {
|
||||||
|
play() { music.currentTime = 0; music.play().catch(() => {}); }, // toetsdruk = gebruikersactie
|
||||||
|
stop() { music.pause(); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Omroeper (Web Speech API + grote tekst in labelEl).
|
||||||
|
// Multi-kill: kills binnen MULTI_WINDOW s na elkaar. Spree: kills zonder geraakt te worden.
|
||||||
|
const MULTI_WINDOW = 6;
|
||||||
|
const MULTI = { 2: 'Double Granny!', 3: 'Multi Granny!', 4: 'Mega Granny!', 5: 'Ultra Granny!', 6: 'M M M MONSTER GRANNY!' };
|
||||||
|
const SPREE = { 5: 'Bingo Spree!', 10: 'Rollator Rampage!', 15: 'Dominating the retirement home!', 20: 'Unstoppable!', 25: 'Godlike!', 30: 'Wicked Sick... of grannies!' };
|
||||||
|
function createAnnouncer(labelEl) {
|
||||||
|
let kills = 0, multi = 0, lastKillAt = -99, spree = 0, clock = 0, timer = 0;
|
||||||
|
function say(text) {
|
||||||
|
labelEl.textContent = text.replace(/^M M M /, 'M-M-M-');
|
||||||
|
labelEl.classList.remove('show'); void labelEl.offsetWidth; labelEl.classList.add('show');
|
||||||
|
timer = 2.2;
|
||||||
|
try {
|
||||||
|
const u = new SpeechSynthesisUtterance(text);
|
||||||
|
const v = speechSynthesis.getVoices().find(x => /^en(-|_)(US|GB)/i.test(x.lang) && /male|david|daniel|george/i.test(x.name))
|
||||||
|
|| speechSynthesis.getVoices().find(x => /^en/i.test(x.lang));
|
||||||
|
if (v) u.voice = v;
|
||||||
|
u.lang = 'en-US'; u.pitch = 0.1; u.rate = 0.8; u.volume = 1;
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
speechSynthesis.speak(u);
|
||||||
|
} catch (e) { /* geen stem, dan alleen tekst */ }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
say,
|
||||||
|
get kills() { return kills; },
|
||||||
|
kill() {
|
||||||
|
kills++;
|
||||||
|
multi = clock - lastKillAt <= MULTI_WINDOW ? multi + 1 : 1;
|
||||||
|
lastKillAt = clock;
|
||||||
|
spree++;
|
||||||
|
if (kills === 1) say('First Blood!');
|
||||||
|
else if (multi >= 2) say(MULTI[Math.min(multi, 6)]);
|
||||||
|
else if (SPREE[spree]) say(SPREE[spree]);
|
||||||
|
return kills;
|
||||||
|
},
|
||||||
|
hurt() {
|
||||||
|
if (spree >= 5) say('Spree ended by a handbag!');
|
||||||
|
spree = 0;
|
||||||
|
},
|
||||||
|
reset() { kills = multi = spree = 0; lastKillAt = -99; },
|
||||||
|
update(dt) {
|
||||||
|
clock += dt;
|
||||||
|
if (timer > 0 && (timer -= dt) <= 0) labelEl.classList.remove('show');
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
try { speechSynthesis.cancel(); } catch (e) { /* */ }
|
||||||
|
labelEl.classList.remove('show');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { LADY, LADY_B, PAL, GIBS, GIB_PAL, SHOUTS, DEATH_SHOUTS, HIT_SHOUTS, MUSIC_CREDIT,
|
||||||
|
pick, pixelCanvas, createSfx, createMusic, createAnnouncer };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// core/ribbon.js — Ribbon (window.Ribbon): de bedieningsbalk bovenin. Eén
|
||||||
|
// plek voor alle commando's en instellingen; de weergaven eronder blijven
|
||||||
|
// vrij.
|
||||||
|
//
|
||||||
|
// Tabbladen komen uit bronnen: 'app' (Bestand, Beeld, Extra) en 'editor'
|
||||||
|
// (de actieve editor, bijv. Start, Gevel, Dak, ... en een contextueel
|
||||||
|
// tabblad voor de selectie). Tabbladen met dezelfde sleutel uit meerdere
|
||||||
|
// bronnen worden samengevoegd (bijv. Beeld: weergave-opties van de woning
|
||||||
|
// + vensterknoppen van de app).
|
||||||
|
//
|
||||||
|
// Ribbon.setSource('editor', [{
|
||||||
|
// key, label, order (sortering), contextual (gekleurd, achteraan),
|
||||||
|
// groups: () => [{ label, html }], // inhoud, opnieuw bij elke weergave
|
||||||
|
// wire: (body) => {}, // events koppelen na het tekenen
|
||||||
|
// onShow: () => {} // tabblad wordt actief
|
||||||
|
// }]);
|
||||||
|
// Ribbon.activate(key) · Ribbon.refresh() · Ribbon.active() · Ribbon.onChange(fn)
|
||||||
|
//
|
||||||
|
// Groepen: een raster van 3 rijen hoog; velden (UI-kit) staan per kolom
|
||||||
|
// onder elkaar. Grote knop: Ribbon.big(...), kleine: Ribbon.small(...).
|
||||||
|
// Plain script (geen ES-module).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.Ribbon = (() => {
|
||||||
|
const sources = {}; // naam -> [tab]
|
||||||
|
const SOURCE_ORDER = ['app', 'editor'];
|
||||||
|
let root = null, tabsEl = null, bodyEl = null;
|
||||||
|
let activeKey = null, lastPlainKey = null;
|
||||||
|
let auto = true; // tabblad nog niet door de gebruiker gekozen: voorkeur 'start'
|
||||||
|
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>`;
|
||||||
|
// Grote knop (3 rijen hoog): icoon boven het label.
|
||||||
|
const big = (label, iconName, attrs, cls) =>
|
||||||
|
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||||
|
// Kleine knop (1 rij).
|
||||||
|
const small = (label, iconName, attrs, cls) =>
|
||||||
|
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||||
|
// Waarde-regel (alleen lezen) binnen een groep.
|
||||||
|
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) { /* alleen deze sessie */ } }
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bron vervangen. Een nieuw contextueel tabblad (andere sleutel) wordt
|
||||||
|
// actief als opts.focus; verdwijnt het actieve, dan terug naar het vorige.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// fn(key) bij elke wissel van het actieve tabblad.
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// core/shooter.js — easter egg: schieten in de loopmodus (Ctrl+Shift+Alt+L).
|
||||||
|
// klik in beeld muis vangen (Esc = loslaten)
|
||||||
|
// linkermuisknop vuren
|
||||||
|
// 1 / 2 / scrollwiel pistool / raketwerper
|
||||||
|
// Kogelgaten en roetplekken blijven op wanden/dak/vloer achter, raketten
|
||||||
|
// laten ook wat rondvliegend metselwerk achter. Bewust simpel en buggy.
|
||||||
|
// Plain script (geen ES-module); gebruikt THREE.
|
||||||
|
//
|
||||||
|
// const gun = createShooter({ scene, camera, domElement, pane, walk, targets: () => [..] });
|
||||||
|
// gun.start(); gun.update(dt); gun.stop(); gun.dispose();
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function createShooter(o) {
|
||||||
|
const { scene, camera, domElement, pane, walk } = o;
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = 'shooter';
|
||||||
|
let active = false, weapon = 0, cooldown = 0, recoil = 0;
|
||||||
|
const decals = [], rockets = [], blasts = [], debris = [];
|
||||||
|
const MAX_DECALS = 250;
|
||||||
|
|
||||||
|
// --- Texturen (canvas) ---
|
||||||
|
function canvasTex(size, draw) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = size;
|
||||||
|
draw(c.getContext('2d'), size);
|
||||||
|
const t = new THREE.CanvasTexture(c);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
const holeTex = canvasTex(64, (g, s) => {
|
||||||
|
const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
|
||||||
|
r.addColorStop(0, 'rgba(0,0,0,1)'); r.addColorStop(0.18, 'rgba(10,8,6,1)');
|
||||||
|
r.addColorStop(0.3, 'rgba(60,50,40,0.8)'); r.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
g.fillStyle = r; g.fillRect(0, 0, s, s);
|
||||||
|
g.strokeStyle = 'rgba(20,15,10,0.7)'; g.lineWidth = 1.5;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const a = Math.random() * Math.PI * 2, l = s * (0.25 + Math.random() * 0.2);
|
||||||
|
g.beginPath(); g.moveTo(s / 2, s / 2); g.lineTo(s / 2 + Math.cos(a) * l, s / 2 + Math.sin(a) * l); g.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const scorchTex = canvasTex(128, (g, s) => {
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const x = s / 2 + (Math.random() - 0.5) * s * 0.5, y = s / 2 + (Math.random() - 0.5) * s * 0.5;
|
||||||
|
const rr = s * (0.1 + Math.random() * 0.25);
|
||||||
|
const r = g.createRadialGradient(x, y, 0, x, y, rr);
|
||||||
|
r.addColorStop(0, 'rgba(15,10,5,0.5)'); r.addColorStop(1, 'rgba(15,10,5,0)');
|
||||||
|
g.fillStyle = r; g.fillRect(0, 0, s, s);
|
||||||
|
}
|
||||||
|
g.fillStyle = 'rgba(255,120,20,0.6)';
|
||||||
|
for (let i = 0; i < 25; i++) g.fillRect(s / 2 + (Math.random() - 0.5) * s * 0.4, s / 2 + (Math.random() - 0.5) * s * 0.4, 2, 2);
|
||||||
|
});
|
||||||
|
const fireTex = canvasTex(64, (g, s) => {
|
||||||
|
const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
|
||||||
|
r.addColorStop(0, 'rgba(255,255,220,1)'); r.addColorStop(0.3, 'rgba(255,180,40,0.9)');
|
||||||
|
r.addColorStop(0.7, 'rgba(200,50,0,0.4)'); r.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
g.fillStyle = r; g.fillRect(0, 0, s, s);
|
||||||
|
});
|
||||||
|
// Puin krijgt een willekeurige steentextuur uit de bibliotheek (als die laadt).
|
||||||
|
const brickTex = new THREE.TextureLoader().load(['assets/textures/buitenmuur/R200 - Witte voeg.jpg', 'assets/textures/buitenmuur/K739 - Witte voeg.jpg', 'assets/textures/buitenmuur/R480 - Witte voeg.jpg'][Math.floor(Math.random() * 3)]);
|
||||||
|
const decalMat = (map) => new THREE.MeshBasicMaterial({ map, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4 });
|
||||||
|
const holeMat = decalMat(holeTex), scorchMat = decalMat(scorchTex);
|
||||||
|
const debrisMat = new THREE.MeshStandardMaterial({ map: brickTex, roughness: 0.9 });
|
||||||
|
const fireMat = new THREE.SpriteMaterial({ map: fireTex, blending: THREE.AdditiveBlending, depthWrite: false });
|
||||||
|
|
||||||
|
// --- Wapens (vastgeplakt aan de camera) ---
|
||||||
|
const metal = new THREE.MeshStandardMaterial({ color: 0x2a2c30, metalness: 0.7, roughness: 0.35 });
|
||||||
|
const olive = new THREE.MeshStandardMaterial({ color: 0x4b5a32, roughness: 0.6 });
|
||||||
|
function box(w, h, d, m, x, y, z) { const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); b.position.set(x, y, z); return b; }
|
||||||
|
const pistol = new THREE.Group();
|
||||||
|
pistol.add(box(0.035, 0.045, 0.2, metal, 0, 0, -0.1), box(0.03, 0.09, 0.04, metal, 0, -0.055, -0.02));
|
||||||
|
const launcher = new THREE.Group();
|
||||||
|
const tube = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.7, 12, 1, true), olive);
|
||||||
|
tube.material.side = THREE.DoubleSide;
|
||||||
|
tube.rotation.x = Math.PI / 2; tube.position.z = -0.25;
|
||||||
|
launcher.add(tube, box(0.03, 0.09, 0.04, metal, 0, -0.08, -0.05), box(0.02, 0.05, 0.03, metal, 0.05, 0.05, -0.2));
|
||||||
|
const flash = new THREE.Sprite(fireMat.clone());
|
||||||
|
flash.scale.set(0.15, 0.15, 1);
|
||||||
|
flash.visible = false;
|
||||||
|
const viewModel = new THREE.Group();
|
||||||
|
viewModel.add(pistol, launcher, flash);
|
||||||
|
const gunLight = new THREE.PointLight(0xffaa44, 0, 6);
|
||||||
|
group.add(viewModel, gunLight);
|
||||||
|
|
||||||
|
// --- HUD ---
|
||||||
|
const hud = document.createElement('div');
|
||||||
|
hud.className = 'shooter-hud';
|
||||||
|
hud.innerHTML = '<div class="shooter-cross"></div><div class="shooter-weapon"></div><div class="shooter-credit"></div><div class="shooter-kills"></div><div class="shooter-shout"></div><div class="shooter-ouch"></div><div class="shooter-announce"></div><div class="shooter-help">Klik = muis vangen · linkerknop = vuren · 1/2 of scroll = wapen · Esc = muis los · Ctrl+Shift+Alt+L = stoppen</div>';
|
||||||
|
pane.appendChild(hud);
|
||||||
|
hud.querySelector('.shooter-credit').textContent = GrannyKit.MUSIC_CREDIT;
|
||||||
|
const weaponLabel = hud.querySelector('.shooter-weapon');
|
||||||
|
const killsLabel = hud.querySelector('.shooter-kills');
|
||||||
|
const shoutLabel = hud.querySelector('.shooter-shout');
|
||||||
|
const ouch = hud.querySelector('.shooter-ouch');
|
||||||
|
let shoutTimer = 0;
|
||||||
|
function shout(text) { shoutLabel.textContent = text; shoutLabel.classList.add('show'); shoutTimer = 2.5; }
|
||||||
|
function setKills(n) { killsLabel.textContent = "Oma's: " + n; }
|
||||||
|
const announcer = GrannyKit.createAnnouncer(hud.querySelector('.shooter-announce'));
|
||||||
|
|
||||||
|
function selectWeapon(i) {
|
||||||
|
weapon = (i + 2) % 2;
|
||||||
|
pistol.visible = weapon === 0;
|
||||||
|
launcher.visible = weapon === 1;
|
||||||
|
weaponLabel.textContent = weapon === 0 ? '[1] Pistool ∞' : '[2] Raketwerper ∞';
|
||||||
|
flash.position.set(0, 0, weapon === 0 ? -0.22 : -0.62);
|
||||||
|
}
|
||||||
|
selectWeapon(0);
|
||||||
|
|
||||||
|
// --- Vijand: oud vrouwtje met rollator (8-bit sprite, Doom-stijl) ---
|
||||||
|
// Eén tegelijk: spawnt op 12-18 m, schuifelt op je af, na de kill komt de volgende.
|
||||||
|
function spriteTex(rows, tint) {
|
||||||
|
const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.PAL, { size: 32, tint, bottom: true }));
|
||||||
|
t.magFilter = t.minFilter = THREE.NearestFilter;
|
||||||
|
t.generateMipmaps = false;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
const ladyTex = [spriteTex(GrannyKit.LADY), spriteTex(GrannyKit.LADY_B)];
|
||||||
|
const ladyHurtTex = spriteTex(GrannyKit.LADY, '#ff3030');
|
||||||
|
const LADY_H = 1.6 * 32 / 25; // sprite-hoogte (m) zodat de figuur ~1,6 m is
|
||||||
|
let mob = null, spawnTimer = 1.5, attackTimer = 0, animT = 0;
|
||||||
|
function spawnMob() {
|
||||||
|
const me = camera.position;
|
||||||
|
let x = me.x, z = me.z;
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const a = Math.random() * Math.PI * 2, d = 12 + Math.random() * 6;
|
||||||
|
x = me.x + Math.sin(a) * d; z = me.z + Math.cos(a) * d;
|
||||||
|
if (!o.blocked || !o.blocked(x, z)) break;
|
||||||
|
}
|
||||||
|
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: ladyTex[0], transparent: true, alphaTest: 0.5 }));
|
||||||
|
sp.scale.set(LADY_H, LADY_H, 1);
|
||||||
|
group.add(sp);
|
||||||
|
mob = { sp, x, z, hp: 3, alive: true, hurt: 0, dead: 0 };
|
||||||
|
placeMob();
|
||||||
|
shout(GrannyKit.pick(GrannyKit.SHOUTS));
|
||||||
|
}
|
||||||
|
function mobFloor(x, z) { return o.floorAt ? o.floorAt(x, z) : 0; }
|
||||||
|
function placeMob() { mob.sp.position.set(mob.x, mobFloor(mob.x, mob.z) + LADY_H / 2 - (mob.dead ? LADY_H * 0.3 : 0), mob.z); }
|
||||||
|
function mobCenter() { return new THREE.Vector3(mob.x, mobFloor(mob.x, mob.z) + 0.8, mob.z); }
|
||||||
|
// Straal tegen een staande cilinder (r 0,4 m, 1,6 m hoog) om de oma.
|
||||||
|
function rayHitsMob(origin, dir, maxDist) {
|
||||||
|
if (!mob || !mob.alive) return false;
|
||||||
|
const c = mobCenter();
|
||||||
|
const t = c.clone().sub(origin).dot(dir);
|
||||||
|
if (t < 0 || t > maxDist) return false;
|
||||||
|
const p = origin.clone().addScaledVector(dir, t);
|
||||||
|
return Math.hypot(p.x - c.x, p.z - c.z) < 0.4 && Math.abs(p.y - c.y) < 0.8;
|
||||||
|
}
|
||||||
|
const gibTex = GrannyKit.GIBS.map(rows => {
|
||||||
|
const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.GIB_PAL, { size: 8 }));
|
||||||
|
t.magFilter = t.minFilter = THREE.NearestFilter;
|
||||||
|
t.generateMipmaps = false;
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
const bloodTex = canvasTex(32, (g) => {
|
||||||
|
g.fillStyle = '#7a0a0a';
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const a = Math.random() * 6.28, d = Math.random() * 10, r = 2 + Math.random() * 5;
|
||||||
|
g.beginPath(); g.arc(16 + Math.cos(a) * d, 16 + Math.sin(a) * d, r, 0, 6.28); g.fill();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bloodTex.magFilter = THREE.NearestFilter;
|
||||||
|
const bloodMat = decalMat(bloodTex);
|
||||||
|
function spawnGibs(center, count) {
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Vooral vlees, plus een paar herkenbare stukken.
|
||||||
|
const k = i < count * 0.5 ? Math.floor(Math.random() * 2) : Math.floor(Math.random() * GrannyKit.GIBS.length);
|
||||||
|
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: gibTex[k], alphaTest: 0.5 }));
|
||||||
|
const sz = 0.18 + Math.random() * 0.14;
|
||||||
|
sp.scale.set(sz, sz, 1);
|
||||||
|
sp.position.copy(center).add(new THREE.Vector3((Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.8, (Math.random() - 0.5) * 0.4));
|
||||||
|
const v = new THREE.Vector3((Math.random() - 0.5) * 9, 2 + Math.random() * 6, (Math.random() - 0.5) * 9);
|
||||||
|
group.add(sp);
|
||||||
|
debris.push({ mesh: sp, v, spin: new THREE.Vector3((Math.random() - 0.5) * 20, 0, 0), rest: false, gib: true });
|
||||||
|
}
|
||||||
|
// Plas onder de plek des onheils.
|
||||||
|
bloodSplat(center.x, center.z, 1.4);
|
||||||
|
trimDebris();
|
||||||
|
}
|
||||||
|
function bloodSplat(x, z, size) {
|
||||||
|
const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), bloodMat);
|
||||||
|
m.rotation.set(-Math.PI / 2, 0, Math.random() * 6.28);
|
||||||
|
m.position.set(x, mobFloor(x, z) + 0.006, z);
|
||||||
|
group.add(m);
|
||||||
|
decals.push(m);
|
||||||
|
}
|
||||||
|
function trimDebris() {
|
||||||
|
while (debris.length > 200) {
|
||||||
|
const old = debris.shift();
|
||||||
|
group.remove(old.mesh);
|
||||||
|
if (old.mesh.isSprite) old.mesh.material.dispose(); else old.mesh.geometry.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function damageMob(n, gib) {
|
||||||
|
if (!mob || !mob.alive) return;
|
||||||
|
mob.hp -= n;
|
||||||
|
mob.hurt = 0.15;
|
||||||
|
spawnBlast(mobCenter(), 0.4, 0.2);
|
||||||
|
if (mob.hp > 0) { bang(0.2, 900, 0.3); return; }
|
||||||
|
mob.alive = false;
|
||||||
|
mob.dead = 0.001;
|
||||||
|
mob.sp.material.map = ladyHurtTex;
|
||||||
|
mob.sp.material.rotation = Math.PI / 2; // omgevallen
|
||||||
|
if (gib) { mob.sp.visible = false; mob.dead = 2.4; spawnGibs(mobCenter(), 22); }
|
||||||
|
else spawnGibs(mobCenter(), 5);
|
||||||
|
setKills(announcer.kill());
|
||||||
|
shout(GrannyKit.pick(GrannyKit.DEATH_SHOUTS));
|
||||||
|
spawnTimer = 1;
|
||||||
|
}
|
||||||
|
function updateMob(t) {
|
||||||
|
if (!mob) { spawnTimer -= t; if (spawnTimer <= 0) spawnMob(); return; }
|
||||||
|
if (!mob.alive) {
|
||||||
|
mob.dead += t;
|
||||||
|
mob.sp.material.opacity = Math.max(0, 1 - (mob.dead - 1.5));
|
||||||
|
mob.sp.material.transparent = true;
|
||||||
|
placeMob();
|
||||||
|
if (mob.dead > 2.5) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const me = camera.position;
|
||||||
|
const dx = me.x - mob.x, dz = me.z - mob.z, dist = Math.hypot(dx, dz);
|
||||||
|
if (dist > 0.9) {
|
||||||
|
const v = 1.3 * t / dist;
|
||||||
|
const nx = mob.x + dx * v, nz = mob.z + dz * v;
|
||||||
|
if (!o.blocked || !o.blocked(nx, nz)) { mob.x = nx; mob.z = nz; }
|
||||||
|
else if (!o.blocked(nx, mob.z)) mob.x = nx;
|
||||||
|
else if (!o.blocked(mob.x, nz)) mob.z = nz;
|
||||||
|
animT += t;
|
||||||
|
} else {
|
||||||
|
attackTimer -= t;
|
||||||
|
if (attackTimer <= 0) {
|
||||||
|
attackTimer = 1.2;
|
||||||
|
bang(0.1, 400, 0.6);
|
||||||
|
ouch.classList.remove('show'); void ouch.offsetWidth; ouch.classList.add('show');
|
||||||
|
announcer.hurt();
|
||||||
|
shout(GrannyKit.pick(GrannyKit.HIT_SHOUTS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mob.hurt -= t;
|
||||||
|
mob.sp.material.map = mob.hurt > 0 ? ladyHurtTex : ladyTex[Math.floor(animT * 4) % 2];
|
||||||
|
placeMob();
|
||||||
|
}
|
||||||
|
|
||||||
|
const music = GrannyKit.createMusic();
|
||||||
|
const sfx = GrannyKit.createSfx();
|
||||||
|
const bang = sfx.bang;
|
||||||
|
|
||||||
|
// --- Raken ---
|
||||||
|
const ray = new THREE.Raycaster();
|
||||||
|
function hitScan(origin, dir, far) {
|
||||||
|
ray.set(origin, dir);
|
||||||
|
ray.far = far;
|
||||||
|
const hits = ray.intersectObjects(o.targets().filter(t => t && t.visible), true);
|
||||||
|
return hits.find(h => h.face && h.object.isMesh && isVisible(h.object)) || null;
|
||||||
|
}
|
||||||
|
function isVisible(obj) {
|
||||||
|
for (let p = obj; p; p = p.parent) if (!p.visible) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function addDecal(hit, mat, size) {
|
||||||
|
const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
|
||||||
|
if (n.dot(ray.ray.direction) > 0) n.negate(); // altijd de kant waar je vandaan schiet
|
||||||
|
const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), mat);
|
||||||
|
m.position.copy(hit.point).addScaledVector(n, 0.004);
|
||||||
|
m.lookAt(hit.point.clone().add(n));
|
||||||
|
m.rotateZ(Math.random() * Math.PI * 2);
|
||||||
|
group.add(m);
|
||||||
|
decals.push(m);
|
||||||
|
if (decals.length > MAX_DECALS) { const old = decals.shift(); group.remove(old); old.geometry.dispose(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = new THREE.Vector3();
|
||||||
|
function fire() {
|
||||||
|
if (cooldown > 0) return;
|
||||||
|
camera.getWorldDirection(tmpDir);
|
||||||
|
// Lichte spreiding, het blijft een configurator.
|
||||||
|
tmpDir.x += (Math.random() - 0.5) * 0.01; tmpDir.y += (Math.random() - 0.5) * 0.01;
|
||||||
|
tmpDir.normalize();
|
||||||
|
flash.visible = true;
|
||||||
|
flash.material.rotation = Math.random() * 6;
|
||||||
|
gunLight.intensity = 2;
|
||||||
|
if (weapon === 0) {
|
||||||
|
cooldown = 0.18; recoil = 1;
|
||||||
|
bang(0.15, 3000, 0.5);
|
||||||
|
const hit = hitScan(camera.position, tmpDir, 200);
|
||||||
|
if (rayHitsMob(camera.position, tmpDir, hit ? hit.distance : 200)) damageMob(1);
|
||||||
|
else if (hit) {
|
||||||
|
addDecal(hit, holeMat, 0.05 + Math.random() * 0.03);
|
||||||
|
spawnBlast(hit.point, 0.15, 0.12);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cooldown = 0.8; recoil = 2.5;
|
||||||
|
bang(0.5, 600, 0.6);
|
||||||
|
const r = new THREE.Group();
|
||||||
|
r.add(box(0.06, 0.06, 0.3, olive, 0, 0, 0));
|
||||||
|
const tail = new THREE.Sprite(fireMat);
|
||||||
|
tail.scale.set(0.3, 0.3, 1); tail.position.z = 0.2;
|
||||||
|
r.add(tail);
|
||||||
|
r.position.copy(camera.position).addScaledVector(tmpDir, 0.6);
|
||||||
|
r.lookAt(r.position.clone().add(tmpDir));
|
||||||
|
group.add(r);
|
||||||
|
rockets.push({ mesh: r, dir: tmpDir.clone(), speed: 14, life: 6 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnBlast(pos, size, life) {
|
||||||
|
const s = new THREE.Sprite(fireMat.clone());
|
||||||
|
s.position.copy(pos);
|
||||||
|
s.scale.set(size, size, 1);
|
||||||
|
group.add(s);
|
||||||
|
blasts.push({ s, size, life, t: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function explode(hit, point, normal) {
|
||||||
|
bang(1.2, 300, 1);
|
||||||
|
if (hit) {
|
||||||
|
addDecal(hit, scorchMat, 1.2 + Math.random() * 0.6);
|
||||||
|
point = hit.point;
|
||||||
|
normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
|
||||||
|
if (normal.dot(ray.ray.direction) > 0) normal.negate();
|
||||||
|
}
|
||||||
|
spawnBlast(point, 2.5, 0.5);
|
||||||
|
const light = new THREE.PointLight(0xff8833, 6, 12);
|
||||||
|
light.position.copy(point);
|
||||||
|
group.add(light);
|
||||||
|
blasts.push({ light, life: 0.5, t: 0 });
|
||||||
|
if (mob && mob.alive && mobCenter().distanceTo(point) < 3) damageMob(99, true);
|
||||||
|
if (!hit) return; // direct op de oma: alleen gibs, geen stenen
|
||||||
|
const n = normal;
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const sz = 0.05 + Math.random() * 0.12;
|
||||||
|
const d = new THREE.Mesh(new THREE.BoxGeometry(sz * 2, sz, sz), debrisMat);
|
||||||
|
d.position.copy(hit.point).addScaledVector(n, 0.1);
|
||||||
|
d.rotation.set(Math.random() * 6, Math.random() * 6, 0);
|
||||||
|
const v = n.clone().multiplyScalar(2 + Math.random() * 4).add(new THREE.Vector3((Math.random() - 0.5) * 6, Math.random() * 5, (Math.random() - 0.5) * 6));
|
||||||
|
group.add(d);
|
||||||
|
debris.push({ mesh: d, v, spin: new THREE.Vector3(Math.random() * 10, Math.random() * 10, 0), rest: false });
|
||||||
|
}
|
||||||
|
trimDebris();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Invoer ---
|
||||||
|
function onClick(e) {
|
||||||
|
if (!active) return;
|
||||||
|
if (document.pointerLockElement !== domElement) { domElement.requestPointerLock && domElement.requestPointerLock(); return; }
|
||||||
|
if (e.button === 0) fire();
|
||||||
|
}
|
||||||
|
function onMouseMove(e) {
|
||||||
|
if (!active || document.pointerLockElement !== domElement) return;
|
||||||
|
walk.look(-e.movementX * 0.0025, -e.movementY * 0.0025);
|
||||||
|
}
|
||||||
|
function onWheel(e) {
|
||||||
|
if (!active) return;
|
||||||
|
e.preventDefault();
|
||||||
|
selectWeapon(weapon + (e.deltaY > 0 ? 1 : -1));
|
||||||
|
}
|
||||||
|
function onKey(e) {
|
||||||
|
if (!active || e.ctrlKey) return;
|
||||||
|
if (e.key === '1') selectWeapon(0);
|
||||||
|
if (e.key === '2') selectWeapon(1);
|
||||||
|
}
|
||||||
|
domElement.addEventListener('mousedown', onClick);
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
domElement.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (mob) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; }
|
||||||
|
shoutLabel.classList.remove('show');
|
||||||
|
[...decals, ...debris.map(d => d.mesh), ...rockets.map(r => r.mesh), ...blasts.map(b => b.s || b.light)].forEach(m => group.remove(m));
|
||||||
|
decals.length = debris.length = rockets.length = blasts.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get active() { return active; },
|
||||||
|
start() {
|
||||||
|
active = true;
|
||||||
|
scene.add(group);
|
||||||
|
hud.classList.add('show');
|
||||||
|
announcer.reset();
|
||||||
|
setKills(0);
|
||||||
|
spawnTimer = 1.5;
|
||||||
|
music.play();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
active = false;
|
||||||
|
if (document.pointerLockElement === domElement) document.exitPointerLock();
|
||||||
|
clear();
|
||||||
|
scene.remove(group);
|
||||||
|
hud.classList.remove('show');
|
||||||
|
music.stop();
|
||||||
|
announcer.stop();
|
||||||
|
},
|
||||||
|
update(dt) {
|
||||||
|
if (!active) return;
|
||||||
|
const t = Math.min(dt, 0.05);
|
||||||
|
cooldown -= t;
|
||||||
|
// Wapen volgt de camera, met wat terugslag.
|
||||||
|
recoil = Math.max(0, recoil - t * 8);
|
||||||
|
viewModel.position.copy(camera.position);
|
||||||
|
viewModel.quaternion.copy(camera.quaternion);
|
||||||
|
viewModel.translateX(0.18); viewModel.translateY(-0.16 + recoil * 0.01); viewModel.translateZ(-0.25 + recoil * 0.03);
|
||||||
|
viewModel.rotateX(recoil * 0.08);
|
||||||
|
gunLight.position.copy(camera.position);
|
||||||
|
if (flash.visible && cooldown < (weapon === 0 ? 0.13 : 0.72)) flash.visible = false;
|
||||||
|
gunLight.intensity = Math.max(0, gunLight.intensity - t * 30);
|
||||||
|
announcer.update(t);
|
||||||
|
updateMob(t);
|
||||||
|
if (shoutTimer > 0 && (shoutTimer -= t) <= 0) shoutLabel.classList.remove('show');
|
||||||
|
|
||||||
|
for (let i = rockets.length - 1; i >= 0; i--) {
|
||||||
|
const r = rockets[i];
|
||||||
|
r.life -= t;
|
||||||
|
const stepLen = r.speed * t;
|
||||||
|
r.speed += 10 * t;
|
||||||
|
const hit = hitScan(r.mesh.position, r.dir, stepLen);
|
||||||
|
if (rayHitsMob(r.mesh.position, r.dir, stepLen + 0.3)) {
|
||||||
|
explode(null, mobCenter(), r.dir.clone().negate());
|
||||||
|
group.remove(r.mesh);
|
||||||
|
rockets.splice(i, 1);
|
||||||
|
} else if (hit || r.life <= 0) {
|
||||||
|
if (hit) explode(hit);
|
||||||
|
group.remove(r.mesh);
|
||||||
|
rockets.splice(i, 1);
|
||||||
|
} else {
|
||||||
|
r.mesh.position.addScaledVector(r.dir, stepLen);
|
||||||
|
if (Math.random() < 0.5) spawnBlast(r.mesh.position.clone().addScaledVector(r.dir, -0.2), 0.2, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = blasts.length - 1; i >= 0; i--) {
|
||||||
|
const b = blasts[i];
|
||||||
|
b.t += t;
|
||||||
|
const k = b.t / b.life;
|
||||||
|
if (b.s) { b.s.scale.setScalar(b.size * (0.6 + k)); b.s.material.opacity = 1 - k; }
|
||||||
|
if (b.light) b.light.intensity = 6 * (1 - k);
|
||||||
|
if (k >= 1) { group.remove(b.s || b.light); if (b.s) b.s.material.dispose(); blasts.splice(i, 1); }
|
||||||
|
}
|
||||||
|
for (const d of debris) {
|
||||||
|
if (d.rest) continue;
|
||||||
|
d.v.y -= 9.81 * t;
|
||||||
|
const next = d.mesh.position.clone().addScaledVector(d.v, t);
|
||||||
|
const floor = (o.floorAt ? o.floorAt(next.x, next.z) : 0) + (d.gib ? d.mesh.scale.y * 0.35 : 0.03);
|
||||||
|
if (next.y <= floor && d.v.y < 0) {
|
||||||
|
next.y = floor;
|
||||||
|
d.v.multiplyScalar(0.4); d.v.y = -d.v.y * 0.5; d.spin.multiplyScalar(0.5);
|
||||||
|
if (d.gib && !d.splat) { d.splat = true; bloodSplat(next.x, next.z, 0.3 + Math.random() * 0.3); }
|
||||||
|
if (d.v.length() < 0.3) d.rest = true;
|
||||||
|
}
|
||||||
|
d.mesh.position.copy(next);
|
||||||
|
if (d.mesh.isSprite) d.mesh.material.rotation += d.spin.x * t;
|
||||||
|
else { d.mesh.rotation.x += d.spin.x * t; d.mesh.rotation.y += d.spin.y * t; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
this.stop();
|
||||||
|
domElement.removeEventListener('mousedown', onClick);
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
domElement.removeEventListener('wheel', onWheel);
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
hud.remove();
|
||||||
|
sfx.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -212,5 +212,5 @@ window.Skybox = (() => {
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, createEnvironment, apply, getMode, lightFactor, createToggle };
|
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, MODES, LABELS, createEnvironment, apply, getMode, setMode: remember, lightFactor, createToggle };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+32
-2
@@ -7,7 +7,8 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// core/ui-kit.js — gedeelde UI-bouwstenen (window.UI): iconen (Bootstrap
|
// core/ui-kit.js — gedeelde UI-bouwstenen (window.UI): iconen (Bootstrap
|
||||||
// Icons, css/vendor), invoervelden, schakelaars, secties, tabbladen,
|
// Icons, css/vendor), invoervelden, schakelaars, secties, tabbladen,
|
||||||
// specificatie-kaarten en de 3-staps weergave-cyclus (sneltoets T).
|
// inklapbare secties, schuiven, specificatie-kaarten en de 3-staps
|
||||||
|
// weergave-cyclus (sneltoets T).
|
||||||
// Eén plek voor opmaak en woordgebruik, zodat alle panelen gelijk ogen.
|
// Eén plek voor opmaak en woordgebruik, zodat alle panelen gelijk ogen.
|
||||||
//
|
//
|
||||||
// Woordgebruik labels: zelfstandig naamwoord met hoofdletter, geen dubbele
|
// Woordgebruik labels: zelfstandig naamwoord met hoofdletter, geen dubbele
|
||||||
@@ -34,6 +35,35 @@ window.UI = (() => {
|
|||||||
`<span class="ui-unit">${unit}</span>`, o);
|
`<span class="ui-unit">${unit}</span>`, o);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schuif met gekoppeld getal (voor maten met een logisch bereik, zoals
|
||||||
|
// hoogtes en hellingen). o: { min, max, step, unit ('mm' standaard), hint }.
|
||||||
|
// Het getal mag buiten het schuifbereik; het model klemt zelf.
|
||||||
|
function range(id, label, value, o) {
|
||||||
|
o = o || {};
|
||||||
|
const unit = o.unit === undefined ? 'mm' : o.unit;
|
||||||
|
const lim = `min="${o.min}" max="${o.max}" step="${o.step || 10}"`;
|
||||||
|
return `<div class="ui-field ui-field-range${o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o.hint)}</label>` +
|
||||||
|
`<div class="ui-control ui-range"><input type="range" id="${id}-r" ${lim} value="${value}"${o.disabled ? ' disabled' : ''}>` +
|
||||||
|
`<input type="number" id="${id}" step="${o.step || 10}" value="${value}"${o.disabled ? ' disabled' : ''}><span class="ui-unit">${unit}</span></div></div>`;
|
||||||
|
}
|
||||||
|
// fn(v) bij schuiven en typen; set(id, v) zet beide zonder event.
|
||||||
|
function onRange(id, fn, after) {
|
||||||
|
const n = document.getElementById(id), r = document.getElementById(id + '-r');
|
||||||
|
if (!n || !r) return;
|
||||||
|
r.addEventListener('input', () => { n.value = r.value; fn(parseFloat(r.value)); after && after(); });
|
||||||
|
n.addEventListener('input', () => {
|
||||||
|
const v = parseFloat(n.value);
|
||||||
|
if (!isFinite(v)) return;
|
||||||
|
r.value = v;
|
||||||
|
fn(v); after && after();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setRange(id, v) {
|
||||||
|
const n = document.getElementById(id), r = document.getElementById(id + '-r');
|
||||||
|
if (n && document.activeElement !== n) n.value = v;
|
||||||
|
if (r) r.value = v;
|
||||||
|
}
|
||||||
|
|
||||||
// Keuzelijst. options: { waarde: 'Label' }.
|
// Keuzelijst. options: { waarde: 'Label' }.
|
||||||
function sel(id, label, options, value, o) {
|
function sel(id, label, options, value, o) {
|
||||||
const opts = Object.entries(options).map(([k, v]) => `<option value="${k}"${k === value ? ' selected' : ''}>${v}</option>`).join('');
|
const opts = Object.entries(options).map(([k, v]) => `<option value="${k}"${k === value ? ' selected' : ''}>${v}</option>`).join('');
|
||||||
@@ -126,5 +156,5 @@ window.UI = (() => {
|
|||||||
el.classList.toggle('fullscreen-2d');
|
el.classList.toggle('fullscreen-2d');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { esc, icon, hint, num, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
|
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ function createWalkControls(camera, domElement, opts) {
|
|||||||
let active = false;
|
let active = false;
|
||||||
let x = 0, z = 0, yaw = 0, pitch = 0;
|
let x = 0, z = 0, yaw = 0, pitch = 0;
|
||||||
let drag = null;
|
let drag = null;
|
||||||
|
let speedFactor = 1; // bijv. 1,3 in de shooter-modus; reset bij start()
|
||||||
|
|
||||||
const inField = () => ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement && document.activeElement.tagName);
|
const inField = () => ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement && document.activeElement.tagName);
|
||||||
const MOVE_KEYS = ['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift'];
|
const MOVE_KEYS = ['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift'];
|
||||||
@@ -71,10 +72,17 @@ function createWalkControls(camera, domElement, opts) {
|
|||||||
get active() { return active; },
|
get active() { return active; },
|
||||||
start(pose) {
|
start(pose) {
|
||||||
x = pose.x; z = pose.z; yaw = pose.yaw || 0; pitch = 0;
|
x = pose.x; z = pose.z; yaw = pose.yaw || 0; pitch = 0;
|
||||||
|
speedFactor = 1;
|
||||||
keys.clear();
|
keys.clear();
|
||||||
active = true;
|
active = true;
|
||||||
apply();
|
apply();
|
||||||
},
|
},
|
||||||
|
// Rondkijken van buitenaf (bijv. muis gevangen in core/shooter.js).
|
||||||
|
set speedFactor(f) { speedFactor = f; },
|
||||||
|
look(dYaw, dPitch) {
|
||||||
|
yaw += dYaw;
|
||||||
|
pitch = Math.max(-1.4, Math.min(1.4, pitch + dPitch));
|
||||||
|
},
|
||||||
stop() { active = false; keys.clear(); drag = null; },
|
stop() { active = false; keys.clear(); drag = null; },
|
||||||
update(dt) {
|
update(dt) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -84,7 +92,7 @@ function createWalkControls(camera, domElement, opts) {
|
|||||||
const f = (keys.has('w') || keys.has('arrowup') ? 1 : 0) - (keys.has('s') || keys.has('arrowdown') ? 1 : 0);
|
const f = (keys.has('w') || keys.has('arrowup') ? 1 : 0) - (keys.has('s') || keys.has('arrowdown') ? 1 : 0);
|
||||||
const r = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
|
const r = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
|
||||||
if (f || r) {
|
if (f || r) {
|
||||||
const v = (keys.has('shift') ? o.run : o.speed) * t / Math.hypot(f, r);
|
const v = (keys.has('shift') ? o.run : o.speed) * speedFactor * t / Math.hypot(f, r);
|
||||||
const fx = Math.sin(yaw), fz = Math.cos(yaw); // vooruit
|
const fx = Math.sin(yaw), fz = Math.cos(yaw); // vooruit
|
||||||
const rx = -fz, rz = fx; // rechts (kijkend langs +Z is rechts -X)
|
const rx = -fz, rz = fx; // rechts (kijkend langs +Z is rechts -X)
|
||||||
step((fx * f + rx * r) * v, (fz * f + rz * r) * v);
|
step((fx * f + rx * r) * v, (fz * f + rz * r) * v);
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// cost/cost-defaults.js — STANDAARD kosten en tijden (datafile).
|
||||||
|
//
|
||||||
|
// Dit zijn de fabrieksinstellingen. Aanpassen kan op twee manieren:
|
||||||
|
// 1. in de app: menu Instellingen > Kosten & tijden (opgeslagen in deze
|
||||||
|
// browser, exporteren/importeren als JSON);
|
||||||
|
// 2. hier in dit bestand (geldt dan voor iedereen als nieuwe standaard).
|
||||||
|
// Tip: exporteer in de app en neem de waarden hier over.
|
||||||
|
//
|
||||||
|
// Een JS-bestand i.p.v. JSON omdat de app ook via file:// draait (fetch van
|
||||||
|
// lokale bestanden werkt daar niet).
|
||||||
|
//
|
||||||
|
// Opbouw: groep -> items. Elk item: { label, unit, value } of met
|
||||||
|
// options: { sleutel: 'tekst' } voor een keuze. Nieuwe items verschijnen
|
||||||
|
// vanzelf in het instellingenvenster; de berekening (CostModel) moet ze
|
||||||
|
// natuurlijk wel gebruiken. Bedragen excl. btw. Tijden in seconden tenzij
|
||||||
|
// anders vermeld. LET OP: de getallen zijn voorlopige schattingen.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.COST_DEFAULTS = {
|
||||||
|
arbeid: {
|
||||||
|
label: 'Arbeid',
|
||||||
|
items: {
|
||||||
|
uurloon: { label: 'Uurloon productie (all-in)', unit: '€/u', value: 45 },
|
||||||
|
bezetting: { label: 'Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel', unit: 'man', value: 2.2 },
|
||||||
|
toeslagUren: { label: 'Toeslag indirecte uren (klaarleggen, opruimen, intern transport)', unit: '%', value: 10 },
|
||||||
|
qa: { label: 'QA (laatste aanpassingen, klantcontact) over de totale werktijd', unit: '%', value: 4 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
zagen: {
|
||||||
|
label: 'Zagen',
|
||||||
|
items: {
|
||||||
|
methode: { label: 'Zaagmethode', options: { robot: 'Zaagrobot', hand: 'Handmatig' }, value: 'robot' },
|
||||||
|
// Machinetijd testontwerp (409 stuks, 818 sneden): ~2 u 55 min.
|
||||||
|
robotSnede: { label: 'Robot: tijd per haakse snede', unit: 's', value: 5 },
|
||||||
|
robotSchuin: { label: 'Robot: extra tijd per schuine snede', unit: 's', value: 2.5 },
|
||||||
|
robotStuk: { label: 'Robot: handling per stuk (invoer, uitvoer, etiket)', unit: 's', value: 15 },
|
||||||
|
handFactor: { label: 'Handmatig: factor op de robottijd', unit: '×', value: 7 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
verbinden: {
|
||||||
|
label: 'Verbindingen (regelwerk)',
|
||||||
|
items: {
|
||||||
|
schroevenPerVerbinding: { label: 'Schroeven per verbinding (stijl/dorpel op regel)', unit: 'st.', value: 3 },
|
||||||
|
eersteSchroef: { label: 'Tijd eerste schroef (gat + schroef + uitlijnen)', unit: 's', value: 10 },
|
||||||
|
volgendeSchroef: { label: 'Tijd volgende schroef (gat + schroef)', unit: 's', value: 5 },
|
||||||
|
prijsPer1000: { label: 'Inkoop constructieschroeven (6×120)', unit: '€/1000', value: 45 },
|
||||||
|
verbindingenPerVloerbalk: { label: 'Verbindingen per vloer-/dakbalk', unit: 'st.', value: 2 },
|
||||||
|
dubbelAfstand: { label: 'Dubbele stijl (deur, hoek): schroef verticaal elke', unit: 'mm', value: 100 },
|
||||||
|
monteerFactor: { label: 'Monteren: factor op de schroeftijd (incl. lijmen, positioneren)', unit: '×', value: 6 },
|
||||||
|
personeel: { label: 'Personeel bij monteren constructie (vloer, wand, dak; i.p.v. de bezetting)', unit: 'man', value: 2 },
|
||||||
|
dakFactor: { label: 'Dak: factor op de tijd per schroef (moeilijker werken)', unit: '×', value: 4 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
montage: {
|
||||||
|
label: 'Montage',
|
||||||
|
items: {
|
||||||
|
muurVastzetten: { label: 'Muursectie plaatsen en vastzetten', unit: 'min', value: 10 },
|
||||||
|
muurPersoneel: { label: 'Personeel bij plaatsen muursectie (i.p.v. de bezetting)', unit: 'man', value: 3 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hout: {
|
||||||
|
label: 'Hout',
|
||||||
|
items: {
|
||||||
|
constructieM3: { label: 'Constructiehout vuren C24 (regelwerk, balken)', unit: '€/m³', value: 550 },
|
||||||
|
latwerkM: { label: 'Latwerk (kruislat)', unit: '€/m', value: 0.9 },
|
||||||
|
latStukMontage: { label: 'Latwerk: per gezaagd stuk (meten, zagen op locatie, passen)', unit: 's', value: 240 },
|
||||||
|
latMeterMontage: { label: 'Latwerk: bevestigen per strekkende meter', unit: 's/m', value: 30 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
plaat: {
|
||||||
|
label: 'Plaatwerk',
|
||||||
|
items: {
|
||||||
|
spaanplaatM2: { label: 'Spaanplaat', unit: '€/m²', value: 9 },
|
||||||
|
spaanplaatMontage: { label: 'Montage spaanplaat (passen, zagen, plaatsen)', unit: 's/m²', value: 120 },
|
||||||
|
osbM2: { label: 'OSB (vloeren, plafonds dak)', unit: '€/m²', value: 12 },
|
||||||
|
osbMontage: { label: 'Montage OSB', unit: 's/m²', value: 90 },
|
||||||
|
cementplaatM2: { label: 'Cementgebonden plaat (gevel, trasraam)', unit: '€/m²', value: 28 },
|
||||||
|
cementplaatMontage: { label: 'Montage cementplaat', unit: 's/m²', value: 240 },
|
||||||
|
schroevenPerM2: { label: 'Plaatschroeven per m²', unit: 'st./m²', value: 15 },
|
||||||
|
tijdPerPlaatschroef: { label: 'Tijd per plaatschroef', unit: 's', value: 4 },
|
||||||
|
plaatschroefPer1000: { label: 'Inkoop plaatschroeven (4×40)', unit: '€/1000', value: 15 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isolatie: {
|
||||||
|
label: 'Isolatie',
|
||||||
|
items: {
|
||||||
|
glaswolM3: { label: 'Glaswol', unit: '€/m³', value: 40 },
|
||||||
|
glaswolMontage: { label: 'Montage glaswol', unit: 's/m³', value: 900 },
|
||||||
|
steenIsolatieM3: { label: 'Isolatieblok steengevel', unit: '€/m³', value: 120 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
gevel: {
|
||||||
|
label: 'Gevel',
|
||||||
|
items: {
|
||||||
|
vochtdoekM2: { label: 'Vochtdoek', unit: '€/m²', value: 1.5 },
|
||||||
|
vochtdoekMontageM: { label: 'Montage vochtdoek per strekkende meter wand', unit: 's/m', value: 600 },
|
||||||
|
rabatM2: { label: 'Rabat (gevelbekleding)', unit: '€/m²', value: 35 },
|
||||||
|
rabatMontage: { label: 'Montage rabat', unit: 's/m²', value: 600 },
|
||||||
|
steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 },
|
||||||
|
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dak: {
|
||||||
|
label: 'Dak & dakrand',
|
||||||
|
items: {
|
||||||
|
dakleerM2: { label: 'Dakleer (EPDM)', unit: '€/m²', value: 18 },
|
||||||
|
dakleerMontage: { label: 'Montage dakleer', unit: 's/m²', value: 600 },
|
||||||
|
rietM2: { label: 'Riet', unit: '€/m²', value: 90 },
|
||||||
|
rietMontage: { label: 'Montage riet', unit: 's/m²', value: 3600 },
|
||||||
|
boeideelM: { label: 'Boeideel', unit: '€/m', value: 25 },
|
||||||
|
boeideelMontage: { label: 'Montage boeideel', unit: 's/m', value: 600 },
|
||||||
|
onderplaatM2: { label: 'Onderplaat boeideel', unit: '€/m²', value: 30 },
|
||||||
|
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
|
||||||
|
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
|
||||||
|
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
vloer: {
|
||||||
|
label: 'Vloer & fundering',
|
||||||
|
items: {
|
||||||
|
afwerkingM2: { label: 'Vloerafwerking', unit: '€/m²', value: 35 },
|
||||||
|
afwerkingMontage: { label: 'Montage vloerafwerking', unit: 's/m²', value: 1200 },
|
||||||
|
sandwichM2: { label: 'Sandwichpaneel', unit: '€/m²', value: 35 },
|
||||||
|
sandwichMontage: { label: 'Montage per sandwichpaneel', unit: 's', value: 600 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
kozijnen: {
|
||||||
|
label: 'Kozijnen',
|
||||||
|
items: {
|
||||||
|
raamM2: { label: 'Raamkozijn incl. glas', unit: '€/m²', value: 450 },
|
||||||
|
deur: { label: 'Binnen-/buitendeur incl. kozijn', unit: '€/st.', value: 900 },
|
||||||
|
voordeur: { label: 'Voordeur incl. kozijn', unit: '€/st.', value: 1800 },
|
||||||
|
montage: { label: 'Montage per kozijn', unit: 's', value: 5400 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
verlichting: {
|
||||||
|
label: 'Verlichting',
|
||||||
|
items: {
|
||||||
|
gevellamp: { label: 'Gevellamp', unit: '€/st.', value: 45 },
|
||||||
|
boeideelSpot: { label: 'Boeideel-spot', unit: '€/st.', value: 25 },
|
||||||
|
binnenspot: { label: 'Binnenspot', unit: '€/st.', value: 20 },
|
||||||
|
montage: { label: 'Montage per lamp/spot (incl. bekabeling)', unit: 's', value: 900 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
bedrijf: {
|
||||||
|
label: 'Afschrijving & bedrijfskosten',
|
||||||
|
items: {
|
||||||
|
machines: { label: 'Afschrijving machines (zaagrobot, gereedschap, inrichting) per productie-uur', unit: '€/u', value: 8 },
|
||||||
|
slijtage: { label: 'Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per productie-uur', unit: '€/u', value: 2 },
|
||||||
|
huisvesting: { label: 'Huisvesting productiehal (huur, energie) per productie-uur', unit: '€/u', value: 6 },
|
||||||
|
transportUnit: { label: 'Transport per unit naar de bouwplaats', unit: '€/unit', value: 750 },
|
||||||
|
kraan: { label: 'Kraan voor plaatsen woning', unit: '€', value: 1200 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
opslag: {
|
||||||
|
label: 'Opslagen',
|
||||||
|
items: {
|
||||||
|
verlies: { label: 'Materiaalverlies (beschadiging, misgezaagd, restmateriaal; naast het berekende snijverlies balken)', unit: '%', value: 5 },
|
||||||
|
klein: { label: 'Klein materiaal (lijm, kit, tape, nieten)', unit: '%', value: 3 },
|
||||||
|
algemeen: { label: 'Algemene kosten (kantoor, verzekering, garantie) over de kostprijs', unit: '%', value: 5 },
|
||||||
|
marge: { label: 'Winstmarge', unit: '%', value: 15 },
|
||||||
|
btw: { label: 'Btw', unit: '%', value: 21 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// cost/cost-model.js — CostModel: ENIGE bron van de kostenberekening
|
||||||
|
// (materiaal + arbeid). Geen DOM. Rekent op de doorgerekende woning
|
||||||
|
// (HouseModel.computeParts + computeTotals) met de kosten/tijden uit
|
||||||
|
// COST_DEFAULTS (cost-defaults.js) plus de eigen instellingen van de
|
||||||
|
// gebruiker (localStorage, instellingenvenster cost-settings.js).
|
||||||
|
//
|
||||||
|
// Opbouw: per element (wandstuk, vloer, dak, woning-breed) regels
|
||||||
|
// { cat, act, label, qty, unit, mat (€), sec (s arbeid) }
|
||||||
|
// cat = materiaalgroep, act = werkzaamheid. Opgeteld per element, per unit
|
||||||
|
// en voor de woning; daarna opslagen (indirecte uren, materiaal, marge, btw).
|
||||||
|
//
|
||||||
|
// Plain script, geladen na house-model.js en cost-defaults.js.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.CostModel = (() => {
|
||||||
|
const STORAGE_KEY = 'tectum.costs';
|
||||||
|
const listeners = [];
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Instellingen: standaard (COST_DEFAULTS) + eigen waarden (localStorage).
|
||||||
|
// values = { groep: { sleutel: waarde } }.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
function defaultValues() {
|
||||||
|
const v = {};
|
||||||
|
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => {
|
||||||
|
v[g] = {};
|
||||||
|
Object.entries(grp.items).forEach(([k, it]) => { v[g][k] = it.value; });
|
||||||
|
});
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
// Alleen bekende sleutels met een geldig type overnemen.
|
||||||
|
function mergeValues(base, over) {
|
||||||
|
const v = JSON.parse(JSON.stringify(base));
|
||||||
|
Object.entries(over || {}).forEach(([g, items]) => {
|
||||||
|
const grp = window.COST_DEFAULTS[g];
|
||||||
|
if (!grp || !items || typeof items !== 'object') return;
|
||||||
|
Object.entries(items).forEach(([k, val]) => {
|
||||||
|
const it = grp.items[k];
|
||||||
|
if (!it) return;
|
||||||
|
if (it.options ? Object.prototype.hasOwnProperty.call(it.options, val) : typeof val === 'number' && isFinite(val)) v[g][k] = val;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
function readOverrides() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; } catch (e) { return {}; }
|
||||||
|
}
|
||||||
|
function getSettings() { return mergeValues(defaultValues(), readOverrides()); }
|
||||||
|
// Slaat alleen de afwijkingen van de standaard op.
|
||||||
|
function saveSettings(values) {
|
||||||
|
const def = defaultValues(), diff = {};
|
||||||
|
Object.entries(mergeValues(def, values)).forEach(([g, items]) => Object.entries(items).forEach(([k, val]) => {
|
||||||
|
if (val !== def[g][k]) (diff[g] = diff[g] || {})[k] = val;
|
||||||
|
}));
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(diff)); } catch (e) { /* geen opslag: alleen deze sessie */ }
|
||||||
|
listeners.forEach(fn => fn());
|
||||||
|
}
|
||||||
|
function resetSettings() { saveSettings(defaultValues()); }
|
||||||
|
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
|
||||||
|
|
||||||
|
// Export/import (JSON-bestand): altijd de volledige set waarden.
|
||||||
|
function exportJson(values) {
|
||||||
|
return { type: 'tectum-costs', version: 1, exportedAt: new Date().toISOString(), values: mergeValues(defaultValues(), values) };
|
||||||
|
}
|
||||||
|
function importJson(obj) {
|
||||||
|
if (!obj || obj.type !== 'tectum-costs' || typeof obj.values !== 'object') throw new Error('geen Tectum-kostenbestand');
|
||||||
|
return mergeValues(defaultValues(), obj.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Berekening
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
const CAT_ORDER = ['Hout', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting'];
|
||||||
|
const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting'];
|
||||||
|
const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl'];
|
||||||
|
const DORPEL_KINDS = ['bovendorpel', 'onderdorpel'];
|
||||||
|
|
||||||
|
function compute(parts, totals, settings) {
|
||||||
|
const S = settings || getSettings();
|
||||||
|
const elements = [];
|
||||||
|
const newEl = (element, label, unit) => { const el = { element, label, unit, lines: [] }; elements.push(el); return el; };
|
||||||
|
// sec = tijd voor één persoon; crew = personeel bij die stap (standaard
|
||||||
|
// de gemiddelde bezetting per productiestap). Opgeslagen sec = manuren.
|
||||||
|
const add = (el, cat, act, label, qty, unit, mat, sec, crew) => {
|
||||||
|
if (!(qty > 0)) return;
|
||||||
|
const c = sec ? (crew !== undefined ? crew : S.arbeid.bezetting) : 0;
|
||||||
|
// clock = doorlooptijd van de stap (s), voor afschrijving/huisvesting.
|
||||||
|
el.lines.push({ cat, act, label, qty, unit, mat: mat || 0, sec: (sec || 0) * c, clock: c ? sec : 0, crew: c });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Gedeelde rekenregels ---
|
||||||
|
const Z = S.zagen, V = S.verbinden, P = S.plaat;
|
||||||
|
const sawFactor = Z.methode === 'hand' ? Z.handFactor : 1;
|
||||||
|
// Zaagtijd van één stuk (s): handling + sneden (+ extra per schuine snede).
|
||||||
|
// saw: totalen voor het zaagoverzicht (robot- en handtijd, los van de keuze).
|
||||||
|
const saw = { pieces: 0, cuts: 0, angled: 0, robotSec: 0 };
|
||||||
|
const sawSec = (cutAngles, count = 1) => {
|
||||||
|
const ca = cutAngles || [0, 0], angled = ca.filter(a => a !== 0).length;
|
||||||
|
const robot = Z.robotStuk + ca.length * Z.robotSnede + angled * Z.robotSchuin;
|
||||||
|
saw.pieces += count; saw.cuts += count * ca.length; saw.angled += count * angled; saw.robotSec += count * robot;
|
||||||
|
return count * robot * sawFactor;
|
||||||
|
};
|
||||||
|
const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)';
|
||||||
|
const jointSec = V.eersteSchroef + Math.max(0, V.schroevenPerVerbinding - 1) * V.volgendeSchroef;
|
||||||
|
// Constructie monteren (lijmen + schroeven): schroeftijd x V.monteerFactor,
|
||||||
|
// met V.personeel man (manuren), op het dak factor V.dakFactor (roof = true).
|
||||||
|
const addJoints = (el, joints, label, roof) => {
|
||||||
|
const n = joints * V.schroevenPerVerbinding;
|
||||||
|
const f = V.monteerFactor * (roof ? V.dakFactor : 1);
|
||||||
|
add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven (${joints} verb. × ${V.schroevenPerVerbinding}, ×${V.monteerFactor}${roof ? `, dak ×${V.dakFactor}` : ''})`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel);
|
||||||
|
};
|
||||||
|
// Plaat: materiaal + montage + plaatschroeven.
|
||||||
|
const addSheet = (el, cat, label, m2, priceM2, montageSecM2) => {
|
||||||
|
if (!(m2 > 0)) return;
|
||||||
|
add(el, cat, 'Plaatwerk', label, m2, 'm²', m2 * priceM2, m2 * montageSecM2);
|
||||||
|
const n = Math.ceil(m2 * P.schroevenPerM2);
|
||||||
|
add(el, 'Schroeven', 'Plaatwerk', `Plaatschroeven ${label.toLowerCase()}`, n, 'st.', n * P.plaatschroefPer1000 / 1000, n * P.tijdPerPlaatschroef);
|
||||||
|
};
|
||||||
|
// Snijverlies wandbalken (inkoop in handelslengtes, hele woning).
|
||||||
|
const waste = totals.purchaseStockMM > 0 ? totals.purchaseWasteMM / Math.max(1, totals.purchaseStockMM - totals.purchaseWasteMM) : 0;
|
||||||
|
|
||||||
|
const fmtM = (v) => v.toLocaleString('nl-NL', { maximumFractionDigits: 1 });
|
||||||
|
// --- Wandstukken (buiten + binnen/naad) ---
|
||||||
|
const seenOpenings = new Set(); // naadwand: openingen één keer (beide delen hebben dezelfde deur)
|
||||||
|
function wallElement(p, unitId) {
|
||||||
|
const el = newEl(p.element, p.label, unitId);
|
||||||
|
const bom = WallModel.computeBOM(p.config, p.frame);
|
||||||
|
const woodM3 = bom.reduce((a, b) => a + b.width * b.depth * b.length, 0) / 1e9;
|
||||||
|
add(el, 'Hout', 'Zagen', `Regelwerk ${bom.length} st. (incl. ${Math.round(waste * 100)}% snijverlies)`, woodM3 * (1 + waste), 'm³', woodM3 * (1 + waste) * S.hout.constructieM3, 0);
|
||||||
|
const cuts = bom.reduce((a, b) => a + (b.cutAngles || [0, 0]).length, 0);
|
||||||
|
add(el, 'Hout', 'Zagen', `${sawLabel}: ${bom.length} stuks`, cuts, 'sneden', 0, bom.reduce((a, b) => a + sawSec(b.cutAngles), 0));
|
||||||
|
const verticals = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).length;
|
||||||
|
const dorpels = bom.filter(b => DORPEL_KINDS.includes(b.kind)).length;
|
||||||
|
const topPlates = bom.filter(b => b.kind === 'bovenregel').length;
|
||||||
|
addJoints(el, 2 * verticals + 2 * dorpels + Math.max(0, topPlates - 1), 'Stijlen/dorpels');
|
||||||
|
// Dubbele stijlen (deur, hoek: twee stijlen tegen elkaar): verticaal
|
||||||
|
// elke V.dubbelAfstand mm een schroef over de gezamenlijke lengte.
|
||||||
|
const studs = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).sort((a, b) => a.x - b.x);
|
||||||
|
let dblScrews = 0, dblPairs = 0;
|
||||||
|
for (let i = 1; i < studs.length; i++) {
|
||||||
|
const a = studs[i - 1], b = studs[i];
|
||||||
|
if (b.x - a.x > (a.width + b.width) / 2 + 1) continue;
|
||||||
|
const ov = Math.min(a.y + a.length / 2, b.y + b.length / 2) - Math.max(a.y - a.length / 2, b.y - b.length / 2);
|
||||||
|
if (ov <= 0) continue;
|
||||||
|
dblPairs++;
|
||||||
|
dblScrews += Math.max(2, Math.floor(ov / V.dubbelAfstand));
|
||||||
|
}
|
||||||
|
add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}× (schroef elke ${V.dubbelAfstand} mm, ×${V.monteerFactor})`, dblScrews, 'st.',
|
||||||
|
dblScrews * V.prijsPer1000 / 1000, dblScrews * V.volgendeSchroef * V.monteerFactor, V.personeel);
|
||||||
|
const M = S.montage;
|
||||||
|
add(el, 'Hout', 'Wanden plaatsen', `Plaatsen en vastzetten (${M.muurVastzetten} min)`, 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel);
|
||||||
|
|
||||||
|
const st = p.stats || {};
|
||||||
|
addSheet(el, 'Plaatwerk', 'Spaanplaat', (st.spaanplaatAreaM2 || 0) + (st.spaanplaatBuitenAreaM2 || 0), P.spaanplaatM2, P.spaanplaatMontage);
|
||||||
|
addSheet(el, 'Plaatwerk', 'Cementplaat', (st.cementplaatAreaM2 || 0) + (st.trasraamAreaM2 || 0), P.cementplaatM2, P.cementplaatMontage);
|
||||||
|
const I = S.isolatie;
|
||||||
|
const wool = (p.specs && p.specs.voidVolumeM3) || 0;
|
||||||
|
add(el, 'Isolatie', 'Isoleren', 'Glaswol', wool, 'm³', wool * I.glaswolM3, wool * I.glaswolMontage);
|
||||||
|
const stoneIso = (st.stoneIsolatieM3 || 0) + (st.trasraamIsolatieM3 || 0);
|
||||||
|
add(el, 'Isolatie', 'Gevel', 'Isolatieblok steen', stoneIso, 'm³', stoneIso * I.steenIsolatieM3, 0);
|
||||||
|
|
||||||
|
const G = S.gevel;
|
||||||
|
const doek = st.doekAreaM2 || 0;
|
||||||
|
// Montage per strekkende meter wand (breedte van het stuk).
|
||||||
|
const doekM = doek > 0 ? p.place.width / 1000 : 0;
|
||||||
|
add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM);
|
||||||
|
// Rabat over het doek, boven het trasraam (gevelvlak uit WallModel).
|
||||||
|
const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
|
||||||
|
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
|
||||||
|
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
|
||||||
|
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
|
||||||
|
const latM = st.battenLengthM || 0;
|
||||||
|
add(el, 'Hout', 'Latwerk', `Latwerk (${st.battenCount || 0} latten inkoop, ${st.battenPieces || 0} stukken)`, latM, 'm', latM * S.hout.latwerkM,
|
||||||
|
(st.battenPieces || 0) * S.hout.latStukMontage + latM * S.hout.latMeterMontage);
|
||||||
|
|
||||||
|
const K = S.kozijnen;
|
||||||
|
const opKey = p.seam ? `iw${p.id}` : null;
|
||||||
|
if (!opKey || !seenOpenings.has(opKey)) {
|
||||||
|
if (opKey) seenOpenings.add(opKey);
|
||||||
|
(p.config.openings || []).forEach(op => {
|
||||||
|
if (op.type === 'raam') {
|
||||||
|
const m2 = WallModel.openingArea(op) / 1e6;
|
||||||
|
add(el, 'Kozijnen', 'Kozijnen', `Raam ${op.w}×${op.h}`, m2, 'm²', m2 * K.raamM2, K.montage);
|
||||||
|
} else {
|
||||||
|
add(el, 'Kozijnen', 'Kozijnen', `${op.type === 'voordeur' ? 'Voordeur' : 'Deur'} ${op.w}×${op.h}`, 1, 'st.', op.type === 'voordeur' ? K.voordeur : K.deur, K.montage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => wallElement(p, u.id))));
|
||||||
|
parts.interior.forEach(p => wallElement(p, p.seamUnit || null));
|
||||||
|
|
||||||
|
// --- Vloer- en dakbalken (balklaag: zagen + verbindingen) ---
|
||||||
|
function joistLayer(el, cd, angleDeg, what, roof) {
|
||||||
|
const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length;
|
||||||
|
const n = cd.joists.length;
|
||||||
|
const ang = angleDeg || 0;
|
||||||
|
add(el, 'Hout', 'Zagen', `${sawLabel}: ${what}balken ${n} + kopbalken ${rims}`, 2 * (n + rims), 'sneden', 0, sawSec([ang, ang], n) + sawSec([0, 0], rims));
|
||||||
|
addJoints(el, n * V.verbindingenPerVloerbalk, `${what}balken`, roof);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.units.forEach(u => {
|
||||||
|
// Vloer
|
||||||
|
const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id);
|
||||||
|
const fs = u.floorSpecs;
|
||||||
|
add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', fs.joistWoodM3 * S.hout.constructieM3, 0);
|
||||||
|
joistLayer(fl, u.floorData.calcData, 0, 'Vloer');
|
||||||
|
addSheet(fl, 'Plaatwerk', 'OSB vloer', fs.osbAreaM2, P.osbM2, P.osbMontage);
|
||||||
|
add(fl, 'Isolatie', 'Isoleren', 'Glaswol vloer', fs.voidM3, 'm³', fs.voidM3 * S.isolatie.glaswolM3, fs.voidM3 * S.isolatie.glaswolMontage);
|
||||||
|
// Afwerking: vloer min de voetafdruk van de wanden op deze unit.
|
||||||
|
const foot = u.walls.reduce((a, w) => a + w.pieces.filter(p => !p.place.baseY).reduce((b, p) => b + p.place.width * p.place.thickness, 0), 0)
|
||||||
|
+ parts.interior.filter(p => p.seamUnit === u.id).reduce((a, p) => a + p.place.width * p.place.thickness, 0);
|
||||||
|
const finM2 = Math.max(0, u.rect.W * u.rect.D - foot) / 1e6;
|
||||||
|
add(fl, 'Vloer', 'Vloer', 'Vloerafwerking', finM2, 'm²', finM2 * S.vloer.afwerkingM2, finM2 * S.vloer.afwerkingMontage);
|
||||||
|
|
||||||
|
// Dak
|
||||||
|
const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
|
||||||
|
const rs = u.roof.specs, D = S.dak;
|
||||||
|
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', rs.joistWoodM3 * S.hout.constructieM3, 0);
|
||||||
|
u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true));
|
||||||
|
// Wolfsdak: bovenste deel (sporen, hoekkepers, nokbalk, kopbalken).
|
||||||
|
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
|
||||||
|
if (cap) {
|
||||||
|
const n = cap.beams.length;
|
||||||
|
add(rf, 'Hout', 'Zagen', `${sawLabel}: wolfseinden ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0));
|
||||||
|
addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true);
|
||||||
|
}
|
||||||
|
addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage);
|
||||||
|
add(rf, 'Isolatie', 'Isoleren', 'Glaswol dak', rs.voidM3, 'm³', rs.voidM3 * S.isolatie.glaswolM3, rs.voidM3 * S.isolatie.glaswolMontage);
|
||||||
|
add(rf, 'Dak', 'Dak', 'Dakleer (EPDM)', rs.dakleerM2 || 0, 'm²', (rs.dakleerM2 || 0) * D.dakleerM2, (rs.dakleerM2 || 0) * D.dakleerMontage);
|
||||||
|
add(rf, 'Dak', 'Dak', 'Riet', rs.rietM2 || 0, 'm²', (rs.rietM2 || 0) * D.rietM2, (rs.rietM2 || 0) * D.rietMontage);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Woning-breed: dakrand, sandwichpanelen, verlichting ---
|
||||||
|
const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null);
|
||||||
|
const D = S.dak;
|
||||||
|
if (totals.roofEdge) {
|
||||||
|
const e = totals.roofEdge;
|
||||||
|
add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage);
|
||||||
|
add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
|
||||||
|
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
|
||||||
|
}
|
||||||
|
if (parts.sandwich) {
|
||||||
|
const sw = parts.sandwich;
|
||||||
|
add(wo, 'Vloer', 'Vloer', `Sandwichpanelen (${sw.panelCount} platen)`, sw.areaM2, 'm²', sw.areaM2 * S.vloer.sandwichM2, sw.panelCount * S.vloer.sandwichMontage);
|
||||||
|
}
|
||||||
|
const L = S.verlichting, lm = totals.lamps;
|
||||||
|
add(wo, 'Verlichting', 'Verlichting', 'Gevellampen', lm.total, 'st.', lm.total * L.gevellamp, lm.total * L.montage);
|
||||||
|
const spots = totals.roofEdge ? totals.roofEdge.spots : 0;
|
||||||
|
add(wo, 'Verlichting', 'Verlichting', 'Boeideel-spots', spots, 'st.', spots * L.boeideelSpot, spots * L.montage);
|
||||||
|
add(wo, 'Verlichting', 'Verlichting', 'Binnenspots', lm.indoor, 'st.', lm.indoor * L.binnenspot, lm.indoor * L.montage);
|
||||||
|
|
||||||
|
// --- Optellen ---
|
||||||
|
const sumLines = (lines) => lines.reduce((a, l) => ({ mat: a.mat + l.mat, sec: a.sec + l.sec, clock: a.clock + l.clock }), { mat: 0, sec: 0, clock: 0 });
|
||||||
|
elements.forEach(el => Object.assign(el, sumLines(el.lines)));
|
||||||
|
const byKey = (key, order) => {
|
||||||
|
const m = new Map();
|
||||||
|
elements.forEach(el => el.lines.forEach(l => {
|
||||||
|
const r = m.get(l[key]) || { name: l[key], mat: 0, sec: 0 };
|
||||||
|
r.mat += l.mat; r.sec += l.sec;
|
||||||
|
m.set(l[key], r);
|
||||||
|
}));
|
||||||
|
return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
|
||||||
|
};
|
||||||
|
// Kostprijs van materiaal + directe arbeidstijd (s), overal dezelfde
|
||||||
|
// regels: materiaal + verlies + klein materiaal; uren + indirect + QA
|
||||||
|
// tegen het uurloon (manuren); afschrijving/slijtage/huisvesting per
|
||||||
|
// productie-uur = doorlooptijd (klokuren, niet x personeel) + indirect, zonder QA.
|
||||||
|
const O = S.opslag, B = S.bedrijf, A = S.arbeid;
|
||||||
|
const perHour = B.machines + B.slijtage + B.huisvesting;
|
||||||
|
const split = (sec) => {
|
||||||
|
const direct = sec / 3600, indirect = direct * A.toeslagUren / 100;
|
||||||
|
const qa = (direct + indirect) * A.qa / 100; // QA: % van de totale werktijd
|
||||||
|
return { direct, indirect, qa, total: direct + indirect + qa };
|
||||||
|
};
|
||||||
|
const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100);
|
||||||
|
const costOf = (r) => mat0(r.mat) + split(r.sec).total * A.uurloon + prodHours(r.clock) * perHour;
|
||||||
|
const mat0 = (mat) => mat * (1 + (O.verlies + O.klein) / 100);
|
||||||
|
elements.forEach(el => { el.kostprijs = costOf(el); });
|
||||||
|
|
||||||
|
const units = parts.units.map(u => {
|
||||||
|
const r = Object.assign({ id: u.id }, sumLines(elements.filter(el => el.unit === u.id).flatMap(el => el.lines)));
|
||||||
|
r.transport = B.transportUnit;
|
||||||
|
r.kostprijs = costOf(r) + r.transport;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
const rest = sumLines(elements.filter(el => el.unit === null).flatMap(el => el.lines));
|
||||||
|
units.push(Object.assign({ id: null, transport: 0 }, rest, { kostprijs: costOf(rest) + B.kraan }));
|
||||||
|
|
||||||
|
const all = sumLines(elements.flatMap(el => el.lines));
|
||||||
|
const h = split(all.sec);
|
||||||
|
h.prod = prodHours(all.clock);
|
||||||
|
const n = parts.units.length;
|
||||||
|
const pct = (v) => String(v).replace('.', ',') + '%';
|
||||||
|
const eur0 = (v) => eur(v);
|
||||||
|
const posts = [
|
||||||
|
['Materiaal', all.mat],
|
||||||
|
[`Materiaalverlies ${pct(O.verlies)}`, all.mat * O.verlies / 100],
|
||||||
|
[`Klein materiaal ${pct(O.klein)}`, all.mat * O.klein / 100],
|
||||||
|
[`Arbeid ${uur(h.total)} manuur × ${eur0(A.uurloon)} (incl. bezetting, indirect en QA)`, h.total * A.uurloon],
|
||||||
|
[`Afschrijving machines ${uur(h.prod)} productie-uur × ${eur(B.machines, 2)}`, h.prod * B.machines],
|
||||||
|
[`Slijtage gereedschap ${uur(h.prod)} productie-uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage],
|
||||||
|
[`Huisvesting productiehal ${uur(h.prod)} productie-uur × ${eur(B.huisvesting, 2)}`, h.prod * B.huisvesting],
|
||||||
|
[`Transport ${n} unit${n === 1 ? '' : 's'} × ${eur0(B.transportUnit)}`, n * B.transportUnit],
|
||||||
|
['Kraan plaatsen', B.kraan]
|
||||||
|
];
|
||||||
|
const kostprijs = posts.reduce((a, p) => a + p[1], 0);
|
||||||
|
const algemeen = kostprijs * O.algemeen / 100;
|
||||||
|
const marge = (kostprijs + algemeen) * O.marge / 100;
|
||||||
|
const exBtw = kostprijs + algemeen + marge;
|
||||||
|
const btw = exBtw * O.btw / 100;
|
||||||
|
// Overzicht als rijen (kaart en PDF): sub = subtotaal.
|
||||||
|
const rows = posts.filter(p => p[1] > 0).map(([label, amount]) => ({ label, amount }))
|
||||||
|
.concat([
|
||||||
|
{ label: 'Kostprijs', amount: kostprijs, sub: true },
|
||||||
|
{ label: `Algemene kosten ${pct(O.algemeen)}`, amount: algemeen },
|
||||||
|
{ label: `Marge ${pct(O.marge)}`, amount: marge },
|
||||||
|
{ label: 'Totaal excl. btw', amount: exBtw, sub: true },
|
||||||
|
{ label: `Btw ${pct(O.btw)}`, amount: btw }
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
settings: S, elements, units,
|
||||||
|
saw: Object.assign(saw, { handSec: saw.robotSec * Z.handFactor, method: Z.methode }),
|
||||||
|
categories: byKey('cat', CAT_ORDER), activities: byKey('act', ACT_ORDER),
|
||||||
|
summary: { mat: all.mat, hoursDirect: h.direct, hoursIndirect: h.indirect, hoursQa: h.qa, hours: h.total, kostprijs, exBtw, btw, inclBtw: exBtw + btw, rows }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrag als "€ 1.234" (of met decimalen).
|
||||||
|
function eur(n, decimals = 0) {
|
||||||
|
return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||||
|
}
|
||||||
|
// Uren als decimaal getal, bijv. "12,5".
|
||||||
|
function uur(hours) {
|
||||||
|
return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { STORAGE_KEY, defaultValues, getSettings, saveSettings, resetSettings, onChange, exportJson, importJson, compute, eur, uur };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// cost/cost-settings.js — CostSettings: venster "Kosten & tijden". Bouwt
|
||||||
|
// het formulier automatisch uit COST_DEFAULTS (elk item een invoerveld),
|
||||||
|
// opslaan via CostModel.saveSettings (localStorage). Exporteren/importeren
|
||||||
|
// als JSON-bestand; "Standaard" zet alle velden terug op cost-defaults.js.
|
||||||
|
// Alleen UI; de rekenregels staan in CostModel.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.CostSettings = (() => {
|
||||||
|
let overlay = null;
|
||||||
|
|
||||||
|
const esc = UI.esc;
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'cost-settings-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div id="cost-settings-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Kosten & tijden</h2>
|
||||||
|
<button type="button" class="modal-close-x" data-cs="cancel">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="cs-body"></div>
|
||||||
|
<div class="material-info">Bedragen excl. btw. Opslaan bewaart in deze browser én downloadt een JSON-bestand; een gewijzigd veld is gemarkeerd. Standaardwaarden staan in js/cost/cost-defaults.js.</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">Exporteren…</button>
|
||||||
|
<button type="button" class="io-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;">Opslaan</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept=".json,application/json" style="display:none">
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) return close();
|
||||||
|
const btn = e.target.closest('[data-cs]');
|
||||||
|
if (!btn) return;
|
||||||
|
const a = btn.dataset.cs;
|
||||||
|
if (a === 'cancel') close();
|
||||||
|
else if (a === 'save') { // ook als JSON-bestand (om te delen/bewaren)
|
||||||
|
const v = readForm();
|
||||||
|
CostModel.saveSettings(v);
|
||||||
|
download(CostModel.exportJson(v));
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
else if (a === 'reset') fill(CostModel.defaultValues());
|
||||||
|
else if (a === 'export') download(CostModel.exportJson(readForm()));
|
||||||
|
else if (a === 'import') overlay.querySelector('input[type=file]').click();
|
||||||
|
});
|
||||||
|
overlay.addEventListener('input', markChanged);
|
||||||
|
overlay.querySelector('input[type=file]').addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
file.text().then(txt => fill(CostModel.importJson(JSON.parse(txt))))
|
||||||
|
.catch(err => alert('Importeren mislukt: ' + err.message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm() {
|
||||||
|
const body = overlay.querySelector('.cs-body');
|
||||||
|
body.innerHTML = Object.entries(window.COST_DEFAULTS).map(([g, grp]) => `
|
||||||
|
<section class="cs-group">
|
||||||
|
<h3>${esc(grp.label)}</h3>
|
||||||
|
${Object.entries(grp.items).map(([k, it]) => `
|
||||||
|
<label class="cs-row">
|
||||||
|
<span class="cs-label">${esc(it.label)}</span>
|
||||||
|
${it.options
|
||||||
|
? `<select data-g="${g}" data-k="${k}">${Object.entries(it.options).map(([v, t]) => `<option value="${esc(v)}">${esc(t)}</option>`).join('')}</select>`
|
||||||
|
: `<input type="number" step="any" data-g="${g}" data-k="${k}">`}
|
||||||
|
<span class="cs-unit">${esc(it.unit || '')}</span>
|
||||||
|
</label>`).join('')}
|
||||||
|
</section>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(values) {
|
||||||
|
overlay.querySelectorAll('[data-g]').forEach(el => { el.value = values[el.dataset.g][el.dataset.k]; });
|
||||||
|
markChanged();
|
||||||
|
}
|
||||||
|
function readForm() {
|
||||||
|
const v = CostModel.getSettings();
|
||||||
|
overlay.querySelectorAll('[data-g]').forEach(el => {
|
||||||
|
const it = window.COST_DEFAULTS[el.dataset.g].items[el.dataset.k];
|
||||||
|
const val = it.options ? el.value : parseFloat(el.value);
|
||||||
|
if (it.options || isFinite(val)) v[el.dataset.g][el.dataset.k] = val;
|
||||||
|
});
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
// Afwijkend van de standaard: gemarkeerd.
|
||||||
|
function markChanged() {
|
||||||
|
const def = CostModel.defaultValues();
|
||||||
|
overlay.querySelectorAll('[data-g]').forEach(el => {
|
||||||
|
const d = def[el.dataset.g][el.dataset.k];
|
||||||
|
el.closest('.cs-row').classList.toggle('changed', typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(obj) {
|
||||||
|
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `tectum-kosten-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!overlay) build();
|
||||||
|
renderForm(); // opnieuw: nieuwe items in cost-defaults.js verschijnen vanzelf
|
||||||
|
fill(CostModel.getSettings());
|
||||||
|
overlay.classList.add('open');
|
||||||
|
}
|
||||||
|
function close() { if (overlay) overlay.classList.remove('open'); }
|
||||||
|
|
||||||
|
return { open, close };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// elektra/elektra-model.js — ElektraModel: ENIGE bron van de elektra-
|
||||||
|
// installatie van een woning (eerste versie, automatisch). Geen DOM.
|
||||||
|
//
|
||||||
|
// Woning-breed (settings.elektra). Alles wordt afgeleid uit de woning:
|
||||||
|
// - Meterkast (3×25 A, 3-fase) als kast aan de binnenzijde van een wand
|
||||||
|
// in de unit met de voordeur, zo dicht mogelijk bij de voordeur, vrij van
|
||||||
|
// openingen (incl. raamwerk) en van de schakelaars.
|
||||||
|
// - Schakelaarcombinatie naast de voordeur (binnenzijde, 1500 mm), aan de
|
||||||
|
// slotzijde (deur draait links, KozijnModel; anders de scharnierzijde):
|
||||||
|
// A = binnenlicht van die unit, B = buitenlicht (boeideel-spots en
|
||||||
|
// gevellampen). Elke andere unit krijgt een schakelaar naast de deur
|
||||||
|
// waardoor je hem binnenkomt (naadwand, anders een buitendeur), C, D, ...
|
||||||
|
// Elke lamp/spot krijgt de letter van zijn schakelaar (p.letter).
|
||||||
|
// - Wandcontactdozen (dubbel, 400 mm): wcdPerUnit per unit, zo ver mogelijk
|
||||||
|
// uit elkaar langs de binnenzijde van de wanden.
|
||||||
|
// - Apparaten (settings.apparaten, APPLIANCES) met elk een eigen groep:
|
||||||
|
// kookgroep (perilex, 2-fase, 5×2,5), wasmachine, droger, warmtepomp,
|
||||||
|
// optioneel oven en vaatwasser. Keukenapparaten bij elkaar in de grootste
|
||||||
|
// andere unit, de rest naast de meterkast (nog geen keuken in het model).
|
||||||
|
// - Handmatig verplaatsen (settings.place[key] = { element, side, u, y }):
|
||||||
|
// key 'mk' (meterkast), 'sw:<unit>' (schakelaars), 'wcd:<unit>:<nr>',
|
||||||
|
// 'app:<apparaat>'. element = wandstuk, side = -1 (kamerzijde; bij een
|
||||||
|
// binnenwand ±1), u = hart langs de wand (wand-x), y = hoogte (optioneel).
|
||||||
|
// Handmatige plekken gaan voor: de automatische plaatsing wijkt uit.
|
||||||
|
// Wandvlakken: binnenzijde van buitenwanden en naadwanddelen, beide
|
||||||
|
// zijden van binnenwanden (in de unit waar hun midden ligt).
|
||||||
|
// - Groepen: per unit een lichtgroep (B10, 1,5 mm²) en een wcd-groep
|
||||||
|
// (B16, 2,5 mm²); het buitenlicht op een eigen groep (B10, XMvK 3×1,5),
|
||||||
|
// op dezelfde aardlek als het binnenlicht van de voordeur-unit (zelfde
|
||||||
|
// schakeldoos). Fasen L1/L2/L3 om en om; aardlekschakelaars (30 mA,
|
||||||
|
// max. 4 groepen elk, minstens 2) om en om, zodat licht en wcd van één
|
||||||
|
// unit niet op dezelfde aardlek zitten.
|
||||||
|
//
|
||||||
|
// Leidingen (HSB): binnen VD-draad in flexibele installatiebuis, via de
|
||||||
|
// stijlen (zelfde wand) of via het plafond (andere wand/plafondpunt): eerst
|
||||||
|
// verticaal in de wand naar het plafond, dan haaks (x, dan z) naar het
|
||||||
|
// volgende punt. Per schakeldoos en groep een luchtdichte verdeeldoos in het
|
||||||
|
// plafond erboven (groepen niet gemengd in één verdeeldoos); vandaar de schakeldraden omlaag en de lampen doorgelust volgens
|
||||||
|
// een minimale opspannende boom (kortste totale lengte). Buitenverlichting in
|
||||||
|
// XMvK. Een leiding over een naad tussen units krijgt een steekverbinder
|
||||||
|
// (modules worden los gebouwd en op de bouwplaats gekoppeld).
|
||||||
|
// Elke leiding: + reserve (lus in de doos, meterkast extra) en + slack %.
|
||||||
|
//
|
||||||
|
// Plain script, geladen na lighting-model.js en unit-model.js.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.ElektraModel = (() => {
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
enabled: true,
|
||||||
|
meterkast: { w: 600, h: 900, d: 250, y: 1000 }, // mm; y = onderkant boven vloer
|
||||||
|
switchHeight: 1500, // hart schakelaar boven vloer
|
||||||
|
wcdHeight: 400, // hart wcd boven vloer
|
||||||
|
wcdPerUnit: 2, // dubbele wcd per unit
|
||||||
|
slack: 10, // % extra op elke leiding
|
||||||
|
reserve: 300, // mm per leiding (lussen in de dozen)
|
||||||
|
meterkastReserve: 500, // mm extra per leiding vanuit de meterkast
|
||||||
|
apparaten: { koken: true, wasmachine: true, droger: true, warmtepomp: true, oven: false, vaatwasser: false },
|
||||||
|
place: {} // handmatige plekken (zie boven)
|
||||||
|
};
|
||||||
|
// Apparaten met een eigen groep. code = label op de plattegrond (twee
|
||||||
|
// letters: geen verwarring met de schakelaarletters).
|
||||||
|
const APPLIANCES = {
|
||||||
|
koken: { label: 'Kookgroep (perilex)', code: 'KK', breaker: '2× B16', poles: 2, cable: 'koken', height: 600, zone: 'keuken', material: 'Perilex-wandcontactdoos (2-fase)' },
|
||||||
|
oven: { label: 'Oven', code: 'OV', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' },
|
||||||
|
vaatwasser: { label: 'Vaatwasser', code: 'VW', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' },
|
||||||
|
wasmachine: { label: 'Wasmachine', code: 'WM', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
|
||||||
|
droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
|
||||||
|
warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' }
|
||||||
|
};
|
||||||
|
const BOX_PITCH = 71; // h.o.h. inbouwdozen in een combinatie
|
||||||
|
const PLATE = 80; // afdekraam (vierkant)
|
||||||
|
const WCD_W = 150; // afdekraam dubbele wcd
|
||||||
|
const DOOR_GAP = 100; // kozijnstijl -> hart eerste doos (bovenop de stijl)
|
||||||
|
const VD_OFFSET = 150; // verdeeldoos: zoveel de kamer in vanaf het wandvlak
|
||||||
|
|
||||||
|
// Leidingtypen. cores: aders (kleur via CORE_COLORS); conduit: buis-Ø (mm),
|
||||||
|
// 0 = kabel zonder buis.
|
||||||
|
const CORE_COLORS = { L: 'bruin', L2: 'zwart', L3: 'grijs', N: 'blauw', PE: 'geel/groen', S: 'zwart' };
|
||||||
|
const CABLES = {
|
||||||
|
licht: { label: 'VD 3×1,5 mm² in buis Ø16', use: 'Verlichting: voeding en doorlus', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 16 },
|
||||||
|
wcd: { label: 'VD 3×2,5 mm² in buis Ø19', use: 'Wcd en apparaten', mm2: 2.5, cores: ['L', 'N', 'PE'], conduit: 19 },
|
||||||
|
koken: { label: 'VD 5×2,5 mm² in buis Ø25', use: 'Kookgroep (perilex, 2-fase)', mm2: 2.5, cores: ['L', 'L2', 'L3', 'N', 'PE'], conduit: 25 },
|
||||||
|
buiten: { label: 'XMvK 3×1,5 mm²', use: 'Buitenverlichting (boeideel/gevel)', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 0 }
|
||||||
|
};
|
||||||
|
// Schakeldraden verdeeldoos -> schakeldoos: fase + één per schakelaar.
|
||||||
|
function cableInfo(key) {
|
||||||
|
if (CABLES[key]) return CABLES[key];
|
||||||
|
const m = /^schakel(\d)$/.exec(key);
|
||||||
|
const n = m ? Number(m[1]) : 2;
|
||||||
|
return { label: `VD ${n}×1,5 mm² in buis Ø16`, use: `Schakeldraden (${n - 1} schakelaar${n > 2 ? 's' : ''})`, mm2: 1.5, cores: ['L'].concat(Array(n - 1).fill('S')), conduit: 16 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettings(settings) {
|
||||||
|
const i = settings || {};
|
||||||
|
const s = Object.assign({}, DEFAULT_SETTINGS, i);
|
||||||
|
s.meterkast = Object.assign({}, DEFAULT_SETTINGS.meterkast, i.meterkast || {});
|
||||||
|
s.apparaten = Object.assign({}, DEFAULT_SETTINGS.apparaten, i.apparaten || {});
|
||||||
|
s.place = Object.assign({}, i.place || {});
|
||||||
|
s.enabled = s.enabled !== false;
|
||||||
|
// v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard).
|
||||||
|
if (!i.v) {
|
||||||
|
if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight;
|
||||||
|
if (i.wcdHeight === 300) s.wcdHeight = DEFAULT_SETTINGS.wcdHeight;
|
||||||
|
}
|
||||||
|
s.v = 2;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Wandvlakken (binnenzijde) waarop iets gemonteerd kan worden.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Zijde van een wandstuk (side -1 = lokaal -Z: bij buitenwand en
|
||||||
|
// naadwanddeel de kamerkant; binnenwand: beide zijden), begrensd tot de
|
||||||
|
// binnenruimte van de unit.
|
||||||
|
function faceOf(p, inner, u, side) {
|
||||||
|
const pl = p.place, W = pl.width, lz = side * pl.thickness / 2;
|
||||||
|
const a = UnitModel.localToWorld(pl, -W / 2, lz), b = UnitModel.localToWorld(pl, W / 2, lz);
|
||||||
|
const axis = Math.abs(b.x - a.x) > Math.abs(b.z - a.z) ? 'x' : 'z';
|
||||||
|
const uOf = (v) => (v - a[axis]) / (b[axis] - a[axis]) * W;
|
||||||
|
const ua = uOf(axis === 'x' ? inner.x0 : inner.z0), ub = uOf(axis === 'x' ? inner.x1 : inner.z1);
|
||||||
|
return {
|
||||||
|
piece: p, unit: u, a, b, axis, W, lz,
|
||||||
|
u0: Math.max(0, Math.min(ua, ub)), u1: Math.min(W, Math.max(ua, ub)),
|
||||||
|
n: { x: side * Math.sin(pl.rotY), z: side * Math.cos(pl.rotY) }, // naar de kamer
|
||||||
|
side,
|
||||||
|
sw: p.config.studWidth || 45,
|
||||||
|
reserved: [] // [{ a, b, y0, y1 }] al bezet (meterkast, schakelaars, wcd)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function facesOf(u, interior) {
|
||||||
|
const inner = LightingModel.unitInnerRect(u, interior);
|
||||||
|
const pieces = [];
|
||||||
|
u.walls.forEach(w => w.pieces.forEach(p => { if (!p.baseY) pieces.push(p); }));
|
||||||
|
interior.filter(p => p.seamUnit === u.id).forEach(p => pieces.push(p));
|
||||||
|
const list = pieces.map(p => faceOf(p, inner, u, -1));
|
||||||
|
const r = u.rect;
|
||||||
|
interior.filter(p => !p.seam && p.place.cx >= r.x0 && p.place.cx <= r.x1 && p.place.cz >= r.z0 && p.place.cz <= r.z1)
|
||||||
|
.forEach(p => list.push(faceOf(p, inner, u, -1), faceOf(p, inner, u, 1)));
|
||||||
|
return list.filter(f => f.u1 - f.u0 > 200);
|
||||||
|
}
|
||||||
|
// Punt op een wandvlak: u langs de wand (0..W), y boven de vloer, off de
|
||||||
|
// kamer in (negatief = de wand in).
|
||||||
|
function facePoint(f, u, y, off) {
|
||||||
|
const q = UnitModel.localToWorld(f.piece.place, u - f.W / 2, f.lz + f.side * (off || 0));
|
||||||
|
return { x: q.x, y: f.piece.place.floorTop + y, z: q.z };
|
||||||
|
}
|
||||||
|
const uAt = (f, q) => (q[f.axis] - f.a[f.axis]) / (f.b[f.axis] - f.a[f.axis]) * f.W;
|
||||||
|
// Vrije stukken van een vlak tussen hoogte y0..y1: openingen (incl.
|
||||||
|
// raamwerk + margin) en wat al bezet is eraf, edge vrij van de uiteinden.
|
||||||
|
function freeOn(f, y0, y1, margin, edge) {
|
||||||
|
const blocked = [];
|
||||||
|
f.piece.config.openings.forEach(op => {
|
||||||
|
if (op.y < y1 && WallModel.openingTopMax(op) > y0) blocked.push([op.x - f.sw - margin, op.x + op.w + f.sw + margin]);
|
||||||
|
});
|
||||||
|
f.reserved.forEach(r => { if (r.y0 < y1 && r.y1 > y0) blocked.push([r.a, r.b]); });
|
||||||
|
return LightingModel.freeIntervals(f.u0 + edge, f.u1 - edge, blocked);
|
||||||
|
}
|
||||||
|
// Dichtstbijzijnde vrije plek (breedte w) op een van de vlakken bij target
|
||||||
|
// (wereld {x, z}). Geeft { f, u, d } of null.
|
||||||
|
function nearestFree(faces, target, w, y0, y1, margin, edge) {
|
||||||
|
let best = null;
|
||||||
|
faces.forEach(f => freeOn(f, y0, y1, margin, edge).forEach(([a, b]) => {
|
||||||
|
if (b - a < w) return;
|
||||||
|
const u = Math.max(a + w / 2, Math.min(b - w / 2, uAt(f, target)));
|
||||||
|
const q = facePoint(f, u, 0, 0);
|
||||||
|
const d = Math.hypot(q.x - target.x, q.z - target.z);
|
||||||
|
if (!best || d < best.d) best = { f, u, d };
|
||||||
|
}));
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schakelaarcombinatie van n dozen naast opening op op vlak f: eerst de
|
||||||
|
// slotzijde (rechts van de opening in wand-x), dan de scharnierzijde.
|
||||||
|
// Geeft de doos-centra (u, dichtst bij de deur eerst) of null.
|
||||||
|
function besideDoor(f, op, n, height) {
|
||||||
|
const width = PLATE + (n - 1) * BOX_PITCH;
|
||||||
|
const free = freeOn(f, height - PLATE / 2, height + PLATE / 2, 0, f.sw);
|
||||||
|
const fits = (lo, hi) => free.some(([a, b]) => lo >= a - 0.5 && hi <= b + 0.5);
|
||||||
|
const r0 = op.x + op.w + f.sw + DOOR_GAP;
|
||||||
|
if (fits(r0 - PLATE / 2, r0 - PLATE / 2 + width)) return Array.from({ length: n }, (_, i) => r0 + i * BOX_PITCH);
|
||||||
|
const l0 = op.x - f.sw - DOOR_GAP;
|
||||||
|
if (fits(l0 + PLATE / 2 - width, l0 + PLATE / 2)) return Array.from({ length: n }, (_, i) => l0 - i * BOX_PITCH);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Routes. Knooppunt: { x, y, z, mount: 'wand'|'plafond'|'buiten', face?, mk? }.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
function makeRouter(units) {
|
||||||
|
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) => {
|
||||||
|
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);
|
||||||
|
const d = Math.hypot(dx, dz);
|
||||||
|
return !best || d < best.d ? { u: c, d } : best;
|
||||||
|
}, null).u;
|
||||||
|
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)));
|
||||||
|
};
|
||||||
|
// Pad als 3D-punten (mm). Per punt: wall = wandvlak waar het in de wand
|
||||||
|
// ligt, ceil = boven het plafond (voor de buizen in de 3D, build3D).
|
||||||
|
function path(a, b) {
|
||||||
|
const P = (p, wall, ceil) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil });
|
||||||
|
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)]; // door de stijlen
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
// Snelle schatting (voor de boom): haaks + hoogteverschil.
|
||||||
|
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
|
||||||
|
// Aantal keer dat het pad (in plattegrond) van de ene unit naar een andere gaat.
|
||||||
|
function crossings(pts) {
|
||||||
|
let last = null, n = 0;
|
||||||
|
pts.slice(1).forEach((q, i) => {
|
||||||
|
const p = pts[i], len = Math.hypot(q.x - p.x, q.z - p.z), steps = Math.max(1, Math.ceil(len / 100));
|
||||||
|
for (let k = 0; k <= steps; k++) {
|
||||||
|
const u = unitAt(p.x + (q.x - p.x) * k / steps, p.z + (q.z - p.z) * k / steps);
|
||||||
|
if (!u) continue;
|
||||||
|
if (last && u.id !== last) n++;
|
||||||
|
last = u.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
return { unitAt, ceilAt, path, length, cost, crossings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Alles voor de hele woning. units/interior = HouseModel-parts,
|
||||||
|
// lighting = parts.lighting (lamps, indoor), roofEdge = parts.roofEdge.
|
||||||
|
// Zet p.letter op elke lamp/spot. Geeft het elektra-resultaat (zie onder).
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
function compute(settings, units, interior, lighting, roofEdge) {
|
||||||
|
const s = normalizeSettings(settings);
|
||||||
|
const indoor = lighting.indoor || [], gevel = lighting.lamps || [], spots = (roofEdge && roofEdge.spots) || [];
|
||||||
|
indoor.concat(gevel, spots).forEach(p => { delete p.letter; });
|
||||||
|
const out = {
|
||||||
|
enabled: s.enabled, settings: s, meterkast: null, combos: [], switches: [], wcds: [], apps: [], boxes: [],
|
||||||
|
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
|
||||||
|
};
|
||||||
|
if (!s.enabled || !units.length) return out;
|
||||||
|
const R = makeRouter(units);
|
||||||
|
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);
|
||||||
|
const allFaces = out.faces = units.reduce((a, u) => a.concat(faces[u.id]), []);
|
||||||
|
|
||||||
|
// 1. Voordeur (anders de eerste buitendeur, anders de eerste unit).
|
||||||
|
let door = null;
|
||||||
|
['voordeur', 'deur'].some(type => units.some(u => u.walls.some(w => w.pieces.some(p => {
|
||||||
|
if (p.baseY) return false;
|
||||||
|
const op = p.config.openings.find(o => o.type === type);
|
||||||
|
const f = op && faceOfPiece(u, p);
|
||||||
|
if (f) door = { unit: u, face: f, op };
|
||||||
|
return !!door;
|
||||||
|
}))));
|
||||||
|
if (!door) out.warnings.push('Geen voordeur of buitendeur gevonden: schakelaars en meterkast vrij geplaatst.');
|
||||||
|
const mainUnit = door ? door.unit : units[0];
|
||||||
|
const hasOutdoor = gevel.length + spots.length > 0;
|
||||||
|
let letterCode = 65;
|
||||||
|
const nextLetter = () => String.fromCharCode(letterCode++);
|
||||||
|
const mainFuncs = hasOutdoor ? ['binnen', 'buiten'] : ['binnen'];
|
||||||
|
const mk = s.meterkast;
|
||||||
|
const apps = Object.keys(APPLIANCES).filter(k => s.apparaten[k]);
|
||||||
|
const num = (v, def) => typeof v === 'number' && isFinite(v) ? v : def;
|
||||||
|
|
||||||
|
// Handmatige plekken eerst vastleggen (de automatische wijken uit).
|
||||||
|
const comboWidth = (n) => PLATE + (n - 1) * BOX_PITCH;
|
||||||
|
const widthOf = (key) => {
|
||||||
|
if (key === 'mk') return mk.w;
|
||||||
|
let m = /^sw:(.+)$/.exec(key);
|
||||||
|
if (m) return units.some(u => u.id === m[1]) ? comboWidth(m[1] === mainUnit.id ? mainFuncs.length : 1) : 0;
|
||||||
|
m = /^wcd:(.+):(\d+)$/.exec(key);
|
||||||
|
if (m) return units.some(u => u.id === m[1]) && Number(m[2]) <= s.wcdPerUnit ? WCD_W : 0;
|
||||||
|
m = /^app:(.+)$/.exec(key);
|
||||||
|
return m && apps.includes(m[1]) ? PLATE : 0;
|
||||||
|
};
|
||||||
|
const fixed = {};
|
||||||
|
Object.keys(s.place).forEach(key => {
|
||||||
|
const o = s.place[key], w = widthOf(key);
|
||||||
|
if (!o || !w) return;
|
||||||
|
const f = allFaces.find(x => x.piece.element === o.element && x.side === (o.side || -1));
|
||||||
|
if (!f) return;
|
||||||
|
const u = Math.max(f.u0 + w / 2, Math.min(f.u1 - w / 2, num(o.u, (f.u0 + f.u1) / 2)));
|
||||||
|
fixed[key] = { f, u, y: num(o.y, null) };
|
||||||
|
f.reserved.push({ a: u - w / 2 - 50, b: u + w / 2 + 50, y0: 0, y1: 1e6 });
|
||||||
|
});
|
||||||
|
const centersAt = (u, n) => Array.from({ length: n }, (_, i) => u - comboWidth(n) / 2 + PLATE / 2 + i * BOX_PITCH);
|
||||||
|
|
||||||
|
// 2. Schakelaars bij de voordeur (A binnen, B buiten).
|
||||||
|
const addCombo = (unit, f, us, funcs, y, manual) => {
|
||||||
|
const yy = y === null || y === undefined ? s.switchHeight : y;
|
||||||
|
const combo = { id: out.combos.length + 1, key: 'sw:' + unit.id, manual: !!manual, unitId: unit.id, face: f, us, y: yy, rotY: f.piece.place.rotY, switches: [] };
|
||||||
|
funcs.forEach((func, i) => {
|
||||||
|
const sw = { letter: nextLetter(), func, unitId: unit.id, combo, u: us[i], lamps: [], label: func === 'buiten' ? 'Buitenlicht' : `Binnenlicht unit ${unit.id}` };
|
||||||
|
sw.pos = facePoint(f, us[i], yy, 0);
|
||||||
|
combo.switches.push(sw);
|
||||||
|
out.switches.push(sw);
|
||||||
|
});
|
||||||
|
const lo = Math.min(...us) - PLATE / 2, hi = Math.max(...us) + PLATE / 2;
|
||||||
|
if (!manual) f.reserved.push({ a: lo - 60, b: hi + 60, y0: 0, y1: 1e6 });
|
||||||
|
combo.u = (lo + hi) / 2;
|
||||||
|
combo.pos = facePoint(f, combo.u, yy, 0);
|
||||||
|
combo.width = hi - lo;
|
||||||
|
combo.center = facePoint(f, combo.u, yy, 5);
|
||||||
|
out.combos.push(combo);
|
||||||
|
return combo;
|
||||||
|
};
|
||||||
|
const fxMain = fixed['sw:' + mainUnit.id];
|
||||||
|
if (fxMain) addCombo(mainUnit, fxMain.f, centersAt(fxMain.u, mainFuncs.length), mainFuncs, fxMain.y, true);
|
||||||
|
else if (door) {
|
||||||
|
const us = besideDoor(door.face, door.op, mainFuncs.length, s.switchHeight);
|
||||||
|
if (us) addCombo(mainUnit, door.face, us, mainFuncs);
|
||||||
|
else {
|
||||||
|
const doorPt = facePoint(door.face, door.op.x + door.op.w / 2, 0, 0);
|
||||||
|
const w = PLATE + (mainFuncs.length - 1) * BOX_PITCH;
|
||||||
|
const nf = nearestFree(faces[mainUnit.id], doorPt, w, s.switchHeight - PLATE / 2, s.switchHeight + PLATE / 2, 0, 100);
|
||||||
|
if (nf) addCombo(mainUnit, nf.f, mainFuncs.map((_, i) => nf.u - w / 2 + PLATE / 2 + i * BOX_PITCH), mainFuncs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!out.combos.length) {
|
||||||
|
const f = faces[mainUnit.id][0];
|
||||||
|
if (f) addCombo(mainUnit, f, mainFuncs.map((_, i) => (f.u0 + f.u1) / 2 + i * BOX_PITCH), mainFuncs);
|
||||||
|
}
|
||||||
|
const entry = out.combos[0] ? out.combos[0].pos : { x: mainUnit.rect.cx, z: mainUnit.rect.cz };
|
||||||
|
|
||||||
|
// 3. Meterkast: zo dicht mogelijk bij de voordeur, eerst in die unit.
|
||||||
|
const target = door ? facePoint(door.face, door.op.x + door.op.w / 2, 0, 0) : entry;
|
||||||
|
const fxMk = fixed.mk;
|
||||||
|
const mkY = fxMk && fxMk.y !== null ? fxMk.y : mk.y;
|
||||||
|
let spot = fxMk ? { f: fxMk.f, u: fxMk.u } : nearestFree(faces[mainUnit.id], target, mk.w, mkY, mkY + mk.h, 50, 50);
|
||||||
|
if (!spot) units.some(u => u !== mainUnit && (spot = nearestFree(faces[u.id], target, mk.w, mkY, mkY + mk.h, 50, 50)));
|
||||||
|
if (spot) {
|
||||||
|
const f = spot.f;
|
||||||
|
if (!fxMk) f.reserved.push({ a: spot.u - mk.w / 2 - 50, b: spot.u + mk.w / 2 + 50, y0: 0, y1: 1e6 });
|
||||||
|
out.meterkast = {
|
||||||
|
key: 'mk', manual: !!fxMk, unitId: f.unit.id, element: f.piece.element, face: f, u: spot.u, w: mk.w, h: mk.h, d: mk.d, y: mkY, rotY: f.piece.place.rotY,
|
||||||
|
center: facePoint(f, spot.u, mkY + mk.h / 2, mk.d / 2),
|
||||||
|
corners: [[-1, 0], [1, 0], [1, mk.d], [-1, mk.d]].map(([i, d]) => facePoint(f, spot.u + i * mk.w / 2, 0, d))
|
||||||
|
};
|
||||||
|
} else out.warnings.push('Geen vrije wand voor de meterkast gevonden.');
|
||||||
|
|
||||||
|
// 4. Schakelaar per andere unit, naast de deur waardoor je hem binnenkomt.
|
||||||
|
units.forEach(u => {
|
||||||
|
if (u === mainUnit) return;
|
||||||
|
const fx = fixed['sw:' + u.id];
|
||||||
|
if (fx) { addCombo(u, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true); return; }
|
||||||
|
const cands = [];
|
||||||
|
faces[u.id].forEach(f => f.piece.config.openings.forEach(op => {
|
||||||
|
if (op.type !== 'deur' && op.type !== 'voordeur') return;
|
||||||
|
const q = facePoint(f, op.x + op.w / 2, 0, 0);
|
||||||
|
cands.push({ f, op, d: Math.hypot(q.x - entry.x, q.z - entry.z) + (f.piece.seamUnit ? 0 : 5000) }); // naadwand-deur eerst
|
||||||
|
}));
|
||||||
|
cands.sort((a, b) => a.d - b.d);
|
||||||
|
for (const c of cands) {
|
||||||
|
const us = besideDoor(c.f, c.op, 1, s.switchHeight);
|
||||||
|
if (us) { addCombo(u, c.f, us, ['binnen']); return; }
|
||||||
|
}
|
||||||
|
out.warnings.push(`Unit ${u.id}: geen deur met ruimte voor een schakelaar; binnenlicht op schakelaar ${out.switches[0] ? out.switches[0].letter : '-'}.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Lampen aan schakelaars koppelen (letter).
|
||||||
|
const swOf = (unitId, func) => out.switches.find(x => x.unitId === unitId && x.func === func) || out.switches.find(x => x.func === func) || out.switches[0];
|
||||||
|
indoor.forEach(p => { const sw = swOf(p.unitId, 'binnen'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x, y: p.y, z: p.z, mount: 'plafond', kind: 'binnenspot', ref: p }); } });
|
||||||
|
gevel.forEach(p => { const sw = swOf(mainUnit.id, 'buiten'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x + p.normal.x * 60, y: p.y, z: p.z + p.normal.z * 60, mount: 'buiten', kind: 'gevellamp', ref: p }); } });
|
||||||
|
spots.forEach(p => { const sw = swOf(mainUnit.id, 'buiten'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x, y: p.y, z: p.z, mount: 'buiten', kind: 'boeideelspot', ref: p }); } });
|
||||||
|
|
||||||
|
// 6. Wandcontactdozen per unit (zo ver mogelijk uit elkaar).
|
||||||
|
units.forEach(u => {
|
||||||
|
const y0 = s.wcdHeight - 60, y1 = s.wcdHeight + 60, cands = [];
|
||||||
|
faces[u.id].forEach(f => freeOn(f, y0, y1, 100, 150).forEach(([a, b]) => {
|
||||||
|
if (b - a < WCD_W + 20) return;
|
||||||
|
(b - a > 1200 ? [a + 300, (a + b) / 2, b - 300] : [(a + b) / 2]).forEach(uu => cands.push({ f, u: uu, len: b - a, p: facePoint(f, uu, s.wcdHeight, 0) }));
|
||||||
|
}));
|
||||||
|
const chosen = [], freeKeys = [];
|
||||||
|
for (let i = 1; i <= s.wcdPerUnit; i++) {
|
||||||
|
const key = `wcd:${u.id}:${i}`, fx = fixed[key];
|
||||||
|
if (!fx) { freeKeys.push(key); continue; }
|
||||||
|
const y = fx.y !== null ? fx.y : s.wcdHeight;
|
||||||
|
chosen.push({ f: fx.f, u: fx.u, y, p: facePoint(fx.f, fx.u, y, 0), key, manual: true });
|
||||||
|
}
|
||||||
|
while (freeKeys.length) {
|
||||||
|
let best = null, bestScore = -1;
|
||||||
|
cands.forEach(c => {
|
||||||
|
if (chosen.some(x => x.f === c.f && Math.abs(x.u - c.u) < WCD_W + 150)) return;
|
||||||
|
const score = chosen.length ? Math.min(...chosen.map(x => Math.hypot(x.p.x - c.p.x, x.p.z - c.p.z))) : c.len;
|
||||||
|
if (score > bestScore) { bestScore = score; best = c; }
|
||||||
|
});
|
||||||
|
if (!best) break;
|
||||||
|
chosen.push(Object.assign({}, best, { key: freeKeys.shift(), y: s.wcdHeight }));
|
||||||
|
best.f.reserved.push({ a: best.u - WCD_W / 2 - 75, b: best.u + WCD_W / 2 + 75, y0: 0, y1: s.wcdHeight + 200 });
|
||||||
|
}
|
||||||
|
if (chosen.length < s.wcdPerUnit) out.warnings.push(`Unit ${u.id}: ruimte voor ${chosen.length} van ${s.wcdPerUnit} wcd's.`);
|
||||||
|
chosen.sort((a, b) => a.key < b.key ? -1 : 1).forEach(c => out.wcds.push({
|
||||||
|
key: c.key, manual: !!c.manual, unitId: u.id, face: c.f, u: c.u, y: c.y, rotY: c.f.piece.place.rotY, pos: c.p, center: facePoint(c.f, c.u, c.y, 6)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6b. Apparaten: keuken bij elkaar in de grootste andere unit, de rest
|
||||||
|
// naast de meterkast.
|
||||||
|
const others = units.filter(u => u !== mainUnit);
|
||||||
|
const kitchen = (others.length ? others : units).reduce((a, b) => a.rect.W * a.rect.D >= b.rect.W * b.rect.D ? a : b);
|
||||||
|
const mkUnit = out.meterkast ? units.find(u => u.id === out.meterkast.unitId) : mainUnit;
|
||||||
|
const zones = {
|
||||||
|
keuken: { target: { x: kitchen.rect.cx, z: kitchen.rect.cz }, order: [kitchen].concat(units.filter(u => u !== kitchen)) },
|
||||||
|
techniek: { target: out.meterkast ? out.meterkast.center : entry, order: [mkUnit].concat(units.filter(u => u !== mkUnit)) }
|
||||||
|
};
|
||||||
|
apps.forEach(k => {
|
||||||
|
const A = APPLIANCES[k], key = 'app:' + k, fx = fixed[key];
|
||||||
|
let f, uu, y = A.height;
|
||||||
|
if (fx) { f = fx.f; uu = fx.u; if (fx.y !== null) y = fx.y; }
|
||||||
|
else {
|
||||||
|
const z = zones[A.zone];
|
||||||
|
let nf = null;
|
||||||
|
z.order.some(u => (nf = nearestFree(faces[u.id], z.target, PLATE + 40, y - 60, y + 60, 100, 150)));
|
||||||
|
if (!nf) { out.warnings.push(`${A.label}: geen vrije wand gevonden.`); return; }
|
||||||
|
f = nf.f; uu = nf.u;
|
||||||
|
f.reserved.push({ a: uu - PLATE / 2 - 80, b: uu + PLATE / 2 + 80, y0: 0, y1: 1e6 });
|
||||||
|
}
|
||||||
|
out.apps.push({ key, manual: !!fx, app: k, label: A.label, code: A.code, unitId: f.unit.id, face: f, u: uu, y, rotY: f.piece.place.rotY, pos: facePoint(f, uu, y, 0), center: facePoint(f, uu, y, 6) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Groepen: per unit licht (B10) + wcd (B16); fasen en aardlek om en om.
|
||||||
|
units.forEach(u => {
|
||||||
|
const sws = out.switches.filter(x => x.unitId === u.id && x.func === 'binnen');
|
||||||
|
if (sws.length) out.groups.push({ kind: 'licht', unitId: u.id, label: `Verlichting unit ${u.id}`, breaker: 'B10', cable: 'licht', switches: sws.map(x => x.letter) });
|
||||||
|
if (out.wcds.some(w => w.unitId === u.id)) out.groups.push({ kind: 'wcd', unitId: u.id, label: `Wcd unit ${u.id}`, breaker: 'B16', cable: 'wcd', switches: [] });
|
||||||
|
const outSw = out.switches.filter(x => x.unitId === u.id && x.func === 'buiten');
|
||||||
|
if (outSw.length) out.groups.push({ kind: 'buiten', unitId: u.id, label: 'Buitenverlichting', breaker: 'B10', cable: 'buiten', switches: outSw.map(x => x.letter) });
|
||||||
|
});
|
||||||
|
out.apps.forEach(a => {
|
||||||
|
const A = APPLIANCES[a.app];
|
||||||
|
out.groups.push({ kind: 'app', app: a.app, unitId: a.unitId, label: A.label, breaker: A.breaker, cable: A.cable, poles: A.poles || 1, switches: [] });
|
||||||
|
});
|
||||||
|
const nRcd = Math.max(2, Math.ceil(out.groups.length / 4));
|
||||||
|
let ph = 0;
|
||||||
|
out.groups.forEach((g, i) => {
|
||||||
|
const n = g.poles || 1;
|
||||||
|
g.nr = i + 1; g.rcd = i % nRcd + 1;
|
||||||
|
g.phase = Array.from({ length: n }, (_, j) => 'L' + ((ph + j) % 3 + 1)).join('+');
|
||||||
|
ph += n;
|
||||||
|
});
|
||||||
|
out.rcdCount = out.groups.length ? nRcd : 0;
|
||||||
|
const groupOf = (kind, unitId) => out.groups.find(g => g.kind === kind && g.unitId === unitId);
|
||||||
|
// Buitenlicht zit in de schakeldoos van het binnenlicht: zelfde aardlek.
|
||||||
|
const gOut = out.groups.find(g => g.kind === 'buiten'), gIn = gOut && groupOf('licht', gOut.unitId);
|
||||||
|
if (gOut && gIn) gOut.rcd = gIn.rcd;
|
||||||
|
// Max. 4 groepen per aardlek: anders een andere groep (niet die twee)
|
||||||
|
// naar de aardlek met de minste groepen.
|
||||||
|
for (let guard = 0; guard < 20; guard++) {
|
||||||
|
const cnt = Array.from({ length: nRcd }, (_, i) => out.groups.filter(g => g.rcd === i + 1).length);
|
||||||
|
const over = cnt.findIndex(c => c > 4);
|
||||||
|
if (over < 0) break;
|
||||||
|
const mv = out.groups.slice().reverse().find(g => g.rcd === over + 1 && g !== gOut && g !== gIn);
|
||||||
|
if (!mv) break;
|
||||||
|
mv.rcd = cnt.indexOf(Math.min(...cnt)) + 1;
|
||||||
|
}
|
||||||
|
out.switches.forEach(sw => { const g = groupOf(sw.func === 'buiten' ? 'buiten' : 'licht', sw.unitId); sw.group = g ? g.nr : null; });
|
||||||
|
out.wcds.forEach(w => { const g = groupOf('wcd', w.unitId); w.group = g ? g.nr : null; });
|
||||||
|
out.apps.forEach(a => { const g = out.groups.find(x => x.kind === 'app' && x.app === a.app); a.group = g ? g.nr : null; });
|
||||||
|
|
||||||
|
// 8. Leidingen.
|
||||||
|
const mkNode = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY + mk.h, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null;
|
||||||
|
const mkLow = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null;
|
||||||
|
const addRun = (type, a, b, group, letter) => {
|
||||||
|
const pts = R.path(a, b);
|
||||||
|
const raw = R.length(pts);
|
||||||
|
const lengthMM = (raw + s.reserve + (a.mk || b.mk ? s.meterkastReserve : 0)) * (1 + s.slack / 100);
|
||||||
|
out.runs.push({ type, group, letter: letter || null, from: a.name || '', to: b.name || '', path: pts, rawMM: raw, lengthMM, crossings: R.crossings(pts), outdoor: a.mount === 'buiten' || b.mount === 'buiten' });
|
||||||
|
};
|
||||||
|
// Minimale opspannende boom vanaf root (Prim, op de snelle schatting).
|
||||||
|
const tree = (root, nodes, typeOf, group, letter) => {
|
||||||
|
const done = [root], rest = nodes.slice();
|
||||||
|
while (rest.length) {
|
||||||
|
let best = null;
|
||||||
|
rest.forEach((n, i) => done.forEach(t => { const c = R.cost(t, n); if (!best || c < best.c) best = { c, i, t }; }));
|
||||||
|
const n = rest.splice(best.i, 1)[0];
|
||||||
|
addRun(typeOf(best.t, n), best.t, n, group, letter);
|
||||||
|
done.push(n);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Per schakeldoos één verdeeldoos per groep (naast elkaar langs de wand).
|
||||||
|
for (const combo of out.combos) {
|
||||||
|
const byGroup = [];
|
||||||
|
combo.switches.forEach(sw => {
|
||||||
|
let e = byGroup.find(x => x.group === sw.group);
|
||||||
|
if (!e) byGroup.push(e = { group: sw.group, switches: [] });
|
||||||
|
e.switches.push(sw);
|
||||||
|
});
|
||||||
|
combo.vds = [];
|
||||||
|
const along = { x: combo.face.b.x - combo.face.a.x, z: combo.face.b.z - combo.face.a.z }, al = Math.hypot(along.x, along.z) || 1;
|
||||||
|
byGroup.forEach((e, i) => {
|
||||||
|
const shift = (i - (byGroup.length - 1) / 2) * VD_OFFSET;
|
||||||
|
const vd = {
|
||||||
|
x: combo.pos.x + combo.face.n.x * VD_OFFSET + along.x / al * shift, z: combo.pos.z + combo.face.n.z * VD_OFFSET + along.z / al * shift,
|
||||||
|
mount: 'plafond', name: `VD${combo.id}${byGroup.length > 1 ? String.fromCharCode(97 + i) : ''}`
|
||||||
|
};
|
||||||
|
vd.y = R.ceilAt(vd.x, vd.z);
|
||||||
|
combo.vds.push(vd);
|
||||||
|
out.boxes.push({ kind: 'verdeeldoos', combo: combo.id, group: e.group, x: vd.x, y: vd.y, z: vd.z, unitId: combo.unitId });
|
||||||
|
if (mkNode) addRun('licht', mkNode, vd, e.group); // voeding binnen: VD in buis
|
||||||
|
const swNode = Object.assign({}, combo.pos, { mount: 'wand', face: combo.face, name: e.switches.map(x => x.letter).join('') });
|
||||||
|
addRun('schakel' + (e.switches.length + 1), vd, swNode, e.group);
|
||||||
|
e.switches.forEach(sw => tree(vd, sw.lamps.map((l, k) => Object.assign(l, { name: `${sw.letter}${k + 1}` })),
|
||||||
|
(a, b) => a.mount === 'buiten' || b.mount === 'buiten' ? 'buiten' : 'licht', e.group, sw.letter));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.groups.filter(g => g.kind === 'wcd').forEach(g => {
|
||||||
|
const nodes = out.wcds.filter(w => w.group === g.nr).map((w, i) => Object.assign({}, w.pos, { mount: 'wand', face: w.face, name: `WCD${g.nr}.${i + 1}` }));
|
||||||
|
if (mkLow && nodes.length) tree(mkLow, nodes, () => 'wcd', g.nr);
|
||||||
|
});
|
||||||
|
// Apparaten: elk een eigen leiding vanuit de meterkast.
|
||||||
|
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));
|
||||||
|
|
||||||
|
// 9. Totalen: per leidingtype, draad per doorsnede/kleur, buis, groep.
|
||||||
|
const byType = {};
|
||||||
|
out.runs.forEach(r => { byType[r.type] = (byType[r.type] || 0) + r.lengthMM; });
|
||||||
|
out.cables = Object.keys(byType).sort().map(k => ({ key: k, label: cableInfo(k).label, use: cableInfo(k).use, meters: byType[k] / 1000 }));
|
||||||
|
const wires = {}, conduits = {};
|
||||||
|
out.runs.forEach(r => {
|
||||||
|
const c = cableInfo(r.type);
|
||||||
|
if (!c.conduit) return;
|
||||||
|
conduits[c.conduit] = (conduits[c.conduit] || 0) + r.lengthMM;
|
||||||
|
c.cores.forEach(core => {
|
||||||
|
const k = `${String(c.mm2).replace('.', ',')} mm² ${CORE_COLORS[core]}`;
|
||||||
|
wires[k] = (wires[k] || 0) + r.lengthMM;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
out.wires = Object.keys(wires).sort().map(k => ({ label: `VD-draad ${k}`, meters: wires[k] / 1000 }));
|
||||||
|
out.conduits = Object.keys(conduits).sort((a, b) => a - b).map(k => ({ label: `Flexibele installatiebuis Ø${k} mm`, meters: conduits[k] / 1000 }));
|
||||||
|
out.groups.forEach(g => {
|
||||||
|
const rs = out.runs.filter(r => r.group === g.nr);
|
||||||
|
g.meters = rs.reduce((a, r) => a + r.lengthMM, 0) / 1000;
|
||||||
|
g.points = g.kind === 'wcd' ? out.wcds.filter(w => w.group === g.nr).length
|
||||||
|
: g.kind === 'app' ? 1
|
||||||
|
: out.switches.filter(x => x.group === g.nr).reduce((a, x) => a + x.lamps.length, 0);
|
||||||
|
});
|
||||||
|
out.groups.forEach(g => {
|
||||||
|
if ((g.kind === 'licht' || g.kind === 'buiten') && g.points > 50) out.warnings.push(`Groep ${g.nr} (${g.label}): ${g.points} lichtpunten; controleer het vermogen (LED).`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 10. Materiaal (aantallen).
|
||||||
|
const crossBy = {};
|
||||||
|
out.runs.forEach(r => { if (r.crossings) { const n = cableInfo(r.type).cores.length; crossBy[n] = (crossBy[n] || 0) + r.crossings; } });
|
||||||
|
const nIndoor = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'binnenspot').length, 0);
|
||||||
|
const nSpots = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'boeideelspot').length, 0);
|
||||||
|
const nGevel = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'gevellamp').length, 0);
|
||||||
|
const nLicht = out.groups.filter(g => g.kind === 'licht').length, nWcdG = out.groups.filter(g => g.kind === 'wcd').length;
|
||||||
|
const frames = {};
|
||||||
|
out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; });
|
||||||
|
const m = out.materials;
|
||||||
|
const add = (naam, aantal, eenheid) => { if (aantal > 0) m.push({ naam, aantal, eenheid: eenheid || 'st.' }); };
|
||||||
|
if (out.meterkast) {
|
||||||
|
add('Groepenkast 3-fase (aansluiting 3×25 A)', 1);
|
||||||
|
add('Hoofdschakelaar 4-polig 40 A', 1);
|
||||||
|
}
|
||||||
|
add('Aardlekschakelaar 2-polig 40 A / 30 mA', out.rcdCount);
|
||||||
|
add('Installatieautomaat B10 1P+N (verlichting)', nLicht);
|
||||||
|
add('Installatieautomaat B10 1P+N (buitenverlichting)', out.groups.filter(g => g.kind === 'buiten').length);
|
||||||
|
add('Installatieautomaat B16 1P+N (wcd)', nWcdG);
|
||||||
|
add('Installatieautomaat B16 1P+N (apparaten)', out.apps.filter(a => (APPLIANCES[a.app].poles || 1) === 1).length);
|
||||||
|
add('Installatieautomaat 2× B16, 2-fase (kookgroep)', out.apps.filter(a => APPLIANCES[a.app].poles === 2).length);
|
||||||
|
add('Schakelaar enkelpolig (inbouw)', out.switches.length);
|
||||||
|
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig`, frames[n]));
|
||||||
|
add('Wandcontactdoos dubbel met randaarde', out.wcds.length);
|
||||||
|
const appMat = {};
|
||||||
|
out.apps.forEach(a => { const n = APPLIANCES[a.app].material; appMat[n] = (appMat[n] || 0) + 1; });
|
||||||
|
Object.keys(appMat).forEach(n => add(n, appMat[n]));
|
||||||
|
add('Luchtdichte hollewanddoos (schakelaar/wcd/apparaat)', out.switches.length + 2 * out.wcds.length + out.apps.length);
|
||||||
|
add('Luchtdichte verdeeldoos (plafond)', out.boxes.length);
|
||||||
|
add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length);
|
||||||
|
add('LED-inbouwspot binnen + luchtdichte spotdoos', nIndoor);
|
||||||
|
add('Buitenspot boeideel (IP44)', nSpots);
|
||||||
|
add('Gevellamp (IP44)', nGevel);
|
||||||
|
add('Luchtdichte kabeldoorvoer naar buiten', out.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && r.to !== '' && !r.to.startsWith('VD')).length);
|
||||||
|
Object.keys(crossBy).forEach(n => add(`Steekverbinder module-naad ${n}-polig (set m/v)`, crossBy[n]));
|
||||||
|
|
||||||
|
// 11. Verplaatsbare onderdelen (plattegrond: slepen, selecteren).
|
||||||
|
// marker = plek van het symbool in de plattegrond (iets de kamer in).
|
||||||
|
const item = (key, kind, label, f, u, y, manual, off, unitId) => out.items.push({
|
||||||
|
key, kind, label, unitId: unitId || f.unit.id, element: f.piece.element, side: f.side, u, y, manual: !!manual, marker: facePoint(f, u, 0, off)
|
||||||
|
});
|
||||||
|
const k = out.meterkast;
|
||||||
|
if (k) item('mk', 'mk', 'Meterkast', k.face, k.u, k.y, k.manual, k.d / 2);
|
||||||
|
out.combos.forEach(c => item(c.key, 'sw', 'Schakelaar ' + c.switches.map(x => x.letter).join(' '), c.face, c.u, c.y, c.manual, 260, c.unitId));
|
||||||
|
out.wcds.forEach(w => item(w.key, 'wcd', `Wcd ${w.key.split(':')[2]} unit ${w.unitId} (groep ${w.group})`, w.face, w.u, w.y, w.manual, 120, w.unitId));
|
||||||
|
out.apps.forEach(a => item(a.key, 'app', `${a.label} (groep ${a.group})`, a.face, a.u, a.y, a.manual, 150));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dichtstbijzijnde wandvlak (kamerzijde) bij plattegrondpunt (x, z), voor
|
||||||
|
// het slepen: { element, side, u, x, z, n } of null.
|
||||||
|
function snapToWall(el, x, z) {
|
||||||
|
let best = null;
|
||||||
|
((el && el.faces) || []).forEach(f => {
|
||||||
|
const u = Math.max(f.u0, Math.min(f.u1, uAt(f, { x, z })));
|
||||||
|
const q = facePoint(f, u, 0, 0);
|
||||||
|
if ((x - q.x) * f.n.x + (z - q.z) * f.n.z < -(f.piece.place.thickness + 50)) return; // aan de andere kant van de wand
|
||||||
|
const d = Math.hypot(q.x - x, q.z - z);
|
||||||
|
if (!best || d < best.d) best = { d, element: f.piece.element, side: f.side, u: Math.round(u / 10) * 10, x: q.x, z: q.z, n: f.n };
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 3D per bouwfase (BuildStages):
|
||||||
|
// elektra meterkast (blijft), installatiebuizen (zeskant, kleur per
|
||||||
|
// leidingtype) in de wanden en boven het plafond, luchtdichte
|
||||||
|
// inbouwdozen, verdeeldozen en spotdozen. Vanaf "beplating"
|
||||||
|
// zitten buizen en dozen achter de platen: niet meer gebouwd.
|
||||||
|
// afwerking afdekramen van schakelaars, wcd's en apparaten.
|
||||||
|
// Kabel zonder buis (XMvK buiten) en losse draad worden niet getekend.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
const WALL_IN = 35; // buis: zoveel achter het wandvlak (in de stijlruimte)
|
||||||
|
const CEIL_UP = 35; // buis: zoveel boven de onderkant plafond (op de plafondplaat)
|
||||||
|
const CONDUIT_COLORS = { licht: 0xe0b020, wcd: 0xcc4a30, koken: 0x8a3a9a, schakel: 0x9a6a20 };
|
||||||
|
function createMaterials() {
|
||||||
|
const conduit = {};
|
||||||
|
Object.keys(CONDUIT_COLORS).forEach(k => { conduit[k] = new THREE.MeshStandardMaterial({ color: CONDUIT_COLORS[k], roughness: 0.55 }); });
|
||||||
|
return {
|
||||||
|
kastMaterial: new THREE.MeshStandardMaterial({ color: 0xd8d8d2, roughness: 0.6 }),
|
||||||
|
deurMaterial: new THREE.MeshStandardMaterial({ color: 0xc4c4bd, roughness: 0.5 }),
|
||||||
|
plateMaterial: new THREE.MeshStandardMaterial({ color: 0xf6f6f2, roughness: 0.4 }),
|
||||||
|
boxMaterial: new THREE.MeshStandardMaterial({ color: 0xe8742a, roughness: 0.6 }),
|
||||||
|
conduit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function build3D(el, materials, stage) {
|
||||||
|
const toM = 0.001, group = new THREE.Group();
|
||||||
|
if (!el || !el.enabled) return group;
|
||||||
|
const mats = materials || createMaterials();
|
||||||
|
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key);
|
||||||
|
const rough = show('elektra') && !show('beplating');
|
||||||
|
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);
|
||||||
|
mesh.rotation.y = rotY;
|
||||||
|
group.add(mesh);
|
||||||
|
return mesh;
|
||||||
|
};
|
||||||
|
const k = el.meterkast;
|
||||||
|
if (k && show('elektra')) {
|
||||||
|
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')) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
if (!rough) return group;
|
||||||
|
|
||||||
|
// Installatiebuizen: zeskantige cilinders per padstuk + bolletje op elke knik.
|
||||||
|
const up = new THREE.Vector3(0, 1, 0);
|
||||||
|
const shift = (p) => ({
|
||||||
|
x: p.x - (p.wall ? p.wall.n.x * WALL_IN : 0), y: p.y + (p.ceil ? CEIL_UP : 0), z: p.z - (p.wall ? p.wall.n.z * WALL_IN : 0)
|
||||||
|
});
|
||||||
|
el.runs.forEach(r => {
|
||||||
|
const c = cableInfo(r.type);
|
||||||
|
if (!c.conduit) return;
|
||||||
|
const rad = c.conduit / 2 * toM;
|
||||||
|
const mat = mats.conduit[r.type] || mats.conduit.schakel;
|
||||||
|
const pts = r.path.map(shift);
|
||||||
|
pts.slice(1).forEach((q, i) => {
|
||||||
|
const p = pts[i], dx = q.x - p.x, dy = q.y - p.y, dz = q.z - p.z, len = Math.hypot(dx, dy, dz);
|
||||||
|
if (len < 1) return;
|
||||||
|
const m = new THREE.Mesh(new THREE.CylinderGeometry(rad, rad, len * toM, 6), mat);
|
||||||
|
m.position.set((p.x + q.x) / 2 * toM, (p.y + q.y) / 2 * toM, (p.z + q.z) / 2 * toM);
|
||||||
|
m.quaternion.setFromUnitVectors(up, new THREE.Vector3(dx / len, dy / len, dz / len));
|
||||||
|
group.add(m);
|
||||||
|
});
|
||||||
|
pts.slice(1, -1).forEach(p => {
|
||||||
|
const j = new THREE.Mesh(new THREE.IcosahedronGeometry(rad * 1.15, 0), mat);
|
||||||
|
j.position.set(p.x * toM, p.y * toM, p.z * toM);
|
||||||
|
group.add(j);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Luchtdichte inbouwdozen (schakelaars, wcd, apparaten): in de wand.
|
||||||
|
const wallBox = (f, u, y) => {
|
||||||
|
const c = facePoint(f, u, y, -22);
|
||||||
|
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.034, 0.034, 0.045, 12), mats.boxMaterial);
|
||||||
|
m.position.set(c.x * toM, c.y * toM, c.z * toM);
|
||||||
|
m.quaternion.setFromUnitVectors(up, new THREE.Vector3(f.n.x, 0, f.n.z));
|
||||||
|
group.add(m);
|
||||||
|
};
|
||||||
|
el.switches.forEach(sw => wallBox(sw.combo.face, sw.u, sw.combo.y));
|
||||||
|
el.wcds.forEach(w => { wallBox(w.face, w.u - 36, w.y); wallBox(w.face, w.u + 36, w.y); });
|
||||||
|
el.apps.forEach(a => wallBox(a.face, a.u, a.y));
|
||||||
|
// Verdeeldozen en spotdozen boven het plafond.
|
||||||
|
el.boxes.forEach(b => box(90, 50, 90, { x: b.x, y: b.y + CEIL_UP + 10, z: b.z }, 0, mats.boxMaterial));
|
||||||
|
el.switches.forEach(sw => sw.lamps.filter(l => l.kind === 'binnenspot').forEach(l => {
|
||||||
|
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.07, 12), mats.boxMaterial);
|
||||||
|
m.position.set(l.x * toM, (l.y + 18 + 35) * toM, l.z * toM);
|
||||||
|
group.add(m);
|
||||||
|
}));
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, cableInfo, normalizeSettings, compute, snapToWall, createMaterials, build3D };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// elektra/elektra-pdf.js — ElektraPdf: elektra in de woning-PDF.
|
||||||
|
// drawPlan : meterkast, schakelaars (letter), wcd's (groep), lamp-
|
||||||
|
// letters en leidingen op de plattegrond (blad Plattegrond)
|
||||||
|
// drawSheet : eigen blad met groepenverklaring, schakelaars, strekkende
|
||||||
|
// meters per leidingtype, draad, buis en materiaal.
|
||||||
|
// Alle gegevens komen uit ElektraModel.compute (parts.elektra).
|
||||||
|
// Plain script, geladen na pdf-sheet.js en elektra-model.js.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.ElektraPdf = (() => {
|
||||||
|
const fmt = (n, d = 1) => Number(n).toFixed(d).replace('.', ',');
|
||||||
|
// Kleur per leidingtype (ook de legenda).
|
||||||
|
const RUN_COLORS = { licht: [215, 160, 0], wcd: [205, 55, 35], buiten: [40, 140, 60], schakel: [150, 95, 20] };
|
||||||
|
const runColor = (type) => RUN_COLORS[type] || RUN_COLORS.schakel;
|
||||||
|
|
||||||
|
// px/py: woning-mm -> blad-mm.
|
||||||
|
function drawPlan(doc, el, px, py) {
|
||||||
|
if (!el || !el.enabled) return;
|
||||||
|
const BLACK = PdfSheet.COLORS.BLACK;
|
||||||
|
// Leidingen (gestippeld, plattegrond-projectie van het pad).
|
||||||
|
doc.setLineWidth(0.15);
|
||||||
|
doc.setLineDashPattern([0.8, 0.6], 0);
|
||||||
|
el.runs.forEach(r => {
|
||||||
|
doc.setDrawColor(...runColor(r.type));
|
||||||
|
r.path.slice(1).forEach((b, i) => {
|
||||||
|
const a = r.path[i];
|
||||||
|
if (Math.hypot(px(b.x) - px(a.x), py(b.z) - py(a.z)) > 0.05) doc.line(px(a.x), py(a.z), px(b.x), py(b.z));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
doc.setLineDashPattern([], 0);
|
||||||
|
|
||||||
|
// Meterkast
|
||||||
|
const k = el.meterkast;
|
||||||
|
if (k) {
|
||||||
|
doc.setDrawColor(...BLACK);
|
||||||
|
doc.setFillColor(90, 90, 90);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
const c = k.corners;
|
||||||
|
doc.lines(c.slice(1).map((p, i) => [px(p.x) - px(c[i].x), py(p.z) - py(c[i].z)]), px(c[0].x), py(c[0].z), [1, 1], 'FD', true);
|
||||||
|
doc.setFontSize(5);
|
||||||
|
doc.setTextColor(...BLACK);
|
||||||
|
const m = { x: (c[0].x + c[2].x) / 2 + k.face.n.x * 350, z: (c[0].z + c[2].z) / 2 + k.face.n.z * 350 };
|
||||||
|
doc.text('MK', px(m.x), py(m.z) + 0.8, { align: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verdeeldozen (plafond): klein vierkantje.
|
||||||
|
doc.setDrawColor(...runColor('schakel'));
|
||||||
|
doc.setLineWidth(0.15);
|
||||||
|
el.boxes.forEach(b => doc.rect(px(b.x) - 0.6, py(b.z) - 0.6, 1.2, 1.2, 'S'));
|
||||||
|
|
||||||
|
// Schakelaars: kadertje met de letters, iets de kamer in.
|
||||||
|
doc.setFontSize(5);
|
||||||
|
el.combos.forEach(c => {
|
||||||
|
const q = { x: c.pos.x + c.face.n.x * 260, z: c.pos.z + c.face.n.z * 260 };
|
||||||
|
const text = c.switches.map(s => s.letter).join(' ');
|
||||||
|
const w = 1.6 + text.length * 1.0;
|
||||||
|
doc.setDrawColor(...BLACK);
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.rect(px(q.x) - w / 2, py(q.z) - 1.2, w, 2.4, 'FD');
|
||||||
|
doc.setTextColor(...BLACK);
|
||||||
|
doc.text(text, px(q.x), py(q.z) + 0.7, { align: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wcd: halve cirkel tegen de wand + groepnummer.
|
||||||
|
el.wcds.forEach(w => {
|
||||||
|
const q = { x: w.pos.x + w.face.n.x * 120, z: w.pos.z + w.face.n.z * 120 };
|
||||||
|
doc.setDrawColor(...RUN_COLORS.wcd);
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.circle(px(q.x), py(q.z), 0.9, 'FD');
|
||||||
|
doc.setTextColor(...RUN_COLORS.wcd);
|
||||||
|
doc.setFontSize(4);
|
||||||
|
doc.text(String(w.group), px(q.x + w.face.n.x * 250), py(q.z + w.face.n.z * 250) + 0.6, { align: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apparaten: kadertje met de code (KK, WM, ...) + groepnummer.
|
||||||
|
el.apps.forEach(a => {
|
||||||
|
const it = el.items.find(x => x.key === a.key), q = it.marker;
|
||||||
|
doc.setDrawColor(...RUN_COLORS.wcd);
|
||||||
|
doc.setFillColor(255, 255, 255);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.rect(px(q.x) - 1.6, py(q.z) - 1.1, 3.2, 2.2, 'FD');
|
||||||
|
doc.setTextColor(...RUN_COLORS.wcd);
|
||||||
|
doc.setFontSize(3.6);
|
||||||
|
doc.text(a.code, px(q.x), py(q.z) + 0.5, { align: 'center' });
|
||||||
|
doc.text(String(a.group), px(q.x + a.face.n.x * 330), py(q.z + a.face.n.z * 330) + 0.5, { align: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lampletters (zelfde letter als de schakelaar).
|
||||||
|
doc.setFontSize(3.6);
|
||||||
|
doc.setTextColor(...BLACK);
|
||||||
|
el.switches.forEach(s => s.lamps.forEach(l => doc.text(s.letter, px(l.x) + 0.9, py(l.z) - 0.7)));
|
||||||
|
}
|
||||||
|
// Twee regels (doc.text met een array).
|
||||||
|
const PLAN_LEGEND = [
|
||||||
|
'Elektra: MK = meterkast, kadertje = schakelaar(s) met letter (dezelfde letter staat bij de lampen erop), rood rondje = dubbele wcd (groepnummer), vierkantje = verdeeldoos in plafond;',
|
||||||
|
'rood kadertje = apparaat (KK kookgroep, WM wasmachine, DR droger, WP warmtepomp, OV oven, VW vaatwasser) met groepnummer; gestippeld = leidingen (geel licht, bruin schakeldraad, rood wcd/apparaat, groen buiten).'
|
||||||
|
];
|
||||||
|
|
||||||
|
function drawSheet(doc, el, sheetOpts) {
|
||||||
|
const BLACK = PdfSheet.COLORS.BLACK;
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...BLACK);
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.text('Elektra', 15, 18);
|
||||||
|
const t = (title) => ({ title, fontSize: 7, rowH: 4.4, lastColW: 16 });
|
||||||
|
let y = 30;
|
||||||
|
y = PdfSheet.table(doc, 15, y, [0, 12, 62, 74, 88, 104, 140, 156], ['Groep', 'Omschrijving', 'Fase', 'Aardlek', 'Automaat', 'Leiding', 'Punten', 'Lengte'],
|
||||||
|
el.groups.map(g => [String(g.nr), g.label, g.phase, `AL${g.rcd}`, g.breaker, ElektraModel.cableInfo(g.cable).label.replace(/ in buis.*/, ''),
|
||||||
|
g.kind === 'app' ? 'apparaat' : `${g.points} ${g.kind === 'wcd' ? 'wcd' : 'lamp'}`, fmt(g.meters) + ' m']),
|
||||||
|
t('Groepenverklaring (aansluiting 3×25 A)'));
|
||||||
|
y += 4;
|
||||||
|
y = PdfSheet.table(doc, 15, y, [0, 14, 60, 76, 92], ['Letter', 'Functie', 'Unit', 'Groep', 'Lampen'],
|
||||||
|
el.switches.map(s => [s.letter, s.label, s.unitId, String(s.group || '-'),
|
||||||
|
`${s.lamps.length} (${[['binnenspot', 'binnenspot'], ['boeideelspot', 'boeideel-spot'], ['gevellamp', 'gevellamp']]
|
||||||
|
.map(([k, l]) => [s.lamps.filter(x => x.kind === k).length, l]).filter(([n]) => n).map(([n, l]) => `${n} ${l}`).join(', ') || '-'})`]),
|
||||||
|
t('Schakelaars (letter staat ook bij de lampen op de plattegrond)'));
|
||||||
|
y += 4;
|
||||||
|
const k = el.meterkast;
|
||||||
|
const notes = [
|
||||||
|
k ? `Meterkast ${k.w}×${k.h}×${k.d} mm (b×h×d) in unit ${k.unitId}, wand ${k.element}, onderkant ${k.y} mm boven vloer.` : 'Geen meterkast geplaatst.',
|
||||||
|
`Schakelaars op ${el.settings.switchHeight} mm, wcd op ${el.settings.wcdHeight} mm (hart, boven bovenkant vloer).`,
|
||||||
|
`Leidinglengte = route via stijlen/plafond + ${el.settings.reserve} mm reserve (meterkast +${el.settings.meterkastReserve} mm), + ${el.settings.slack}% marge.`,
|
||||||
|
'Groepen per unit: licht B10 (1,5 mm²), wcd B16 (2,5 mm²); buitenlicht eigen groep B10 (XMvK), zelfde aardlek als het binnenlicht in dezelfde schakeldoos.',
|
||||||
|
'Per schakeldoos één verdeeldoos per groep; fasen en aardlekschakelaars om en om.',
|
||||||
|
'Apparaten (kookgroep, wasmachine, ...) automatisch geplaatst zolang er geen keuken/bijkeuken is; verplaatsbaar in de plattegrond.',
|
||||||
|
'Indicatief eerste ontwerp; uitvoering en controle volgens NEN 1010 door de installateur.'
|
||||||
|
].concat(el.warnings.map(w => 'Let op: ' + w));
|
||||||
|
doc.setFontSize(7);
|
||||||
|
notes.forEach(n => { doc.text(n, 15, y); y += 4.2; });
|
||||||
|
|
||||||
|
let y2 = 30;
|
||||||
|
y2 = PdfSheet.table(doc, 205, y2, [0, 46, 102], ['Leidingtype', 'Gebruik', 'Lengte'],
|
||||||
|
el.cables.map(c => [c.label, c.use, fmt(c.meters) + ' m']), t('Leidingen (strekkende meter)'));
|
||||||
|
y2 += 4;
|
||||||
|
y2 = PdfSheet.table(doc, 205, y2, [0, 102], ['Draad', 'Lengte'], el.wires.map(w => [w.label, fmt(w.meters) + ' m']), t('Draad (per ader)'));
|
||||||
|
y2 += 4;
|
||||||
|
PdfSheet.table(doc, 205, y2, [0, 102], ['Buis', 'Lengte'], el.conduits.map(c => [c.label, fmt(c.meters) + ' m']), t('Installatiebuis'));
|
||||||
|
|
||||||
|
PdfSheet.table(doc, 330, 30, [0, 66], ['Onderdeel', 'Aantal'], el.materials.map(m => [m.naam, `${m.aantal} ${m.eenheid}`]), Object.assign(t('Materiaal'), { maxY: 240 }));
|
||||||
|
|
||||||
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { RUN_COLORS, drawPlan, drawSheet, PLAN_LEGEND };
|
||||||
|
})();
|
||||||
@@ -257,7 +257,9 @@ window.FloorModel = (() => {
|
|||||||
// Z = -diepte/2), Y = dikte (balkhoogte + OSB). Balken en isolatie zijn
|
// Z = -diepte/2), Y = dikte (balkhoogte + OSB). Balken en isolatie zijn
|
||||||
// simpele dozen; de OSB-onderlaag is een geëxtrudeerd Shape met ronde
|
// simpele dozen; de OSB-onderlaag is een geëxtrudeerd Shape met ronde
|
||||||
// uitsparingen voor de doorvoeren.
|
// uitsparingen voor de doorvoeren.
|
||||||
// opts: { materials, osbVisible, finish }
|
// opts: { materials, osbVisible, finish, outerJoistsOnly }
|
||||||
|
// outerJoistsOnly: alleen de buitenste balken en kopbalken (de rest is
|
||||||
|
// ingesloten, bijv. dakbalken tussen plafondplaat en dakbedekking).
|
||||||
// finish (optioneel): { key (FINISHES), originX, originZ } — afwerking als
|
// finish (optioneel): { key (FINISHES), originX, originZ } — afwerking als
|
||||||
// dun vlak op de OSB; UV's in meters vanaf origin (m, woning-coördinaten
|
// dun vlak op de OSB; UV's in meters vanaf origin (m, woning-coördinaten
|
||||||
// van het vloermidden), zodat het patroon over units doorloopt.
|
// van het vloermidden), zodat het patroon over units doorloopt.
|
||||||
@@ -277,7 +279,8 @@ window.FloorModel = (() => {
|
|||||||
const rims = calcData.rims || {};
|
const rims = calcData.rims || {};
|
||||||
const joistLenM = (calcData.joistLen || depth) * toM;
|
const joistLenM = (calcData.joistLen || depth) * toM;
|
||||||
const joistZ = ((rims.front ? joistW : 0) - (rims.back ? joistW : 0)) / 2 * toM;
|
const joistZ = ((rims.front ? joistW : 0) - (rims.back ? joistW : 0)) / 2 * toM;
|
||||||
joists.forEach(xMM => {
|
joists.forEach((xMM, i) => {
|
||||||
|
if (opts.outerJoistsOnly && i > 0 && i < joists.length - 1) return;
|
||||||
const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM);
|
const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM);
|
||||||
const mesh = new THREE.Mesh(geo, woodMaterial);
|
const mesh = new THREE.Mesh(geo, woodMaterial);
|
||||||
mesh.position.set(xMM * toM, joistHM / 2, joistZ);
|
mesh.position.set(xMM * toM, joistHM / 2, joistZ);
|
||||||
|
|||||||
+205
-67
@@ -64,7 +64,7 @@ window.HouseModel = (() => {
|
|||||||
// rabat = vochtdoek + kruislat + rabat (richting: cladding);
|
// rabat = vochtdoek + kruislat + rabat (richting: cladding);
|
||||||
// steen = cement-gebonden plaat + steenblok (stone = steencode, WallModel.STONE).
|
// steen = cement-gebonden plaat + steenblok (stone = steencode, WallModel.STONE).
|
||||||
// Het trasraam (eigen afwerking/steen) komt onder beide.
|
// Het trasraam (eigen afwerking/steen) komt onder beide.
|
||||||
facade: { type: 'rabat', stone: 'K490' },
|
facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel' }, // rabatColor: WallModel.RABAT_COLORS
|
||||||
cladding: 'horizontaal', // richting rabat, voorlopig voor alle buitenwanden
|
cladding: 'horizontaal', // richting rabat, voorlopig voor alle buitenwanden
|
||||||
// Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte
|
// Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte
|
||||||
// vanaf het beloopbare vloervlak (WallModel.trasraamFor).
|
// vanaf het beloopbare vloervlak (WallModel.trasraamFor).
|
||||||
@@ -78,7 +78,8 @@ window.HouseModel = (() => {
|
|||||||
groundLevel: 0, // maaiveld: zoveel mm onder het loopvlak (bovenkant vloer); voor de 3D-omgeving
|
groundLevel: 0, // maaiveld: zoveel mm onder het loopvlak (bovenkant vloer); voor de 3D-omgeving
|
||||||
floorFinish: 'visgraat_001', // vloerafwerking (FloorModel.FINISHES), voorlopig globaal
|
floorFinish: 'visgraat_001', // vloerafwerking (FloorModel.FINISHES), voorlopig globaal
|
||||||
roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS),
|
roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS),
|
||||||
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS)
|
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS),
|
||||||
|
elektra: ElektraModel.normalizeSettings() // meterkast, schakelaars, wcd, leidingen (ElektraModel)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,21 +99,52 @@ window.HouseModel = (() => {
|
|||||||
return { type, stone, override: !!o };
|
return { type, stone, override: !!o };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TIJDELIJK (testen): een nieuwe woning is standaard het testontwerp:
|
// TIJDELIJK (testen): een nieuwe woning is standaard het voorbeeldontwerp
|
||||||
// L-vorm, unit A lessenaar (noord 2600, zuid 3200) met riet. Op false
|
// (ook als json/premade/vlinderwoning.json): 92 m², A (straat) en B (tuin)
|
||||||
// zetten om terug te gaan naar de gewone standaard (één unit).
|
// met een vlinderdak (lessenaars laag op de naad), C een lage tuinkamer
|
||||||
|
// met plat dak, veel glas. Op false zetten voor de gewone standaard (één unit).
|
||||||
const TEST_DEFAULT = true;
|
const TEST_DEFAULT = true;
|
||||||
|
|
||||||
|
function createVlinderwoning() {
|
||||||
|
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
|
||||||
|
st.project.naam = 'Vlinderwoning';
|
||||||
|
st.settings.trasraam.finish = 'K490';
|
||||||
|
const A = createUnitEntry('A', null, { width: 10000, depth: 4000, windowOn: [] });
|
||||||
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10000, depth: 3600, windowOn: [] });
|
||||||
|
const C = createUnitEntry('C', { to: 'A', side: 'rechts', align: 'start', offset: 0 }, { width: 4000, depth: 4000, windowOn: [] });
|
||||||
|
st.units.push(A, B, C);
|
||||||
|
const h = (u, k, l, r) => Object.assign(u.unit.walls[k].wall, { heightAll: Math.min(l, r), heightLeft: l, heightRight: r, heightMid: null });
|
||||||
|
const HI = 3400, LO = 2700;
|
||||||
|
h(A, 'voor', HI, HI); h(A, 'achter', LO, LO); h(A, 'links', HI, LO); h(A, 'rechts', LO, HI);
|
||||||
|
h(B, 'voor', LO, LO); h(B, 'achter', HI, HI); h(B, 'links', LO, HI); h(B, 'rechts', HI, LO);
|
||||||
|
['voor', 'achter', 'links', 'rechts'].forEach(k => h(C, k, LO, LO));
|
||||||
|
A.roof.covering = B.roof.covering = 'riet_002';
|
||||||
|
C.roof.covering = 'dakleer';
|
||||||
|
// Vulling (KozijnModel): deuren met glasstrook, ramen met een
|
||||||
|
// borstwering (vanaf 900 mm) met ventilatierooster.
|
||||||
|
const ops = (u, side, list) => {
|
||||||
|
u.unit.walls[side].openings = list.map(([type, w, hh, y, anchor, offset], i) => ({
|
||||||
|
id: i + 1, type, w, h: hh, y, anchor, offset,
|
||||||
|
fill: Object.assign(KozijnModel.defaultFill(type), type === 'raam' ? { rooster: y >= 900 } : { glas: true })
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
ops(A, 'voor', [['raam', 1200, 1100, 1000, 'left', 700], ['voordeur', 1150, 2300, 0, 'left', 2700], ['raam', 500, 2300, 0, 'left', 3950],
|
||||||
|
['raam', 2200, 600, 2000, 'left', 5200], ['raam', 900, 2100, 300, 'right', 1800], ['raam', 900, 2100, 300, 'right', 600]]);
|
||||||
|
ops(A, 'links', [['raam', 1400, 1200, 900, 'middle', 0]]);
|
||||||
|
ops(B, 'achter', [['raam', 600, 2600, 250, 'left', 800], ['raam', 600, 2600, 250, 'left', 1700], ['deur', 900, 2300, 0, 'middle', -1600],
|
||||||
|
['raam', 2000, 2300, 0, 'middle', 0], ['deur', 900, 2300, 0, 'middle', 1600], ['raam', 1200, 1200, 1000, 'right', 700]]);
|
||||||
|
ops(B, 'links', [['raam', 1600, 500, 1700, 'middle', 0]]);
|
||||||
|
ops(B, 'rechts', [['raam', 1000, 1300, 900, 'left', 500], ['raam', 1000, 1300, 900, 'right', 500]]);
|
||||||
|
ops(C, 'voor', [['raam', 2600, 1500, 700, 'middle', 0]]);
|
||||||
|
ops(C, 'rechts', [['raam', 1100, 2100, 250, 'left', 500], ['raam', 1100, 2100, 250, 'right', 500]]);
|
||||||
|
ops(C, 'achter', [['deur', 900, 2200, 0, 'left', 500], ['raam', 2000, 2200, 0, 'right', 400]]);
|
||||||
|
ensureSeamWalls(st);
|
||||||
|
computeParts(st, { skip3D: true });
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
function createDefaultState(template) {
|
function createDefaultState(template) {
|
||||||
if (TEST_DEFAULT && (!template || template === 'single')) {
|
if (TEST_DEFAULT && (!template || template === 'single')) return createVlinderwoning();
|
||||||
const st = createDefaultState('L');
|
|
||||||
const A = st.units[0], W = A.unit.walls;
|
|
||||||
const h = (k, l, r) => Object.assign(W[k].wall, { heightAll: Math.min(l, r), heightLeft: l, heightRight: r, heightMid: null });
|
|
||||||
h('achter', 3200, 3200); h('links', 2600, 3200); h('rechts', 3200, 2600);
|
|
||||||
A.roof.covering = 'riet_001';
|
|
||||||
computeParts(st, { skip3D: true });
|
|
||||||
return st;
|
|
||||||
}
|
|
||||||
const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
|
const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
|
||||||
state.units.push(createUnitEntry('A', null, { width: 6000, depth: 3000 }));
|
state.units.push(createUnitEntry('A', null, { width: 6000, depth: 3000 }));
|
||||||
if (template === 'L') {
|
if (template === 'L') {
|
||||||
@@ -120,12 +152,12 @@ window.HouseModel = (() => {
|
|||||||
// Standaard L-woning: voordeur midden in de noordgevel van A, drie
|
// Standaard L-woning: voordeur midden in de noordgevel van A, drie
|
||||||
// ramen in de zuidgevel van B (bovenkanten gelijk met de voordeur).
|
// ramen in de zuidgevel van B (bovenkanten gelijk met de voordeur).
|
||||||
state.units[0].unit.walls.voor.openings = [
|
state.units[0].unit.walls.voor.openings = [
|
||||||
{ id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0 }
|
{ id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('voordeur') }
|
||||||
];
|
];
|
||||||
state.units[1].unit.walls.achter.openings = [
|
state.units[1].unit.walls.achter.openings = [
|
||||||
{ id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450 },
|
{ id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450, fill: KozijnModel.defaultFill('raam') },
|
||||||
{ id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0 },
|
{ id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') },
|
||||||
{ id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450 }
|
{ id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450, fill: KozijnModel.defaultFill('raam') }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
ensureSeamWalls(state);
|
ensureSeamWalls(state);
|
||||||
@@ -276,7 +308,8 @@ window.HouseModel = (() => {
|
|||||||
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = height || wall.wall.heightAll;
|
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = height || wall.wall.heightAll;
|
||||||
wall.materials = { interior: 'spaanplaat', exterior: 'geen' };
|
wall.materials = { interior: 'spaanplaat', exterior: 'geen' };
|
||||||
const d = WallModel.OPENING_TYPES.deur;
|
const d = WallModel.OPENING_TYPES.deur;
|
||||||
wall.openings = [{ id: 1, type: 'deur', w: d.w, h: d.h, y: d.y, anchor: 'middle', offset: 0 }];
|
// Binnendeur: wit, zonder glas.
|
||||||
|
wall.openings = [{ id: 1, type: 'deur', w: d.w, h: d.h, y: d.y, anchor: 'middle', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit' }) }];
|
||||||
setThickness(wall, DEFAULT_SEAM_LEAF_MM);
|
setThickness(wall, DEFAULT_SEAM_LEAF_MM);
|
||||||
return wall;
|
return wall;
|
||||||
}
|
}
|
||||||
@@ -325,13 +358,27 @@ window.HouseModel = (() => {
|
|||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Alle onderdelen doorgerekend (+ 3D, tenzij opts.skip3D).
|
// Alle onderdelen doorgerekend (+ 3D, tenzij opts.skip3D).
|
||||||
// opts: { wallMaterials, floorMaterials, beplatingVisible, skip3D }
|
// opts: { wallMaterials, floorMaterials, ..., stage, lampenVisible, skip3D }
|
||||||
|
// stage = bouwfase (BuildStages, standaard afgewerkt): bepaalt welke lagen
|
||||||
|
// gebouwd worden; de berekening (stats, totalen) is altijd volledig.
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Past de woning-brede instellingen toe op de onderdeel-states (muteert
|
// Past de woning-brede instellingen toe op de onderdeel-states (muteert
|
||||||
// state): h.o.h. van alle wanden, en de dakmaat als die de vloer volgt.
|
// state): h.o.h. van alle wanden, en de dakmaat als die de vloer volgt.
|
||||||
|
// Grenzen (UnitModel.LIMITS) afdwingen: vloermaat, goot/plat dak en nok.
|
||||||
|
// Muteert state.
|
||||||
|
function enforceLimits(state) {
|
||||||
|
const L = UnitModel.LIMITS;
|
||||||
|
state.units.forEach(u => {
|
||||||
|
UnitModel.clampSize(u.unit.floor.floor);
|
||||||
|
const f = UnitModel.roofForm(u.unit);
|
||||||
|
if (f.low > L.eave + 0.5 || f.high > L.ridge + 0.5) UnitModel.applyRoofForm(u.unit, f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function applyGlobalSettings(state) {
|
function applyGlobalSettings(state) {
|
||||||
const sp = state.settings.spacing;
|
const sp = state.settings.spacing;
|
||||||
const tr = state.settings.trasraam;
|
const tr = state.settings.trasraam;
|
||||||
|
enforceLimits(state);
|
||||||
state.units.forEach(u => {
|
state.units.forEach(u => {
|
||||||
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
|
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null;
|
||||||
Object.entries(u.unit.walls).forEach(([side, ws]) => {
|
Object.entries(u.unit.walls).forEach(([side, ws]) => {
|
||||||
@@ -339,8 +386,8 @@ window.HouseModel = (() => {
|
|||||||
ws.wall.maxSpacing = sp.exterior.max;
|
ws.wall.maxSpacing = sp.exterior.max;
|
||||||
const fc = facadeFor(state, u, side);
|
const fc = facadeFor(state, u, side);
|
||||||
ws.materials = Object.assign({}, ws.materials, fc.type === 'steen'
|
ws.materials = Object.assign({}, ws.materials, fc.type === 'steen'
|
||||||
? { exterior: 'cementplaat', stone: fc.stone, cladding: state.settings.cladding, trasraam }
|
? { exterior: 'cementplaat', stone: fc.stone, cladding: state.settings.cladding, trasraam, rabatColor: null }
|
||||||
: { exterior: 'vochtdoek', stone: null, cladding: state.settings.cladding, trasraam });
|
: { exterior: 'vochtdoek', stone: null, cladding: state.settings.cladding, trasraam, rabatColor: state.settings.facade.rabatColor });
|
||||||
});
|
});
|
||||||
if (u.roof.followFloor) {
|
if (u.roof.followFloor) {
|
||||||
u.roof.deck.floor.width = u.unit.floor.floor.width;
|
u.roof.deck.floor.width = u.unit.floor.floor.width;
|
||||||
@@ -399,6 +446,8 @@ window.HouseModel = (() => {
|
|||||||
function computeParts(state, opts) {
|
function computeParts(state, opts) {
|
||||||
const o = opts || {};
|
const o = opts || {};
|
||||||
const build = !o.skip3D;
|
const build = !o.skip3D;
|
||||||
|
const stage = BuildStages.clamp(o.stage);
|
||||||
|
const shows = (key) => BuildStages.shows(stage, key);
|
||||||
applyGlobalSettings(state);
|
applyGlobalSettings(state);
|
||||||
const rects = unitRects(state);
|
const rects = unitRects(state);
|
||||||
const rectById = {};
|
const rectById = {};
|
||||||
@@ -406,12 +455,11 @@ window.HouseModel = (() => {
|
|||||||
const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side]));
|
const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side]));
|
||||||
const { cuts, seams } = computeSeams(rects, wallThickness);
|
const { cuts, seams } = computeSeams(rects, wallThickness);
|
||||||
|
|
||||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop) {
|
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove) {
|
||||||
if (!build) return { group: null, stats: null, specs: null };
|
if (!build) return { group: null, stats: null, specs: null };
|
||||||
const built = WallModel.build3D(config, frame, {
|
const built = WallModel.build3D(config, frame, {
|
||||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop,
|
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove,
|
||||||
painted: state.settings.interiorPaint,
|
painted: state.settings.interiorPaint && shows('afwerking'), stage, rabatColor: state.settings.facade.rabatColor
|
||||||
spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false
|
|
||||||
});
|
});
|
||||||
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
||||||
}
|
}
|
||||||
@@ -458,21 +506,22 @@ window.HouseModel = (() => {
|
|||||||
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
|
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
|
||||||
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
|
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
|
||||||
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
|
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
|
||||||
materials: o.floorMaterials, osbVisible: o.beplatingVisible !== false,
|
materials: o.floorMaterials, osbVisible: shows('beplating'),
|
||||||
finish: { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 }
|
finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
|
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
|
||||||
|
|
||||||
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), in de vorm die
|
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), in de vorm die
|
||||||
// de wanden aangeven (plat, lessenaar of zadel; RoofModel.computeShape).
|
// de wanden aangeven (plat, lessenaar, zadel, mansarde of wolfsdak;
|
||||||
|
// RoofModel.computeShape).
|
||||||
const shape = RoofModel.computeShape(u.unit, layout);
|
const shape = RoofModel.computeShape(u.unit, layout);
|
||||||
const deckFrame = FloorModel.computeFrame(u.roof.deck.floor);
|
const deckFrame = FloorModel.computeFrame(u.roof.deck.floor);
|
||||||
const roofW = deckFrame.width, roofD = deckFrame.depth;
|
const roofW = deckFrame.width, roofD = deckFrame.depth;
|
||||||
const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0;
|
const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0;
|
||||||
const ur = RoofModel.computeUnitRoof(u.roof, shape, { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }, rims);
|
const ur = RoofModel.computeUnitRoof(u.roof, shape, { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }, rims);
|
||||||
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
|
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
|
||||||
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel' }[shape.type];
|
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', mansarde: 'mansarde', wolf: 'wolfsdak' }[shape.type];
|
||||||
const multi = ur.planes.length > 1;
|
const multi = ur.planes.length > 1;
|
||||||
ur.planes.forEach((pl, i) => {
|
ur.planes.forEach((pl, i) => {
|
||||||
pl.element = `${u.id}-D${i + 1}`;
|
pl.element = `${u.id}-D${i + 1}`;
|
||||||
@@ -481,6 +530,7 @@ window.HouseModel = (() => {
|
|||||||
});
|
});
|
||||||
const roof = {
|
const roof = {
|
||||||
element: `${u.id}-D1`, shape, covering: ur.covering, planes: ur.planes, specs: ur.specs,
|
element: `${u.id}-D1`, shape, covering: ur.covering, planes: ur.planes, specs: ur.specs,
|
||||||
|
unitRoof: ur, // o.a. het bovenste deel van een wolfsdak (cap, RoofModel.capRegions)
|
||||||
rect: { x0: roofCx - roofW / 2, x1: roofCx + roofW / 2, z0: roofCz - roofD / 2, z1: roofCz + roofD / 2, cx: roofCx, cz: roofCz, W: roofW, D: roofD },
|
rect: { x0: roofCx - roofW / 2, x1: roofCx + roofW / 2, z0: roofCz - roofD / 2, z1: roofCz + roofD / 2, cx: roofCx, cz: roofCz, W: roofW, D: roofD },
|
||||||
base: layout.floorTop, // 3D: group-Y = 0 op het loopvlak
|
base: layout.floorTop, // 3D: group-Y = 0 op het loopvlak
|
||||||
// Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut).
|
// Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut).
|
||||||
@@ -488,12 +538,11 @@ window.HouseModel = (() => {
|
|||||||
topAt: (x, z) => layout.floorTop + ur.topAt(x - rect.cx, z - rect.cz),
|
topAt: (x, z) => layout.floorTop + ur.topAt(x - rect.cx, z - rect.cz),
|
||||||
ridges: ur.ridges.map(r => ({ axis: r.axis, value: r.value + (r.axis === 'x' ? rect.cx : rect.cz) })),
|
ridges: ur.ridges.map(r => ({ axis: r.axis, value: r.value + (r.axis === 'x' ? rect.cx : rect.cz) })),
|
||||||
top: layout.floorTop + ur.maxTop,
|
top: layout.floorTop + ur.maxTop,
|
||||||
// Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM)
|
// Bouwfase: plafondplaat bij "beplating", bedekking bij "gevel".
|
||||||
// horen bij "Beplating".
|
|
||||||
group: build ? RoofModel.buildUnit3D(ur, {
|
group: build ? RoofModel.buildUnit3D(ur, {
|
||||||
materials: o.roofMaterials,
|
materials: o.roofMaterials,
|
||||||
osbVisible: o.beplatingVisible !== false,
|
osbVisible: shows('beplating'),
|
||||||
dakleerVisible: o.beplatingVisible !== false
|
dakleerVisible: shows('gevel')
|
||||||
}) : null
|
}) : null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -640,15 +689,17 @@ window.HouseModel = (() => {
|
|||||||
const axisOf = (q) => horiz ? q.x - u.rect.cx : q.z - u.rect.cz;
|
const axisOf = (q) => horiz ? q.x - u.rect.cx : q.z - u.rect.cz;
|
||||||
const W = lf.place.width;
|
const W = lf.place.width;
|
||||||
const pL = axisOf(UnitModel.localToWorld(lf.place, -W / 2, 0)), pR = axisOf(UnitModel.localToWorld(lf.place, W / 2, 0));
|
const pL = axisOf(UnitModel.localToWorld(lf.place, -W / 2, 0)), pR = axisOf(UnitModel.localToWorld(lf.place, W / 2, 0));
|
||||||
const peak = prof.length > 2 ? prof[1] : null;
|
|
||||||
// Opbouw erboven (raiseSeamWalls): het deel stopt op de voet daarvan.
|
// Opbouw erboven (raiseSeamWalls): het deel stopt op de voet daarvan.
|
||||||
const cap = raisedCap[`${lf.uid}|${lf.uid === sm.a ? sm.b : sm.a}`];
|
const cap = raisedCap[`${lf.uid}|${lf.uid === sm.a ? sm.b : sm.a}`];
|
||||||
const lim = (h) => cap !== undefined ? Math.min(h, cap) : h;
|
const lim = (h) => cap !== undefined ? Math.min(h, cap) : h;
|
||||||
const hL = lim(RoofModel.profileHeight(prof, pL)), hR = lim(RoofModel.profileHeight(prof, pR));
|
const hL = lim(RoofModel.profileHeight(prof, pL)), hR = lim(RoofModel.profileHeight(prof, pR));
|
||||||
const hasPeak = !!peak && peak.p > Math.min(pL, pR) + 1 && peak.p < Math.max(pL, pR) - 1 && lim(peak.h) > Math.max(hL, hR) + 0.5;
|
// Tussenpunten (nok, knikken) die in het deel vallen, als fractie vanaf links.
|
||||||
|
const lo = Math.min(pL, pR), hi = Math.max(pL, pR);
|
||||||
|
const inner = prof.slice(1, -1).filter(q => q.p > lo + 1 && q.p < hi - 1)
|
||||||
|
.map(q => ({ f: Math.abs(q.p - pL) / (hi - lo), h: Math.round(lim(q.h)) })).sort((a, b) => a.f - b.f);
|
||||||
return {
|
return {
|
||||||
heightLeft: Math.round(hL), heightRight: Math.round(hR),
|
heightLeft: Math.round(hL), heightRight: Math.round(hR),
|
||||||
heightMid: hasPeak ? Math.round(lim(peak.h)) : null, peakX: hasPeak ? Math.abs(peak.p - pL) : undefined
|
heightMid: null, peakX: undefined, topPts: inner.length ? inner : undefined
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const paramsOf = (lf) => Object.assign({}, iw.wall.wall, { width: lf.place.width }, heightsOf(lf));
|
const paramsOf = (lf) => Object.assign({}, iw.wall.wall, { width: lf.place.width }, heightsOf(lf));
|
||||||
@@ -669,7 +720,7 @@ window.HouseModel = (() => {
|
|||||||
const first = leaves[0];
|
const first = leaves[0];
|
||||||
const firstParams = paramsOf(first);
|
const firstParams = paramsOf(first);
|
||||||
// Voor de wand-editor: breedte en daklijn van het eerste deel.
|
// Voor de wand-editor: breedte en daklijn van het eerste deel.
|
||||||
Object.assign(iw.wall.wall, { width: first.place.width, heightLeft: firstParams.heightLeft, heightRight: firstParams.heightRight, heightMid: firstParams.heightMid });
|
Object.assign(iw.wall.wall, { width: first.place.width, heightLeft: firstParams.heightLeft, heightRight: firstParams.heightRight, heightMid: null, topPts: firstParams.topPts });
|
||||||
const firstConfig = WallModel.buildConfig(firstParams, iw.wall.openings || []);
|
const firstConfig = WallModel.buildConfig(firstParams, iw.wall.openings || []);
|
||||||
const toLocalU = (pl, q) => {
|
const toLocalU = (pl, q) => {
|
||||||
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
|
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
|
||||||
@@ -683,6 +734,7 @@ window.HouseModel = (() => {
|
|||||||
const wb = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x + op.w, 0);
|
const wb = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x + op.w, 0);
|
||||||
const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb);
|
const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb);
|
||||||
// Gespiegeld (andere kijkrichting): schuine bovenzijde klapt om.
|
// Gespiegeld (andere kijkrichting): schuine bovenzijde klapt om.
|
||||||
|
// Het kozijn (fill) staat alleen in het eerste deel.
|
||||||
const flip = ua > ub;
|
const flip = ua > ub;
|
||||||
return { id: op.id, type: op.type, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
|
return { id: op.id, type: op.type, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
|
||||||
});
|
});
|
||||||
@@ -706,6 +758,23 @@ window.HouseModel = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schuine regel op de bovenregel (WallModel, CONFIG.topWedge): helt het
|
||||||
|
// dak dwars over de wand (goot van een zadel-/lessenaardak, onder een
|
||||||
|
// wolfseind), dan zit er tussen de vlakke bovenregel en de onderkant van
|
||||||
|
// het dak een wig. Per positie langs de wand de hoogte van die wig aan
|
||||||
|
// de buiten- (o) en binnenkant (i) van het regelwerk (mm), of null.
|
||||||
|
function topWedge(p, roof) {
|
||||||
|
const cfg = p.config, half = cfg.wallWidth / 2, zF = cfg.studDepth / 2, tl = WallModel.topLine(cfg);
|
||||||
|
const xs = new Set([-half, half].concat(tl.kinks));
|
||||||
|
for (let x = -half + 250; x < half; x += 250) xs.add(x);
|
||||||
|
const pts = [...xs].sort((a, b) => a - b).map(x => {
|
||||||
|
const top = p.place.floorTop + tl.at(x);
|
||||||
|
const at = (z) => { const w = UnitModel.localToWorld(p.place, x, z); return Math.max(0, Math.min(800, roof.ceilingAt(w.x, w.z) - top)); };
|
||||||
|
return { x, o: Math.round(at(zF)), i: Math.round(at(-zF)) };
|
||||||
|
});
|
||||||
|
return pts.some(q => Math.max(q.o, q.i) >= 5) ? { pts } : null;
|
||||||
|
}
|
||||||
|
|
||||||
// Doek en latwerk volgen de hele gevel (niet per unit/wandstuk), ook
|
// Doek en latwerk volgen de hele gevel (niet per unit/wandstuk), ook
|
||||||
// over het blootliggende deel van een naadwand. Daarna pas bouwen.
|
// over het blootliggende deel van een naadwand. Daarna pas bouwen.
|
||||||
const extPieces = [];
|
const extPieces = [];
|
||||||
@@ -718,7 +787,10 @@ window.HouseModel = (() => {
|
|||||||
units.forEach(u => {
|
units.forEach(u => {
|
||||||
u.walls.forEach(w => w.pieces.forEach(p => {
|
u.walls.forEach(w => w.pieces.forEach(p => {
|
||||||
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
|
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
|
||||||
p.skinAbove = Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - u.roof.ceilingAt(m.x, m.z)));
|
// (vanaf de bovenkant van de wand: daar kan nog een schuine regel op)
|
||||||
|
const wallTop = p.place.floorTop + WallModel.topLine(p.config).at(0);
|
||||||
|
p.skinAbove = Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - Math.min(wallTop, u.roof.ceilingAt(m.x, m.z))));
|
||||||
|
p.config.topWedge = topWedge(p, u.roof);
|
||||||
// Binnenhoek-doorloop (negatieve skinExt): tot de bovenkant van het
|
// Binnenhoek-doorloop (negatieve skinExt): tot de bovenkant van het
|
||||||
// dak van de buur-unit die daar staat geen buitenlagen (trimTop).
|
// dak van de buur-unit die daar staat geen buitenlagen (trimTop).
|
||||||
const e = p.skinExt || {}, W = p.place.width;
|
const e = p.skinExt || {}, W = p.place.width;
|
||||||
@@ -731,23 +803,11 @@ window.HouseModel = (() => {
|
|||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
|
||||||
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);
|
|
||||||
if (built.group && p.shiftZ) {
|
|
||||||
const outer = new THREE.Group();
|
|
||||||
built.group.position.z = p.shiftZ * 0.001;
|
|
||||||
outer.add(built.group);
|
|
||||||
built.group = outer;
|
|
||||||
}
|
|
||||||
Object.assign(p, built);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken;
|
// Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken;
|
||||||
// boeidelen aan/uit per unit. De rand volgt de dakvorm van de unit waar
|
// boeidelen aan/uit per unit. De rand volgt de dakvorm (en hoogte) van
|
||||||
// de ribbe bij hoort; platte daken delen één randhoogte (de hoogste).
|
// de unit waar de ribbe bij hoort, ook bij platte daken: een hoogte-
|
||||||
const flatTops = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
|
// verschil verspringt, en vanaf de boeideelhoogte krijgt het hoge dak een
|
||||||
const flatTop = flatTops.length ? Math.max(...flatTops) : 0;
|
// eigen rand en loopt die van het lage door (RoofModel.computeEdge).
|
||||||
// Dikte van de gevelafwerking langs een dakrand-ribbe (boeideel komt
|
// Dikte van de gevelafwerking langs een dakrand-ribbe (boeideel komt
|
||||||
// erbovenop): gevelstuk op die lijn met dezelfde buitenrichting (+), of
|
// erbovenop): gevelstuk op die lijn met dezelfde buitenrichting (+), of
|
||||||
// een gevel van een ander deel die naar dit dak toe kijkt (-).
|
// een gevel van een ander deel die naar dit dak toe kijkt (-).
|
||||||
@@ -771,27 +831,66 @@ window.HouseModel = (() => {
|
|||||||
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
|
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
|
||||||
units.map(u => ({
|
units.map(u => ({
|
||||||
unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false,
|
unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false,
|
||||||
topAt: u.roof.shape.type === 'plat' ? () => flatTop : u.roof.topAt, ridges: u.roof.ridges
|
topAt: u.roof.topAt, ridges: u.roof.ridges
|
||||||
})), facadeDepth) : null;
|
})), facadeDepth) : null;
|
||||||
|
// Latwerk stopt onder het boeideel/de daktrim die boven het wandstuk
|
||||||
|
// zit (battenAbove, mm t.o.v. de wandbovenkant, kan negatief zijn): rand
|
||||||
|
// bovenkant = dak + EDGE_ABOVE, hoogte fasciaHeight resp. trimHeight + EDGE_ABOVE.
|
||||||
|
const pointInPoly = (q, poly) => {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const a = poly[i], b = poly[j];
|
||||||
|
if ((a.z > q.z) !== (b.z > q.z) && q.x < (b.x - a.x) * (q.z - a.z) / (b.z - a.z) + a.x) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
};
|
||||||
|
if (roofEdge) extPieces.forEach(p => {
|
||||||
|
const nrm = { x: p.place.normal.x, z: p.place.normal.y };
|
||||||
|
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
|
||||||
|
// Een paar afstanden vóór de gevel: de rand begint op de dakrand, die
|
||||||
|
// op of iets vóór het wandvlak kan liggen.
|
||||||
|
const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d }))
|
||||||
|
.map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean);
|
||||||
|
if (!sg) return;
|
||||||
|
const h = sg.fascia ? roofEdge.fasciaHeight : roofEdge.trimHeight + roofEdge.edgeAbove;
|
||||||
|
p.battenAbove = (p.skinAbove || 0) + roofEdge.edgeAbove - h;
|
||||||
|
});
|
||||||
|
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
||||||
|
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);
|
||||||
|
if (built.group && p.shiftZ) {
|
||||||
|
const outer = new THREE.Group();
|
||||||
|
built.group.position.z = p.shiftZ * 0.001;
|
||||||
|
outer.add(built.group);
|
||||||
|
built.group = outer;
|
||||||
|
}
|
||||||
|
Object.assign(p, built);
|
||||||
|
});
|
||||||
|
|
||||||
// Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots).
|
// Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots).
|
||||||
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
|
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
|
||||||
// Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen".
|
// Zichtbaarheid: dakrand en lampen in de laatste bouwfase; lampen ook via "Lampen".
|
||||||
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
|
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
|
||||||
edgeVisible: o.beplatingVisible !== false, spotsVisible: o.lampenVisible !== false
|
edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking')
|
||||||
}) : null;
|
}) : null;
|
||||||
// Binnenspots in het plafond, per unit (LightingModel.computeIndoor).
|
// Binnenspots in het plafond, per unit (LightingModel.computeIndoor).
|
||||||
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
|
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
|
||||||
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
|
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
|
||||||
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false;
|
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false && shows('afwerking');
|
||||||
|
// Elektra: meterkast, schakelaars (letter = ook op hun lampen), wcd's,
|
||||||
|
// groepen en leidingen naar de meterkast (ElektraModel).
|
||||||
|
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge);
|
||||||
|
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
|
||||||
|
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
|
||||||
|
|
||||||
// Sandwichpanelen rondom de vloeren, vanaf het loopvlak naar beneden.
|
// Sandwichpanelen rondom de vloeren, vanaf het loopvlak naar beneden.
|
||||||
const sandwich = state.settings.sandwich && units.length
|
const sandwich = state.settings.sandwich && units.length
|
||||||
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
|
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
|
||||||
const sandwichGroup = build && sandwich ? SandwichModel.build3D(sandwich, o.sandwichMaterials || SandwichModel.createMaterials(), {
|
const sandwichGroup = build && sandwich ? SandwichModel.build3D(sandwich, o.sandwichMaterials || SandwichModel.createMaterials(), {
|
||||||
visible: o.beplatingVisible !== false
|
visible: shows('gevel')
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, sandwich, sandwichGroup };
|
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alle wandstukken (buiten + binnen) plat op een rij.
|
// Alle wandstukken (buiten + binnen) plat op een rij.
|
||||||
@@ -872,8 +971,38 @@ window.HouseModel = (() => {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// TIJDELIJK (verwijderen voor productie): capaciteit zaagmachine.
|
// ------------------------------------------------------------
|
||||||
const SAW_M_PER_H = 2400;
|
// Transport: elke unit gaat als losse module op de wagen (vloer, wanden
|
||||||
|
// incl. zijn naadwanddelen, dak; zonder dakrand). Buitenmaat incl. de
|
||||||
|
// gevelafwerking (latwerk + rabat, steen). Breedteklassen indicatief
|
||||||
|
// (wegvervoer NL): tot 2550 mm regulier, tot 3500 mm met ontheffing,
|
||||||
|
// daarboven met begeleiding. Controleer altijd bij de vervoerder.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
const TRANSPORT = { widthRegular: 2550, widthPermit: 3500 };
|
||||||
|
const TRANSPORT_CLASSES = { regulier: 'Regulier', ontheffing: 'Ontheffing', begeleiding: 'Ontheffing + begeleiding' };
|
||||||
|
function computeTransport(parts) {
|
||||||
|
const kgOf = (p) => (p.specs && p.specs.totalKg) || 0;
|
||||||
|
return parts.units.map(u => {
|
||||||
|
const leaves = parts.interior.filter(p => p.seamUnit === u.id);
|
||||||
|
const kg = {
|
||||||
|
vloer: u.floorSpecs.totalKg,
|
||||||
|
wanden: u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + kgOf(p), 0), 0) + leaves.reduce((a, p) => a + kgOf(p), 0),
|
||||||
|
dak: u.roof.specs.totalKg
|
||||||
|
};
|
||||||
|
// Afwerking buiten het wandvlak per zijde (0 als de wand vervalt).
|
||||||
|
const out = (side) => Math.max(0, ...((u.walls.find(w => w.side === side) || { pieces: [] }).pieces
|
||||||
|
.filter(p => !p.baseY)
|
||||||
|
.map(p => Math.max(WallModel.claddingOffset(p.buitenMateriaal, p.stone),
|
||||||
|
p.trasraam && WallModel.isStoneFinish(p.trasraam.finish) ? WallModel.STONE.THICK_MM : 0))));
|
||||||
|
const ew = u.rect.W + out('links') + out('rechts'), ed = u.rect.D + out('voor') + out('achter');
|
||||||
|
const width = Math.min(ew, ed);
|
||||||
|
return {
|
||||||
|
id: u.id, kg, totalKg: kg.vloer + kg.wanden + kg.dak,
|
||||||
|
length: Math.max(ew, ed), width, height: u.roof.top,
|
||||||
|
cls: width <= TRANSPORT.widthRegular ? 'regulier' : width <= TRANSPORT.widthPermit ? 'ontheffing' : 'begeleiding'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function computeTotals(parts) {
|
function computeTotals(parts) {
|
||||||
const pieces = allWallPieces(parts);
|
const pieces = allWallPieces(parts);
|
||||||
@@ -906,6 +1035,9 @@ window.HouseModel = (() => {
|
|||||||
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen, [ang, ang]));
|
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen, [ang, ang]));
|
||||||
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
|
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
|
||||||
});
|
});
|
||||||
|
// Wolfsdak: bovenste deel (sporen, hoekkepers, nokbalk, kopbalken).
|
||||||
|
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
|
||||||
|
if (cap) cap.beams.forEach(bm => addPiece('dakbalk', cap.joistW, cap.joistH, bm.length, [bm.angle, bm.angle]));
|
||||||
});
|
});
|
||||||
const wallBom = [];
|
const wallBom = [];
|
||||||
pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length, b.cutAngles); }));
|
pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length, b.cutAngles); }));
|
||||||
@@ -934,9 +1066,9 @@ window.HouseModel = (() => {
|
|||||||
return {
|
return {
|
||||||
kinds: kindList,
|
kinds: kindList,
|
||||||
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width),
|
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width),
|
||||||
// TIJDELIJK (verwijderen voor productie): zaagmachine, SAW_M_PER_H meter per uur.
|
// Zaagmachine: totale zaaglengte, sneden en hoeken (tijd: CostModel).
|
||||||
saw: {
|
saw: {
|
||||||
meters: saw.lengthMM / 1000, hours: saw.lengthMM / 1000 / SAW_M_PER_H, cuts: saw.cuts,
|
meters: saw.lengthMM / 1000, cuts: saw.cuts,
|
||||||
angles: Array.from(saw.angles.entries()).map(([deg, count]) => ({ deg, count })).sort((a, b) => a.deg - b.deg)
|
angles: Array.from(saw.angles.entries()).map(([deg, count]) => ({ deg, count })).sort((a, b) => a.deg - b.deg)
|
||||||
},
|
},
|
||||||
beamCount: kindList.reduce((a, k) => a + k.count, 0),
|
beamCount: kindList.reduce((a, k) => a + k.count, 0),
|
||||||
@@ -986,6 +1118,10 @@ window.HouseModel = (() => {
|
|||||||
{
|
{
|
||||||
naam: 'Vochtdoek (buitenzijde)', m2: byMat('vochtdoek').reduce((a, p) => a + p.stats.doekAreaM2, 0),
|
naam: 'Vochtdoek (buitenzijde)', m2: byMat('vochtdoek').reduce((a, p) => a + p.stats.doekAreaM2, 0),
|
||||||
m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + p.specs.buitenKg, 0)
|
m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + p.specs.buitenKg, 0)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
naam: `Rabat ${WallModel.CLADDING_THICK_MM} mm (gevelbekleding)`, m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.rabatAreaM2 || 0), 0),
|
||||||
|
m3: null, kg: 0 // gewicht volgt
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
weights,
|
weights,
|
||||||
@@ -1053,6 +1189,7 @@ window.HouseModel = (() => {
|
|||||||
state.settings.facade = Object.assign(def.facade, state.settings.facade || {});
|
state.settings.facade = Object.assign(def.facade, state.settings.facade || {});
|
||||||
if (!FACADE_TYPES[state.settings.facade.type]) state.settings.facade.type = 'rabat';
|
if (!FACADE_TYPES[state.settings.facade.type]) state.settings.facade.type = 'rabat';
|
||||||
if (!WallModel.isStoneFinish(state.settings.facade.stone)) state.settings.facade.stone = def.facade.stone;
|
if (!WallModel.isStoneFinish(state.settings.facade.stone)) state.settings.facade.stone = def.facade.stone;
|
||||||
|
if (!WallModel.RABAT_COLORS[state.settings.facade.rabatColor]) state.settings.facade.rabatColor = def.facade.rabatColor;
|
||||||
state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {});
|
state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {});
|
||||||
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
|
||||||
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
|
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
|
||||||
@@ -1071,6 +1208,7 @@ window.HouseModel = (() => {
|
|||||||
if (!lightIn.mode) lightIn.mode = legacyUnitLight ? legacyUnitLight.mode : (oldRoof.spots === false ? 'geen' : 'spots');
|
if (!lightIn.mode) lightIn.mode = legacyUnitLight ? legacyUnitLight.mode : (oldRoof.spots === false ? 'geen' : 'spots');
|
||||||
if (!lightIn.direction && legacyUnitLight && legacyUnitLight.direction) lightIn.direction = legacyUnitLight.direction;
|
if (!lightIn.direction && legacyUnitLight && legacyUnitLight.direction) lightIn.direction = legacyUnitLight.direction;
|
||||||
state.settings.lighting = LightingModel.normalizeSettings(lightIn);
|
state.settings.lighting = LightingModel.normalizeSettings(lightIn);
|
||||||
|
state.settings.elektra = ElektraModel.normalizeSettings(state.settings.elektra);
|
||||||
state.units.forEach(u => {
|
state.units.forEach(u => {
|
||||||
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
|
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
|
||||||
if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; }
|
if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; }
|
||||||
@@ -1086,9 +1224,9 @@ window.HouseModel = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, SAW_M_PER_H, // SAW_M_PER_H: tijdelijk
|
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor,
|
||||||
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
||||||
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
||||||
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, validateState, fromUnitDesign, normalize
|
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+256
-23
@@ -20,7 +20,7 @@ window.HousePdf = (() => {
|
|||||||
const KIND_LABELS = {
|
const KIND_LABELS = {
|
||||||
'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel',
|
'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel',
|
||||||
'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl',
|
'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl',
|
||||||
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel'
|
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel', 'schuine regel': 'Schuine regel'
|
||||||
};
|
};
|
||||||
const PLAN_ELEMENT = 'PL-1';
|
const PLAN_ELEMENT = 'PL-1';
|
||||||
const VIEWS_ELEMENT = 'AZ-1';
|
const VIEWS_ELEMENT = 'AZ-1';
|
||||||
@@ -46,10 +46,13 @@ window.HousePdf = (() => {
|
|||||||
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
|
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
|
||||||
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
|
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
|
||||||
u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
|
u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
|
||||||
|
// Wolfsdak: het bovenste deel (wolfseinden, nok) op een eigen blad.
|
||||||
|
if (u.roof.unitRoof && u.roof.unitRoof.cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u });
|
||||||
});
|
});
|
||||||
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
|
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
|
||||||
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
|
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
|
||||||
list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' });
|
list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' });
|
||||||
|
if (parts.elektra && parts.elektra.enabled) list.push({ kind: 'elektra', element: 'EL', label: 'Elektra' });
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +131,9 @@ window.HousePdf = (() => {
|
|||||||
doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD');
|
doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Elektra: meterkast, schakelaars (letters), wcd, leidingen (ElektraPdf).
|
||||||
|
ElektraPdf.drawPlan(doc, parts.elektra, px, py);
|
||||||
|
|
||||||
// Naden: gestippeld oranje
|
// Naden: gestippeld oranje
|
||||||
doc.setDrawColor(230, 130, 30);
|
doc.setDrawColor(230, 130, 30);
|
||||||
doc.setLineWidth(0.4);
|
doc.setLineWidth(0.4);
|
||||||
@@ -177,6 +183,7 @@ window.HousePdf = (() => {
|
|||||||
doc.setFontSize(6);
|
doc.setFontSize(6);
|
||||||
doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' +
|
doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' +
|
||||||
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4);
|
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4);
|
||||||
|
if (parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
@@ -216,7 +223,7 @@ window.HousePdf = (() => {
|
|||||||
const addWall = (p, y0) => {
|
const addWall = (p, y0) => {
|
||||||
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
||||||
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone);
|
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone);
|
||||||
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : VIEW_COLORS.rabat;
|
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : WallModel.rabatHex(state.settings.facade.rabatColor);
|
||||||
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
||||||
// Trasraam (strook onderaan, iets naar voren)
|
// Trasraam (strook onderaan, iets naar voren)
|
||||||
const tr = p.trasraam;
|
const tr = p.trasraam;
|
||||||
@@ -246,14 +253,7 @@ window.HousePdf = (() => {
|
|||||||
if (sh.type === 'plat') return;
|
if (sh.type === 'plat') return;
|
||||||
const cov = RoofModel.COVERINGS[r.covering] || RoofModel.COVERINGS.dakleer;
|
const cov = RoofModel.COVERINGS[r.covering] || RoofModel.COVERINGS.dakleer;
|
||||||
const ctr = sh.axis === 'x' ? u.rect.cx : u.rect.cz;
|
const ctr = sh.axis === 'x' ? u.rect.cx : u.rect.cz;
|
||||||
r.planes.forEach(pl => {
|
const pushRoof = (pts) => {
|
||||||
const a0 = ctr + pl.p0, a1 = ctr + pl.p1, mid = (a0 + a1) / 2;
|
|
||||||
// Hoogte net binnen het vlak (op de nok geeft topAt het hoogste vlak).
|
|
||||||
const inward = (t) => t + Math.sign(mid - t) * 0.5;
|
|
||||||
const corners = sh.axis === 'x'
|
|
||||||
? [[a0, r.rect.z0], [a1, r.rect.z0], [a1, r.rect.z1], [a0, r.rect.z1]]
|
|
||||||
: [[r.rect.x0, a0], [r.rect.x1, a0], [r.rect.x1, a1], [r.rect.x0, a1]];
|
|
||||||
const pts = corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z }));
|
|
||||||
// Normaal (omhoog) uit het vlak.
|
// Normaal (omhoog) uit het vlak.
|
||||||
const [A, B, C] = pts;
|
const [A, B, C] = pts;
|
||||||
const ux = B.x - A.x, uy = B.y - A.y, uz = B.z - A.z, vx = C.x - A.x, vy = C.y - A.y, vz = C.z - A.z;
|
const ux = B.x - A.x, uy = B.y - A.y, uz = B.z - A.z, vx = C.x - A.x, vy = C.y - A.y, vz = C.z - A.z;
|
||||||
@@ -261,18 +261,45 @@ window.HousePdf = (() => {
|
|||||||
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
|
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
|
||||||
const len = Math.hypot(nx, ny, nz) || 1;
|
const len = Math.hypot(nx, ny, nz) || 1;
|
||||||
faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
|
faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
|
||||||
|
};
|
||||||
|
// Wolfsdak: bovenste deel per vlak (RoofModel.capRegions, unit-lokaal (p, q)).
|
||||||
|
RoofModel.capRegions(r.unitRoof).forEach(rg => {
|
||||||
|
pushRoof(rg.poly.map(pt => {
|
||||||
|
const x = u.rect.cx + (sh.axis === 'x' ? pt.x : pt.z), z = u.rect.cz + (sh.axis === 'x' ? pt.z : pt.x);
|
||||||
|
return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z };
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
r.planes.forEach(pl => {
|
||||||
|
const a0 = ctr + pl.p0, a1 = ctr + pl.p1, mid = (a0 + a1) / 2;
|
||||||
|
// Hoogte net binnen het vlak (op de nok geeft topAt het hoogste vlak).
|
||||||
|
const inward = (t) => t + Math.sign(mid - t) * 0.5;
|
||||||
|
const corners = sh.axis === 'x'
|
||||||
|
? [[a0, r.rect.z0], [a1, r.rect.z0], [a1, r.rect.z1], [a0, r.rect.z1]]
|
||||||
|
: [[r.rect.x0, a0], [r.rect.x1, a0], [r.rect.x1, a1], [r.rect.x0, a1]];
|
||||||
|
pushRoof(corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z })));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dakrand: buitenvlak van elk boeideel / elke daktrim.
|
// Dakrand: elk boeideel / elke daktrim zoals je hem van buiten ziet: de
|
||||||
|
// bovenkant volgt het dak (sg.topAt, langs de ribbe bemonsterd, zodat
|
||||||
|
// knikken van een kopgevel - zadel, mansarde, wolfsdak - meekomen), en
|
||||||
|
// telt zowel de dakkant als de buitenkant mee (bij een hellend dak ligt
|
||||||
|
// de buitenkant lager, de bovenkant van het boeideel dekt de gevel af).
|
||||||
const edge = parts.roofEdge;
|
const edge = parts.roofEdge;
|
||||||
if (edge) {
|
if (edge) {
|
||||||
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
|
const 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 => {
|
edge.segments.forEach(sg => {
|
||||||
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
||||||
const A = sg.outerA, B = sg.outerB, tA = sg.topAt(A), tB = sg.topAt(B);
|
const n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100));
|
||||||
const pts = [{ x: A.x, y: tA, z: A.z }, { x: B.x, y: tB, z: B.z }, { x: B.x, y: tB - h, z: B.z }, { x: A.x, y: tA - h, z: A.z }];
|
const top = [], bottom = [];
|
||||||
faces.push({ pts, n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
for (let k = 0; k <= n; k++) {
|
||||||
|
const t = k / n, I = lerp(sg.a, sg.b, t), O = lerp(sg.outerA, sg.outerB, t);
|
||||||
|
const tI = sg.topAt(I), tO = sg.topAt(O);
|
||||||
|
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({ pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return faces;
|
return faces;
|
||||||
@@ -292,7 +319,7 @@ window.HousePdf = (() => {
|
|||||||
seen.add(key);
|
seen.add(key);
|
||||||
items.push(fc.type === 'steen'
|
items.push(fc.type === 'steen'
|
||||||
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
||||||
: { label: 'Gevel', value: `Rabat (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: VIEW_COLORS.rabat });
|
: { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) });
|
||||||
}));
|
}));
|
||||||
if (st.trasraam.enabled) {
|
if (st.trasraam.enabled) {
|
||||||
const tf = st.trasraam.finish;
|
const tf = st.trasraam.finish;
|
||||||
@@ -420,6 +447,80 @@ window.HousePdf = (() => {
|
|||||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blad voor het bovenste deel van een wolfsdak (RoofModel.computeCap):
|
||||||
|
// bovenaanzicht van het houtwerk (sporen, hoekkepers, nokbalk, kopbalken)
|
||||||
|
// op de dakvlakken, en de stuklijst.
|
||||||
|
const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', nokbalk: 'Nokbalk', kopbalk: 'Kopbalk' };
|
||||||
|
function drawCapSheet(doc, u, sheetOpts) {
|
||||||
|
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
||||||
|
const ur = u.roof.unitRoof, c = ur.cap;
|
||||||
|
// (p, q) -> plattegrond (x, z), unit-lokaal mm.
|
||||||
|
const xz = (pt) => ur.alongX ? { x: pt.x, z: pt.z } : { x: pt.z, z: pt.x };
|
||||||
|
const band = [{ x: c.pA, z: c.q0 }, { x: c.pB, z: c.q0 }, { x: c.pB, z: c.q1 }, { x: c.pA, z: c.q1 }].map(xz);
|
||||||
|
const xs = band.map(p => p.x), zs = band.map(p => p.z);
|
||||||
|
const X0 = 20, X1 = 275, Y0 = 32, Y1 = 230;
|
||||||
|
const sc = Math.min((X1 - X0) / (Math.max(...xs) - Math.min(...xs)), (Y1 - Y0) / (Math.max(...zs) - Math.min(...zs)));
|
||||||
|
const ox = (X0 + X1) / 2 - (Math.min(...xs) + Math.max(...xs)) / 2 * sc, oz = (Y0 + Y1) / 2 - (Math.min(...zs) + Math.max(...zs)) / 2 * sc;
|
||||||
|
const P = (p) => [ox + p.x * sc, oz + p.z * sc];
|
||||||
|
const poly = (pts, style) => {
|
||||||
|
const q = pts.map(P);
|
||||||
|
doc.lines(q.slice(1).map((b, k) => [b[0] - q[k][0], b[1] - q[k][1]]), q[0][0], q[0][1], [1, 1], style, true);
|
||||||
|
};
|
||||||
|
doc.setTextColor(...BLACK);
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20);
|
||||||
|
// Dakvlakken (grijs) en de band.
|
||||||
|
doc.setDrawColor(...GREY);
|
||||||
|
doc.setLineWidth(0.15);
|
||||||
|
c.regions.forEach(rg => poly(rg.poly.map(xz), 'S'));
|
||||||
|
// Balken (rood, plattegrond: rechthoek van balkbreedte rond de hartlijn).
|
||||||
|
doc.setDrawColor(...RED);
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
c.beams.forEach(bm => poly(bm.poly.map(xz), 'S'));
|
||||||
|
// Maten: lengte en diepte van de band, diepte van een wolfseind.
|
||||||
|
doc.setDrawColor(...BLUE);
|
||||||
|
doc.setTextColor(...BLUE);
|
||||||
|
doc.setFontSize(7);
|
||||||
|
const bx0 = Math.min(...xs), bx1 = Math.max(...xs), bz0 = Math.min(...zs), bz1 = Math.max(...zs);
|
||||||
|
const [ax, ay] = P({ x: bx0, z: bz1 }), [bx, by] = P({ x: bx1, z: bz1 });
|
||||||
|
PdfSheet.dimLine(doc, ax, ay + 8, bx, by + 8);
|
||||||
|
doc.text(`${Math.round(bx1 - bx0)}`, (ax + bx) / 2, ay + 7, { align: 'center' });
|
||||||
|
const [cx0, cy0] = P({ x: bx1, z: bz0 }), [cx1, cy1] = P({ x: bx1, z: bz1 });
|
||||||
|
PdfSheet.dimLine(doc, cx0 + 8, cy0, cx1 + 8, cy1);
|
||||||
|
doc.text(`${Math.round(bz1 - bz0)}`, cx0 + 9.5, (cy0 + cy1) / 2, { angle: 90 });
|
||||||
|
|
||||||
|
// Stuklijst en kerngegevens.
|
||||||
|
const groups = new Map();
|
||||||
|
c.beams.forEach(bm => {
|
||||||
|
const key = `${bm.kind}|${Math.round(bm.length / 10) * 10}|${bm.angle}`;
|
||||||
|
const g = groups.get(key) || { kind: bm.kind, length: Math.round(bm.length / 10) * 10, angle: bm.angle, n: 0 };
|
||||||
|
g.n++;
|
||||||
|
groups.set(key, g);
|
||||||
|
});
|
||||||
|
const order = Object.keys(CAP_KINDS);
|
||||||
|
const rows = [...groups.values()].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.length - a.length)
|
||||||
|
.map(g => [CAP_KINDS[g.kind] || g.kind, String(g.length), String(g.angle).replace('.', ','), String(g.n)]);
|
||||||
|
const H = (p) => RoofModel.profileHeight(ur.shape.profile, p);
|
||||||
|
const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ',');
|
||||||
|
const hip = c.regions.find(rg => rg.key === 'h1');
|
||||||
|
const facts = [
|
||||||
|
['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`],
|
||||||
|
['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`],
|
||||||
|
['Helling dakvlakken', `${deg((H(c.pR) - H(c.pA)) / (c.pR - c.pA))}°`],
|
||||||
|
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'],
|
||||||
|
['Balken', `${c.joistW} x ${c.joistH} mm`],
|
||||||
|
['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`],
|
||||||
|
['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`]
|
||||||
|
];
|
||||||
|
doc.setTextColor(...BLACK);
|
||||||
|
let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 });
|
||||||
|
PdfSheet.table(doc, 290, y + 6, [0, 32, 56, 78], ['Soort', 'Lengte (mm)', 'Hoek (°)', 'Aantal'], rows, { title: 'Stuklijst balken', fontSize: 7, rowH: 4.6, lastColW: 14, maxY: 240 });
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setTextColor(...GREY);
|
||||||
|
doc.text('Hoogtes vanaf bovenkant vloer. Lengte = langs de balk; hoek = helling van de balk (zaagsnede aan beide koppen). Grijs = dakvlakken, rood = houtwerk.', X0, 240);
|
||||||
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||||
|
}
|
||||||
|
|
||||||
function drawTotalsSheet(doc, totals, sheetOpts) {
|
function drawTotalsSheet(doc, totals, sheetOpts) {
|
||||||
const { BLACK } = PdfSheet.COLORS;
|
const { BLACK } = PdfSheet.COLORS;
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
@@ -476,7 +577,7 @@ window.HousePdf = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Eigen blad na de totalen: zaaglijst vuren (in kolommen) + zaagmachine.
|
// Eigen blad na de totalen: zaaglijst vuren (in kolommen) + zaagmachine.
|
||||||
function drawSawSheet(doc, totals, sheetOpts) {
|
function drawSawSheet(doc, totals, cost, sheetOpts) {
|
||||||
const { BLACK } = PdfSheet.COLORS;
|
const { BLACK } = PdfSheet.COLORS;
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.setTextColor(...BLACK);
|
doc.setTextColor(...BLACK);
|
||||||
@@ -496,10 +597,10 @@ window.HousePdf = (() => {
|
|||||||
|
|
||||||
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
||||||
const sw = totals.saw;
|
const sw = totals.saw;
|
||||||
const hm = (h) => `${Math.floor(h)}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`;
|
|
||||||
PdfSheet.table(doc, 290, 30, [0, 55], ['Onderdeel', 'Waarde'], [
|
PdfSheet.table(doc, 290, 30, [0, 55], ['Onderdeel', 'Waarde'], [
|
||||||
['Zaagmeters', fmt(sw.meters, 1) + ' m'],
|
['Zaagmeters', fmt(sw.meters, 1) + ' m'],
|
||||||
[`Zaagtijd (${HouseModel.SAW_M_PER_H} m/u)`, hm(sw.hours) + ' u:mm'],
|
['Zaagtijd robot', CostModel.uur(cost.saw.robotSec / 3600) + ' uur'],
|
||||||
|
[`Zaagtijd handmatig (\u00d7${cost.settings.zagen.handFactor})`, CostModel.uur(cost.saw.handSec / 3600) + ' uur'],
|
||||||
['Zaagsneden', sw.cuts + ' st.'],
|
['Zaagsneden', sw.cuts + ' st.'],
|
||||||
['Verschillende hoeken', String(sw.angles.length)]
|
['Verschillende hoeken', String(sw.angles.length)]
|
||||||
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0\u00b0)' : `Schuin ${fmt(a.deg, 1)}\u00b0`, a.count + ' sneden'])), tableOpts('Zaagmachine (tijdelijk)'));
|
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0\u00b0)' : `Schuin ${fmt(a.deg, 1)}\u00b0`, a.count + ' sneden'])), tableOpts('Zaagmachine (tijdelijk)'));
|
||||||
@@ -507,12 +608,134 @@ window.HousePdf = (() => {
|
|||||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Kostenraming (CostModel): samenvatting + regels per element.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
const COST_ROW_H = 3.4;
|
||||||
|
// Kolommen van een detailblad: x, onderkant (rechts het titelblok).
|
||||||
|
const COST_COLS = [{ x: 15, yMax: 278 }, { x: 150, yMax: 278 }, { x: 285, yMax: 246 }];
|
||||||
|
const COST_Y0 = 30;
|
||||||
|
const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '\u2026' : t);
|
||||||
|
// Platte regels voor de detailbladen: kop per element, dan de regels.
|
||||||
|
function costDetailRows(cost) {
|
||||||
|
return cost.elements.flatMap(el => elementCostRows(el));
|
||||||
|
}
|
||||||
|
// Kop + regels van één element.
|
||||||
|
function elementCostRows(el) {
|
||||||
|
const rows = [];
|
||||||
|
if (el.lines.length) {
|
||||||
|
rows.push({ head: true, label: `${el.element} ${el.label}`, mat: CostModel.eur(el.mat), time: CostModel.uur(el.sec / 3600) });
|
||||||
|
el.lines.forEach(l => rows.push({
|
||||||
|
label: l.label, qty: `${fmt(l.qty, ['st.', 'sneden', 'sectie'].includes(l.unit) ? 0 : 2)} ${l.unit}`,
|
||||||
|
mat: l.mat ? CostModel.eur(l.mat, 2) : '', time: l.sec ? CostModel.uur(l.sec / 3600) : ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
// Kostenblok op het blad van het onderdeel zelf (onder de hoofdtekening,
|
||||||
|
// links van het titelblok). Te veel regels: rest op het detailblad.
|
||||||
|
function drawElementCost(doc, el, extraLabel) {
|
||||||
|
if (!el || !el.lines.length) return;
|
||||||
|
const x = 132, y = 236, yMax = 287;
|
||||||
|
const rows = elementCostRows(el);
|
||||||
|
rows[0] = Object.assign({}, rows[0], { label: `Kosten ${el.element}${extraLabel ? ' (' + extraLabel + ')' : ''}` });
|
||||||
|
rows.push({ head: true, label: 'Kostprijs (incl. verlies, bezetting, indirect, QA, afschrijving)', mat: CostModel.eur(el.kostprijs), time: '' });
|
||||||
|
const fit = Math.floor((yMax - y - 5) / COST_ROW_H);
|
||||||
|
let shown = rows;
|
||||||
|
if (rows.length > fit) {
|
||||||
|
shown = rows.slice(0, fit - 2).concat([{ label: `+ ${rows.length - fit + 1} regels: zie kostenblad detail`, mat: '', time: '' }, rows[rows.length - 1]]);
|
||||||
|
}
|
||||||
|
drawCostTable(doc, x, y, shown);
|
||||||
|
}
|
||||||
|
// Verdeelt de regels over bladen (kolommen vullen van boven naar beneden).
|
||||||
|
function paginateCostRows(rows) {
|
||||||
|
const pages = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < rows.length) {
|
||||||
|
const cols = COST_COLS.map(c => {
|
||||||
|
const n = Math.floor((c.yMax - COST_Y0 - 6) / COST_ROW_H);
|
||||||
|
const chunk = rows.slice(i, i + n);
|
||||||
|
i += chunk.length;
|
||||||
|
return chunk;
|
||||||
|
});
|
||||||
|
pages.push(cols);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
function drawCostTable(doc, x, y, rows) {
|
||||||
|
const { BLACK, GREY } = PdfSheet.COLORS;
|
||||||
|
doc.setFontSize(6.5);
|
||||||
|
doc.setTextColor(...GREY);
|
||||||
|
doc.text('Omschrijving', x, y); doc.text('Hoeveelheid', x + 84, y, { align: 'right' });
|
||||||
|
doc.text('Materiaal', x + 104, y, { align: 'right' }); doc.text('Manuur', x + 124, y, { align: 'right' });
|
||||||
|
doc.setDrawColor(...GREY); doc.setLineWidth(0.15);
|
||||||
|
doc.line(x, y + 1.5, x + 126, y + 1.5);
|
||||||
|
y += 5;
|
||||||
|
rows.forEach(r => {
|
||||||
|
doc.setTextColor(...BLACK);
|
||||||
|
doc.setFont('helvetica', r.head ? 'bold' : 'normal');
|
||||||
|
doc.text(clip(r.label, r.head ? 60 : 52), r.head ? x : x + 2, y);
|
||||||
|
if (r.qty) doc.text(r.qty, x + 84, y, { align: 'right' });
|
||||||
|
doc.text(r.mat, x + 104, y, { align: 'right' });
|
||||||
|
doc.text(r.time, x + 124, y, { align: 'right' });
|
||||||
|
y += COST_ROW_H;
|
||||||
|
});
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCostSheet(doc, cost, sheetOpts) {
|
||||||
|
const { BLACK } = PdfSheet.COLORS;
|
||||||
|
const s = cost.summary, S = cost.settings, eur = (n) => CostModel.eur(n);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...BLACK);
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.text('Kostenraming (indicatief)', 15, 18);
|
||||||
|
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 24 });
|
||||||
|
let y = PdfSheet.table(doc, 15, 30, [0, 82], ['Post', 'Bedrag'],
|
||||||
|
s.rows.map(r => [r.label, eur(r.amount)]).concat([['Totaal incl. btw', eur(s.inclBtw)]]), tableOpts('Samenvatting'));
|
||||||
|
y = PdfSheet.table(doc, 15, y + 6, [0, 82], ['Groep', 'Materiaal'],
|
||||||
|
cost.categories.filter(r => r.mat > 0).map(r => [r.name, eur(r.mat)]), tableOpts('Materiaal per groep'));
|
||||||
|
PdfSheet.table(doc, 15, y + 6, [0, 82], ['Werkzaamheid', 'Manuur'],
|
||||||
|
cost.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600)])
|
||||||
|
.concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect)], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa)], ['Totaal', CostModel.uur(s.hours)]]),
|
||||||
|
tableOpts('Arbeid per werkzaamheid'));
|
||||||
|
|
||||||
|
PdfSheet.table(doc, 130, 30, [0, 72, 97, 122], ['Element / onderdeel', 'Materiaal', 'Manuur', 'Kostprijs*'],
|
||||||
|
cost.elements.filter(el => el.lines.length).map(el => [clip(`${el.element} ${el.label}`, 48), eur(el.mat), CostModel.uur(el.sec / 3600), eur(el.kostprijs)]),
|
||||||
|
Object.assign(tableOpts('Per element'), { maxY: 278 }));
|
||||||
|
PdfSheet.table(doc, 290, 30, [0, 40, 65, 88], ['Unit', 'Materiaal', 'Manuur', 'Kostprijs*'],
|
||||||
|
cost.units.map(u => [u.id ? `Unit ${u.id}` : 'Woning-breed', eur(u.mat), CostModel.uur(u.sec / 3600), eur(u.kostprijs)]), tableOpts('Per unit'));
|
||||||
|
doc.setFontSize(6.5);
|
||||||
|
doc.text('* Kostprijs: materiaal incl. verlies en klein materiaal + arbeid (manuren incl. bezetting, indirect en QA) + afschrijving/huisvesting', 290, 236);
|
||||||
|
doc.text(' (+ transport per unit, kraan bij woning-breed); zonder algemene kosten, marge en btw.', 290, 240);
|
||||||
|
doc.text('Schattingen op basis van Instellingen > Kosten & tijden; bedragen excl. btw tenzij vermeld.', 290, 244);
|
||||||
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCostDetailSheet(doc, cols, sheetOpts) {
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...PdfSheet.COLORS.BLACK);
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.text('Kostenraming per element (detail)', 15, 18);
|
||||||
|
cols.forEach((rows, i) => { if (rows.length) drawCostTable(doc, COST_COLS[i].x, COST_Y0, rows); });
|
||||||
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||||
|
}
|
||||||
|
|
||||||
function generate(state) {
|
function generate(state) {
|
||||||
const parts = HouseModel.computeParts(state, {
|
const parts = HouseModel.computeParts(state, {
|
||||||
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
|
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
|
||||||
});
|
});
|
||||||
const totals = HouseModel.computeTotals(parts);
|
const totals = HouseModel.computeTotals(parts);
|
||||||
const sheets = sheetList(parts);
|
const sheets = sheetList(parts);
|
||||||
|
const cost = CostModel.compute(parts, totals);
|
||||||
|
const costOf = (element) => cost.elements.find(el => el.element === element);
|
||||||
|
const drawWallWithCost = (sheet, opts) => {
|
||||||
|
WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts));
|
||||||
|
drawElementCost(doc, costOf(sheet.element));
|
||||||
|
};
|
||||||
|
const detailPages = paginateCostRows(costDetailRows(cost));
|
||||||
|
sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' });
|
||||||
|
detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols }));
|
||||||
const project = state.project;
|
const project = state.project;
|
||||||
const doc = PdfSheet.createDoc();
|
const doc = PdfSheet.createDoc();
|
||||||
|
|
||||||
@@ -521,14 +744,24 @@ window.HousePdf = (() => {
|
|||||||
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
|
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
|
||||||
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
|
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
|
||||||
else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
|
else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
|
||||||
else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts);
|
else if (sheet.kind === 'floor') {
|
||||||
else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true }, opts));
|
FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts);
|
||||||
else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts));
|
drawElementCost(doc, costOf(sheet.element));
|
||||||
else if (sheet.kind === 'saw') drawSawSheet(doc, totals, opts);
|
} else if (sheet.kind === 'roof') {
|
||||||
|
FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true }, opts));
|
||||||
|
// Dakkosten per unit (alle dakvlakken samen): op het eerste dakvlak.
|
||||||
|
if (/-D1$/.test(sheet.element)) drawElementCost(doc, costOf(sheet.element.replace(/\d+$/, '')), 'alle dakvlakken');
|
||||||
|
}
|
||||||
|
else if (sheet.kind === 'cap') drawCapSheet(doc, sheet.unit, opts);
|
||||||
|
else if (sheet.kind === 'wall') drawWallWithCost(sheet, opts);
|
||||||
|
else if (sheet.kind === 'saw') drawSawSheet(doc, totals, cost, opts);
|
||||||
|
else if (sheet.kind === 'elektra') ElektraPdf.drawSheet(doc, parts.elektra, opts);
|
||||||
|
else if (sheet.kind === 'cost') drawCostSheet(doc, cost, opts);
|
||||||
|
else if (sheet.kind === 'costdetail') drawCostDetailSheet(doc, sheet.cols, opts);
|
||||||
else drawTotalsSheet(doc, totals, opts);
|
else drawTotalsSheet(doc, totals, opts);
|
||||||
});
|
});
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { generate, sheetList, materialThumbUrls };
|
return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+1062
-368
File diff suppressed because it is too large
Load Diff
+130
-15
@@ -251,6 +251,23 @@ window.LightingModel = (() => {
|
|||||||
return Math.hypot(p.x - (a.x + t * dx), p.z - (a.z + t * dz));
|
return Math.hypot(p.x - (a.x + t * dx), p.z - (a.z + t * dz));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Binnenruimte van een unit (woning-mm): de rect min de buitenwanden, en
|
||||||
|
// waar een naadwanddeel van deze unit tegen de naad staat, tot diens
|
||||||
|
// binnenzijde (net als bij een buitenwand). Ook gebruikt door ElektraModel.
|
||||||
|
function unitInnerRect(u, interior) {
|
||||||
|
const T = {};
|
||||||
|
u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; });
|
||||||
|
const r = u.rect;
|
||||||
|
interior.filter(iw => iw.seamUnit === u.id).forEach(iw => {
|
||||||
|
const p = iw.place, horiz = Math.abs(Math.sin(p.rotY)) < 0.5;
|
||||||
|
const [side, d] = horiz
|
||||||
|
? (Math.abs(p.cz - r.z0) < Math.abs(p.cz - r.z1) ? ['voor', p.cz - r.z0] : ['achter', r.z1 - p.cz])
|
||||||
|
: (Math.abs(p.cx - r.x0) < Math.abs(p.cx - r.x1) ? ['links', p.cx - r.x0] : ['rechts', r.x1 - p.cx]);
|
||||||
|
T[side] = Math.max(T[side] || 0, d + p.thickness / 2);
|
||||||
|
});
|
||||||
|
return { x0: r.x0 + (T.links || 0), x1: r.x1 - (T.rechts || 0), z0: r.z0 + (T.voor || 0), z1: r.z1 - (T.achter || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
// Binnenspots per unit. units = HouseModel-parts.units (rect, walls, roof.ceilingAt),
|
// Binnenspots per unit. units = HouseModel-parts.units (rect, walls, roof.ceilingAt),
|
||||||
// interior = parts.interior (binnenwanden met place). Geeft
|
// interior = parts.interior (binnenwanden met place). Geeft
|
||||||
// [{ unitId, x, z, y, floorY }] (mm, y = onderkant plafond, floorY = loopvlak).
|
// [{ unitId, x, z, y, floorY }] (mm, y = onderkant plafond, floorY = loopvlak).
|
||||||
@@ -259,21 +276,10 @@ window.LightingModel = (() => {
|
|||||||
if (!s.indoor) return [];
|
if (!s.indoor) return [];
|
||||||
const out = [];
|
const out = [];
|
||||||
units.forEach(u => {
|
units.forEach(u => {
|
||||||
const T = {};
|
const off = s.indoorOffset, inner = unitInnerRect(u, interior);
|
||||||
u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; });
|
|
||||||
const r = u.rect, off = s.indoorOffset;
|
|
||||||
// Naadwanddelen van deze unit staan binnen de rect tegen de naad: het
|
|
||||||
// raster begint dan vanaf hun binnenzijde (net als bij een buitenwand).
|
|
||||||
const own = interior.filter(iw => !iw.seamUnit || iw.seamUnit === u.id);
|
const own = interior.filter(iw => !iw.seamUnit || iw.seamUnit === u.id);
|
||||||
own.filter(iw => iw.seamUnit).forEach(iw => {
|
const xs = gridPositions(inner.x0 + off, inner.x1 - off, s.indoorMin, s.indoorMax);
|
||||||
const p = iw.place, horiz = Math.abs(Math.sin(p.rotY)) < 0.5;
|
const zs = gridPositions(inner.z0 + off, inner.z1 - off, s.indoorMin, s.indoorMax);
|
||||||
const [side, d] = horiz
|
|
||||||
? (Math.abs(p.cz - r.z0) < Math.abs(p.cz - r.z1) ? ['voor', p.cz - r.z0] : ['achter', r.z1 - p.cz])
|
|
||||||
: (Math.abs(p.cx - r.x0) < Math.abs(p.cx - r.x1) ? ['links', p.cx - r.x0] : ['rechts', r.x1 - p.cx]);
|
|
||||||
T[side] = Math.max(T[side] || 0, d + p.thickness / 2);
|
|
||||||
});
|
|
||||||
const xs = gridPositions(r.x0 + (T.links || 0) + off, r.x1 - (T.rechts || 0) - off, s.indoorMin, s.indoorMax);
|
|
||||||
const zs = gridPositions(r.z0 + (T.voor || 0) + off, r.z1 - (T.achter || 0) - off, s.indoorMin, s.indoorMax);
|
|
||||||
xs.forEach(x => zs.forEach(z => {
|
xs.forEach(x => zs.forEach(z => {
|
||||||
const tooClose = own.some(iw => {
|
const tooClose = own.some(iw => {
|
||||||
const half = iw.place.width / 2, c = Math.cos(iw.place.rotY), sn = Math.sin(iw.place.rotY);
|
const half = iw.place.width / 2, c = Math.cos(iw.place.rotY), sn = Math.sin(iw.place.rotY);
|
||||||
@@ -374,8 +380,117 @@ window.LightingModel = (() => {
|
|||||||
const drop = (p.y - p.floorY) * toM; // plafond -> loopvlak
|
const drop = (p.y - p.floorY) * toM; // plafond -> loopvlak
|
||||||
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);
|
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);
|
||||||
});
|
});
|
||||||
|
if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo));
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, createMaterials, build3D, buildLights3D };
|
// ------------------------------------------------------------
|
||||||
|
// Nep-lichtvlekken (wall-wash) voor de render. Echte spots mogen niet op
|
||||||
|
// de gevel schijnen (geen schaduw: het licht zou door de muur naar binnen
|
||||||
|
// gaan, zie buildLights3D). In plaats daarvan komt op het gevelvlak een
|
||||||
|
// additief, zacht kegelvormig verloop: smal en fel bij de lamp, breder en
|
||||||
|
// zwakker naar beneden (of naar boven bij een up-light). Geen licht-
|
||||||
|
// berekening, dus geen grens aan het aantal.
|
||||||
|
// walls = buitenwandstukken (place, buitenMateriaal, stone); groundY = mm.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
const WASH = { color: 0xffd9a0, spot: 0.55, lamp: 0.45, spread: 0.5, lampReach: 1800 };
|
||||||
|
let washTexture = null;
|
||||||
|
function getWashTexture() {
|
||||||
|
if (washTexture) return washTexture;
|
||||||
|
const W = 128, H = 256;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W; canvas.height = H;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const img = ctx.createImageData(W, H);
|
||||||
|
for (let j = 0; j < H; j++) {
|
||||||
|
const t = j / (H - 1); // 0 = bij de lamp, 1 = einde bundel
|
||||||
|
const half = 0.12 + 0.88 * Math.pow(t, 0.6); // bundelbreedte (fractie van de halve breedte)
|
||||||
|
const fade = Math.min(1, t * 12) * Math.pow(1 - t, 1.6); // zacht begin, uitdoven
|
||||||
|
for (let i = 0; i < W; i++) {
|
||||||
|
const x = Math.abs((i + 0.5) / W * 2 - 1) / half;
|
||||||
|
const v = x >= 1 ? 0 : fade * Math.pow(1 - x * x, 2);
|
||||||
|
const k = (j * W + i) * 4;
|
||||||
|
img.data[k] = img.data[k + 1] = img.data[k + 2] = Math.round(255 * v);
|
||||||
|
img.data[k + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
|
washTexture = new THREE.CanvasTexture(canvas);
|
||||||
|
return washTexture;
|
||||||
|
}
|
||||||
|
function washMesh(widthM, heightM, opacity, up) {
|
||||||
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
|
map: getWashTexture(), color: WASH.color, transparent: true, opacity,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2
|
||||||
|
});
|
||||||
|
const geo = new THREE.PlaneGeometry(widthM, heightM);
|
||||||
|
// Lamp aan de bovenrand (down) resp. onderrand (up).
|
||||||
|
geo.translate(0, up ? heightM / 2 : -heightM / 2, 0);
|
||||||
|
if (up) { // textuur gespiegeld: lamp onderaan
|
||||||
|
const uv = geo.attributes.uv;
|
||||||
|
for (let i = 0; i < uv.count; i++) uv.setY(i, 1 - uv.getY(i));
|
||||||
|
}
|
||||||
|
const m = new THREE.Mesh(geo, mat);
|
||||||
|
m.renderOrder = 2;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
function buildWash3D(lamps, spots, spotY, walls, groundY, fo) {
|
||||||
|
const toM = 0.001;
|
||||||
|
const group = new THREE.Group();
|
||||||
|
// Gevelvlak achter een punt (mm) met buitenrichting n: dichtstbijzijnde
|
||||||
|
// wandstuk met dezelfde normaal waar het punt vóór ligt.
|
||||||
|
const facadeBehind = (q, n) => {
|
||||||
|
let best = null;
|
||||||
|
walls.forEach(p => {
|
||||||
|
const pl = p.place;
|
||||||
|
if (pl.normal.x * n.x + pl.normal.y * n.z < 0.9) return;
|
||||||
|
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
|
||||||
|
const dx = q.x - pl.cx, dz = q.z - pl.cz;
|
||||||
|
const lx = dx * c - dz * sn, lz = dx * sn + dz * c;
|
||||||
|
const face = pl.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone);
|
||||||
|
const d = lz - face;
|
||||||
|
if (Math.abs(lx) > pl.width / 2 + 50 || d < -5 || d > 1500) return;
|
||||||
|
if (!best || d < best.d) best = { d, piece: p, lx, face };
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
const place = (mesh, pl, lx, lz, yMM) => {
|
||||||
|
const w = UnitModel.localToWorld(pl, lx, lz);
|
||||||
|
mesh.position.set(w.x * toM, yMM * toM, w.z * toM);
|
||||||
|
mesh.rotation.y = pl.rotY;
|
||||||
|
group.add(mesh);
|
||||||
|
};
|
||||||
|
// Boeideel-spots: van de spot omlaag tot het maaiveld (op een hoek,
|
||||||
|
// diagonale normaal, geen vlek).
|
||||||
|
(spots || []).forEach(sp => {
|
||||||
|
const n = sp.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
|
||||||
|
const nn = { x: n.x / len, z: n.z / len };
|
||||||
|
if (Math.abs(nn.x) > 0.1 && Math.abs(nn.z) > 0.1) return;
|
||||||
|
const top = (sp.y !== undefined ? sp.y : spotY) - 30;
|
||||||
|
const hit = facadeBehind(sp, nn);
|
||||||
|
if (!hit || top - groundY < 300) return;
|
||||||
|
const H = (top - groundY) * toM;
|
||||||
|
place(washMesh(2 * (0.1 + H * WASH.spread), H, WASH.spot * fo), hit.piece.place, hit.lx, hit.face + 6, top);
|
||||||
|
});
|
||||||
|
// Gevellampen (op de gevel): bundel omlaag en/of omhoog.
|
||||||
|
(lamps || []).forEach(l => {
|
||||||
|
const n = { x: l.normal.x, z: l.normal.z };
|
||||||
|
const hit = facadeBehind({ x: l.x + n.x * 20, z: l.z + n.z * 20 }, n);
|
||||||
|
if (!hit) return;
|
||||||
|
const pl = hit.piece.place;
|
||||||
|
if (l.direction !== 'up') {
|
||||||
|
const H = Math.min(WASH.lampReach, l.y - 90 - groundY) * toM;
|
||||||
|
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo), pl, hit.lx, hit.face + 6, l.y - 90);
|
||||||
|
}
|
||||||
|
if (l.direction !== 'down') {
|
||||||
|
// Niet boven de gevel uit (tot de bovenkant van het dak).
|
||||||
|
const wallTop = pl.floorTop + WallModel.topLine(hit.piece.config).maxY + (hit.piece.skinAbove || 0);
|
||||||
|
const H = Math.min(WASH.lampReach, wallTop - l.y - 90) * toM;
|
||||||
|
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo, true), pl, hit.lx, hit.face + 6, l.y + 90);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+315
-24
@@ -13,8 +13,9 @@
|
|||||||
// bovenop. Het "dek" van een dak is dus gewoon een vloer-state.
|
// bovenop. Het "dek" van een dak is dus gewoon een vloer-state.
|
||||||
//
|
//
|
||||||
// Dakvorm volgt de wanden (computeShape): het paar tegenoverliggende wanden
|
// Dakvorm volgt de wanden (computeShape): het paar tegenoverliggende wanden
|
||||||
// met een schuine bovenrand of middenpunt (nok) zijn de kopgevels, het dak
|
// met een schuine bovenrand of tussenpunten (nok, knikken) zijn de kopgevels,
|
||||||
// volgt hun daklijn (plat, lessenaar of zadel). UnitModel.syncWallHeights
|
// het dak volgt hun daklijn (plat, lessenaar, zadel, mansarde of wolfsdak;
|
||||||
|
// bij een wolfsdak komen daar de wolfseinden bij, zie computeShape). UnitModel.syncWallHeights
|
||||||
// houdt de wanden daarvoor consistent. Het dek (in de plattegrond) wordt per
|
// houdt de wanden daarvoor consistent. Het dek (in de plattegrond) wordt per
|
||||||
// dakvlak omgerekend naar een schuin vlak met de balken als sporen (van
|
// dakvlak omgerekend naar een schuin vlak met de balken als sporen (van
|
||||||
// laag naar hoog); doorvoeren gaan mee naar het vlak waarin ze vallen.
|
// laag naar hoog); doorvoeren gaan mee naar het vlak waarin ze vallen.
|
||||||
@@ -139,8 +140,10 @@ window.RoofModel = (() => {
|
|||||||
const m = opts.materials;
|
const m = opts.materials;
|
||||||
const toM = 0.001;
|
const toM = 0.001;
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
|
// Plafondplaat én bedekking zichtbaar: de sporen ertussen zijn ingesloten.
|
||||||
const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, {
|
const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, {
|
||||||
materials: m, osbVisible: opts.osbVisible !== false
|
materials: m, osbVisible: opts.osbVisible !== false,
|
||||||
|
outerJoistsOnly: opts.osbVisible !== false && opts.dakleerVisible !== false
|
||||||
});
|
});
|
||||||
const deckH = (calcData.joistH + calcData.underlayThick) * toM;
|
const deckH = (calcData.joistH + calcData.underlayThick) * toM;
|
||||||
deck.scale.y = -1;
|
deck.scale.y = -1;
|
||||||
@@ -169,8 +172,7 @@ window.RoofModel = (() => {
|
|||||||
leer.rotation.x = Math.PI / 2;
|
leer.rotation.x = Math.PI / 2;
|
||||||
leer.position.y = deckH + cov.thickMM * toM;
|
leer.position.y = deckH + cov.thickMM * toM;
|
||||||
leer.castShadow = cov.thickMM > DAKLEER_MM;
|
leer.castShadow = cov.thickMM > DAKLEER_MM;
|
||||||
leer.visible = opts.dakleerVisible !== false;
|
if (opts.dakleerVisible !== false) group.add(leer);
|
||||||
group.add(leer);
|
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,22 +204,41 @@ window.RoofModel = (() => {
|
|||||||
const s = UnitModel.WALL_SIDES.find(x => x.key === side);
|
const s = UnitModel.WALL_SIDES.find(x => x.key === side);
|
||||||
const c = s.axis === 'x' ? wl.cx : wl.cz;
|
const c = s.axis === 'x' ? wl.cx : wl.cz;
|
||||||
const pts = [{ p: c - s.dir * wl.width / 2, h: w.heightLeft }, { p: c + s.dir * wl.width / 2, h: w.heightRight }];
|
const pts = [{ p: c - s.dir * wl.width / 2, h: w.heightLeft }, { p: c + s.dir * wl.width / 2, h: w.heightRight }];
|
||||||
if (w.heightMid > 0) pts.push({ p: c, h: Math.max(w.heightMid, w.heightLeft, w.heightRight) });
|
// Tussenpunten (nok, knikken van mansarde/wolfsdak), x vanaf links.
|
||||||
|
WallModel.wallTopPoints(w, wl.width, w.heightLeft, w.heightRight)
|
||||||
|
.forEach(q => pts.push({ p: c - s.dir * wl.width / 2 + s.dir * q.x, h: q.h }));
|
||||||
return pts.sort((a, b) => a.p - b.p);
|
return pts.sort((a, b) => a.p - b.p);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dakvorm van een unit. layout = UnitModel.computeLayout-resultaat.
|
// Dakvorm van een unit. layout = UnitModel.computeLayout-resultaat.
|
||||||
// Geeft { type: 'plat'|'lessenaar'|'zadel', axis: 'x'|'z', profile }:
|
// Geeft { type: 'plat'|'lessenaar'|'zadel'|'mansarde'|'wolf', axis: 'x'|'z',
|
||||||
// de hoogte van de dakonderkant (vanaf het loopvlak) hangt alleen af van
|
// profile, hip? }: de hoogte van de dakonderkant (vanaf het loopvlak)
|
||||||
// de positie langs axis (profileHeight). Kopgevels: de eerste wand met
|
// hangt alleen af van de positie langs axis (profileHeight), behalve bij
|
||||||
// een schuine daklijn (noord/zuid -> dak helt langs X, west/oost -> langs Z).
|
// een wolfsdak (hip). Kopgevels: de eerste wand met een schuine daklijn
|
||||||
// Zonder schuine wanden: plat op de hoogste wand.
|
// (noord/zuid -> dak helt langs X, west/oost -> langs Z). Zonder schuine
|
||||||
|
// wanden: plat op de hoogste wand.
|
||||||
|
// zadel 3 punten (goot, nok, goot)
|
||||||
|
// mansarde 5 punten (goot, knik, nok, knik, goot)
|
||||||
|
// wolf 4 punten met een vlakke top: de kopgevel stopt op het
|
||||||
|
// wolfseind. Het profiel wordt dat van het zadeldak (de
|
||||||
|
// schuine stukken doorgetrokken tot de nok), met de knikken op
|
||||||
|
// het wolfseind; hip = { h }: de wolfseinden lopen vanaf de
|
||||||
|
// kopgevels op hoogte h omhoog tot de nok (computeCap).
|
||||||
function computeShape(unitState, layout) {
|
function computeShape(unitState, layout) {
|
||||||
for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) {
|
for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) {
|
||||||
const profile = wallProfile(unitState, layout, side);
|
const profile = wallProfile(unitState, layout, side);
|
||||||
if (profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) {
|
if (!profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) continue;
|
||||||
return { type: profile.length > 2 ? 'zadel' : 'lessenaar', axis, profile };
|
if (profile.length === 4 && Math.abs(profile[1].h - profile[2].h) < 0.5) {
|
||||||
|
const [L, A, B, R] = profile, sA = (A.h - L.h) / (A.p - L.p), sB = (R.h - B.h) / (R.p - B.p);
|
||||||
|
// Snijpunt van de doorgetrokken schuine stukken = nok.
|
||||||
|
const pr = Math.abs(sA - sB) > 1e-9 ? (B.h - sB * B.p - A.h + sA * A.p) / (sA - sB) : (A.p + B.p) / 2;
|
||||||
|
const ridge = { p: pr, h: A.h + sA * (pr - A.p) };
|
||||||
|
if (sA > 0 && sB < 0 && pr > A.p && pr < B.p) {
|
||||||
|
return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel';
|
||||||
|
return { type, axis, profile };
|
||||||
}
|
}
|
||||||
const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws)));
|
const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws)));
|
||||||
return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] };
|
return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] };
|
||||||
@@ -297,22 +318,200 @@ window.RoofModel = (() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const planeAt = (q) => planes.find((pl, i) => q < pl.p1 || i === planes.length - 1);
|
// Knik tussen twee vlakken met een verschillende helling (mansarde):
|
||||||
const undersideAt = (x, z) => H(alongX ? x : z);
|
// de bovenkanten snijden elkaar vóór (het steilere vlak is verticaal
|
||||||
const topAt = (x, z) => {
|
// dikker), dus de vlakken sluiten in verstek aan: onderkant op de knik,
|
||||||
|
// bovenkant op het snijpunt van de bovenvlakken (miter = verschuiving
|
||||||
|
// van de bovenkant t.o.v. de knik, mm langs de as; 0 = verticaal).
|
||||||
|
planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; });
|
||||||
|
planes.slice(0, -1).forEach((pi, i) => {
|
||||||
|
const pj = planes[i + 1], ai = Math.tan(pi.theta), aj = Math.tan(pj.theta);
|
||||||
|
if (Math.abs(ai - aj) < 1e-4) return;
|
||||||
|
const dp = (pj.vThick - pi.vThick) / (ai - aj);
|
||||||
|
pi.miter1 = dp; pj.miter0 = dp;
|
||||||
|
});
|
||||||
|
const mainUnder = (x, z) => H(alongX ? x : z);
|
||||||
|
// Bovenkant: het laagste van de (doorgetrokken) bovenvlakken; de daklijn
|
||||||
|
// is bol, dus dat is precies het dak, ook rond een knik.
|
||||||
|
const mainTop = (x, z) => {
|
||||||
const q = alongX ? x : z;
|
const q = alongX ? x : z;
|
||||||
const onKink = planes.filter(pl => Math.abs(q - pl.p0) < 0.5 || Math.abs(q - pl.p1) < 0.5);
|
return Math.min(...planes.map(pl => pl.h0 + (q - pl.p0) * Math.tan(pl.theta) + pl.vThick));
|
||||||
const t = onKink.length > 1 ? Math.max(...onKink.map(pl => pl.vThick)) : planeAt(q).vThick;
|
|
||||||
return H(q) + t;
|
|
||||||
};
|
};
|
||||||
|
// Wolfsdak: de vlakken tussen de wolfseind-knikken worden geen
|
||||||
|
// rechthoekige dekken; dat bovenste deel (cap, computeCap) is het laagste
|
||||||
|
// van de twee dakvlakken en de twee wolfseinden, met één top.
|
||||||
|
let cap = null, undersideAt = mainUnder, topAt = mainTop, outPlanes = planes;
|
||||||
|
if (shape.type === 'wolf' && shape.hip && kinks.length === 3) {
|
||||||
|
cap = computeCap(roofState, shape, rect, alongX, kinks, covering);
|
||||||
|
outPlanes = planes.filter(pl => !(pl.p0 >= cap.pA - 0.5 && pl.p1 <= cap.pB + 0.5));
|
||||||
|
const at = (x, z) => ({ x: alongX ? x : z, z: alongX ? z : x }); // (p, q)
|
||||||
|
undersideAt = (x, z) => Math.min(mainUnder(x, z), ...cap.regions.map(rg => rg.under(at(x, z))));
|
||||||
|
topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
shape, covering, planes, specs: sumSpecs(planes.map(pl => pl.specs)),
|
shape, covering, planes: outPlanes, specs: sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : [])),
|
||||||
undersideAt, topAt,
|
undersideAt, topAt, cap, rect, alongX,
|
||||||
ridges: kinks.map(value => ({ axis: shape.axis, value })),
|
ridges: kinks.map(value => ({ axis: shape.axis, value })),
|
||||||
maxTop: Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick))
|
maxTop: cap ? cap.maxTop : Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Bovenste deel van een wolfsdak (cap). Coördinaten (p, q) als { x: p,
|
||||||
|
// z: q } (unit-lokaal mm): p langs de helling van het hoofddak, q langs de
|
||||||
|
// nok. Band p = pA..pB (wolfseind-knikken), q = q0..q1 (de kopgevels).
|
||||||
|
// Vier vlakken (onderkant dak): m1/m2 de twee dakvlakken, h1/h2 de
|
||||||
|
// wolfseinden: vanaf de kopgevel op hipH met de dakhelling omhoog tot de
|
||||||
|
// nokhoogte (driehoek), daartussen een rechte nok op pR. Is de unit te kort
|
||||||
|
// voor een nokstuk, dan worden de wolfseinden steiler en lopen ze in één
|
||||||
|
// punt samen. Per vlak het gebied waar het het laagste is (regions).
|
||||||
|
// Houtwerk zoals de dekken: plafondplaat, sporen (dakvlakken: op dezelfde
|
||||||
|
// q als de balken van het onderste dek; wolfseinden: verdeeld over pA..pB),
|
||||||
|
// hoekkepers van elke hoek van de band naar het nokeinde, nokbalk, kopbalk
|
||||||
|
// langs de voet van een wolfseind.
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel
|
||||||
|
function computeCap(roofState, shape, rect, alongX, kinks, covering) {
|
||||||
|
const H = (p) => profileHeight(shape.profile, p);
|
||||||
|
const [pA, pR, pB] = kinks, hipH = shape.hip.h, top = H(pR);
|
||||||
|
const q0 = alongX ? rect.z0 : rect.x0, q1 = alongX ? rect.z1 : rect.x1, qM = (q0 + q1) / 2;
|
||||||
|
const fp = roofState.deck.floor;
|
||||||
|
const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22;
|
||||||
|
const cov = COVERINGS[covering] || COVERINGS.dakleer;
|
||||||
|
const sA = (top - H(pA)) / (pR - pA), sB = (top - H(pB)) / (pB - pR);
|
||||||
|
// Helling wolfseinden: die van het dak, maar minstens zo steil dat ze de
|
||||||
|
// nokhoogte halen vóór het midden tussen de kopgevels.
|
||||||
|
const tH = Math.max((sA + sB) / 2, (top - hipH) / Math.max(1, qM - q0));
|
||||||
|
const inset = (top - hipH) / tH; // nokeinde vanaf de kopgevel
|
||||||
|
const plane = (key, slope, f, along) => {
|
||||||
|
const cos = Math.cos(Math.atan(slope));
|
||||||
|
return { key, f, along, cos, tV: underlay / cos, jV: joistH / cos, covV: cov.thickMM / cos };
|
||||||
|
};
|
||||||
|
const fns = [
|
||||||
|
plane('m1', sA, (p) => H(pA) + (p.x - pA) * sA, 'p'),
|
||||||
|
plane('m2', sB, (p) => H(pB) + (pB - p.x) * sB, 'p'),
|
||||||
|
plane('h1', tH, (p) => hipH + (p.z - q0) * tH, 'q'),
|
||||||
|
plane('h2', tH, (p) => hipH + (q1 - p.z) * tH, 'q')
|
||||||
|
];
|
||||||
|
const band = [{ x: pA, z: q0 }, { x: pB, z: q0 }, { x: pB, z: q1 }, { x: pA, z: q1 }];
|
||||||
|
const regions = fns.map((fi, i) => {
|
||||||
|
let poly = band;
|
||||||
|
fns.forEach((fj, j) => { if (j !== i && poly.length >= 3) poly = clipPoly(poly, (p) => fj.f(p) - fi.f(p)); });
|
||||||
|
// Samenvallende punten (afsnijden door de top) weg.
|
||||||
|
poly = poly.filter((p, k) => { const q = poly[(k + 1) % poly.length]; return Math.hypot(q.x - p.x, q.z - p.z) > 0.5; });
|
||||||
|
return Object.assign({}, fi, { poly, under: fi.f, vThick: fi.tV + fi.jV + fi.covV });
|
||||||
|
}).filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100);
|
||||||
|
|
||||||
|
// Snijden van een lijn (vaste p of vaste q) met een convexe veelhoek.
|
||||||
|
const cutLine = (poly, fixed, v) => {
|
||||||
|
const ts = [];
|
||||||
|
poly.forEach((a, k) => {
|
||||||
|
const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed];
|
||||||
|
if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return;
|
||||||
|
const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x';
|
||||||
|
ts.push(a[o] + (b[o] - a[o]) * t);
|
||||||
|
});
|
||||||
|
return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null;
|
||||||
|
};
|
||||||
|
// Balken als veelhoek in de plattegrond (rechthoek van balkbreedte rond
|
||||||
|
// de hartlijn A-B, afgezaagd tegen de balken waar hij op aansluit) met
|
||||||
|
// de onderkant op het dakvlak (yAt: + plafondplaat). Lengte = langs de
|
||||||
|
// balk, hoek = helling van de balk.
|
||||||
|
const hw = joistW / 2;
|
||||||
|
const rectAround = (A, B) => {
|
||||||
|
const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw;
|
||||||
|
return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }];
|
||||||
|
};
|
||||||
|
// Halfvlak aan de kant van `from` naast het zijvlak van balk m (>= 0 blijft).
|
||||||
|
const awayFrom = (m, from) => {
|
||||||
|
const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L;
|
||||||
|
const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz;
|
||||||
|
const sgn = d(from) >= 0 ? 1 : -1;
|
||||||
|
return (p) => sgn * d(p) - hw;
|
||||||
|
};
|
||||||
|
const distToSeg = (p, m) => {
|
||||||
|
const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz;
|
||||||
|
const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2));
|
||||||
|
return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t));
|
||||||
|
};
|
||||||
|
const beams = [];
|
||||||
|
const addBeam = (kind, rg, A, B, clips, lineHeight) => {
|
||||||
|
if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null;
|
||||||
|
let poly = rectAround(A, B);
|
||||||
|
(clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); });
|
||||||
|
if (poly.length < 3) return null;
|
||||||
|
const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz);
|
||||||
|
const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV;
|
||||||
|
// Hartlijnbalk (hoekkeper, nokbalk): hoogte langs de hartlijn; anders op het vlak.
|
||||||
|
const yAt = lineHeight
|
||||||
|
? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L)))
|
||||||
|
: (p) => rg.under(p) + rg.tV;
|
||||||
|
const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L);
|
||||||
|
const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L;
|
||||||
|
if (plan < 50) return null;
|
||||||
|
const bm = {
|
||||||
|
kind, A, B, poly, yAt, jV: rg.jV,
|
||||||
|
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
|
||||||
|
};
|
||||||
|
beams.push(bm);
|
||||||
|
return bm;
|
||||||
|
};
|
||||||
|
// Raamwerk: kopbalken (binnen de kopgevel, op de bovenregel), hoekkepers
|
||||||
|
// van elke hoek van de band naar het nokeinde, nokbalk ertussen. De
|
||||||
|
// hoekkepers staan op de kopbalk en sluiten bovenin in verstek op elkaar
|
||||||
|
// aan; de nokbalk is afgezaagd tegen de hoekkepers.
|
||||||
|
const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0];
|
||||||
|
const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0];
|
||||||
|
const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }];
|
||||||
|
const hasRidge = ends[1].z - ends[0].z >= 100;
|
||||||
|
const kops = [q0 + hw, q1 - hw].map(qv => addBeam('kopbalk', hOf(qv), { x: pA, z: qv }, { x: pB, z: qv }, [
|
||||||
|
(p) => p.z - q0 - 0.01, (p) => q1 - p.z - 0.01])).filter(Boolean);
|
||||||
|
const hips = band.map(c => {
|
||||||
|
const e = c.z < qM ? ends[0] : ends[1], sp = Math.sign(pR - c.x), sq = Math.sign(qM - c.z);
|
||||||
|
const clips = [(p) => sp * (pR - p.x)];
|
||||||
|
if (!hasRidge) clips.push((p) => sq * (qM - p.z));
|
||||||
|
// Op de kopbalk: vanaf zijn binnenkant.
|
||||||
|
clips.push((p) => sq * (c.z + sq * joistW - p.z) * -1);
|
||||||
|
return addBeam('hoekkeper', mOf(c.x), c, e, clips, true);
|
||||||
|
}).filter(Boolean);
|
||||||
|
let ridgeBeam = null;
|
||||||
|
if (hasRidge) {
|
||||||
|
const mid = { x: pR, z: qM };
|
||||||
|
ridgeBeam = addBeam('nokbalk', mOf(pR - 1), ends[0], ends[1], hips.map(h => awayFrom(h, mid)), true);
|
||||||
|
}
|
||||||
|
const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []);
|
||||||
|
// Sporen: afgezaagd tegen de balk(en) waar ze op uitkomen.
|
||||||
|
const spread = (len) => FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: joistW, joistHeight: joistH, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: underlay }).joists.map(x => x + len / 2);
|
||||||
|
const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(pB - pA).map(v => pA + v);
|
||||||
|
regions.forEach(rg => {
|
||||||
|
const fixed = rg.along === 'p' ? 'z' : 'x';
|
||||||
|
(rg.along === 'p' ? qs : ps).forEach(v => {
|
||||||
|
const span = cutLine(rg.poly, fixed, v);
|
||||||
|
if (!span) return;
|
||||||
|
const pt = (t) => fixed === 'z' ? { x: t, z: v } : { x: v, z: t };
|
||||||
|
const A = pt(span[0]), B = pt(span[1]), mid = pt((span[0] + span[1]) / 2);
|
||||||
|
const clips = members.filter(m => distToSeg(A, m) < hw + 1 || distToSeg(B, m) < hw + 1).map(m => awayFrom(m, mid));
|
||||||
|
addBeam('spoor', rg, A, B, clips);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Specificaties (zelfde sleutels als computeSpecs).
|
||||||
|
const W = joistW * 1e-3, Hh = joistH * 1e-3;
|
||||||
|
const woodM3 = beams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0);
|
||||||
|
const areaM2 = regions.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6;
|
||||||
|
const isLeer = !COVERINGS[covering] || covering === 'dakleer';
|
||||||
|
const covKg = areaM2 * cov.kgM2, osbVol = areaM2 * underlay * 1e-3;
|
||||||
|
const specs = {
|
||||||
|
joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3,
|
||||||
|
osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3,
|
||||||
|
voidM3: Math.max(0, areaM2 * Hh - woodM3),
|
||||||
|
dakleerM2: isLeer ? areaM2 : 0, dakleerKg: isLeer ? covKg : 0, rietM2: isLeer ? 0 : areaM2, rietKg: isLeer ? 0 : covKg,
|
||||||
|
totalKg: woodM3 * DENSITY_WOOD_KGM3 + osbVol * DENSITY_OSB_KGM3 + covKg
|
||||||
|
};
|
||||||
|
const maxTop = top + Math.max(...regions.map(rg => rg.vThick));
|
||||||
|
return { pA, pR, pB, q0, q1, hipH, joistW, joistH, regions, beams, specs, maxTop };
|
||||||
|
}
|
||||||
|
|
||||||
// 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek,
|
// 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek,
|
||||||
// Y = 0 op het loopvlak): per dakvlak build3D, zo vervormd dat de
|
// Y = 0 op het loopvlak): per dakvlak build3D, zo vervormd dat de
|
||||||
// onderkant van het plafond precies op de daklijn van de wanden ligt.
|
// onderkant van het plafond precies op de daklijn van de wanden ligt.
|
||||||
@@ -327,6 +526,7 @@ window.RoofModel = (() => {
|
|||||||
function buildUnit3D(unitRoof, opts) {
|
function buildUnit3D(unitRoof, opts) {
|
||||||
const toM = 0.001;
|
const toM = 0.001;
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
|
if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts));
|
||||||
unitRoof.planes.forEach(pl => {
|
unitRoof.planes.forEach(pl => {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
g.position.set(pl.cx * toM, pl.y * toM, pl.cz * toM);
|
g.position.set(pl.cx * toM, pl.y * toM, pl.cz * toM);
|
||||||
@@ -339,13 +539,104 @@ window.RoofModel = (() => {
|
|||||||
0, 1 / c, sn, 0,
|
0, 1 / c, sn, 0,
|
||||||
0, 0, c, 0,
|
0, 0, c, 0,
|
||||||
0, 0, 0, 1);
|
0, 0, 0, 1);
|
||||||
shear.add(build3D(pl.data.calcData, pl.data.resolvedOpenings, Object.assign({ covering: unitRoof.covering }, opts)));
|
const deck = build3D(pl.data.calcData, pl.data.resolvedOpenings, Object.assign({ covering: unitRoof.covering }, opts));
|
||||||
|
if (pl.miter0 || pl.miter1) miterDeck(deck, pl, c);
|
||||||
|
shear.add(deck);
|
||||||
g.add(shear);
|
g.add(shear);
|
||||||
group.add(g);
|
group.add(g);
|
||||||
});
|
});
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verstek op de uiteinden van een dek (vóór de afschuiving; lokaal z
|
||||||
|
// langs de helling, -L/2 .. L/2, y = 0 onderkant .. T bovenkant): elk
|
||||||
|
// hoekpunt op een uiteinde schuift met de hoogte mee naar de verstekhoek
|
||||||
|
// (pl.miter0 / miter1 in mm langs de as, omgerekend langs de helling).
|
||||||
|
function miterDeck(deck, pl, cos) {
|
||||||
|
const toM = 0.001, cd = pl.data.calcData;
|
||||||
|
const L = cd.depth * toM, T = pl.vThick * cos * toM;
|
||||||
|
const d0 = pl.miter0 * toM / cos, d1 = pl.miter1 * toM / cos;
|
||||||
|
const v = new THREE.Vector3(), rel = new THREE.Matrix4(), inv = new THREE.Matrix4();
|
||||||
|
deck.updateMatrixWorld(true);
|
||||||
|
const base = new THREE.Matrix4().copy(deck.matrixWorld).invert();
|
||||||
|
deck.traverse(o => {
|
||||||
|
const pos = o.geometry && o.geometry.attributes && o.geometry.attributes.position;
|
||||||
|
if (!pos) return;
|
||||||
|
rel.multiplyMatrices(base, o.matrixWorld);
|
||||||
|
inv.copy(rel).invert();
|
||||||
|
for (let k = 0; k < pos.count; k++) {
|
||||||
|
v.fromBufferAttribute(pos, k).applyMatrix4(rel);
|
||||||
|
const t = Math.max(0, Math.min(1, v.y / T));
|
||||||
|
if (d1 && v.z > L / 2 - 1e-4) v.z = L / 2 + d1 * t;
|
||||||
|
else if (d0 && v.z < -L / 2 + 1e-4) v.z = -L / 2 + d0 * t;
|
||||||
|
else continue;
|
||||||
|
v.applyMatrix4(inv);
|
||||||
|
pos.setXYZ(k, v.x, v.y, v.z);
|
||||||
|
}
|
||||||
|
pos.needsUpdate = true;
|
||||||
|
o.geometry.computeBoundingSphere();
|
||||||
|
if (o.geometry.attributes.normal) o.geometry.computeVertexNormals();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bovenste deel van een wolfsdak (unitRoof.cap, zie computeCap): per
|
||||||
|
// hellend vlak plafondplaat en bedekking (verticale dikte, UV langs de
|
||||||
|
// helling), sporen/hoekkepers/kopbalken als balken met verticale kopse
|
||||||
|
// kanten (zoals de afgeschoven dekken). Plaat én bedekking zichtbaar:
|
||||||
|
// alleen de kopbalken (de rest is ingesloten).
|
||||||
|
function capRegions(unitRoof) { return unitRoof.cap ? unitRoof.cap.regions : []; }
|
||||||
|
function buildCap3D(unitRoof, opts) {
|
||||||
|
const toM = 0.001, m = opts.materials, c = unitRoof.cap, r = unitRoof.rect;
|
||||||
|
const xc = (r.x0 + r.x1) / 2, zc = (r.z0 + r.z1) / 2;
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const osbOn = opts.osbVisible !== false, covOn = opts.dakleerVisible !== false;
|
||||||
|
const covMat = coveringMaterial(unitRoof.covering) || m.dakleerMaterial;
|
||||||
|
// (p, q) -> wereld (m, t.o.v. het dakmidden).
|
||||||
|
const P = (pt, y) => {
|
||||||
|
const x = unitRoof.alongX ? pt.x : pt.z, z = unitRoof.alongX ? pt.z : pt.x;
|
||||||
|
return [(x - xc) * toM, y * toM, (z - zc) * toM];
|
||||||
|
};
|
||||||
|
// Plaat onder een convexe veelhoek: onderkant bottom(pt), dikte h (verticaal).
|
||||||
|
function slab(poly, bottom, h, topMat, sideMat, uvOf) {
|
||||||
|
const n = poly.length, pos = [], uv = [];
|
||||||
|
const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt)));
|
||||||
|
const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0];
|
||||||
|
const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); };
|
||||||
|
// Oriëntatie zo dat de bovenkant naar boven wijst.
|
||||||
|
const up = (T[1][0] - T[0][0]) * (T[2][2] - T[0][2]) - (T[1][2] - T[0][2]) * (T[2][0] - T[0][0]) < 0;
|
||||||
|
for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]);
|
||||||
|
const topCount = pos.length / 3;
|
||||||
|
for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2);
|
||||||
|
for (let k = 0; k < n; k++) {
|
||||||
|
const j = (k + 1) % n;
|
||||||
|
up ? (tri(T[k], B[k], B[j], z2, z2, z2), tri(T[k], B[j], T[j], z2, z2, z2))
|
||||||
|
: (tri(T[k], B[j], B[k], z2, z2, z2), tri(T[k], T[j], B[j], z2, z2, z2));
|
||||||
|
}
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||||
|
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
|
||||||
|
geo.addGroup(0, topCount, 0);
|
||||||
|
geo.addGroup(topCount, pos.length / 3 - topCount, 1);
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
const mesh = new THREE.Mesh(geo, [topMat, sideMat]);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
c.regions.forEach(rg => {
|
||||||
|
if (osbOn) slab(rg.poly, rg.under, rg.tV, m.osbMaterial, m.osbMaterial);
|
||||||
|
if (covOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV, rg.covV, covMat, covMat,
|
||||||
|
(pt) => rg.along === 'p' ? [pt.z * toM, pt.x * toM / rg.cos] : [pt.x * toM, pt.z * toM / rg.cos]);
|
||||||
|
});
|
||||||
|
// Balken: hun (afgezaagde) veelhoek in de plattegrond, onderkant op
|
||||||
|
// bm.yAt, hoogte jV verticaal.
|
||||||
|
c.beams.forEach(bm => {
|
||||||
|
if (osbOn && covOn && bm.kind !== 'kopbalk') return;
|
||||||
|
slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial);
|
||||||
|
});
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Dakrand over de hele woning
|
// Dakrand over de hele woning
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
@@ -593,7 +884,7 @@ window.RoofModel = (() => {
|
|||||||
return {
|
return {
|
||||||
DAKLEER_MM, EDGE_ABOVE_MM, RIET, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
|
DAKLEER_MM, EDGE_ABOVE_MM, RIET, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
|
||||||
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
||||||
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D,
|
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions,
|
||||||
computeEdge, buildEdge3D, normalizeSettings
|
computeEdge, buildEdge3D, normalizeSettings
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+201
-27
@@ -192,24 +192,31 @@ window.UnitModel = (() => {
|
|||||||
const fullConfig = WallModel.configFromState(wallState);
|
const fullConfig = WallModel.configFromState(wallState);
|
||||||
const W = wallLayout.width;
|
const W = wallLayout.width;
|
||||||
const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight;
|
const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight;
|
||||||
// Middenpunt (nok) midden op de wand; een stuk krijgt de nok alleen als
|
// Tussenpunten van de daklijn (nok, knikken; WallModel.wallTopPoints);
|
||||||
// die erin valt, anders de daklijn ter plaatse als gewone helling.
|
// een stuk krijgt de punten die erin vallen, verder de daklijn ter
|
||||||
const hM = wallState.wall.heightMid > 0 ? Math.max(wallState.wall.heightMid, hL, hR) : null;
|
// plaatse als gewone helling.
|
||||||
const pU = W / 2;
|
const tops = WallModel.wallTopPoints(wallState.wall, W, hL, hR);
|
||||||
const heightAt = (u) => hM === null ? hL + (u / W) * (hR - hL)
|
const tp = [{ x: 0, h: hL }, ...tops, { x: W, h: hR }];
|
||||||
: u <= pU ? hL + (u / pU) * (hM - hL) : hM + ((u - pU) / (W - pU)) * (hR - hM);
|
const heightAt = (u) => {
|
||||||
|
let i = 0;
|
||||||
|
while (i < tp.length - 2 && u > tp[i + 1].x) i++;
|
||||||
|
const a = tp[i], b = tp[i + 1];
|
||||||
|
return a.h + (u - a.x) / (b.x - a.x) * (b.h - a.h);
|
||||||
|
};
|
||||||
|
const topsIn = (a, b, base) => {
|
||||||
|
const inner = tops.filter(q => q.x > a + 1 && q.x < b - 1);
|
||||||
|
return inner.length ? { heightMid: null, topPts: inner.map(q => ({ f: (q.x - a) / (b - a), h: q.h - base })) } : { heightMid: null, topPts: undefined };
|
||||||
|
};
|
||||||
const multi = wallLayout.pieces.length > 1;
|
const multi = wallLayout.pieces.length > 1;
|
||||||
const used = new Set();
|
const used = new Set();
|
||||||
|
|
||||||
const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => {
|
const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => {
|
||||||
const openings = fullConfig.openings
|
const openings = fullConfig.openings
|
||||||
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS)
|
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS)
|
||||||
.map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; });
|
.map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; });
|
||||||
const hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
|
|
||||||
const params = Object.assign({}, wallState.wall, {
|
const params = Object.assign({}, wallState.wall, {
|
||||||
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b)),
|
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b))
|
||||||
heightMid: hasPeak ? hM : null, peakX: hasPeak ? pU - a : undefined
|
}, topsIn(a, b, 0));
|
||||||
});
|
|
||||||
const config = WallModel.buildConfig(params, openings);
|
const config = WallModel.buildConfig(params, openings);
|
||||||
return {
|
return {
|
||||||
suffix: multi ? String.fromCharCode(97 + i) : '', // a, b, ...
|
suffix: multi ? String.fromCharCode(97 + i) : '', // a, b, ...
|
||||||
@@ -228,16 +235,14 @@ window.UnitModel = (() => {
|
|||||||
const ins = wallLayout.endInset || { left: 0, right: 0 };
|
const ins = wallLayout.endInset || { left: 0, right: 0 };
|
||||||
raisedList.forEach((r, i) => {
|
raisedList.forEach((r, i) => {
|
||||||
const { u0: a, u1: b, base } = r;
|
const { u0: a, u1: b, base } = r;
|
||||||
const hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
|
|
||||||
const hA = heightAt(a) - base, hB = heightAt(b) - base;
|
const hA = heightAt(a) - base, hB = heightAt(b) - base;
|
||||||
if (Math.min(hA, hB) < MIN_RAISE_MM) return;
|
if (Math.min(hA, hB) < MIN_RAISE_MM) return;
|
||||||
const openings = fullConfig.openings
|
const openings = fullConfig.openings
|
||||||
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS)
|
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS)
|
||||||
.map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; });
|
.map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; });
|
||||||
const params = Object.assign({}, wallState.wall, {
|
const params = Object.assign({}, wallState.wall, {
|
||||||
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB),
|
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB)
|
||||||
heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined
|
}, topsIn(a, b, base));
|
||||||
});
|
|
||||||
const config = WallModel.buildConfig(params, openings);
|
const config = WallModel.buildConfig(params, openings);
|
||||||
pieces.push({
|
pieces.push({
|
||||||
suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''), // opbouw
|
suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''), // opbouw
|
||||||
@@ -258,9 +263,11 @@ window.UnitModel = (() => {
|
|||||||
// Wandhoogtes koppelen, zodat er altijd een logisch dak op past. De
|
// Wandhoogtes koppelen, zodat er altijd een logisch dak op past. De
|
||||||
// bewerkte wand (side) bepaalt de dakvorm:
|
// bewerkte wand (side) bepaalt de dakvorm:
|
||||||
// - de tegenoverliggende wand krijgt dezelfde hoogtes gespiegeld (van
|
// - de tegenoverliggende wand krijgt dezelfde hoogtes gespiegeld (van
|
||||||
// buitenaf gezien wisselen links en rechts), incl. middenpunt (nok);
|
// buitenaf gezien wisselen links en rechts), incl. nok en knikken;
|
||||||
// - de twee haakse wanden worden vlak op de hoogte van de hoek waar ze
|
// - de twee haakse wanden worden vlak op de hoogte van het dak boven
|
||||||
// op aansluiten (goothoogte bij een lessenaar- of zadeldak).
|
// hun buitenkant (goothoogte bij een lessenaar- of zadeldak). Staat
|
||||||
|
// de kopgevel tussen de haakse wanden in, dan loopt zijn daklijn
|
||||||
|
// daarvoor door tot de buitenkant van de vloer (gableSpan).
|
||||||
// Voorbeeld: west zuid hoger dan noord -> oost ook, noordwand vlak op de
|
// Voorbeeld: west zuid hoger dan noord -> oost ook, noordwand vlak op de
|
||||||
// lage, zuidwand vlak op de hoge hoogte. Muteert state.
|
// lage, zuidwand vlak op de hoge hoogte. Muteert state.
|
||||||
// Welke haakse wand zit aan de linker/rechterkant (van buitenaf gezien).
|
// Welke haakse wand zit aan de linker/rechterkant (van buitenaf gezien).
|
||||||
@@ -271,22 +278,189 @@ window.UnitModel = (() => {
|
|||||||
rechts: { left: 'achter', right: 'voor' }
|
rechts: { left: 'achter', right: 'voor' }
|
||||||
};
|
};
|
||||||
const OPPOSITE = { voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' };
|
const OPPOSITE = { voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' };
|
||||||
|
// Plaats van een wand t.o.v. de volle vloermaat langs zijn as (van
|
||||||
|
// buitenaf gezien): e0/e1 = afstand van de vloerrand tot het linker-/
|
||||||
|
// rechteruiteinde, w = wandlengte, span = vloermaat (= e0 + w + e1).
|
||||||
|
function gableSpan(state, side) {
|
||||||
|
const layout = computeLayout(state, {});
|
||||||
|
const wl = layout.walls.find(x => x.side === side);
|
||||||
|
const s = WALL_SIDES.find(x => x.key === side);
|
||||||
|
const span = s.axis === 'x' ? layout.floorWidth : layout.floorDepth;
|
||||||
|
const c = s.axis === 'x' ? wl.cx : wl.cz;
|
||||||
|
const left = c - s.dir * wl.width / 2, right = c + s.dir * wl.width / 2;
|
||||||
|
const e0 = Math.max(0, Math.abs(left + s.dir * span / 2)), e1 = Math.max(0, Math.abs(s.dir * span / 2 - right));
|
||||||
|
return { e0, e1, w: wl.width, span, layout };
|
||||||
|
}
|
||||||
|
// Daklijn van een wand over de volle vloermaat: [{ u, h }] met u vanaf de
|
||||||
|
// linker vloerrand; de eerste en laatste helling lopen door tot de randen.
|
||||||
|
function fullTopLine(wall, g) {
|
||||||
|
const pts = [{ u: g.e0, h: wall.heightLeft }]
|
||||||
|
.concat(WallModel.wallTopPoints(wall, g.w, wall.heightLeft, wall.heightRight).map(q => ({ u: g.e0 + q.x, h: q.h })))
|
||||||
|
.concat([{ u: g.e0 + g.w, h: wall.heightRight }]);
|
||||||
|
const n = pts.length, s0 = (pts[1].h - pts[0].h) / (pts[1].u - pts[0].u), s1 = (pts[n - 1].h - pts[n - 2].h) / (pts[n - 1].u - pts[n - 2].u);
|
||||||
|
pts[0] = { u: 0, h: pts[0].h - g.e0 * s0 };
|
||||||
|
pts[n - 1] = { u: g.span, h: pts[n - 1].h + g.e1 * s1 };
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
// Omgekeerd: een daklijn over de volle vloermaat ([{ u, h }]) op de wand
|
||||||
|
// zetten (hoogtes op de uiteinden, knikken als topPts; één knik in het
|
||||||
|
// midden als heightMid).
|
||||||
|
function setWallTopLine(wall, g, line) {
|
||||||
|
const at = (u) => {
|
||||||
|
let i = 0;
|
||||||
|
while (i < line.length - 2 && u > line[i + 1].u) i++;
|
||||||
|
const a = line[i], b = line[i + 1];
|
||||||
|
return a.h + (u - a.u) / (b.u - a.u) * (b.h - a.h);
|
||||||
|
};
|
||||||
|
const hL = Math.round(at(g.e0)), hR = Math.round(at(g.e0 + g.w));
|
||||||
|
const inner = line.slice(1, -1).filter(q => q.u > g.e0 + 1 && q.u < g.e0 + g.w - 1).map(q => ({ f: (q.u - g.e0) / g.w, h: Math.round(q.h) }));
|
||||||
|
const mid = inner.length === 1 && Math.abs(inner[0].f - 0.5) < 1e-3;
|
||||||
|
Object.assign(wall, { heightLeft: hL, heightRight: hR, heightMid: mid ? inner[0].h : null, heightAll: Math.min(hL, hR) });
|
||||||
|
if (inner.length && !mid) wall.topPts = inner; else delete wall.topPts;
|
||||||
|
}
|
||||||
function syncWallHeights(state, side) {
|
function syncWallHeights(state, side) {
|
||||||
const w = state.walls[side].wall;
|
const w = state.walls[side].wall;
|
||||||
const mid = w.heightMid > 0 ? w.heightMid : null;
|
const mid = w.heightMid > 0 ? w.heightMid : null;
|
||||||
const set = (key, hL, hR, hM) => {
|
const pts = Array.isArray(w.topPts) && w.topPts.length ? w.topPts : null;
|
||||||
|
// Haakse wanden: op de onderkant van het dak boven hun buiten- of
|
||||||
|
// binnenkant, de laagste van de twee (zo steekt de wand nooit door het
|
||||||
|
// dak; de wig tussen bovenregel en dak vult een schuine regel op, zie
|
||||||
|
// HouseModel / WallModel topWedge).
|
||||||
|
const g = gableSpan(state, side), full = fullTopLine(w, g), n = full.length;
|
||||||
|
const thick = (key) => (g.layout.walls.find(x => x.side === key) || {}).thickness || 0;
|
||||||
|
const s0 = (full[1].h - full[0].h) / (full[1].u - full[0].u), s1 = (full[n - 1].h - full[n - 2].h) / (full[n - 1].u - full[n - 2].u);
|
||||||
|
const edgeL = Math.round(Math.min(full[0].h, full[0].h + thick(WALL_ENDS[side].left) * s0));
|
||||||
|
const edgeR = Math.round(Math.min(full[n - 1].h, full[n - 1].h - thick(WALL_ENDS[side].right) * s1));
|
||||||
|
const set = (key, hL, hR, hM, tp) => {
|
||||||
const t = state.walls[key].wall;
|
const t = state.walls[key].wall;
|
||||||
t.heightLeft = hL; t.heightRight = hR; t.heightMid = hM;
|
t.heightLeft = hL; t.heightRight = hR; t.heightMid = hM;
|
||||||
t.heightAll = hM === null && hL === hR ? hL : w.heightAll;
|
if (tp) t.topPts = tp; else delete t.topPts;
|
||||||
|
t.heightAll = hM === null && !tp && hL === hR ? hL : w.heightAll;
|
||||||
};
|
};
|
||||||
set(OPPOSITE[side], w.heightRight, w.heightLeft, mid);
|
// Gespiegeld (van buitenaf gezien): knikken van rechts naar links.
|
||||||
set(WALL_ENDS[side].left, w.heightLeft, w.heightLeft, null);
|
set(OPPOSITE[side], w.heightRight, w.heightLeft, mid, pts && pts.map(q => ({ f: 1 - q.f, h: q.h })).reverse());
|
||||||
set(WALL_ENDS[side].right, w.heightRight, w.heightRight, null);
|
set(WALL_ENDS[side].left, edgeL, edgeL, null);
|
||||||
|
set(WALL_ENDS[side].right, edgeR, edgeR, null);
|
||||||
}
|
}
|
||||||
// Hoogste punt van een wand (incl. nok).
|
// Hoogste punt van een wand (incl. nok en knikken).
|
||||||
function wallTopOf(wallState) {
|
function wallTopOf(wallState) {
|
||||||
const w = wallState.wall;
|
const w = wallState.wall;
|
||||||
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0);
|
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Dakvorm als invoer. Het dak volgt de wanden (RoofModel.computeShape);
|
||||||
|
// hier de omgekeerde weg: één dakvorm per unit, de wanden volgen
|
||||||
|
// (via syncWallHeights op de kopgevel).
|
||||||
|
// { type: 'plat', low } hoogte
|
||||||
|
// { type: 'lessenaar', dir, low, high } dir = hoge zijde (voor/achter/links/rechts)
|
||||||
|
// { type: 'zadel', ridge, low, high } ridge = richting nok: 'z' (noord–zuid) of 'x' (west–oost)
|
||||||
|
// { type: 'mansarde', ridge, low, high, kneeH, kneeIn, kneeRatio }
|
||||||
|
// steil ondervlak van de goot tot de knik (kneeH, kneeIn = afstand
|
||||||
|
// van de knik tot de gevel), daarboven flauw tot de nok
|
||||||
|
// { type: 'wolf', ridge, low, high, cut, cutRatio }
|
||||||
|
// zadeldak waarvan de kopgevels boven `cut` zijn afgeschuind
|
||||||
|
// (wolfseinden, dakhelling tot de nok; rechte nok ertussen)
|
||||||
|
// kneeRatio / cutRatio: plaats van de knik resp. het wolfseind tussen goot
|
||||||
|
// (0) en nok (1). Gaat voor kneeH / cut, zodat ze meeschalen als goot of
|
||||||
|
// nok verandert (roofForm geeft ze altijd terug).
|
||||||
|
// low = goothoogte, high = hoge goot resp. nok (mm vanaf het loopvlak),
|
||||||
|
// over de volle vloermaat: de goot ligt boven de buitenkant van de
|
||||||
|
// gevel, de nok in het midden (ook als de kopgevel tussen de haakse
|
||||||
|
// wanden staat; fullTopLine). kneeIn = afstand van de vloerrand.
|
||||||
|
// Mansarde en wolfsdak: de kopgevel krijgt een daklijn met knikken
|
||||||
|
// (wall.topPts, zie WallModel.wallTopPoints).
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Grenzen van onze woningen (mm, vanaf het loopvlak; maat van de vloer):
|
||||||
|
// nok (hoogste punt onderkant dak) max 5 m, goot / plat dak max 3 m, een
|
||||||
|
// unit max 4,3 x 14 m (de korte zijde max 4,3 m).
|
||||||
|
const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 };
|
||||||
|
// Vloermaat binnen de grenzen (muteert floor = state.floor.floor). keep:
|
||||||
|
// 'width' of 'depth' = de zojuist gewijzigde maat; bij twee lange zijden
|
||||||
|
// wordt die geklemd (anders de kortste).
|
||||||
|
function clampSize(floor, keep) {
|
||||||
|
floor.width = Math.min(LIMITS.long, floor.width);
|
||||||
|
floor.depth = Math.min(LIMITS.long, floor.depth);
|
||||||
|
if (Math.min(floor.width, floor.depth) > LIMITS.short) {
|
||||||
|
const k = keep || (floor.width <= floor.depth ? 'width' : 'depth');
|
||||||
|
floor[k] = LIMITS.short;
|
||||||
|
}
|
||||||
|
return floor;
|
||||||
|
}
|
||||||
|
const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' };
|
||||||
|
const RIDGES = { z: 'Noord–zuid', x: 'West–oost' };
|
||||||
|
const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf';
|
||||||
|
// Kopgevel (wand met de schuine daklijn) bij een dakvorm.
|
||||||
|
function gableOf(form) {
|
||||||
|
if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter';
|
||||||
|
if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links';
|
||||||
|
return 'achter';
|
||||||
|
}
|
||||||
|
// Huidige dakvorm, afgelezen uit de wanden (zelfde volgorde als computeShape).
|
||||||
|
function roofForm(unitState) {
|
||||||
|
const W = unitState.walls;
|
||||||
|
for (const side of ['achter', 'voor', 'links', 'rechts']) {
|
||||||
|
const w = W[side].wall;
|
||||||
|
const ridge = side === 'voor' || side === 'achter' ? 'z' : 'x';
|
||||||
|
const full = fullTopLine(w, gableSpan(unitState, side));
|
||||||
|
const F = full[full.length - 1].u, inner = full.slice(1, -1);
|
||||||
|
const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h));
|
||||||
|
if (inner.length === 3) {
|
||||||
|
const high = Math.round(inner[1].h);
|
||||||
|
return { type: 'mansarde', ridge, low, high, kneeH: Math.round(inner[0].h), kneeIn: Math.round(inner[0].u), kneeRatio: (inner[0].h - low) / Math.max(1, high - low) };
|
||||||
|
}
|
||||||
|
if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) {
|
||||||
|
const slope = (inner[0].h - L.h) / Math.max(1, inner[0].u);
|
||||||
|
const high = Math.round(L.h + slope * F / 2);
|
||||||
|
return { type: 'wolf', ridge, low, high, cut: Math.round(inner[0].h), cutRatio: (inner[0].h - low) / Math.max(1, high - low) };
|
||||||
|
}
|
||||||
|
if (inner.length === 1) return { type: 'zadel', ridge, low, high: Math.round(inner[0].h) };
|
||||||
|
if (Math.abs(L.h - R.h) > 0.5) {
|
||||||
|
const dir = R.h > L.h ? WALL_ENDS[side].right : WALL_ENDS[side].left;
|
||||||
|
return { type: 'lessenaar', dir, low, high: Math.round(Math.max(L.h, R.h)) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const h = Math.max(...Object.values(W).map(wallTopOf));
|
||||||
|
return { type: 'plat', low: h, high: h };
|
||||||
|
}
|
||||||
|
// Dakvorm toepassen: de kopgevel krijgt de daklijn (over de volle
|
||||||
|
// vloermaat bepaald, zie fullTopLine), syncWallHeights doet de rest.
|
||||||
|
// Zonder hoogteverschil wordt het een plat dak. Muteert state.
|
||||||
|
function applyRoofForm(unitState, form) {
|
||||||
|
const low = Math.round(Math.min(LIMITS.eave, form.low));
|
||||||
|
const high = Math.round(Math.min(LIMITS.ridge, Math.max(low, form.high || low)));
|
||||||
|
const sloped = form.type !== 'plat' && high > low;
|
||||||
|
const side = sloped ? gableOf(form) : 'achter';
|
||||||
|
const g = gableSpan(unitState, side), F = g.span, half = F / 2;
|
||||||
|
let line = [{ u: 0, h: low }, { u: F, h: low }];
|
||||||
|
if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
||||||
|
else if (sloped && form.type === 'mansarde') {
|
||||||
|
// Knik binnen de halve gevel; altijd boven de rechte lijn goot–nok
|
||||||
|
// (anders geen mansarde maar een hol dak).
|
||||||
|
// (en op de kopgevel zelf, ook als die tussen de haakse wanden staat)
|
||||||
|
const kIn = Math.max(Math.max(g.e0, g.e1) + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18)));
|
||||||
|
const minK = low + (high - low) * kIn / half + 20;
|
||||||
|
const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH;
|
||||||
|
const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK)));
|
||||||
|
line = kH > minK - 1 && high - kH >= 20
|
||||||
|
? [{ u: 0, h: low }, { u: kIn, h: kH }, { u: half, h: high }, { u: F - kIn, h: kH }, { u: F, h: low }]
|
||||||
|
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
||||||
|
} else if (sloped && form.type === 'wolf') {
|
||||||
|
// Wolfseind: de kopgevel stopt op `cut`, waar de dakhelling die hoogte haalt.
|
||||||
|
// Op de kopgevel zelf (ook als die tussen de haakse wanden staat).
|
||||||
|
const minR = (Math.max(g.e0, g.e1) + 50) / half;
|
||||||
|
const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6;
|
||||||
|
const cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low))));
|
||||||
|
const uc = (cut - low) / (high - low) * half;
|
||||||
|
line = high - low > 100
|
||||||
|
? [{ u: 0, h: low }, { u: uc, h: cut }, { u: F - uc, h: cut }, { u: F, h: low }]
|
||||||
|
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
||||||
|
} else if (sloped) {
|
||||||
|
const highLeft = WALL_ENDS[side].left === form.dir;
|
||||||
|
line = [{ u: 0, h: highLeft ? high : low }, { u: F, h: highLeft ? low : high }];
|
||||||
|
}
|
||||||
|
setWallTopLine(unitState.walls[side].wall, g, line);
|
||||||
|
syncWallHeights(unitState, side);
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateState(design) {
|
function validateState(design) {
|
||||||
@@ -302,5 +476,5 @@ window.UnitModel = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { WALL_SIDES, CORNER_MODES, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, validateState };
|
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, validateState };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/*!
|
||||||
|
* Tectum
|
||||||
|
* Copyright © 2026 Machiel Heinen.
|
||||||
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
|
* Zie LICENTIE.md.
|
||||||
|
*/
|
||||||
|
// ============================================================
|
||||||
|
// wall/kozijn-model.js — Vulling van een opening (window.KozijnModel):
|
||||||
|
// kunststof kozijnen naar het voorbeeld van de Aluplast Ideal 7000
|
||||||
|
// (examples/Buiten kozijnen.pdf): kader 70 mm in het aanzicht, 120 mm
|
||||||
|
// diep, buitenzijde in een andere kleur dan de binnenzijde (folie).
|
||||||
|
//
|
||||||
|
// Een opening krijgt een (optionele) vulling, opgeslagen bij de opening:
|
||||||
|
// op.fill = { type: 'raam'|'deur', rooster, glas, out, in }
|
||||||
|
// raam vast glas in het kader; rooster = ventilatierooster bovenin
|
||||||
|
// deur deurblad (draait links) met dorpel; is de opening breder of
|
||||||
|
// hoger dan één deur, dan vult vast glas de rest (zijlicht,
|
||||||
|
// bovenlicht). glas = glasstrook in het deurblad
|
||||||
|
// out/in kleur buiten/binnen (COLORS)
|
||||||
|
// Zonder fill blijft de opening open (zoals voorheen). Het formaat volgt
|
||||||
|
// altijd de opening (ook een schuine bovenzijde).
|
||||||
|
//
|
||||||
|
// Plain script (geen ES-module), geen DOM. Gebruikt door WallModel.build3D.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.KozijnModel = (() => {
|
||||||
|
const COLORS = {
|
||||||
|
wit: { label: 'Wit', ral: 'RAL 9016', hex: 0xf1f0ea },
|
||||||
|
grijs: { label: 'Grijs', ral: 'RAL 7038', hex: 0xaeb0aa },
|
||||||
|
antraciet: { label: 'Antraciet', ral: 'RAL 7016', hex: 0x383e42 },
|
||||||
|
zwart: { label: 'Zwart', ral: 'RAL 9005', hex: 0x151515 }
|
||||||
|
};
|
||||||
|
const FILL_TYPES = { geen: 'Geen (open)', raam: 'Raam (vast glas)', deur: 'Deur' };
|
||||||
|
const DEFAULT_OUT = 'antraciet', DEFAULT_IN = 'wit';
|
||||||
|
|
||||||
|
// Maten (mm).
|
||||||
|
const P = {
|
||||||
|
gap: 5, // stelruimte rondom
|
||||||
|
kader: 70, // kader in het aanzicht
|
||||||
|
depth: 120, // kaderdiepte
|
||||||
|
dorpel: 30, // deurdorpel (hoogte)
|
||||||
|
tussen: 70, // tussenstijl/-regel (deur naast zijlicht, onder bovenlicht)
|
||||||
|
rooster: 80, // ventilatierooster (hoogte)
|
||||||
|
glass: 24, // glasdikte
|
||||||
|
leafW: 930, // deurblad (binnenmaat kader) als er een zijlicht naast past
|
||||||
|
leafH: 2200, // deurblad als er een bovenlicht boven past
|
||||||
|
leafT: 68, // deurbladdikte
|
||||||
|
minLight: 250 // kleinste zijlicht/bovenlicht
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorKey = (k, def) => COLORS[k] ? k : def;
|
||||||
|
// Volledige vulling (defaults ingevuld) of null (geen vulling).
|
||||||
|
function normalize(fill) {
|
||||||
|
if (!fill || !FILL_TYPES[fill.type] || fill.type === 'geen') return null;
|
||||||
|
return {
|
||||||
|
type: fill.type, rooster: !!fill.rooster, glas: fill.glas !== false,
|
||||||
|
out: colorKey(fill.out, DEFAULT_OUT), in: colorKey(fill.in, DEFAULT_IN)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Standaardvulling bij een openingtype (WallModel.OPENING_TYPES); kleuren
|
||||||
|
// van `like` (bijv. de vorige opening) overnemen.
|
||||||
|
function defaultFill(openingType, like) {
|
||||||
|
const l = normalize(like) || {};
|
||||||
|
const type = openingType === 'deur' || openingType === 'voordeur' ? 'deur' : 'raam';
|
||||||
|
return { type, rooster: false, glas: openingType === 'voordeur', out: l.out || DEFAULT_OUT, in: l.in || DEFAULT_IN };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 2D-veelhoeken (mm, tegen de klok in, x vanaf de linkerkant van de
|
||||||
|
// opening, y vanaf de onderkant).
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Convexe veelhoek naar binnen verschoven, per zijde (zijde i = punt i ->
|
||||||
|
// punt i+1) een eigen afstand d[i].
|
||||||
|
function insetPoly(pts, d) {
|
||||||
|
const n = pts.length;
|
||||||
|
const lines = pts.map((p, i) => {
|
||||||
|
const q = pts[(i + 1) % n];
|
||||||
|
const len = Math.hypot(q.x - p.x, q.y - p.y) || 1;
|
||||||
|
const dx = (q.x - p.x) / len, dy = (q.y - p.y) / len;
|
||||||
|
return { x: p.x - dy * d[i], y: p.y + dx * d[i], dx, dy };
|
||||||
|
});
|
||||||
|
return pts.map((p, i) => {
|
||||||
|
const a = lines[(i - 1 + n) % n], b = lines[i];
|
||||||
|
const den = a.dx * b.dy - a.dy * b.dx;
|
||||||
|
if (Math.abs(den) < 1e-9) return { x: b.x, y: b.y };
|
||||||
|
const t = ((b.x - a.x) * b.dy - (b.y - a.y) * b.dx) / den;
|
||||||
|
return { x: a.x + a.dx * t, y: a.y + a.dy * t };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Veelhoek afgekapt op een halfvlak (Sutherland-Hodgman): f(p) >= 0
|
||||||
|
// blijft, f is lineair.
|
||||||
|
function clipPoly(pts, f) {
|
||||||
|
const out = [];
|
||||||
|
pts.forEach((p, i) => {
|
||||||
|
const q = pts[(i + 1) % pts.length];
|
||||||
|
const fp = f(p), fq = f(q);
|
||||||
|
if (fp >= 0) out.push(p);
|
||||||
|
if ((fp >= 0) !== (fq >= 0)) {
|
||||||
|
const t = fp / (fp - fq);
|
||||||
|
out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const rect = (x0, x1, y0, y1) => [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }];
|
||||||
|
const areaOf = (pts) => Math.abs(pts.reduce((a, p, i) => { const q = pts[(i + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 3D
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
const mats = {};
|
||||||
|
function colorMat(key) {
|
||||||
|
const k = 'c:' + colorKey(key, DEFAULT_OUT);
|
||||||
|
return mats[k] || (mats[k] = new THREE.MeshStandardMaterial({ color: COLORS[colorKey(key, DEFAULT_OUT)].hex, roughness: 0.45 }));
|
||||||
|
}
|
||||||
|
function mat(key) {
|
||||||
|
if (mats[key]) return mats[key];
|
||||||
|
if (key === 'glass') mats[key] = new THREE.MeshStandardMaterial({ color: 0x9fbfcc, roughness: 0.05, metalness: 0.2, transparent: true, opacity: 0.28, depthWrite: false, side: THREE.DoubleSide });
|
||||||
|
else if (key === 'rubber') mats[key] = new THREE.MeshStandardMaterial({ color: 0x1b1b1b, roughness: 0.8 });
|
||||||
|
else mats[key] = new THREE.MeshStandardMaterial({ color: 0x9a9da0, roughness: 0.3, metalness: 0.7 }); // beslag
|
||||||
|
return mats[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toM = 0.001;
|
||||||
|
const toShape = (pts, holes) => {
|
||||||
|
const s = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.y * toM)));
|
||||||
|
(holes || []).forEach(h => s.holes.push(new THREE.Path(h.map(p => new THREE.Vector2(p.x * toM, p.y * toM)))));
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
// Plaat van z0 tot z1 (mm); buitenvlak (z1) in matOut als dat afwijkt
|
||||||
|
// (folie aan de buitenzijde, de rest in de binnenkleur).
|
||||||
|
function slab(g, pts, holes, z0, z1, matIn, matOut, shadow) {
|
||||||
|
if (pts.length < 3 || areaOf(pts) < 1) return;
|
||||||
|
const FOIL = matOut && matOut !== matIn ? 1 : 0;
|
||||||
|
const add = (a, b, m) => {
|
||||||
|
const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(toShape(pts, holes), { depth: (b - a) * toM, bevelEnabled: false }), m);
|
||||||
|
mesh.position.z = a * toM;
|
||||||
|
mesh.castShadow = shadow !== false;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
g.add(mesh);
|
||||||
|
};
|
||||||
|
add(z0, z1 - FOIL, matIn);
|
||||||
|
if (FOIL) add(z1 - FOIL, z1, matOut);
|
||||||
|
}
|
||||||
|
const box = (g, m, x, y, z, w, h, d) => {
|
||||||
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), m);
|
||||||
|
mesh.position.set(x * toM, y * toM, z * toM);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
g.add(mesh);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kozijn in een opening (opgelost: w, h links, hR rechts). Geeft een
|
||||||
|
// groep in mm-lokale coördinaten van de opening (x vanaf links, y vanaf
|
||||||
|
// de onderkant, +z = buiten) in meters, of null.
|
||||||
|
// place: { zOut (mm, buitenvlak kozijn), depth (mm), exterior (dorpel) }
|
||||||
|
function build3D(op, fill, place) {
|
||||||
|
const f = normalize(fill);
|
||||||
|
if (!f) return null;
|
||||||
|
const g = new THREE.Group();
|
||||||
|
const mIn = colorMat(f.in), mOut = colorMat(f.out);
|
||||||
|
const depth = Math.min(P.depth, place.depth || P.depth);
|
||||||
|
const z1 = place.zOut, z0 = z1 - depth;
|
||||||
|
const hR = op.hR !== undefined ? op.hR : op.h;
|
||||||
|
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, P.gap, P.gap, P.gap]);
|
||||||
|
// Bovenkant (schuin) van een veelhoek binnen het kader: >= 0 onder de
|
||||||
|
// lijn door TL-TR van `q` (lineair, voor clipPoly).
|
||||||
|
const below = (q) => (p) => {
|
||||||
|
const a = q[3], b = q[2];
|
||||||
|
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
|
||||||
|
};
|
||||||
|
const glass = (pts) => slab(g, pts, null, z1 - 50 - P.glass / 2, z1 - 50 + P.glass / 2, mat('glass'), null, false);
|
||||||
|
|
||||||
|
if (f.type === 'raam') {
|
||||||
|
const inner = insetPoly(outer, [P.kader, P.kader, P.kader, P.kader]);
|
||||||
|
slab(g, outer, [inner], z0, z1, mIn, mOut);
|
||||||
|
let pane = inner;
|
||||||
|
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) {
|
||||||
|
pane = insetPoly(inner, [0, 0, P.rooster, 0]);
|
||||||
|
const band = [pane[3], pane[2], inner[2], inner[3]];
|
||||||
|
slab(g, [band[0], band[1], band[2], band[3]], null, z1 - 105, z1 - 8, mIn, mOut);
|
||||||
|
// Lamellen: donkere sleuven op beide vlakken.
|
||||||
|
[0.3, 0.55, 0.8].forEach(t => {
|
||||||
|
const a = insetPoly(inner, [0, 20, P.rooster * t - 4, 20]), b = insetPoly(inner, [0, 20, P.rooster * t + 4, 20]);
|
||||||
|
const slot = [b[3], b[2], a[2], a[3]];
|
||||||
|
slab(g, slot, null, z1 - 8, z1 - 6, mat('rubber'));
|
||||||
|
slab(g, slot, null, z1 - 107, z1 - 105, mat('rubber'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
glass(pane);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deur: kader als U (zonder onderregel) op een dorpel (buitenwand) of
|
||||||
|
// tot de vloer (binnenwand).
|
||||||
|
const sill = place.exterior !== false ? P.dorpel : 0;
|
||||||
|
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
|
||||||
|
const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]);
|
||||||
|
const u = [frameOuter[0], inner[0], inner[3], inner[2], inner[1], frameOuter[1], frameOuter[2], frameOuter[3]];
|
||||||
|
slab(g, u, null, z0, z1, mIn, mOut);
|
||||||
|
if (sill) box(g, mat('rubber'), (outer[0].x + outer[1].x) / 2, outer[0].y + sill / 2, z1 - depth / 2 + 15, outer[1].x - outer[0].x, sill, depth + 30);
|
||||||
|
|
||||||
|
const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y;
|
||||||
|
const top = below(inner);
|
||||||
|
const topAt = (x) => inner[3].y + (x - inner[3].x) / (inner[2].x - inner[3].x) * (inner[2].y - inner[3].y);
|
||||||
|
// Zijlicht rechts als er naast een deurblad nog genoeg ruimte is.
|
||||||
|
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
|
||||||
|
const lx1 = side ? ix0 + P.leafW : ix1;
|
||||||
|
// Bovenlicht als er boven een deurblad nog genoeg ruimte is (overal).
|
||||||
|
const lowTop = Math.min(topAt(ix0), topAt(ix1));
|
||||||
|
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
|
||||||
|
const ly1 = transom ? iy0 + P.leafH : Infinity;
|
||||||
|
const region = (x0, x1, y0, y1) => clipPoly(rect(x0, x1, y0, Math.min(y1, 1e6)), top);
|
||||||
|
const bar = (pts) => slab(g, pts, null, z0 + 10, z1 - 10, mIn, mOut);
|
||||||
|
if (side) {
|
||||||
|
bar(region(lx1, lx1 + P.tussen, iy0, ly1));
|
||||||
|
// Zijlicht: onderregel + glas.
|
||||||
|
bar(region(lx1 + P.tussen, ix1, iy0, iy0 + P.kader));
|
||||||
|
glass(region(lx1 + P.tussen, ix1, iy0 + P.kader, ly1));
|
||||||
|
}
|
||||||
|
if (transom) {
|
||||||
|
bar(region(ix0, ix1, ly1, ly1 + P.tussen));
|
||||||
|
glass(region(ix0, ix1, ly1 + P.tussen, Infinity));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer).
|
||||||
|
const leaf = clipPoly(rect(ix0 + 3, lx1 - 3, iy0 + (sill ? 3 : 10), Math.min(ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 }));
|
||||||
|
if (leaf.length < 3) return g;
|
||||||
|
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
|
||||||
|
const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), ly1) - 3;
|
||||||
|
const lzOut = z1 - 15, lzIn = lzOut - P.leafT;
|
||||||
|
let holes = null;
|
||||||
|
const lw = lxR - lx0;
|
||||||
|
if (f.glas && lw >= 500 && lt - lb >= 1400) {
|
||||||
|
const gx = lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
|
||||||
|
const strip = rect(gx - gw / 2, gx + gw / 2, lb + 350, lt - 300);
|
||||||
|
holes = [strip];
|
||||||
|
slab(g, strip, null, (lzIn + lzOut) / 2 - P.glass / 2, (lzIn + lzOut) / 2 + P.glass / 2, mat('glass'), null, false);
|
||||||
|
}
|
||||||
|
slab(g, leaf, holes, lzIn, lzOut, mIn, mOut);
|
||||||
|
// Kruk aan beide zijden (scharnieren links, slot rechts).
|
||||||
|
if (lw >= 400) {
|
||||||
|
const hx = lxR - 75, hy = Math.min(lb + 1050, (lb + lt) / 2);
|
||||||
|
[[lzOut, 1], [lzIn, -1]].forEach(([z, s]) => {
|
||||||
|
box(g, mat('beslag'), hx, hy - 30, z + s * 5, 34, 210, 10); // schild
|
||||||
|
box(g, mat('beslag'), hx, hy, z + s * 30, 20, 20, 50); // nek
|
||||||
|
box(g, mat('beslag'), hx - 60, hy, z + s * 55, 140, 20, 20); // greep
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { COLORS, FILL_TYPES, PROFILE: P, normalize, defaultFill, build3D };
|
||||||
|
})();
|
||||||
+343
-109
@@ -14,8 +14,9 @@
|
|||||||
// voor zowel de losse wand als de unit gelden.
|
// voor zowel de losse wand als de unit gelden.
|
||||||
//
|
//
|
||||||
// Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export):
|
// Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export):
|
||||||
// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...},
|
// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, topPts?, studWidth, ...},
|
||||||
// materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset}] }
|
// materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset,fill?}] }
|
||||||
|
// fill = vulling van de opening (kozijn, zie wall/kozijn-model.js).
|
||||||
//
|
//
|
||||||
// Plain script (geen ES-module), moet na core/geometry.js geladen worden.
|
// Plain script (geen ES-module), moet na core/geometry.js geladen worden.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -51,7 +52,64 @@ window.WallModel = (() => {
|
|||||||
// vochtdoek; cement-gebonden platen zijn voor steenstrips.
|
// vochtdoek; cement-gebonden platen zijn voor steenstrips.
|
||||||
const BATTEN = { thick: 20, width: 30, length: 4000, spacing: 400 };
|
const BATTEN = { thick: 20, width: 30, length: 4000, spacing: 400 };
|
||||||
const CLADDING_DIRECTIONS = { horizontaal: 'Horizontaal', verticaal: 'Verticaal' };
|
const CLADDING_DIRECTIONS = { horizontaal: 'Horizontaal', verticaal: 'Verticaal' };
|
||||||
const CLADDING_THICK_MM = 12; // gevelbekleding (WEO/Novalit), nog niet getekend
|
const CLADDING_THICK_MM = 12; // gevelbekleding (WEO/Novalit)
|
||||||
|
|
||||||
|
// Rabat (gevelbekleding op de kruislat): planken van BOARD_MM werkende
|
||||||
|
// breedte in de richting van materials.cladding, in een kleur uit
|
||||||
|
// RABAT_COLORS (woning-instelling). In 3D één blok per wandstuk, van het
|
||||||
|
// doek tot de voorkant van de planken (latwerk + plank): zo zijn hoeken,
|
||||||
|
// dagkanten en onderrand dicht en vervallen doek en latten erachter. De
|
||||||
|
// planken zijn een textuur in gevel-coördinaten (lopen door over naden).
|
||||||
|
const RABAT = { BOARD_MM: 150, TILE_BOARDS: 8 };
|
||||||
|
const RABAT_COLORS = {
|
||||||
|
naturel: { label: 'Naturel', hex: '#a0805f' },
|
||||||
|
antraciet: { label: 'Antraciet', hex: '#3c4045' },
|
||||||
|
zwart: { label: 'Zwart', hex: '#1f2022' },
|
||||||
|
grijs: { label: 'Grijs', hex: '#8b8f93' },
|
||||||
|
wit: { label: 'Gebroken wit', hex: '#e6e3dc' },
|
||||||
|
groen: { label: 'Mosgroen', hex: '#55604c' }
|
||||||
|
};
|
||||||
|
const rabatHex = (key) => (RABAT_COLORS[key] || RABAT_COLORS.naturel).hex;
|
||||||
|
// Eén materiaal per kleur + richting (gedeeld). Textuur: TILE_BOARDS
|
||||||
|
// planken, elk met een lichte bovenkant en een schaduwlijn onderaan
|
||||||
|
// (overlap); kleine kleurverschillen per plank.
|
||||||
|
const rabatMaterials = {};
|
||||||
|
function rabatMaterial(colorKey, direction) {
|
||||||
|
const key = (RABAT_COLORS[colorKey] ? colorKey : 'naturel') + '|' + (direction === 'verticaal' ? 'v' : 'h');
|
||||||
|
if (rabatMaterials[key]) return rabatMaterials[key];
|
||||||
|
const hex = rabatHex(colorKey);
|
||||||
|
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: hex, roughness: 0.8 });
|
||||||
|
} else {
|
||||||
|
const B = 32, N = RABAT.TILE_BOARDS, vert = direction === 'verticaal';
|
||||||
|
canvas.width = vert ? B * N : 16;
|
||||||
|
canvas.height = vert ? 16 : B * N;
|
||||||
|
const base = new THREE.Color(hex);
|
||||||
|
const shade = (f) => '#' + base.clone().multiplyScalar(f).getHexString();
|
||||||
|
// rect(a, b) = band over de lengte van de plank, van a tot b (px dwars).
|
||||||
|
const rect = (a, b, color) => {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
if (vert) ctx.fillRect(a, 0, b - a, canvas.height); else ctx.fillRect(0, a, canvas.width, b - a);
|
||||||
|
};
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const a = i * B, f = 0.96 + ((i * 37) % 9) / 100; // 0,96 .. 1,04
|
||||||
|
rect(a, a + B, shade(f));
|
||||||
|
rect(a, a + 2, shade(f * 1.12)); // bovenkant (licht)
|
||||||
|
rect(a + B - 4, a + B, shade(f * 0.55)); // schaduw van de overlap
|
||||||
|
}
|
||||||
|
const tex = new THREE.CanvasTexture(canvas);
|
||||||
|
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||||
|
const tileM = RABAT.BOARD_MM * N / 1000;
|
||||||
|
if (vert) tex.repeat.set(1 / tileM, 1); else tex.repeat.set(1, 1 / tileM);
|
||||||
|
tex.anisotropy = 8;
|
||||||
|
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.8 });
|
||||||
|
}
|
||||||
|
rabatMaterials[key] = mat;
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
|
||||||
// Hoe ver de afgewerkte gevel buiten het wandvlak ligt (mm): bij vochtdoek
|
// Hoe ver de afgewerkte gevel buiten het wandvlak ligt (mm): bij vochtdoek
|
||||||
// de kruislat (2 lagen) + de gevelbekleding. Gebruikt o.a. voor de
|
// de kruislat (2 lagen) + de gevelbekleding. Gebruikt o.a. voor de
|
||||||
@@ -198,9 +256,10 @@ window.WallModel = (() => {
|
|||||||
const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat', geen: 'Geen (naadzijde)' };
|
const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat', geen: 'Geen (naadzijde)' };
|
||||||
|
|
||||||
// Bovenrand van de wand (daklijn): één recht stuk van links naar rechts,
|
// Bovenrand van de wand (daklijn): één recht stuk van links naar rechts,
|
||||||
// of met een middenpunt (nok, wallPeak) twee stukken die elkaar daar
|
// of via tussenpunten (CONFIG.wallTop: nok, of de knikken van een
|
||||||
// raken. De nok ligt nooit lager dan de goten (buildConfig), dus de
|
// mansarde-/wolfsdak) meerdere stukken. De daklijn is altijd bol
|
||||||
// bovenrand is overal het MINIMUM van de (doorgetrokken) stukken — dat
|
// (wallTopPoints), dus de bovenrand is overal het MINIMUM van de
|
||||||
|
// (doorgetrokken) stukken — dat
|
||||||
// houdt at/under/rangeAbove simpel en maakt "laagste punt boven een
|
// houdt at/under/rangeAbove simpel en maakt "laagste punt boven een
|
||||||
// opening" gelijk aan het laagste van de twee uiteinden.
|
// opening" gelijk aan het laagste van de twee uiteinden.
|
||||||
// x t.o.v. het wandmidden, alles in mm x k (k = 0.001: meters).
|
// x t.o.v. het wandmidden, alles in mm x k (k = 0.001: meters).
|
||||||
@@ -213,8 +272,8 @@ window.WallModel = (() => {
|
|||||||
k = k || 1;
|
k = k || 1;
|
||||||
const halfW = CONFIG.wallWidth / 2 * k;
|
const halfW = CONFIG.wallWidth / 2 * k;
|
||||||
const L = { x: -halfW, y: CONFIG.wallHeightLeft * k }, R = { x: halfW, y: CONFIG.wallHeightRight * k };
|
const L = { x: -halfW, y: CONFIG.wallHeightLeft * k }, R = { x: halfW, y: CONFIG.wallHeightRight * k };
|
||||||
const pk = CONFIG.wallPeak ? { x: (CONFIG.wallPeak.x - CONFIG.wallWidth / 2) * k, y: CONFIG.wallPeak.h * k } : null;
|
const inner = (CONFIG.wallTop || []).map(q => ({ x: (q.x - CONFIG.wallWidth / 2) * k, y: q.h * k }));
|
||||||
const pts = pk ? [L, pk, R] : [L, R];
|
const pts = [L, ...inner, R];
|
||||||
const segs = [];
|
const segs = [];
|
||||||
for (let i = 0; i < pts.length - 1; i++) {
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
const a = pts[i], b = pts[i + 1], slope = (b.y - a.y) / (b.x - a.x);
|
const a = pts[i], b = pts[i + 1], slope = (b.y - a.y) / (b.x - a.x);
|
||||||
@@ -223,15 +282,15 @@ window.WallModel = (() => {
|
|||||||
const lineY = (sg, x) => sg.a.y + (x - sg.a.x) * sg.slope;
|
const lineY = (sg, x) => sg.a.y + (x - sg.a.x) * sg.slope;
|
||||||
const at = (x) => Math.min(...segs.map(sg => lineY(sg, x)));
|
const at = (x) => Math.min(...segs.map(sg => lineY(sg, x)));
|
||||||
const under = (x) => Math.min(...segs.map(sg => lineY(sg, x) - sg.vo));
|
const under = (x) => Math.min(...segs.map(sg => lineY(sg, x) - sg.vo));
|
||||||
let knee = null;
|
// Knieën: snijpunt van de onderkanten van twee opeenvolgende bovenregels.
|
||||||
if (segs.length === 2) {
|
const knees = segs.slice(0, -1).map((s0, i) => {
|
||||||
const [s0, s1] = segs, ds = s0.slope - s1.slope;
|
const s1 = segs[i + 1], ds = s0.slope - s1.slope;
|
||||||
// s0.a.y + (x - s0.a.x) s0.slope - vo0 = s1.a.y + (x - s1.a.x) s1.slope - vo1
|
// s0.a.y + (x - s0.a.x) s0.slope - vo0 = s1.a.y + (x - s1.a.x) s1.slope - vo1
|
||||||
const x = Math.abs(ds) > 1e-12
|
const x = Math.abs(ds) > 1e-12
|
||||||
? ((s1.a.y - s1.a.x * s1.slope - s1.vo) - (s0.a.y - s0.a.x * s0.slope - s0.vo)) / ds
|
? ((s1.a.y - s1.a.x * s1.slope - s1.vo) - (s0.a.y - s0.a.x * s0.slope - s0.vo)) / ds
|
||||||
: pk.x;
|
: s0.b.x;
|
||||||
knee = { x, y: under(x) };
|
return { x, y: under(x) };
|
||||||
}
|
});
|
||||||
const rangeAbove = (y) => {
|
const rangeAbove = (y) => {
|
||||||
let x0 = -halfW, x1 = halfW;
|
let x0 = -halfW, x1 = halfW;
|
||||||
segs.forEach(sg => {
|
segs.forEach(sg => {
|
||||||
@@ -246,30 +305,54 @@ window.WallModel = (() => {
|
|||||||
.filter(c => c.x > c.sg.a.x && c.x < c.sg.b.x).map(c => c.x);
|
.filter(c => c.x > c.sg.a.x && c.x < c.sg.b.x).map(c => c.x);
|
||||||
const underPts = (xa, xb) => {
|
const underPts = (xa, xb) => {
|
||||||
const out = [{ x: xa, y: under(xa) }];
|
const out = [{ x: xa, y: under(xa) }];
|
||||||
if (knee && knee.x > xa && knee.x < xb) out.push({ x: knee.x, y: knee.y });
|
knees.filter(kn => kn.x > xa && kn.x < xb).forEach(kn => out.push({ x: kn.x, y: kn.y }));
|
||||||
out.push({ x: xb, y: under(xb) });
|
out.push({ x: xb, y: under(xb) });
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
// peak: hoogste tussenpunt (nok), voor maatvoering.
|
||||||
|
const peak = inner.length ? inner.reduce((m, q) => q.y > m.y ? q : m) : null;
|
||||||
return {
|
return {
|
||||||
pts, segs, peak: pk, knee, at, under, rangeAbove, crossings, underPts,
|
pts, segs, peak, knees, knee: knees[0] || null, at, under, rangeAbove, crossings, underPts,
|
||||||
maxY: Math.max(...pts.map(p => p.y)), minY: Math.min(L.y, R.y),
|
maxY: Math.max(...pts.map(p => p.y)), minY: Math.min(L.y, R.y),
|
||||||
kinks: pk ? [pk.x] : []
|
kinks: inner.map(q => q.x)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Omtrek(ken) van de bovenregel(s) (zelfde eenheid als topLine): één
|
// Omtrek(ken) van de bovenregel(s) (zelfde eenheid als topLine): één
|
||||||
// parallellogram, of met nok twee stukken in verstek (nok boven, knie onder).
|
// parallellogram, of per stuk van de daklijn een bovenregel; die sluiten
|
||||||
|
// in verstek op elkaar aan (knik boven, knie onder).
|
||||||
function topPlateOutlines(tl) {
|
function topPlateOutlines(tl) {
|
||||||
const [L, R] = [tl.pts[0], tl.pts[tl.pts.length - 1]];
|
const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n];
|
||||||
if (!tl.peak) {
|
return tl.segs.map((sg, i) => [
|
||||||
const vo = tl.segs[0].vo;
|
sg.a, sg.b,
|
||||||
return [[L, R, { x: R.x, y: R.y - vo }, { x: L.x, y: L.y - vo }]];
|
i < n - 1 ? tl.knees[i] : { x: R.x, y: R.y - sg.vo },
|
||||||
|
i > 0 ? tl.knees[i - 1] : { x: L.x, y: L.y - sg.vo }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tussenpunten van de daklijn (mm vanaf links) uit de wand-parameters:
|
||||||
|
// topPts [{ f (0..1 van de breedte), h }] (mansarde, wolfsdak), of het
|
||||||
|
// middenpunt heightMid op peakX (standaard midden). Alleen punten die
|
||||||
|
// de daklijn bol houden (boven de koorde tussen hun buren); de bovenrand
|
||||||
|
// is dan overal het minimum van de doorgetrokken stukken (topLine).
|
||||||
|
function wallTopPoints(params, wallWidth, hLeft, hRight) {
|
||||||
|
let raw = [];
|
||||||
|
if (Array.isArray(params.topPts) && params.topPts.length) {
|
||||||
|
raw = params.topPts.map(q => ({ x: q.f * wallWidth, h: Math.round(q.h) }));
|
||||||
|
} else if (params.heightMid > 0) {
|
||||||
|
const px = params.peakX !== undefined ? params.peakX : wallWidth / 2;
|
||||||
|
raw = [{ x: px, h: Math.round(Math.max(params.heightMid, hLeft, hRight)) }];
|
||||||
}
|
}
|
||||||
const P = tl.peak, K = tl.knee;
|
let pts = [{ x: 0, h: hLeft }, ...raw.filter(q => q.x > 1 && q.x < wallWidth - 1).sort((a, b) => a.x - b.x), { x: wallWidth, h: hRight }];
|
||||||
return [
|
for (let changed = true; changed;) {
|
||||||
[L, P, K, { x: L.x, y: L.y - tl.segs[0].vo }],
|
changed = false;
|
||||||
[P, R, { x: R.x, y: R.y - tl.segs[1].vo }, K]
|
for (let i = 1; i < pts.length - 1; i++) {
|
||||||
];
|
const a = pts[i - 1], b = pts[i + 1], q = pts[i];
|
||||||
|
const chord = a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h);
|
||||||
|
if (q.x - a.x < 1 || b.x - q.x < 1 || q.h <= chord + 0.5) { pts.splice(i, 1); changed = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pts.slice(1, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heuristiek (geen volwaardige constraint-solver): elke opening wordt eerst
|
// Heuristiek (geen volwaardige constraint-solver): elke opening wordt eerst
|
||||||
@@ -305,7 +388,7 @@ window.WallModel = (() => {
|
|||||||
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
|
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
|
||||||
else x = raw.offset;
|
else x = raw.offset;
|
||||||
|
|
||||||
return { id: raw.id, type: raw.type || 'raam', w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
|
return { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
|
||||||
});
|
});
|
||||||
|
|
||||||
resolved.sort((a, b) => a.x - b.x);
|
resolved.sort((a, b) => a.x - b.x);
|
||||||
@@ -400,24 +483,23 @@ window.WallModel = (() => {
|
|||||||
const hRight = Math.round(params.heightRight || 2900);
|
const hRight = Math.round(params.heightRight || 2900);
|
||||||
const studWidth = Math.round(params.studWidth || STANDARD_BEAM.width);
|
const studWidth = Math.round(params.studWidth || STANDARD_BEAM.width);
|
||||||
const plateThick = Math.round(params.plateThick || STANDARD_BEAM.width);
|
const plateThick = Math.round(params.plateThick || STANDARD_BEAM.width);
|
||||||
// Middenpunt (nok): standaard midden op de wand, of op peakX (mm vanaf
|
// Tussenpunten van de daklijn (nok; knikken bij mansarde/wolfsdak),
|
||||||
// links; een wandstuk uit een unit waar de nok niet in het midden
|
// zie wallTopPoints. Een wandstuk uit een unit krijgt ze op zijn eigen
|
||||||
// valt). Nooit lager dan de hoogste goot; gelijk = geen nok.
|
// positie (peakX / topPts).
|
||||||
let wallPeak = null;
|
const wallTop = wallTopPoints(params, wallWidth, hLeft, hRight);
|
||||||
if (params.heightMid > 0) {
|
const tp = [{ x: 0, h: hLeft }, ...wallTop, { x: wallWidth, h: hRight }];
|
||||||
const px = params.peakX !== undefined ? params.peakX : wallWidth / 2;
|
const roofAt = (x) => {
|
||||||
const h = Math.round(Math.max(params.heightMid, hLeft, hRight));
|
let i = 0;
|
||||||
if (px > 1 && px < wallWidth - 1 && h > hLeft + (px / wallWidth) * (hRight - hLeft) + 0.5) wallPeak = { x: px, h };
|
while (i < tp.length - 2 && x > tp[i + 1].x) i++;
|
||||||
}
|
const a = tp[i], b = tp[i + 1];
|
||||||
const roofAt = (x) => wallPeak
|
return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h);
|
||||||
? (x <= wallPeak.x ? hLeft + x / wallPeak.x * (wallPeak.h - hLeft) : wallPeak.h + (x - wallPeak.x) / (wallWidth - wallPeak.x) * (hRight - wallPeak.h))
|
};
|
||||||
: hLeft + (x / wallWidth) * (hRight - hLeft);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wallWidth,
|
wallWidth,
|
||||||
wallHeightLeft: hLeft,
|
wallHeightLeft: hLeft,
|
||||||
wallHeightRight: hRight,
|
wallHeightRight: hRight,
|
||||||
wallPeak,
|
wallTop,
|
||||||
studWidth,
|
studWidth,
|
||||||
studDepth: Math.round(params.studDepth || STANDARD_BEAM.depth),
|
studDepth: Math.round(params.studDepth || STANDARD_BEAM.depth),
|
||||||
plateThick,
|
plateThick,
|
||||||
@@ -447,7 +529,7 @@ window.WallModel = (() => {
|
|||||||
const top = topLine(CONFIG);
|
const top = topLine(CONFIG);
|
||||||
const slope = top.segs[0].slope;
|
const slope = top.segs[0].slope;
|
||||||
// Met nok: de steilste helling (beide kanten lopen dan op naar de nok).
|
// Met nok: de steilste helling (beide kanten lopen dan op naar de nok).
|
||||||
const angleDeg = top.peak
|
const angleDeg = top.segs.length > 1
|
||||||
? Math.round(Math.max(...top.segs.map(sg => Math.atan(Math.abs(sg.slope)))) * (180 / Math.PI))
|
? Math.round(Math.max(...top.segs.map(sg => Math.atan(Math.abs(sg.slope)))) * (180 / Math.PI))
|
||||||
: Math.round(Math.atan(slope) * (180 / Math.PI));
|
: Math.round(Math.atan(slope) * (180 / Math.PI));
|
||||||
|
|
||||||
@@ -623,9 +705,12 @@ window.WallModel = (() => {
|
|||||||
// facade (optioneel, woning): { u0, extStart, isEnd } — plaats van dit
|
// facade (optioneel, woning): { u0, extStart, isEnd } — plaats van dit
|
||||||
// wandstuk in de doorlopende gevel (zie HouseModel.assignFacades): u0 =
|
// wandstuk in de doorlopende gevel (zie HouseModel.assignFacades): u0 =
|
||||||
// afstand (mm, wandvlak) van het gevelbegin (incl. hoekdoorloop) tot de
|
// afstand (mm, wandvlak) van het gevelbegin (incl. hoekdoorloop) tot de
|
||||||
// linkerrand van dit stuk. Het verticale latten-raster volgt dan de
|
// linkerrand van dit stuk (gebruikt door de horizontale latten via gridY0).
|
||||||
// hele gevel i.p.v. per stuk opnieuw te beginnen; een eindlat komt
|
// Verticale latten: per wandstuk (de modules worden los vervoerd, de
|
||||||
// alleen aan het einde van de gevel.
|
// bekleding eindigt op de rand van het stuk) een lat op beide randen en
|
||||||
|
// langs beide zijden van elke opening (de bekleding moet daar op een lat
|
||||||
|
// eindigen); daartussen gelijk verdeeld, h.o.h. max. BATTEN.spacing
|
||||||
|
// (krimpend als het vak kleiner is, minimaal de twee randlatten).
|
||||||
// facade.gridY0 (optioneel, mm, wand-coördinaten): hoogte waarop het
|
// facade.gridY0 (optioneel, mm, wand-coördinaten): hoogte waarop het
|
||||||
// horizontale latten-raster van de hele gevel begint. Een wandstuk dat
|
// horizontale latten-raster van de hele gevel begint. Een wandstuk dat
|
||||||
// hoger begint (opbouw, blootliggende naadwand) volgt zo hetzelfde raster.
|
// hoger begint (opbouw, blootliggende naadwand) volgt zo hetzelfde raster.
|
||||||
@@ -637,8 +722,6 @@ window.WallModel = (() => {
|
|||||||
const y0 = Math.max(skinStartY(trasraam), skinFrom || 0); // boven het trasraam
|
const y0 = Math.max(skinStartY(trasraam), skinFrom || 0); // boven het trasraam
|
||||||
const ext = cornerExt(skinExt, 2 * BATTEN.thick); // over de hoekstijlen van de doorlopende wand
|
const ext = cornerExt(skinExt, 2 * BATTEN.thick); // over de hoekstijlen van de doorlopende wand
|
||||||
const xL = -CONFIG.wallWidth / 2 - ext.left, xR = CONFIG.wallWidth / 2 + ext.right;
|
const xL = -CONFIG.wallWidth / 2 - ext.left, xR = CONFIG.wallWidth / 2 + ext.right;
|
||||||
const gridX0 = facade ? -CONFIG.wallWidth / 2 - facade.u0 - (facade.extStart ? 2 * BATTEN.thick : 0) : xL;
|
|
||||||
const isEnd = facade ? facade.isEnd : true;
|
|
||||||
const halfW = CONFIG.wallWidth / 2;
|
const halfW = CONFIG.wallWidth / 2;
|
||||||
const tl = topLine(CONFIG), topYAt = tl.at;
|
const tl = topLine(CONFIG), topYAt = tl.at;
|
||||||
const holes = sheathingHoles(CONFIG).concat(extraHoles || []);
|
const holes = sheathingHoles(CONFIG).concat(extraHoles || []);
|
||||||
@@ -653,17 +736,35 @@ window.WallModel = (() => {
|
|||||||
yStart = g + Math.max(0, Math.ceil((yStart - g) / sp - 1e-9)) * sp;
|
yStart = g + Math.max(0, Math.ceil((yStart - g) / sp - 1e-9)) * sp;
|
||||||
}
|
}
|
||||||
for (let y = yStart; y < tl.maxY - w / 2; y += sp) {
|
for (let y = yStart; y < tl.maxY - w / 2; y += sp) {
|
||||||
|
// Schuine bovenrand: afkappen op de bovenkant van de lat (y + w/2),
|
||||||
|
// anders steekt het uiteinde tot w/2 boven de daklijn (door het dak).
|
||||||
|
const cap = tl.rangeAbove(y + w / 2);
|
||||||
|
if (!cap) continue;
|
||||||
skinSegmentsAtY(CONFIG, y, extraHoles).forEach(([x0, x1]) => {
|
skinSegmentsAtY(CONFIG, y, extraHoles).forEach(([x0, x1]) => {
|
||||||
|
x0 = Math.max(x0, cap[0]); x1 = Math.min(x1, cap[1]);
|
||||||
if (x0 <= -halfW + 0.5) x0 = xL;
|
if (x0 <= -halfW + 0.5) x0 = xL;
|
||||||
if (x1 >= halfW - 0.5) x1 = xR;
|
if (x1 >= halfW - 0.5) x1 = xR;
|
||||||
if (x1 - x0 > w) horizontaal.push({ x0, x1, y });
|
if (x1 - x0 > w) horizontaal.push({ x0, x1, y });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Verticale latten: vanaf de linkerrand elke 400 mm, plus een lat op de rechterrand.
|
// Verticale latten: vaste posities (randen, zijden van openingen),
|
||||||
|
// daartussen gelijk verdeeld met h.o.h. <= sp.
|
||||||
|
const lo = xL + w / 2, hi = xR - w / 2;
|
||||||
|
const fixed = [lo, hi];
|
||||||
|
holes.forEach(h => {
|
||||||
|
if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return; // valt buiten de bekleding
|
||||||
|
[h.hx0 - w / 2, h.hx1 + w / 2].forEach(x => { if (x > lo && x < hi) fixed.push(x); });
|
||||||
|
});
|
||||||
|
fixed.sort((a, b) => a - b);
|
||||||
|
const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w); // niet dubbel op elkaar
|
||||||
const xs = [];
|
const xs = [];
|
||||||
// Raster vanaf het gevelbegin; een lat hoort bij het stuk waar zijn hart in valt.
|
anchors.forEach((x, i) => {
|
||||||
for (let x = gridX0 + w / 2 + Math.max(0, Math.ceil((xL - gridX0 - w / 2) / sp - 1e-9)) * sp; x <= xR - (isEnd ? w / 2 - 0.5 : 0) && x < xR; x += sp) xs.push(x);
|
xs.push(x);
|
||||||
if (isEnd && (!xs.length || xR - w / 2 - xs[xs.length - 1] > 50)) xs.push(xR - w / 2);
|
const next = anchors[i + 1];
|
||||||
|
if (next === undefined) return;
|
||||||
|
const n = Math.ceil((next - x) / sp - 1e-9);
|
||||||
|
for (let k = 1; k < n; k++) xs.push(x + (next - x) * k / n);
|
||||||
|
});
|
||||||
const verticaal = [];
|
const verticaal = [];
|
||||||
xs.forEach(x => {
|
xs.forEach(x => {
|
||||||
const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2));
|
const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2));
|
||||||
@@ -757,14 +858,19 @@ window.WallModel = (() => {
|
|||||||
// Bouwt de volledige 3D-wand (in meters, lokaal assenstelsel: X = breedte
|
// Bouwt de volledige 3D-wand (in meters, lokaal assenstelsel: X = breedte
|
||||||
// gecentreerd, Y = hoogte vanaf onderkant onderregel, +Z = buitenzijde/zuid)
|
// gecentreerd, Y = hoogte vanaf onderkant onderregel, +Z = buitenzijde/zuid)
|
||||||
// en geeft daarnaast de volume-boekhouding terug voor de specificaties.
|
// en geeft daarnaast de volume-boekhouding terug voor de specificaties.
|
||||||
// opts: { materials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible }
|
// opts: { materials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible, stage, rabatColor }
|
||||||
// trasraam: { bottom, top } in mm (wand-coördinaten, zie trasraamFor) of null.
|
// trasraam: { bottom, top } in mm (wand-coördinaten, zie trasraamFor) of null.
|
||||||
// skinExt: { left, right } in mm (zie cornerExt) of null.
|
// skinExt: { left, right } in mm (zie cornerExt) of null.
|
||||||
// facade: { u0, extStart, isEnd } (zie computeBattens) of null.
|
// facade: { u0, extStart, isEnd } (zie computeBattens) of null.
|
||||||
|
// stage: bouwfase (BuildStages, standaard afgewerkt). Lagen van een latere
|
||||||
|
// fase en lagen die in deze fase volledig ingesloten zijn worden niet
|
||||||
|
// gebouwd; de stats rekenen altijd met alle lagen.
|
||||||
function build3D(CONFIG, calcData, opts) {
|
function build3D(CONFIG, calcData, opts) {
|
||||||
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
|
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
|
||||||
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
|
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
|
||||||
const spaanplaatVisible = opts.spaanplaatVisible !== false;
|
const stage = BuildStages.clamp(opts.stage);
|
||||||
|
const show = (key) => BuildStages.shows(stage, key);
|
||||||
|
const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating');
|
||||||
const buitenVisible = opts.buitenVisible !== false;
|
const buitenVisible = opts.buitenVisible !== false;
|
||||||
const cladding = opts.cladding || 'horizontaal';
|
const cladding = opts.cladding || 'horizontaal';
|
||||||
// Trasraam: bij rabat (vochtdoek) en bij een steengevel (cementplaat + stone).
|
// Trasraam: bij rabat (vochtdoek) en bij een steengevel (cementplaat + stone).
|
||||||
@@ -778,6 +884,19 @@ window.WallModel = (() => {
|
|||||||
// spaanplaatBelow: onder skinFrom aan de buitenzijde spaanplaat (naadwand
|
// spaanplaatBelow: onder skinFrom aan de buitenzijde spaanplaat (naadwand
|
||||||
// geheel in één unit: onder het dak van de andere unit is het een binnenwand).
|
// geheel in één unit: onder het dak van de andere unit is het een binnenwand).
|
||||||
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
|
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
|
||||||
|
// Zichtbare buitenlagen in deze fase. Bij rabat (vochtdoek-gevel) dekt
|
||||||
|
// het rabatblok doek en latten volledig af (zie RABAT).
|
||||||
|
const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel');
|
||||||
|
const doekOn = buitenVisible && show('doek') && !rabatOn;
|
||||||
|
const battensOn = buitenVisible && show('latwerk') && !rabatOn;
|
||||||
|
// Regelwerk ingesloten: binnen spaanplaat, buiten een plaat of het doek
|
||||||
|
// (naadzijde 'geen': het deel van de andere unit staat ertegen). Dan
|
||||||
|
// alleen de stukken die in de dagkant van een opening of aan de rand
|
||||||
|
// te zien zijn (hoek-, koningsstijlen, regels, dorpels).
|
||||||
|
const outerCovered = buitenMateriaal === 'geen' ||
|
||||||
|
(buitenVisible && show(buitenMateriaal === 'vochtdoek' ? 'doek' : 'beplating'));
|
||||||
|
const frameEnclosed = opts.cull !== false && spaanplaatVisible && outerCovered;
|
||||||
|
const HIDDEN_STUDS = { regel: true, kreupel: true, dubbel: true };
|
||||||
// Watermerk-UV's (doek) in gevel-coördinaten, zodat het patroon over
|
// Watermerk-UV's (doek) in gevel-coördinaten, zodat het patroon over
|
||||||
// naden/wandstukken doorloopt (UV = vormcoördinaat in m + verschuiving).
|
// naden/wandstukken doorloopt (UV = vormcoördinaat in m + verschuiving).
|
||||||
const uvShiftM = facade ? (CONFIG.wallWidth / 2 + facade.u0) * 0.001 : 0;
|
const uvShiftM = facade ? (CONFIG.wallWidth / 2 + facade.u0) * 0.001 : 0;
|
||||||
@@ -805,7 +924,8 @@ window.WallModel = (() => {
|
|||||||
let doekAreaM2 = 0; // enkelzijdig, alleen als buitenMateriaal='vochtdoek'
|
let doekAreaM2 = 0; // enkelzijdig, alleen als buitenMateriaal='vochtdoek'
|
||||||
let spaanplaatBuitenAreaM2 = 0; // alleen als buitenMateriaal='spaanplaat' (binnenwanden)
|
let spaanplaatBuitenAreaM2 = 0; // alleen als buitenMateriaal='spaanplaat' (binnenwanden)
|
||||||
let spaanplaatBuitenVolumeM3 = 0;
|
let spaanplaatBuitenVolumeM3 = 0;
|
||||||
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0; // kruislat (alleen vochtdoek)
|
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0; // kruislat (alleen vochtdoek); count = inkoop, pieces = gezaagde stukken
|
||||||
|
let rabatAreaM2 = 0; // gevelbekleding rabat (gevelvlak, alleen vochtdoek)
|
||||||
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0; // cement-gebonden plaat onderaan (alleen vochtdoek)
|
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0; // cement-gebonden plaat onderaan (alleen vochtdoek)
|
||||||
let stoneAreaM2 = 0, stoneIsolatieM3 = 0; // steengevel (cementplaat + stone): strip + isolatieblok
|
let stoneAreaM2 = 0, stoneIsolatieM3 = 0; // steengevel (cementplaat + stone): strip + isolatieblok
|
||||||
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0; // stenen trasraam: steenstrip (gevelvlak) + isolatieblok
|
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0; // stenen trasraam: steenstrip (gevelvlak) + isolatieblok
|
||||||
@@ -845,9 +965,16 @@ window.WallModel = (() => {
|
|||||||
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
|
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
|
||||||
const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, {
|
const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, {
|
||||||
wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove,
|
wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove,
|
||||||
wallPeak: CONFIG.wallPeak ? { x: CONFIG.wallPeak.x, h: CONFIG.wallPeak.h + skinAbove } : null
|
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + skinAbove }))
|
||||||
}) : CONFIG;
|
}) : CONFIG;
|
||||||
const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl;
|
const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl;
|
||||||
|
// Latwerk stopt onder het boeideel/de daktrim (opts.battenAbove, mm
|
||||||
|
// t.o.v. de wandbovenkant, kan negatief zijn); zonder rand tot skinAbove.
|
||||||
|
const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove;
|
||||||
|
const CONFIG_BAT = battenAbove ? Object.assign({}, CONFIG, {
|
||||||
|
wallHeightLeft: CONFIG.wallHeightLeft + battenAbove, wallHeightRight: CONFIG.wallHeightRight + battenAbove,
|
||||||
|
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + battenAbove }))
|
||||||
|
}) : CONFIG;
|
||||||
// Onderkant bovenregel van xLeft naar xRight (incl. verstek bij de nok).
|
// Onderkant bovenregel van xLeft naar xRight (incl. verstek bij de nok).
|
||||||
const underPts = tl.underPts;
|
const underPts = tl.underPts;
|
||||||
|
|
||||||
@@ -904,9 +1031,32 @@ window.WallModel = (() => {
|
|||||||
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
|
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
|
||||||
horizBeamCount++;
|
horizBeamCount++;
|
||||||
});
|
});
|
||||||
|
// Schuine regel op de bovenregel (CONFIG.topWedge): vult de wig tot
|
||||||
|
// de onderkant van het dak, over de diepte van het regelwerk.
|
||||||
|
if (CONFIG.topWedge) {
|
||||||
|
const wg = CONFIG.topWedge.pts, zO = sDepthM / 2, zI = -sDepthM / 2, pos = [], tlMM = topLine(CONFIG);
|
||||||
|
const P = (q, z, h) => [q.x * toM, (tlMM.at(q.x) + h) * toM, z];
|
||||||
|
const quad = (a, b, c, d) => pos.push(...a, ...b, ...c, ...a, ...c, ...d);
|
||||||
|
for (let k = 0; k < wg.length - 1; k++) {
|
||||||
|
const a = wg[k], b = wg[k + 1];
|
||||||
|
quad(P(a, zO, a.o), P(b, zO, b.o), P(b, zI, b.i), P(a, zI, a.i)); // boven (dakkant)
|
||||||
|
quad(P(a, zO, 0), P(b, zO, 0), P(b, zO, b.o), P(a, zO, a.o)); // buiten
|
||||||
|
quad(P(a, zI, a.i), P(b, zI, b.i), P(b, zI, 0), P(a, zI, 0)); // binnen
|
||||||
|
woodVolumeM3 += ((a.o + a.i) / 2 + (b.o + b.i) / 2) / 2 * (b.x - a.x) * CONFIG.studDepth * 1e-9;
|
||||||
|
}
|
||||||
|
[wg[0], wg[wg.length - 1]].forEach(q => quad(P(q, zO, 0), P(q, zO, q.o), P(q, zI, q.i), P(q, zI, 0))); // kopse kanten
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
const wedge = new THREE.Mesh(geo, woodMaterial.side === THREE.DoubleSide ? woodMaterial : wedgeMaterial(woodMaterial));
|
||||||
|
wedge.castShadow = true;
|
||||||
|
wallGroup3D.add(wedge);
|
||||||
|
}
|
||||||
|
|
||||||
// C. VERTICALE STIJLEN (vol, koning, dubbel bij deuren, kreupel): elk
|
// C. VERTICALE STIJLEN (vol, koning, dubbel bij deuren, kreupel): elk
|
||||||
// stuk van y0 tot y1, of tot onder de daklijn (y1 null), zie computeFrame.
|
// stuk van y0 tot y1, of tot onder de daklijn (y1 null), zie computeFrame.
|
||||||
|
// Ingesloten stijlen (frameEnclosed) tellen mee in het volume, maar
|
||||||
|
// worden niet gebouwd.
|
||||||
calcData.studs.forEach(st => {
|
calcData.studs.forEach(st => {
|
||||||
const xM = st.x * toM;
|
const xM = st.x * toM;
|
||||||
const xLeft = xM - (sWidthM / 2);
|
const xLeft = xM - (sWidthM / 2);
|
||||||
@@ -916,6 +1066,7 @@ window.WallModel = (() => {
|
|||||||
const pts = st.y1 === null
|
const pts = st.y1 === null
|
||||||
? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...underPts(xLeft, xRight).reverse()]
|
? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...underPts(xLeft, xRight).reverse()]
|
||||||
: [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }];
|
: [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }];
|
||||||
|
if (frameEnclosed && HIDDEN_STUDS[st.kind]) { woodVolumeM3 += polygonArea(pts) * sDepthM; return; }
|
||||||
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2));
|
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1063,7 +1214,7 @@ window.WallModel = (() => {
|
|||||||
if (b - a <= edgeEps) return [];
|
if (b - a <= edgeEps) return [];
|
||||||
const capped = yCap === Infinity;
|
const capped = yCap === Infinity;
|
||||||
const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects,
|
const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects,
|
||||||
capped ? { extraXs: tlOut.kinks } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap)) });
|
capped ? { extraXs: tlOut.kinks.concat([-halfW, halfW]) } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap), [-halfW, halfW]) });
|
||||||
return pieces.map(piece => {
|
return pieces.map(piece => {
|
||||||
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
|
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
|
||||||
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y)))));
|
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y)))));
|
||||||
@@ -1071,10 +1222,9 @@ window.WallModel = (() => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
const add = (shapes, depth, z, mat) => {
|
const add = (shapes, depth, z, mat) => {
|
||||||
if (!shapes.length) return;
|
if (!shapes.length || !buitenVisible || !show('gevel')) return;
|
||||||
const mesh = new THREE.Mesh(setUV(new THREE.ExtrudeGeometry(shapes, { steps: 1, depth, bevelEnabled: false })), mat);
|
const mesh = new THREE.Mesh(setUV(new THREE.ExtrudeGeometry(shapes, { steps: 1, depth, bevelEnabled: false })), mat);
|
||||||
mesh.position.set(0, 0, z);
|
mesh.position.set(0, 0, z);
|
||||||
mesh.visible = buitenVisible;
|
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
wallGroup3D.add(mesh);
|
wallGroup3D.add(mesh);
|
||||||
};
|
};
|
||||||
@@ -1136,22 +1286,19 @@ window.WallModel = (() => {
|
|||||||
return shape;
|
return shape;
|
||||||
});
|
});
|
||||||
if (!boardShapes.length) continue;
|
if (!boardShapes.length) continue;
|
||||||
|
|
||||||
const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
|
|
||||||
const boardGeo = new THREE.ExtrudeGeometry(boardShapes, boardExtrudeSettings);
|
|
||||||
const boardAreaM2 = boardShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
const boardAreaM2 = boardShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
||||||
|
|
||||||
const board = new THREE.Mesh(boardGeo, material);
|
// Niet zichtbaar (fase/weergave): alleen meten, niet bouwen.
|
||||||
board.position.set(0, 0, z);
|
if (visible) {
|
||||||
board.visible = visible;
|
const boardGeo = new THREE.ExtrudeGeometry(boardShapes, { steps: 1, depth: sThickM, bevelEnabled: false });
|
||||||
wallGroup3D.add(board);
|
const board = new THREE.Mesh(boardGeo, material);
|
||||||
|
board.position.set(0, 0, z);
|
||||||
if (!seamless) {
|
wallGroup3D.add(board);
|
||||||
const edgesGeo = new THREE.EdgesGeometry(boardGeo);
|
if (!seamless) {
|
||||||
const edgeLines = new THREE.LineSegments(edgesGeo, edgeMaterial);
|
const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(boardGeo), edgeMaterial);
|
||||||
edgeLines.position.set(0, 0, z);
|
edgeLines.position.set(0, 0, z);
|
||||||
edgeLines.visible = visible;
|
wallGroup3D.add(edgeLines);
|
||||||
wallGroup3D.add(edgeLines);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
areaM2 += boardAreaM2;
|
areaM2 += boardAreaM2;
|
||||||
@@ -1163,15 +1310,15 @@ window.WallModel = (() => {
|
|||||||
const shape = new THREE.Shape([
|
const shape = new THREE.Shape([
|
||||||
new THREE.Vector2(a, yB), new THREE.Vector2(b, yB), new THREE.Vector2(b, skinTopAt(b)), new THREE.Vector2(a, skinTopAt(a))
|
new THREE.Vector2(a, yB), new THREE.Vector2(b, yB), new THREE.Vector2(b, skinTopAt(b)), new THREE.Vector2(a, skinTopAt(a))
|
||||||
]);
|
]);
|
||||||
const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: sThickM, bevelEnabled: false });
|
if (visible) {
|
||||||
const mesh = new THREE.Mesh(geo, material);
|
const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: sThickM, bevelEnabled: false });
|
||||||
mesh.position.set(0, 0, z + ext.zOut);
|
const mesh = new THREE.Mesh(geo, material);
|
||||||
mesh.visible = visible;
|
mesh.position.set(0, 0, z + ext.zOut);
|
||||||
wallGroup3D.add(mesh);
|
wallGroup3D.add(mesh);
|
||||||
const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMaterial);
|
const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMaterial);
|
||||||
edgeLines.position.set(0, 0, z + ext.zOut);
|
edgeLines.position.set(0, 0, z + ext.zOut);
|
||||||
edgeLines.visible = visible;
|
wallGroup3D.add(edgeLines);
|
||||||
wallGroup3D.add(edgeLines);
|
}
|
||||||
const a2 = shapeAreaWithHoles(shape);
|
const a2 = shapeAreaWithHoles(shape);
|
||||||
areaM2 += a2;
|
areaM2 += a2;
|
||||||
volumeM3 += a2 * sThickM;
|
volumeM3 += a2 * sThickM;
|
||||||
@@ -1191,8 +1338,7 @@ window.WallModel = (() => {
|
|||||||
const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial);
|
const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial);
|
||||||
cap.rotation.y = dir * Math.PI / 2;
|
cap.rotation.y = dir * Math.PI / 2;
|
||||||
cap.position.set(x + dir * 0.0003, h / 2, 0);
|
cap.position.set(x + dir * 0.0003, h / 2, 0);
|
||||||
cap.visible = spaanplaatVisible;
|
if (spaanplaatVisible) wallGroup3D.add(cap);
|
||||||
wallGroup3D.add(cap);
|
|
||||||
});
|
});
|
||||||
spaanplaatAreaM2 = spaanplaatResult.areaM2;
|
spaanplaatAreaM2 = spaanplaatResult.areaM2;
|
||||||
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
|
spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
|
||||||
@@ -1200,19 +1346,19 @@ window.WallModel = (() => {
|
|||||||
// Buitenzijde (zuid): cement-gebonden platen (zelfde plaatlogica als
|
// Buitenzijde (zuid): cement-gebonden platen (zelfde plaatlogica als
|
||||||
// hierboven) OF vochtdoek (horizontale banen, zie onder) — nooit beide.
|
// hierboven) OF vochtdoek (horizontale banen, zie onder) — nooit beide.
|
||||||
if (spaanplaatBelow) {
|
if (spaanplaatBelow) {
|
||||||
const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible, { y0: 0, y1: skinFrom * 0.001 });
|
const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating'), { y0: 0, y1: skinFrom * 0.001 });
|
||||||
spaanplaatBuitenAreaM2 = spaanOnder.areaM2;
|
spaanplaatBuitenAreaM2 = spaanOnder.areaM2;
|
||||||
spaanplaatBuitenVolumeM3 = spaanOnder.volumeM3;
|
spaanplaatBuitenVolumeM3 = spaanOnder.volumeM3;
|
||||||
}
|
}
|
||||||
if (buitenMateriaal === 'spaanplaat') {
|
if (buitenMateriaal === 'spaanplaat') {
|
||||||
// Binnenwand: aan beide zijden spaanplaat.
|
// Binnenwand: aan beide zijden spaanplaat.
|
||||||
const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible);
|
const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating'));
|
||||||
spaanplaatBuitenAreaM2 = spaanBuiten.areaM2;
|
spaanplaatBuitenAreaM2 = spaanBuiten.areaM2;
|
||||||
spaanplaatBuitenVolumeM3 = spaanBuiten.volumeM3;
|
spaanplaatBuitenVolumeM3 = spaanBuiten.volumeM3;
|
||||||
} else if (buitenMateriaal === 'geen') {
|
} else if (buitenMateriaal === 'geen') {
|
||||||
// Naadzijde: geen beplating (het deel van de andere unit staat ertegen).
|
// Naadzijde: geen beplating (het deel van de andere unit staat ertegen).
|
||||||
} else if (buitenMateriaal === 'cementplaat') {
|
} else if (buitenMateriaal === 'cementplaat') {
|
||||||
const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, null,
|
const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('beplating'), null,
|
||||||
Object.assign(extM(0), { zOut: sThickM + 2 * skinGapM }));
|
Object.assign(extM(0), { zOut: sThickM + 2 * skinGapM }));
|
||||||
cementplaatAreaM2 = cementResult.areaM2;
|
cementplaatAreaM2 = cementResult.areaM2;
|
||||||
cementplaatVolumeM3 = cementResult.volumeM3;
|
cementplaatVolumeM3 = cementResult.volumeM3;
|
||||||
@@ -1227,7 +1373,7 @@ window.WallModel = (() => {
|
|||||||
trasraamIsolatieM3 = r.isoM3;
|
trasraamIsolatieM3 = r.isoM3;
|
||||||
y0 = trasraam.top * toM;
|
y0 = trasraam.top * toM;
|
||||||
} else if (trasraam) {
|
} else if (trasraam) {
|
||||||
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, {
|
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
|
||||||
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + skinGapM
|
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + skinGapM
|
||||||
}, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM));
|
}, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM));
|
||||||
trasraamAreaM2 = tras.areaM2;
|
trasraamAreaM2 = tras.areaM2;
|
||||||
@@ -1257,6 +1403,10 @@ window.WallModel = (() => {
|
|||||||
// transparant zijn).
|
// transparant zijn).
|
||||||
const doekZStepM = 0.0002;
|
const doekZStepM = 0.0002;
|
||||||
const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte
|
const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte
|
||||||
|
// Doek tot de bovenrand van de gevel (achter de dakrand langs, ook
|
||||||
|
// zichtbaar in fasen zonder dakrand); latwerk en rabat stoppen onder
|
||||||
|
// het boeideel/de daktrim (tlBat).
|
||||||
|
const tlBat = CONFIG_BAT === CONFIG_OUT ? tlOut : topLine(CONFIG_BAT, toM);
|
||||||
const maxRoofY = tlOut.maxY;
|
const maxRoofY = tlOut.maxY;
|
||||||
|
|
||||||
let rowY0 = skinFrom * toM;
|
let rowY0 = skinFrom * toM;
|
||||||
@@ -1279,17 +1429,15 @@ window.WallModel = (() => {
|
|||||||
return shape;
|
return shape;
|
||||||
});
|
});
|
||||||
if (rowShapes.length) {
|
if (rowShapes.length) {
|
||||||
const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false };
|
|
||||||
const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings));
|
|
||||||
const rowAreaM2 = rowShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
const rowAreaM2 = rowShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
||||||
|
if (doekOn) {
|
||||||
const doekRow = new THREE.Mesh(rowGeo, doekMaterial);
|
const doekRow = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(rowShapes, { steps: 1, depth: sThickM, bevelEnabled: false })), doekMaterial);
|
||||||
// 1 mm naar buiten: niet in hetzelfde vlak als de dakrand (dak op
|
// 1 mm naar buiten: niet in hetzelfde vlak als de dakrand (dak op
|
||||||
// de wand), anders flikkert het dak door het doek heen waar de
|
// de wand), anders flikkert het dak door het doek heen waar de
|
||||||
// gevel tot onder de dakrand doorloopt (skinAbove).
|
// gevel tot onder de dakrand doorloopt (skinAbove).
|
||||||
doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M);
|
doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M);
|
||||||
doekRow.visible = buitenVisible;
|
wallGroup3D.add(doekRow);
|
||||||
wallGroup3D.add(doekRow);
|
}
|
||||||
|
|
||||||
// Elke baan telt met zijn volle oppervlak mee (incl. de overlapzone
|
// Elke baan telt met zijn volle oppervlak mee (incl. de overlapzone
|
||||||
// met de vorige baan) — dat is fysiek correct voor een materiaal-
|
// met de vorige baan) — dat is fysiek correct voor een materiaal-
|
||||||
@@ -1306,10 +1454,11 @@ window.WallModel = (() => {
|
|||||||
if (b - a <= edgeEps || Math.min(roofAt(a), roofAt(b)) <= rowY0 + edgeEps) return;
|
if (b - a <= edgeEps || Math.min(roofAt(a), roofAt(b)) <= rowY0 + edgeEps) return;
|
||||||
const pts = [{ x: a, y: rowY0 }, { x: b, y: rowY0 }, { x: b, y: Math.min(rowY1, roofAt(b)) }, { x: a, y: Math.min(rowY1, roofAt(a)) }];
|
const pts = [{ x: a, y: rowY0 }, { x: b, y: rowY0 }, { x: b, y: Math.min(rowY1, roofAt(b)) }, { x: a, y: Math.min(rowY1, roofAt(a)) }];
|
||||||
const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y)));
|
const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y)));
|
||||||
const mesh = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: stripM, bevelEnabled: false })), doekMaterial);
|
if (doekOn) {
|
||||||
mesh.position.set(0, 0, zOut);
|
const mesh = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: stripM, bevelEnabled: false })), doekMaterial);
|
||||||
mesh.visible = buitenVisible;
|
mesh.position.set(0, 0, zOut);
|
||||||
wallGroup3D.add(mesh);
|
wallGroup3D.add(mesh);
|
||||||
|
}
|
||||||
doekAreaM2 += shapeAreaWithHoles(shape);
|
doekAreaM2 += shapeAreaWithHoles(shape);
|
||||||
});
|
});
|
||||||
rowY0 += doekStepM;
|
rowY0 += doekStepM;
|
||||||
@@ -1325,7 +1474,7 @@ window.WallModel = (() => {
|
|||||||
trasraamStoneAreaM2 = r.areaM2;
|
trasraamStoneAreaM2 = r.areaM2;
|
||||||
trasraamIsolatieM3 = r.isoM3;
|
trasraamIsolatieM3 = r.isoM3;
|
||||||
} else if (trasraam) {
|
} else if (trasraam) {
|
||||||
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, {
|
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
|
||||||
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM
|
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM
|
||||||
}, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM));
|
}, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM));
|
||||||
trasraamAreaM2 = tras.areaM2;
|
trasraamAreaM2 = tras.areaM2;
|
||||||
@@ -1334,7 +1483,7 @@ window.WallModel = (() => {
|
|||||||
|
|
||||||
// G. KRUISLAT op het vochtdoek (computeBattens): onderste laag direct op
|
// G. KRUISLAT op het vochtdoek (computeBattens): onderste laag direct op
|
||||||
// het doek, bovenste laag (haaks op de gevelbekleding) daaroverheen.
|
// het doek, bovenste laag (haaks op de gevelbekleding) daaroverheen.
|
||||||
const bat = computeBattens(CONFIG_OUT, cladding, trasraam, skinExt, facade, skinFrom, trimHolesMM);
|
const bat = computeBattens(CONFIG_BAT, cladding, trasraam, skinExt, facade, skinFrom, trimHolesMM);
|
||||||
const bT = BATTEN.thick * toM, bW = BATTEN.width * toM;
|
const bT = BATTEN.thick * toM, bW = BATTEN.width * toM;
|
||||||
const zDoek = (maxDepth / 2) + skinGapM + sThickM + rowIndex * doekZStepM + skinGapM + DOEK_OUT_M;
|
const zDoek = (maxDepth / 2) + skinGapM + sThickM + rowIndex * doekZStepM + skinGapM + DOEK_OUT_M;
|
||||||
const addLayer = (orientation, z) => {
|
const addLayer = (orientation, z) => {
|
||||||
@@ -1343,15 +1492,69 @@ window.WallModel = (() => {
|
|||||||
const len = horiz ? (b.x1 - b.x0) * toM : (b.y1 - b.y0) * toM;
|
const len = horiz ? (b.x1 - b.x0) * toM : (b.y1 - b.y0) * toM;
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(horiz ? len : bW, horiz ? bW : len, bT), battenMaterial);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(horiz ? len : bW, horiz ? bW : len, bT), battenMaterial);
|
||||||
mesh.position.set(horiz ? (b.x0 + b.x1) / 2 * toM : b.x * toM, horiz ? b.y * toM : (b.y0 + b.y1) / 2 * toM, z + bT / 2);
|
mesh.position.set(horiz ? (b.x0 + b.x1) / 2 * toM : b.x * toM, horiz ? b.y * toM : (b.y0 + b.y1) / 2 * toM, z + bT / 2);
|
||||||
mesh.visible = buitenVisible;
|
|
||||||
wallGroup3D.add(mesh);
|
wallGroup3D.add(mesh);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
addLayer(bat.under, zDoek);
|
if (battensOn) {
|
||||||
addLayer(bat.top, zDoek + bT);
|
addLayer(bat.under, zDoek);
|
||||||
|
addLayer(bat.top, zDoek + bT);
|
||||||
|
}
|
||||||
battenLengthM = bat.totalLengthMM * toM;
|
battenLengthM = bat.totalLengthMM * toM;
|
||||||
battenVolumeM3 = battenLengthM * bT * bW;
|
battenVolumeM3 = battenLengthM * bT * bW;
|
||||||
battenCount = bat.stock.count;
|
battenCount = bat.stock.count;
|
||||||
|
battenPieces = bat.lengths.length;
|
||||||
|
|
||||||
|
// I. RABAT op de kruislat: één blok van het doek tot de voorkant van
|
||||||
|
// de planken (RABAT), boven het trasraam tot onder de dakrand (zoals
|
||||||
|
// het latwerk), met dezelfde uitsparingen en hoekdoorloop als de
|
||||||
|
// andere buitenlagen. Oppervlak (gevelvlak) altijd, het blok alleen
|
||||||
|
// vanaf fase "gevel".
|
||||||
|
const rabatDepthMM = 2 * BATTEN.thick + CLADDING_THICK_MM;
|
||||||
|
const rExt = extM(rabatDepthMM);
|
||||||
|
const rxL = -halfW - rExt.left, rxR = halfW + rExt.right;
|
||||||
|
const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM;
|
||||||
|
const rTop = (x) => tlBat.at(Math.max(-halfW, Math.min(halfW, x)));
|
||||||
|
const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps)
|
||||||
|
? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => {
|
||||||
|
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
|
||||||
|
piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y)))));
|
||||||
|
return shape;
|
||||||
|
}) : [];
|
||||||
|
rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
|
||||||
|
if (rabatOn && rabatShapes.length) {
|
||||||
|
const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false });
|
||||||
|
// UV's in gevel-coördinaten: u langs de gevel, v vanaf het latten-
|
||||||
|
// raster (bovenkant trasraam), zodat de planknaden doorlopen.
|
||||||
|
const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM;
|
||||||
|
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) + uvShiftM, y = pos.getY(k) - gridM, z = pos.getZ(k);
|
||||||
|
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));
|
||||||
|
mesh.position.set(0, 0, zDoek);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
wallGroup3D.add(mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kozijnen (KozijnModel): vanaf de fase gevel. Buitenwand: buitenvlak
|
||||||
|
// gelijk met de buitenbeplating, 120 mm diep, met dorpel; binnen- en
|
||||||
|
// naadwand: over de volle wanddikte, zonder dorpel.
|
||||||
|
if (show('gevel') && 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 }
|
||||||
|
: buitenMateriaal === 'geen' ? { zOut: half, depth: CONFIG.studDepth + sh, exterior: false }
|
||||||
|
: { zOut: half + sh, depth: CONFIG.studDepth + 2 * sh, exterior: false };
|
||||||
|
CONFIG.openings.forEach(op => {
|
||||||
|
const k = op.fill ? KozijnModel.build3D(op, op.fill, place) : null;
|
||||||
|
if (!k) return;
|
||||||
|
k.position.set(startXM + op.x * toM, op.y * toM, 0);
|
||||||
|
wallGroup3D.add(k);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1359,11 +1562,18 @@ window.WallModel = (() => {
|
|||||||
stats: {
|
stats: {
|
||||||
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
|
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
|
||||||
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
|
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
|
||||||
battenLengthM, battenVolumeM3, battenCount, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3
|
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hout, dubbelzijdig (de schuine regel heeft geen vaste oriëntatie).
|
||||||
|
const wedgeMats = new WeakMap();
|
||||||
|
function wedgeMaterial(wood) {
|
||||||
|
if (!wedgeMats.has(wood)) { const m = wood.clone(); m.side = THREE.DoubleSide; wedgeMats.set(wood, m); }
|
||||||
|
return wedgeMats.get(wood);
|
||||||
|
}
|
||||||
|
|
||||||
// Specificaties (volumes -> gewichten), op basis van de stats uit build3D.
|
// Specificaties (volumes -> gewichten), op basis van de stats uit build3D.
|
||||||
function computeSpecs(CONFIG, stats, buitenMateriaal) {
|
function computeSpecs(CONFIG, stats, buitenMateriaal) {
|
||||||
const toM = 0.001;
|
const toM = 0.001;
|
||||||
@@ -1440,6 +1650,27 @@ window.WallModel = (() => {
|
|||||||
pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2, cutAngles: [0, 0] });
|
pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2, cutAngles: [0, 0] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schuine regel (CONFIG.topWedge): één stuk over de wand, uit een balk
|
||||||
|
// van de grootste wighoogte gezaagd onder de dakhelling.
|
||||||
|
// Per aaneengesloten stuk waar een wig zit (bijv. alleen onder een wolfseind).
|
||||||
|
if (CONFIG.topWedge) {
|
||||||
|
const wg = CONFIG.topWedge.pts, has = (q) => Math.max(q.o, q.i) >= 5, runs = [];
|
||||||
|
for (let k = 0; k < wg.length - 1; k++) {
|
||||||
|
if (!has(wg[k]) && !has(wg[k + 1])) continue;
|
||||||
|
const last = runs[runs.length - 1];
|
||||||
|
if (last && last[last.length - 1] === wg[k]) last.push(wg[k + 1]); else runs.push([wg[k], wg[k + 1]]);
|
||||||
|
}
|
||||||
|
runs.forEach(run => {
|
||||||
|
const hMax = Math.max(...run.map(q => Math.max(q.o, q.i)));
|
||||||
|
const bevel = Math.round(Math.max(...run.map(q => Math.atan(Math.abs(q.o - q.i) / CONFIG.studDepth))) * 1800 / Math.PI) / 10;
|
||||||
|
let len = 0;
|
||||||
|
for (let k = 0; k < run.length - 1; k++) len += Math.hypot(run[k + 1].x - run[k].x, tl.at(run[k + 1].x) - tl.at(run[k].x));
|
||||||
|
const outline = run.map(q => ({ x: q.x, y: tl.at(q.x) })).concat(run.slice().reverse().map(q => ({ x: q.x, y: tl.at(q.x) + Math.max(q.o, q.i) })));
|
||||||
|
const xm = (run[0].x + run[run.length - 1].x) / 2;
|
||||||
|
pieces.push({ length: Math.round(len), width: Math.max(1, Math.round(hMax)), depth: CONFIG.studDepth, kind: 'schuine regel', x: xm, y: tl.at(xm) + hMax / 2, outline, cutAngles: [bevel, bevel] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Bovenregel(s): met nok twee stukken (verstek). outline = werkelijke
|
// Bovenregel(s): met nok twee stukken (verstek). outline = werkelijke
|
||||||
// omtrek (PDF-tekening).
|
// omtrek (PDF-tekening).
|
||||||
topPlateOutlines(tl).forEach((outline, i) => {
|
topPlateOutlines(tl).forEach((outline, i) => {
|
||||||
@@ -1580,6 +1811,9 @@ window.WallModel = (() => {
|
|||||||
if (o.type !== undefined && !OPENING_TYPES[o.type]) {
|
if (o.type !== undefined && !OPENING_TYPES[o.type]) {
|
||||||
throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`);
|
throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`);
|
||||||
}
|
}
|
||||||
|
if (o.fill != null && (typeof o.fill !== 'object' || !KozijnModel.FILL_TYPES[o.fill.type])) {
|
||||||
|
throw new Error(`opening #${i + 1}: fill.type moet ${Object.keys(KozijnModel.FILL_TYPES).join('/')} zijn`);
|
||||||
|
}
|
||||||
if (!['left', 'middle', 'right'].includes(o.anchor)) {
|
if (!['left', 'middle', 'right'].includes(o.anchor)) {
|
||||||
throw new Error(`opening #${i + 1}: anchor moet 'left', 'middle' of 'right' zijn (was: ${o.anchor})`);
|
throw new Error(`opening #${i + 1}: anchor moet 'left', 'middle' of 'right' zijn (was: ${o.anchor})`);
|
||||||
}
|
}
|
||||||
@@ -1587,8 +1821,8 @@ window.WallModel = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
||||||
sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
sheathingHoles, skinSegmentsAtY, topLine, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
||||||
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
||||||
createMaterials, build3D, computeSpecs
|
createMaterials, build3D, computeSpecs
|
||||||
};
|
};
|
||||||
|
|||||||
+8
-5
@@ -180,13 +180,16 @@ window.WallPdf = (() => {
|
|||||||
doc.text(String(Math.round(hLeft)), px(-wallWidth / 2) - 9, (py(0) + py(hLeft)) / 2, { align: 'center', angle: 90 });
|
doc.text(String(Math.round(hLeft)), px(-wallWidth / 2) - 9, (py(0) + py(hLeft)) / 2, { align: 'center', angle: 90 });
|
||||||
drawDimLine(px(wallWidth / 2) + 7, py(0), px(wallWidth / 2) + 7, py(hRight));
|
drawDimLine(px(wallWidth / 2) + 7, py(0), px(wallWidth / 2) + 7, py(hRight));
|
||||||
doc.text(String(Math.round(hRight)), px(wallWidth / 2) + 11, (py(0) + py(hRight)) / 2, { align: 'center', angle: 90 });
|
doc.text(String(Math.round(hRight)), px(wallWidth / 2) + 11, (py(0) + py(hRight)) / 2, { align: 'center', angle: 90 });
|
||||||
if (tl.peak) {
|
// Tussenpunten van de daklijn: stippellijn van vloer tot nok/knik
|
||||||
// Nokhoogte: stippellijn van vloer tot nok.
|
// (nok = het enige hoogste punt; mansarde-/wolfsgevel: knikken).
|
||||||
|
const inner = tl.pts.slice(1, -1);
|
||||||
|
inner.forEach(q => {
|
||||||
|
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
|
||||||
doc.setLineDashPattern([1, 1], 0);
|
doc.setLineDashPattern([1, 1], 0);
|
||||||
doc.line(px(tl.peak.x), py(0), px(tl.peak.x), py(tl.peak.y));
|
doc.line(px(q.x), py(0), px(q.x), py(q.y));
|
||||||
doc.setLineDashPattern([], 0);
|
doc.setLineDashPattern([], 0);
|
||||||
doc.text('nok ' + Math.round(tl.peak.y), px(tl.peak.x) + 1.5, py(tl.peak.y) + 4);
|
doc.text((isNok ? 'nok ' : 'knik ') + Math.round(q.y), px(q.x) + 1.5, py(q.y) + 4);
|
||||||
}
|
});
|
||||||
|
|
||||||
const studEdges = new Set([0, wallWidth]);
|
const studEdges = new Set([0, wallWidth]);
|
||||||
calcData.fullStuds.forEach(x => studEdges.add(Math.round(x - CONFIG.studWidth / 2 + wallWidth / 2)));
|
calcData.fullStuds.forEach(x => studEdges.add(Math.round(x - CONFIG.studWidth / 2 + wallWidth / 2)));
|
||||||
|
|||||||
+79
-12
@@ -255,6 +255,11 @@ function mountWallEditor(container, callbacks) {
|
|||||||
const lockCladding = callbacks && callbacks.lockCladding;
|
const lockCladding = callbacks && callbacks.lockCladding;
|
||||||
// ... en de buitenzijde (gevelbekleding per woning, override per wand).
|
// ... en de buitenzijde (gevelbekleding per woning, override per wand).
|
||||||
const lockExterior = callbacks && callbacks.lockExterior;
|
const lockExterior = callbacks && callbacks.lockExterior;
|
||||||
|
// Alles op het tabblad Materiaal ligt dan vast: tabblad weg, uitleg op Opbouw.
|
||||||
|
if (lockExterior && lockCladding) {
|
||||||
|
const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]');
|
||||||
|
if (tabBtn) tabBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand.
|
// Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand.
|
||||||
if (callbacks && callbacks.lockSpacing) {
|
if (callbacks && callbacks.lockSpacing) {
|
||||||
@@ -279,8 +284,12 @@ function mountWallEditor(container, callbacks) {
|
|||||||
let calcData = { fullStuds: [], studs: [] };
|
let calcData = { fullStuds: [], studs: [] };
|
||||||
let wallGroup3D = null;
|
let wallGroup3D = null;
|
||||||
let lastBaseHeight = 2400;
|
let lastBaseHeight = 2400;
|
||||||
|
// Daklijn met knikken (mansarde/wolfsdak, uit de unit-dakvorm): blijft
|
||||||
|
// staan tot een hoogte hier met de hand wordt gewijzigd.
|
||||||
|
let wallTopPts = null;
|
||||||
|
|
||||||
// Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset }
|
// Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset, fill? }
|
||||||
|
// fill = vulling (kozijn, KozijnModel); zonder fill blijft hij open.
|
||||||
// hR = hoogte rechts (schuine bovenzijde); zonder hR is de bovenkant
|
// hR = hoogte rechts (schuine bovenzijde); zonder hR is de bovenkant
|
||||||
// recht en is h de hoogte. De onderkant is altijd waterpas.
|
// recht en is h de hoogte. De onderkant is altijd waterpas.
|
||||||
// 'offset' betekent afhankelijk van anchor:
|
// 'offset' betekent afhankelijk van anchor:
|
||||||
@@ -288,7 +297,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
// right -> afstand vanaf rechter wandrand tot rechterkant opening
|
// right -> afstand vanaf rechter wandrand tot rechterkant opening
|
||||||
// middle -> verschuiving t.o.v. het midden (0 = gecentreerd, + = naar rechts)
|
// middle -> verschuiving t.o.v. het midden (0 = gecentreerd, + = naar rechts)
|
||||||
let openings = [
|
let openings = [
|
||||||
{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }
|
{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }
|
||||||
];
|
];
|
||||||
let openingIdCounter = 2;
|
let openingIdCounter = 2;
|
||||||
|
|
||||||
@@ -323,7 +332,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
const isSloped = (op) => op.hR !== undefined;
|
const isSloped = (op) => op.hR !== undefined;
|
||||||
// Ruwe opening zoals opgeslagen (hR alleen bij een schuine bovenzijde).
|
// Ruwe opening zoals opgeslagen (hR alleen bij een schuine bovenzijde).
|
||||||
const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset },
|
const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset },
|
||||||
o.hR !== undefined ? { hR: o.hR } : {});
|
o.hR !== undefined ? { hR: o.hR } : {}, o.fill ? { fill: Object.assign({}, o.fill) } : {});
|
||||||
|
|
||||||
function syncOpeningCardInputs(op) {
|
function syncOpeningCardInputs(op) {
|
||||||
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
|
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
|
||||||
@@ -372,7 +381,8 @@ function mountWallEditor(container, callbacks) {
|
|||||||
h: preset.h,
|
h: preset.h,
|
||||||
y: preset.y,
|
y: preset.y,
|
||||||
anchor: 'left',
|
anchor: 'left',
|
||||||
offset: 300 + n * 1400
|
offset: 300 + n * 1400,
|
||||||
|
fill: KozijnModel.defaultFill('raam', lastFill())
|
||||||
});
|
});
|
||||||
renderOpeningsList();
|
renderOpeningsList();
|
||||||
updateAll();
|
updateAll();
|
||||||
@@ -439,6 +449,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
const tmp = hLeftInput.value;
|
const tmp = hLeftInput.value;
|
||||||
hLeftInput.value = hRightInput.value;
|
hLeftInput.value = hRightInput.value;
|
||||||
hRightInput.value = tmp;
|
hRightInput.value = tmp;
|
||||||
|
if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse();
|
||||||
|
|
||||||
openings = openings.map(o => {
|
openings = openings.map(o => {
|
||||||
// Schuine bovenzijde klapt mee om.
|
// Schuine bovenzijde klapt mee om.
|
||||||
@@ -454,6 +465,40 @@ function mountWallEditor(container, callbacks) {
|
|||||||
setIoStatus('Horizontaal gespiegeld.', 'ok');
|
setIoStatus('Horizontaal gespiegeld.', 'ok');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vulling (kozijn, KozijnModel): type, rooster/glasstrook en kleuren.
|
||||||
|
function fillControls(op) {
|
||||||
|
const f = KozijnModel.normalize(op.fill);
|
||||||
|
const type = f ? f.type : 'geen';
|
||||||
|
const opts = (entries, cur) => entries.map(([k, label]) => `<option value="${k}" ${k === cur ? 'selected' : ''}>${label}</option>`).join('');
|
||||||
|
const colors = Object.entries(KozijnModel.COLORS).map(([k, c]) => [k, `${c.label} (${c.ral})`]);
|
||||||
|
const sw = (field, label, on, title) => `<div class="control-group"${title ? ` title="${title}"` : ''}>
|
||||||
|
<label>${label}</label>
|
||||||
|
<div><label class="ui-switch"><input type="checkbox" data-field="${field}" data-id="${op.id}" ${on ? 'checked' : ''}><span></span></label></div>
|
||||||
|
</div>`;
|
||||||
|
return `<div class="controls-grid opening-fill">
|
||||||
|
<div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Vulling</label>
|
||||||
|
<div><select data-field="fill" data-id="${op.id}">${opts(Object.entries(KozijnModel.FILL_TYPES), type)}</select></div>
|
||||||
|
</div>
|
||||||
|
${type === 'raam' ? sw('fillRooster', 'Rooster', f.rooster, 'Ventilatierooster bovenin het kozijn') : ''}
|
||||||
|
${type === 'deur' ? sw('fillGlas', 'Glasstrook', f.glas, 'Glasstrook in het deurblad') : ''}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
${f ? `<div class="control-group">
|
||||||
|
<label>Kleur buiten</label>
|
||||||
|
<div><select data-field="fillOut" data-id="${op.id}">${opts(colors, f.out)}</select></div>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Kleur binnen</label>
|
||||||
|
<div><select data-field="fillIn" data-id="${op.id}">${opts(colors, f.in)}</select></div>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
// Kleuren van de laatste opening met een vulling (voor nieuwe openingen).
|
||||||
|
const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; };
|
||||||
|
|
||||||
function renderOpeningsList() {
|
function renderOpeningsList() {
|
||||||
const list = document.getElementById('openings-list');
|
const list = document.getElementById('openings-list');
|
||||||
list.innerHTML = openings.map(op => `
|
list.innerHTML = openings.map(op => `
|
||||||
@@ -511,6 +556,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
|
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${fillControls(op)}
|
||||||
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
|
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -538,12 +584,22 @@ function mountWallEditor(container, callbacks) {
|
|||||||
const op = openings.find(o => o.id === id);
|
const op = openings.find(o => o.id === id);
|
||||||
if (!op) return;
|
if (!op) return;
|
||||||
if (field === 'type') {
|
if (field === 'type') {
|
||||||
// Type kiezen = standaardmaat van dat type overnemen (daarna vrij aan te passen).
|
// Type kiezen = standaardmaat en -vulling van dat type overnemen (daarna vrij aan te passen).
|
||||||
const preset = WallModel.OPENING_TYPES[e.target.value];
|
const preset = WallModel.OPENING_TYPES[e.target.value];
|
||||||
op.type = e.target.value;
|
op.type = e.target.value;
|
||||||
op.w = preset.w; op.h = preset.h; op.y = preset.y;
|
op.w = preset.w; op.h = preset.h; op.y = preset.y;
|
||||||
delete op.hR;
|
delete op.hR;
|
||||||
|
if (op.fill) op.fill = KozijnModel.defaultFill(op.type, op.fill);
|
||||||
renderOpeningsList();
|
renderOpeningsList();
|
||||||
|
} else if (field === 'fill') {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === 'geen') delete op.fill;
|
||||||
|
else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v });
|
||||||
|
renderOpeningsList();
|
||||||
|
} else if (/^fill(Rooster|Glas|Out|In)$/.test(field)) {
|
||||||
|
if (!op.fill) return;
|
||||||
|
const key = { fillRooster: 'rooster', fillGlas: 'glas', fillOut: 'out', fillIn: 'in' }[field];
|
||||||
|
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
||||||
} else if (field === 'sloped') {
|
} else if (field === 'sloped') {
|
||||||
if (e.target.checked) op.hR = op.h; else delete op.hR;
|
if (e.target.checked) op.hR = op.h; else delete op.hR;
|
||||||
renderOpeningsList();
|
renderOpeningsList();
|
||||||
@@ -597,6 +653,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
heightRight: parseFloat(document.getElementById('inp-wall-h-right').value),
|
heightRight: parseFloat(document.getElementById('inp-wall-h-right').value),
|
||||||
// Middenpunt (nok); null = rechte bovenrand.
|
// Middenpunt (nok); null = rechte bovenrand.
|
||||||
heightMid: document.getElementById('inp-wall-peak').checked ? parseFloat(document.getElementById('inp-wall-h-mid').value) : null,
|
heightMid: document.getElementById('inp-wall-peak').checked ? parseFloat(document.getElementById('inp-wall-h-mid').value) : null,
|
||||||
|
topPts: wallTopPts ? wallTopPts.map(q => Object.assign({}, q)) : undefined,
|
||||||
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
|
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
|
||||||
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
|
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
|
||||||
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
|
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
|
||||||
@@ -610,7 +667,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
function getPureState() {
|
function getPureState() {
|
||||||
return {
|
return {
|
||||||
wall: collectWallParams(),
|
wall: collectWallParams(),
|
||||||
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone },
|
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, rabatColor },
|
||||||
openings: openings.map(rawOpening)
|
openings: openings.map(rawOpening)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -631,11 +688,13 @@ function mountWallEditor(container, callbacks) {
|
|||||||
document.getElementById('inp-min-sp').value = state.wall.minSpacing;
|
document.getElementById('inp-min-sp').value = state.wall.minSpacing;
|
||||||
document.getElementById('inp-max-sp').value = state.wall.maxSpacing;
|
document.getElementById('inp-max-sp').value = state.wall.maxSpacing;
|
||||||
lastBaseHeight = state.wall.heightAll;
|
lastBaseHeight = state.wall.heightAll;
|
||||||
|
wallTopPts = Array.isArray(state.wall.topPts) && state.wall.topPts.length ? state.wall.topPts.map(q => Object.assign({}, q)) : null;
|
||||||
|
|
||||||
buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek';
|
buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek';
|
||||||
cladding = (state.materials && state.materials.cladding) || 'horizontaal';
|
cladding = (state.materials && state.materials.cladding) || 'horizontaal';
|
||||||
trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling
|
trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling
|
||||||
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen)
|
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen)
|
||||||
|
rabatColor = (state.materials && state.materials.rabatColor) || null; // woning-instelling (rabat)
|
||||||
syncMaterialButtons();
|
syncMaterialButtons();
|
||||||
|
|
||||||
openings = state.openings.map(rawOpening);
|
openings = state.openings.map(rawOpening);
|
||||||
@@ -789,6 +848,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
heightLeft: hLeft,
|
heightLeft: hLeft,
|
||||||
heightRight: hRight,
|
heightRight: hRight,
|
||||||
heightMid: peakOn ? hMid : null,
|
heightMid: peakOn ? hMid : null,
|
||||||
|
topPts: wallTopPts || undefined,
|
||||||
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
|
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
|
||||||
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
|
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
|
||||||
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
|
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
|
||||||
@@ -987,14 +1047,16 @@ function mountWallEditor(container, callbacks) {
|
|||||||
const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14);
|
const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14);
|
||||||
drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM),
|
drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM),
|
||||||
'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2);
|
'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2);
|
||||||
if (tl.peak) {
|
// Tussenpunten van de daklijn (nok = het enige hoogste punt, anders knik).
|
||||||
const pxC = toCanvasX(tl.peak.x);
|
const inner = tl.pts.slice(1, -1);
|
||||||
|
inner.forEach(q => {
|
||||||
|
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
|
||||||
ctxFront.fillStyle = '#9ab';
|
ctxFront.fillStyle = '#9ab';
|
||||||
ctxFront.font = '11px sans-serif';
|
ctxFront.font = '11px sans-serif';
|
||||||
ctxFront.textAlign = 'center';
|
ctxFront.textAlign = 'center';
|
||||||
ctxFront.fillText('nok ' + Math.round(tl.peak.y) + 'mm', pxC, Math.max(12, toCanvasY(tl.peak.y) - 6));
|
ctxFront.fillText((isNok ? 'nok ' : 'knik ') + Math.round(q.y) + 'mm', toCanvasX(q.x), Math.max(12, toCanvasY(q.y) - 6));
|
||||||
ctxFront.textAlign = 'start';
|
ctxFront.textAlign = 'start';
|
||||||
}
|
});
|
||||||
|
|
||||||
// Stijlstuk van y0 tot y1 (of tot de daklijn), zie WallModel.computeFrame.
|
// Stijlstuk van y0 tot y1 (of tot de daklijn), zie WallModel.computeFrame.
|
||||||
const drawStudFront = (st) => {
|
const drawStudFront = (st) => {
|
||||||
@@ -1322,6 +1384,8 @@ function mountWallEditor(container, callbacks) {
|
|||||||
let cladding = 'horizontaal'; // richting gevelbekleding (alleen bij vochtdoek)
|
let cladding = 'horizontaal'; // richting gevelbekleding (alleen bij vochtdoek)
|
||||||
let trasraam = null; // { bottom, top } mm, komt uit de woning-instellingen (WallModel.trasraamFor)
|
let trasraam = null; // { bottom, top } mm, komt uit de woning-instellingen (WallModel.trasraamFor)
|
||||||
let stone = null; // steencode bij cementplaat, komt uit de woning-instellingen
|
let stone = null; // steencode bij cementplaat, komt uit de woning-instellingen
|
||||||
|
let rabatColor = null; // kleur rabat (WallModel.RABAT_COLORS), komt uit de woning-instellingen
|
||||||
|
let stage = BuildStages.FINAL; // bouwfase van de 3D (schuif linksonder)
|
||||||
function syncMaterialButtons() {
|
function syncMaterialButtons() {
|
||||||
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
||||||
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
|
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
|
||||||
@@ -1355,6 +1419,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
|
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
|
||||||
};
|
};
|
||||||
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
|
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
|
||||||
|
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
|
||||||
|
|
||||||
// 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld
|
// 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld
|
||||||
// met de unit-editor).
|
// met de unit-editor).
|
||||||
@@ -1363,7 +1428,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
function update3DScene() {
|
function update3DScene() {
|
||||||
if (wallGroup3D) scene.remove(wallGroup3D);
|
if (wallGroup3D) scene.remove(wallGroup3D);
|
||||||
const result = WallModel.build3D(CONFIG, calcData, {
|
const result = WallModel.build3D(CONFIG, calcData, {
|
||||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, spaanplaatVisible, buitenVisible
|
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, rabatColor, spaanplaatVisible, buitenVisible, stage
|
||||||
});
|
});
|
||||||
wallGroup3D = result.group;
|
wallGroup3D = result.group;
|
||||||
lastWallStats = result.stats;
|
lastWallStats = result.stats;
|
||||||
@@ -1425,6 +1490,8 @@ function mountWallEditor(container, callbacks) {
|
|||||||
|
|
||||||
const inputs = document.querySelectorAll('#tab-wall input');
|
const inputs = document.querySelectorAll('#tab-wall input');
|
||||||
inputs.forEach(input => {
|
inputs.forEach(input => {
|
||||||
|
// Hoogte met de hand gewijzigd: de knikken van een mansarde-/wolfsdak vervallen.
|
||||||
|
if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; });
|
||||||
input.addEventListener('input', updateAll);
|
input.addEventListener('input', updateAll);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1476,6 +1543,6 @@ window.WallEditor = {
|
|||||||
sheathingThick: 10, minSpacing: 240, maxSpacing: 480
|
sheathingThick: 10, minSpacing: 240, maxSpacing: 480
|
||||||
},
|
},
|
||||||
materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' },
|
materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' },
|
||||||
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }]
|
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }]
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user