6 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 bd084e4a96 v0.6.1: standaardmodellen uit één lijst (HouseModel.MODELS) voor Snel nieuw en het venster Nieuw, gedeelde bouwstenen (modelKit) voor Amstel en nieuw model Wetering (10,5 × 4,8 m, twee units zonder naadwand, hoekkeuken, badkamer, slaapkamer met kast); keukenblok met kookplaat (plek instelbaar) en spoelbak aan/uit, kookplaat ook in de kosten van een keukenblok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:53:24 +02:00
MachielandClaude Opus 5.5 ceda0b554d 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>
2026-10-11 00:34:27 +02:00
MachielandClaude Opus 5.5 b5510d56c7 v0.5.3: elektra-buizen min. 50 mm uit elkaar (gebundeld alleen naar de meterkast), vloer-wcd met leiding door de vloer, elektra op de PDF-bladen per wand/vloer/dakvlak, kosten elektra (buis + draad per meter, boorgaten 90 s geteld op stijlen/regels/balken, dozen, groepenkast, keuring), dakbeschot OSB 18 mm bovenop de sporen, felsplaten als dakbedekking
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:54:49 +02:00
MachielandClaude Opus 5.5 e1a688728c v0.5.2: elektra (meterkast 3×25 A, schakelaars met letters op hun lampen, wcd, apparaatgroepen incl. perilex, groepenverklaring met fasen/aardlek, leidingen naar de meterkast met strekkende meters per type, PDF-blad + plattegrond, verplaatsbaar door slepen, bouwfase Elektra met installatiebuizen in 3D); ribbon, bouwfasen, kozijnen, kostenmodel en eerdere ongecommitte wijzigingen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:40:11 +02:00
MachielandClaude Opus 5.5 8cdfa21f46 v0.4.17: openingen met schuine bovenzijde (hoogte links/rechts, volg dakhelling), 9 sleeppunten in het vooraanzicht; regelwerk, beplating, doek, steen en latwerk volgen de schuinte (columnMinusRects met schuine uitsparingen)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:01:54 +02:00
MachielandClaude Opus 5.5 23bfe153a2 v0.4.16: PDF-aanzichten (vlakke render) met legenda afwerking en afmetingen, zaaglijst/zaagmachine eigen blad, infopaneel afmetingen, lopen (Shift+T) met camera-menu, zachter licht, naadwanden volgen koppeling, boeideel over de gevel (gevel tot bovenkant dak), thumbnails riet/vloer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:29:57 +02:00
55 changed files with 10521 additions and 2733 deletions
+202 -17
View File
@@ -18,20 +18,25 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
## Structuur ## Structuur
``` ```
index.html app-shell: menubalk, splashscreen index.html app-shell: ribbon, splashscreen
css/app.css alle stijl css/app.css alle stijl
js/vendor/ three.js r128, eigen OrbitControls, jsPDF js/vendor/ three.js r128, eigen OrbitControls, jsPDF
js/core/ gedeelde helpers: geometrie, UI-kit, PDF-bladen, labels, js/core/ gedeelde helpers: geometrie, bouwfasen, ribbon, UI-kit, PDF-bladen,
camerageheugen, weergavefilter, skybox, licentie, logo labels, camerageheugen, weergavefilter, skybox, licentie, logo
js/wall/ WallModel (logica) + wand-PDF + wand-editor js/wall/ WallModel (logica) + wand-PDF + wand-editor
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim) js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
js/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots js/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots
js/elektra/ ElektraModel + ElektraPdf: meterkast, schakelaars (letters), wcd, groepen, leidingen
js/sandwich/ SandwichModel: sandwichpanelen rondom js/sandwich/ SandwichModel: sandwichpanelen rondom
js/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
``` ```
@@ -44,6 +49,15 @@ assets/ logo, texturen, skybox
- **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid, - **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
links = west, rechts = oost (interne sleutels blijven voor/achter/…). links = west, rechts = oost (interne sleutels blijven voor/achter/…).
- **Hoogtes** gelden vanaf bovenkant vloer (loopvlak). - **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
- **Bouwfasen** (`BuildStages`): regelwerk → elektra (buizen, dozen, meterkast) → beplating → vochtdoek →
latwerk → gevel & dak → afgewerkt, de volgorde in de hal. De 3D toont de
gekozen fase; lagen die daarin aan beide zijden zijn ingesloten worden niet
gebouwd (stijlen tussen spaanplaat en doek, latten en doek achter de rabat,
sporen tussen plafond en dakbedekking). Specificaties en totalen rekenen
altijd met alle lagen.
- **Invoer volgt de logica:** de dakvorm is een eigenschap van de unit
(plat/lessenaar/zadel, richting, goot- en nokhoogte of helling); de
wanden volgen.
## Functionaliteit ## Functionaliteit
@@ -52,9 +66,70 @@ assets/ logo, texturen, skybox
38x235 met dubbele hoekstijlen en een dubbele stijl naast deuren, zaagplan 38x235 met dubbele hoekstijlen en een dubbele stijl naast deuren, zaagplan
op handelslengtes, spaanplaat binnen, vochtdoek (met kruislat en trasraam) op handelslengtes, spaanplaat binnen, vochtdoek (met kruislat en trasraam)
of cementplaat buiten. of cementplaat buiten.
- **Wandhoogte:** schuine bovenrand of middenpunt (nok) met goothoogtes. In - **Elektra** (`elektra/`): meterkast (3×25 A) bij de voordeur, schakelaars
een unit bepaalt de laatst gewijzigde wand de dakvorm: de tegenoverliggende met letter (A binnen, B buiten, per unit een eigen; dezelfde letter bij de
wand spiegelt mee, de haakse wanden worden vlak op de hoekhoogtes. lampen erop), dubbele wcd's per unit, apparaten met eigen groep (kookgroep
perilex, wasmachine, droger, warmtepomp, optioneel oven/vaatwasser).
Groepenverklaring met fasen en aardlekschakelaars, leidingen via stijlen
en plafond naar de meterkast (strekkende meters per type, draad, buis,
steekverbinders op module-naden), materiaallijst en eigen PDF-blad.
Buizen minstens 50 mm uit elkaar (gebundeld alleen het laatste stuk naar
de meterkast). Vloer-wcd's met leiding door de vloer. Elektra ook op de
PDF-bladen per wand, vloer en dakvlak (plafond). Alles verplaatsbaar door
te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per
meter, boorgaten (geteld op stijlen, regels en balken), dozen,
afmonteren, groepenkast en keuring.
- **Kozijnen** (`wall/kozijn-model.js`): per opening een vulling (geen, raam
met vast glas en optioneel ventilatierooster, of deur met dorpel en
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
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,
lessenaar, zadel, mansarde of wolfsdak, met schuiven en hellingshoek).
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde
met twee knikken, bij een wolfsdak afgeknot op het wolfseind, met
daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een
rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het
bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers,
nokbalk en kopbalken (ook in zaaglijst, specificaties en kosten).
Goot en nok gelden over de volle vloermaat (goot boven de buitenkant van
de gevel); staat de kopgevel tussen de haakse wanden, dan loopt zijn
daklijn daar precies op aan. Knik en wolfseind schalen mee met goot en
nok (verhouding). Bij een knik (mansarde) sluiten de dakvlakken in
verstek aan. De PDF heeft voor een wolfsdak een eigen blad met het
bovenste deel (bovenaanzicht houtwerk + stuklijst).
Grenzen (`UnitModel.LIMITS`): nok max 5 m, goot / plat dak max 3 m (vanaf
bovenkant vloer tot onderkant dak), unit max 4,3 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen
boeideel (versprongen, het lage loopt door tot onder het hoge). Wijzig je een
wandhoogte in de wand-editor, dan geldt dezelfde regel vanuit die wand.
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
@@ -70,33 +145,143 @@ assets/ logo, texturen, skybox
- **Dak:** per unit opgebouwd als gespiegelde vloer + dakleer, in de vorm - **Dak:** per unit opgebouwd als gespiegelde vloer + dakleer, in de vorm
die de wanden aangeven: plat, lessenaar (schuine kopgevels) of zadel die de wanden aangeven: plat, lessenaar (schuine kopgevels) of zadel
(nok). Elk dakvlak is een eigen dek met de balken als sporen en een eigen (nok). Elk dakvlak is een eigen dek met de balken als sporen en een eigen
PDF-blad; doorvoeren gaan naar het vlak waarin ze vallen. Bedekking per PDF-blad; doorvoeren gaan naar het vlak waarin ze vallen. Opbouw:
unit: dakleer, of bij een hellend dak riet (180 mm, 3,8 kg/m²); de plafondplaat (OSB) binnen, sporen, dakbeschot (OSB 18 mm) bovenop en
dakrand schuift dan mee omhoog. De dakrand daarop de bedekking per unit: dakleer, of bij een hellend dak riet
(180 mm, 3,8 kg/m²) of felsplaten (staande naad, zink/antraciet/zwart);
de dakrand schuift dan mee omhoog. De dakrand
(boeidelen/daktrim, spots) loopt als één rand door en volgt de helling. (boeidelen/daktrim, spots) loopt als één rand door en volgt de helling.
Is een dak langs de naad minstens een boeideelhoogte hoger dan het Is een dak langs de naad minstens een boeideelhoogte hoger dan het
aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak
zonder spots). De rand van het lage dak loopt dan door tot de gevel van zonder spots). De rand van het lage dak loopt dan door tot de gevel van
het hoge, of langs een zijgevel tot de volle overstek (onder de hoge het hoge, of langs een zijgevel tot de volle overstek (onder de hoge
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit. rand). Boeideel en daktrim steken 80 mm boven het dakleer uit.
- **Gevel:** gevelbekleding per woning, met override per wand: rabat - **Gevel:** gevelbekleding per woning, met override per wand en per
(vochtdoek + kruislat) of steen (181 texturen). Steen is overal een wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
getekend als plankentextuur die over naden doorloopt), steen (181
texturen) of stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
per woning, `WallModel.STUC`). Een zijde kan gesplitst worden (wand ›
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.
- **3D:** weergavefilters (o.a. Vloerplan: wanden afgesneden op 500 mm, - **Ribbon** (`core/ribbon.js`, vervangt de menubalk): Bestand · Start ·
snede wit afgedekt), binnenzijde van de wanden in een verfkleur (globaal), Gevel · Dak · Binnen · Constructie · Verlichting · Rapport · Beeld · Extra,
omgeving Ontwerp/Dag/Nacht, Render met lampen als echte lichtbronnen. plus een gekleurd tabblad voor de selectie (unit, wandstuk, binnenwand of
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (plattegrond, naadwand) dat vanzelf opent. Velden staan in groepen naast elkaar, met
schuiven voor maten met een logisch bereik. Dubbelklik op een tabblad
klapt de ribbon in. Plattegrond en 3D gebruiken de volle breedte; tabblad
Rapport toont het rapport (transport per unit, gewicht, afmetingen,
kosten, hout, plaatwerk, met staafdiagrammen) in plaats van de plattegrond.
- **3D:** schuif Bouwfase linksonder; Beeld › Tonen: Dak/Lampen; Beeld ›
Doorsnede (wanden afgesneden op een instelbare hoogte, schuif in de ribbon
en onder de 3D, snede wit afgedekt), binnenzijde in een
verfkleur (afgewerkte fase), omgeving Ontwerp/Dag/Nacht, Render met lampen
als echte lichtbronnen.
- **Transport:** per unit buitenmaat (incl. gevelafwerking), hoogte en
gewicht (vloer/wanden/dak) met een indicatieve breedteklasse.
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
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: een nieuwe woning is het testontwerp (L-vorm, lessenaar met - Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij
riet); uit te zetten met `TEST_DEFAULT` in `js/house/house-model.js`. 2400 m/u, zaagsneden, hoeken) in de app en op het totalenblad (`SAW_M_PER_H`).
- Tijdelijk: een nieuwe woning is het voorbeeldontwerp "Vlinderwoning"
(92 m², drie units, vlinderdak met riet + platte tuinkamer, ook als
`json/premade/vlinderwoning.json`); uit te zetten met `TEST_DEFAULT` in
`js/house/house-model.js`. 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).
Model **Wetering** (`HouseModel.createWetering`, naar
`examples/ontwerp_wetering.pdf`): 10,5 × 4,8 m, twee units van 2,6 en
2,2 m zonder naadwand (open woonkamer over de volle diepte), verticaal
rabat grijs, plat dak met zwart boeideel, hoekkeuken met kookplaat,
badkamer, slaapkamer met kledingkast. De standaardmodellen staan in
`HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden
daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die
`modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft
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).
+2 -2
View File
@@ -5,5 +5,5 @@ Assets voor de 3D-weergave en de PDF:
- tiles.jpg tegeltextuur (1024x1024) voor de ronde vloer onder het object - tiles.jpg tegeltextuur (1024x1024) voor de ronde vloer onder het object
- textures/buitenmuur/ steen voor het trasraam: "<code> - Witte voeg.jpg" (2062x1125, 2 mm/px, - textures/buitenmuur/ steen voor het trasraam: "<code> - Witte voeg.jpg" (2062x1125, 2 mm/px,
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) - 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 - textures/dak/ dakbedekking: riet_<nr>.jpg (1024x1024, tegel 1 m); nummers in RoofModel RIET.nrs; thumb/32|64|128/*.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+189 -93
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);
@@ -236,6 +229,7 @@
position: relative; position: relative;
} }
.opening-card.opening-selected { background: var(--primary-soft); border-color: var(--primary); } .opening-card.opening-selected { background: var(--primary-soft); border-color: var(--primary); }
.opening-fill { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border); }
.opening-card.opening-warn { .opening-card.opening-warn {
border-color: var(--danger); border-color: var(--danger);
box-shadow: 0 0 0 1px var(--danger) inset; box-shadow: 0 0 0 1px var(--danger) inset;
@@ -285,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; }
@@ -297,57 +290,8 @@
} }
.material-toggle-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; } .material-toggle-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); } .material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); }
/* ============================================================
APP SHELL: menubalk, workspace-wrapper, splashscreen, about-modal
============================================================ */
html, body { height: 100%; } html, body { height: 100%; }
#app-bar {
height: 40px;
background: var(--van-emous-blue);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 4px;
position: relative;
z-index: 100;
user-select: none;
}
#app-bar .menu-group { position: relative; }
#app-bar .menu-btn {
background: none; border: none; color: var(--nav-link-color); font-size: 13px;
padding: 8px 14px; cursor: pointer; border-radius: 4px;
}
#app-bar .menu-btn:hover, #app-bar .menu-btn.open { background: var(--surface); color: var(--primary); }
#app-bar .menu-dropdown {
position: absolute; top: 100%; left: 0; background: var(--surface); border: 1px solid var(--border);
border-radius: 0 0 6px 6px; min-width: 220px; display: none; box-shadow: 0 8px 20px rgba(33, 33, 33, .15);
}
#app-bar .menu-dropdown.open { display: block; }
#app-bar .menu-item {
padding: 9px 16px; font-size: 13px; color: var(--nav-link-color); cursor: pointer; position: relative;
display: flex; justify-content: space-between; align-items: center;
}
#app-bar .menu-item:hover { background: var(--primary-soft); color: var(--primary); }
#app-bar .menu-hotkey { color: var(--text-faint); font-size: 11px; margin-left: 16px; }
#app-bar .menu-item.disabled { color: #b0b8c2; cursor: not-allowed; pointer-events: none; }
#app-bar .menu-item.disabled:hover { background: none; color: #b0b8c2; }
#app-bar .submenu {
position: absolute; left: 100%; top: -1px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; min-width: 200px; display: none; box-shadow: 0 8px 20px rgba(33, 33, 33, .15);
}
#app-bar .menu-item:hover .submenu, #app-bar .submenu.open { display: block; }
#app-bar .submenu .menu-item { color: var(--nav-link-color); }
#app-bar .submenu .menu-item:hover { background: var(--primary-soft); color: var(--primary); }
#app-bar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; padding-right: 6px; }
#app-version-tag { font-size: 11px; color: var(--text-muted); }
#btn-about {
background: var(--surface); border: 1px solid var(--border); color: var(--nav-link-color); font-size: 12px;
padding: 5px 10px; border-radius: 4px; cursor: pointer;
}
#btn-about:hover { border-color: var(--primary); color: var(--primary); }
#tab-bar { #tab-bar {
height: 34px; height: 34px;
background: var(--van-emous-ultra-light); background: var(--van-emous-ultra-light);
@@ -368,13 +312,15 @@ html, body { height: 100%; }
} }
.tab-item .tab-close:hover { color: var(--danger); } .tab-item .tab-close:hover { color: var(--danger); }
#workspace { width: 100vw; height: calc(100vh - 40px - 34px); position: relative; } body { display: flex; flex-direction: column; }
#tab-bar { flex: none; }
#workspace { width: 100vw; flex: 1; min-height: 0; position: relative; }
#workspace-empty { #workspace-empty {
height: 100%; display: flex; align-items: center; justify-content: center; height: 100%; display: flex; align-items: center; justify-content: center;
color: var(--text-faint); font-size: 14px; flex-direction: column; gap: 10px; color: var(--text-faint); font-size: 14px; flex-direction: column; gap: 10px;
} }
/* --- 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;
@@ -385,7 +331,6 @@ html, body { height: 100%; }
#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;
@@ -435,6 +380,37 @@ html, body { height: 100%; }
#wall-project-modal .modal-footer, #house-project-modal .modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; } #wall-project-modal .modal-footer, #house-project-modal .modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
#wall-project-modal .control-group input, #house-project-modal .control-group input { width: 150px; text-align: left; } #wall-project-modal .control-group input, #house-project-modal .control-group input { width: 150px; text-align: left; }
#cost-settings-overlay {
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
display: none; align-items: center; justify-content: center;
}
#cost-settings-overlay.open { display: flex; }
#cost-settings-modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px;
width: 760px; max-width: 94%; max-height: 90vh; display: flex; flex-direction: column;
box-shadow: 0 12px 30px rgba(33, 33, 33, .2);
}
#cost-settings-modal .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
#cost-settings-modal .modal-header h2 { margin: 0; font-size: 15px; color: var(--primary); }
#cost-settings-modal .modal-close-x { background: none; border: none; color: var(--text-faint); font-size: 19px; line-height: 1; cursor: pointer; padding: 0 4px; }
#cost-settings-modal .modal-close-x:hover { color: var(--danger); }
#cost-settings-modal .modal-footer { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
#cost-settings-modal .cs-body { overflow-y: auto; min-height: 0; flex: 1; columns: 2 340px; column-gap: 24px; }
#cost-settings-modal .cs-group { break-inside: avoid; margin-bottom: 14px; }
#cost-settings-modal .cs-group h3 { font-size: 12px; color: var(--primary); text-transform: uppercase; letter-spacing: .5px; margin: 0 0 6px; }
#cost-settings-modal .cs-row { display: grid; grid-template-columns: 1fr 84px 48px; gap: 6px; align-items: center; font-size: 12px; padding: 2px 0; }
#cost-settings-modal .cs-row input, #cost-settings-modal .cs-row select { width: 100%; text-align: right; }
#cost-settings-modal .cs-row select { text-align: left; }
#cost-settings-modal .cs-unit { color: var(--text-faint); font-size: 11px; }
#cost-settings-modal .cs-row.changed .cs-label { color: var(--primary); font-weight: bold; }
#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;
display: none; align-items: center; justify-content: center; display: none; align-items: center; justify-content: center;
@@ -452,16 +428,11 @@ html, body { height: 100%; }
color: var(--nav-link-color); cursor: pointer; width: 110px; color: var(--nav-link-color); cursor: pointer; width: 110px;
} }
.new-option svg { width: 40px; height: 40px; } .new-option svg { width: 40px; height: 40px; }
.new-option .bi { font-size: 36px; line-height: 40px; }
.new-option span { font-size: 13px; } .new-option span { font-size: 13px; }
.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;
@@ -480,10 +451,6 @@ html, body { height: 100%; }
} }
#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;
@@ -508,7 +475,6 @@ html, body { height: 100%; }
.hn-item.disabled:hover { background: none; color: #9aa4b0; } .hn-item.disabled:hover { background: none; color: #9aa4b0; }
.hn-actions { display: flex; gap: 6px; margin-top: 10px; } .hn-actions { display: flex; gap: 6px; margin-top: 10px; }
.hn-actions .io-btn { flex: 1; } .hn-actions .io-btn { flex: 1; }
.hn-soon { margin-top: 10px; border-top: 1px solid var(--border); padding-top: 6px; }
#house-nav .io-status { margin: auto 0 0; padding: 6px; } #house-nav .io-status { margin: auto 0 0; padding: 6px; }
#house-part-host { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; } #house-part-host { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
@@ -517,25 +483,33 @@ html, body { height: 100%; }
#house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; } #house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; }
#house-overview { display: flex; width: 100%; } #house-overview { display: flex; width: 100%; }
.ho-col { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; } .ho-views { flex: 1; display: flex; min-width: 0; min-height: 0; }
.ho-left { border-right: 1px solid var(--border); } .ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); }
#house-overview.fullscreen-3d .ho-left { display: none; } #ho-pane-plan { border-right: 1px solid var(--border); }
#house-overview.fullscreen-2d .ho-right { display: none; } @media (max-width: 1100px) {
.ho-pane { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); } .ho-views { flex-direction: column; }
#ho-pane-plan, .ho-report { border-right: none; border-bottom: 1px solid var(--border); }
}
#house-overview.fullscreen-3d #ho-pane-plan, #house-overview.fullscreen-3d .ho-report { display: none !important; }
#house-overview.fullscreen-2d #ho-pane-3d { display: none; }
.ho-report { display: none; flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 14px 18px 20px; background: var(--van-emous-ultra-light); border-right: 1px solid var(--border); }
#house-overview.show-report .ho-report { display: block; }
#house-overview.show-report #ho-pane-plan { display: none; }
.ho-report .ui-spec-grid { grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); align-items: start; }
.ho-report-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
.ho-report-head h1 { margin: 0; font-size: 14px; }
.ho-report-head span { font-size: 12px; color: var(--text-muted); }
.ho-warn { position: absolute; top: 52px; left: 15px; right: 15px; z-index: 9; pointer-events: none; }
.ho-warn .ui-warn { margin: 0 0 6px; max-width: 560px; }
.ho-hint { .ho-hint {
position: absolute; left: 15px; bottom: 10px; font-size: 11px; color: #bbb; position: absolute; left: 15px; bottom: 10px; font-size: 11px; color: #bbb;
background: rgba(0, 0, 0, .6); padding: 3px 8px; border-radius: 4px; pointer-events: none; background: rgba(0, 0, 0, .6); padding: 3px 8px; border-radius: 4px; pointer-events: none;
} }
#ho-props, #ho-specs {
height: 340px; flex: none; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-y: auto; padding: 15px 20px;
}
#ho-specs .controls-grid { grid-template-columns: 1fr 1fr; }
.ho-prop-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .ho-prop-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.io-btn-danger { border-color: #e3b4b0; color: var(--danger); } .io-btn-danger { border-color: #e3b4b0; color: var(--danger); }
.io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); } .io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.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;
@@ -545,10 +519,6 @@ html, body { height: 100%; }
.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 {
@@ -560,7 +530,7 @@ html, body { height: 100%; }
.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;
} }
@@ -618,10 +588,8 @@ html, body { height: 100%; }
.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 #ho-specs, .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; }
@@ -642,5 +610,133 @@ 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; }
.walk-hint { display: none; position: absolute; left: 50%; top: 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; }
.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; }
.opening-roof-btn {
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;
}
.opening-roof-btn:hover { background: var(--primary-soft); border-color: var(--primary); }
.ui-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 11px; color: var(--text-faint); margin-bottom: 4px; }
.ui-crumbs .bi { font-size: 9px; }
.ui-crumbs span:last-child { color: var(--text-muted); }
.ui-field-range { flex-wrap: wrap; row-gap: 2px; padding: 2px 0 4px; }
.ui-field-range label { flex: 1 1 100%; }
.ui-range { flex: 1 1 100%; gap: 8px; }
.ui-range input[type=range] { flex: 1; min-width: 0; accent-color: var(--primary); }
.ui-range input[type=number] { width: 84px; }
.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
.stage-bar {
position: absolute; left: 15px; bottom: 14px; z-index: 10; min-width: 300px; max-width: calc(100% - 30px);
background: rgba(0, 0, 0, 0.75); border: 1px solid #444; border-radius: 6px; padding: 7px 12px 6px; color: #eee; font-size: 12px;
}
.stage-row, .stage-extra { display: flex; align-items: center; gap: 10px; }
.stage-title { display: inline-flex; align-items: center; gap: 6px; color: var(--primary-on-dark); font-weight: 600; white-space: nowrap; }
.stage-range { flex: 1; min-width: 90px; accent-color: var(--primary-on-dark); }
.stage-name { min-width: 84px; font-weight: 600; white-space: nowrap; }
.stage-track { flex: 1; display: flex; flex-direction: column; min-width: 120px; }
.stage-track .stage-range { width: 100%; margin: 0; }
.stage-ticks { display: flex; justify-content: space-between; padding: 0 3px; font-size: 10px; color: #888; }
.stage-ticks span { cursor: pointer; padding: 0 3px; }
.stage-ticks span.on { color: var(--primary-on-dark); }
.stage-extra { display: none; margin-top: 6px; padding-top: 6px; border-top: 1px solid #444; }
.stage-extra.show { display: flex; }
.viz { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; }
.viz-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; }
.viz-legend span { display: inline-flex; align-items: center; gap: 5px; }
.viz-key { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.viz-key.s1, .viz-seg.s1 { background: var(--series-1); }
.viz-key.s2, .viz-seg.s2 { background: var(--series-2); }
.viz-key.s3, .viz-seg.s3 { background: var(--series-3); }
.viz-row { display: grid; grid-template-columns: 96px 1fr auto; align-items: center; gap: 8px; padding: 4px 0; }
.viz-label { font-size: 12px; color: var(--text); line-height: 1.25; }
.viz-label small { display: block; font-size: 10px; color: var(--text-faint); }
.viz-row:has(.viz-label small) { grid-template-columns: 1fr auto; }
.viz-row:has(.viz-label small) .viz-label { grid-column: 1 / -1; }
.viz-track { height: 12px; }
.viz-bar { display: flex; gap: 2px; height: 100%; min-width: 2px; }
.viz-seg { height: 100%; min-width: 2px; }
.viz-seg:last-child { border-radius: 0 4px 4px 0; }
.viz-seg:hover { filter: brightness(1.12); }
.viz-val { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text); text-align: right; white-space: nowrap; }
.viz-note { font-size: 11px; color: var(--text-faint); line-height: 1.45; margin-top: 6px; }
#ribbon { flex: none; background: var(--van-emous-blue); border-bottom: 1px solid var(--border); position: relative; z-index: 100; user-select: none; }
.rb-top { display: flex; align-items: flex-end; height: 34px; padding: 0 8px; }
.rb-brand { display: flex; align-items: center; height: 100%; padding: 0 14px 0 4px; font-weight: 700; color: var(--primary); letter-spacing: .5px; }
.rb-tabs { display: flex; gap: 2px; height: 100%; align-items: flex-end; overflow-x: auto; scrollbar-width: none; }
.rb-tab {
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 14px; border: 1px solid transparent; border-bottom: none;
border-radius: 5px 5px 0 0; background: none; font: inherit; font-size: 12.5px; color: var(--nav-link-color); cursor: pointer; white-space: nowrap;
}
.rb-tab:hover { background: rgba(255, 255, 255, .55); color: var(--primary); }
.rb-tab.active { background: var(--van-emous-ultra-light); border-color: var(--border); color: var(--primary); font-weight: 600; margin-bottom: -1px; height: 29px; }
.rb-tab.rb-ctx { color: #fff; background: var(--primary); margin-left: 10px; }
.rb-tab.rb-ctx:hover { background: var(--primary-hover); color: #fff; }
.rb-tab.rb-ctx.active { background: var(--van-emous-ultra-light); color: var(--primary); box-shadow: inset 0 3px 0 var(--primary); }
.rb-right { margin-left: auto; display: flex; align-items: center; height: 100%; gap: 10px; font-size: 11px; color: var(--text-muted); padding-left: 12px; }
.rb-body {
position: relative; display: flex; align-items: stretch; height: 104px; padding: 6px 40px 4px 6px;
background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-x: auto; overflow-y: hidden;
}
#ribbon.rb-collapsed .rb-body { display: none; }
.rb-collapse { position: absolute; right: 8px; bottom: 6px; border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 2px 4px; }
.rb-collapse:hover { color: var(--primary); }
.rb-group { display: flex; flex-direction: column; flex: none; padding: 0 12px; border-right: 1px solid var(--border-soft); }
.rb-group:last-of-type { border-right: none; }
.rb-group-body { flex: 1; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 16px; align-items: center; justify-content: start; }
.rb-group-label { text-align: center; font-size: 10.5px; color: var(--text-faint); padding-top: 2px; white-space: nowrap; }
.rb-btn {
display: inline-flex; align-items: center; gap: 6px; border: 1px solid transparent; background: none; border-radius: 4px;
font: inherit; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap;
}
.rb-btn:hover:not(:disabled) { background: var(--primary-soft); border-color: rgba(185, 89, 19, .25); color: var(--primary); }
.rb-btn:disabled { color: #a9b2bd; cursor: default; }
.rb-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
.rb-btn.rb-danger:hover:not(:disabled) { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
.rb-small { height: 24px; padding: 0 7px; }
.rb-small .bi { font-size: 14px; color: var(--primary); }
.rb-btn:disabled .bi { color: inherit; }
.rb-big { grid-row: span 3; flex-direction: column; justify-content: center; gap: 4px; min-width: 64px; height: 76px; padding: 4px 8px; text-align: center; }
.rb-big .bi { font-size: 24px; color: var(--primary); }
.rb-big span { white-space: normal; max-width: 86px; line-height: 1.2; }
.rb-kv { display: flex; justify-content: space-between; gap: 12px; font-size: 11.5px; color: var(--text-muted); min-width: 150px; }
.rb-kv b { color: var(--text); font-variant-numeric: tabular-nums; }
.rb-kv small { font-weight: normal; color: var(--text-faint); margin-left: 2px; }
.rb-note { grid-row: span 3; max-width: 260px; font-size: 11px; line-height: 1.4; color: var(--text-muted); white-space: normal; align-self: center; }
.rb-note.rb-warn { color: var(--danger); }
.rb-group .ui-field { min-height: 0; height: 26px; gap: 8px; }
.rb-group .ui-field label { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
.rb-group .ui-control input[type=number] { width: 70px; padding: 2px 5px; font-size: 12px; }
.rb-group .ui-control select, .rb-group .ui-control input[type=text] { width: 132px; padding: 2px 4px; font-size: 12px; }
.rb-group .ui-control input[type=color] { width: 46px; height: 22px; padding: 0; border: 1px solid var(--border); border-radius: 3px; background: none; }
.rb-group .ui-unit { width: 20px; margin-left: 3px; font-size: 10.5px; }
.rb-group .ui-switch { margin-right: 0; }
.rb-group .ui-field-range { flex-wrap: nowrap; padding: 0; }
.rb-group .ui-field-range label { flex: none; }
.rb-group .ui-range { flex: none; gap: 6px; }
.rb-group .ui-range input[type=range] { width: 96px; flex: none; }
.rb-group .ui-range input[type=number] { width: 62px; }
.rb-thumb { width: 26px; height: 26px; border-radius: 3px; border: 1px solid var(--border); object-fit: cover; display: block; }
.rb-swatch { width: 26px; height: 22px; border-radius: 3px; border: 1px solid var(--border); }
.rb-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; height: 24px; }
.rb-seg .rb-btn { border-radius: 0; border: none; border-right: 1px solid var(--border-soft); height: 22px; padding: 0 8px; }
.rb-seg .rb-btn:last-child { border-right: none; }
.rb-seg .rb-btn.active { background: var(--primary); color: #fff; }
.rb-seg .rb-btn.active .bi { color: #fff; }
.rb-check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap; }
.rb-check input { accent-color: var(--primary); }
.rb-pair { display: flex; align-items: center; gap: 6px; }
.rb-cols { grid-row: span 3; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 18px; align-items: center; }
.rb-cols:empty { display: none; }
+19 -40
View File
@@ -22,47 +22,14 @@
<div class="splash-version"><span class="splash-name">Tectum</span> &mdash; Versie <span class="app-version-number">0.0.0</span></div> <div class="splash-version"><span class="splash-name">Tectum</span> &mdash; Versie <span class="app-version-number">0.0.0</span></div>
</div> </div>
<div id="app-bar"> <header id="ribbon">
<div class="menu-group"> <div class="rb-top">
<button id="menu-bestand-btn" class="menu-btn">Bestand</button> <div class="rb-brand"><span class="splash-name">Tectum</span></div>
<div id="menu-bestand-dropdown" class="menu-dropdown"> <div class="rb-tabs" role="tablist"></div>
<div class="menu-item" id="menu-nieuw-item"> <div class="rb-right"><span class="app-version-slot"></span></div>
<span>Nieuw</span><span class="menu-hotkey">Ctrl+N</span>
<div class="submenu">
<div class="menu-item menu-nieuw-option" data-kind="woning" data-template="single">Woning (1 unit)</div>
<div class="menu-item menu-nieuw-option" data-kind="woning" data-template="L">Woning L-vorm (2 units)</div>
<div class="menu-item menu-nieuw-option" data-kind="muur">Los element: muur</div>
<div class="menu-item menu-nieuw-option" data-kind="vloer">Los element: vloer</div>
</div>
</div>
<div class="menu-item" id="menu-opslaan-als"><span>Opslaan als&hellip;</span><span class="menu-hotkey">Ctrl+S</span></div>
<div class="menu-item" id="menu-importeren">Importeren&hellip;</div>
<div class="menu-item" id="menu-project-props">Project eigenschappen&hellip;</div>
</div>
</div> </div>
<div class="rb-body"></div>
<div class="menu-group"> </header>
<button id="menu-bewerken-btn" class="menu-btn">Bewerken</button>
<div id="menu-bewerken-dropdown" class="menu-dropdown">
<div class="menu-item" id="menu-mirror-h"><span>Horizontaal spiegelen</span><span class="menu-hotkey">H</span></div>
<div class="menu-item disabled" id="menu-mirror-v" title="Nog niet beschikbaar: een wand mag aan de onderzijde niet scheef worden.">Verticaal spiegelen</div>
</div>
</div>
<div class="menu-group">
<button id="menu-beeld-btn" class="menu-btn">Beeld</button>
<div id="menu-beeld-dropdown" class="menu-dropdown">
<div class="menu-item" id="menu-fullscreen"><span>Volledig scherm</span><span class="menu-hotkey">F</span></div>
<div class="menu-item" id="menu-3d-fullscreen"><span>3D groot / alleen render</span><span class="menu-hotkey">T</span></div>
<div class="menu-item" id="menu-2d-fullscreen"><span>2D groot</span><span class="menu-hotkey">R</span></div>
</div>
</div>
<div id="app-bar-right">
<span id="app-version-tag" class="app-version-slot"></span>
<button id="btn-about">Over deze software</button>
</div>
</div>
<div id="about-overlay"> <div id="about-overlay">
<div id="about-modal"> <div id="about-modal">
@@ -114,24 +81,36 @@
<script src="js/core/license.js"></script> <script src="js/core/license.js"></script>
<script src="js/core/logo-data.js"></script> <script src="js/core/logo-data.js"></script>
<script src="js/core/geometry.js"></script> <script src="js/core/geometry.js"></script>
<script src="js/core/build-stages.js"></script>
<script src="js/core/canvas-labels.js"></script> <script src="js/core/canvas-labels.js"></script>
<script src="js/core/camera-memory.js"></script> <script src="js/core/camera-memory.js"></script>
<script src="js/core/walk-controls.js"></script>
<script src="js/core/view-filter.js"></script> <script src="js/core/view-filter.js"></script>
<script src="js/core/ui-kit.js"></script> <script src="js/core/ui-kit.js"></script>
<script src="js/core/ribbon.js"></script>
<script src="js/core/skybox.js"></script> <script src="js/core/skybox.js"></script>
<script src="js/vendor/jspdf.umd.min.js"></script> <script src="js/vendor/jspdf.umd.min.js"></script>
<script src="js/core/pdf-sheet.js"></script> <script src="js/core/pdf-sheet.js"></script>
<script src="js/wall/kozijn-model.js"></script>
<script src="js/wall/wall-model.js"></script> <script src="js/wall/wall-model.js"></script>
<script src="js/wall/wall-pdf.js"></script> <script src="js/wall/wall-pdf.js"></script>
<script src="js/floor/floor-model.js"></script> <script src="js/floor/floor-model.js"></script>
<script src="js/floor/floor-pdf.js"></script> <script src="js/floor/floor-pdf.js"></script>
<script src="js/roof/roof-model.js"></script> <script src="js/roof/roof-model.js"></script>
<script src="js/lighting/lighting-model.js"></script> <script src="js/lighting/lighting-model.js"></script>
<script src="js/elektra/elektra-model.js"></script>
<script src="js/sandwich/sandwich-model.js"></script> <script src="js/sandwich/sandwich-model.js"></script>
<script src="js/wall/wall.js"></script> <script src="js/wall/wall.js"></script>
<script src="js/floor/floor.js"></script> <script src="js/floor/floor.js"></script>
<script src="js/unit/unit-model.js"></script> <script src="js/unit/unit-model.js"></script>
<script src="js/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-csv.js"></script>
<script src="js/cost/cost-model.js"></script>
<script src="js/cost/cost-settings.js"></script>
<script src="js/elektra/elektra-pdf.js"></script>
<script src="js/house/house-pdf.js"></script> <script src="js/house/house-pdf.js"></script>
<script src="js/house/house.js"></script> <script src="js/house/house.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
+58 -77
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
const APP_VERSION = '0.4.15'; const APP_VERSION = '0.6.1';
// 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 = [];
@@ -33,13 +33,53 @@ function setWorkspaceEmpty() {
} }
function updateEditMenuState() { function updateEditMenuState() {
const has = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function'); Ribbon.refresh();
document.getElementById('menu-opslaan-als').classList.toggle('disabled', !has('exportDesign')); }
document.getElementById('menu-importeren').classList.toggle('disabled', !has('triggerImport'));
document.getElementById('menu-mirror-h').classList.toggle('disabled', !has('mirrorHorizontal')); const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
document.getElementById('menu-3d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen3D')); const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); };
document.getElementById('menu-2d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen2D')); const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled';
document.getElementById('menu-project-props').classList.toggle('disabled', !has('openProjectProperties'));
function appRibbonTabs() {
const { big, small } = Ribbon;
const wireClicks = (body) => body.querySelectorAll('[data-app]').forEach(b => b.addEventListener('click', () => {
const a = b.dataset.app;
if (a === 'new') openNewModal();
else if (a === 'new-kind') openNew(b.dataset.kind, b.dataset.template);
else if (a === 'fullscreen') toggleBrowserFullscreen();
else if (a === 'costs') CostSettings.open();
else if (a === 'about') document.getElementById('about-overlay').classList.add('open');
else editorCall(a);
}));
return [
{ key: 'bestand', label: 'Bestand', order: 0, wire: wireClicks, groups: () => [
{ label: 'Document', html:
big('Nieuw', 'file-earmark-plus', 'data-app="new" title="Nieuw ontwerp (Ctrl+N)"') +
big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) +
big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) },
{ label: 'Snel nieuw', html:
Object.entries(HouseModel.MODELS).map(([key, m]) => small(m.label, m.icon, `data-app="new-kind" data-kind="woning" data-template="${key}"`)).join('') +
small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') +
small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') +
small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') +
small('Los: vloer', 'grid-3x3', 'data-app="new-kind" data-kind="vloer"') },
{ label: 'Project', html: big('Eigenschappen', 'card-text', `data-app="openProjectProperties" title="Projectgegevens voor de PDF" ${disabledUnless('openProjectProperties')}`) }
] },
{ key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [
{ label: 'Venster', html:
small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') +
small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) +
small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) },
{ label: 'Camera', html:
big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) +
small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') }
] },
{ key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [
{ label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) },
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') },
{ label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') }
] }
];
} }
function renderTabBar() { function renderTabBar() {
@@ -62,6 +102,7 @@ function onTabSaved(tab, filenameWithExt) {
function mountEditorForTab(tab) { function mountEditorForTab(tab) {
const ws = document.getElementById('workspace'); const ws = document.getElementById('workspace');
ws.innerHTML = ''; ws.innerHTML = '';
Ribbon.setSource('editor', []);
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id }); currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') { if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
currentEditor.loadStateJson(tab.stateJson); currentEditor.loadStateJson(tab.stateJson);
@@ -84,7 +125,7 @@ function switchToTab(tabId) {
} }
function openNew(kind, template) { function openNew(kind, template) {
if (!editorModuleFor(kind)) { closeAllMenus(); return; } // 'dak' nog niet beschikbaar if (!editorModuleFor(kind)) return;
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') {
@@ -99,7 +140,6 @@ function openNew(kind, template) {
mountEditorForTab(tab); mountEditorForTab(tab);
updateEditMenuState(); updateEditMenuState();
renderTabBar(); renderTabBar();
closeAllMenus();
} }
function isTabDirty(tab) { function isTabDirty(tab) {
@@ -139,6 +179,7 @@ function closeTab(tabId) {
switchToTab(tabs[tabs.length - 1].id); switchToTab(tabs[tabs.length - 1].id);
} else { } else {
setWorkspaceEmpty(); setWorkspaceEmpty();
Ribbon.setSource('editor', []);
updateEditMenuState(); updateEditMenuState();
renderTabBar(); renderTabBar();
} }
@@ -153,65 +194,6 @@ function wireTabBar() {
}); });
} }
function closeAllMenus() {
document.querySelectorAll('.menu-dropdown, .submenu').forEach(el => el.classList.remove('open'));
document.querySelectorAll('.menu-btn').forEach(el => el.classList.remove('open'));
}
function wireTopLevelMenu(btnId, dropdownId) {
const btn = document.getElementById(btnId);
const dropdown = document.getElementById(dropdownId);
btn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = !dropdown.classList.contains('open');
closeAllMenus();
if (willOpen) { dropdown.classList.add('open'); btn.classList.add('open'); }
});
}
function wireMenu() {
wireTopLevelMenu('menu-bestand-btn', 'menu-bestand-dropdown');
wireTopLevelMenu('menu-bewerken-btn', 'menu-bewerken-dropdown');
wireTopLevelMenu('menu-beeld-btn', 'menu-beeld-dropdown');
document.getElementById('menu-nieuw-item').addEventListener('click', (e) => e.stopPropagation());
document.querySelectorAll('.menu-nieuw-option').forEach(opt => {
opt.addEventListener('click', () => openNew(opt.dataset.kind, opt.dataset.template));
});
document.getElementById('menu-opslaan-als').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
closeAllMenus();
});
document.getElementById('menu-importeren').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.triggerImport === 'function') currentEditor.triggerImport();
closeAllMenus();
});
document.getElementById('menu-mirror-h').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
closeAllMenus();
});
document.getElementById('menu-fullscreen').addEventListener('click', () => {
toggleBrowserFullscreen();
closeAllMenus();
});
document.getElementById('menu-3d-fullscreen').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
closeAllMenus();
});
document.getElementById('menu-2d-fullscreen').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
closeAllMenus();
});
document.getElementById('menu-project-props').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.openProjectProperties === 'function') currentEditor.openProjectProperties();
closeAllMenus();
});
document.addEventListener('click', closeAllMenus);
}
// Minimale markdown-weergave voor de licentietekst (koppen + alinea's).
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 => {
@@ -225,16 +207,14 @@ function renderLicenseHtml(md) {
function wireAbout() { function wireAbout() {
const overlay = document.getElementById('about-overlay'); const overlay = document.getElementById('about-overlay');
document.getElementById('about-license').innerHTML = renderLicenseHtml(window.TECTUM_LICENSE_MD || ''); document.getElementById('about-license').innerHTML = renderLicenseHtml(window.TECTUM_LICENSE_MD || '');
document.getElementById('btn-about').addEventListener('click', (e) => {
e.stopPropagation();
overlay.classList.add('open');
});
document.getElementById('about-close').addEventListener('click', () => overlay.classList.remove('open')); document.getElementById('about-close').addEventListener('click', () => overlay.classList.remove('open'));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
} }
function wireNewModal() { function wireNewModal() {
const overlay = document.getElementById('new-overlay'); const overlay = document.getElementById('new-overlay');
overlay.querySelector('.new-options').insertAdjacentHTML('afterbegin', Object.entries(HouseModel.MODELS).map(([key, m]) =>
`<button class="new-option" data-kind="woning" data-template="${key}"><i class="bi bi-${m.icon}"></i><span>${m.label}</span></button>`).join(''));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => { document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => {
opt.addEventListener('click', () => { opt.addEventListener('click', () => {
@@ -245,7 +225,6 @@ function wireNewModal() {
} }
function openNewModal() { function openNewModal() {
closeAllMenus();
document.getElementById('new-overlay').classList.add('open'); document.getElementById('new-overlay').classList.add('open');
} }
@@ -274,6 +253,8 @@ function wireHotkeys() {
toggleBrowserFullscreen(); toggleBrowserFullscreen();
} else if (e.key.toLowerCase() === 'h') { } else if (e.key.toLowerCase() === 'h') {
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal(); if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
} else if (e.key.toLowerCase() === 't' && e.shiftKey) {
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk();
} else if (e.key.toLowerCase() === 't') { } else if (e.key.toLowerCase() === 't') {
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D(); if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
} else if (e.key.toLowerCase() === 'r') { } else if (e.key.toLowerCase() === 'r') {
@@ -285,19 +266,19 @@ function wireHotkeys() {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; }); document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; }); document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
wireMenu(); Ribbon.init(document.getElementById('ribbon'));
Ribbon.setSource('app', appRibbonTabs());
wireAbout(); wireAbout();
wireNewModal(); wireNewModal();
wireHotkeys(); wireHotkeys();
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');
+62
View File
@@ -0,0 +1,62 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.BuildStages = (() => {
const LIST = [
{ key: 'regelwerk', label: 'Regelwerk', hint: 'Vloer-, wand- en dakbalken' },
{ key: 'elektra', label: 'Elektra', hint: 'Installatiebuizen door wanden en plafond, dozen en meterkast' },
{ key: 'beplating', label: 'Beplating', hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
{ key: 'doek', label: 'Vochtdoek', hint: 'Vochtdoek op de buitenzijde' },
{ key: 'latwerk', label: 'Latwerk', hint: 'Kruislat en trasraamplaat' },
{ key: 'gevel', label: 'Gevel & dak', hint: 'Rabat, steen, kozijnen, dakbedekking, sandwichpanelen' },
{ key: 'afwerking', label: 'Afgewerkt', hint: 'Dakrand, verlichting, vloerafwerking, verf binnen' }
];
const FINAL = LIST.length - 1;
const INDEX = Object.fromEntries(LIST.map((s, i) => [s.key, i]));
const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL;
const shows = (stage, key) => clamp(stage) >= INDEX[key];
function createBar(pane, stage, onChange) {
const el = document.createElement('div');
el.className = 'stage-bar';
el.innerHTML = `
<div class="stage-row">
<span class="stage-title"><i class="bi bi-layers"></i>Bouwfase</span>
<div class="stage-track">
<input type="range" class="stage-range" min="0" max="${FINAL}" step="1" value="${clamp(stage)}" aria-label="Bouwfase">
<div class="stage-ticks">${LIST.map((s, i) => `<span data-i="${i}" title="${s.label}: ${s.hint}">${i + 1}</span>`).join('')}</div>
</div>
<span class="stage-name"></span>
</div>
<div class="stage-extra"></div>`;
pane.appendChild(el);
const range = el.querySelector('.stage-range'), name = el.querySelector('.stage-name');
const sync = () => {
const i = clamp(Number(range.value));
name.textContent = LIST[i].label;
name.title = LIST[i].hint;
el.querySelectorAll('.stage-ticks span').forEach(t => t.classList.toggle('on', Number(t.dataset.i) <= i));
};
range.addEventListener('input', () => { sync(); onChange(clamp(Number(range.value))); });
el.querySelector('.stage-ticks').addEventListener('click', (e) => {
const t = e.target.closest('[data-i]');
if (!t) return;
range.value = t.dataset.i;
sync();
onChange(clamp(Number(range.value)));
});
// T, R, ... zijn sneltoetsen van de weergave, niet van de schuif.
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
// Na slepen focus loslaten, zodat de pijltoetsen weer naar de weergave (lopen) gaan.
el.addEventListener('pointerup', (e) => { if (e.target.matches('input[type=range]')) setTimeout(() => e.target.blur(), 0); });
sync();
return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } };
}
return { LIST, FINAL, INDEX, clamp, shows, createBar };
})();
-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;
+81 -107
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,88 +125,103 @@ 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 rechthoekige uitsparingen // reepje materiaal met randlijnen door de opening lopen). Schuin (y1r) alleen bovenin.
// ({ x0, x1, y0, y1 }). 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.
// opts (optioneel):
// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan);
// topYAt mag dan min(yCap, daklijn) zijn. Uitsparingen die tot
// boven yCap reiken worden er netjes op afgekapt (inkeping in de
// bovenrand).
// extraXs — knikpunten van topYAt (waar de daklijn yCap kruist), zodat de
// bovenrand tussen twee hoekpunten steeds lineair is.
// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }], of null
// als een uitsparing tot in de schuine bovenrand reikt (niet ondersteund).
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;
const extraXs = ((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps); const top = (x) => Math.min(yCap, topYAt(x));
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);
const rs = rects const rs = rects
.map(r => ({ x0: Math.max(x0, r.x0), x1: Math.min(x1, r.x1), y0: Math.max(yBottom, r.y0), y1: Math.min(yCap, r.y1) })) .filter(r => Math.min(x1, r.x1) - Math.max(x0, r.x0) > eps)
.filter(r => r.x1 - r.x0 > eps && r.y1 - r.y0 > eps); .map(r => {
const xs = uniq([x0, x1, ...extraXs, ...rs.flatMap(r => [r.x0, r.x1])]); const y1r = r.y1r !== undefined ? r.y1r : r.y1, s = (y1r - r.y1) / (r.x1 - r.x0);
return { x0: Math.max(x0, r.x0), x1: Math.min(x1, r.x1), y0: r.y0, topAt: (x) => r.y1 + (x - r.x0) * s };
});
const inRect = (x, y) => rs.some(r => x > r.x0 && x < r.x1 && y > r.y0 && y < r.topAt(x));
// Laagste bovenrand binnen [a, b] (topYAt is lineair tussen de knikpunten). const raw = [() => yBottom, top];
const minTopIn = (a, b) => Math.min(...xs.filter(x => x >= a - eps && x <= b + eps).map(topYAt)); rs.forEach(r => raw.push(() => r.y0, r.topAt));
const reachesTop = (r) => r.y1 >= yCap - eps && minTopIn(r.x0, r.x1) >= yCap - eps; // tot de vlakke bovenrand const clamp = (f) => (x) => Math.min(top(x), Math.max(yBottom, f(x)));
if (rs.some(r => !reachesTop(r) && r.y1 >= minTopIn(r.x0, r.x1) - eps)) return null; const lines = raw.map(clamp);
const ys = uniq([yBottom, ...rs.flatMap(r => reachesTop(r) ? [r.y0] : [r.y0, r.y1]).filter(y => y > yBottom + eps)]); let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]);
const nCols = xs.length - 1, nRows = ys.length; // bovenste rij loopt tot de daklijn const cuts = [];
for (let i = 0; i < xs.length - 1; i++) {
const a = xs[i], b = xs[i + 1];
for (let p = 0; p < raw.length; p++) {
for (let q = p + 1; q < raw.length; q++) {
const da = raw[p](a) - raw[q](a), db = raw[p](b) - raw[q](b);
if ((da > eps && db < -eps) || (da < -eps && db > eps)) cuts.push(a + (b - a) * da / (da - db));
}
}
}
xs = uniq(xs.concat(cuts));
const vertex = (i, j) => ({ x: xs[i], y: j < nRows ? ys[j] : topYAt(xs[i]) }); const cols = [];
const filled = (i, j) => { for (let i = 0; i < xs.length - 1; i++) {
if (i < 0 || i >= nCols || j < 0 || j >= nRows) return false; const a = xs[i], b = xs[i + 1], m = (a + b) / 2;
const xm = (xs[i] + xs[i + 1]) / 2; const vals = lines.map(f => ({ a: f(a), b: f(b), m: f(m) }))
const ym = j < nRows - 1 ? (ys[j] + ys[j + 1]) / 2 : (ys[j] + topYAt(xm)) / 2; .sort((p, q) => p.m - q.m)
return !rs.some(r => xm > r.x0 && xm < r.x1 && ym > r.y0 && ym < r.y1); .filter((v, k, arr) => k === 0 || v.m - arr[k - 1].m > eps || Math.abs(v.a - arr[k - 1].a) > eps || Math.abs(v.b - arr[k - 1].b) > eps);
const cells = [];
for (let k = 0; k < vals.length - 1; k++) {
const lo = vals[k], hi = vals[k + 1];
if (hi.m - lo.m <= eps) continue;
cells.push({ lo, hi, filled: !inRect(m, (lo.m + hi.m) / 2) });
}
cols.push({ a, b, cells });
}
const verts = [];
const vid = (x, y) => {
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 }); }
return k;
}; };
const edges = new Map();
// Gerichte randen van gevulde cellen (CCW), alleen waar de buur leeg is. const addEdge = (p, q) => {
const edges = new Map(); // "i,j" -> [[i2,j2], ...] const a = vid(p.x, p.y), b = vid(q.x, q.y);
const addEdge = (a, b) => { if (a === b) return;
const key = a.join(','); if (!edges.has(a)) edges.set(a, []);
if (!edges.has(key)) edges.set(key, []); edges.get(a).push(b);
edges.get(key).push(b);
}; };
for (let i = 0; i < nCols; i++) { cols.forEach(col => col.cells.forEach((c, k) => {
for (let j = 0; j < nRows; j++) { if (!c.filled) return;
if (!filled(i, j)) continue; 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 (!filled(i, j - 1)) addEdge([i, j], [i + 1, j]); 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 (!filled(i + 1, j)) addEdge([i + 1, j], [i + 1, j + 1]); }));
if (!filled(i, j + 1)) addEdge([i + 1, j + 1], [i, j + 1]); for (let i = 0; i < xs.length; i++) {
if (!filled(i - 1, j)) addEdge([i, j + 1], [i, j]); 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 right = i < cols.length ? cols[i].cells.filter(c => c.filled).map(c => [c.lo.a, c.hi.a]) : [];
const ys = uniq(left.concat(right).flat());
for (let k = 0; k < ys.length - 1; k++) {
const ya = ys[k], yb = ys[k + 1], ym = (ya + yb) / 2;
if (yb - ya <= eps) continue;
const lf = left.some(([p, q]) => ym > p && ym < q), rf = right.some(([p, q]) => ym > p && ym < q);
if (lf && !rf) addEdge({ x, y: ya }, { x, y: yb });
else if (rf && !lf) addEdge({ x, y: yb }, { x, y: ya });
} }
} }
// Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
const loops = []; const loops = [];
edges.forEach((list, startKey) => { edges.forEach((list, start) => {
while (list.length) { while (list.length) {
const loop = []; const loop = [start];
let cur = startKey.split(',').map(Number); let next = list.pop(), guard = 0;
let next = list.pop(); while (next !== start && guard++ < 100000) {
loop.push(cur);
while (next.join(',') !== startKey) {
loop.push(next); loop.push(next);
cur = next; const outs = edges.get(next);
const outs = edges.get(cur.join(',')); if (!outs || !outs.length) break;
next = outs.pop(); next = outs.pop();
} }
loops.push(loop); if (next === start) loops.push(loop);
} }
}); });
const polys = loops.map(loop => { const polys = loops.map(loop => {
// Eerst dubbele punten weg (bijv. een spits toelopend uiteinde waar de const pts = loop.map(k => verts[k]);
// bovenrand de onderrand raakt), dan collineaire tussenpunten.
const pts = loop.map(([i, j]) => vertex(i, j))
.filter((p, k, arr) => { const q = arr[(k + 1) % arr.length]; return Math.hypot(p.x - q.x, p.y - q.y) > 1e-9; });
return pts.filter((p, k) => { return pts.filter((p, k) => {
const a = pts[(k - 1 + pts.length) % pts.length], b = pts[(k + 1) % pts.length]; const a = pts[(k - 1 + pts.length) % pts.length], b = pts[(k + 1) % pts.length];
return Math.abs((p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x)) > 1e-12; return Math.abs((p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x)) > 1e-12;
@@ -254,18 +241,15 @@ 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: [] }));
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 owner = outers.find(o => inside(hole[0], o.outer)) || outers[0]; const mid = { x: (hole[0].x + hole[1].x) / 2, y: (hole[0].y + hole[1].y) / 2 };
const owner = outers.find(o => inside(mid, o.outer)) || outers[0];
if (owner) owner.holes.push(hole); if (owner) owner.holes.push(hole);
}); });
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);
@@ -308,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) => {
@@ -328,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] }));
}); });
+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
+2 -6
View File
File diff suppressed because one or more lines are too long
+20 -21
View File
@@ -4,24 +4,33 @@
* 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'
}; };
// generate() is synchroon: afbeeldingen vooraf laden met preloadImage.
const imageCache = {};
function preloadImage(url) {
if (!url || url in imageCache || typeof Image === 'undefined' || typeof document === 'undefined') return;
imageCache[url] = null;
const img = new Image();
img.onload = () => {
try {
const c = document.createElement('canvas');
c.width = img.naturalWidth; c.height = img.naturalHeight;
c.getContext('2d').drawImage(img, 0, 0);
imageCache[url] = c.toDataURL('image/png');
} catch (e) { }
};
img.src = url;
}
const imageData = (url) => (url && imageCache[url]) || null;
function createDoc() { function createDoc() {
const { jsPDF } = window.jspdf; const { jsPDF } = window.jspdf;
return new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a3' }); return new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a3' });
@@ -36,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);
@@ -49,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 || {};
@@ -83,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);
@@ -95,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);
@@ -103,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 || {};
@@ -130,5 +129,5 @@ window.PdfSheet = (() => {
return y; return y;
} }
return { COLORS, createDoc, readProjectFromDom, dimLine, titleBlock, table }; return { COLORS, preloadImage, imageData, createDoc, readProjectFromDom, dimLine, titleBlock, table };
})(); })();
-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;
+116
View File
@@ -0,0 +1,116 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.Ribbon = (() => {
const sources = {};
const SOURCE_ORDER = ['app', 'editor'];
let root = null, tabsEl = null, bodyEl = null;
let activeKey = null, lastPlainKey = null;
let auto = true;
let collapsed = false, notified = null;
const listeners = [];
const PREF_KEY = 'tectum.ribbon.collapsed';
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
const big = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
const small = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
const kv = (label, value, unit) =>
`<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
function allTabs() {
const byKey = new Map();
SOURCE_ORDER.concat(Object.keys(sources).filter(n => !SOURCE_ORDER.includes(n))).forEach(name => {
(sources[name] || []).forEach(t => {
if (!byKey.has(t.key)) byKey.set(t.key, { key: t.key, label: t.label, order: t.order || 0, contextual: !!t.contextual, icon: t.icon, parts: [] });
const m = byKey.get(t.key);
if (t.order !== undefined && name !== 'app') m.order = t.order;
if (t.label && name !== 'app') m.label = t.label;
if (t.contextual) m.contextual = true;
m.parts.push(t);
});
});
return Array.from(byKey.values()).sort((a, b) => (a.contextual - b.contextual) || (a.order - b.order));
}
function renderTabs(list) {
tabsEl.innerHTML = list.map(t =>
`<button type="button" class="rb-tab${t.key === activeKey ? ' active' : ''}${t.contextual ? ' rb-ctx' : ''}" data-rb-tab="${t.key}">${t.contextual && t.icon ? icon(t.icon) : ''}<span>${t.label}</span></button>`).join('');
}
function renderBody(list) {
const tab = list.find(t => t.key === activeKey);
if (!tab) { bodyEl.innerHTML = ''; return; }
const groups = [];
tab.parts.forEach(p => (p.groups ? p.groups() : []).forEach(g => g && groups.push(g)));
bodyEl.innerHTML = groups.map(g =>
`<section class="rb-group${g.cls ? ' ' + g.cls : ''}"><div class="rb-group-body">${g.html}</div><div class="rb-group-label">${g.label}</div></section>`).join('') +
`<button type="button" class="rb-collapse" title="Ribbon inklappen (dubbelklik op een tabblad)">${icon('chevron-up')}</button>`;
tab.parts.forEach(p => p.wire && p.wire(bodyEl));
}
function render() {
if (!root) return;
const list = allTabs();
if (!list.some(t => t.key === activeKey) || (auto && !list.some(t => t.key === activeKey && t.contextual))) {
const pick = [lastPlainKey, 'start'].find(k => list.some(t => t.key === k));
activeKey = pick || (list.find(t => !t.contextual) || {}).key || null;
}
renderTabs(list);
renderBody(list);
root.classList.toggle('rb-collapsed', collapsed);
if (notified !== activeKey) { notified = activeKey; listeners.forEach(fn => fn(activeKey)); }
}
function activate(key) {
const list = allTabs();
const tab = list.find(t => t.key === key);
if (!tab) return;
activeKey = key;
auto = false;
if (!tab.contextual) lastPlainKey = key;
if (collapsed) { collapsed = false; savePref(); }
render();
tab.parts.forEach(p => p.onShow && p.onShow());
}
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { } }
function init(el) {
root = el;
try { collapsed = localStorage.getItem(PREF_KEY) === '1'; } catch (e) { collapsed = false; }
tabsEl = el.querySelector('.rb-tabs');
bodyEl = el.querySelector('.rb-body');
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest('[data-rb-tab]');
if (!b) return;
if (b.dataset.rbTab === activeKey && collapsed) { collapsed = false; savePref(); render(); return; }
activate(b.dataset.rbTab);
});
tabsEl.addEventListener('dblclick', (e) => {
if (!e.target.closest('[data-rb-tab]')) return;
collapsed = !collapsed; savePref(); render();
});
bodyEl.addEventListener('click', (e) => {
if (e.target.closest('.rb-collapse')) { collapsed = true; savePref(); render(); }
});
render();
}
function setSource(name, tabs, opts) {
sources[name] = tabs || [];
const ctx = (tabs || []).find(t => t.contextual);
if (opts && opts.focus && ctx) { activeKey = ctx.key; if (collapsed) { collapsed = false; savePref(); } }
render();
if (opts && opts.focus && ctx && ctx.onShow) ctx.onShow();
}
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv };
})();
+11 -32
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();
@@ -212,5 +191,5 @@ window.Skybox = (() => {
return wrap; return wrap;
} }
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, createEnvironment, apply, getMode, lightFactor, createToggle }; return { CAMERA_HEIGHT_M, NIGHT_FACTOR, MODES, LABELS, createEnvironment, apply, getMode, setMode: remember, lightFactor, createToggle };
})(); })();
+36 -23
View File
@@ -4,17 +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,
// 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;');
@@ -25,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;
@@ -34,13 +23,37 @@ window.UI = (() => {
`<span class="ui-unit">${unit}</span>`, o); `<span class="ui-unit">${unit}</span>`, o);
} }
// Keuzelijst. options: { waarde: 'Label' }. // Het getal mag buiten het schuifbereik; het model klemt zelf.
function range(id, label, value, o) {
o = o || {};
const unit = o.unit === undefined ? 'mm' : o.unit;
const lim = `min="${o.min}" max="${o.max}" step="${o.step || 10}"`;
return `<div class="ui-field ui-field-range${o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o.hint)}</label>` +
`<div class="ui-control ui-range"><input type="range" id="${id}-r" ${lim} value="${value}"${o.disabled ? ' disabled' : ''}>` +
`<input type="number" id="${id}" step="${o.step || 10}" value="${value}"${o.disabled ? ' disabled' : ''}><span class="ui-unit">${unit}</span></div></div>`;
}
function onRange(id, fn, after) {
const n = document.getElementById(id), r = document.getElementById(id + '-r');
if (!n || !r) return;
r.addEventListener('input', () => { n.value = r.value; fn(parseFloat(r.value)); after && after(); });
n.addEventListener('input', () => {
const v = parseFloat(n.value);
if (!isFinite(v)) return;
r.value = v;
fn(v); after && after();
});
}
function setRange(id, v) {
const n = document.getElementById(id), r = document.getElementById(id + '-r');
if (n && document.activeElement !== n) n.value = v;
if (r) r.value = v;
}
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);
} }
@@ -49,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);
@@ -64,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;
@@ -74,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;
@@ -88,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;
@@ -113,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');
@@ -126,5 +139,5 @@ window.UI = (() => {
el.classList.toggle('fullscreen-2d'); el.classList.toggle('fullscreen-2d');
} }
return { esc, icon, hint, num, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D }; return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, 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');
+85
View File
@@ -0,0 +1,85 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
function createWalkControls(camera, domElement, 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();
let active = false;
let x = 0, z = 0, yaw = 0, pitch = 0;
let drag = null;
const inField = () => ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement && document.activeElement.tagName);
const MOVE_KEYS = ['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift'];
function onKeyDown(e) {
if (!active || inField() || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.toLowerCase();
if (!MOVE_KEYS.includes(k)) return;
keys.add(k);
e.preventDefault();
}
function onKeyUp(e) { keys.delete(e.key.toLowerCase()); }
function onDown(e) { if (active && e.button === 0) drag = { x: e.clientX, y: e.clientY }; }
function onMove(e) {
if (!active || !drag) return;
const w = domElement.clientWidth || 1;
yaw -= (e.clientX - drag.x) / w * Math.PI;
pitch = Math.max(-1.2, Math.min(1.2, pitch - (e.clientY - drag.y) / w * Math.PI));
drag = { x: e.clientX, y: e.clientY };
}
function onUp() { drag = null; }
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
domElement.addEventListener('pointerdown', onDown);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
function apply() {
camera.position.set(x, o.floorAt(x, z) + o.eye, z);
// yaw 0 = kijkrichting +Z; pitch > 0 = omhoog.
const dir = new THREE.Vector3(Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch));
camera.lookAt(camera.position.clone().add(dir));
}
function step(dx, dz) {
if (!o.blocked(x + dx, z + dz)) { x += dx; z += dz; return; }
if (!o.blocked(x + dx, z)) x += dx;
else if (!o.blocked(x, z + dz)) z += dz;
}
return {
get active() { return active; },
start(pose) {
x = pose.x; z = pose.z; yaw = pose.yaw || 0; pitch = 0;
keys.clear();
active = true;
apply();
},
stop() { active = false; keys.clear(); drag = null; },
update(dt) {
if (!active) return;
const t = Math.min(dt, 0.1);
if (keys.has('arrowleft')) yaw += o.turn * t;
if (keys.has('arrowright')) yaw -= o.turn * t;
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);
if (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);
const rx = -fz, rz = fx;
step((fx * f + rx * r) * v, (fz * f + rz * r) * v);
}
apply();
},
dispose() {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
domElement.removeEventListener('pointerdown', onDown);
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
}
};
}
+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();
+259
View File
@@ -0,0 +1,259 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Alleen noodwaarden: de echte waarden staan in kosten/intern.csv en kosten/leveranciers.csv.
window.COST_DEFAULTS = {
arbeid: {
label: 'Arbeid',
items: {
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 },
toeslagUren: { label: 'Toeslag indirecte uren (klaarleggen, opruimen, intern transport)', unit: '%', value: 10 },
qa: { label: 'QA (laatste aanpassingen, klantcontact) over de totale werktijd', unit: '%', value: 4 }
}
},
zagen: {
label: 'Zagen',
items: {
methode: { label: 'Zaagmethode', options: { robot: 'Zaagrobot', hand: 'Handmatig' }, value: 'robot' },
robotSnede: { label: 'Robot: tijd per haakse snede', unit: 's', value: 5 },
robotSchuin: { label: 'Robot: extra tijd per schuine snede', unit: 's', value: 2.5 },
robotStuk: { label: 'Robot: handling per stuk (invoer, uitvoer, etiket)', unit: 's', value: 15 },
handFactor: { label: 'Handmatig: factor op de robottijd', unit: '×', value: 7 }
}
},
verbinden: {
label: 'Verbindingen (regelwerk)',
items: {
schroevenPerVerbinding: { label: 'Schroeven per verbinding (stijl/dorpel op regel)', unit: 'st.', value: 3 },
eersteSchroef: { label: 'Tijd eerste schroef (gat + schroef + uitlijnen)', unit: 's', value: 10 },
volgendeSchroef: { label: 'Tijd volgende schroef (gat + schroef)', unit: 's', value: 5 },
prijsPer1000: { label: 'Inkoop constructieschroeven (6×120)', unit: '€/1000', value: 45 },
verbindingenPerVloerbalk: { label: 'Verbindingen per vloer-/dakbalk', unit: 'st.', value: 2 },
dubbelAfstand: { label: 'Dubbele stijl (deur, hoek): schroef verticaal elke', unit: 'mm', value: 100 },
monteerFactor: { label: 'Monteren: factor op de schroeftijd (incl. lijmen, positioneren)', unit: '×', value: 6 },
personeel: { label: 'Personeel bij monteren constructie (vloer, wand, dak; i.p.v. de bezetting)', unit: 'man', value: 2 },
dakFactor: { label: 'Dak: factor op de tijd per schroef (moeilijker werken)', unit: '×', value: 4 }
}
},
montage: {
label: 'Montage',
items: {
muurVastzetten: { label: 'Muursectie plaatsen en vastzetten', unit: 'min', value: 10 },
muurPersoneel: { label: 'Personeel bij plaatsen muursectie (i.p.v. de bezetting)', unit: 'man', value: 3 }
}
},
hout: {
label: 'Hout',
items: {
constructieM3: { label: 'Constructiehout vuren C24 (regelwerk, balken)', unit: '€/m³', value: 550 },
latwerkM: { label: 'Latwerk (kruislat)', unit: '€/m', value: 0.9 },
latStukMontage: { label: 'Latwerk: per gezaagd stuk (meten, zagen op locatie, passen)', unit: 's', value: 240 },
latMeterMontage: { label: 'Latwerk: bevestigen per strekkende meter', unit: 's/m', value: 30 }
}
},
plaat: {
label: 'Plaatwerk',
items: {
spaanplaatM2: { label: 'Spaanplaat', unit: '€/m²', value: 9 },
spaanplaatMontage: { label: 'Montage spaanplaat (passen, zagen, plaatsen)', unit: 's/m²', value: 120 },
osbM2: { label: 'OSB (vloeren, plafonds dak)', unit: '€/m²', value: 12 },
osbMontage: { label: 'Montage OSB', unit: 's/m²', value: 90 },
cementplaatM2: { label: 'Cementgebonden plaat (gevel, trasraam)', unit: '€/m²', value: 28 },
cementplaatMontage: { label: 'Montage cementplaat', unit: 's/m²', value: 240 },
schroevenPerM2: { label: 'Plaatschroeven per m²', unit: 'st./m²', value: 15 },
tijdPerPlaatschroef: { label: 'Tijd per plaatschroef', unit: 's', value: 4 },
plaatschroefPer1000: { label: 'Inkoop plaatschroeven (4×40)', unit: '€/1000', value: 15 }
}
},
isolatie: {
label: 'Isolatie',
items: {
glaswolM3: { label: 'Glaswol', unit: '€/m³', value: 40 },
glaswolMontage: { label: 'Montage glaswol', unit: 's/m³', value: 900 },
steenIsolatieM3: { label: 'Isolatieblok steengevel', unit: '€/m³', value: 120 }
}
},
gevel: {
label: 'Gevel',
items: {
vochtdoekM2: { label: 'Vochtdoek', unit: '€/m²', value: 1.5 },
vochtdoekMontageM: { label: 'Montage vochtdoek per strekkende meter wand', unit: 's/m', value: 600 },
rabatM2: { label: 'Rabat (gevelbekleding)', unit: '€/m²', value: 35 },
rabatMontage: { label: 'Montage rabat', unit: 's/m²', value: 600 },
steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 },
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 },
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 }
}
},
dak: {
label: 'Dak & dakrand',
items: {
dakleerM2: { label: 'Dakleer (EPDM)', unit: '€/m²', value: 18 },
dakleerMontage: { label: 'Montage dakleer', unit: 's/m²', value: 600 },
rietM2: { label: 'Riet', unit: '€/m²', value: 90 },
rietMontage: { label: 'Montage riet', unit: 's/m²', value: 3600 },
beschotM2: { label: 'Dakbeschot OSB 18 mm (bovenop de sporen)', unit: '€/m²', value: 14 },
beschotMontage: { label: 'Montage dakbeschot', unit: 's/m²', value: 150 },
felsM2: { label: 'Felsplaten staande naad (zink/staal, klangen, scheidingslaag)', unit: '€/m²', value: 65 },
felsMontage: { label: 'Montage felsplaten', unit: 's/m²', value: 1800 },
boeideelM: { label: 'Boeideel', unit: '€/m', value: 25 },
boeideelMontage: { label: 'Montage boeideel', unit: 's/m', value: 600 },
onderplaatM2: { label: 'Onderplaat boeideel', unit: '€/m²', value: 30 },
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 },
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 }
}
},
vloer: {
label: 'Vloer & fundering',
items: {
afwerkingM2: { label: 'Vloerafwerking', unit: '€/m²', value: 35 },
afwerkingMontage: { label: 'Montage vloerafwerking', unit: 's/m²', value: 1200 },
sandwichM2: { label: 'Sandwichpaneel', unit: '€/m²', value: 35 },
sandwichMontage: { label: 'Montage per sandwichpaneel', unit: 's', value: 600 }
}
},
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: {
label: 'Kozijnen',
items: {
raamM2: { label: 'Raamkozijn incl. glas', unit: '€/m²', value: 450 },
deur: { label: 'Binnen-/buitendeur incl. kozijn', unit: '€/st.', value: 900 },
voordeur: { label: 'Voordeur incl. kozijn', unit: '€/st.', value: 1800 },
schuifdeurBlad: { label: 'Schuifdeur per blad (blad, rail of pocket, beslag, aftimmering)', unit: '€/st.', value: 650 },
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: {
label: 'Verlichting',
items: {
gevellamp: { label: 'Gevellamp', unit: '€/st.', value: 45 },
boeideelSpot: { label: 'Boeideel-spot', unit: '€/st.', value: 25 },
binnenspot: { label: 'Binnenspot', unit: '€/st.', value: 20 },
montage: { label: 'Montage per lamp/spot (armatuur plaatsen en aansluiten; leidingen en dozen onder Elektra)', unit: 's', value: 300 }
}
},
elektra: {
label: 'Elektra',
items: {
personeel: { label: 'Personeel bij elektra (i.p.v. de bezetting)', unit: 'man', value: 1 },
boorgat: { label: 'Boorgat door stijl, regel of balk (aftekenen, boren, ontbramen)', unit: 's', value: 90 },
buisLeggen: { label: 'Installatiebuis leggen en bevestigen', unit: 's/m', value: 60 },
draadTrekken: { label: 'Draad trekken (per meter buis, alle aders)', unit: 's/m', value: 20 },
kabelLeggen: { label: 'XMvK-kabel leggen en bevestigen (buiten)', unit: 's/m', value: 40 },
doosPlaatsen: { label: 'Inbouwdoos plaatsen (luchtdicht, hollewand)', unit: 's', value: 240 },
verdeeldoos: { label: 'Verdeeldoos plaatsen en verbinden', unit: 's', value: 600 },
spotdoos: { label: 'Spotdoos plaatsen (luchtdicht plafond)', unit: 's', value: 300 },
vloerdoos: { label: 'Vloerdoos plaatsen (gat in vloer, aansluiten)', unit: 's', value: 1800 },
afmonteren: { label: 'Afmonteren schakelaar/wcd/apparaataansluiting', unit: 's', value: 300 },
steekverbinderMontage: { label: 'Steekverbinder module-naad monteren (set m/v)', unit: 's', value: 300 },
kastMontage: { label: 'Groepenkast plaatsen (kast, hoofdschakelaar, aardlek)', unit: 's', value: 3600 },
groepMontage: { label: 'Per groep: automaat plaatsen, bedraden, labelen', unit: 's', value: 900 },
keuring: { label: 'Meten, testen en opleveren (NEN 1010 / NEN 3140)', unit: 's', value: 10800 },
buis16M: { label: 'Installatiebuis flexibel Ø16', unit: '€/m', value: 0.35 },
buis19M: { label: 'Installatiebuis flexibel Ø19', unit: '€/m', value: 0.45 },
buis25M: { label: 'Installatiebuis flexibel Ø25', unit: '€/m', value: 0.8 },
draad15M: { label: 'VD-draad 1,5 mm² (per ader)', unit: '€/m', value: 0.28 },
draad25M: { label: 'VD-draad 2,5 mm² (per ader)', unit: '€/m', value: 0.45 },
xmvkM: { label: 'XMvK 3×1,5 mm²', unit: '€/m', value: 1.25 },
inbouwdoos: { label: 'Luchtdichte hollewanddoos', unit: '€/st.', value: 1.6 },
verdeeldoosSt: { label: 'Luchtdichte verdeeldoos', unit: '€/st.', value: 4.5 },
spotdoosSt: { label: 'Luchtdichte spotdoos', unit: '€/st.', value: 7.5 },
vloerdoosSt: { label: 'Vloerdoos met dubbele wcd', unit: '€/st.', value: 95 },
schakelaar: { label: 'Schakelaar enkelpolig (inbouw)', unit: '€/st.', value: 9 },
afdekraam1: { label: 'Afdekraam 1-voudig', unit: '€/st.', value: 3 },
afdekraam2: { label: 'Afdekraam 2-voudig', unit: '€/st.', value: 5 },
wcdDubbel: { label: 'Wandcontactdoos dubbel met randaarde', unit: '€/st.', value: 10 },
wcdEnkel: { label: 'Wandcontactdoos enkel met randaarde', unit: '€/st.', value: 6 },
perilex: { label: 'Perilex-wandcontactdoos (kookgroep)', unit: '€/st.', value: 18 },
werkschakelaar: { label: 'Werkschakelaar (warmtepomp)', unit: '€/st.', value: 28 },
lasklem: { label: 'Lasklem 3-voudig', unit: '€/st.', value: 0.35 },
steekverbinder3: { label: 'Steekverbinder 3-polig (set m/v)', unit: '€/set', value: 9 },
steekverbinder5: { label: 'Steekverbinder 5-polig (set m/v)', unit: '€/set', value: 14 },
doorvoer: { label: 'Luchtdichte kabeldoorvoer naar buiten', unit: '€/st.', value: 3.5 },
kast: { label: 'Groepenkast 3-fase leeg (behuizing, kammen, aansluitmateriaal)', unit: '€/st.', value: 180 },
hoofdschakelaar: { label: 'Hoofdschakelaar 4-polig 40 A', unit: '€/st.', value: 55 },
aardlek: { label: 'Aardlekschakelaar 2-polig 40 A / 30 mA', unit: '€/st.', value: 40 },
automaat: { label: 'Installatieautomaat 1P+N (B10/B16)', unit: '€/st.', value: 14 },
automaat2f: { label: 'Installatieautomaat 2-fase (kookgroep)', unit: '€/st.', value: 30 }
}
},
bedrijf: {
label: 'Afschrijving & bedrijfskosten',
items: {
machines: { label: 'Afschrijving machines (zaagrobot, gereedschap, inrichting) per uur', unit: '€/u', value: 8 },
slijtage: { label: 'Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per uur', unit: '€/u', value: 2 },
huisvesting: { label: 'Huisvesting productiehal (huur, energie) per uur', unit: '€/u', value: 6 },
transportUnit: { label: 'Transport per unit naar de bouwplaats', unit: '€/unit', value: 750 },
kraan: { label: 'Kraan voor plaatsen woning', unit: '€', value: 1200 }
}
},
opslag: {
label: 'Opslagen',
items: {
verlies: { label: 'Materiaalverlies (beschadiging, misgezaagd, restmateriaal; naast het berekende snijverlies balken)', unit: '%', value: 5 },
klein: { label: 'Klein materiaal (lijm, kit, tape, nieten)', unit: '%', value: 3 },
algemeen: { label: 'Algemene kosten (kantoor, verzekering, garantie) over de kostprijs', unit: '%', value: 5 },
marge: { label: 'Winstmarge', unit: '%', value: 15 },
btw: { label: 'Btw', unit: '%', value: 21 }
}
}
};
+412
View File
@@ -0,0 +1,412 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.CostModel = (() => {
const listeners = [];
let session = {};
try { localStorage.removeItem('tectum.costs'); localStorage.removeItem('tectum.leveranciers'); } catch (e) { /* oude browseropslag */ }
function defaultValues() {
const v = {};
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => {
v[g] = {};
Object.entries(grp.items).forEach(([k, it]) => { v[g][k] = it.value; });
});
return CostCsv.overlay('intern', v);
}
function mergeValues(base, over) {
const v = JSON.parse(JSON.stringify(base));
Object.entries(over || {}).forEach(([g, items]) => {
const grp = window.COST_DEFAULTS[g];
if (!grp || !items || typeof items !== 'object') return;
Object.entries(items).forEach(([k, val]) => {
const it = grp.items[k];
if (!it) return;
if (it.options ? Object.prototype.hasOwnProperty.call(it.options, val) : typeof val === 'number' && isFinite(val)) v[g][k] = val;
});
});
return v;
}
const listed = (g, k) => !!CostCsv.entry('leveranciers', `${g}.${k}`);
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 saveSettings(values) {
const def = defaultValues(), diff = {};
Object.entries(mergeValues(def, values)).forEach(([g, items]) => Object.entries(items).forEach(([k, val]) => {
if (val !== def[g][k] && !listed(g, k)) (diff[g] = diff[g] || {})[k] = val;
}));
session = diff;
listeners.forEach(fn => fn());
}
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()));
function importFile(text) {
if (/^\s*\{/.test(text)) {
const obj = JSON.parse(text);
if (!obj || obj.type !== 'tectum-costs' || typeof obj.values !== 'object') throw new Error('geen Tectum-kostenbestand');
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'];
const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Afwerking', 'Inrichting', 'Verlichting', 'Elektra'];
const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl'];
const DORPEL_KINDS = ['bovendorpel', 'onderdorpel'];
function compute(parts, totals, settings) {
const S = settings || getSettings();
const elements = [];
const newEl = (element, label, unit) => { const el = { element, label, unit, lines: [] }; elements.push(el); return el; };
// sec = tijd voor één persoon; opgeslagen sec = manuren (× crew).
const add = (el, cat, act, label, qty, unit, mat, sec, crew) => {
if (!(qty > 0)) return;
const c = sec ? (crew !== undefined ? crew : S.arbeid.bezetting) : 0;
// clock = doorlooptijd van de stap (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 });
};
const Z = S.zagen, V = S.verbinden, P = S.plaat;
const sawFactor = Z.methode === 'hand' ? Z.handFactor : 1;
const saw = { pieces: 0, cuts: 0, angled: 0, robotSec: 0 };
const sawSec = (cutAngles, count = 1) => {
const ca = cutAngles || [0, 0], angled = ca.filter(a => a !== 0).length;
const robot = Z.robotStuk + ca.length * Z.robotSnede + angled * Z.robotSchuin;
saw.pieces += count; saw.cuts += count * ca.length; saw.angled += count * angled; saw.robotSec += count * robot;
return count * robot * sawFactor;
};
const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)';
const jointSec = V.eersteSchroef + Math.max(0, V.schroevenPerVerbinding - 1) * V.volgendeSchroef;
const addJoints = (el, joints, label, roof) => {
const n = joints * V.schroevenPerVerbinding;
const f = V.monteerFactor * (roof ? V.dakFactor : 1);
add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel);
};
const addSheet = (el, cat, label, m2, priceM2, montageSecM2) => {
if (!(m2 > 0)) return;
add(el, cat, 'Plaatwerk', label, m2, 'm²', m2 * priceM2, m2 * montageSecM2);
const n = Math.ceil(m2 * P.schroevenPerM2);
add(el, 'Schroeven', 'Plaatwerk', `Plaatschroeven ${label.toLowerCase()}`, n, 'st.', n * P.plaatschroefPer1000 / 1000, n * P.tijdPerPlaatschroef);
};
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 seenOpenings = new Set(); // naadwand: openingen één keer (beide delen hebben dezelfde deur)
function wallElement(p, unitId) {
const el = newEl(p.element, p.label, unitId);
const bom = WallModel.computeBOM(p.config, p.frame);
const woodM3 = bom.reduce((a, b) => a + b.width * b.depth * b.length, 0) / 1e9;
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);
add(el, 'Hout', 'Zagen', `${sawLabel}: ${bom.length} stuks`, cuts, 'sneden', 0, bom.reduce((a, b) => a + sawSec(b.cutAngles), 0));
const verticals = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).length;
const dorpels = bom.filter(b => DORPEL_KINDS.includes(b.kind)).length;
const topPlates = bom.filter(b => b.kind === 'bovenregel').length;
addJoints(el, 2 * verticals + 2 * dorpels + Math.max(0, topPlates - 1), 'Stijlen/dorpels');
const studs = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).sort((a, b) => a.x - b.x);
let dblScrews = 0, dblPairs = 0;
for (let i = 1; i < studs.length; i++) {
const a = studs[i - 1], b = studs[i];
if (b.x - a.x > (a.width + b.width) / 2 + 1) continue;
const ov = Math.min(a.y + a.length / 2, b.y + b.length / 2) - Math.max(a.y - a.length / 2, b.y - b.length / 2);
if (ov <= 0) continue;
dblPairs++;
dblScrews += Math.max(2, Math.floor(ov / V.dubbelAfstand));
}
add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}×`, dblScrews, 'st.',
dblScrews * V.prijsPer1000 / 1000, dblScrews * V.volgendeSchroef * V.monteerFactor, V.personeel);
const M = S.montage;
add(el, 'Hout', 'Wanden plaatsen', 'Plaatsen en vastzetten', 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel);
const st = p.stats || {};
addSheet(el, 'Plaatwerk', 'Spaanplaat', (st.spaanplaatAreaM2 || 0) + (st.spaanplaatBuitenAreaM2 || 0), P.spaanplaatM2, P.spaanplaatMontage);
addSheet(el, 'Plaatwerk', 'Cementplaat', (st.cementplaatAreaM2 || 0) + (st.trasraamAreaM2 || 0), P.cementplaatM2, P.cementplaatMontage);
const I = S.isolatie;
const wool = (p.specs && p.specs.voidVolumeM3) || 0;
add(el, 'Isolatie', 'Isoleren', 'Glaswol', wool, 'm³', wool * I.glaswolM3, wool * I.glaswolMontage);
const stoneIso = (st.stoneIsolatieM3 || 0) + (st.trasraamIsolatieM3 || 0);
add(el, 'Isolatie', 'Gevel', 'Isolatieblok steen', stoneIso, 'm³', stoneIso * I.steenIsolatieM3, 0);
const G = S.gevel;
const doek = st.doekAreaM2 || 0;
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);
const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
const stuc = st.stucAreaM2 || 0;
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 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,
(st.battenPieces || 0) * S.hout.latStukMontage + latM * S.hout.latMeterMontage);
const K = S.kozijnen;
const opKey = p.seam ? `iw${p.id}` : null;
if (!opKey || !seenOpenings.has(opKey)) {
if (opKey) seenOpenings.add(opKey);
(p.config.openings || []).forEach(op => {
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;
add(el, 'Kozijnen', 'Kozijnen', `Raam ${op.w}×${op.h}`, m2, 'm²', m2 * K.raamM2, K.montage);
} else {
add(el, 'Kozijnen', 'Kozijnen', `${op.type === 'voordeur' ? 'Voordeur' : 'Deur'} ${op.w}×${op.h}`, 1, 'st.', op.type === 'voordeur' ? K.voordeur : K.deur, K.montage);
}
});
}
}
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => wallElement(p, u.id))));
parts.interior.forEach(p => wallElement(p, p.seamUnit || null));
function joistLayer(el, cd, angleDeg, what, roof) {
const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length;
const n = cd.joists.length;
const ang = angleDeg || 0;
add(el, 'Hout', 'Zagen', `${sawLabel}: ${what}balken ${n} + kopbalken ${rims}`, 2 * (n + rims), 'sneden', 0, sawSec([ang, ang], n) + sawSec([0, 0], rims));
addJoints(el, n * V.verbindingenPerVloerbalk, `${what}balken`, roof);
}
parts.units.forEach(u => {
const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id);
const fs = u.floorSpecs;
add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', joistWoodCost(fs.joistWoodM3, u.floorData.calcData), 0);
joistLayer(fl, u.floorData.calcData, 0, 'Vloer');
addSheet(fl, 'Plaatwerk', 'OSB vloer', fs.osbAreaM2, P.osbM2, P.osbMontage);
add(fl, 'Isolatie', 'Isoleren', 'Glaswol vloer', fs.voidM3, 'm³', fs.voidM3 * S.isolatie.glaswolM3, fs.voidM3 * S.isolatie.glaswolMontage);
const foot = u.walls.reduce((a, w) => a + w.pieces.filter(p => !p.place.baseY).reduce((b, p) => b + p.place.width * p.place.thickness, 0), 0)
+ parts.interior.filter(p => p.seamUnit === u.id).reduce((a, p) => a + p.place.width * p.place.thickness, 0);
const finM2 = Math.max(0, u.rect.W * u.rect.D - foot) / 1e6;
add(fl, 'Vloer', 'Vloer', 'Vloerafwerking', finM2, 'm²', finM2 * S.vloer.afwerkingM2, finM2 * S.vloer.afwerkingMontage);
const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
const rs = u.roof.specs, D = S.dak;
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', 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));
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
if (cap) {
const n = cap.beams.length;
add(rf, 'Hout', 'Zagen', `${sawLabel}: wolfseinden ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0));
addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true);
}
addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage);
addSheet(rf, 'Plaatwerk', `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm`, rs.beschotM2 || 0, D.beschotM2, D.beschotMontage);
add(rf, 'Isolatie', 'Isoleren', 'Glaswol dak', rs.voidM3, 'm³', rs.voidM3 * S.isolatie.glaswolM3, rs.voidM3 * S.isolatie.glaswolMontage);
add(rf, 'Dak', 'Dak', 'Dakleer (EPDM)', rs.dakleerM2 || 0, 'm²', (rs.dakleerM2 || 0) * D.dakleerM2, (rs.dakleerM2 || 0) * D.dakleerMontage);
add(rf, 'Dak', 'Dak', 'Riet', rs.rietM2 || 0, 'm²', (rs.rietM2 || 0) * D.rietM2, (rs.rietM2 || 0) * D.rietMontage);
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);
}
});
const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null);
const D = S.dak;
if (totals.roofEdge) {
const e = totals.roofEdge;
add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage);
add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
}
if (parts.sandwich) {
const sw = parts.sandwich;
add(wo, 'Vloer', 'Vloer', `Sandwichpanelen (${sw.panelCount} platen)`, sw.areaM2, 'm²', sw.areaM2 * S.vloer.sandwichM2, sw.panelCount * S.vloer.sandwichMontage);
}
const L = S.verlichting, lm = totals.lamps;
add(wo, 'Verlichting', 'Verlichting', 'Gevellampen', lm.total, 'st.', lm.total * L.gevellamp, lm.total * L.montage);
const spots = totals.roofEdge ? totals.roofEdge.spots : 0;
add(wo, 'Verlichting', 'Verlichting', 'Boeideel-spots', spots, 'st.', spots * L.boeideelSpot, spots * L.montage);
add(wo, 'Verlichting', 'Verlichting', 'Binnenspots', lm.indoor, 'st.', lm.indoor * L.binnenspot, lm.indoor * L.montage);
const elk = parts.elektra, E = S.elektra;
if (elk && elk.enabled) {
const unitIds = new Set(parts.units.map(u => u.id)), elOf = {};
const elFor = (uid) => {
const id = unitIds.has(uid) ? uid : null, k = id || '-';
return elOf[k] || (elOf[k] = newEl(id ? `${id}-EL` : 'EL', id ? `Unit ${id} elektra` : 'Elektra', id));
};
const ea = (uid, label, qty, unit, mat, sec) => add(elFor(uid), 'Elektra', 'Elektra', label, qty, unit, mat, sec, E.personeel);
const mkUnit = elk.meterkast ? elk.meterkast.unitId : null;
const groupUnit = (nr) => (elk.groups.find(g => g.nr === nr) || {}).unitId || mkUnit;
const buisPrijs = (d) => d >= 25 ? E.buis25M : d >= 19 ? E.buis19M : E.buis16M;
const agg = {};
elk.runs.forEach(r => {
const uid = groupUnit(r.group), a = agg[uid] = agg[uid] || { types: {}, holes: 0, cross: {} };
a.types[r.type] = (a.types[r.type] || 0) + r.lengthMM / 1000;
a.holes += r.holes || 0;
if (r.crossings) { const n = ElektraModel.cableInfo(r.type).cores.length; a.cross[n] = (a.cross[n] || 0) + r.crossings; }
});
Object.keys(agg).forEach(uid => {
const a = agg[uid];
Object.keys(a.types).sort().forEach(type => {
const c = ElektraModel.cableInfo(type), m = a.types[type];
if (c.conduit) {
const draad = c.mm2 >= 2.5 ? E.draad25M : E.draad15M;
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);
});
ea(uid, 'Boorgaten stijlen/regels/balken', a.holes, 'st.', 0, a.holes * E.boorgat);
Object.keys(a.cross).forEach(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);
});
});
const count = (list, uid) => list.filter(x => x.unitId === uid).length;
parts.units.forEach(u => {
const uid = u.id;
const nSw = count(elk.switches, uid), combos = elk.combos.filter(c => c.unitId === uid);
const raam1 = combos.filter(c => c.switches.length === 1).length, raam2 = combos.length - raam1;
ea(uid, `Schakelaars (${raam1}× 1-voudig, ${raam2}× 2-voudig raam)`, nSw, 'st.',
nSw * (E.schakelaar + E.inbouwdoos) + raam1 * E.afdekraam1 + raam2 * E.afdekraam2, nSw * (E.doosPlaatsen + E.afmonteren));
const nW = count(elk.wcds, uid);
ea(uid, 'Wandcontactdozen dubbel (2 inbouwdozen)', nW, 'st.', nW * (E.wcdDubbel + 2 * E.inbouwdoos), nW * (2 * E.doosPlaatsen + E.afmonteren));
elk.apps.filter(a => a.unitId === uid).forEach(a => {
const price = a.app === 'koken' ? E.perilex : a.app === 'warmtepomp' ? E.werkschakelaar : E.wcdEnkel;
ea(uid, `Aansluitpunt ${a.label.toLowerCase()}`, 1, 'st.', price + E.inbouwdoos, E.doosPlaatsen + E.afmonteren);
});
const nV = count(elk.floorWcds, uid);
ea(uid, 'Vloerdoos met dubbele wcd', nV, 'st.', nV * E.vloerdoosSt, nV * (E.vloerdoos + E.afmonteren));
const nB = count(elk.boxes, uid), nKl = nB * 3 + nSw + nW;
ea(uid, `Verdeeldozen plafond (+ ${nKl} lasklemmen)`, nB, 'st.', nB * E.verdeeldoosSt + nKl * E.lasklem, nB * E.verdeeldoos);
const nSpot = elk.switches.filter(sw => sw.unitId === uid).reduce((t, sw) => t + sw.lamps.filter(l => l.kind === 'binnenspot').length, 0);
ea(uid, 'Spotdozen plafond (luchtdicht)', nSpot, 'st.', nSpot * E.spotdoosSt, nSpot * E.spotdoos);
const nDv = elk.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && groupUnit(r.group) === uid).length;
ea(uid, 'Kabeldoorvoer naar buiten', nDv, 'st.', nDv * E.doorvoer, 0);
});
if (elk.meterkast) {
const g1 = elk.groups.filter(g => (g.poles || 1) === 1).length, g2 = elk.groups.length - g1;
ea(mkUnit, `Groepenkast 3×25 A: kast, hoofdschakelaar, ${elk.rcdCount} aardlek`, 1, 'st.', E.kast + E.hoofdschakelaar + elk.rcdCount * E.aardlek, E.kastMontage);
ea(mkUnit, `Groepen: ${g1}× automaat 1P+N${g2 ? `, ${g2}× 2-fase` : ''}`, elk.groups.length, 'groep', g1 * E.automaat + g2 * E.automaat2f, elk.groups.length * E.groepMontage);
ea(mkUnit, 'Meten, testen en opleveren', 1, 'woning', 0, E.keuring);
}
}
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);
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);
if ((f.cost === 'keuken' || f.cost === 'kookeiland') && f.opts && f.opts.kookplaat) add(el, 'Inrichting', 'Inrichting', 'Inductiekookplaat', 1, 'st.', IN.kookplaat || 0, 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)));
const byKey = (key, order) => {
const m = new Map();
elements.forEach(el => el.lines.forEach(l => {
const r = m.get(l[key]) || { name: l[key], mat: 0, sec: 0 };
r.mat += l.mat; r.sec += l.sec;
m.set(l[key], r);
}));
return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
};
// Afschrijving/slijtage/huisvesting per productie-uur: klokuren (niet × personeel) + indirect, zonder QA.
const O = S.opslag, B = S.bedrijf, A = S.arbeid;
const perHour = B.machines + B.slijtage + B.huisvesting;
const split = (sec) => {
const direct = sec / 3600, indirect = direct * A.toeslagUren / 100;
const qa = (direct + indirect) * A.qa / 100;
return { direct, indirect, qa, total: direct + indirect + qa };
};
const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100);
const costOf = (r) => mat0(r.mat) + split(r.sec).total * A.uurloon + prodHours(r.clock) * perHour;
const mat0 = (mat) => mat * (1 + (O.verlies + O.klein) / 100);
elements.forEach(el => { el.kostprijs = costOf(el); });
const units = parts.units.map(u => {
const r = Object.assign({ id: u.id }, sumLines(elements.filter(el => el.unit === u.id).flatMap(el => el.lines)));
r.transport = B.transportUnit;
r.kostprijs = costOf(r) + r.transport;
return r;
});
const rest = sumLines(elements.filter(el => el.unit === null).flatMap(el => el.lines));
units.push(Object.assign({ id: null, transport: 0 }, rest, { kostprijs: costOf(rest) + B.kraan }));
const all = sumLines(elements.flatMap(el => el.lines));
const h = split(all.sec);
h.prod = prodHours(all.clock);
const n = parts.units.length;
const pct = (v) => String(v).replace('.', ',') + '%';
const eur0 = (v) => eur(v);
const posts = [
['Materiaal', all.mat],
[`Materiaalverlies ${pct(O.verlies)}`, all.mat * O.verlies / 100],
[`Klein materiaal ${pct(O.klein)}`, all.mat * O.klein / 100],
[`Arbeid ${uur(h.total)} manuur × ${eur0(A.uurloon)}`, h.total * A.uurloon],
[`Afschrijving machines ${uur(h.prod)} uur × ${eur(B.machines, 2)}`, h.prod * B.machines],
[`Slijtage gereedschap ${uur(h.prod)} uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage],
[`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],
['Kraan plaatsen', B.kraan]
];
const kostprijs = posts.reduce((a, p) => a + p[1], 0);
const algemeen = kostprijs * O.algemeen / 100;
const marge = (kostprijs + algemeen) * O.marge / 100;
const exBtw = kostprijs + algemeen + marge;
const btw = exBtw * O.btw / 100;
const rows = posts.filter(p => p[1] > 0).map(([label, amount]) => ({ label, amount }))
.concat([
{ label: 'Kostprijs', amount: kostprijs, sub: true },
{ label: `Algemene kosten ${pct(O.algemeen)}`, amount: algemeen },
{ label: `Marge ${pct(O.marge)}`, amount: marge },
{ label: 'Totaal excl. btw', amount: exBtw, sub: true },
{ label: `Btw ${pct(O.btw)}`, amount: btw }
]);
return {
settings: S, elements, units,
saw: Object.assign(saw, { handSec: saw.robotSec * Z.handFactor, method: Z.methode }),
categories: byKey('cat', CAT_ORDER), activities: byKey('act', ACT_ORDER),
summary: { mat: all.mat, hoursDirect: h.direct, hoursIndirect: h.indirect, hoursQa: h.qa, hours: h.total, kostprijs, exBtw, btw, inclBtw: exBtw + btw, rows }
};
}
function eur(n, decimals = 0) {
return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
function uur(hours) {
return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
}
return { defaultValues, getSettings, saveSettings, onChange, importFile, compute, eur, uur };
})();
+173
View File
@@ -0,0 +1,173 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.CostSettings = (() => {
let overlay = null;
const esc = UI.esc;
const SJABLOON_BALKEN = [[38, 235], [70, 220]];
function build() {
overlay = document.createElement('div');
overlay.id = 'cost-settings-overlay';
overlay.innerHTML = `
<div id="cost-settings-modal">
<div class="modal-header">
<h2>Kosten &amp; tijden</h2>
<button type="button" class="modal-close-x" data-cs="cancel">&times;</button>
</div>
<div class="cs-pricelist"></div>
<div class="cs-body"></div>
<div class="material-info">Bedragen excl. btw. De CSV's in kosten/ zijn leidend: Toepassen geldt tot herladen; om een wijziging te houden "intern.csv downloaden" en het bestand in kosten/ vervangen, of met "Wijzigingen downloaden" alleen de aangepaste velden (met de standaardwaarde ernaast) opslaan. Prijzen pas je aan in leveranciers.csv (CSV = daaruit, anders noodwaarde). Een veld dat afwijkt van intern.csv is gemarkeerd.</div>
<div class="modal-footer">
<button type="button" class="io-btn" data-cs="reset">Standaard</button>
<button type="button" class="io-btn" data-cs="export">intern.csv downloaden</button>
<button type="button" class="io-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
<button type="button" class="io-btn" data-cs="import">Importeren&hellip;</button>
<span style="flex:1"></span>
<button type="button" class="io-btn" data-cs="cancel">Annuleren</button>
<button type="button" class="material-toggle-btn active" data-cs="save" style="flex:none; padding:6px 18px;">Toepassen</button>
</div>
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
<input type="file" data-file="leveranciers" accept=".csv,text/csv,text/plain" style="display:none">
</div>`;
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) return close();
const btn = e.target.closest('[data-cs]');
if (!btn) return;
const a = btn.dataset.cs;
if (a === 'cancel') close();
else if (a === 'save') {
CostModel.saveSettings(readForm());
close();
}
else if (a === 'reset') fill(withLeveranciers(CostModel.defaultValues()));
else if (a === 'export') exportIntern();
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.querySelector('[data-file=import]').addEventListener('change', (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
file.text().then(txt => fill(withLeveranciers(CostModel.importFile(txt))))
.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() {
renderStatus();
const body = overlay.querySelector('.cs-body');
body.innerHTML = Object.entries(window.COST_DEFAULTS).map(([g, grp]) => `
<section class="cs-group">
<h3>${esc(grp.label)}</h3>
${Object.entries(grp.items).map(([k, it]) => {
const lev = levEntry(g, k), int = CostCsv.entry('intern', `${g}.${k}`), price = CostCsv.isPrice(it);
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
? `<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}"${price ? ' disabled' : ''}>`}
<span class="cs-unit">${esc(it.unit || '')}</span>
</label>`;
}).join('')}
</section>`).join('');
}
function fill(values) {
overlay.querySelectorAll('[data-g]').forEach(el => { el.value = values[el.dataset.g][el.dataset.k]; });
markChanged();
}
function readForm(base) {
const v = base || CostModel.getSettings();
overlay.querySelectorAll('[data-g]:not(:disabled)').forEach(el => {
const it = window.COST_DEFAULTS[el.dataset.g].items[el.dataset.k];
const val = it.options ? el.value : parseFloat(el.value);
if (it.options || isFinite(val)) v[el.dataset.g][el.dataset.k] = val;
});
return v;
}
function markChanged() {
const def = CostModel.defaultValues();
overlay.querySelectorAll('[data-g]').forEach(el => {
const d = def[el.dataset.g][el.dataset.k];
el.closest('.cs-row').classList.toggle('changed', !el.disabled && typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d);
});
}
function saveFile(text, name) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], { type: 'text/csv' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function open() {
if (!overlay) build();
renderForm();
fill(CostModel.getSettings());
overlay.classList.add('open');
}
function close() { if (overlay) overlay.classList.remove('open'); }
return { open, close };
})();
+868
View File
@@ -0,0 +1,868 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.ElektraModel = (() => {
const DEFAULT_SETTINGS = {
enabled: true,
meterkast: { w: 600, h: 900, d: 250, y: 1000 }, // y = onderkant boven vloer
switchHeight: 1500,
wcdHeight: 400,
wcdPerUnit: 2,
slack: 10, // %
reserve: 300,
meterkastReserve: 500,
apparaten: { koken: true, wasmachine: true, droger: true, warmtepomp: true, oven: false, vaatwasser: false },
place: {},
vloerWcd: [],
wcdExtra: {},
removed: []
};
const CLEAR = 50;
const FLOOR_DEPTH = 100;
// Codes van twee letters: geen verwarring met de schakelaarletters.
const APPLIANCES = {
koken: { label: 'Kookgroep (perilex)', code: 'KK', breaker: '2× B16', poles: 2, cable: 'koken', height: 600, zone: 'keuken', material: 'Perilex-wandcontactdoos (2-fase)' },
oven: { label: 'Oven', code: 'OV', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' },
vaatwasser: { label: 'Vaatwasser', code: 'VW', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' },
wasmachine: { label: 'Wasmachine', code: 'WM', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' }
};
const BOX_PITCH = 71;
const PLATE = 80;
const WCD_W = 150;
const DOOR_GAP = 100;
const VD_OFFSET = 150;
const CORE_COLORS = { L: 'bruin', L2: 'zwart', L3: 'grijs', N: 'blauw', PE: 'geel/groen', S: 'zwart' };
const CABLES = {
licht: { label: 'VD 3×1,5 mm² in buis Ø16', use: 'Verlichting: voeding en doorlus', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 16 },
wcd: { label: 'VD 3×2,5 mm² in buis Ø19', use: 'Wcd en apparaten', mm2: 2.5, cores: ['L', 'N', 'PE'], conduit: 19 },
koken: { label: 'VD 5×2,5 mm² in buis Ø25', use: 'Kookgroep (perilex, 2-fase)', mm2: 2.5, cores: ['L', 'L2', 'L3', 'N', 'PE'], conduit: 25 },
buiten: { label: 'XMvK 3×1,5 mm²', use: 'Buitenverlichting (boeideel/gevel)', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 0 }
};
function cableInfo(key) {
if (CABLES[key]) return CABLES[key];
const m = /^schakel(\d)$/.exec(key);
const n = m ? Number(m[1]) : 2;
return { label: `VD ${n}×1,5 mm² in buis Ø16`, use: `Schakeldraden (${n - 1} schakelaar${n > 2 ? 's' : ''})`, mm2: 1.5, cores: ['L'].concat(Array(n - 1).fill('S')), conduit: 16 };
}
function normalizeSettings(settings) {
const i = settings || {};
const s = Object.assign({}, DEFAULT_SETTINGS, i);
s.meterkast = Object.assign({}, DEFAULT_SETTINGS.meterkast, i.meterkast || {});
s.apparaten = Object.assign({}, DEFAULT_SETTINGS.apparaten, i.apparaten || {});
s.place = Object.assign({}, i.place || {});
s.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.enabled = s.enabled !== false;
// v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard).
if (!i.v) {
if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight;
if (i.wcdHeight === 300) s.wcdHeight = DEFAULT_SETTINGS.wcdHeight;
}
s.v = 2;
return s;
}
// side -1 = lokaal -Z = kamerkant (buitenwand, naadwanddeel); binnenwand: beide zijden.
function faceOf(p, inner, u, side) {
const pl = p.place, W = pl.width, lz = side * pl.thickness / 2;
const a = UnitModel.localToWorld(pl, -W / 2, lz), b = UnitModel.localToWorld(pl, W / 2, lz);
const axis = Math.abs(b.x - a.x) > Math.abs(b.z - a.z) ? 'x' : 'z';
const uOf = (v) => (v - a[axis]) / (b[axis] - a[axis]) * W;
const ua = uOf(axis === 'x' ? inner.x0 : inner.z0), ub = uOf(axis === 'x' ? inner.x1 : inner.z1);
return {
piece: p, unit: u, a, b, axis, W, lz,
u0: Math.max(0, Math.min(ua, ub)), u1: Math.min(W, Math.max(ua, ub)),
n: { x: side * Math.sin(pl.rotY), z: side * Math.cos(pl.rotY) },
side,
sw: p.config.studWidth || 45,
reserved: []
};
}
function facesOf(u, interior) {
const inner = LightingModel.unitInnerRect(u, interior);
const pieces = [];
u.walls.forEach(w => w.pieces.forEach(p => { if (!p.baseY) pieces.push(p); }));
interior.filter(p => p.seamUnit === u.id).forEach(p => pieces.push(p));
const list = pieces.map(p => faceOf(p, inner, u, -1));
const r = u.rect;
interior.filter(p => !p.seam && p.place.cx >= r.x0 && p.place.cx <= r.x1 && p.place.cz >= r.z0 && p.place.cz <= r.z1)
.forEach(p => list.push(faceOf(p, inner, u, -1), faceOf(p, inner, u, 1)));
return list.filter(f => f.u1 - f.u0 > 200);
}
// off: de kamer in (negatief = de wand in).
function facePoint(f, u, y, off) {
const q = UnitModel.localToWorld(f.piece.place, u - f.W / 2, f.lz + f.side * (off || 0));
return { x: q.x, y: f.piece.place.floorTop + y, z: q.z };
}
const uAt = (f, q) => (q[f.axis] - f.a[f.axis]) / (f.b[f.axis] - f.a[f.axis]) * f.W;
function freeOn(f, y0, y1, margin, edge) {
const blocked = [];
f.piece.config.openings.forEach(op => {
if (op.y < y1 && WallModel.openingTopMax(op) > y0) blocked.push([op.x - f.sw - margin, op.x + op.w + f.sw + margin]);
});
f.reserved.forEach(r => { if (r.y0 < y1 && r.y1 > y0) blocked.push([r.a, r.b]); });
return LightingModel.freeIntervals(f.u0 + edge, f.u1 - edge, blocked);
}
function nearestFree(faces, target, w, y0, y1, margin, edge) {
let best = null;
faces.forEach(f => freeOn(f, y0, y1, margin, edge).forEach(([a, b]) => {
if (b - a < w) return;
const u = Math.max(a + w / 2, Math.min(b - w / 2, uAt(f, target)));
const q = facePoint(f, u, 0, 0);
const d = Math.hypot(q.x - target.x, q.z - target.z);
if (!best || d < best.d) best = { f, u, d };
}));
return best;
}
// Eerst de slotzijde (rechts van de opening in wand-x), dan de scharnierzijde.
function besideDoor(f, op, n, height) {
const width = PLATE + (n - 1) * BOX_PITCH;
const free = freeOn(f, height - PLATE / 2, height + PLATE / 2, 0, f.sw);
const fits = (lo, hi) => free.some(([a, b]) => lo >= a - 0.5 && hi <= b + 0.5);
const r0 = op.x + op.w + f.sw + DOOR_GAP;
if (fits(r0 - PLATE / 2, r0 - PLATE / 2 + width)) return Array.from({ length: n }, (_, i) => r0 + i * BOX_PITCH);
const l0 = op.x - f.sw - DOOR_GAP;
if (fits(l0 + PLATE / 2 - width, l0 + PLATE / 2)) return Array.from({ length: n }, (_, i) => l0 - i * BOX_PITCH);
return null;
}
function makeRouter(units) {
const unitAt = (x, z) => units.find(u => x >= u.rect.x0 - 1 && x <= u.rect.x1 + 1 && z >= u.rect.z0 - 1 && z <= u.rect.z1 + 1) || null;
const ceilAt = (x, z) => {
let u = unitAt(x, z);
if (!u) u = units.reduce((best, c) => {
const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1);
const d = Math.hypot(dx, dz);
return !best || d < best.d ? { u: c, d } : best;
}, null).u;
const r = u.rect;
return u.roof.ceilingAt(Math.max(r.x0 + 1, Math.min(r.x1 - 1, x)), Math.max(r.z0 + 1, Math.min(r.z1 - 1, z)));
};
const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; };
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 });
if (a.mount === 'vloer' || b.mount === 'vloer') {
const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH;
return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true), P({ x: b.x, y: fa, z: a.z }, null, false, true),
P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')];
}
if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a), P({ x: b.x, y: a.y, z: a.z }), P(b)];
const top = (p) => p.mount === 'plafond' ? p.y : ceilAt(p.x, p.z);
const ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1;
const pts = [P(a, a.face, a.mount === 'plafond')];
if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a.x, y: ya, z: a.z }, a.face, true));
pts.push(P({ x: b.x, y: ceilAt(b.x, a.z), z: a.z }, null, true));
pts.push(P({ x: b.x, y: yb, z: b.z }, dropB ? b.face : null, true));
if (dropB) pts.push(P(b, b.face, false));
return pts;
}
const length = (pts) => pts.slice(1).reduce((s, q, i) => s + Math.hypot(q.x - pts[i].x, q.y - pts[i].y, q.z - pts[i].z), 0);
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
function crossings(pts) {
let last = null, n = 0;
pts.slice(1).forEach((q, i) => {
const p = pts[i], len = Math.hypot(q.x - p.x, q.z - p.z), steps = Math.max(1, Math.ceil(len / 100));
for (let k = 0; k <= steps; k++) {
const u = unitAt(p.x + (q.x - p.x) * k / steps, p.z + (q.z - p.z) * k / steps);
if (!u) continue;
if (last && u.id !== last) n++;
last = u.id;
}
});
return n;
}
return { unitAt, ceilAt, floorAt, path, length, cost, crossings };
}
function compute(settings, units, interior, lighting, roofEdge, rooms) {
const s = normalizeSettings(settings);
const indoor = lighting.indoor || [], gevel = lighting.lamps || [], spots = (roofEdge && roofEdge.spots) || [];
indoor.concat(gevel, spots).forEach(p => { delete p.letter; });
const out = {
enabled: s.enabled, settings: s, meterkast: null, combos: [], switches: [], wcds: [], apps: [], floorWcds: [], boxes: [],
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
};
if (!s.enabled || !units.length) return out;
const R = makeRouter(units);
const faces = {};
units.forEach(u => { faces[u.id] = facesOf(u, interior); });
const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1);
const allFaces = out.faces = units.reduce((a, u) => a.concat(faces[u.id]), []);
let door = null;
['voordeur', 'deur'].some(type => units.some(u => u.walls.some(w => w.pieces.some(p => {
if (p.baseY) return false;
const op = p.config.openings.find(o => o.type === type);
const f = op && faceOfPiece(u, p);
if (f) door = { unit: u, face: f, op };
return !!door;
}))));
if (!door) out.warnings.push('Geen voordeur of buitendeur gevonden: schakelaars en meterkast vrij geplaatst.');
const mainUnit = door ? door.unit : units[0];
const hasOutdoor = gevel.length + spots.length > 0;
let letterCode = 65;
const nextLetter = () => String.fromCharCode(letterCode++);
const mainFuncs = hasOutdoor ? ['binnen', 'buiten'] : ['binnen'];
const mk = s.meterkast;
const apps = Object.keys(APPLIANCES).filter(k => s.apparaten[k]);
const num = (v, def) => typeof v === 'number' && isFinite(v) ? v : def;
const removed = (key) => s.removed.includes(key);
const wcdCount = (uid) => s.wcdPerUnit + Math.max(0, Math.round(num(s.wcdExtra[uid], 0)));
const comboWidth = (n) => PLATE + (n - 1) * BOX_PITCH;
const widthOf = (key) => {
if (key === 'mk') return mk.w;
let m = /^sw:(.+)$/.exec(key);
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);
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);
return m && apps.includes(m[1]) ? PLATE : 0;
};
const fixed = {};
Object.keys(s.place).forEach(key => {
const o = s.place[key], w = widthOf(key);
if (!o || !w) return;
const f = allFaces.find(x => x.piece.element === o.element && x.side === (o.side || -1));
if (!f) return;
const u = Math.max(f.u0 + w / 2, Math.min(f.u1 - w / 2, num(o.u, (f.u0 + f.u1) / 2)));
fixed[key] = { f, u, y: num(o.y, null) };
f.reserved.push({ a: u - w / 2 - 50, b: u + w / 2 + 50, y0: 0, y1: 1e6 });
});
const centersAt = (u, n) => Array.from({ length: n }, (_, i) => u - comboWidth(n) / 2 + PLATE / 2 + i * BOX_PITCH);
const addCombo = (unit, f, us, funcs, y, manual, key, name) => {
const yy = y === null || y === undefined ? s.switchHeight : y;
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) => {
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);
combo.switches.push(sw);
out.switches.push(sw);
});
const lo = Math.min(...us) - PLATE / 2, hi = Math.max(...us) + PLATE / 2;
if (!manual) f.reserved.push({ a: lo - 60, b: hi + 60, y0: 0, y1: 1e6 });
combo.u = (lo + hi) / 2;
combo.pos = facePoint(f, combo.u, yy, 0);
combo.width = hi - lo;
combo.center = facePoint(f, combo.u, yy, 5);
out.combos.push(combo);
return combo;
};
const fxMain = fixed['sw:' + mainUnit.id], mainOff = removed('sw:' + mainUnit.id);
if (mainOff) { } else if (fxMain) addCombo(mainUnit, fxMain.f, centersAt(fxMain.u, mainFuncs.length), mainFuncs, fxMain.y, true);
else if (door) {
const us = besideDoor(door.face, door.op, mainFuncs.length, s.switchHeight);
if (us) addCombo(mainUnit, door.face, us, mainFuncs);
else {
const doorPt = facePoint(door.face, door.op.x + door.op.w / 2, 0, 0);
const w = PLATE + (mainFuncs.length - 1) * BOX_PITCH;
const nf = nearestFree(faces[mainUnit.id], doorPt, w, s.switchHeight - PLATE / 2, s.switchHeight + PLATE / 2, 0, 100);
if (nf) addCombo(mainUnit, nf.f, mainFuncs.map((_, i) => nf.u - w / 2 + PLATE / 2 + i * BOX_PITCH), mainFuncs);
}
}
if (!out.combos.length && !mainOff) {
const f = faces[mainUnit.id][0];
if (f) addCombo(mainUnit, f, mainFuncs.map((_, i) => (f.u0 + f.u1) / 2 + i * BOX_PITCH), mainFuncs);
}
const entry = out.combos[0] ? out.combos[0].pos : { x: mainUnit.rect.cx, z: mainUnit.rect.cz };
const target = door ? facePoint(door.face, door.op.x + door.op.w / 2, 0, 0) : entry;
const fxMk = fixed.mk;
const mkY = fxMk && fxMk.y !== null ? fxMk.y : mk.y;
let spot = fxMk ? { f: fxMk.f, u: fxMk.u } : nearestFree(faces[mainUnit.id], target, mk.w, mkY, mkY + mk.h, 50, 50);
if (!spot) units.some(u => u !== mainUnit && (spot = nearestFree(faces[u.id], target, mk.w, mkY, mkY + mk.h, 50, 50)));
if (spot) {
const f = spot.f;
if (!fxMk) f.reserved.push({ a: spot.u - mk.w / 2 - 50, b: spot.u + mk.w / 2 + 50, y0: 0, y1: 1e6 });
out.meterkast = {
key: 'mk', manual: !!fxMk, unitId: f.unit.id, element: f.piece.element, face: f, u: spot.u, w: mk.w, h: mk.h, d: mk.d, y: mkY, rotY: f.piece.place.rotY,
center: facePoint(f, spot.u, mkY + mk.h / 2, mk.d / 2),
corners: [[-1, 0], [1, 0], [1, mk.d], [-1, mk.d]].map(([i, d]) => facePoint(f, spot.u + i * mk.w / 2, 0, d))
};
} else out.warnings.push('Geen vrije wand voor de meterkast gevonden.');
const isDoorOp = (op) => op.type === 'deur' || op.type === 'voordeur' || (op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur'));
const regionOf = (pt) => rooms ? rooms.regionAt(pt.x, pt.z) : null;
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];
if (fx) { addCombo(u, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true); return; }
const cands = [];
faces[u.id].forEach(f => f.piece.config.openings.forEach(op => {
if (op.type !== 'deur' && op.type !== 'voordeur') return;
const q = facePoint(f, op.x + op.w / 2, 0, 0);
cands.push({ f, op, d: Math.hypot(q.x - entry.x, q.z - entry.z) + (f.piece.seamUnit ? 0 : 5000) });
}));
cands.sort((a, b) => a.d - b.d);
for (const c of cands) {
const us = besideDoor(c.f, c.op, 1, s.switchHeight);
if (us) { addCombo(u, c.f, us, ['binnen']); return; }
}
out.warnings.push(`Unit ${u.id}: geen deur met ruimte voor een schakelaar; binnenlicht op schakelaar ${out.switches[0] ? out.switches[0].letter : '-'}.`);
});
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 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 }); } });
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 }); } });
units.forEach(u => {
const y0 = s.wcdHeight - 60, y1 = s.wcdHeight + 60, cands = [];
faces[u.id].forEach(f => freeOn(f, y0, y1, 100, 150).forEach(([a, b]) => {
if (b - a < WCD_W + 20) return;
(b - a > 1200 ? [a + 300, (a + b) / 2, b - 300] : [(a + b) / 2]).forEach(uu => cands.push({ f, u: uu, len: b - a, p: facePoint(f, uu, s.wcdHeight, 0) }));
}));
const chosen = [], freeKeys = [], nWcd = wcdCount(u.id);
let want = 0;
for (let i = 1; i <= nWcd; i++) {
const key = `wcd:${u.id}:${i}`, fx = fixed[key];
if (removed(key)) continue;
want++;
if (!fx) { freeKeys.push(key); continue; }
const y = fx.y !== null ? fx.y : s.wcdHeight;
chosen.push({ f: fx.f, u: fx.u, y, p: facePoint(fx.f, fx.u, y, 0), key, manual: true });
}
while (freeKeys.length) {
let best = null, bestScore = -1;
cands.forEach(c => {
if (chosen.some(x => x.f === c.f && Math.abs(x.u - c.u) < WCD_W + 150)) return;
const score = chosen.length ? Math.min(...chosen.map(x => Math.hypot(x.p.x - c.p.x, x.p.z - c.p.z))) : c.len;
if (score > bestScore) { bestScore = score; best = c; }
});
if (!best) break;
chosen.push(Object.assign({}, best, { key: freeKeys.shift(), y: s.wcdHeight }));
best.f.reserved.push({ a: best.u - WCD_W / 2 - 75, b: best.u + WCD_W / 2 + 75, y0: 0, y1: s.wcdHeight + 200 });
}
if (chosen.length < want) out.warnings.push(`Unit ${u.id}: ruimte voor ${chosen.length} van ${want} wcd's.`);
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)
}));
});
const others = units.filter(u => u !== mainUnit);
const kitchen = (others.length ? others : units).reduce((a, b) => a.rect.W * a.rect.D >= b.rect.W * b.rect.D ? a : b);
const mkUnit = out.meterkast ? units.find(u => u.id === out.meterkast.unitId) : mainUnit;
const zones = {
keuken: { target: { x: kitchen.rect.cx, z: kitchen.rect.cz }, order: [kitchen].concat(units.filter(u => u !== kitchen)) },
techniek: { target: out.meterkast ? out.meterkast.center : entry, order: [mkUnit].concat(units.filter(u => u !== mkUnit)) }
};
apps.forEach(k => {
const A = APPLIANCES[k], key = 'app:' + k, fx = fixed[key];
let f, uu, y = A.height;
if (fx) { f = fx.f; uu = fx.u; if (fx.y !== null) y = fx.y; }
else {
const z = zones[A.zone];
let nf = null;
z.order.some(u => (nf = nearestFree(faces[u.id], z.target, PLATE + 40, y - 60, y + 60, 100, 150)));
if (!nf) { out.warnings.push(`${A.label}: geen vrije wand gevonden.`); return; }
f = nf.f; uu = nf.u;
f.reserved.push({ a: uu - PLATE / 2 - 80, b: uu + PLATE / 2 + 80, y0: 0, y1: 1e6 });
}
out.apps.push({ key, manual: !!fx, app: k, label: A.label, code: A.code, unitId: f.unit.id, face: f, u: uu, y, rotY: f.piece.place.rotY, pos: facePoint(f, uu, y, 0), center: facePoint(f, uu, y, 6) });
});
s.vloerWcd.forEach(o => {
const u = units.find(x => x.id === o.unitId);
if (!u) return;
const x = u.rect.x0 + Math.max(100, Math.min(u.rect.W - 100, num(o.x, u.rect.W / 2)));
const z = u.rect.z0 + Math.max(100, Math.min(u.rect.D - 100, num(o.z, u.rect.D / 2)));
out.floorWcds.push({ key: 'vwcd:' + o.id, id: o.id, unitId: u.id, x, z, y: u.layout.floorTop });
});
units.forEach(u => {
const sws = out.switches.filter(x => x.unitId === u.id && x.func === 'binnen');
if (sws.length) out.groups.push({ kind: 'licht', unitId: u.id, label: `Verlichting unit ${u.id}`, breaker: 'B10', cable: 'licht', switches: sws.map(x => x.letter) });
if (out.wcds.some(w => w.unitId === u.id) || out.floorWcds.some(w => w.unitId === u.id)) out.groups.push({ kind: 'wcd', unitId: u.id, label: `Wcd unit ${u.id}`, breaker: 'B16', cable: 'wcd', switches: [] });
const outSw = out.switches.filter(x => x.unitId === u.id && x.func === 'buiten');
if (outSw.length) out.groups.push({ kind: 'buiten', unitId: u.id, label: 'Buitenverlichting', breaker: 'B10', cable: 'buiten', switches: outSw.map(x => x.letter) });
});
out.apps.forEach(a => {
const A = APPLIANCES[a.app];
out.groups.push({ kind: 'app', app: a.app, unitId: a.unitId, label: A.label, breaker: A.breaker, cable: A.cable, poles: A.poles || 1, switches: [] });
});
const nRcd = Math.max(2, Math.ceil(out.groups.length / 4));
let ph = 0;
out.groups.forEach((g, i) => {
const n = g.poles || 1;
g.nr = i + 1; g.rcd = i % nRcd + 1;
g.phase = Array.from({ length: n }, (_, j) => 'L' + ((ph + j) % 3 + 1)).join('+');
ph += n;
});
out.rcdCount = out.groups.length ? nRcd : 0;
const groupOf = (kind, unitId) => out.groups.find(g => g.kind === kind && g.unitId === unitId);
// Buitenlicht zit in de schakeldoos van het binnenlicht: zelfde aardlek.
const gOut = out.groups.find(g => g.kind === 'buiten'), gIn = gOut && groupOf('licht', gOut.unitId);
if (gOut && gIn) gOut.rcd = gIn.rcd;
for (let guard = 0; guard < 20; guard++) {
const cnt = Array.from({ length: nRcd }, (_, i) => out.groups.filter(g => g.rcd === i + 1).length);
const over = cnt.findIndex(c => c > 4);
if (over < 0) break;
const mv = out.groups.slice().reverse().find(g => g.rcd === over + 1 && g !== gOut && g !== gIn);
if (!mv) break;
mv.rcd = cnt.indexOf(Math.min(...cnt)) + 1;
}
out.switches.forEach(sw => { const g = groupOf(sw.func === 'buiten' ? 'buiten' : 'licht', sw.unitId); sw.group = g ? g.nr : null; });
out.wcds.forEach(w => { const g = groupOf('wcd', w.unitId); w.group = g ? g.nr : null; });
out.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; });
const mkNode = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY + mk.h, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null;
const mkLow = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null;
const addRun = (type, a, b, group, letter) => {
const pts = R.path(a, b);
const raw = R.length(pts);
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' });
};
const tree = (root, nodes, typeOf, group, letter) => {
const done = [root], rest = nodes.slice();
while (rest.length) {
let best = null;
rest.forEach((n, i) => done.forEach(t => { const c = R.cost(t, n); if (!best || c < best.c) best = { c, i, t }; }));
const n = rest.splice(best.i, 1)[0];
addRun(typeOf(best.t, n), best.t, n, group, letter);
done.push(n);
}
};
for (const combo of out.combos) {
const byGroup = [];
combo.switches.forEach(sw => {
let e = byGroup.find(x => x.group === sw.group);
if (!e) byGroup.push(e = { group: sw.group, switches: [] });
e.switches.push(sw);
});
combo.vds = [];
const along = { x: combo.face.b.x - combo.face.a.x, z: combo.face.b.z - combo.face.a.z }, al = Math.hypot(along.x, along.z) || 1;
byGroup.forEach((e, i) => {
const shift = (i - (byGroup.length - 1) / 2) * VD_OFFSET;
const vd = {
x: combo.pos.x + combo.face.n.x * VD_OFFSET + along.x / al * shift, z: combo.pos.z + combo.face.n.z * VD_OFFSET + along.z / al * shift,
mount: 'plafond', name: `VD${combo.id}${byGroup.length > 1 ? String.fromCharCode(97 + i) : ''}`
};
vd.y = R.ceilAt(vd.x, vd.z);
combo.vds.push(vd);
out.boxes.push({ kind: 'verdeeldoos', combo: combo.id, group: e.group, x: vd.x, y: vd.y, z: vd.z, unitId: combo.unitId });
if (mkNode) addRun('licht', mkNode, vd, e.group);
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('') });
addRun('schakel' + (e.switches.length + 1), vd, swNode, e.group);
e.switches.forEach(sw => tree(vd, sw.lamps.map((l, k) => Object.assign(l, { name: `${sw.letter}${k + 1}` })),
(a, b) => a.mount === 'buiten' || b.mount === 'buiten' ? 'buiten' : 'licht', e.group, sw.letter));
});
}
out.groups.filter(g => g.kind === 'wcd').forEach(g => {
const nodes = out.wcds.filter(w => w.group === g.nr).map((w, i) => Object.assign({}, w.pos, { mount: 'wand', face: w.face, name: `WCD${g.nr}.${i + 1}` }))
.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) out.apps.forEach(a => addRun(APPLIANCES[a.app].cable, mkLow, Object.assign({}, a.pos, { mount: 'wand', face: a.face, name: a.code }), a.group));
separateRuns(out.runs);
const holesOf = makeHoleCounter(units, R);
out.runs.forEach(r => {
r.rawMM = R.length(r.path);
r.lengthMM = (r.rawMM + s.reserve + (r.mk ? s.meterkastReserve : 0)) * (1 + s.slack / 100);
r.crossings = R.crossings(r.path);
r.holes = holesOf(r);
});
out.holes = out.runs.reduce((a, r) => a + r.holes, 0);
const byType = {};
out.runs.forEach(r => { byType[r.type] = (byType[r.type] || 0) + r.lengthMM; });
out.cables = Object.keys(byType).sort().map(k => ({ key: k, label: cableInfo(k).label, use: cableInfo(k).use, meters: byType[k] / 1000 }));
const wires = {}, conduits = {};
out.runs.forEach(r => {
const c = cableInfo(r.type);
if (!c.conduit) return;
conduits[c.conduit] = (conduits[c.conduit] || 0) + r.lengthMM;
c.cores.forEach(core => {
const k = `${String(c.mm2).replace('.', ',')} mm² ${CORE_COLORS[core]}`;
wires[k] = (wires[k] || 0) + r.lengthMM;
});
});
out.wires = Object.keys(wires).sort().map(k => ({ label: `VD-draad ${k}`, meters: wires[k] / 1000 }));
out.conduits = Object.keys(conduits).sort((a, b) => a - b).map(k => ({ label: `Flexibele installatiebuis Ø${k} mm`, meters: conduits[k] / 1000 }));
out.groups.forEach(g => {
const rs = out.runs.filter(r => r.group === g.nr);
g.meters = rs.reduce((a, r) => a + r.lengthMM, 0) / 1000;
g.points = g.kind === 'wcd' ? out.wcds.filter(w => w.group === g.nr).length
: g.kind === 'app' ? 1
: out.switches.filter(x => x.group === g.nr).reduce((a, x) => a + x.lamps.length, 0);
});
out.groups.forEach(g => {
if ((g.kind === 'licht' || g.kind === 'buiten') && g.points > 50) out.warnings.push(`Groep ${g.nr} (${g.label}): ${g.points} lichtpunten; controleer het vermogen (LED).`);
});
const crossBy = {};
out.runs.forEach(r => { if (r.crossings) { const n = cableInfo(r.type).cores.length; crossBy[n] = (crossBy[n] || 0) + r.crossings; } });
const nIndoor = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'binnenspot').length, 0);
const nSpots = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'boeideelspot').length, 0);
const nGevel = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'gevellamp').length, 0);
const nLicht = out.groups.filter(g => g.kind === 'licht').length, nWcdG = out.groups.filter(g => g.kind === 'wcd').length;
const frames = {};
out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; });
const m = out.materials;
const add = (naam, aantal, eenheid) => { if (aantal > 0) m.push({ naam, aantal, eenheid: eenheid || 'st.' }); };
if (out.meterkast) {
add('Groepenkast 3-fase (aansluiting 3×25 A)', 1);
add('Hoofdschakelaar 4-polig 40 A', 1);
}
add('Aardlekschakelaar 2-polig 40 A / 30 mA', out.rcdCount);
add('Installatieautomaat B10 1P+N (verlichting)', nLicht);
add('Installatieautomaat B10 1P+N (buitenverlichting)', out.groups.filter(g => g.kind === 'buiten').length);
add('Installatieautomaat B16 1P+N (wcd)', nWcdG);
add('Installatieautomaat B16 1P+N (apparaten)', out.apps.filter(a => (APPLIANCES[a.app].poles || 1) === 1).length);
add('Installatieautomaat 2× B16, 2-fase (kookgroep)', out.apps.filter(a => APPLIANCES[a.app].poles === 2).length);
add('Schakelaar enkelpolig (inbouw)', out.switches.length);
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig`, frames[n]));
add('Wandcontactdoos dubbel met randaarde', out.wcds.length);
add('Vloerdoos met dubbele wcd (randaarde)', out.floorWcds.length);
const appMat = {};
out.apps.forEach(a => { const n = APPLIANCES[a.app].material; appMat[n] = (appMat[n] || 0) + 1; });
Object.keys(appMat).forEach(n => add(n, appMat[n]));
add('Luchtdichte hollewanddoos (schakelaar/wcd/apparaat)', out.switches.length + 2 * out.wcds.length + out.apps.length);
add('Luchtdichte verdeeldoos (plafond)', out.boxes.length);
add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length);
add('LED-inbouwspot binnen + luchtdichte spotdoos', nIndoor);
add('Buitenspot boeideel (IP44)', nSpots);
add('Gevellamp (IP44)', nGevel);
add('Luchtdichte kabeldoorvoer naar buiten', out.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && r.to !== '' && !r.to.startsWith('VD')).length);
Object.keys(crossBy).forEach(n => add(`Steekverbinder module-naad ${n}-polig (set m/v)`, crossBy[n]));
const item = (key, kind, label, f, u, y, manual, off, unitId) => out.items.push({
key, kind, label, unitId: unitId || f.unit.id, element: f.piece.element, side: f.side, u, y, manual: !!manual, marker: facePoint(f, u, 0, off)
});
const k = out.meterkast;
if (k) item('mk', 'mk', 'Meterkast', k.face, k.u, k.y, k.manual, k.d / 2);
out.combos.forEach(c => item(c.key, 'sw', 'Schakelaar ' + c.switches.map(x => x.letter).join(' '), c.face, c.u, c.y, c.manual, 260, c.unitId));
out.wcds.forEach(w => item(w.key, 'wcd', `Wcd ${w.key.split(':')[2]} unit ${w.unitId} (groep ${w.group})`, w.face, w.u, w.y, w.manual, 120, w.unitId));
out.apps.forEach(a => item(a.key, 'app', `${a.label} (groep ${a.group})`, a.face, a.u, a.y, a.manual, 150));
out.floorWcds.forEach(w => {
const u = units.find(x => x.id === w.unitId);
out.items.push({ key: w.key, kind: 'vwcd', label: `Vloer-wcd unit ${w.unitId} (groep ${w.group})`, unitId: w.unitId, x: w.x - u.rect.x0, z: w.z - u.rect.z0, manual: true, marker: { x: w.x, z: w.z } });
});
return out;
}
// Uitzondering: het laatste stuk naar de meterkast; daar komen de buizen tegen elkaar aan de kast binnen.
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 axisOf = (p, q) => {
const dx = Math.abs(q.x - p.x), dy = Math.abs(q.y - p.y), dz = Math.abs(q.z - p.z);
return dx >= dy && dx >= dz ? 'x' : dz >= dy ? 'z' : 'y';
};
const lenOf = (p, q) => Math.hypot(q.x - p.x, q.y - p.y, q.z - p.z);
const seg = (run, p, q, r) => {
const axis = axisOf(p, q), perp = {};
['x', 'y', 'z'].filter(k => k !== axis).forEach(k => { perp[k] = (p[k] + q[k]) / 2; });
return { run, axis, lo: Math.min(p[axis], q[axis]), hi: Math.max(p[axis], q[axis]), perp, r };
};
const placed = [];
const conflict = (sg) => placed.some(t => t.run !== sg.run && t.axis === sg.axis && Math.min(t.hi, sg.hi) - Math.max(t.lo, sg.lo) > 1 &&
Math.hypot(...Object.keys(sg.perp).map(k => sg.perp[k] - t.perp[k])) < CLEAR + sg.r + t.r - 0.5);
const faceDir = (f) => { const L = Math.hypot(f.b.x - f.a.x, f.b.z - f.a.z) || 1; return { x: (f.b.x - f.a.x) / L, y: 0, z: (f.b.z - f.a.z) / L }; };
const shiftDir = (p, q, axis) => {
const plane = (p.ceil || p.floor) && (q.ceil || q.floor);
if (axis === 'y') { const f = p.wall || q.wall; return f ? faceDir(f) : { x: 1, y: 0, z: 0 }; }
if (!plane && (p.wall || q.wall)) return { x: 0, y: 1, z: 0 };
return axis === 'x' ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
};
const conduit = runs.filter(r => cableInfo(r.type).conduit > 0);
const radius = (r) => cableInfo(r.type).conduit / 2;
const bundles = {};
conduit.filter(r => r.mk && r.path[0].wall).forEach(r => {
const k = Math.round(r.path[0].y);
(bundles[k] = bundles[k] || []).push(r);
});
Object.values(bundles).forEach(list => {
const d = faceDir(list[0].path[0].wall);
const along = (r) => { const q = r.path[Math.min(2, r.path.length - 1)]; return (q.x - r.path[0].x) * d.x + (q.z - r.path[0].z) * d.z; };
list.sort((a, b) => along(a) - along(b));
const total = list.reduce((a, r) => a + 2 * radius(r), 0);
let off = -total / 2;
list.forEach(r => {
const o = off + radius(r);
off += 2 * radius(r);
const orig = r.path, dAx = Math.abs(d.x) > Math.abs(d.z) ? 'x' : 'z';
const out = orig.map(p => p);
out[0] = add(orig[0], d, o);
for (let k = 0; k < orig.length - 1; k++) {
const len = lenOf(orig[k], orig[k + 1]);
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; }
out[k + 1] = add(orig[k + 1], d, o);
}
r.path = out;
placed.push(seg(r, out[0], out[1], radius(r)));
r.fixed = 1;
});
});
conduit.forEach(r => {
const rad = radius(r), step = CLEAR + 2 * rad;
let pts = r.path;
for (let i = r.fixed || 0; i < pts.length - 1; i++) {
const p = pts[i], q = pts[i + 1];
if (lenOf(p, q) < 1) continue;
const sg = seg(r, p, q, rad);
if (!conflict(sg)) { placed.push(sg); continue; }
const dir = shiftDir(p, q, sg.axis);
// Knikken tellen mee, behalve direct aan een doos/lamp en aan het eind van de meterkastbundel.
const freeStart = i === 0 || (r.fixed && i === r.fixed), freeEnd = i + 1 === pts.length - 1;
let done = false;
for (let k = 1; k <= 12 && !done; k++) {
for (const sign of [1, -1]) {
const kk = sign * k * step, p2 = add(p, dir, kk), q2 = add(q, dir, kk), s2 = seg(r, p2, q2, rad);
const j1 = seg(r, p, p2, rad), j2 = seg(r, q2, q, rad);
if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue;
pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1));
placed.push(s2, j1, j2);
i += 2;
done = true;
break;
}
}
if (!done) placed.push(sg);
}
r.path = simplifyPath(pts);
});
runs.forEach(r => { delete r.fixed; });
}
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;
let out = pts.filter((p, i) => i === 0 || !same(p, pts[i - 1]));
let changed = true;
while (changed) {
changed = false;
for (let i = 1; i < out.length - 1; i++) {
if (i + 1 < out.length && same(out[i - 1], out[i + 1])) { out.splice(i, 2); changed = true; break; }
}
}
return out;
}
function snapToWall(el, x, z) {
let best = null;
((el && el.faces) || []).forEach(f => {
const u = Math.max(f.u0, Math.min(f.u1, uAt(f, { x, z })));
const q = facePoint(f, u, 0, 0);
if ((x - q.x) * f.n.x + (z - q.z) * f.n.z < -(f.piece.place.thickness + 50)) return;
const d = Math.hypot(q.x - x, q.z - z);
if (!best || d < best.d) best = { d, element: f.piece.element, side: f.side, u: Math.round(u / 10) * 10, x: q.x, z: q.z, n: f.n };
});
return best;
}
// Kabel zonder buis (XMvK buiten) en losse draad worden niet getekend.
const WALL_IN = 35;
const CEIL_UP = 35;
const CONDUIT_COLORS = { licht: 0xe0b020, wcd: 0xcc4a30, koken: 0x8a3a9a, schakel: 0x9a6a20 };
function createMaterials() {
const conduit = {};
Object.keys(CONDUIT_COLORS).forEach(k => { conduit[k] = new THREE.MeshStandardMaterial({ color: CONDUIT_COLORS[k], roughness: 0.55 }); });
return {
kastMaterial: new THREE.MeshStandardMaterial({ color: 0xd8d8d2, roughness: 0.6 }),
deurMaterial: new THREE.MeshStandardMaterial({ color: 0xc4c4bd, roughness: 0.5 }),
plateMaterial: new THREE.MeshStandardMaterial({ color: 0xf6f6f2, roughness: 0.4 }),
boxMaterial: new THREE.MeshStandardMaterial({ color: 0xe8742a, roughness: 0.6 }),
conduit
};
}
function build3D(el, materials, stage) {
const toM = 0.001, group = new THREE.Group();
if (!el || !el.enabled) return group;
const mats = materials || createMaterials();
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key);
const rough = show('elektra') && !show('beplating');
const box = (w, h, d, c, rotY, mat) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
mesh.position.set(c.x * toM, c.y * toM, c.z * toM);
mesh.rotation.y = rotY;
group.add(mesh);
return mesh;
};
const k = el.meterkast;
if (k && show('elektra')) {
box(k.w, k.h, k.d, k.center, k.rotY, mats.kastMaterial);
const front = facePoint(k.face, k.u, k.y + k.h / 2, k.d + 2);
box(k.w - 30, k.h - 30, 4, front, k.rotY, mats.deurMaterial);
}
if (show('afwerking')) {
el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial));
el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial));
el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial));
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;
const up = new THREE.Vector3(0, 1, 0);
const shift = (p) => ({
x: p.x - (p.wall ? p.wall.n.x * WALL_IN : 0), y: p.y + (p.ceil ? CEIL_UP : 0), z: p.z - (p.wall ? p.wall.n.z * WALL_IN : 0)
});
el.runs.forEach(r => {
const c = cableInfo(r.type);
if (!c.conduit) return;
const rad = c.conduit / 2 * toM;
const mat = mats.conduit[r.type] || mats.conduit.schakel;
const pts = r.path.map(shift);
pts.slice(1).forEach((q, i) => {
const p = pts[i], dx = q.x - p.x, dy = q.y - p.y, dz = q.z - p.z, len = Math.hypot(dx, dy, dz);
if (len < 1) return;
const m = new THREE.Mesh(new THREE.CylinderGeometry(rad, rad, len * toM, 6), mat);
m.position.set((p.x + q.x) / 2 * toM, (p.y + q.y) / 2 * toM, (p.z + q.z) / 2 * toM);
m.quaternion.setFromUnitVectors(up, new THREE.Vector3(dx / len, dy / len, dz / len));
group.add(m);
});
pts.slice(1, -1).forEach(p => {
const j = new THREE.Mesh(new THREE.IcosahedronGeometry(rad * 1.15, 0), mat);
j.position.set(p.x * toM, p.y * toM, p.z * toM);
group.add(j);
});
});
const wallBox = (f, u, y) => {
const c = facePoint(f, u, y, -22);
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.034, 0.034, 0.045, 12), mats.boxMaterial);
m.position.set(c.x * toM, c.y * toM, c.z * toM);
m.quaternion.setFromUnitVectors(up, new THREE.Vector3(f.n.x, 0, f.n.z));
group.add(m);
};
el.switches.forEach(sw => wallBox(sw.combo.face, sw.u, sw.combo.y));
el.wcds.forEach(w => { wallBox(w.face, w.u - 36, w.y); wallBox(w.face, w.u + 36, w.y); });
el.apps.forEach(a => wallBox(a.face, a.u, a.y));
el.floorWcds.forEach(w => box(100, 80, 100, { x: w.x, y: w.y - 40, z: w.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 => {
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.07, 12), mats.boxMaterial);
m.position.set(l.x * toM, (l.y + 18 + 35) * toM, l.z * toM);
group.add(m);
}));
return group;
}
const wallCoords = (f, p) => ({ u: uAt(f, p), y: p.y - f.piece.place.floorTop });
function clipPlan(p, q, b) {
let t0 = 0, t1 = 1;
for (const [k, lo, hi] of [['x', b.x0, b.x1], ['z', b.z0, b.z1]]) {
const d = q[k] - p[k];
if (Math.abs(d) < 1e-9) { if (p[k] < lo - 0.5 || p[k] > hi + 0.5) return null; continue; }
let a = (lo - p[k]) / d, c = (hi - p[k]) / d;
if (a > c) [a, c] = [c, a];
t0 = Math.max(t0, a); t1 = Math.min(t1, c);
if (t0 > t1) return null;
}
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)];
}
// Zelfde omrekening als de doorvoeren in RoofModel.computeUnitRoof: gelijk houden.
function roofDeckMap(unit, pl) {
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;
return {
bounds: alongX
? { x0: Math.max(r.x0, r.cx + pl.p0), x1: Math.min(r.x1, r.cx + pl.p1), z0: r.z0, z1: r.z1 }
: { x0: r.x0, x1: r.x1, z0: Math.max(r.z0, r.cz + pl.p0), z1: Math.min(r.z1, r.cz + pl.p1) },
map: (p) => ({ sx: alongX ? rr.z1 - p.z : p.x - rr.x0, sy: (qOf(p) - pl.p0) / pl.cos })
};
}
const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl'];
function makeHoleCounter(units, R) {
const boms = new Map();
const bomOf = (piece) => {
if (!boms.has(piece)) boms.set(piece, piece.frame ? WallModel.computeBOM(piece.config, piece.frame) : []);
return boms.get(piece);
};
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;
return (run) => {
if (!cableInfo(run.type).conduit) return run.type === 'buiten' && run.from.startsWith('VD') ? 1 : 0;
let n = 0;
run.path.slice(1).forEach((q, i) => {
const p = run.path[i];
const vertical = Math.abs(q.y - p.y) > Math.hypot(q.x - p.x, q.z - p.z);
if (p.wall && q.wall && p.wall.piece === q.wall.piece && !(p.ceil && q.ceil)) {
const f = p.wall, a = wallCoords(f, p), b = wallCoords(f, q), bom = bomOf(f.piece);
if (vertical) {
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;
if (p.floor !== q.floor) n += 1;
} else {
n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length;
}
} else if (!vertical && p.ceil && q.ceil) {
const mid = { x: (p.x + q.x) / 2, z: (p.z + q.z) / 2 }, u = R.unitAt(mid.x, mid.z);
if (u) u.roof.planes.forEach(pl => {
const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds);
if (c) n += joistsBetween(pl.data.calcData, dm.map(c[0]).sx, dm.map(c[1]).sx);
});
} else if (!vertical && p.floor && q.floor) {
const u = R.unitAt((p.x + q.x) / 2, (p.z + q.z) / 2);
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);
} else if (vertical && (p.floor || q.floor) && !(p.wall || q.wall)) n += 1;
});
return n;
};
}
return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D };
})();
+246
View File
@@ -0,0 +1,246 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.ElektraPdf = (() => {
const fmt = (n, d = 1) => Number(n).toFixed(d).replace('.', ',');
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;
function drawPlan(doc, el, px, py) {
if (!el || !el.enabled) return;
const BLACK = PdfSheet.COLORS.BLACK;
doc.setLineWidth(0.15);
doc.setLineDashPattern([0.8, 0.6], 0);
el.runs.forEach(r => {
doc.setDrawColor(...runColor(r.type));
r.path.slice(1).forEach((b, i) => {
const a = r.path[i];
if (Math.hypot(px(b.x) - px(a.x), py(b.z) - py(a.z)) > 0.05) doc.line(px(a.x), py(a.z), px(b.x), py(b.z));
});
});
doc.setLineDashPattern([], 0);
const k = el.meterkast;
if (k) {
doc.setDrawColor(...BLACK);
doc.setFillColor(90, 90, 90);
doc.setLineWidth(0.2);
const c = k.corners;
doc.lines(c.slice(1).map((p, i) => [px(p.x) - px(c[i].x), py(p.z) - py(c[i].z)]), px(c[0].x), py(c[0].z), [1, 1], 'FD', true);
doc.setFontSize(5);
doc.setTextColor(...BLACK);
const m = { x: (c[0].x + c[2].x) / 2 + k.face.n.x * 350, z: (c[0].z + c[2].z) / 2 + k.face.n.z * 350 };
doc.text('MK', px(m.x), py(m.z) + 0.8, { align: 'center' });
}
doc.setDrawColor(...runColor('schakel'));
doc.setLineWidth(0.15);
el.boxes.forEach(b => doc.rect(px(b.x) - 0.6, py(b.z) - 0.6, 1.2, 1.2, 'S'));
doc.setFontSize(5);
el.combos.forEach(c => {
const q = { x: c.pos.x + c.face.n.x * 260, z: c.pos.z + c.face.n.z * 260 };
const text = c.switches.map(s => s.letter).join(' ');
const w = 1.6 + text.length * 1.0;
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 255, 255);
doc.setLineWidth(0.2);
doc.rect(px(q.x) - w / 2, py(q.z) - 1.2, w, 2.4, 'FD');
doc.setTextColor(...BLACK);
doc.text(text, px(q.x), py(q.z) + 0.7, { align: 'center' });
});
el.wcds.forEach(w => {
const q = { x: w.pos.x + w.face.n.x * 120, z: w.pos.z + w.face.n.z * 120 };
doc.setDrawColor(...RUN_COLORS.wcd);
doc.setFillColor(255, 255, 255);
doc.setLineWidth(0.2);
doc.circle(px(q.x), py(q.z), 0.9, 'FD');
doc.setTextColor(...RUN_COLORS.wcd);
doc.setFontSize(4);
doc.text(String(w.group), px(q.x + w.face.n.x * 250), py(q.z + w.face.n.z * 250) + 0.6, { align: 'center' });
});
el.apps.forEach(a => {
const it = el.items.find(x => x.key === a.key), q = it.marker;
doc.setDrawColor(...RUN_COLORS.wcd);
doc.setFillColor(255, 255, 255);
doc.setLineWidth(0.2);
doc.rect(px(q.x) - 1.6, py(q.z) - 1.1, 3.2, 2.2, 'FD');
doc.setTextColor(...RUN_COLORS.wcd);
doc.setFontSize(3.6);
doc.text(a.code, px(q.x), py(q.z) + 0.5, { align: 'center' });
doc.text(String(a.group), px(q.x + a.face.n.x * 330), py(q.z + a.face.n.z * 330) + 0.5, { align: 'center' });
});
el.floorWcds.forEach(w => {
doc.setDrawColor(...RUN_COLORS.wcd);
doc.setFillColor(255, 255, 255);
doc.setLineWidth(0.2);
doc.rect(px(w.x) - 1.1, py(w.z) - 1.1, 2.2, 2.2, 'FD');
doc.setTextColor(...RUN_COLORS.wcd);
doc.setFontSize(3.6);
doc.text('V' + w.group, px(w.x), py(w.z) + 0.5, { align: 'center' });
});
doc.setFontSize(3.6);
doc.setTextColor(...BLACK);
el.switches.forEach(s => s.lamps.forEach(l => doc.text(s.letter, px(l.x) + 0.9, py(l.z) - 0.7)));
}
const PLAN_LEGEND = [
'Elektra: MK = meterkast, kadertje = schakelaar(s) met letter (dezelfde letter staat bij de lampen erop), rood rondje = dubbele wcd (groepnummer), vierkantje = verdeeldoos in plafond;',
'rood kadertje = apparaat (KK kookgroep, WM wasmachine, DR droger, WP warmtepomp, OV oven, VW vaatwasser) met groepnummer, V = vloer-wcd; gestippeld = leidingen (geel licht, bruin schakeldraad, rood wcd/apparaat, groen buiten).'
];
function drawSheet(doc, el, sheetOpts) {
const BLACK = PdfSheet.COLORS.BLACK;
doc.setFont('helvetica', 'normal');
doc.setTextColor(...BLACK);
doc.setFontSize(14);
doc.text('Elektra', 15, 18);
const t = (title) => ({ title, fontSize: 7, rowH: 4.4, lastColW: 16 });
let y = 30;
y = PdfSheet.table(doc, 15, y, [0, 12, 62, 74, 88, 104, 140, 156], ['Groep', 'Omschrijving', 'Fase', 'Aardlek', 'Automaat', 'Leiding', 'Punten', 'Lengte'],
el.groups.map(g => [String(g.nr), g.label, g.phase, `AL${g.rcd}`, g.breaker, ElektraModel.cableInfo(g.cable).label.replace(/ in buis.*/, ''),
g.kind === 'app' ? 'apparaat' : `${g.points} ${g.kind === 'wcd' ? 'wcd' : 'lamp'}`, fmt(g.meters) + ' m']),
t('Groepenverklaring (aansluiting 3×25 A)'));
y += 4;
y = PdfSheet.table(doc, 15, y, [0, 14, 60, 76, 92], ['Letter', 'Functie', 'Unit', 'Groep', 'Lampen'],
el.switches.map(s => [s.letter, s.label, s.unitId, String(s.group || '-'),
`${s.lamps.length} (${[['binnenspot', 'binnenspot'], ['boeideelspot', 'boeideel-spot'], ['gevellamp', 'gevellamp']]
.map(([k, l]) => [s.lamps.filter(x => x.kind === k).length, l]).filter(([n]) => n).map(([n, l]) => `${n} ${l}`).join(', ') || '-'})`]),
t('Schakelaars (letter staat ook bij de lampen op de plattegrond)'));
y += 4;
const k = el.meterkast;
const notes = [
k ? `Meterkast ${k.w}×${k.h}×${k.d} mm (b×h×d) in unit ${k.unitId}, wand ${k.element}, onderkant ${k.y} mm boven vloer.` : 'Geen meterkast geplaatst.',
`Schakelaars op ${el.settings.switchHeight} mm, wcd op ${el.settings.wcdHeight} mm (hart, boven bovenkant vloer).`,
`Leidinglengte = route via stijlen/plafond + ${el.settings.reserve} mm reserve (meterkast +${el.settings.meterkastReserve} mm), + ${el.settings.slack}% marge.`,
'Groepen per unit: licht B10 (1,5 mm²), wcd B16 (2,5 mm²); buitenlicht eigen groep B10 (XMvK), zelfde aardlek als het binnenlicht in dezelfde schakeldoos.',
'Per schakeldoos één verdeeldoos per groep; fasen en aardlekschakelaars om en om.',
'Apparaten (kookgroep, wasmachine, ...) automatisch geplaatst zolang er geen keuken/bijkeuken is; verplaatsbaar in de plattegrond.',
'Indicatief eerste ontwerp; uitvoering en controle volgens NEN 1010 door de installateur.'
].concat(el.warnings.map(w => 'Let op: ' + w));
doc.setFontSize(7);
notes.forEach(n => { doc.text(n, 15, y); y += 4.2; });
let y2 = 30;
y2 = PdfSheet.table(doc, 205, y2, [0, 46, 102], ['Leidingtype', 'Gebruik', 'Lengte'],
el.cables.map(c => [c.label, c.use, fmt(c.meters) + ' m']), t('Leidingen (strekkende meter)'));
y2 += 4;
y2 = PdfSheet.table(doc, 205, y2, [0, 102], ['Draad', 'Lengte'], el.wires.map(w => [w.label, fmt(w.meters) + ' m']), t('Draad (per ader)'));
y2 += 4;
PdfSheet.table(doc, 205, y2, [0, 102], ['Buis', 'Lengte'], el.conduits.map(c => [c.label, fmt(c.meters) + ' m']), t('Installatiebuis'));
PdfSheet.table(doc, 330, 30, [0, 66], ['Onderdeel', 'Aantal'], el.materials.map(m => [m.naam, `${m.aantal} ${m.eenheid}`]), Object.assign(t('Materiaal'), { maxY: 240 }));
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
const lineCol = (doc, type) => doc.setDrawColor(...runColor(type));
const clip = (p, q, b) => ElektraModel.clipPlan(p, q, b);
const conduitSegs = (el, test) => {
const out = [];
el.runs.forEach(r => {
if (!ElektraModel.cableInfo(r.type).conduit) return;
r.path.slice(1).forEach((q, i) => { const p = r.path[i]; if (test(p, q)) out.push({ type: r.type, p, q }); });
});
return out;
};
const labelBox = (doc, x, y, text, color) => {
const w = 1.6 + text.length * 1.05;
doc.setDrawColor(...color);
doc.setFillColor(255, 255, 255);
doc.setLineWidth(0.2);
doc.rect(x - w / 2, y - 1.2, w, 2.4, 'FD');
doc.setTextColor(...color);
doc.setFontSize(5);
doc.text(text, x, y + 0.7, { align: 'center' });
};
function wallOverlay(el, piece) {
if (!el || !el.enabled) return null;
const on = (p) => p.wall && p.wall.piece === piece;
const segs = conduitSegs(el, (p, q) => on(p) && on(q));
const combos = el.combos.filter(c => c.face.piece === piece), wcds = el.wcds.filter(w => w.face.piece === piece);
const apps = el.apps.filter(a => a.face.piece === piece), mk = el.meterkast && el.meterkast.face.piece === piece ? el.meterkast : null;
if (!segs.length && !combos.length && !wcds.length && !apps.length && !mk) return null;
const BLACK = PdfSheet.COLORS.BLACK;
return (doc, pu, py) => {
doc.setLineWidth(0.35);
segs.forEach(sg => {
const a = ElektraModel.wallCoords(sg.p.wall, sg.p), b = ElektraModel.wallCoords(sg.q.wall, sg.q);
lineCol(doc, sg.type);
doc.line(pu(a.u), py(a.y), pu(b.u), py(b.y));
});
if (mk) {
doc.setDrawColor(...BLACK);
doc.setFillColor(110, 110, 110);
doc.setLineWidth(0.25);
doc.rect(pu(mk.u - mk.w / 2), py(mk.y + mk.h), (pu(mk.u + mk.w / 2) - pu(mk.u - mk.w / 2)), py(mk.y) - py(mk.y + mk.h), 'FD');
doc.setTextColor(255, 255, 255);
doc.setFontSize(6);
doc.text('MK', pu(mk.u), py(mk.y + mk.h / 2) + 1, { align: 'center' });
}
combos.forEach(c => labelBox(doc, pu(c.u), py(c.y), c.switches.map(x => x.letter).join(' '), BLACK));
wcds.forEach(w => labelBox(doc, pu(w.u), py(w.y), `wcd ${w.group}`, RUN_COLORS.wcd));
apps.forEach(a => labelBox(doc, pu(a.u), py(a.y), `${a.code} ${a.group}`, RUN_COLORS.wcd));
};
}
function floorOverlay(el, unit) {
if (!el || !el.enabled) return null;
const r = unit.rect, b = { x0: r.x0, x1: r.x1, z0: r.z0, z1: r.z1 };
const segs = conduitSegs(el, (p, q) => p.floor && q.floor).map(sg => Object.assign(sg, { c: clip(sg.p, sg.q, b) })).filter(sg => sg.c);
const vw = el.floorWcds.filter(w => w.unitId === unit.id);
if (!segs.length && !vw.length) return null;
return (doc, px, py) => {
doc.setLineWidth(0.35);
segs.forEach(sg => {
const [a, c] = sg.c;
if (Math.hypot(c.x - a.x, c.z - a.z) < 1) return;
lineCol(doc, sg.type);
doc.line(px(a.x - r.x0), py(a.z - r.z0), px(c.x - r.x0), py(c.z - r.z0));
});
vw.forEach(w => labelBox(doc, px(w.x - r.x0), py(w.z - r.z0), `vloer-wcd ${w.group}`, RUN_COLORS.wcd));
};
}
function roofOverlay(el, unit, pl) {
if (!el || !el.enabled) return null;
const dm = ElektraModel.roofDeckMap(unit, pl), map = dm.map, b = dm.bounds;
const inside = (p) => p.x >= b.x0 - 0.5 && p.x <= b.x1 + 0.5 && p.z >= b.z0 - 0.5 && p.z <= b.z1 + 0.5;
const segs = conduitSegs(el, (p, q) => p.ceil && q.ceil).map(sg => Object.assign(sg, { c: clip(sg.p, sg.q, b) })).filter(sg => sg.c);
const boxes = el.boxes.filter(bx => inside(bx));
const lamps = [];
el.switches.forEach(sw => sw.lamps.forEach(l => { if (l.kind === 'binnenspot' && inside(l)) lamps.push({ l, letter: sw.letter }); }));
if (!segs.length && !boxes.length && !lamps.length) return null;
const BLACK = PdfSheet.COLORS.BLACK;
return (doc, px, py) => {
doc.setLineWidth(0.35);
segs.forEach(sg => {
const a = map(sg.c[0]), c = map(sg.c[1]);
if (Math.hypot(c.sx - a.sx, c.sy - a.sy) < 1) return;
lineCol(doc, sg.type);
doc.line(px(a.sx), py(a.sy), px(c.sx), py(c.sy));
});
doc.setLineWidth(0.2);
boxes.forEach(bx => { const m = map(bx); lineCol(doc, 'schakel'); doc.rect(px(m.sx) - 0.9, py(m.sy) - 0.9, 1.8, 1.8, 'S'); });
doc.setFontSize(4);
lamps.forEach(({ l, letter }) => {
const m = map(l);
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 233, 168);
doc.circle(px(m.sx), py(m.sy), 0.8, 'FD');
doc.setTextColor(...BLACK);
doc.text(letter, px(m.sx) + 1.1, py(m.sy) - 0.8);
});
};
}
return { RUN_COLORS, drawPlan, drawSheet, PLAN_LEGEND, wallOverlay, floorOverlay, roofOverlay };
})();
+51 -92
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,17 +159,50 @@ 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)' }, geen: { label: 'Geen (OSB)', color: '#cda86a' },
visgraat_001: { label: 'Visgraat (Hongaarse punt)', 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' }
}; };
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) {
const f = FINISHES[key];
return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null;
}
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) => {
@@ -247,16 +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 }
// 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;
@@ -267,12 +237,11 @@ 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;
joists.forEach(xMM => { joists.forEach((xMM, i) => {
if (opts.outerJoistsOnly && i > 0 && i < joists.length - 1) return;
const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM); const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM);
const mesh = new THREE.Mesh(geo, woodMaterial); const mesh = new THREE.Mesh(geo, woodMaterial);
mesh.position.set(xMM * toM, joistHM / 2, joistZ); mesh.position.set(xMM * toM, joistHM / 2, joistZ);
@@ -286,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);
@@ -327,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;
@@ -340,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();
@@ -369,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'];
@@ -393,7 +352,7 @@ window.FloorModel = (() => {
} }
return { return {
FINISHES, finishMaterial, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels, FINISHES, finishMaterial, finishThumbUrl, tileTexture, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
computeSpecs, computeFromState, createMaterials, build3D computeSpecs, computeFromState, createMaterials, build3D
}; };
})(); })();
+6 -29
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,6 +98,8 @@ 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' });
}); });
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);
doc.setDrawColor(...BLUE); doc.setDrawColor(...BLUE);
@@ -131,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);
@@ -151,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;
@@ -160,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() {
+741 -260
View File
File diff suppressed because it is too large Load Diff
+548 -37
View File
@@ -4,45 +4,40 @@
* 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 plattegrond woning (units, wandstukken, naden, binnenwanden)
// daarna per unit: vloer (FloorPdf) + elk wandstuk (WallPdf)
// daarna elke binnenwand (WallPdf)
// laatste totalen (balken, inkoop, zaaglijst, glaswol, plaatwerk, gewicht)
// 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 = {
'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel', 'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel',
'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl', 'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl',
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel' 'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel', 'schuine regel': 'Schuine regel'
}; };
const PLAN_ELEMENT = 'PL-1'; const PLAN_ELEMENT = 'PL-1';
const VIEWS_ELEMENT = 'AZ-1';
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 });
} }
// 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 = [{ kind: 'plan', element: PLAN_ELEMENT, label: 'Plattegrond woning' }]; const list = [
{ kind: 'views', element: VIEWS_ELEMENT, label: 'Aanzichten woning' },
{ kind: 'plan', element: PLAN_ELEMENT, label: 'Plattegrond woning' }
];
parts.units.forEach(u => { parts.units.forEach(u => {
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 }));
if (u.roof.unitRoof && u.roof.unitRoof.cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u });
}); });
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })); parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' }); list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' });
if (parts.elektra && parts.elektra.enabled) list.push({ kind: 'elektra', element: 'EL', label: 'Elektra' });
return list; return list;
} }
@@ -51,7 +46,7 @@ window.HousePdf = (() => {
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'], PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
sheets.slice(1).map((s, i) => [s.element, s.label, String(i + 2)]), sheets.map((s, i) => [s.element, s.label, String(i + 1)]).filter(r => r[0] !== PLAN_ELEMENT),
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 }); { title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
@@ -68,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);
@@ -78,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);
@@ -91,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);
@@ -113,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);
@@ -121,14 +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');
}); });
// Naden: gestippeld oranje ElektraPdf.drawPlan(doc, parts.elektra, px, py);
doc.setDrawColor(230, 130, 30); doc.setDrawColor(230, 130, 30);
doc.setLineWidth(0.4); doc.setLineWidth(0.4);
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);
@@ -149,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);
@@ -159,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);
@@ -169,7 +212,315 @@ 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);
}
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 VIEW_COLORS = {
rabat: '#a0805f', cementplaat: '#808285', glas: '#a9c9dc', deur: '#5b4a3b', lijn: [60, 60, 60], grond: [120, 120, 120]
};
// d = richting van woning naar kijker (x, z); u = horizontale schermas.
const VIEWS = [
{ key: 'noord', title: 'Noordgevel', d: { x: 0, z: -1 }, u: (p) => -p.x },
{ key: 'oost', title: 'Oostgevel', d: { x: 1, z: 0 }, u: (p) => -p.z },
{ key: 'zuid', title: 'Zuidgevel', d: { x: 0, z: 1 }, u: (p) => p.x },
{ key: 'west', title: 'Westgevel', d: { x: -1, z: 0 }, u: (p) => p.z }
];
function viewFaces(parts, state) {
const faces = [];
const wallFace = (p, y0, color, depthBias) => {
const W = p.place.width, T = p.place.thickness;
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 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 loc = [{ x: xl, y: y0 }, { x: xr, y: y0 }, { x: xr, y: top(xr) }]
.concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y + up })))
.concat([{ x: xl, y: top(xl) }]);
return loc;
};
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 n = { x: p.place.normal.x, z: p.place.normal.y };
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : WallModel.rabatHex(state.settings.facade.rabatColor);
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
const tr = p.trasraam;
if (tr && tr.top > y0) {
const W = p.place.width;
const band = wallFace(p, y0).map(q => ({ x: q.x, y: Math.min(q.y, tr.top) }));
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
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 });
}
(p.config.openings || []).forEach(op => {
if (WallModel.openingTopMax(op) <= y0) return;
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 color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
faces.push({ pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true });
});
};
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.units.forEach(u => {
const r = u.roof, sh = r.shape;
if (sh.type === 'plat') return;
const cov = RoofModel.COVERINGS[r.covering] || RoofModel.COVERINGS.dakleer;
const ctr = sh.axis === 'x' ? u.rect.cx : u.rect.cz;
const pushRoof = (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;
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
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 });
};
RoofModel.capRegions(r.unitRoof).forEach(rg => {
pushRoof(rg.poly.map(pt => {
const x = u.rect.cx + (sh.axis === 'x' ? pt.x : pt.z), z = u.rect.cz + (sh.axis === 'x' ? pt.z : pt.x);
return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z };
}));
});
r.planes.forEach(pl => {
const a0 = ctr + pl.p0, a1 = ctr + pl.p1, mid = (a0 + a1) / 2;
// Hoogte net binnen het vlak (op de nok geeft topAt het hoogste vlak).
const inward = (t) => t + Math.sign(mid - t) * 0.5;
const corners = sh.axis === 'x'
? [[a0, r.rect.z0], [a1, r.rect.z0], [a1, r.rect.z1], [a0, r.rect.z1]]
: [[r.rect.x0, a0], [r.rect.x1, a0], [r.rect.x1, a1], [r.rect.x0, a1]];
pushRoof(corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z })));
});
});
const edge = parts.roofEdge;
if (edge) {
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, z: a.z + (b.z - a.z) * t });
edge.segments.forEach(sg => {
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
const n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100));
const top = [], bottom = [];
for (let k = 0; k <= n; k++) {
const t = k / n, I = lerp(sg.a, sg.b, t), O = lerp(sg.outerA, sg.outerB, t);
const tI = sg.topAt(I), tO = sg.topAt(O);
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
}
faces.push({ pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
});
}
return faces;
}
function materialLegend(state, parts) {
const items = [];
const st = state.settings;
const seen = new Set();
const facades = [];
state.units.forEach(u => Object.keys(u.unit.walls).forEach(side => {
facades.push(HouseModel.facadeFor(state, u, side));
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;
seen.add(key);
items.push(fc.type === 'steen'
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
: fc.type === 'stuc'
? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) }
: { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) });
});
if (st.trasraam.enabled) {
const tf = st.trasraam.finish;
items.push(WallModel.isStoneFinish(tf)
? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] }
: { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat });
}
(parts ? parts.units : state.units).forEach(u => {
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
items.push({ label: `Dak unit ${u.id}`, value: c.label, thumb: RoofModel.coveringThumbUrl(cov, 64), color: c.color });
});
const ff = FloorModel.FINISHES[st.floorFinish] || FloorModel.FINISHES.geen;
items.push({ label: 'Vloer', value: ff.label, thumb: FloorModel.finishThumbUrl(st.floorFinish, 64), color: ff.color });
const fascia = state.units.some(u => u.roof.fascia !== false), trim = state.units.some(u => u.roof.fascia === false);
if (fascia) {
const fcol = RoofModel.FASCIA_COLORS[st.roof.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet;
items.push({ label: 'Boeideel', value: fcol.label, color: fcol.hex });
}
if (trim) items.push({ label: 'Daktrim', value: 'Aluminium', color: 0xb8bcc0 });
items.push(st.interiorPaint
? { label: 'Binnenwanden', value: `Geschilderd ${st.interiorColor}`, color: st.interiorColor }
: { label: 'Binnenwanden', value: 'Spaanplaat', color: '#1e4620' });
return items;
}
function materialThumbUrls(state) {
return materialLegend(state, null).map(i => i.thumb).filter(Boolean);
}
function drawMaterialLegend(doc, items, x0, y0, x1, y1) {
const { BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.setTextColor(...BLACK);
doc.text('Afwerking', x0, y0 + 3);
doc.setFont('helvetica', 'normal');
const cellW = 45, cellH = 11, sw = 9, top = y0 + 6;
const cols = Math.max(1, Math.floor((x1 - x0) / cellW)), rows = Math.max(1, Math.floor((y1 - top) / cellH));
items.slice(0, cols * rows).forEach((it, i) => {
const cx = x0 + Math.floor(i / rows) * cellW, cy = top + (i % rows) * cellH;
const data = PdfSheet.imageData(it.thumb);
if (data) doc.addImage(data, 'PNG', cx, cy, sw, sw);
else {
doc.setFillColor(...(typeof it.color === 'number' ? numRgb(it.color) : hexRgb(it.color || '#cccccc')));
doc.rect(cx, cy, sw, sw, 'F');
}
doc.setDrawColor(...GREY);
doc.setLineWidth(0.1);
doc.rect(cx, cy, sw, sw, 'S');
doc.setFontSize(6);
doc.setTextColor(...GREY);
doc.text(it.label, cx + sw + 1.5, cy + 3.2);
doc.setFontSize(7);
doc.setTextColor(...BLACK);
doc.text(doc.splitTextToSize(it.value, cellW - sw - 3)[0], cx + sw + 1.5, cy + 7.2);
});
}
function drawDimensions(doc, rows, x0, y0, x1) {
const { BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.setTextColor(...BLACK);
doc.text('Afmetingen', x0, y0 + 3);
doc.setFont('helvetica', 'normal');
const half = Math.ceil(rows.length / 2), colW = (x1 - x0) / 2, rowH = 4.6;
rows.forEach(([label, value, unit], i) => {
const cx = x0 + Math.floor(i / half) * colW, cy = y0 + 8 + (i % half) * rowH;
doc.setFontSize(5.5);
doc.setTextColor(...GREY);
doc.text(label, cx, cy - 1.7);
doc.setFontSize(7);
doc.setTextColor(...BLACK);
doc.text(`${value}${unit ? ' ' + unit : ''}`, cx, cy + 0.9);
});
}
function drawViewsSheet(doc, parts, state, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
const faces = viewFaces(parts, state);
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
const views = VIEWS.map(v => {
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)
.map(f => ({ f, depth: f.pts.reduce((a, p) => a + dot(p), 0) / f.pts.length + f.bias, pts: f.pts.map(p => ({ u: v.u(p), v: p.y })) }))
.sort((a, b) => a.depth - b.depth);
const us = list.flatMap(l => l.pts.map(p => p.u)), vs = list.flatMap(l => l.pts.map(p => p.v)).concat([groundY]);
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
}).filter(x => x.list.length);
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 scale = Math.min(...views.map(w => Math.min((cellW - 16) / (w.u1 - w.u0), (cellH - titleH - 10) / (w.v1 - w.v0))));
doc.setFont('helvetica', 'normal');
views.forEach((w, i) => {
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 base = cy + cellH - 6;
const P = (p) => [ox + p.u * scale, base - (p.v - groundY) * scale];
doc.setTextColor(...BLACK);
doc.setFontSize(11);
doc.text(w.v.title, cx, cy + 5);
doc.setLineWidth(0.1);
doc.setDrawColor(...VIEW_COLORS.lijn);
w.list.forEach(l => {
const pts = l.pts.map(P);
const col = typeof l.f.color === 'number' ? numRgb(l.f.color) : hexRgb(l.f.color);
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.setDrawColor(...VIEW_COLORS.grond);
doc.setLineWidth(0.4);
doc.line(ox + w.u0 * scale - 6, base, ox + w.u1 * scale + 6, base);
});
doc.setFontSize(7);
doc.setTextColor(...BLACK);
doc.text(`Aanzichten (vlakke weergave, schaal ca. 1:${Math.round(1 / scale)}); kleuren: gemiddelde kleur van de gekozen steen / bedekking.`, X0, Y1 + 5);
drawMaterialLegend(doc, materialLegend(state, parts), X0, Y1 + 7, 192, 289);
drawDimensions(doc, HouseModel.dimensionRows(HouseModel.computeDimensions(parts, state.settings.groundLevel), fmt), 197, Y1 + 7, 285);
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', nokbalk: 'Nokbalk', kopbalk: 'Kopbalk' };
function drawCapSheet(doc, u, sheetOpts) {
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
const ur = u.roof.unitRoof, c = ur.cap;
const xz = (pt) => ur.alongX ? { x: pt.x, z: pt.z } : { x: pt.z, z: pt.x };
const band = [{ x: c.pA, z: c.q0 }, { x: c.pB, z: c.q0 }, { x: c.pB, z: c.q1 }, { x: c.pA, z: c.q1 }].map(xz);
const xs = band.map(p => p.x), zs = band.map(p => p.z);
const X0 = 20, X1 = 275, Y0 = 32, Y1 = 230;
const sc = Math.min((X1 - X0) / (Math.max(...xs) - Math.min(...xs)), (Y1 - Y0) / (Math.max(...zs) - Math.min(...zs)));
const ox = (X0 + X1) / 2 - (Math.min(...xs) + Math.max(...xs)) / 2 * sc, oz = (Y0 + Y1) / 2 - (Math.min(...zs) + Math.max(...zs)) / 2 * sc;
const P = (p) => [ox + p.x * sc, oz + p.z * sc];
const poly = (pts, style) => {
const q = pts.map(P);
doc.lines(q.slice(1).map((b, k) => [b[0] - q[k][0], b[1] - q[k][1]]), q[0][0], q[0][1], [1, 1], style, true);
};
doc.setTextColor(...BLACK);
doc.setFontSize(11);
doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20);
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
c.regions.forEach(rg => poly(rg.poly.map(xz), 'S'));
doc.setDrawColor(...RED);
doc.setLineWidth(0.2);
c.beams.forEach(bm => poly(bm.poly.map(xz), 'S'));
doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE);
doc.setFontSize(7);
const bx0 = Math.min(...xs), bx1 = Math.max(...xs), bz0 = Math.min(...zs), bz1 = Math.max(...zs);
const [ax, ay] = P({ x: bx0, z: bz1 }), [bx, by] = P({ x: bx1, z: bz1 });
PdfSheet.dimLine(doc, ax, ay + 8, bx, by + 8);
doc.text(`${Math.round(bx1 - bx0)}`, (ax + bx) / 2, ay + 7, { align: 'center' });
const [cx0, cy0] = P({ x: bx1, z: bz0 }), [cx1, cy1] = P({ x: bx1, z: bz1 });
PdfSheet.dimLine(doc, cx0 + 8, cy0, cx1 + 8, cy1);
doc.text(`${Math.round(bz1 - bz0)}`, cx0 + 9.5, (cy0 + cy1) / 2, { angle: 90 });
const groups = new Map();
c.beams.forEach(bm => {
const key = `${bm.kind}|${Math.round(bm.length / 10) * 10}|${bm.angle}`;
const g = groups.get(key) || { kind: bm.kind, length: Math.round(bm.length / 10) * 10, angle: bm.angle, n: 0 };
g.n++;
groups.set(key, g);
});
const order = Object.keys(CAP_KINDS);
const rows = [...groups.values()].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.length - a.length)
.map(g => [CAP_KINDS[g.kind] || g.kind, String(g.length), String(g.angle).replace('.', ','), String(g.n)]);
const H = (p) => RoofModel.profileHeight(ur.shape.profile, p);
const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ',');
const hip = c.regions.find(rg => rg.key === 'h1');
const facts = [
['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`],
['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`],
['Helling dakvlakken', `${deg(c.slope)}°`],
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'],
['Balken', `${c.joistW} x ${c.joistH} mm`],
['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`],
['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`]
];
doc.setTextColor(...BLACK);
let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 });
PdfSheet.table(doc, 290, y + 6, [0, 32, 56, 78], ['Soort', 'Lengte (mm)', 'Hoek (°)', 'Aantal'], rows, { title: 'Stuklijst balken', fontSize: 7, rowH: 4.6, lastColW: 14, maxY: 240 });
doc.setFontSize(7);
doc.setTextColor(...GREY);
doc.text('Hoogtes vanaf bovenkant vloer. Lengte = langs de balk; hoek = helling van de balk (zaagsnede aan beide koppen). Grijs = dakvlakken, rood = houtwerk.', X0, 240);
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
} }
function drawTotalsSheet(doc, totals, sheetOpts) { function drawTotalsSheet(doc, totals, sheetOpts) {
@@ -197,22 +548,25 @@ window.HousePdf = (() => {
fmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%']); fmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%']);
PdfSheet.table(doc, 15, y, [0, 30, 55], ['Doorsnede', 'Lengte', 'Aantal'], purchaseRows, tableOpts('Inkoop wandbalken (alle wanden)')); PdfSheet.table(doc, 15, y, [0, 30, 55], ['Doorsnede', 'Lengte', 'Aantal'], purchaseRows, tableOpts('Inkoop wandbalken (alle wanden)'));
PdfSheet.table(doc, 120, 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'],
totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]),
Object.assign(tableOpts('Zaaglijst vuren (totaal)'), { maxY: 285 }));
y = 30; y = 30;
const isoRows = totals.isolatie.map(r => [r.label, fmt(r.m3, 3) + ' m3']); const isoRows = totals.isolatie.map(r => [r.label, fmt(r.m3, 3) + ' m3']);
isoRows.push(['Totaal glaswol', fmt(totals.isolatieM3, 3) + ' m3']); isoRows.push(['Totaal glaswol', fmt(totals.isolatieM3, 3) + ' m3']);
y = PdfSheet.table(doc, 215, y, [0, 50], ['Onderdeel', 'Inhoud'], isoRows, tableOpts('Isolatie (glaswol, spouwvullend)')); y = PdfSheet.table(doc, 120, y, [0, 50], ['Onderdeel', 'Inhoud'], isoRows, tableOpts('Isolatie (glaswol, spouwvullend)'));
y += 4; y += 4;
y = PdfSheet.table(doc, 215, y, [0, 58, 80, 102], ['Materiaal', 'Opp. (m2)', 'Vol. (m3)', 'Gewicht'], y = PdfSheet.table(doc, 120, y, [0, 58, 80, 102], ['Materiaal', 'Opp. (m2)', 'Vol. (m3)', 'Gewicht'],
totals.plaatwerk.map(p => [p.naam, fmt(p.m2, 2), p.m3 === null ? 'n.v.t.' : fmt(p.m3, 3), fmt(p.kg) + ' kg']), totals.plaatwerk.map(p => [p.naam, fmt(p.m2, 2), p.m3 === null ? 'n.v.t.' : fmt(p.m3, 3), fmt(p.kg) + ' kg']),
tableOpts('Plaatwerk')); tableOpts('Plaatwerk'));
y += 4; y += 4;
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, 215, 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;
@@ -225,32 +579,189 @@ window.HousePdf = (() => {
rows.push(['Boeideel-spots', e.spots + ' stuks']); rows.push(['Boeideel-spots', e.spots + ' stuks']);
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]); rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]); rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
PdfSheet.table(doc, 215, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting')); PdfSheet.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
} }
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
} }
function drawSawSheet(doc, totals, cost, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal');
doc.setTextColor(...BLACK);
doc.setFontSize(14);
doc.text('Zaaglijst', 15, 18);
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 16 });
const rows = totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]);
const perCol = 52, cols = [15, 100, 185];
for (let c = 0; c < cols.length && c * perCol < rows.length; c++) {
PdfSheet.table(doc, cols[c], 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'], rows.slice(c * perCol, (c + 1) * perCol),
tableOpts(c === 0 ? 'Zaaglijst vuren (totaal)' : 'Zaaglijst (vervolg)'));
}
if (rows.length > perCol * cols.length) {
doc.setFontSize(7);
doc.text(`+ ${rows.length - perCol * cols.length} regels (niet getoond)`, 15, 285);
}
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
const sw = totals.saw;
PdfSheet.table(doc, 290, 30, [0, 55], ['Onderdeel', 'Waarde'], [
['Zaagmeters', fmt(sw.meters, 1) + ' m'],
['Zaagtijd robot', CostModel.uur(cost.saw.robotSec / 3600) + ' uur'],
[`Zaagtijd handmatig (\u00d7${cost.settings.zagen.handFactor})`, CostModel.uur(cost.saw.handSec / 3600) + ' uur'],
['Zaagsneden', sw.cuts + ' st.'],
['Verschillende hoeken', String(sw.angles.length)]
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0\u00b0)' : `Schuin ${fmt(a.deg, 1)}\u00b0`, a.count + ' sneden'])), tableOpts('Zaagmachine (tijdelijk)'));
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
const COST_ROW_H = 3.4;
const COST_COLS = [{ x: 15, yMax: 278 }, { x: 150, yMax: 278 }, { x: 285, yMax: 246 }];
const COST_Y0 = 30;
const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '\u2026' : t);
function costDetailRows(cost) {
return cost.elements.flatMap(el => elementCostRows(el));
}
function elementCostRows(el) {
const rows = [];
if (el.lines.length) {
rows.push({ head: true, label: `${el.element} ${el.label}`, mat: CostModel.eur(el.mat), time: CostModel.uur(el.sec / 3600) });
el.lines.forEach(l => rows.push({
label: l.label, qty: `${fmt(l.qty, ['st.', 'sneden', 'sectie'].includes(l.unit) ? 0 : 2)} ${l.unit}`,
mat: l.mat ? CostModel.eur(l.mat, 2) : '', time: l.sec ? CostModel.uur(l.sec / 3600) : ''
}));
}
return rows;
}
function drawElementCost(doc, el, extraLabel) {
if (!el || !el.lines.length) return;
const x = 132, y = 236, yMax = 287;
const rows = elementCostRows(el);
rows[0] = Object.assign({}, rows[0], { label: `Kosten ${el.element}${extraLabel ? ' (' + extraLabel + ')' : ''}` });
rows.push({ head: true, label: 'Kostprijs', mat: CostModel.eur(el.kostprijs), time: '' });
const fit = Math.floor((yMax - y - 5) / COST_ROW_H);
let shown = rows;
if (rows.length > fit) {
shown = rows.slice(0, fit - 2).concat([{ label: `+ ${rows.length - fit + 1} regels: zie kostenblad detail`, mat: '', time: '' }, rows[rows.length - 1]]);
}
drawCostTable(doc, x, y, shown);
}
function paginateCostRows(rows) {
const pages = [];
let i = 0;
while (i < rows.length) {
const cols = COST_COLS.map(c => {
const n = Math.floor((c.yMax - COST_Y0 - 6) / COST_ROW_H);
const chunk = rows.slice(i, i + n);
i += chunk.length;
return chunk;
});
pages.push(cols);
}
return pages;
}
function drawCostTable(doc, x, y, rows) {
const { BLACK, GREY } = PdfSheet.COLORS;
doc.setFontSize(6.5);
doc.setTextColor(...GREY);
doc.text('Omschrijving', x, y); doc.text('Hoeveelheid', x + 84, y, { align: 'right' });
doc.text('Materiaal', x + 104, y, { align: 'right' }); doc.text('Manuur', x + 124, y, { align: 'right' });
doc.setDrawColor(...GREY); doc.setLineWidth(0.15);
doc.line(x, y + 1.5, x + 126, y + 1.5);
y += 5;
rows.forEach(r => {
doc.setTextColor(...BLACK);
doc.setFont('helvetica', r.head ? 'bold' : 'normal');
doc.text(clip(r.label, r.head ? 60 : 52), r.head ? x : x + 2, y);
if (r.qty) doc.text(r.qty, x + 84, y, { align: 'right' });
doc.text(r.mat, x + 104, y, { align: 'right' });
doc.text(r.time, x + 124, y, { align: 'right' });
y += COST_ROW_H;
});
doc.setFont('helvetica', 'normal');
}
function drawCostSheet(doc, cost, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
const s = cost.summary, S = cost.settings, eur = (n) => CostModel.eur(n);
doc.setFont('helvetica', 'normal');
doc.setTextColor(...BLACK);
doc.setFontSize(14);
doc.text('Kostenraming (indicatief)', 15, 18);
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 24 });
let y = PdfSheet.table(doc, 15, 30, [0, 82], ['Post', 'Bedrag'],
s.rows.map(r => [r.label, eur(r.amount)]).concat([['Totaal incl. btw', eur(s.inclBtw)]]), tableOpts('Samenvatting'));
y = PdfSheet.table(doc, 15, y + 6, [0, 82], ['Groep', 'Materiaal'],
cost.categories.filter(r => r.mat > 0).map(r => [r.name, eur(r.mat)]), tableOpts('Materiaal per groep'));
PdfSheet.table(doc, 15, y + 6, [0, 82], ['Werkzaamheid', 'Manuur'],
cost.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600)])
.concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect)], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa)], ['Totaal', CostModel.uur(s.hours)]]),
tableOpts('Arbeid per werkzaamheid'));
PdfSheet.table(doc, 130, 30, [0, 72, 97, 122], ['Element / onderdeel', 'Materiaal', 'Manuur', 'Kostprijs*'],
cost.elements.filter(el => el.lines.length).map(el => [clip(`${el.element} ${el.label}`, 48), eur(el.mat), CostModel.uur(el.sec / 3600), eur(el.kostprijs)]),
Object.assign(tableOpts('Per element'), { maxY: 278 }));
PdfSheet.table(doc, 290, 30, [0, 40, 65, 88], ['Unit', 'Materiaal', 'Manuur', 'Kostprijs*'],
cost.units.map(u => [u.id ? `Unit ${u.id}` : 'Woning-breed', eur(u.mat), CostModel.uur(u.sec / 3600), eur(u.kostprijs)]), tableOpts('Per unit'));
doc.setFontSize(6.5);
doc.text('* Kostprijs: materiaal incl. verlies en klein materiaal + arbeid (manuren incl. bezetting, indirect en QA) + afschrijving/huisvesting', 290, 236);
doc.text(' (+ transport per unit, kraan bij woning-breed); zonder algemene kosten, marge en btw.', 290, 240);
doc.text('Schattingen op basis van Instellingen > Kosten & tijden; bedragen excl. btw tenzij vermeld.', 290, 244);
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
function drawCostDetailSheet(doc, cols, sheetOpts) {
doc.setFont('helvetica', 'normal');
doc.setTextColor(...PdfSheet.COLORS.BLACK);
doc.setFontSize(14);
doc.text('Kostenraming per element (detail)', 15, 18);
cols.forEach((rows, i) => { if (rows.length) drawCostTable(doc, COST_COLS[i].x, COST_Y0, rows); });
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
function generate(state) { function generate(state) {
const parts = HouseModel.computeParts(state, { const parts = HouseModel.computeParts(state, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials() wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
}); });
const totals = HouseModel.computeTotals(parts); const totals = HouseModel.computeTotals(parts);
const sheets = sheetList(parts); const sheets = sheetList(parts);
const cost = CostModel.compute(parts, totals);
const costOf = (element) => cost.elements.find(el => el.element === element);
const drawWallWithCost = (sheet, opts) => {
WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [],
overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts));
drawElementCost(doc, costOf(sheet.element));
};
const detailPages = paginateCostRows(costDetailRows(cost));
sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' });
detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols }));
const project = state.project; const project = state.project;
const doc = PdfSheet.createDoc(); const doc = PdfSheet.createDoc();
sheets.forEach((sheet, i) => { sheets.forEach((sheet, i) => {
if (i > 0) doc.addPage('a3', 'landscape'); if (i > 0) doc.addPage('a3', 'landscape');
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` }; const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts); if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts); else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true }, opts)); else if (sheet.kind === 'floor') {
else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts)); FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, Object.assign({ overlay: ElektraPdf.floorOverlay(parts.elektra, sheet.unit) }, opts));
drawElementCost(doc, costOf(sheet.element));
} else if (sheet.kind === 'roof') {
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));
if (/-D1$/.test(sheet.element)) drawElementCost(doc, costOf(sheet.element.replace(/\d+$/, '')), 'alle dakvlakken');
}
else if (sheet.kind === 'cap') drawCapSheet(doc, sheet.unit, opts);
else if (sheet.kind === 'wall') drawWallWithCost(sheet, opts);
else if (sheet.kind === 'saw') drawSawSheet(doc, totals, cost, opts);
else if (sheet.kind === 'elektra') ElektraPdf.drawSheet(doc, parts.elektra, opts);
else if (sheet.kind === 'cost') drawCostSheet(doc, cost, opts);
else if (sheet.kind === 'costdetail') drawCostDetailSheet(doc, sheet.cols, opts);
else drawTotalsSheet(doc, totals, opts); else drawTotalsSheet(doc, totals, opts);
}); });
return doc; return doc;
} }
return { generate, sheetList }; return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS };
})(); })();
+1897 -463
View File
File diff suppressed because it is too large Load Diff
+531
View File
@@ -0,0 +1,531 @@
/*!
* 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', spoel: true, spoelbak: 0.6, kookplaat: false, kookplaatPlek: 0.2, hogeKast: 'links', vaatwasser: true, bovenkasten: false },
hint: 'Onderkasten met werkblad, spoelbak en eventueel kookplaat (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) {
if (f.opts.spoel !== false) {
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);
}
if (f.opts.kookplaat) {
const { hx, hw, hd } = hobOf(f, K, island);
boxAt(g, MAT.kookplaat(), hx - hw / 2, hx + hw / 2, top, top + 4, -hd / 2, hd / 2);
hobZones(hw, hd).forEach(z => ecyl(g, MAT.ring(), hx + 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 plekX(K, v, def) {
const t = Math.max(0, Math.min(1, v === undefined ? def : v));
return Math.max(K.x0 + 400, Math.min(K.x1 - 400, K.x0 + t * (K.x1 - K.x0)));
}
const spoelX = (f, K) => plekX(K, f.opts.spoelbak, 0.6);
// Kookeiland: kookplaat in het midden; keukenblok: op kookplaatPlek.
const hobOf = (f, K, island) => ({
hx: island ? 0 : plekX(K, f.opts.kookplaatPlek, 0.2), hw: Math.min(780, K.x1 - K.x0 - 100), hd: Math.min(520, f.d - 80)
});
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 && f.opts.spoel !== false) {
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'));
}
if (f.opts.kookplaat) {
const { hx, hw, hd } = hobOf(f, K, island);
out.push(rectP(hx - hw / 2, hx + hw / 2, -hd / 2, hd / 2, 'thin'));
hobZones(hw, hd).forEach(z => out.push(circP(hx + 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 };
})();
+182 -132
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,47 +127,42 @@ 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))));
return; return;
} }
cfg.openings.filter(op => op.y - sW <= H && op.y + op.h + sW >= H).forEach(op => { cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + sW >= H).forEach(op => {
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW); const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
blocked.push([Math.min(a, b), Math.max(a, b)]); blocked.push([Math.min(a, b), Math.max(a, b)]);
}); });
}); });
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,44 +173,41 @@ 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));
} }
// Binnenspots per unit. units = HouseModel-parts.units (rect, walls, roof.ceilingAt), function unitInnerRect(u, interior) {
// interior = parts.interior (binnenwanden met place). Geeft const T = {};
// [{ unitId, x, z, y, floorY }] (mm, y = onderkant plafond, floorY = loopvlak). u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; });
const r = u.rect;
interior.filter(iw => iw.seamUnit === u.id).forEach(iw => {
const p = iw.place, horiz = Math.abs(Math.sin(p.rotY)) < 0.5;
const [side, d] = horiz
? (Math.abs(p.cz - r.z0) < Math.abs(p.cz - r.z1) ? ['voor', p.cz - r.z0] : ['achter', r.z1 - p.cz])
: (Math.abs(p.cx - r.x0) < Math.abs(p.cx - r.x1) ? ['links', p.cx - r.x0] : ['rechts', r.x1 - p.cx]);
T[side] = Math.max(T[side] || 0, d + p.thickness / 2);
});
return { x0: r.x0 + (T.links || 0), x1: r.x1 - (T.rechts || 0), z0: r.z0 + (T.voor || 0), z1: r.z1 - (T.achter || 0) };
}
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 [];
const out = []; const out = [];
units.forEach(u => { units.forEach(u => {
const T = {}; const off = s.indoorOffset, inner = unitInnerRect(u, interior);
u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; });
const r = u.rect, off = s.indoorOffset;
// Naadwanddelen van deze unit staan binnen de rect tegen de naad: het
// raster begint dan vanaf hun binnenzijde (net als bij een buitenwand).
const own = interior.filter(iw => !iw.seamUnit || iw.seamUnit === u.id); const own = interior.filter(iw => !iw.seamUnit || iw.seamUnit === u.id);
own.filter(iw => iw.seamUnit).forEach(iw => { const xs = gridPositions(inner.x0 + off, inner.x1 - off, s.indoorMin, s.indoorMax);
const p = iw.place, horiz = Math.abs(Math.sin(p.rotY)) < 0.5; const zs = gridPositions(inner.z0 + off, inner.z1 - off, s.indoorMin, s.indoorMax);
const [side, d] = horiz
? (Math.abs(p.cz - r.z0) < Math.abs(p.cz - r.z1) ? ['voor', p.cz - r.z0] : ['achter', r.z1 - p.cz])
: (Math.abs(p.cx - r.x0) < Math.abs(p.cx - r.x1) ? ['links', p.cx - r.x0] : ['rechts', r.x1 - p.cx]);
T[side] = Math.max(T[side] || 0, d + p.thickness / 2);
});
const xs = gridPositions(r.x0 + (T.links || 0) + off, r.x1 - (T.rechts || 0) - off, s.indoorMin, s.indoorMax);
const zs = gridPositions(r.z0 + (T.voor || 0) + off, r.z1 - (T.achter || 0) - off, s.indoorMin, s.indoorMax);
xs.forEach(x => zs.forEach(z => { xs.forEach(x => zs.forEach(z => {
const tooClose = own.some(iw => { const tooClose = own.some(iw => {
const half = iw.place.width / 2, c = Math.cos(iw.place.rotY), sn = Math.sin(iw.place.rotY); const half = iw.place.width / 2, c = Math.cos(iw.place.rotY), sn = Math.sin(iw.place.rotY);
@@ -286,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 }),
@@ -295,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();
@@ -310,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);
}; };
@@ -323,56 +286,143 @@ 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: const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
// - buitenlampen en boeideel-spots zijn half hun bundelhoek naar buiten const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 };
// 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).
const OUTDOOR = { lamp: 2.0, spot: 1.6, angle: 0.5 };
const INDOOR = { intensity: 4.0, angle: 1.0, reach: 1.12, decay: 0.6 };
const DAY_OUTDOOR_FACTOR = 0.35; const DAY_OUTDOOR_FACTOR = 0.35;
function buildLights3D(lamps, spots, spotY, indoor, o) { function buildLights3D(lamps, spots, spotY, indoor, o) {
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) => { const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay);
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, 0.6, 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); 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); 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); 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); 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));
return group;
}
// Nep-lichtvlekken op de gevel: echte spots zonder schaduw zouden door de muur naar binnen schijnen.
const WASH = { color: 0xffd9a0, spot: 0.55, lamp: 0.45, spread: 0.5, lampReach: 1800 };
let washTexture = null;
function getWashTexture() {
if (washTexture) return washTexture;
const W = 128, H = 256;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
for (let j = 0; j < H; j++) {
const t = j / (H - 1);
const half = 0.12 + 0.88 * Math.pow(t, 0.6);
const fade = Math.min(1, t * 12) * Math.pow(1 - t, 1.6);
for (let i = 0; i < W; i++) {
const x = Math.abs((i + 0.5) / W * 2 - 1) / half;
const v = x >= 1 ? 0 : fade * Math.pow(1 - x * x, 2);
const k = (j * W + i) * 4;
img.data[k] = img.data[k + 1] = img.data[k + 2] = Math.round(255 * v);
img.data[k + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
washTexture = new THREE.CanvasTexture(canvas);
return washTexture;
}
function washMesh(widthM, heightM, opacity, up) {
const mat = new THREE.MeshBasicMaterial({
map: getWashTexture(), color: WASH.color, transparent: true, opacity,
blending: THREE.AdditiveBlending, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2
});
const geo = new THREE.PlaneGeometry(widthM, heightM);
geo.translate(0, up ? heightM / 2 : -heightM / 2, 0);
if (up) {
const uv = geo.attributes.uv;
for (let i = 0; i < uv.count; i++) uv.setY(i, 1 - uv.getY(i));
}
const m = new THREE.Mesh(geo, mat);
m.renderOrder = 2;
return m;
}
function buildWash3D(lamps, spots, spotY, walls, groundY, fo) {
const toM = 0.001;
const group = new THREE.Group();
const facadeBehind = (q, n) => {
let best = null;
walls.forEach(p => {
const pl = p.place;
if (pl.normal.x * n.x + pl.normal.y * n.z < 0.9) return;
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
const dx = q.x - pl.cx, dz = q.z - pl.cz;
const lx = dx * c - dz * sn, lz = dx * sn + dz * c;
const face = pl.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
const d = lz - face;
if (Math.abs(lx) > pl.width / 2 + 50 || d < -5 || d > 1500) return;
if (!best || d < best.d) best = { d, piece: p, lx, face };
});
return best;
};
const place = (mesh, pl, lx, lz, yMM, ref) => {
mesh.userData.ref = ref;
mesh.userData.wash = true;
const w = UnitModel.localToWorld(pl, lx, lz);
mesh.position.set(w.x * toM, yMM * toM, w.z * toM);
mesh.rotation.y = pl.rotY;
group.add(mesh);
};
(spots || []).forEach(sp => {
const n = sp.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
const nn = { x: n.x / len, z: n.z / len };
if (Math.abs(nn.x) > 0.1 && Math.abs(nn.z) > 0.1) return;
const top = (sp.y !== undefined ? sp.y : spotY) - 30;
const hit = facadeBehind(sp, nn);
if (!hit || top - groundY < 300) return;
const H = (top - groundY) * toM;
place(washMesh(2 * (0.1 + H * WASH.spread), H, WASH.spot * fo), hit.piece.place, hit.lx, hit.face + 6, top, sp);
});
(lamps || []).forEach(l => {
const n = { x: l.normal.x, z: l.normal.z };
const hit = facadeBehind({ x: l.x + n.x * 20, z: l.z + n.z * 20 }, n);
if (!hit) return;
const pl = hit.piece.place;
if (l.direction !== 'up') {
const H = Math.min(WASH.lampReach, l.y - 90 - groundY) * toM;
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo), pl, hit.lx, hit.face + 6, l.y - 90, l);
}
if (l.direction !== 'down') {
const wallTop = pl.floorTop + WallModel.topLine(hit.piece.config).maxY + (hit.piece.skinAbove || 0);
const H = Math.min(WASH.lampReach, wallTop - l.y - 90) * toM;
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo, true), pl, hit.lx, hit.face + 6, l.y + 90, l);
}
}); });
return group; return group;
} }
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, createMaterials, build3D, buildLights3D }; return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D };
})(); })();
+502 -176
View File
@@ -4,56 +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 middenpunt (nok) zijn de kopgevels, het dak
// volgt hun daklijn (plat, lessenaar of zadel). 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 DENSITY_DAKLEER_KGM2 = 1.4; // EPDM ~1,4 kg/m2 const BESCHOT_MM = 18;
const TRIM_THICK_MM = 25; // daktrim-profiel, uitstekend buiten de dakrand const DENSITY_DAKLEER_KGM2 = 1.4;
const EDGE_ABOVE_MM = 80; // bovenkant boeideel/daktrim ligt zoveel boven het dakleer const TRIM_THICK_MM = 25;
const SPOT_RATIO = 0.4; // spots op 40% van de overstek, gemeten vanaf de gevel const EDGE_ABOVE_MM = 80;
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 },
@@ -62,20 +32,31 @@ 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 const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } };
// schuiven vanzelf mee omhoog. Texturen: assets/textures/dak/, naadloos; const FELS = { thickMM: 25, kgM2: 6.5, seamMM: 500, colors: { zink: ['Felsplaat zink', '#8e9497'], antraciet: ['Felsplaat antraciet', '#3d4146'], zwart: ['Felsplaat zwart', '#1f2022'] } };
// 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).
const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'] };
const COVERINGS = Object.assign({ const COVERINGS = Object.assign({
dakleer: { label: 'Dakleer (EPDM)', 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 => ({
['riet_' + nr]: { label: 'Riet ' + nr, thickMM: RIET.thickMM, kgM2: RIET.kgM2, file: `assets/textures/dak/riet_${nr}.jpg`, tileMM: RIET.tileMM, sloped: true } ['riet_' + nr]: { label: 'Riet ' + nr, kind: 'riet', color: RIET.colors[nr] || '#8a7560', thickMM: RIET.thickMM, kgM2: RIET.kgM2, file: `assets/textures/dak/riet_${nr}.jpg`, tileMM: RIET.tileMM, sloped: true }
})), ...Object.entries(FELS.colors).map(([k, [label, color]]) => ({
['fels_' + k]: { label, kind: 'fels', color, thickMM: FELS.thickMM, kgM2: FELS.kgM2, seamMM: FELS.seamMM, sloped: true }
}))); })));
// Werkelijke bedekking voor een dakvorm (riet niet op een plat dak). const coverKind = (key) => (COVERINGS[key] || COVERINGS.dakleer).kind;
function coverSpecs(covering, areaM2) {
const c = COVERINGS[covering] || COVERINGS.dakleer, kg = areaM2 * c.kgM2, k = c.kind;
return {
dakleerM2: k === 'dakleer' ? areaM2 : 0, dakleerKg: k === 'dakleer' ? kg : 0,
rietM2: k === 'riet' ? areaM2 : 0, rietKg: k === 'riet' ? kg : 0,
felsM2: k === 'fels' ? areaM2 : 0, felsKg: k === 'fels' ? kg : 0,
coverKg: kg
};
}
function coveringThumbUrl(key, px) {
const c = COVERINGS[key];
return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null;
}
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';
@@ -83,8 +64,13 @@ window.RoofModel = (() => {
const coveringMaterials = {}; const coveringMaterials = {};
function coveringMaterial(key) { function coveringMaterial(key) {
const c = COVERINGS[key]; const c = COVERINGS[key];
if (!c || !c.file) return null; if (!c) return null;
if (coveringMaterials[key]) return coveringMaterials[key]; if (coveringMaterials[key]) return coveringMaterials[key];
if (c.kind === 'fels') {
coveringMaterials[key] = new THREE.MeshStandardMaterial({ color: c.color, roughness: 0.4, metalness: 0.55, side: THREE.DoubleSide });
return coveringMaterials[key];
}
if (!c.file) return null;
const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 }); const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 });
if (typeof document !== 'undefined' && THREE.TextureLoader) { if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(c.file, (tex) => { new THREE.TextureLoader().load(c.file, (tex) => {
@@ -102,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 };
@@ -111,9 +95,8 @@ 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 + bedekking (mm, haaks op het dakvlak).
function thickness(calcData, covering) { function thickness(calcData, covering) {
return calcData.joistH + calcData.underlayThick + (COVERINGS[covering] || COVERINGS.dakleer).thickMM; return calcData.joistH + calcData.underlayThick + BESCHOT_MM + (COVERINGS[covering] || COVERINGS.dakleer).thickMM;
} }
function createMaterials() { function createMaterials() {
@@ -121,28 +104,27 @@ 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
// komt het dakleer er bovenop.
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();
const deckMats = opts.ceilingMaterial ? Object.assign({}, m, { osbMaterial: opts.ceilingMaterial }) : m;
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
}); });
const deckH = (calcData.joistH + calcData.underlayThick) * toM; const deckH = (calcData.joistH + calcData.underlayThick) * toM;
deck.scale.y = -1; deck.scale.y = -1;
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);
@@ -155,70 +137,109 @@ 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 leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: cov.thickMM * toM, bevelEnabled: false }), covMat); const bT = BESCHOT_MM * toM;
leer.rotation.x = Math.PI / 2; if (opts.osbVisible !== false) {
leer.position.y = deckH + cov.thickMM * toM; const beschot = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: bT, bevelEnabled: false }), m.osbMaterial);
leer.castShadow = cov.thickMM > DAKLEER_MM; beschot.rotation.x = Math.PI / 2;
leer.visible = opts.dakleerVisible !== false; beschot.position.y = deckH + bT;
group.add(leer); beschot.castShadow = true;
group.add(beschot);
}
if (opts.dakleerVisible !== false) {
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);
leer.rotation.x = Math.PI / 2;
leer.position.y = deckH + bT + slabT;
leer.castShadow = cov.thickMM > DAKLEER_MM;
group.add(leer);
if (cov.kind === 'fels') {
const n = Math.max(0, Math.floor(calcData.width / cov.seamMM));
const x0 = -(n * cov.seamMM) / 2;
for (let i = 0; i <= n; i++) {
const seam = new THREE.Mesh(new THREE.BoxGeometry(0.01, cov.thickMM * toM, dM), covMat);
seam.position.set((x0 + i * cov.seamMM) * toM, deckH + bT + cov.thickMM * toM / 2, 0);
seam.castShadow = true;
group.add(seam);
}
}
}
return group; return group;
} }
// Specificaties: die van het dek (FloorModel) + de bedekking (dakleer of
// bijv. riet: coveringM2/Kg, dakleer dan 0).
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 isLeer = !COVERINGS[covering] || covering === 'dakleer'; const cs = coverSpecs(covering, areaM2);
const kg = areaM2 * (COVERINGS[covering] || COVERINGS.dakleer).kgM2; const bVol = areaM2 * BESCHOT_MM * 1e-3, bKg = bVol * DENSITY_OSB_KGM3;
return Object.assign({}, deck, { return Object.assign({}, deck, cs, {
dakleerM2: isLeer ? areaM2 : 0, dakleerKg: isLeer ? kg : 0, beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bKg,
rietM2: isLeer ? 0 : areaM2, rietKg: isLeer ? 0 : kg, totalKg: deck.totalKg + bKg + cs.coverKg
totalKg: deck.totalKg + kg
}); });
} }
// ------------------------------------------------------------ // 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 };
if (w.heightMid > 0) pts.push({ p: c, h: Math.max(w.heightMid, w.heightLeft, w.heightRight) }); const at = (x) => c - s.dir * wl.width / 2 + s.dir * (x - ref.u0);
const pts = [{ p: at(0), h: w.heightLeft }, { p: at(ref.w), h: w.heightRight }];
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', axis: 'x'|'z', profile }:
// de hoogte van de dakonderkant (vanaf het loopvlak) hangt alleen af van
// de positie langs axis (profileHeight). 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.
function computeShape(unitState, layout) { function computeShape(unitState, layout) {
for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) { for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) {
const profile = wallProfile(unitState, layout, side); const profile = wallProfile(unitState, layout, side);
if (profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) { if (!profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) continue;
return { type: profile.length > 2 ? 'zadel' : 'lessenaar', axis, profile }; return shapeFromProfile(axis, profile);
}
} }
const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws))); const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws)));
return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] }; return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] };
} }
function shapeFromProfile(axis, profile) {
if (profile.length === 4 && Math.abs(profile[1].h - profile[2].h) < 0.5) {
const [L, A, B, R] = profile, sA = (A.h - L.h) / (A.p - L.p), sB = (R.h - B.h) / (R.p - B.p);
const pr = Math.abs(sA - sB) > 1e-9 ? (B.h - sB * B.p - A.h + sA * A.p) / (sA - sB) : (A.p + B.p) / 2;
const ridge = { p: pr, h: A.h + sA * (pr - A.p) };
if (sA > 0 && sB < 0 && pr > A.p && pr < B.p) {
return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } };
}
}
const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel';
return { type, axis, profile };
}
// Hoogte van het profiel op positie p; buiten de wand loopt de laatste // Helling wolfseinden: die van het dak, maar minstens zo steil dat ze de nok halen vóór het midden.
// helling door (overstek, boeideel). 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 };
}
// De daklijn is bol, dus het profiel is het minimum van zijn (doorgetrokken) stukken.
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;
@@ -227,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 => {
@@ -236,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;
@@ -255,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 };
@@ -268,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);
@@ -286,41 +389,200 @@ 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
}); });
}); });
const planeAt = (q) => planes.find((pl, i) => q < pl.p1 || i === planes.length - 1); // Mansardeknik: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik,
const undersideAt = (x, z) => H(alongX ? x : z); // bovenkant op het snijpunt van de bovenvlakken.
const topAt = (x, z) => { planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; });
planes.slice(0, -1).forEach((pi, i) => {
const pj = planes[i + 1], ai = Math.tan(pi.theta), aj = Math.tan(pj.theta);
if (Math.abs(ai - aj) < 1e-4) return;
const dp = (pj.vThick - pi.vThick) / (ai - aj);
pi.miter1 = dp; pj.miter0 = dp;
});
const mainUnder = (x, z) => H(alongX ? x : z);
const mainTop = (x, z) => {
const q = alongX ? x : z; const q = alongX ? x : z;
const onKink = planes.filter(pl => Math.abs(q - pl.p0) < 0.5 || Math.abs(q - pl.p1) < 0.5); return Math.min(...planes.map(pl => pl.h0 + (q - pl.p0) * Math.tan(pl.theta) + pl.vThick));
const t = onKink.length > 1 ? Math.max(...onKink.map(pl => pl.vThick)) : planeAt(q).vThick;
return H(q) + t;
}; };
let cap = null, undersideAt = mainUnder, topAt = mainTop, outPlanes = planes;
const pA = shape.type === 'wolf' && shape.hip ? shape.profile[1].p : 0, pB = pA && shape.profile[3].p;
if (shape.type === 'wolf' && shape.hip && Math.min(pB, a1) - Math.max(pA, a0) > 1) cap = computeCap(roofState, shape, rect, alongX, covering);
if (cap) {
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))));
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, specs: sumSpecs(planes.map(pl => pl.specs)), shape, covering, planes: outPlanes, specs, ridgeBeam,
undersideAt, topAt, undersideAt, topAt, cap, rect, alongX,
ridges: kinks.map(value => ({ axis: shape.axis, value })), ridges: kinks.map(value => ({ axis: shape.axis, value })),
maxTop: Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick)) maxTop: cap ? cap.maxTop : Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick))
}; };
} }
// 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek, const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel
// Y = 0 op het loopvlak): per dakvlak build3D, zo vervormd dat de function computeCap(roofState, shape, rect, alongX, covering) {
// onderkant van het plafond precies op de daklijn van de wanden ligt. const H = (p) => profileHeight(shape.profile, p);
// Geen kanteling maar een afschuiving (lokaal +Z = oplopende as): const uq0 = alongX ? rect.z0 : rect.x0, uq1 = alongX ? rect.z1 : rect.x1;
// plan = z·cos θ, Y = y/cos θ + z·sin θ const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1;
// De onderkant ligt dan op de helling, de dikte haaks op de helling blijft const hq = shape.hip.q0 !== undefined ? [shape.hip.q0, shape.hip.q1] : [uq0, uq1];
// gelijk (verticaal = dikte/cos θ, zoals vThick), de lengte langs de const { pA, pR, pB, hipH, top, sA, sB, tH, inset, q0, q1, qM } = hipGeom(shape, hq[0], hq[1]);
// helling ook (balklengtes), en de kopse kanten staan loodrecht (zoals een const fp = roofState.deck.floor;
// loodrechte zaagsnede aan sporen). Bij kantelen zouden de kopse kanten const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22;
// haaks op de helling staan en ligt de bovenkant aan de hoge kant const cov = COVERINGS[covering] || COVERINGS.dakleer;
// dikte·sin θ terug van de dakrand. const cp0 = Math.max(pA, a0), cp1 = Math.min(pB, a1), cq0 = Math.max(q0, uq0), cq1 = Math.min(q1, uq1);
const clipBox = (poly) => [(p) => p.x - cp0, (p) => cp1 - p.x, (p) => p.z - cq0, (p) => cq1 - p.z]
.reduce((pl, f) => pl.length >= 3 ? clipPoly(pl, f) : pl, poly);
const plane = (key, slope, f, along) => {
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 };
};
const fns = [
plane('m1', sA, (p) => H(pA) + (p.x - pA) * sA, 'p'),
plane('m2', sB, (p) => H(pB) + (pB - p.x) * sB, 'p'),
plane('h1', tH, (p) => hipH + (p.z - q0) * tH, 'q'),
plane('h2', tH, (p) => hipH + (q1 - p.z) * tH, 'q')
];
const band = [{ x: pA, z: q0 }, { x: pB, z: q0 }, { x: pB, z: q1 }, { x: pA, z: q1 }];
const regions = fns.map((fi, i) => {
let poly = band;
fns.forEach((fj, j) => { if (j !== i && poly.length >= 3) poly = clipPoly(poly, (p) => fj.f(p) - fi.f(p)); });
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 });
}).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;
const cutLine = (poly, fixed, v) => {
const ts = [];
poly.forEach((a, k) => {
const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed];
if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return;
const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x';
ts.push(a[o] + (b[o] - a[o]) * t);
});
return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null;
};
const hw = joistW / 2;
const rectAround = (A, B) => {
const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw;
return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }];
};
const awayFrom = (m, from) => {
const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L;
const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz;
const sgn = d(from) >= 0 ? 1 : -1;
return (p) => sgn * d(p) - hw;
};
const distToSeg = (p, m) => {
const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz;
const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2));
return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t));
};
const beams = [];
const addBeam = (kind, rg, A, B, clips, lineHeight) => {
if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null;
let poly = rectAround(A, B);
(clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); });
if (poly.length < 3) return null;
const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz);
const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV;
const yAt = lineHeight
? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L)))
: (p) => rg.under(p) + rg.tV;
const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L);
const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L;
if (plan < 50) return null;
const bm = {
kind, A, B, poly, yAt, jV: rg.jV, slope,
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
};
beams.push(bm);
return bm;
};
const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0];
const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0];
const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }];
const hasRidge = ends[1].z - ends[0].z >= 100;
const kops = [q0 + hw, q1 - hw].map(qv => addBeam('kopbalk', hOf(qv), { x: pA, z: qv }, { x: pB, z: qv }, [
(p) => p.z - q0 - 0.01, (p) => q1 - p.z - 0.01])).filter(Boolean);
const hips = band.map(c => {
const e = c.z < qM ? ends[0] : ends[1], sp = Math.sign(pR - c.x), sq = Math.sign(qM - c.z);
const clips = [(p) => sp * (pR - p.x)];
if (!hasRidge) clips.push((p) => sq * (qM - p.z));
clips.push((p) => sq * (c.z + sq * joistW - p.z) * -1);
return addBeam('hoekkeper', mOf(c.x), c, e, clips, true);
}).filter(Boolean);
let ridgeBeam = null;
if (hasRidge) {
const mid = { x: pR, z: qM };
ridgeBeam = addBeam('nokbalk', mOf(pR - 1), ends[0], ends[1], hips.map(h => awayFrom(h, mid)), true);
// 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 spread = (len) => FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: joistW, joistHeight: joistH, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: underlay }).joists.map(x => x + len / 2);
const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(pB - pA).map(v => pA + v);
regions.forEach(rg => {
const fixed = rg.along === 'p' ? 'z' : 'x';
(rg.along === 'p' ? qs : ps).forEach(v => {
const span = cutLine(rg.poly, fixed, v);
if (!span) return;
const pt = (t) => fixed === 'z' ? { x: t, z: v } : { x: v, z: t };
const A = pt(span[0]), B = pt(span[1]), mid = pt((span[0] + span[1]) / 2);
const clips = members.filter(m => distToSeg(A, m) < hw + 1 || distToSeg(B, m) < hw + 1).map(m => awayFrom(m, mid));
addBeam('spoor', rg, A, B, clips);
});
});
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 woodM3 = ownBeams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0);
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 specs = Object.assign({
joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3,
osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3,
beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bVol * DENSITY_OSB_KGM3,
voidM3: Math.max(0, areaM2 * Hh - woodM3),
totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg
}, cs);
const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick))));
return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop };
}
// Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ.
// Zo blijven dikte haaks op de helling en lengte gelijk, en staan de kopse kanten loodrecht (zoals de zaagsnede aan sporen).
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.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);
@@ -333,19 +595,91 @@ window.RoofModel = (() => {
0, 1 / c, sn, 0, 0, 1 / c, sn, 0,
0, 0, c, 0, 0, 0, c, 0,
0, 0, 0, 1); 0, 0, 0, 1);
shear.add(build3D(pl.data.calcData, pl.data.resolvedOpenings, Object.assign({ covering: unitRoof.covering }, opts))); const deck = build3D(pl.data.calcData, pl.data.resolvedOpenings, Object.assign({ covering: unitRoof.covering }, opts));
if (pl.miter0 || pl.miter1) miterDeck(deck, pl, c);
shear.add(deck);
g.add(shear); g.add(shear);
group.add(g); group.add(g);
}); });
return group; return group;
} }
// ------------------------------------------------------------ function miterDeck(deck, pl, cos) {
// Dakrand over de hele woning const toM = 0.001, cd = pl.data.calcData;
// ------------------------------------------------------------ const L = cd.depth * toM, T = pl.vThick * cos * toM;
const d0 = pl.miter0 * toM / cos, d1 = pl.miter1 * toM / cos;
const v = new THREE.Vector3(), rel = new THREE.Matrix4(), inv = new THREE.Matrix4();
deck.updateMatrixWorld(true);
const base = new THREE.Matrix4().copy(deck.matrixWorld).invert();
deck.traverse(o => {
const pos = o.geometry && o.geometry.attributes && o.geometry.attributes.position;
if (!pos) return;
rel.multiplyMatrices(base, o.matrixWorld);
inv.copy(rel).invert();
for (let k = 0; k < pos.count; k++) {
v.fromBufferAttribute(pos, k).applyMatrix4(rel);
const t = Math.max(0, Math.min(1, v.y / T));
if (d1 && v.z > L / 2 - 1e-4) v.z = L / 2 + d1 * t;
else if (d0 && v.z < -L / 2 + 1e-4) v.z = -L / 2 + d0 * t;
else continue;
v.applyMatrix4(inv);
pos.setXYZ(k, v.x, v.y, v.z);
}
pos.needsUpdate = true;
o.geometry.computeBoundingSphere();
if (o.geometry.attributes.normal) o.geometry.computeVertexNormals();
});
}
function capRegions(unitRoof) { return unitRoof.cap ? unitRoof.cap.regions : []; }
function buildCap3D(unitRoof, opts) {
const toM = 0.001, m = opts.materials, c = unitRoof.cap, r = unitRoof.rect;
const xc = (r.x0 + r.x1) / 2, zc = (r.z0 + r.z1) / 2;
const group = new THREE.Group();
const osbOn = opts.osbVisible !== false, covOn = opts.dakleerVisible !== false;
const covMat = coveringMaterial(unitRoof.covering) || m.dakleerMaterial;
const P = (pt, y) => {
const x = unitRoof.alongX ? pt.x : pt.z, z = unitRoof.alongX ? pt.z : pt.x;
return [(x - xc) * toM, y * toM, (z - zc) * toM];
};
function slab(poly, bottom, h, topMat, sideMat, uvOf) {
const n = poly.length, pos = [], uv = [];
const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt)));
const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0];
const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); };
const up = (T[1][0] - T[0][0]) * (T[2][2] - T[0][2]) - (T[1][2] - T[0][2]) * (T[2][0] - T[0][0]) < 0;
for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]);
const topCount = pos.length / 3;
for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2);
for (let k = 0; k < n; k++) {
const j = (k + 1) % n;
up ? (tri(T[k], B[k], B[j], z2, z2, z2), tri(T[k], B[j], T[j], z2, z2, z2))
: (tri(T[k], B[j], B[k], z2, z2, z2), tri(T[k], T[j], B[j], z2, z2, z2));
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
geo.addGroup(0, topCount, 0);
geo.addGroup(topCount, pos.length / 3 - topCount, 1);
geo.computeVertexNormals();
const mesh = new THREE.Mesh(geo, [topMat, sideMat]);
mesh.castShadow = true;
mesh.receiveShadow = true;
group.add(mesh);
}
c.regions.forEach(rg => {
if (osbOn) slab(rg.poly, rg.under, rg.tV, 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);
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]);
});
c.beams.forEach(bm => {
if (osbOn && bm.kind !== 'kopbalk') return;
slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial);
});
return group;
}
// 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 }));
@@ -374,21 +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 function computeEdge(settings, roofs, facadeDepth) {
// 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).
function computeEdge(settings, roofs) {
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) => {
@@ -428,25 +755,32 @@ 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
}; };
}); });
// Naast een vervallen ribbe (tegen een hoger dak): const depRaw = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) || 0 : 0));
// - ligt het verlengde van de rand in het hoge deel, dan loopt hij const dep = depRaw.map(d => Math.min(0, d));
// recht door tot die gevel (spot op spotafstand van de gevel); const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO);
// - ligt het verlengde ernaast (de rand loopt langs de zijgevel van const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5;
// het hoge deel), dan loopt hij recht door tot de overstek, onder const shifted = segs.map((sg, i) => {
// de rand van het hoge dak (van bovenaf overlappen ze). const pi = (i - 1 + n) % n, ni = (i + 1) % n, pv = segs[pi], nx = segs[ni];
const at = (v, own, ownD, other, otherD) => perp(own, other)
? { x: v.x + own.x * ownD + other.x * otherD, z: v.z + own.z * ownD + other.z * otherD }
: { x: v.x + own.x * ownD, z: v.z + own.z * ownD };
return {
a: at(sg.a, sg.normal, dep[i], pv.normal, dep[pi]), b: at(sg.b, sg.normal, dep[i], nx.normal, dep[ni]),
spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni])
};
});
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] }));
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;
@@ -482,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) => {
@@ -497,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]);
@@ -512,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]];
@@ -530,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);
@@ -545,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;
@@ -554,14 +878,16 @@ 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;
} }
return { return {
DAKLEER_MM, EDGE_ABOVE_MM, RIET, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, 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, 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;
+225 -126
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') { links: [-D / 2, D / 2],
span = { rechts: [-D / 2, D / 2],
links: [-D / 2, D / 2], voor: [-W / 2 + tE('links', -D / 2), W / 2 - tE('rechts', -D / 2)],
rechts: [-D / 2, D / 2], achter: [-W / 2 + tE('links', D / 2), W / 2 - tE('rechts', D / 2)]
voor: [-W / 2 + tEff('links', -D / 2), W / 2 - tEff('rechts', -D / 2)], } : {
achter: [-W / 2 + tEff('links', D / 2), W / 2 - tEff('rechts', D / 2)] voor: [-W / 2, W / 2],
}; achter: [-W / 2, W / 2],
} else { links: [-D / 2 + tE('voor', -W / 2), D / 2 - tE('achter', -W / 2)],
span = { rechts: [-D / 2 + tE('voor', W / 2), D / 2 - tE('achter', W / 2)]
voor: [-W / 2, W / 2], };
achter: [-W / 2, W / 2], const span = spans(tEff);
links: [-D / 2 + tEff('voor', -W / 2), D / 2 - tEff('achter', -W / 2)], const span0 = spans((side) => T[side]);
rechts: [-D / 2 + tEff('voor', W / 2), D / 2 - tEff('achter', W / 2)]
};
}
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,72 +107,107 @@ 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);
// Middenpunt (nok) midden op de wand; een stuk krijgt de nok alleen als const tp = [{ x: 0, h: hL }, ...tops, { x: W, h: hR }];
// die erin valt, anders de daklijn ter plaatse als gewone helling. const heightAt = (u) => {
const hM = wallState.wall.heightMid > 0 ? Math.max(wallState.wall.heightMid, hL, hR) : null; let i = 0;
const pU = W / 2; while (i < tp.length - 2 && u > tp[i + 1].x) i++;
const heightAt = (u) => hM === null ? hL + (u / W) * (hR - hL) const a = tp[i], b = tp[i + 1];
: u <= pU ? hL + (u / pU) * (hM - hL) : hM + ((u - pU) / (W - pU)) * (hR - hM); return a.h + (u - a.x) / (b.x - a.x) * (b.h - a.h);
};
const topsIn = (a, b, base) => {
const inner = tops.filter(q => q.x > a + 1 && q.x < b - 1);
return inner.length ? { heightMid: null, topPts: inner.map(q => ({ f: (q.x - a) / (b - a), h: q.h - base })) } : { heightMid: null, topPts: undefined };
};
const multi = wallLayout.pieces.length > 1; const multi = wallLayout.pieces.length > 1;
const used = new Set(); const used = new Set();
const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => { const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => {
const openings = fullConfig.openings const openings = fullConfig.openings
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS) .filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS)
.map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y, anchor: 'left', offset: op.x - a }; }); .map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; });
const hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
const params = Object.assign({}, wallState.wall, { const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b)), width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b))
heightMid: hasPeak ? hM : null, peakX: hasPeak ? pU - a : undefined }, topsIn(a, b, 0));
});
const config = WallModel.buildConfig(params, openings); const config = WallModel.buildConfig(params, openings);
return { return {
suffix: multi ? String.fromCharCode(97 + i) : '', // a, b, ... suffix: multi ? String.fromCharCode(97 + i) : '',
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
@@ -228,22 +219,18 @@ window.UnitModel = (() => {
const ins = wallLayout.endInset || { left: 0, right: 0 }; const ins = wallLayout.endInset || { left: 0, right: 0 };
raisedList.forEach((r, i) => { raisedList.forEach((r, i) => {
const { u0: a, u1: b, base } = r; const { u0: a, u1: b, base } = r;
const hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
const hA = heightAt(a) - base, hB = heightAt(b) - base; const hA = heightAt(a) - base, hB = heightAt(b) - base;
if (Math.min(hA, hB) < MIN_RAISE_MM) return; if (Math.min(hA, hB) < MIN_RAISE_MM) return;
const openings = fullConfig.openings const openings = fullConfig.openings
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS) .filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS)
.map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y - base, anchor: 'left', offset: op.x - a }; }); .map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; });
const params = Object.assign({}, wallState.wall, { const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB), width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB)
heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined }, topsIn(a, b, base));
});
const config = WallModel.buildConfig(params, openings); const config = WallModel.buildConfig(params, openings);
pieces.push({ pieces.push({
suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''), // opbouw suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''),
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
@@ -255,15 +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. middenpunt (nok);
// - de twee haakse wanden worden vlak op de hoogte van de hoek waar ze
// op aansluiten (goothoogte bij een lessenaar- of zadeldak).
// 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' },
@@ -271,22 +249,143 @@ 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' };
function gableSpan(state, side) {
const layout = computeLayout(state, {});
const wl = layout.walls.find(x => x.side === side);
const s = WALL_SIDES.find(x => x.key === side);
const span = s.axis === 'x' ? layout.floorWidth : layout.floorDepth;
const c = s.axis === 'x' ? wl.cx : wl.cz;
const left = c - s.dir * wl.width / 2, right = c + s.dir * wl.width / 2;
const e0 = Math.max(0, Math.abs(left + s.dir * span / 2)), e1 = Math.max(0, Math.abs(s.dir * span / 2 - right));
return { e0, e1, w: wl.width, span, layout };
}
function fullTopLine(wall, g) {
const pts = [{ u: g.e0, h: wall.heightLeft }]
.concat(WallModel.wallTopPoints(wall, g.w, wall.heightLeft, wall.heightRight).map(q => ({ u: g.e0 + q.x, h: q.h })))
.concat([{ u: g.e0 + g.w, h: wall.heightRight }]);
const n = pts.length, s0 = (pts[1].h - pts[0].h) / (pts[1].u - pts[0].u), s1 = (pts[n - 1].h - pts[n - 2].h) / (pts[n - 1].u - pts[n - 2].u);
pts[0] = { u: 0, h: pts[0].h - g.e0 * s0 };
pts[n - 1] = { u: g.span, h: pts[n - 1].h + g.e1 * s1 };
return pts;
}
function setWallTopLine(wall, g, line) {
const at = (u) => {
let i = 0;
while (i < line.length - 2 && u > line[i + 1].u) i++;
const a = line[i], b = line[i + 1];
return a.h + (u - a.u) / (b.u - a.u) * (b.h - a.h);
};
const hL = Math.round(at(g.e0)), hR = Math.round(at(g.e0 + g.w));
const inner = line.slice(1, -1).filter(q => q.u > g.e0 + 1 && q.u < g.e0 + g.w - 1).map(q => ({ f: (q.u - g.e0) / g.w, h: Math.round(q.h) }));
const mid = inner.length === 1 && Math.abs(inner[0].f - 0.5) < 1e-3;
Object.assign(wall, { heightLeft: hL, heightRight: hR, heightMid: mid ? inner[0].h : null, heightAll: Math.min(hL, hR) });
if (inner.length && !mid) wall.topPts = inner; else delete wall.topPts;
}
function syncWallHeights(state, side) { function syncWallHeights(state, side) {
const w = state.walls[side].wall; const w = state.walls[side].wall;
const mid = w.heightMid > 0 ? w.heightMid : null; const mid = w.heightMid > 0 ? w.heightMid : null;
const set = (key, hL, hR, hM) => { const pts = Array.isArray(w.topPts) && w.topPts.length ? w.topPts : null;
// De laagste van de twee: zo steekt de wand nooit door het dak (de wig vult een schuine regel op).
const g = gableSpan(state, side), full = fullTopLine(w, g), n = full.length;
const thick = (key) => (g.layout.walls.find(x => x.side === key) || {}).thickness || 0;
const s0 = (full[1].h - full[0].h) / (full[1].u - full[0].u), s1 = (full[n - 1].h - full[n - 2].h) / (full[n - 1].u - full[n - 2].u);
const edgeL = Math.round(Math.min(full[0].h, full[0].h + thick(WALL_ENDS[side].left) * s0));
const edgeR = Math.round(Math.min(full[n - 1].h, full[n - 1].h - thick(WALL_ENDS[side].right) * s1));
const set = (key, hL, hR, hM, tp) => {
const t = state.walls[key].wall; const t = state.walls[key].wall;
t.heightLeft = hL; t.heightRight = hR; t.heightMid = hM; t.heightLeft = hL; t.heightRight = hR; t.heightMid = hM;
t.heightAll = hM === null && hL === hR ? hL : w.heightAll; if (tp) t.topPts = tp; else delete t.topPts;
t.heightAll = hM === null && !tp && hL === hR ? hL : w.heightAll;
}; };
set(OPPOSITE[side], w.heightRight, w.heightLeft, mid); 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, w.heightLeft, w.heightLeft, null); set(WALL_ENDS[side].left, edgeL, edgeL, null);
set(WALL_ENDS[side].right, w.heightRight, w.heightRight, null); set(WALL_ENDS[side].right, edgeR, edgeR, null);
} }
// Hoogste punt van een wand (incl. nok).
function wallTopOf(wallState) { function wallTopOf(wallState) {
const w = wallState.wall; const w = wallState.wall;
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0); return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h));
}
// kneeRatio / cutRatio gaan voor kneeH / cut, zodat ze meeschalen als goot of nok verandert.
const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 };
function clampSize(floor, keep) {
floor.width = Math.min(LIMITS.long, floor.width);
floor.depth = Math.min(LIMITS.long, floor.depth);
if (Math.min(floor.width, floor.depth) > LIMITS.short) {
const k = keep || (floor.width <= floor.depth ? 'width' : 'depth');
floor[k] = LIMITS.short;
}
return floor;
}
const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' };
const RIDGES = { z: 'Noord–zuid', x: 'West–oost' };
const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf';
function gableOf(form) {
if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter';
if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links';
return 'achter';
}
function roofForm(unitState) {
const W = unitState.walls;
for (const side of ['achter', 'voor', 'links', 'rechts']) {
const w = W[side].wall;
const ridge = side === 'voor' || side === 'achter' ? 'z' : 'x';
const full = fullTopLine(w, gableSpan(unitState, side));
const F = full[full.length - 1].u, inner = full.slice(1, -1);
const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h));
if (inner.length === 3) {
const high = Math.round(inner[1].h);
return { type: 'mansarde', ridge, low, high, kneeH: Math.round(inner[0].h), kneeIn: Math.round(inner[0].u), kneeRatio: (inner[0].h - low) / Math.max(1, high - low) };
}
if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) {
const slope = (inner[0].h - L.h) / Math.max(1, inner[0].u);
const high = Math.round(L.h + slope * F / 2);
return { type: 'wolf', ridge, low, high, cut: Math.round(inner[0].h), cutRatio: (inner[0].h - low) / Math.max(1, high - low) };
}
if (inner.length === 1) return { type: 'zadel', ridge, low, high: Math.round(inner[0].h) };
if (Math.abs(L.h - R.h) > 0.5) {
const dir = R.h > L.h ? WALL_ENDS[side].right : WALL_ENDS[side].left;
return { type: 'lessenaar', dir, low, high: Math.round(Math.max(L.h, R.h)) };
}
}
const h = Math.max(...Object.values(W).map(wallTopOf));
return { type: 'plat', low: h, high: h };
}
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 high = Math.round(Math.min(LIMITS.ridge, Math.max(low, form.high || low)));
const sloped = form.type !== 'plat' && high > low;
const half = F / 2;
let line = [{ u: 0, h: low }, { u: F, h: low }];
if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
else if (sloped && form.type === 'mansarde') {
// Knik altijd boven de rechte lijn goot–nok, anders wordt het een hol dak.
const kIn = Math.max(eMin + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18)));
const minK = low + (high - low) * kIn / half + 20;
const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH;
const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK)));
line = kH > minK - 1 && high - kH >= 20
? [{ u: 0, h: low }, { u: kIn, h: kH }, { u: half, h: high }, { u: F - kIn, h: kH }, { u: F, h: low }]
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
} else if (sloped && form.type === 'wolf') {
const minR = (eMin + 50) / half;
const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6;
const cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low))));
const uc = (cut - low) / (high - low) * half;
line = high - low > 100
? [{ u: 0, h: low }, { u: uc, h: cut }, { u: F - uc, h: cut }, { u: F, h: low }]
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
} else if (sloped) {
line = [{ u: 0, h: highAtStart ? high : low }, { u: F, h: highAtStart ? low : high }];
}
return line;
} }
function validateState(design) { function validateState(design) {
@@ -302,5 +401,5 @@ window.UnitModel = (() => {
} }
} }
return { WALL_SIDES, CORNER_MODES, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, validateState }; return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, formLine, validateState };
})(); })();
+330
View File
@@ -0,0 +1,330 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Kunststof kozijnen naar de Aluplast Ideal 7000 (examples/Buiten kozijnen.pdf).
// links/rechts (leaf, hinge, dir) zijn van buitenaf gezien = vanaf +z.
// userData.door: open/target in radialen (draaien) of meters (schuiven); de loopmodus (house.js) zet target.
window.KozijnModel = (() => {
const COLORS = {
wit: { label: 'Wit', ral: 'RAL 9016', hex: 0xf1f0ea },
grijs: { label: 'Grijs', ral: 'RAL 7038', hex: 0xaeb0aa },
antraciet: { label: 'Antraciet', ral: 'RAL 7016', hex: 0x383e42 },
zwart: { label: 'Zwart', ral: 'RAL 9005', hex: 0x151515 }
};
const FILL_TYPES = { geen: 'Geen (open)', raam: 'Raam (vast glas)', deur: 'Deur', 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 P = {
gap: 5,
kader: 70,
depth: 120,
dorpel: 30,
tussen: 70, // tussenstijl/-regel (deur naast zijlicht, onder bovenlicht)
rooster: 80,
glass: 24,
leafW: 930, // deurblad (binnenmaat kader) als er een zijlicht naast past
leafH: 2200, // deurblad als er een bovenlicht boven past
leafT: 68,
minLight: 200
};
const colorKey = (k, def) => COLORS[k] ? k : def;
const pick = (v, opts, def) => opts[v] ? v : def;
function normalize(fill) {
if (!fill || !FILL_TYPES[fill.type] || fill.type === 'geen') return null;
const f = {
type: fill.type, rooster: !!fill.rooster, glas: fill.glas !== false,
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;
}
function defaultFill(openingType, like) {
const l = normalize(like) || {};
const type = openingType === 'deur' || openingType === 'voordeur' ? 'deur' : 'raam';
return { type, rooster: false, glas: openingType === 'voordeur', out: l.out || DEFAULT_OUT, in: l.in || DEFAULT_IN };
}
function insetPoly(pts, d) {
const n = pts.length;
const lines = pts.map((p, i) => {
const q = pts[(i + 1) % n];
const len = Math.hypot(q.x - p.x, q.y - p.y) || 1;
const dx = (q.x - p.x) / len, dy = (q.y - p.y) / len;
return { x: p.x - dy * d[i], y: p.y + dx * d[i], dx, dy };
});
return pts.map((p, i) => {
const a = lines[(i - 1 + n) % n], b = lines[i];
const den = a.dx * b.dy - a.dy * b.dx;
if (Math.abs(den) < 1e-9) return { x: b.x, y: b.y };
const t = ((b.x - a.x) * b.dy - (b.y - a.y) * b.dx) / den;
return { x: a.x + a.dx * t, y: a.y + a.dy * t };
});
}
function clipPoly(pts, f) {
const out = [];
pts.forEach((p, i) => {
const q = pts[(i + 1) % pts.length];
const fp = f(p), fq = f(q);
if (fp >= 0) out.push(p);
if ((fp >= 0) !== (fq >= 0)) {
const t = fp / (fp - fq);
out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
}
});
return out;
}
const rect = (x0, x1, y0, y1) => [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }];
const areaOf = (pts) => Math.abs(pts.reduce((a, p, i) => { const q = pts[(i + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2;
const mats = {};
function colorMat(key) {
const k = 'c:' + colorKey(key, DEFAULT_OUT);
return mats[k] || (mats[k] = new THREE.MeshStandardMaterial({ color: COLORS[colorKey(key, DEFAULT_OUT)].hex, roughness: 0.45 }));
}
function mat(key) {
if (mats[key]) return mats[key];
if (key === 'glass') mats[key] = new THREE.MeshStandardMaterial({ color: 0x9fbfcc, roughness: 0.05, metalness: 0.2, transparent: true, opacity: 0.28, depthWrite: false, side: THREE.DoubleSide });
else if (key === 'rubber') mats[key] = new THREE.MeshStandardMaterial({ color: 0x1b1b1b, roughness: 0.8 });
else mats[key] = new THREE.MeshStandardMaterial({ color: 0x9a9da0, roughness: 0.3, metalness: 0.7 });
return mats[key];
}
const toM = 0.001;
const toShape = (pts, holes) => {
const s = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.y * toM)));
(holes || []).forEach(h => s.holes.push(new THREE.Path(h.map(p => new THREE.Vector2(p.x * toM, p.y * toM)))));
return s;
};
function slab(g, pts, holes, z0, z1, matIn, matOut, shadow) {
if (pts.length < 3 || areaOf(pts) < 1) return;
const FOIL = matOut && matOut !== matIn ? 1 : 0;
const add = (a, b, m) => {
const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(toShape(pts, holes), { depth: (b - a) * toM, bevelEnabled: false }), m);
mesh.position.z = a * toM;
mesh.castShadow = shadow !== false;
mesh.receiveShadow = true;
g.add(mesh);
};
add(z0, z1 - FOIL, matIn);
if (FOIL) add(z1 - FOIL, z1, matOut);
}
const box = (g, m, x, y, z, w, h, d) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), m);
mesh.position.set(x * toM, y * toM, z * toM);
mesh.castShadow = true;
g.add(mesh);
};
// Lokaal: x vanaf links, y vanaf de onderkant, +z = buiten (mm in, meters uit).
function build3D(op, fill, place) {
const f = normalize(fill);
if (!f) return null;
const g = new THREE.Group();
const mIn = colorMat(f.in), mOut = colorMat(f.out);
const depth = Math.min(P.depth, place.depth || P.depth);
const z1 = place.zOut, z0 = z1 - depth;
const hR = op.hR !== undefined ? op.hR : op.h;
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, P.gap, P.gap, P.gap]);
const below = (q) => (p) => {
const a = q[3], b = q[2];
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
};
const glass = (pts) => slab(g, pts, null, z1 - 50 - P.glass / 2, z1 - 50 + P.glass / 2, mat('glass'), null, false);
if (f.type === 'raam') {
const inner = insetPoly(outer, [P.kader, P.kader, P.kader, P.kader]);
slab(g, outer, [inner], z0, z1, mIn, mOut);
let pane = inner;
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) {
pane = insetPoly(inner, [0, 0, P.rooster, 0]);
const band = [pane[3], pane[2], inner[2], inner[3]];
slab(g, [band[0], band[1], band[2], band[3]], null, z1 - 105, z1 - 8, mIn, mOut);
[0.3, 0.55, 0.8].forEach(t => {
const a = insetPoly(inner, [0, 20, P.rooster * t - 4, 20]), b = insetPoly(inner, [0, 20, P.rooster * t + 4, 20]);
const slot = [b[3], b[2], a[2], a[3]];
slab(g, slot, null, z1 - 8, z1 - 6, mat('rubber'));
slab(g, slot, null, z1 - 107, z1 - 105, mat('rubber'));
});
}
glass(pane);
return g;
}
const sill = place.exterior !== false ? P.dorpel : 0;
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]);
const u = [frameOuter[0], inner[0], inner[3], inner[2], inner[1], frameOuter[1], frameOuter[2], frameOuter[3]];
slab(g, u, null, z0, z1, mIn, mOut);
if (sill) box(g, mat('rubber'), (outer[0].x + outer[1].x) / 2, outer[0].y + sill / 2, z1 - depth / 2 + 15, outer[1].x - outer[0].x, sill, depth + 30);
const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y;
const top = below(inner);
const topAt = (x) => inner[3].y + (x - inner[3].x) / (inner[2].x - inner[3].x) * (inner[2].y - inner[3].y);
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 la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
const lowTop = Math.min(topAt(ix0), topAt(ix1));
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
const ly1 = transom ? iy0 + P.leafH : Infinity;
if (side) {
const [t0, g0, g1] = leafR ? [la - P.tussen, ix0, la - P.tussen] : [lb, lb + P.tussen, ix1];
bar(region(t0, t0 + P.tussen, iy0, ly1));
bar(region(g0, g1, iy0, iy0 + P.kader));
glass(region(g0, g1, iy0 + P.kader, ly1));
}
if (transom) {
bar(region(ix0, ix1, ly1, ly1 + P.tussen));
glass(region(ix0, ix1, ly1 + P.tussen, Infinity));
}
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer).
const leaf = clipPoly(rect(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;
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
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;
let holes = null;
const lw = lxR - lx0;
if (f.volglas && lw >= 400 && lt - lb0 >= 800) {
holes = [rect(lx0 + 110, lxR - 110, lb0 + 140, lt - 110)]; // glasdeur: smal kader rondom
} else if (f.glas && lw >= 500 && lt - lb0 >= 1400) {
const gx = hingeR ? lxR - lw * 0.3 : lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
holes = [rect(gx - gw / 2, gx + gw / 2, lb0 + 350, lt - 300)]; // glas: in het deurblad (hieronder)
}
const outward = f.swing === 'buiten';
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) {
const kx = hingeR ? lx0 + 75 : lxR - 75, gd = hingeR ? 60 : -60;
[[lzOut, 1], [lzIn, -1]].forEach(([z, s]) => {
box(lg, mat('beslag'), kx, hy - 30, z + s * 5, 34, 210, 10); // schild
box(lg, mat('beslag'), kx, hy, z + s * 30, 20, 20, 50); // nek
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;
}
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 };
})();
+630 -631
View File
File diff suppressed because it is too large Load Diff
+15 -44
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,14 @@ 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;
const x0 = px(opXWorld), y0 = py(op.y + op.h), 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);
doc.rect(x0, y0, x1 - x0, y1 - y0, 'S'); [[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, y0); 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 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' });
// erboven.
doc.text(`${op.w}x${op.h}`, (x0 + x1) / 2, y0 + 5, { align: 'center' });
}); });
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);
@@ -179,13 +154,14 @@ window.WallPdf = (() => {
doc.text(String(Math.round(hLeft)), px(-wallWidth / 2) - 9, (py(0) + py(hLeft)) / 2, { align: 'center', angle: 90 }); doc.text(String(Math.round(hLeft)), px(-wallWidth / 2) - 9, (py(0) + py(hLeft)) / 2, { align: 'center', angle: 90 });
drawDimLine(px(wallWidth / 2) + 7, py(0), px(wallWidth / 2) + 7, py(hRight)); drawDimLine(px(wallWidth / 2) + 7, py(0), px(wallWidth / 2) + 7, py(hRight));
doc.text(String(Math.round(hRight)), px(wallWidth / 2) + 11, (py(0) + py(hRight)) / 2, { align: 'center', angle: 90 }); doc.text(String(Math.round(hRight)), px(wallWidth / 2) + 11, (py(0) + py(hRight)) / 2, { align: 'center', angle: 90 });
if (tl.peak) { const inner = tl.pts.slice(1, -1);
// Nokhoogte: stippellijn van vloer tot nok. inner.forEach(q => {
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
doc.setLineDashPattern([1, 1], 0); doc.setLineDashPattern([1, 1], 0);
doc.line(px(tl.peak.x), py(0), px(tl.peak.x), py(tl.peak.y)); doc.line(px(q.x), py(0), px(q.x), py(q.y));
doc.setLineDashPattern([], 0); doc.setLineDashPattern([], 0);
doc.text('nok ' + Math.round(tl.peak.y), px(tl.peak.x) + 1.5, py(tl.peak.y) + 4); doc.text((isNok ? 'nok ' : 'knik ') + Math.round(q.y), px(q.x) + 1.5, py(q.y) + 4);
} });
const studEdges = new Set([0, wallWidth]); const studEdges = new Set([0, wallWidth]);
calcData.fullStuds.forEach(x => studEdges.add(Math.round(x - CONFIG.studWidth / 2 + wallWidth / 2))); calcData.fullStuds.forEach(x => studEdges.add(Math.round(x - CONFIG.studWidth / 2 + wallWidth / 2)));
@@ -215,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);
@@ -227,9 +202,9 @@ window.WallPdf = (() => {
doc.text('L', lx + 2, ly + 1); doc.text('L', lx + 2, ly + 1);
}); });
// --- Bovenaanzicht (doorsnede op stijlhoogte, zoals de voorbeeld- if (opts.overlay) opts.overlay(doc, (u) => px(-wallWidth / 2 + u), py);
// tekeningen): binnenzijde boven, buitenzijde onder (= de kant van
// waaruit het vooraanzicht bekeken wordt). Zelfde horizontale schaal. // Binnenzijde boven, buitenzijde onder (de kant van het vooraanzicht).
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;
@@ -238,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;
@@ -251,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))];
@@ -280,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;
+188 -157
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,19 +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;
if (lockExterior && lockCladding) {
const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]');
if (tabBtn) tabBtn.style.display = 'none';
}
// Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand.
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);
@@ -264,61 +248,46 @@ 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;
let wallTopPts = null;
// Elke opening: { id, w, h, y, anchor: 'left'|'middle'|'right', offset } // offset: left = vanaf de linker wandrand, right = vanaf de rechter, middle = t.o.v. het midden (+ = rechts).
// 'offset' betekent afhankelijk van anchor: // hR = hoogte rechts bij een schuine bovenzijde; de onderkant is altijd waterpas.
// 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 } { 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. N/Z-handles wijzigen alleen de hoogte, O/W-handles
// alleen de breedte — precies zoals bij de meeste 2D-tekentools.
// ------------------------------------------------------------
let selectedOpeningId = null; let selectedOpeningId = null;
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH } 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 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) } : {});
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;
['w', 'h', 'y', 'offset'].forEach(field => { if (!!card.querySelector('input[data-field="hR"]') !== isSloped(op)) { renderOpeningsList(); return; }
['w', 'h', 'hR', 'y', 'offset'].forEach(field => {
const input = card.querySelector(`input[data-field="${field}"]`); const input = card.querySelector(`input[data-field="${field}"]`);
if (input && document.activeElement !== input) input.value = Math.round(op[field]); if (input && document.activeElement !== input) input.value = Math.round(op[field]);
}); });
@@ -341,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';
@@ -360,14 +326,15 @@ function mountWallEditor(container, callbacks) {
h: preset.h, h: preset.h,
y: preset.y, y: preset.y,
anchor: 'left', anchor: 'left',
offset: 300 + n * 1400 offset: 300 + n * 1400,
fill: KozijnModel.defaultFill('raam', lastFill())
}); });
renderOpeningsList(); renderOpeningsList();
updateAll(); updateAll();
} }
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);
} }
@@ -411,24 +378,17 @@ 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');
const tmp = hLeftInput.value; const tmp = hLeftInput.value;
hLeftInput.value = hRightInput.value; hLeftInput.value = hRightInput.value;
hRightInput.value = tmp; hRightInput.value = tmp;
if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse();
openings = openings.map(o => { openings = openings.map(o => {
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' };
if (o.anchor === 'middle') return { ...o, offset: -o.offset }; if (o.anchor === 'middle') return { ...o, offset: -o.offset };
@@ -440,6 +400,51 @@ function mountWallEditor(container, callbacks) {
setIoStatus('Horizontaal gespiegeld.', 'ok'); setIoStatus('Horizontaal gespiegeld.', 'ok');
} }
function fillControls(op) {
const f = KozijnModel.normalize(op.fill);
const type = f ? f.type : 'geen';
const opts = (entries, cur) => entries.map(([k, label]) => `<option value="${k}" ${k === cur ? 'selected' : ''}>${label}</option>`).join('');
const colors = Object.entries(KozijnModel.COLORS).map(([k, c]) => [k, `${c.label} (${c.ral})`]);
const sw = (field, label, on, title) => `<div class="control-group"${title ? ` title="${title}"` : ''}>
<label>${label}</label>
<div><label class="ui-switch"><input type="checkbox" data-field="${field}" data-id="${op.id}" ${on ? 'checked' : ''}><span></span></label></div>
</div>`;
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">
<div>
<div class="control-group">
<label>Vulling</label>
<div><select data-field="fill" data-id="${op.id}">${opts(Object.entries(KozijnModel.FILL_TYPES), type)}</select></div>
</div>
${type === 'raam' ? sw('fillRooster', 'Rooster', f.rooster, 'Ventilatierooster bovenin het kozijn') : ''}
${type === 'deur' ? sw('fillGlas', 'Glasstrook', f.glas, 'Glasstrook in het deurblad') : ''}
${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>
${f ? `<div class="control-group">
<label>Kleur buiten</label>
<div><select data-field="fillOut" data-id="${op.id}">${opts(colors, f.out)}</select></div>
</div>
<div class="control-group">
<label>Kleur binnen</label>
<div><select data-field="fillIn" data-id="${op.id}">${opts(colors, f.in)}</select></div>
</div>` : ''}
</div>
</div>`;
}
const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; };
function renderOpeningsList() { function renderOpeningsList() {
const list = document.getElementById('openings-list'); const list = document.getElementById('openings-list');
list.innerHTML = openings.map(op => ` list.innerHTML = openings.map(op => `
@@ -463,9 +468,13 @@ function mountWallEditor(container, callbacks) {
<div><input type="number" data-field="w" data-id="${op.id}" value="${op.w}" step="50" min="400" max="2850"><span class="unit">mm</span></div> <div><input type="number" data-field="w" data-id="${op.id}" value="${op.w}" step="50" min="400" max="2850"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label>Hoogte</label> <label>${isSloped(op) ? 'Hoogte links' : 'Hoogte'}</label>
<div><input type="number" data-field="h" data-id="${op.id}" value="${op.h}" step="50" min="400"><span class="unit">mm</span></div> <div><input type="number" data-field="h" data-id="${op.id}" value="${op.h}" step="50" min="400"><span class="unit">mm</span></div>
</div> </div>
${isSloped(op) ? `<div class="control-group">
<label>Hoogte rechts</label>
<div><input type="number" data-field="hR" data-id="${op.id}" value="${op.hR}" step="50" min="400"><span class="unit">mm</span></div>
</div>` : ''}
<div class="control-group"> <div class="control-group">
<label>Afstand tot vloer</label> <label>Afstand tot vloer</label>
<div><input type="number" data-field="y" data-id="${op.id}" value="${op.y}" step="50" min="0"><span class="unit">mm</span></div> <div><input type="number" data-field="y" data-id="${op.id}" value="${op.y}" step="50" min="0"><span class="unit">mm</span></div>
@@ -486,16 +495,20 @@ function mountWallEditor(container, callbacks) {
<label>${anchorOffsetLabel(op.anchor)}</label> <label>${anchorOffsetLabel(op.anchor)}</label>
<div><input type="number" data-field="offset" data-id="${op.id}" value="${op.offset}" step="50"><span class="unit">mm</span></div> <div><input type="number" data-field="offset" data-id="${op.id}" value="${op.offset}" step="50"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group">
<label>Schuine bovenzijde</label>
<div><label class="ui-switch"><input type="checkbox" data-field="sloped" data-id="${op.id}" ${isSloped(op) ? 'checked' : ''}><span></span></label></div>
</div>
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
</div> </div>
</div> </div>
${fillControls(op)}
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div> <div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
</div> </div>
`).join(''); `).join('');
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')) {
@@ -506,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;
@@ -515,14 +526,29 @@ function mountWallEditor(container, callbacks) {
const op = openings.find(o => o.id === id); const op = openings.find(o => o.id === id);
if (!op) return; if (!op) return;
if (field === 'type') { if (field === 'type') {
// Type kiezen = standaardmaat van dat type overnemen (daarna vrij aan te passen).
const preset = WallModel.OPENING_TYPES[e.target.value]; const preset = WallModel.OPENING_TYPES[e.target.value];
op.type = e.target.value; op.type = e.target.value;
op.w = preset.w; op.h = preset.h; op.y = preset.y; op.w = preset.w; op.h = preset.h; op.y = preset.y;
delete op.hR;
if (op.fill) op.fill = KozijnModel.defaultFill(op.type, op.fill);
renderOpeningsList();
} else if (field === 'fill') {
const v = e.target.value;
if (v === 'geen') delete op.fill;
else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' || v === 'schuifdeur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v });
renderOpeningsList();
} else if (/^fill(Rooster|Glas|Volglas|Out|In|Hinge|Leaf|Swing|Leaves|Mount|Side|Dir)$/.test(field)) {
if (!op.fill) return;
const key = { fillRooster: 'rooster', fillGlas: 'glas', fillVolglas: 'volglas', fillOut: 'out', fillIn: 'in', fillHinge: 'hinge', fillLeaf: 'leaf', fillSwing: 'swing',
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') {
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;
} }
@@ -532,13 +558,21 @@ function mountWallEditor(container, callbacks) {
if (e.target.dataset.action === 'remove') { if (e.target.dataset.action === 'remove') {
removeOpening(parseInt(e.target.dataset.id, 10)); removeOpening(parseInt(e.target.dataset.id, 10));
} }
const roofBtn = e.target.closest('[data-action="follow-roof"]');
if (roofBtn) followRoof(parseInt(roofBtn.dataset.id, 10));
}); });
document.getElementById('btn-add-opening').addEventListener('click', addOpening); document.getElementById('btn-add-opening').addEventListener('click', addOpening);
// ============================================================ function followRoof(id) {
// JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters + const op = openings.find(o => o.id === id);
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug. const res = (CONFIG.openings || []).find(o => o.id === id);
// ============================================================ if (!op || !res) return;
const at = WallModel.topLine(CONFIG).at, x0 = -CONFIG.wallWidth / 2 + res.x;
op.hR = Math.max(400, Math.round(op.h + at(x0 + res.w) - at(x0)));
syncOpeningCardInputs(op);
updateAll();
}
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;
@@ -553,8 +587,8 @@ 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,
studWidth: parseFloat(document.getElementById('inp-stud-w').value), studWidth: parseFloat(document.getElementById('inp-stud-w').value),
studDepth: parseFloat(document.getElementById('inp-stud-d').value), studDepth: parseFloat(document.getElementById('inp-stud-d').value),
plateThick: parseFloat(document.getElementById('inp-plate-w').value), plateThick: parseFloat(document.getElementById('inp-plate-w').value),
@@ -568,8 +602,8 @@ function mountWallEditor(container, callbacks) {
function getPureState() { function getPureState() {
return { return {
wall: collectWallParams(), wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone }, materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor },
openings: openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset })) openings: openings.map(rawOpening)
}; };
} }
@@ -589,14 +623,17 @@ function mountWallEditor(container, callbacks) {
document.getElementById('inp-min-sp').value = state.wall.minSpacing; document.getElementById('inp-min-sp').value = state.wall.minSpacing;
document.getElementById('inp-max-sp').value = state.wall.maxSpacing; document.getElementById('inp-max-sp').value = state.wall.maxSpacing;
lastBaseHeight = state.wall.heightAll; lastBaseHeight = state.wall.heightAll;
wallTopPts = Array.isArray(state.wall.topPts) && state.wall.topPts.length ? state.wall.topPts.map(q => Object.assign({}, q)) : null;
buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek'; buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek';
cladding = (state.materials && state.materials.cladding) || 'horizontaal'; cladding = (state.materials && state.materials.cladding) || 'horizontaal';
trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling trasraam = (state.materials && state.materials.trasraam) || null;
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen) stone = (state.materials && state.materials.stone) || null;
stuc = (state.materials && state.materials.stuc) || null;
rabatColor = (state.materials && state.materials.rabatColor) || null;
syncMaterialButtons(); syncMaterialButtons();
openings = state.openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset })); openings = state.openings.map(rawOpening);
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1; openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
selectedOpeningId = null; selectedOpeningId = null;
@@ -604,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();
@@ -683,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');
@@ -741,12 +769,12 @@ 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,
heightRight: hRight, heightRight: hRight,
heightMid: peakOn ? hMid : null, heightMid: peakOn ? hMid : null,
topPts: wallTopPts || undefined,
studWidth: parseFloat(document.getElementById('inp-stud-w').value), studWidth: parseFloat(document.getElementById('inp-stud-w').value),
studDepth: parseFloat(document.getElementById('inp-stud-d').value), studDepth: parseFloat(document.getElementById('inp-stud-d').value),
plateThick: parseFloat(document.getElementById('inp-plate-w').value), plateThick: parseFloat(document.getElementById('inp-plate-w').value),
@@ -766,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');
@@ -814,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)));
@@ -839,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');
@@ -873,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) {
@@ -888,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;
@@ -903,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();
@@ -913,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;
@@ -932,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));
@@ -945,16 +959,16 @@ function mountWallEditor(container, callbacks) {
const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14); const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14);
drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM), drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM),
'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2); 'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2);
if (tl.peak) { const inner = tl.pts.slice(1, -1);
const pxC = toCanvasX(tl.peak.x); inner.forEach(q => {
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
ctxFront.fillStyle = '#9ab'; ctxFront.fillStyle = '#9ab';
ctxFront.font = '11px sans-serif'; ctxFront.font = '11px sans-serif';
ctxFront.textAlign = 'center'; ctxFront.textAlign = 'center';
ctxFront.fillText('nok ' + Math.round(tl.peak.y) + 'mm', pxC, Math.max(12, toCanvasY(tl.peak.y) - 6)); ctxFront.fillText((isNok ? 'nok ' : 'knik ') + Math.round(q.y) + 'mm', toCanvasX(q.x), Math.max(12, toCanvasY(q.y) - 6));
ctxFront.textAlign = 'start'; ctxFront.textAlign = 'start';
} });
// Stijlstuk van y0 tot y1 (of tot de daklijn), zie WallModel.computeFrame.
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;
@@ -981,38 +995,51 @@ function mountWallEditor(container, callbacks) {
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); const y1 = toCanvasY(op.y + op.h);
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, localX: op.x, y: op.y, w: op.w, h: op.h }); 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 });
const outline = () => {
ctxFront.beginPath();
ctxFront.moveTo(x0, y0); ctxFront.lineTo(x1, y0); ctxFront.lineTo(x1, y1r); ctxFront.lineTo(x0, y1);
ctxFront.closePath();
};
outline();
ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)'; ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)';
ctxFront.fillRect(x0, y1, x1 - x0, y0 - y1); ctxFront.fill();
ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333'; ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333';
ctxFront.lineWidth = isSelected ? 2.5 : 1; ctxFront.lineWidth = isSelected ? 2.5 : 1;
ctxFront.setLineDash(isSelected ? [] : [3, 3]); ctxFront.setLineDash(isSelected ? [] : [3, 3]);
ctxFront.strokeRect(x0, y1, x1 - x0, y0 - y1); ctxFront.stroke();
ctxFront.setLineDash([]); ctxFront.setLineDash([]);
ctxFront.lineWidth = 1; ctxFront.lineWidth = 1;
ctxFront.fillStyle = '#fff'; ctxFront.fillStyle = '#fff';
ctxFront.font = '9px sans-serif'; ctxFront.font = '9px sans-serif';
ctxFront.textAlign = 'center'; ctxFront.textAlign = 'center';
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, y1 - 4); ctxFront.fillText('#' + op.id, (x0 + x1) / 2, (y1 + y1r) / 2 - 4);
if (isSelected) { if (isSelected) {
const midX = (x0 + x1) / 2, midY = (y0 + y1) / 2; const midX = (x0 + x1) / 2, midTop = (y1 + y1r) / 2;
const handles = [ const handles = [
{ handle: 'n', cx: midX, cy: y1 }, { handle: 'nw', cx: x0, cy: y1 },
{ handle: 'n', cx: midX, cy: midTop },
{ handle: 'ne', cx: x1, cy: y1r },
{ handle: 'w', cx: x0, cy: (y0 + y1) / 2 },
{ handle: 'c', cx: midX, cy: (y0 + midTop) / 2 },
{ handle: 'e', cx: x1, cy: (y0 + y1r) / 2 },
{ handle: 'sw', cx: x0, cy: y0 },
{ handle: 's', cx: midX, cy: y0 }, { handle: 's', cx: midX, cy: y0 },
{ handle: 'e', cx: x1, cy: midY }, { handle: 'se', cx: x1, cy: y0 }
{ handle: 'w', cx: x0, cy: midY }
]; ];
handles.forEach(hd => { handles.forEach(hd => {
frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy }); frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy });
ctxFront.fillStyle = '#4da6ff'; ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff';
ctxFront.strokeStyle = '#0a1a2a'; ctxFront.strokeStyle = '#0a1a2a';
ctxFront.beginPath(); ctxFront.beginPath();
ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10); 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);
ctxFront.fill(); ctxFront.fill();
ctxFront.stroke(); ctxFront.stroke();
}); });
@@ -1021,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;
@@ -1030,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);
@@ -1062,9 +1086,11 @@ 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) {
return frontOpeningRects.find(r => return frontOpeningRects.find(r => {
mx >= Math.min(r.x0, r.x1) && mx <= Math.max(r.x0, r.x1) && if (mx < r.x0 || mx > r.x1) return false;
my >= Math.min(r.y0, r.y1) && my <= Math.max(r.y0, r.y1)); const yTop = r.y1 + (mx - r.x0) / ((r.x1 - r.x0) || 1) * (r.y1r - r.y1);
return my <= r.y0 && my >= yTop;
});
} }
canvas2DFront.classList.add('interactive'); canvas2DFront.classList.add('interactive');
@@ -1077,9 +1103,9 @@ function mountWallEditor(container, callbacks) {
if (hit) { if (hit) {
const resolved = frontOpeningRects.find(r => r.id === hit.id); const resolved = frontOpeningRects.find(r => r.id === hit.id);
dragInfo = { dragInfo = {
type: 'resize', handle: hit.handle, openingId: hit.id, type: hit.handle === 'c' ? 'move' : 'resize', handle: hit.handle, openingId: hit.id,
startMouseX: e.clientX, startMouseY: e.clientY, startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h, startHR: resolved.hR
}; };
e.preventDefault(); e.preventDefault();
return; return;
@@ -1092,7 +1118,7 @@ function mountWallEditor(container, callbacks) {
dragInfo = { dragInfo = {
type: 'move', openingId: bodyHit.id, type: 'move', openingId: bodyHit.id,
startMouseX: e.clientX, startMouseY: e.clientY, startMouseX: e.clientX, startMouseY: e.clientY,
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h, startHR: bodyHit.hR
}; };
highlightSelectedCard(); highlightSelectedCard();
draw2D(); draw2D();
@@ -1121,17 +1147,28 @@ 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') {
if (dragInfo.handle === 'n') { const hd = dragInfo.handle;
op.h = Math.max(400, snap50(dragInfo.startH + dyMM)); const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
} else if (dragInfo.handle === 's') { if (hd === 'n') {
const top = dragInfo.startY + dragInfo.startH; const dh = snap50(dyMM);
const newY = Math.min(snap50(dragInfo.startY + dyMM), top - 400); op.h = Math.max(400, dragInfo.startH + dh);
if (sloped) op.hR = Math.max(400, dragInfo.startHR + dh);
} else if (hd === 'nw' || hd === 'ne') {
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;
if (op.h === op.hR) delete op.hR;
}
if (hd === 's' || hd === 'sw' || hd === 'se') {
const topL = dragInfo.startY + dragInfo.startH, topR = dragInfo.startY + dragInfo.startHR;
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);
op.h = top - op.y; op.h = topL - op.y;
} else if (dragInfo.handle === 'e') { if (isSloped(op)) op.hR = topR - op.y;
}
if (hd === 'e' || hd === 'ne' || hd === 'se') {
const newW = Math.max(400, snap50(dragInfo.startW + dxMM)); const newW = Math.max(400, snap50(dragInfo.startW + dxMM));
applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth); applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth);
} else if (dragInfo.handle === 'w') { } else if (hd === 'w' || hd === 'nw' || hd === 'sw') {
const right = dragInfo.startLocalX + dragInfo.startW; const right = dragInfo.startLocalX + dragInfo.startW;
const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400); const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400);
applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth); applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth);
@@ -1207,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);
@@ -1240,14 +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 stuc = null;
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));
@@ -1264,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' }
@@ -1273,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 = () => {
@@ -1281,27 +1315,23 @@ function mountWallEditor(container, callbacks) {
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D }); Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
}; };
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); }); Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
// 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld
// 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, spaanplaatVisible, buitenVisible 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 });
} }
@@ -1335,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);
@@ -1351,6 +1380,8 @@ function mountWallEditor(container, callbacks) {
const inputs = document.querySelectorAll('#tab-wall input'); const inputs = document.querySelectorAll('#tab-wall input');
inputs.forEach(input => { inputs.forEach(input => {
// Hoogte met de hand gewijzigd: de knikken van een mansarde-/wolfsdak vervallen.
if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; });
input.addEventListener('input', updateAll); input.addEventListener('input', updateAll);
}); });
@@ -1402,6 +1433,6 @@ window.WallEditor = {
sheathingThick: 10, minSpacing: 240, maxSpacing: 480 sheathingThick: 10, minSpacing: 240, maxSpacing: 480
}, },
materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' }, materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' },
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }] openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }]
}) })
}; };
File diff suppressed because it is too large Load Diff
+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