v0.6.0: ruimtes (verfemmer: functie, netto m², vloerafwerking, FIBO-wandpanelen, eigen verfkleur, vals plafond, ruimtestaat op de PDF), inrichting (sanitair, keuken, kookeiland, kasten, bed, zithoek, boiler; slepen met snappen op het wandvlak, draaien, kosten), kozijnen met deurrichting, glasdeur en schuifdeuren (inbouw/opbouw, deuren open in de loopmodus) plus dagkanten, gevel stucwerk en wandstukken splitsen met eigen gevel, dak over de hele woning met overkapping, geschilderd plafond, model Amstel als nieuw ontwerp, kosten en prijzen uit kosten/intern.csv en kosten/leveranciers.csv (CostCsv); easter egg verwijderd; overbodige comments opgeschoond (alleen copyright, uitzonderingen en valkuilen blijven)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-11 00:34:27 +02:00
co-authored by Claude Opus 5.5
parent b5510d56c7
commit ceda0b554d
45 changed files with 4117 additions and 3700 deletions
+121 -6
View File
@@ -29,10 +29,14 @@ js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/d
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/elektra/ ElektraModel + ElektraPdf: meterkast, schakelaars (letters), wcd, groepen, leidingen
js/sandwich/ SandwichModel: sandwichpanelen rondom js/sandwich/ SandwichModel: sandwichpanelen rondom
js/room/ RoomModel: ruimtes (verfemmer), functie, netto m², afwerking, vals plafond
js/interior/ FixtureModel: inrichting (sanitair, keuken, kasten, meubels, boiler)
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
js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
js/app.js menubalk, versienummer, actieve editor js/app.js menubalk, versienummer, actieve editor
json/defaults/ referentie-JSON's (niet runtime ingeladen) json/defaults/ referentie-JSON's (niet runtime ingeladen)
kosten/ intern.csv (eigen waarden) + leveranciers.csv (live prijzen), bij het starten ingeladen
assets/ logo, texturen, skybox assets/ logo, texturen, skybox
``` ```
@@ -77,9 +81,21 @@ assets/ logo, texturen, skybox
afmonteren, groepenkast en keuring. afmonteren, groepenkast en keuring.
- **Kozijnen** (`wall/kozijn-model.js`): per opening een vulling (geen, raam - **Kozijnen** (`wall/kozijn-model.js`): per opening een vulling (geen, raam
met vast glas en optioneel ventilatierooster, of deur met dorpel en met vast glas en optioneel ventilatierooster, of deur met dorpel en
optionele glasstrook; zij- en bovenlicht vullen de rest van een grote optionele glasstrook of als glasdeur; zij- en bovenlicht vullen de rest van een grote
opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de
maat van de opening, ook een schuine bovenzijde. maat van de opening, ook een schuine bovenzijde. Deur: kant van het blad,
scharnierzijde en draairichting (naar binnen/buiten); openslaande deuren
met zijlichten = twee deuren naast elkaar. Schuifdeur: 1 of 2 bladen
(uit elkaar), in de wand of op de wand aan een rail (binnen- of
buitenzijde), glazen of dicht blad. In de plattegrond (app en PDF) een
draaicirkel per deur en de schuifrichting per schuifdeur; in de loopmodus
gaan deuren open (dubbele schuifdeur samen). Dagkanten (afgewerkte
fase): binnen een geschilderde omranding, dagkantplaten tot het kozijn en
een vensterbank onder ramen; buiten een omranding in de kozijnkleur en
een waterslag onder ramen (`WallModel.build3D`).
- **Plafond:** in de afgewerkte fase is de onderkant van het dak
geschilderd in de plafondkleur (Binnen › Wanden › Plafond), zolang
"Geschilderd" aan staat; anders kale OSB.
- **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat, - **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat,
lessenaar, zadel, mansarde of wolfsdak, met schuiven en hellingshoek). lessenaar, zadel, mansarde of wolfsdak, met schuiven en hellingshoek).
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde
@@ -98,6 +114,22 @@ assets/ logo, texturen, skybox
bovenkant vloer tot onderkant dak), unit max 4,3 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen 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 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. wandhoogte in de wand-editor, dan geldt dezelfde regel vanuit die wand.
De daklijn van een wand hoort bij de wand zonder naden: loopt een wand
door een naad langer door (bijv. de kopgevel naast een gekoppelde unit),
dan blijven nok en knikken op hun plaats (`UnitModel.topLineOn`).
- **Overkapping:** bij een dak over de hele woning met `cover` loopt het
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
terras naast een kortere module), met een afgewerkte onderplaat in de
kleur van het boeideel (`HouseModel.coverExtensions`).
- **Dak over de hele woning:** tabblad Dak › "Over hele woning": één
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle
units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden
de wolfseinden kilkepers vragen, dan wordt het een zadeldak), bijv. een
grote wolfskap over twee units naast elkaar. Elke unit
houdt zijn eigen dakdeel (dekken, bovenste deel van het wolfsdak geknipt
op de unit, PDF-bladen, kosten); de wanden van alle units volgen het
woningdak. De daken per unit blijven bewaard (`roof.unitWalls`) en komen
terug als je het weer uitzet.
- **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
@@ -124,13 +156,44 @@ assets/ logo, texturen, skybox
zonder spots). De rand van het lage dak loopt dan door tot de gevel van zonder spots). De rand van het lage dak loopt dan door tot de gevel van
het hoge, of langs een zijgevel tot de volle overstek (onder de hoge het hoge, of langs een zijgevel tot de volle overstek (onder de hoge
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit. rand). Boeideel en daktrim steken 80 mm boven het dakleer uit.
- **Gevel:** gevelbekleding per woning, met override per wand: rabat - **Gevel:** gevelbekleding per woning, met override per wand en per
(vochtdoek + kruislat + rabat 12 mm, kleur per woning, getekend als wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
plankentextuur die over naden doorloopt) of steen (181 texturen). Steen is overal een getekend als plankentextuur die over naden doorloopt), steen (181
texturen) of stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
per woning, `WallModel.STUC`). Een zijde kan gesplitst worden (wand ›
Wandstukken › Splitsen op, gemeten vanaf de linker vloerhoek van buitenaf):
elk stuk wordt een eigen wand-element en kan een eigen gevel krijgen
(bijv. stuc links, hout rechts). Steen is overal een
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`). 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.
- **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is
een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door
een wand te gaan (deuren en ramen tellen als wand). Exact op het raster van
de wandvlakken, dus de netto vloer tot op de mm; kiertjes tot 30 mm tussen
wanden worden dichtgezet. Gereedschap *Ruimte vullen* (Start/Binnen): kies
een functie (woonkamer, keuken, slaapkamer, badkamer, toilet, hal, ...) en
klik in de plattegrond; of selecteer een gebied en kies daar de functie
(nog eens klikken = de unit eronder). Per ruimte: naam, vloerafwerking
(o.a. tegels), FIBO-wandpanelen (decor, tot een hoogte of tot het plafond),
eigen verfkleur en een vals plafond op een vaste hoogte (alleen waar het
dak hoger ligt; wandafwerking en binnenspots volgen). Laag in de
plattegrond (Beeld › Tonen: Ruimtes), label met netto m², in de structuur,
het rapport, de kosten (vals plafond, FIBO) en op de PDF-plattegrond met
een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee.
- **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset
aan een voorzetwand), fontein, badmeubel met spiegel, inloopdouche
(douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met
koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat,
kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil,
salontafel, tv-meubel, boiler en wasmachine. Binnen › Inrichting: kies
een stuk en klik in de plattegrond; slepen, bij een wand snapt de rug op
het wandvlak (Alt = vrij), D = draaien, Del = verwijderen. Per stuk maten
en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D
(afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep
Inrichting; los meubilair telt niet mee). Het stuk hoort bij een unit en
schuift mee.
- **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld, - **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
override per wand) en binnenspots in een raster per unit. override per wand) en binnenspots in een raster per unit.
- **Ribbon** (`core/ribbon.js`, vervangt de menubalk): Bestand · Start · - **Ribbon** (`core/ribbon.js`, vervangt de menubalk): Bestand · Start ·
@@ -152,6 +215,49 @@ assets/ logo, texturen, skybox
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).
## Kosten en prijzen (CSV)
Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die
de app bij het starten ophaalt. Elke waarde staat op één plek; de CSV's zijn
leidend en de app bewaart niets in de browser.
- `kosten/intern.csv`: eigen waarden: tijden, uurloon, bezetting, kosten per
uur, opslagen, marge (alles behalve prijzen).
- `kosten/leveranciers.csv`: alle inkoopprijzen (bijv. uit API-scripts van
leveranciers), ook constructiehout per balkmaat.
- `js/cost/cost-defaults.js`: welke posten er zijn (labels, eenheden, keuzes)
en noodwaarden voor wat een CSV mist.
```
# intern.csv
code;omschrijving;eenheid;waarde
arbeid.uurloon;Uurloon productie (all-in);€/u;45
zagen.methode;Zaagmethode;;robot
# leveranciers.csv
code;omschrijving;eenheid;prijs;leverancier;artikelnummer;bijgewerkt
elektra.wcdEnkel;Wandcontactdoos enkel met randaarde;€/st.;6,00;Groothandel X;123456;2026-10-09
hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026-10-09
```
- Verplicht: `code` en `waarde` (of `prijs`), excl. btw; `6,00`, `6.00`,
`1.234,50` en `€ 6` mogen; bij een keuze de sleutel of de tekst.
Scheidingsteken `;`, `,` of tab; regels met `#` worden overgeslagen.
- `code` = `<groep>.<sleutel>` uit `cost-defaults.js`, of `hout.balk.<b>x<h>`
voor constructiehout per balkmaat in €/m (zonder balkmaat: m³-prijs × volume).
- `omschrijving` is de naam van de post in de app (leeg = die uit `cost-defaults.js`).
- `eenheid` is optioneel; ingevuld moet die gelijk zijn aan die van de post,
anders wordt de regel geweigerd.
- Een prijs in intern.csv of een tijd in leveranciers.csv wordt geweigerd
("hoort in …"). Meerdere regels met dezelfde code in leveranciers.csv
(leveranciers): de laagste prijs telt.
- Onbekende codes (bijv. onderdelen die de app nog niet rekent) en fouten staan
bovenaan in Kosten & tijden.
- Kosten & tijden: **Toepassen** geldt tot herladen (om te proberen);
**intern.csv downloaden** om het bestand in `kosten/` te vervangen.
Prijsvelden staan vast. Bij Leveranciers: **Sjabloon** (alle prijscodes),
**Tijdelijk laden** (tot herladen of **Terug naar server**).
## Bekende beperkingen ## Bekende beperkingen
- Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij - Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij
@@ -159,7 +265,16 @@ assets/ logo, texturen, skybox
- Tijdelijk: een nieuwe woning is het voorbeeldontwerp "Vlinderwoning" - Tijdelijk: een nieuwe woning is het voorbeeldontwerp "Vlinderwoning"
(92 m², drie units, vlinderdak met riet + platte tuinkamer, ook als (92 m², drie units, vlinderdak met riet + platte tuinkamer, ook als
`json/premade/vlinderwoning.json`); uit te zetten met `TEST_DEFAULT` in `json/premade/vlinderwoning.json`); uit te zetten met `TEST_DEFAULT` in
`js/house/house-model.js`. `js/house/house-model.js`. Bij het starten opent model **Amstel**
(`HouseModel.createAmstel`, naar `examples/ontwerp_amstel.pdf`): 11,24 ×
7,2 m, twee modules onder één plat dak dat over het terras doorloopt,
stucwerk wit met verticaal hout aan de slaapkamerkant, binnenwanden,
dubbele schuifdeur tussen woonkamer en hal, en complete inrichting.
Langer maken: beide units breder (alles is vanaf de westgevel gemaat).
Ook via Bestand › Woning Amstel. De L-woning (8 × 3,6 m + 4,5 × 3,6 m)
blijft beschikbaar als "Woning L-vorm".
- Netto m² per ruimte telt de hele vloer, ook onder een laag schuin dak
(geen NEN 2580-correctie voor hoogte < 1,5 m).
- 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,6 +7,3 @@ 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.
+17 -74
View File
@@ -4,13 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
/* ============================================================ /* De 2D-/3D-weergaven blijven donker (--viewport-bg): de canvas-tekeningen gebruiken lichte lijnen. */
HUISSTIJL Van Emous — kleuren als CSS-variabelen (bron: SCSS-variabelen
$van-emous-blue / -orange / -ultra-light, $nav-link-color, $primary).
De bediening (menu's, panelen, navigatie) is licht; de 2D-/3D-weergaven
houden een donkere achtergrond (--viewport-bg), omdat de canvas-tekeningen
daar lichte lijnen/teksten op gebruiken.
============================================================ */
:root { :root {
--van-emous-blue: #dce4ee; --van-emous-blue: #dce4ee;
--van-emous-orange: #b95913; --van-emous-orange: #b95913;
@@ -20,7 +14,7 @@
--primary-hover: #9c4a0f; --primary-hover: #9c4a0f;
--primary-soft: rgba(185, 89, 19, 0.10); --primary-soft: rgba(185, 89, 19, 0.10);
--primary-on-dark: #e8883d; /* oranje dat leesbaar blijft op de donkere weergaven */ --primary-on-dark: #e8883d;
--text: var(--nav-link-color); --text: var(--nav-link-color);
--text-muted: #5b6673; --text-muted: #5b6673;
--text-faint: #8a94a0; --text-faint: #8a94a0;
@@ -85,7 +79,7 @@
background: var(--viewport-bg); background: var(--viewport-bg);
} }
.pane-2d-sub + .pane-2d-sub { border-top: 1px solid var(--border); } .pane-2d-sub + .pane-2d-sub { border-top: 1px solid var(--border); }
.pane-2d-primary { flex: 2; } /* primair aanzicht (bijv. vooraanzicht bij een wand) krijgt meer ruimte */ .pane-2d-primary { flex: 2; }
.pane-2d-sub canvas.interactive { cursor: default; } .pane-2d-sub canvas.interactive { cursor: default; }
.pane-2d-row { display: flex; flex-direction: row; padding: 0; } .pane-2d-row { display: flex; flex-direction: row; padding: 0; }
.pane-2d-half { flex: 1; position: relative; min-width: 0; min-height: 0; background: var(--viewport-bg); } .pane-2d-half { flex: 1; position: relative; min-width: 0; min-height: 0; background: var(--viewport-bg); }
@@ -226,7 +220,6 @@
.highlight { color: var(--primary); font-weight: bold; } .highlight { color: var(--primary); font-weight: bold; }
canvas { display: block; width: 100%; height: 100%; } canvas { display: block; width: 100%; height: 100%; }
/* --- Openingen tab (nieuw) --- */
.opening-card { .opening-card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -286,7 +279,6 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
/* --- Specificaties tab (nieuw) --- */
.spec-section { margin-bottom: 14px; } .spec-section { margin-bottom: 14px; }
.material-info { font-size: 12px; color: var(--text-muted); line-height: 1.5; margin: 4px 0 10px; } .material-info { font-size: 12px; color: var(--text-muted); line-height: 1.5; margin: 4px 0 10px; }
@@ -298,9 +290,6 @@
} }
.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: ribbon, workspace-wrapper, splashscreen, about-modal
============================================================ */
html, body { height: 100%; } html, body { height: 100%; }
#tab-bar { #tab-bar {
@@ -331,7 +320,7 @@ body { display: flex; flex-direction: column; }
color: var(--text-faint); font-size: 14px; flex-direction: column; gap: 10px; color: var(--text-faint); font-size: 14px; flex-direction: column; gap: 10px;
} }
/* --- Splashscreen (blijft donker: het logo bevat lichte delen) --- */ /* Splashscreen blijft donker: het logo bevat lichte delen. */
#splash-screen { #splash-screen {
position: fixed; inset: 0; background: #0d0d0d; z-index: 9999; position: fixed; inset: 0; background: #0d0d0d; z-index: 9999;
display: flex; flex-direction: column; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
@@ -342,7 +331,6 @@ body { display: flex; flex-direction: column; }
#splash-screen .splash-name { color: #eee; font-weight: bold; letter-spacing: 1px; } #splash-screen .splash-name { color: #eee; font-weight: bold; letter-spacing: 1px; }
@keyframes splashFadeOut { 0% { opacity: 1; } 100% { opacity: 0; visibility: hidden; pointer-events: none; } } @keyframes splashFadeOut { 0% { opacity: 1; } 100% { opacity: 0; visibility: hidden; pointer-events: none; } }
/* --- "Over deze software"-modal --- */
#about-overlay { #about-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;
@@ -392,7 +380,6 @@ body { display: flex; flex-direction: column; }
#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 { #cost-settings-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;
@@ -416,6 +403,13 @@ body { display: flex; flex-direction: column; }
#cost-settings-modal .cs-row select { text-align: left; } #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-unit { color: var(--text-faint); font-size: 11px; }
#cost-settings-modal .cs-row.changed .cs-label { color: var(--primary); font-weight: bold; } #cost-settings-modal .cs-row.changed .cs-label { color: var(--primary); font-weight: bold; }
#cost-settings-modal .cs-row.from-list input { color: var(--primary); font-weight: bold; }
#cost-settings-modal .cs-row.from-list .cs-unit::after { content: ' CSV'; color: var(--primary); font-size: 9px; }
#cost-settings-modal .cs-pricelist { font-size: 12px; margin-bottom: 10px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; }
#cost-settings-modal .cs-pricelist .io-btn { padding: 1px 8px; margin-left: 6px; }
#cost-settings-modal .cs-pricelist .cs-list + .cs-list { margin-top: 4px; }
#cost-settings-modal .cs-pricelist ul { margin: 4px 0 0; padding-left: 18px; max-height: 120px; overflow-y: auto; }
#cost-settings-modal .cs-pricelist li.err { color: var(--danger); }
#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;
@@ -438,12 +432,6 @@ body { display: flex; flex-direction: column; }
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); } .new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
.new-option.disabled { opacity: .4; cursor: not-allowed; } .new-option.disabled { opacity: .4; cursor: not-allowed; }
/* ============================================================
VLOER-EDITOR (floor.js) — hergebruikt bestaande klassen als
.opening-card/.add-opening-btn/.global-warning/.controls-grid/.control-group/
.tab-bar/.tab-btn/.tab-content/.result-row/.highlight/.divider (identiek
aan de wand-editor, dus geen dubbele stijl-definities nodig).
============================================================ */
#floor-app { display: flex; width: 100%; height: 100%; } #floor-app { display: flex; width: 100%; height: 100%; }
#floor-left-column { #floor-left-column {
flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0;
@@ -462,10 +450,6 @@ body { display: flex; flex-direction: column; }
} }
#floor-specs-panel-3d .controls-grid { grid-template-columns: 1fr 1fr; } #floor-specs-panel-3d .controls-grid { grid-template-columns: 1fr 1fr; }
/* ============================================================
WONING-EDITOR (house/house.js) — navigatieboom links, actief onderdeel
rechts (overzicht of de ECHTE wand-/vloer-editor in #house-part-host).
============================================================ */
#house-app { display: flex; width: 100%; height: 100%; } #house-app { display: flex; width: 100%; height: 100%; }
#house-nav { #house-nav {
width: 240px; flex: none; display: flex; flex-direction: column; gap: 6px; width: 240px; flex: none; display: flex; flex-direction: column; gap: 6px;
@@ -497,8 +481,6 @@ body { display: flex; flex-direction: column; }
#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-views { flex: 1; display: flex; min-width: 0; min-height: 0; } .ho-views { flex: 1; display: flex; min-width: 0; min-height: 0; }
.ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); } .ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); }
@@ -527,7 +509,6 @@ body { display: flex; flex-direction: column; }
.io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); } .io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.control-group input:disabled { opacity: .5; cursor: not-allowed; background: var(--border-soft); } .control-group input:disabled { opacity: .5; cursor: not-allowed; background: var(--border-soft); }
/* --- Weergave-filter (core/view-filter.js): dropdown met vinkjes op de 3D-weergave --- */
.view-filter { position: relative; } .view-filter { position: relative; }
.vf-menu { .vf-menu {
position: absolute; right: 0; top: 100%; margin-top: 6px; min-width: 210px; display: none; position: absolute; right: 0; top: 100%; margin-top: 6px; min-width: 210px; display: none;
@@ -537,10 +518,6 @@ body { display: flex; flex-direction: column; }
.vf-item { display: flex; align-items: center; gap: 8px; color: #eee; font-size: 13px; padding: 4px 0; cursor: pointer; user-select: none; } .vf-item { display: flex; align-items: center; gap: 8px; color: #eee; font-size: 13px; padding: 4px 0; cursor: pointer; user-select: none; }
.vf-item input { accent-color: var(--primary-on-dark); width: 15px; height: 15px; cursor: pointer; } .vf-item input { accent-color: var(--primary-on-dark); width: 15px; height: 15px; cursor: pointer; }
/* ============================================================
UI-KIT (core/ui-kit.js): velden, schakelaars, secties, tabbladen,
specificatie-kaarten. Iconen: Bootstrap Icons (css/vendor).
============================================================ */
.bi { line-height: 1; vertical-align: -0.125em; } .bi { line-height: 1; vertical-align: -0.125em; }
.ui-section { margin-bottom: 14px; } .ui-section { margin-bottom: 14px; }
.ui-section h2, .ui-spec-card h2 { .ui-section h2, .ui-spec-card h2 {
@@ -552,7 +529,7 @@ body { display: flex; flex-direction: column; }
.ui-field label { font-size: 12.5px; color: var(--text); display: flex; align-items: center; gap: 5px; } .ui-field label { font-size: 12.5px; color: var(--text); display: flex; align-items: center; gap: 5px; }
.ui-field.is-disabled label { color: var(--text-faint); } .ui-field.is-disabled label { color: var(--text-faint); }
.ui-control { display: flex; align-items: center; flex: none; } .ui-control { display: flex; align-items: center; flex: none; }
.ui-control input[type=number], .ui-control select { .ui-control input[type=number], .ui-control input[type=text], .ui-control select {
width: 118px; background: var(--surface); border: 1px solid var(--border); color: var(--text); width: 118px; background: var(--surface); border: 1px solid var(--border); color: var(--text);
padding: 4px 6px; border-radius: 4px; font-size: 12.5px; font-family: inherit; padding: 4px 6px; border-radius: 4px; font-size: 12.5px; font-family: inherit;
} }
@@ -610,10 +587,8 @@ body { display: flex; flex-direction: column; }
.hn-item.active .bi, .hn-item:hover .bi { color: var(--primary); } .hn-item.active .bi, .hn-item:hover .bi { color: var(--primary); }
.hn-label { display: inline-flex; align-items: center; } .hn-label { display: inline-flex; align-items: center; }
/* Weergave-cyclus (T): render-only = ook het info-deel onder de 3D weg. */
.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. */
.control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; } .control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; }
.control-group label { font-size: 12.5px; color: var(--text); } .control-group label { font-size: 12.5px; color: var(--text); }
.control-group input, .control-group select { width: 118px; font-size: 12.5px; padding: 4px 6px; font-family: inherit; } .control-group input, .control-group select { width: 118px; font-size: 12.5px; padding: 4px 6px; font-family: inherit; }
@@ -634,44 +609,21 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
.vf-option.active { color: var(--primary-on-dark); font-weight: 600; } .vf-option.active { color: var(--primary-on-dark); font-weight: 600; }
.vf-menu .vf-option:hover { color: var(--primary-on-dark); } .vf-menu .vf-option:hover { color: var(--primary-on-dark); }
/* Voorvertoning steen trasraam (woning > Gevel) */
.hs-stone-preview { display: block; width: 128px; height: auto; margin: 4px 0 8px; border: 1px solid var(--border, #ccc); border-radius: 3px; } .hs-stone-preview { display: block; width: 128px; height: auto; margin: 4px 0 8px; border: 1px solid var(--border, #ccc); border-radius: 3px; }
/* Lopen (first person, derde weergavestand): uitleg onderin de 3D. */ .walk-hint { display: none; position: absolute; left: 50%; top: 14px; transform: translateX(-50%); padding: 6px 12px; border-radius: 4px;
.walk-hint { display: none; 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; 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; }
.walk-prompt { display: none; position: absolute; left: 50%; top: 58%; transform: translateX(-50%); padding: 5px 11px; border-radius: 4px;
background: rgba(0, 0, 0, 0.65); color: #fff; font-size: 13px; font-weight: 600; pointer-events: none; white-space: nowrap; z-index: 5; }
.walk-prompt.show { display: block; }
/* Easter egg: schieten (Ctrl+L) */
.shooter-hud { display: none; position: absolute; inset: 0; pointer-events: none; z-index: 6; font-family: monospace; color: #ffd27a; }
.shooter-hud.show { display: block; }
.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) */
.opening-roof-btn { .opening-roof-btn {
display: inline-flex; align-items: center; gap: 5px; margin-top: 2px; padding: 4px 8px; font-size: 11.5px; display: inline-flex; align-items: center; gap: 5px; margin-top: 2px; padding: 4px 8px; font-size: 11.5px;
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 { 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 .bi { font-size: 9px; }
.ui-crumbs span:last-child { color: var(--text-muted); } .ui-crumbs span:last-child { color: var(--text-muted); }
@@ -682,7 +634,6 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
.ui-range input[type=number] { width: 84px; } .ui-range input[type=number] { width: 84px; }
.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; } .ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
/* Bouwfase (core/build-stages.js): schuif linksonder in de 3D. */
.stage-bar { .stage-bar {
position: absolute; left: 15px; bottom: 14px; z-index: 10; min-width: 300px; max-width: calc(100% - 30px); 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; background: rgba(0, 0, 0, 0.75); border: 1px solid #444; border-radius: 6px; padding: 7px 12px 6px; color: #eee; font-size: 12px;
@@ -698,10 +649,7 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
.stage-ticks span.on { color: var(--primary-on-dark); } .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 { display: none; margin-top: 6px; padding-top: 6px; border-top: 1px solid #444; }
.stage-extra.show { display: flex; } .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 { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; }
.viz-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; } .viz-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; }
.viz-legend span { display: inline-flex; align-items: center; gap: 5px; } .viz-legend span { display: inline-flex; align-items: center; gap: 5px; }
@@ -722,9 +670,6 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
.viz-val { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text); text-align: right; white-space: nowrap; } .viz-val { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text); text-align: right; white-space: nowrap; }
.viz-note { font-size: 11px; color: var(--text-faint); line-height: 1.45; margin-top: 6px; } .viz-note { font-size: 11px; color: var(--text-faint); line-height: 1.45; margin-top: 6px; }
/* ============================================================
RIBBON (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; } #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-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-brand { display: flex; align-items: center; height: 100%; padding: 0 14px 0 4px; font-weight: 700; color: var(--primary); letter-spacing: .5px; }
@@ -769,11 +714,10 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
.rb-kv small { font-weight: normal; color: var(--text-faint); margin-left: 2px; } .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 { grid-row: span 3; max-width: 260px; font-size: 11px; line-height: 1.4; color: var(--text-muted); white-space: normal; align-self: center; }
.rb-note.rb-warn { color: var(--danger); } .rb-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 { 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-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 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 select, .rb-group .ui-control input[type=text] { width: 132px; padding: 2px 4px; font-size: 12px; }
.rb-group .ui-control input[type=color] { width: 46px; height: 22px; padding: 0; border: 1px solid var(--border); border-radius: 3px; background: none; } .rb-group .ui-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-unit { width: 20px; margin-left: 3px; font-size: 10.5px; }
.rb-group .ui-switch { margin-right: 0; } .rb-group .ui-switch { margin-right: 0; }
@@ -792,7 +736,6 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
.rb-check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap; } .rb-check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap; }
.rb-check input { accent-color: var(--primary); } .rb-check input { accent-color: var(--primary); }
/* Ribbon: hulpklassen voor de woning-tabbladen. */
.rb-pair { display: flex; align-items: center; gap: 6px; } .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 { 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; } .rb-cols:empty { display: none; }
+7 -2
View File
@@ -51,6 +51,10 @@
<div id="new-modal"> <div id="new-modal">
<h2>Nieuw</h2> <h2>Nieuw</h2>
<div class="new-options"> <div class="new-options">
<button class="new-option" data-kind="woning" data-template="amstel">
<svg viewBox="0 0 48 48"><rect x="6" y="10" width="36" height="14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M6 24 L6 38 L38 38 L38 24" fill="none" stroke="currentColor" stroke-width="2"/><rect x="38" y="24" width="4" height="14" fill="none" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/><line x1="22" y1="24" x2="22" y2="38" stroke="currentColor" stroke-width="1"/></svg>
<span>Woning Amstel</span>
</button>
<button class="new-option" data-kind="woning" data-template="single"> <button class="new-option" data-kind="woning" data-template="single">
<svg viewBox="0 0 48 48"><rect x="8" y="14" width="32" height="20" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="24" x2="40" y2="24" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/></svg> <svg viewBox="0 0 48 48"><rect x="8" y="14" width="32" height="20" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="24" x2="40" y2="24" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/></svg>
<span>Woning</span> <span>Woning</span>
@@ -85,8 +89,6 @@
<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/ribbon.js"></script>
@@ -105,8 +107,11 @@
<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/room/room-model.js"></script>
<script src="js/interior/fixture-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-defaults.js"></script>
<script src="js/cost/cost-csv.js"></script>
<script src="js/cost/cost-model.js"></script> <script src="js/cost/cost-model.js"></script>
<script src="js/cost/cost-settings.js"></script> <script src="js/cost/cost-settings.js"></script>
<script src="js/elektra/elektra-pdf.js"></script> <script src="js/elektra/elektra-pdf.js"></script>
+4 -14
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.5.3'; const APP_VERSION = '0.6.0';
// 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,9 +32,6 @@ 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() {
Ribbon.refresh(); Ribbon.refresh();
} }
@@ -61,6 +58,7 @@ function appRibbonTabs() {
big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) + 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')}`) }, big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) },
{ label: 'Snel nieuw', html: { label: 'Snel nieuw', html:
small('Woning Amstel', 'house-heart', 'data-app="new-kind" data-kind="woning" data-template="amstel"') +
small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') + 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('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: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') +
@@ -127,7 +125,7 @@ function switchToTab(tabId) {
} }
function openNew(kind, template) { function openNew(kind, template) {
if (!editorModuleFor(kind)) return; // 'dak' nog niet beschikbaar if (!editorModuleFor(kind)) return;
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') {
@@ -196,7 +194,6 @@ function wireTabBar() {
}); });
} }
// 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;');
return md.trim().split(/\n\s*\n/).map(block => { return md.trim().split(/\n\s*\n/).map(block => {
@@ -248,12 +245,6 @@ 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') {
@@ -279,14 +270,13 @@ document.addEventListener('DOMContentLoaded', () => {
wireNewModal(); wireNewModal();
wireHotkeys(); wireHotkeys();
wireTabBar(); wireTabBar();
// Tectum draait via een webserver (texturen, lucht); file:// werkt niet volledig.
if (location.protocol === 'file:') { if (location.protocol === 'file:') {
const note = document.createElement('div'); const note = document.createElement('div');
note.className = 'ui-warn file-protocol-warning'; note.className = 'ui-warn file-protocol-warning';
note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>'; note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>';
document.body.appendChild(note); document.body.appendChild(note);
} }
openNew('woning', 'L'); openNew('woning', 'amstel');
setTimeout(() => { setTimeout(() => {
const splash = document.getElementById('splash-screen'); const splash = document.getElementById('splash-screen');
+3 -26
View File
@@ -4,26 +4,6 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * 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/dakbeschot, cementplaat buiten
// 3 doek vochtdoek
// 4 latwerk kruislat, trasraamplaat
// 5 gevel rabat, steen, kozijnen, dakbedekking (dakleer, riet, felsplaten), 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 = (() => { window.BuildStages = (() => {
const LIST = [ const LIST = [
@@ -38,14 +18,9 @@ window.BuildStages = (() => {
const FINAL = LIST.length - 1; const FINAL = LIST.length - 1;
const INDEX = Object.fromEntries(LIST.map((s, i) => [s.key, i])); 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; 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]; 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) { function createBar(pane, stage, onChange) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'stage-bar'; el.className = 'stage-bar';
@@ -75,8 +50,10 @@ window.BuildStages = (() => {
sync(); sync();
onChange(clamp(Number(range.value))); onChange(clamp(Number(range.value)));
}); });
// Toetsen in de weergave mogen de schuif niet bedienen (T, R, ...). // T, R, ... zijn sneltoetsen van de weergave, niet van de schuif.
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); }); range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
// Na slepen focus loslaten, zodat de pijltoetsen weer naar de weergave (lopen) gaan.
el.addEventListener('pointerup', (e) => { if (e.target.matches('input[type=range]')) setTimeout(() => e.target.blur(), 0); });
sync(); sync();
return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } }; return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } };
} }
-9
View File
@@ -4,14 +4,6 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// core/camera-memory.js — onthoudt de 3D-camerastand (positie + draaipunt)
// per weergave, zodat wisselen tussen onderdelen/tabbladen of een her-mount
// na een wijziging niet terugspringt naar de standaardcamera.
// Sleutels komen van de aanroeper (bijv. "tab3:wall:A:voor"). Alleen in het
// geheugen van deze sessie.
// Plain script (geen ES-module).
// ============================================================
window.CameraMemory = (() => { window.CameraMemory = (() => {
const store = new Map(); const store = new Map();
@@ -21,7 +13,6 @@ window.CameraMemory = (() => {
store.set(key, { position: camera.position.toArray(), target: controls.target.toArray() }); store.set(key, { position: camera.position.toArray(), target: controls.target.toArray() });
} }
// true als er een opgeslagen stand was (en die is toegepast).
function restore(key, camera, controls) { function restore(key, camera, controls) {
const m = key && store.get(key); const m = key && store.get(key);
if (!m) return false; if (!m) return false;
-8
View File
@@ -4,17 +4,9 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// core/canvas-labels.js — gedeelde stijl voor maat-labels in de 2D-canvassen
// (blauwe maatlijn + "X: 6000mm"-tekst, zoals het wand-vooraanzicht). Gebruikt
// door wand, vloer en unit, zodat alle labels er hetzelfde uitzien.
// Plain script (geen ES-module).
// ============================================================
const CANVAS_LABEL_COLOR = '#4da6ff'; const CANVAS_LABEL_COLOR = '#4da6ff';
// Tekent een maatlijn van (x1,y1) naar (x2,y2) plus tekst op (textX,textY).
// Een verticale lijn (x1 === x2) krijgt de tekst 90° gedraaid.
function drawCanvasDim(ctx, x1, y1, x2, y2, text, textX, textY) { function drawCanvasDim(ctx, x1, y1, x2, y2, text, textX, textY) {
ctx.save(); ctx.save();
ctx.strokeStyle = CANVAS_LABEL_COLOR; ctx.strokeStyle = CANVAS_LABEL_COLOR;
+6 -74
View File
@@ -4,20 +4,8 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================ // Plain script, geen ES-module: imports breken bij file:// (CORS).
// core/geometry.js — gedeelde, pure math. Geen DOM, geen THREE-afhankelijkheid
// behalve shapeAreaWithHoles (die werkt met elk object dat .getPoints()/.holes
// heeft, dus ook met THREE.Shape). Plain <script> (geen module), zodat dit
// ook werkt bij rechtstreeks openen van index.html (file://) — daar breken
// ES-module-imports door browser-CORS.
//
// Wordt geladen VOOR wall.js en floor.js: de functies hieronder zijn dus
// gewoon globaal beschikbaar in beide, zonder duplicatie.
// ============================================================
// Regelwerk-verdeling (h.o.h.): bepaalt hoeveel balken/stijlen er in een
// gegeven overspanning passen, gegeven een minimale/maximale hart-op-hart-
// afstand. Gebruikt door wand-stijlen EN vloerbalken (zelfde algoritme).
function calculateSegmentPositions(spanWidth, minSp, maxSp) { function calculateSegmentPositions(spanWidth, minSp, maxSp) {
if (spanWidth <= 0) return []; if (spanWidth <= 0) return [];
let numBays = Math.ceil(spanWidth / maxSp); let numBays = Math.ceil(spanWidth / maxSp);
@@ -35,7 +23,6 @@ function calculateSegmentPositions(spanWidth, minSp, maxSp) {
return positions; return positions;
} }
// Oppervlakte van een simpele polygon (shoelace-formule).
function polygonArea(points) { function polygonArea(points) {
let area = 0; let area = 0;
for (let i = 0; i < points.length; i++) { for (let i = 0; i < points.length; i++) {
@@ -46,11 +33,7 @@ function polygonArea(points) {
return Math.abs(area) / 2; return Math.abs(area) / 2;
} }
// Sutherland-Hodgman clip van een (convexe) polygon tegen een horizontale
// halfvlak-grens. Gebruikt om bijv. de wand-omtrek (het schuine trapezium) te
// knippen tot losse horizontale banen voor het vochtdoek.
function clipPolygonYLE(points, yLimit) { function clipPolygonYLE(points, yLimit) {
// Houdt het deel met y <= yLimit
const result = []; const result = [];
for (let i = 0; i < points.length; i++) { for (let i = 0; i < points.length; i++) {
const cur = points[i]; const cur = points[i];
@@ -71,7 +54,6 @@ function clipPolygonYLE(points, yLimit) {
return result; return result;
} }
function clipPolygonYGE(points, yLimit) { function clipPolygonYGE(points, yLimit) {
// Houdt het deel met y >= yLimit
const result = []; const result = [];
for (let i = 0; i < points.length; i++) { for (let i = 0; i < points.length; i++) {
const cur = points[i]; const cur = points[i];
@@ -92,26 +74,17 @@ function clipPolygonYGE(points, yLimit) {
return result; return result;
} }
// Oppervlakte van een THREE.Shape inclusief aftrek van zijn holes.
function shapeAreaWithHoles(shape) { function shapeAreaWithHoles(shape) {
let area = polygonArea(shape.getPoints()); let area = polygonArea(shape.getPoints());
(shape.holes || []).forEach(hole => { area -= polygonArea(hole.getPoints()); }); (shape.holes || []).forEach(hole => { area -= polygonArea(hole.getPoints()); });
return area; return area;
} }
// Simpele euclidische afstand — gebruikt door floor.js voor de
// cirkel-overlapcheck (2D, dus geen 1D-ankerlogica zoals bij wand-openingen).
function dist2D(a, b) { function dist2D(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y); return Math.hypot(a.x - b.x, a.y - b.y);
} }
// Zaagplan: verdeelt stukken (lengtes in mm) over handelslengtes met zo min // Zaagsnede-breedte wordt verwaarloosd.
// mogelijk snijverlies. Stukken langer dan de langste handelslengte worden
// eerst opgeknipt (las/stuik in het werk). Daarna best-fit-decreasing: elk
// stuk gaat in de staaf waar het het krapst past; tot slot krijgt elke staaf
// de kortste handelslengte waar zijn stukken nog in passen.
// Resultaat: [{ stock, cuts: [lengtes], used, waste }]. Zaagsnede-breedte
// wordt verwaarloosd.
function planStockCutting(pieceLengths, stockLengths) { function planStockCutting(pieceLengths, stockLengths) {
const stocks = stockLengths.slice().sort((a, b) => a - b); const stocks = stockLengths.slice().sort((a, b) => a - b);
const maxStock = stocks[stocks.length - 1]; const maxStock = stocks[stocks.length - 1];
@@ -142,7 +115,6 @@ function planStockCutting(pieceLengths, stockLengths) {
return bars; return bars;
} }
// Samenvatting van een zaagplan: aantal staven per handelslengte + totalen.
function summarizeStockPlan(bars) { function summarizeStockPlan(bars) {
const byStock = new Map(); const byStock = new Map();
bars.forEach(b => byStock.set(b.stock, (byStock.get(b.stock) || 0) + 1)); bars.forEach(b => byStock.set(b.stock, (byStock.get(b.stock) || 0) + 1));
@@ -153,25 +125,8 @@ function summarizeStockPlan(bars) {
}; };
} }
// Verticale strook [x0, x1] van y = yBottom tot een (evt. schuine, lineaire) // Een uitsparing over de rand wordt een inkeping, geen afgekapt gat (dat liet een haarfijn
// bovenrand topYAt(x), MIN een set uitsparingen ({ x0, x1, y0, y1, y1r? }): // reepje materiaal met randlijnen door de opening lopen). Schuin (y1r) alleen bovenin.
// rechthoeken, of met y1r een schuine bovenkant (lineair van (x0, y1) naar
// (x1, y1r); schuin bovenin mag, onderin niet). Uitsparingen die over de
// rand van de strook vallen worden een echte inkeping in de omtrek (i.p.v.
// een gat dat net binnen de rand wordt afgekapt — dat liet een haarfijn
// reepje materiaal met zichtbare randlijnen door de opening lopen). Een
// uitsparing over de volle breedte splitst de strook in losse delen; een
// uitsparing tot in de (schuine) bovenrand wordt een inkeping in de bovenrand.
// opts (optioneel):
// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan).
// extraXs — knikpunten van topYAt (bijv. waar de daklijn yCap kruist), zodat
// de bovenrand tussen twee hoekpunten steeds lineair is.
// Werkwijze: de strook wordt in kolommen geknipt (op alle x-randen, knikken
// en snijpunten van de lijnen), zodat binnen een kolom elke lijn (onderkant,
// onder-/bovenkant uitsparing, bovenrand) lineair is en ze elkaar niet
// kruisen; de cellen tussen twee lijnen zijn vol of leeg, en de randen
// tussen vol en leeg worden tot lussen aaneengeregen.
// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }].
function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) { function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
const eps = 1e-9; const eps = 1e-9;
const yCap = (opts && opts.yCap !== undefined) ? opts.yCap : Infinity; const yCap = (opts && opts.yCap !== undefined) ? opts.yCap : Infinity;
@@ -186,14 +141,12 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
}); });
const inRect = (x, y) => rs.some(r => x > r.x0 && x < r.x1 && y > r.y0 && y < r.topAt(x)); const inRect = (x, y) => rs.some(r => x > r.x0 && x < r.x1 && y > r.y0 && y < r.topAt(x));
// Lijnen (ongeklemd); geklemd tussen yBottom en de bovenrand.
const raw = [() => yBottom, top]; const raw = [() => yBottom, top];
rs.forEach(r => raw.push(() => r.y0, r.topAt)); rs.forEach(r => raw.push(() => r.y0, r.topAt));
const clamp = (f) => (x) => Math.min(top(x), Math.max(yBottom, f(x))); const clamp = (f) => (x) => Math.min(top(x), Math.max(yBottom, f(x)));
const lines = raw.map(clamp); const lines = raw.map(clamp);
let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]); let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]);
// Snijpunten van de lijnen (ook met de bovenrand/onderkant) als extra kolomranden.
const cuts = []; const cuts = [];
for (let i = 0; i < xs.length - 1; i++) { for (let i = 0; i < xs.length - 1; i++) {
const a = xs[i], b = xs[i + 1]; const a = xs[i], b = xs[i + 1];
@@ -206,7 +159,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
} }
xs = uniq(xs.concat(cuts)); xs = uniq(xs.concat(cuts));
// Per kolom: cellen tussen opeenvolgende (verschillende) lijnen.
const cols = []; const cols = [];
for (let i = 0; i < xs.length - 1; i++) { for (let i = 0; i < xs.length - 1; i++) {
const a = xs[i], b = xs[i + 1], m = (a + b) / 2; const a = xs[i], b = xs[i + 1], m = (a + b) / 2;
@@ -222,27 +174,24 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
cols.push({ a, b, cells }); cols.push({ a, b, cells });
} }
// Hoekpunten gedeeld via een register (bijna-gelijke punten = hetzelfde punt).
const verts = []; const verts = [];
const vid = (x, y) => { const vid = (x, y) => {
let k = verts.findIndex(v => Math.abs(v.x - x) < 1e-7 && Math.abs(v.y - y) < 1e-7); let k = verts.findIndex(v => Math.abs(v.x - x) < 1e-7 && Math.abs(v.y - y) < 1e-7);
if (k < 0) { k = verts.length; verts.push({ x, y }); } if (k < 0) { k = verts.length; verts.push({ x, y }); }
return k; return k;
}; };
const edges = new Map(); // van -> [naar, ...] const edges = new Map();
const addEdge = (p, q) => { const addEdge = (p, q) => {
const a = vid(p.x, p.y), b = vid(q.x, q.y); const a = vid(p.x, p.y), b = vid(q.x, q.y);
if (a === b) return; if (a === b) return;
if (!edges.has(a)) edges.set(a, []); if (!edges.has(a)) edges.set(a, []);
edges.get(a).push(b); edges.get(a).push(b);
}; };
// Onder-/bovenranden binnen een kolom (CCW: onder naar rechts, boven naar links).
cols.forEach(col => col.cells.forEach((c, k) => { cols.forEach(col => col.cells.forEach((c, k) => {
if (!c.filled) return; if (!c.filled) return;
if (!(k > 0 && col.cells[k - 1].filled && col.cells[k - 1].hi === c.lo)) addEdge({ x: col.a, y: c.lo.a }, { x: col.b, y: c.lo.b }); if (!(k > 0 && col.cells[k - 1].filled && col.cells[k - 1].hi === c.lo)) addEdge({ x: col.a, y: c.lo.a }, { x: col.b, y: c.lo.b });
if (!(k < col.cells.length - 1 && col.cells[k + 1].filled && col.cells[k + 1].lo === c.hi)) addEdge({ x: col.b, y: c.hi.b }, { x: col.a, y: c.hi.a }); if (!(k < col.cells.length - 1 && col.cells[k + 1].filled && col.cells[k + 1].lo === c.hi)) addEdge({ x: col.b, y: c.hi.b }, { x: col.a, y: c.hi.a });
})); }));
// Verticale randen op elke kolomgrens: waar links vol en rechts leeg is (of andersom).
for (let i = 0; i < xs.length; i++) { for (let i = 0; i < xs.length; i++) {
const x = xs[i]; const x = xs[i];
const left = i > 0 ? cols[i - 1].cells.filter(c => c.filled).map(c => [c.lo.b, c.hi.b]) : []; const left = i > 0 ? cols[i - 1].cells.filter(c => c.filled).map(c => [c.lo.b, c.hi.b]) : [];
@@ -257,7 +206,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
} }
} }
// Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
const loops = []; const loops = [];
edges.forEach((list, start) => { edges.forEach((list, start) => {
while (list.length) { while (list.length) {
@@ -293,7 +241,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
return c; return c;
}; };
const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] })); const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] }));
// Gat bij de kleinste buitenlus die het bevat.
outers.sort((p, q) => signedArea(p.outer) - signedArea(q.outer)); outers.sort((p, q) => signedArea(p.outer) - signedArea(q.outer));
polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => { polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => {
const mid = { x: (hole[0].x + hole[1].x) / 2, y: (hole[0].y + hole[1].y) / 2 }; const mid = { x: (hole[0].x + hole[1].x) / 2, y: (hole[0].y + hole[1].y) / 2 };
@@ -303,11 +250,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
return outers; return outers;
} }
// Omtrek van de vereniging van as-evenwijdige rechthoeken ({x0, x1, z0, z1}),
// als gesloten lussen van punten {x, z}. Buitenlussen zijn tegen de klok in
// (positieve oppervlakte in het x/z-vlak), gaten met de klok mee. Collineaire
// tussenpunten zijn verwijderd. Gesorteerd op oppervlakte (grootste eerst).
// Gebruikt voor de dakrand (boeidelen/daktrim) over meerdere units heen.
function rectUnionLoops(rects) { function rectUnionLoops(rects) {
const eps = 1e-6; const eps = 1e-6;
const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps); const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
@@ -350,16 +292,11 @@ function rectUnionLoops(rects) {
return loops.map(pts => ({ pts, area: area(pts) })).sort((a, b) => Math.abs(b.area) - Math.abs(a.area)); return loops.map(pts => ({ pts, area: area(pts) })).sort((a, b) => Math.abs(b.area) - Math.abs(a.area));
} }
// Buitenwaartse normaal (as-evenwijdig, lengte 1) van ribbe a -> b van een
// lus tegen de klok in (positieve oppervlakte in x/z).
function rectilinearNormal(a, b) { function rectilinearNormal(a, b) {
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1; const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
return { x: dz / len, z: -dx / len }; return { x: dz / len, z: -dx / len };
} }
// Verschuift een rechthoekige (as-evenwijdige) lus tegen de klok in over
// afstand d naar buiten. Collineaire tussenpunten (bijv. een unit-hoek midden
// in een rechte gevel) blijven behouden en schuiven loodrecht mee.
function offsetRectilinearLoop(pts, d) { function offsetRectilinearLoop(pts, d) {
const n = pts.length; const n = pts.length;
return pts.map((p, k) => { return pts.map((p, k) => {
@@ -370,17 +307,12 @@ function offsetRectilinearLoop(pts, d) {
}); });
} }
// Gevels: groepeert lijnstukken in de plattegrond tot doorlopende gevels
// (zelfde as + normaal + gevelvlak, aansluitend of overlappend). segs: [{ axis ('x'|'z'),
// normal {x, z}, line (positie op de andere as), s0, s1 (s0 < s1) }, ...].
// Geeft [{ axis, normal, line, s0, s1, segs (op s0 gesorteerd) }].
// Gebruikt door de woning (latwerk/doek-patroon) en de buitenverlichting.
function groupFacadeRuns(segs, opts) { function groupFacadeRuns(segs, opts) {
const lineTol = (opts && opts.lineTol) || 1, gapTol = (opts && opts.gapTol) || 5; const lineTol = (opts && opts.lineTol) || 1, gapTol = (opts && opts.gapTol) || 5;
const runs = []; const runs = [];
segs.slice().sort((a, b) => a.s0 - b.s0).forEach(sg => { segs.slice().sort((a, b) => a.s0 - b.s0).forEach(sg => {
const run = runs.find(r => r.axis === sg.axis && Math.abs(r.normal.x - sg.normal.x) < 1e-6 && Math.abs(r.normal.z - sg.normal.z) < 1e-6 && const run = runs.find(r => r.axis === sg.axis && Math.abs(r.normal.x - sg.normal.x) < 1e-6 && Math.abs(r.normal.z - sg.normal.z) < 1e-6 &&
Math.abs(r.line - sg.line) < lineTol && sg.s0 <= r.s1 + gapTol); // aansluitend of overlappend Math.abs(r.line - sg.line) < lineTol && sg.s0 <= r.s1 + gapTol);
if (run) { run.segs.push(sg); run.s1 = Math.max(run.s1, sg.s1); } if (run) { run.segs.push(sg); run.s1 = Math.max(run.s1, sg.s1); }
else runs.push(Object.assign({}, sg, { s0: sg.s0, s1: sg.s1, segs: [sg] })); else runs.push(Object.assign({}, sg, { s0: sg.s0, s1: sg.s1, segs: [sg] }));
}); });
-174
View File
@@ -1,174 +0,0 @@
/*!
* 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 };
})();
+1 -6
View File
@@ -4,12 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================ // Kopie van LICENTIE.md (leidend): bij een wijziging beide aanpassen.
// core/license.js — de licentietekst voor "Over deze software". Kopie van
// LICENTIE.md (dat bestand is leidend; bij een wijziging beide aanpassen).
// Als JS-string opgenomen omdat fetch() van lokale bestanden via file://
// niet betrouwbaar werkt.
// ============================================================
window.TECTUM_LICENSE_MD = `# Licentie en auteursrecht window.TECTUM_LICENSE_MD = `# Licentie en auteursrecht
+1 -5
View File
File diff suppressed because one or more lines are too long
+3 -25
View File
@@ -4,28 +4,16 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// core/pdf-sheet.js — gedeelde bouwstenen voor de A3-tekenbladen (jsPDF):
// kleuren, nieuw document, maatlijn en titelblok. Gebruikt door WallPdf,
// FloorPdf en de unit-PDF, zodat elk blad er identiek uitziet.
// Plain script (geen ES-module), geladen na jspdf.umd.min.js.
// ============================================================
window.PdfSheet = (() => { window.PdfSheet = (() => {
// Kleurlogica: rood = houtwerk-omtrekken, blauw = hoofdmaatvoering,
// zwart = opening-kruis, posnum-labels en de onderste maatketens.
const COLORS = { RED: [190, 25, 25], BLUE: [25, 50, 180], BLACK: [0, 0, 0], GREY: [130, 130, 130] }; const COLORS = { RED: [190, 25, 25], BLUE: [25, 50, 180], BLACK: [0, 0, 0], GREY: [130, 130, 130] };
// Veldnamen van de "Project eigenschappen"-modal van de wand-/vloer-editor.
const PROJECT_DOM_IDS = { const PROJECT_DOM_IDS = {
naam: 'inp-proj-naam', plaats: 'inp-proj-plaats', straat: 'inp-proj-straat', nummer: 'inp-proj-nummer', naam: 'inp-proj-naam', plaats: 'inp-proj-plaats', straat: 'inp-proj-straat', nummer: 'inp-proj-nummer',
tekenaar: 'inp-proj-tekenaar', titel: 'inp-proj-titel', element: 'inp-proj-element', opmerking: 'inp-proj-opmerking' tekenaar: 'inp-proj-tekenaar', titel: 'inp-proj-titel', element: 'inp-proj-element', opmerking: 'inp-proj-opmerking'
}; };
// Afbeeldingen (bijv. textuur-voorvertoningen) voor in de PDF. generate() // generate() is synchroon: afbeeldingen vooraf laden met preloadImage.
// is synchroon, dus vooraf laden (preloadImage, bijv. zodra een materiaal
// gekozen is); imageData geeft de data-URL, of null als hij (nog) niet
// geladen is (dan tekent de aanroeper een kleurvlak).
const imageCache = {}; const imageCache = {};
function preloadImage(url) { function preloadImage(url) {
if (!url || url in imageCache || typeof Image === 'undefined' || typeof document === 'undefined') return; if (!url || url in imageCache || typeof Image === 'undefined' || typeof document === 'undefined') return;
@@ -37,7 +25,7 @@ window.PdfSheet = (() => {
c.width = img.naturalWidth; c.height = img.naturalHeight; c.width = img.naturalWidth; c.height = img.naturalHeight;
c.getContext('2d').drawImage(img, 0, 0); c.getContext('2d').drawImage(img, 0, 0);
imageCache[url] = c.toDataURL('image/png'); imageCache[url] = c.toDataURL('image/png');
} catch (e) { /* laat null: kleurvlak */ } } catch (e) { }
}; };
img.src = url; img.src = url;
} }
@@ -57,9 +45,6 @@ window.PdfSheet = (() => {
return project; return project;
} }
// Maatlijn met kleine loodrechte eindstreepjes ("|") op beide
// uiteinden, zodat duidelijk is waar de maat precies begint/eindigt
// i.p.v. een lijn die zonder markering in de lucht lijkt te eindigen.
const DIM_TICK = 1.5; const DIM_TICK = 1.5;
function dimLine(doc, x1, y1, x2, y2) { function dimLine(doc, x1, y1, x2, y2) {
doc.line(x1, y1, x2, y2); doc.line(x1, y1, x2, y2);
@@ -70,10 +55,6 @@ window.PdfSheet = (() => {
doc.line(x2 - tx, y2 - ty, x2 + tx, y2 + ty); doc.line(x2 - tx, y2 - ty, x2 + tx, y2 + ty);
} }
// Titelblok (rechtsonder), in twee kolommen zodat het weinig hoogte
// inneemt, met het logo (core/logo-data.js) rechtsonder.
// project: { naam, plaats, straat, nummer, tekenaar, opmerking };
// opts: { titel, element, blad } (blad bijv. "2 / 7").
function titleBlock(doc, project, opts) { function titleBlock(doc, project, opts) {
const { BLACK, GREY } = COLORS; const { BLACK, GREY } = COLORS;
const p = project || {}; const p = project || {};
@@ -104,7 +85,6 @@ window.PdfSheet = (() => {
}); });
}); });
// Scheidingslijn + onderstrook: blad links, logo rechts.
const stripY = tbY0 + 25; const stripY = tbY0 + 25;
doc.setDrawColor(...GREY); doc.setDrawColor(...GREY);
doc.setLineWidth(0.15); doc.setLineWidth(0.15);
@@ -116,7 +96,7 @@ window.PdfSheet = (() => {
} }
const logoH = 7.5, logoW = logoH * (window.TECTUM_LOGO ? window.TECTUM_LOGO.aspect : 4.55); const logoH = 7.5, logoW = logoH * (window.TECTUM_LOGO ? window.TECTUM_LOGO.aspect : 4.55);
if (window.TECTUM_LOGO) { if (window.TECTUM_LOGO) {
doc.addImage(window.TECTUM_LOGO.png, 'PNG', tbX0 + tbW - 3 - logoW, tbY0 + tbH - 2 - logoH, logoW, logoH, 'tectum-logo', 'FAST'); // alias: 1x in het bestand, gecomprimeerd doc.addImage(window.TECTUM_LOGO.png, 'PNG', tbX0 + tbW - 3 - logoW, tbY0 + tbH - 2 - logoH, logoW, logoH, 'tectum-logo', 'FAST'); // alias: logo 1x in het bestand
} else { } else {
doc.setTextColor(...GREY); doc.setTextColor(...GREY);
doc.setFontSize(6); doc.setFontSize(6);
@@ -124,8 +104,6 @@ window.PdfSheet = (() => {
} }
} }
// Eenvoudige tabel: headers + rijen (arrays van strings) vanaf (x, y) met
// vaste kolom-x'en. Geeft de y-positie onder de tabel terug.
function table(doc, x, y, colX, headers, rows, opts) { function table(doc, x, y, colX, headers, rows, opts) {
const { BLACK, GREY } = COLORS; const { BLACK, GREY } = COLORS;
const o = opts || {}; const o = opts || {};
-16
View File
@@ -4,18 +4,6 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// js/core/pdf-writer.js — minimale, dependency-vrije PDF-generator.
// Schrijft geldige PDF 1.4 bytes rechtstreeks (geen jsPDF: geen
// netwerktoegang om dat te vendoren). Ondersteunt lijnen, rechthoeken,
// tekst (standaard Helvetica, geen embedding nodig) en kleuren, over
// meerdere pagina's. Coordinaten in PDF-punten (1pt = 1/72 inch),
// oorsprong linksonder — precies zoals PDF dat zelf verwacht.
//
// Puur string/byte-opbouw, geen browser-specifieke API's nodig om het
// DOCUMENT te bouwen (alleen het downloaden ervan is browser-specifiek,
// zie savePdfBlob hieronder).
// ============================================================
function createPdfDocument() { function createPdfDocument() {
const objects = []; const objects = [];
@@ -46,7 +34,6 @@ function createPdfDocument() {
return out; return out;
} }
// Ruwe schatting van Helvetica-tekstbreedte (geen AFM-metrics embedded).
function estimateTextWidth(str, size) { function estimateTextWidth(str, size) {
return str.length * size * 0.55; return str.length * size * 0.55;
} }
@@ -145,9 +132,6 @@ function createPdfDocument() {
return { addPage, build }; return { addPage, build };
} }
// Zet een Latin1-string (elk char = 1 byte, zie hierboven) om naar een
// Blob en start een download. Browser-specifiek, puur I/O — de opbouw
// hierboven is dat niet.
function savePdfBlob(latin1String, filename) { function savePdfBlob(latin1String, filename) {
const bytes = new Uint8Array(latin1String.length); const bytes = new Uint8Array(latin1String.length);
for (let i = 0; i < latin1String.length; i++) bytes[i] = latin1String.charCodeAt(i) & 0xff; for (let i = 0; i < latin1String.length; i++) bytes[i] = latin1String.charCodeAt(i) & 0xff;
+3 -32
View File
@@ -4,48 +4,22 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * 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 = (() => { window.Ribbon = (() => {
const sources = {}; // naam -> [tab] const sources = {};
const SOURCE_ORDER = ['app', 'editor']; const SOURCE_ORDER = ['app', 'editor'];
let root = null, tabsEl = null, bodyEl = null; let root = null, tabsEl = null, bodyEl = null;
let activeKey = null, lastPlainKey = null; let activeKey = null, lastPlainKey = null;
let auto = true; // tabblad nog niet door de gebruiker gekozen: voorkeur 'start' let auto = true;
let collapsed = false, notified = null; let collapsed = false, notified = null;
const listeners = []; const listeners = [];
const PREF_KEY = 'tectum.ribbon.collapsed'; const PREF_KEY = 'tectum.ribbon.collapsed';
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`; 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) => const big = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`; `<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) => const small = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`; `<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) => const kv = (label, value, unit) =>
`<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`; `<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
@@ -105,7 +79,7 @@ window.Ribbon = (() => {
tab.parts.forEach(p => p.onShow && p.onShow()); tab.parts.forEach(p => p.onShow && p.onShow());
} }
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { /* alleen deze sessie */ } } function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { } }
function init(el) { function init(el) {
root = el; root = el;
@@ -128,8 +102,6 @@ window.Ribbon = (() => {
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) { function setSource(name, tabs, opts) {
sources[name] = tabs || []; sources[name] = tabs || [];
const ctx = (tabs || []).find(t => t.contextual); const ctx = (tabs || []).find(t => t.contextual);
@@ -138,7 +110,6 @@ window.Ribbon = (() => {
if (opts && opts.focus && ctx && ctx.onShow) ctx.onShow(); 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); }; } 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 }; return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv };
-469
View File
@@ -1,469 +0,0 @@
/*!
* 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();
}
};
}
+10 -31
View File
@@ -4,33 +4,20 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================ // Grondprojectie: port van three.js GroundedSkybox (r157+, MIT); wij draaien r128.
// core/skybox.js — omgeving voor de 3D-weergaven (Ontwerp / Dag / Nacht).
// Een panorama (assets/skybox.webp of skybox-night.webp) wordt op de grond
// geprojecteerd, zodat het object op de grond staat; onder het object ligt
// een ronde tegelvloer (assets/tiles.jpg) met een witte opstaande rand, groot
// genoeg voor het hele object. Vloer en rand reageren op het licht en vangen
// de schaduw op. Nacht: omgevingslicht op NIGHT_FACTOR (lampen blijven 100%).
//
// Grondprojectie: port van three.js GroundedSkybox (r157+, MIT; wij draaien
// r128): een binnenstebuiten bol waarvan de onderhelft plat gedrukt wordt op
// een vloer op CAMERA_HEIGHT_M onder het midden van de bol.
//
// Vereist een webserver (texturen via TextureLoader). Plain script.
// ============================================================
window.Skybox = (() => { window.Skybox = (() => {
const PREF_KEY = 'tectum.sky'; const PREF_KEY = 'tectum.sky';
const MODES = ['none', 'day', 'night']; const MODES = ['none', 'day', 'night'];
const SKY_URLS = { day: 'assets/skybox.webp', night: 'assets/skybox-night.webp' }; const SKY_URLS = { day: 'assets/skybox.webp', night: 'assets/skybox-night.webp' };
const TILES_URL = 'assets/tiles.jpg'; const TILES_URL = 'assets/tiles.jpg';
const CAMERA_HEIGHT_M = 1.7; // hoogte van de panoramacamera boven de grond const CAMERA_HEIGHT_M = 1.7;
const RADIUS_M = 60; // straal van de omgevingsbol const RADIUS_M = 60;
const TILE_M = 2.4; // één tegeltextuur (1024 px) beslaat zoveel meter const TILE_M = 2.4;
const MARGIN_M = 1.5; // vrije rand tussen object en opstaande rand const MARGIN_M = 1.5;
const SIZE_FACTOR = 1.2; // tegelvloer 20% ruimer dan object + marge const SIZE_FACTOR = 1.2;
const RIM_HEIGHT_M = 0.10, RIM_THICK_M = 0.025; const RIM_HEIGHT_M = 0.10, RIM_THICK_M = 0.025;
const NIGHT_FACTOR = 0.15; // omgevingslicht 's nachts const NIGHT_FACTOR = 0.15;
const SUN_OFFSET = { x: 8, y: 14, z: 6 }; const SUN_OFFSET = { x: 8, y: 14, z: 6 };
const cache = {}; const cache = {};
@@ -51,7 +38,6 @@ window.Skybox = (() => {
for (let i = 0; i < pos.count; i++) { for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i); v.fromBufferAttribute(pos, i);
if (v.y < 0) { if (v.y < 0) {
// Onderhelft plat op y = -h; vloeiende overgang naar de bol.
const f = v.y < y1 ? -h / v.y : (1 - v.y * v.y / (3 * y1 * y1)); const f = v.y < y1 ? -h / v.y : (1 - v.y * v.y / (3 * y1 * y1));
v.multiplyScalar(f); v.multiplyScalar(f);
pos.setXYZ(i, v.x, v.y, v.z); pos.setXYZ(i, v.x, v.y, v.z);
@@ -59,12 +45,11 @@ window.Skybox = (() => {
} }
pos.needsUpdate = true; pos.needsUpdate = true;
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map, depthWrite: false })); const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map, depthWrite: false }));
mesh.position.y = h - 0.01; // vlakke vloer net onder de tegelvloer mesh.position.y = h - 0.01;
mesh.renderOrder = -1; mesh.renderOrder = -1;
return mesh; return mesh;
} }
// Ronde tegelvloer (straal r) + witte opstaande rand erlangs.
function buildTerrace(map, r) { function buildTerrace(map, r) {
const group = new THREE.Group(); const group = new THREE.Group();
const tex = map.clone(); const tex = map.clone();
@@ -86,13 +71,12 @@ window.Skybox = (() => {
new THREE.ExtrudeGeometry(ring, { depth: RIM_HEIGHT_M, bevelEnabled: false, curveSegments: 128 }), new THREE.ExtrudeGeometry(ring, { depth: RIM_HEIGHT_M, bevelEnabled: false, curveSegments: 128 }),
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 }) new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 })
); );
rim.rotation.x = -Math.PI / 2; // extrusie omhoog rim.rotation.x = -Math.PI / 2;
rim.castShadow = rim.receiveShadow = true; rim.castShadow = rim.receiveShadow = true;
group.add(rim); group.add(rim);
return group; return group;
} }
// Omgeving voor één scene: { group, setMode(mode), fitTo(object3D, groundY) }.
function createEnvironment(scene) { function createEnvironment(scene) {
const group = new THREE.Group(); const group = new THREE.Group();
group.visible = false; group.visible = false;
@@ -124,7 +108,6 @@ window.Skybox = (() => {
return { return {
group, group,
setMode(m) { mode = m; group.visible = m !== 'none'; showSky(); }, setMode(m) { mode = m; group.visible = m !== 'none'; showSky(); },
// Midden en straal uit de omhullende van het object (horizontaal), vloer op groundY (m).
fitTo(object3D, groundY) { fitTo(object3D, groundY) {
if (!object3D) return; if (!object3D) return;
const box = new THREE.Box3().setFromObject(object3D); const box = new THREE.Box3().setFromObject(object3D);
@@ -144,14 +127,11 @@ window.Skybox = (() => {
} catch (e) { return 'none'; } } catch (e) { return 'none'; }
} }
function remember(m) { function remember(m) {
try { localStorage.setItem(PREF_KEY, m); } catch (e) { /* geen opslag: alleen deze sessie */ } try { localStorage.setItem(PREF_KEY, m); } catch (e) { }
} }
const lightFactor = (m) => m === 'night' ? NIGHT_FACTOR : 1; const lightFactor = (m) => m === 'night' ? NIGHT_FACTOR : 1;
// Omgeving toepassen: lucht/vloer, raster, camera niet onder de grond, zon
// met schaduw, en het licht (nacht: NIGHT_FACTOR).
// o: { env, grid, controls, sun, ambient, base: { ambient, sun }, shadowRoot }
function apply(m, o) { function apply(m, o) {
const on = m !== 'none'; const on = m !== 'none';
o.env.setMode(m); o.env.setMode(m);
@@ -177,7 +157,6 @@ window.Skybox = (() => {
if (o.shadowRoot) o.shadowRoot.traverse(obj => { if (obj.isMesh) obj.castShadow = on; }); if (o.shadowRoot) o.shadowRoot.traverse(obj => { if (obj.isMesh) obj.castShadow = on; });
} }
// Keuzemenu in een 3D-werkbalk: Ontwerp (geen omgeving) / Dag / Nacht.
const LABELS = { none: ['pencil-square', 'Ontwerp'], day: ['sun', 'Dag'], night: ['moon-stars', 'Nacht'] }; const LABELS = { none: ['pencil-square', 'Ontwerp'], day: ['sun', 'Dag'], night: ['moon-stars', 'Nacht'] };
function createToggle(toolbar, onChange) { function createToggle(toolbar, onChange) {
let m = getMode(); let m = getMode();
+10 -27
View File
@@ -4,18 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================ // Labels: zelfstandig naamwoord met hoofdletter, geen dubbele punt, uitleg in een hint, eenheid rechts.
// core/ui-kit.js — gedeelde UI-bouwstenen (window.UI): iconen (Bootstrap
// Icons, css/vendor), invoervelden, schakelaars, secties, tabbladen,
// 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.
//
// Woordgebruik labels: zelfstandig naamwoord met hoofdletter, geen dubbele
// punt, geen uitleg tussen haakjes (die gaat in een hint), eenheid rechts
// van het veld. Bijvoorbeeld "Afstand min." i.p.v. "Lampen min. h.o.h.".
// Plain script (geen ES-module).
// ============================================================
window.UI = (() => { window.UI = (() => {
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -26,7 +15,6 @@ window.UI = (() => {
return `<div class="ui-field${o && o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o && o.hint)}</label><div class="ui-control">${control}</div></div>`; return `<div class="ui-field${o && o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o && o.hint)}</label><div class="ui-control">${control}</div></div>`;
} }
// Getal. o: { step, unit ('mm' standaard, '' = geen), hint, min, disabled }
function num(id, label, value, o) { function num(id, label, value, o) {
o = o || {}; o = o || {};
const unit = o.unit === undefined ? 'mm' : o.unit; const unit = o.unit === undefined ? 'mm' : o.unit;
@@ -35,8 +23,6 @@ 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. // Het getal mag buiten het schuifbereik; het model klemt zelf.
function range(id, label, value, o) { function range(id, label, value, o) {
o = o || {}; o = o || {};
@@ -46,7 +32,6 @@ window.UI = (() => {
`<div class="ui-control ui-range"><input type="range" id="${id}-r" ${lim} value="${value}"${o.disabled ? ' disabled' : ''}>` + `<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>`; `<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) { function onRange(id, fn, after) {
const n = document.getElementById(id), r = document.getElementById(id + '-r'); const n = document.getElementById(id), r = document.getElementById(id + '-r');
if (!n || !r) return; if (!n || !r) return;
@@ -64,13 +49,11 @@ window.UI = (() => {
if (r) r.value = v; if (r) r.value = v;
} }
// 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('');
return field(id, label, `<select id="${id}">${opts}</select><span class="ui-unit"></span>`, o); return field(id, label, `<select id="${id}">${opts}</select><span class="ui-unit"></span>`, o);
} }
// Kleurkiezer (waarde '#rrggbb'); onColor reageert pas bij loslaten.
function color(id, label, value, o) { function color(id, label, value, o) {
return field(id, label, `<input type="color" id="${id}" value="${value}"><span class="ui-unit"></span>`, o); return field(id, label, `<input type="color" id="${id}" value="${value}"><span class="ui-unit"></span>`, o);
} }
@@ -79,7 +62,14 @@ window.UI = (() => {
if (el) el.addEventListener('change', (e) => { fn(e.target.value); after && after(); }); if (el) el.addEventListener('change', (e) => { fn(e.target.value); after && after(); });
} }
// Aan/uit-schakelaar. function text(id, label, value, o) {
return field(id, label, `<input type="text" id="${id}" value="${esc(value || '')}"${o && o.placeholder ? ` placeholder="${esc(o.placeholder)}"` : ''}><span class="ui-unit"></span>`, o);
}
function onText(id, fn, after) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', (e) => { fn(e.target.value); after && after(); });
}
function toggle(id, label, checked, o) { function toggle(id, label, checked, o) {
return field(id, label, return field(id, label,
`<label class="ui-switch"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}><span></span></label><span class="ui-unit"></span>`, o); `<label class="ui-switch"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}><span></span></label><span class="ui-unit"></span>`, o);
@@ -94,8 +84,6 @@ window.UI = (() => {
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`; `<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`; const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
// Tabbladen. group = sleutel waaronder het actieve tabblad onthouden wordt
// (bijv. 'woning', 'unit'); tabs = [{ key, label, icon, html }].
const activeTabs = {}; const activeTabs = {};
function tabs(group, list) { function tabs(group, list) {
const active = list.some(t => t.key === activeTabs[group]) ? activeTabs[group] : list[0].key; const active = list.some(t => t.key === activeTabs[group]) ? activeTabs[group] : list[0].key;
@@ -104,7 +92,6 @@ window.UI = (() => {
${list.map(t => `<div class="ui-tab-page${t.key === active ? ' active' : ''}" data-tab-page="${t.key}">${t.html}</div>`).join('')} ${list.map(t => `<div class="ui-tab-page${t.key === active ? ' active' : ''}" data-tab-page="${t.key}">${t.html}</div>`).join('')}
</div>`; </div>`;
} }
// Eenmalig per container: klik op een tab wisselt de pagina.
function wireTabs(container) { function wireTabs(container) {
if (container._uiTabsWired) return; if (container._uiTabsWired) return;
container._uiTabsWired = true; container._uiTabsWired = true;
@@ -118,14 +105,12 @@ window.UI = (() => {
}); });
} }
// Specificaties: kaart met regels [label, waarde, eenheid].
function specCard(title, iconName, rows, total) { function specCard(title, iconName, rows, total) {
const row = ([label, value, unit]) => const row = ([label, value, unit]) =>
`<div class="ui-spec-row"><span>${label}</span><span class="ui-spec-val">${value}${unit ? `<small>${unit}</small>` : ''}</span></div>`; `<div class="ui-spec-row"><span>${label}</span><span class="ui-spec-val">${value}${unit ? `<small>${unit}</small>` : ''}</span></div>`;
return `<div class="ui-spec-card"><h2>${icon(iconName)}${title}</h2>${rows.map(row).join('')}${total ? `<div class="ui-spec-total">${row(total)}</div>` : ''}</div>`; return `<div class="ui-spec-card"><h2>${icon(iconName)}${title}</h2>${rows.map(row).join('')}${total ? `<div class="ui-spec-total">${row(total)}</div>` : ''}</div>`;
} }
// Invoer koppelen: onNum/onSel/onToggle(root, id, fn, after).
function onNum(id, fn, after, o) { function onNum(id, fn, after, o) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
@@ -143,8 +128,6 @@ window.UI = (() => {
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); }); if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
} }
// Weergave-cyclus 3D (sneltoets T): standaard -> 3D groot -> alleen de
// render (ook het info-/specificatiedeel eronder weg) -> standaard.
function cycle3D(el) { function cycle3D(el) {
el.classList.remove('fullscreen-2d'); el.classList.remove('fullscreen-2d');
if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d'); if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d');
@@ -156,5 +139,5 @@ window.UI = (() => {
el.classList.toggle('fullscreen-2d'); el.classList.toggle('fullscreen-2d');
} }
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 }; return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
})(); })();
-10
View File
@@ -4,15 +4,6 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// core/view-filter.js — gedeelde "Weergave"-dropdown met vinkjes voor de
// 3D-weergaven (woning, wand, vloer/dak). Vervangt de losse Aan/Uit-knoppen.
// Plain script (geen ES-module).
//
// const filter = createViewFilter(toolbarEl, [
// { key: 'beplating', label: 'Beplating', checked: true }, ...
// ], (state) => { ... }); // state = { beplating: true, ... }
// ============================================================
function createViewFilter(toolbar, items, onChange) { function createViewFilter(toolbar, items, onChange) {
const state = {}; const state = {};
@@ -41,7 +32,6 @@ function createViewFilter(toolbar, items, onChange) {
state[key] = e.target.checked; state[key] = e.target.checked;
onChange(Object.assign({}, state)); onChange(Object.assign({}, state));
}); });
// Klik buiten het menu sluit het (listener verdwijnt met het element).
const closeOnOutside = () => { const closeOnOutside = () => {
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; } if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
menu.classList.remove('open'); menu.classList.remove('open');
+4 -29
View File
@@ -4,30 +4,13 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// core/walk-controls.js — lopen in de 3D-weergave (first person).
// W/S of pijl omhoog/omlaag vooruit/achteruit
// A/D opzij stappen
// pijl links/rechts draaien
// Shift rennen
// slepen met de muis rondkijken
// Botsen (optioneel) en vloerhoogte komen van de aanroeper:
// opts.blocked(x, z) -> true als je daar niet kunt staan (m); standaard nooit
// opts.floorAt(x, z) -> hoogte van de ondergrond (m)
// opts.eye -> ooghoogte boven de ondergrond (m, standaard 1,65)
// Plain script (geen ES-module); gebruikt THREE.
//
// const walk = createWalkControls(camera, domElement, opts);
// walk.start({ x, z, yaw }); walk.update(dt); walk.stop();
// ============================================================
function createWalkControls(camera, domElement, opts) { function createWalkControls(camera, domElement, opts) {
const o = Object.assign({ eye: 1.65, speed: 1.68, run: 3.84, turn: Math.PI / 2, blocked: () => false, floorAt: () => 0 }, opts || {}); const o = Object.assign({ eye: 1.65, speed: 2.1, run: 4.8, turn: Math.PI / 2, blocked: () => false, floorAt: () => 0 }, opts || {});
const keys = new Set(); const keys = new Set();
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'];
@@ -61,7 +44,6 @@ function createWalkControls(camera, domElement, opts) {
camera.lookAt(camera.position.clone().add(dir)); camera.lookAt(camera.position.clone().add(dir));
} }
// Stap met botsen: eerst de hele stap, anders per as (langs een muur glijden).
function step(dx, dz) { function step(dx, dz) {
if (!o.blocked(x + dx, z + dz)) { x += dx; z += dz; return; } if (!o.blocked(x + dx, z + dz)) { x += dx; z += dz; return; }
if (!o.blocked(x + dx, z)) x += dx; if (!o.blocked(x + dx, z)) x += dx;
@@ -72,17 +54,10 @@ 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;
@@ -92,9 +67,9 @@ 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) * speedFactor * t / Math.hypot(f, r); const v = (keys.has('shift') ? o.run : o.speed) * t / Math.hypot(f, r);
const fx = Math.sin(yaw), fz = Math.cos(yaw); // vooruit const fx = Math.sin(yaw), fz = Math.cos(yaw);
const rx = -fz, rz = fx; // rechts (kijkend langs +Z is rechts -X) const rx = -fz, rz = fx;
step((fx * f + rx * r) * v, (fz * f + rz * r) * v); step((fx * f + rx * r) * v, (fz * f + rz * r) * v);
} }
apply(); apply();
+199
View File
@@ -0,0 +1,199 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.CostCsv = (() => {
const BALK_RE = /^hout\.balk\.(\d+)x(\d+)$/;
const LISTS = {
intern: { url: 'kosten/intern.csv', label: 'Intern', pricesOnly: false },
leveranciers: { url: 'kosten/leveranciers.csv', label: 'Leveranciers', pricesOnly: true }
};
const listeners = [];
const empty = () => ({ source: null, name: null, loadedAt: null, values: {}, unknown: [], errors: [] });
const state = { intern: empty(), leveranciers: empty() };
function splitLine(line, sep) {
const out = [];
let cur = '', q = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (q) {
if (ch === '"' && line[i + 1] === '"') { cur += '"'; i++; }
else if (ch === '"') q = false;
else cur += ch;
} else if (ch === '"') q = true;
else if (ch === sep) { out.push(cur.trim()); cur = ''; }
else cur += ch;
}
out.push(cur.trim());
return out;
}
function detectSep(header) {
const n = (c) => header.split(c).length;
return [';', '\t', ','].reduce((best, c) => n(c) > n(best) ? c : best, ';');
}
function parseNumber(s) {
let t = String(s || '').replace(/[^\d,.\-]/g, '');
if (!t) return NaN;
const c = t.lastIndexOf(','), d = t.lastIndexOf('.');
if (c >= 0 && d >= 0) t = c > d ? t.replace(/\./g, '').replace(',', '.') : t.replace(/,/g, '');
else if (c >= 0) t = t.replace(',', '.');
return parseFloat(t);
}
const normUnit = (u) => String(u || '').toLowerCase().replace(/\s+/g, '').replace(/^eur(o)?/, '€').replace(/\.$/, '').replace('m2', 'm²').replace('m3', 'm³');
const COLS = {
code: ['code', 'sleutel', 'id'],
value: ['waarde', 'prijs', 'value', 'price'],
unit: ['eenheid', 'unit'],
label: ['omschrijving', 'naam', 'description'],
supplier: ['leverancier', 'supplier'],
article: ['artikelnummer', 'artikel', 'sku'],
updated: ['bijgewerkt', 'datum', 'date', 'updated']
};
const isPrice = (it) => !it.options && /^€/.test(it.unit || '') && it.unit !== '€/u';
function itemFor(code) {
if (BALK_RE.test(code)) return { unit: '€/m', price: true };
const [g, k] = code.split('.');
const it = window.COST_DEFAULTS[g] && window.COST_DEFAULTS[g].items[k];
return it ? Object.assign({ price: isPrice(it) }, it) : null;
}
function optionKey(it, s) {
const t = String(s || '').trim().toLowerCase();
return Object.keys(it.options).find(k => k.toLowerCase() === t || String(it.options[k]).toLowerCase() === t);
}
function parse(text, list) {
const pricesOnly = LISTS[list].pricesOnly;
const lines = String(text).replace(/^/, '').split(/\r?\n/);
const hi = lines.findIndex(l => l.trim() && !l.trim().startsWith('#'));
if (hi < 0) return { values: {}, unknown: [], errors: [] };
const sep = detectSep(lines[hi]);
const head = splitLine(lines[hi], sep).map(h => h.toLowerCase());
const col = {};
Object.entries(COLS).forEach(([k, names]) => { col[k] = head.findIndex(h => names.includes(h)); });
if (col.code < 0 || col.value < 0) throw new Error('kolommen "code" en "waarde" (of "prijs") ontbreken');
const values = {}, unknown = [], errors = [];
for (let i = hi + 1; i < lines.length; i++) {
if (!lines[i].trim() || lines[i].trim().startsWith('#')) continue;
const f = splitLine(lines[i], sep), get = (k) => col[k] >= 0 ? (f[col[k]] || '') : '';
const code = get('code'), raw = get('value'), line = i + 1;
if (!code) continue;
const it = itemFor(code);
if (!it) { unknown.push({ code, line, label: get('label') }); continue; }
if (it.price !== pricesOnly) { errors.push(`regel ${line}: ${code} hoort in ${it.price ? 'leveranciers.csv' : 'intern.csv'}`); continue; }
let value;
if (it.options) {
value = optionKey(it, raw);
if (value === undefined) { errors.push(`regel ${line}: ${code}: "${raw}" is geen keuze (${Object.keys(it.options).join(', ')})`); continue; }
} else {
value = parseNumber(raw);
if (!(isFinite(value) && value >= 0)) { errors.push(`regel ${line}: ${code}: geen geldig getal "${raw}"`); continue; }
if (get('unit') && normUnit(get('unit')) !== normUnit(it.unit)) { errors.push(`regel ${line}: ${code}: eenheid "${get('unit')}", verwacht ${it.unit}`); continue; }
}
const prev = values[code];
if (prev && (!pricesOnly || prev.value <= value)) {
if (!pricesOnly) errors.push(`regel ${line}: ${code} staat ook op regel ${prev.line}; die telt`);
continue;
}
values[code] = { value, unit: it.unit || '', label: get('label'), supplier: get('supplier'), article: get('article'), updated: get('updated'), line };
}
return { values, unknown, errors };
}
const notify = () => listeners.forEach(fn => fn());
function apply(list, text, source, name, loadedAt) {
try {
state[list] = Object.assign({ source, name, loadedAt }, parse(text, list));
} catch (e) {
state[list] = Object.assign(empty(), { source, name, loadedAt, errors: [e.message] });
}
}
function loadList(list) {
const url = LISTS[list].url;
return fetch(url, { cache: 'no-store' })
.then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
.catch(() => null);
}
function load() {
return Promise.all(Object.keys(LISTS).map(loadList)).then(notify);
}
function loadText(list, text, name) {
parse(text, list);
apply(list, text, 'tijdelijk', name, new Date().toISOString());
notify();
}
function reload(list) {
state[list] = empty();
return loadList(list).then(notify);
}
const entry = (list, code) => state[list].values[code] || null;
const labelOf = (code, fallback) => (entry('intern', code) || entry('leveranciers', code) || {}).label || fallback;
const balkKey = (w, h) => `${Math.round(Math.min(w, h))}x${Math.round(Math.max(w, h))}`;
function overlay(list, values) {
Object.entries(state[list].values).forEach(([code, e]) => {
const [g, k] = code.split('.');
if (values[g] && k in values[g]) values[g][k] = e.value;
});
return values;
}
function balken(list) {
const out = {};
Object.entries(state[list].values).forEach(([code, e]) => {
const m = code.match(BALK_RE);
if (m) out[balkKey(+m[1], +m[2])] = e.value;
});
return out;
}
function status(list) {
const s = state[list];
return { url: LISTS[list].url, label: LISTS[list].label, source: s.source, name: s.name, loadedAt: s.loadedAt, count: Object.keys(s.values).length, unknown: s.unknown.slice(), errors: s.errors.slice() };
}
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
function template(list, values, balkMaten) {
const pricesOnly = LISTS[list].pricesOnly;
const num = (v) => typeof v === 'number' ? String(Math.round(v * 10000) / 10000).replace('.', ',') : v;
const rows = [pricesOnly ? ['code', 'omschrijving', 'eenheid', 'prijs', 'leverancier', 'artikelnummer', 'bijgewerkt'] : ['code', 'omschrijving', 'eenheid', 'waarde']];
const row = (code, label, unit, v) => {
const e = entry(list, code) || {};
rows.push(pricesOnly ? [code, label, unit, num(v), e.supplier || '', e.article || '', e.updated || ''] : [code, label, unit, num(v)]);
};
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => Object.entries(grp.items).forEach(([k, it]) => {
if (pricesOnly === isPrice(it)) row(`${g}.${k}`, labelOf(`${g}.${k}`, it.label), it.unit || '', values[g][k]);
}));
if (!pricesOnly) return finish(rows);
const have = balken(list), maten = (balkMaten || []).concat(Object.keys(have).map(k => k.split('x').map(Number)));
const seen = new Set();
maten.forEach(([w, h]) => {
const key = balkKey(w, h);
if (seen.has(key)) return;
seen.add(key);
const perM = have[key] !== undefined ? have[key] : w * h / 1e6 * values.hout.constructieM3;
row(`hout.balk.${key}`, labelOf(`hout.balk.${key}`, `Constructiehout vuren C24 ${key.replace('x', '×')}`), '€/m', perM);
});
return finish(rows);
}
function changes(values, base) {
const num = (v) => typeof v === 'number' ? String(Math.round(v * 10000) / 10000).replace('.', ',') : v;
const rows = [['code', 'omschrijving', 'eenheid', 'waarde', 'standaard']];
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => Object.entries(grp.items).forEach(([k, it]) => {
if (isPrice(it) || values[g][k] === base[g][k]) return;
rows.push([`${g}.${k}`, labelOf(`${g}.${k}`, it.label), it.unit || '', num(values[g][k]), num(base[g][k])]);
}));
return rows.length > 1 ? finish(rows) : null;
}
// BOM: Excel/LibreOffice herkennen dan UTF-8 (€, ², ×).
function finish(rows) {
const q = (s) => /[;"\n]/.test(s) ? `"${String(s).replace(/"/g, '""')}"` : s;
return '\uFEFF' + rows.map(r => r.map(q).join(';')).join('\n') + '\n';
}
return { LISTS, isPrice, load, loadText, reload, entry, labelOf, overlay, balken, balkKey, status, onChange, template, changes, parse };
})();
CostCsv.load();
+49 -27
View File
@@ -4,30 +4,13 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================ // Alleen noodwaarden: de echte waarden staan in kosten/intern.csv en kosten/leveranciers.csv.
// 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 = { window.COST_DEFAULTS = {
arbeid: { arbeid: {
label: 'Arbeid', label: 'Arbeid',
items: { items: {
uurloon: { label: 'Uurloon productie (all-in)', unit: '€/u', value: 45 }, uurloon: { label: 'Uurloon productie (all-in)', unit: '€/u', value: 55 },
bezetting: { label: 'Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel', unit: 'man', value: 2.2 }, 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 }, 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 } qa: { label: 'QA (laatste aanpassingen, klantcontact) over de totale werktijd', unit: '%', value: 4 }
@@ -38,7 +21,6 @@ window.COST_DEFAULTS = {
label: 'Zagen', label: 'Zagen',
items: { items: {
methode: { label: 'Zaagmethode', options: { robot: 'Zaagrobot', hand: 'Handmatig' }, value: 'robot' }, 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 }, robotSnede: { label: 'Robot: tijd per haakse snede', unit: 's', value: 5 },
robotSchuin: { label: 'Robot: extra tijd per schuine snede', unit: 's', value: 2.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 }, robotStuk: { label: 'Robot: handling per stuk (invoer, uitvoer, etiket)', unit: 's', value: 15 },
@@ -111,7 +93,9 @@ window.COST_DEFAULTS = {
rabatM2: { label: 'Rabat (gevelbekleding)', unit: '€/m²', value: 35 }, rabatM2: { label: 'Rabat (gevelbekleding)', unit: '€/m²', value: 35 },
rabatMontage: { label: 'Montage rabat', unit: 's/m²', value: 600 }, rabatMontage: { label: 'Montage rabat', unit: 's/m²', value: 600 },
steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 }, steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 },
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 } steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 },
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 }
} }
}, },
@@ -131,7 +115,12 @@ window.COST_DEFAULTS = {
onderplaatM2: { label: 'Onderplaat boeideel', unit: '€/m²', value: 30 }, onderplaatM2: { label: 'Onderplaat boeideel', unit: '€/m²', value: 30 },
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 }, onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 }, daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 } daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 },
staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 },
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 },
nokVulhoutMontage: { label: 'Vulhout in de holling van de nokbalk aanbrengen (beide zijden)', unit: 's/m', value: 240 },
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 }
} }
}, },
@@ -145,16 +134,51 @@ window.COST_DEFAULTS = {
} }
}, },
afwerking: {
label: 'Afwerking ruimtes',
items: {
valsPlafondM2: { label: 'Vals plafond (regelwerk + gipsplaat, afgewerkt)', unit: '€/m²', value: 38 },
valsPlafondMontage: { label: 'Montage vals plafond', unit: 's/m²', value: 1800 },
fiboM2: { label: 'FIBO-wandpaneel (incl. profielen en kit)', unit: '€/m²', value: 70 },
fiboMontage: { label: 'Montage FIBO-wandpaneel', unit: 's/m²', value: 1500 }
}
},
kozijnen: { kozijnen: {
label: 'Kozijnen', label: 'Kozijnen',
items: { items: {
raamM2: { label: 'Raamkozijn incl. glas', unit: '€/m²', value: 450 }, raamM2: { label: 'Raamkozijn incl. glas', unit: '€/m²', value: 450 },
deur: { label: 'Binnen-/buitendeur incl. kozijn', unit: '€/st.', value: 900 }, deur: { label: 'Binnen-/buitendeur incl. kozijn', unit: '€/st.', value: 900 },
voordeur: { label: 'Voordeur incl. kozijn', unit: '€/st.', value: 1800 }, voordeur: { label: 'Voordeur incl. kozijn', unit: '€/st.', value: 1800 },
schuifdeurBlad: { label: 'Schuifdeur per blad (blad, rail of pocket, beslag, aftimmering)', unit: '€/st.', value: 650 },
montage: { label: 'Montage per kozijn', unit: 's', value: 5400 } montage: { label: 'Montage per kozijn', unit: 's', value: 5400 }
} }
}, },
inrichting: {
label: 'Inrichting',
items: {
personeel: { label: 'Personeel bij inrichting (installateur/monteur, i.p.v. de bezetting)', unit: 'man', value: 1 },
toilet: { label: 'Wandcloset met inbouwreservoir, voorzetwand en bedieningsplaat', unit: '€/st.', value: 650 },
toiletMontage: { label: 'Montage wandcloset (reservoir, voorzetwand, afmonteren)', unit: 's', value: 10800 },
fontein: { label: 'Fontein met kraan en sifon', unit: '€/st.', value: 220 },
fonteinMontage: { label: 'Montage fontein', unit: 's', value: 3600 },
wastafel: { label: 'Badmeubel met wastafel, kraan en spiegel', unit: '€/st.', value: 850 },
wastafelMontage: { label: 'Montage badmeubel', unit: 's', value: 5400 },
douche: { label: 'Inloopdouche (douchegoot, glaswand, regendouche-set)', unit: '€/st.', value: 1450 },
doucheMontage: { label: 'Montage inloopdouche (goot, afschot, glaswand, kraan)', unit: 's', value: 14400 },
keukenM: { label: 'Keukenblok (kasten, werkblad, spoelbak, apparatuur) per strekkende meter', unit: '€/m', value: 1500 },
keukenMontage: { label: 'Montage keukenblok', unit: 's/m', value: 3600 },
kookeilandM: { label: 'Kookeiland (kasten, werkblad) per strekkende meter', unit: '€/m', value: 1300 },
kookeilandMontage: { label: 'Montage kookeiland', unit: 's/m', value: 3600 },
kookplaat: { label: 'Inductiekookplaat 4 zones', unit: '€/st.', value: 650 },
kastM: { label: 'Kledingkast / inbouwkast per strekkende meter', unit: '€/m', value: 550 },
kastMontage: { label: 'Montage kast', unit: 's/m', value: 1800 },
boiler: { label: 'Elektrische boiler 80 l incl. inlaatcombinatie', unit: '€/st.', value: 600 },
boilerMontage: { label: 'Montage boiler', unit: 's', value: 7200 }
}
},
verlichting: { verlichting: {
label: 'Verlichting', label: 'Verlichting',
items: { items: {
@@ -165,8 +189,6 @@ window.COST_DEFAULTS = {
} }
}, },
// Indicatieve groothandelsprijzen (excl. btw) en tijden; zie ElektraModel
// voor de hoeveelheden (strekkende meters per leidingtype, boorgaten, dozen).
elektra: { elektra: {
label: 'Elektra', label: 'Elektra',
items: { items: {
@@ -216,9 +238,9 @@ window.COST_DEFAULTS = {
bedrijf: { bedrijf: {
label: 'Afschrijving & bedrijfskosten', label: 'Afschrijving & bedrijfskosten',
items: { items: {
machines: { label: 'Afschrijving machines (zaagrobot, gereedschap, inrichting) per productie-uur', unit: '€/u', value: 8 }, machines: { label: 'Afschrijving machines (zaagrobot, gereedschap, inrichting) per uur', unit: '€/u', value: 8 },
slijtage: { label: 'Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per productie-uur', unit: '€/u', value: 2 }, slijtage: { label: 'Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per uur', unit: '€/u', value: 2 },
huisvesting: { label: 'Huisvesting productiehal (huur, energie) per productie-uur', unit: '€/u', value: 6 }, huisvesting: { label: 'Huisvesting productiehal (huur, energie) per uur', unit: '€/u', value: 6 },
transportUnit: { label: 'Transport per unit naar de bouwplaats', unit: '€/unit', value: 750 }, transportUnit: { label: 'Transport per unit naar de bouwplaats', unit: '€/unit', value: 750 },
kraan: { label: 'Kraan voor plaatsen woning', unit: '€', value: 1200 } kraan: { label: 'Kraan voor plaatsen woning', unit: '€', value: 1200 }
} }
+84 -86
View File
@@ -4,38 +4,20 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * 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 = (() => { window.CostModel = (() => {
const STORAGE_KEY = 'tectum.costs';
const listeners = []; const listeners = [];
let session = {};
try { localStorage.removeItem('tectum.costs'); localStorage.removeItem('tectum.leveranciers'); } catch (e) { /* oude browseropslag */ }
// ------------------------------------------------------------
// Instellingen: standaard (COST_DEFAULTS) + eigen waarden (localStorage).
// values = { groep: { sleutel: waarde } }.
// ------------------------------------------------------------
function defaultValues() { function defaultValues() {
const v = {}; const v = {};
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => { Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => {
v[g] = {}; v[g] = {};
Object.entries(grp.items).forEach(([k, it]) => { v[g][k] = it.value; }); Object.entries(grp.items).forEach(([k, it]) => { v[g][k] = it.value; });
}); });
return v; return CostCsv.overlay('intern', v);
} }
// Alleen bekende sleutels met een geldig type overnemen.
function mergeValues(base, over) { function mergeValues(base, over) {
const v = JSON.parse(JSON.stringify(base)); const v = JSON.parse(JSON.stringify(base));
Object.entries(over || {}).forEach(([g, items]) => { Object.entries(over || {}).forEach(([g, items]) => {
@@ -49,36 +31,40 @@ window.CostModel = (() => {
}); });
return v; return v;
} }
function readOverrides() { const listed = (g, k) => !!CostCsv.entry('leveranciers', `${g}.${k}`);
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; } catch (e) { return {}; } function getSettings(opts) {
const own = mergeValues(defaultValues(), session);
const v = opts && opts.leveranciers === false ? own : CostCsv.overlay('leveranciers', own);
v.balken = CostCsv.balken('leveranciers');
return v;
} }
function getSettings() { return mergeValues(defaultValues(), readOverrides()); }
// Slaat alleen de afwijkingen van de standaard op.
function saveSettings(values) { function saveSettings(values) {
const def = defaultValues(), diff = {}; const def = defaultValues(), diff = {};
Object.entries(mergeValues(def, values)).forEach(([g, items]) => Object.entries(items).forEach(([k, val]) => { 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; if (val !== def[g][k] && !listed(g, k)) (diff[g] = diff[g] || {})[k] = val;
})); }));
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(diff)); } catch (e) { /* geen opslag: alleen deze sessie */ } session = diff;
listeners.forEach(fn => fn()); 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); }; } function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
CostCsv.onChange(() => listeners.forEach(fn => fn()));
// Export/import (JSON-bestand): altijd de volledige set waarden. function importFile(text) {
function exportJson(values) { if (/^\s*\{/.test(text)) {
return { type: 'tectum-costs', version: 1, exportedAt: new Date().toISOString(), values: mergeValues(defaultValues(), values) }; const obj = JSON.parse(text);
}
function importJson(obj) {
if (!obj || obj.type !== 'tectum-costs' || typeof obj.values !== 'object') throw new Error('geen Tectum-kostenbestand'); if (!obj || obj.type !== 'tectum-costs' || typeof obj.values !== 'object') throw new Error('geen Tectum-kostenbestand');
return mergeValues(defaultValues(), obj.values); return mergeValues(defaultValues(), obj.values);
} }
const r = CostCsv.parse(text, 'intern'), over = {};
Object.entries(r.values).forEach(([code, e]) => {
const [g, k] = code.split('.');
if (window.COST_DEFAULTS[g]) (over[g] = over[g] || {})[k] = e.value;
});
return mergeValues(defaultValues(), over);
}
// ------------------------------------------------------------ const CAT_ORDER = ['Hout', 'Staal', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Afwerking', 'Inrichting', 'Verlichting', 'Elektra'];
// Berekening const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Afwerking', 'Inrichting', 'Verlichting', 'Elektra'];
// ------------------------------------------------------------
const CAT_ORDER = ['Hout', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting', 'Elektra'];
const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting', 'Elektra'];
const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl']; const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl'];
const DORPEL_KINDS = ['bovendorpel', 'onderdorpel']; const DORPEL_KINDS = ['bovendorpel', 'onderdorpel'];
@@ -86,20 +72,16 @@ window.CostModel = (() => {
const S = settings || getSettings(); const S = settings || getSettings();
const elements = []; const elements = [];
const newEl = (element, label, unit) => { const el = { element, label, unit, lines: [] }; elements.push(el); return el; }; 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 // sec = tijd voor één persoon; opgeslagen sec = manuren (× crew).
// de gemiddelde bezetting per productiestap). Opgeslagen sec = manuren.
const add = (el, cat, act, label, qty, unit, mat, sec, crew) => { const add = (el, cat, act, label, qty, unit, mat, sec, crew) => {
if (!(qty > 0)) return; if (!(qty > 0)) return;
const c = sec ? (crew !== undefined ? crew : S.arbeid.bezetting) : 0; const c = sec ? (crew !== undefined ? crew : S.arbeid.bezetting) : 0;
// clock = doorlooptijd van de stap (s), voor afschrijving/huisvesting. // clock = doorlooptijd van de stap (klokuren), voor afschrijving/huisvesting.
el.lines.push({ cat, act, label, qty, unit, mat: mat || 0, sec: (sec || 0) * c, clock: c ? sec : 0, crew: c }); 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 Z = S.zagen, V = S.verbinden, P = S.plaat;
const sawFactor = Z.methode === 'hand' ? Z.handFactor : 1; 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 saw = { pieces: 0, cuts: 0, angled: 0, robotSec: 0 };
const sawSec = (cutAngles, count = 1) => { const sawSec = (cutAngles, count = 1) => {
const ca = cutAngles || [0, 0], angled = ca.filter(a => a !== 0).length; const ca = cutAngles || [0, 0], angled = ca.filter(a => a !== 0).length;
@@ -109,39 +91,40 @@ window.CostModel = (() => {
}; };
const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)'; const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)';
const jointSec = V.eersteSchroef + Math.max(0, V.schroevenPerVerbinding - 1) * V.volgendeSchroef; 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 addJoints = (el, joints, label, roof) => {
const n = joints * V.schroevenPerVerbinding; const n = joints * V.schroevenPerVerbinding;
const f = V.monteerFactor * (roof ? V.dakFactor : 1); 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); add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel);
}; };
// Plaat: materiaal + montage + plaatschroeven.
const addSheet = (el, cat, label, m2, priceM2, montageSecM2) => { const addSheet = (el, cat, label, m2, priceM2, montageSecM2) => {
if (!(m2 > 0)) return; if (!(m2 > 0)) return;
add(el, cat, 'Plaatwerk', label, m2, 'm²', m2 * priceM2, m2 * montageSecM2); add(el, cat, 'Plaatwerk', label, m2, 'm²', m2 * priceM2, m2 * montageSecM2);
const n = Math.ceil(m2 * P.schroevenPerM2); const n = Math.ceil(m2 * P.schroevenPerM2);
add(el, 'Schroeven', 'Plaatwerk', `Plaatschroeven ${label.toLowerCase()}`, n, 'st.', n * P.plaatschroefPer1000 / 1000, n * P.tijdPerPlaatschroef); 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 waste = totals.purchaseStockMM > 0 ? totals.purchaseWasteMM / Math.max(1, totals.purchaseStockMM - totals.purchaseWasteMM) : 0;
const balken = S.balken || {};
const woodCost = (w, h, lengthM) => {
const perM = balken[CostCsv.balkKey(w, h)];
return perM !== undefined ? lengthM * perM : w * h / 1e6 * lengthM * S.hout.constructieM3;
};
const joistWoodCost = (m3, sec) => sec && sec.joistW && sec.joistH ? woodCost(sec.joistW, sec.joistH, m3 / (sec.joistW * sec.joistH / 1e6)) : m3 * S.hout.constructieM3;
const fmtM = (v) => v.toLocaleString('nl-NL', { maximumFractionDigits: 1 }); 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) const seenOpenings = new Set(); // naadwand: openingen één keer (beide delen hebben dezelfde deur)
function wallElement(p, unitId) { function wallElement(p, unitId) {
const el = newEl(p.element, p.label, unitId); const el = newEl(p.element, p.label, unitId);
const bom = WallModel.computeBOM(p.config, p.frame); const bom = WallModel.computeBOM(p.config, p.frame);
const woodM3 = bom.reduce((a, b) => a + b.width * b.depth * b.length, 0) / 1e9; 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 woodMat = bom.reduce((a, b) => a + woodCost(b.width, b.depth, b.length / 1000), 0);
add(el, 'Hout', 'Zagen', `Regelwerk ${bom.length} st.`, woodM3 * (1 + waste), 'm³', woodMat * (1 + waste), 0);
const cuts = bom.reduce((a, b) => a + (b.cutAngles || [0, 0]).length, 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)); 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 verticals = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).length;
const dorpels = bom.filter(b => DORPEL_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; const topPlates = bom.filter(b => b.kind === 'bovenregel').length;
addJoints(el, 2 * verticals + 2 * dorpels + Math.max(0, topPlates - 1), 'Stijlen/dorpels'); 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); const studs = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).sort((a, b) => a.x - b.x);
let dblScrews = 0, dblPairs = 0; let dblScrews = 0, dblPairs = 0;
for (let i = 1; i < studs.length; i++) { for (let i = 1; i < studs.length; i++) {
@@ -152,10 +135,10 @@ window.CostModel = (() => {
dblPairs++; dblPairs++;
dblScrews += Math.max(2, Math.floor(ov / V.dubbelAfstand)); 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.', add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}×`, dblScrews, 'st.',
dblScrews * V.prijsPer1000 / 1000, dblScrews * V.volgendeSchroef * V.monteerFactor, V.personeel); dblScrews * V.prijsPer1000 / 1000, dblScrews * V.volgendeSchroef * V.monteerFactor, V.personeel);
const M = S.montage; const M = S.montage;
add(el, 'Hout', 'Wanden plaatsen', `Plaatsen en vastzetten (${M.muurVastzetten} min)`, 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel); add(el, 'Hout', 'Wanden plaatsen', 'Plaatsen en vastzetten', 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel);
const st = p.stats || {}; const st = p.stats || {};
addSheet(el, 'Plaatwerk', 'Spaanplaat', (st.spaanplaatAreaM2 || 0) + (st.spaanplaatBuitenAreaM2 || 0), P.spaanplaatM2, P.spaanplaatMontage); addSheet(el, 'Plaatwerk', 'Spaanplaat', (st.spaanplaatAreaM2 || 0) + (st.spaanplaatBuitenAreaM2 || 0), P.spaanplaatM2, P.spaanplaatMontage);
@@ -168,14 +151,14 @@ window.CostModel = (() => {
const G = S.gevel; const G = S.gevel;
const doek = st.doekAreaM2 || 0; const doek = st.doekAreaM2 || 0;
// Montage per strekkende meter wand (breedte van het stuk).
const doekM = doek > 0 ? p.place.width / 1000 : 0; 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); 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; const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage); add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0); const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage); add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
const stuc = st.stucAreaM2 || 0;
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0));
const latM = st.battenLengthM || 0; 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, 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); (st.battenPieces || 0) * S.hout.latStukMontage + latM * S.hout.latMeterMontage);
@@ -185,7 +168,11 @@ window.CostModel = (() => {
if (!opKey || !seenOpenings.has(opKey)) { if (!opKey || !seenOpenings.has(opKey)) {
if (opKey) seenOpenings.add(opKey); if (opKey) seenOpenings.add(opKey);
(p.config.openings || []).forEach(op => { (p.config.openings || []).forEach(op => {
if (op.type === 'raam') { const fill = KozijnModel.normalize(op.fill);
if (fill && fill.type === 'schuifdeur') {
add(el, 'Kozijnen', 'Kozijnen', `Schuifdeur ${op.w}×${op.h} (${fill.leaves} blad${fill.leaves > 1 ? 'en' : ''}, ${fill.mount === 'opbouw' ? 'op de wand' : 'in de wand'})`,
fill.leaves, 'blad', fill.leaves * (K.schuifdeurBlad || K.deur), K.montage);
} else if (op.type === 'raam') {
const m2 = WallModel.openingArea(op) / 1e6; const m2 = WallModel.openingArea(op) / 1e6;
add(el, 'Kozijnen', 'Kozijnen', `Raam ${op.w}×${op.h}`, m2, 'm²', m2 * K.raamM2, K.montage); add(el, 'Kozijnen', 'Kozijnen', `Raam ${op.w}×${op.h}`, m2, 'm²', m2 * K.raamM2, K.montage);
} else { } else {
@@ -197,7 +184,6 @@ window.CostModel = (() => {
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => wallElement(p, u.id)))); 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)); parts.interior.forEach(p => wallElement(p, p.seamUnit || null));
// --- Vloer- en dakbalken (balklaag: zagen + verbindingen) ---
function joistLayer(el, cd, angleDeg, what, roof) { function joistLayer(el, cd, angleDeg, what, roof) {
const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length; const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length;
const n = cd.joists.length; const n = cd.joists.length;
@@ -207,25 +193,21 @@ window.CostModel = (() => {
} }
parts.units.forEach(u => { parts.units.forEach(u => {
// Vloer
const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id); const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id);
const fs = u.floorSpecs; const fs = u.floorSpecs;
add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', fs.joistWoodM3 * S.hout.constructieM3, 0); add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', joistWoodCost(fs.joistWoodM3, u.floorData.calcData), 0);
joistLayer(fl, u.floorData.calcData, 0, 'Vloer'); joistLayer(fl, u.floorData.calcData, 0, 'Vloer');
addSheet(fl, 'Plaatwerk', 'OSB vloer', fs.osbAreaM2, P.osbM2, P.osbMontage); 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); 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) 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); + 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; 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); 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 rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
const rs = u.roof.specs, D = S.dak; const rs = u.roof.specs, D = S.dak;
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', rs.joistWoodM3 * S.hout.constructieM3, 0); add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] && u.roof.planes[0].data.calcData), 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)); 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; const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
if (cap) { if (cap) {
const n = cap.beams.length; const n = cap.beams.length;
@@ -238,9 +220,16 @@ window.CostModel = (() => {
add(rf, 'Dak', 'Dak', 'Dakleer (EPDM)', rs.dakleerM2 || 0, 'm²', (rs.dakleerM2 || 0) * D.dakleerM2, (rs.dakleerM2 || 0) * D.dakleerMontage); 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); add(rf, 'Dak', 'Dak', 'Riet', rs.rietM2 || 0, 'm²', (rs.rietM2 || 0) * D.rietM2, (rs.rietM2 || 0) * D.rietMontage);
add(rf, 'Dak', 'Dak', 'Felsplaten (staande naad)', rs.felsM2 || 0, 'm²', (rs.felsM2 || 0) * D.felsM2, (rs.felsM2 || 0) * D.felsMontage); add(rf, 'Dak', 'Dak', 'Felsplaten (staande naad)', rs.felsM2 || 0, 'm²', (rs.felsM2 || 0) * D.felsM2, (rs.felsM2 || 0) * D.felsMontage);
const rb = u.roof.unitRoof && u.roof.unitRoof.ridgeBeam;
if (rb) {
const lm = rb.length / 1000;
add(rf, 'Staal', 'Dak', `Stalen nokbalk ${rb.profile.name}, ${fmtM(lm)} m (+ 2 opleggingen)`, rb.steelKg, 'kg',
rb.steelKg * D.staalKg + 2 * D.nokOplegging, D.nokPlaatsen, D.nokPersoneel);
add(rf, 'Hout', 'Dak', `Vulhout holling nokbalk 2× ${Math.round(rb.fillW)}×${Math.round(rb.fillH)}`, rb.fillM3, 'm³',
woodCost(Math.round(rb.fillW), Math.round(rb.fillH), 2 * lm), lm * D.nokVulhoutMontage);
}
}); });
// --- Woning-breed: dakrand, sandwichpanelen, verlichting ---
const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null); const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null);
const D = S.dak; const D = S.dak;
if (totals.roofEdge) { if (totals.roofEdge) {
@@ -259,10 +248,6 @@ window.CostModel = (() => {
add(wo, 'Verlichting', 'Verlichting', 'Boeideel-spots', spots, 'st.', spots * L.boeideelSpot, spots * L.montage); 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); add(wo, 'Verlichting', 'Verlichting', 'Binnenspots', lm.indoor, 'st.', lm.indoor * L.binnenspot, lm.indoor * L.montage);
// --- Elektra (ElektraModel): per unit de leidingen (strekkende meters
// per type: buis + draad per ader, of XMvK), boorgaten, steekverbinders,
// dozen en aansluitingen; meterkast, groepen en keuring bij de unit waar
// de meterkast hangt. Arbeid met E.personeel man.
const elk = parts.elektra, E = S.elektra; const elk = parts.elektra, E = S.elektra;
if (elk && elk.enabled) { if (elk && elk.enabled) {
const unitIds = new Set(parts.units.map(u => u.id)), elOf = {}; const unitIds = new Set(parts.units.map(u => u.id)), elOf = {};
@@ -287,10 +272,10 @@ window.CostModel = (() => {
const c = ElektraModel.cableInfo(type), m = a.types[type]; const c = ElektraModel.cableInfo(type), m = a.types[type];
if (c.conduit) { if (c.conduit) {
const draad = c.mm2 >= 2.5 ? E.draad25M : E.draad15M; const draad = c.mm2 >= 2.5 ? E.draad25M : E.draad15M;
ea(uid, `${c.label}: ${c.use.toLowerCase()} (buis + ${c.cores.length} aders)`, m, 'm', m * (buisPrijs(c.conduit) + c.cores.length * draad), m * (E.buisLeggen + E.draadTrekken)); ea(uid, `${c.label}: ${c.use.toLowerCase()}`, m, 'm', m * (buisPrijs(c.conduit) + c.cores.length * draad), m * (E.buisLeggen + E.draadTrekken));
} else ea(uid, `${c.label}: ${c.use.toLowerCase()}`, m, 'm', m * E.xmvkM, m * E.kabelLeggen); } else ea(uid, `${c.label}: ${c.use.toLowerCase()}`, m, 'm', m * E.xmvkM, m * E.kabelLeggen);
}); });
ea(uid, `Boorgaten stijlen/regels/balken (${E.boorgat} s per gat)`, a.holes, 'st.', 0, a.holes * E.boorgat); ea(uid, 'Boorgaten stijlen/regels/balken', a.holes, 'st.', 0, a.holes * E.boorgat);
Object.keys(a.cross).forEach(n => { Object.keys(a.cross).forEach(n => {
const k = a.cross[n]; const k = a.cross[n];
ea(uid, `Steekverbinder module-naad ${n}-polig`, k, 'set', k * (Number(n) >= 5 ? E.steekverbinder5 : E.steekverbinder3), k * E.steekverbinderMontage); ea(uid, `Steekverbinder module-naad ${n}-polig`, k, 'set', k * (Number(n) >= 5 ? E.steekverbinder5 : E.steekverbinder3), k * E.steekverbinderMontage);
@@ -326,7 +311,26 @@ window.CostModel = (() => {
} }
} }
// --- Optellen --- const AF = S.afwerking, rmOf = {};
if (AF && parts.rooms) parts.rooms.rooms.filter(r => r.ceilM2 > 0 || r.fiboM2 > 0).forEach(r => {
const el = rmOf[r.unitId] || (rmOf[r.unitId] = newEl(`${r.unitId}-RU`, `Unit ${r.unitId} ruimtes`, r.unitId));
add(el, 'Afwerking', 'Afwerking', `${r.name}: vals plafond ${r.ceiling} mm`, r.ceilM2, 'm²', r.ceilM2 * AF.valsPlafondM2, r.ceilM2 * AF.valsPlafondMontage);
add(el, 'Afwerking', 'Afwerking', `${r.name}: FIBO-wandpanelen`, r.fiboM2, 'm²', r.fiboM2 * AF.fiboM2, r.fiboM2 * AF.fiboMontage);
});
const IN = S.inrichting, fxOf = {};
if (IN && parts.fixtures) parts.fixtures.filter(f => f.cost).forEach(f => {
const el = fxOf[f.unitId] || (fxOf[f.unitId] = newEl(`${f.unitId}-IN`, `Unit ${f.unitId} inrichting`, f.unitId));
const m = f.w / 1000, crew = IN.personeel;
const perM = (what, price, sec) => add(el, 'Inrichting', 'Inrichting', `${what} ${f.w} mm`, m, 'm', m * (price || 0), m * (sec || 0), crew);
if (f.cost === 'keuken') perM('Keukenblok', IN.keukenM, IN.keukenMontage);
else if (f.cost === 'kookeiland') {
perM('Kookeiland', IN.kookeilandM, IN.kookeilandMontage);
if (f.opts && f.opts.kookplaat) add(el, 'Inrichting', 'Inrichting', 'Inductiekookplaat', 1, 'st.', IN.kookplaat || 0, 0);
} else if (f.cost === 'kast') perM(f.label, IN.kastM, IN.kastMontage);
else add(el, 'Inrichting', 'Inrichting', f.label, 1, 'st.', IN[f.cost] || 0, IN[f.cost + 'Montage'] || 0, crew);
});
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 }); 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))); elements.forEach(el => Object.assign(el, sumLines(el.lines)));
const byKey = (key, order) => { const byKey = (key, order) => {
@@ -338,15 +342,12 @@ window.CostModel = (() => {
})); }));
return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)); return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
}; };
// Kostprijs van materiaal + directe arbeidstijd (s), overal dezelfde // Afschrijving/slijtage/huisvesting per productie-uur: klokuren (niet × personeel) + indirect, zonder QA.
// 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 O = S.opslag, B = S.bedrijf, A = S.arbeid;
const perHour = B.machines + B.slijtage + B.huisvesting; const perHour = B.machines + B.slijtage + B.huisvesting;
const split = (sec) => { const split = (sec) => {
const direct = sec / 3600, indirect = direct * A.toeslagUren / 100; const direct = sec / 3600, indirect = direct * A.toeslagUren / 100;
const qa = (direct + indirect) * A.qa / 100; // QA: % van de totale werktijd const qa = (direct + indirect) * A.qa / 100;
return { direct, indirect, qa, total: direct + indirect + qa }; return { direct, indirect, qa, total: direct + indirect + qa };
}; };
const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100); const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100);
@@ -373,10 +374,10 @@ window.CostModel = (() => {
['Materiaal', all.mat], ['Materiaal', all.mat],
[`Materiaalverlies ${pct(O.verlies)}`, all.mat * O.verlies / 100], [`Materiaalverlies ${pct(O.verlies)}`, all.mat * O.verlies / 100],
[`Klein materiaal ${pct(O.klein)}`, all.mat * O.klein / 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], [`Arbeid ${uur(h.total)} manuur × ${eur0(A.uurloon)}`, h.total * A.uurloon],
[`Afschrijving machines ${uur(h.prod)} productie-uur × ${eur(B.machines, 2)}`, h.prod * B.machines], [`Afschrijving machines ${uur(h.prod)} uur × ${eur(B.machines, 2)}`, h.prod * B.machines],
[`Slijtage gereedschap ${uur(h.prod)} productie-uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage], [`Slijtage gereedschap ${uur(h.prod)} uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage],
[`Huisvesting productiehal ${uur(h.prod)} productie-uur × ${eur(B.huisvesting, 2)}`, h.prod * B.huisvesting], [`Huisvesting productiehal ${uur(h.prod)} uur × ${eur(B.huisvesting, 2)}`, h.prod * B.huisvesting],
[`Transport ${n} unit${n === 1 ? '' : 's'} × ${eur0(B.transportUnit)}`, n * B.transportUnit], [`Transport ${n} unit${n === 1 ? '' : 's'} × ${eur0(B.transportUnit)}`, n * B.transportUnit],
['Kraan plaatsen', B.kraan] ['Kraan plaatsen', B.kraan]
]; ];
@@ -385,7 +386,6 @@ window.CostModel = (() => {
const marge = (kostprijs + algemeen) * O.marge / 100; const marge = (kostprijs + algemeen) * O.marge / 100;
const exBtw = kostprijs + algemeen + marge; const exBtw = kostprijs + algemeen + marge;
const btw = exBtw * O.btw / 100; 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 })) const rows = posts.filter(p => p[1] > 0).map(([label, amount]) => ({ label, amount }))
.concat([ .concat([
{ label: 'Kostprijs', amount: kostprijs, sub: true }, { label: 'Kostprijs', amount: kostprijs, sub: true },
@@ -402,14 +402,12 @@ window.CostModel = (() => {
}; };
} }
// Bedrag als "€ 1.234" (of met decimalen).
function eur(n, decimals = 0) { function eur(n, decimals = 0) {
return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
} }
// Uren als decimaal getal, bijv. "12,5".
function uur(hours) { function uur(hours) {
return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
} }
return { STORAGE_KEY, defaultValues, getSettings, saveSettings, resetSettings, onChange, exportJson, importJson, compute, eur, uur }; return { defaultValues, getSettings, saveSettings, onChange, importFile, compute, eur, uur };
})(); })();
+85 -36
View File
@@ -4,18 +4,12 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * 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 = (() => { window.CostSettings = (() => {
let overlay = null; let overlay = null;
const esc = UI.esc; const esc = UI.esc;
const SJABLOON_BALKEN = [[38, 235], [70, 220]];
function build() { function build() {
overlay = document.createElement('div'); overlay = document.createElement('div');
@@ -26,17 +20,20 @@ window.CostSettings = (() => {
<h2>Kosten &amp; tijden</h2> <h2>Kosten &amp; tijden</h2>
<button type="button" class="modal-close-x" data-cs="cancel">&times;</button> <button type="button" class="modal-close-x" data-cs="cancel">&times;</button>
</div> </div>
<div class="cs-pricelist"></div>
<div class="cs-body"></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="material-info">Bedragen excl. btw. De CSV's in kosten/ zijn leidend: Toepassen geldt tot herladen; om een wijziging te houden "intern.csv downloaden" en het bestand in kosten/ vervangen, of met "Wijzigingen downloaden" alleen de aangepaste velden (met de standaardwaarde ernaast) opslaan. Prijzen pas je aan in leveranciers.csv (CSV = daaruit, anders noodwaarde). Een veld dat afwijkt van intern.csv is gemarkeerd.</div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="io-btn" data-cs="reset">Standaard</button> <button type="button" class="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="export">intern.csv downloaden</button>
<button type="button" class="io-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
<button type="button" class="io-btn" data-cs="import">Importeren&hellip;</button> <button type="button" class="io-btn" data-cs="import">Importeren&hellip;</button>
<span style="flex:1"></span> <span style="flex:1"></span>
<button type="button" class="io-btn" data-cs="cancel">Annuleren</button> <button type="button" class="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> <button type="button" class="material-toggle-btn active" data-cs="save" style="flex:none; padding:6px 18px;">Toepassen</button>
</div> </div>
<input type="file" accept=".json,application/json" style="display:none"> <input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
<input type="file" data-file="leveranciers" accept=".csv,text/csv,text/plain" style="display:none">
</div>`; </div>`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { overlay.addEventListener('click', (e) => {
@@ -45,39 +42,93 @@ window.CostSettings = (() => {
if (!btn) return; if (!btn) return;
const a = btn.dataset.cs; const a = btn.dataset.cs;
if (a === 'cancel') close(); if (a === 'cancel') close();
else if (a === 'save') { // ook als JSON-bestand (om te delen/bewaren) else if (a === 'save') {
const v = readForm(); CostModel.saveSettings(readForm());
CostModel.saveSettings(v);
download(CostModel.exportJson(v));
close(); close();
} }
else if (a === 'reset') fill(CostModel.defaultValues()); else if (a === 'reset') fill(withLeveranciers(CostModel.defaultValues()));
else if (a === 'export') download(CostModel.exportJson(readForm())); else if (a === 'export') exportIntern();
else if (a === 'import') overlay.querySelector('input[type=file]').click(); else if (a === 'export-changes') exportChanges();
else if (a === 'import') overlay.querySelector('[data-file=import]').click();
else if (a === 'lev-load') overlay.querySelector('[data-file=leveranciers]').click();
else if (a === 'lev-clear') CostCsv.reload('leveranciers');
else if (a === 'lev-template') saveFile(CostCsv.template('leveranciers', readForm(), SJABLOON_BALKEN), 'leveranciers.csv');
}); });
overlay.addEventListener('input', markChanged); overlay.querySelector('[data-file=import]').addEventListener('change', (e) => {
overlay.querySelector('input[type=file]').addEventListener('change', (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
e.target.value = ''; e.target.value = '';
if (!file) return; if (!file) return;
file.text().then(txt => fill(CostModel.importJson(JSON.parse(txt)))) file.text().then(txt => fill(withLeveranciers(CostModel.importFile(txt))))
.catch(err => alert('Importeren mislukt: ' + err.message)); .catch(err => alert('Importeren mislukt: ' + err.message));
}); });
overlay.querySelector('[data-file=leveranciers]').addEventListener('change', (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
file.text().then(txt => CostCsv.loadText('leveranciers', txt, file.name))
.catch(err => alert('Leverancierslijst laden mislukt: ' + err.message));
});
CostCsv.onChange(() => {
if (!overlay.classList.contains('open')) return;
const v = readForm();
renderForm();
fill(withLeveranciers(v));
});
overlay.addEventListener('input', markChanged);
}
const levEntry = (g, k) => CostCsv.entry('leveranciers', `${g}.${k}`);
const withLeveranciers = (values) => CostCsv.overlay('leveranciers', values);
function exportIntern() {
saveFile(CostCsv.template('intern', readForm(CostModel.getSettings({ leveranciers: false }))), 'intern.csv');
}
function exportChanges() {
const text = CostCsv.changes(readForm(CostModel.getSettings({ leveranciers: false })), CostModel.defaultValues());
if (!text) return alert('Geen wijzigingen ten opzichte van intern.csv.');
const d = new Date(), p = (n) => String(n).padStart(2, '0');
saveFile(text, `wijzigingen-intern-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.csv`);
}
function renderStatus() {
const when = (st) => st.loadedAt ? new Date(st.loadedAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) : '';
const block = (list, extra) => {
const st = CostCsv.status(list);
const head = !st.source
? `<b>${esc(st.label)}:</b> geen ${esc(st.url)} gevonden`
: `<b>${esc(st.label)}:</b> ${esc(st.name || '')}${st.source === 'tijdelijk' ? ' (tijdelijk ingeladen, tot herladen)' : ''} · ${st.count} waarden · ${esc(when(st))}`;
const issues = st.errors.map(e => `<li class="err">${esc(e)}</li>`)
.concat(st.unknown.map(u => `<li>regel ${u.line}: code ${esc(u.code)}${u.label ? ` (${esc(u.label)})` : ''} wordt (nog) niet gebruikt</li>`));
return `<div class="cs-list"><div>${head}${extra ? extra(st) : ''}</div>`
+ (issues.length ? `<details><summary>${st.errors.length} fout(en), ${st.unknown.length} onbekende code(s)</summary><ul>${issues.join('')}</ul></details>` : '') + '</div>';
};
overlay.querySelector('.cs-pricelist').innerHTML = block('intern')
+ block('leveranciers', (st) => ` <button type="button" class="io-btn" data-cs="lev-load">Tijdelijk laden&hellip;</button>`
+ `<button type="button" class="io-btn" data-cs="lev-template">Sjabloon</button>`
+ (st.source === 'tijdelijk' ? `<button type="button" class="io-btn" data-cs="lev-clear">Terug naar server</button>` : ''));
} }
function renderForm() { function renderForm() {
renderStatus();
const body = overlay.querySelector('.cs-body'); const body = overlay.querySelector('.cs-body');
body.innerHTML = Object.entries(window.COST_DEFAULTS).map(([g, grp]) => ` body.innerHTML = Object.entries(window.COST_DEFAULTS).map(([g, grp]) => `
<section class="cs-group"> <section class="cs-group">
<h3>${esc(grp.label)}</h3> <h3>${esc(grp.label)}</h3>
${Object.entries(grp.items).map(([k, it]) => ` ${Object.entries(grp.items).map(([k, it]) => {
<label class="cs-row"> const lev = levEntry(g, k), int = CostCsv.entry('intern', `${g}.${k}`), price = CostCsv.isPrice(it);
<span class="cs-label">${esc(it.label)}</span> const tip = lev ? `Uit leveranciers.csv${lev.supplier ? ': ' + lev.supplier : ''}${lev.article ? ', art. ' + lev.article : ''}${lev.updated ? ', ' + lev.updated : ''} (regel ${lev.line})`
: price ? `${g}.${k}: niet in leveranciers.csv, noodwaarde uit cost-defaults.js`
: `code ${g}.${k}${int ? ` (intern.csv regel ${int.line})` : ': niet in intern.csv, noodwaarde uit cost-defaults.js'}`;
return `
<label class="cs-row${lev ? ' from-list' : ''}" title="${esc(tip)}">
<span class="cs-label">${esc(CostCsv.labelOf(`${g}.${k}`, it.label))}</span>
${it.options ${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>` ? `<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}">`} : `<input type="number" step="any" data-g="${g}" data-k="${k}"${price ? ' disabled' : ''}>`}
<span class="cs-unit">${esc(it.unit || '')}</span> <span class="cs-unit">${esc(it.unit || '')}</span>
</label>`).join('')} </label>`;
}).join('')}
</section>`).join(''); </section>`).join('');
} }
@@ -85,36 +136,34 @@ window.CostSettings = (() => {
overlay.querySelectorAll('[data-g]').forEach(el => { el.value = values[el.dataset.g][el.dataset.k]; }); overlay.querySelectorAll('[data-g]').forEach(el => { el.value = values[el.dataset.g][el.dataset.k]; });
markChanged(); markChanged();
} }
function readForm() { function readForm(base) {
const v = CostModel.getSettings(); const v = base || CostModel.getSettings();
overlay.querySelectorAll('[data-g]').forEach(el => { overlay.querySelectorAll('[data-g]:not(:disabled)').forEach(el => {
const it = window.COST_DEFAULTS[el.dataset.g].items[el.dataset.k]; const it = window.COST_DEFAULTS[el.dataset.g].items[el.dataset.k];
const val = it.options ? el.value : parseFloat(el.value); const val = it.options ? el.value : parseFloat(el.value);
if (it.options || isFinite(val)) v[el.dataset.g][el.dataset.k] = val; if (it.options || isFinite(val)) v[el.dataset.g][el.dataset.k] = val;
}); });
return v; return v;
} }
// Afwijkend van de standaard: gemarkeerd.
function markChanged() { function markChanged() {
const def = CostModel.defaultValues(); const def = CostModel.defaultValues();
overlay.querySelectorAll('[data-g]').forEach(el => { overlay.querySelectorAll('[data-g]').forEach(el => {
const d = def[el.dataset.g][el.dataset.k]; 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); el.closest('.cs-row').classList.toggle('changed', !el.disabled && typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d);
}); });
} }
function download(obj) { function saveFile(text, name) {
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(new Blob([text], { type: 'text/csv' }));
a.download = `tectum-kosten-${new Date().toISOString().slice(0, 10)}.json`; a.download = name;
a.click(); a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
} }
function open() { function open() {
if (!overlay) build(); if (!overlay) build();
renderForm(); // opnieuw: nieuwe items in cost-defaults.js verschijnen vanzelf renderForm();
fill(CostModel.getSettings()); fill(CostModel.getSettings());
overlay.classList.add('open'); overlay.classList.add('open');
} }
+115 -201
View File
@@ -4,84 +4,26 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * 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).
// - Vloer-wcd's (settings.vloerWcd = [{ id, unitId, x, z }], x/z vanaf de
// noordwesthoek van de unit): vloerdoos met dubbele wcd (bijv. bij de
// eettafel), op de wcd-groep van die unit, leiding door de vloer.
// - 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 %.
// Boorgaten per leiding (run.holes, voor de kosten): in een wand de stijlen
// (horizontaal stuk) of de regels/dorpels (verticaal stuk) die de buis
// kruist (WallModel.computeBOM), in plafond en vloer de balken dwars op de
// buis (balkposities van het dek), een gat in de vloerplaat waar een buis
// de vloer in gaat, en een doorvoer naar buiten voor de buitenverlichting.
// Buizen houden onderling minstens CLEAR (50 mm) vrij: na het routeren
// schuift elk te dicht (evenwijdig, overlappend) padstuk naar een eigen baan
// met een korte knik ervoor en erna (separateRuns). Alleen het laatste stuk
// naar de meterkast is gebundeld: de buizen komen tegen elkaar aan de kast
// binnen. Leidingen naar een vloer-wcd lopen in de vloer (onder de OSB).
//
// Plain script, geladen na lighting-model.js en unit-model.js.
// ============================================================
window.ElektraModel = (() => { window.ElektraModel = (() => {
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
enabled: true, enabled: true,
meterkast: { w: 600, h: 900, d: 250, y: 1000 }, // mm; y = onderkant boven vloer meterkast: { w: 600, h: 900, d: 250, y: 1000 }, // y = onderkant boven vloer
switchHeight: 1500, // hart schakelaar boven vloer switchHeight: 1500,
wcdHeight: 400, // hart wcd boven vloer wcdHeight: 400,
wcdPerUnit: 2, // dubbele wcd per unit wcdPerUnit: 2,
slack: 10, // % extra op elke leiding slack: 10, // %
reserve: 300, // mm per leiding (lussen in de dozen) reserve: 300,
meterkastReserve: 500, // mm extra per leiding vanuit de meterkast meterkastReserve: 500,
apparaten: { koken: true, wasmachine: true, droger: true, warmtepomp: true, oven: false, vaatwasser: false }, apparaten: { koken: true, wasmachine: true, droger: true, warmtepomp: true, oven: false, vaatwasser: false },
place: {}, // handmatige plekken (zie boven) place: {},
vloerWcd: [] // vloer-wcd's (zie boven) vloerWcd: [],
wcdExtra: {},
removed: []
}; };
const CLEAR = 50; // min. vrije ruimte tussen buizen const CLEAR = 50;
const FLOOR_DEPTH = 100; // buis in de vloer: zoveel onder de bovenkant vloer const FLOOR_DEPTH = 100;
// Apparaten met een eigen groep. code = label op de plattegrond (twee // Codes van twee letters: geen verwarring met de schakelaarletters.
// letters: geen verwarring met de schakelaarletters).
const APPLIANCES = { const APPLIANCES = {
koken: { label: 'Kookgroep (perilex)', code: 'KK', breaker: '2× B16', poles: 2, cable: 'koken', height: 600, zone: 'keuken', material: 'Perilex-wandcontactdoos (2-fase)' }, 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' }, oven: { label: 'Oven', code: 'OV', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' },
@@ -90,14 +32,12 @@ window.ElektraModel = (() => {
droger: { label: 'Droger', code: 'DR', 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)' } 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 BOX_PITCH = 71;
const PLATE = 80; // afdekraam (vierkant) const PLATE = 80;
const WCD_W = 150; // afdekraam dubbele wcd const WCD_W = 150;
const DOOR_GAP = 100; // kozijnstijl -> hart eerste doos (bovenop de stijl) const DOOR_GAP = 100;
const VD_OFFSET = 150; // verdeeldoos: zoveel de kamer in vanaf het wandvlak const VD_OFFSET = 150;
// 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 CORE_COLORS = { L: 'bruin', L2: 'zwart', L3: 'grijs', N: 'blauw', PE: 'geel/groen', S: 'zwart' };
const CABLES = { 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 }, licht: { label: 'VD 3×1,5 mm² in buis Ø16', use: 'Verlichting: voeding en doorlus', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 16 },
@@ -105,7 +45,6 @@ window.ElektraModel = (() => {
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 }, 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 } 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) { function cableInfo(key) {
if (CABLES[key]) return CABLES[key]; if (CABLES[key]) return CABLES[key];
const m = /^schakel(\d)$/.exec(key); const m = /^schakel(\d)$/.exec(key);
@@ -119,6 +58,8 @@ window.ElektraModel = (() => {
s.meterkast = Object.assign({}, DEFAULT_SETTINGS.meterkast, i.meterkast || {}); s.meterkast = Object.assign({}, DEFAULT_SETTINGS.meterkast, i.meterkast || {});
s.apparaten = Object.assign({}, DEFAULT_SETTINGS.apparaten, i.apparaten || {}); s.apparaten = Object.assign({}, DEFAULT_SETTINGS.apparaten, i.apparaten || {});
s.place = Object.assign({}, i.place || {}); s.place = Object.assign({}, i.place || {});
s.wcdExtra = Object.assign({}, i.wcdExtra || {});
s.removed = Array.isArray(i.removed) ? i.removed.slice() : [];
s.vloerWcd = Array.isArray(i.vloerWcd) ? i.vloerWcd.filter(o => o && o.unitId).map(o => Object.assign({}, o)) : []; s.vloerWcd = Array.isArray(i.vloerWcd) ? i.vloerWcd.filter(o => o && o.unitId).map(o => Object.assign({}, o)) : [];
s.enabled = s.enabled !== false; s.enabled = s.enabled !== false;
// v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard). // v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard).
@@ -130,12 +71,7 @@ window.ElektraModel = (() => {
return s; return s;
} }
// ------------------------------------------------------------ // side -1 = lokaal -Z = kamerkant (buitenwand, naadwanddeel); binnenwand: beide zijden.
// 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) { function faceOf(p, inner, u, side) {
const pl = p.place, W = pl.width, lz = side * pl.thickness / 2; 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 a = UnitModel.localToWorld(pl, -W / 2, lz), b = UnitModel.localToWorld(pl, W / 2, lz);
@@ -145,10 +81,10 @@ window.ElektraModel = (() => {
return { return {
piece: p, unit: u, a, b, axis, W, lz, 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)), 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 n: { x: side * Math.sin(pl.rotY), z: side * Math.cos(pl.rotY) },
side, side,
sw: p.config.studWidth || 45, sw: p.config.studWidth || 45,
reserved: [] // [{ a, b, y0, y1 }] al bezet (meterkast, schakelaars, wcd) reserved: []
}; };
} }
function facesOf(u, interior) { function facesOf(u, interior) {
@@ -162,15 +98,12 @@ window.ElektraModel = (() => {
.forEach(p => list.push(faceOf(p, inner, u, -1), faceOf(p, inner, u, 1))); .forEach(p => list.push(faceOf(p, inner, u, -1), faceOf(p, inner, u, 1)));
return list.filter(f => f.u1 - f.u0 > 200); 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 // off: de kamer in (negatief = de wand in).
// kamer in (negatief = de wand in).
function facePoint(f, u, y, off) { function facePoint(f, u, y, off) {
const q = UnitModel.localToWorld(f.piece.place, u - f.W / 2, f.lz + f.side * (off || 0)); 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 }; 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; 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) { function freeOn(f, y0, y1, margin, edge) {
const blocked = []; const blocked = [];
f.piece.config.openings.forEach(op => { f.piece.config.openings.forEach(op => {
@@ -179,8 +112,6 @@ window.ElektraModel = (() => {
f.reserved.forEach(r => { if (r.y0 < y1 && r.y1 > y0) blocked.push([r.a, r.b]); }); 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); 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) { function nearestFree(faces, target, w, y0, y1, margin, edge) {
let best = null; let best = null;
faces.forEach(f => freeOn(f, y0, y1, margin, edge).forEach(([a, b]) => { faces.forEach(f => freeOn(f, y0, y1, margin, edge).forEach(([a, b]) => {
@@ -193,9 +124,7 @@ window.ElektraModel = (() => {
return best; return best;
} }
// Schakelaarcombinatie van n dozen naast opening op op vlak f: eerst de // Eerst de slotzijde (rechts van de opening in wand-x), dan de scharnierzijde.
// 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) { function besideDoor(f, op, n, height) {
const width = PLATE + (n - 1) * BOX_PITCH; const width = PLATE + (n - 1) * BOX_PITCH;
const free = freeOn(f, height - PLATE / 2, height + PLATE / 2, 0, f.sw); const free = freeOn(f, height - PLATE / 2, height + PLATE / 2, 0, f.sw);
@@ -207,9 +136,6 @@ window.ElektraModel = (() => {
return null; return null;
} }
// ------------------------------------------------------------
// Routes. Knooppunt: { x, y, z, mount: 'wand'|'plafond'|'buiten', face?, mk? }.
// ------------------------------------------------------------
function makeRouter(units) { 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 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) => { const ceilAt = (x, z) => {
@@ -223,17 +149,14 @@ window.ElektraModel = (() => {
return u.roof.ceilingAt(Math.max(r.x0 + 1, Math.min(r.x1 - 1, x)), Math.max(r.z0 + 1, Math.min(r.z1 - 1, z))); return u.roof.ceilingAt(Math.max(r.x0 + 1, Math.min(r.x1 - 1, x)), Math.max(r.z0 + 1, Math.min(r.z1 - 1, z)));
}; };
const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; }; const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; };
// Pad als 3D-punten (mm). Per punt: wall = wandvlak waar het in de wand
// ligt, ceil = boven het plafond, floor = in de vloer (voor de buizen in
// de 3D en de bladen per wand/vloer/plafond).
function path(a, b) { function path(a, b) {
const P = (p, wall, ceil, floor) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil, floor: !!floor }); const P = (p, wall, ceil, floor) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil, floor: !!floor });
if (a.mount === 'vloer' || b.mount === 'vloer') { // door de vloer if (a.mount === 'vloer' || b.mount === 'vloer') {
const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH; const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH;
return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true), P({ x: b.x, y: fa, z: a.z }, null, false, true), return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true), P({ x: b.x, y: fa, z: a.z }, null, false, true),
P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')]; P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')];
} }
if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)]; // door de stijlen if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a), P({ x: b.x, y: a.y, z: a.z }), P(b)]; 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 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 ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1;
@@ -245,9 +168,7 @@ window.ElektraModel = (() => {
return pts; 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); 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); 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) { function crossings(pts) {
let last = null, n = 0; let last = null, n = 0;
pts.slice(1).forEach((q, i) => { pts.slice(1).forEach((q, i) => {
@@ -264,12 +185,7 @@ window.ElektraModel = (() => {
return { unitAt, ceilAt, floorAt, path, length, cost, crossings }; return { unitAt, ceilAt, floorAt, path, length, cost, crossings };
} }
// ------------------------------------------------------------ function compute(settings, units, interior, lighting, roofEdge, rooms) {
// 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 s = normalizeSettings(settings);
const indoor = lighting.indoor || [], gevel = lighting.lamps || [], spots = (roofEdge && roofEdge.spots) || []; const indoor = lighting.indoor || [], gevel = lighting.lamps || [], spots = (roofEdge && roofEdge.spots) || [];
indoor.concat(gevel, spots).forEach(p => { delete p.letter; }); indoor.concat(gevel, spots).forEach(p => { delete p.letter; });
@@ -284,7 +200,6 @@ window.ElektraModel = (() => {
const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1); const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1);
const allFaces = out.faces = units.reduce((a, u) => a.concat(faces[u.id]), []); 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; let door = null;
['voordeur', 'deur'].some(type => units.some(u => u.walls.some(w => w.pieces.some(p => { ['voordeur', 'deur'].some(type => units.some(u => u.walls.some(w => w.pieces.some(p => {
if (p.baseY) return false; if (p.baseY) return false;
@@ -302,15 +217,20 @@ window.ElektraModel = (() => {
const mk = s.meterkast; const mk = s.meterkast;
const apps = Object.keys(APPLIANCES).filter(k => s.apparaten[k]); const apps = Object.keys(APPLIANCES).filter(k => s.apparaten[k]);
const num = (v, def) => typeof v === 'number' && isFinite(v) ? v : def; const num = (v, def) => typeof v === 'number' && isFinite(v) ? v : def;
const removed = (key) => s.removed.includes(key);
const wcdCount = (uid) => s.wcdPerUnit + Math.max(0, Math.round(num(s.wcdExtra[uid], 0)));
// Handmatige plekken eerst vastleggen (de automatische wijken uit).
const comboWidth = (n) => PLATE + (n - 1) * BOX_PITCH; const comboWidth = (n) => PLATE + (n - 1) * BOX_PITCH;
const widthOf = (key) => { const widthOf = (key) => {
if (key === 'mk') return mk.w; if (key === 'mk') return mk.w;
let m = /^sw:(.+)$/.exec(key); let m = /^sw:(.+)$/.exec(key);
if (m) return units.some(u => u.id === m[1]) ? comboWidth(m[1] === mainUnit.id ? mainFuncs.length : 1) : 0; if (m) {
if (removed(key)) return 0;
if (m[1] === mainUnit.id) return comboWidth(mainFuncs.length);
return units.some(u => u.id === m[1]) ? (rooms ? 0 : comboWidth(1)) : comboWidth(1);
}
m = /^wcd:(.+):(\d+)$/.exec(key); m = /^wcd:(.+):(\d+)$/.exec(key);
if (m) return units.some(u => u.id === m[1]) && Number(m[2]) <= s.wcdPerUnit ? WCD_W : 0; if (m) return units.some(u => u.id === m[1]) && Number(m[2]) <= wcdCount(m[1]) && !removed(key) ? WCD_W : 0;
m = /^app:(.+)$/.exec(key); m = /^app:(.+)$/.exec(key);
return m && apps.includes(m[1]) ? PLATE : 0; return m && apps.includes(m[1]) ? PLATE : 0;
}; };
@@ -326,12 +246,11 @@ window.ElektraModel = (() => {
}); });
const centersAt = (u, n) => Array.from({ length: n }, (_, i) => u - comboWidth(n) / 2 + PLATE / 2 + i * BOX_PITCH); 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, key, name) => {
const addCombo = (unit, f, us, funcs, y, manual) => {
const yy = y === null || y === undefined ? s.switchHeight : y; 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: [] }; const combo = { id: out.combos.length + 1, key: key || 'sw:' + unit.id, manual: !!manual, unitId: unit.id, face: f, us, y: yy, rotY: f.piece.place.rotY, switches: [] };
funcs.forEach((func, i) => { 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}` }; const sw = { letter: nextLetter(), func, unitId: unit.id, combo, u: us[i], lamps: [], label: func === 'buiten' ? 'Buitenlicht' : `Binnenlicht ${name || 'unit ' + unit.id}` };
sw.pos = facePoint(f, us[i], yy, 0); sw.pos = facePoint(f, us[i], yy, 0);
combo.switches.push(sw); combo.switches.push(sw);
out.switches.push(sw); out.switches.push(sw);
@@ -345,8 +264,8 @@ window.ElektraModel = (() => {
out.combos.push(combo); out.combos.push(combo);
return combo; return combo;
}; };
const fxMain = fixed['sw:' + mainUnit.id]; const fxMain = fixed['sw:' + mainUnit.id], mainOff = removed('sw:' + mainUnit.id);
if (fxMain) addCombo(mainUnit, fxMain.f, centersAt(fxMain.u, mainFuncs.length), mainFuncs, fxMain.y, true); if (mainOff) { } else if (fxMain) addCombo(mainUnit, fxMain.f, centersAt(fxMain.u, mainFuncs.length), mainFuncs, fxMain.y, true);
else if (door) { else if (door) {
const us = besideDoor(door.face, door.op, mainFuncs.length, s.switchHeight); const us = besideDoor(door.face, door.op, mainFuncs.length, s.switchHeight);
if (us) addCombo(mainUnit, door.face, us, mainFuncs); if (us) addCombo(mainUnit, door.face, us, mainFuncs);
@@ -357,13 +276,12 @@ window.ElektraModel = (() => {
if (nf) addCombo(mainUnit, nf.f, mainFuncs.map((_, i) => nf.u - w / 2 + PLATE / 2 + i * BOX_PITCH), mainFuncs); if (nf) addCombo(mainUnit, nf.f, mainFuncs.map((_, i) => nf.u - w / 2 + PLATE / 2 + i * BOX_PITCH), mainFuncs);
} }
} }
if (!out.combos.length) { if (!out.combos.length && !mainOff) {
const f = faces[mainUnit.id][0]; const f = faces[mainUnit.id][0];
if (f) addCombo(mainUnit, f, mainFuncs.map((_, i) => (f.u0 + f.u1) / 2 + i * BOX_PITCH), mainFuncs); 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 }; 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 target = door ? facePoint(door.face, door.op.x + door.op.w / 2, 0, 0) : entry;
const fxMk = fixed.mk; const fxMk = fixed.mk;
const mkY = fxMk && fxMk.y !== null ? fxMk.y : mk.y; const mkY = fxMk && fxMk.y !== null ? fxMk.y : mk.y;
@@ -379,16 +297,59 @@ window.ElektraModel = (() => {
}; };
} else out.warnings.push('Geen vrije wand voor de meterkast gevonden.'); } else out.warnings.push('Geen vrije wand voor de meterkast gevonden.');
// 4. Schakelaar per andere unit, naast de deur waardoor je hem binnenkomt. const isDoorOp = (op) => op.type === 'deur' || op.type === 'voordeur' || (op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur'));
units.forEach(u => { const regionOf = (pt) => rooms ? rooms.regionAt(pt.x, pt.z) : null;
if (u === mainUnit) return; const roomSw = new Map();
if (rooms) {
const entryPt = door ? facePoint(door.face, door.op.x + door.op.w / 2, 0, 150) : entry;
const entryReg = regionOf(entryPt);
const mainSw = out.switches.find(x => x.func === 'binnen');
if (entryReg && mainSw) {
roomSw.set(entryReg.idx, mainSw);
const r0 = rooms.rooms.find(r => r.key === entryReg.idx);
if (r0) mainSw.label = `Binnenlicht ${r0.name.toLowerCase()}`;
}
const list = rooms.rooms.concat(rooms.free).filter(r => r.areaM2 >= 0.6 && !roomSw.has(r.key))
.sort((a, b) => Math.hypot(a.label.x - entryPt.x, a.label.z - entryPt.z) - Math.hypot(b.label.x - entryPt.x, b.label.z - entryPt.z));
list.forEach(r => {
const key = r.id !== undefined ? 'sw:r' + r.id : `sw:g${Math.round(r.label.x / 100)}_${Math.round(r.label.z / 100)}`;
const name = r.name ? r.name.toLowerCase() : 'ruimte';
if (removed(key)) return;
const inR = (f, uu) => { const g = regionOf(facePoint(f, uu, 0, 150)); return g && g.idx === r.key; };
const fx = fixed[key];
let combo = null;
if (fx) combo = addCombo(fx.f.unit, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true, key, name);
else {
const cands = [];
allFaces.forEach(f => f.piece.config.openings.forEach(op => {
if (!isDoorOp(op) || !inR(f, op.x + op.w / 2)) return;
const q = facePoint(f, op.x + op.w / 2, 0, 0);
cands.push({ f, op, d: Math.hypot(q.x - entryPt.x, q.z - entryPt.z) });
}));
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) { combo = addCombo(c.f.unit, c.f, us, ['binnen'], null, false, key, name); break; }
}
if (!combo) {
const own = allFaces.filter(f => inR(f, (f.u0 + f.u1) / 2));
const nf = nearestFree(own, r.label, PLATE, s.switchHeight - PLATE / 2, s.switchHeight + PLATE / 2, 0, 100);
if (nf && inR(nf.f, nf.u)) combo = addCombo(nf.f.unit, nf.f, [nf.u], ['binnen'], null, false, key, name);
}
}
if (combo) roomSw.set(r.key, combo.switches[0]);
else out.warnings.push(`${r.name || 'Ruimte'}: geen plek voor een schakelaar; licht op een andere schakelaar.`);
});
}
if (!rooms) units.forEach(u => {
if (u === mainUnit || removed('sw:' + u.id)) return;
const fx = fixed['sw:' + u.id]; const fx = fixed['sw:' + u.id];
if (fx) { addCombo(u, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true); return; } if (fx) { addCombo(u, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true); return; }
const cands = []; const cands = [];
faces[u.id].forEach(f => f.piece.config.openings.forEach(op => { faces[u.id].forEach(f => f.piece.config.openings.forEach(op => {
if (op.type !== 'deur' && op.type !== 'voordeur') return; if (op.type !== 'deur' && op.type !== 'voordeur') return;
const q = facePoint(f, op.x + op.w / 2, 0, 0); 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.push({ f, op, d: Math.hypot(q.x - entry.x, q.z - entry.z) + (f.piece.seamUnit ? 0 : 5000) });
})); }));
cands.sort((a, b) => a.d - b.d); cands.sort((a, b) => a.d - b.d);
for (const c of cands) { for (const c of cands) {
@@ -398,22 +359,24 @@ window.ElektraModel = (() => {
out.warnings.push(`Unit ${u.id}: geen deur met ruimte voor een schakelaar; binnenlicht op schakelaar ${out.switches[0] ? out.switches[0].letter : '-'}.`); 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]; 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 }); } }); const roomSwOf = (p) => { const g = regionOf(p); return g ? roomSw.get(g.idx) : null; };
indoor.forEach(p => { const sw = roomSwOf(p) || 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 }); } }); 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 }); } }); 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 => { units.forEach(u => {
const y0 = s.wcdHeight - 60, y1 = s.wcdHeight + 60, cands = []; const y0 = s.wcdHeight - 60, y1 = s.wcdHeight + 60, cands = [];
faces[u.id].forEach(f => freeOn(f, y0, y1, 100, 150).forEach(([a, b]) => { faces[u.id].forEach(f => freeOn(f, y0, y1, 100, 150).forEach(([a, b]) => {
if (b - a < WCD_W + 20) return; 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) })); (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 = []; const chosen = [], freeKeys = [], nWcd = wcdCount(u.id);
for (let i = 1; i <= s.wcdPerUnit; i++) { let want = 0;
for (let i = 1; i <= nWcd; i++) {
const key = `wcd:${u.id}:${i}`, fx = fixed[key]; const key = `wcd:${u.id}:${i}`, fx = fixed[key];
if (removed(key)) continue;
want++;
if (!fx) { freeKeys.push(key); continue; } if (!fx) { freeKeys.push(key); continue; }
const y = fx.y !== null ? fx.y : s.wcdHeight; 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 }); chosen.push({ f: fx.f, u: fx.u, y, p: facePoint(fx.f, fx.u, y, 0), key, manual: true });
@@ -429,14 +392,12 @@ window.ElektraModel = (() => {
chosen.push(Object.assign({}, best, { key: freeKeys.shift(), y: s.wcdHeight })); 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 }); 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.`); if (chosen.length < want) out.warnings.push(`Unit ${u.id}: ruimte voor ${chosen.length} van ${want} wcd's.`);
chosen.sort((a, b) => a.key < b.key ? -1 : 1).forEach(c => out.wcds.push({ chosen.sort((a, b) => Number(a.key.split(':')[2]) - Number(b.key.split(':')[2])).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) 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 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 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 mkUnit = out.meterkast ? units.find(u => u.id === out.meterkast.unitId) : mainUnit;
@@ -459,7 +420,6 @@ window.ElektraModel = (() => {
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) }); 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) });
}); });
// 6c. Vloer-wcd's (handmatig toegevoegd, x/z vanaf de noordwesthoek van de unit).
s.vloerWcd.forEach(o => { s.vloerWcd.forEach(o => {
const u = units.find(x => x.id === o.unitId); const u = units.find(x => x.id === o.unitId);
if (!u) return; if (!u) return;
@@ -468,7 +428,6 @@ window.ElektraModel = (() => {
out.floorWcds.push({ key: 'vwcd:' + o.id, id: o.id, unitId: u.id, x, z, y: u.layout.floorTop }); out.floorWcds.push({ key: 'vwcd:' + o.id, id: o.id, unitId: u.id, x, z, y: u.layout.floorTop });
}); });
// 7. Groepen: per unit licht (B10) + wcd (B16); fasen en aardlek om en om.
units.forEach(u => { units.forEach(u => {
const sws = out.switches.filter(x => x.unitId === u.id && x.func === 'binnen'); 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 (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) });
@@ -493,8 +452,6 @@ window.ElektraModel = (() => {
// Buitenlicht zit in de schakeldoos van het binnenlicht: zelfde aardlek. // 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); const gOut = out.groups.find(g => g.kind === 'buiten'), gIn = gOut && groupOf('licht', gOut.unitId);
if (gOut && gIn) gOut.rcd = gIn.rcd; 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++) { for (let guard = 0; guard < 20; guard++) {
const cnt = Array.from({ length: nRcd }, (_, i) => out.groups.filter(g => g.rcd === i + 1).length); const cnt = Array.from({ length: nRcd }, (_, i) => out.groups.filter(g => g.rcd === i + 1).length);
const over = cnt.findIndex(c => c > 4); const over = cnt.findIndex(c => c > 4);
@@ -508,7 +465,6 @@ window.ElektraModel = (() => {
out.floorWcds.forEach(w => { const g = groupOf('wcd', w.unitId); w.group = g ? g.nr : null; }); out.floorWcds.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; }); 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 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 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 addRun = (type, a, b, group, letter) => {
@@ -516,7 +472,6 @@ window.ElektraModel = (() => {
const raw = R.length(pts); const raw = R.length(pts);
out.runs.push({ type, group, letter: letter || null, from: a.name || '', to: b.name || '', path: pts, mk: !!(a.mk || b.mk), outdoor: a.mount === 'buiten' || b.mount === 'buiten' }); out.runs.push({ type, group, letter: letter || null, from: a.name || '', to: b.name || '', path: pts, mk: !!(a.mk || b.mk), 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 tree = (root, nodes, typeOf, group, letter) => {
const done = [root], rest = nodes.slice(); const done = [root], rest = nodes.slice();
while (rest.length) { while (rest.length) {
@@ -527,7 +482,6 @@ window.ElektraModel = (() => {
done.push(n); done.push(n);
} }
}; };
// Per schakeldoos één verdeeldoos per groep (naast elkaar langs de wand).
for (const combo of out.combos) { for (const combo of out.combos) {
const byGroup = []; const byGroup = [];
combo.switches.forEach(sw => { combo.switches.forEach(sw => {
@@ -546,8 +500,7 @@ window.ElektraModel = (() => {
vd.y = R.ceilAt(vd.x, vd.z); vd.y = R.ceilAt(vd.x, vd.z);
combo.vds.push(vd); 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 }); 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 if (mkNode) addRun('licht', mkNode, vd, e.group);
// Naar de eigen inbouwdoos (of het midden ervan) van deze groep.
const uSw = e.switches.reduce((t, x) => t + x.u, 0) / e.switches.length; const uSw = e.switches.reduce((t, x) => t + x.u, 0) / e.switches.length;
const swNode = Object.assign(facePoint(combo.face, uSw, combo.y, 0), { mount: 'wand', face: combo.face, name: e.switches.map(x => x.letter).join('') }); const swNode = Object.assign(facePoint(combo.face, uSw, combo.y, 0), { mount: 'wand', face: combo.face, name: e.switches.map(x => x.letter).join('') });
addRun('schakel' + (e.switches.length + 1), vd, swNode, e.group); addRun('schakel' + (e.switches.length + 1), vd, swNode, e.group);
@@ -560,10 +513,8 @@ window.ElektraModel = (() => {
.concat(out.floorWcds.filter(w => w.group === g.nr).map((w, i) => ({ x: w.x, y: w.y, z: w.z, mount: 'vloer', name: `VWCD${g.nr}.${i + 1}` }))); .concat(out.floorWcds.filter(w => w.group === g.nr).map((w, i) => ({ x: w.x, y: w.y, z: w.z, mount: 'vloer', name: `VWCD${g.nr}.${i + 1}` })));
if (mkLow && nodes.length) tree(mkLow, nodes, () => 'wcd', g.nr); 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)); 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));
// 8b. Buizen uit elkaar (min. CLEAR), daarna lengtes, naad-overgangen en boorgaten.
separateRuns(out.runs); separateRuns(out.runs);
const holesOf = makeHoleCounter(units, R); const holesOf = makeHoleCounter(units, R);
out.runs.forEach(r => { out.runs.forEach(r => {
@@ -574,7 +525,6 @@ window.ElektraModel = (() => {
}); });
out.holes = out.runs.reduce((a, r) => a + r.holes, 0); out.holes = out.runs.reduce((a, r) => a + r.holes, 0);
// 9. Totalen: per leidingtype, draad per doorsnede/kleur, buis, groep.
const byType = {}; const byType = {};
out.runs.forEach(r => { byType[r.type] = (byType[r.type] || 0) + r.lengthMM; }); 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 })); out.cables = Object.keys(byType).sort().map(k => ({ key: k, label: cableInfo(k).label, use: cableInfo(k).use, meters: byType[k] / 1000 }));
@@ -601,7 +551,6 @@ window.ElektraModel = (() => {
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).`); 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 = {}; const crossBy = {};
out.runs.forEach(r => { if (r.crossings) { const n = cableInfo(r.type).cores.length; crossBy[n] = (crossBy[n] || 0) + r.crossings; } }); 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 nIndoor = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'binnenspot').length, 0);
@@ -638,8 +587,6 @@ window.ElektraModel = (() => {
add('Luchtdichte kabeldoorvoer naar buiten', out.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && r.to !== '' && !r.to.startsWith('VD')).length); 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])); 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({ 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) 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)
}); });
@@ -655,16 +602,7 @@ window.ElektraModel = (() => {
return out; return out;
} }
// ------------------------------------------------------------ // Uitzondering: het laatste stuk naar de meterkast; daar komen de buizen tegen elkaar aan de kast binnen.
// Buizen uit elkaar (muteert run.path). Padstukken zijn haaks (x, y of z).
// Te dicht = evenwijdig, overlappend langs de as en minder dan CLEAR vrij
// (buitenkant tot buitenkant). Zo'n stuk schuift naar de eerste vrije baan
// (±1, ±2, ... × CLEAR + buis-Ø): in een wand langs de wand (verticaal
// stuk) of omhoog/omlaag (horizontaal stuk), in plafond/vloer horizontaal
// dwars. Een knik ervoor en erna verbindt het weer met de rest van het pad.
// Uitzondering: het laatste stuk naar de meterkast. Daar komen de buizen
// naast elkaar (tegen elkaar aan) de kast binnen.
// ------------------------------------------------------------
function separateRuns(runs) { function separateRuns(runs) {
const add = (p, d, k) => Object.assign({}, p, { x: p.x + d.x * k, y: p.y + d.y * k, z: p.z + d.z * k }); const add = (p, d, k) => Object.assign({}, p, { x: p.x + d.x * k, y: p.y + d.y * k, z: p.z + d.z * k });
const axisOf = (p, q) => { const axisOf = (p, q) => {
@@ -690,8 +628,6 @@ window.ElektraModel = (() => {
const conduit = runs.filter(r => cableInfo(r.type).conduit > 0); const conduit = runs.filter(r => cableInfo(r.type).conduit > 0);
const radius = (r) => cableInfo(r.type).conduit / 2; const radius = (r) => cableInfo(r.type).conduit / 2;
// 1. Meterkast: per aansluitpunt (boven/onder) de buizen naast elkaar,
// tegen elkaar aan, gesorteerd naar de kant waar ze heen gaan.
const bundles = {}; const bundles = {};
conduit.filter(r => r.mk && r.path[0].wall).forEach(r => { conduit.filter(r => r.mk && r.path[0].wall).forEach(r => {
const k = Math.round(r.path[0].y); const k = Math.round(r.path[0].y);
@@ -711,17 +647,16 @@ window.ElektraModel = (() => {
out[0] = add(orig[0], d, o); out[0] = add(orig[0], d, o);
for (let k = 0; k < orig.length - 1; k++) { for (let k = 0; k < orig.length - 1; k++) {
const len = lenOf(orig[k], orig[k + 1]); const len = lenOf(orig[k], orig[k + 1]);
if (len >= 1 && axisOf(orig[k], orig[k + 1]) === dAx) break; // loopt langs de kast: alleen langer/korter if (len >= 1 && axisOf(orig[k], orig[k + 1]) === dAx) break;
if (k + 1 === orig.length - 1) { out.splice(k + 1, 0, add(orig[k + 1], d, o)); break; } // eindpunt blijft: knik ervoor if (k + 1 === orig.length - 1) { out.splice(k + 1, 0, add(orig[k + 1], d, o)); break; }
out[k + 1] = add(orig[k + 1], d, o); out[k + 1] = add(orig[k + 1], d, o);
} }
r.path = out; r.path = out;
placed.push(seg(r, out[0], out[1], radius(r))); // gebundeld stuk: vast placed.push(seg(r, out[0], out[1], radius(r)));
r.fixed = 1; r.fixed = 1;
}); });
}); });
// 2. De rest: per padstuk een vrije baan zoeken.
conduit.forEach(r => { conduit.forEach(r => {
const rad = radius(r), step = CLEAR + 2 * rad; const rad = radius(r), step = CLEAR + 2 * rad;
let pts = r.path; let pts = r.path;
@@ -731,8 +666,7 @@ window.ElektraModel = (() => {
const sg = seg(r, p, q, rad); const sg = seg(r, p, q, rad);
if (!conflict(sg)) { placed.push(sg); continue; } if (!conflict(sg)) { placed.push(sg); continue; }
const dir = shiftDir(p, q, sg.axis); const dir = shiftDir(p, q, sg.axis);
// Knikken tellen ook mee, behalve direct aan een doos/lamp (daar // Knikken tellen mee, behalve direct aan een doos/lamp en aan het eind van de meterkastbundel.
// komen buizen samen) en aan het eind van de meterkastbundel.
const freeStart = i === 0 || (r.fixed && i === r.fixed), freeEnd = i + 1 === pts.length - 1; const freeStart = i === 0 || (r.fixed && i === r.fixed), freeEnd = i + 1 === pts.length - 1;
let done = false; let done = false;
for (let k = 1; k <= 12 && !done; k++) { for (let k = 1; k <= 12 && !done; k++) {
@@ -742,7 +676,7 @@ window.ElektraModel = (() => {
if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue; if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue;
pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1)); pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1));
placed.push(s2, j1, j2); placed.push(s2, j1, j2);
i += 2; // verder vanaf de knik terug naar q i += 2;
done = true; done = true;
break; break;
} }
@@ -753,7 +687,6 @@ window.ElektraModel = (() => {
}); });
runs.forEach(r => { delete r.fixed; }); runs.forEach(r => { delete r.fixed; });
} }
// Dubbele punten en heen-en-weer-knikken eruit.
function simplifyPath(pts) { function simplifyPath(pts) {
const same = (a, b) => Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.z - b.z) < 0.5; const same = (a, b) => Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.z - b.z) < 0.5;
let out = pts.filter((p, i) => i === 0 || !same(p, pts[i - 1])); let out = pts.filter((p, i) => i === 0 || !same(p, pts[i - 1]));
@@ -767,31 +700,21 @@ window.ElektraModel = (() => {
return out; 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) { function snapToWall(el, x, z) {
let best = null; let best = null;
((el && el.faces) || []).forEach(f => { ((el && el.faces) || []).forEach(f => {
const u = Math.max(f.u0, Math.min(f.u1, uAt(f, { x, z }))); const u = Math.max(f.u0, Math.min(f.u1, uAt(f, { x, z })));
const q = facePoint(f, u, 0, 0); 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 if ((x - q.x) * f.n.x + (z - q.z) * f.n.z < -(f.piece.place.thickness + 50)) return;
const d = Math.hypot(q.x - x, q.z - z); 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 }; 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; 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. // Kabel zonder buis (XMvK buiten) en losse draad worden niet getekend.
// ------------------------------------------------------------ const WALL_IN = 35;
const WALL_IN = 35; // buis: zoveel achter het wandvlak (in de stijlruimte) const CEIL_UP = 35;
const CEIL_UP = 35; // buis: zoveel boven de onderkant plafond (op de plafondplaat)
const CONDUIT_COLORS = { licht: 0xe0b020, wcd: 0xcc4a30, koken: 0x8a3a9a, schakel: 0x9a6a20 }; const CONDUIT_COLORS = { licht: 0xe0b020, wcd: 0xcc4a30, koken: 0x8a3a9a, schakel: 0x9a6a20 };
function createMaterials() { function createMaterials() {
const conduit = {}; const conduit = {};
@@ -827,11 +750,10 @@ window.ElektraModel = (() => {
el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial)); el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial));
el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial)); el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial));
el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial)); el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial));
el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial)); // deksel vloerdoos el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial));
} }
if (!rough) return group; if (!rough) return group;
// Installatiebuizen: zeskantige cilinders per padstuk + bolletje op elke knik.
const up = new THREE.Vector3(0, 1, 0); const up = new THREE.Vector3(0, 1, 0);
const shift = (p) => ({ 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) 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)
@@ -856,7 +778,6 @@ window.ElektraModel = (() => {
group.add(j); group.add(j);
}); });
}); });
// Luchtdichte inbouwdozen (schakelaars, wcd, apparaten): in de wand.
const wallBox = (f, u, y) => { const wallBox = (f, u, y) => {
const c = facePoint(f, u, y, -22); const c = facePoint(f, u, y, -22);
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.034, 0.034, 0.045, 12), mats.boxMaterial); const m = new THREE.Mesh(new THREE.CylinderGeometry(0.034, 0.034, 0.045, 12), mats.boxMaterial);
@@ -867,8 +788,7 @@ window.ElektraModel = (() => {
el.switches.forEach(sw => wallBox(sw.combo.face, sw.u, sw.combo.y)); 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.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)); el.apps.forEach(a => wallBox(a.face, a.u, a.y));
el.floorWcds.forEach(w => box(100, 80, 100, { x: w.x, y: w.y - 40, z: w.z }, 0, mats.boxMaterial)); // vloerdoos el.floorWcds.forEach(w => box(100, 80, 100, { x: w.x, y: w.y - 40, z: w.z }, 0, mats.boxMaterial));
// 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.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 => { 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); const m = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.07, 12), mats.boxMaterial);
@@ -878,10 +798,8 @@ window.ElektraModel = (() => {
return group; return group;
} }
// Plek van een punt op een wandvlak: { u, y } (wand-x en hoogte boven de vloer).
const wallCoords = (f, p) => ({ u: uAt(f, p), y: p.y - f.piece.place.floorTop }); const wallCoords = (f, p) => ({ u: uAt(f, p), y: p.y - f.piece.place.floorTop });
// Lijnstuk p-q (wereld-mm) binnen grenzen b = { x0, x1, z0, z1 } (plattegrond); null = erbuiten.
function clipPlan(p, q, b) { function clipPlan(p, q, b) {
let t0 = 0, t1 = 1; let t0 = 0, t1 = 1;
for (const [k, lo, hi] of [['x', b.x0, b.x1], ['z', b.z0, b.z1]]) { for (const [k, lo, hi] of [['x', b.x0, b.x1], ['z', b.z0, b.z1]]) {
@@ -895,10 +813,7 @@ window.ElektraModel = (() => {
const at = (t) => Object.assign({}, p, { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t, z: p.z + (q.z - p.z) * t }); const at = (t) => Object.assign({}, p, { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t, z: p.z + (q.z - p.z) * t });
return [at(t0), at(t1)]; return [at(t0), at(t1)];
} }
// Dakvlak pl van unit: deel van de plattegrond (bounds) en omrekening naar // Zelfde omrekening als de doorvoeren in RoofModel.computeUnitRoof: gelijk houden.
// het dek zoals de dakbladen (map: { sx, sy }, mm vanaf linksvoor), gelijk
// aan de doorvoeren in RoofModel.computeUnitRoof. De balken van het dek
// staan op sx = joist + breedte/2 en lopen langs sy.
function roofDeckMap(unit, pl) { function roofDeckMap(unit, pl) {
const alongX = Math.abs(Math.sin(pl.yaw)) > 0.5, rr = unit.roof.rect, r = unit.rect; const alongX = Math.abs(Math.sin(pl.yaw)) > 0.5, rr = unit.roof.rect, r = unit.rect;
const qOf = (p) => alongX ? p.x - r.cx : p.z - r.cz; const qOf = (p) => alongX ? p.x - r.cx : p.z - r.cz;
@@ -909,7 +824,6 @@ window.ElektraModel = (() => {
map: (p) => ({ sx: alongX ? rr.z1 - p.z : p.x - rr.x0, sy: (qOf(p) - pl.p0) / pl.cos }) map: (p) => ({ sx: alongX ? rr.z1 - p.z : p.x - rr.x0, sy: (qOf(p) - pl.p0) / pl.cos })
}; };
} }
// Boorgaten van een leiding (zie kop). Telt per padstuk.
const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl']; const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl'];
function makeHoleCounter(units, R) { function makeHoleCounter(units, R) {
const boms = new Map(); const boms = new Map();
@@ -920,7 +834,7 @@ window.ElektraModel = (() => {
const between = (v, a, b) => v > Math.min(a, b) + 1 && v < Math.max(a, b) - 1; const between = (v, a, b) => v > Math.min(a, b) + 1 && v < Math.max(a, b) - 1;
const joistsBetween = (cd, a, b) => cd.joists.filter(j => between(j + cd.width / 2, a, b)).length; const joistsBetween = (cd, a, b) => cd.joists.filter(j => between(j + cd.width / 2, a, b)).length;
return (run) => { return (run) => {
if (!cableInfo(run.type).conduit) return run.type === 'buiten' && run.from.startsWith('VD') ? 1 : 0; // doorvoer naar buiten if (!cableInfo(run.type).conduit) return run.type === 'buiten' && run.from.startsWith('VD') ? 1 : 0;
let n = 0; let n = 0;
run.path.slice(1).forEach((q, i) => { run.path.slice(1).forEach((q, i) => {
const p = run.path[i]; const p = run.path[i];
@@ -930,7 +844,7 @@ window.ElektraModel = (() => {
if (vertical) { if (vertical) {
const x = a.u - f.W / 2; const x = a.u - f.W / 2;
n += bom.filter(bp => !VERTICAL_KINDS.includes(bp.kind) && Math.abs(x - bp.x) <= bp.length / 2 && between(bp.y, a.y, b.y)).length; n += bom.filter(bp => !VERTICAL_KINDS.includes(bp.kind) && Math.abs(x - bp.x) <= bp.length / 2 && between(bp.y, a.y, b.y)).length;
if (p.floor !== q.floor) n += 1; // door de vloerplaat/randbalk de vloer in if (p.floor !== q.floor) n += 1;
} else { } else {
n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length; n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length;
} }
@@ -944,7 +858,7 @@ window.ElektraModel = (() => {
const u = R.unitAt((p.x + q.x) / 2, (p.z + q.z) / 2); const u = R.unitAt((p.x + q.x) / 2, (p.z + q.z) / 2);
const c = u && clipPlan(p, q, u.rect); const c = u && clipPlan(p, q, u.rect);
if (c) n += joistsBetween(u.floorData.calcData, c[0].x - u.rect.x0, c[1].x - u.rect.x0); if (c) n += joistsBetween(u.floorData.calcData, c[0].x - u.rect.x0, c[1].x - u.rect.x0);
} else if (vertical && (p.floor || q.floor) && !(p.wall || q.wall)) n += 1; // vloerdoos: gat in de vloerplaat } else if (vertical && (p.floor || q.floor) && !(p.wall || q.wall)) n += 1;
}); });
return n; return n;
}; };
-31
View File
@@ -4,27 +4,15 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * 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 = (() => { window.ElektraPdf = (() => {
const fmt = (n, d = 1) => Number(n).toFixed(d).replace('.', ','); 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 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; const runColor = (type) => RUN_COLORS[type] || RUN_COLORS.schakel;
// px/py: woning-mm -> blad-mm.
function drawPlan(doc, el, px, py) { function drawPlan(doc, el, px, py) {
if (!el || !el.enabled) return; if (!el || !el.enabled) return;
const BLACK = PdfSheet.COLORS.BLACK; const BLACK = PdfSheet.COLORS.BLACK;
// Leidingen (gestippeld, plattegrond-projectie van het pad).
doc.setLineWidth(0.15); doc.setLineWidth(0.15);
doc.setLineDashPattern([0.8, 0.6], 0); doc.setLineDashPattern([0.8, 0.6], 0);
el.runs.forEach(r => { el.runs.forEach(r => {
@@ -36,7 +24,6 @@ window.ElektraPdf = (() => {
}); });
doc.setLineDashPattern([], 0); doc.setLineDashPattern([], 0);
// Meterkast
const k = el.meterkast; const k = el.meterkast;
if (k) { if (k) {
doc.setDrawColor(...BLACK); doc.setDrawColor(...BLACK);
@@ -50,12 +37,10 @@ window.ElektraPdf = (() => {
doc.text('MK', px(m.x), py(m.z) + 0.8, { align: 'center' }); doc.text('MK', px(m.x), py(m.z) + 0.8, { align: 'center' });
} }
// Verdeeldozen (plafond): klein vierkantje.
doc.setDrawColor(...runColor('schakel')); doc.setDrawColor(...runColor('schakel'));
doc.setLineWidth(0.15); 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')); 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); doc.setFontSize(5);
el.combos.forEach(c => { 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 q = { x: c.pos.x + c.face.n.x * 260, z: c.pos.z + c.face.n.z * 260 };
@@ -69,7 +54,6 @@ window.ElektraPdf = (() => {
doc.text(text, px(q.x), py(q.z) + 0.7, { align: 'center' }); doc.text(text, px(q.x), py(q.z) + 0.7, { align: 'center' });
}); });
// Wcd: halve cirkel tegen de wand + groepnummer.
el.wcds.forEach(w => { el.wcds.forEach(w => {
const q = { x: w.pos.x + w.face.n.x * 120, z: w.pos.z + w.face.n.z * 120 }; 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.setDrawColor(...RUN_COLORS.wcd);
@@ -81,7 +65,6 @@ window.ElektraPdf = (() => {
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' }); 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 => { el.apps.forEach(a => {
const it = el.items.find(x => x.key === a.key), q = it.marker; const it = el.items.find(x => x.key === a.key), q = it.marker;
doc.setDrawColor(...RUN_COLORS.wcd); doc.setDrawColor(...RUN_COLORS.wcd);
@@ -94,7 +77,6 @@ window.ElektraPdf = (() => {
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' }); 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' });
}); });
// Vloer-wcd: vierkantje met V + groepnummer.
el.floorWcds.forEach(w => { el.floorWcds.forEach(w => {
doc.setDrawColor(...RUN_COLORS.wcd); doc.setDrawColor(...RUN_COLORS.wcd);
doc.setFillColor(255, 255, 255); doc.setFillColor(255, 255, 255);
@@ -105,12 +87,10 @@ window.ElektraPdf = (() => {
doc.text('V' + w.group, px(w.x), py(w.z) + 0.5, { align: 'center' }); doc.text('V' + w.group, px(w.x), py(w.z) + 0.5, { align: 'center' });
}); });
// Lampletters (zelfde letter als de schakelaar).
doc.setFontSize(3.6); doc.setFontSize(3.6);
doc.setTextColor(...BLACK); 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))); 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 = [ 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;', '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, V = vloer-wcd; gestippeld = leidingen (geel licht, bruin schakeldraad, rood wcd/apparaat, groen buiten).' 'rood kadertje = apparaat (KK kookgroep, WM wasmachine, DR droger, WP warmtepomp, OV oven, VW vaatwasser) met groepnummer, V = vloer-wcd; gestippeld = leidingen (geel licht, bruin schakeldraad, rood wcd/apparaat, groen buiten).'
@@ -161,13 +141,8 @@ window.ElektraPdf = (() => {
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
} }
// ------------------------------------------------------------
// Elektra op de bladen per onderdeel. Elk geeft een overlay-functie voor
// WallPdf/FloorPdf (opts.overlay) of null als er niets te tekenen is.
// ------------------------------------------------------------
const lineCol = (doc, type) => doc.setDrawColor(...runColor(type)); const lineCol = (doc, type) => doc.setDrawColor(...runColor(type));
const clip = (p, q, b) => ElektraModel.clipPlan(p, q, b); const clip = (p, q, b) => ElektraModel.clipPlan(p, q, b);
// Leidingstukken van alle buizen waarvoor test(p, q) geldt.
const conduitSegs = (el, test) => { const conduitSegs = (el, test) => {
const out = []; const out = [];
el.runs.forEach(r => { el.runs.forEach(r => {
@@ -187,8 +162,6 @@ window.ElektraPdf = (() => {
doc.text(text, x, y + 0.7, { align: 'center' }); doc.text(text, x, y + 0.7, { align: 'center' });
}; };
// Wand (vooraanzicht, wand-x zoals de openingen): buizen in de wand,
// schakelaars, wcd's, apparaten en de meterkast op dit wandstuk.
function wallOverlay(el, piece) { function wallOverlay(el, piece) {
if (!el || !el.enabled) return null; if (!el || !el.enabled) return null;
const on = (p) => p.wall && p.wall.piece === piece; const on = (p) => p.wall && p.wall.piece === piece;
@@ -219,7 +192,6 @@ window.ElektraPdf = (() => {
}; };
} }
// Vloer van een unit (bovenaanzicht vanaf linksvoor): buizen in de vloer + vloer-wcd's.
function floorOverlay(el, unit) { function floorOverlay(el, unit) {
if (!el || !el.enabled) return null; if (!el || !el.enabled) return null;
const r = unit.rect, b = { x0: r.x0, x1: r.x1, z0: r.z0, z1: r.z1 }; const r = unit.rect, b = { x0: r.x0, x1: r.x1, z0: r.z0, z1: r.z1 };
@@ -238,9 +210,6 @@ window.ElektraPdf = (() => {
}; };
} }
// Dakvlak (dek zoals de dakbladen, zelfde omrekening als de doorvoeren in
// RoofModel.computeUnitRoof): buizen boven het plafond, verdeeldozen en
// binnenspots (met letter) in dit vlak.
function roofOverlay(el, unit, pl) { function roofOverlay(el, unit, pl) {
if (!el || !el.enabled) return null; if (!el || !el.enabled) return null;
const dm = ElektraModel.roofDeckMap(unit, pl), map = dm.map, b = dm.bounds; const dm = ElektraModel.roofDeckMap(unit, pl), map = dm.map, b = dm.bounds;
+44 -93
View File
@@ -4,33 +4,13 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// floor/floor-model.js — ENIGE bron van waarheid voor de vloer-logica:
// balkverdeling, doorvoer-resolutie (tussen de balken), chassis-wielen,
// specificaties en de 3D-opbouw. Geen DOM. Wordt gebruikt door:
// - floor/floor.js (de losse vloer-editor)
// - unit/unit.js (unit = vloer + 4 wanden)
// Wijzigingen aan de vloer-logica horen dus HIER.
//
// Werkt met een "vloer-state" (zelfde formaat als getStateJson/JSON-export):
// { floor: {width, depth, joistWidth, joistHeight, minSpacing, maxSpacing, underlayThick},
// chassis: {enabled, wheelDiameter, trackWidth, wheelbase},
// openings: [{id, diameter, anchorX, offsetX, offsetY}] }
//
// Plain script (geen ES-module), moet na core/geometry.js geladen worden.
// ============================================================
window.FloorModel = (() => { window.FloorModel = (() => {
const DENSITY_WOOD_KGM3 = 450; // zelfde referentiewaarde als WallModel const DENSITY_WOOD_KGM3 = 450;
const DENSITY_OSB_KGM3 = 650; // OSB-plaat, zelfde referentiewaarde als voorheen "onderlaag" const DENSITY_OSB_KGM3 = 650;
const BAY_MARGIN_MM = 15; // vrije marge tussen doorvoer-rand en balkflank const BAY_MARGIN_MM = 15;
const MIN_DIAMETER_MM = 20; // kleinste zinnige doorvoer; past dit niet, dan vervalt de doorvoer const MIN_DIAMETER_MM = 20;
// ----------------------------------------------------------
// Balkvakken: de ruimte tussen twee opeenvolgende balken (in wereld-
// coordinaten, zelfde stelsel als calcData.joists). Een doorvoer mag
// nooit een balk kruisen, dus wordt altijd binnen zo'n vak geklemd.
// ----------------------------------------------------------
function computeBays(joists, joistW) { function computeBays(joists, joistW) {
const bays = []; const bays = [];
for (let i = 0; i < joists.length - 1; i++) { for (let i = 0; i < joists.length - 1; i++) {
@@ -52,19 +32,9 @@ window.FloorModel = (() => {
return nearest; return nearest;
} }
// Resolutie: X via hetzelfde anker+offset-patroon als de wand-openingen, // Past de diameter niet (ook niet verkleind tot MIN_DIAMETER_MM), dan vervalt de doorvoer alleen uit
// maar het resultaat wordt altijd teruggeklemd tot het balkvak waar het in // tekening en telling: hij blijft in de invoer, zodat een andere balkafstand hem terugbrengt.
// valt (nooit een balk kruisend). Past de gevraagde diameter niet — zelfs // Overlap van twee doorvoeren in hetzelfde vak wordt gemeld, niet opgelost (bewust).
// niet na verkleinen tot het minimum — dan vervalt de doorvoer (blijft wel
// gewoon in de invoerlijst staan, verdwijnt alleen uit de tekening/telling,
// zodat een latere wijziging aan de balkafstand 'm weer terug kan brengen).
//
// Y is een simpele afstand vanaf de voorrand — balken lopen over de volle
// diepte door, dus Y heeft geen balk-interactie zoals X die wel heeft.
//
// Overlap tussen twee doorvoeren IN HETZELFDE vak is een apart, 2D-
// probleem (afstand tussen middelpunten) — dat wordt gedetecteerd en
// gemeld, niet automatisch opgelost. Bewuste, kleinere scope, geen bug.
function resolveFloorOpenings(rawOpenings, width, depth, joists, joistW) { function resolveFloorOpenings(rawOpenings, width, depth, joists, joistW) {
const bays = computeBays(joists, joistW); const bays = computeBays(joists, joistW);
@@ -111,14 +81,6 @@ window.FloorModel = (() => {
return resolved; return resolved;
} }
// ----------------------------------------------------------
// Balkverdeling: zelfde algoritme (calculateSegmentPositions) als de wand.
// params = state.floor (of de ruwe invoerwaarden uit de editor).
// rims (optioneel): { front, back } — kopbalk langs de voor-/achterrand
// (haaks op de balken), bijv. op een naad tussen twee units waar de
// modules weer aan elkaar gemonteerd worden. De langsbalken worden daar
// met één balkbreedte ingekort. Langs de zijranden ligt altijd al een balk.
// ----------------------------------------------------------
function computeFrame(params, rims) { function computeFrame(params, rims) {
const width = params.width || 2000; const width = params.width || 2000;
const depth = params.depth || 2000; const depth = params.depth || 2000;
@@ -140,18 +102,11 @@ window.FloorModel = (() => {
return { joists, width, depth, joistW, joistH, underlayThick, rims: r, joistLen }; return { joists, width, depth, joistW, joistH, underlayThick, rims: r, joistLen };
} }
// Bovenkant vloer (mm) = waar de wanden van een unit op staan.
function topHeight(calcData) { function topHeight(calcData) {
return calcData.joistH + calcData.underlayThick; return calcData.joistH + calcData.underlayThick;
} }
// ---------------------------------------------------------- // Isolatie krijgt bewust geen gewicht: de materiaalkeuze is aan de gebruiker.
// Specificaties: balkhout, OSB-onderlaag, isolatie-leegte (zelfde soort
// "envelope minus houtwerk minus openingen"-logica als wall.js) en het
// gewichtstotaal. Isolatie krijgt — net als bij de wand — bewust geen
// eigen dichtheid/gewicht toegekend (materiaalkeuze is aan de gebruiker),
// alleen de resterende inhoud wordt gerapporteerd.
// ----------------------------------------------------------
function computeSpecs(calcData, resolvedOpenings) { function computeSpecs(calcData, resolvedOpenings) {
const toM = 0.001; const toM = 0.001;
const { width, depth, joists, joistW, joistH, underlayThick } = calcData; const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
@@ -183,13 +138,6 @@ window.FloorModel = (() => {
return { joistWoodM3, joistWoodKg, osbAreaM2, osbVolM3, osbKg, voidM3, totalKg }; return { joistWoodM3, joistWoodKg, osbAreaM2, osbVolM3, osbKg, voidM3, totalKg };
} }
// ----------------------------------------------------------
// Chassis-laag: 4 wielen in een "dobbelsteen 4"-indeling, exact
// gecentreerd op de vloer (X: midden van de breedte, Y: midden van de
// diepte). Losse laag t.o.v. het regelwerk — wordt niet tegen balken
// geklemd zoals de doorvoeren, alleen gewaarschuwd als een wiel buiten de
// vloerrand zou uitsteken.
// ----------------------------------------------------------
function computeChassisWheels(chassis, width, depth) { function computeChassisWheels(chassis, width, depth) {
if (!chassis || !chassis.enabled) return { wheels: [], outOfBounds: false }; if (!chassis || !chassis.enabled) return { wheels: [], outOfBounds: false };
const r = chassis.wheelDiameter / 2; const r = chassis.wheelDiameter / 2;
@@ -204,8 +152,6 @@ window.FloorModel = (() => {
return { wheels, outOfBounds }; return { wheels, outOfBounds };
} }
// Alles in één keer vanuit een vloer-state (handig voor de unit).
// rims: zie computeFrame.
function computeFromState(state, rims) { function computeFromState(state, rims) {
const calcData = computeFrame(state.floor, rims); const calcData = computeFrame(state.floor, rims);
const resolvedOpenings = resolveFloorOpenings(state.openings || [], calcData.width, calcData.depth, calcData.joists, calcData.joistW); const resolvedOpenings = resolveFloorOpenings(state.openings || [], calcData.width, calcData.depth, calcData.joists, calcData.joistW);
@@ -213,13 +159,36 @@ window.FloorModel = (() => {
return { calcData, resolvedOpenings, chassisData }; return { calcData, resolvedOpenings, chassisData };
} }
// Vloerafwerking (bovenop de OSB). Texturen in assets/textures/vloer/,
// naadloos herhalend; tileMM = maat van één tegel van de textuur.
const FINISHES = { const FINISHES = {
geen: { label: 'Geen (OSB)', color: '#cda86a' }, geen: { label: 'Geen (OSB)', color: '#cda86a' },
visgraat_001: { label: 'Visgraat (Hongaarse punt)', color: '#a28670', file: 'assets/textures/vloer/visgraat_001.jpg', tileMM: 3000 } visgraat_001: { label: 'Visgraat (Hongaarse punt)', color: '#a28670', file: 'assets/textures/vloer/visgraat_001.jpg', tileMM: 3000 },
tegel_wit: { label: 'Tegel wit 300×300', color: '#ecebe6', tile: { w: 300, h: 300, joint: 3 }, jointColor: '#b9b6ad' },
tegel_grijs: { label: 'Tegel grijs 600×600', color: '#a9a8a3', tile: { w: 600, h: 600, joint: 3 }, jointColor: '#7d7c78' },
tegel_antraciet: { label: 'Tegel antraciet 600×600', color: '#4a4b4d', tile: { w: 600, h: 600, joint: 3 }, jointColor: '#2c2d2e' }
}; };
// Voorvertoning (assets/textures/vloer/thumb/<px>/<naam>.png) of null. function tileTexture(tile, color, jointColor) {
if (typeof document === 'undefined') return null;
const k = 512 / Math.max(tile.w, tile.h);
const cv = document.createElement('canvas');
cv.width = Math.max(8, Math.round(tile.w * k));
cv.height = Math.max(8, Math.round(tile.h * k));
const c = cv.getContext('2d');
c.fillStyle = color;
c.fillRect(0, 0, cv.width, cv.height);
for (let i = 0; i < 400; i++) {
c.fillStyle = `rgba(${Math.random() < 0.5 ? '255,255,255' : '0,0,0'},0.025)`;
c.fillRect(Math.random() * cv.width, Math.random() * cv.height, 6 + Math.random() * 20, 6 + Math.random() * 20);
}
const j = Math.max(1, Math.round(tile.joint * k));
c.fillStyle = jointColor;
c.fillRect(0, 0, cv.width, j);
c.fillRect(0, 0, j, cv.height);
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / tile.w, 1000 / tile.h);
tex.anisotropy = 8;
return tex;
}
function finishThumbUrl(key, px) { function finishThumbUrl(key, px) {
const f = FINISHES[key]; const f = FINISHES[key];
return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null; return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null;
@@ -227,8 +196,13 @@ window.FloorModel = (() => {
const finishMaterials = {}; const finishMaterials = {};
function finishMaterial(key) { function finishMaterial(key) {
const f = FINISHES[key]; const f = FINISHES[key];
if (!f || !f.file) return null; if (!f || !(f.file || f.tile)) return null;
if (finishMaterials[key]) return finishMaterials[key]; if (finishMaterials[key]) return finishMaterials[key];
if (f.tile) {
const tex = tileTexture(f.tile, f.color, f.jointColor);
finishMaterials[key] = new THREE.MeshStandardMaterial(tex ? { map: tex, roughness: 0.45 } : { color: f.color, roughness: 0.45 });
return finishMaterials[key];
}
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 }); const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
if (typeof document !== 'undefined' && THREE.TextureLoader) { if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(f.file, (tex) => { new THREE.TextureLoader().load(f.file, (tex) => {
@@ -252,18 +226,7 @@ window.FloorModel = (() => {
}; };
} }
// ---------------------------------------------------------- // finish.originX/Z: UV's in woning-meters, zodat het patroon over units doorloopt.
// 3D-opbouw (meters): X = breedte, Z = diepte (y=0 "voor" ligt op
// Z = -diepte/2), Y = dikte (balkhoogte + OSB). Balken en isolatie zijn
// simpele dozen; de OSB-onderlaag is een geëxtrudeerd Shape met ronde
// uitsparingen voor de doorvoeren.
// 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
// dun vlak op de OSB; UV's in meters vanaf origin (m, woning-coördinaten
// van het vloermidden), zodat het patroon over units doorloopt.
// ----------------------------------------------------------
function build3D(calcData, resolvedOpenings, chassisData, opts) { function build3D(calcData, resolvedOpenings, chassisData, opts) {
const { woodMaterial, osbMaterial, wheelMaterial } = opts.materials; const { woodMaterial, osbMaterial, wheelMaterial } = opts.materials;
const osbVisible = opts.osbVisible !== false; const osbVisible = opts.osbVisible !== false;
@@ -274,8 +237,6 @@ window.FloorModel = (() => {
const widthM = width * toM, depthM = depth * toM; const widthM = width * toM, depthM = depth * toM;
const joistWM = joistW * toM, joistHM = joistH * toM, underlayM = underlayThick * toM; const joistWM = joistW * toM, joistHM = joistH * toM, underlayM = underlayThick * toM;
// A. VLOERBALKEN — dozen over de diepte (ingekort bij een kopbalk), plus
// de kopbalken (rims) over de volle breedte.
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;
@@ -294,13 +255,8 @@ window.FloorModel = (() => {
floorGroup3D.add(mesh); floorGroup3D.add(mesh);
}); });
// (Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.) // Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.
// C. OSB-ONDERLAAG — één geëxtrudeerd vlak over de volle vloer, met
// ronde uitsparingen voor de doorvoeren. Shape wordt opgebouwd in
// (worldX, worldZ)-vlak en vervolgens 90° gekanteld zodat de dikte
// (extrusierichting) verticaal (wereld-Y) komt te liggen, boven op de
// balken.
const osbShape = new THREE.Shape(); const osbShape = new THREE.Shape();
osbShape.moveTo(-widthM / 2, -depthM / 2); osbShape.moveTo(-widthM / 2, -depthM / 2);
osbShape.lineTo(widthM / 2, -depthM / 2); osbShape.lineTo(widthM / 2, -depthM / 2);
@@ -335,8 +291,6 @@ window.FloorModel = (() => {
osbEdges.visible = osbVisible; osbEdges.visible = osbVisible;
floorGroup3D.add(osbEdges); floorGroup3D.add(osbEdges);
// D. CHASSIS-WIELEN — schematische cilinders onder de vloer, alleen als
// de chassis-laag aan staat (zelfde brondata als de 2D-tekening).
(chassisData ? chassisData.wheels : []).forEach(wheel => { (chassisData ? chassisData.wheels : []).forEach(wheel => {
const rM = wheel.r * toM; const rM = wheel.r * toM;
const thicknessM = 0.08; const thicknessM = 0.08;
@@ -348,8 +302,6 @@ window.FloorModel = (() => {
floorGroup3D.add(mesh); floorGroup3D.add(mesh);
}); });
// Vloerafwerking: vlak net boven de OSB, over de hele vloer (onder de
// wanden verdwijnt het), met de doorvoeren uitgespaard.
const fin = opts.finish && finishMaterial(opts.finish.key); const fin = opts.finish && finishMaterial(opts.finish.key);
if (fin) { if (fin) {
const shape = new THREE.Shape(); const shape = new THREE.Shape();
@@ -377,7 +329,6 @@ window.FloorModel = (() => {
return floorGroup3D; return floorGroup3D;
} }
// Structuurcontrole van een vloer-state (bijv. bij JSON-import).
function validateState(design) { function validateState(design) {
if (!design.floor || typeof design.floor !== 'object') throw new Error('veld "floor" ontbreekt'); if (!design.floor || typeof design.floor !== 'object') throw new Error('veld "floor" ontbreekt');
const requiredFloorFields = ['width', 'depth', 'joistWidth', 'joistHeight', 'minSpacing', 'maxSpacing', 'underlayThick']; const requiredFloorFields = ['width', 'depth', 'joistWidth', 'joistHeight', 'minSpacing', 'maxSpacing', 'underlayThick'];
@@ -401,7 +352,7 @@ window.FloorModel = (() => {
} }
return { return {
FINISHES, finishMaterial, finishThumbUrl, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels, FINISHES, finishMaterial, finishThumbUrl, tileTexture, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
computeSpecs, computeFromState, createMaterials, build3D computeSpecs, computeFromState, createMaterials, build3D
}; };
})(); })();
+4 -31
View File
@@ -4,25 +4,10 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// floor/floor-pdf.js — ENIGE bron van het vloer-tekenblad (A3 liggend):
// frame- en plaatmateriaal, bovenaanzicht met balken/doorvoeren en
// maatketens, doorsnede en titelblok. Gebruikt door de losse vloer-editor en
// door de unit-PDF.
// Plain script, geladen na floor-model.js en core/pdf-sheet.js.
// ============================================================
window.FloorPdf = (() => { window.FloorPdf = (() => {
// ------------------------------------------------------------ // Bewust altijd één doorlopende module (de voorbeelden in examples/ splitsen grote vloeren).
// Zelfde opzet als WallPdf (frame-materiaallijst, plaatmateriaallijst,
// titelblok, hoofdaanzicht met maatketens). Layout/kolommen zijn
// afgestemd op de bestaande "Vloer^V-x"-tekeningen (zie examples/) —
// die voorbeelden splitsen een grote vloer in meerdere modules; hier
// wordt bewust altijd maar één, doorlopende module getekend.
// ------------------------------------------------------------
// Tekent één volledig vloerblad op de HUIDIGE pagina van `doc`.
// opts: { project, titel, element, blad, roof }
function drawSheet(doc, calcData, resolvedOpenings, opts) { function drawSheet(doc, calcData, resolvedOpenings, opts) {
const { width, depth, joists, joistW, joistH, underlayThick } = calcData; const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
@@ -31,8 +16,7 @@ window.FloorPdf = (() => {
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
// --- Frame materiaal-tabel (linksboven) — één groep: alle vloerbalken // Eén groep: alle vloerbalken hebben dezelfde doorsnede en lengte.
// hebben in dit model dezelfde doorsnede/lengte. ---
const tblX = [8, 26, 42, 58, 76, 92]; const tblX = [8, 26, 42, 58, 76, 92];
let ty = 14; let ty = 14;
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
@@ -51,12 +35,11 @@ window.FloorPdf = (() => {
[String(1), String(joistW), String(joistH), String(Math.round(joistLen)), String(joists.length)] [String(1), String(joistW), String(joistH), String(Math.round(joistLen)), String(joists.length)]
.forEach((v, i) => doc.text(v, tblX[i], ty)); .forEach((v, i) => doc.text(v, tblX[i], ty));
if (rimCount) { if (rimCount) {
ty += 4.3; // kopbalk(en) op een naad (FloorModel.computeFrame) ty += 4.3;
[String(2), String(joistW), String(joistH), String(Math.round(width)), String(rimCount)] [String(2), String(joistW), String(joistH), String(Math.round(width)), String(rimCount)]
.forEach((v, i) => doc.text(v, tblX[i], ty)); .forEach((v, i) => doc.text(v, tblX[i], ty));
} }
// --- Plaatmateriaal + isolatie ---
let my = 240; let my = 240;
doc.setFontSize(8.5); doc.setFontSize(8.5);
doc.text('Plaat materiaal', tblX[0], my); my += 4.5; doc.text('Plaat materiaal', tblX[0], my); my += 4.5;
@@ -73,10 +56,8 @@ window.FloorPdf = (() => {
my += 6; my += 6;
doc.text('Isolatie: leegte tussen de balken (spouwvullend)', tblX[0], my); doc.text('Isolatie: leegte tussen de balken (spouwvullend)', tblX[0], my);
// --- Titelblok (rechtsonder) ---
PdfSheet.titleBlock(doc, opts.project, { titel: opts.titel, element: opts.element, blad: opts.blad }); PdfSheet.titleBlock(doc, opts.project, { titel: opts.titel, element: opts.element, blad: opts.blad });
// --- Hoofdaanzicht (bovenaanzicht) ---
const drawX0 = 95, drawX1 = 410, drawY0 = 15, drawY1 = 178; const drawX0 = 95, drawX1 = 410, drawY0 = 15, drawY1 = 178;
const marginSides = 25, marginTop = 15, marginBottom = 30; const marginSides = 25, marginTop = 15, marginBottom = 30;
const availW = (drawX1 - drawX0) - marginSides * 2; const availW = (drawX1 - drawX0) - marginSides * 2;
@@ -86,7 +67,7 @@ window.FloorPdf = (() => {
const originX = drawX0 + marginSides; const originX = drawX0 + marginSides;
const topY = drawY0 + marginTop; const topY = drawY0 + marginTop;
const px = (xmm) => originX + xmm * scale; const px = (xmm) => originX + xmm * scale;
const py = (ymm) => topY + ymm * scale; // y=0 (voor) boven, groeit naar beneden const py = (ymm) => topY + ymm * scale;
doc.setDrawColor(...GREY); doc.setDrawColor(...GREY);
doc.setLineWidth(0.25); doc.setLineWidth(0.25);
@@ -117,8 +98,6 @@ window.FloorPdf = (() => {
doc.text('#' + o.id, cx, cy - o.r * scale - 2, { align: 'center' }); doc.text('#' + o.id, cx, cy - o.r * scale - 2, { align: 'center' });
}); });
// Extra laag van de aanroeper (bijv. elektra, ElektraPdf): overlay(doc, px, py),
// px/py = mm vanaf linksvoor van het dek -> blad-mm.
if (opts.overlay) opts.overlay(doc, px, py); if (opts.overlay) opts.overlay(doc, px, py);
const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2); const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2);
@@ -135,8 +114,6 @@ window.FloorPdf = (() => {
drawDimLine(dimLeftX, py(0), dimLeftX, py(depth)); drawDimLine(dimLeftX, py(0), dimLeftX, py(depth));
doc.text(String(Math.round(depth)), dimLeftX - 2, (py(0) + py(depth)) / 2, { align: 'center', angle: 90 }); doc.text(String(Math.round(depth)), dimLeftX - 2, (py(0) + py(depth)) / 2, { align: 'center', angle: 90 });
// H.o.h.-segmenten + cumulatieve maten onder de tekening — zelfde
// opbouw als de stijl-maatketens in wall.js, nu op de balkposities.
const joistEdges = new Set([0, width]); const joistEdges = new Set([0, width]);
joists.forEach(x => joistEdges.add(Math.round(x + width / 2 - joistW / 2))); joists.forEach(x => joistEdges.add(Math.round(x + width / 2 - joistW / 2)));
const cumPoints = [...joistEdges].filter(v => v >= 0 && v <= width).sort((a, b) => a - b); const cumPoints = [...joistEdges].filter(v => v >= 0 && v <= width).sort((a, b) => a - b);
@@ -155,8 +132,6 @@ window.FloorPdf = (() => {
doc.line(px(v), cumY - 2.5, px(v), py(depth) + 2); doc.line(px(v), cumY - 2.5, px(v), py(depth) + 2);
}); });
// --- Doorsnede (balkprofiel) — kleine profielweergave onderaan,
// analoog aan de bestaande 2D-doorsnede-canvas. ---
const secY0 = Math.min(PAGE_H - 20, py(depth) + 30); const secY0 = Math.min(PAGE_H - 20, py(depth) + 30);
const secScale = Math.min((drawX1 - drawX0 - 40) / width, 18 / (joistH + underlayThick)); const secScale = Math.min((drawX1 - drawX0 - 40) / width, 18 / (joistH + underlayThick));
const secOriginX = drawX0 + 20; const secOriginX = drawX0 + 20;
@@ -164,8 +139,6 @@ window.FloorPdf = (() => {
const spx = (xmm) => secOriginX + xmm * secScale; const spx = (xmm) => secOriginX + xmm * secScale;
const spy = (ymm) => secFloorY - ymm * secScale; const spy = (ymm) => secFloorY - ymm * secScale;
// Dak (opts.roof): 180° gespiegeld — OSB onder (plafond), balken
// erboven, dakleer bovenop (RoofModel).
const joistY0 = opts.roof ? underlayThick : 0; const joistY0 = opts.roof ? underlayThick : 0;
const osbY0 = opts.roof ? 0 : joistH; const osbY0 = opts.roof ? 0 : joistH;
doc.setDrawColor(...RED); doc.setDrawColor(...RED);
+4 -96
View File
@@ -4,28 +4,6 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// floor/floor.js — Vloer-editor. Hergebruikt calculateSegmentPositions uit
// core/geometry.js (moet voor dit bestand geladen worden) voor exact
// dezelfde h.o.h.-balkverdeling als de wand, nu toegepast op vloerbalken.
// Vloeren krijgen UITSLUITEND cirkelvormige openingen (leidingdoorvoer,
// bijv. riool/water) — en die mogen alleen TUSSEN twee balken vallen, nooit
// een balk kruisen: een doorvoer wordt daarom automatisch verkleind tot een
// maat die wél binnen zijn balkvak past, en pas verwijderd (uit de
// weergave, niet uit de invoer) als zelfs een minimale diameter nergens past.
//
// Zelfde soorten logica als wall.js: een isolatielaag (leegte tussen de
// balken, analytisch bepaald — geen gewicht toegekend, precies zoals bij de
// wand), een 3D-perspectief (balken, OSB-onderlaag met doorvoer-gaten,
// isolatie, chassis-wielen) en een specificaties-paneel met gewichten.
// Onderlaag is standaard OSB-plaat van 22mm.
//
// De rekenlogica (balken, doorvoeren, chassis, specs, 3D-opbouw) staat in
// floor/floor-model.js (FloorModel) — gedeeld met de unit-editor.
//
// Plain script (geen ES-module), zelfde mount()/unmount()-patroon als
// wall.js.
// ============================================================
const FLOOR_TEMPLATE = ` const FLOOR_TEMPLATE = `
<div id="floor-app"> <div id="floor-app">
@@ -198,9 +176,6 @@ const FLOOR_TEMPLATE = `
</div> </div>
`; `;
// Dakmodus: een plat dak is een vloer die 180° gespiegeld is (OSB-plaat aan
// de onderzijde als plafond, balken erboven) met dakleer als buitenlaag
// (RoofModel). Zelfde editor, andere teksten + gespiegelde doorsnede/3D.
function floorTemplateForRoof() { function floorTemplateForRoof() {
const r = [ const r = [
[' Vloerbalken</h1>', ' Dakbalken</h1>'], [' Vloerbalken</h1>', ' Dakbalken</h1>'],
@@ -220,8 +195,6 @@ function mountFloorEditor(container, callbacks) {
const roofMode = !!(callbacks && callbacks.mode === 'roof'); const roofMode = !!(callbacks && callbacks.mode === 'roof');
container.innerHTML = roofMode ? floorTemplateForRoof() : FLOOR_TEMPLATE; container.innerHTML = roofMode ? floorTemplateForRoof() : FLOOR_TEMPLATE;
// Binnen een woning kan de maat vastliggen (dak volgt de vloer);
// callbacks.lockSize is dan de uitleg-tekst voor de tooltip.
if (callbacks && callbacks.lockSize) { if (callbacks && callbacks.lockSize) {
['f-inp-width', 'f-inp-depth'].forEach(id => { ['f-inp-width', 'f-inp-depth'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -230,25 +203,12 @@ function mountFloorEditor(container, callbacks) {
}); });
} }
// ----------------------------------------------------------
// State
// ----------------------------------------------------------
// Dichtheden, balkvak-marge en minimale doorvoer-diameter: zie FloorModel.
// Geen residuele demo-doorvoer meer — leeg beginnen, gebruiker voegt zelf toe.
let openings = []; let openings = [];
let openingIdCounter = 1; let openingIdCounter = 1;
// Chassis-laag: een losse, schematische onderconstructie (transportwielen) // Chassis wordt niet geklemd (anders dan doorvoeren), alleen gewaarschuwd buiten de vloerrand.
// die NIET met de balkpositie hoeft te overlappen — in tegenstelling tot de
// doorvoeren hierboven wordt hier niets geklemd/verkleind, alleen buiten de
// vloerrand gewaarschuwd (zie computeChassisWheels).
let chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 }; let chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
// Tab-navigatie (1. Vloer Opbouw / 2. Doorvoeren / 3. Chassis) — dezelfde
// generieke .tab-btn/.tab-content-wiring als wall.js. Ontbrak hier eerder
// (residu van vóór de tab-bar werd toegevoegd), waardoor de knoppen wel
// getekend maar niet klikbaar waren.
function switchTab(tabId) { function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(ct => ct.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(ct => ct.classList.remove('active'));
@@ -342,11 +302,7 @@ function mountFloorEditor(container, callbacks) {
})); }));
syncChassisToggleLabel(); syncChassisToggleLabel();
// ------------------------------------------------------------ // Bewust dezelfde id's als wall.js: er is nooit meer dan één editor tegelijk gemount.
// Project eigenschappen — zelfde modal/velden als wall.js (bewust
// dezelfde id's: er is nooit meer dan één editor gelijktijdig gemount,
// dus hergebruik hiervan is veilig en voorkomt dubbele CSS/HTML).
// ------------------------------------------------------------
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer', const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking']; 'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
let projectPropsSnapshot = null; let projectPropsSnapshot = null;
@@ -375,12 +331,6 @@ function mountFloorEditor(container, callbacks) {
} }
}); });
// ------------------------------------------------------------
// JSON export/import — zelfde patroon als wall.js: sla het volledige
// vloer-ontwerp op (parameters + chassis + ruwe doorvoeren) als JSON, en
// laad het weer terug. Bij "Opslaan als" wordt ook automatisch de
// bijbehorende PDF-tekening gegenereerd (zie generateFloorPdf).
// ------------------------------------------------------------
function setIoStatus(msg, cls) { function setIoStatus(msg, cls) {
const el = document.getElementById('f-io-status'); const el = document.getElementById('f-io-status');
el.textContent = msg; el.textContent = msg;
@@ -456,12 +406,6 @@ function mountFloorEditor(container, callbacks) {
e.target.value = ''; e.target.value = '';
}); });
// Balkvakken + doorvoer-resolutie (nooit een balk kruisen): zie
// FloorModel.resolveOpenings (floor/floor-model.js).
// ----------------------------------------------------------
// Balkverdeling: FloorModel.computeFrame (zelfde algoritme als de wand).
// ----------------------------------------------------------
let calcData = { joists: [] }; let calcData = { joists: [] };
function fmt(n, decimals = 1) { function fmt(n, decimals = 1) {
@@ -483,8 +427,6 @@ function mountFloorEditor(container, callbacks) {
}); });
} }
// Specificaties: FloorModel.computeSpecs (balkhout, OSB, isolatie-leegte,
// gewichtstotaal).
function updateSpecificaties(resolvedOpenings) { function updateSpecificaties(resolvedOpenings) {
const specs = roofMode ? RoofModel.computeSpecs(calcData, resolvedOpenings) : FloorModel.computeSpecs(calcData, resolvedOpenings); const specs = roofMode ? RoofModel.computeSpecs(calcData, resolvedOpenings) : FloorModel.computeSpecs(calcData, resolvedOpenings);
if (roofMode) { if (roofMode) {
@@ -508,17 +450,12 @@ function mountFloorEditor(container, callbacks) {
return specs; return specs;
} }
// Chassis-laag (4 wielen, "dobbelsteen 4"): FloorModel.computeChassisWheels.
function updateChassisWarning(outOfBounds) { function updateChassisWarning(outOfBounds) {
const warnBox = document.getElementById('f-chassis-warning'); const warnBox = document.getElementById('f-chassis-warning');
warnBox.style.display = outOfBounds ? 'block' : 'none'; warnBox.style.display = outOfBounds ? 'block' : 'none';
warnBox.textContent = 'Let op: één of meer wielen steken buiten de vloerrand — verklein de spoorbreedte/wielbasis of vergroot de vloer.'; warnBox.textContent = 'Let op: één of meer wielen steken buiten de vloerrand — verklein de spoorbreedte/wielbasis of vergroot de vloer.';
} }
// ----------------------------------------------------------
// Canvas: bovenaanzicht
// ----------------------------------------------------------
const canvasTop = document.getElementById('floor-canvas-top'); const canvasTop = document.getElementById('floor-canvas-top');
const ctxTop = canvasTop.getContext('2d'); const ctxTop = canvasTop.getContext('2d');
@@ -570,7 +507,6 @@ function mountFloorEditor(container, callbacks) {
ctxTop.fill(); ctxTop.stroke(); ctxTop.fill(); ctxTop.stroke();
}); });
// Maatlabels (zelfde stijl als het wand-vooraanzicht, core/canvas-labels.js)
const xAxisY = Math.max(14, toY(0) - 14); const xAxisY = Math.max(14, toY(0) - 14);
drawCanvasDim(ctxTop, toX(-width / 2), xAxisY, toX(width / 2), xAxisY, drawCanvasDim(ctxTop, toX(-width / 2), xAxisY, toX(width / 2), xAxisY,
'X: ' + Math.round(width) + 'mm', cx, Math.max(10, xAxisY - 4)); 'X: ' + Math.round(width) + 'mm', cx, Math.max(10, xAxisY - 4));
@@ -579,10 +515,6 @@ function mountFloorEditor(container, callbacks) {
'Y: ' + Math.round(depth) + 'mm', Math.max(10, yAxisX - 4), (toY(0) + toY(depth)) / 2); 'Y: ' + Math.round(depth) + 'mm', Math.max(10, yAxisX - 4), (toY(0) + toY(depth)) / 2);
} }
// ----------------------------------------------------------
// Canvas: doorsnede (balkprofiel — breedte x hoogte, aanvullende kijkhoek
// naast het bovenaanzicht, analoog aan de vooraanzicht-doorsnede bij wall.js)
// ----------------------------------------------------------
const canvasSection = document.getElementById('floor-canvas-section'); const canvasSection = document.getElementById('floor-canvas-section');
const ctxSection = canvasSection.getContext('2d'); const ctxSection = canvasSection.getContext('2d');
@@ -594,7 +526,6 @@ function mountFloorEditor(container, callbacks) {
const w = canvasSection.width, h = canvasSection.height; const w = canvasSection.width, h = canvasSection.height;
ctxSection.clearRect(0, 0, w, h); ctxSection.clearRect(0, 0, w, h);
// Dakmodus: 180° gespiegeld — OSB onder, balken erboven, dakleer bovenop.
const leerT = roofMode ? RoofModel.DAKLEER_MM : 0; const leerT = roofMode ? RoofModel.DAKLEER_MM : 0;
const totalH = joistH + underlayThick + leerT; const totalH = joistH + underlayThick + leerT;
const joistY0 = roofMode ? underlayThick : 0; const joistY0 = roofMode ? underlayThick : 0;
@@ -610,9 +541,6 @@ function mountFloorEditor(container, callbacks) {
ctxSection.lineTo(toX(width / 2), toY(0)); ctxSection.lineTo(toX(width / 2), toY(0));
ctxSection.stroke(); ctxSection.stroke();
// Isolatie (leegte tussen de balken) — geschematiseerd als een
// doorlopende, licht transparante band ter hoogte van de balken, zodat
// ook in de doorsnede duidelijk is wát er tussen de balken zit.
ctxSection.fillStyle = 'rgba(255, 224, 140, 0.25)'; ctxSection.fillStyle = 'rgba(255, 224, 140, 0.25)';
ctxSection.fillRect(toX(-width / 2), toY(joistY0 + joistH), width * scale, toY(joistY0) - toY(joistY0 + joistH)); ctxSection.fillRect(toX(-width / 2), toY(joistY0 + joistH), width * scale, toY(joistY0) - toY(joistY0 + joistH));
@@ -622,7 +550,6 @@ function mountFloorEditor(container, callbacks) {
ctxSection.strokeRect(toX(x - joistW / 2), toY(joistY0 + joistH), joistW * scale, joistH * scale); ctxSection.strokeRect(toX(x - joistW / 2), toY(joistY0 + joistH), joistW * scale, joistH * scale);
}); });
// OSB-plaat: bij een vloer boven op de balken, bij een dak eronder (plafond).
ctxSection.fillStyle = '#cda86a'; ctxSection.strokeStyle = '#8a6836'; ctxSection.fillStyle = '#cda86a'; ctxSection.strokeStyle = '#8a6836';
ctxSection.fillRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick)); ctxSection.fillRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick));
ctxSection.strokeRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick)); ctxSection.strokeRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick));
@@ -642,7 +569,6 @@ function mountFloorEditor(container, callbacks) {
ctxSection.fillText(underlayThick + 'mm OSB', cx, toY(joistH + underlayThick) - 6); ctxSection.fillText(underlayThick + 'mm OSB', cx, toY(joistH + underlayThick) - 6);
} }
// Maatlabels (zelfde stijl als het wand-vooraanzicht)
const xAxisY = Math.min(h - 12, toY(0) + 8); const xAxisY = Math.min(h - 12, toY(0) + 8);
drawCanvasDim(ctxSection, toX(-width / 2), xAxisY, toX(width / 2), xAxisY, drawCanvasDim(ctxSection, toX(-width / 2), xAxisY, toX(width / 2), xAxisY,
'X: ' + Math.round(width) + 'mm', cx, Math.min(h - 2, xAxisY + 11)); 'X: ' + Math.round(width) + 'mm', cx, Math.min(h - 2, xAxisY + 11));
@@ -651,9 +577,6 @@ function mountFloorEditor(container, callbacks) {
'Z: ' + Math.round(totalH) + 'mm', Math.min(w - 4, zAxisX + 12), (toY(0) + toY(totalH)) / 2); 'Z: ' + Math.round(totalH) + 'mm', Math.min(w - 4, zAxisX + 12), (toY(0) + toY(totalH)) / 2);
} }
// ----------------------------------------------------------
// Waarschuwingen naar de openingen-kaarten
// ----------------------------------------------------------
function updateWarnings(resolved) { function updateWarnings(resolved) {
const anyWarn = resolved.some(o => o.overflowing); const anyWarn = resolved.some(o => o.overflowing);
const warnBox = document.getElementById('f-openings-global-warn'); const warnBox = document.getElementById('f-openings-global-warn');
@@ -672,14 +595,6 @@ function mountFloorEditor(container, callbacks) {
}); });
} }
// ----------------------------------------------------------
// THREE.JS 3D RENDERER — zelfde opzet als wall.js (scene/camera/renderer/
// OrbitControls/licht/grid), maar plat gelegd: X = breedte, Z = diepte,
// Y = dikte (balkhoogte + OSB), in plaats van wall.js' X/Y/Z (breedte/
// hoogte/diepte). Balken en isolatie zijn simpele dozen; de OSB-onderlaag
// is — net als de platen bij wall.js — een geëxtrudeerd Shape met ronde
// uitsparingen voor de doorvoeren.
// ----------------------------------------------------------
const pane3D = document.getElementById('floor-pane-3d'); const pane3D = document.getElementById('floor-pane-3d');
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a); scene.background = new THREE.Color(0x1a1a1a);
@@ -717,15 +632,12 @@ function mountFloorEditor(container, callbacks) {
let lastResolvedOpenings = []; let lastResolvedOpenings = [];
let lastChassisData = { wheels: [], outOfBounds: false }; let lastChassisData = { wheels: [], outOfBounds: false };
// Weergave-filter (core/view-filter.js). Beplating = OSB-plaat (bij een
// dak: plafondplaat + dakleer).
createViewFilter(document.getElementById('floor-toolbar-3d'), [ createViewFilter(document.getElementById('floor-toolbar-3d'), [
{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' } { key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }
], (v) => { ], (v) => {
osbVisible = v.beplating; osbVisible = v.beplating;
update3DScene(); update3DScene();
}); });
// Omgeving (core/skybox.js: geen/dag/nacht); de vloer staat op de tegelvloer.
let skyMode = Skybox.getMode(); let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene); const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => { const applySky = () => {
@@ -740,13 +652,9 @@ function mountFloorEditor(container, callbacks) {
? RoofModel.build3D(calcData, lastResolvedOpenings, { materials: floorMaterials, osbVisible, dakleerVisible: osbVisible }) ? RoofModel.build3D(calcData, lastResolvedOpenings, { materials: floorMaterials, osbVisible, dakleerVisible: osbVisible })
: FloorModel.build3D(calcData, lastResolvedOpenings, lastChassisData, { materials: floorMaterials, osbVisible }); : FloorModel.build3D(calcData, lastResolvedOpenings, lastChassisData, { materials: floorMaterials, osbVisible });
scene.add(floorGroup3D); scene.add(floorGroup3D);
applySky(); // schaduw van de nieuwe vloer applySky();
} }
// ------------------------------------------------------------
// PDF-export: het tekenblad zelf komt uit FloorPdf (floor/floor-pdf.js),
// gedeeld met de unit-PDF.
// ------------------------------------------------------------
function generateFloorPdf() { function generateFloorPdf() {
recalculate(); recalculate();
const resolvedOpenings = FloorModel.resolveOpenings(openings, calcData.width, calcData.depth, calcData.joists, calcData.joistW); const resolvedOpenings = FloorModel.resolveOpenings(openings, calcData.width, calcData.depth, calcData.joists, calcData.joistW);
@@ -784,7 +692,7 @@ function mountFloorEditor(container, callbacks) {
window.addEventListener('resize', onWindowResize); window.addEventListener('resize', onWindowResize);
function toggleFullscreen3D() { function toggleFullscreen3D() {
UI.cycle3D(document.getElementById('floor-app')); // T: standaard -> 3D groot -> alleen render UI.cycle3D(document.getElementById('floor-app'));
requestAnimationFrame(onWindowResize); requestAnimationFrame(onWindowResize);
} }
function toggleFullscreen2D() { function toggleFullscreen2D() {
+422 -277
View File
File diff suppressed because it is too large Load Diff
+77 -79
View File
@@ -4,17 +4,6 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// house/house-pdf.js — HousePdf: de samenhangende woning-PDF.
// Blad 1 aanzichten noord/oost/zuid/west (vlakke "render", vectorvormen)
// Blad 2 plattegrond woning (units, wandstukken, naden, binnenwanden)
// daarna per unit: vloer (FloorPdf) + elk wandstuk (WallPdf)
// daarna elke binnenwand (WallPdf)
// daarna totalen (balken, inkoop, glaswol, plaatwerk, gewicht, dakrand)
// laatste zaaglijst vuren + zaagmachine-overzicht (tijdelijk)
// De losse bladen komen uit de gedeelde WallPdf/FloorPdf.
// Plain script, geladen na house-model.js.
// ============================================================
window.HousePdf = (() => { window.HousePdf = (() => {
const KIND_LABELS = { const KIND_LABELS = {
@@ -29,14 +18,11 @@ window.HousePdf = (() => {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
} }
// Rechthoek van een wandstuk (bovenaanzicht) als 4 hoekpunten.
function pieceCorners(place, lx0, lx1) { function pieceCorners(place, lx0, lx1) {
const t = place.thickness / 2; const t = place.thickness / 2;
return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([lx, lz]) => UnitModel.localToWorld(place, lx, lz)); return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([lx, lz]) => UnitModel.localToWorld(place, lx, lz));
} }
// Bladvolgorde + elementnummers; gedeeld door generate() en de
// onderdelenlijst op blad 1.
function sheetList(parts) { function sheetList(parts) {
const list = [ const list = [
{ kind: 'views', element: VIEWS_ELEMENT, label: 'Aanzichten woning' }, { kind: 'views', element: VIEWS_ELEMENT, label: 'Aanzichten woning' },
@@ -46,7 +32,6 @@ 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 }); 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 }));
@@ -78,7 +63,6 @@ window.HousePdf = (() => {
doc.lines(pts.slice(1).map((p, i) => [px(p.x) - px(pts[i].x), py(p.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], style, true); doc.lines(pts.slice(1).map((p, i) => [px(p.x) - px(pts[i].x), py(p.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], style, true);
}; };
// Vloeren + unit-letter
rects.forEach(r => { rects.forEach(r => {
doc.setDrawColor(...GREY); doc.setDrawColor(...GREY);
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
@@ -88,7 +72,13 @@ window.HousePdf = (() => {
doc.text(r.id, px(r.cx), py(r.cz) + 2, { align: 'center' }); doc.text(r.id, px(r.cx), py(r.cz) + 2, { align: 'center' });
}); });
// Wandstukken (buiten + binnen) met openingen const R = parts.rooms;
const light = (hex, k) => [1, 3, 5].map(i => Math.round(255 - (255 - parseInt(hex.slice(i, i + 2), 16)) * k));
R.rooms.forEach(r => {
doc.setFillColor(...light(r.color, 0.35));
r.rects.forEach(c => doc.rect(px(c.x0), py(c.z0), (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale, 'F'));
});
const drawPiece = (p) => { const drawPiece = (p) => {
doc.setDrawColor(...RED); doc.setDrawColor(...RED);
doc.setLineWidth(0.3); doc.setLineWidth(0.3);
@@ -101,11 +91,33 @@ window.HousePdf = (() => {
poly(c, 'FD'); poly(c, 'FD');
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z)); doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
doc.line(px(c[1].x), py(c[1].z), px(c[3].x), py(c[3].z)); doc.line(px(c[1].x), py(c[1].z), px(c[3].x), py(c[3].z));
if (op.fill) {
const W = p.place.width, T = p.place.thickness;
KozijnModel.planSymbol(op, op.fill, { zIn: -T / 2, zOut: T / 2 }).forEach(sh => {
doc.setLineWidth(sh.kind === 'leaf' ? 0.25 : 0.12);
doc.setLineDashPattern(sh.kind === 'arc' || sh.kind === 'rail' ? [0.8, 0.8] : [], 0);
pline(sh.pts.map(pt => UnitModel.localToWorld(p.place, -W / 2 + op.x + pt.x, pt.z)), !!sh.closed);
});
doc.setLineDashPattern([], 0);
doc.setLineWidth(0.2);
}
}); });
}; };
const pline = (pts, closed) => {
if (pts.length < 2) return;
doc.lines(pts.slice(1).map((q, i) => [px(q.x) - px(pts[i].x), py(q.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], 'S', closed);
};
doc.setDrawColor(110, 110, 110);
(parts.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
doc.setLineWidth(sh.kind === 'thin' ? 0.1 : 0.18);
doc.setLineDashPattern(sh.kind === 'dash' ? [0.8, 0.6] : [], 0);
if (sh.circle) doc.circle(px(sh.circle.x), py(sh.circle.z), Math.max(0.2, sh.circle.r * scale), 'S');
else if (sh.text) { doc.setFontSize(4); doc.setTextColor(110, 110, 110); doc.text(sh.text, px(sh.at.x), py(sh.at.z) + 0.6, { align: 'center' }); }
else pline(sh.pts, !!sh.closed);
}));
doc.setLineDashPattern([], 0);
HouseModel.allWallPieces(parts).forEach(drawPiece); HouseModel.allWallPieces(parts).forEach(drawPiece);
// Dak: dakranden (grijs gestippeld), boeideel/daktrim-buitenrand, spots
doc.setLineDashPattern([1, 1.2], 0); doc.setLineDashPattern([1, 1.2], 0);
doc.setDrawColor(...GREY); doc.setDrawColor(...GREY);
doc.setLineWidth(0.15); doc.setLineWidth(0.15);
@@ -123,7 +135,6 @@ window.HousePdf = (() => {
} }
doc.setLineDashPattern([], 0); doc.setLineDashPattern([], 0);
// Gevellampen
doc.setDrawColor(...BLACK); doc.setDrawColor(...BLACK);
doc.setFillColor(255, 211, 107); doc.setFillColor(255, 211, 107);
doc.setLineWidth(0.15); doc.setLineWidth(0.15);
@@ -131,17 +142,14 @@ 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); ElektraPdf.drawPlan(doc, parts.elektra, px, py);
// Naden: gestippeld oranje
doc.setDrawColor(230, 130, 30); doc.setDrawColor(230, 130, 30);
doc.setLineWidth(0.4); doc.setLineWidth(0.4);
doc.setLineDashPattern([1.5, 1], 0); doc.setLineDashPattern([1.5, 1], 0);
parts.seams.forEach(s => doc.line(px(s.x0), py(s.z0), px(s.x1), py(s.z1))); parts.seams.forEach(s => doc.line(px(s.x0), py(s.z0), px(s.x1), py(s.z1)));
doc.setLineDashPattern([], 0); doc.setLineDashPattern([], 0);
// Maatlijnen buiten elk buiten-wandstuk + labels binnenwanden
doc.setDrawColor(...BLUE); doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE); doc.setTextColor(...BLUE);
doc.setLineWidth(0.15); doc.setLineWidth(0.15);
@@ -162,7 +170,30 @@ window.HousePdf = (() => {
doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: Math.abs(Math.sin(p.place.rotY)) < 0.5 ? 0 : 90 }); doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: Math.abs(Math.sin(p.place.rotY)) < 0.5 ? 0 : 90 });
}); });
// Buitenmaten woning R.rooms.concat(R.free).forEach(r => {
const x = px(r.label.x), y = py(r.label.z), named = !!r.name;
const lines = [[named ? r.name : 'Ruimte (geen functie)', named ? 'bold' : 'italic', 7], [`${fmt(r.areaM2, 2)} m²`, 'normal', 6.5]];
if (r.ceilingActive) lines.push([`plafond ${r.ceiling}`, 'normal', 5.5]);
doc.setTextColor(...(named ? BLACK : GREY));
lines.forEach(([t, style, size], i) => {
doc.setFont('helvetica', style);
doc.setFontSize(size);
doc.text(t, x, y + (i - (lines.length - 1) / 2) * 3 + 1, { align: 'center' });
});
});
doc.setFont('helvetica', 'normal');
if (R.rooms.length || R.free.length) {
doc.setFontSize(6);
const fit = (t, mm) => { let v = String(t); while (v.length > 3 && doc.getTextWidth(v) > mm) v = v.slice(0, -2); return v === String(t) ? v : v + '.'; };
const rows = R.rooms.map(r => [fit(r.name, 32), fit(r.typeLabel, 26), fmt(r.areaM2, 2), fit(RoomModel.ceilingLabel(r), 30), fit(RoomModel.floorLabel(r), 30), fit(RoomModel.wallLabel(r), 38)])
.concat(R.free.map(r => ['Ruimte', '(geen functie)', fmt(r.areaM2, 2), '', '', '']));
rows.push(['Totaal netto', '', fmt(R.rooms.concat(R.free).reduce((a, r) => a + r.areaM2, 0), 2), '', '', '']);
const many = rows.length > 8;
PdfSheet.table(doc, 105, 242, [0, 34, 62, 78, 110, 142], ['Ruimte', 'Functie', 'Netto m²', 'Plafond', 'Vloer', 'Wanden'], rows,
{ title: 'Ruimtestaat', titleSize: 8, lastColW: 40, maxY: 287, rowH: many ? 3.1 : 3.8, fontSize: many ? 5.5 : 6 });
}
doc.setDrawColor(...BLUE); doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE); doc.setTextColor(...BLUE);
doc.setFontSize(7); doc.setFontSize(7);
@@ -172,7 +203,6 @@ window.HousePdf = (() => {
PdfSheet.dimLine(doc, leftX, py(bz0), leftX, py(bz1)); PdfSheet.dimLine(doc, leftX, py(bz0), leftX, py(bz1));
doc.text('Y: ' + Math.round(bz1 - bz0), leftX - 2, (py(bz0) + py(bz1)) / 2, { align: 'center', angle: 90 }); doc.text('Y: ' + Math.round(bz1 - bz0), leftX - 2, (py(bz0) + py(bz1)) / 2, { align: 'center', angle: 90 });
// Noordpijl (boven = noord)
doc.setFillColor(...BLUE); doc.setFillColor(...BLUE);
doc.triangle(drawX1 - 8, drawY0, drawX1 - 11, drawY0 + 9, drawX1 - 5, drawY0 + 9, 'F'); doc.triangle(drawX1 - 8, drawY0, drawX1 - 11, drawY0 + 9, drawX1 - 5, drawY0 + 9, 'F');
doc.setTextColor(...BLUE); doc.setTextColor(...BLUE);
@@ -182,16 +212,10 @@ window.HousePdf = (() => {
doc.setTextColor(...GREY); doc.setTextColor(...GREY);
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, kleurvlak = ruimte (netto m²)', drawX0, drawY1 + 4);
if (parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5); if (parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5);
} }
// ------------------------------------------------------------
// Aanzichten: per windrichting een vlakke "render" van de gevels. Alle
// zichtbare vlakken (gevels incl. trasraam en openingen, boeideel/daktrim,
// hellende dakvlakken) als polygonen in het aanzicht, van achter naar voren
// getekend (schildersalgoritme), in egale kleuren uit het model.
// ------------------------------------------------------------
const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }; const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
const numRgb = (n) => [(n >> 16) & 255, (n >> 8) & 255, n & 255]; const numRgb = (n) => [(n >> 16) & 255, (n >> 8) & 255, n & 255];
const VIEW_COLORS = { const VIEW_COLORS = {
@@ -205,12 +229,11 @@ window.HousePdf = (() => {
{ key: 'west', title: 'Westgevel', d: { x: -1, z: 0 }, u: (p) => p.z } { key: 'west', title: 'Westgevel', d: { x: -1, z: 0 }, u: (p) => p.z }
]; ];
// Alle vlakken in wereldcoördinaten: { pts: [{x, y, z}], n: {x, z} | null (dakvlak: nH), color, bias }.
function viewFaces(parts, state) { function viewFaces(parts, state) {
const faces = []; const faces = [];
const wallFace = (p, y0, color, depthBias) => { const wallFace = (p, y0, color, depthBias) => {
const W = p.place.width, T = p.place.thickness; const W = p.place.width, T = p.place.thickness;
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone), e = p.skinExt || {}; const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), e = p.skinExt || {};
const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0); const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0);
const tl = WallModel.topLine(p.config), up = p.skinAbove || 0; const tl = WallModel.topLine(p.config), up = p.skinAbove || 0;
const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up; const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up;
@@ -222,10 +245,9 @@ window.HousePdf = (() => {
const toWorld = (p, lx, y, lz) => { const w = UnitModel.localToWorld(p.place, lx, lz); return { x: w.x, y: p.place.floorTop + y, z: w.z }; }; const toWorld = (p, lx, y, lz) => { const w = UnitModel.localToWorld(p.place, lx, lz); return { x: w.x, y: p.place.floorTop + y, z: w.z }; };
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, p.stuc);
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : WallModel.rabatHex(state.settings.facade.rabatColor); const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : 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)
const tr = p.trasraam; const tr = p.trasraam;
if (tr && tr.top > y0) { if (tr && tr.top > y0) {
const W = p.place.width; const W = p.place.width;
@@ -234,10 +256,8 @@ window.HousePdf = (() => {
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12); const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
faces.push({ pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 }); faces.push({ pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
} }
// Openingen (glas / deur), vóór de gevel
(p.config.openings || []).forEach(op => { (p.config.openings || []).forEach(op => {
if (WallModel.openingTopMax(op) <= y0) return; if (WallModel.openingTopMax(op) <= y0) return;
// Schuine bovenzijde: bovenkant links/rechts.
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y); const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y);
const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR); const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR);
const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur; const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
@@ -247,14 +267,12 @@ window.HousePdf = (() => {
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0)))); parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0))));
parts.interior.filter(p => p.skinFrom !== undefined && p.place.normal).forEach(p => addWall(p, p.skinFrom)); parts.interior.filter(p => p.skinFrom !== undefined && p.place.normal).forEach(p => addWall(p, p.skinFrom));
// Hellende dakvlakken (bovenkant, egaal in de kleur van de bedekking).
parts.units.forEach(u => { parts.units.forEach(u => {
const r = u.roof, sh = r.shape; const r = u.roof, sh = r.shape;
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;
const pushRoof = (pts) => { const pushRoof = (pts) => {
// 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;
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
@@ -262,7 +280,6 @@ window.HousePdf = (() => {
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 => { RoofModel.capRegions(r.unitRoof).forEach(rg => {
pushRoof(rg.poly.map(pt => { 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); const x = u.rect.cx + (sh.axis === 'x' ? pt.x : pt.z), z = u.rect.cz + (sh.axis === 'x' ? pt.z : pt.x);
@@ -280,11 +297,6 @@ window.HousePdf = (() => {
}); });
}); });
// 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;
@@ -305,22 +317,25 @@ window.HousePdf = (() => {
return faces; return faces;
} }
// Legenda "Afwerking": gevel(s) (incl. overrides), trasraam, dak per unit,
// vloer, boeideel/daktrim en binnenwanden. Per item een voorvertoning
// (thumb, via PdfSheet.preloadImage vooraf geladen) of een kleurvlak.
function materialLegend(state, parts) { function materialLegend(state, parts) {
const items = []; const items = [];
const st = state.settings; const st = state.settings;
const seen = new Set(); const seen = new Set();
const facades = [];
state.units.forEach(u => Object.keys(u.unit.walls).forEach(side => { state.units.forEach(u => Object.keys(u.unit.walls).forEach(side => {
const fc = HouseModel.facadeFor(state, u, side); facades.push(HouseModel.facadeFor(state, u, side));
const key = fc.type === 'steen' ? 'steen:' + fc.stone : 'rabat'; UnitModel.normalizeSplits(u.unit.walls[side].splits).filter(sp => sp.facade).forEach(sp => facades.push(HouseModel.facadeFor(state, u, side, sp.facade)));
}));
facades.forEach(fc => {
const key = fc.type === 'steen' ? 'steen:' + fc.stone : fc.type === 'stuc' ? 'stuc:' + fc.stuc : 'rabat';
if (seen.has(key)) return; if (seen.has(key)) return;
seen.add(key); seen.add(key);
items.push(fc.type === 'steen' items.push(fc.type === 'steen'
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] } ? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
: fc.type === 'stuc'
? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) }
: { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) }); : { 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;
items.push(WallModel.isStoneFinish(tf) items.push(WallModel.isStoneFinish(tf)
@@ -345,7 +360,6 @@ window.HousePdf = (() => {
: { label: 'Binnenwanden', value: 'Spaanplaat', color: '#1e4620' }); : { label: 'Binnenwanden', value: 'Spaanplaat', color: '#1e4620' });
return items; return items;
} }
// Voorvertoningen die de legenda gebruikt (om vooraf te laden).
function materialThumbUrls(state) { function materialThumbUrls(state) {
return materialLegend(state, null).map(i => i.thumb).filter(Boolean); return materialLegend(state, null).map(i => i.thumb).filter(Boolean);
} }
@@ -379,7 +393,6 @@ window.HousePdf = (() => {
}); });
} }
// Afmetingen (zelfde rijen als het infopaneel in de app), in twee kolommen.
function drawDimensions(doc, rows, x0, y0, x1) { function drawDimensions(doc, rows, x0, y0, x1) {
const { BLACK, GREY } = PdfSheet.COLORS; const { BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
@@ -403,7 +416,6 @@ window.HousePdf = (() => {
const { BLACK } = PdfSheet.COLORS; const { BLACK } = PdfSheet.COLORS;
const faces = viewFaces(parts, state); const faces = viewFaces(parts, state);
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0); const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
// Per aanzicht de zichtbare vlakken in (u, v) mm, met diepte.
const views = VIEWS.map(v => { const views = VIEWS.map(v => {
const dot = (p) => p.x * v.d.x + p.z * v.d.z; const dot = (p) => p.x * v.d.x + p.z * v.d.z;
const list = faces.filter(f => f.roof ? (f.n.x * v.d.x + f.n.z * v.d.z) > 0.01 : (f.n.x * v.d.x + f.n.z * v.d.z) > 0.5) const list = faces.filter(f => f.roof ? (f.n.x * v.d.x + f.n.z * v.d.z) > 0.01 : (f.n.x * v.d.x + f.n.z * v.d.z) > 0.5)
@@ -413,7 +425,6 @@ window.HousePdf = (() => {
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) }; return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
}).filter(x => x.list.length); }).filter(x => x.list.length);
// Raster 2 x 2 boven het titelblok; één schaal voor alle aanzichten.
const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7; const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7;
const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2; const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2;
const scale = Math.min(...views.map(w => Math.min((cellW - 16) / (w.u1 - w.u0), (cellH - titleH - 10) / (w.v1 - w.v0)))); const scale = Math.min(...views.map(w => Math.min((cellW - 16) / (w.u1 - w.u0), (cellH - titleH - 10) / (w.v1 - w.v0))));
@@ -421,7 +432,7 @@ window.HousePdf = (() => {
views.forEach((w, i) => { views.forEach((w, i) => {
const cx = X0 + (i % 2) * (cellW + gap), cy = Y0 + Math.floor(i / 2) * (cellH + gap); const cx = X0 + (i % 2) * (cellW + gap), cy = Y0 + Math.floor(i / 2) * (cellH + gap);
const ox = cx + cellW / 2 - (w.u0 + w.u1) / 2 * scale; const ox = cx + cellW / 2 - (w.u0 + w.u1) / 2 * scale;
const base = cy + cellH - 6; // grondlijn const base = cy + cellH - 6;
const P = (p) => [ox + p.u * scale, base - (p.v - groundY) * scale]; const P = (p) => [ox + p.u * scale, base - (p.v - groundY) * scale];
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
doc.setFontSize(11); doc.setFontSize(11);
@@ -434,7 +445,6 @@ window.HousePdf = (() => {
doc.setFillColor(...col); doc.setFillColor(...col);
doc.lines(pts.slice(1).map((q, k) => [q[0] - pts[k][0], q[1] - pts[k][1]]), pts[0][0], pts[0][1], [1, 1], 'FD', true); doc.lines(pts.slice(1).map((q, k) => [q[0] - pts[k][0], q[1] - pts[k][1]]), pts[0][0], pts[0][1], [1, 1], 'FD', true);
}); });
// Grondlijn
doc.setDrawColor(...VIEW_COLORS.grond); doc.setDrawColor(...VIEW_COLORS.grond);
doc.setLineWidth(0.4); doc.setLineWidth(0.4);
doc.line(ox + w.u0 * scale - 6, base, ox + w.u1 * scale + 6, base); doc.line(ox + w.u0 * scale - 6, base, ox + w.u1 * scale + 6, base);
@@ -447,14 +457,10 @@ 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' }; const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', nokbalk: 'Nokbalk', kopbalk: 'Kopbalk' };
function drawCapSheet(doc, u, sheetOpts) { function drawCapSheet(doc, u, sheetOpts) {
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
const ur = u.roof.unitRoof, c = ur.cap; 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 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 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 xs = band.map(p => p.x), zs = band.map(p => p.z);
@@ -469,15 +475,12 @@ window.HousePdf = (() => {
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
doc.setFontSize(11); doc.setFontSize(11);
doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20); doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20);
// Dakvlakken (grijs) en de band.
doc.setDrawColor(...GREY); doc.setDrawColor(...GREY);
doc.setLineWidth(0.15); doc.setLineWidth(0.15);
c.regions.forEach(rg => poly(rg.poly.map(xz), 'S')); c.regions.forEach(rg => poly(rg.poly.map(xz), 'S'));
// Balken (rood, plattegrond: rechthoek van balkbreedte rond de hartlijn).
doc.setDrawColor(...RED); doc.setDrawColor(...RED);
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
c.beams.forEach(bm => poly(bm.poly.map(xz), 'S')); 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.setDrawColor(...BLUE);
doc.setTextColor(...BLUE); doc.setTextColor(...BLUE);
doc.setFontSize(7); doc.setFontSize(7);
@@ -489,7 +492,6 @@ window.HousePdf = (() => {
PdfSheet.dimLine(doc, cx0 + 8, cy0, cx1 + 8, cy1); PdfSheet.dimLine(doc, cx0 + 8, cy0, cx1 + 8, cy1);
doc.text(`${Math.round(bz1 - bz0)}`, cx0 + 9.5, (cy0 + cy1) / 2, { angle: 90 }); doc.text(`${Math.round(bz1 - bz0)}`, cx0 + 9.5, (cy0 + cy1) / 2, { angle: 90 });
// Stuklijst en kerngegevens.
const groups = new Map(); const groups = new Map();
c.beams.forEach(bm => { c.beams.forEach(bm => {
const key = `${bm.kind}|${Math.round(bm.length / 10) * 10}|${bm.angle}`; const key = `${bm.kind}|${Math.round(bm.length / 10) * 10}|${bm.angle}`;
@@ -506,7 +508,7 @@ window.HousePdf = (() => {
const facts = [ const facts = [
['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`], ['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`],
['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`], ['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`],
['Helling dakvlakken', `${deg((H(c.pR) - H(c.pA)) / (c.pR - c.pA))}°`], ['Helling dakvlakken', `${deg(c.slope)}°`],
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'], ['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'],
['Balken', `${c.joistW} x ${c.joistH} mm`], ['Balken', `${c.joistW} x ${c.joistH} mm`],
['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`], ['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`],
@@ -558,6 +560,13 @@ window.HousePdf = (() => {
const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']); const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']);
wRows.push(['Totaal woning', fmt(totals.totalKg) + ' kg']); wRows.push(['Totaal woning', fmt(totals.totalKg) + ' kg']);
y = PdfSheet.table(doc, 120, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht')); y = PdfSheet.table(doc, 120, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht'));
if (totals.nokbalken.length) {
y += 4;
y = PdfSheet.table(doc, 120, y, [0, 22, 60], ['Unit', 'Profiel', 'Lengte, gewicht, vulhout'], totals.nokbalken.map(n => [
n.unit, `${n.name} S235${n.ok ? '' : ' (te licht!)'}`,
`${fmt(n.lengthM, 2)} m, ${fmt(n.kg)} kg, ${fmt(n.fillM3, 3)} m3${n.below ? `, ${n.below} mm onder plafond` : ''}`]),
tableOpts('Stalen nokbalk (IPE, vulhout in de holling)'));
}
if (totals.roofEdge) { if (totals.roofEdge) {
y += 4; y += 4;
const e = totals.roofEdge, L = totals.lamps; const e = totals.roofEdge, L = totals.lamps;
@@ -576,7 +585,6 @@ window.HousePdf = (() => {
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
} }
// Eigen blad na de totalen: zaaglijst vuren (in kolommen) + zaagmachine.
function drawSawSheet(doc, totals, cost, sheetOpts) { function drawSawSheet(doc, totals, cost, sheetOpts) {
const { BLACK } = PdfSheet.COLORS; const { BLACK } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
@@ -608,19 +616,13 @@ 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; 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_COLS = [{ x: 15, yMax: 278 }, { x: 150, yMax: 278 }, { x: 285, yMax: 246 }];
const COST_Y0 = 30; const COST_Y0 = 30;
const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '\u2026' : t); 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) { function costDetailRows(cost) {
return cost.elements.flatMap(el => elementCostRows(el)); return cost.elements.flatMap(el => elementCostRows(el));
} }
// Kop + regels van één element.
function elementCostRows(el) { function elementCostRows(el) {
const rows = []; const rows = [];
if (el.lines.length) { if (el.lines.length) {
@@ -632,14 +634,12 @@ window.HousePdf = (() => {
} }
return rows; 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) { function drawElementCost(doc, el, extraLabel) {
if (!el || !el.lines.length) return; if (!el || !el.lines.length) return;
const x = 132, y = 236, yMax = 287; const x = 132, y = 236, yMax = 287;
const rows = elementCostRows(el); const rows = elementCostRows(el);
rows[0] = Object.assign({}, rows[0], { label: `Kosten ${el.element}${extraLabel ? ' (' + extraLabel + ')' : ''}` }); 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: '' }); rows.push({ head: true, label: 'Kostprijs', mat: CostModel.eur(el.kostprijs), time: '' });
const fit = Math.floor((yMax - y - 5) / COST_ROW_H); const fit = Math.floor((yMax - y - 5) / COST_ROW_H);
let shown = rows; let shown = rows;
if (rows.length > fit) { if (rows.length > fit) {
@@ -647,7 +647,6 @@ window.HousePdf = (() => {
} }
drawCostTable(doc, x, y, shown); drawCostTable(doc, x, y, shown);
} }
// Verdeelt de regels over bladen (kolommen vullen van boven naar beneden).
function paginateCostRows(rows) { function paginateCostRows(rows) {
const pages = []; const pages = [];
let i = 0; let i = 0;
@@ -751,7 +750,6 @@ window.HousePdf = (() => {
} else if (sheet.kind === 'roof') { } else if (sheet.kind === 'roof') {
const pu = parts.units.find(u => u.roof.planes.includes(sheet.plane)); const pu = parts.units.find(u => u.roof.planes.includes(sheet.plane));
FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true, overlay: pu && ElektraPdf.roofOverlay(parts.elektra, pu, sheet.plane) }, opts)); FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true, overlay: pu && ElektraPdf.roofOverlay(parts.elektra, pu, sheet.plane) }, 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'); 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 === 'cap') drawCapSheet(doc, sheet.unit, opts);
+881 -312
View File
File diff suppressed because it is too large Load Diff
+523
View File
@@ -0,0 +1,523 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Lokaal (mm): x langs de breedte (midden 0), z van de rug (-d/2) naar de voorkant, y vanaf het loopvlak.
// rot in graden met de klok mee, 0 = rug naar het noorden. anchor 'oost': x vanaf de oostrand.
window.FixtureModel = (() => {
const toM = 0.001;
const CATS = { sanitair: 'Sanitair', keuken: 'Keuken', kasten: 'Kasten', wonen: 'Wonen', slapen: 'Slapen', techniek: 'Techniek' };
const mats = {};
const M = (key, make) => mats[key] || (mats[key] = make());
const std = (color, rough, metal, extra) => new THREE.MeshStandardMaterial(Object.assign({ color, roughness: rough, metalness: metal || 0 }, extra || {}));
const MAT = {
keramiek: () => M('keramiek', () => std(0xf7f7f4, 0.18, 0.05)),
chroom: () => M('chroom', () => std(0xd8dadc, 0.15, 0.95)),
zwartMetaal: () => M('zwartMetaal', () => std(0x1d1e20, 0.4, 0.6)),
glas: () => M('glas', () => std(0xb9d4dc, 0.05, 0.1, { transparent: true, opacity: 0.25, depthWrite: false, side: THREE.DoubleSide })),
spiegel: () => M('spiegel', () => std(0xdfe6ea, 0.03, 1)),
voorzet: () => M('voorzet', () => std(0xeceae5, 0.6)),
tegel: () => M('tegel', () => std(0x8d8f91, 0.5)),
rubber: () => M('rubber', () => std(0x1b1b1b, 0.8)),
werkblad: () => M('werkblad', () => std(0x2f3133, 0.35)),
kookplaat: () => M('kookplaat', () => std(0x0e0e10, 0.08, 0.2)),
ring: () => M('ring', () => std(0x5a5c60, 0.3)),
plint: () => M('plint', () => std(0x202122, 0.7)),
stof: () => M('stof', () => std(0x7f8487, 0.95)),
stofDonker: () => M('stofDonker', () => std(0x5c6064, 0.95)),
stofGroen: () => M('stofGroen', () => std(0x6e7d68, 0.95)),
beddengoed: () => M('beddengoed', () => std(0xf2f0ea, 0.9)),
kussen: () => M('kussen', () => std(0xe4e1d8, 0.9)),
hout: () => M('hout', () => std(0x9b7a55, 0.6)),
houtDonker: () => M('houtDonker', () => std(0x4a3a2c, 0.6)),
wit: () => M('wit', () => std(0xf0efea, 0.5)),
scherm: () => M('scherm', () => std(0x0b0c0e, 0.1, 0.3))
};
const FRONTS = {
wit: { label: 'Wit mat', hex: 0xefeeea },
grijs: { label: 'Lichtgrijs', hex: 0xc4c5c2 },
antraciet: { label: 'Antraciet', hex: 0x3b3e42 },
zwart: { label: 'Zwart', hex: 0x1d1e20 },
eiken: { label: 'Eiken', hex: 0xa8865e }
};
const frontMat = (k) => M('front:' + k, () => std((FRONTS[k] || FRONTS.wit).hex, 0.55));
function box(g, mat, x, y, z, w, h, d) {
if (w <= 0 || h <= 0 || d <= 0) return null;
const m = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
m.position.set(x * toM, y * toM, z * toM);
m.castShadow = true; m.receiveShadow = true;
g.add(m);
return m;
}
const boxAt = (g, mat, x0, x1, y0, y1, z0, z1) => box(g, mat, (x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2, x1 - x0, y1 - y0, z1 - z0);
function ecyl(g, mat, x, z, rx, rz, y0, y1, taper) {
const geo = new THREE.CylinderGeometry(1, taper || 1, 1, 40);
const m = new THREE.Mesh(geo, mat);
m.scale.set(rx * toM, (y1 - y0) * toM, rz * toM);
m.position.set(x * toM, (y0 + y1) / 2 * toM, z * toM);
m.castShadow = true; m.receiveShadow = true;
g.add(m);
return m;
}
const rectP = (x0, x1, z0, z1, kind, fill) => ({ pts: [{ x: x0, z: z0 }, { x: x1, z: z0 }, { x: x1, z: z1 }, { x: x0, z: z1 }], closed: true, kind, fill });
const lineP = (x0, z0, x1, z1, kind) => ({ pts: [{ x: x0, z: z0 }, { x: x1, z: z1 }], kind });
function ellP(cx, cz, rx, rz, kind, a0, a1) {
const pts = [], n = 32, s = a0 === undefined ? 0 : a0, e = a1 === undefined ? Math.PI * 2 : a1;
for (let i = 0; i <= n; i++) { const t = s + (e - s) * i / n; pts.push({ x: cx + rx * Math.cos(t), z: cz + rz * Math.sin(t) }); }
return { pts, closed: a0 === undefined, kind };
}
const circP = (x, z, r, kind) => ({ circle: { x, z, r }, kind });
function rrectP(x0, x1, z0, z1, r, kind) {
const pts = [], q = (cx, cz, a) => { for (let i = 0; i <= 6; i++) { const t = a + i / 6 * Math.PI / 2; pts.push({ x: cx + r * Math.cos(t), z: cz + r * Math.sin(t) }); } };
q(x1 - r, z1 - r, 0); q(x0 + r, z1 - r, Math.PI / 2); q(x0 + r, z0 + r, Math.PI); q(x1 - r, z0 + r, Math.PI * 1.5);
return { pts, closed: true, kind };
}
const TYPES = {
toilet: {
label: 'Toilet (zwevend)', cat: 'sanitair', w: 600, d: 690, h: 1200, resize: ['w', 'h'], minW: 450, wall: true, cost: 'toilet',
hint: 'Wandcloset aan een inbouwreservoir in een voorzetwand (150 mm), zitting 420 mm',
build(f, g) {
const D = f.d, zb = -D / 2, zf = zb + 150;
boxAt(g, MAT.voorzet(), -f.w / 2, f.w / 2, 0, f.h, zb, zf); // voorzetwand
boxAt(g, MAT.chroom(), -120, 120, 920, 1080, zf, zf + 8); // bedieningsplaat
boxAt(g, MAT.wit(), -110, 110, 930, 1070, zf + 8, zf + 10);
const cz = zf + 270;
ecyl(g, MAT.keramiek(), 0, cz, 180, 270, 250, 400, 0.72); // pot
boxAt(g, MAT.keramiek(), -150, 150, 300, 400, zf, zf + 120); // aansluiting op de wand
ecyl(g, MAT.keramiek(), 0, cz + 5, 185, 272, 400, 420); // zitting
ecyl(g, MAT.keramiek(), 0, cz + 5, 180, 265, 422, 436); // deksel
},
plan(f) {
const zb = -f.d / 2, zf = zb + 150, cz = zf + 270;
return [rectP(-f.w / 2, f.w / 2, zb, zf, 'solid', true), ellP(0, cz, 180, 270), ellP(0, cz + 30, 115, 190, 'thin'), rectP(-150, 150, zf, zf + 70, 'thin')];
}
},
fontein: {
label: 'Fontein', cat: 'sanitair', w: 400, d: 250, h: 850, resize: ['w', 'd', 'h'], wall: true, cost: 'fontein',
hint: 'Handwasbak met kraan; h = bovenkant',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2, top = f.h;
boxAt(g, MAT.keramiek(), -f.w / 2, f.w / 2, top - 130, top, zb, zf);
boxAt(g, MAT.rubber(), -f.w / 2 + 40, f.w / 2 - 40, top - 4, top + 0.5, zb + 70, zf - 30);
tap(g, f.w / 2 - 70, top, zb + 35, 120);
boxAt(g, MAT.chroom(), -25, 25, 300, top - 130, zb, zb + 50); // sifon
},
plan(f) { const zb = -f.d / 2; return [rectP(-f.w / 2, f.w / 2, zb, f.d / 2), rrectP(-f.w / 2 + 40, f.w / 2 - 40, zb + 70, f.d / 2 - 30, 40, 'thin'), circP(f.w / 2 - 70, zb + 35, 18, 'thin')]; }
},
wastafel: {
label: 'Badmeubel met spiegel', cat: 'sanitair', w: 800, d: 460, h: 850, resize: ['w', 'd', 'h'], wall: true, cost: 'wastafel',
opts: { front: 'eiken', spiegel: true }, hint: 'Zwevend onderkastje met wastafel; h = bovenkant blad',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2, top = f.h;
boxAt(g, frontMat(f.opts.front), -f.w / 2, f.w / 2, top - 520, top - 50, zb, zf - 5);
boxAt(g, MAT.rubber(), -f.w / 2 + 5, f.w / 2 - 5, top - 288, top - 284, zf - 5, zf - 3); // greep-naad
boxAt(g, MAT.keramiek(), -f.w / 2, f.w / 2, top - 50, top, zb, zf);
boxAt(g, MAT.rubber(), -Math.min(250, f.w / 2 - 60), Math.min(250, f.w / 2 - 60), top - 3, top + 0.5, zb + 110, zf - 40);
tap(g, 0, top, zb + 60, 160);
if (f.opts.spiegel) {
boxAt(g, MAT.zwartMetaal(), -f.w / 2, f.w / 2, top + 250, top + 1000, zb, zb + 22);
boxAt(g, MAT.spiegel(), -f.w / 2 + 12, f.w / 2 - 12, top + 262, top + 988, zb + 22, zb + 24);
}
},
plan(f) {
const zb = -f.d / 2, bw = Math.min(250, f.w / 2 - 60);
return [rectP(-f.w / 2, f.w / 2, zb, f.d / 2), ellP(0, (zb + 110 + f.d / 2 - 40) / 2, bw, (f.d - 150) / 2, 'thin'), circP(0, zb + 60, 18, 'thin')];
}
},
douche: {
label: 'Inloopdouche', cat: 'sanitair', w: 900, d: 1200, h: 2100, resize: ['w', 'd', 'h'], wall: true, cost: 'douche',
opts: { glas: 'rechts', glasLengte: 1000 }, hint: 'Betegelde vloer met douchegoot, glaswand opzij, regendouche en thermostaatkraan aan de rugwand',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2;
boxAt(g, MAT.tegel(), -f.w / 2, f.w / 2, 0, 6, zb, zf); // vloer
boxAt(g, MAT.zwartMetaal(), -f.w / 2 + 60, f.w / 2 - 60, 6, 8, zb + 60, zb + 110); // douchegoot
// Thermostaatkraan + glijstang
boxAt(g, MAT.zwartMetaal(), -170, 170, 1050, 1110, zb, zb + 70);
boxAt(g, MAT.zwartMetaal(), 140, 165, 1110, 2050, zb + 30, zb + 55); // stang naar de regendouche
boxAt(g, MAT.zwartMetaal(), 140, 165, 2030, 2055, zb + 30, zb + 330); // arm
ecyl(g, MAT.zwartMetaal(), 152, zb + 330, 150, 150, 2005, 2025); // regendouche Ø300
// Glaswand opzij (zwart profiel aan de wand)
if (f.opts.glas !== 'geen') {
const x = f.opts.glas === 'links' ? -f.w / 2 : f.w / 2, L = Math.min(f.d, f.opts.glasLengte || 1000);
boxAt(g, MAT.glas(), x - 4, x + 4, 30, f.h, zb, zb + L);
boxAt(g, MAT.zwartMetaal(), x - 12, x + 12, 30, f.h, zb, zb + 25);
boxAt(g, MAT.zwartMetaal(), x - 10, x + 10, f.h - 20, f.h, zb, zb + L);
}
},
plan(f) {
const zb = -f.d / 2, out = [rectP(-f.w / 2, f.w / 2, zb, f.d / 2, 'dash'), rectP(-f.w / 2 + 60, f.w / 2 - 60, zb + 60, zb + 110, 'thin'), circP(152, zb + 330, 150, 'thin')];
if (f.opts.glas !== 'geen') {
const x = f.opts.glas === 'links' ? -f.w / 2 : f.w / 2, L = Math.min(f.d, f.opts.glasLengte || 1000);
out.push(rectP(x - 15, x + 15, zb, zb + L, 'solid', true));
}
return out;
}
},
keuken: {
label: 'Keukenblok', cat: 'keuken', w: 2660, d: 600, h: 910, resize: ['w', 'd', 'h'], minW: 600, wall: true, cost: 'keuken',
opts: { front: 'antraciet', spoelbak: 0.6, hogeKast: 'links', vaatwasser: true, bovenkasten: false },
hint: 'Onderkasten met werkblad en spoelbak (plek: 0 = links, 1 = rechts, van voren gezien); hoge kast 600 mm met inbouwkoelkast en oven',
build(f, g) { kitchenRun(g, f, false); },
plan(f) { return kitchenPlan(f, false); }
},
kookeiland: {
label: 'Kookeiland', cat: 'keuken', w: 2660, d: 600, h: 910, resize: ['w', 'd', 'h'], minW: 600, wall: false, cost: 'kookeiland',
opts: { front: 'antraciet', kookplaat: true },
hint: 'Vrijstaand, met inductiekookplaat (4 zones) in het midden',
build(f, g) { kitchenRun(g, f, true); },
plan(f) { return kitchenPlan(f, true); }
},
kledingkast: {
label: 'Kledingkast', cat: 'kasten', w: 3020, d: 600, h: 2400, resize: ['w', 'd', 'h'], minW: 400, wall: true, cost: 'kast',
opts: { front: 'wit' }, hint: 'Hang/leg, deuren van ca. 750 mm (twee per vak)',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2, n = Math.max(1, Math.round(f.w / 750));
boxAt(g, MAT.wit(), -f.w / 2, f.w / 2, 0, f.h, zb, zf - 20);
const dw = f.w / n;
for (let i = 0; i < n; i++) {
const x0 = -f.w / 2 + i * dw;
[[x0 + 2, x0 + dw / 2 - 2], [x0 + dw / 2 + 2, x0 + dw - 2]].forEach(([a, b], k) => {
boxAt(g, frontMat(f.opts.front), a, b, 80, f.h - 4, zf - 20, zf);
const hx = k === 0 ? b - 40 : a + 40;
boxAt(g, MAT.zwartMetaal(), hx - 6, hx + 6, 900, 1300, zf, zf + 18);
});
}
boxAt(g, MAT.plint(), -f.w / 2, f.w / 2, 0, 80, zf - 60, zf - 20);
},
plan(f) {
const zb = -f.d / 2, n = Math.max(1, Math.round(f.w / 750)), out = [rectP(-f.w / 2, f.w / 2, zb, f.d / 2)];
for (let i = 1; i < n; i++) out.push(lineP(-f.w / 2 + i * f.w / n, zb, -f.w / 2 + i * f.w / n, f.d / 2, 'thin'));
out.push(lineP(-f.w / 2 + 30, 0, f.w / 2 - 30, 0, 'dash')); // roede
return out;
}
},
bed: {
label: 'Bed', cat: 'slapen', w: 1600, d: 2100, h: 520, resize: ['w', 'd'], minW: 800, wall: true, cost: null,
opts: { nachtkastjes: true }, hint: 'Hoofdbord tegen de wand; 2 kussens vanaf 1200 mm breed',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2;
boxAt(g, MAT.stofDonker(), -f.w / 2, f.w / 2, 0, 1050, zb, zb + 80); // hoofdbord
boxAt(g, MAT.stofDonker(), -f.w / 2, f.w / 2, 60, 330, zb + 80, zf); // boxspring
boxAt(g, MAT.plint(), -f.w / 2 + 40, f.w / 2 - 40, 0, 60, zb + 120, zf - 40);
boxAt(g, MAT.beddengoed(), -f.w / 2 + 10, f.w / 2 - 10, 330, f.h, zb + 85, zf - 5); // matras
boxAt(g, MAT.kussen(), -f.w / 2 + 5, f.w / 2 - 5, f.h - 10, f.h + 40, zb + 650, zf); // dekbed
const nk = f.w >= 1200 ? 2 : 1, kw = (f.w - 60 * (nk + 1)) / nk;
for (let i = 0; i < nk; i++) boxAt(g, MAT.wit(), -f.w / 2 + 60 + i * (kw + 60), -f.w / 2 + 60 + i * (kw + 60) + kw, f.h - 5, f.h + 110, zb + 110, zb + 500);
if (f.opts.nachtkastjes) [-1, 1].forEach(s => {
const x0 = s < 0 ? -f.w / 2 - 470 : f.w / 2 + 20;
boxAt(g, MAT.houtDonker(), x0, x0 + 450, 150, 520, zb + 10, zb + 410);
});
},
plan(f) {
const zb = -f.d / 2, out = [rectP(-f.w / 2, f.w / 2, zb, f.d / 2), lineP(-f.w / 2, zb + 80, f.w / 2, zb + 80, 'thin'), lineP(-f.w / 2, zb + 650, f.w / 2, zb + 650, 'thin')];
const nk = f.w >= 1200 ? 2 : 1, kw = (f.w - 60 * (nk + 1)) / nk;
for (let i = 0; i < nk; i++) out.push(rrectP(-f.w / 2 + 60 + i * (kw + 60), -f.w / 2 + 60 + i * (kw + 60) + kw, zb + 110, zb + 500, 60, 'thin'));
out.push(lineP(-f.w / 2, zb + 650, f.w / 2 - 300, f.d / 2, 'thin'));
if (f.opts.nachtkastjes) [-1, 1].forEach(s => { const x0 = s < 0 ? -f.w / 2 - 470 : f.w / 2 + 20; out.push(rectP(x0, x0 + 450, zb + 10, zb + 410, 'thin')); });
return out;
}
},
eettafel: {
label: 'Eettafel met stoelen', cat: 'wonen', w: 2000, d: 1100, h: 750, resize: ['w', 'd'], minW: 600, wall: false, cost: null,
opts: { vorm: 'ovaal', stoelen: 6 }, hint: 'Ovaal of rechthoekig; stoelen verdeeld over de lange zijden',
build(f, g) {
const ov = f.opts.vorm === 'ovaal';
if (ov) { ecyl(g, MAT.houtDonker(), 0, 0, f.w / 2, f.d / 2, f.h - 30, f.h); ecyl(g, MAT.zwartMetaal(), 0, 0, 60, 60, 20, f.h - 30); ecyl(g, MAT.zwartMetaal(), 0, 0, Math.min(f.w, f.d) * 0.3, Math.min(f.w, f.d) * 0.22, 0, 20); }
else {
boxAt(g, MAT.houtDonker(), -f.w / 2, f.w / 2, f.h - 30, f.h, -f.d / 2, f.d / 2);
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => boxAt(g, MAT.zwartMetaal(), a * (f.w / 2 - 80) - 25, a * (f.w / 2 - 80) + 25, 0, f.h - 30, b * (f.d / 2 - 80) - 25, b * (f.d / 2 - 80) + 25));
}
chairSpots(f).forEach(c => chair(g, c.x, c.z, c.rot));
},
plan(f) {
const out = [f.opts.vorm === 'ovaal' ? ellP(0, 0, f.w / 2, f.d / 2) : rectP(-f.w / 2, f.w / 2, -f.d / 2, f.d / 2)];
chairSpots(f).forEach(c => {
const pts = [[-225, -220], [225, -220], [225, 220], [-225, 220]].map(([x, z]) => rot2(x, z, c.rot)).map(p => ({ x: p.x + c.x, z: p.z + c.z }));
out.push({ pts, closed: true, kind: 'thin' });
const b = [[-225, 190], [225, 190]].map(([x, z]) => rot2(x, z, c.rot)).map(p => ({ x: p.x + c.x, z: p.z + c.z }));
out.push({ pts: b, kind: 'thin' });
});
return out;
}
},
bank: {
label: 'Bank', cat: 'wonen', w: 2600, d: 950, h: 820, resize: ['w', 'd'], minW: 1200, wall: false, cost: null,
opts: { chaise: 'rechts', chaiseLengte: 1700 }, hint: 'Rug aan de achterzijde; chaise longue links/rechts (van voren gezien)',
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2, mat = MAT.stof();
boxAt(g, mat, -f.w / 2, f.w / 2, 80, 430, zb, zf); // zitting
boxAt(g, mat, -f.w / 2, f.w / 2, 80, f.h, zb, zb + 220); // rug
const ch = f.opts.chaise, L = Math.max(f.d, f.opts.chaiseLengte || 1700);
if (ch === 'links' || ch === 'rechts') {
const x0 = ch === 'links' ? -f.w / 2 : f.w / 2 - 900, x1 = x0 + 900;
boxAt(g, mat, x0, x1, 80, 430, zf, zb + L);
boxAt(g, MAT.stofDonker(), x0 + 10, x1 - 10, 430, 480, zf, zb + L - 10);
}
[-1, 1].forEach(s => { if (!((s < 0 && ch === 'links') || (s > 0 && ch === 'rechts'))) boxAt(g, mat, s * f.w / 2 - (s > 0 ? 180 : 0), s * f.w / 2 + (s < 0 ? 180 : 0), 80, 620, zb, zf); });
const nz = Math.max(1, Math.round((f.w - 360) / 800)), cw = (f.w - 360) / nz;
for (let i = 0; i < nz; i++) boxAt(g, MAT.stofDonker(), -f.w / 2 + 180 + i * cw + 10, -f.w / 2 + 180 + (i + 1) * cw - 10, 430, 480, zb + 230, zf - 10);
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => boxAt(g, MAT.zwartMetaal(), a * (f.w / 2 - 60) - 15, a * (f.w / 2 - 60) + 15, 0, 80, b * (f.d / 2 - 60) - 15, b * (f.d / 2 - 60) + 15));
},
plan(f) {
const zb = -f.d / 2, zf = f.d / 2, ch = f.opts.chaise, L = Math.max(f.d, f.opts.chaiseLengte || 1700);
let pts = [{ x: -f.w / 2, z: zb }, { x: f.w / 2, z: zb }, { x: f.w / 2, z: zf }, { x: -f.w / 2, z: zf }];
if (ch === 'rechts') pts = [{ x: -f.w / 2, z: zb }, { x: f.w / 2, z: zb }, { x: f.w / 2, z: zb + L }, { x: f.w / 2 - 900, z: zb + L }, { x: f.w / 2 - 900, z: zf }, { x: -f.w / 2, z: zf }];
if (ch === 'links') pts = [{ x: -f.w / 2, z: zb }, { x: f.w / 2, z: zb }, { x: f.w / 2, z: zf }, { x: -f.w / 2 + 900, z: zf }, { x: -f.w / 2 + 900, z: zb + L }, { x: -f.w / 2, z: zb + L }];
const out = [{ pts, closed: true }, lineP(-f.w / 2, zb + 220, f.w / 2, zb + 220, 'thin')];
const nz = Math.max(1, Math.round((f.w - 360) / 800)), cw = (f.w - 360) / nz;
for (let i = 1; i < nz; i++) out.push(lineP(-f.w / 2 + 180 + i * cw, zb + 220, -f.w / 2 + 180 + i * cw, zf, 'thin'));
return out;
}
},
fauteuil: {
label: 'Fauteuil', cat: 'wonen', w: 850, d: 850, h: 800, resize: ['w', 'd'], wall: false, cost: null, opts: {},
build(f, g) {
const zb = -f.d / 2, zf = f.d / 2;
boxAt(g, MAT.stofGroen(), -f.w / 2 + 110, f.w / 2 - 110, 150, 430, zb + 150, zf);
boxAt(g, MAT.stofGroen(), -f.w / 2, f.w / 2, 150, f.h, zb, zb + 160);
[-1, 1].forEach(s => boxAt(g, MAT.stofGroen(), s > 0 ? f.w / 2 - 120 : -f.w / 2, s > 0 ? f.w / 2 : -f.w / 2 + 120, 150, 600, zb, zf));
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => ecyl(g, MAT.houtDonker(), a * (f.w / 2 - 70), b * (f.d / 2 - 70), 18, 18, 0, 150, 0.7));
},
plan(f) { const zb = -f.d / 2; return [rrectP(-f.w / 2, f.w / 2, zb, f.d / 2, 80), lineP(-f.w / 2, zb + 160, f.w / 2, zb + 160, 'thin'), lineP(-f.w / 2 + 120, zb + 160, -f.w / 2 + 120, f.d / 2, 'thin'), lineP(f.w / 2 - 120, zb + 160, f.w / 2 - 120, f.d / 2, 'thin')]; }
},
salontafel: {
label: 'Salontafel', cat: 'wonen', w: 800, d: 800, h: 400, resize: ['w', 'd', 'h'], wall: false, cost: null, opts: { vorm: 'rond' },
build(f, g) {
if (f.opts.vorm === 'rond') { ecyl(g, MAT.hout(), 0, 0, f.w / 2, f.d / 2, f.h - 30, f.h); ecyl(g, MAT.zwartMetaal(), 0, 0, f.w / 6, f.d / 6, 0, f.h - 30, 1.3); }
else { boxAt(g, MAT.hout(), -f.w / 2, f.w / 2, f.h - 30, f.h, -f.d / 2, f.d / 2); boxAt(g, MAT.zwartMetaal(), -f.w / 2 + 40, f.w / 2 - 40, 0, f.h - 30, -f.d / 2 + 40, f.d / 2 - 40); }
},
plan(f) { return [f.opts.vorm === 'rond' ? ellP(0, 0, f.w / 2, f.d / 2) : rectP(-f.w / 2, f.w / 2, -f.d / 2, f.d / 2)]; }
},
tvmeubel: {
label: 'Tv-meubel', cat: 'wonen', w: 1800, d: 400, h: 450, resize: ['w', 'd', 'h'], wall: true, cost: null, opts: { tv: true },
build(f, g) {
const zb = -f.d / 2;
boxAt(g, MAT.houtDonker(), -f.w / 2, f.w / 2, 150, f.h, zb, f.d / 2);
if (f.opts.tv) { const tw = Math.min(1450, f.w - 200); boxAt(g, MAT.scherm(), -tw / 2, tw / 2, 1000, 1000 + tw * 0.5625, zb, zb + 40); }
},
plan(f) { return [rectP(-f.w / 2, f.w / 2, -f.d / 2, f.d / 2), lineP(-f.w / 2, 0, f.w / 2, 0, 'thin')]; }
},
boiler: {
label: 'Elektrische boiler', cat: 'techniek', w: 450, d: 450, h: 800, resize: ['w', 'h'], wall: true, cost: 'boiler',
opts: { liter: 80 }, hint: 'Hangend aan de wand (onderkant 900 mm); w = diameter',
build(f, g) {
const r = f.w / 2;
ecyl(g, MAT.wit(), 0, 0, r, Math.min(r, f.d / 2), 900, 900 + f.h);
boxAt(g, MAT.zwartMetaal(), -40, 40, 860, 900, -20, 20);
[-60, 60].forEach(x => boxAt(g, MAT.chroom(), x - 10, x + 10, 400, 870, -f.d / 2 + 20, -f.d / 2 + 40));
},
plan(f) { return [ellP(0, 0, f.w / 2, f.d / 2), ellP(0, 0, f.w / 2 - 25, f.d / 2 - 25, 'thin'), { text: `EB ${f.opts.liter || 80}L`, at: { x: 0, z: 0 } }]; }
},
wasmachine: {
label: 'Wasmachine', cat: 'techniek', w: 600, d: 600, h: 850, resize: [], wall: true, cost: null, opts: {},
build(f, g) {
boxAt(g, MAT.wit(), -300, 300, 0, 850, -300, 280);
const door = ecyl(g, MAT.zwartMetaal(), 0, 0, 160, 160, 0, 10);
door.rotation.x = Math.PI / 2;
door.position.set(0, 0.45, 0.285);
},
plan() { return [rectP(-300, 300, -300, 300), circP(0, 0, 200, 'thin'), { text: 'WM', at: { x: 0, z: 0 } }]; }
}
};
function tap(g, x, y, z, reach) {
ecyl(g, MAT.chroom(), x, z, 18, 18, y, y + 180);
boxAt(g, MAT.chroom(), x - 10, x + 10, y + 160, y + 180, z, z + reach);
}
function rot2(x, z, deg) {
const a = deg * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
return { x: x * c - z * s, z: x * s + z * c };
}
function chairSpots(f) {
const n = Math.max(0, Math.min(12, Math.round(f.opts.stoelen || 0)));
const out = [];
const perSide = Math.ceil(n / 2);
[[-1, 180], [1, 0]].forEach(([s, r], k) => {
const m = k === 0 ? perSide : n - perSide;
for (let i = 0; i < m; i++) {
const x = -f.w / 2 + f.w * (i + 0.5) / m * 0.86 + f.w * 0.07;
const edge = f.opts.vorm === 'ovaal' ? (f.d / 2) * Math.sqrt(Math.max(0, 1 - (x / (f.w / 2)) ** 2)) : f.d / 2;
out.push({ x, z: s * (edge + 120), rot: r });
}
});
return out;
}
function chair(g, x, z, rotDeg) {
const c = new THREE.Group();
boxAt(c, MAT.stofDonker(), -225, 225, 430, 480, -220, 220);
boxAt(c, MAT.stofDonker(), -225, 225, 480, 880, 170, 220);
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => boxAt(c, MAT.zwartMetaal(), a * 195 - 12, a * 195 + 12, 0, 430, b * 190 - 12, b * 190 + 12));
c.rotation.y = -rotDeg * Math.PI / 180;
c.position.set(x * toM, 0, z * toM);
g.add(c);
}
// Van voren gezien: links = -x.
function kitchenModules(f, island) {
const tall = !island && (f.opts.hogeKast === 'links' || f.opts.hogeKast === 'rechts') && f.w >= 1200 ? f.opts.hogeKast : null;
const x0 = -f.w / 2 + (tall === 'links' ? 600 : 0), x1 = f.w / 2 - (tall === 'rechts' ? 600 : 0);
const n = Math.max(1, Math.round((x1 - x0) / 600));
return { tall, x0, x1, n, mw: (x1 - x0) / n };
}
function kitchenRun(g, f, island) {
const zb = -f.d / 2, zf = f.d / 2, top = f.h, front = frontMat(f.opts.front);
const K = kitchenModules(f, island);
boxAt(g, MAT.plint(), K.x0, K.x1, 0, 100, zb + (island ? 60 : 0), zf - 60);
boxAt(g, front, K.x0, K.x1, 100, top - 40, zb + (island ? 0 : 0), zf - 22);
for (let i = 0; i < K.n; i++) {
const a = K.x0 + i * K.mw + 1.5, b = K.x0 + (i + 1) * K.mw - 1.5;
boxAt(g, front, a, b, 105, top - 45, zf - 22, zf - 2);
if (island) boxAt(g, front, a, b, 105, top - 45, zb + 2, zb + 22);
}
boxAt(g, MAT.werkblad(), K.x0 - (island ? 20 : 0), K.x1 + (island ? 20 : 0), top - 40, top, zb - (island ? 20 : 0), zf + 20);
if (K.tall) {
const a = K.tall === 'links' ? -f.w / 2 : f.w / 2 - 600, b = a + 600;
boxAt(g, front, a, b, 0, 2200, zb, zf - 22);
boxAt(g, front, a + 2, b - 2, 105, 1190, zf - 22, zf - 2); // koelkastdeur
boxAt(g, MAT.scherm(), a + 20, b - 20, 1200, 1780, zf - 22, zf - 2); // oven
boxAt(g, MAT.zwartMetaal(), a + 30, b - 30, 1730, 1750, zf - 2, zf + 15);
boxAt(g, front, a + 2, b - 2, 1790, 2195, zf - 22, zf - 2);
}
if (!island) {
// Spoelbak + kraan
const sx = spoelX(f, K);
boxAt(g, MAT.chroom(), sx - 380, sx + 380, top - 2, top + 0.5, zb + 90, zf - 70);
boxAt(g, MAT.rubber(), sx - 360, sx + 360, top - 1, top + 1, zb + 105, zf - 85);
ecyl(g, MAT.zwartMetaal(), sx + 200, zb + 55, 22, 22, top, top + 300);
boxAt(g, MAT.zwartMetaal(), sx + 190, sx + 210, top + 280, top + 300, zb + 55, zb + 280);
if (f.opts.bovenkasten) boxAt(g, front, K.x0, K.x1, 1450, 2150, zb, zb + 350);
} else if (f.opts.kookplaat) {
const hw = Math.min(780, K.x1 - K.x0 - 100), hd = Math.min(520, f.d - 80);
boxAt(g, MAT.kookplaat(), -hw / 2, hw / 2, top, top + 4, -hd / 2, hd / 2);
hobZones(hw, hd).forEach(z => ecyl(g, MAT.ring(), z.x, z.z, z.r, z.r, top + 4, top + 5));
}
}
const hobZones = (hw, hd) => [[-0.25, -0.25, 0.19], [0.25, -0.25, 0.14], [-0.25, 0.25, 0.14], [0.25, 0.25, 0.19]]
.map(([a, b, r]) => ({ x: a * hw, z: b * hd, r: r * Math.min(hw, hd * 1.4) }));
function spoelX(f, K) {
const t = Math.max(0, Math.min(1, f.opts.spoelbak === undefined ? 0.6 : f.opts.spoelbak));
return Math.max(K.x0 + 400, Math.min(K.x1 - 400, K.x0 + t * (K.x1 - K.x0)));
}
function kitchenPlan(f, island) {
const zb = -f.d / 2, zf = f.d / 2, K = kitchenModules(f, island), out = [];
out.push(rectP(K.x0, K.x1, zb, zf));
for (let i = 1; i < K.n; i++) out.push(lineP(K.x0 + i * K.mw, zb, K.x0 + i * K.mw, zf, 'thin'));
if (K.tall) {
const a = K.tall === 'links' ? -f.w / 2 : f.w / 2 - 600;
out.push(rectP(a, a + 600, zb, zf), lineP(a, zb, a + 600, zf, 'thin'), lineP(a, zf, a + 600, zb, 'thin'));
}
if (!island) {
const sx = spoelX(f, K);
out.push(rrectP(sx - 360, sx + 360, zb + 105, zf - 85, 40, 'thin'), circP(sx + 200, zb + 55, 22, 'thin'));
} else if (f.opts.kookplaat) {
const hw = Math.min(780, K.x1 - K.x0 - 100), hd = Math.min(520, f.d - 80);
out.push(rectP(-hw / 2, hw / 2, -hd / 2, hd / 2, 'thin'));
hobZones(hw, hd).forEach(z => out.push(circP(z.x, z.z, z.r, 'thin')));
}
return out;
}
const ROTS = [0, 90, 180, 270];
function create(state, type, unitId, x, z, rot) {
const T = TYPES[type] || TYPES.bed;
const id = (state.fixtures || []).reduce((m, f) => Math.max(m, f.id || 0), 0) + 1;
return normalizeOne({ id, type, unitId, x: Math.round(x), z: Math.round(z), rot: rot || 0, w: T.w, d: T.d, h: T.h, opts: Object.assign({}, T.opts || {}) });
}
function normalizeOne(f, i) {
const T = TYPES[f.type];
if (!T) return null;
const num = (v, def, min) => (isFinite(v) && v > 0 ? Math.max(min || 50, Math.round(v)) : def);
return {
id: f.id > 0 ? f.id : (i || 0) + 1, type: f.type, unitId: String(f.unitId || 'A'),
x: Math.round(Number(f.x) || 0), z: Math.round(Number(f.z) || 0), anchor: f.anchor === 'oost' ? 'oost' : 'west', rot: ((Math.round(Number(f.rot) || 0) % 360) + 360) % 360,
w: T.resize.includes('w') ? num(f.w, T.w, T.minW) : T.w, d: T.resize.includes('d') ? num(f.d, T.d) : T.d, h: T.resize.includes('h') ? num(f.h, T.h) : T.h,
opts: Object.assign({}, T.opts || {}, f.opts || {})
};
}
function normalize(list, unitIds) {
return (Array.isArray(list) ? list : []).map(normalizeOne).filter(f => f && (!unitIds || unitIds.includes(f.unitId)));
}
function toWorld(item, lx, lz) {
const p = rot2(lx, lz, item.rot);
return { x: item.cx + p.x, z: item.cz + p.z };
}
function compute(list, units) {
return (list || []).map(f => {
const u = units.find(q => q.id === f.unitId);
if (!u) return null;
const T = TYPES[f.type];
const item = { id: f.id, type: f.type, label: T.label, cat: T.cat, cost: T.cost, state: f, w: f.w, d: f.d, h: f.h, opts: f.opts, rot: f.rot,
unitId: f.unitId, cx: f.anchor === 'oost' ? u.rect.x1 - f.x : u.rect.x0 + f.x, cz: u.rect.z0 + f.z, floorTop: u.layout.floorTop };
item.corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([a, b]) => toWorld(item, a * f.w / 2, b * f.d / 2));
return item;
}).filter(Boolean);
}
function planShapes(item) {
const T = TYPES[item.type];
const f = { w: item.w, d: item.d, h: item.h, opts: item.opts };
return T.plan(f).map(p => {
if (p.circle) { const c = toWorld(item, p.circle.x, p.circle.z); return Object.assign({}, p, { circle: { x: c.x, z: c.z, r: p.circle.r } }); }
if (p.text) return Object.assign({}, p, { at: toWorld(item, p.at.x, p.at.z) });
return Object.assign({}, p, { pts: p.pts.map(q => toWorld(item, q.x, q.z)) });
});
}
function contains(item, x, z, tol) {
const p = rot2(x - item.cx, z - item.cz, -item.rot), t = tol || 0;
return Math.abs(p.x) <= item.w / 2 + t && Math.abs(p.z) <= item.d / 2 + t;
}
function hit(items, x, z, tol) {
// Kleinste eerst (een fontein op een groter stuk blijft selecteerbaar).
return items.filter(it => contains(it, x, z, tol)).sort((a, b) => a.w * a.d - b.w * b.d)[0] || null;
}
function snap(item, x, z, pieces, reach) {
const T = TYPES[item.type], R = reach || 350;
const grid = (v) => Math.round(v / 10) * 10;
if (!T.wall) return { x: grid(x), z: grid(z), rot: item.rot };
let best = null;
(pieces || []).forEach(p => {
if (p.place.baseY) return;
const pl = p.place, alongX = Math.abs(Math.sin(pl.rotY)) < 0.5;
const half = pl.width / 2, t = pl.thickness / 2;
const s0 = (alongX ? pl.cx : pl.cz) - half, s1 = (alongX ? pl.cx : pl.cz) + half;
const s = alongX ? x : z, n = alongX ? z : x, c = alongX ? pl.cz : pl.cx;
if (s < s0 - 50 || s > s1 + 50) return;
[-1, 1].forEach(side => {
const face = c + side * t, dist = (n - face) * side;
if (dist < -50 || dist > R + item.d / 2) return;
const gap = Math.abs(dist - item.d / 2);
if (best && gap >= best.gap) return;
const rot = alongX ? (side > 0 ? 0 : 180) : (side > 0 ? 270 : 90);
const sC = s1 - s0 > item.w ? Math.max(s0 + item.w / 2, Math.min(s1 - item.w / 2, s)) : (s0 + s1) / 2;
const nC = face + side * item.d / 2;
best = { gap, x: alongX ? sC : nC, z: alongX ? nC : sC, rot };
});
});
return best ? { x: grid(best.x), z: grid(best.z), rot: best.rot } : { x: grid(x), z: grid(z), rot: item.rot };
}
function build3D(items) {
const group = new THREE.Group();
items.forEach(it => {
const g = new THREE.Group();
TYPES[it.type].build({ w: it.w, d: it.d, h: it.h, opts: it.opts }, g);
g.rotation.y = -it.rot * Math.PI / 180;
g.position.set(it.cx * toM, it.floorTop * toM, it.cz * toM);
g.userData.fixture = it.id;
group.add(g);
});
return group;
}
const blocks = (item) => item.type !== 'douche';
return { TYPES, CATS, FRONTS, ROTS, create, normalize, compute, planShapes, contains, hit, snap, build3D, blocks, toWorld };
})();
+77 -145
View File
@@ -4,61 +4,26 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// lighting/lighting-model.js — LightingModel: ENIGE bron van de
// buitenverlichting van een woning. Geen DOM.
//
// Woning-breed (settings.lighting): type, lichtrichting en alle afstanden.
// mode: 'geen' | 'spots' (boeideel-spots, alleen waar boeidelen zijn) |
// 'gevel' (gevellampen), direction: 'both' | 'down' | 'up'.
//
// Gevellampen worden per GEVEL verdeeld, niet per wand: wandstukken in
// hetzelfde gevelvlak die op elkaar aansluiten (ook over een naad tussen
// units) vormen samen één gevel van hoek tot hoek. De balklogica per wand
// blijft ongewijzigd; alleen de lampen lopen uniform door.
// Op lamphoogte (standaard 1950 mm) worden openingen incl. raamwerk
// (koningsstijl/dorpels) van de gevel afgetrokken. Op elk vrij stuk van
// minstens minWallWidth (2000 mm) komen lampen: de eerste en laatste op
// endOffset (300 mm) van het uiteinde (hoek of opening), daartussen
// verdeeld met min/max h.o.h. (zelfde verdeling als stijlen). Smallere vrije
// stukken krijgen geen lampen. Gevellampen en boeideel-spots sluiten elkaar
// uit: bij 'gevel' komen er nooit spots.
// Per wand(zijde) blijft een harde override mogelijk
// (unit.lighting.walls[side]): 'auto' (standaard), 'openingen' (lampen op
// openingOffset = 500 mm naast elke opening, bijv. slaapkamers) of 'geen'.
//
// Binnenspots (computeIndoor) worden PER UNIT berekend, net als de spots in
// het boeideel verdeeld tussen min/max h.o.h.: een raster in het plafond
// binnen de unit, minstens indoorOffset van de binnenzijde van elke wand (of
// van de unitrand waar een naad met een andere unit is), met in beide
// richtingen indoorMin..indoorMax h.o.h. Past er in een richting geen
// indoorMin tussen, dan één spot in het midden. Spots te dicht bij een
// binnenwand vervallen.
//
// Plain script, geladen na core/geometry.js en roof-model.js.
// ============================================================
window.LightingModel = (() => { window.LightingModel = (() => {
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
mode: 'spots', // 'geen' | 'spots' | 'gevel' mode: 'spots',
direction: 'both', // gevellampen: 'both' | 'down' | 'up' direction: 'both',
lampHeight: 1950, // mm boven vloer lampHeight: 1950,
lampMin: 1500, // mm h.o.h. gevellampen lampMin: 1500,
lampMax: 3000, lampMax: 3000,
minWallWidth: 2000, // kleinste vrije gevelbreedte voor gevellampen minWallWidth: 2000,
endOffset: 300, // eerste/laatste lamp vanaf hoek/uiteinde endOffset: 300,
openingOffset: 500, // override: lampen zoveel naast elke opening openingOffset: 500,
// Binnenspots in het plafond, per unit (computeIndoor).
indoor: true, indoor: true,
indoorMin: 800, // mm h.o.h., in beide richtingen indoorMin: 800,
indoorMax: 1500, indoorMax: 1500,
indoorOffset: 600 // min. afstand tot wand (binnenzijde) of unitrand (naad) indoorOffset: 600
}; };
const MODES = { geen: 'Geen', spots: 'Boeideel-spots', gevel: 'Gevellampen' }; const MODES = { geen: 'Geen', spots: 'Boeideel-spots', gevel: 'Gevellampen' };
const DIRECTIONS = { both: 'Boven én onder', down: 'Alleen onder', up: 'Alleen boven' }; const DIRECTIONS = { both: 'Boven én onder', down: 'Alleen onder', up: 'Alleen boven' };
const WALL_OVERRIDES = { auto: 'Automatisch', openingen: 'Naast openingen (override)', geen: 'Geen lampen' }; const WALL_OVERRIDES = { auto: 'Automatisch', openingen: 'Naast openingen (override)', geen: 'Geen lampen' };
// Per unit alleen nog de override per wandzijde.
function createDefaultUnitLighting() { function createDefaultUnitLighting() {
return { walls: {} }; return { walls: {} };
} }
@@ -67,7 +32,6 @@ window.LightingModel = (() => {
return Object.assign({}, DEFAULT_SETTINGS, settings || {}); return Object.assign({}, DEFAULT_SETTINGS, settings || {});
} }
// Spots op een lijnstuk A -> B: op beide uiteinden + daartussen verdeeld.
function spotsBetween(A, B, min, max, out) { function spotsBetween(A, B, min, max, out) {
const len = Math.hypot(B.x - A.x, B.z - A.z); const len = Math.hypot(B.x - A.x, B.z - A.z);
out.push({ x: A.x, z: A.z }, { x: B.x, z: B.z }); out.push({ x: A.x, z: A.z }, { x: B.x, z: B.z });
@@ -75,8 +39,6 @@ window.LightingModel = (() => {
calculateSegmentPositions(len, min, max).forEach(p => out.push({ x: A.x + (B.x - A.x) * p / len, z: A.z + (B.z - A.z) * p / len })); calculateSegmentPositions(len, min, max).forEach(p => out.push({ x: A.x + (B.x - A.x) * p / len, z: A.z + (B.z - A.z) * p / len }));
} }
// Lampen die dichter dan `min` bij elkaar staan worden samengevoegd tot één
// lamp in het midden, tot alle onderlinge afstanden >= min zijn.
function enforceMinSpacing(positions, min) { function enforceMinSpacing(positions, min) {
let pts = positions.slice().sort((a, b) => a - b); let pts = positions.slice().sort((a, b) => a - b);
let changed = true; let changed = true;
@@ -93,7 +55,6 @@ window.LightingModel = (() => {
return pts; return pts;
} }
// Aftrekken van intervallen: [lo, hi] min blocked -> vrije intervallen.
function freeIntervals(lo, hi, blocked) { function freeIntervals(lo, hi, blocked) {
const free = []; const free = [];
let cursor = lo; let cursor = lo;
@@ -105,40 +66,26 @@ window.LightingModel = (() => {
return free.filter(([a, b]) => b - a > 1); return free.filter(([a, b]) => b - a > 1);
} }
// Alles voor de hele woning. units = HouseModel-parts.units (met
// walls[].pieces[].config/place), stateUnits = state.units (overrides per
// wand), edge = RoofModel.computeEdge-resultaat (edge.spots wordt hier
// gevuld). Zet per wandstuk p.lamps = [{ u, y }] (voor editor/PDF).
// Geeft { lamps: [{ x, y, z, normal, direction, element, u, yLocal }] }.
function compute(settings, roofSettings, units, stateUnits, edge) { function compute(settings, roofSettings, units, stateUnits, edge) {
const s = normalizeSettings(settings); const s = normalizeSettings(settings);
const overrides = {}; const overrides = {};
stateUnits.forEach(u => { overrides[u.id] = (u.lighting && u.lighting.walls) || {}; }); stateUnits.forEach(u => { overrides[u.id] = (u.lighting && u.lighting.walls) || {}; });
// 1. Buiten-wandstukken als lijnstukken in hun gevelvlak (wereld-mm).
const segs = []; const segs = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
p.lamps = []; p.lamps = [];
if (p.baseY) return; // opbouw boven een lager dak: geen gevellampen if (p.baseY) return; // opbouw boven een lager dak: geen gevellampen
const W = p.place.width; const W = p.place.width;
// Lampen hangen op de afgewerkte gevel: wandvlak + kruislat + bekleding. const T = p.place.thickness + 2 * WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
const T = p.place.thickness + 2 * WallModel.claddingOffset(p.buitenMateriaal, p.stone);
const normal = { x: p.place.normal.x, z: p.place.normal.y }; // place.normal is {x, y} (plattegrond) const normal = { x: p.place.normal.x, z: p.place.normal.y }; // place.normal is {x, y} (plattegrond)
const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z'; const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z';
const perp = axis === 'x' ? 'z' : 'x'; const perp = axis === 'x' ? 'z' : 'x';
// Binnenhoek-doorloop (negatieve skinExt): de gevel begint pas daarna.
const tL = Math.max(0, -((p.skinExt || {}).left || 0)), tR = Math.max(0, -((p.skinExt || {}).right || 0)); const tL = Math.max(0, -((p.skinExt || {}).left || 0)), tR = Math.max(0, -((p.skinExt || {}).right || 0));
const q0 = UnitModel.localToWorld(p.place, -W / 2 + tL, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2 - tR, T / 2); const q0 = UnitModel.localToWorld(p.place, -W / 2 + tL, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2 - tR, T / 2);
// Gevel loopt tot de afgewerkte hoek, zodat endOffset altijd vanaf de // Gevel loopt tot de afgewerkte hoek, zodat endOffset vanaf de zichtbare hoek telt.
// zichtbare hoek telt: elk uiteinde + afwerking (latwerk + bekleding van const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), e = p.skinExt || {};
// de haakse wand die eroverheen loopt), en waar dit stuk de kopse kant
// van een doorlopende wand bedekt (skinExt) ook nog die wanddikte.
// Binnenhoeken en naden: zie de voetafdrukken in 3 en de overlap in 2.
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone), e = p.skinExt || {};
const extL = (e.left > 0 ? e.left : 0) + c, extR = (e.right > 0 ? e.right : 0) + c; const extL = (e.left > 0 ? e.left : 0) + c, extR = (e.right > 0 ? e.right : 0) + c;
const up = q1[axis] >= q0[axis]; const up = q1[axis] >= q0[axis];
// Voetafdruk van dit stuk incl. afwerking (om bedekte delen van andere
// gevels af te trekken, zie 3).
const corners = [[-W / 2, -p.place.thickness / 2], [W / 2, -p.place.thickness / 2], [-W / 2, T / 2], [W / 2, T / 2]] const corners = [[-W / 2, -p.place.thickness / 2], [W / 2, -p.place.thickness / 2], [-W / 2, T / 2], [W / 2, T / 2]]
.map(([lx, lz]) => UnitModel.localToWorld(p.place, lx, lz)); .map(([lx, lz]) => UnitModel.localToWorld(p.place, lx, lz));
segs.push({ segs.push({
@@ -152,10 +99,8 @@ window.LightingModel = (() => {
const uToS = (sg, u) => sg.sAt0 + (sg.sAtW - sg.sAt0) * (u / sg.piece.place.width); const uToS = (sg, u) => sg.sAt0 + (sg.sAtW - sg.sAt0) * (u / sg.piece.place.width);
const sToU = (sg, v) => (v - sg.sAt0) / (sg.sAtW - sg.sAt0) * sg.piece.place.width; const sToU = (sg, v) => (v - sg.sAt0) / (sg.sAtW - sg.sAt0) * sg.piece.place.width;
// 2. Gevels: zelfde normaal + zelfde gevelvlak + aansluitend. const runs = groupFacadeRuns(segs);
const runs = groupFacadeRuns(segs); // core/geometry.js
// 3. Lampen per gevel.
const lamps = []; const lamps = [];
const H = s.lampHeight; const H = s.lampHeight;
const addLamp = (run, v) => { const addLamp = (run, v) => {
@@ -169,11 +114,8 @@ window.LightingModel = (() => {
if (s.mode === 'gevel') { if (s.mode === 'gevel') {
runs.forEach(run => { runs.forEach(run => {
const blocked = []; const blocked = [];
const pos = []; // lampposities langs deze gevel (wereld-mm) const pos = [];
// Delen van deze gevel die achter een andere wand liggen (bijv. het // Delen achter een andere wand (bijv. een stuk dat de naad in loopt) zijn geen zichtbare gevel.
// stuk van een wand dat de naad in loopt om een binnenhoek te sluiten)
// zijn geen zichtbare gevel: de gevel begint pas bij de afgewerkte
// buitenkant van die andere wand.
segs.filter(o => !run.segs.includes(o)).forEach(o => { segs.filter(o => !run.segs.includes(o)).forEach(o => {
const f = o.foot; const f = o.foot;
const pMin = run.axis === 'x' ? f.z0 : f.x0, pMax = run.axis === 'x' ? f.z1 : f.x1; const pMin = run.axis === 'x' ? f.z0 : f.x0, pMax = run.axis === 'x' ? f.z1 : f.x1;
@@ -185,7 +127,6 @@ window.LightingModel = (() => {
const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100; const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100;
if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; } if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; }
if (sg.override === 'openingen') { if (sg.override === 'openingen') {
// Harde override: lampen naast elke opening, dit stuk doet verder niet mee.
blocked.push([sg.s0, sg.s1]); blocked.push([sg.s0, sg.s1]);
cfg.openings.forEach(op => [op.x - s.openingOffset, op.x + op.w + s.openingOffset] cfg.openings.forEach(op => [op.x - s.openingOffset, op.x + op.w + s.openingOffset]
.filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u)))); .filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
@@ -197,35 +138,31 @@ window.LightingModel = (() => {
}); });
}); });
freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => { freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => {
if (b - a < s.minWallWidth) return; // te smal: geen lampen if (b - a < s.minWallWidth) return;
const first = a + s.endOffset, last = b - s.endOffset; const first = a + s.endOffset, last = b - s.endOffset;
pos.push(first); pos.push(first);
calculateSegmentPositions(last - first, s.lampMin, s.lampMax).forEach(p => pos.push(first + p)); calculateSegmentPositions(last - first, s.lampMin, s.lampMax).forEach(p => pos.push(first + p));
pos.push(last); pos.push(last);
}); });
// Min. h.o.h. geldt over ALLE lampen van de gevel samen (ook bij // Min. h.o.h. geldt over alle lampen van de gevel samen (ook overrides en de grens tussen wanden).
// afstand hoek/opening, overrides en de grens tussen wanden).
enforceMinSpacing(pos, s.lampMin).forEach(v => addLamp(run, v)); enforceMinSpacing(pos, s.lampMin).forEach(v => addLamp(run, v));
}); });
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => p.lamps.sort((a, b) => a.u - b.u)))); units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => p.lamps.sort((a, b) => a.u - b.u))));
} }
// 4. Boeideel-spots per dakrand-ribbe (alleen bij type 'spots').
if (edge) { if (edge) {
const spots = []; const spots = [];
if (s.mode === 'spots') { if (s.mode === 'spots') {
const rs = RoofModel.normalizeSettings(roofSettings); const rs = RoofModel.normalizeSettings(roofSettings);
edge.segments.filter(sg => sg.fascia && !sg.overRoof).forEach(sg => { // niet boven een lager dak edge.segments.filter(sg => sg.fascia && !sg.overRoof).forEach(sg => {
const from = spots.length; const from = spots.length;
spotsBetween(sg.spotA, sg.spotB, rs.spotMin, rs.spotMax, spots); spotsBetween(sg.spotA, sg.spotB, rs.spotMin, rs.spotMax, spots);
for (let k = from; k < spots.length; k++) { for (let k = from; k < spots.length; k++) {
spots[k].normal = { x: sg.normal.x, z: sg.normal.z }; spots[k].normal = { x: sg.normal.x, z: sg.normal.z };
spots[k].y = sg.topAt(spots[k]) - edge.fasciaHeight; // onderkant boeideel (volgt de dakvorm) spots[k].y = sg.topAt(spots[k]) - edge.fasciaHeight;
} }
}); });
} }
// Dubbele spots (hoeken) samenvoegen; de buitenrichting wordt dan de
// som van beide gevels (diagonaal naar buiten).
edge.spots = []; edge.spots = [];
spots.forEach(p => { spots.forEach(p => {
const q = edge.spots.find(e => Math.hypot(e.x - p.x, e.z - p.z) < 5); const q = edge.spots.find(e => Math.hypot(e.x - p.x, e.z - p.z) < 5);
@@ -236,24 +173,18 @@ window.LightingModel = (() => {
return { lamps }; return { lamps };
} }
// Posities op [a, b]: beide uiteinden + verdeeld (calculateSegmentPositions);
// korter dan min (of negatief) = één positie in het midden.
function gridPositions(a, b, min, max) { function gridPositions(a, b, min, max) {
const L = b - a; const L = b - a;
if (L < min) return [(a + b) / 2]; if (L < min) return [(a + b) / 2];
return [a, ...calculateSegmentPositions(L, min, max).map(p => a + p), b]; return [a, ...calculateSegmentPositions(L, min, max).map(p => a + p), b];
} }
// Afstand van punt p tot lijnstuk a-b (mm).
function distToSegment(p, a, b) { function distToSegment(p, a, b) {
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1; const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1;
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.z - a.z) * dz) / len2)); const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.z - a.z) * dz) / len2));
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) { function unitInnerRect(u, interior) {
const T = {}; const T = {};
u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; }); u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; });
@@ -268,9 +199,6 @@ window.LightingModel = (() => {
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) }; 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),
// interior = parts.interior (binnenwanden met place). Geeft
// [{ unitId, x, z, y, floorY }] (mm, y = onderkant plafond, floorY = loopvlak).
function computeIndoor(settings, units, interior) { function computeIndoor(settings, units, interior) {
const s = normalizeSettings(settings); const s = normalizeSettings(settings);
if (!s.indoor) return []; if (!s.indoor) return [];
@@ -292,6 +220,36 @@ window.LightingModel = (() => {
return out; return out;
} }
const ROOM_SPOT_MIN_M2 = 0.6;
function roomSpots(settings, indoor, rooms, units) {
const s = normalizeSettings(settings);
if (!s.indoor || !rooms) return indoor;
rooms.rooms.concat(rooms.free).forEach(r => {
if (r.areaM2 < ROOM_SPOT_MIN_M2) return;
if (indoor.some(p => { const g = rooms.regionAt(p.x, p.z); return g && g.idx === r.key; })) return;
const c = r.label, u = units.find(q => c.x >= q.rect.x0 && c.x <= q.rect.x1 && c.z >= q.rect.z0 && c.z <= q.rect.z1);
if (!u) return;
indoor.push({ unitId: u.id, x: c.x, z: c.z, y: u.roof.ceilingAt(c.x, c.z), floorY: u.layout.floorTop, room: true });
});
return indoor;
}
let offMaterial = null;
function applySwitches(group, isOff) {
if (!group) return;
offMaterial = offMaterial || new THREE.MeshStandardMaterial({ color: 0xcfcbc0, roughness: 0.5 });
group.traverse(o => {
const ref = o.userData.ref;
if (!ref) return;
const off = !!isOff(ref);
if (o.userData.glow) {
if (!o.userData.onMat) o.userData.onMat = o.material;
o.material = off ? offMaterial : o.userData.onMat;
} else if (o.isLight || o.userData.wash) o.visible = !off;
else if (o.userData.beam) { if (off) o.visible = false; }
});
}
function createMaterials() { function createMaterials() {
return { return {
housingMaterial: new THREE.MeshStandardMaterial({ color: 0x2b2f33, roughness: 0.5, metalness: 0.3 }), housingMaterial: new THREE.MeshStandardMaterial({ color: 0x2b2f33, roughness: 0.5, metalness: 0.3 }),
@@ -301,8 +259,6 @@ window.LightingModel = (() => {
}; };
} }
// 3D: behuizing op het gevelvlak + lichtbundel(s) omhoog/omlaag, en de
// binnenspots (indoor, zie computeIndoor) als schijfje in het plafond.
function build3D(lamps, materials, indoor) { function build3D(lamps, materials, indoor) {
const toM = 0.001; const toM = 0.001;
const group = new THREE.Group(); const group = new THREE.Group();
@@ -316,10 +272,11 @@ window.LightingModel = (() => {
const addGlow = (sign) => { const addGlow = (sign) => {
const g = new THREE.Mesh(new THREE.BoxGeometry(alongX ? w * 0.8 : d * 0.8, 0.006, alongX ? d * 0.8 : w * 0.8), materials.glowMaterial); const g = new THREE.Mesh(new THREE.BoxGeometry(alongX ? w * 0.8 : d * 0.8, 0.006, alongX ? d * 0.8 : w * 0.8), materials.glowMaterial);
g.position.set(cx, cy + sign * h / 2, cz); g.position.set(cx, cy + sign * h / 2, cz);
g.userData = { ref: l, glow: true };
group.add(g); group.add(g);
const cone = new THREE.Mesh(new THREE.ConeGeometry(beamR, beamH, 20, 1, true), materials.beamMaterial); const cone = new THREE.Mesh(new THREE.ConeGeometry(beamR, beamH, 20, 1, true), materials.beamMaterial);
cone.userData.beam = true; // verborgen in de volle render (dan schijnt echt licht) cone.userData = { beam: true, ref: l };
if (sign > 0) cone.rotation.x = Math.PI; // punt bij de lamp, breed naar boven if (sign > 0) cone.rotation.x = Math.PI;
cone.position.set(cx, cy + sign * (h / 2 + beamH / 2), cz); cone.position.set(cx, cy + sign * (h / 2 + beamH / 2), cz);
group.add(cone); group.add(cone);
}; };
@@ -329,25 +286,15 @@ window.LightingModel = (() => {
(indoor || []).forEach(p => { (indoor || []).forEach(p => {
const spot = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.01, 16), materials.indoorMaterial); const spot = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.01, 16), materials.indoorMaterial);
spot.position.set(p.x * toM, p.y * toM - 0.006, p.z * toM); spot.position.set(p.x * toM, p.y * toM - 0.006, p.z * toM);
spot.userData = { ref: p, glow: true };
group.add(spot); group.add(spot);
}); });
return group; return group;
} }
// Render: elke gevellamp (per lichtrichting), boeideel-spot en binnenspot // Geen schaduw per lamp (één schaduwkaart per lamp, de browser kan er ~16), dus licht door muren
// wordt een echte SpotLight. Echte schaduw per lamp is te zwaar (één // geometrisch voorkomen: buitenlampen en boeideel-spots half hun bundelhoek naar buiten gekanteld,
// schaduwkaart per lamp; de browser kan er maar ~16), dus licht door muren // binnenspots met een harde reikwijdte net voorbij de vloer (INDOOR_REACH).
// wordt geometrisch voorkomen:
// - buitenlampen en boeideel-spots zijn half hun bundelhoek naar buiten
// gekanteld: de binnenrand van de bundel loopt verticaal langs de gevel;
// - binnenspots hebben een harde reikwijdte net voorbij de vloer
// (INDOOR_REACH), zodat ze niet tot de grond buiten reiken.
// spots = [{ x, z, y }] (mm, boeideel; y = onderkant boeideel, anders spotY),
// indoor = [{ x, z, y, floorY }] (mm). o.outdoorFactor schaalt de buiten-
// lampen (overdag lager: dan vallen ze nauwelijks op).
// Zacht en breed i.p.v. fel en smal: lagere intensiteit, grotere bundel
// en een brede zachte rand (penumbra), zodat naast elkaar liggende spots
// niet optellen tot felle vlekken.
const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 }; const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 }; const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 };
const DAY_OUTDOOR_FACTOR = 0.35; const DAY_OUTDOOR_FACTOR = 0.35;
@@ -355,44 +302,34 @@ window.LightingModel = (() => {
const toM = 0.001; const toM = 0.001;
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1; const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
const group = new THREE.Group(); const group = new THREE.Group();
// Spot op (x, y, z) die naar (x + tx, y + ty, z + tz) schijnt. const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra) => {
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay); const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay);
light.userData.ref = ref;
light.position.set(x, y, z); light.position.set(x, y, z);
light.target.position.set(x + tx, y + ty, z + tz); light.target.position.set(x + tx, y + ty, z + tz);
group.add(light); group.add(light);
group.add(light.target); group.add(light.target);
}; };
const tilt = Math.tan(OUTDOOR.angle); // bundelas half de bundelhoek naar buiten const tilt = Math.tan(OUTDOOR.angle);
lamps.forEach(l => { lamps.forEach(l => {
const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM; const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM;
const nx = l.normal.x * tilt, nz = l.normal.z * tilt; const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra); if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
if (l.direction !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra); if (l.direction !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
}); });
// Boeideel-spots: naar buiten gekanteld (normaal van de dakrand; op een
// hoek diagonaal).
(spots || []).forEach(p => { (spots || []).forEach(p => {
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1; const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra); add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra, p);
}); });
(indoor || []).forEach(p => { (indoor || []).forEach(p => {
const drop = (p.y - p.floorY) * toM; // plafond -> loopvlak const drop = (p.y - p.floorY) * toM;
add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra); add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra, p);
}); });
if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo)); if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo));
return group; return group;
} }
// ------------------------------------------------------------ // Nep-lichtvlekken op de gevel: echte spots zonder schaduw zouden door de muur naar binnen schijnen.
// 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 }; const WASH = { color: 0xffd9a0, spot: 0.55, lamp: 0.45, spread: 0.5, lampReach: 1800 };
let washTexture = null; let washTexture = null;
function getWashTexture() { function getWashTexture() {
@@ -403,9 +340,9 @@ window.LightingModel = (() => {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H); const img = ctx.createImageData(W, H);
for (let j = 0; j < H; j++) { for (let j = 0; j < H; j++) {
const t = j / (H - 1); // 0 = bij de lamp, 1 = einde bundel const t = j / (H - 1);
const half = 0.12 + 0.88 * Math.pow(t, 0.6); // bundelbreedte (fractie van de halve breedte) const half = 0.12 + 0.88 * Math.pow(t, 0.6);
const fade = Math.min(1, t * 12) * Math.pow(1 - t, 1.6); // zacht begin, uitdoven const fade = Math.min(1, t * 12) * Math.pow(1 - t, 1.6);
for (let i = 0; i < W; i++) { for (let i = 0; i < W; i++) {
const x = Math.abs((i + 0.5) / W * 2 - 1) / half; const x = Math.abs((i + 0.5) / W * 2 - 1) / half;
const v = x >= 1 ? 0 : fade * Math.pow(1 - x * x, 2); const v = x >= 1 ? 0 : fade * Math.pow(1 - x * x, 2);
@@ -424,9 +361,8 @@ window.LightingModel = (() => {
blending: THREE.AdditiveBlending, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2 blending: THREE.AdditiveBlending, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2
}); });
const geo = new THREE.PlaneGeometry(widthM, heightM); const geo = new THREE.PlaneGeometry(widthM, heightM);
// Lamp aan de bovenrand (down) resp. onderrand (up).
geo.translate(0, up ? heightM / 2 : -heightM / 2, 0); geo.translate(0, up ? heightM / 2 : -heightM / 2, 0);
if (up) { // textuur gespiegeld: lamp onderaan if (up) {
const uv = geo.attributes.uv; const uv = geo.attributes.uv;
for (let i = 0; i < uv.count; i++) uv.setY(i, 1 - uv.getY(i)); for (let i = 0; i < uv.count; i++) uv.setY(i, 1 - uv.getY(i));
} }
@@ -437,8 +373,6 @@ window.LightingModel = (() => {
function buildWash3D(lamps, spots, spotY, walls, groundY, fo) { function buildWash3D(lamps, spots, spotY, walls, groundY, fo) {
const toM = 0.001; const toM = 0.001;
const group = new THREE.Group(); 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) => { const facadeBehind = (q, n) => {
let best = null; let best = null;
walls.forEach(p => { walls.forEach(p => {
@@ -447,21 +381,21 @@ window.LightingModel = (() => {
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY); const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
const dx = q.x - pl.cx, dz = q.z - pl.cz; const dx = q.x - pl.cx, dz = q.z - pl.cz;
const lx = dx * c - dz * sn, lz = dx * sn + dz * c; const lx = dx * c - dz * sn, lz = dx * sn + dz * c;
const face = pl.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone); const face = pl.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
const d = lz - face; const d = lz - face;
if (Math.abs(lx) > pl.width / 2 + 50 || d < -5 || d > 1500) return; if (Math.abs(lx) > pl.width / 2 + 50 || d < -5 || d > 1500) return;
if (!best || d < best.d) best = { d, piece: p, lx, face }; if (!best || d < best.d) best = { d, piece: p, lx, face };
}); });
return best; return best;
}; };
const place = (mesh, pl, lx, lz, yMM) => { const place = (mesh, pl, lx, lz, yMM, ref) => {
mesh.userData.ref = ref;
mesh.userData.wash = true;
const w = UnitModel.localToWorld(pl, lx, lz); const w = UnitModel.localToWorld(pl, lx, lz);
mesh.position.set(w.x * toM, yMM * toM, w.z * toM); mesh.position.set(w.x * toM, yMM * toM, w.z * toM);
mesh.rotation.y = pl.rotY; mesh.rotation.y = pl.rotY;
group.add(mesh); group.add(mesh);
}; };
// Boeideel-spots: van de spot omlaag tot het maaiveld (op een hoek,
// diagonale normaal, geen vlek).
(spots || []).forEach(sp => { (spots || []).forEach(sp => {
const n = sp.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1; 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 }; const nn = { x: n.x / len, z: n.z / len };
@@ -470,9 +404,8 @@ window.LightingModel = (() => {
const hit = facadeBehind(sp, nn); const hit = facadeBehind(sp, nn);
if (!hit || top - groundY < 300) return; if (!hit || top - groundY < 300) return;
const H = (top - groundY) * toM; 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); place(washMesh(2 * (0.1 + H * WASH.spread), H, WASH.spot * fo), hit.piece.place, hit.lx, hit.face + 6, top, sp);
}); });
// Gevellampen (op de gevel): bundel omlaag en/of omhoog.
(lamps || []).forEach(l => { (lamps || []).forEach(l => {
const n = { x: l.normal.x, z: l.normal.z }; 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); const hit = facadeBehind({ x: l.x + n.x * 20, z: l.z + n.z * 20 }, n);
@@ -480,17 +413,16 @@ window.LightingModel = (() => {
const pl = hit.piece.place; const pl = hit.piece.place;
if (l.direction !== 'up') { if (l.direction !== 'up') {
const H = Math.min(WASH.lampReach, l.y - 90 - groundY) * toM; 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 (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo), pl, hit.lx, hit.face + 6, l.y - 90, l);
} }
if (l.direction !== 'down') { 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 wallTop = pl.floorTop + WallModel.topLine(hit.piece.config).maxY + (hit.piece.skinAbove || 0);
const H = Math.min(WASH.lampReach, wallTop - l.y - 90) * toM; 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); 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, l);
} }
}); });
return group; return group;
} }
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D }; return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D };
})(); })();
+211 -254
View File
@@ -4,58 +4,26 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================ // Een dakvlak is een vloer-state, 180° gespiegeld: OSB-plaat onder (plafond), balken erboven.
// roof/roof-model.js — RoofModel: ENIGE bron van de dak-logica. Geen DOM.
//
// Een dakvlak is opgebouwd als een vloer (FloorModel: balken, isolatie,
// doorvoeren, OSB-plaat), maar 180° gespiegeld: de OSB-plaat zit aan de
// onderzijde (plafond), de balken erboven, met dakleer als buitenlaag
// bovenop. Het "dek" van een dak is dus gewoon een vloer-state.
//
// Dakvorm volgt de wanden (computeShape): het paar tegenoverliggende wanden
// met een schuine bovenrand of tussenpunten (nok, knikken) zijn de kopgevels,
// 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
// dakvlak omgerekend naar een schuin vlak met de balken als sporen (van
// laag naar hoog); doorvoeren gaan mee naar het vlak waarin ze vallen.
//
// Dakrand (over de hele woning, doorlopend over naden tussen units): de
// omtrek van alle daken samen, opgeknipt per ribbe (en op elke unit-hoek).
// Per ribbe bepaalt de unit waar die ribbe bij hoort of het een boeideel of
// een daktrim is (boeidelen aan/uit per unit). De ribben sluiten in verstek
// op elkaar aan, dus op een naad tussen units is niets te zien.
// - boeidelen: een doos van 200..400 mm overstek (gemeten vanaf de
// dakrand = standaard de buitenkant gevel), standaard 300 mm hoog.
// Spots in de onderplaat staan op 40% van de overstek (spotLine); welke
// spots er komen bepaalt LightingModel.
// - zonder boeidelen: een daktrim van 80 mm hoog (onder het dakleer).
// De bovenkant van boeideel en daktrim ligt EDGE_ABOVE_MM boven het dakleer
// (opstand rondom), dus het dakleer ligt altijd lager dan de rand.
//
// Plain script, geladen na floor-model.js en core/geometry.js.
// ============================================================
window.RoofModel = (() => { window.RoofModel = (() => {
const DAKLEER_MM = 2; // visuele dikte van het dakleer const DAKLEER_MM = 2; // visuele dikte
const BESCHOT_MM = 18; // dakbeschot (OSB) bovenop de sporen, onder de bedekking const BESCHOT_MM = 18;
const DENSITY_DAKLEER_KGM2 = 1.4; // EPDM ~1,4 kg/m2 const DENSITY_DAKLEER_KGM2 = 1.4;
const TRIM_THICK_MM = 25; // daktrim-profiel, uitstekend buiten de dakrand const TRIM_THICK_MM = 25;
const EDGE_ABOVE_MM = 80; // bovenkant boeideel/daktrim ligt zoveel boven het dakleer const EDGE_ABOVE_MM = 80;
const SPOT_RATIO = 0.4; // spots op 40% van de overstek, gemeten vanaf de gevel const SPOT_RATIO = 0.4;
const OVERHANG_MIN = 200, OVERHANG_MAX = 400; const OVERHANG_MIN = 200, OVERHANG_MAX = 400;
// Woning-brede maten van de dakrand (aan/uit per unit: roof.fascia).
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
overhang: 300, // mm, 200..400 overhang: 300,
fasciaHeight: 300, // mm fasciaHeight: 300,
spotMin: 800, // mm h.o.h. boeideel-spots spotMin: 800,
spotMax: 1200, spotMax: 1200,
trimHeight: 80, // mm trimHeight: 80,
fasciaColor: 'antraciet' // FASCIA_COLORS fasciaColor: 'antraciet'
}; };
// Kleur van het boeideel (egaal).
const FASCIA_COLORS = { const FASCIA_COLORS = {
wit: { label: 'Wit', hex: 0xf2f2f0 }, wit: { label: 'Wit', hex: 0xf2f2f0 },
lichtgrijs: { label: 'Lichtgrijs', hex: 0xbfc3c6 }, lichtgrijs: { label: 'Lichtgrijs', hex: 0xbfc3c6 },
@@ -64,19 +32,10 @@ window.RoofModel = (() => {
zwart: { label: 'Zwart', hex: 0x151617 } zwart: { label: 'Zwart', hex: 0x151617 }
}; };
// Dakbedekking per unit (roof.covering). Riet e.d. alleen bij een hellend // Riet e.d. alleen bij een hellend dak; plat valt terug op dakleer.
// dak (lessenaar/zadel); plat valt terug op dakleer. thickMM telt mee in de // Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer hier toevoegen).
// dakdikte (vThick/topAt), dus boeideel/daktrim en een opbouw ernaast
// schuiven vanzelf mee omhoog. Texturen: assets/textures/dak/, naadloos;
// de stengels lopen langs de helling.
// Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer
// hier toevoegen); zelfde opbouw (180 mm, 3,8 kg/m2).
// colors: gemiddelde kleur per textuur (vlakke weergave, PDF-aanzichten).
const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } }; const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } };
// Felsplaten (staande naad, zink/staal op scheidingslaag): 25 mm hoge
// felsnaden elke seamMM langs de helling; alleen hellend.
const FELS = { thickMM: 25, kgM2: 6.5, seamMM: 500, colors: { zink: ['Felsplaat zink', '#8e9497'], antraciet: ['Felsplaat antraciet', '#3d4146'], zwart: ['Felsplaat zwart', '#1f2022'] } }; const FELS = { thickMM: 25, kgM2: 6.5, seamMM: 500, colors: { zink: ['Felsplaat zink', '#8e9497'], antraciet: ['Felsplaat antraciet', '#3d4146'], zwart: ['Felsplaat zwart', '#1f2022'] } };
// kind: soort bedekking (dakleer | riet | fels) voor specificaties en kosten.
const COVERINGS = Object.assign({ const COVERINGS = Object.assign({
dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 } dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 }
}, ...RIET.nrs.map(nr => ({ }, ...RIET.nrs.map(nr => ({
@@ -85,7 +44,6 @@ window.RoofModel = (() => {
['fels_' + k]: { label, kind: 'fels', color, thickMM: FELS.thickMM, kgM2: FELS.kgM2, seamMM: FELS.seamMM, sloped: true } ['fels_' + k]: { label, kind: 'fels', color, thickMM: FELS.thickMM, kgM2: FELS.kgM2, seamMM: FELS.seamMM, sloped: true }
}))); })));
const coverKind = (key) => (COVERINGS[key] || COVERINGS.dakleer).kind; const coverKind = (key) => (COVERINGS[key] || COVERINGS.dakleer).kind;
// Hoeveelheden per soort bedekking (zelfde sleutels in elke specs).
function coverSpecs(covering, areaM2) { function coverSpecs(covering, areaM2) {
const c = COVERINGS[covering] || COVERINGS.dakleer, kg = areaM2 * c.kgM2, k = c.kind; const c = COVERINGS[covering] || COVERINGS.dakleer, kg = areaM2 * c.kgM2, k = c.kind;
return { return {
@@ -95,12 +53,10 @@ window.RoofModel = (() => {
coverKg: kg coverKg: kg
}; };
} }
// Voorvertoning (assets/textures/dak/thumb/<px>/<naam>.png) of null.
function coveringThumbUrl(key, px) { function coveringThumbUrl(key, px) {
const c = COVERINGS[key]; const c = COVERINGS[key];
return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null; return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null;
} }
// Werkelijke bedekking voor een dakvorm (riet niet op een plat dak).
function coveringFor(key, shapeType) { function coveringFor(key, shapeType) {
const c = COVERINGS[key]; const c = COVERINGS[key];
return c && !(c.sloped && shapeType === 'plat') ? key : 'dakleer'; return c && !(c.sloped && shapeType === 'plat') ? key : 'dakleer';
@@ -132,8 +88,6 @@ window.RoofModel = (() => {
function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
// Dak-state per unit: volgt standaard de vloermaat (followFloor), maar mag
// groter/kleiner en verschoven worden (override).
function createDefaultRoof(floorState) { function createDefaultRoof(floorState) {
const deck = clone(floorState); const deck = clone(floorState);
deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 }; deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
@@ -141,7 +95,6 @@ window.RoofModel = (() => {
return { followFloor: true, offsetX: 0, offsetZ: 0, fascia: true, covering: 'dakleer', deck }; return { followFloor: true, offsetX: 0, offsetZ: 0, fascia: true, covering: 'dakleer', deck };
} }
// Dikte dek (plafondplaat + sporen + dakbeschot) + bedekking (mm, haaks op het dakvlak).
function thickness(calcData, covering) { function thickness(calcData, covering) {
return calcData.joistH + calcData.underlayThick + BESCHOT_MM + (COVERINGS[covering] || COVERINGS.dakleer).thickMM; return calcData.joistH + calcData.underlayThick + BESCHOT_MM + (COVERINGS[covering] || COVERINGS.dakleer).thickMM;
} }
@@ -151,23 +104,20 @@ window.RoofModel = (() => {
dakleerMaterial: new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9, side: THREE.DoubleSide }), dakleerMaterial: new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9, side: THREE.DoubleSide }),
fasciaMaterial: new THREE.MeshStandardMaterial({ color: 0x3a3f44, roughness: 0.6, side: THREE.DoubleSide }), fasciaMaterial: new THREE.MeshStandardMaterial({ color: 0x3a3f44, roughness: 0.6, side: THREE.DoubleSide }),
trimMaterial: new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.5, side: THREE.DoubleSide }), trimMaterial: new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.5, side: THREE.DoubleSide }),
steelMaterial: new THREE.MeshStandardMaterial({ color: 0x5f666c, roughness: 0.5, metalness: 0.6 }),
ceilingMaterial: new THREE.MeshStandardMaterial({ color: 0xf4f3ef, roughness: 0.92, side: THREE.DoubleSide }),
spotOnMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe9a8, emissiveIntensity: 0.9 }) spotOnMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe9a8, emissiveIntensity: 0.9 })
}); });
} }
// 3D van één dak (meters, gecentreerd op het dakmidden, Y = 0 onderkant // Vloer-3D gespiegeld met scale.y = -1 (three.js corrigeert de vlakoriëntatie zelf).
// plafondplaat). Het vloer-3D (FloorModel.build3D) wordt gespiegeld
// (scale.y = -1; three.js corrigeert de vlakoriëntatie zelf), daarna
// komen het dakbeschot (OSB, fase beplating) en de bedekking (fase gevel)
// er bovenop. Felsplaten: dunne plaat met staande naden langs de helling.
function build3D(calcData, resolvedOpenings, opts) { function build3D(calcData, resolvedOpenings, opts) {
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 deckMats = opts.ceilingMaterial ? Object.assign({}, m, { osbMaterial: opts.ceilingMaterial }) : m;
// Plafondplaat én dakbeschot zichtbaar (fase beplating): 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: deckMats, osbVisible: opts.osbVisible !== false,
outerJoistsOnly: opts.osbVisible !== false outerJoistsOnly: opts.osbVisible !== false
}); });
const deckH = (calcData.joistH + calcData.underlayThick) * toM; const deckH = (calcData.joistH + calcData.underlayThick) * toM;
@@ -175,8 +125,6 @@ window.RoofModel = (() => {
deck.position.y = deckH; deck.position.y = deckH;
group.add(deck); group.add(deck);
// Dakleer: vlak over het hele dak, met dezelfde ronde uitsparingen als
// de doorvoeren (zelfde opbouw als de OSB-plaat in FloorModel).
const wM = calcData.width * toM, dM = calcData.depth * toM; const wM = calcData.width * toM, dM = calcData.depth * toM;
const shape = new THREE.Shape(); const shape = new THREE.Shape();
shape.moveTo(-wM / 2, -dM / 2); shape.moveTo(-wM / 2, -dM / 2);
@@ -189,8 +137,6 @@ window.RoofModel = (() => {
hole.absarc(o.x * toM, o.y * toM - dM / 2, o.r * toM, 0, Math.PI * 2, false); hole.absarc(o.x * toM, o.y * toM - dM / 2, o.r * toM, 0, Math.PI * 2, false);
shape.holes.push(hole); shape.holes.push(hole);
}); });
// Andere bedekking (opts.covering, bijv. riet): een laag van thickMM met
// de textuur (vorm-coördinaten in m = UV; langs de helling = v).
const cov = COVERINGS[opts.covering] || COVERINGS.dakleer; const cov = COVERINGS[opts.covering] || COVERINGS.dakleer;
const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial; const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial;
const bT = BESCHOT_MM * toM; const bT = BESCHOT_MM * toM;
@@ -202,7 +148,6 @@ window.RoofModel = (() => {
group.add(beschot); group.add(beschot);
} }
if (opts.dakleerVisible !== false) { if (opts.dakleerVisible !== false) {
// Felsplaat: dunne plaat + naden; anders de volle laag (dakleer, riet).
const slabT = (cov.kind === 'fels' ? 1.5 : cov.thickMM) * toM; const slabT = (cov.kind === 'fels' ? 1.5 : cov.thickMM) * toM;
const leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: slabT, bevelEnabled: false }), covMat); const leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: slabT, bevelEnabled: false }), covMat);
leer.rotation.x = Math.PI / 2; leer.rotation.x = Math.PI / 2;
@@ -223,11 +168,9 @@ window.RoofModel = (() => {
return group; return group;
} }
// Specificaties: die van het dek (FloorModel; osb = plafondplaat) + het
// dakbeschot (beschot*) + de bedekking (coverSpecs: dakleer/riet/fels).
function computeSpecs(calcData, resolvedOpenings, covering) { function computeSpecs(calcData, resolvedOpenings, covering) {
const deck = FloorModel.computeSpecs(calcData, resolvedOpenings); const deck = FloorModel.computeSpecs(calcData, resolvedOpenings);
const areaM2 = deck.osbAreaM2; // zelfde vlak minus doorvoeren const areaM2 = deck.osbAreaM2;
const cs = coverSpecs(covering, areaM2); const cs = coverSpecs(covering, areaM2);
const bVol = areaM2 * BESCHOT_MM * 1e-3, bKg = bVol * DENSITY_OSB_KGM3; const bVol = areaM2 * BESCHOT_MM * 1e-3, bKg = bVol * DENSITY_OSB_KGM3;
return Object.assign({}, deck, cs, { return Object.assign({}, deck, cs, {
@@ -236,47 +179,33 @@ window.RoofModel = (() => {
}); });
} }
// ------------------------------------------------------------ // De daklijn hoort bij de wand zonder naden (wl.ref): is de wand door een naad langer of korter,
// Dakvorm per unit (volgt de wanden) // dan blijft het profiel gelijk.
// ------------------------------------------------------------
// Daklijn van één wand als profiel langs zijn as (unit-lokaal mm):
// [{ p, h }] gesorteerd op p, h vanaf het loopvlak. Zelfde definitie als
// UnitModel.wallPieces: links/rechts op de uiteinden van de wand (van
// buitenaf gezien), het middenpunt (nok) midden op de wand.
function wallProfile(unitState, layout, side) { function wallProfile(unitState, layout, side) {
const w = unitState.walls[side].wall; const w = unitState.walls[side].wall;
const wl = layout.walls.find(x => x.side === side); const wl = layout.walls.find(x => x.side === side);
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 ref = wl.ref || { w: wl.width, u0: 0 };
// Tussenpunten (nok, knikken van mansarde/wolfsdak), x vanaf links. const at = (x) => c - s.dir * wl.width / 2 + s.dir * (x - ref.u0);
WallModel.wallTopPoints(w, wl.width, w.heightLeft, w.heightRight) const pts = [{ p: at(0), h: w.heightLeft }, { p: at(ref.w), h: w.heightRight }];
.forEach(q => pts.push({ p: c - s.dir * wl.width / 2 + s.dir * q.x, h: q.h })); WallModel.wallTopPoints(w, ref.w, w.heightLeft, w.heightRight)
.forEach(q => pts.push({ p: at(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.
// Geeft { type: 'plat'|'lessenaar'|'zadel'|'mansarde'|'wolf', axis: 'x'|'z',
// profile, hip? }: de hoogte van de dakonderkant (vanaf het loopvlak)
// hangt alleen af van de positie langs axis (profileHeight), behalve bij
// een wolfsdak (hip). Kopgevels: de eerste wand met een schuine daklijn
// (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)) continue; if (!profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) continue;
return shapeFromProfile(axis, profile);
}
const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws)));
return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] };
}
function shapeFromProfile(axis, profile) {
if (profile.length === 4 && Math.abs(profile[1].h - profile[2].h) < 0.5) { 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); 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 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) }; const ridge = { p: pr, h: A.h + sA * (pr - A.p) };
if (sA > 0 && sB < 0 && pr > A.p && pr < B.p) { if (sA > 0 && sB < 0 && pr > A.p && pr < B.p) {
@@ -286,12 +215,31 @@ window.RoofModel = (() => {
const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel'; const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel';
return { type, axis, profile }; return { type, axis, profile };
} }
const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws)));
return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] }; // Helling wolfseinden: die van het dak, maar minstens zo steil dat ze de nok halen vóór het midden.
function hipGeom(shape, q0, q1) {
const [, A, R, B] = shape.profile, hipH = shape.hip.h, top = R.h, qM = (q0 + q1) / 2;
const sA = (top - A.h) / (R.p - A.p), sB = (top - B.h) / (B.p - R.p);
const tH = Math.max((sA + sB) / 2, (top - hipH) / Math.max(1, qM - q0));
return { pA: A.p, pR: R.p, pB: B.p, hipH, top, sA, sB, tH, inset: (top - hipH) / tH, q0, q1, qM };
} }
// Hoogte van het profiel op positie p; buiten de wand loopt de laatste // De daklijn is bol, dus het profiel is het minimum van zijn (doorgetrokken) stukken.
// helling door (overstek, boeideel). function shapeLines(shape) {
const pr = shape.profile;
if (pr.length === 1) return [{ a: pr[0].h, bp: 0, bq: 0 }];
const out = pr.slice(0, -1).map((a, i) => {
const b = pr[i + 1], s = (b.h - a.h) / (b.p - a.p);
return { a: a.h - s * a.p, bp: s, bq: 0 };
});
if (shape.type === 'wolf' && shape.hip && shape.hip.q0 !== undefined) {
const g = hipGeom(shape, shape.hip.q0, shape.hip.q1);
out.push({ a: g.hipH - g.q0 * g.tH, bp: 0, bq: g.tH }, { a: g.hipH + g.q1 * g.tH, bp: 0, bq: -g.tH });
}
return out;
}
// Buiten de wand loopt de laatste helling door (overstek, boeideel).
function profileHeight(profile, p) { function profileHeight(profile, p) {
if (profile.length === 1) return profile[0].h; if (profile.length === 1) return profile[0].h;
let i = 0; let i = 0;
@@ -300,7 +248,98 @@ window.RoofModel = (() => {
return a.h + (p - a.p) / (b.p - a.p) * (b.h - a.h); return a.h + (p - a.p) / (b.p - a.p) * (b.h - a.h);
} }
// Tel alle getallen van een lijst specificaties op. // Stalen nokbalk, per unit één (modules worden los gebouwd), opgelegd op de kopgevels.
// Kleinste IPE die voldoet aan sterkte (1,2·G + 1,5·S, σ ≤ fy) en doorbuiging (G + S, ≤ L/250).
// [naam, h, b, tw, tf (mm), kg/m, Iy (cm4), Wy (cm3)]
const IPE = [
['IPE 100', 100, 55, 4.1, 5.7, 8.1, 171, 34.2], ['IPE 120', 120, 64, 4.4, 6.3, 10.4, 318, 53.0],
['IPE 140', 140, 73, 4.7, 6.9, 12.9, 541, 77.3], ['IPE 160', 160, 82, 5.0, 7.4, 15.8, 869, 109],
['IPE 180', 180, 91, 5.3, 8.0, 18.8, 1317, 146], ['IPE 200', 200, 100, 5.6, 8.5, 22.4, 1943, 194],
['IPE 220', 220, 110, 5.9, 9.2, 26.2, 2772, 252], ['IPE 240', 240, 120, 6.2, 9.8, 30.7, 3892, 324],
['IPE 270', 270, 135, 6.6, 10.2, 36.1, 5790, 429], ['IPE 300', 300, 150, 7.1, 10.7, 42.2, 8356, 557],
['IPE 330', 330, 160, 7.5, 11.5, 49.1, 11770, 713], ['IPE 360', 360, 170, 8.0, 12.7, 57.1, 16270, 904],
['IPE 400', 400, 180, 8.6, 13.5, 66.3, 23130, 1160], ['IPE 450', 450, 190, 9.4, 14.6, 77.6, 33740, 1500],
['IPE 500', 500, 200, 10.2, 16.0, 90.7, 48200, 1930]
].map(([name, h, b, tw, tf, kgM, I, W]) => ({ name, h, b, tw, tf, kgM, I, W }));
const STEEL_RIDGE = {
fy: 235, E: 210000, // S235, N/mm2
snowKNm2: 0.56, // sneeuw op het dak (0,8 × 0,7 kN/m2)
finishKNm2: 0.3, // afwerking/installaties onder het dak
gammaG: 1.2, gammaQ: 1.5, deflection: 250
};
const ridgeTypes = ['zadel', 'mansarde', 'wolf'];
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2) {
if (roofState.steelRidge === false || !ridgeTypes.includes(shape.type)) return null;
const pr = shape.profile, inner = pr.slice(1, -1);
if (!inner.length) return null;
const R = inner.reduce((a, q) => q.h > a.h ? q : a);
const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1;
if (R.p < a0 - 0.5 || R.p >= a1 - 0.5) return null;
const uq0 = alongX ? rect.z0 : rect.x0, uq1 = alongX ? rect.z1 : rect.x1;
let q0 = uq0, q1 = uq1;
if (shape.type === 'wolf' && shape.hip) {
const hq = shape.hip.q0 !== undefined ? [shape.hip.q0, shape.hip.q1] : [uq0, uq1];
const g = hipGeom(shape, hq[0], hq[1]);
q0 = Math.max(uq0, Math.min(g.qM, g.q0 + g.inset));
q1 = Math.min(uq1, Math.max(g.qM, g.q1 - g.inset));
}
const L = q1 - q0;
if (L < 100) return null;
// q in N/mm = kN/m.
const trib = (a1 - a0) / 2 / 1000;
const gRoof = (kgM2 * 9.81e-3 + STEEL_RIDGE.finishKNm2) * trib, sQ = STEEL_RIDGE.snowKNm2 * trib;
const check = (s) => {
const g = gRoof + s.kgM * 9.81e-3;
const M = (STEEL_RIDGE.gammaG * g + STEEL_RIDGE.gammaQ * sQ) * L * L / 8;
const util = M / (s.W * 1e3 * STEEL_RIDGE.fy);
const defl = 5 * (g + sQ) * Math.pow(L, 4) / (384 * STEEL_RIDGE.E * s.I * 1e4);
return { util, defl, deflMax: L / STEEL_RIDGE.deflection, ok: util <= 1 && defl <= L / STEEL_RIDGE.deflection };
};
let sec = IPE.find(s => check(s).ok);
const ok = !!sec;
if (!sec) sec = IPE[IPE.length - 1];
const c = check(sec);
const segs = pr.slice(0, -1).map((a, i) => {
const b = pr[i + 1], sl = (b.h - a.h) / (b.p - a.p);
return { at: (p) => a.h + (p - a.p) * sl, f: Math.sqrt(1 + sl * sl) };
});
const under = cd.underlayThick, jH = cd.joistH;
const zone = (p, t) => Math.min(...segs.map(sg => sg.at(p) + t * sg.f));
const yTop = Math.min(zone(R.p - sec.b / 2, under + jH), zone(R.p + sec.b / 2, under + jH));
const yBottom = yTop - sec.h;
const below = Math.max(0, Math.round(zone(R.p, 0) - yBottom));
const fillW = (sec.b - sec.tw) / 2, fillH = sec.h - 2 * sec.tf;
const fillM3 = 2 * fillW * fillH * L / 1e9;
return {
profile: sec, ok, util: c.util, defl: c.defl, deflMax: c.deflMax, loadKNm: gRoof + sQ + sec.kgM * 9.81e-3,
p: R.p, q0, q1, length: Math.round(L), yTop, yBottom, below,
steelKg: sec.kgM * L / 1000, fillW, fillH, fillM3, fillKg: fillM3 * DENSITY_WOOD_KGM3
};
}
function buildRidgeBeam3D(rb, xc, zc, alongX, m) {
const toM = 0.001, s = rb.profile, L = rb.length * toM;
const b = s.b * toM / 2, w = s.tw * toM / 2, h = s.h * toM, t = s.tf * toM;
const shp = new THREE.Shape();
[[-b, 0], [b, 0], [b, t], [w, t], [w, h - t], [b, h - t], [b, h], [-b, h], [-b, h - t], [-w, h - t], [-w, t], [-b, t]]
.forEach(([x, y], i) => i ? shp.lineTo(x, y) : shp.moveTo(x, y));
shp.closePath();
const g = new THREE.Group();
const beam = new THREE.Mesh(new THREE.ExtrudeGeometry(shp, { steps: 1, depth: L, bevelEnabled: false }), m.steelMaterial);
beam.castShadow = beam.receiveShadow = true;
g.add(beam);
const fw = rb.fillW * toM, fh = rb.fillH * toM;
[-1, 1].forEach(sd => {
const fill = new THREE.Mesh(new THREE.BoxGeometry(fw, fh, L), m.woodMaterial);
fill.position.set(sd * (w + fw / 2), t + fh / 2, L / 2);
g.add(fill);
});
// Lokaal: x dwars, y omhoog, z langs de nok (vanaf q0).
if (alongX) g.position.set((rb.p - xc) * toM, rb.yBottom * toM, (rb.q0 - zc) * toM);
else { g.rotation.y = Math.PI / 2; g.position.set((rb.q0 - xc) * toM, rb.yBottom * toM, (rb.p - zc) * toM); }
return g;
}
function sumSpecs(list) { function sumSpecs(list) {
const out = Object.assign({}, list[0]); const out = Object.assign({}, list[0]);
list.slice(1).forEach(sp => Object.keys(sp).forEach(k => { list.slice(1).forEach(sp => Object.keys(sp).forEach(k => {
@@ -309,14 +348,6 @@ window.RoofModel = (() => {
return out; return out;
} }
// Het dak van één unit: de dakrechthoek (unit-lokaal mm { x0, x1, z0, z1 },
// = maat van roofState.deck) geknipt op de nok in dakvlakken. Per vlak een
// eigen dek (vloer-state) met breedte = maat dwars op de helling en diepte
// = lengte langs de helling, zodat de balken als sporen van laag naar hoog
// lopen. rims: zie FloorModel.computeFrame (alleen bij helling langs Z).
// Geeft { shape, planes, specs, undersideAt(x, z), topAt(x, z), ridges, maxTop }
// (unit-lokaal, hoogtes vanaf het loopvlak). Vlak-posities (cx, cz) zijn
// t.o.v. het midden van de dakrechthoek.
function computeUnitRoof(roofState, shape, rect, rims) { function computeUnitRoof(roofState, shape, rect, rims) {
const alongX = shape.axis === 'x'; const alongX = shape.axis === 'x';
const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0; const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0;
@@ -328,7 +359,6 @@ window.RoofModel = (() => {
const H = (p) => profileHeight(shape.profile, p); const H = (p) => profileHeight(shape.profile, p);
const covering = coveringFor(roofState.covering, shape.type); const covering = coveringFor(roofState.covering, shape.type);
// Doorvoeren: positie in de plattegrond (zoals in de dak-editor) -> vlak.
const holes = flat.resolvedOpenings.filter(o => !o.removed).map(o => { const holes = flat.resolvedOpenings.filter(o => !o.removed).map(o => {
const px = xc + o.x, pz = rect.z0 + o.y; const px = xc + o.x, pz = rect.z0 + o.y;
return { o, px, pz, q: alongX ? px : pz }; return { o, px, pz, q: alongX ? px : pz };
@@ -341,7 +371,7 @@ window.RoofModel = (() => {
const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 }; const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 };
let data; let data;
if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5) { if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5) {
data = flat; // plat dak: het dek zoals het is data = flat;
} else { } else {
const deck = clone(roofState.deck); const deck = clone(roofState.deck);
deck.floor.width = Math.round(alongX ? D : W); deck.floor.width = Math.round(alongX ? D : W);
@@ -359,16 +389,12 @@ window.RoofModel = (() => {
return Object.assign(plane, { return Object.assign(plane, {
data, specs: computeSpecs(data.calcData, data.resolvedOpenings, covering), data, specs: computeSpecs(data.calcData, data.resolvedOpenings, covering),
cx: alongX ? mid - xc : 0, cz: alongX ? 0 : mid - zc, y: (h0 + h1) / 2, cx: alongX ? mid - xc : 0, cz: alongX ? 0 : mid - zc, y: (h0 + h1) / 2,
// Verticale dikte (dek + bedekking) van dit vlak.
vThick: thickness(data.calcData, covering) / cos vThick: thickness(data.calcData, covering) / cos
}); });
}); });
// Knik tussen twee vlakken met een verschillende helling (mansarde): // Mansardeknik: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik,
// de bovenkanten snijden elkaar vóór (het steilere vlak is verticaal // bovenkant op het snijpunt van de bovenvlakken.
// 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.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; });
planes.slice(0, -1).forEach((pi, i) => { planes.slice(0, -1).forEach((pi, i) => {
const pj = planes[i + 1], ai = Math.tan(pi.theta), aj = Math.tan(pj.theta); const pj = planes[i + 1], ai = Math.tan(pi.theta), aj = Math.tan(pj.theta);
@@ -377,58 +403,47 @@ window.RoofModel = (() => {
pi.miter1 = dp; pj.miter0 = dp; pi.miter1 = dp; pj.miter0 = dp;
}); });
const mainUnder = (x, z) => H(alongX ? x : z); 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 mainTop = (x, z) => {
const q = alongX ? x : z; const q = alongX ? x : z;
return Math.min(...planes.map(pl => pl.h0 + (q - pl.p0) * Math.tan(pl.theta) + pl.vThick)); return Math.min(...planes.map(pl => pl.h0 + (q - pl.p0) * Math.tan(pl.theta) + pl.vThick));
}; };
// 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; let cap = null, undersideAt = mainUnder, topAt = mainTop, outPlanes = planes;
if (shape.type === 'wolf' && shape.hip && kinks.length === 3) { const pA = shape.type === 'wolf' && shape.hip ? shape.profile[1].p : 0, pB = pA && shape.profile[3].p;
cap = computeCap(roofState, shape, rect, alongX, kinks, covering); if (shape.type === 'wolf' && shape.hip && Math.min(pB, a1) - Math.max(pA, a0) > 1) cap = computeCap(roofState, shape, rect, alongX, covering);
outPlanes = planes.filter(pl => !(pl.p0 >= cap.pA - 0.5 && pl.p1 <= cap.pB + 0.5)); if (cap) {
const at = (x, z) => ({ x: alongX ? x : z, z: alongX ? z : x }); // (p, q) outPlanes = planes.filter(pl => !(pl.p0 >= pA - 0.5 && pl.p1 <= pB + 0.5));
const at = (x, z) => ({ x: alongX ? x : z, z: alongX ? z : x });
undersideAt = (x, z) => Math.min(mainUnder(x, z), ...cap.regions.map(rg => rg.under(at(x, z)))); 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)); topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick));
} }
const specs = sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : []));
const ridgeBeam = computeRidgeBeam(roofState, shape, rect, alongX, flat.calcData, specs.totalKg / Math.max(0.1, W * D / 1e6));
Object.assign(specs, {
nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0,
nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0
});
specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg;
return { return {
shape, covering, planes: outPlanes, specs: sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : [])), shape, covering, planes: outPlanes, specs, ridgeBeam,
undersideAt, topAt, cap, rect, alongX, undersideAt, topAt, cap, rect, alongX,
ridges: kinks.map(value => ({ axis: shape.axis, value })), ridges: kinks.map(value => ({ axis: shape.axis, value })),
maxTop: cap ? cap.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 const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel
function computeCap(roofState, shape, rect, alongX, kinks, covering) { function computeCap(roofState, shape, rect, alongX, covering) {
const H = (p) => profileHeight(shape.profile, p); const H = (p) => profileHeight(shape.profile, p);
const [pA, pR, pB] = kinks, hipH = shape.hip.h, top = H(pR); const uq0 = alongX ? rect.z0 : rect.x0, uq1 = alongX ? rect.z1 : rect.x1;
const q0 = alongX ? rect.z0 : rect.x0, q1 = alongX ? rect.z1 : rect.x1, qM = (q0 + q1) / 2; const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1;
const hq = shape.hip.q0 !== undefined ? [shape.hip.q0, shape.hip.q1] : [uq0, uq1];
const { pA, pR, pB, hipH, top, sA, sB, tH, inset, q0, q1, qM } = hipGeom(shape, hq[0], hq[1]);
const fp = roofState.deck.floor; const fp = roofState.deck.floor;
const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22; const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22;
const cov = COVERINGS[covering] || COVERINGS.dakleer; const cov = COVERINGS[covering] || COVERINGS.dakleer;
const sA = (top - H(pA)) / (pR - pA), sB = (top - H(pB)) / (pB - pR); const cp0 = Math.max(pA, a0), cp1 = Math.min(pB, a1), cq0 = Math.max(q0, uq0), cq1 = Math.min(q1, uq1);
// Helling wolfseinden: die van het dak, maar minstens zo steil dat ze de const clipBox = (poly) => [(p) => p.x - cp0, (p) => cp1 - p.x, (p) => p.z - cq0, (p) => cq1 - p.z]
// nokhoogte halen vóór het midden tussen de kopgevels. .reduce((pl, f) => pl.length >= 3 ? clipPoly(pl, f) : pl, poly);
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 plane = (key, slope, f, along) => {
const cos = Math.cos(Math.atan(slope)); const cos = Math.cos(Math.atan(slope));
return { key, f, along, cos, tV: underlay / cos, jV: joistH / cos, bV: BESCHOT_MM / cos, covV: cov.thickMM / cos }; return { key, f, along, cos, tV: underlay / cos, jV: joistH / cos, bV: BESCHOT_MM / cos, covV: cov.thickMM / cos };
@@ -443,12 +458,13 @@ window.RoofModel = (() => {
const regions = fns.map((fi, i) => { const regions = fns.map((fi, i) => {
let poly = band; let poly = band;
fns.forEach((fj, j) => { if (j !== i && poly.length >= 3) poly = clipPoly(poly, (p) => fj.f(p) - fi.f(p)); }); 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; }); 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.bV + fi.covV }); return Object.assign({}, fi, { poly, under: fi.f, vThick: fi.tV + fi.jV + fi.bV + fi.covV });
}).filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100); }).filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100);
const clipped = regions.map(rg => Object.assign({}, rg, { poly: clipBox(rg.poly) }))
.filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100);
if (!clipped.some(rg => rg.along === 'q')) return null;
// Snijden van een lijn (vaste p of vaste q) met een convexe veelhoek.
const cutLine = (poly, fixed, v) => { const cutLine = (poly, fixed, v) => {
const ts = []; const ts = [];
poly.forEach((a, k) => { poly.forEach((a, k) => {
@@ -459,16 +475,11 @@ window.RoofModel = (() => {
}); });
return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null; 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 hw = joistW / 2;
const rectAround = (A, B) => { 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; 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 }]; 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 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 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 d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz;
@@ -488,7 +499,6 @@ window.RoofModel = (() => {
if (poly.length < 3) return null; if (poly.length < 3) return null;
const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz); 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; 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 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) => 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; : (p) => rg.under(p) + rg.tV;
@@ -496,16 +506,12 @@ window.RoofModel = (() => {
const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L; const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L;
if (plan < 50) return null; if (plan < 50) return null;
const bm = { const bm = {
kind, A, B, poly, yAt, jV: rg.jV, kind, A, B, poly, yAt, jV: rg.jV, slope,
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10 length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
}; };
beams.push(bm); beams.push(bm);
return 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 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 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 ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }];
@@ -516,7 +522,6 @@ window.RoofModel = (() => {
const e = c.z < qM ? ends[0] : ends[1], sp = Math.sign(pR - c.x), sq = Math.sign(qM - c.z); 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)]; const clips = [(p) => sp * (pR - p.x)];
if (!hasRidge) clips.push((p) => sq * (qM - p.z)); 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); clips.push((p) => sq * (c.z + sq * joistW - p.z) * -1);
return addBeam('hoekkeper', mOf(c.x), c, e, clips, true); return addBeam('hoekkeper', mOf(c.x), c, e, clips, true);
}).filter(Boolean); }).filter(Boolean);
@@ -524,9 +529,11 @@ window.RoofModel = (() => {
if (hasRidge) { if (hasRidge) {
const mid = { x: pR, z: qM }; const mid = { x: pR, z: qM };
ridgeBeam = addBeam('nokbalk', mOf(pR - 1), ends[0], ends[1], hips.map(h => awayFrom(h, mid)), true); ridgeBeam = addBeam('nokbalk', mOf(pR - 1), ends[0], ends[1], hips.map(h => awayFrom(h, mid)), true);
// Stalen nokbalk (computeRidgeBeam) in plaats van hout: wel om de sporen
// tegen af te zagen, niet als houten balk.
if (ridgeBeam && roofState.steelRidge !== false) beams.splice(beams.indexOf(ridgeBeam), 1);
} }
const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []); 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 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); const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(pB - pA).map(v => pA + v);
regions.forEach(rg => { regions.forEach(rg => {
@@ -541,10 +548,19 @@ window.RoofModel = (() => {
}); });
}); });
// Specificaties (zelfde sleutels als computeSpecs). const ownBeams = beams.map(bm => {
const poly = clipBox(bm.poly);
if (poly.length < 3 || quadArea(poly) < 100) return null;
const dx = bm.B.x - bm.A.x, dz = bm.B.z - bm.A.z, L = Math.hypot(dx, dz);
const ts = poly.map(p => ((p.x - bm.A.x) * dx + (p.z - bm.A.z) * dz) / L);
const plan = Math.max(...ts) - Math.min(...ts), slope = bm.slope;
if (plan < 50) return null;
return Object.assign({}, bm, { poly, length: Math.round(plan * Math.sqrt(1 + slope * slope)) });
}).filter(Boolean);
const W = joistW * 1e-3, Hh = joistH * 1e-3; const W = joistW * 1e-3, Hh = joistH * 1e-3;
const woodM3 = beams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0); const woodM3 = ownBeams.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 areaM2 = clipped.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6;
const cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3; const cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3;
const specs = Object.assign({ const specs = Object.assign({
joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3, joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3,
@@ -553,25 +569,20 @@ window.RoofModel = (() => {
voidM3: Math.max(0, areaM2 * Hh - woodM3), voidM3: Math.max(0, areaM2 * Hh - woodM3),
totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg
}, cs); }, cs);
const maxTop = top + Math.max(...regions.map(rg => rg.vThick)); const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick))));
return { pA, pR, pB, q0, q1, hipH, joistW, joistH, regions, beams, specs, maxTop }; return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop };
} }
// 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek, // Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ.
// Y = 0 op het loopvlak): per dakvlak build3D, zo vervormd dat de // Zo blijven dikte haaks op de helling en lengte gelijk, en staan de kopse kanten loodrecht (zoals de zaagsnede aan sporen).
// onderkant van het plafond precies op de daklijn van de wanden ligt.
// Geen kanteling maar een afschuiving (lokaal +Z = oplopende as):
// plan = z·cos θ, Y = y/cos θ + z·sin θ
// De onderkant ligt dan op de helling, de dikte haaks op de helling blijft
// gelijk (verticaal = dikte/cos θ, zoals vThick), de lengte langs de
// helling ook (balklengtes), en de kopse kanten staan loodrecht (zoals een
// loodrechte zaagsnede aan sporen). Bij kantelen zouden de kopse kanten
// haaks op de helling staan en ligt de bovenkant aan de hoge kant
// dikte·sin θ terug van de dakrand.
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)); if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts));
if (unitRoof.ridgeBeam) {
const r = unitRoof.rect;
group.add(buildRidgeBeam3D(unitRoof.ridgeBeam, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, unitRoof.alongX, opts.materials));
}
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);
@@ -593,10 +604,6 @@ window.RoofModel = (() => {
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) { function miterDeck(deck, pl, cos) {
const toM = 0.001, cd = pl.data.calcData; const toM = 0.001, cd = pl.data.calcData;
const L = cd.depth * toM, T = pl.vThick * cos * toM; const L = cd.depth * toM, T = pl.vThick * cos * toM;
@@ -624,11 +631,6 @@ window.RoofModel = (() => {
}); });
} }
// 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 capRegions(unitRoof) { return unitRoof.cap ? unitRoof.cap.regions : []; }
function buildCap3D(unitRoof, opts) { function buildCap3D(unitRoof, opts) {
const toM = 0.001, m = opts.materials, c = unitRoof.cap, r = unitRoof.rect; const toM = 0.001, m = opts.materials, c = unitRoof.cap, r = unitRoof.rect;
@@ -636,18 +638,15 @@ window.RoofModel = (() => {
const group = new THREE.Group(); const group = new THREE.Group();
const osbOn = opts.osbVisible !== false, covOn = opts.dakleerVisible !== false; const osbOn = opts.osbVisible !== false, covOn = opts.dakleerVisible !== false;
const covMat = coveringMaterial(unitRoof.covering) || m.dakleerMaterial; const covMat = coveringMaterial(unitRoof.covering) || m.dakleerMaterial;
// (p, q) -> wereld (m, t.o.v. het dakmidden).
const P = (pt, y) => { const P = (pt, y) => {
const x = unitRoof.alongX ? pt.x : pt.z, z = unitRoof.alongX ? pt.z : pt.x; const x = unitRoof.alongX ? pt.x : pt.z, z = unitRoof.alongX ? pt.z : pt.x;
return [(x - xc) * toM, y * toM, (z - zc) * toM]; 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) { function slab(poly, bottom, h, topMat, sideMat, uvOf) {
const n = poly.length, pos = [], uv = []; 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 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 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); }; 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; 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]); 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; const topCount = pos.length / 3;
@@ -669,26 +668,18 @@ window.RoofModel = (() => {
group.add(mesh); group.add(mesh);
} }
c.regions.forEach(rg => { c.regions.forEach(rg => {
if (osbOn) slab(rg.poly, rg.under, rg.tV, m.osbMaterial, m.osbMaterial); if (osbOn) slab(rg.poly, rg.under, rg.tV, opts.ceilingMaterial || m.osbMaterial, m.osbMaterial);
if (osbOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV, rg.bV, m.osbMaterial, m.osbMaterial); // dakbeschot if (osbOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV, rg.bV, m.osbMaterial, m.osbMaterial);
if (covOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV + rg.bV, rg.covV, covMat, covMat, if (covOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV + rg.bV, rg.covV, covMat, covMat,
(pt) => rg.along === 'p' ? [pt.z * toM, pt.x * toM / rg.cos] : [pt.x * toM, pt.z * toM / rg.cos]); (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 => { c.beams.forEach(bm => {
if (osbOn && bm.kind !== 'kopbalk') return; // ingesloten tussen plafondplaat en dakbeschot if (osbOn && bm.kind !== 'kopbalk') return;
slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial); slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial);
}); });
return group; return group;
} }
// ------------------------------------------------------------
// Dakrand over de hele woning
// ------------------------------------------------------------
// Splits de ribben van de omtrek op elke hoek van een dak-rechthoek die op
// die ribbe ligt ("hoek van een unit"), zodat spots daar ook beginnen/eindigen.
function splitAtCorners(pts, rects) { function splitAtCorners(pts, rects) {
const corners = []; const corners = [];
rects.forEach(r => corners.push({ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 })); rects.forEach(r => corners.push({ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 }));
@@ -717,28 +708,14 @@ window.RoofModel = (() => {
return Math.abs(pts.reduce((s, p, k) => { const q = pts[(k + 1) % pts.length]; return s + p.x * q.z - q.x * p.z; }, 0) / 2); return Math.abs(pts.reduce((s, p, k) => { const q = pts[(k + 1) % pts.length]; return s + p.x * q.z - q.x * p.z; }, 0) / 2);
} }
// roofs: [{ unitId, rect: { x0, x1, z0, z1 }, fascia: bool, topAt(x, z), // facadeDepth negatief = gevel van een hoger deel die naar dit dak kijkt: de rand stopt tegen die gevel.
// ridges: [{ axis, value }] }] (mm, woning-assen); topAt = bovenkant
// dakleer (absoluut, loopt buiten het dak door met de helling). Geeft per
// ribbe (segments) de geometrie terug; edge.spots wordt door LightingModel
// gevuld (met y = onderkant boeideel ter plaatse).
// facadeDepth(a, b, normal) (optioneel, mm): dikte van de gevelafwerking
// (doek + latwerk, steen) buiten het wandvlak langs die ribbe. De gevel
// loopt achter het boeideel door tot de bovenkant van het dak; het
// boeideel / de daktrim valt er overheen en sluit aan de dakkant aan op
// het dak (binnenrand = dakrand, van boven dicht). De spots komen in de
// onderplaat vóór de gevel. Negatief = gevel van een ander (hoger) deel
// die naar dit dak toe kijkt: de rand stopt tegen die afgewerkte gevel.
function computeEdge(settings, roofs, facadeDepth) { function computeEdge(settings, roofs, facadeDepth) {
const s = normalizeSettings(settings); const s = normalizeSettings(settings);
const rects = roofs.map(r => r.rect); const rects = roofs.map(r => r.rect);
if (!roofs.length) return null; if (!roofs.length) return null;
// Hoogtegroepen: daken die tegen elkaar liggen horen bij één doorlopende // Daken die tegen elkaar liggen delen één rand, tenzij het hoogteverschil langs de hele naad
// rand, tenzij het hoogteverschil langs de hele naad minstens de // minstens de boeideelhoogte is.
// boeideelhoogte is. Dan krijgt het hoge dak een eigen rand rondom (ook
// boven het lage dak), en loopt de rand van het lage dak tot de gevel
// van het hoge.
const group = roofs.map((r, i) => i); const group = roofs.map((r, i) => i);
const find = (i) => group[i] === i ? i : (group[i] = find(group[i])); const find = (i) => group[i] === i ? i : (group[i] = find(group[i]));
const sharedEdge = (A, B) => { const sharedEdge = (A, B) => {
@@ -778,22 +755,16 @@ window.RoofModel = (() => {
const owner = members.find(r => onRectEdge(r.rect, mid)) || members[0]; const owner = members.find(r => onRectEdge(r.rect, mid)) || members[0];
const fascia = owner.fascia !== false; const fascia = owner.fascia !== false;
const off = fascia ? offBoei : offTrim; const off = fascia ? offBoei : offTrim;
// Ribbe tegen een dak van een andere groep: tegen een hoger dak // Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots.
// vervalt hij (daar staat de gevel van het hoge), boven een lager
// dak blijft hij, maar zonder spots.
const nb = others.find(r => onRectEdge(r.rect, mid)); const nb = others.find(r => onRectEdge(r.rect, mid));
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z); const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
return { return {
a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b), a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b),
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j], outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j],
// Bovenkant van de rand (dakleer + opstand).
topAt: (p) => owner.topAt(p.x, p.z) + EDGE_ABOVE_MM, ridges: owner.ridges || [], topAt: (p) => owner.topAt(p.x, p.z) + EDGE_ABOVE_MM, ridges: owner.ridges || [],
overRoof: !!nb && !dropped, dropped overRoof: !!nb && !dropped, dropped
}; };
}); });
// Binnenrand: alleen tegen de gevel van een hoger deel (negatieve
// diepte) inkorten; spotlijn vóór de gevel. Op een hoek van twee haakse
// ribben het snijpunt van beide verschoven lijnen, anders loodrecht.
const depRaw = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) || 0 : 0)); const depRaw = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) || 0 : 0));
const dep = depRaw.map(d => Math.min(0, d)); const dep = depRaw.map(d => Math.min(0, d));
const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO); const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO);
@@ -810,12 +781,6 @@ window.RoofModel = (() => {
}); });
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] })); segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] }));
// Naast een vervallen ribbe (tegen een hoger dak):
// - ligt het verlengde van de rand in het hoge deel, dan loopt hij
// recht door tot die gevel (spot op spotafstand van de gevel);
// - ligt het verlengde ernaast (de rand loopt langs de zijgevel van
// het hoge deel), dan loopt hij recht door tot de overstek, onder
// de rand van het hoge dak (van bovenaf overlappen ze).
const insideOther = (p) => others.some(r => p.x > r.rect.x0 + 0.5 && p.x < r.rect.x1 - 0.5 && p.z > r.rect.z0 + 0.5 && p.z < r.rect.z1 - 0.5); const insideOther = (p) => others.some(r => p.x > r.rect.x0 + 0.5 && p.x < r.rect.x1 - 0.5 && p.z > r.rect.z0 + 0.5 && p.z < r.rect.z1 - 0.5);
segs.forEach((sg, i) => { segs.forEach((sg, i) => {
if (sg.dropped) return; if (sg.dropped) return;
@@ -851,8 +816,6 @@ window.RoofModel = (() => {
}; };
} }
// Convexe veelhoek (plattegrond, mm) knippen met een halfvlak
// (Sutherland-Hodgman): houd de punten waar f(p) >= 0.
function clipPoly(poly, f) { function clipPoly(poly, f) {
const out = []; const out = [];
poly.forEach((p, k) => { poly.forEach((p, k) => {
@@ -866,8 +829,6 @@ window.RoofModel = (() => {
return out; return out;
} }
// Prisma onder een convexe veelhoek: bovenkant per hoekpunt op topFn(p),
// onderkant h lager (mm -> meters).
function prismGeometry(poly, topFn, h) { function prismGeometry(poly, topFn, h) {
const toM = 0.001, n = poly.length, pos = []; const toM = 0.001, n = poly.length, pos = [];
const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]); const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]);
@@ -881,17 +842,12 @@ window.RoofModel = (() => {
return geo; return geo;
} }
// 3D van de dakrand: per ribbe een vierhoek (dakrand -> buitenrand, in
// verstek), naar beneden over de hoogte van boeideel resp. daktrim. De
// bovenkant volgt het dak (sg.topAt); op een nok wordt de ribbe geknipt.
// Plus de boeideel-spots (edge.spots) in de onderplaat.
// opts: { edgeVisible (volgt "Beplating"), spotsVisible (volgt "Lampen") }
function buildEdge3D(edge, materials, opts) { function buildEdge3D(edge, materials, opts) {
const o = opts || {}; const o = opts || {};
const toM = 0.001; const toM = 0.001;
const group = new THREE.Group(); const group = new THREE.Group();
if (!edge) return group; if (!edge) return group;
// Het boeidelen-materiaal wordt gedeeld: kleur volgens de instelling. // Gedeeld materiaal: kleur volgens de instelling.
materials.fasciaMaterial.color.setHex((FASCIA_COLORS[edge.fasciaColor] || FASCIA_COLORS.antraciet).hex); materials.fasciaMaterial.color.setHex((FASCIA_COLORS[edge.fasciaColor] || FASCIA_COLORS.antraciet).hex);
edge.segments.forEach(sg => { edge.segments.forEach(sg => {
let polys = [[sg.a, sg.b, sg.outerB, sg.outerA]]; let polys = [[sg.a, sg.b, sg.outerB, sg.outerA]];
@@ -899,8 +855,6 @@ window.RoofModel = (() => {
const f = (p) => r.axis === 'x' ? p.x - r.value : p.z - r.value; const f = (p) => r.axis === 'x' ? p.x - r.value : p.z - r.value;
polys = polys.flatMap(pl => [clipPoly(pl, f), clipPoly(pl, p => -f(p))]).filter(pl => pl.length >= 3); polys = polys.flatMap(pl => [clipPoly(pl, f), clipPoly(pl, p => -f(p))]).filter(pl => pl.length >= 3);
}); });
// Boeideel: totale hoogte fasciaHeight vanaf de bovenkant van de rand;
// daktrim: trimHeight onder het dakleer + de opstand erboven.
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove; const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
polys.forEach(pl => { polys.forEach(pl => {
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial); const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial);
@@ -914,6 +868,7 @@ window.RoofModel = (() => {
const spot = new THREE.Mesh(new THREE.CylinderGeometry(spotR, spotR, spotH, 16), materials.spotOnMaterial); const spot = new THREE.Mesh(new THREE.CylinderGeometry(spotR, spotR, spotH, 16), materials.spotOnMaterial);
spot.position.set(p.x * toM, p.y * toM - spotH / 2 + 0.002, p.z * toM); spot.position.set(p.x * toM, p.y * toM - spotH / 2 + 0.002, p.z * toM);
spot.visible = o.spotsVisible !== false; spot.visible = o.spotsVisible !== false;
spot.userData = { ref: p, glow: true };
group.add(spot); group.add(spot);
}); });
return group; return group;
@@ -923,6 +878,8 @@ window.RoofModel = (() => {
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {}); const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang)); s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang));
delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit
const h = s.house || {};
s.house = { enabled: !!h.enabled, form: h.form && UnitModel.ROOF_TYPES[h.form.type] ? Object.assign({}, h.form) : null, cover: !!h.cover };
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor; if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
return s; return s;
} }
@@ -930,7 +887,7 @@ window.RoofModel = (() => {
return { return {
DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl, DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs, createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions, computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions,
computeEdge, buildEdge3D, normalizeSettings computeEdge, buildEdge3D, normalizeSettings
}; };
})(); })();
+446
View File
@@ -0,0 +1,446 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Deuren en ramen tellen als wand. Wanduiteinden tellen END_TOL langer, zodat een kiertje bij
// een getekende binnenwand de ruimte niet laat doorlopen.
window.RoomModel = (() => {
const EPS = 0.5;
const END_TOL = 15;
const MIN_AREA_MM2 = 0.05e6;
const FACE_OFF = 2;
const CEIL_STEP = 300;
const CEIL_MIN = 1800, CEIL_MAX = 4500;
const TYPES = {
woonkamer: { label: 'Woonkamer', color: '#e6c36a' },
keuken: { label: 'Keuken', color: '#f0a05a', floor: 'tegel_grijs' },
woonkeuken: { label: 'Woonkeuken', color: '#e9b35f' },
slaapkamer: { label: 'Slaapkamer', color: '#8fb3e8' },
badkamer: { label: 'Badkamer', color: '#5fc9c0', floor: 'tegel_antraciet', fibo: { decor: 'wit_tegel', height: null }, ceiling: 2600 },
toilet: { label: 'Toilet', color: '#7cc7e3', floor: 'tegel_antraciet', fibo: { decor: 'wit_tegel', height: 1200 }, ceiling: 2600 },
hal: { label: 'Hal / entree', color: '#c9bfae' },
berging: { label: 'Berging', color: '#a99a86' },
techniek: { label: 'Techniek', color: '#9a9aa6' },
wasruimte: { label: 'Wasruimte', color: '#9fd3a4', floor: 'tegel_grijs' },
werkkamer: { label: 'Werkkamer', color: '#c3a6e0' },
overig: { label: 'Overig', color: '#b8b8b8' }
};
const FIBO = {
wit_tegel: { label: 'Wit, tegel 300×600', color: '#f1f0ec', jointColor: '#cfcdc6', tile: { w: 600, h: 300, joint: 3 } },
grijs_tegel: { label: 'Lichtgrijs, tegel 300×600', color: '#c9c8c3', jointColor: '#a3a29d', tile: { w: 600, h: 300, joint: 3 } },
antraciet_tegel: { label: 'Antraciet, tegel 300×600', color: '#55575a', jointColor: '#3a3b3d', tile: { w: 600, h: 300, joint: 3 } },
wit_metro: { label: 'Wit, metrotegel 100×200', color: '#f3f2ee', jointColor: '#c8c6be', tile: { w: 200, h: 100, joint: 2 } },
beton: { label: 'Betonlook (naadloos)', color: '#a6a39c' }
};
const isHex = (v) => /^#[0-9a-f]{6}$/i.test(v || '');
function applyType(room, type) {
const T = TYPES[type] || TYPES.overig;
room.type = TYPES[type] ? type : 'overig';
room.floor = T.floor || null;
room.fibo = T.fibo ? Object.assign({}, T.fibo) : null;
room.ceiling = T.ceiling || null;
room.paint = null;
return room;
}
function create(state, rect, x, z, type) {
const id = (state.rooms || []).reduce((m, r) => Math.max(m, r.id || 0), 0) + 1;
return applyType({ id, unitId: rect.id, x: Math.round(x - rect.x0), z: Math.round(z - rect.z0), name: '' }, type);
}
function normalizeRoom(r, i) {
return {
id: r.id > 0 ? r.id : i + 1,
unitId: String(r.unitId || 'A'), x: Number(r.x) || 0, z: Number(r.z) || 0,
type: TYPES[r.type] ? r.type : 'overig',
name: typeof r.name === 'string' ? r.name : '',
floor: r.floor && FloorModel.FINISHES[r.floor] ? r.floor : null,
paint: isHex(r.paint) ? r.paint : null,
fibo: r.fibo && FIBO[r.fibo.decor] ? { decor: r.fibo.decor, height: r.fibo.height > 0 ? Math.round(r.fibo.height) : null } : null,
ceiling: r.ceiling > 0 ? Math.max(CEIL_MIN, Math.min(CEIL_MAX, Math.round(r.ceiling))) : null
};
}
function normalize(list, unitIds) {
return (Array.isArray(list) ? list : []).map(normalizeRoom).filter(r => !unitIds || unitIds.includes(r.unitId));
}
const q = (v) => Math.round(v * 10) / 10;
function bsearch(arr, v) {
if (!arr.length || v < arr[0] || v > arr[arr.length - 1]) return -1;
let lo = 0, hi = arr.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (arr[m] <= v) lo = m; else hi = m; }
return lo;
}
function buildGrid(pieces, rects) {
const blockers = [];
pieces.forEach(p => {
if (p.place.baseY) return; // opbouw boven een lager dak staat niet op de vloer
const pl = p.place, alongX = Math.abs(Math.sin(pl.rotY)) < 0.5;
const hw = pl.width / 2, ht = pl.thickness / 2;
const r = alongX ? [pl.cx - hw, pl.cx + hw, pl.cz - ht, pl.cz + ht] : [pl.cx - ht, pl.cx + ht, pl.cz - hw, pl.cz + hw];
const ext = alongX ? [r[0] - END_TOL, r[1] + END_TOL, r[2], r[3]] : [r[0], r[1], r[2] - END_TOL, r[3] + END_TOL];
blockers.push({ piece: p, r: r.map(q), ext: ext.map(q), alongX });
});
const sx = new Set(), sz = new Set();
const addR = (r) => { sx.add(r[0]); sx.add(r[1]); sz.add(r[2]); sz.add(r[3]); };
const unitR = rects.map(r => [r.x0, r.x1, r.z0, r.z1].map(q));
unitR.forEach(addR);
blockers.forEach(b => { addR(b.r); addR(b.ext); });
const xs = [...sx].sort((a, b) => a - b), zs = [...sz].sort((a, b) => a - b);
const nx = Math.max(0, xs.length - 1), nz = Math.max(0, zs.length - 1);
const ix = new Map(xs.map((v, i) => [v, i])), iz = new Map(zs.map((v, i) => [v, i]));
const unitOf = new Int16Array(nx * nz).fill(-1);
const block = new Int32Array(nx * nz).fill(-1);
const mark = (arr, r, val, onlyEmpty) => {
const i0 = ix.get(r[0]), i1 = ix.get(r[1]), j0 = iz.get(r[2]), j1 = iz.get(r[3]);
for (let j = j0; j < j1; j++) for (let i = i0; i < i1; i++) {
const k = j * nx + i;
if (!onlyEmpty || arr[k] < 0) arr[k] = val;
}
};
unitR.forEach((r, i) => mark(unitOf, r, i, true));
blockers.forEach((b, i) => mark(block, b.r, i, true));
blockers.forEach((b, i) => mark(block, b.ext, i, true));
return { xs, zs, nx, nz, unitOf, block, blockers };
}
function fill(g, rects) {
const { xs, zs, nx, nz, unitOf, block } = g;
const region = new Int32Array(nx * nz).fill(-1);
const regions = [];
for (let k0 = 0; k0 < nx * nz; k0++) {
if (unitOf[k0] < 0 || block[k0] >= 0 || region[k0] >= 0) continue;
const idx = regions.length, cells = [], units = new Set();
let area = 0, mx = 0, mz = 0;
const stack = [k0];
region[k0] = idx;
while (stack.length) {
const k = stack.pop(), i = k % nx, j = (k - i) / nx;
const w = xs[i + 1] - xs[i], d = zs[j + 1] - zs[j], a = w * d;
cells.push(k);
units.add(rects[unitOf[k]].id);
area += a; mx += a * (xs[i] + w / 2); mz += a * (zs[j] + d / 2);
const nb = [i > 0 ? k - 1 : -1, i < nx - 1 ? k + 1 : -1, j > 0 ? k - nx : -1, j < nz - 1 ? k + nx : -1];
nb.forEach(m => {
if (m < 0 || region[m] >= 0 || unitOf[m] < 0 || block[m] >= 0) return;
region[m] = idx;
stack.push(m);
});
}
regions.push({ idx, cells, units: [...units], area, cx: mx / area, cz: mz / area });
}
return { region, regions };
}
function outline(g, region, reg) {
const { xs, zs, nx, nz, block } = g;
const inReg = (k) => region[k] === reg.idx;
const rows = new Map();
reg.cells.forEach(k => { const i = k % nx, j = (k - i) / nx; (rows.get(j) || rows.set(j, []).get(j)).push(i); });
let open = [];
const rects = [];
[...rows.keys()].sort((a, b) => a - b).forEach(j => {
const is = rows.get(j).sort((a, b) => a - b), runs = [];
is.forEach(i => { const last = runs[runs.length - 1]; if (last && last[1] === i) last[1] = i + 1; else runs.push([i, i + 1]); });
const next = [];
runs.forEach(([a, b]) => {
const r = open.find(o => o.a === a && o.b === b && o.j1 === j);
if (r) { r.j1 = j + 1; next.push(r); } else { const n = { a, b, j0: j, j1: j + 1 }; rects.push(n); next.push(n); }
});
open = next;
});
const out = rects.map(r => ({ x0: xs[r.a], x1: xs[r.b], z0: zs[r.j0], z1: zs[r.j1] }));
const raw = [];
reg.cells.forEach(k => {
const i = k % nx, j = (k - i) / nx;
const side = (m, axis, v, s0, s1, n) => {
if (m >= 0 && inReg(m)) return;
raw.push({ axis, v, s0, s1, n, owner: m >= 0 ? block[m] : -1 });
};
side(i < nx - 1 ? k + 1 : -1, 'x', xs[i + 1], zs[j], zs[j + 1], { x: -1, z: 0 });
side(i > 0 ? k - 1 : -1, 'x', xs[i], zs[j], zs[j + 1], { x: 1, z: 0 });
side(j < nz - 1 ? k + nx : -1, 'z', zs[j + 1], xs[i], xs[i + 1], { x: 0, z: -1 });
side(j > 0 ? k - nx : -1, 'z', zs[j], xs[i], xs[i + 1], { x: 0, z: 1 });
});
const groups = new Map();
raw.forEach(e => { const key = `${e.axis}|${e.v}|${e.n.x}|${e.n.z}|${e.owner}`; (groups.get(key) || groups.set(key, []).get(key)).push(e); });
const edges = [];
groups.forEach(list => {
list.sort((a, b) => a.s0 - b.s0);
list.forEach(e => {
const last = edges[edges.length - 1];
if (last && last.key === list && Math.abs(last.s1 - e.s0) < EPS) last.s1 = e.s1;
else edges.push(Object.assign({ key: list }, e));
});
});
edges.forEach(e => delete e.key);
return { rects: out, edges };
}
function labelPoint(g, region, reg, rects) {
const i = bsearch(g.xs, reg.cx), j = bsearch(g.zs, reg.cz);
if (i >= 0 && j >= 0 && i < g.nx && j < g.nz && region[j * g.nx + i] === reg.idx) return { x: reg.cx, z: reg.cz };
const big = rects.reduce((b, r) => (r.x1 - r.x0) * (r.z1 - r.z0) > (b.x1 - b.x0) * (b.z1 - b.z0) ? r : b, rects[0]);
return { x: (big.x0 + big.x1) / 2, z: (big.z0 + big.z1) / 2 };
}
function bandOnSide(p, e, lo, H) {
const pl = p.place, cfg = p.config, W = cfg.wallWidth, tl = WallModel.topLine(cfg);
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
const toS = (lx) => e.axis === 'z' ? pl.cx + lx * c : pl.cz - lx * sn;
const toLx = (s) => e.axis === 'z' ? (s - pl.cx) / c : -(s - pl.cz) / sn;
const la = Math.max(-W / 2, Math.min(toLx(e.s0), toLx(e.s1))), lb = Math.min(W / 2, Math.max(toLx(e.s0), toLx(e.s1)));
if (lb - la < 1) return { quads: [], area: 0 };
const hiAt = (lx) => Math.min(tl.at(lx), H);
const ops = (cfg.openings || []).map(op => ({ x0: -W / 2 + op.x, x1: -W / 2 + op.x + op.w, y0: op.y, top: (lx) => WallModel.openingTopAt(op, lx + W / 2) }));
const bs = new Set([la, lb]);
tl.pts.forEach(pt => bs.add(pt.x));
for (let i = 0; i < tl.pts.length - 1; i++) {
const a = tl.pts[i], b = tl.pts[i + 1];
if (isFinite(H) && (a.y - H) * (b.y - H) < 0) bs.add(a.x + (H - a.y) / (b.y - a.y) * (b.x - a.x));
}
ops.forEach(o => {
bs.add(o.x0); bs.add(o.x1);
const t0 = o.top(o.x0), t1 = o.top(o.x1);
[lo, H].forEach(L => { if (isFinite(L) && (t0 - L) * (t1 - L) < 0) bs.add(o.x0 + (L - t0) / (t1 - t0) * (o.x1 - o.x0)); });
});
const xsB = [...bs].filter(x => x >= la - 0.01 && x <= lb + 0.01).sort((a, b) => a - b);
const quads = [];
let area = 0;
for (let k = 0; k < xsB.length - 1; k++) {
const a = xsB[k], b = xsB[k + 1];
if (b - a < 1) continue;
const m = (a + b) / 2;
if (hiAt(m) <= lo + 1) continue;
let bot = () => lo;
const ivs = [];
ops.filter(o => o.x0 <= m && o.x1 >= m).sort((u, v) => u.y0 - v.y0).forEach(o => {
const bf = bot;
ivs.push([bf, () => o.y0]);
bot = (x) => Math.max(bf(x), o.top(x));
});
ivs.push([bot, hiAt]);
ivs.forEach(([bf, tf]) => {
const at = (x) => { const y0 = Math.max(lo, bf(x)), y1 = Math.min(hiAt(x), tf(x)); return [y0, Math.max(y0, y1)]; };
const [a0, a1] = at(a), [b0, b1] = at(b);
if (a1 - a0 < 0.5 && b1 - b0 < 0.5) return;
quads.push([[toS(a), a0], [toS(b), b0], [toS(b), b1], [toS(a), a1]]);
area += (b - a) * ((a1 - a0) + (b1 - b0)) / 2;
});
}
return { quads, area };
}
function bandOnEnd(p, e, lo, H) {
const tl = WallModel.topLine(p.config), W = p.config.wallWidth;
const top = Math.min(H, Math.min(tl.at(-W / 2), tl.at(W / 2)));
if (top - lo < 1) return { quads: [], area: 0 };
return { quads: [[[e.s0, lo], [e.s1, lo], [e.s1, top], [e.s0, top]]], area: (e.s1 - e.s0) * (top - lo) };
}
function compute(roomStates, units, pieces) {
const rects = units.map(u => u.rect);
const g = buildGrid(pieces, rects);
const { region, regions } = fill(g, rects);
const cellAt = (x, z) => {
const i = bsearch(g.xs, x), j = bsearch(g.zs, z);
return i < 0 || j < 0 || i >= g.nx || j >= g.nz ? -1 : j * g.nx + i;
};
const valid = (r) => r && r.area >= MIN_AREA_MM2;
const regionAt = (x, z) => { const k = cellAt(x, z); return k >= 0 && region[k] >= 0 && valid(regions[region[k]]) ? regions[region[k]] : null; };
// Zaadpunt in een wand (wand verschoven): het dichtstbijzijnde gebied binnen 400 mm.
const seedRegion = (x, z) => {
const r0 = regionAt(x, z);
if (r0) return r0;
for (let d = 50; d <= 400; d += 50) {
for (let a = 0; a < 8; a++) {
const r = regionAt(x + d * Math.cos(a * Math.PI / 4), z + d * Math.sin(a * Math.PI / 4));
if (r) return r;
}
}
return null;
};
const owner = new Map();
const lost = [];
(roomStates || []).forEach(rs => {
const u = units.find(x => x.id === rs.unitId);
const reg = u ? seedRegion(u.rect.x0 + rs.x, u.rect.z0 + rs.z) : null;
if (!reg) lost.push({ id: rs.id, state: rs, reason: u ? 'Geen afgesloten gebied op deze plek' : `Unit ${rs.unitId} bestaat niet` });
else if (owner.has(reg.idx)) lost.push({ id: rs.id, state: rs, reason: `Valt samen met een andere ruimte (wand weg?)` });
else owner.set(reg.idx, rs);
});
const pieceIdx = new Map(g.blockers.map((b, i) => [i, b]));
const unitAt = (x, z) => units.find(u => x >= u.rect.x0 - EPS && x <= u.rect.x1 + EPS && z >= u.rect.z0 - EPS && z <= u.rect.z1 + EPS);
const sameType = {};
owner.forEach(rs => { sameType[rs.type] = (sameType[rs.type] || 0) + 1; });
const seenType = {};
const rooms = [], free = [];
regions.filter(valid).forEach(reg => {
const { rects: rr, edges } = outline(g, region, reg);
const lp = labelPoint(g, region, reg, rr);
const u0 = units.find(u => u.id === reg.units[0]);
const floorTop = u0 ? u0.layout.floorTop : 0;
const base = { key: reg.idx, areaM2: reg.area / 1e6, rects: rr, edges, label: lp, units: reg.units, floorTop };
const rs = owner.get(reg.idx);
if (!rs) { free.push(base); return; }
const T = TYPES[rs.type] || TYPES.overig;
seenType[rs.type] = (seenType[rs.type] || 0) + 1;
const name = rs.name || (sameType[rs.type] > 1 ? `${T.label} ${seenType[rs.type]}` : T.label);
const share = {};
rr.forEach(r => { const u = unitAt((r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2); if (u) share[u.id] = (share[u.id] || 0) + (r.x1 - r.x0) * (r.z1 - r.z0); });
const unitId = Object.keys(share).sort((a, b) => share[b] - share[a])[0] || reg.units[0];
const ceilQuads = [];
let ceilMM2 = 0;
if (rs.ceiling) {
const yAbs = floorTop + rs.ceiling;
rr.forEach(r => {
const nX = Math.max(1, Math.ceil((r.x1 - r.x0) / CEIL_STEP)), nZ = Math.max(1, Math.ceil((r.z1 - r.z0) / CEIL_STEP));
for (let a = 0; a < nX; a++) for (let b = 0; b < nZ; b++) {
const x0 = r.x0 + (r.x1 - r.x0) * a / nX, x1 = r.x0 + (r.x1 - r.x0) * (a + 1) / nX;
const z0 = r.z0 + (r.z1 - r.z0) * b / nZ, z1 = r.z0 + (r.z1 - r.z0) * (b + 1) / nZ;
const u = unitAt((x0 + x1) / 2, (z0 + z1) / 2);
if (!u) continue;
const ok = [[x0 + 1, z0 + 1], [x1 - 1, z0 + 1], [x1 - 1, z1 - 1], [x0 + 1, z1 - 1]].every(([x, z]) => u.roof.ceilingAt(x, z) > yAbs + 10);
if (!ok) continue;
ceilQuads.push({ x0, x1, z0, z1, y: yAbs });
ceilMM2 += (x1 - x0) * (z1 - z0);
}
});
}
const H = rs.ceiling && ceilQuads.length ? rs.ceiling : Infinity;
const fiboH = rs.fibo ? (rs.fibo.height ? Math.min(rs.fibo.height, H) : H) : 0;
const faces = [];
let fiboMM2 = 0, paintMM2 = 0;
edges.forEach(e => {
const b = pieceIdx.get(e.owner);
if (!b) return;
const side = b.alongX === (e.axis === 'z');
const band = (lo, hi) => side ? bandOnSide(b.piece, e, lo, hi) : bandOnEnd(b.piece, e, lo, hi);
const world = ([s, y]) => e.axis === 'z'
? { x: s, y: b.piece.place.floorTop + y, z: e.v + e.n.z * FACE_OFF, u: s }
: { x: e.v + e.n.x * FACE_OFF, y: b.piece.place.floorTop + y, z: s, u: s };
if (rs.fibo) {
const r = band(0, fiboH);
fiboMM2 += r.area;
r.quads.forEach(qd => faces.push({ kind: 'fibo', pts: qd.map(world) }));
}
if (rs.paint && (!rs.fibo || fiboH < H)) {
const r = band(rs.fibo ? fiboH : 0, H);
paintMM2 += r.area;
r.quads.forEach(qd => faces.push({ kind: 'paint', pts: qd.map(world) }));
}
});
rooms.push(Object.assign(base, {
id: rs.id, state: rs, type: rs.type, typeLabel: T.label, name, color: T.color, unitId,
floor: rs.floor, paint: rs.paint, fibo: rs.fibo, ceiling: rs.ceiling,
ceilingActive: ceilQuads.length > 0, ceilQuads, ceilM2: ceilMM2 / 1e6,
faces, fiboM2: fiboMM2 / 1e6, paintM2: paintMM2 / 1e6
}));
});
const roomAt = (x, z) => { const reg = regionAt(x, z); return reg ? rooms.find(r => r.key === reg.idx) || null : null; };
const freeAt = (x, z) => { const reg = regionAt(x, z); return reg ? free.find(r => r.key === reg.idx) || null : null; };
return { rooms, free, lost, roomAt, freeAt, regionAt };
}
function lowerSpots(result, spots) {
(spots || []).forEach(sp => {
const r = result.roomAt(sp.x, sp.z);
if (r && r.ceilingActive && sp.floorY + r.ceiling < sp.y) sp.y = sp.floorY + r.ceiling;
});
}
const matCache = {};
const offset = (m) => Object.assign(m, { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -2, side: THREE.DoubleSide });
function fiboMaterial(decor) {
const key = 'fibo:' + decor;
if (matCache[key]) return matCache[key];
const f = FIBO[decor] || FIBO.wit_tegel;
const tex = f.tile && FloorModel.tileTexture ? FloorModel.tileTexture(f.tile, f.color, f.jointColor) : null;
return (matCache[key] = offset(new THREE.MeshStandardMaterial(tex ? { map: tex, roughness: 0.3 } : { color: f.color, roughness: 0.5 })));
}
function paintMaterial(hex) {
const key = 'paint:' + hex;
return matCache[key] || (matCache[key] = offset(new THREE.MeshStandardMaterial({ color: hex, roughness: 0.9 })));
}
// Kopie van de vloerafwerking, zodat hij boven de woningvloer wint.
function floorMaterial(key) {
const f = FloorModel.FINISHES[key] || FloorModel.FINISHES.geen;
const base = FloorModel.finishMaterial(key);
const ck = 'floor:' + key;
if (!matCache[ck]) matCache[ck] = offset(base ? base.clone() : new THREE.MeshStandardMaterial({ color: f.color, roughness: 0.75 }));
const m = matCache[ck];
if (base && base.map && m.map !== base.map) { m.map = base.map; m.color.copy(base.color); m.needsUpdate = true; }
return m;
}
let ceilMat = null;
const ceilingMaterial = () => ceilMat || (ceilMat = new THREE.MeshStandardMaterial({ color: 0xf4f3ef, roughness: 0.95, side: THREE.DoubleSide }));
function quadGeometry(quads, uvOf) {
const pos = [], uv = [];
quads.forEach(qd => [0, 1, 2, 0, 2, 3].forEach(i => {
const p = qd[i];
pos.push(p.x * 0.001, p.y * 0.001, p.z * 0.001);
const t = uvOf(p);
uv.push(t[0], t[1]);
}));
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
geo.computeVertexNormals();
return geo;
}
function build3D(result, opts) {
const o = opts || {};
const group = new THREE.Group(), ceilings = new THREE.Group();
group.add(ceilings);
group.userData.ceilings = ceilings;
result.rooms.forEach(r => {
if (r.floor) {
const y = r.floorTop + FACE_OFF;
const quads = r.rects.map(c => [{ x: c.x0, z: c.z0 }, { x: c.x1, z: c.z0 }, { x: c.x1, z: c.z1 }, { x: c.x0, z: c.z1 }].map(p => Object.assign(p, { y })));
const m = new THREE.Mesh(quadGeometry(quads, p => [p.x * 0.001, p.z * 0.001]), floorMaterial(r.floor));
m.receiveShadow = true;
group.add(m);
}
const byKind = { fibo: [], paint: [] };
r.faces.forEach(f => byKind[f.kind].push(f.pts));
if (byKind.fibo.length) group.add(new THREE.Mesh(quadGeometry(byKind.fibo, p => [p.u * 0.001, p.y * 0.001]), fiboMaterial(r.fibo.decor)));
if (byKind.paint.length) group.add(new THREE.Mesh(quadGeometry(byKind.paint, p => [p.u * 0.001, p.y * 0.001]), paintMaterial(r.paint)));
if (r.ceilQuads.length) {
const quads = r.ceilQuads.map(c => [{ x: c.x0, z: c.z0 }, { x: c.x1, z: c.z0 }, { x: c.x1, z: c.z1 }, { x: c.x0, z: c.z1 }].map(p => Object.assign(p, { y: c.y })));
ceilings.add(new THREE.Mesh(quadGeometry(quads, p => [p.x * 0.001, p.z * 0.001]), ceilingMaterial()));
}
});
ceilings.visible = o.ceilings !== false;
return group;
}
function floorLabel(r) { return r.floor ? (FloorModel.FINISHES[r.floor] || {}).label || r.floor : 'Woning'; }
function wallLabel(r) {
const parts = [];
if (r.fibo) parts.push(`FIBO ${(FIBO[r.fibo.decor] || {}).label.toLowerCase()}${r.fibo.height ? ` tot ${r.fibo.height} mm` : ''}`);
if (r.paint) parts.push(`verf ${r.paint}`);
return parts.join(', ') || 'Woning';
}
function ceilingLabel(r) { return r.ceiling ? (r.ceilingActive ? `vals plafond ${r.ceiling}` : `${r.ceiling} (dak lager)`) : 'volgt dak'; }
return {
TYPES, FIBO, CEIL_MIN, CEIL_MAX, applyType, create, normalize, compute, lowerSpots, build3D,
floorLabel, wallLabel, ceilingLabel
};
})();
+6 -23
View File
@@ -4,21 +4,10 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// sandwich/sandwich-model.js — sandwichpanelen rondom de woning (SandwichModel).
// De vloer staat gedeeltelijk in de grond; de panelen werken de vloerrand af.
// Ze beginnen op Y=0 van de vloer (het beloopbare vlak = bovenkant vloer) en
// lopen DEPTH_MM naar beneden, tegen de buitenkant van de vloerrand, in één
// doorlopende rand (in verstek) om de vereniging van alle unit-vloeren.
// Plain script (geen ES-module), geladen na core/geometry.js.
// ============================================================
window.SandwichModel = (() => { window.SandwichModel = (() => {
const DEPTH_MM = 600; // vanaf het loopvlak naar beneden (de grond in) const DEPTH_MM = 600;
const THICK_MM = 40; // paneeldikte const THICK_MM = 40;
// Handelsmaat plaat: 495 breed x 1000 lang x 40 dik, 9 kg per plaat.
// Verticaal geplaatst (495 = werkende breedte langs de gevel), lengte
// 1000 op DEPTH_MM afgezaagd: één plaat per 495 mm gevel.
const PANEL_WIDTH_MM = 495; const PANEL_WIDTH_MM = 495;
const PANEL_LENGTH_MM = 1000; const PANEL_LENGTH_MM = 1000;
const PANEL_KG = 9.0; const PANEL_KG = 9.0;
@@ -30,9 +19,6 @@ window.SandwichModel = (() => {
}; };
} }
// rects: unit-vloeren { x0, x1, z0, z1 } (mm, woning-assen); topY =
// loopvlak (mm). Per ribbe van elke omtreklus een vierhoek (vloerrand ->
// buitenkant paneel, in verstek, ook bij binnenhoeken).
function compute(rects, topY) { function compute(rects, topY) {
if (!rects.length) return null; if (!rects.length) return null;
const segments = []; const segments = [];
@@ -43,18 +29,16 @@ window.SandwichModel = (() => {
segments.push({ a, b: base[j], outerA: off[i], outerB: off[j] }); segments.push({ a, b: base[j], outerA: off[i], outerB: off[j] });
}); });
}); });
// Lengte gemeten over de hartlijn van het paneel.
const lengthMM = segments.reduce((t, sg) => t + (Math.hypot(sg.b.x - sg.a.x, sg.b.z - sg.a.z) + Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z)) / 2, 0); const lengthMM = segments.reduce((t, sg) => t + (Math.hypot(sg.b.x - sg.a.x, sg.b.z - sg.a.z) + Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z)) / 2, 0);
const areaM2 = lengthMM * DEPTH_MM / 1e6; const areaM2 = lengthMM * DEPTH_MM / 1e6;
const panelCount = segments.reduce((t, sg) => t + Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / PANEL_WIDTH_MM - 1e-9), 0); const panelCount = segments.reduce((t, sg) => t + Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / PANEL_WIDTH_MM - 1e-9), 0);
return { return {
segments, top: topY, bottom: topY - DEPTH_MM, depth: DEPTH_MM, thick: THICK_MM, segments, top: topY, bottom: topY - DEPTH_MM, depth: DEPTH_MM, thick: THICK_MM,
lengthMM, areaM2, panelCount, kg: panelCount * PANEL_KG, // inkoop: hele platen lengthMM, areaM2, panelCount, kg: panelCount * PANEL_KG,
wasteMM: PANEL_LENGTH_MM - DEPTH_MM // afzaagstuk per plaat wasteMM: PANEL_LENGTH_MM - DEPTH_MM
}; };
} }
// opts: { visible (volgt "Beplating") }
function build3D(data, materials, opts) { function build3D(data, materials, opts) {
const o = opts || {}; const o = opts || {};
const toM = 0.001; const toM = 0.001;
@@ -65,15 +49,14 @@ window.SandwichModel = (() => {
const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.z * toM))); const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.z * toM)));
const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: data.depth * toM, bevelEnabled: false }); const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: data.depth * toM, bevelEnabled: false });
const mesh = new THREE.Mesh(geo, materials.panelMaterial); const mesh = new THREE.Mesh(geo, materials.panelMaterial);
mesh.rotation.x = Math.PI / 2; // shape-y -> wereld-Z, extrusie naar beneden mesh.rotation.x = Math.PI / 2;
mesh.position.y = data.top * toM; mesh.position.y = data.top * toM;
group.add(mesh); group.add(mesh);
// Paneelnaden op de buitenkant, elke PANEL_WIDTH_MM vanaf ribbe-start.
const len = Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z); const len = Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z);
const pts3 = []; const pts3 = [];
for (let d = PANEL_WIDTH_MM; d < len - 1; d += PANEL_WIDTH_MM) { for (let d = PANEL_WIDTH_MM; d < len - 1; d += PANEL_WIDTH_MM) {
const t = d / len, eps = 0.5; // net buiten het paneelvlak const t = d / len, eps = 0.5;
const nx = sg.outerA.x - sg.a.x, nz = sg.outerA.z - sg.a.z, nl = Math.hypot(nx, nz) || 1; const nx = sg.outerA.x - sg.a.x, nz = sg.outerA.z - sg.a.z, nl = Math.hypot(nx, nz) || 1;
const x = (sg.outerA.x + (sg.outerB.x - sg.outerA.x) * t + nx / nl * eps) * toM; const x = (sg.outerA.x + (sg.outerB.x - sg.outerA.x) * t + nx / nl * eps) * toM;
const z = (sg.outerA.z + (sg.outerB.z - sg.outerA.z) * t + nz / nl * eps) * toM; const z = (sg.outerA.z + (sg.outerB.z - sg.outerA.z) * t + nz / nl * eps) * toM;
+90 -165
View File
@@ -4,38 +4,10 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// unit/unit-model.js — UnitModel: ENIGE bron van de unit-logica (1 vloer +
// 4 wanden). Geen DOM. Bevat zelf geen wand-/vloerlogica: alles gaat via
// WallModel/FloorModel. Gebruikt door de woning (house/*).
//
// Een unit-state is:
// { layout: { corners }, floor: <vloer-state>, walls: { voor, achter, links, rechts: <wand-state> } }
//
// Assenstelsel (mm, unit-lokaal): X = breedte, Z = diepte, vloermidden op
// (0, 0), "voor" op Z = -diepte/2 (zelfde als FloorModel.build3D).
//
// Naden: wanneer een andere unit tegen een zijde aan staat, vervalt de wand
// over dat stuk (cuts: [a, b, extA, extB], zie HouseModel.computeSeams —
// extA/extB laten het aangrenzende wandstuk de naad in doorlopen om een
// binnenhoek te sluiten). Een wand kan daardoor in meerdere stukken uiteenvallen
// (bijv. de achterwand bij een L-vorm); elk stuk is een volwaardige wand
// (eigen regelwerk, hoekstijlen, PDF-blad). Een wand die haaks op een
// vervallen hoek staat, loopt door tot de vloerrand, zodat de buitengevel
// over de naad heen doorloopt.
//
// Plain script, geladen na wall-model.js en floor-model.js.
// ============================================================
window.UnitModel = (() => { window.UnitModel = (() => {
// Elke wand staat met zijn buitenzijde (+Z lokaal, "zuid" in de wand- // rotY draait lokaal +Z naar buiten; "links" = gezien van buitenaf. normal: plattegrond, x rechts, y omlaag.
// editor) naar buiten. rotY draait lokaal +Z naar de buitenrichting; "links" // De sleutels voor/achter/links/rechts blijven intern (compatibele JSON); weergave als windrichting.
// in de wand-editor = links gezien van buitenaf. axis = de as waarlangs de
// wand loopt; dir = +1 als lokaal +X met die as meeloopt.
// normal = buitenrichting in de plattegrond (x rechts, y omlaag; voor boven).
// Weergave in windrichtingen t.o.v. het 2D-bovenaanzicht (boven = noord):
// voor = noord, achter = zuid, links = west, rechts = oost. De sleutels
// (voor/achter/links/rechts) blijven intern, voor compatibele JSON.
const WALL_SIDES = [ const WALL_SIDES = [
{ key: 'voor', label: 'Wand noord', nr: 1, rotY: Math.PI, axis: 'x', dir: -1, normal: { x: 0, y: -1 } }, { key: 'voor', label: 'Wand noord', nr: 1, rotY: Math.PI, axis: 'x', dir: -1, normal: { x: 0, y: -1 } },
{ key: 'achter', label: 'Wand zuid', nr: 2, rotY: 0, axis: 'x', dir: 1, normal: { x: 0, y: 1 } }, { key: 'achter', label: 'Wand zuid', nr: 2, rotY: 0, axis: 'x', dir: 1, normal: { x: 0, y: 1 } },
@@ -43,8 +15,6 @@ window.UnitModel = (() => {
{ key: 'rechts', label: 'Wand oost', nr: 4, rotY: Math.PI / 2, axis: 'z', dir: -1, normal: { x: 1, y: 0 } } { key: 'rechts', label: 'Wand oost', nr: 4, rotY: Math.PI / 2, axis: 'z', dir: -1, normal: { x: 1, y: 0 } }
]; ];
// Hoekaansluiting: welke wanden lopen door over de volle vloermaat, en welke
// passen er tussenin.
const CORNER_MODES = { const CORNER_MODES = {
'voor-achter': 'Noord/zuid doorlopend', 'voor-achter': 'Noord/zuid doorlopend',
'zijkanten': 'West/oost doorlopend' 'zijkanten': 'West/oost doorlopend'
@@ -52,9 +22,8 @@ window.UnitModel = (() => {
const DEFAULT_HEIGHT_MM = 2600; const DEFAULT_HEIGHT_MM = 2600;
// Kortere wandstukken (restje naast een naad) worden weggelaten.
const MIN_PIECE_MM = 300; const MIN_PIECE_MM = 300;
const EPS = 0.5; // mm const EPS = 0.5;
function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
@@ -66,7 +35,6 @@ window.UnitModel = (() => {
const walls = {}; const walls = {};
WALL_SIDES.forEach(s => { WALL_SIDES.forEach(s => {
const wall = WallEditor.getDefaultState(); const wall = WallEditor.getDefaultState();
// Standaard unithoogte (los van de standaard van een losse wand).
const h = o.height || DEFAULT_HEIGHT_MM; const h = o.height || DEFAULT_HEIGHT_MM;
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = h; wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = h;
if (!(o.windowOn || ['voor']).includes(s.key)) wall.openings = []; if (!(o.windowOn || ['voor']).includes(s.key)) wall.openings = [];
@@ -77,17 +45,12 @@ window.UnitModel = (() => {
return state; return state;
} }
// Lokaal wand-punt (mm, x langs de wand gecentreerd, z dwars) -> X/Z in het // Zelfde rotatie als THREE's rotation.y.
// omliggende stelsel. Zelfde rotatie als THREE's rotation.y.
function localToWorld(w, lx, lz) { function localToWorld(w, lx, lz) {
const c = Math.cos(w.rotY), s = Math.sin(w.rotY); const c = Math.cos(w.rotY), s = Math.sin(w.rotY);
return { x: w.cx + lx * c + lz * s, z: w.cz - lx * s + lz * c }; return { x: w.cx + lx * c + lz * s, z: w.cz - lx * s + lz * c };
} }
// Maat + positie van elke wand t.o.v. het vloermidden, rekening houdend met
// naden. cuts = { voor: [[a, b], ...], ... } in unit-lokale as-coördinaten
// (X voor voor/achter, Z voor links/rechts). Zet ook de afgeleide
// wandbreedte in de wand-states (muteert state).
function computeLayout(state, cuts) { function computeLayout(state, cuts) {
const c = cuts || {}; const c = cuts || {};
const floorFrame = FloorModel.computeFrame(state.floor.floor); const floorFrame = FloorModel.computeFrame(state.floor.floor);
@@ -96,38 +59,31 @@ window.UnitModel = (() => {
const T = {}; const T = {};
WALL_SIDES.forEach(s => { T[s.key] = WallModel.thicknessOf(WallModel.configFromState(state.walls[s.key])); }); WALL_SIDES.forEach(s => { T[s.key] = WallModel.thicknessOf(WallModel.configFromState(state.walls[s.key])); });
// Vervalt de wand van `side` op as-positie p (bijv. een hoek)?
const cutAt = (side, p) => (c[side] || []).some(([a, b]) => a <= p + EPS && b >= p - EPS); const cutAt = (side, p) => (c[side] || []).some(([a, b]) => a <= p + EPS && b >= p - EPS);
// Effectieve dikte van de doorlopende wand op een hoek: 0 als die daar vervalt.
const tEff = (side, p) => cutAt(side, p) ? 0 : T[side]; const tEff = (side, p) => cutAt(side, p) ? 0 : T[side];
let span; // per zijde [s0, s1] langs de eigen as const spans = (tE) => state.layout.corners === 'zijkanten' ? {
if (state.layout.corners === 'zijkanten') {
span = {
links: [-D / 2, D / 2], links: [-D / 2, D / 2],
rechts: [-D / 2, D / 2], rechts: [-D / 2, D / 2],
voor: [-W / 2 + tEff('links', -D / 2), W / 2 - tEff('rechts', -D / 2)], voor: [-W / 2 + tE('links', -D / 2), W / 2 - tE('rechts', -D / 2)],
achter: [-W / 2 + tEff('links', D / 2), W / 2 - tEff('rechts', D / 2)] achter: [-W / 2 + tE('links', D / 2), W / 2 - tE('rechts', D / 2)]
}; } : {
} else {
span = {
voor: [-W / 2, W / 2], voor: [-W / 2, W / 2],
achter: [-W / 2, W / 2], achter: [-W / 2, W / 2],
links: [-D / 2 + tEff('voor', -W / 2), D / 2 - tEff('achter', -W / 2)], links: [-D / 2 + tE('voor', -W / 2), D / 2 - tE('achter', -W / 2)],
rechts: [-D / 2 + tEff('voor', W / 2), D / 2 - tEff('achter', W / 2)] rechts: [-D / 2 + tE('voor', W / 2), D / 2 - tE('achter', W / 2)]
}; };
} const span = spans(tEff);
const span0 = spans((side) => T[side]);
const walls = WALL_SIDES.map(s => { const walls = WALL_SIDES.map(s => {
const [s0, s1] = span[s.key]; const [s0, s1] = span[s.key], [r0, r1] = span0[s.key];
const width = Math.max(MIN_PIECE_MM, Math.round(s1 - s0)); const width = Math.max(MIN_PIECE_MM, Math.round(s1 - s0));
const mid = (s0 + s1) / 2; const mid = (s0 + s1) / 2;
const off = { voor: -D / 2 + T.voor / 2, achter: D / 2 - T.achter / 2, links: -W / 2 + T.links / 2, rechts: W / 2 - T.rechts / 2 }[s.key]; const off = { voor: -D / 2 + T.voor / 2, achter: D / 2 - T.achter / 2, links: -W / 2 + T.links / 2, rechts: W / 2 - T.rechts / 2 }[s.key];
const cx = s.axis === 'x' ? mid : off; const cx = s.axis === 'x' ? mid : off;
const cz = s.axis === 'x' ? off : mid; const cz = s.axis === 'x' ? off : mid;
// Naden omgerekend naar "vanaf links gezien van buitenaf" (u, 0..width),
// incl. doorloop (ext) van het wandstuk vóór resp. ná de naad.
const toU = (p) => s.dir > 0 ? p - s0 : s1 - p; const toU = (p) => s.dir > 0 ? p - s0 : s1 - p;
const rawU = (c[s.key] || []) const rawU = (c[s.key] || [])
.map(([a, b, ea, eb]) => { .map(([a, b, ea, eb]) => {
@@ -138,10 +94,10 @@ window.UnitModel = (() => {
.filter(([a, b]) => b - a > EPS) .filter(([a, b]) => b - a > EPS)
.sort((p, q) => p[0] - q[0]); .sort((p, q) => p[0] - q[0]);
// Resterende stukken wand; een stuk dat aan een naad grenst loopt de const full = s.axis === 'x' ? W / 2 : D / 2;
// naad in door met de doorloop (ext) van die kant. const ext0 = Math.max(0, Math.round(s0 + full)), ext1 = Math.max(0, Math.round(full - s1));
// [u0, u1, inL, inR]: inL/inR = hoever het stuk aan die kant de naad const skinExt = s.dir > 0 ? { left: ext0, right: ext1 } : { left: ext1, right: ext0 };
// in loopt (binnenhoek dicht, zie computeSeams).
const pieces = []; const pieces = [];
let cursor = 0, inL = 0; let cursor = 0, inL = 0;
rawU.forEach(([a, b, e0, e1]) => { rawU.forEach(([a, b, e0, e1]) => {
@@ -151,51 +107,82 @@ window.UnitModel = (() => {
inL = b - cursor; inL = b - cursor;
}); });
if (width - cursor >= MIN_PIECE_MM) pieces.push([cursor, width, inL, 0]); if (width - cursor >= MIN_PIECE_MM) pieces.push([cursor, width, inL, 0]);
// Werkelijk vervallende zones (voor de weergave in de wand-editor). const splitsU = normalizeSplits(state.walls[s.key].splits).map(sp => Object.assign({}, sp, { u: sp.u - skinExt.left }))
.filter(sp => sp.u > MIN_PIECE_MM && sp.u < width - MIN_PIECE_MM);
splitsU.forEach(sp => {
const k = pieces.findIndex(([a, b]) => sp.u >= a + MIN_PIECE_MM - EPS && sp.u <= b - MIN_PIECE_MM + EPS);
if (k < 0) return;
const [a, b, inL, inR] = pieces[k];
pieces.splice(k, 1, [a, sp.u, inL, 0], [sp.u, b, 0, inR]);
});
const cutsU = rawU.map(([a, b, e0, e1]) => [a + e0, b - e1]).filter(([a, b]) => b - a > EPS); const cutsU = rawU.map(([a, b, e0, e1]) => [a + e0, b - e1]).filter(([a, b]) => b - a > EPS);
state.walls[s.key].wall.width = width; state.walls[s.key].wall.width = width;
// Hoekdoorloop buitenlagen (WallModel.cornerExt): een tussenpassende
// wand bedekt ook de kopse kant van de doorlopende wand ernaast.
const full = s.axis === 'x' ? W / 2 : D / 2;
const ext0 = Math.max(0, Math.round(s0 + full)), ext1 = Math.max(0, Math.round(full - s1));
const skinExt = s.dir > 0 ? { left: ext0, right: ext1 } : { left: ext1, right: ext0 };
// Doorlopende wand die op een hoek vervalt: de haakse wand loopt daar
// tot de vloerrand door en staat dus op deze wandlijn (endInset, u-mm
// vanaf links/rechts). Een opbouw in die naad-zone begint erna.
const through = state.layout.corners === 'zijkanten' ? s.axis === 'z' : s.axis === 'x'; const through = state.layout.corners === 'zijkanten' ? s.axis === 'z' : s.axis === 'x';
const pL = s.dir > 0 ? s0 : s1, pR = s.dir > 0 ? s1 : s0; const pL = s.dir > 0 ? s0 : s1, pR = s.dir > 0 ? s1 : s0;
const endInset = { const endInset = {
left: through && cutAt(s.key, pL) ? T[WALL_ENDS[s.key].left] : 0, left: through && cutAt(s.key, pL) ? T[WALL_ENDS[s.key].left] : 0,
right: through && cutAt(s.key, pR) ? T[WALL_ENDS[s.key].right] : 0 right: through && cutAt(s.key, pR) ? T[WALL_ENDS[s.key].right] : 0
}; };
const ref = { w: Math.max(MIN_PIECE_MM, Math.round(r1 - r0)), u0: s.dir > 0 ? s0 - r0 : r1 - s1 };
return { return {
side: s.key, label: s.label, nr: s.nr, rotY: s.rotY, normal: s.normal, axis: s.axis, side: s.key, label: s.label, nr: s.nr, rotY: s.rotY, normal: s.normal, axis: s.axis,
thickness: T[s.key], width, cx, cz, cutsU, pieces, skinExt, endInset thickness: T[s.key], width, cx, cz, cutsU, pieces, skinExt, endInset, ref, splitsU
}; };
}); });
return { floorWidth: W, floorDepth: D, floorTop: FloorModel.topHeight(floorFrame), walls }; return { floorWidth: W, floorDepth: D, floorTop: FloorModel.topHeight(floorFrame), walls };
} }
// Wandstukken van één zijde als losse wand-configs. Openingen worden eerst function normalizeSplits(list) {
// op de volle zijde opgelost (ankers gelden voor de hele zijde) en daarna return (Array.isArray(list) ? list : []).map(sp => typeof sp === 'number' ? { u: sp, facade: null } : sp)
// aan het stuk toegewezen waar ze volledig in vallen; openingen die in een .filter(sp => sp && isFinite(sp.u) && sp.u > 0)
// naad vallen vervallen (dropped). .map(sp => ({ u: Math.round(sp.u), facade: sp.facade && sp.facade.type ? Object.assign({}, sp.facade) : null }))
// raised (optioneel): [{ u0, u1, base }] = opbouw in een naad-zone: daar .sort((a, b) => a.u - b.u);
// staat de wand pas vanaf hoogte `base` (mm vanaf het loopvlak, bijv. boven }
// het lagere dak van de buur-unit) tot de daklijn. Zo'n stuk heeft function splitFacadeAt(wallLayout, u0, u1) {
// baseY = base; openingen die er volledig boven base in vallen horen erbij const m = (u0 + u1) / 2;
// (y t.o.v. de onderkant van het stuk). Te lage opbouw vervalt. let f = null;
(wallLayout.splitsU || []).forEach(sp => { if (sp.u <= m) f = sp.facade; });
return f;
}
// Openingen eerst op de volle zijde oplossen (ankers gelden voor de hele zijde), dan toewijzen;
// openingen in een naad vervallen.
const MIN_RAISE_MM = 150; const MIN_RAISE_MM = 150;
function topLineOn(wallLayout, wall) {
const ref = wallLayout.ref || { w: wallLayout.width, u0: 0 };
const W = wallLayout.width;
const pts = [{ x: 0, h: wall.heightLeft }, ...WallModel.wallTopPoints(wall, ref.w, wall.heightLeft, wall.heightRight), { x: ref.w, h: wall.heightRight }]
.map(q => ({ x: q.x - ref.u0, h: q.h }));
const at = (x) => {
let i = 0;
while (i < pts.length - 2 && x > pts[i + 1].x) i++;
const a = pts[i], b = pts[i + 1];
return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h);
};
return { hL: Math.round(at(0)), hR: Math.round(at(W)), tops: pts.slice(1, -1).filter(q => q.x > 1 && q.x < W - 1) };
}
function topParamsOn(wallLayout, wall) {
const { hL, hR, tops } = topLineOn(wallLayout, wall), W = wallLayout.width;
const mid = tops.length === 1 && Math.abs(tops[0].x - W / 2) < 0.5;
return { heightLeft: hL, heightRight: hR, heightMid: mid ? tops[0].h : null, topPts: tops.length && !mid ? tops.map(q => ({ f: q.x / W, h: q.h })) : undefined };
}
function topParamsFromLayout(wallLayout, wall) {
const ref = wallLayout.ref;
if (!ref || (ref.u0 === 0 && ref.w === wallLayout.width)) return wall;
const back = { width: ref.w, ref: { w: wallLayout.width, u0: -ref.u0 } };
const p = topParamsOn(back, Object.assign({}, wall, { width: wallLayout.width }));
Object.assign(wall, p, { heightAll: Math.min(p.heightLeft, p.heightRight) });
if (!p.topPts) delete wall.topPts;
return wall;
}
function wallPieces(wallLayout, wallState, raised) { function wallPieces(wallLayout, wallState, raised) {
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, hR, tops } = topLineOn(wallLayout, wallState.wall);
// Tussenpunten van de daklijn (nok, knikken; WallModel.wallTopPoints);
// een stuk krijgt de punten die erin vallen, verder de daklijn ter
// plaatse als gewone helling.
const tops = WallModel.wallTopPoints(wallState.wall, W, hL, hR);
const tp = [{ x: 0, h: hL }, ...tops, { x: W, h: hR }]; const tp = [{ x: 0, h: hL }, ...tops, { x: W, h: hR }];
const heightAt = (u) => { const heightAt = (u) => {
let i = 0; let i = 0;
@@ -219,11 +206,8 @@ window.UnitModel = (() => {
}, topsIn(a, b, 0)); }, 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) : '',
u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2, u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2,
// Loopt het stuk een naad in (binnenhoek), dan ligt dat deel binnen
// de wand van de buur-unit: buitenlagen daar niet (negatieve
// doorloop, zie WallModel.cornerExt) en een binnenplaat op de kopse kant.
skinExt: { skinExt: {
left: inL > EPS ? -inL : a < EPS ? wallLayout.skinExt.left : 0, left: inL > EPS ? -inL : a < EPS ? wallLayout.skinExt.left : 0,
right: inR > EPS ? -inR : b > W - EPS ? wallLayout.skinExt.right : 0 right: inR > EPS ? -inR : b > W - EPS ? wallLayout.skinExt.right : 0
@@ -245,10 +229,8 @@ window.UnitModel = (() => {
}, topsIn(a, b, base)); }, 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 : ''),
u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2, baseY: Math.round(base), u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2, baseY: Math.round(base),
// Naast de haakse wand in de hoek (endInset) bedekken de buitenlagen
// ook diens kopse kant, die boven het lage dak bloot ligt.
skinExt: { skinExt: {
left: ins.left > 0 && a <= ins.left + EPS ? ins.left : 0, left: ins.left > 0 && a <= ins.left + EPS ? ins.left : 0,
right: ins.right > 0 && b >= W - ins.right - EPS ? ins.right : 0 right: ins.right > 0 && b >= W - ins.right - EPS ? ins.right : 0
@@ -260,17 +242,6 @@ window.UnitModel = (() => {
return { pieces, dropped, fullConfig }; return { pieces, dropped, fullConfig };
} }
// Wandhoogtes koppelen, zodat er altijd een logisch dak op past. De
// bewerkte wand (side) bepaalt de dakvorm:
// - de tegenoverliggende wand krijgt dezelfde hoogtes gespiegeld (van
// buitenaf gezien wisselen links en rechts), incl. nok en knikken;
// - de twee haakse wanden worden vlak op de hoogte van het dak boven
// 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
// lage, zuidwand vlak op de hoge hoogte. Muteert state.
// Welke haakse wand zit aan de linker/rechterkant (van buitenaf gezien).
const WALL_ENDS = { const WALL_ENDS = {
voor: { left: 'rechts', right: 'links' }, voor: { left: 'rechts', right: 'links' },
achter: { left: 'links', right: 'rechts' }, achter: { left: 'links', right: 'rechts' },
@@ -278,9 +249,6 @@ 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) { function gableSpan(state, side) {
const layout = computeLayout(state, {}); const layout = computeLayout(state, {});
const wl = layout.walls.find(x => x.side === side); const wl = layout.walls.find(x => x.side === side);
@@ -291,8 +259,6 @@ window.UnitModel = (() => {
const e0 = Math.max(0, Math.abs(left + s.dir * span / 2)), e1 = Math.max(0, Math.abs(s.dir * span / 2 - right)); 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 }; 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) { function fullTopLine(wall, g) {
const pts = [{ u: g.e0, h: wall.heightLeft }] 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(WallModel.wallTopPoints(wall, g.w, wall.heightLeft, wall.heightRight).map(q => ({ u: g.e0 + q.x, h: q.h })))
@@ -302,9 +268,6 @@ window.UnitModel = (() => {
pts[n - 1] = { u: g.span, h: pts[n - 1].h + g.e1 * s1 }; pts[n - 1] = { u: g.span, h: pts[n - 1].h + g.e1 * s1 };
return pts; 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) { function setWallTopLine(wall, g, line) {
const at = (u) => { const at = (u) => {
let i = 0; let i = 0;
@@ -322,10 +285,7 @@ window.UnitModel = (() => {
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 pts = Array.isArray(w.topPts) && w.topPts.length ? w.topPts : null; const pts = Array.isArray(w.topPts) && w.topPts.length ? w.topPts : null;
// Haakse wanden: op de onderkant van het dak boven hun buiten- of // De laagste van de twee: zo steekt de wand nooit door het dak (de wig vult een schuine regel op).
// 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 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 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 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);
@@ -337,47 +297,17 @@ window.UnitModel = (() => {
if (tp) t.topPts = tp; else delete t.topPts; if (tp) t.topPts = tp; else delete t.topPts;
t.heightAll = hM === null && !tp && hL === hR ? hL : w.heightAll; t.heightAll = hM === null && !tp && hL === hR ? hL : w.heightAll;
}; };
// Gespiegeld (van buitenaf gezien): knikken van rechts naar links.
set(OPPOSITE[side], w.heightRight, w.heightLeft, mid, pts && pts.map(q => ({ f: 1 - q.f, h: q.h })).reverse()); set(OPPOSITE[side], w.heightRight, w.heightLeft, mid, pts && pts.map(q => ({ f: 1 - q.f, h: q.h })).reverse());
set(WALL_ENDS[side].left, edgeL, edgeL, null); set(WALL_ENDS[side].left, edgeL, edgeL, null);
set(WALL_ENDS[side].right, edgeR, edgeR, null); set(WALL_ENDS[side].right, edgeR, edgeR, null);
} }
// 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, ...(w.topPts || []).map(q => q.h)); return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h));
} }
// ------------------------------------------------------------ // kneeRatio / cutRatio gaan voor kneeH / cut, zodat ze meeschalen als goot of nok verandert.
// 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 }; 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) { function clampSize(floor, keep) {
floor.width = Math.min(LIMITS.long, floor.width); floor.width = Math.min(LIMITS.long, floor.width);
floor.depth = Math.min(LIMITS.long, floor.depth); floor.depth = Math.min(LIMITS.long, floor.depth);
@@ -390,13 +320,11 @@ window.UnitModel = (() => {
const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' }; const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' };
const RIDGES = { z: 'Noord–zuid', x: 'West–oost' }; const RIDGES = { z: 'Noord–zuid', x: 'West–oost' };
const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf'; const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf';
// Kopgevel (wand met de schuine daklijn) bij een dakvorm.
function gableOf(form) { function gableOf(form) {
if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter'; if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter';
if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links'; if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links';
return 'achter'; return 'achter';
} }
// Huidige dakvorm, afgelezen uit de wanden (zelfde volgorde als computeShape).
function roofForm(unitState) { function roofForm(unitState) {
const W = unitState.walls; const W = unitState.walls;
for (const side of ['achter', 'voor', 'links', 'rechts']) { for (const side of ['achter', 'voor', 'links', 'rechts']) {
@@ -423,22 +351,23 @@ window.UnitModel = (() => {
const h = Math.max(...Object.values(W).map(wallTopOf)); const h = Math.max(...Object.values(W).map(wallTopOf));
return { type: 'plat', low: h, high: h }; 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) { function applyRoofForm(unitState, form) {
const side = form.type !== 'plat' ? gableOf(form) : 'achter';
const g = gableSpan(unitState, side);
const line = formLine(form, g.span, Math.max(g.e0, g.e1), WALL_ENDS[side].left === form.dir);
setWallTopLine(unitState.walls[side].wall, g, line);
syncWallHeights(unitState, side);
}
function formLine(form, F, eMin, highAtStart) {
const low = Math.round(Math.min(LIMITS.eave, form.low)); 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 high = Math.round(Math.min(LIMITS.ridge, Math.max(low, form.high || low)));
const sloped = form.type !== 'plat' && high > low; const sloped = form.type !== 'plat' && high > low;
const side = sloped ? gableOf(form) : 'achter'; const half = F / 2;
const g = gableSpan(unitState, side), F = g.span, half = F / 2;
let line = [{ u: 0, h: low }, { u: F, h: low }]; 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 }]; if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
else if (sloped && form.type === 'mansarde') { else if (sloped && form.type === 'mansarde') {
// Knik binnen de halve gevel; altijd boven de rechte lijn goot–nok // Knik altijd boven de rechte lijn goot–nok, anders wordt het een hol dak.
// (anders geen mansarde maar een hol dak). const kIn = Math.max(eMin + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18)));
// (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 minK = low + (high - low) * kIn / half + 20;
const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH; 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))); const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK)));
@@ -446,9 +375,7 @@ window.UnitModel = (() => {
? [{ 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: 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 }]; : [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
} else if (sloped && form.type === 'wolf') { } else if (sloped && form.type === 'wolf') {
// Wolfseind: de kopgevel stopt op `cut`, waar de dakhelling die hoogte haalt. const minR = (eMin + 50) / half;
// 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 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 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; const uc = (cut - low) / (high - low) * half;
@@ -456,11 +383,9 @@ window.UnitModel = (() => {
? [{ u: 0, h: low }, { u: uc, h: cut }, { u: F - uc, h: cut }, { u: F, h: low }] ? [{ 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 }]; : [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
} else if (sloped) { } else if (sloped) {
const highLeft = WALL_ENDS[side].left === form.dir; line = [{ u: 0, h: highAtStart ? high : low }, { u: F, h: highAtStart ? low : high }];
line = [{ u: 0, h: highLeft ? high : low }, { u: F, h: highLeft ? low : high }];
} }
setWallTopLine(unitState.walls[side].wall, g, line); return line;
syncWallHeights(unitState, side);
} }
function validateState(design) { function validateState(design) {
@@ -476,5 +401,5 @@ window.UnitModel = (() => {
} }
} }
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, validateState }; return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, formLine, validateState };
})(); })();
+155 -78
View File
@@ -4,24 +4,9 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================ // Kunststof kozijnen naar de Aluplast Ideal 7000 (examples/Buiten kozijnen.pdf).
// wall/kozijn-model.js — Vulling van een opening (window.KozijnModel): // links/rechts (leaf, hinge, dir) zijn van buitenaf gezien = vanaf +z.
// kunststof kozijnen naar het voorbeeld van de Aluplast Ideal 7000 // userData.door: open/target in radialen (draaien) of meters (schuiven); de loopmodus (house.js) zet target.
// (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 = (() => { window.KozijnModel = (() => {
const COLORS = { const COLORS = {
@@ -30,47 +15,54 @@ window.KozijnModel = (() => {
antraciet: { label: 'Antraciet', ral: 'RAL 7016', hex: 0x383e42 }, antraciet: { label: 'Antraciet', ral: 'RAL 7016', hex: 0x383e42 },
zwart: { label: 'Zwart', ral: 'RAL 9005', hex: 0x151515 } zwart: { label: 'Zwart', ral: 'RAL 9005', hex: 0x151515 }
}; };
const FILL_TYPES = { geen: 'Geen (open)', raam: 'Raam (vast glas)', deur: 'Deur' }; const FILL_TYPES = { geen: 'Geen (open)', raam: 'Raam (vast glas)', deur: 'Deur', schuifdeur: 'Schuifdeur' };
const HINGES = { links: 'Links', rechts: 'Rechts' };
const SWINGS = { binnen: 'Naar binnen', buiten: 'Naar buiten' };
const SLIDE_MOUNTS = { inbouw: 'In de wand', opbouw: 'Op de wand (rail)' };
const SLIDE_SIDES = { binnen: 'Binnenzijde', buiten: 'Buitenzijde' };
const SLIDE_DIRS = { links: 'Naar links', rechts: 'Naar rechts' };
const DEFAULT_OUT = 'antraciet', DEFAULT_IN = 'wit'; const DEFAULT_OUT = 'antraciet', DEFAULT_IN = 'wit';
// Maten (mm).
const P = { const P = {
gap: 5, // stelruimte rondom gap: 5,
kader: 70, // kader in het aanzicht kader: 70,
depth: 120, // kaderdiepte depth: 120,
dorpel: 30, // deurdorpel (hoogte) dorpel: 30,
tussen: 70, // tussenstijl/-regel (deur naast zijlicht, onder bovenlicht) tussen: 70, // tussenstijl/-regel (deur naast zijlicht, onder bovenlicht)
rooster: 80, // ventilatierooster (hoogte) rooster: 80,
glass: 24, // glasdikte glass: 24,
leafW: 930, // deurblad (binnenmaat kader) als er een zijlicht naast past leafW: 930, // deurblad (binnenmaat kader) als er een zijlicht naast past
leafH: 2200, // deurblad als er een bovenlicht boven past leafH: 2200, // deurblad als er een bovenlicht boven past
leafT: 68, // deurbladdikte leafT: 68,
minLight: 250 // kleinste zijlicht/bovenlicht minLight: 200
}; };
const colorKey = (k, def) => COLORS[k] ? k : def; const colorKey = (k, def) => COLORS[k] ? k : def;
// Volledige vulling (defaults ingevuld) of null (geen vulling). const pick = (v, opts, def) => opts[v] ? v : def;
function normalize(fill) { function normalize(fill) {
if (!fill || !FILL_TYPES[fill.type] || fill.type === 'geen') return null; if (!fill || !FILL_TYPES[fill.type] || fill.type === 'geen') return null;
return { const f = {
type: fill.type, rooster: !!fill.rooster, glas: fill.glas !== false, type: fill.type, rooster: !!fill.rooster, glas: fill.glas !== false,
out: colorKey(fill.out, DEFAULT_OUT), in: colorKey(fill.in, DEFAULT_IN) out: colorKey(fill.out, DEFAULT_OUT), in: colorKey(fill.in, DEFAULT_IN)
}; };
if (f.type === 'deur') {
f.hinge = pick(fill.hinge, HINGES, 'links');
f.leaf = pick(fill.leaf, HINGES, f.hinge);
f.swing = pick(fill.swing, SWINGS, 'binnen');
f.volglas = !!fill.volglas;
}
if (f.type === 'schuifdeur') Object.assign(f, {
leaves: Number(fill.leaves) === 2 ? 2 : 1, mount: pick(fill.mount, SLIDE_MOUNTS, 'inbouw'),
side: pick(fill.side, SLIDE_SIDES, 'binnen'), dir: pick(fill.dir, SLIDE_DIRS, 'links')
});
return f;
} }
// Standaardvulling bij een openingtype (WallModel.OPENING_TYPES); kleuren
// van `like` (bijv. de vorige opening) overnemen.
function defaultFill(openingType, like) { function defaultFill(openingType, like) {
const l = normalize(like) || {}; const l = normalize(like) || {};
const type = openingType === 'deur' || openingType === 'voordeur' ? 'deur' : 'raam'; 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 }; 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) { function insetPoly(pts, d) {
const n = pts.length; const n = pts.length;
const lines = pts.map((p, i) => { const lines = pts.map((p, i) => {
@@ -87,8 +79,6 @@ window.KozijnModel = (() => {
return { x: a.x + a.dx * t, y: a.y + a.dy * t }; 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) { function clipPoly(pts, f) {
const out = []; const out = [];
pts.forEach((p, i) => { pts.forEach((p, i) => {
@@ -105,9 +95,6 @@ window.KozijnModel = (() => {
const rect = (x0, x1, y0, y1) => [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }]; 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; 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 = {}; const mats = {};
function colorMat(key) { function colorMat(key) {
const k = 'c:' + colorKey(key, DEFAULT_OUT); const k = 'c:' + colorKey(key, DEFAULT_OUT);
@@ -117,7 +104,7 @@ window.KozijnModel = (() => {
if (mats[key]) return mats[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 }); 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 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 else mats[key] = new THREE.MeshStandardMaterial({ color: 0x9a9da0, roughness: 0.3, metalness: 0.7 });
return mats[key]; return mats[key];
} }
@@ -127,8 +114,6 @@ window.KozijnModel = (() => {
(holes || []).forEach(h => s.holes.push(new THREE.Path(h.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; 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) { function slab(g, pts, holes, z0, z1, matIn, matOut, shadow) {
if (pts.length < 3 || areaOf(pts) < 1) return; if (pts.length < 3 || areaOf(pts) < 1) return;
const FOIL = matOut && matOut !== matIn ? 1 : 0; const FOIL = matOut && matOut !== matIn ? 1 : 0;
@@ -149,10 +134,7 @@ window.KozijnModel = (() => {
g.add(mesh); g.add(mesh);
}; };
// Kozijn in een opening (opgelost: w, h links, hR rechts). Geeft een // Lokaal: x vanaf links, y vanaf de onderkant, +z = buiten (mm in, meters uit).
// 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) { function build3D(op, fill, place) {
const f = normalize(fill); const f = normalize(fill);
if (!f) return null; if (!f) return null;
@@ -162,8 +144,6 @@ window.KozijnModel = (() => {
const z1 = place.zOut, z0 = z1 - depth; const z1 = place.zOut, z0 = z1 - depth;
const hR = op.hR !== undefined ? op.hR : op.h; 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]); 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 below = (q) => (p) => {
const a = q[3], b = q[2]; const a = q[3], b = q[2];
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y); return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
@@ -178,7 +158,6 @@ window.KozijnModel = (() => {
pane = insetPoly(inner, [0, 0, P.rooster, 0]); pane = insetPoly(inner, [0, 0, P.rooster, 0]);
const band = [pane[3], pane[2], inner[2], inner[3]]; 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); 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 => { [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 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]]; const slot = [b[3], b[2], a[2], a[3]];
@@ -190,8 +169,6 @@ window.KozijnModel = (() => {
return g; 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 sill = place.exterior !== false ? P.dorpel : 0;
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]); const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]); const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]);
@@ -202,20 +179,25 @@ window.KozijnModel = (() => {
const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y; const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y;
const top = below(inner); 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); 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 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);
const link = `${Math.round(op.x || 0)}|${Math.round(op.y || 0)}|${op.w}`;
if (f.type === 'schuifdeur') {
buildSlideLeaves(g, f, { ix0, ix1, iy0, topAt, z0, z1, mIn, mOut, sill, place, link, op });
return g;
}
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight; const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const lx1 = side ? ix0 + P.leafW : ix1; const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
// Bovenlicht als er boven een deurblad nog genoeg ruimte is (overal).
const lowTop = Math.min(topAt(ix0), topAt(ix1)); const lowTop = Math.min(topAt(ix0), topAt(ix1));
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight; const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
const ly1 = transom ? iy0 + P.leafH : Infinity; 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) { if (side) {
bar(region(lx1, lx1 + P.tussen, iy0, ly1)); const [t0, g0, g1] = leafR ? [la - P.tussen, ix0, la - P.tussen] : [lb, lb + P.tussen, ix1];
// Zijlicht: onderregel + glas. bar(region(t0, t0 + P.tussen, iy0, ly1));
bar(region(lx1 + P.tussen, ix1, iy0, iy0 + P.kader)); bar(region(g0, g1, iy0, iy0 + P.kader));
glass(region(lx1 + P.tussen, ix1, iy0 + P.kader, ly1)); glass(region(g0, g1, iy0 + P.kader, ly1));
} }
if (transom) { if (transom) {
bar(region(ix0, ix1, ly1, ly1 + P.tussen)); bar(region(ix0, ix1, ly1, ly1 + P.tussen));
@@ -223,31 +205,126 @@ window.KozijnModel = (() => {
} }
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer). // 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 })); const leaf = clipPoly(rect(la + 3, lb - 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; if (leaf.length < 3) return g;
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y); 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 lx0 = Math.min(...xs), lxR = Math.max(...xs), lb0 = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), ly1) - 3;
const lzOut = z1 - 15, lzIn = lzOut - P.leafT; const lzOut = z1 - 15, lzIn = lzOut - P.leafT;
let holes = null; let holes = null;
const lw = lxR - lx0; const lw = lxR - lx0;
if (f.glas && lw >= 500 && lt - lb >= 1400) { if (f.volglas && lw >= 400 && lt - lb0 >= 800) {
const gx = lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18); holes = [rect(lx0 + 110, lxR - 110, lb0 + 140, lt - 110)]; // glasdeur: smal kader rondom
const strip = rect(gx - gw / 2, gx + gw / 2, lb + 350, lt - 300); } else if (f.glas && lw >= 500 && lt - lb0 >= 1400) {
holes = [strip]; const gx = hingeR ? lxR - lw * 0.3 : lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
slab(g, strip, null, (lzIn + lzOut) / 2 - P.glass / 2, (lzIn + lzOut) / 2 + P.glass / 2, mat('glass'), null, false); holes = [rect(gx - gw / 2, gx + gw / 2, lb0 + 350, lt - 300)]; // glas: in het deurblad (hieronder)
} }
slab(g, leaf, holes, lzIn, lzOut, mIn, mOut); const outward = f.swing === 'buiten';
// Kruk aan beide zijden (scharnieren links, slot rechts). const hx = hingeR ? lxR : lx0, hz = outward ? lzOut : lzIn;
const pivot = new THREE.Group(), lg = new THREE.Group();
pivot.position.set(hx * toM, 0, hz * toM);
lg.position.set(-hx * toM, 0, -hz * toM);
pivot.add(lg);
g.add(pivot);
if (holes) slab(lg, holes[0], null, (lzIn + lzOut) / 2 - P.glass / 2, (lzIn + lzOut) / 2 + P.glass / 2, mat('glass'), null, false);
slab(lg, leaf, holes, lzIn, lzOut, mIn, mOut);
const hy = Math.min(lb0 + 1050, (lb0 + lt) / 2);
if (lw >= 400) { if (lw >= 400) {
const hx = lxR - 75, hy = Math.min(lb + 1050, (lb + lt) / 2); const kx = hingeR ? lx0 + 75 : lxR - 75, gd = hingeR ? 60 : -60;
[[lzOut, 1], [lzIn, -1]].forEach(([z, s]) => { [[lzOut, 1], [lzIn, -1]].forEach(([z, s]) => {
box(g, mat('beslag'), hx, hy - 30, z + s * 5, 34, 210, 10); // schild box(lg, mat('beslag'), kx, hy - 30, z + s * 5, 34, 210, 10); // schild
box(g, mat('beslag'), hx, hy, z + s * 30, 20, 20, 50); // nek box(lg, mat('beslag'), kx, hy, z + s * 30, 20, 20, 50); // nek
box(g, mat('beslag'), hx - 60, hy, z + s * 55, 140, 20, 20); // greep box(lg, mat('beslag'), kx + gd, hy, z + s * 55, 140, 20, 20); // greep
}); });
} }
const dir = (hingeR ? -1 : 1) * (outward ? -1 : 1);
pivot.userData.door = {
kind: 'swing', dir, max: Math.PI / 2, open: 0, target: 0, link,
base: pivot.position.x, center: new THREE.Vector3((lx0 + lxR) / 2 * toM, hy * toM, (lzIn + lzOut) / 2 * toM)
};
return g; return g;
} }
return { COLORS, FILL_TYPES, PROFILE: P, normalize, defaultFill, build3D }; const SLIDE = { overlap: 50, gap: 12, railH: 40, railD: 45, leafT: 40, frame: 70 };
function buildSlideLeaves(g, f, o) {
const n = f.leaves, opbouw = f.mount === 'opbouw';
const out = f.side === 'buiten';
const faceIn = o.place.faceIn !== undefined ? o.place.faceIn : o.z0;
const faceOut = o.place.faceOut !== undefined ? o.place.faceOut : o.z1;
const ov = opbouw ? SLIDE.overlap : -3;
const xa = o.ix0 - ov, xb = o.ix1 + ov;
const yb = o.iy0 + (o.sill ? 3 : 10);
const yt = Math.min(o.topAt(o.ix0), o.topAt(o.ix1)) + (opbouw ? SLIDE.overlap : -3);
const lt = opbouw ? SLIDE.leafT : P.leafT;
const [lz0, lz1] = opbouw
? (out ? [faceOut + SLIDE.gap, faceOut + SLIDE.gap + lt] : [faceIn - SLIDE.gap - lt, faceIn - SLIDE.gap])
: [(faceIn + faceOut) / 2 - lt / 2, (faceIn + faceOut) / 2 + lt / 2];
const lw = (xb - xa) / n;
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
dirs.forEach((dir, i) => {
const x0 = xa + i * lw + (n === 2 ? (i ? 1 : 0) : 0), x1 = xa + (i + 1) * lw - (n === 2 && !i ? 1 : 0);
const lg = new THREE.Group();
g.add(lg);
const pts = rect(x0, x1, yb, yt);
if (f.glas && x1 - x0 > 2 * SLIDE.frame + 100) {
const pane = rect(x0 + SLIDE.frame, x1 - SLIDE.frame, yb + SLIDE.frame + 30, yt - SLIDE.frame);
slab(lg, pts, [pane], lz0, lz1, colorMat(f.in), opbouw ? null : colorMat(f.out));
slab(lg, pane, null, (lz0 + lz1) / 2 - 4, (lz0 + lz1) / 2 + 4, mat('glass'), null, false);
} else {
slab(lg, pts, null, lz0, lz1, colorMat(f.in), opbouw ? null : colorMat(f.out));
}
const hx = dir < 0 ? x1 - 60 : x0 + 60, hy = Math.min(yb + 1050, (yb + yt) / 2);
if (opbouw) [lz0 - 18, lz1 + 18].forEach(z => box(lg, mat('beslag'), hx, hy, z, 20, 400, 20));
else [lz0 - 1, lz1 + 1].forEach(z => box(lg, mat('beslag'), hx, hy, z, 24, 160, 2));
lg.userData.door = {
kind: 'slide', dir, max: (x1 - x0 - (opbouw ? 2 * SLIDE.overlap : 60)) * toM, open: 0, target: 0, link: o.link,
base: 0, center: new THREE.Vector3((x0 + x1) / 2 * toM, hy * toM, (lz0 + lz1) / 2 * toM)
};
});
if (opbouw) {
const ra = n === 2 ? xa - lw : (dirs[0] < 0 ? xa - lw : xa), rb = n === 2 ? xb + lw : (dirs[0] < 0 ? xb : xb + lw);
const rz = out ? faceOut + SLIDE.railD / 2 : faceIn - SLIDE.railD / 2;
box(g, mat('beslag'), (ra + rb) / 2, yt + SLIDE.railH / 2 + 5, rz, rb - ra, SLIDE.railH, SLIDE.railD);
}
}
function planSymbol(op, fill, faces) {
const f = normalize(fill);
if (!f || f.type === 'raam') return [];
const zIn = faces.zIn, zOut = faces.zOut;
const ix0 = P.gap + P.kader, ix1 = op.w - P.gap - P.kader;
const out = [];
if (f.type === 'deur') {
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
const L = lb - la, hx = hingeR ? lb : la, s = hingeR ? -1 : 1;
const outward = f.swing === 'buiten', hz = outward ? zOut : zIn, dz = outward ? 1 : -1;
out.push({ kind: 'leaf', pts: [{ x: hx, z: hz }, { x: hx, z: hz + dz * L }] });
const arc = [];
for (let k = 0; k <= 16; k++) { const t = k / 16 * Math.PI / 2; arc.push({ x: hx + s * L * Math.cos(t), z: hz + dz * L * Math.sin(t) }); }
out.push({ kind: 'arc', pts: arc });
return out;
}
const n = f.leaves, opbouw = f.mount === 'opbouw', sideOut = f.side === 'buiten';
const ov = opbouw ? SLIDE.overlap : -3;
const xa = ix0 - ov, xb = ix1 + ov, lw = (xb - xa) / n;
const lt = opbouw ? SLIDE.leafT : P.leafT;
const [z0, z1] = opbouw
? (sideOut ? [zOut + SLIDE.gap, zOut + SLIDE.gap + lt] : [zIn - SLIDE.gap - lt, zIn - SLIDE.gap])
: [(zIn + zOut) / 2 - lt / 2, (zIn + zOut) / 2 + lt / 2];
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
const za = sideOut ? Math.max(zOut, z1) + 140 : Math.min(zIn, z0) - 140;
dirs.forEach((dir, i) => {
const x0 = xa + i * lw, x1 = x0 + lw;
out.push({ kind: 'leaf', closed: true, pts: [{ x: x0, z: z0 }, { x: x1, z: z0 }, { x: x1, z: z1 }, { x: x0, z: z1 }] });
const c = (x0 + x1) / 2, len = Math.min(500, lw * 0.6);
const a = { x: c - dir * len / 2, z: za }, b = { x: c + dir * len / 2, z: za };
out.push({ kind: 'arrow', pts: [a, b] });
out.push({ kind: 'arrow', pts: [{ x: b.x - dir * 90, z: za - 45 }, b, { x: b.x - dir * 90, z: za + 45 }] });
out.push({ kind: 'rail', closed: true, pts: [{ x: x0 + dir * lw, z: z0 }, { x: x1 + dir * lw, z: z0 }, { x: x1 + dir * lw, z: z1 }, { x: x0 + dir * lw, z: z1 }] });
});
return out;
}
return { COLORS, FILL_TYPES, HINGES, SWINGS, SLIDE_MOUNTS, SLIDE_SIDES, SLIDE_DIRS, PROFILE: P, normalize, defaultFill, build3D, planSymbol, colorMaterial: colorMat };
})(); })();
+224 -500
View File
File diff suppressed because it is too large Load Diff
+3 -40
View File
@@ -4,18 +4,8 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// wall/wall-pdf.js — ENIGE bron van het wand-tekenblad (A3 liggend):
// frame-materiaallijst, plaatmateriaal, vooraanzicht met posnums en
// maatketens, bovenaanzicht (doorsnede regelwerk + beplating), zijaanzicht
// (laagopbouw) en titelblok. Gebruikt door de losse wand-editor (1 blad) en
// door de unit (1 blad per wand in de gezamenlijke PDF).
// Plain script, geladen na wall-model.js en core/pdf-sheet.js.
// ============================================================
window.WallPdf = (() => { window.WallPdf = (() => {
// Tekent één volledig wandblad op de HUIDIGE pagina van `doc`.
// opts: { buitenMateriaal, cladding, trasraam, skinExt, project, titel, element, blad, lamps: [{ u, y }] }
function drawSheet(doc, CONFIG, calcData, opts) { function drawSheet(doc, CONFIG, calcData, opts) {
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek'; const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
const pieces = WallModel.computeBOM(CONFIG, calcData); const pieces = WallModel.computeBOM(CONFIG, calcData);
@@ -26,7 +16,6 @@ window.WallPdf = (() => {
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
// --- Frame materiaal-tabel (linksboven) ---
const tblX = [8, 26, 42, 58, 76, 92]; const tblX = [8, 26, 42, 58, 76, 92];
let ty = 14; let ty = 14;
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
@@ -41,13 +30,12 @@ window.WallPdf = (() => {
doc.line(tblX[0], ty, tblX[4] + 12, ty); doc.line(tblX[0], ty, tblX[4] + 12, ty);
ty += 4; ty += 4;
groups.forEach(g => { groups.forEach(g => {
if (ty > 232) return; // laat ruimte vrij voor materiaaltabellen eronder if (ty > 232) return;
[String(g.posnum), String(g.width), String(g.depth), String(g.length), String(g.count)] [String(g.posnum), String(g.width), String(g.depth), String(g.length), String(g.count)]
.forEach((v, i) => doc.text(v, tblX[i], ty)); .forEach((v, i) => doc.text(v, tblX[i], ty));
ty += 4.3; ty += 4.3;
}); });
// --- Inkoop balken (handelslengtes, zaagplan uit WallModel) ---
const purchase = WallModel.planPurchase(pieces); const purchase = WallModel.planPurchase(pieces);
ty += 3; ty += 3;
doc.setFontSize(9); doc.setFontSize(9);
@@ -65,7 +53,6 @@ window.WallPdf = (() => {
}); });
}); });
// --- Materiaal binnen/buiten + isolatie ---
let my = 240; let my = 240;
const midH = Math.round(WallModel.topLine(CONFIG).maxY); const midH = Math.round(WallModel.topLine(CONFIG).maxY);
doc.setFontSize(8.5); doc.setFontSize(8.5);
@@ -79,7 +66,6 @@ window.WallPdf = (() => {
doc.text(`${CONFIG.wallWidth} x ${CONFIG.sheathingThick} x ${midH} ${buitenNaam} 1`, tblX[0], my); my += 6; doc.text(`${CONFIG.wallWidth} x ${CONFIG.sheathingThick} x ${midH} ${buitenNaam} 1`, tblX[0], my); my += 6;
doc.text('Isolatie: glaswol (spouwvullend)', tblX[0], my); doc.text('Isolatie: glaswol (spouwvullend)', tblX[0], my);
if (buitenMateriaal === 'vochtdoek') { if (buitenMateriaal === 'vochtdoek') {
// Kruislat op het vochtdoek (zelfde berekening als de 3D: WallModel.computeBattens).
const bat = WallModel.computeBattens(CONFIG, opts.cladding || 'horizontaal', opts.trasraam, opts.skinExt, opts.facade, opts.skinFrom); const bat = WallModel.computeBattens(CONFIG, opts.cladding || 'horizontaal', opts.trasraam, opts.skinExt, opts.facade, opts.skinFrom);
const B = WallModel.BATTEN; const B = WallModel.BATTEN;
my += 4.5; my += 4.5;
@@ -95,12 +81,8 @@ window.WallPdf = (() => {
} }
} }
// --- Titelblok (rechtsonder) ---
PdfSheet.titleBlock(doc, opts.project, { titel: opts.titel, element: opts.element, blad: opts.blad }); PdfSheet.titleBlock(doc, opts.project, { titel: opts.titel, element: opts.element, blad: opts.blad });
// --- Hoofdaanzicht ---
// drawX1/marginBottom laten ruimte vrij voor het zijaanzicht (rechts)
// en het bovenaanzicht (onder de maatketens).
const drawX0 = 95, drawX1 = 380, drawY0 = 15, drawY1 = 225; const drawX0 = 95, drawX1 = 380, drawY0 = 15, drawY1 = 225;
const marginSides = 25, marginTop = 15, marginBottom = 68; const marginSides = 25, marginTop = 15, marginBottom = 68;
const availW = (drawX1 - drawX0) - marginSides * 2; const availW = (drawX1 - drawX0) - marginSides * 2;
@@ -126,14 +108,9 @@ window.WallPdf = (() => {
doc.setLineWidth(0.3); doc.setLineWidth(0.3);
pieces.forEach(p => { pieces.forEach(p => {
if (p.outline) { if (p.outline) {
// Werkelijke omtrek: schuine kop (volgt de daklijn, evt. met knik
// onder de nok) resp. bovenregel in verstek.
const c = p.outline.map(q => [px(q.x), py(q.y)]); const c = p.outline.map(q => [px(q.x), py(q.y)]);
c.forEach((q, i) => { const n = c[(i + 1) % c.length]; doc.line(q[0], q[1], n[0], n[1]); }); c.forEach((q, i) => { const n = c[(i + 1) % c.length]; doc.line(q[0], q[1], n[0], n[1]); });
} else if (p.kind === 'verticale balk' || p.kind === 'kreupele stijl') { } else if (p.kind === 'verticale balk' || p.kind === 'kreupele stijl') {
// Viervlak i.p.v. rechthoek: de bovenkant loopt schuin mee met de
// daklijn (yTopLeft/yTopRight kunnen verschillen), zodat zowel de
// hoogste als de laagste kant van de balk op de juiste plek komt.
const wx0 = p.x - p.width / 2, wx1 = p.x + p.width / 2; const wx0 = p.x - p.width / 2, wx1 = p.x + p.width / 2;
const corners = [ const corners = [
[px(wx0), py(p.yBottomLeft)], [px(wx1), py(p.yBottomRight)], [px(wx0), py(p.yBottomLeft)], [px(wx1), py(p.yBottomRight)],
@@ -154,16 +131,13 @@ window.WallPdf = (() => {
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
CONFIG.openings.forEach(op => { CONFIG.openings.forEach(op => {
const opXWorld = -wallWidth / 2 + op.x; const opXWorld = -wallWidth / 2 + op.x;
// Schuine bovenzijde: y0 = bovenkant links, y0r = rechts.
const x0 = px(opXWorld), y0 = py(op.y + op.h), y0r = py(op.y + op.hR), x1 = px(opXWorld + op.w), y1 = py(op.y); const x0 = px(opXWorld), y0 = py(op.y + op.h), y0r = py(op.y + op.hR), x1 = px(opXWorld + op.w), y1 = py(op.y);
[[x0, y1, x1, y1], [x1, y1, x1, y0r], [x1, y0r, x0, y0], [x0, y0, x0, y1]].forEach(l => doc.line(...l)); [[x0, y1, x1, y1], [x1, y1, x1, y0r], [x1, y0r, x0, y0], [x0, y0, x0, y1]].forEach(l => doc.line(...l));
doc.line(x0, y0, x1, y1); doc.line(x0, y0, x1, y1);
doc.line(x0, y1, x1, y0r); doc.line(x0, y1, x1, y0r);
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
doc.setFontSize(6); doc.setFontSize(6);
// Dagmaat-label BINNEN de opening zetten (net onder de bovenkant), // Label binnen de opening: erboven overlapte het met de maatketen.
// i.p.v. erboven — daar overlapte het met de maatketen/omtrek
// erboven.
doc.text(op.hR !== op.h ? `${op.w}x${op.h}/${op.hR}` : `${op.w}x${op.h}`, (x0 + x1) / 2, Math.max(y0, y0r) + 5, { align: 'center' }); doc.text(op.hR !== op.h ? `${op.w}x${op.h}/${op.hR}` : `${op.w}x${op.h}`, (x0 + x1) / 2, Math.max(y0, y0r) + 5, { align: 'center' });
}); });
@@ -180,8 +154,6 @@ 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 });
// Tussenpunten van de daklijn: stippellijn van vloer tot nok/knik
// (nok = het enige hoogste punt; mansarde-/wolfsgevel: knikken).
const inner = tl.pts.slice(1, -1); const inner = tl.pts.slice(1, -1);
inner.forEach(q => { inner.forEach(q => {
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1; const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
@@ -219,7 +191,6 @@ window.WallPdf = (() => {
doc.text(String(Math.round(openingPoints[i + 1] - openingPoints[i])), px(mid - wallWidth / 2), openY, { align: 'center' }); doc.text(String(Math.round(openingPoints[i + 1] - openingPoints[i])), px(mid - wallWidth / 2), openY, { align: 'center' });
} }
// --- Gevellampen (woning): rechthoekje met "L" op lamphoogte ---
(opts.lamps || []).forEach(l => { (opts.lamps || []).forEach(l => {
const lx = px(-wallWidth / 2 + l.u), ly = py(l.y); const lx = px(-wallWidth / 2 + l.u), ly = py(l.y);
doc.setDrawColor(...BLACK); doc.setDrawColor(...BLACK);
@@ -231,13 +202,9 @@ window.WallPdf = (() => {
doc.text('L', lx + 2, ly + 1); doc.text('L', lx + 2, ly + 1);
}); });
// --- Extra laag van de aanroeper (bijv. elektra, ElektraPdf):
// overlay(doc, pu, py), pu = wand-x (0..breedte) -> blad-mm.
if (opts.overlay) opts.overlay(doc, (u) => px(-wallWidth / 2 + u), py); if (opts.overlay) opts.overlay(doc, (u) => px(-wallWidth / 2 + u), py);
// --- Bovenaanzicht (doorsnede op stijlhoogte, zoals de voorbeeld- // Binnenzijde boven, buitenzijde onder (de kant van het vooraanzicht).
// tekeningen): binnenzijde boven, buitenzijde onder (= de kant van
// waaruit het vooraanzicht bekeken wordt). Zelfde horizontale schaal.
const sT = CONFIG.sheathingThick; const sT = CONFIG.sheathingThick;
const frameD = Math.max(CONFIG.studDepth, CONFIG.plateDepth); const frameD = Math.max(CONFIG.studDepth, CONFIG.plateDepth);
const tvY0 = floorY + 34; const tvY0 = floorY + 34;
@@ -246,7 +213,6 @@ window.WallPdf = (() => {
const tvY1 = tvFrame1 + sT * scale; const tvY1 = tvFrame1 + sT * scale;
const halfW = wallWidth / 2; const halfW = wallWidth / 2;
// Beplating, onderbroken ter plaatse van de openingen.
const skinCuts = CONFIG.openings.map(op => [op.x, op.x + op.w]).sort((a, b) => a[0] - b[0]); const skinCuts = CONFIG.openings.map(op => [op.x, op.x + op.w]).sort((a, b) => a[0] - b[0]);
const skinSegments = []; const skinSegments = [];
let skinCursor = 0; let skinCursor = 0;
@@ -259,7 +225,6 @@ window.WallPdf = (() => {
doc.rect(px(a - halfW), tvFrame1, (b - a) * scale, sT * scale, 'S'); doc.rect(px(a - halfW), tvFrame1, (b - a) * scale, sT * scale, 'S');
}); });
// Stijlen (vol + kreupel) als doorsneden: rechthoek met kruis.
doc.setDrawColor(...RED); doc.setDrawColor(...RED);
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
const studXs = [...new Set(calcData.studs.map(st => st.x))]; const studXs = [...new Set(calcData.studs.map(st => st.x))];
@@ -288,8 +253,6 @@ window.WallPdf = (() => {
doc.setFontSize(7); doc.setFontSize(7);
doc.text(String(Math.round(wallWidth)), px(0), tvDimY + 3.5, { align: 'center' }); doc.text(String(Math.round(wallWidth)), px(0), tvDimY + 3.5, { align: 'center' });
// --- Zijaanzicht (rechterkopse kant): laagopbouw binnen | regelwerk |
// buiten, met onder- en bovenregel, op dezelfde schaal.
const svX0 = 392; const svX0 = 392;
const svFrame0 = svX0 + sT * scale; const svFrame0 = svX0 + sT * scale;
const svFrame1 = svFrame0 + frameD * scale; const svFrame1 = svFrame0 + frameD * scale;
+46 -156
View File
@@ -4,21 +4,6 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================
// wall/wall.js — Wand-editor (ruwbouw compleet: regelwerk, openingen, OSB
// achterzijde, vochtdoek voorzijde, isolatie/specificaties). Ongewijzigde
// logica t.o.v. de vorige single-file versie, alleen verpakt als los te
// laden/lossen module via mount()/unmount(), en met de gedeelde geometrie-
// functies (calculateSegmentPositions, polygonArea, clip*, shapeAreaWithHoles)
// verplaatst naar core/geometry.js (moet VOOR dit bestand geladen worden).
//
// De rekenlogica (ankerresolutie, regelwerk, 3D-opbouw, specificaties) staat
// in wall/wall-model.js (WallModel) — gedeeld met de unit-editor. Dit bestand
// bevat alleen de UI: invoer, 2D-tekeningen, interactie, JSON/PDF-export.
//
// Plain script (geen ES-module) — werkt zo ook bij rechtstreeks openen van
// index.html via file://.
// ============================================================
const WALL_TEMPLATE = ` const WALL_TEMPLATE = `
<div id="app-container"> <div id="app-container">
@@ -244,24 +229,18 @@ function mountWallEditor(container, callbacks) {
const onSaved = callbacks && callbacks.onSaved; const onSaved = callbacks && callbacks.onSaved;
container.innerHTML = WALL_TEMPLATE; container.innerHTML = WALL_TEMPLATE;
// Binnen een unit volgt de wandbreedte uit de vloer (zie unit/unit.js);
// callbacks.lockWidth is dan de uitleg-tekst voor de tooltip.
if (callbacks && callbacks.lockWidth) { if (callbacks && callbacks.lockWidth) {
const widthInput = document.getElementById('inp-wall-w'); const widthInput = document.getElementById('inp-wall-w');
widthInput.disabled = true; widthInput.disabled = true;
widthInput.title = callbacks.lockWidth; widthInput.title = callbacks.lockWidth;
} }
// Binnen een woning is de richting van de gevelbekleding (voorlopig) globaal.
const lockCladding = callbacks && callbacks.lockCladding; const lockCladding = callbacks && callbacks.lockCladding;
// ... 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) { if (lockExterior && lockCladding) {
const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]'); const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]');
if (tabBtn) tabBtn.style.display = 'none'; if (tabBtn) tabBtn.style.display = 'none';
} }
// Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand.
if (callbacks && callbacks.lockSpacing) { if (callbacks && callbacks.lockSpacing) {
['inp-min-sp', 'inp-max-sp'].forEach(id => { ['inp-min-sp', 'inp-max-sp'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -269,75 +248,44 @@ function mountWallEditor(container, callbacks) {
el.title = callbacks.lockSpacing; el.title = callbacks.lockSpacing;
}); });
} }
// Binnen een woning: delen van de wand die op een naad met een andere unit
// vallen (en dus vervallen), als [vanaf-links, tot] in mm.
const seamCuts = (callbacks && callbacks.seamCuts) || []; const seamCuts = (callbacks && callbacks.seamCuts) || [];
// Binnen een woning: gevellampen op deze wand ({ u vanaf links, y }), zie LightingModel.
const lampMarks = (callbacks && callbacks.lamps) || []; const lampMarks = (callbacks && callbacks.lamps) || [];
// ============================================================
// STATE
// ============================================================
// Dichtheden/gewichten: zie WallModel (wall/wall-model.js).
let CONFIG = {}; let CONFIG = {};
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; let wallTopPts = null;
// Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset, fill? } // offset: left = vanaf de linker wandrand, right = vanaf de rechter, middle = t.o.v. het midden (+ = rechts).
// fill = vulling (kozijn, KozijnModel); zonder fill blijft hij open. // hR = hoogte rechts bij een schuine bovenzijde; de onderkant is altijd waterpas.
// hR = hoogte rechts (schuine bovenzijde); zonder hR is de bovenkant
// recht en is h de hoogte. De onderkant is altijd waterpas.
// 'offset' betekent afhankelijk van anchor:
// left -> afstand vanaf linker wandrand tot linkerkant opening
// right -> afstand vanaf rechter wandrand tot rechterkant opening
// 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, fill: KozijnModel.defaultFill('raam') } { id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }
]; ];
let openingIdCounter = 2; let openingIdCounter = 2;
// ------------------------------------------------------------
// Interactieve selectie/verslepen van openingen in het vooraanzicht
// (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van
// de invoervelden. 9 sleeppunten: 4 hoeken, 4 zijden en het midden
// (verplaatsen). N/Z wijzigen de hoogte, O/W de breedte. De bovenste
// hoeken (NW/NO) verslepen ook alleen hun eigen kant in de hoogte: zo
// ontstaat een schuine bovenzijde. Onderin blijft het altijd waterpas
// (ZW/ZO: breedte + onderkant).
// ------------------------------------------------------------
let selectedOpeningId = null; let selectedOpeningId = null;
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH, startHR } let dragInfo = null;
let frontView = { scale: 1, centerX: 0, floorY: 0 }; // bijgewerkt door draw2DFront() let frontView = { scale: 1, centerX: 0, floorY: 0 };
let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront() let frontOpeningRects = [];
let frontHandleRects = []; // idem, alleen voor de geselecteerde opening let frontHandleRects = [];
function snap50(mm) { return Math.round(mm / 50) * 50; } function snap50(mm) { return Math.round(mm / 50) * 50; }
// Zet offset (en evt. breedte) zodat de opening's linkerrand op
// `leftLocalX` komt te liggen met breedte `w`, ongeacht welk ankertype
// actief is — dit is de kern die verslepen/resizen mogelijk maakt zonder
// dat het ankertype zelf hoeft te veranderen.
function applyOpeningPosition(op, leftLocalX, w, wallWidth) { function applyOpeningPosition(op, leftLocalX, w, wallWidth) {
if (op.anchor === 'right') op.offset = wallWidth - leftLocalX - w; if (op.anchor === 'right') op.offset = wallWidth - leftLocalX - w;
else if (op.anchor === 'middle') op.offset = leftLocalX - (wallWidth - w) / 2; else if (op.anchor === 'middle') op.offset = leftLocalX - (wallWidth - w) / 2;
else op.offset = leftLocalX; // 'left' else op.offset = leftLocalX;
op.w = w; op.w = w;
} }
const isSloped = (op) => op.hR !== undefined; const isSloped = (op) => op.hR !== undefined;
// 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.fill ? { fill: Object.assign({}, o.fill) } : {}); 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}"]`);
if (!card) return; if (!card) return;
// Schuin aan/uit veranderd (hoek slepen): kaart opnieuw opbouwen.
if (!!card.querySelector('input[data-field="hR"]') !== isSloped(op)) { renderOpeningsList(); return; } if (!!card.querySelector('input[data-field="hR"]') !== isSloped(op)) { renderOpeningsList(); return; }
['w', 'h', 'hR', 'y', 'offset'].forEach(field => { ['w', 'h', 'hR', 'y', 'offset'].forEach(field => {
const input = card.querySelector(`input[data-field="${field}"]`); const input = card.querySelector(`input[data-field="${field}"]`);
@@ -362,9 +310,6 @@ function mountWallEditor(container, callbacks) {
btn.addEventListener('click', () => switchTab(btn.dataset.tab)); btn.addEventListener('click', () => switchTab(btn.dataset.tab));
}); });
// ============================================================
// OPENINGEN: UI (lijst met kaarten, ankerkeuze, toevoegen/verwijderen)
// ============================================================
function anchorOffsetLabel(anchor) { function anchorOffsetLabel(anchor) {
if (anchor === 'right') return 'Afstand tot rechts'; if (anchor === 'right') return 'Afstand tot rechts';
if (anchor === 'middle') return 'Verschuiving t.o.v. midden'; if (anchor === 'middle') return 'Verschuiving t.o.v. midden';
@@ -389,7 +334,7 @@ function mountWallEditor(container, callbacks) {
} }
function toggleFullscreen3D() { function toggleFullscreen3D() {
UI.cycle3D(document.getElementById('app-container')); // T: standaard -> 3D groot -> alleen render UI.cycle3D(document.getElementById('app-container'));
requestAnimationFrame(onWindowResize); requestAnimationFrame(onWindowResize);
} }
@@ -433,16 +378,7 @@ function mountWallEditor(container, callbacks) {
updateAll(); updateAll();
} }
// Horizontaal spiegelen (Bewerken-menu): wisselt links/rechts om. De // Verticaal spiegelen bewust niet: een wand mag onderin niet scheef worden (menu-item blijft uit).
// hoogte-links/-rechts worden verwisseld (de schuine kant "klapt om"),
// en elke opening wordt zo herankerd dat 'ie fysiek op dezelfde plek
// blijft t.o.v. zijn (nu omgewisselde) referentierand:
// - anchor 'left' <-> 'right', offset ONGEWIJZIGD (het is en blijft
// een afstand-tot-een-rand, alleen de rand zelf wisselt)
// - anchor 'middle': offset wordt genegeerd (spiegelbeeld t.o.v. midden)
// Verticaal spiegelen is bewust niet geïmplementeerd: een wand mag aan de
// onderzijde niet scheef worden, dus dat menu-item blijft permanent
// disabled (zie index.html/app.js).
function mirrorHorizontal() { function mirrorHorizontal() {
const hLeftInput = document.getElementById('inp-wall-h-left'); const hLeftInput = document.getElementById('inp-wall-h-left');
const hRightInput = document.getElementById('inp-wall-h-right'); const hRightInput = document.getElementById('inp-wall-h-right');
@@ -452,7 +388,6 @@ function mountWallEditor(container, callbacks) {
if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse(); 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.
if (isSloped(o)) o = { ...o, h: o.hR, hR: o.h }; if (isSloped(o)) o = { ...o, h: o.hR, hR: o.h };
if (o.anchor === 'left') return { ...o, anchor: 'right' }; if (o.anchor === 'left') return { ...o, anchor: 'right' };
if (o.anchor === 'right') return { ...o, anchor: 'left' }; if (o.anchor === 'right') return { ...o, anchor: 'left' };
@@ -465,7 +400,6 @@ function mountWallEditor(container, callbacks) {
setIoStatus('Horizontaal gespiegeld.', 'ok'); setIoStatus('Horizontaal gespiegeld.', 'ok');
} }
// Vulling (kozijn, KozijnModel): type, rooster/glasstrook en kleuren.
function fillControls(op) { function fillControls(op) {
const f = KozijnModel.normalize(op.fill); const f = KozijnModel.normalize(op.fill);
const type = f ? f.type : 'geen'; const type = f ? f.type : 'geen';
@@ -475,6 +409,10 @@ function mountWallEditor(container, callbacks) {
<label>${label}</label> <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><label class="ui-switch"><input type="checkbox" data-field="${field}" data-id="${op.id}" ${on ? 'checked' : ''}><span></span></label></div>
</div>`; </div>`;
const pick = (field, label, entries, cur, title) => `<div class="control-group"${title ? ` title="${title}"` : ''}>
<label>${label}</label>
<div><select data-field="${field}" data-id="${op.id}">${opts(Object.entries(entries), cur)}</select></div>
</div>`;
return `<div class="controls-grid opening-fill"> return `<div class="controls-grid opening-fill">
<div> <div>
<div class="control-group"> <div class="control-group">
@@ -483,6 +421,15 @@ function mountWallEditor(container, callbacks) {
</div> </div>
${type === 'raam' ? sw('fillRooster', 'Rooster', f.rooster, 'Ventilatierooster bovenin het kozijn') : ''} ${type === 'raam' ? sw('fillRooster', 'Rooster', f.rooster, 'Ventilatierooster bovenin het kozijn') : ''}
${type === 'deur' ? sw('fillGlas', 'Glasstrook', f.glas, 'Glasstrook in het deurblad') : ''} ${type === 'deur' ? sw('fillGlas', 'Glasstrook', f.glas, 'Glasstrook in het deurblad') : ''}
${type === 'schuifdeur' ? sw('fillGlas', 'Glazen blad', f.glas, 'Glas in een smal kader; uit = dicht blad') : ''}
${type === 'deur' ? sw('fillVolglas', 'Glasdeur', f.volglas, 'Glas in het hele blad (smal kader)') +
pick('fillLeaf', 'Blad', KozijnModel.HINGES, f.leaf, 'Kant van het deurblad (van buitenaf gezien) als er een zijlicht naast past') +
pick('fillHinge', 'Scharnier', KozijnModel.HINGES, f.hinge, 'Van buitenaf gezien. Openslaande deuren: twee deuren naast elkaar, bladen naar het midden, scharnieren buiten') +
pick('fillSwing', 'Draait', KozijnModel.SWINGS, f.swing) : ''}
${type === 'schuifdeur' ? pick('fillLeaves', 'Bladen', { 1: '1 blad', 2: '2 bladen (uit elkaar)' }, String(f.leaves)) +
pick('fillMount', 'Montage', KozijnModel.SLIDE_MOUNTS, f.mount) +
(f.mount === 'opbouw' ? pick('fillSide', 'Rail aan', KozijnModel.SLIDE_SIDES, f.side, 'Buitenzijde = +z (bij een binnenwand de "buiten"-kant)') : '') +
(f.leaves === 1 ? pick('fillDir', 'Schuift', KozijnModel.SLIDE_DIRS, f.dir, 'Van buitenaf gezien') : '') : ''}
</div> </div>
<div> <div>
${f ? `<div class="control-group"> ${f ? `<div class="control-group">
@@ -496,7 +443,6 @@ function mountWallEditor(container, callbacks) {
</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; }; const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; };
function renderOpeningsList() { function renderOpeningsList() {
@@ -563,8 +509,6 @@ function mountWallEditor(container, callbacks) {
highlightSelectedCard(); highlightSelectedCard();
} }
// Klik op de titelbalk van een kaart selecteert 'm ook (symmetrisch met
// klikken op de opening in het vooraanzicht).
document.getElementById('openings-list').addEventListener('click', (e) => { document.getElementById('openings-list').addEventListener('click', (e) => {
const header = e.target.closest('.opening-card-header'); const header = e.target.closest('.opening-card-header');
if (header && !e.target.closest('.opening-remove-btn')) { if (header && !e.target.closest('.opening-remove-btn')) {
@@ -575,8 +519,6 @@ function mountWallEditor(container, callbacks) {
} }
}); });
// Event delegation: 1 listener voor alle inputs/knoppen in de openingen-lijst,
// in plaats van opnieuw listeners koppelen na elke re-render.
document.getElementById('openings-list').addEventListener('input', (e) => { document.getElementById('openings-list').addEventListener('input', (e) => {
const id = parseInt(e.target.dataset.id, 10); const id = parseInt(e.target.dataset.id, 10);
const field = e.target.dataset.field; const field = e.target.dataset.field;
@@ -584,7 +526,6 @@ 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 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;
@@ -594,18 +535,20 @@ function mountWallEditor(container, callbacks) {
} else if (field === 'fill') { } else if (field === 'fill') {
const v = e.target.value; const v = e.target.value;
if (v === 'geen') delete op.fill; 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 }); else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' || v === 'schuifdeur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v });
renderOpeningsList(); renderOpeningsList();
} else if (/^fill(Rooster|Glas|Out|In)$/.test(field)) { } else if (/^fill(Rooster|Glas|Volglas|Out|In|Hinge|Leaf|Swing|Leaves|Mount|Side|Dir)$/.test(field)) {
if (!op.fill) return; if (!op.fill) return;
const key = { fillRooster: 'rooster', fillGlas: 'glas', fillOut: 'out', fillIn: 'in' }[field]; const key = { fillRooster: 'rooster', fillGlas: 'glas', fillVolglas: 'volglas', fillOut: 'out', fillIn: 'in', fillHinge: 'hinge', fillLeaf: 'leaf', fillSwing: 'swing',
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : e.target.value; fillLeaves: 'leaves', fillMount: 'mount', fillSide: 'side', fillDir: 'dir' }[field];
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : key === 'leaves' ? Number(e.target.value) : e.target.value;
if (key === 'leaves' || key === 'mount') renderOpeningsList();
} 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();
} else if (field === 'anchor') { } else if (field === 'anchor') {
op.anchor = e.target.value; op.anchor = e.target.value;
renderOpeningsList(); // label van 'offset' hangt af van anchor renderOpeningsList();
} else { } else {
op[field] = parseFloat(e.target.value) || 0; op[field] = parseFloat(e.target.value) || 0;
} }
@@ -620,9 +563,6 @@ function mountWallEditor(container, callbacks) {
}); });
document.getElementById('btn-add-opening').addEventListener('click', addOpening); document.getElementById('btn-add-opening').addEventListener('click', addOpening);
// Bovenkant evenwijdig aan de daklijn: hoogte rechts = hoogte links +
// het verschil in daklijn tussen de rechter- en linkerrand (op de
// opgeloste positie; met een nok ertussen het rechte stuk ertussen).
function followRoof(id) { function followRoof(id) {
const op = openings.find(o => o.id === id); const op = openings.find(o => o.id === id);
const res = (CONFIG.openings || []).find(o => o.id === id); const res = (CONFIG.openings || []).find(o => o.id === id);
@@ -633,10 +573,6 @@ function mountWallEditor(container, callbacks) {
updateAll(); updateAll();
} }
// ============================================================
// JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters +
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug.
// ============================================================
function setIoStatus(msg, cls) { function setIoStatus(msg, cls) {
const el = document.getElementById('io-status'); const el = document.getElementById('io-status');
el.textContent = msg; el.textContent = msg;
@@ -651,7 +587,6 @@ function mountWallEditor(container, callbacks) {
heightAll: parseFloat(document.getElementById('inp-wall-h-all').value), heightAll: parseFloat(document.getElementById('inp-wall-h-all').value),
heightLeft: parseFloat(document.getElementById('inp-wall-h-left').value), heightLeft: parseFloat(document.getElementById('inp-wall-h-left').value),
heightRight: parseFloat(document.getElementById('inp-wall-h-right').value), heightRight: parseFloat(document.getElementById('inp-wall-h-right').value),
// 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, 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),
@@ -667,7 +602,7 @@ function mountWallEditor(container, callbacks) {
function getPureState() { function getPureState() {
return { return {
wall: collectWallParams(), wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, rabatColor }, materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor },
openings: openings.map(rawOpening) openings: openings.map(rawOpening)
}; };
} }
@@ -692,9 +627,10 @@ function mountWallEditor(container, callbacks) {
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;
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen) stone = (state.materials && state.materials.stone) || null;
rabatColor = (state.materials && state.materials.rabatColor) || null; // woning-instelling (rabat) stuc = (state.materials && state.materials.stuc) || null;
rabatColor = (state.materials && state.materials.rabatColor) || null;
syncMaterialButtons(); syncMaterialButtons();
openings = state.openings.map(rawOpening); openings = state.openings.map(rawOpening);
@@ -705,10 +641,6 @@ function mountWallEditor(container, callbacks) {
updateAll(); updateAll();
} }
// ------------------------------------------------------------
// PDF-export: het tekenblad zelf komt uit WallPdf (wall/wall-pdf.js),
// gedeeld met de unit-PDF.
// ------------------------------------------------------------
function generateWallPdf() { function generateWallPdf() {
recalculate(); recalculate();
const doc = PdfSheet.createDoc(); const doc = PdfSheet.createDoc();
@@ -784,11 +716,6 @@ function mountWallEditor(container, callbacks) {
e.target.value = ''; e.target.value = '';
}); });
// ============================================================
// OPENINGEN: ankerresolutie + overlap-handling — zie
// WallModel.resolveOpenings (wall/wall-model.js).
// ============================================================
function updateOpeningWarnings(resolvedOpenings) { function updateOpeningWarnings(resolvedOpenings) {
const anyOverflow = resolvedOpenings.some(o => o.overflowing); const anyOverflow = resolvedOpenings.some(o => o.overflowing);
const globalWarn = document.getElementById('openings-global-warning'); const globalWarn = document.getElementById('openings-global-warning');
@@ -842,7 +769,6 @@ function mountWallEditor(container, callbacks) {
document.getElementById('lbl-wall-h-left').textContent = peakOn ? 'Goothoogte links' : 'Hoogte links'; document.getElementById('lbl-wall-h-left').textContent = peakOn ? 'Goothoogte links' : 'Hoogte links';
document.getElementById('lbl-wall-h-right').textContent = peakOn ? 'Goothoogte rechts' : 'Hoogte rechts'; document.getElementById('lbl-wall-h-right').textContent = peakOn ? 'Goothoogte rechts' : 'Hoogte rechts';
// Afronding/fallbacks + ankerresolutie van de openingen: WallModel.
CONFIG = WallModel.buildConfig({ CONFIG = WallModel.buildConfig({
width: wallWidth, width: wallWidth,
heightLeft: hLeft, heightLeft: hLeft,
@@ -868,7 +794,6 @@ function mountWallEditor(container, callbacks) {
setSpecAll('roof-angle', calcData.angleDeg + '°'); setSpecAll('roof-angle', calcData.angleDeg + '°');
} }
// --- 2D CANVAS RENDERER: BOVENAANZICHT ---
const canvas2DTop = document.getElementById('canvas-2d-top'); const canvas2DTop = document.getElementById('canvas-2d-top');
const ctx = canvas2DTop.getContext('2d'); const ctx = canvas2DTop.getContext('2d');
@@ -916,8 +841,6 @@ function mountWallEditor(container, callbacks) {
ctx.strokeRect(screenX, screenY, studWidthPx, studDepthPx); ctx.strokeRect(screenX, screenY, studWidthPx, studDepthPx);
}; };
// Bovenaanzicht: één doorsnede per stijlpositie (kreupel alleen als er
// op die plek geen volle stijl staat).
const fullXs = new Set(calcData.studs.filter(st => st.kind !== 'kreupel').map(st => st.x)); const fullXs = new Set(calcData.studs.filter(st => st.kind !== 'kreupel').map(st => st.x));
[...new Set(calcData.studs.map(st => st.x))].forEach(x => drawStud2D(x, !fullXs.has(x))); [...new Set(calcData.studs.map(st => st.x))].forEach(x => drawStud2D(x, !fullXs.has(x)));
@@ -941,7 +864,6 @@ function mountWallEditor(container, callbacks) {
}); });
} }
// --- 2D CANVAS RENDERER: VOORAANZICHT (ZUID) ---
const canvas2DFront = document.getElementById('canvas-2d-front'); const canvas2DFront = document.getElementById('canvas-2d-front');
const ctxFront = canvas2DFront.getContext('2d'); const ctxFront = canvas2DFront.getContext('2d');
@@ -975,9 +897,6 @@ function mountWallEditor(container, callbacks) {
for (let x = 0; x < w; x += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(x, 0); ctxFront.lineTo(x, h); ctxFront.stroke(); } for (let x = 0; x < w; x += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(x, 0); ctxFront.lineTo(x, h); ctxFront.stroke(); }
for (let y = 0; y < h; y += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(0, y); ctxFront.lineTo(w, y); ctxFront.stroke(); } for (let y = 0; y < h; y += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(0, y); ctxFront.lineTo(w, y); ctxFront.stroke(); }
// Referentielijnen vochtdoek-banen (1500mm h.o.h. vanaf de vloer).
// Zelfde uitsparingen als de fill (WallModel.skinSegmentsAtY): de
// lijnen stoppen bij openingen en bij de (schuine) bovenrand.
ctxFront.strokeStyle = '#555'; ctxFront.strokeStyle = '#555';
ctxFront.setLineDash([4, 4]); ctxFront.setLineDash([4, 4]);
for (let bandY = 1500; buitenMateriaal === 'vochtdoek' && bandY < maxRoofMM; bandY += 1500) { for (let bandY = 1500; buitenMateriaal === 'vochtdoek' && bandY < maxRoofMM; bandY += 1500) {
@@ -990,7 +909,6 @@ function mountWallEditor(container, callbacks) {
} }
ctxFront.setLineDash([]); ctxFront.setLineDash([]);
// Bovenkant trasraam (cement-gebonden plaat, woning-instelling).
if (buitenMateriaal === 'vochtdoek' && trasraam && trasraam.top > 0) { if (buitenMateriaal === 'vochtdoek' && trasraam && trasraam.top > 0) {
ctxFront.strokeStyle = '#a0a3a8'; ctxFront.strokeStyle = '#a0a3a8';
ctxFront.lineWidth = 1.5; ctxFront.lineWidth = 1.5;
@@ -1005,7 +923,6 @@ function mountWallEditor(container, callbacks) {
ctxFront.fillText('trasraam', toCanvasX(-halfWMM) + 4, toCanvasY(trasraam.top) - 3); ctxFront.fillText('trasraam', toCanvasX(-halfWMM) + 4, toCanvasY(trasraam.top) - 3);
} }
// Wand-omtrek (incl. schuine bovenrand)
ctxFront.strokeStyle = '#666'; ctxFront.strokeStyle = '#666';
ctxFront.lineWidth = 2; ctxFront.lineWidth = 2;
ctxFront.beginPath(); ctxFront.beginPath();
@@ -1015,9 +932,6 @@ function mountWallEditor(container, callbacks) {
ctxFront.closePath(); ctxFront.closePath();
ctxFront.stroke(); ctxFront.stroke();
// Onder- en bovenregel als volwaardig gevuld vlak (was alleen de
// omtreklijn hierboven — dat liet lege ruimtes zien i.p.v. de
// daadwerkelijke leggers).
ctxFront.fillStyle = '#c29b62'; ctxFront.fillStyle = '#c29b62';
ctxFront.strokeStyle = '#8a6836'; ctxFront.strokeStyle = '#8a6836';
ctxFront.lineWidth = 1; ctxFront.lineWidth = 1;
@@ -1034,8 +948,6 @@ function mountWallEditor(container, callbacks) {
ctxFront.stroke(); ctxFront.stroke();
}); });
// X/Y-assen (lijn + label), altijd binnen het canvas gehouden zodat er
// niks buiten het zichtbare venster valt. Stijl: core/canvas-labels.js.
const xAxisY = Math.min(h - 6, toCanvasY(0) + 16); const xAxisY = Math.min(h - 6, toCanvasY(0) + 16);
drawCanvasDim(ctxFront, toCanvasX(-halfWMM), xAxisY, toCanvasX(halfWMM), xAxisY, drawCanvasDim(ctxFront, toCanvasX(-halfWMM), xAxisY, toCanvasX(halfWMM), xAxisY,
'X: ' + Math.round(wallWidthMM) + 'mm', (toCanvasX(-halfWMM) + toCanvasX(halfWMM)) / 2, Math.min(h - 2, xAxisY + 11)); 'X: ' + Math.round(wallWidthMM) + 'mm', (toCanvasX(-halfWMM) + toCanvasX(halfWMM)) / 2, Math.min(h - 2, xAxisY + 11));
@@ -1047,7 +959,6 @@ 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);
// Tussenpunten van de daklijn (nok = het enige hoogste punt, anders knik).
const inner = tl.pts.slice(1, -1); const inner = tl.pts.slice(1, -1);
inner.forEach(q => { inner.forEach(q => {
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1; const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
@@ -1058,7 +969,6 @@ function mountWallEditor(container, callbacks) {
ctxFront.textAlign = 'start'; ctxFront.textAlign = 'start';
}); });
// Stijlstuk van y0 tot y1 (of tot de daklijn), zie WallModel.computeFrame.
const drawStudFront = (st) => { const drawStudFront = (st) => {
const xLeft = st.x - CONFIG.studWidth / 2; const xLeft = st.x - CONFIG.studWidth / 2;
const xRight = st.x + CONFIG.studWidth / 2; const xRight = st.x + CONFIG.studWidth / 2;
@@ -1084,8 +994,8 @@ function mountWallEditor(container, callbacks) {
const x0 = toCanvasX(opLeftMM); const x0 = toCanvasX(opLeftMM);
const x1 = toCanvasX(opLeftMM + op.w); const x1 = toCanvasX(opLeftMM + op.w);
const y0 = toCanvasY(op.y); const y0 = toCanvasY(op.y);
const y1 = toCanvasY(op.y + op.h); // bovenkant links const y1 = toCanvasY(op.y + op.h);
const y1r = toCanvasY(op.y + op.hR); // bovenkant rechts (schuine bovenzijde) const y1r = toCanvasY(op.y + op.hR);
const isSelected = op.id === selectedOpeningId; const isSelected = op.id === selectedOpeningId;
frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, y1r, localX: op.x, y: op.y, w: op.w, h: op.h, hR: op.hR }); frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, y1r, localX: op.x, y: op.y, w: op.w, h: op.h, hR: op.hR });
@@ -1128,7 +1038,6 @@ function mountWallEditor(container, callbacks) {
ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff'; ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff';
ctxFront.strokeStyle = '#0a1a2a'; ctxFront.strokeStyle = '#0a1a2a';
ctxFront.beginPath(); ctxFront.beginPath();
// Midden (verplaatsen) rond, de rest vierkant.
if (hd.handle === 'c') ctxFront.arc(hd.cx, hd.cy, 5, 0, Math.PI * 2); if (hd.handle === 'c') ctxFront.arc(hd.cx, hd.cy, 5, 0, Math.PI * 2);
else ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10); else ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
ctxFront.fill(); ctxFront.fill();
@@ -1139,7 +1048,6 @@ function mountWallEditor(container, callbacks) {
drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM); drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM);
// Gevellampen (buitenverlichting woning)
lampMarks.forEach(l => { lampMarks.forEach(l => {
const x = toCanvasX(-halfWMM + l.u), y = toCanvasY(l.y); const x = toCanvasX(-halfWMM + l.u), y = toCanvasY(l.y);
ctxFront.fillStyle = '#ffd36b'; ctxFront.strokeStyle = '#1a1a1a'; ctxFront.lineWidth = 1; ctxFront.fillStyle = '#ffd36b'; ctxFront.strokeStyle = '#1a1a1a'; ctxFront.lineWidth = 1;
@@ -1148,8 +1056,6 @@ function mountWallEditor(container, callbacks) {
}); });
} }
// Naad-zones (wand vervalt daar, zie woning) als gearceerde overlay.
// Met upTo (mm): opbouw boven het lagere dak, alleen gearceerd tot daar.
function drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM) { function drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM) {
seamCuts.forEach(([u0, u1, upTo]) => { seamCuts.forEach(([u0, u1, upTo]) => {
const x0 = toCanvasX(-halfWMM + u0), x1 = toCanvasX(-halfWMM + u1); const x0 = toCanvasX(-halfWMM + u0), x1 = toCanvasX(-halfWMM + u1);
@@ -1180,7 +1086,6 @@ function mountWallEditor(container, callbacks) {
return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8); return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8);
} }
function frontOpeningAt(mx, my) { function frontOpeningAt(mx, my) {
// Binnen de (evt. schuine) omtrek: canvas-y loopt omlaag.
return frontOpeningRects.find(r => { return frontOpeningRects.find(r => {
if (mx < r.x0 || mx > r.x1) return false; if (mx < r.x0 || mx > r.x1) return false;
const yTop = r.y1 + (mx - r.x0) / ((r.x1 - r.x0) || 1) * (r.y1r - r.y1); const yTop = r.y1 + (mx - r.x0) / ((r.x1 - r.x0) || 1) * (r.y1r - r.y1);
@@ -1242,22 +1147,18 @@ function mountWallEditor(container, callbacks) {
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth); applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
op.y = Math.max(0, snap50(dragInfo.startY + dyMM)); op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
} else if (dragInfo.type === 'resize') { } else if (dragInfo.type === 'resize') {
// Samengestelde handles: 'nw' = bovenkant links + linkerrand, enz.
const hd = dragInfo.handle; const hd = dragInfo.handle;
const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op); const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
if (hd === 'n') { if (hd === 'n') {
// Hele bovenkant omhoog/omlaag (helling blijft).
const dh = snap50(dyMM); const dh = snap50(dyMM);
op.h = Math.max(400, dragInfo.startH + dh); op.h = Math.max(400, dragInfo.startH + dh);
if (sloped) op.hR = Math.max(400, dragInfo.startHR + dh); if (sloped) op.hR = Math.max(400, dragInfo.startHR + dh);
} else if (hd === 'nw' || hd === 'ne') { } else if (hd === 'nw' || hd === 'ne') {
// Eén bovenhoek: alleen die kant in de hoogte -> schuine bovenzijde.
op.h = hd === 'nw' ? Math.max(400, snap50(dragInfo.startH + dyMM)) : dragInfo.startH; op.h = hd === 'nw' ? Math.max(400, snap50(dragInfo.startH + dyMM)) : dragInfo.startH;
op.hR = hd === 'ne' ? Math.max(400, snap50(dragInfo.startHR + dyMM)) : dragInfo.startHR; op.hR = hd === 'ne' ? Math.max(400, snap50(dragInfo.startHR + dyMM)) : dragInfo.startHR;
if (op.h === op.hR) delete op.hR; // weer recht if (op.h === op.hR) delete op.hR;
} }
if (hd === 's' || hd === 'sw' || hd === 'se') { if (hd === 's' || hd === 'sw' || hd === 'se') {
// Onderkant; de bovenkant blijft staan (onderin altijd waterpas).
const topL = dragInfo.startY + dragInfo.startH, topR = dragInfo.startY + dragInfo.startHR; const topL = dragInfo.startY + dragInfo.startH, topR = dragInfo.startY + dragInfo.startHR;
const newY = Math.min(snap50(dragInfo.startY + dyMM), Math.min(topL, topR) - 400); const newY = Math.min(snap50(dragInfo.startY + dyMM), Math.min(topL, topR) - 400);
op.y = Math.max(0, newY); op.y = Math.max(0, newY);
@@ -1343,7 +1244,6 @@ function mountWallEditor(container, callbacks) {
draw2DSide(); draw2DSide();
} }
// --- THREE.JS 3D RENDERER ---
const pane3D = document.getElementById('pane-3d'); const pane3D = document.getElementById('pane-3d');
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a); scene.background = new THREE.Color(0x1a1a1a);
@@ -1376,16 +1276,14 @@ function mountWallEditor(container, callbacks) {
const wallMaterials = WallModel.createMaterials(); const wallMaterials = WallModel.createMaterials();
// Buitenzijde-materiaalkeuze (tab 3): 'vochtdoek' of 'cementplaat', // Alleen knoppen met data-material/-cladding: de "Opslaan"-knop van Project eigenschappen heeft ook de klasse material-toggle-btn.
// wederzijds exclusief. Binnenzijde is altijd spaanplaat (geen alternatief).
// (Alleen knoppen met data-material/-cladding: de "Opslaan"-knop van
// Project eigenschappen heeft ook de klasse material-toggle-btn.)
let buitenMateriaal = 'vochtdoek'; let buitenMateriaal = 'vochtdoek';
let cladding = 'horizontaal'; // richting gevelbekleding (alleen bij vochtdoek) let cladding = 'horizontaal';
let trasraam = null; // { bottom, top } mm, komt uit de woning-instellingen (WallModel.trasraamFor) let trasraam = null;
let stone = null; // steencode bij cementplaat, komt uit de woning-instellingen let stone = null;
let rabatColor = null; // kleur rabat (WallModel.RABAT_COLORS), komt uit de woning-instellingen let stuc = null;
let stage = BuildStages.FINAL; // bouwfase van de 3D (schuif linksonder) let rabatColor = null;
let stage = BuildStages.FINAL;
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));
@@ -1402,7 +1300,6 @@ function mountWallEditor(container, callbacks) {
let spaanplaatVisible = true; let spaanplaatVisible = true;
let buitenVisible = true; let buitenVisible = true;
// Weergave-filter (core/view-filter.js): beplating binnen/buiten + isolatie.
createViewFilter(document.getElementById('wall-toolbar-3d'), [ createViewFilter(document.getElementById('wall-toolbar-3d'), [
{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'binnen', label: 'Beplating binnenzijde' },
{ key: 'buiten', label: 'Beplating buitenzijde' } { key: 'buiten', label: 'Beplating buitenzijde' }
@@ -1411,7 +1308,6 @@ function mountWallEditor(container, callbacks) {
buitenVisible = v.buiten; buitenVisible = v.buiten;
update3DScene(); update3DScene();
}); });
// Omgeving (core/skybox.js: geen/dag/nacht); de wand staat op de tegelvloer.
let skyMode = Skybox.getMode(); let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene); const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => { const applySky = () => {
@@ -1421,26 +1317,21 @@ function mountWallEditor(container, callbacks) {
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(); }); BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
// 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld
// met de unit-editor).
let lastWallStats = null; let lastWallStats = null;
function update3DScene() { function update3DScene() {
if (wallGroup3D) scene.remove(wallGroup3D); if (wallGroup3D) scene.remove(wallGroup3D);
const result = WallModel.build3D(CONFIG, calcData, { const result = WallModel.build3D(CONFIG, calcData, {
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, rabatColor, spaanplaatVisible, buitenVisible, stage materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, stage
}); });
wallGroup3D = result.group; wallGroup3D = result.group;
lastWallStats = result.stats; lastWallStats = result.stats;
scene.add(wallGroup3D); scene.add(wallGroup3D);
applySky(); // schaduw van de nieuwe wand applySky();
updateSpecificaties(); updateSpecificaties();
} }
// ============================================================
// SPECIFICATIES (tab 3)
// ============================================================
function fmt(n, decimals = 1) { function fmt(n, decimals = 1) {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
} }
@@ -1474,7 +1365,6 @@ function mountWallEditor(container, callbacks) {
setSpecAll('void-m3', fmt(specs.voidVolumeM3, 3) + ' m3'); setSpecAll('void-m3', fmt(specs.voidVolumeM3, 3) + ' m3');
setSpecAll('horiz-beams', lastWallStats.horizBeamCount + ' stuks'); setSpecAll('horiz-beams', lastWallStats.horizBeamCount + ' stuks');
// Inkoop in handelslengtes (zaagplan uit WallModel).
const purchase = WallModel.planPurchase(WallModel.computeBOM(CONFIG, calcData)); const purchase = WallModel.planPurchase(WallModel.computeBOM(CONFIG, calcData));
const wasteMM = purchase.reduce((a, sec) => a + sec.wasteMM, 0); const wasteMM = purchase.reduce((a, sec) => a + sec.wasteMM, 0);
const stockMM = purchase.reduce((a, sec) => a + sec.stockMM, 0); const stockMM = purchase.reduce((a, sec) => a + sec.stockMM, 0);
+79
View File
@@ -0,0 +1,79 @@
code;omschrijving;eenheid;waarde
arbeid.uurloon;Uurloon productie (all-in);€/u;55
arbeid.bezetting;Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel;man;2,2
arbeid.toeslagUren;Toeslag indirecte uren (klaarleggen, opruimen, intern transport);%;10
arbeid.qa;QA (laatste aanpassingen, klantcontact) over de totale werktijd;%;4
zagen.methode;Zaagmethode;;robot
zagen.robotSnede;Robot: tijd per haakse snede;s;5
zagen.robotSchuin;Robot: extra tijd per schuine snede;s;2,5
zagen.robotStuk;Robot: handling per stuk (invoer, uitvoer, etiket);s;15
zagen.handFactor;Handmatig: factor op de robottijd;×;7
verbinden.schroevenPerVerbinding;Schroeven per verbinding (stijl/dorpel op regel);st.;3
verbinden.eersteSchroef;Tijd eerste schroef (gat + schroef + uitlijnen);s;10
verbinden.volgendeSchroef;Tijd volgende schroef (gat + schroef);s;5
verbinden.verbindingenPerVloerbalk;Verbindingen per vloer-/dakbalk;st.;2
verbinden.dubbelAfstand;Dubbele stijl (deur, hoek): schroef verticaal elke;mm;100
verbinden.monteerFactor;Monteren: factor op de schroeftijd (incl. lijmen, positioneren);×;6
verbinden.personeel;"Personeel bij monteren constructie (vloer, wand, dak; i.p.v. de bezetting)";man;2
verbinden.dakFactor;Dak: factor op de tijd per schroef (moeilijker werken);×;4
montage.muurVastzetten;Muursectie plaatsen en vastzetten;min;10
montage.muurPersoneel;Personeel bij plaatsen muursectie (i.p.v. de bezetting);man;3
hout.latStukMontage;Latwerk: per gezaagd stuk (meten, zagen op locatie, passen);s;240
hout.latMeterMontage;Latwerk: bevestigen per strekkende meter;s/m;30
plaat.spaanplaatMontage;Montage spaanplaat (passen, zagen, plaatsen);s/m²;120
plaat.osbMontage;Montage OSB;s/m²;90
plaat.cementplaatMontage;Montage cementplaat;s/m²;240
plaat.schroevenPerM2;Plaatschroeven per m²;st./m²;15
plaat.tijdPerPlaatschroef;Tijd per plaatschroef;s;4
isolatie.glaswolMontage;Montage glaswol;s/m³;900
gevel.vochtdoekMontageM;Montage vochtdoek per strekkende meter wand;s/m;600
gevel.rabatMontage;Montage rabat;s/m²;600
gevel.steenMontage;Montage steenstrips;s/m²;1800
gevel.stucMontage;Stucwerk aanbrengen (wapenen, pleisteren);s/m²;1500
dak.dakleerMontage;Montage dakleer;s/m²;600
dak.rietMontage;Montage riet;s/m²;3600
dak.beschotMontage;Montage dakbeschot;s/m²;150
dak.felsMontage;Montage felsplaten;s/m²;1800
dak.boeideelMontage;Montage boeideel;s/m;600
dak.onderplaatMontage;Montage onderplaat;s/m²;900
dak.daktrimMontage;Montage daktrim;s/m;300
dak.nokPlaatsen;Stalen nokbalk plaatsen, stellen en vastzetten;s;2700
dak.nokVulhoutMontage;Vulhout in de holling van de nokbalk aanbrengen (beide zijden);s/m;240
dak.nokPersoneel;Personeel bij plaatsen nokbalk (i.p.v. de bezetting);man;3
vloer.afwerkingMontage;Montage vloerafwerking;s/m²;1200
vloer.sandwichMontage;Montage per sandwichpaneel;s;600
afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800
afwerking.fiboMontage;Montage FIBO-wandpaneel;s/m²;1500
inrichting.personeel;Personeel bij inrichting (installateur/monteur, i.p.v. de bezetting);man;1
inrichting.toiletMontage;Montage wandcloset (reservoir, voorzetwand, afmonteren);s;10800
inrichting.fonteinMontage;Montage fontein;s;3600
inrichting.wastafelMontage;Montage badmeubel;s;5400
inrichting.doucheMontage;Montage inloopdouche (goot, afschot, glaswand, kraan);s;14400
inrichting.keukenMontage;Montage keukenblok;s/m;3600
inrichting.kookeilandMontage;Montage kookeiland;s/m;3600
inrichting.kastMontage;Montage kast;s/m;1800
inrichting.boilerMontage;Montage boiler;s;7200
kozijnen.montage;Montage per kozijn;s;5400
verlichting.montage;"Montage per lamp/spot (armatuur plaatsen en aansluiten; leidingen en dozen onder Elektra)";s;300
elektra.personeel;Personeel bij elektra (i.p.v. de bezetting);man;1
elektra.boorgat;Boorgat door stijl, regel of balk (aftekenen, boren, ontbramen);s;90
elektra.buisLeggen;Installatiebuis leggen en bevestigen;s/m;60
elektra.draadTrekken;Draad trekken (per meter buis, alle aders);s/m;20
elektra.kabelLeggen;XMvK-kabel leggen en bevestigen (buiten);s/m;40
elektra.doosPlaatsen;Inbouwdoos plaatsen (luchtdicht, hollewand);s;240
elektra.verdeeldoos;Verdeeldoos plaatsen en verbinden;s;600
elektra.spotdoos;Spotdoos plaatsen (luchtdicht plafond);s;300
elektra.vloerdoos;Vloerdoos plaatsen (gat in vloer, aansluiten);s;1800
elektra.afmonteren;Afmonteren schakelaar/wcd/apparaataansluiting;s;300
elektra.steekverbinderMontage;Steekverbinder module-naad monteren (set m/v);s;300
elektra.kastMontage;Groepenkast plaatsen (kast, hoofdschakelaar, aardlek);s;3600
elektra.groepMontage;Per groep: automaat plaatsen, bedraden, labelen;s;900
elektra.keuring;Meten, testen en opleveren (NEN 1010 / NEN 3140);s;10800
bedrijf.machines;Afschrijving machines (zaagrobot, gereedschap, inrichting) per uur;€/u;8
bedrijf.slijtage;Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per uur;€/u;2
bedrijf.huisvesting;Huisvesting productiehal (huur, energie) per uur;€/u;6
opslag.verlies;"Materiaalverlies (beschadiging, misgezaagd, restmateriaal; naast het berekende snijverlies balken)";%;5
opslag.klein;Klein materiaal (lijm, kit, tape, nieten);%;3
opslag.algemeen;Algemene kosten (kantoor, verzekering, garantie) over de kostprijs;%;5
opslag.marge;Winstmarge;%;15
opslag.btw;Btw;%;21
1 code omschrijving eenheid waarde
2 arbeid.uurloon Uurloon productie (all-in) €/u 55
3 arbeid.bezetting Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel man 2,2
4 arbeid.toeslagUren Toeslag indirecte uren (klaarleggen, opruimen, intern transport) % 10
5 arbeid.qa QA (laatste aanpassingen, klantcontact) over de totale werktijd % 4
6 zagen.methode Zaagmethode robot
7 zagen.robotSnede Robot: tijd per haakse snede s 5
8 zagen.robotSchuin Robot: extra tijd per schuine snede s 2,5
9 zagen.robotStuk Robot: handling per stuk (invoer, uitvoer, etiket) s 15
10 zagen.handFactor Handmatig: factor op de robottijd × 7
11 verbinden.schroevenPerVerbinding Schroeven per verbinding (stijl/dorpel op regel) st. 3
12 verbinden.eersteSchroef Tijd eerste schroef (gat + schroef + uitlijnen) s 10
13 verbinden.volgendeSchroef Tijd volgende schroef (gat + schroef) s 5
14 verbinden.verbindingenPerVloerbalk Verbindingen per vloer-/dakbalk st. 2
15 verbinden.dubbelAfstand Dubbele stijl (deur, hoek): schroef verticaal elke mm 100
16 verbinden.monteerFactor Monteren: factor op de schroeftijd (incl. lijmen, positioneren) × 6
17 verbinden.personeel Personeel bij monteren constructie (vloer, wand, dak; i.p.v. de bezetting) man 2
18 verbinden.dakFactor Dak: factor op de tijd per schroef (moeilijker werken) × 4
19 montage.muurVastzetten Muursectie plaatsen en vastzetten min 10
20 montage.muurPersoneel Personeel bij plaatsen muursectie (i.p.v. de bezetting) man 3
21 hout.latStukMontage Latwerk: per gezaagd stuk (meten, zagen op locatie, passen) s 240
22 hout.latMeterMontage Latwerk: bevestigen per strekkende meter s/m 30
23 plaat.spaanplaatMontage Montage spaanplaat (passen, zagen, plaatsen) s/m² 120
24 plaat.osbMontage Montage OSB s/m² 90
25 plaat.cementplaatMontage Montage cementplaat s/m² 240
26 plaat.schroevenPerM2 Plaatschroeven per m² st./m² 15
27 plaat.tijdPerPlaatschroef Tijd per plaatschroef s 4
28 isolatie.glaswolMontage Montage glaswol s/m³ 900
29 gevel.vochtdoekMontageM Montage vochtdoek per strekkende meter wand s/m 600
30 gevel.rabatMontage Montage rabat s/m² 600
31 gevel.steenMontage Montage steenstrips s/m² 1800
32 gevel.stucMontage Stucwerk aanbrengen (wapenen, pleisteren) s/m² 1500
33 dak.dakleerMontage Montage dakleer s/m² 600
34 dak.rietMontage Montage riet s/m² 3600
35 dak.beschotMontage Montage dakbeschot s/m² 150
36 dak.felsMontage Montage felsplaten s/m² 1800
37 dak.boeideelMontage Montage boeideel s/m 600
38 dak.onderplaatMontage Montage onderplaat s/m² 900
39 dak.daktrimMontage Montage daktrim s/m 300
40 dak.nokPlaatsen Stalen nokbalk plaatsen, stellen en vastzetten s 2700
41 dak.nokVulhoutMontage Vulhout in de holling van de nokbalk aanbrengen (beide zijden) s/m 240
42 dak.nokPersoneel Personeel bij plaatsen nokbalk (i.p.v. de bezetting) man 3
43 vloer.afwerkingMontage Montage vloerafwerking s/m² 1200
44 vloer.sandwichMontage Montage per sandwichpaneel s 600
45 afwerking.valsPlafondMontage Montage vals plafond s/m² 1800
46 afwerking.fiboMontage Montage FIBO-wandpaneel s/m² 1500
47 inrichting.personeel Personeel bij inrichting (installateur/monteur, i.p.v. de bezetting) man 1
48 inrichting.toiletMontage Montage wandcloset (reservoir, voorzetwand, afmonteren) s 10800
49 inrichting.fonteinMontage Montage fontein s 3600
50 inrichting.wastafelMontage Montage badmeubel s 5400
51 inrichting.doucheMontage Montage inloopdouche (goot, afschot, glaswand, kraan) s 14400
52 inrichting.keukenMontage Montage keukenblok s/m 3600
53 inrichting.kookeilandMontage Montage kookeiland s/m 3600
54 inrichting.kastMontage Montage kast s/m 1800
55 inrichting.boilerMontage Montage boiler s 7200
56 kozijnen.montage Montage per kozijn s 5400
57 verlichting.montage Montage per lamp/spot (armatuur plaatsen en aansluiten; leidingen en dozen onder Elektra) s 300
58 elektra.personeel Personeel bij elektra (i.p.v. de bezetting) man 1
59 elektra.boorgat Boorgat door stijl, regel of balk (aftekenen, boren, ontbramen) s 90
60 elektra.buisLeggen Installatiebuis leggen en bevestigen s/m 60
61 elektra.draadTrekken Draad trekken (per meter buis, alle aders) s/m 20
62 elektra.kabelLeggen XMvK-kabel leggen en bevestigen (buiten) s/m 40
63 elektra.doosPlaatsen Inbouwdoos plaatsen (luchtdicht, hollewand) s 240
64 elektra.verdeeldoos Verdeeldoos plaatsen en verbinden s 600
65 elektra.spotdoos Spotdoos plaatsen (luchtdicht plafond) s 300
66 elektra.vloerdoos Vloerdoos plaatsen (gat in vloer, aansluiten) s 1800
67 elektra.afmonteren Afmonteren schakelaar/wcd/apparaataansluiting s 300
68 elektra.steekverbinderMontage Steekverbinder module-naad monteren (set m/v) s 300
69 elektra.kastMontage Groepenkast plaatsen (kast, hoofdschakelaar, aardlek) s 3600
70 elektra.groepMontage Per groep: automaat plaatsen, bedraden, labelen s 900
71 elektra.keuring Meten, testen en opleveren (NEN 1010 / NEN 3140) s 10800
72 bedrijf.machines Afschrijving machines (zaagrobot, gereedschap, inrichting) per uur €/u 8
73 bedrijf.slijtage Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per uur €/u 2
74 bedrijf.huisvesting Huisvesting productiehal (huur, energie) per uur €/u 6
75 opslag.verlies Materiaalverlies (beschadiging, misgezaagd, restmateriaal; naast het berekende snijverlies balken) % 5
76 opslag.klein Klein materiaal (lijm, kit, tape, nieten) % 3
77 opslag.algemeen Algemene kosten (kantoor, verzekering, garantie) over de kostprijs % 5
78 opslag.marge Winstmarge % 15
79 opslag.btw Btw % 21
+73
View File
@@ -0,0 +1,73 @@
code;omschrijving;eenheid;prijs;leverancier;artikelnummer;bijgewerkt
verbinden.prijsPer1000;Inkoop constructieschroeven (6×120);€/1000;45;;;
hout.constructieM3;Constructiehout vuren C24 (regelwerk, balken);€/m³;550;;;
hout.latwerkM;Latwerk (kruislat);€/m;0,9;;;
plaat.spaanplaatM2;Spaanplaat;€/m²;9;;;
plaat.osbM2;OSB (vloeren, plafonds dak);€/m²;12;;;
plaat.cementplaatM2;Cementgebonden plaat (gevel, trasraam);€/m²;28;;;
plaat.plaatschroefPer1000;Inkoop plaatschroeven (4×40);€/1000;15;;;
isolatie.glaswolM3;Glaswol;€/m³;40;;;
isolatie.steenIsolatieM3;Isolatieblok steengevel;€/m³;120;;;
gevel.vochtdoekM2;Vochtdoek;€/m²;1,5;;;
gevel.rabatM2;Rabat (gevelbekleding);€/m²;35;;;
gevel.steenM2;Steenstrips (gevel + trasraam);€/m²;60;;;
gevel.stucM2;Stucwerk gevel (wapening + sierpleister op cementgebonden plaat);€/m²;45;;;
dak.dakleerM2;Dakleer (EPDM);€/m²;18;;;
dak.rietM2;Riet;€/m²;90;;;
dak.beschotM2;Dakbeschot OSB 18 mm (bovenop de sporen);€/m²;14;;;
dak.felsM2;Felsplaten staande naad (zink/staal, klangen, scheidingslaag);€/m²;65;;;
dak.boeideelM;Boeideel;€/m;25;;;
dak.onderplaatM2;Onderplaat boeideel;€/m²;30;;;
dak.daktrimM;Daktrim;€/m;12;;;
dak.staalKg;Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond);€/kg;2,9;;;
dak.nokOplegging;Oplegging nokbalk (stalen schoen + bouten), 2 per balk;€/st.;45;;;
vloer.afwerkingM2;Vloerafwerking;€/m²;35;;;
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
afwerking.fiboM2;FIBO-wandpaneel (incl. profielen en kit);€/m²;70;;;
inrichting.toilet;Wandcloset met inbouwreservoir, voorzetwand en bedieningsplaat;€/st.;650;;;
inrichting.fontein;Fontein met kraan en sifon;€/st.;220;;;
inrichting.wastafel;Badmeubel met wastafel, kraan en spiegel;€/st.;850;;;
inrichting.douche;Inloopdouche (douchegoot, glaswand, regendouche-set);€/st.;1450;;;
inrichting.keukenM;Keukenblok (kasten, werkblad, spoelbak, apparatuur) per strekkende meter;€/m;1500;;;
inrichting.kookeilandM;Kookeiland (kasten, werkblad) per strekkende meter;€/m;1300;;;
inrichting.kookplaat;Inductiekookplaat 4 zones;€/st.;650;;;
inrichting.kastM;Kledingkast / inbouwkast per strekkende meter;€/m;550;;;
inrichting.boiler;Elektrische boiler 80 l incl. inlaatcombinatie;€/st.;600;;;
kozijnen.raamM2;Raamkozijn incl. glas;€/m²;450;;;
kozijnen.deur;Binnen-/buitendeur incl. kozijn;€/st.;900;;;
kozijnen.voordeur;Voordeur incl. kozijn;€/st.;1800;;;
kozijnen.schuifdeurBlad;Schuifdeur per blad (blad, rail of pocket, beslag, aftimmering);€/st.;650;;;
verlichting.gevellamp;Gevellamp;€/st.;45;;;
verlichting.boeideelSpot;Boeideel-spot;€/st.;25;;;
verlichting.binnenspot;Binnenspot;€/st.;20;;;
elektra.buis16M;Installatiebuis flexibel Ø16;€/m;0,35;;;
elektra.buis19M;Installatiebuis flexibel Ø19;€/m;0,45;;;
elektra.buis25M;Installatiebuis flexibel Ø25;€/m;0,8;;;
elektra.draad15M;VD-draad 1,5 mm² (per ader);€/m;0,28;;;
elektra.draad25M;VD-draad 2,5 mm² (per ader);€/m;0,45;;;
elektra.xmvkM;XMvK 3×1,5 mm²;€/m;1,25;;;
elektra.inbouwdoos;Luchtdichte hollewanddoos;€/st.;1,6;;;
elektra.verdeeldoosSt;Luchtdichte verdeeldoos;€/st.;4,5;;;
elektra.spotdoosSt;Luchtdichte spotdoos;€/st.;7,5;;;
elektra.vloerdoosSt;Vloerdoos met dubbele wcd;€/st.;95;;;
elektra.schakelaar;Schakelaar enkelpolig (inbouw);€/st.;9;;;
elektra.afdekraam1;Afdekraam 1-voudig;€/st.;3;;;
elektra.afdekraam2;Afdekraam 2-voudig;€/st.;5;;;
elektra.wcdDubbel;Wandcontactdoos dubbel met randaarde;€/st.;10;;;
elektra.wcdEnkel;Wandcontactdoos enkel met randaarde;€/st.;6;;;
elektra.perilex;Perilex-wandcontactdoos (kookgroep);€/st.;18;;;
elektra.werkschakelaar;Werkschakelaar (warmtepomp);€/st.;28;;;
elektra.lasklem;Lasklem 3-voudig;€/st.;0,35;;;
elektra.steekverbinder3;Steekverbinder 3-polig (set m/v);€/set;9;;;
elektra.steekverbinder5;Steekverbinder 5-polig (set m/v);€/set;14;;;
elektra.doorvoer;Luchtdichte kabeldoorvoer naar buiten;€/st.;3,5;;;
elektra.kast;Groepenkast 3-fase leeg (behuizing, kammen, aansluitmateriaal);€/st.;180;;;
elektra.hoofdschakelaar;Hoofdschakelaar 4-polig 40 A;€/st.;55;;;
elektra.aardlek;Aardlekschakelaar 2-polig 40 A / 30 mA;€/st.;40;;;
elektra.automaat;Installatieautomaat 1P+N (B10/B16);€/st.;14;;;
elektra.automaat2f;Installatieautomaat 2-fase (kookgroep);€/st.;30;;;
bedrijf.transportUnit;Transport per unit naar de bouwplaats;€/unit;750;;;
bedrijf.kraan;Kraan voor plaatsen woning;€;1200;;;
hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,9115;;;
hout.balk.70x220;Constructiehout vuren C24 70×220;€/m;8,47;;;
1 code omschrijving eenheid prijs leverancier artikelnummer bijgewerkt
2 verbinden.prijsPer1000 Inkoop constructieschroeven (6×120) €/1000 45
3 hout.constructieM3 Constructiehout vuren C24 (regelwerk, balken) €/m³ 550
4 hout.latwerkM Latwerk (kruislat) €/m 0,9
5 plaat.spaanplaatM2 Spaanplaat €/m² 9
6 plaat.osbM2 OSB (vloeren, plafonds dak) €/m² 12
7 plaat.cementplaatM2 Cementgebonden plaat (gevel, trasraam) €/m² 28
8 plaat.plaatschroefPer1000 Inkoop plaatschroeven (4×40) €/1000 15
9 isolatie.glaswolM3 Glaswol €/m³ 40
10 isolatie.steenIsolatieM3 Isolatieblok steengevel €/m³ 120
11 gevel.vochtdoekM2 Vochtdoek €/m² 1,5
12 gevel.rabatM2 Rabat (gevelbekleding) €/m² 35
13 gevel.steenM2 Steenstrips (gevel + trasraam) €/m² 60
14 gevel.stucM2 Stucwerk gevel (wapening + sierpleister op cementgebonden plaat) €/m² 45
15 dak.dakleerM2 Dakleer (EPDM) €/m² 18
16 dak.rietM2 Riet €/m² 90
17 dak.beschotM2 Dakbeschot OSB 18 mm (bovenop de sporen) €/m² 14
18 dak.felsM2 Felsplaten staande naad (zink/staal, klangen, scheidingslaag) €/m² 65
19 dak.boeideelM Boeideel €/m 25
20 dak.onderplaatM2 Onderplaat boeideel €/m² 30
21 dak.daktrimM Daktrim €/m 12
22 dak.staalKg Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond) €/kg 2,9
23 dak.nokOplegging Oplegging nokbalk (stalen schoen + bouten), 2 per balk €/st. 45
24 vloer.afwerkingM2 Vloerafwerking €/m² 35
25 vloer.sandwichM2 Sandwichpaneel €/m² 35
26 afwerking.valsPlafondM2 Vals plafond (regelwerk + gipsplaat, afgewerkt) €/m² 38
27 afwerking.fiboM2 FIBO-wandpaneel (incl. profielen en kit) €/m² 70
28 inrichting.toilet Wandcloset met inbouwreservoir, voorzetwand en bedieningsplaat €/st. 650
29 inrichting.fontein Fontein met kraan en sifon €/st. 220
30 inrichting.wastafel Badmeubel met wastafel, kraan en spiegel €/st. 850
31 inrichting.douche Inloopdouche (douchegoot, glaswand, regendouche-set) €/st. 1450
32 inrichting.keukenM Keukenblok (kasten, werkblad, spoelbak, apparatuur) per strekkende meter €/m 1500
33 inrichting.kookeilandM Kookeiland (kasten, werkblad) per strekkende meter €/m 1300
34 inrichting.kookplaat Inductiekookplaat 4 zones €/st. 650
35 inrichting.kastM Kledingkast / inbouwkast per strekkende meter €/m 550
36 inrichting.boiler Elektrische boiler 80 l incl. inlaatcombinatie €/st. 600
37 kozijnen.raamM2 Raamkozijn incl. glas €/m² 450
38 kozijnen.deur Binnen-/buitendeur incl. kozijn €/st. 900
39 kozijnen.voordeur Voordeur incl. kozijn €/st. 1800
40 kozijnen.schuifdeurBlad Schuifdeur per blad (blad, rail of pocket, beslag, aftimmering) €/st. 650
41 verlichting.gevellamp Gevellamp €/st. 45
42 verlichting.boeideelSpot Boeideel-spot €/st. 25
43 verlichting.binnenspot Binnenspot €/st. 20
44 elektra.buis16M Installatiebuis flexibel Ø16 €/m 0,35
45 elektra.buis19M Installatiebuis flexibel Ø19 €/m 0,45
46 elektra.buis25M Installatiebuis flexibel Ø25 €/m 0,8
47 elektra.draad15M VD-draad 1,5 mm² (per ader) €/m 0,28
48 elektra.draad25M VD-draad 2,5 mm² (per ader) €/m 0,45
49 elektra.xmvkM XMvK 3×1,5 mm² €/m 1,25
50 elektra.inbouwdoos Luchtdichte hollewanddoos €/st. 1,6
51 elektra.verdeeldoosSt Luchtdichte verdeeldoos €/st. 4,5
52 elektra.spotdoosSt Luchtdichte spotdoos €/st. 7,5
53 elektra.vloerdoosSt Vloerdoos met dubbele wcd €/st. 95
54 elektra.schakelaar Schakelaar enkelpolig (inbouw) €/st. 9
55 elektra.afdekraam1 Afdekraam 1-voudig €/st. 3
56 elektra.afdekraam2 Afdekraam 2-voudig €/st. 5
57 elektra.wcdDubbel Wandcontactdoos dubbel met randaarde €/st. 10
58 elektra.wcdEnkel Wandcontactdoos enkel met randaarde €/st. 6
59 elektra.perilex Perilex-wandcontactdoos (kookgroep) €/st. 18
60 elektra.werkschakelaar Werkschakelaar (warmtepomp) €/st. 28
61 elektra.lasklem Lasklem 3-voudig €/st. 0,35
62 elektra.steekverbinder3 Steekverbinder 3-polig (set m/v) €/set 9
63 elektra.steekverbinder5 Steekverbinder 5-polig (set m/v) €/set 14
64 elektra.doorvoer Luchtdichte kabeldoorvoer naar buiten €/st. 3,5
65 elektra.kast Groepenkast 3-fase leeg (behuizing, kammen, aansluitmateriaal) €/st. 180
66 elektra.hoofdschakelaar Hoofdschakelaar 4-polig 40 A €/st. 55
67 elektra.aardlek Aardlekschakelaar 2-polig 40 A / 30 mA €/st. 40
68 elektra.automaat Installatieautomaat 1P+N (B10/B16) €/st. 14
69 elektra.automaat2f Installatieautomaat 2-fase (kookgroep) €/st. 30
70 bedrijf.transportUnit Transport per unit naar de bouwplaats €/unit 750
71 bedrijf.kraan Kraan voor plaatsen woning € 1200
72 hout.balk.38x235 Constructiehout vuren C24 38×235 €/m 4,9115
73 hout.balk.70x220 Constructiehout vuren C24 70×220 €/m 8,47