v0.5.2: elektra (meterkast 3×25 A, schakelaars met letters op hun lampen, wcd, apparaatgroepen incl. perilex, groepenverklaring met fasen/aardlek, leidingen naar de meterkast met strekkende meters per type, PDF-blad + plattegrond, verplaatsbaar door slepen, bouwfase Elektra met installatiebuizen in 3D); ribbon, bouwfasen, kozijnen, kostenmodel en eerdere ongecommitte wijzigingen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-06 18:40:11 +02:00
co-authored by Claude Opus 5.5
parent 8cdfa21f46
commit e1a688728c
30 changed files with 6667 additions and 868 deletions
+65 -12
View File
@@ -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).
+3
View File
@@ -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
View File
@@ -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
View File
@@ -22,64 +22,14 @@
<div class="splash-version"><span class="splash-name">Tectum</span> &mdash; Versie <span class="app-version-number">0.0.0</span></div> <div class="splash-version"><span class="splash-name">Tectum</span> &mdash; 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&hellip;</span><span class="menu-hotkey">Ctrl+S</span></div>
<div class="menu-item" id="menu-importeren">Importeren&hellip;</div>
<div class="menu-item" id="menu-project-props">Project eigenschappen&hellip;</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">&rsaquo;</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, &uarr; / &darr;</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">&larr; / &rarr;</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>
+61 -80
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
const APP_VERSION = '0.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const esc = (t) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -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();
+85
View File
@@ -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 };
})();
+174
View File
@@ -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 };
})();
+145
View File
@@ -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 };
})();
+469
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
})(); })();
+9 -1
View File
@@ -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);
+185
View File
@@ -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 }
}
}
};
+346
View File
@@ -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 };
})();
+124
View File
@@ -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 &amp; tijden</h2>
<button type="button" class="modal-close-x" data-cs="cancel">&times;</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&hellip;</button>
<button type="button" class="io-btn" data-cs="import">Importeren&hellip;</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 };
})();
+716
View File
@@ -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 };
})();
+154
View File
@@ -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 };
})();
+5 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+130 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
})(); })();
+253
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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