12 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 373c2c2fb7 v0.4.12: dak volgt wandvorm (plat/lessenaar/zadel), dakrand per hoogte, naadwand midden of geheel in unit A/B
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:43:42 +02:00
MachielandClaude Opus 5.5 001206a108 README opgeschoond: korte opzet met bulletpoints, geen changelog meer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:41:01 +02:00
MachielandClaude Opus 5.5 9126588df7 v0.4.10: wandhoogtes gekoppeld, middenpunt (nok), binnenspots langs naadwanden
- WallModel: optioneel middenpunt (heightMid, topLine), bovenregels in verstek;
  stijlen, openingen, beplating, doek, latwerk, BOM, 2D en PDF volgen de knik
- wand-editor: schakelaar "Middenpunt (nok)" + nokhoogte, links/rechts = goothoogte
- UnitModel.syncWallHeights: tegenoverliggende wand gespiegeld, haakse wanden
  vlak op de hoekhoogtes; wallPieces met nok per stuk
- LightingModel.computeIndoor: raster vanaf binnenzijde eigen naadwanddeel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:35:21 +02:00
MachielandClaude Opus 5.5 9bd7faeaa6 v0.4.9: naadwanden (2 delen, deur), kopbalken op naden, isolatie niet meer getekend
- HouseModel: naadwand per naad (ensureSeamWalls bij nieuwe woning en unit toevoegen),
  twee delen met beplating alleen aan de kamerzijde, openingen gespiegeld
- WallModel: buitenzijde 'geen', thicknessOf eenzijdig
- FloorModel: kopbalken (rims) voor vloer en dak op naden, ook in PDF en totalen
- isolatie: 3D-lagen en filter verwijderd, berekening blijft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:13:07 +02:00
MachielandClaude Opus 5.5 f163f4deb1 v0.4.8: gevellampen tellen vanaf de zichtbare afgewerkte hoek
- bedekte gevelstukken (wandstuk in de naad, binnenhoek L) afgetrokken via voetafdruk
- uiteinden verlengd met afwerking (+ skinExt bij tussenpassende wanden)
- groupFacadeRuns accepteert overlap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:54:41 +02:00
MachielandClaude Opus 5.5 448c2b1289 v0.4.7: lampen niet meer door muren, buiten overdag gedimd, binnenspots feller
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:47:08 +02:00
MachielandClaude Opus 5.5 5ecf3e0c2a v0.4.6: binnenspots per unit, omgeving als keuzemenu (Ontwerp/Dag/Nacht), lampen 15% zachter
- LightingModel.computeIndoor: plafondraster per unit (800-1500 h.o.h., 600 van wand/naad)
- 3D, plattegrond, render, totalen en PDF
- tegelvloer 20% groter; gras volledig verwijderd

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:44:06 +02:00
MachielandClaude Opus 5.5 7ae76fe390 v0.4.5: tegelvloer met witte rand i.p.v. gras, Lucht geen/dag/nacht, Render met daglicht + lampen
- core/skybox.js: tegelvloer (tiles.jpg) passend om het object, rand 100x25 mm wit
- nacht-panorama; nacht = 15% omgevingslicht + lampen 100%
- Render-knop met camera-icoon: echte lampen in het gekozen licht
- gras verwijderd (bestanden lokaal bewaard, uit git)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:34:19 +02:00
MachielandClaude Opus 5.5 9ed9cee2e0 v0.4.4: gras kleurgecorrigeerd naar panorama-grasgroen, belichting gecompenseerd
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:13:41 +02:00
MachielandClaude Opus 5.5 f8da490640 v0.4.3: lucht op de grond (grounded skybox) met gras en schaduw; webserver verplicht
- core/skybox.js: panorama op de grond geprojecteerd (port GroundedSkybox),
  grastextuur in cirkel rond het object met overvloeiing, zon met schaduw,
  camera niet onder de grond
- woning: maaiveld als instelling (standaard 350 mm onder bovenkant vloer)
- assets opgeschoond (alleen grass.jpg + skybox.webp), skybox-data.js weg
- start.sh + melding bij file://

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:07:35 +02:00
MachielandClaude Opus 5.5 3a4b9608c0 v0.4.2: iconen gerepareerd, lucht-achtergrond (skybox) met toggle
- bootstrap-icons: @font-face correct ingebed (selector .bi::before was stuk)
- core/skybox.js + assets/skybox-data.js (lazy), knop Lucht in alle 3D-weergaven
- woning: met lucht aan raster en sandwichpanelen onzichtbaar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:58:07 +02:00
MachielandClaude Opus 5.5 3591fad220 v0.4.1: UI opgeschoond — tabbladen, iconen, uniforme labels, T-cyclus
- core/ui-kit.js: gedeelde velden/schakelaars/tabs/specificatiekaarten
- Bootstrap Icons lokaal (css/vendor, font ingebed)
- Woning- en unit-instellingen in tabbladen, labels gestandaardiseerd
- Specificaties onder 3D als kaarten
- T: standaard -> 3D groot -> alleen render -> standaard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:51:18 +02:00
27 changed files with 2214 additions and 1152 deletions
+79 -222
View File
@@ -1,237 +1,94 @@
# Tectum — configurator Van Emous Mantelzorgwoningen (intern) # Tectum — configurator Van Emous Mantelzorgwoningen (intern)
Losse, meerdere-bestanden-opzet van de eerdere single-file wand-tool, nu met Configurator voor mantelzorgwoningen: wand, vloer en woning (gekoppelde units)
een menubalk om tussen objecttypen (muur/vloer) te schakelen. met 2D, 3D, specificaties en PDF-tekenbladen.
Copyright © 2026 Machiel Heinen. Alle rechten Copyright © 2026 Machiel Heinen. Alle rechten voorbehouden; gebruik
voorbehouden; gebruik uitsluitend met schriftelijke toestemming. Zie uitsluitend met schriftelijke toestemming. Zie [LICENTIE.md](LICENTIE.md).
[LICENTIE.md](LICENTIE.md). Elk bronbestand begint met deze copyright-kop. Elk bronbestand begint met deze copyright-kop.
## Draaien ## Draaien
**Optie 1 — direct openen (geen server nodig):**
Dubbelklik `index.html`. Alles werkt met gewone `<script src="...">`-tags
(geen ES-modules), dus dit werkt ook via `file://`.
**Optie 2 — lokale server (optioneel):**
``` ```
cd project ./start.sh # of: python3 -m http.server 8000
python3 -m http.server 8000
``` ```
en open `http://localhost:8000`. Open `http://localhost:8000`. Elke statische webserver volstaat; geen
build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
## Structuur ## Structuur
``` ```
index.html app-shell: menubalk, splashscreen, over-deze-software index.html app-shell: menubalk, splashscreen
css/ css/app.css alle stijl
app.css alle stijl, in één bestand js/vendor/ three.js r128, eigen OrbitControls, jsPDF
js/ js/core/ gedeelde helpers: geometrie, UI-kit, PDF-bladen, labels,
vendor/ camerageheugen, weergavefilter, skybox, licentie, logo
three.min.js three.js r128, lokaal gevendord (geen CDN meer) js/wall/ WallModel (logica) + wand-PDF + wand-editor
OrbitControls.js eigen, compacte OrbitControls-vervanging (zie js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
"Bewuste keuzes" hieronder) — leunt op three.min.js js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
core/ js/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots
geometry.js gedeelde, pure functies (regelwerk-verdeling, js/sandwich/ SandwichModel: sandwichpanelen rondom
polygon-oppervlaktes, clip-functies) — gebruikt js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
door zowel wall.js als floor.js, niet gedupliceerd js/house/ HouseModel (woning) + woning-PDF + woning-editor
canvas-labels.js gedeelde stijl voor maatlabels ("X: 6000mm") in de js/app.js menubalk, versienummer, actieve editor
2D-canvassen van wand, vloer en unit json/defaults/ referentie-JSON's (niet runtime ingeladen)
pdf-sheet.js gedeelde PDF-bouwstenen: titelblok, maatlijn, tabel assets/ logo, texturen, skybox
camera-memory.js onthoudt de 3D-camerastand per tabblad + onderdeel
view-filter.js "Weergave"-dropdown met vinkjes voor de 3D-weergaven
license.js licentietekst voor "Over deze software" (kopie van
LICENTIE.md, dat bestand is leidend)
logo-data.js logo als PNG-data voor de PDF-titelblokken
(opnieuw genereren uit assets/logo.svg, zie kop)
wall/
wall-model.js WallModel: ENIGE bron van de wand-logica (anker-
resolutie openingen, regelwerk, 3D-opbouw, specs,
validatie) — geen DOM
wall-pdf.js WallPdf: het wand-tekenblad (aanzicht, boven- en
zijaanzicht, materiaallijst) — losse wand én unit
wall.js wand-editor (UI: invoer, 2D, interactie, JSON/PDF)
floor/
floor-model.js FloorModel: ENIGE bron van de vloer-logica (balken,
doorvoeren, chassis, 3D-opbouw, specs, validatie)
floor-pdf.js FloorPdf: het vloer-tekenblad — losse vloer én unit
floor.js vloer-editor (UI, uitsluitend cirkelvormige
openingen/doorvoeren)
roof/
roof-model.js RoofModel: plat dak = vloer (FloorModel) 180°
gespiegeld + dakleer; dakrand (boeidelen met spots
of daktrim) als één doorlopende rand om de woning
lighting/
lighting-model.js LightingModel: buitenverlichting per unit (geen /
boeideel-spots / gevellampen), gevellampen per
wandstuk met terugval naar spots, override per wand
unit/
unit-model.js UnitModel: 1 vloer + 4 wanden, hoekaansluiting,
naden (wand vervalt deels/geheel) en wandstukken
house/
house-model.js HouseModel: woning = gekoppelde units + binnenwanden;
posities (koppelen aan zijde + uitlijning), naden,
totalen
house-pdf.js HousePdf: samenhangende PDF (plattegrond, per unit
vloer + wandstukken, binnenwanden, totalen)
house.js woning-editor (UI): navigatieboom, plattegrond met
selecteren/binnenwand tekenen, eigenschappen, 3D;
mount de echte wand-/vloer-editor per onderdeel
app.js menubalk-logica, versienummer, mount/unmount van
de actieve editor
json/
defaults/ referentie-JSON's die tonen wat "Nieuw" oplevert
(worden NIET ingeladen via fetch(), zie hieronder)
premade/ bestemmingsmap voor opgeslagen/geëxporteerde designs
assets/ logo (preloader) + bestemmingsmap voor toekomstige
GLB's/texturen
``` ```
## Bewuste keuzes / bekende beperkingen ## Uitgangspunten
- **Volledig CDN-vrij.** three.js (r128) staat lokaal in `js/vendor/three.min.js`. - **Eén bron per logica.** Rekenwerk en 3D-opbouw zitten alleen in de
OrbitControls is een eigen, compacte implementatie (`js/vendor/OrbitControls.js`) `*Model`/`*Pdf`-modules; editors en woning hergebruiken die.
— het originele addon-bestand was niet beschikbaar om te vendoren, dus is de - **Geen ES-modules, geen CDN.** Gewone `<script>`-tags in vaste volgorde
gebruikte subset (roteren via slepen, zoomen via scrollwiel, pannen via (zie `index.html`), alle libraries lokaal.
rechts-slepen, `target`/`update()`) opnieuw geschreven en losstaand getest - **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
(rotatie/zoom-richting/grenzen/pannen/dispose). Functioneel gelijkwaardig links = west, rechts = oost (interne sleutels blijven voor/achter/…).
voor wat deze tool nodig heeft, maar geen 1-op-1 kopie van alle opties van - **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
de officiële addon (bijv. geen damping/inertia).
- **Geen ES-modules.** Alle JS-bestanden zijn gewone `<script>`-tags, geladen ## Functionaliteit
in een vaste volgorde (`geometry.js` → `canvas-labels.js` → jsPDF →
`pdf-sheet.js` → `wall-model.js` → `wall-pdf.js` → `floor-model.js` → - **Wand:** openingen (raam/deur/voordeur) met ankers, ook boven elkaar
`floor-pdf.js` → `roof-model.js` → `lighting-model.js` → `wall.js` → `floor.js` → `unit-model.js` → `house-model.js` (raam boven een deur; stijlen ertussen van dorpel tot dorpel), regelwerk
→ `house-pdf.js` → `house.js` → `app.js`). 38x235 met dubbele hoekstijlen en een dubbele stijl naast deuren, zaagplan
Reden: ES-module-imports (`import ... from`) breken bij rechtstreeks openen op handelslengtes, spaanplaat binnen, vochtdoek (met kruislat en trasraam)
via `file://` door browser-CORS-beleid. Dit was expliciet een vereiste of cementplaat buiten.
("gewoon via een index file"), dus is hier bewust voor gekozen. - **Wandhoogte:** schuine bovenrand of middenpunt (nok) met goothoogtes. In
- **`json/defaults/*.json` worden niet gefetcht.** Om dezelfde reden (fetch() een unit bepaalt de laatst gewijzigde wand de dakvorm: de tegenoverliggende
van lokale bestanden werkt ook niet betrouwbaar via `file://`): de wand spiegelt mee, de haakse wanden worden vlak op de hoekhoogtes.
standaardwaarden van "Nieuw" staan hardcoded in `wall.js`/`floor.js` zelf, - **Vloer:** balken, cirkelvormige doorvoeren, OSB, optioneel chassis.
en komen overeen met deze JSON-bestanden. De JSON's zijn dus referentie/ - **Woning:** units koppelen aan een zijde (uitlijning + verschuiving); op
documentatie, en (voor wanden) een bestand dat je via "Importeer JSON" kan naden vervallen de buitenwanden en komt een naadwand met deur: midden op
inladen — geen runtime-configuratiebron. de naad in twee delen (één per unit), of als één binnenwand geheel in unit
- **Alleen wall.js heeft Exporteer/Importeer JSON.** floor.js nog niet. A of B. De hoogte volgt per deel de daklijn van zijn unit; steekt een deel
- **Floor.js heeft nu dezelfde soorten logica als wall.js.** 2D-bovenaanzicht boven het dak van de andere unit uit, dan krijgt dat stuk buitenafwerking
+ doorsnede, regelwerk (vloerbalken), cirkelvormige doorvoeren, een (vochtdoek + latwerk) die met de gevel meeloopt.
onderlaag van OSB-plaat (standaard 22mm, dikte + oppervlak + volume + Binnenwanden teken je in de plattegrond.
gewicht), een isolatielaag (leegte tussen de balken, analytisch bepaald — - **Dak:** per unit opgebouwd als gespiegelde vloer + dakleer, in de vorm
net als bij de wand krijgt isolatie zelf bewust geen dichtheid/gewicht), die de wanden aangeven: plat, lessenaar (schuine kopgevels) of zadel
een volwaardig 3D-perspectief (balken/isolatie/OSB met doorvoer-gaten/ (nok). Elk dakvlak is een eigen dek met de balken als sporen en een eigen
chassis-wielen, met aan/uit-toggles voor OSB en isolatie, plus fullscreen- PDF-blad; doorvoeren gaan naar het vlak waarin ze vallen. De dakrand
2D/3D) en een specificaties-paneel met een gewichtstotaal. Losse, (boeidelen/daktrim, spots) loopt als één rand door en volgt de helling.
optionele chassis-laag (4 transportwielen in een "dobbelsteen 4"-indeling, Is een dak langs de naad minstens een boeideelhoogte hoger dan het
exact gecentreerd op de vloer — spoorbreedte/wielbasis/wieldiameter aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak
instelbaar, geen interactie met de balkposities). zonder spots) en loopt de rand van het lage dak door tot de gevel.
- **Single point of truth wand/vloer/unit.** Alle reken- en 3D-logica zit in - **Gevel:** latwerk en vochtdoek lopen per gevel door over unit-naden;
`WallModel`/`FloorModel`. De losse editors én de unit roepen die aan, dus sandwichpanelen rondom, 600 mm de grond in.
een wijziging in de wand- of vloerlogica geldt automatisch overal. De unit - **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
bewaart per onderdeel een gewone wand-/vloer-state; wandbreedtes worden override per wand) en binnenspots in een raster per unit.
afgeleid uit vloermaat + wanddiktes + hoekaansluiting, openingen houden - **3D:** weergavefilters, omgeving Ontwerp/Dag/Nacht, Render met lampen als
hun anker (links/midden/rechts). "Opslaan als" bij een unit geeft JSON + echte lichtbronnen.
één samenhangende PDF: bovenaanzicht unit (U-1), vloer (V-1), elke wand - **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (plattegrond,
(W-1…W-4, via WallPdf/FloorPdf) en een totalenblad (balken, zaaglijst, vloeren, wandstukken, binnenwanden, totalen).
glaswol, plaatwerk, gewichten). Projectgegevens van de unit staan in
`state.project` en gelden voor alle bladen. ## Bekende beperkingen
- **Standaarden (WallModel).** Muurbalk 38 x 235 mm (stijlen én regels),
ingekocht in 3600/4200/4800/5400 mm; een zaagplan (`planStockCutting` in - Units kunnen niet gedraaid worden.
geometry.js) kiest per staaf de lengte met het minste snijverlies — bij een - Getekende binnenwanden volgen de dakvorm (nog) niet.
unit over alle wanden samen. Stukken langer dan 5400 worden opgeknipt. - De dak-editor toont het dek in de plattegrond (niet per schuin dakvlak).
Elke wandkop heeft een dubbele hoekstijl (2 x 38 mm). De app start met een - Geen schaduw per lamp in de render (browserlimiet).
unit. - Eigen OrbitControls: roteren/zoomen/pannen, geen damping.
- **Woning (v0.2).** Bestand → Nieuw → Woning (1 unit) of Woning L-vorm. Een
unit koppel je aan een zijde van een andere unit (uitlijning start/midden/ ## Versie
eind + verschuiving). Op de naad vervalt de wand van beide units over het
gedeelde stuk; een wand kan daardoor in meerdere stukken uiteenvallen `APP_VERSION` in `js/app.js`: laatste cijfer per iteratie, middelste per
(bijv. A-W2a/b), haakse wanden lopen dan door tot de vloerrand. Binnen- mijlpaal (met git-tag), eerste bij live-gang. De changelog staat in git.
wanden teken je in de plattegrond (standaard 100 mm, spaanplaat beide
zijden) en bewerk je met de gewone wand-editor. Een wandstuk dat aan een
naad eindigt loopt de naad in door met de dikte van de haakse buurwand,
zodat binnenhoeken (L-vorm) dicht zijn. Zijden heten naar windrichting
t.o.v. het bovenaanzicht (boven = noord): voor = noord, achter = zuid,
links = west, rechts = oost (intern blijven de oude sleutels). Oude unit-JSON's kunnen in
een woning worden geïmporteerd. Units kunnen (nog) niet gedraaid worden.
- **Mijlpaal v0.4 — gevelopbouw.** Samenvatting van de v0.3.11–0.3.19-iteraties:
kruislat-latwerk op vochtdoek (richting gevelbekleding globaal), lampen op de
afgewerkte gevel (+52 mm), logo-watermerk op het doek, trasraam (cement-
gebonden plaat, 350 mm vanaf het loopvlak), sandwichpanelen rondom (600 mm de
grond in), hoekdoorloop van de buitenlagen over de hoekstijlen, en latwerk/
doek per doorlopende gevel over unit-naden heen. Details per onderdeel hieronder.
- **Plat dak (v0.3).** Elke unit heeft een dak (tweede laag) dat standaard
de vloermaat volgt, maar een eigen maat/verschuiving mag krijgen. Opbouw =
vloer 180° gespiegeld (OSB als plafond onder, balken, dakleer bovenop); het
dak wordt bewerkt met de vloer-editor in dakmodus. De dakrand is woning-
breed: boeidelen (overstek 200–400 mm vanaf de dakrand = standaard de
gevel, 300 mm hoog, spots op 40% van de overstek, op elke (unit-)hoek en
daartussen verdeeld met min/max h.o.h.) of een daktrim van 80 mm. De rand
loopt als één geheel over de naden tussen units. Het dak ligt op de hoogste
wandbovenkant van de unit; bij units met verschillende hoogtes ligt de
dakrand op het hoogste dak.
- **Dakrand & buitenverlichting (v0.3.3).** Boeidelen aan/uit per unit
(anders daktrim); de rand wordt per ribbe opgebouwd (in verstek, geen
zichtbare naad). Verlichting is woning-breed: type (geen / boeideel-spots /
gevellampen), lichtrichting en alle afstanden. Gevellampen worden per
GEVEL verdeeld (aansluitende wandstukken in hetzelfde vlak, ook over een
naad tussen units), niet per wand: op lamphoogte (standaard 1950) alleen op
vrije stukken >= 2000 mm (openingen incl. raamwerk eraf), eerste/laatste
300 mm van hoek/opening, daartussen min/max h.o.h.; smallere stukken krijgen
geen lampen. Gevellampen en boeideel-spots sluiten elkaar uit. De min.
h.o.h. geldt over alle lampen van een gevel samen (ook bij afstand hoek/
opening en overrides): te dicht bij elkaar = samengevoegd tot één lamp in
het midden. Per wandzijde blijft een override: automatisch / 500 mm
naast elke opening / geen. Standaard unithoogte 2600 mm.
- **3D-weergave (v0.3.5).** Filters als dropdown met vinkjes. Woning:
Beplating (wandplaten, vloer- en plafondplaten, dakleer, boeideel/daktrim),
Isolatie, Dak, Lampen. "Volle render": avondscène met maaiveld, elke
gevellamp (per lichtrichting) en boeideel-spot is een echte SpotLight
(zonder schaduwen, om het licht te houden); de nep-lichtkegels zijn dan
verborgen.
- **Latwerk gevelbekleding (v0.3.11).** Bij vochtdoek komt er een kruislat
op (WallModel.computeBattens): latten 20x30 mm, handelslengte 4000, 400 mm
h.o.h. in beide richtingen, uitgespaard rond openingen en afgekapt op de
(schuine) bovenrand. De bovenste laag staat haaks op de gevelbekleding
(WEO/Novalit, 12 mm; richting per losse wand, in een woning voorlopig
globaal als woning-instelling). Gevellampen hangen op de afgewerkte gevel:
wandvlak + kruislat (40) + bekleding (12) = 52 mm naar buiten. Cement-gebonden
platen (steenstrips) krijgen geen latwerk. Latten tellen mee in gewicht,
specificaties, wand-PDF en woningtotalen; zichtbaar met "Beplating".
- **Vochtdoek-bedrukking (v0.3.13).** Net als op de echte doeken staat het logo
als medium-grijs watermerk (geen kleur) onder 45° op het vochtdoek, 1,5 m
breed, herhaald per tegel van 2 m (WallModel.createMaterials, CanvasTexture).
- **Trasraam (v0.3.14).** Woning-instelling (aan/uit + hoogte, standaard 350 mm
vanaf Y=0 van de vloer = het beloopbare vlak / bovenkant vloer). Onderaan
elke vochtdoek-gevel komt een strook cement-gebonden plaat (montage-
achterkant), vóór het doek; het doek loopt er ongewijzigd achter door en
het latwerk begint erboven. Zelfde plaat- en uitsparingslogica als de
buitenplaten, dus openingen blijven gerespecteerd (WallModel.trasraamFor,
build3D opts.trasraam, computeBattens). Telt mee in gewicht, wand-PDF en de
woningtotalen ("Cementplaat (trasraam)").
- **Hoekdoorloop buitenlagen (v0.3.18).** Een wand die tussen twee doorlopende
wanden past, bedekt met zijn buitenlagen ook de kopse kant (hoekstijlen) van
die wanden: UnitModel geeft per wand(stuk) skinExt { left, right } (= dikte
van de doorlopende wand, 0 bij naden/vrije einden), WallModel.cornerExt telt
per laag de eigen diepte erbij. Latwerk en trasraam lopen door; vochtdoek
en cementplaat (liggen in de beplatingsruimte) krijgen een strook net vóór
het wandvlak.
- **Latwerk en doek per gevel (v0.3.19).** HouseModel.assignFacades groepeert
collineaire, aansluitende buitenwandstukken over alle units tot gevels
(groupFacadeRuns in core/geometry.js, ook gebruikt door de buitenverlichting)
en geeft elk stuk p.facade { u0, extStart, isEnd }. Het verticale
latten-raster (400 h.o.h.) en het watermerk op het doek lopen daardoor over
unit-naden door; een eindlat komt alleen aan het einde van de gevel.
- **Sandwichpanelen (v0.3.16).** Woning-instelling aan/uit. SandwichModel
(js/sandwich/sandwich-model.js) legt één doorlopende rand (in verstek) om de
vereniging van alle unit-vloeren: 40 mm dik, van het loopvlak (bovenkant
vloer) 600 mm naar beneden, de grond in. Plaat 495 x 1000 x 40 (9 kg),
verticaal: één plaat per 495 mm gevel, 1000 afgezaagd op 600. Naden per
495 mm; zichtbaar met "Beplating"; m2, aantal platen en gewicht (hele
platen) in de woningtotalen.
- **Openingtypes.** Raam (500x1900), deur (900x2150) en voordeur (1150x2150);
type kiezen zet de standaardmaat, daarna vrij aan te passen. De app start
met een L-woning met een voordeur (noord, unit A) en 3 ramen (zuid, unit B).
- **Woning-instellingen.** Regelwerk-h.o.h. (min/max) geldt globaal voor alle
buitenwanden resp. alle binnenwanden; in de wand-editor zijn die velden
binnen een woning vergrendeld.
- **Versienummer** staat als enkele constante (`APP_VERSION`) in `app.js`.
Conventie: laatste cijfer per kleine iteratie, middelste per mijlpaal
(aangegeven in chat), eerste bij live-gang.
+5 -2
View File
@@ -1,2 +1,5 @@
Bestemmingsmap voor toekomstige assets: GLB-modellen (ramen/deuren), Assets voor de 3D-weergave en de PDF:
texturen/bumpmaps, e.d. Nog leeg. - logo.svg / logo-animated.svg logo (splash, about); PDF-logo staat als data in js/core/logo-data.js
- skybox.webp equirectangulair panorama dag (4096x2048), "Lucht: Dag" (core/skybox.js)
- skybox-night.webp equirectangulair panorama nacht (4096x2048), "Lucht: Nacht"
- tiles.jpg tegeltextuur (1024x1024) voor de ronde vloer onder het object
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

+97
View File
@@ -544,3 +544,100 @@ html, body { height: 100%; }
.vf-menu.open { display: block; } .vf-menu.open { display: block; }
.vf-item { display: flex; align-items: center; gap: 8px; color: #eee; font-size: 13px; padding: 4px 0; cursor: pointer; user-select: none; } .vf-item { 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; }
.ui-section { margin-bottom: 14px; }
.ui-section h2, .ui-spec-card h2 {
display: flex; align-items: center; gap: 7px; margin: 0 0 8px; font-size: 12px; font-weight: 600;
color: var(--primary); text-transform: uppercase; letter-spacing: .6px;
}
.ui-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 28px; }
.ui-field { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 28px; }
.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-control { display: flex; align-items: center; flex: none; }
.ui-control input[type=number], .ui-control select {
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;
}
.ui-control input[type=number] { text-align: right; }
.ui-control input:focus, .ui-control select:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px var(--primary-soft); }
.ui-control input:disabled { opacity: .55; background: var(--border-soft); cursor: not-allowed; }
.ui-unit { width: 26px; margin-left: 5px; font-size: 11px; color: var(--text-faint); }
.ui-hint { color: var(--text-faint); font-size: 11px; cursor: help; }
.ui-hint:hover { color: var(--primary); }
.ui-switch { position: relative; display: inline-block; width: 36px; height: 20px; margin-right: 82px; }
.ui-switch input { opacity: 0; width: 0; height: 0; }
.ui-switch span { position: absolute; inset: 0; background: #c9d1db; border-radius: 20px; cursor: pointer; transition: background .15s; }
.ui-switch span::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: transform .15s; }
.ui-switch input:checked + span { background: var(--primary); }
.ui-switch input:checked + span::before { transform: translateX(16px); }
.ui-switch input:focus-visible + span { box-shadow: 0 0 0 2px var(--primary-soft); }
.ui-info, .ui-warn { display: flex; gap: 8px; align-items: flex-start; font-size: 12px; line-height: 1.5; border-radius: 4px; padding: 7px 10px; margin: 6px 0 10px; }
.ui-info { color: var(--text-muted); background: var(--surface); border: 1px solid var(--border-soft); }
.ui-warn { color: var(--danger); background: var(--danger-soft); border: 1px solid var(--danger); }
.ui-info .bi, .ui-warn .bi { margin-top: 2px; }
.ui-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.ui-btn {
display: inline-flex; align-items: center; gap: 6px; background: var(--surface); border: 1px solid var(--border);
color: var(--text); padding: 5px 11px; border-radius: 4px; font-size: 12px; cursor: pointer; font-family: inherit;
}
.ui-btn:hover { border-color: var(--primary); color: var(--primary); }
.ui-btn.ui-btn-danger { color: var(--danger); border-color: #e3b4b0; }
.ui-btn.ui-btn-danger:hover { background: var(--danger-soft); border-color: var(--danger); }
.ui-btn.ui-btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.ui-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.ui-panel-head h1 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 14px; }
.ui-panel-head .ui-sub { font-size: 12px; color: var(--text-muted); }
.ui-tab-bar { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 0 0 12px; }
.ui-tab {
display: inline-flex; align-items: center; gap: 6px; background: none; border: none; border-bottom: 2px solid transparent;
margin-bottom: -1px; padding: 7px 12px; font-size: 12.5px; color: var(--text-muted); cursor: pointer; font-family: inherit;
}
.ui-tab:hover { color: var(--text); }
.ui-tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; }
.ui-tab-page { display: none; }
.ui-tab-page.active { display: block; }
.ui-spec-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
.ui-spec-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 6px; padding: 10px 12px; }
.ui-spec-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); }
.ui-spec-row:last-child { border-bottom: none; }
.ui-spec-val { font-weight: 600; color: var(--text); text-align: right; font-variant-numeric: tabular-nums; }
.ui-spec-val small { font-weight: normal; color: var(--text-faint); margin-left: 3px; }
.ui-spec-total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 2px; }
.ui-spec-total .ui-spec-val { color: var(--primary); }
.btn-3d-toggle { display: inline-flex; align-items: center; gap: 6px; }
.hn-item .bi { margin-right: 6px; color: var(--text-muted); }
.hn-item.active .bi, .hn-item:hover .bi { color: var(--primary); }
.hn-label { display: inline-flex; align-items: center; }
/* Weergave-cyclus (T): render-only = ook het info-deel onder de 3D weg. */
.render-only #ho-specs, .render-only #specs-panel-3d, .render-only #floor-specs-panel-3d { display: none; }
/* Bestaande editor-panelen (wand/vloer) in dezelfde stijl als de UI-kit. */
.control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; }
.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; }
h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 600; letter-spacing: .6px; }
.tab-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; text-transform: none; font-size: 12.5px; letter-spacing: 0; }
.result-row { padding: 3px 0; border-bottom: 1px dashed var(--border-soft); }
.result-row:last-child { border-bottom: none; }
.result-row .highlight { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
#specs-panel-3d .spec-section, #floor-specs-panel-3d .spec-section {
background: var(--surface); border: 1px solid var(--border-soft); border-radius: 6px; padding: 10px 12px; margin-bottom: 12px;
}
.add-opening-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; text-transform: none; font-size: 12.5px; }
.hn-actions .ui-btn { flex: 1; justify-content: center; }
.hn-title { display: flex; align-items: center; gap: 6px; }
.file-protocol-warning { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 9000; max-width: 640px; box-shadow: 0 8px 20px rgba(33,33,33,.2); }
.file-protocol-warning code { background: rgba(0,0,0,.06); padding: 0 4px; border-radius: 3px; }
.vf-option { gap: 9px; }
.vf-option.active { color: var(--primary-on-dark); font-weight: 600; }
.vf-menu .vf-option:hover { color: var(--primary-on-dark); }
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019-2024 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -10,6 +10,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tectum — Van Emous Mantelzorgwoningen</title> <title>Tectum — Van Emous Mantelzorgwoningen</title>
<link rel="stylesheet" href="css/vendor/bootstrap-icons.min.css">
<link rel="stylesheet" href="css/app.css"> <link rel="stylesheet" href="css/app.css">
<script src="js/vendor/three.min.js"></script> <script src="js/vendor/three.min.js"></script>
<script src="js/vendor/OrbitControls.js"></script> <script src="js/vendor/OrbitControls.js"></script>
@@ -52,8 +53,8 @@
<button id="menu-beeld-btn" class="menu-btn">Beeld</button> <button id="menu-beeld-btn" class="menu-btn">Beeld</button>
<div id="menu-beeld-dropdown" class="menu-dropdown"> <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-fullscreen"><span>Volledig scherm</span><span class="menu-hotkey">F</span></div>
<div class="menu-item" id="menu-3d-fullscreen"><span>3D volledig scherm</span><span class="menu-hotkey">T</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 volledig scherm</span><span class="menu-hotkey">R</span></div> <div class="menu-item" id="menu-2d-fullscreen"><span>2D groot</span><span class="menu-hotkey">R</span></div>
</div> </div>
</div> </div>
@@ -116,6 +117,8 @@
<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/view-filter.js"></script> <script src="js/core/view-filter.js"></script>
<script src="js/core/ui-kit.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/wall-model.js"></script> <script src="js/wall/wall-model.js"></script>
+8 -1
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.0'; const APP_VERSION = '0.4.12';
// 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 = [];
@@ -290,6 +290,13 @@ document.addEventListener('DOMContentLoaded', () => {
wireNewModal(); wireNewModal();
wireHotkeys(); wireHotkeys();
wireTabBar(); wireTabBar();
// Tectum draait via een webserver (texturen, lucht); file:// werkt niet volledig.
if (location.protocol === 'file:') {
const note = document.createElement('div');
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>';
document.body.appendChild(note);
}
openNew('woning', 'L'); openNew('woning', 'L');
setTimeout(() => { setTimeout(() => {
+2 -2
View File
@@ -329,7 +329,7 @@ function offsetRectilinearLoop(pts, d) {
} }
// Gevels: groepeert lijnstukken in de plattegrond tot doorlopende gevels // Gevels: groepeert lijnstukken in de plattegrond tot doorlopende gevels
// (zelfde as + normaal + gevelvlak, aansluitend). segs: [{ axis ('x'|'z'), // (zelfde as + normaal + gevelvlak, aansluitend of overlappend). segs: [{ axis ('x'|'z'),
// normal {x, z}, line (positie op de andere as), s0, s1 (s0 < s1) }, ...]. // normal {x, z}, line (positie op de andere as), s0, s1 (s0 < s1) }, ...].
// Geeft [{ axis, normal, line, s0, s1, segs (op s0 gesorteerd) }]. // Geeft [{ axis, normal, line, s0, s1, segs (op s0 gesorteerd) }].
// Gebruikt door de woning (latwerk/doek-patroon) en de buitenverlichting. // Gebruikt door de woning (latwerk/doek-patroon) en de buitenverlichting.
@@ -338,7 +338,7 @@ function groupFacadeRuns(segs, opts) {
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 && Math.abs(r.s1 - sg.s0) < gapTol); Math.abs(r.line - sg.line) < lineTol && sg.s0 <= r.s1 + gapTol); // aansluitend of overlappend
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] }));
}); });
+216
View File
@@ -0,0 +1,216 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// 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 = (() => {
const PREF_KEY = 'tectum.sky';
const MODES = ['none', 'day', 'night'];
const SKY_URLS = { day: 'assets/skybox.webp', night: 'assets/skybox-night.webp' };
const TILES_URL = 'assets/tiles.jpg';
const CAMERA_HEIGHT_M = 1.7; // hoogte van de panoramacamera boven de grond
const RADIUS_M = 60; // straal van de omgevingsbol
const TILE_M = 2.4; // één tegeltextuur (1024 px) beslaat zoveel meter
const MARGIN_M = 1.5; // vrije rand tussen object en opstaande rand
const SIZE_FACTOR = 1.2; // tegelvloer 20% ruimer dan object + marge
const RIM_HEIGHT_M = 0.10, RIM_THICK_M = 0.025;
const NIGHT_FACTOR = 0.15; // omgevingslicht 's nachts
const SUN_OFFSET = { x: 8, y: 14, z: 6 };
const cache = {};
function loadTexture(url) {
if (!cache[url]) {
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(url, resolve, undefined, reject));
cache[url].catch(() => { delete cache[url]; });
}
return cache[url];
}
function buildSky(map) {
const geometry = new THREE.SphereGeometry(RADIUS_M, 256, 128);
geometry.scale(1, 1, -1); // binnenkant zichtbaar
const pos = geometry.getAttribute('position');
const v = new THREE.Vector3();
const h = CAMERA_HEIGHT_M, y1 = -h * 3 / 2;
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i);
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));
v.multiplyScalar(f);
pos.setXYZ(i, v.x, v.y, v.z);
}
}
pos.needsUpdate = true;
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.renderOrder = -1;
return mesh;
}
// Ronde tegelvloer (straal r) + witte opstaande rand erlangs.
function buildTerrace(map, r) {
const group = new THREE.Group();
const tex = map.clone();
tex.needsUpdate = true;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.anisotropy = 8;
tex.repeat.set(2 * r / TILE_M, 2 * r / TILE_M);
const floor = new THREE.Mesh(new THREE.CircleGeometry(r, 128), new THREE.MeshStandardMaterial({ map: tex, roughness: 0.85 }));
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
group.add(floor);
const ring = new THREE.Shape();
ring.absarc(0, 0, r + RIM_THICK_M, 0, Math.PI * 2, false);
const hole = new THREE.Path();
hole.absarc(0, 0, r, 0, Math.PI * 2, true);
ring.holes.push(hole);
const rim = new THREE.Mesh(
new THREE.ExtrudeGeometry(ring, { depth: RIM_HEIGHT_M, bevelEnabled: false, curveSegments: 128 }),
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 })
);
rim.rotation.x = -Math.PI / 2; // extrusie omhoog
rim.castShadow = rim.receiveShadow = true;
group.add(rim);
return group;
}
// Omgeving voor één scene: { group, setMode(mode), fitTo(object3D, groundY) }.
function createEnvironment(scene) {
const group = new THREE.Group();
group.visible = false;
scene.add(group);
const skies = {};
let terrace = null, terraceR = 0, tilesTex = null, mode = 'none';
function showSky() {
Object.keys(skies).forEach(k => { skies[k].visible = k === mode; });
if (mode === 'none' || skies[mode]) return;
const wanted = mode;
loadTexture(SKY_URLS[wanted]).then(tex => {
if (!skies[wanted]) { skies[wanted] = buildSky(tex); group.add(skies[wanted]); }
Object.keys(skies).forEach(k => { skies[k].visible = k === mode; });
}).catch(() => console.warn('Tectum: lucht niet geladen (draait de app via een webserver?)'));
}
function setTerrace(r) {
if (terrace && Math.abs(r - terraceR) < 0.05) return;
terraceR = r;
const build = () => {
if (terrace) { group.remove(terrace); terrace.traverse(o => { if (o.geometry) o.geometry.dispose(); }); }
terrace = buildTerrace(tilesTex, terraceR);
group.add(terrace);
};
if (tilesTex) build();
else loadTexture(TILES_URL).then(t => { tilesTex = t; build(); }).catch(() => {});
}
return {
group,
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) {
if (!object3D) return;
const box = new THREE.Box3().setFromObject(object3D);
if (box.isEmpty()) return;
const c = box.getCenter(new THREE.Vector3());
group.position.set(c.x, groundY, c.z);
setTerrace((Math.hypot(box.max.x - box.min.x, box.max.z - box.min.z) / 2 + MARGIN_M) * SIZE_FACTOR);
}
};
}
function getMode() {
try {
const v = localStorage.getItem(PREF_KEY);
if (MODES.includes(v)) return v;
return localStorage.getItem('tectum.skybox') === '1' ? 'day' : 'none'; // oude aan/uit-voorkeur
} catch (e) { return 'none'; }
}
function remember(m) {
try { localStorage.setItem(PREF_KEY, m); } catch (e) { /* geen opslag: alleen deze sessie */ }
}
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) {
const on = m !== 'none';
o.env.setMode(m);
if (o.grid) o.grid.visible = !on;
if (o.controls) o.controls.maxPolarAngle = on ? Math.PI / 2 - 0.03 : Math.PI - 0.001;
const f = lightFactor(m);
if (o.ambient && o.base) o.ambient.intensity = o.base.ambient * f;
if (o.sun && o.base) o.sun.intensity = o.base.sun * f;
if (o.sun) {
o.sun.castShadow = on;
if (on) {
const p = o.env.group.position, cam = o.sun.shadow.camera;
cam.left = cam.bottom = -20; cam.right = cam.top = 20;
cam.near = 0.5; cam.far = 80;
cam.updateProjectionMatrix();
o.sun.shadow.mapSize.set(2048, 2048);
o.sun.shadow.bias = -0.0005;
o.sun.position.set(p.x + SUN_OFFSET.x, p.y + SUN_OFFSET.y, p.z + SUN_OFFSET.z);
o.sun.target.position.copy(p);
o.sun.target.updateMatrixWorld();
}
}
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'] };
function createToggle(toolbar, onChange) {
let m = getMode();
const wrap = document.createElement('div');
wrap.className = 'view-filter';
wrap.innerHTML = `<button type="button" class="btn-3d-toggle vf-btn" title="Omgeving"></button>
<div class="vf-menu">${MODES.map(k => `<div class="vf-item vf-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</div>`).join('')}</div>`;
toolbar.appendChild(wrap);
const btn = wrap.querySelector('.vf-btn'), menu = wrap.querySelector('.vf-menu');
const sync = () => {
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i>${LABELS[m][1]}<i class="bi bi-chevron-down"></i>`;
menu.querySelectorAll('.vf-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
};
btn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('open'); });
menu.addEventListener('click', (e) => {
e.stopPropagation();
const opt = e.target.closest('.vf-option');
if (!opt) return;
menu.classList.remove('open');
if (opt.dataset.mode === m) return;
m = opt.dataset.mode;
remember(m);
sync();
onChange(m);
});
const closeOnOutside = () => {
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
menu.classList.remove('open');
};
document.addEventListener('click', closeOnOutside);
sync();
return wrap;
}
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, createEnvironment, apply, getMode, lightFactor, createToggle };
})();
+121
View File
@@ -0,0 +1,121 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// 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 = (() => {
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const icon = (name, cls) => `<i class="bi bi-${name}${cls ? ' ' + cls : ''}" aria-hidden="true"></i>`;
const hint = (text) => text ? `<span class="ui-hint" title="${esc(text)}">${icon('info-circle')}</span>` : '';
function field(id, label, control, o) {
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) {
o = o || {};
const unit = o.unit === undefined ? 'mm' : o.unit;
return field(id, label,
`<input type="number" id="${id}" value="${value}" step="${o.step || 50}"${o.min !== undefined ? ` min="${o.min}"` : ''}${o.disabled ? ' disabled' : ''}>` +
`<span class="ui-unit">${unit}</span>`, o);
}
// Keuzelijst. options: { waarde: 'Label' }.
function sel(id, label, options, value, o) {
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);
}
// Aan/uit-schakelaar.
function toggle(id, label, checked, o) {
return field(id, label,
`<label class="ui-switch"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}><span></span></label><span class="ui-unit"></span>`, o);
}
const section = (title, iconName, body) =>
`<section class="ui-section"><h2>${iconName ? icon(iconName) : ''}${title}</h2>${body}</section>`;
const info = (text) => `<div class="ui-info">${icon('info-circle')}<span>${text}</span></div>`;
const warn = (text) => `<div class="ui-warn">${icon('exclamation-triangle')}<span>${text}</span></div>`;
const grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`;
const button = (label, iconName, attrs, cls) =>
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
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 = {};
function tabs(group, list) {
const active = list.some(t => t.key === activeTabs[group]) ? activeTabs[group] : list[0].key;
return `<div class="ui-tabs" data-tab-group="${group}">
<div class="ui-tab-bar">${list.map(t => `<button type="button" class="ui-tab${t.key === active ? ' active' : ''}" data-tab="${t.key}">${t.icon ? icon(t.icon) : ''}<span>${t.label}</span></button>`).join('')}</div>
${list.map(t => `<div class="ui-tab-page${t.key === active ? ' active' : ''}" data-tab-page="${t.key}">${t.html}</div>`).join('')}
</div>`;
}
// Eenmalig per container: klik op een tab wisselt de pagina.
function wireTabs(container) {
if (container._uiTabsWired) return;
container._uiTabsWired = true;
container.addEventListener('click', (e) => {
const btn = e.target.closest('.ui-tab');
if (!btn) return;
const wrap = btn.closest('.ui-tabs');
activeTabs[wrap.dataset.tabGroup] = btn.dataset.tab;
wrap.querySelectorAll('.ui-tab').forEach(b => b.classList.toggle('active', b === btn));
wrap.querySelectorAll('.ui-tab-page').forEach(p => p.classList.toggle('active', p.dataset.tabPage === btn.dataset.tab));
});
}
// Specificaties: kaart met regels [label, waarde, eenheid].
function specCard(title, iconName, rows, total) {
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>`;
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) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (isFinite(v) && (!(o && o.positive) || v > 0)) { fn(v); after && after(); }
});
}
function onSel(id, fn, after) {
const el = document.getElementById(id);
if (el) el.addEventListener('input', (e) => { fn(e.target.value); after && after(); });
}
function onToggle(id, fn, after) {
const el = document.getElementById(id);
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) {
el.classList.remove('fullscreen-2d');
if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d');
else if (!el.classList.contains('render-only')) el.classList.add('render-only');
else el.classList.remove('fullscreen-3d', 'render-only');
}
function toggle2D(el) {
el.classList.remove('fullscreen-3d', 'render-only');
el.classList.toggle('fullscreen-2d');
}
return { esc, icon, hint, num, sel, toggle, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
})();
+1 -1
View File
@@ -21,7 +21,7 @@ function createViewFilter(toolbar, items, onChange) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'view-filter'; wrap.className = 'view-filter';
wrap.innerHTML = ` wrap.innerHTML = `
<button type="button" class="btn-3d-toggle vf-btn">Weergave &#9662;</button> <button type="button" class="btn-3d-toggle vf-btn"><i class="bi bi-eye"></i>Weergave<i class="bi bi-chevron-down"></i></button>
<div class="vf-menu"> <div class="vf-menu">
${items.map(it => `<label class="vf-item"><input type="checkbox" data-key="${it.key}" ${state[it.key] ? 'checked' : ''}> ${it.label}</label>`).join('')} ${items.map(it => `<label class="vf-item"><input type="checkbox" data-key="${it.key}" ${state[it.key] ? 'checked' : ''}> ${it.label}</label>`).join('')}
</div>`; </div>`;
+30 -27
View File
@@ -114,8 +114,12 @@ window.FloorModel = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Balkverdeling: zelfde algoritme (calculateSegmentPositions) als de wand. // Balkverdeling: zelfde algoritme (calculateSegmentPositions) als de wand.
// params = state.floor (of de ruwe invoerwaarden uit de editor). // 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) { function computeFrame(params, rims) {
const width = params.width || 2000; const width = params.width || 2000;
const depth = params.depth || 2000; const depth = params.depth || 2000;
const joistW = params.joistWidth || 70; const joistW = params.joistWidth || 70;
@@ -131,7 +135,9 @@ window.FloorModel = (() => {
calculateSegmentPositions(width - joistW, minSp, maxSp).forEach(p => joists.push(leftJoistX + p)); calculateSegmentPositions(width - joistW, minSp, maxSp).forEach(p => joists.push(leftJoistX + p));
joists = [...new Set(joists.map(x => Math.round(x)))].sort((a, b) => a - b); joists = [...new Set(joists.map(x => Math.round(x)))].sort((a, b) => a - b);
return { joists, width, depth, joistW, joistH, underlayThick }; const r = { front: !!(rims && rims.front), back: !!(rims && rims.back) };
const joistLen = depth - (r.front ? joistW : 0) - (r.back ? joistW : 0);
return { joists, width, depth, joistW, joistH, underlayThick, rims: r, joistLen };
} }
// Bovenkant vloer (mm) = waar de wanden van een unit op staan. // Bovenkant vloer (mm) = waar de wanden van een unit op staan.
@@ -151,8 +157,10 @@ window.FloorModel = (() => {
const { width, depth, joists, joistW, joistH, underlayThick } = calcData; const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
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;
const rimCount = calcData.rims ? (calcData.rims.front ? 1 : 0) + (calcData.rims.back ? 1 : 0) : 0;
const joistLenM = (calcData.joistLen || depth) * toM;
const joistWoodM3 = joists.length * joistWM * joistHM * depthM; const joistWoodM3 = joists.length * joistWM * joistHM * joistLenM + rimCount * joistWM * joistHM * widthM;
const joistWoodKg = joistWoodM3 * DENSITY_WOOD_KGM3; const joistWoodKg = joistWoodM3 * DENSITY_WOOD_KGM3;
const grossAreaM2 = widthM * depthM; const grossAreaM2 = widthM * depthM;
@@ -197,8 +205,9 @@ window.FloorModel = (() => {
} }
// Alles in één keer vanuit een vloer-state (handig voor de unit). // Alles in één keer vanuit een vloer-state (handig voor de unit).
function computeFromState(state) { // rims: zie computeFrame.
const calcData = computeFrame(state.floor); function computeFromState(state, 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);
const chassisData = computeChassisWheels(state.chassis, calcData.width, calcData.depth); const chassisData = computeChassisWheels(state.chassis, calcData.width, calcData.depth);
return { calcData, resolvedOpenings, chassisData }; return { calcData, resolvedOpenings, chassisData };
@@ -208,9 +217,6 @@ window.FloorModel = (() => {
return { return {
woodMaterial: new THREE.MeshStandardMaterial({ color: 0xc29b62, roughness: 0.6 }), woodMaterial: new THREE.MeshStandardMaterial({ color: 0xc29b62, roughness: 0.6 }),
osbMaterial: new THREE.MeshStandardMaterial({ color: 0xcda86a, roughness: 0.75, side: THREE.DoubleSide }), osbMaterial: new THREE.MeshStandardMaterial({ color: 0xcda86a, roughness: 0.75, side: THREE.DoubleSide }),
isolatieMaterial: new THREE.MeshStandardMaterial({
color: 0xffe08c, roughness: 0.9, transparent: true, opacity: 0.5, side: THREE.DoubleSide
}),
wheelMaterial: new THREE.MeshStandardMaterial({ color: 0x151515, roughness: 0.5 }) wheelMaterial: new THREE.MeshStandardMaterial({ color: 0x151515, roughness: 0.5 })
}; };
} }
@@ -220,12 +226,11 @@ window.FloorModel = (() => {
// Z = -diepte/2), Y = dikte (balkhoogte + OSB). Balken en isolatie zijn // Z = -diepte/2), Y = dikte (balkhoogte + OSB). Balken en isolatie zijn
// simpele dozen; de OSB-onderlaag is een geëxtrudeerd Shape met ronde // simpele dozen; de OSB-onderlaag is een geëxtrudeerd Shape met ronde
// uitsparingen voor de doorvoeren. // uitsparingen voor de doorvoeren.
// opts: { materials, osbVisible, isolatieVisible } // opts: { materials, osbVisible }
// ---------------------------------------------------------- // ----------------------------------------------------------
function build3D(calcData, resolvedOpenings, chassisData, opts) { function build3D(calcData, resolvedOpenings, chassisData, opts) {
const { woodMaterial, osbMaterial, isolatieMaterial, wheelMaterial } = opts.materials; const { woodMaterial, osbMaterial, wheelMaterial } = opts.materials;
const osbVisible = opts.osbVisible !== false; const osbVisible = opts.osbVisible !== false;
const isolatieVisible = opts.isolatieVisible !== false;
const floorGroup3D = new THREE.Group(); const floorGroup3D = new THREE.Group();
const toM = 0.001; const toM = 0.001;
@@ -233,28 +238,26 @@ 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, lopen over de volle diepte door. // A. VLOERBALKEN — dozen over de diepte (ingekort bij een kopbalk), plus
// de kopbalken (rims) over de volle breedte.
const rims = calcData.rims || {};
const joistLenM = (calcData.joistLen || depth) * toM;
const joistZ = ((rims.front ? joistW : 0) - (rims.back ? joistW : 0)) / 2 * toM;
joists.forEach(xMM => { joists.forEach(xMM => {
const geo = new THREE.BoxGeometry(joistWM, joistHM, depthM); 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, 0); mesh.position.set(xMM * toM, joistHM / 2, joistZ);
mesh.castShadow = true;
floorGroup3D.add(mesh);
});
[['front', -1], ['back', 1]].filter(([k]) => rims[k]).forEach(([, sign]) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(widthM, joistHM, joistWM), woodMaterial);
mesh.position.set(0, joistHM / 2, sign * (depthM - joistWM) / 2);
mesh.castShadow = true; mesh.castShadow = true;
floorGroup3D.add(mesh); floorGroup3D.add(mesh);
}); });
// B. ISOLATIE — vult elk balkvak (tussen twee opeenvolgende balken) over // (Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.)
// de volle balkhoogte/diepte. Zelfde "leegte tussen het regelwerk"-
// gedachte als bij wall.js, hier ook echt gevisualiseerd als laag.
const bays = computeBays(joists, joistW);
bays.forEach(bay => {
const bayWidthM = (bay.right - bay.left) * toM;
if (bayWidthM <= 0) return;
const geo = new THREE.BoxGeometry(bayWidthM, joistHM, depthM);
const mesh = new THREE.Mesh(geo, isolatieMaterial);
mesh.position.set(((bay.left + bay.right) / 2) * toM, joistHM / 2, 0);
mesh.visible = isolatieVisible;
floorGroup3D.add(mesh);
});
// C. OSB-ONDERLAAG — één geëxtrudeerd vlak over de volle vloer, met // C. OSB-ONDERLAAG — één geëxtrudeerd vlak over de volle vloer, met
// ronde uitsparingen voor de doorvoeren. Shape wordt opgebouwd in // ronde uitsparingen voor de doorvoeren. Shape wordt opgebouwd in
+14 -2
View File
@@ -46,8 +46,15 @@ window.FloorPdf = (() => {
doc.setLineWidth(0.15); doc.setLineWidth(0.15);
doc.line(tblX[0], ty, tblX[4] + 12, ty); doc.line(tblX[0], ty, tblX[4] + 12, ty);
ty += 4; ty += 4;
[String(1), String(joistW), String(joistH), String(Math.round(depth)), String(joists.length)] const rims = calcData.rims || {}, joistLen = calcData.joistLen || depth;
const rimCount = (rims.front ? 1 : 0) + (rims.back ? 1 : 0);
[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) {
ty += 4.3; // kopbalk(en) op een naad (FloorModel.computeFrame)
[String(2), String(joistW), String(joistH), String(Math.round(width)), String(rimCount)]
.forEach((v, i) => doc.text(v, tblX[i], ty));
}
// --- Plaatmateriaal + isolatie --- // --- Plaatmateriaal + isolatie ---
let my = 240; let my = 240;
@@ -89,11 +96,16 @@ window.FloorPdf = (() => {
doc.setLineWidth(0.3); doc.setLineWidth(0.3);
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
doc.setFontSize(5); doc.setFontSize(5);
const jz0 = rims.front ? joistW : 0;
joists.forEach(x => { joists.forEach(x => {
const xLeft = x + width / 2 - joistW / 2; const xLeft = x + width / 2 - joistW / 2;
doc.rect(px(xLeft), py(0), joistW * scale, depth * scale, 'S'); doc.rect(px(xLeft), py(jz0), joistW * scale, joistLen * scale, 'S');
doc.text('1', px(xLeft + joistW / 2), py(depth / 2), { align: 'center' }); doc.text('1', px(xLeft + joistW / 2), py(depth / 2), { align: 'center' });
}); });
[['front', 0], ['back', depth - joistW]].filter(([k]) => rims[k]).forEach(([, z]) => {
doc.rect(px(0), py(z), width * scale, joistW * scale, 'S');
doc.text('2', px(width / 2), py(z + joistW / 2) + 1.2, { align: 'center' });
});
doc.setDrawColor(...RED); doc.setDrawColor(...RED);
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
+83 -81
View File
@@ -32,11 +32,11 @@ const FLOOR_TEMPLATE = `
<div id="floor-left-column"> <div id="floor-left-column">
<div id="floor-pane-2d-container"> <div id="floor-pane-2d-container">
<div class="pane-2d-sub" id="floor-pane-top"> <div class="pane-2d-sub" id="floor-pane-top">
<div class="pane-label">2D BOVENAANZICHT</div> <div class="pane-label">BOVENAANZICHT</div>
<canvas id="floor-canvas-top"></canvas> <canvas id="floor-canvas-top"></canvas>
</div> </div>
<div class="pane-2d-sub" id="floor-pane-section"> <div class="pane-2d-sub" id="floor-pane-section">
<div class="pane-label">DOORSNEDE (BALKPROFIEL)</div> <div class="pane-label">DOORSNEDE</div>
<canvas id="floor-canvas-section"></canvas> <canvas id="floor-canvas-section"></canvas>
</div> </div>
</div> </div>
@@ -46,59 +46,59 @@ const FLOOR_TEMPLATE = `
<span id="f-io-status" class="io-status"></span> <span id="f-io-status" class="io-status"></span>
</div> </div>
<div class="tab-bar"> <div class="tab-bar">
<button class="tab-btn active" data-tab="floor-tab-main">1. Vloer Opbouw</button> <button class="tab-btn active" data-tab="floor-tab-main"><i class="bi bi-rulers"></i> Opbouw</button>
<button class="tab-btn" data-tab="floor-tab-openings">2. Doorvoeren</button> <button class="tab-btn" data-tab="floor-tab-openings"><i class="bi bi-bullseye"></i> Doorvoeren</button>
<button class="tab-btn" data-tab="floor-tab-chassis">3. Chassis</button> <button class="tab-btn" data-tab="floor-tab-chassis"><i class="bi bi-truck"></i> Chassis</button>
</div> </div>
<div id="floor-tab-main" class="tab-content active"> <div id="floor-tab-main" class="tab-content active">
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<h1>Vloer Dimensies (mm)</h1> <h1><i class="bi bi-rulers"></i> Afmetingen</h1>
<div class="control-group"><label>Breedte:</label> <div class="control-group"><label>Breedte</label>
<div><input type="number" id="f-inp-width" value="2000" step="50"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-width" value="2000" step="50"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Diepte:</label> <div class="control-group"><label>Diepte</label>
<div><input type="number" id="f-inp-depth" value="2000" step="50"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-depth" value="2000" step="50"><span class="unit">mm</span></div></div>
<div class="divider"></div> <div class="divider"></div>
<h1>Vloerbalken</h1> <h1><i class="bi bi-columns-gap"></i> Vloerbalken</h1>
<div class="control-group"><label>Balk Breedte:</label> <div class="control-group"><label>Breedte</label>
<div><input type="number" id="f-inp-joist-w" value="70" step="5"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-joist-w" value="70" step="5"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Balk Hoogte:</label> <div class="control-group"><label>Hoogte</label>
<div><input type="number" id="f-inp-joist-h" value="220" step="5"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-joist-h" value="220" step="5"><span class="unit">mm</span></div></div>
<div class="divider"></div> <div class="divider"></div>
<h1>Onderlaag (OSB-plaat)</h1> <h1><i class="bi bi-layers"></i> Onderlaag OSB</h1>
<div class="control-group"><label>Dikte:</label> <div class="control-group"><label>Dikte</label>
<div><input type="number" id="f-inp-underlay-t" value="22" step="1" min="3" max="40"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-underlay-t" value="22" step="1" min="3" max="40"><span class="unit">mm</span></div></div>
</div> </div>
<div> <div>
<h1>Regelwerk Afstand (h.o.h.)</h1> <h1><i class="bi bi-arrows"></i> Regelwerk</h1>
<div class="control-group"><label>Min. h.o.h.:</label> <div class="control-group"><label>Afstand min.</label>
<div><input type="number" id="f-inp-min-sp" value="300" step="10"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-min-sp" value="300" step="10"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Max. h.o.h.:</label> <div class="control-group"><label>Afstand max.</label>
<div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div>
<div class="divider"></div> <div class="divider"></div>
<div class="result-row"><span>Totaal vloerbalken:</span><span data-spec="joist-count" class="highlight">-</span></div> <div class="result-row"><span>Vloerbalken</span><span data-spec="joist-count" class="highlight">-</span></div>
<div class="result-row"><span>Vloeroppervlak:</span><span data-spec="floor-area" class="highlight">-</span></div> <div class="result-row"><span>Vloeroppervlak</span><span data-spec="floor-area" class="highlight">-</span></div>
<div class="result-row"><span>Volume balkhout:</span><span data-spec="joist-m3" class="highlight">-</span></div> <div class="result-row"><span>Volume balkhout</span><span data-spec="joist-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht balkhout:</span><span data-spec="joist-kg" class="highlight">-</span></div> <div class="result-row"><span>Gewicht balkhout</span><span data-spec="joist-kg" class="highlight">-</span></div>
<div class="divider"></div> <div class="divider"></div>
<div class="result-row"><span>OSB-plaat oppervlak:</span><span data-spec="osb-m2" class="highlight">-</span></div> <div class="result-row"><span>OSB oppervlak</span><span data-spec="osb-m2" class="highlight">-</span></div>
<div class="result-row"><span>OSB-plaat volume:</span><span data-spec="osb-m3" class="highlight">-</span></div> <div class="result-row"><span>OSB volume</span><span data-spec="osb-m3" class="highlight">-</span></div>
<div class="result-row"><span>OSB-plaat gewicht:</span><span data-spec="osb-kg" class="highlight">-</span></div> <div class="result-row"><span>OSB gewicht</span><span data-spec="osb-kg" class="highlight">-</span></div>
<div class="divider"></div> <div class="divider"></div>
<div class="result-row"><span>Isolatie-inhoud (leegte):</span><span data-spec="void-m3" class="highlight">-</span></div> <div class="result-row"><span>Isolatie</span><span data-spec="void-m3" class="highlight">-</span></div>
<div class="result-row"><span>Totaal gewicht vloer:</span><span data-spec="total-kg" class="highlight">-</span></div> <div class="result-row"><span>Totaal gewicht</span><span data-spec="total-kg" class="highlight">-</span></div>
</div> </div>
</div> </div>
</div> </div>
<div id="floor-tab-openings" class="tab-content"> <div id="floor-tab-openings" class="tab-content">
<button id="f-btn-add-opening" class="add-opening-btn">+ Doorvoer Toevoegen</button> <button id="f-btn-add-opening" class="add-opening-btn"><i class="bi bi-plus-lg"></i> Doorvoer toevoegen</button>
<div id="f-openings-global-warn" class="global-warning" style="display:none;"></div> <div id="f-openings-global-warn" class="global-warning" style="display:none;"></div>
<div id="f-openings-list"></div> <div id="f-openings-list"></div>
</div> </div>
@@ -109,13 +109,13 @@ const FLOOR_TEMPLATE = `
</div> </div>
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<div class="control-group"><label>Wieldiameter:</label> <div class="control-group"><label>Wieldiameter</label>
<div><input type="number" id="f-inp-wheel-d" value="400" step="10" min="50"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-wheel-d" value="400" step="10" min="50"><span class="unit">mm</span></div></div>
</div> </div>
<div> <div>
<div class="control-group"><label>Spoorbreedte (X):</label> <div class="control-group"><label>Spoorbreedte</label>
<div><input type="number" id="f-inp-track" value="1200" step="50" min="100"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-track" value="1200" step="50" min="100"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Wielbasis (Y):</label> <div class="control-group"><label>Wielbasis</label>
<div><input type="number" id="f-inp-wheelbase" value="1200" step="50" min="100"><span class="unit">mm</span></div></div> <div><input type="number" id="f-inp-wheelbase" value="1200" step="50" min="100"><span class="unit">mm</span></div></div>
</div> </div>
</div> </div>
@@ -126,7 +126,7 @@ const FLOOR_TEMPLATE = `
<div id="floor-right-column"> <div id="floor-right-column">
<div id="floor-pane-3d"> <div id="floor-pane-3d">
<div class="pane-label">3D PERSPECTIEF</div> <div class="pane-label">3D</div>
<div class="toolbar-3d" id="floor-toolbar-3d"></div> <div class="toolbar-3d" id="floor-toolbar-3d"></div>
</div> </div>
@@ -134,27 +134,27 @@ const FLOOR_TEMPLATE = `
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<div class="spec-section"> <div class="spec-section">
<h1>Balken</h1> <h1><i class="bi bi-columns-gap"></i> Balken</h1>
<div class="result-row"><span>Vloerbalken:</span><span data-spec="joist-count" class="highlight">-</span></div> <div class="result-row"><span>Vloerbalken</span><span data-spec="joist-count" class="highlight">-</span></div>
<div class="result-row"><span>Vloeroppervlak:</span><span data-spec="floor-area" class="highlight">-</span></div> <div class="result-row"><span>Vloeroppervlak</span><span data-spec="floor-area" class="highlight">-</span></div>
</div> </div>
<div class="spec-section"> <div class="spec-section">
<h1>Houtwerk</h1> <h1><i class="bi bi-tree"></i> Houtwerk</h1>
<div class="result-row"><span>Volume balkhout:</span><span data-spec="joist-m3" class="highlight">-</span></div> <div class="result-row"><span>Volume balkhout</span><span data-spec="joist-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht balkhout:</span><span data-spec="joist-kg" class="highlight">-</span></div> <div class="result-row"><span>Gewicht balkhout</span><span data-spec="joist-kg" class="highlight">-</span></div>
</div> </div>
</div> </div>
<div> <div>
<div class="spec-section"> <div class="spec-section">
<h1>OSB-plaat (onderlaag, 22mm)</h1> <h1><i class="bi bi-layers"></i> OSB onderlaag</h1>
<div class="result-row"><span>Oppervlak:</span><span data-spec="osb-m2" class="highlight">-</span></div> <div class="result-row"><span>Oppervlak</span><span data-spec="osb-m2" class="highlight">-</span></div>
<div class="result-row"><span>Volume:</span><span data-spec="osb-m3" class="highlight">-</span></div> <div class="result-row"><span>Volume</span><span data-spec="osb-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht:</span><span data-spec="osb-kg" class="highlight">-</span></div> <div class="result-row"><span>Gewicht</span><span data-spec="osb-kg" class="highlight">-</span></div>
</div> </div>
<div class="spec-section"> <div class="spec-section">
<h1>Isolatie &amp; totaal</h1> <h1><i class="bi bi-speedometer2"></i> Isolatie en gewicht</h1>
<div class="result-row"><span>Inhoud leegtes:</span><span data-spec="void-m3" class="highlight">-</span></div> <div class="result-row"><span>Isolatie</span><span data-spec="void-m3" class="highlight">-</span></div>
<div class="result-row"><span>Totaal gewicht vloer:</span><span data-spec="total-kg" class="highlight">-</span></div> <div class="result-row"><span>Totaal gewicht</span><span data-spec="total-kg" class="highlight">-</span></div>
</div> </div>
</div> </div>
</div> </div>
@@ -170,23 +170,23 @@ const FLOOR_TEMPLATE = `
</div> </div>
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<div class="control-group"><label for="inp-proj-naam">Projectnaam:</label> <div class="control-group"><label for="inp-proj-naam">Projectnaam</label>
<div><input type="text" id="inp-proj-naam" value=""></div></div> <div><input type="text" id="inp-proj-naam" value=""></div></div>
<div class="control-group"><label for="inp-proj-plaats">Plaats:</label> <div class="control-group"><label for="inp-proj-plaats">Plaats</label>
<div><input type="text" id="inp-proj-plaats" value=""></div></div> <div><input type="text" id="inp-proj-plaats" value=""></div></div>
<div class="control-group"><label for="inp-proj-straat">Straat:</label> <div class="control-group"><label for="inp-proj-straat">Straat</label>
<div><input type="text" id="inp-proj-straat" value=""></div></div> <div><input type="text" id="inp-proj-straat" value=""></div></div>
<div class="control-group"><label for="inp-proj-nummer">Projectnummer:</label> <div class="control-group"><label for="inp-proj-nummer">Projectnummer</label>
<div><input type="text" id="inp-proj-nummer" value=""></div></div> <div><input type="text" id="inp-proj-nummer" value=""></div></div>
</div> </div>
<div> <div>
<div class="control-group"><label for="inp-proj-tekenaar">Tekenaar:</label> <div class="control-group"><label for="inp-proj-tekenaar">Tekenaar</label>
<div><input type="text" id="inp-proj-tekenaar" value=""></div></div> <div><input type="text" id="inp-proj-tekenaar" value=""></div></div>
<div class="control-group"><label for="inp-proj-titel">Titel:</label> <div class="control-group"><label for="inp-proj-titel">Titel</label>
<div><input type="text" id="inp-proj-titel" value="Vloer"></div></div> <div><input type="text" id="inp-proj-titel" value="Vloer"></div></div>
<div class="control-group"><label for="inp-proj-element">Element nummer:</label> <div class="control-group"><label for="inp-proj-element">Elementnummer</label>
<div><input type="text" id="inp-proj-element" value=""></div></div> <div><input type="text" id="inp-proj-element" value=""></div></div>
<div class="control-group"><label for="inp-proj-opmerking">Opmerking:</label> <div class="control-group"><label for="inp-proj-opmerking">Opmerking</label>
<div><input type="text" id="inp-proj-opmerking" value=""></div></div> <div><input type="text" id="inp-proj-opmerking" value=""></div></div>
</div> </div>
</div> </div>
@@ -203,18 +203,16 @@ const FLOOR_TEMPLATE = `
// (RoofModel). Zelfde editor, andere teksten + gespiegelde doorsnede/3D. // (RoofModel). Zelfde editor, andere teksten + gespiegelde doorsnede/3D.
function floorTemplateForRoof() { function floorTemplateForRoof() {
const r = [ const r = [
['1. Vloer Opbouw', '1. Dak Opbouw'], [' Vloerbalken</h1>', ' Dakbalken</h1>'],
['Vloer Dimensies (mm)', 'Dak Dimensies (mm)'], ['<span>Vloerbalken</span>', '<span>Dakbalken</span>'],
['<h1>Vloerbalken</h1>', '<h1>Dakbalken</h1>'], [' Onderlaag OSB</h1>', ' Plafondplaat OSB</h1>'],
['Onderlaag (OSB-plaat)', 'Plafondplaat (OSB, onderzijde)'], [' OSB onderlaag</h1>', ' OSB plafond</h1>'],
['Totaal vloerbalken:', 'Totaal dakbalken:'], ['<button class="tab-btn" data-tab="floor-tab-chassis"><i class="bi bi-truck"></i> Chassis</button>', ''],
['OSB-plaat (onderlaag, 22mm)', 'OSB-plaat (plafond)'], [' Isolatie en gewicht</h1>', ' Dakleer, isolatie en gewicht</h1>\n <div class="result-row"><span>Dakleer oppervlak</span><span data-spec="dakleer-m2" class="highlight">-</span></div>\n <div class="result-row"><span>Dakleer gewicht</span><span data-spec="dakleer-kg" class="highlight">-</span></div>']
['<button class="tab-btn" data-tab="floor-tab-chassis">3. Chassis</button>', ''],
['<h1>Isolatie &amp; totaal</h1>', '<h1>Dakleer, isolatie &amp; totaal</h1>\n <div class="result-row"><span>Dakleer oppervlak:</span><span data-spec="dakleer-m2" class="highlight">-</span></div>\n <div class="result-row"><span>Dakleer gewicht:</span><span data-spec="dakleer-kg" class="highlight">-</span></div>']
]; ];
let t = FLOOR_TEMPLATE; let t = FLOOR_TEMPLATE;
r.forEach(([a, b]) => { t = t.split(a).join(b); }); r.forEach(([a, b]) => { t = t.split(a).join(b); });
return t.split('Vloeroppervlak:').join('Dakoppervlak:').split('Totaal gewicht vloer:').join('Totaal gewicht dak:'); return t.split('Vloeroppervlak').join('Dakoppervlak');
} }
function mountFloorEditor(container, callbacks) { function mountFloorEditor(container, callbacks) {
@@ -262,9 +260,9 @@ function mountFloorEditor(container, callbacks) {
}); });
function anchorLabel(a) { function anchorLabel(a) {
if (a === 'right') return 'Afstand vanaf oostzijde (X)'; if (a === 'right') return 'Afstand tot oostzijde';
if (a === 'middle') return 'Verschuiving t.o.v. midden (X)'; if (a === 'middle') return 'Verschuiving t.o.v. midden';
return 'Afstand vanaf westzijde (X)'; return 'Afstand tot westzijde';
} }
function renderOpeningsList() { function renderOpeningsList() {
@@ -277,13 +275,13 @@ function mountFloorEditor(container, callbacks) {
</div> </div>
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<div class="control-group"><label>Diameter:</label> <div class="control-group"><label>Diameter</label>
<div><input type="number" data-field="diameter" data-id="${op.id}" value="${op.diameter}" step="5" min="10"><span class="unit">mm</span></div></div> <div><input type="number" data-field="diameter" data-id="${op.id}" value="${op.diameter}" step="5" min="10"><span class="unit">mm</span></div></div>
<div class="control-group"><label>Afstand vanaf noordzijde (Y):</label> <div class="control-group"><label>Afstand tot noordzijde</label>
<div><input type="number" data-field="offsetY" data-id="${op.id}" value="${op.offsetY}" step="50"><span class="unit">mm</span></div></div> <div><input type="number" data-field="offsetY" data-id="${op.id}" value="${op.offsetY}" step="50"><span class="unit">mm</span></div></div>
</div> </div>
<div> <div>
<div class="control-group"><label>Anker (X):</label> <div class="control-group"><label>Anker</label>
<div> <div>
<select data-field="anchorX" data-id="${op.id}"> <select data-field="anchorX" data-id="${op.id}">
<option value="left" ${op.anchorX === 'left' ? 'selected' : ''}>West</option> <option value="left" ${op.anchorX === 'left' ? 'selected' : ''}>West</option>
@@ -292,7 +290,7 @@ function mountFloorEditor(container, callbacks) {
</select> </select>
</div> </div>
</div> </div>
<div class="control-group"><label>${anchorLabel(op.anchorX)}:</label> <div class="control-group"><label>${anchorLabel(op.anchorX)}</label>
<div><input type="number" data-field="offsetX" data-id="${op.id}" value="${op.offsetX}" step="50"><span class="unit">mm</span></div></div> <div><input type="number" data-field="offsetX" data-id="${op.id}" value="${op.offsetX}" step="50"><span class="unit">mm</span></div></div>
</div> </div>
</div> </div>
@@ -702,18 +700,19 @@ function mountFloorEditor(container, callbacks) {
controls.update(); controls.update();
} }
scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const floorAmbient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(floorAmbient);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 8, 5); dirLight.position.set(5, 8, 5);
dirLight.castShadow = true; dirLight.castShadow = true;
scene.add(dirLight); scene.add(dirLight);
scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x222222)); const grid = new THREE.GridHelper(10, 20, 0x444444, 0x222222);
scene.add(grid);
const floorMaterials = roofMode ? RoofModel.createMaterials() : FloorModel.createMaterials(); const floorMaterials = roofMode ? RoofModel.createMaterials() : FloorModel.createMaterials();
let osbVisible = true; let osbVisible = true;
let isolatieVisible = true;
let floorGroup3D = null; let floorGroup3D = null;
let lastResolvedOpenings = []; let lastResolvedOpenings = [];
let lastChassisData = { wheels: [], outOfBounds: false }; let lastChassisData = { wheels: [], outOfBounds: false };
@@ -721,20 +720,27 @@ function mountFloorEditor(container, callbacks) {
// Weergave-filter (core/view-filter.js). Beplating = OSB-plaat (bij een // Weergave-filter (core/view-filter.js). Beplating = OSB-plaat (bij een
// dak: plafondplaat + dakleer). // 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)' }
{ key: 'isolatie', label: 'Isolatie' }
], (v) => { ], (v) => {
osbVisible = v.beplating; osbVisible = v.beplating;
isolatieVisible = v.isolatie;
update3DScene(); update3DScene();
}); });
// Omgeving (core/skybox.js: geen/dag/nacht); de vloer staat op de tegelvloer.
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => {
skyEnv.fitTo(floorGroup3D, 0);
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient: floorAmbient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: floorGroup3D });
};
Skybox.createToggle(document.getElementById('floor-toolbar-3d'), (m) => { skyMode = m; applySky(); });
function update3DScene() { function update3DScene() {
if (floorGroup3D) scene.remove(floorGroup3D); if (floorGroup3D) scene.remove(floorGroup3D);
floorGroup3D = roofMode floorGroup3D = roofMode
? RoofModel.build3D(calcData, lastResolvedOpenings, { materials: floorMaterials, osbVisible, isolatieVisible, dakleerVisible: osbVisible }) ? RoofModel.build3D(calcData, lastResolvedOpenings, { materials: floorMaterials, osbVisible, dakleerVisible: osbVisible })
: FloorModel.build3D(calcData, lastResolvedOpenings, lastChassisData, { materials: floorMaterials, osbVisible, isolatieVisible }); : FloorModel.build3D(calcData, lastResolvedOpenings, lastChassisData, { materials: floorMaterials, osbVisible });
scene.add(floorGroup3D); scene.add(floorGroup3D);
applySky(); // schaduw van de nieuwe vloer
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -778,15 +784,11 @@ function mountFloorEditor(container, callbacks) {
window.addEventListener('resize', onWindowResize); window.addEventListener('resize', onWindowResize);
function toggleFullscreen3D() { function toggleFullscreen3D() {
const el = document.getElementById('floor-app'); UI.cycle3D(document.getElementById('floor-app')); // T: standaard -> 3D groot -> alleen render
el.classList.remove('fullscreen-2d');
el.classList.toggle('fullscreen-3d');
requestAnimationFrame(onWindowResize); requestAnimationFrame(onWindowResize);
} }
function toggleFullscreen2D() { function toggleFullscreen2D() {
const el = document.getElementById('floor-app'); UI.toggle2D(document.getElementById('floor-app'));
el.classList.remove('fullscreen-3d');
el.classList.toggle('fullscreen-2d');
requestAnimationFrame(onWindowResize); requestAnimationFrame(onWindowResize);
} }
+258 -45
View File
@@ -65,6 +65,7 @@ window.HouseModel = (() => {
// vanaf het beloopbare vloervlak (WallModel.trasraamFor). // vanaf het beloopbare vloervlak (WallModel.trasraamFor).
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM }, trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM },
sandwich: true, // sandwichpanelen rondom (SandwichModel), vanaf loopvlak 600 mm de grond in sandwich: true, // sandwichpanelen rondom (SandwichModel), vanaf loopvlak 600 mm de grond in
groundLevel: 350, // maaiveld: zoveel mm onder het loopvlak (bovenkant vloer); voor de 3D-omgeving
roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS), roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS),
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS) lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS)
}; };
@@ -91,6 +92,7 @@ window.HouseModel = (() => {
{ id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450 } { id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450 }
]; ];
} }
ensureSeamWalls(state);
computeParts(state, { skip3D: true }); computeParts(state, { skip3D: true });
return state; return state;
} }
@@ -207,15 +209,55 @@ window.HouseModel = (() => {
return wall; return wall;
} }
// Dikte = regelwerk + 2x beplating (WallModel.thicknessOf); de diepte van // Dikte = regelwerk + beplating (2x, of 1x bij een naadwand-deel met
// stijlen en regels wordt daarop afgestemd. // buitenzijde 'geen'); de diepte van stijlen en regels wordt daarop afgestemd.
const skinSides = (wallState) => ((wallState.materials || {}).exterior === 'geen' ? 1 : 2);
function setThickness(wallState, thickness) { function setThickness(wallState, thickness) {
const depth = Math.max(20, Math.round(thickness - 2 * wallState.wall.sheathingThick)); const depth = Math.max(20, Math.round(thickness - skinSides(wallState) * wallState.wall.sheathingThick));
wallState.wall.studDepth = depth; wallState.wall.studDepth = depth;
wallState.wall.plateDepth = depth; wallState.wall.plateDepth = depth;
} }
function thicknessOf(wallState) { function thicknessOf(wallState) {
return Math.max(wallState.wall.studDepth, wallState.wall.plateDepth) + 2 * wallState.wall.sheathingThick; return Math.max(wallState.wall.studDepth, wallState.wall.plateDepth) + skinSides(wallState) * wallState.wall.sheathingThick;
}
// ------------------------------------------------------------
// Naadwanden: op elke naad tussen twee units staat standaard een tussenwand
// met in het midden een deur. Hij bestaat uit twee delen, één per unit
// (de modules worden los vervoerd), die met hun regelwerk tegen elkaar aan
// staan: balken en isolatie in beide delen, beplating alleen aan de
// kamerzijde (buitenMateriaal 'geen' aan de naadzijde). Eén wand-state
// (hoogte, openingen) voor beide delen; de openingen gelden voor het deel
// in unit `seam.a` en worden in het andere deel gespiegeld, zodat de deur
// recht door beide delen gaat. De gebruiker kan hem aanpassen of
// verwijderen; alleen bij een nieuwe woning en bij "unit toevoegen" worden
// ontbrekende naadwanden aangemaakt (ensureSeamWalls).
// state.interiorWalls: { id, seam: { a, b }, wall }
// ------------------------------------------------------------
const DEFAULT_SEAM_LEAF_MM = 100; // dikte per deel (regelwerk + 1x beplating)
function seamWallDefaults(height) {
const wall = WallEditor.getDefaultState();
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = height || wall.wall.heightAll;
wall.materials = { interior: 'spaanplaat', exterior: 'geen' };
const d = WallModel.OPENING_TYPES.deur;
wall.openings = [{ id: 1, type: 'deur', w: d.w, h: d.h, y: d.y, anchor: 'middle', offset: 0 }];
setThickness(wall, DEFAULT_SEAM_LEAF_MM);
return wall;
}
const sameSeam = (s, a, b) => (s.a === a && s.b === b) || (s.a === b && s.b === a);
// Maakt ontbrekende naadwanden aan (alleen naden met onlyUnitId, indien gegeven).
function ensureSeamWalls(state, onlyUnitId) {
const { seams } = computeSeams(unitRects(state));
seams.forEach(sm => {
if (onlyUnitId && sm.a !== onlyUnitId && sm.b !== onlyUnitId) return;
if (state.interiorWalls.some(iw => iw.seam && sameSeam(iw.seam, sm.a, sm.b))) return;
const id = state.interiorWalls.reduce((m, w) => Math.max(m, w.id), 0) + 1;
const height = Math.min(...[sm.a, sm.b].map(uid => {
const w = state.units.find(u => u.id === uid).unit.walls.voor.wall;
return Math.min(w.heightLeft, w.heightRight);
}));
state.interiorWalls.push({ id, seam: { a: sm.a, b: sm.b }, wall: seamWallDefaults(height) });
});
} }
function interiorLayout(iw) { function interiorLayout(iw) {
@@ -232,7 +274,7 @@ window.HouseModel = (() => {
// ------------------------------------------------------------ // ------------------------------------------------------------
// Alle onderdelen doorgerekend (+ 3D, tenzij opts.skip3D). // Alle onderdelen doorgerekend (+ 3D, tenzij opts.skip3D).
// opts: { wallMaterials, floorMaterials, beplatingVisible, isolatieVisible, skip3D } // opts: { wallMaterials, floorMaterials, beplatingVisible, skip3D }
// ------------------------------------------------------------ // ------------------------------------------------------------
// Past de woning-brede instellingen toe op de onderdeel-states (muteert // Past de woning-brede instellingen toe op de onderdeel-states (muteert
// state): h.o.h. van alle wanden, en de dakmaat als die de vloer volgt. // state): h.o.h. van alle wanden, en de dakmaat als die de vloer volgt.
@@ -261,15 +303,16 @@ window.HouseModel = (() => {
// { u0, extStart, isEnd } (zie WallModel.computeBattens): u0 = afstand // { u0, extStart, isEnd } (zie WallModel.computeBattens): u0 = afstand
// langs het wandvlak van het gevelbegin (links, van buitenaf gezien, incl. // langs het wandvlak van het gevelbegin (links, van buitenaf gezien, incl.
// hoekdoorloop van het eerste stuk) tot de linkerrand van het stuk. // hoekdoorloop van het eerste stuk) tot de linkerrand van het stuk.
function assignFacades(units) { // pieces: buitenwandstukken + naadwanddelen met een blootliggend buitendeel.
function assignFacades(pieces) {
const segs = []; const segs = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { pieces.forEach(p => {
const W = p.place.width, T = p.place.thickness; const W = p.place.width, T = p.place.thickness;
const normal = { x: p.place.normal.x, z: p.place.normal.y }; const normal = { x: p.place.normal.x, z: p.place.normal.y };
const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z', perp = axis === 'x' ? 'z' : 'x'; const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z', perp = axis === 'x' ? 'z' : 'x';
const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2); const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2);
segs.push({ piece: p, axis, normal, line: q0[perp], sAt0: q0[axis], sAtW: q1[axis], s0: Math.min(q0[axis], q1[axis]), s1: Math.max(q0[axis], q1[axis]) }); segs.push({ piece: p, axis, normal, line: q0[perp], sAt0: q0[axis], sAtW: q1[axis], s0: Math.min(q0[axis], q1[axis]), s1: Math.max(q0[axis], q1[axis]) });
}))); });
groupFacadeRuns(segs).forEach(run => { groupFacadeRuns(segs).forEach(run => {
const dir = Math.sign(run.segs[0].sAtW - run.segs[0].sAt0) || 1; const dir = Math.sign(run.segs[0].sAtW - run.segs[0].sAt0) || 1;
const sLeft = dir > 0 ? run.s0 : run.s1, sRight = dir > 0 ? run.s1 : run.s0; const sLeft = dir > 0 ? run.s0 : run.s1, sRight = dir > 0 ? run.s1 : run.s0;
@@ -291,12 +334,11 @@ window.HouseModel = (() => {
const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side])); const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side]));
const { cuts, seams } = computeSeams(rects, wallThickness); const { cuts, seams } = computeSeams(rects, wallThickness);
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade) { function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow) {
if (!build) return { group: null, stats: null, specs: null }; if (!build) return { group: null, stats: null, specs: null };
const built = WallModel.build3D(config, frame, { const built = WallModel.build3D(config, frame, {
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow,
spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false, spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false
isolatieVisible: o.isolatieVisible !== false
}); });
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) }; return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
} }
@@ -304,10 +346,13 @@ window.HouseModel = (() => {
const units = state.units.map(u => { const units = state.units.map(u => {
const rect = rectById[u.id]; const rect = rectById[u.id];
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]); const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
const floorData = FloorModel.computeFromState(u.unit.floor); // Kopbalk op een naad langs voor-/achterrand (haaks op de balken): daar
// worden de modules weer aan elkaar gemonteerd. Zelfde voor het dak.
const rims = { front: cuts[u.id].voor.length > 0, back: cuts[u.id].achter.length > 0 };
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings); const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, { const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
materials: o.floorMaterials, osbVisible: o.beplatingVisible !== false, isolatieVisible: o.isolatieVisible !== false materials: o.floorMaterials, osbVisible: o.beplatingVisible !== false
}) : null; }) : null;
const walls = layout.walls.map(wl => { const walls = layout.walls.map(wl => {
@@ -328,22 +373,34 @@ window.HouseModel = (() => {
return Object.assign({}, wl, { pieces, dropped: wp.dropped, removed: pieces.length === 0 }); return Object.assign({}, wl, { pieces, dropped: wp.dropped, removed: pieces.length === 0 });
}); });
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), op de hoogste // Dak: dek = vloer-state (180° gespiegeld, RoofModel), in de vorm die
// wandbovenkant van deze unit. // de wanden aangeven (plat, lessenaar of zadel; RoofModel.computeShape).
const roofData = FloorModel.computeFromState(u.roof.deck); const shape = RoofModel.computeShape(u.unit, layout);
const roofSpecs = RoofModel.computeSpecs(roofData.calcData, roofData.resolvedOpenings); const deckFrame = FloorModel.computeFrame(u.roof.deck.floor);
const wallTop = Math.max(...Object.values(u.unit.walls).map(ws => Math.max(ws.wall.heightLeft, ws.wall.heightRight))); const roofW = deckFrame.width, roofD = deckFrame.depth;
const roofW = roofData.calcData.width, roofD = roofData.calcData.depth; const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0;
const roofCx = rect.cx + (u.roof.offsetX || 0), roofCz = rect.cz + (u.roof.offsetZ || 0); const ur = RoofModel.computeUnitRoof(u.roof, shape, { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }, rims);
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel' }[shape.type];
const multi = ur.planes.length > 1;
ur.planes.forEach((pl, i) => {
pl.element = `${u.id}-D${i + 1}`;
const deg = Math.round(Math.abs(pl.theta) * 180 / Math.PI * 10) / 10;
pl.label = `Unit ${u.id} dak${multi ? ' deel ' + (i + 1) : ''} (${shapeLabel}${deg ? ', ' + String(deg).replace('.', ',') + '°' : ''})`;
});
const roof = { const roof = {
element: `${u.id}-D1`, data: roofData, specs: roofSpecs, element: `${u.id}-D1`, shape, planes: ur.planes, specs: ur.specs,
rect: { x0: roofCx - roofW / 2, x1: roofCx + roofW / 2, z0: roofCz - roofD / 2, z1: roofCz + roofD / 2, cx: roofCx, cz: roofCz, W: roofW, D: roofD }, rect: { x0: roofCx - roofW / 2, x1: roofCx + roofW / 2, z0: roofCz - roofD / 2, z1: roofCz + roofD / 2, cx: roofCx, cz: roofCz, W: roofW, D: roofD },
base: layout.floorTop + wallTop, base: layout.floorTop, // 3D: group-Y = 0 op het loopvlak
top: layout.floorTop + wallTop + RoofModel.thickness(roofData.calcData), // Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut).
ceilingAt: (x, z) => layout.floorTop + ur.undersideAt(x - rect.cx, z - rect.cz),
topAt: (x, z) => layout.floorTop + ur.topAt(x - rect.cx, z - rect.cz),
ridges: ur.ridges.map(r => ({ axis: r.axis, value: r.value + (r.axis === 'x' ? rect.cx : rect.cz) })),
top: layout.floorTop + ur.maxTop,
// Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM) // Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM)
// horen bij "Beplating", de dakisolatie bij "Isolatie". // horen bij "Beplating".
group: build ? RoofModel.build3D(roofData.calcData, roofData.resolvedOpenings, { group: build ? RoofModel.buildUnit3D(ur, {
materials: o.roofMaterials, isolatieVisible: o.isolatieVisible !== false, materials: o.roofMaterials,
osbVisible: o.beplatingVisible !== false, osbVisible: o.beplatingVisible !== false,
dakleerVisible: o.beplatingVisible !== false dakleerVisible: o.beplatingVisible !== false
}) : null }) : null
@@ -355,38 +412,188 @@ window.HouseModel = (() => {
}; };
}); });
// Doek en latwerk volgen de hele gevel (niet per unit/wandstuk).
assignFacades(units);
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
Object.assign(p, buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade));
})));
// Binnenwanden staan op de vloer van de unit waarin hun midden valt. // Binnenwanden staan op de vloer van de unit waarin hun midden valt.
const interior = state.interiorWalls.map((iw, i) => { // Naadwanden (iw.seam) leveren twee delen op, één per unit (seamLeaves).
const interior = [];
let bwNr = 0, nwNr = 0;
state.interiorWalls.forEach(iw => {
if (iw.seam) { nwNr++; seamLeaves(iw, nwNr).forEach(l => interior.push(l)); return; }
bwNr++;
const lay = interiorLayout(iw); const lay = interiorLayout(iw);
const host = units.find(u => lay.cx >= u.rect.x0 && lay.cx <= u.rect.x1 && lay.cz >= u.rect.z0 && lay.cz <= u.rect.z1); const host = units.find(u => lay.cx >= u.rect.x0 && lay.cx <= u.rect.x1 && lay.cz >= u.rect.z0 && lay.cz <= u.rect.z1);
const floorTop = host ? host.layout.floorTop : Math.max(...units.map(u => u.layout.floorTop)); const floorTop = host ? host.layout.floorTop : Math.max(...units.map(u => u.layout.floorTop));
const config = WallModel.configFromState(iw.wall); const config = WallModel.configFromState(iw.wall);
const frame = WallModel.computeFrame(config); const frame = WallModel.computeFrame(config);
const buitenMateriaal = (iw.wall.materials || {}).exterior || 'spaanplaat'; const buitenMateriaal = (iw.wall.materials || {}).exterior || 'spaanplaat';
return Object.assign({ interior.push({
id: iw.id, element: `BW-${i + 1}`, label: `Binnenwand ${i + 1}`, config, frame, buitenMateriaal, id: iw.id, element: `BW-${bwNr}`, label: `Binnenwand ${bwNr}`, config, frame, buitenMateriaal,
place: { cx: lay.cx, cz: lay.cz, rotY: lay.rotY, width: lay.width, thickness: WallModel.thicknessOf(config), floorTop } place: { cx: lay.cx, cz: lay.cz, rotY: lay.rotY, width: lay.width, thickness: WallModel.thicknessOf(config), floorTop }
}, buildWall(config, frame, buitenMateriaal)); });
});
// Twee delen van een naadwand. Elk deel staat met zijn regelwerk op de
// naad (buitenzijde 'geen' = naadzijde) in zijn eigen unit. Een deel loopt van wand tot wand: waar een buitenwandstuk de hartlijn
// van het deel kruist, stopt het (voetafdrukken, zoals bij de
// gevellampen). De hoogte volgt de daklijn van de buitenwand van de unit
// aan de naadzijde (RoofModel.wallProfile), zodat het deel onder het dak
// van zijn eigen unit past. Steekt een deel boven het dak van de andere
// unit uit (bijv. hoge unit tegen een lage), dan krijgt dat stuk van de
// naadzijde buitenafwerking (vochtdoek + latwerk, skinFrom) die met de
// gevel van de woning meeloopt; het regelwerk blijft dat van de naadwand.
function seamLeaves(iw, nr) {
const sm = seams.find(x => sameSeam(x, iw.seam.a, iw.seam.b));
if (!sm) return []; // units niet (meer) tegen elkaar: naadwand vervalt
const horiz = Math.abs(sm.z0 - sm.z1) < EPS;
// Buitenwand van de unit aan de naadzijde (side +1: unit a, lage kant).
const seamSide = (side) => horiz ? (side > 0 ? 'achter' : 'voor') : (side > 0 ? 'rechts' : 'links');
const unitState = (uid) => state.units.find(u => u.id === uid).unit;
const baseConfig = WallModel.configFromState(iw.wall);
const maxD = Math.max(baseConfig.studDepth, baseConfig.plateDepth);
const T1 = WallModel.thicknessOf(baseConfig, 'geen');
// Plaats (iw.seam.in): leeg = midden op de naad (twee delen), 'a'/'b' =
// geheel in die unit: één binnenwand (beplating aan beide zijden) tegen
// de naad aan, met de hoogte van de daklijn van die unit.
const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
const T2 = WallModel.thicknessOf(baseConfig, 'spaanplaat');
const extPieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => extPieces.push(p))));
const along = (q) => horiz ? q.x : q.z, perpOf = (q) => horiz ? q.z : q.x;
const seamPerp = horiz ? sm.z0 : sm.x0;
const s0 = horiz ? sm.x0 : sm.z0, s1 = horiz ? sm.x1 : sm.z1;
const leaves = [];
[[sm.a, 1], [sm.b, -1]].forEach(([uid, side]) => {
// side +1: unit a ligt aan de lage kant, de naad aan zijn +kant.
if (inUnit && uid !== inUnit) return;
const T = inUnit ? T2 : T1;
const line = seamPerp - side * T / 2;
const blocked = [];
extPieces.forEach(p => {
const cs = [[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([i, j]) => UnitModel.localToWorld(p.place, i * p.place.width / 2, j * p.place.thickness / 2));
const pMin = Math.min(...cs.map(perpOf)), pMax = Math.max(...cs.map(perpOf));
if (line > pMin + 0.5 && line < pMax - 0.5) blocked.push([Math.min(...cs.map(along)), Math.max(...cs.map(along))]);
});
let cursor = s0, best = null;
blocked.sort((p, q) => p[0] - q[0]).concat([[s1, s1]]).forEach(([a, b]) => {
const hi = Math.min(a, s1);
if (hi - cursor > (best ? best[1] - best[0] : 0)) best = [cursor, hi];
cursor = Math.max(cursor, b);
});
if (!best || best[1] - best[0] < 300) return;
const width = Math.round(best[1] - best[0]), mid = (best[0] + best[1]) / 2;
const rotY = horiz ? (side > 0 ? 0 : Math.PI) : (side > 0 ? Math.PI / 2 : -Math.PI / 2);
const place = {
cx: horiz ? mid : line, cz: horiz ? line : mid, rotY, width, thickness: T,
normal: { x: Math.round(Math.sin(rotY)), y: Math.round(Math.cos(rotY)) }, // naadzijde
floorTop: units.find(u => u.id === uid).layout.floorTop
};
leaves.push({ uid, side, place });
});
if (!leaves.length) return [];
// Hoogtes van een deel: daklijn van de buitenwand aan de naadzijde, op
// de uiteinden van het deel (incl. nok als die erin valt).
const heightsOf = (lf) => {
const u = units.find(x => x.id === lf.uid);
const prof = RoofModel.wallProfile(unitState(lf.uid), u.layout, seamSide(lf.side));
const axisOf = (q) => horiz ? q.x - u.rect.cx : q.z - u.rect.cz;
const W = lf.place.width;
const pL = axisOf(UnitModel.localToWorld(lf.place, -W / 2, 0)), pR = axisOf(UnitModel.localToWorld(lf.place, W / 2, 0));
const peak = prof.length > 2 ? prof[1] : null;
const hasPeak = !!peak && peak.p > Math.min(pL, pR) + 1 && peak.p < Math.max(pL, pR) - 1;
return {
heightLeft: Math.round(RoofModel.profileHeight(prof, pL)), heightRight: Math.round(RoofModel.profileHeight(prof, pR)),
heightMid: hasPeak ? Math.round(peak.h) : null, peakX: hasPeak ? Math.abs(peak.p - pL) : undefined
};
};
const paramsOf = (lf) => Object.assign({}, iw.wall.wall, { width: lf.place.width }, heightsOf(lf));
// Laagste bovenkant dak van de andere unit langs dit deel (mm vanaf het
// loopvlak van het deel), of null als het deel er niet bovenuit steekt.
const exposedFrom = (lf, config) => {
const other = units.find(u => u.id === (lf.uid === sm.a ? sm.b : sm.a));
const W = lf.place.width;
const ends = [-W / 2, W / 2].map(lx => along(UnitModel.localToWorld(lf.place, lx, 0)));
const lo = Math.min(...ends), hi = Math.max(...ends);
const ts = ends.concat(other.roof.ridges.filter(r => r.axis === (horiz ? 'x' : 'z') && r.value > lo && r.value < hi).map(r => r.value));
const from = Math.min(...ts.map(t => horiz ? other.roof.topAt(t, seamPerp) : other.roof.topAt(seamPerp, t))) - lf.place.floorTop;
return from < WallModel.topLine(config).maxY - 50 ? Math.max(0, Math.round(from)) : null;
};
// Openingen: opgeslagen voor het eerste deel; het andere deel krijgt
// dezelfde openingen op dezelfde plek in de wereld (gespiegeld).
const first = leaves[0];
const firstParams = paramsOf(first);
// Voor de wand-editor: breedte en daklijn van het eerste deel.
Object.assign(iw.wall.wall, { width: first.place.width, heightLeft: firstParams.heightLeft, heightRight: firstParams.heightRight, heightMid: firstParams.heightMid });
const firstConfig = WallModel.buildConfig(firstParams, iw.wall.openings || []);
const toLocalU = (pl, q) => {
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
return (q.x - pl.cx) * c - (q.z - pl.cz) * sn + pl.width / 2;
};
return leaves.map((lf, k) => {
let config = firstConfig;
if (k > 0) {
const ops = firstConfig.openings.map(op => {
const wa = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x, 0);
const wb = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x + op.w, 0);
const u = Math.min(toLocalU(lf.place, wa), toLocalU(lf.place, wb));
return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y, anchor: 'left', offset: Math.round(u) };
});
config = WallModel.buildConfig(paramsOf(lf), ops);
}
const frame = WallModel.computeFrame(config);
const skinFrom = exposedFrom(lf, config);
return {
id: iw.id, seam: iw.seam, seamUnit: lf.uid,
element: `NW-${nr}${lf.uid}`, label: `Naadwand ${nr} (unit ${lf.uid})`,
config, frame, place: lf.place,
// Het regelwerk staat gecentreerd op lokaal z = 0, de beplating
// alleen aan de achterzijde: bij het bouwen schuiven zodat het
// midden van de totale dikte op place.cx/cz ligt (regelwerk op de naad).
// Geheel in één unit: beplating aan beide zijden, dus symmetrisch.
shiftZ: inUnit ? 0 : (T1 - maxD) / 2,
...(skinFrom === null
? { buitenMateriaal: inUnit ? 'spaanplaat' : 'geen' }
: { buitenMateriaal: 'vochtdoek', skinFrom, spaanplaatBelow: !!inUnit, cladding: state.settings.cladding || 'horizontaal', trasraam: null })
};
});
}
// Doek en latwerk volgen de hele gevel (niet per unit/wandstuk), ook
// over het blootliggende deel van een naadwand. Daarna pas bouwen.
const extPieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => extPieces.push(p))));
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
extPieces.concat(interior).forEach(p => {
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow);
if (built.group && p.shiftZ) {
const outer = new THREE.Group();
built.group.position.z = p.shiftZ * 0.001;
outer.add(built.group);
built.group = outer;
}
Object.assign(p, built);
}); });
// Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken; // Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken;
// boeidelen aan/uit per unit. // boeidelen aan/uit per unit. De rand volgt de dakvorm van de unit waar
// de ribbe bij hoort; platte daken delen één randhoogte (de hoogste).
const flatTops = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top);
const flatTop = flatTops.length ? Math.max(...flatTops) : 0;
const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof, const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof,
units.map(u => ({ unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false })), units.map(u => ({
Math.max(...units.map(u => u.roof.top))) : null; unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false,
topAt: u.roof.shape.type === 'plat' ? () => flatTop : u.roof.topAt, ridges: u.roof.ridges
}))) : null;
// Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots). // Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots).
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge); const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
// Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen". // Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen".
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, { const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
edgeVisible: o.beplatingVisible !== false, spotsVisible: o.lampenVisible !== false edgeVisible: o.beplatingVisible !== false, spotsVisible: o.lampenVisible !== false
}) : null; }) : null;
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials()) : null; // Binnenspots in het plafond, per unit (LightingModel.computeIndoor).
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false; if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false;
// Sandwichpanelen rondom de vloeren, vanaf het loopvlak naar beneden. // Sandwichpanelen rondom de vloeren, vanaf het loopvlak naar beneden.
@@ -425,9 +632,13 @@ window.HouseModel = (() => {
} }
parts.units.forEach(u => { parts.units.forEach(u => {
const fc = u.floorData.calcData; const fc = u.floorData.calcData;
fc.joists.forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.depth)); fc.joists.forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.joistLen));
const rc = u.roof.data.calcData; ['front', 'back'].filter(k => fc.rims[k]).forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.width));
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.depth)); u.roof.planes.forEach(pl => {
const rc = pl.data.calcData;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen));
['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width));
});
}); });
const wallBom = []; const wallBom = [];
pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length); })); pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length); }));
@@ -501,6 +712,7 @@ window.HouseModel = (() => {
} : null, } : null,
lamps: { lamps: {
total: parts.lighting.lamps.length, total: parts.lighting.lamps.length,
indoor: (parts.lighting.indoor || []).length,
both: parts.lighting.lamps.filter(l => l.direction === 'both').length, both: parts.lighting.lamps.filter(l => l.direction === 'both').length,
down: parts.lighting.lamps.filter(l => l.direction === 'down').length, down: parts.lighting.lamps.filter(l => l.direction === 'down').length,
up: parts.lighting.lamps.filter(l => l.direction === 'up').length up: parts.lighting.lamps.filter(l => l.direction === 'up').length
@@ -543,6 +755,7 @@ window.HouseModel = (() => {
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false; state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM; if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
state.settings.sandwich = state.settings.sandwich !== false; state.settings.sandwich = state.settings.sandwich !== false;
if (!(state.settings.groundLevel >= 0)) state.settings.groundLevel = def.groundLevel;
const oldRoof = state.settings.roof || {}; // v0.3.0: edge/spots waren woning-breed const oldRoof = state.settings.roof || {}; // v0.3.0: edge/spots waren woning-breed
state.settings.roof = RoofModel.normalizeSettings(oldRoof); state.settings.roof = RoofModel.normalizeSettings(oldRoof);
// v0.3.1: type/richting van de verlichting stonden per unit; nu woning-breed. // v0.3.1: type/richting van de verlichting stonden per unit; nu woning-breed.
@@ -563,7 +776,7 @@ window.HouseModel = (() => {
return { return {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs, clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls,
computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize
}; };
})(); })();
+6 -5
View File
@@ -39,7 +39,7 @@ window.HousePdf = (() => {
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 })));
list.push({ kind: 'roof', element: u.roof.element, label: `Unit ${u.id} dak (plat)`, unit: u }); u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
}); });
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' });
@@ -146,7 +146,7 @@ window.HousePdf = (() => {
}))); })));
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
parts.interior.forEach(p => { parts.interior.forEach(p => {
doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: p.place.rotY === 0 ? 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 // Buitenmaten woning
@@ -224,7 +224,8 @@ window.HousePdf = (() => {
if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]); if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]);
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})`]);
PdfSheet.table(doc, 215, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & buitenverlichting')); rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
PdfSheet.table(doc, 215, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
} }
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
@@ -244,8 +245,8 @@ window.HousePdf = (() => {
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` }; const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts); if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts); else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts);
else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.unit.roof.data.calcData, sheet.unit.roof.data.resolvedOpenings, Object.assign({ roof: true }, 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 === '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, lamps: sheet.piece.lamps || [] }, opts)); else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts));
else drawTotalsSheet(doc, totals, opts); else drawTotalsSheet(doc, totals, opts);
}); });
return doc; return doc;
+304 -287
View File
@@ -23,11 +23,11 @@
const HOUSE_TEMPLATE = ` const HOUSE_TEMPLATE = `
<div id="house-app"> <div id="house-app">
<aside id="house-nav"> <aside id="house-nav">
<div class="hn-title">Woning</div> <div class="hn-title"><i class="bi bi-diagram-3"></i> Woning</div>
<div id="house-nav-tree"></div> <div id="house-nav-tree"></div>
<div class="hn-actions"> <div class="hn-actions">
<button type="button" id="hn-add-unit" class="io-btn">+ Unit</button> <button type="button" id="hn-add-unit" class="ui-btn"><i class="bi bi-plus-lg"></i><span>Unit</span></button>
<button type="button" id="hn-add-iw" class="io-btn">+ Binnenwand</button> <button type="button" id="hn-add-iw" class="ui-btn"><i class="bi bi-plus-lg"></i><span>Binnenwand</span></button>
</div> </div>
<div class="hn-soon"> <div class="hn-soon">
<div class="hn-item disabled" title="Nog niet beschikbaar">Schuine daken</div> <div class="hn-item disabled" title="Nog niet beschikbaar">Schuine daken</div>
@@ -47,14 +47,14 @@ const HOUSE_TEMPLATE = `
</div> </div>
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<div class="control-group"><label for="h-proj-naam">Projectnaam:</label><div><input type="text" id="h-proj-naam"></div></div> <div class="control-group"><label for="h-proj-naam">Projectnaam</label><div><input type="text" id="h-proj-naam"></div></div>
<div class="control-group"><label for="h-proj-plaats">Plaats:</label><div><input type="text" id="h-proj-plaats"></div></div> <div class="control-group"><label for="h-proj-plaats">Plaats</label><div><input type="text" id="h-proj-plaats"></div></div>
<div class="control-group"><label for="h-proj-straat">Straat:</label><div><input type="text" id="h-proj-straat"></div></div> <div class="control-group"><label for="h-proj-straat">Straat</label><div><input type="text" id="h-proj-straat"></div></div>
</div> </div>
<div> <div>
<div class="control-group"><label for="h-proj-nummer">Projectnummer:</label><div><input type="text" id="h-proj-nummer"></div></div> <div class="control-group"><label for="h-proj-nummer">Projectnummer</label><div><input type="text" id="h-proj-nummer"></div></div>
<div class="control-group"><label for="h-proj-tekenaar">Tekenaar:</label><div><input type="text" id="h-proj-tekenaar"></div></div> <div class="control-group"><label for="h-proj-tekenaar">Tekenaar</label><div><input type="text" id="h-proj-tekenaar"></div></div>
<div class="control-group"><label for="h-proj-opmerking">Opmerking:</label><div><input type="text" id="h-proj-opmerking"></div></div> <div class="control-group"><label for="h-proj-opmerking">Opmerking</label><div><input type="text" id="h-proj-opmerking"></div></div>
</div> </div>
</div> </div>
<div class="material-info">Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.</div> <div class="material-info">Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.</div>
@@ -70,10 +70,10 @@ const HOUSE_OVERVIEW_TEMPLATE = `
<div id="house-overview"> <div id="house-overview">
<div class="ho-col ho-left"> <div class="ho-col ho-left">
<div id="ho-pane-plan" class="ho-pane"> <div id="ho-pane-plan" class="ho-pane">
<div class="pane-label">2D PLATTEGROND</div> <div class="pane-label">PLATTEGROND</div>
<div class="toolbar-3d"> <div class="toolbar-3d">
<button type="button" id="ho-tool-select" class="btn-3d-toggle active">Selecteren</button> <button type="button" id="ho-tool-select" class="btn-3d-toggle active" title="Selecteren"><i class="bi bi-cursor"></i>Selecteren</button>
<button type="button" id="ho-tool-iw" class="btn-3d-toggle">Binnenwand tekenen</button> <button type="button" id="ho-tool-iw" class="btn-3d-toggle" title="Binnenwand tekenen"><i class="bi bi-pencil"></i>Binnenwand</button>
</div> </div>
<canvas id="ho-canvas"></canvas> <canvas id="ho-canvas"></canvas>
<div id="ho-hint" class="ho-hint"></div> <div id="ho-hint" class="ho-hint"></div>
@@ -82,9 +82,9 @@ const HOUSE_OVERVIEW_TEMPLATE = `
</div> </div>
<div class="ho-col ho-right"> <div class="ho-col ho-right">
<div id="ho-pane-3d" class="ho-pane"> <div id="ho-pane-3d" class="ho-pane">
<div class="pane-label">3D PERSPECTIEF</div> <div class="pane-label">3D</div>
<div class="toolbar-3d" id="ho-toolbar-3d"> <div class="toolbar-3d" id="ho-toolbar-3d">
<button type="button" id="ho-btn-render" class="btn-3d-toggle" title="Avondscène: elke lamp en spot is een echte lichtbron">Volle render</button> <button type="button" id="ho-btn-render" class="btn-3d-toggle" title="Render: elke lamp en spot is een echte lichtbron"><i class="bi bi-camera"></i>Render</button>
</div> </div>
</div> </div>
<div id="ho-specs"></div> <div id="ho-specs"></div>
@@ -133,9 +133,21 @@ function mountHouseEditor(container, callbacks) {
if (!activeEditor || typeof activeEditor.getStateJson !== 'function' || !activePart) return; if (!activeEditor || typeof activeEditor.getStateJson !== 'function' || !activePart) return;
const [kind, a, b] = activePart.split(':'); const [kind, a, b] = activePart.split(':');
if (kind === 'floor' && unitById(a)) unitById(a).unit.floor = activeEditor.getStateJson(); if (kind === 'floor' && unitById(a)) unitById(a).unit.floor = activeEditor.getStateJson();
else if (kind === 'wall' && unitById(a)) unitById(a).unit.walls[b] = activeEditor.getStateJson(); else if (kind === 'wall' && unitById(a)) {
// Hoogtes gewijzigd: tegenoverliggende wand spiegelen, haakse wanden
// vlak op de hoekhoogtes (UnitModel.syncWallHeights).
const unit = unitById(a).unit, before = unit.walls[b].wall;
const next = activeEditor.getStateJson();
unit.walls[b] = next;
const h = (w) => [w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : null].join('|');
if (h(before) !== h(next.wall)) UnitModel.syncWallHeights(unit, b);
}
else if (kind === 'roof' && unitById(a)) unitById(a).roof.deck = activeEditor.getStateJson(); else if (kind === 'roof' && unitById(a)) unitById(a).roof.deck = activeEditor.getStateJson();
else if (kind === 'iw' && iwById(Number(a))) iwById(Number(a)).wall = activeEditor.getStateJson(); else if (kind === 'iw' && iwById(Number(a))) {
const iw = iwById(Number(a));
iw.wall = activeEditor.getStateJson();
if (iw.seam) iw.wall.materials = Object.assign({}, iw.wall.materials, { exterior: 'geen' }); // naadzijde blijft zonder plaat
}
} }
function showPart(part) { function showPart(part) {
@@ -172,7 +184,8 @@ function mountHouseEditor(container, callbacks) {
activeEditor.loadStateJson(HouseModel.clone(u.roof.deck)); activeEditor.loadStateJson(HouseModel.clone(u.roof.deck));
} else if (kind === 'iw' && iwById(Number(a))) { } else if (kind === 'iw' && iwById(Number(a))) {
HouseModel.applyGlobalSettings(state); HouseModel.applyGlobalSettings(state);
HouseModel.interiorLayout(iwById(Number(a))); // breedte bijwerken if (iwById(Number(a)).seam) HouseModel.computeParts(state, { skip3D: true }); // breedte naadwand-deel
else HouseModel.interiorLayout(iwById(Number(a))); // breedte bijwerken
activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'De lengte van een binnenwand volgt uit de tekening (overzicht).', lockSpacing: SPACING_LOCK_MSG }); activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'De lengte van een binnenwand volgt uit de tekening (overzicht).', lockSpacing: SPACING_LOCK_MSG });
activeEditor.loadStateJson(HouseModel.clone(iwById(Number(a)).wall)); activeEditor.loadStateJson(HouseModel.clone(iwById(Number(a)).wall));
} else { } else {
@@ -187,25 +200,33 @@ function mountHouseEditor(container, callbacks) {
// ------------------------------------------------------------ // ------------------------------------------------------------
function renderNav() { function renderNav() {
const parts = HouseModel.computeParts(state, { skip3D: true }); const parts = HouseModel.computeParts(state, { skip3D: true });
const item = (part, label, meta, cls) => const item = (part, label, meta, cls, iconName) =>
`<div class="hn-item ${cls || ''} ${activePart === part ? 'active' : ''}" data-part="${part}"> `<div class="hn-item ${cls || ''} ${activePart === part ? 'active' : ''}" data-part="${part}">
<span>${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`; <span class="hn-label">${iconName ? UI.icon(iconName) : ''}${label}</span>${meta ? `<span class="hn-meta">${meta}</span>` : ''}</div>`;
let html = item('overzicht', 'Overzicht', '', 'hn-top'); let html = item('overzicht', 'Overzicht', '', 'hn-top', 'grid-1x2');
parts.units.forEach(u => { parts.units.forEach(u => {
const sel = selection && selection.type === 'unit' && selection.id === u.id && activePart === 'overzicht'; const sel = selection && selection.type === 'unit' && selection.id === u.id && activePart === 'overzicht';
html += `<div class="hn-group">`; html += `<div class="hn-group">`;
html += `<div class="hn-item hn-unit ${sel ? 'active' : ''}" data-select-unit="${u.id}"><span>Unit ${u.id}</span><span class="hn-meta">${u.rect.W} &times; ${u.rect.D}</span></div>`; html += `<div class="hn-item hn-unit ${sel ? 'active' : ''}" data-select-unit="${u.id}"><span class="hn-label">${UI.icon('box')}Unit ${u.id}</span><span class="hn-meta">${u.rect.W} &times; ${u.rect.D}</span></div>`;
html += item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub'); html += item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub', 'grid-3x3');
html += item(`roof:${u.id}`, 'Dak (plat)', u.roof.element, 'hn-sub'); html += item(`roof:${u.id}`, 'Dak', u.roof.element, 'hn-sub', 'house');
u.walls.forEach(w => { u.walls.forEach(w => {
const meta = w.removed ? 'vervalt (naad)' : w.cutsU.length ? `deels naad &middot; ${w.pieces.map(p => p.element).join(', ')}` : w.pieces[0].element; const meta = w.removed ? 'vervalt' : w.pieces.map(p => p.element).join(', ');
html += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : '')); html += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : ''), 'bricks');
}); });
html += `</div>`; html += `</div>`;
}); });
if (parts.interior.length) { if (parts.interior.length) {
html += `<div class="hn-group"><div class="hn-item hn-unit hn-static"><span>Binnenwanden</span></div>`; html += `<div class="hn-group"><div class="hn-item hn-unit hn-static"><span class="hn-label">${UI.icon('distribute-horizontal')}Binnenwanden</span></div>`;
parts.interior.forEach(p => { html += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub'); }); const seen = new Set();
parts.interior.forEach(p => {
if (seen.has(p.id)) return; // naadwand: twee delen, één item
seen.add(p.id);
if (p.seam) {
const els = parts.interior.filter(x => x.id === p.id).map(x => x.element).join(', ');
html += item(`iw:${p.id}`, `Naadwand ${p.seam.a}|${p.seam.b}`, els, 'hn-sub', 'layout-split');
} else html += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub', 'distribute-horizontal');
});
html += `</div>`; html += `</div>`;
} }
document.getElementById('house-nav-tree').innerHTML = html; document.getElementById('house-nav-tree').innerHTML = html;
@@ -234,6 +255,7 @@ function mountHouseEditor(container, callbacks) {
const side = ['rechts', 'achter', 'links', 'voor'].find(s => !taken.has(s)) || 'rechts'; const side = ['rechts', 'achter', 'links', 'voor'].find(s => !taken.has(s)) || 'rechts';
const id = HouseModel.nextUnitId(state); const id = HouseModel.nextUnitId(state);
state.units.push(HouseModel.createUnitEntry(id, { to: parent.id, side, align: 'start', offset: 0 }, { width: 3000, depth: 3000, windowOn: [] })); state.units.push(HouseModel.createUnitEntry(id, { to: parent.id, side, align: 'start', offset: 0 }, { width: 3000, depth: 3000, windowOn: [] }));
HouseModel.ensureSeamWalls(state, id); // tussenwand met deur op de nieuwe naad
selection = { type: 'unit', id }; selection = { type: 'unit', id };
showPart('overzicht'); showPart('overzicht');
} }
@@ -299,42 +321,48 @@ function mountHouseEditor(container, callbacks) {
let dakVisible = true; let dakVisible = true;
// Opgeslagen camerastand gaat voor; anders eenmalig passend maken. // Opgeslagen camerastand gaat voor; anders eenmalig passend maken.
let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls); let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls);
let beplatingVisible = true, isolatieVisible = true, lampenVisible = true; let beplatingVisible = true, lampenVisible = true;
let fullRender = false; let fullRender = false;
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
// Weergave-filter (core/view-filter.js). Beplating = wandplaten, vloer- // Weergave-filter (core/view-filter.js). Beplating = wandplaten, vloer-
// en plafondplaten, dakleer en boeideel/daktrim. // en plafondplaten, dakleer en boeideel/daktrim.
createViewFilter(document.getElementById('ho-toolbar-3d'), [ createViewFilter(document.getElementById('ho-toolbar-3d'), [
{ key: 'beplating', label: 'Beplating' }, { key: 'beplating', label: 'Beplating' },
{ key: 'isolatie', label: 'Isolatie' },
{ key: 'dak', label: 'Dak' }, { key: 'dak', label: 'Dak' },
{ key: 'lampen', label: 'Lampen' } { key: 'lampen', label: 'Lampen' }
], (v) => { ], (v) => {
beplatingVisible = v.beplating; beplatingVisible = v.beplating;
isolatieVisible = v.isolatie;
dakVisible = v.dak; dakVisible = v.dak;
lampenVisible = v.lampen; lampenVisible = v.lampen;
refresh({ keepProps: true }); refresh({ keepProps: true });
}); });
// Volle render: avondscène, lampen en spots als echte lichtbronnen. // Omgeving (core/skybox.js: geen/dag/nacht) en Render. Render = lampen en
// spots als echte lichtbronnen, in het licht van de gekozen omgeving;
// Nacht = omgevingslicht op 15% en de lampen altijd aan (100%). De
// tegelvloer ligt op het maaiveld, dus het ingegraven deel van vloer en
// sandwichpanelen verdwijnt eronder.
let lightsGroup = null; let lightsGroup = null;
function applyRenderMode() { function applyRenderMode() {
scene.background = new THREE.Color(fullRender ? 0x0b0f18 : 0x1a1a1a); const lampsOn = fullRender || skyMode === 'night';
ambient.intensity = fullRender ? 0.1 : 0.6; scene.background = new THREE.Color(0x1a1a1a);
dirLight.intensity = fullRender ? 0.12 : 0.8; Skybox.apply(skyMode, { env: skyEnv, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: houseGroup });
ground.visible = fullRender; ground.visible = fullRender && skyMode === 'none'; // zonder omgeving: vlak waarop het lamplicht valt
grid.visible = !fullRender; grid.visible = skyMode === 'none' && !fullRender;
if (lightsGroup) { scene.remove(lightsGroup); lightsGroup = null; } if (lightsGroup) { scene.remove(lightsGroup); lightsGroup = null; }
// De nep-lichtbundels (kegels) alleen in de snelle weergave; in de // De nep-lichtbundels (kegels) alleen zonder echte lampen.
// volle render schijnt er echt licht. if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !lampsOn; });
if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !fullRender; }); if (lampsOn && parts && lampenVisible) {
if (fullRender && parts && lampenVisible) {
const edge = dakVisible ? parts.roofEdge : null; const edge = dakVisible ? parts.roofEdge : null;
lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0); lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, parts.lighting.indoor, {
outdoorFactor: skyMode === 'night' ? 1 : LightingModel.DAY_OUTDOOR_FACTOR
});
scene.add(lightsGroup); scene.add(lightsGroup);
} }
} }
Skybox.createToggle(document.getElementById('ho-toolbar-3d'), (m) => { skyMode = m; applyRenderMode(); });
document.getElementById('ho-btn-render').addEventListener('click', (e) => { document.getElementById('ho-btn-render').addEventListener('click', (e) => {
fullRender = !fullRender; fullRender = !fullRender;
e.currentTarget.classList.toggle('active', fullRender); e.currentTarget.classList.toggle('active', fullRender);
@@ -578,6 +606,13 @@ function mountHouseEditor(container, callbacks) {
} }
ctx.restore(); ctx.restore();
// Binnenspots (plafond): open cirkeltje
ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5;
(parts.lighting.indoor || []).forEach(sp => {
const q = toPx(sp.x, sp.z);
ctx.beginPath(); ctx.arc(q.x, q.y, 3.5, 0, Math.PI * 2); ctx.stroke();
});
// Gevellampen: blokje op het gevelvlak // Gevellampen: blokje op het gevelvlak
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1; ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1;
parts.lighting.lamps.forEach(l => { parts.lighting.lamps.forEach(l => {
@@ -617,227 +652,215 @@ function mountHouseEditor(container, callbacks) {
} }
} }
// --- Eigenschappenpaneel (contextafhankelijk) --- // --- Eigenschappenpaneel (contextafhankelijk, opgebouwd met UI-kit) ---
const num = (id, label, value, step) => const { num, sel, toggle, section, info, button, actions } = UI;
`<div class="control-group"><label for="${id}">${label}:</label><div><input type="number" id="${id}" value="${value}" step="${step || 50}"><span class="unit">mm</span></div></div>`; const cols = UI.grid; // (grid = de 3D-rasterhulp)
const sel = (id, label, options, value) => UI.wireTabs(propsEl);
`<div class="control-group"><label for="${id}">${label}:</label><div><select id="${id}">${Object.entries(options).map(([k, v]) => `<option value="${k}" ${k === value ? 'selected' : ''}>${v}</option>`).join('')}</select></div></div>`; const head = (iconName, title, sub) =>
`<div class="ui-panel-head"><h1>${UI.icon(iconName)}${title}</h1>${sub ? `<span class="ui-sub">${sub}</span>` : ''}</div>`;
const after = () => refresh({ keepProps: true }); // getal: panelen blijven staan
const rerender = () => refresh(); // keuze: velden kunnen wijzigen
function renderProps() { function renderProps() {
const warn = parts.overlaps.length const warn = (parts.overlaps.length ? UI.warn(`Units overlappen elkaar: ${parts.overlaps.map(p => p.join(' + ')).join(', ')}.`) : '') +
? `<div class="global-warning">Let op: units overlappen elkaar (${parts.overlaps.map(p => p.join(' + ')).join(', ')}).</div>` : ''; parts.rects.filter(r => r.error).map(r => UI.warn(`Unit ${r.id}: ${r.error}`)).join('');
const errs = parts.rects.filter(r => r.error).map(r => `<div class="global-warning">Unit ${r.id}: ${r.error}</div>`).join('');
if (selection && selection.type === 'unit' && unitById(selection.id)) { if (selection && selection.type === 'unit' && unitById(selection.id)) renderUnitProps(unitById(selection.id), warn);
const u = unitById(selection.id); else if (selection && selection.type === 'wall') renderWallProps(warn);
else if (selection && selection.type === 'iw' && iwById(selection.id)) renderIwProps(iwById(selection.id), warn);
else renderHouseProps(warn);
}
function renderUnitProps(u, warn) {
const isRoot = state.units[0].id === u.id; const isRoot = state.units[0].id === u.id;
const a = u.attach; const a = u.attach;
const axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z'; const axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z';
propsEl.innerHTML = `${warn}${errs} const f = u.unit.floor.floor;
<div class="controls-grid"> const koppeling = isRoot
<div> ? info('Basis-unit: de noordwesthoek ligt op (0, 0). Andere units koppelen hieraan.')
<h1>Unit ${u.id}</h1> : sel('hp-att-to', 'Gekoppeld aan', Object.fromEntries(state.units.filter(o => o.id !== u.id).map(o => [o.id, 'Unit ' + o.id])), a.to) +
${num('hp-width', 'Breedte (west-oost)', u.unit.floor.floor.width)} sel('hp-att-side', 'Zijde', HouseModel.SIDE_LABELS, a.side) +
${num('hp-depth', 'Diepte (noord-zuid)', u.unit.floor.floor.depth)} sel('hp-att-align', 'Uitlijning', HouseModel.ALIGN_LABELS[axis], a.align) +
${sel('hp-corners', 'Hoeken', UnitModel.CORNER_MODES, u.unit.layout.corners)} num('hp-att-offset', 'Verschuiving', a.offset || 0);
</div> const dak = toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) +
<div> (u.roof.followFloor ? '' :
<h1>Koppeling</h1> num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) +
${isRoot ? '<div class="material-info">Basis-unit: noordwest-hoek op (0, 0). Andere units koppelen hieraan.</div>' : ` num('hp-roof-d', 'Diepte', u.roof.deck.floor.depth, { hint: 'Noord–zuid' }) +
${sel('hp-att-to', 'Gekoppeld aan', Object.fromEntries(state.units.filter(o => o.id !== u.id).map(o => [o.id, 'Unit ' + o.id])), a.to)} num('hp-roof-ox', 'Verschuiving oost', u.roof.offsetX || 0) +
${sel('hp-att-side', 'Aan zijde', HouseModel.SIDE_LABELS, a.side)} num('hp-roof-oz', 'Verschuiving zuid', u.roof.offsetZ || 0)) +
${sel('hp-att-align', 'Uitlijning', HouseModel.ALIGN_LABELS[axis], a.align)} toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim van 80 mm' });
${num('hp-att-offset', 'Verschuiving', a.offset || 0)}`}
</div> propsEl.innerHTML = warn + head('box', `Unit ${u.id}`, `${f.width} &times; ${f.depth} mm`) +
</div> UI.tabs('unit', [
<div class="divider"></div> { key: 'maat', label: 'Afmetingen', icon: 'rulers', html: cols(
<div class="controls-grid"> num('hp-width', 'Breedte', f.width, { hint: 'West–oost' }) + num('hp-depth', 'Diepte', f.depth, { hint: 'Noord–zuid' }),
<div> sel('hp-corners', 'Hoekaansluiting', UnitModel.CORNER_MODES, u.unit.layout.corners)) },
<h1>Dak (plat)</h1> { key: 'koppeling', label: 'Koppeling', icon: 'link-45deg', html: cols(koppeling, '') },
${sel('hp-roof-follow', 'Dak volgt vloer', { ja: 'Ja (standaard)', nee: 'Nee, eigen maat' }, u.roof.followFloor ? 'ja' : 'nee')} { key: 'dak', label: 'Dak', icon: 'house', html: cols(dak, u.roof.followFloor ? info('Het dak heeft de maat van de vloer. Zet "Maat volgt vloer" uit voor een eigen maat of verschuiving.') : '') }
${u.roof.followFloor ? '' : ` ]) +
${num('hp-roof-w', 'Dakbreedte (west-oost)', u.roof.deck.floor.width)} actions(
${num('hp-roof-d', 'Dakdiepte (noord-zuid)', u.roof.deck.floor.depth)}`} button('Vloer bewerken', 'grid-3x3', `data-go="floor:${u.id}"`),
</div> button('Dak bewerken', 'house', `data-go="roof:${u.id}"`),
<div> isRoot ? '' : button('Unit verwijderen', 'trash', `data-remove-unit="${u.id}"`, 'ui-btn-danger'));
${u.roof.followFloor ? '<div class="material-info">Het dak heeft dezelfde maat als de vloer. Kies "Nee, eigen maat" om het dak langer/breder te maken of te verschuiven.</div>' : `
<h1>Positie dak</h1> UI.onNum('hp-width', v => { if (v >= 1000) f.width = v; }, after);
${num('hp-roof-ox', 'Verschuiving oost (+)', u.roof.offsetX || 0)} UI.onNum('hp-depth', v => { if (v >= 1000) f.depth = v; }, after);
${num('hp-roof-oz', 'Verschuiving zuid (+)', u.roof.offsetZ || 0)}`} UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after);
</div> UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender);
</div> UI.onSel('hp-att-to', v => { u.attach.to = v; }, rerender);
<div class="divider"></div> UI.onSel('hp-att-side', v => { u.attach.side = v; }, rerender);
<div class="controls-grid"> UI.onSel('hp-att-align', v => { u.attach.align = v; }, rerender);
<div> UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender);
<h1>Dakrand</h1> UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender);
${sel('hp-fascia', 'Boeidelen', { aan: 'Aan', uit: 'Uit (daktrim 80 mm)' }, u.roof.fascia !== false ? 'aan' : 'uit')} UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after);
</div> UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after);
<div> UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after);
<div class="material-info">Buitenverlichting (type, richting, afstanden) is woning-breed: Overzicht, niets geselecteerd.</div> UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after);
</div> }
</div>
<div class="ho-prop-actions"> function renderWallProps(warn) {
<button type="button" class="io-btn" data-go="floor:${u.id}">Vloer bewerken</button>
<button type="button" class="io-btn" data-go="roof:${u.id}">Dak bewerken</button>
${isRoot ? '' : `<button type="button" class="io-btn io-btn-danger" data-remove-unit="${u.id}">Unit verwijderen</button>`}
</div>`;
const onNum = (id, fn) => document.getElementById(id) && document.getElementById(id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (isFinite(v)) { fn(v); refresh({ keepProps: true }); }
});
onNum('hp-width', v => { if (v >= 1000) u.unit.floor.floor.width = v; });
onNum('hp-depth', v => { if (v >= 1000) u.unit.floor.floor.depth = v; });
onNum('hp-att-offset', v => { u.attach.offset = v; });
const onSel = (id, fn) => document.getElementById(id) && document.getElementById(id).addEventListener('input', (e) => { fn(e.target.value); refresh(); });
onSel('hp-corners', v => { u.unit.layout.corners = v; });
onSel('hp-att-to', v => { u.attach.to = v; });
onSel('hp-att-side', v => { u.attach.side = v; });
onSel('hp-att-align', v => { u.attach.align = v; });
onSel('hp-roof-follow', v => { u.roof.followFloor = v === 'ja'; });
onSel('hp-fascia', v => { u.roof.fascia = v === 'aan'; });
onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; });
onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; });
onNum('hp-roof-ox', v => { u.roof.offsetX = v; });
onNum('hp-roof-oz', v => { u.roof.offsetZ = v; });
} else if (selection && selection.type === 'wall') {
const pu = parts.units.find(x => x.id === selection.unit); const pu = parts.units.find(x => x.id === selection.unit);
const wl = pu && pu.walls.find(x => x.side === selection.side); const wl = pu && pu.walls.find(x => x.side === selection.side);
const piece = wl && wl.pieces.find(p => p.element === selection.element); const piece = wl && wl.pieces.find(p => p.element === selection.element);
if (!piece) { selection = null; return renderProps(); } if (!piece) { selection = null; return renderProps(); }
propsEl.innerHTML = `${warn} const lt = state.settings.lighting;
<h1>${piece.element} &mdash; ${piece.label}</h1> const rows = [
<div class="result-row"><span>Lengte volle zijde:</span><span class="highlight">${wl.width} mm</span></div> ['Lengte zijde', wl.width, 'mm'],
<div class="result-row"><span>Dit wandstuk:</span><span class="highlight">${piece.place.width} mm (${Math.round(piece.u0)}&ndash;${Math.round(piece.u1)} vanaf links, van buitenaf gezien)</span></div> ['Lengte wandstuk', piece.place.width, 'mm'],
<div class="result-row"><span>Naad met andere unit:</span><span class="highlight">${wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)}&ndash;${Math.round(b)}`).join(', ') + ' mm vervalt' : 'nee'}</span></div> ['Positie', `${Math.round(piece.u0)} – ${Math.round(piece.u1)}`, 'mm'],
<div class="result-row"><span>Openingen in dit stuk:</span><span class="highlight">${piece.config.openings.length}</span></div> ['Naad', wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)} – ${Math.round(b)}`).join(', ') : 'geen', wl.cutsU.length ? 'mm' : ''],
${state.settings.lighting.mode === 'gevel' ? ` ['Openingen', piece.config.openings.length, '']
<div class="divider"></div> ];
<h1>Gevellampen op deze wand</h1> const lampen = lt.mode === 'gevel'
${sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, unitById(pu.id).lighting.walls[wl.side] || 'auto')} ? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, unitById(pu.id).lighting.walls[wl.side] || 'auto',
<div class="result-row"><span>Lampen op dit wandstuk:</span><span class="highlight">${piece.lamps.length}</span></div> { hint: `Automatisch: ${lt.endOffset} mm uit de hoek, alleen op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm naast elke opening.` }) +
<div class="material-info">Automatisch: vanaf ${state.settings.lighting.endOffset} mm van hoek/uiteinde, alleen op vrije stukken gevel &ge; ${state.settings.lighting.minWallWidth} mm op ${state.settings.lighting.lampHeight} mm hoogte (smallere stukken krijgen geen lampen). Override: ${state.settings.lighting.openingOffset} mm naast elke opening.</div>` UI.specCard('Op dit wandstuk', 'lightbulb', [['Gevellampen', piece.lamps.length, 'st.']])
: '<div class="material-info">Gevellampen: kies in de woning-instellingen "Buitenverlichting: Gevellampen".</div>'} : info('Gevellampen staan uit. Kies bij Woning › Verlichting het type "Gevellampen".');
${wl.dropped.length ? `<div class="global-warning">${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.</div>` : ''} propsEl.innerHTML = warn + head('bricks', piece.element, piece.label) +
<div class="ho-prop-actions"><button type="button" class="io-btn" data-go="wall:${pu.id}:${wl.side}">Wand bewerken</button></div>`; cols(UI.specCard('Wandstuk', 'rulers', rows), section('Gevellampen', 'lightbulb', lampen)) +
const hw = document.getElementById('hw-light'); (wl.dropped.length ? UI.warn(`${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.`) : '') +
if (hw) hw.addEventListener('input', (e) => { unitById(pu.id).lighting.walls[wl.side] = e.target.value; refresh(); }); actions(button('Wand bewerken', 'pencil-square', `data-go="wall:${pu.id}:${wl.side}"`));
} else if (selection && selection.type === 'iw' && iwById(selection.id)) { UI.onSel('hw-light', v => { unitById(pu.id).lighting.walls[wl.side] = v; }, rerender);
const iw = iwById(selection.id);
const p = parts.interior.find(x => x.id === iw.id);
propsEl.innerHTML = `${warn}
<div class="controls-grid">
<div>
<h1>${p.element} &mdash; binnenwand</h1>
${num('hi-x1', 'Begin X', iw.x1)}${num('hi-z1', 'Begin Y', iw.z1)}
${num('hi-x2', 'Eind X', iw.x2)}${num('hi-z2', 'Eind Y', iw.z2)}
</div>
<div>
<h1>Opbouw</h1>
${num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), 5)}
${num('hi-height', 'Hoogte', iw.wall.wall.heightAll)}
<div class="result-row"><span>Lengte:</span><span class="highlight">${p.place.width} mm</span></div>
</div>
</div>
<div class="ho-prop-actions">
<button type="button" class="io-btn" data-go="iw:${iw.id}">Wand bewerken (openingen)</button>
<button type="button" class="io-btn io-btn-danger" data-remove-iw="${iw.id}">Verwijderen</button>
</div>`;
const onNum = (id, fn) => document.getElementById(id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (isFinite(v)) { fn(v); refresh({ keepProps: true }); }
});
['x1', 'z1', 'x2', 'z2'].forEach(f => onNum('hi-' + f, v => { iw[f] = v; }));
onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); });
onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; });
} else {
propsEl.innerHTML = `${warn}${errs}
<h1>Woning</h1>
<div class="material-info">${state.units.length} unit(s), ${state.interiorWalls.length} binnenwand(en).
Klik in de plattegrond op een unit, wand of binnenwand om die te selecteren; dubbelklik om te bewerken.
Waar units tegen elkaar staan (oranje gestippeld) vervalt de wand.</div>
<div class="ho-prop-actions">
<button type="button" class="io-btn" data-action="add-unit">+ Unit toevoegen</button>
<button type="button" class="io-btn" data-action="draw-iw">Binnenwand tekenen</button>
<button type="button" class="io-btn" data-action="project">Project eigenschappen</button>
</div>
<div class="divider"></div>
${renderSettingsHtml()}`;
wireSettings();
}
} }
// Woning-instellingen: h.o.h. voor ALLE buiten-/binnenwanden + dakrand. function renderIwProps(iw, warn) {
function renderSettingsHtml() { const p = parts.interior.find(x => x.id === iw.id);
const sp = state.settings.spacing, r = state.settings.roof, lt = state.settings.lighting; if (iw.seam) return renderSeamProps(iw, warn);
return ` if (!p) { selection = null; return renderProps(); }
<div class="controls-grid"> propsEl.innerHTML = warn + head('distribute-horizontal', p.element, `Binnenwand &middot; ${p.place.width} mm`) +
<div> cols(
<h1>Gevelbekleding (test, alle buitenwanden)</h1> section('Positie', 'crosshair', num('hi-x1', 'Begin X', iw.x1) + num('hi-z1', 'Begin Y', iw.z1) + num('hi-x2', 'Eind X', iw.x2) + num('hi-z2', 'Eind Y', iw.z2)),
${sel('hs-cladding', 'Richting WEO/Novalit', WallModel.CLADDING_DIRECTIONS, state.settings.cladding)} section('Opbouw', 'layers', num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), { step: 5 }) + num('hi-height', 'Hoogte', iw.wall.wall.heightAll))) +
${sel('hs-trasraam', 'Trasraam (cement-gebonden plaat)', { aan: 'Aan', uit: 'Uit' }, state.settings.trasraam.enabled ? 'aan' : 'uit')} actions(
${state.settings.trasraam.enabled ? num('hs-trasraam-h', 'Hoogte trasraam (vanaf bovenkant vloer)', state.settings.trasraam.height, 10) : ''} button('Openingen bewerken', 'pencil-square', `data-go="iw:${iw.id}"`),
${sel('hs-sandwich', `Sandwichpanelen rondom (${SandwichModel.DEPTH_MM} mm de grond in)`, { aan: 'Aan', uit: 'Uit' }, state.settings.sandwich ? 'aan' : 'uit')} button('Verwijderen', 'trash', `data-remove-iw="${iw.id}"`, 'ui-btn-danger'));
<div class="divider"></div> ['x1', 'z1', 'x2', 'z2'].forEach(k => UI.onNum('hi-' + k, v => { iw[k] = v; }, after));
<h1>Regelwerk h.o.h. (alle wanden)</h1> UI.onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); }, after);
${num('hs-ext-min', 'Buitenwanden min.', sp.exterior.min, 10)} UI.onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; }, after);
${num('hs-ext-max', 'Buitenwanden max.', sp.exterior.max, 10)}
${num('hs-int-min', 'Binnenwanden min.', sp.interior.min, 10)}
${num('hs-int-max', 'Binnenwanden max.', sp.interior.max, 10)}
</div>
<div>
<h1>Dakrand (maten, hele woning)</h1>
${num('hs-overhang', `Overstek boeideel (${RoofModel.OVERHANG_MIN}–${RoofModel.OVERHANG_MAX})`, r.overhang, 10)}
${num('hs-fascia-h', 'Hoogte boeideel', r.fasciaHeight, 10)}
${num('hs-trim-h', 'Hoogte daktrim', r.trimHeight, 5)}
${num('hs-spot-min', 'Spots min. h.o.h.', r.spotMin, 50)}
${num('hs-spot-max', 'Spots max. h.o.h.', r.spotMax, 50)}
</div>
</div>
<div class="divider"></div>
<div class="controls-grid">
<div>
<h1>Buitenverlichting (hele woning)</h1>
${sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode)}
${lt.mode === 'gevel' ? sel('hs-light-dir', 'Lichtrichting', LightingModel.DIRECTIONS, lt.direction) : ''}
${lt.mode === 'spots' ? '<div class="material-info">Boeideel-spots komen alleen bij units met boeidelen.</div>' : ''}
${num('hs-lamp-h', 'Lamphoogte', lt.lampHeight, 50)}
${num('hs-lamp-min', 'Lampen min. h.o.h.', lt.lampMin, 50)}
${num('hs-lamp-max', 'Lampen max. h.o.h.', lt.lampMax, 50)}
</div>
<div>
<h1>&nbsp;</h1>
${num('hs-lamp-w', 'Min. vrije gevelbreedte', lt.minWallWidth, 50)}
${num('hs-lamp-end', 'Vanaf hoek/uiteinde', lt.endOffset, 50)}
${num('hs-lamp-open', 'Override: naast opening', lt.openingOffset, 50)}
</div>
</div>`;
} }
function wireSettings() {
const sp = state.settings.spacing, r = state.settings.roof; // Naadwand: midden op de naad (twee delen, één per unit) of geheel in
const onNum = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('input', (e) => { const v = parseFloat(e.target.value); if (isFinite(v) && v > 0) { fn(v); refresh({ keepProps: true }); } }); }; // unit A/B (één wand). Eén set openingen en maten; de hoogte volgt per
const onSel = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('input', (e) => { fn(e.target.value); refresh(); }); }; // deel de daklijn van zijn unit.
onSel('hs-cladding', v => { state.settings.cladding = v; }); function renderSeamProps(iw, warn) {
onSel('hs-trasraam', v => { state.settings.trasraam.enabled = v === 'aan'; }); const leaves = parts.interior.filter(x => x.id === iw.id);
onSel('hs-sandwich', v => { state.settings.sandwich = v === 'aan'; }); const title = `Naadwand ${iw.seam.a} | ${iw.seam.b}`;
onNum('hs-trasraam-h', v => { state.settings.trasraam.height = v; }); const exposed = leaves.filter(l => l.skinFrom !== undefined);
onNum('hs-ext-min', v => { sp.exterior.min = v; }); const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null;
onNum('hs-ext-max', v => { sp.exterior.max = v; }); const plaatsen = { midden: 'Midden op de naad (2 delen)', a: `Geheel in unit ${iw.seam.a}`, b: `Geheel in unit ${iw.seam.b}` };
onNum('hs-int-min', v => { sp.interior.min = v; }); const sheet = iw.wall.wall.sheathingThick;
onNum('hs-int-max', v => { sp.interior.max = v; }); const dikte = inUnit
const lt = state.settings.lighting; ? num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall) + sheet, { step: 5, hint: 'Regelwerk + beplating aan beide zijden' })
onNum('hs-overhang', v => { r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }); : num('hi-thick', 'Dikte per deel', HouseModel.thicknessOf(iw.wall), { step: 5, hint: 'Regelwerk + beplating aan de kamerzijde; naadzijde zonder plaat' });
onNum('hs-fascia-h', v => { r.fasciaHeight = v; }); const uitleg = inUnit
onNum('hs-spot-min', v => { r.spotMin = v; }); ? `Binnenwand geheel in unit ${inUnit}, tegen de naad aan, met beplating aan beide zijden. De hoogte volgt de daklijn van unit ${inUnit}.`
onSel('hs-light-mode', v => { lt.mode = v; }); : 'Tussenwand op de naad, per unit een eigen deel dat tegen het andere aan staat. Openingen gelden voor beide delen (de deur loopt door). De hoogte volgt per deel de daklijn van zijn unit.';
onSel('hs-light-dir', v => { lt.direction = v; }); propsEl.innerHTML = warn + head('layout-split', title, leaves.length ? (leaves.length === 1 ? '1 deel' : `${leaves.length} delen`) : 'vervalt') +
onNum('hs-lamp-h', v => { lt.lampHeight = v; }); (leaves.length ? '' : UI.warn(`Unit ${iw.seam.a} en ${iw.seam.b} staan niet (meer) tegen elkaar; deze naadwand wordt niet gebouwd.`)) +
onNum('hs-lamp-min', v => { lt.lampMin = v; }); cols(
onNum('hs-lamp-max', v => { lt.lampMax = v; }); section('Opbouw', 'layers', sel('hi-seam-in', 'Plaats', plaatsen, iw.seam.in || 'midden') + dikte),
onNum('hs-lamp-w', v => { lt.minWallWidth = v; }); UI.specCard('Delen', 'layout-split', leaves.map(l => [`${l.element} (unit ${l.seamUnit})`, l.place.width, 'mm']))) +
onNum('hs-lamp-end', v => { lt.endOffset = v; }); info(uitleg +
onNum('hs-lamp-open', v => { lt.openingOffset = v; }); exposed.map(l => ` ${l.element} steekt vanaf ${l.skinFrom} mm boven het dak van de andere unit uit; dat stuk krijgt buitenafwerking die met de gevel meeloopt.`).join('')) +
onNum('hs-spot-max', v => { r.spotMax = v; }); actions(
onNum('hs-trim-h', v => { r.trimHeight = v; }); button('Openingen bewerken', 'pencil-square', `data-go="iw:${iw.id}"`),
button('Verwijderen', 'trash', `data-remove-iw="${iw.id}"`, 'ui-btn-danger'));
UI.onSel('hi-seam-in', v => { if (v === 'midden') delete iw.seam.in; else iw.seam.in = v; }, rerender);
UI.onNum('hi-thick', v => { const t = inUnit ? v - sheet : v; if (t >= 40) HouseModel.setThickness(iw.wall, t); }, after);
}
// Woning-instellingen (niets geselecteerd): alles wat voor de hele woning
// geldt, in tabbladen.
function renderHouseProps(warn) {
const st = state.settings, sp = st.spacing, r = st.roof, lt = st.lighting;
const pos = { positive: true };
const verlichting = lt.mode === 'gevel'
? cols(
section('Gevellampen', 'lightbulb', sel('hs-light-dir', 'Lichtrichting', LightingModel.DIRECTIONS, lt.direction) +
num('hs-lamp-h', 'Hoogte', lt.lampHeight, { hint: 'Vanaf bovenkant vloer' }) +
num('hs-lamp-min', 'Afstand min.', lt.lampMin, { hint: 'Hart-op-hart' }) + num('hs-lamp-max', 'Afstand max.', lt.lampMax, { hint: 'Hart-op-hart' })),
section('Plaatsing', 'distribute-horizontal', num('hs-lamp-w', 'Min. gevelbreedte', lt.minWallWidth, { hint: 'Smallere vrije stukken gevel krijgen geen lamp' }) +
num('hs-lamp-end', 'Afstand tot hoek', lt.endOffset) +
num('hs-lamp-open', 'Afstand tot opening', lt.openingOffset, { hint: 'Voor wanden met de instelling "Naast openingen"' })))
: lt.mode === 'spots'
? cols(section('Boeideel-spots', 'lightbulb', num('hs-spot-min', 'Afstand min.', r.spotMin, { hint: 'Hart-op-hart' }) + num('hs-spot-max', 'Afstand max.', r.spotMax, { hint: 'Hart-op-hart' })),
info('Spots komen alleen onder units met boeidelen.'))
: '';
propsEl.innerHTML = warn + head('house-door', 'Woning', `${state.units.length} unit(s) &middot; ${state.interiorWalls.length} binnenwand(en)`) +
UI.tabs('woning', [
{ key: 'algemeen', label: 'Algemeen', icon: 'house-door', html:
info('Klik in de plattegrond op een unit, wand of binnenwand om te selecteren; dubbelklik om te bewerken. Waar units tegen elkaar staan (oranje gestippeld) vervalt de wand.') +
actions(button('Unit toevoegen', 'plus-lg', 'data-action="add-unit"'), button('Binnenwand tekenen', 'pencil', 'data-action="draw-iw"'),
button('Projectgegevens', 'card-text', 'data-action="project"')) },
{ key: 'gevel', label: 'Gevel', icon: 'bricks', html: cols(
section('Gevelbekleding', 'grip-horizontal', sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'WEO/Novalit, voorlopig voor alle buitenwanden' })) +
section('Trasraam', 'bricks', toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Cement-gebonden plaat onderaan de gevel' }) +
(st.trasraam.enabled ? num('hs-trasraam-h', 'Hoogte', st.trasraam.height, { step: 10, hint: 'Vanaf bovenkant vloer' }) : '')),
section('Sandwichpanelen', 'bounding-box', toggle('hs-sandwich', 'Rondom woning', st.sandwich,
{ hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` })) +
section('Maaiveld', 'tree', num('hs-ground', 'Onder bovenkant vloer', st.groundLevel, { step: 10, hint: 'Hoogte van de tegelvloer in de 3D-weergave bij Dag/Nacht' }))) },
{ key: 'constructie', label: 'Regelwerk', icon: 'columns-gap', html: cols(
section('Buitenwanden', 'bricks', num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart stijlen' })),
section('Binnenwanden', 'distribute-horizontal', num('hs-int-min', 'Afstand min.', sp.interior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-int-max', 'Afstand max.', sp.interior.max, { step: 10, hint: 'Hart-op-hart stijlen' }))) },
{ key: 'dak', label: 'Dakrand', icon: 'house', html: cols(
section('Boeideel', 'border-outer', num('hs-overhang', 'Overstek', r.overhang, { step: 10, hint: `${RoofModel.OVERHANG_MIN} – ${RoofModel.OVERHANG_MAX} mm` }) + num('hs-fascia-h', 'Hoogte', r.fasciaHeight, { step: 10 })),
section('Daktrim', 'border-top', num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }))) },
{ key: 'verlichting', label: 'Verlichting', icon: 'lightbulb', html:
cols(section('Buiten', 'sun', sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode)),
section('Binnenspots', 'circle', toggle('hs-indoor', 'Spots in plafond', lt.indoor, { hint: 'Per unit verdeeld in het plafond' }) +
(lt.indoor ? num('hs-indoor-min', 'Afstand min.', lt.indoorMin, { hint: 'Hart-op-hart, in beide richtingen' }) +
num('hs-indoor-max', 'Afstand max.', lt.indoorMax, { hint: 'Hart-op-hart, in beide richtingen' }) +
num('hs-indoor-off', 'Afstand tot wand', lt.indoorOffset, { hint: 'Tot de binnenzijde van een wand, of tot de unitrand bij een naad' }) : ''))) +
verlichting }
]);
UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender);
UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender);
UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender);
UI.onNum('hs-trasraam-h', v => { st.trasraam.height = v; }, after, pos);
UI.onNum('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after);
UI.onNum('hs-ext-min', v => { sp.exterior.min = v; }, after, pos);
UI.onNum('hs-ext-max', v => { sp.exterior.max = v; }, after, pos);
UI.onNum('hs-int-min', v => { sp.interior.min = v; }, after, pos);
UI.onNum('hs-int-max', v => { sp.interior.max = v; }, after, pos);
UI.onNum('hs-overhang', v => { r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after, pos);
UI.onNum('hs-fascia-h', v => { r.fasciaHeight = v; }, after, pos);
UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos);
UI.onNum('hs-spot-min', v => { r.spotMin = v; }, after, pos);
UI.onNum('hs-spot-max', v => { r.spotMax = v; }, after, pos);
UI.onSel('hs-light-mode', v => { lt.mode = v; }, rerender);
UI.onSel('hs-light-dir', v => { lt.direction = v; }, rerender);
UI.onNum('hs-lamp-h', v => { lt.lampHeight = v; }, after, pos);
UI.onNum('hs-lamp-min', v => { lt.lampMin = v; }, after, pos);
UI.onNum('hs-lamp-max', v => { lt.lampMax = v; }, after, pos);
UI.onNum('hs-lamp-w', v => { lt.minWallWidth = v; }, after, pos);
UI.onNum('hs-lamp-end', v => { lt.endOffset = v; }, after, pos);
UI.onNum('hs-lamp-open', v => { lt.openingOffset = v; }, after, pos);
UI.onToggle('hs-indoor', v => { lt.indoor = v; }, rerender);
UI.onNum('hs-indoor-min', v => { lt.indoorMin = v; }, after, pos);
UI.onNum('hs-indoor-max', v => { lt.indoorMax = v; }, after, pos);
UI.onNum('hs-indoor-off', v => { lt.indoorOffset = v; }, after);
} }
propsEl.addEventListener('click', (e) => { propsEl.addEventListener('click', (e) => {
@@ -859,42 +882,34 @@ function mountHouseEditor(container, callbacks) {
else if (act.dataset.action === 'project') openProjectProperties(); else if (act.dataset.action === 'project') openProjectProperties();
}); });
// --- Specificaties/totalen --- // --- Specificaties/totalen (onder de 3D) ---
function renderSpecs(totals) { function renderSpecs(totals) {
const row = (label, value) => `<div class="result-row"><span>${label}:</span><span class="highlight">${value}</span></div>`; const f = houseFmt;
document.getElementById('ho-specs').innerHTML = ` const cards = [
<div class="controls-grid"> UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']),
<div> UI.specCard('Hout & isolatie', 'tree', [
<div class="spec-section"><h1>Gewicht</h1> ['Balken', totals.beamCount, 'st.'],
${totals.weights.map(r => row(r.label, houseFmt(r.kg) + ' kg')).join('')} ['Volume hout', f(totals.woodM3, 3), 'm³'],
<div class="divider"></div>${row('Totaal woning', houseFmt(totals.totalKg) + ' kg')} ['Inkoop wandbalken', WallModel.purchaseText(totals.purchase), ''],
</div> ['Snijverlies', `${f(totals.purchaseWasteMM / 1000, 2)} m · ${f(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0)}`, '%'],
</div> [`Latwerk ${WallModel.BATTEN.thick}×${WallModel.BATTEN.width}`, `${totals.battens.count} × ${WallModel.BATTEN.length}`, 'mm'],
<div> ['Glaswol', f(totals.isolatieM3, 3), 'm³']
<div class="spec-section"><h1>Houtwerk &amp; isolatie</h1> ]),
${row('Aantal balken', totals.beamCount + ' stuks')} UI.specCard('Plaatwerk', 'layers', totals.plaatwerk.filter(p => p.m2 > 0).map(p => [p.naam, f(p.m2, 2), 'm²']))
${row('Volume hout', houseFmt(totals.woodM3, 3) + ' m3')} ];
${row('Latwerk 20x30 (kruislat)', totals.battens.count + ' st. &agrave; ' + WallModel.BATTEN.length + ' mm')} if (totals.roofEdge) {
${row('Inkoop wandbalken', WallModel.purchaseText(totals.purchase))} const e = totals.roofEdge, rows = [];
${row('Snijverlies', houseFmt(totals.purchaseWasteMM / 1000, 2) + ' m (' + houseFmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%)')} if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']);
${row('Glaswol (isolatie)', houseFmt(totals.isolatieM3, 3) + ' m3')} if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']);
</div> rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']);
<div class="spec-section"><h1>Plaatwerk</h1> cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows));
${totals.plaatwerk.filter(p => p.m2 > 0).map(p => row(p.naam, houseFmt(p.m2, 2) + ' m2')).join('')} }
</div> document.getElementById('ho-specs').innerHTML = `<div class="ui-spec-grid">${cards.join('')}</div>`;
${totals.roofEdge ? `<div class="spec-section"><h1>Dakrand &amp; buitenverlichting</h1>
${totals.roofEdge.fasciaLengthM > 0 ? row('Boeidelen', houseFmt(totals.roofEdge.fasciaLengthM, 2) + ' m &middot; ' + totals.roofEdge.fasciaHeight + ' mm hoog') + row('Onderplaat boeideel', houseFmt(totals.roofEdge.soffitM2, 2) + ' m2') : ''}
${totals.roofEdge.trimLengthM > 0 ? row('Daktrim', houseFmt(totals.roofEdge.trimLengthM, 2) + ' m &middot; ' + totals.roofEdge.trimHeight + ' mm hoog') : ''}
${row('Boeideel-spots', totals.roofEdge.spots + ' stuks')}
${row('Gevellampen', totals.lamps.total + ' stuks')}
</div>` : ''}
</div>
</div>`;
} }
// --- Alles opnieuw doorrekenen --- // --- Alles opnieuw doorrekenen ---
function refresh(opts) { function refresh(opts) {
parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, isolatieVisible, lampenVisible }); parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, lampenVisible });
const totals = HouseModel.computeTotals(parts); const totals = HouseModel.computeTotals(parts);
if (houseGroup) scene.remove(houseGroup); if (houseGroup) scene.remove(houseGroup);
@@ -919,12 +934,16 @@ function mountHouseEditor(container, callbacks) {
if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup); if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup);
if (parts.lightingGroup) houseGroup.add(parts.lightingGroup); if (parts.lightingGroup) houseGroup.add(parts.lightingGroup);
scene.add(houseGroup); scene.add(houseGroup);
applyRenderMode();
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1)); const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1));
const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1)); const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
const cx = (bx0 + bx1) / 2 * toM, cz = (bz0 + bz1) / 2 * toM; const cx = (bx0 + bx1) / 2 * toM, cz = (bz0 + bz1) / 2 * toM;
grid.position.set(cx, 0, cz); grid.position.set(cx, 0, cz);
// Maaiveld: settings.groundLevel onder het loopvlak (bovenkant vloer).
const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel) * toM;
skyEnv.fitTo(houseGroup, groundY);
ground.position.set(cx, groundY - 0.002, cz);
applyRenderMode();
if (!cameraFitted) { if (!cameraFitted) {
const size = Math.max(bx1 - bx0, bz1 - bz0) * toM; const size = Math.max(bx1 - bx0, bz1 - bz0) * toM;
controls.target.set(cx, 1.2, cz); controls.target.set(cx, 1.2, cz);
@@ -952,10 +971,8 @@ function mountHouseEditor(container, callbacks) {
} }
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
function toggleFullscreen(cls, other) { function toggleView(fn) {
const el = document.getElementById('house-overview'); fn(document.getElementById('house-overview'));
el.classList.remove(other);
el.classList.toggle(cls);
requestAnimationFrame(onResize); requestAnimationFrame(onResize);
} }
@@ -974,8 +991,8 @@ function mountHouseEditor(container, callbacks) {
}, },
refresh, refresh,
setTool, setTool,
toggleFullscreen3D: () => toggleFullscreen('fullscreen-3d', 'fullscreen-2d'), toggleFullscreen3D: () => toggleView(UI.cycle3D),
toggleFullscreen2D: () => toggleFullscreen('fullscreen-2d', 'fullscreen-3d') toggleFullscreen2D: () => toggleView(UI.toggle2D)
}; };
} }
+149 -18
View File
@@ -27,6 +27,14 @@
// (unit.lighting.walls[side]): 'auto' (standaard), 'openingen' (lampen op // (unit.lighting.walls[side]): 'auto' (standaard), 'openingen' (lampen op
// openingOffset = 500 mm naast elke opening, bijv. slaapkamers) of 'geen'. // 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. // Plain script, geladen na core/geometry.js en roof-model.js.
// ============================================================ // ============================================================
@@ -39,7 +47,12 @@ window.LightingModel = (() => {
lampMax: 3000, lampMax: 3000,
minWallWidth: 2000, // kleinste vrije gevelbreedte voor gevellampen minWallWidth: 2000, // kleinste vrije gevelbreedte voor gevellampen
endOffset: 300, // eerste/laatste lamp vanaf hoek/uiteinde endOffset: 300, // eerste/laatste lamp vanaf hoek/uiteinde
openingOffset: 500 // override: lampen zoveel naast elke opening openingOffset: 500, // override: lampen zoveel naast elke opening
// Binnenspots in het plafond, per unit (computeIndoor).
indoor: true,
indoorMin: 800, // mm h.o.h., in beide richtingen
indoorMax: 1500,
indoorOffset: 600 // min. afstand tot wand (binnenzijde) of unitrand (naad)
}; };
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' };
@@ -113,9 +126,23 @@ window.LightingModel = (() => {
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';
const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2); const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2);
// Gevel loopt tot de afgewerkte hoek, zodat endOffset altijd vanaf de
// zichtbare hoek telt: elk uiteinde + afwerking (latwerk + bekleding van
// 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), e = p.skinExt || {};
const extL = (e.left > 0 ? e.left : 0) + c, extR = (e.right > 0 ? e.right : 0) + c;
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]]
.map(([lx, lz]) => UnitModel.localToWorld(p.place, lx, lz));
segs.push({ segs.push({
piece: p, normal, axis, perp, line: q0[perp], piece: p, normal, axis, perp, line: q0[perp],
sAt0: q0[axis], sAtW: q1[axis], s0: Math.min(q0[axis], q1[axis]), s1: Math.max(q0[axis], q1[axis]), sAt0: q0[axis], sAtW: q1[axis],
s0: up ? q0[axis] - extL : q1[axis] - extR, s1: up ? q1[axis] + extR : q0[axis] + extL,
foot: { x0: Math.min(...corners.map(q => q.x)), x1: Math.max(...corners.map(q => q.x)), z0: Math.min(...corners.map(q => q.z)), z1: Math.max(...corners.map(q => q.z)) },
override: overrides[u.id][w.side] || 'auto' override: overrides[u.id][w.side] || 'auto'
}); });
}))); })));
@@ -140,6 +167,16 @@ window.LightingModel = (() => {
runs.forEach(run => { runs.forEach(run => {
const blocked = []; const blocked = [];
const pos = []; // lampposities langs deze gevel (wereld-mm) const pos = []; // lampposities langs deze gevel (wereld-mm)
// Delen van deze gevel die achter een andere wand liggen (bijv. het
// 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 => {
const f = o.foot;
const pMin = run.axis === 'x' ? f.z0 : f.x0, pMax = run.axis === 'x' ? f.z1 : f.x1;
const aMin = run.axis === 'x' ? f.x0 : f.z0, aMax = run.axis === 'x' ? f.x1 : f.z1;
if (run.line > pMin + 1 && run.line < pMax - 1 && aMax > run.s0 && aMin < run.s1) blocked.push([Math.max(run.s0, aMin), Math.min(run.s1, aMax)]);
});
run.segs.forEach(sg => { run.segs.forEach(sg => {
const cfg = sg.piece.config, sW = cfg.studWidth; const cfg = sg.piece.config, sW = cfg.studWidth;
const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100; const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100;
@@ -175,23 +212,89 @@ window.LightingModel = (() => {
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).forEach(sg => spotsBetween(sg.spotA, sg.spotB, rs.spotMin, rs.spotMax, spots)); edge.segments.filter(sg => sg.fascia && !sg.overRoof).forEach(sg => { // niet boven een lager dak
const from = spots.length;
spotsBetween(sg.spotA, sg.spotB, rs.spotMin, rs.spotMax, spots);
for (let k = from; k < spots.length; k++) {
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)
} }
edge.spots = spots.filter((p, i) => spots.findIndex(q => Math.hypot(q.x - p.x, q.z - p.z) < 5) === i); });
}
// Dubbele spots (hoeken) samenvoegen; de buitenrichting wordt dan de
// som van beide gevels (diagonaal naar buiten).
edge.spots = [];
spots.forEach(p => {
const q = edge.spots.find(e => Math.hypot(e.x - p.x, e.z - p.z) < 5);
if (!q) edge.spots.push({ x: p.x, z: p.z, y: p.y, normal: p.normal });
else if (p.normal && q.normal) q.normal = { x: q.normal.x + p.normal.x, z: q.normal.z + p.normal.z };
});
} }
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) {
const L = b - a;
if (L < min) return [(a + b) / 2];
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) {
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));
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),
// interior = parts.interior (binnenwanden met place). Geeft
// [{ unitId, x, z, y, floorY }] (mm, y = onderkant plafond, floorY = loopvlak).
function computeIndoor(settings, units, interior) {
const s = normalizeSettings(settings);
if (!s.indoor) return [];
const out = [];
units.forEach(u => {
const T = {};
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);
own.filter(iw => iw.seamUnit).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);
});
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 => {
const tooClose = own.some(iw => {
const half = iw.place.width / 2, c = Math.cos(iw.place.rotY), sn = Math.sin(iw.place.rotY);
const a = { x: iw.place.cx - half * c, z: iw.place.cz + half * sn }, b = { x: iw.place.cx + half * c, z: iw.place.cz - half * sn };
return distToSegment({ x, z }, a, b) < iw.place.thickness / 2 + off - 1;
});
if (!tooClose) out.push({ unitId: u.id, x, z, y: u.roof.ceilingAt(x, z), floorY: u.layout.floorTop });
}));
});
return out;
}
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 }),
glowMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe2a0, emissiveIntensity: 1 }), glowMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe2a0, emissiveIntensity: 1 }),
indoorMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe2a0, emissiveIntensity: 0.8 }),
beamMaterial: new THREE.MeshBasicMaterial({ color: 0xffe9a8, transparent: true, opacity: 0.16, depthWrite: false, side: THREE.DoubleSide }) beamMaterial: new THREE.MeshBasicMaterial({ color: 0xffe9a8, transparent: true, opacity: 0.16, depthWrite: false, side: THREE.DoubleSide })
}; };
} }
// 3D: behuizing op het gevelvlak + lichtbundel(s) omhoog/omlaag. // 3D: behuizing op het gevelvlak + lichtbundel(s) omhoog/omlaag, en de
function build3D(lamps, materials) { // binnenspots (indoor, zie computeIndoor) als schijfje in het plafond.
function build3D(lamps, materials, indoor) {
const toM = 0.001; const toM = 0.001;
const group = new THREE.Group(); const group = new THREE.Group();
const w = 0.08, h = 0.16, d = 0.07, beamH = 0.7, beamR = 0.22; const w = 0.08, h = 0.16, d = 0.07, beamH = 0.7, beamR = 0.22;
@@ -214,31 +317,59 @@ window.LightingModel = (() => {
if (l.direction !== 'up') addGlow(-1); if (l.direction !== 'up') addGlow(-1);
if (l.direction !== 'down') addGlow(1); if (l.direction !== 'down') addGlow(1);
}); });
(indoor || []).forEach(p => {
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);
group.add(spot);
});
return group; return group;
} }
// Volle render: elke gevellamp (per lichtrichting) en elke boeideel-spot // Render: elke gevellamp (per lichtrichting), boeideel-spot en binnenspot
// wordt een echte SpotLight. Gevellampen staan net buiten het gevelvlak en // wordt een echte SpotLight. Echte schaduw per lamp is te zwaar (één
// schijnen langs de gevel omhoog/omlaag; spots schijnen recht naar beneden. // schaduwkaart per lamp; de browser kan er maar ~16), dus licht door muren
// spots = [{ x, z }] (mm), spotY = hoogte onderkant boeideel (mm). // wordt geometrisch voorkomen:
function buildLights3D(lamps, spots, spotY) { // - buitenlampen en boeideel-spots zijn half hun bundelhoek naar buiten
// gekanteld: de binnenrand van de bundel loopt verticaal langs de gevel;
// - binnenspots hebben een harde reikwijdte net voorbij de vloer
// (INDOOR_REACH), zodat ze niet tot de grond buiten reiken.
// spots = [{ x, z, y }] (mm, boeideel; y = onderkant boeideel, anders spotY),
// indoor = [{ x, z, y, floorY }] (mm). o.outdoorFactor schaalt de buiten-
// lampen (overdag lager: dan vallen ze nauwelijks op).
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;
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 group = new THREE.Group(); const group = new THREE.Group();
const add = (x, y, z, dirY, intensity, angle, distance) => { // Spot op (x, y, z) die naar (x + tx, y + ty, z + tz) schijnt.
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, 0.6, 1.5); const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay) => {
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, 0.6, decay);
light.position.set(x, y, z); light.position.set(x, y, z);
light.target.position.set(x, y + dirY * 2, z); 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
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;
if (l.direction !== 'up') add(x, y - 0.09, z, -1, 3, 0.55, 4.5); const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
if (l.direction !== 'down') add(x, y + 0.09, z, 1, 3, 0.55, 4.5); 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 !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5);
});
// Boeideel-spots: naar buiten gekanteld (normaal van de dakrand; op een
// hoek diagonaal).
(spots || []).forEach(p => {
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5);
});
(indoor || []).forEach(p => {
const drop = (p.y - p.floorY) * toM; // plafond -> loopvlak
add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay);
}); });
(spots || []).forEach(p => add(p.x * toM, spotY * toM - 0.02, p.z * toM, -1, 2.5, 0.6, 5.5));
return group; return group;
} }
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, normalizeSettings, compute, createMaterials, build3D, buildLights3D }; return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, createMaterials, build3D, buildLights3D };
})(); })();
+260 -28
View File
@@ -5,14 +5,20 @@
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
// ============================================================ // ============================================================
// roof/roof-model.js — RoofModel: ENIGE bron van de dak-logica (voorlopig // roof/roof-model.js — RoofModel: ENIGE bron van de dak-logica. Geen DOM.
// alleen plat dak). Geen DOM.
// //
// Een plat dak is opgebouwd als een vloer (FloorModel: balken, isolatie, // Een dakvlak is opgebouwd als een vloer (FloorModel: balken, isolatie,
// doorvoeren, OSB-plaat), maar 180° gespiegeld: de OSB-plaat zit aan de // doorvoeren, OSB-plaat), maar 180° gespiegeld: de OSB-plaat zit aan de
// onderzijde (plafond), de balken erboven, met dakleer als buitenlaag // onderzijde (plafond), de balken erboven, met dakleer als buitenlaag
// bovenop. Het "dek" van een dak is dus gewoon een vloer-state. // bovenop. Het "dek" van een dak is dus gewoon een vloer-state.
// //
// Dakvorm volgt de wanden (computeShape): het paar tegenoverliggende wanden
// 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 // Dakrand (over de hele woning, doorlopend over naden tussen units): de
// omtrek van alle daken samen, opgeknipt per ribbe (en op elke unit-hoek). // 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 // Per ribbe bepaalt de unit waar die ribbe bij hoort of het een boeideel of
@@ -77,7 +83,7 @@ window.RoofModel = (() => {
const toM = 0.001; const toM = 0.001;
const group = new THREE.Group(); const group = new THREE.Group();
const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, { const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, {
materials: m, osbVisible: opts.osbVisible !== false, isolatieVisible: opts.isolatieVisible !== false materials: m, osbVisible: 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;
@@ -114,6 +120,147 @@ window.RoofModel = (() => {
return Object.assign({}, deck, { dakleerM2, dakleerKg, totalKg: deck.totalKg + dakleerKg }); return Object.assign({}, deck, { dakleerM2, dakleerKg, totalKg: deck.totalKg + dakleerKg });
} }
// ------------------------------------------------------------
// Dakvorm per unit (volgt de wanden)
// ------------------------------------------------------------
// 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) {
const w = unitState.walls[side].wall;
const wl = layout.walls.find(x => x.side === side);
const s = UnitModel.WALL_SIDES.find(x => x.key === side);
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 }];
if (w.heightMid > 0) pts.push({ p: c, h: Math.max(w.heightMid, w.heightLeft, w.heightRight) });
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) {
for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) {
const profile = wallProfile(unitState, layout, side);
if (profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) {
return { type: profile.length > 2 ? 'zadel' : 'lessenaar', axis, profile };
}
}
const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws)));
return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] };
}
// Hoogte van het profiel op positie p; buiten de wand loopt de laatste
// helling door (overstek, boeideel).
function profileHeight(profile, p) {
if (profile.length === 1) return profile[0].h;
let i = 0;
while (i < profile.length - 2 && p > profile[i + 1].p) i++;
const a = profile[i], b = profile[i + 1];
return a.h + (p - a.p) / (b.p - a.p) * (b.h - a.h);
}
// Tel alle getallen van een lijst specificaties op.
function sumSpecs(list) {
const out = Object.assign({}, list[0]);
list.slice(1).forEach(sp => Object.keys(sp).forEach(k => {
if (typeof sp[k] === 'number' && typeof out[k] === 'number') out[k] += sp[k];
}));
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) {
const alongX = shape.axis === 'x';
const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0;
const xc = (rect.x0 + rect.x1) / 2, zc = (rect.z0 + rect.z1) / 2;
const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1;
const kinks = shape.profile.slice(1, -1).map(q => q.p).filter(p => p > a0 + 1 && p < a1 - 1);
const cuts = [a0, ...kinks, a1];
const flat = FloorModel.computeFromState(roofState.deck, rims);
const H = (p) => profileHeight(shape.profile, p);
// Doorvoeren: positie in de plattegrond (zoals in de dak-editor) -> vlak.
const holes = flat.resolvedOpenings.filter(o => !o.removed).map(o => {
const px = xc + o.x, pz = rect.z0 + o.y;
return { o, px, pz, q: alongX ? px : pz };
});
const planes = cuts.slice(0, -1).map((p0, i) => {
const p1 = cuts[i + 1], last = i === cuts.length - 2;
const h0 = H(p0), h1 = H(p1), run = p1 - p0;
const slopeLen = Math.hypot(run, h1 - h0), cos = run / slopeLen;
const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 };
let data;
if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5) {
data = flat; // plat dak: het dek zoals het is
} else {
const deck = clone(roofState.deck);
deck.floor.width = Math.round(alongX ? D : W);
deck.floor.depth = Math.round(slopeLen);
deck.openings = holes.filter(hl => hl.q >= p0 && (hl.q < p1 || last)).map(hl => ({
id: hl.o.id, diameter: hl.o.requestedDiameter, anchorX: 'left',
// Bij helling langs X is het dek een kwartslag gedraaid: lokaal x -> -Z.
offsetX: Math.round(alongX ? rect.z1 - hl.pz : hl.px - rect.x0),
offsetY: Math.round((hl.q - p0) / cos)
}));
const pr = alongX ? {} : { front: !!rims.front && Math.abs(p0 - rect.z0) < 1, back: !!rims.back && Math.abs(p1 - rect.z1) < 1 };
data = FloorModel.computeFromState(deck, pr);
}
const mid = (p0 + p1) / 2;
return Object.assign(plane, {
data, specs: computeSpecs(data.calcData, data.resolvedOpenings),
cx: alongX ? mid - xc : 0, cz: alongX ? 0 : mid - zc, y: (h0 + h1) / 2,
// Verticale dikte (dek + dakleer) van dit vlak.
vThick: thickness(data.calcData) / cos
});
});
const planeAt = (q) => planes.find((pl, i) => q < pl.p1 || i === planes.length - 1);
const undersideAt = (x, z) => H(alongX ? x : z);
const topAt = (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);
const t = onKink.length > 1 ? Math.max(...onKink.map(pl => pl.vThick)) : planeAt(q).vThick;
return H(q) + t;
};
return {
shape, planes, specs: sumSpecs(planes.map(pl => pl.specs)),
undersideAt, topAt,
ridges: kinks.map(value => ({ axis: shape.axis, value })),
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,
// Y = 0 op het loopvlak): per dakvlak build3D, gekanteld om de onderkant
// van het plafond zodat die precies op de daklijn van de wanden ligt.
function buildUnit3D(unitRoof, opts) {
const toM = 0.001;
const group = new THREE.Group();
unitRoof.planes.forEach(pl => {
const g = new THREE.Group();
g.add(build3D(pl.data.calcData, pl.data.resolvedOpenings, opts));
g.position.set(pl.cx * toM, pl.y * toM, pl.cz * toM);
// Eerst kantelen om lokaal X (lokaal +Z = oplopende as), dan draaien.
g.rotation.set(-pl.theta, pl.yaw, 0, 'YXZ');
group.add(g);
});
return group;
}
// ------------------------------------------------------------ // ------------------------------------------------------------
// Dakrand over de hele woning // Dakrand over de hele woning
// ------------------------------------------------------------ // ------------------------------------------------------------
@@ -148,37 +295,89 @@ 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 }] (mm, woning- // roofs: [{ unitId, rect: { x0, x1, z0, z1 }, fascia: bool, topAt(x, z),
// assen); topY = bovenkant dakleer (mm). Geeft per ribbe (segments) de // ridges: [{ axis, value }] }] (mm, woning-assen); topAt = bovenkant
// geometrie terug; edge.spots wordt door LightingModel gevuld. // dakleer (absoluut, loopt buiten het dak door met de helling). Geeft per
function computeEdge(settings, roofs, topY) { // 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);
const loops = rectUnionLoops(rects); if (!roofs.length) return null;
if (!loops.length) return null;
// Hoogtegroepen: daken die tegen elkaar liggen horen bij één doorlopende
// rand, tenzij het hoogteverschil langs de hele naad minstens de
// 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 find = (i) => group[i] === i ? i : (group[i] = find(group[i]));
const sharedEdge = (A, B) => {
const e = 0.5;
for (const [ax, bx, lo0, lo1, hi0, hi1, axis] of [
[A.x1, B.x0, A.z0, A.z1, B.z0, B.z1, 'x'], [B.x1, A.x0, A.z0, A.z1, B.z0, B.z1, 'x'],
[A.z1, B.z0, A.x0, A.x1, B.x0, B.x1, 'z'], [B.z1, A.z0, A.x0, A.x1, B.x0, B.x1, 'z']]) {
const s0 = Math.max(lo0, hi0), s1 = Math.min(lo1, hi1);
if (Math.abs(ax - bx) < e && s1 - s0 > e) {
return [s0, (s0 + s1) / 2, s1].map(t => axis === 'x' ? { x: ax, z: t } : { x: t, z: ax });
}
}
return null;
};
roofs.forEach((A, i) => roofs.forEach((B, j) => {
if (j <= i) return;
const pts = sharedEdge(A.rect, B.rect);
if (!pts) return;
const diff = Math.min(...pts.map(p => Math.abs(A.topAt(p.x, p.z) - B.topAt(p.x, p.z))));
if (diff < s.fasciaHeight) group[find(i)] = find(j);
}));
const segments = [];
[...new Set(roofs.map((r, i) => find(i)))].forEach(g => {
const members = roofs.filter((r, i) => find(i) === g);
const others = roofs.filter((r, i) => find(i) !== g);
const loops = rectUnionLoops(members.map(r => r.rect));
if (!loops.length) return;
const base = splitAtCorners(loops[0].pts, rects); const base = splitAtCorners(loops[0].pts, rects);
const offBoei = offsetRectilinearLoop(base, s.overhang); const offBoei = offsetRectilinearLoop(base, s.overhang);
const offTrim = offsetRectilinearLoop(base, TRIM_THICK_MM); const offTrim = offsetRectilinearLoop(base, TRIM_THICK_MM);
const offSpot = offsetRectilinearLoop(base, s.overhang * SPOT_RATIO); const offSpot = offsetRectilinearLoop(base, s.overhang * SPOT_RATIO);
const n = base.length; const n = base.length;
const segs = base.map((a, i) => {
const segments = base.map((a, i) => {
const j = (i + 1) % n, b = base[j]; const j = (i + 1) % n, b = base[j];
const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 }; const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 };
const owner = roofs.find(r => onRectEdge(r.rect, mid)) || roofs[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
// 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 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],
topAt: (p) => owner.topAt(p.x, p.z), ridges: owner.ridges || [],
overRoof: !!nb && !dropped, dropped
}; };
}); });
// Naast een vervallen ribbe loopt de rand recht door tot de gevel.
segs.forEach((sg, i) => {
const prev = segs[(i - 1 + n) % n], next = segs[(i + 1) % n];
const alongZ = Math.abs(sg.a.x - sg.b.x) < 0.5;
const square = (key, v) => { sg[key] = alongZ ? { x: sg[key].x, z: v.z } : { x: v.x, z: sg[key].z }; };
if (!sg.dropped && prev.dropped) square('outerA', sg.a);
if (!sg.dropped && next.dropped) square('outerB', sg.b);
});
segs.filter(sg => !sg.dropped).forEach(sg => segments.push(sg));
});
const segLen = (sg) => Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z); const segLen = (sg) => Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z);
const top = Math.max(...segments.map(sg => Math.max(sg.topAt(sg.a), sg.topAt(sg.b))));
return { return {
base, segments, overhang: s.overhang, spotRatio: SPOT_RATIO, segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight,
top: topY, trimTop: topY + TRIM_ABOVE_MM, top, trimAbove: TRIM_ABOVE_MM,
fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0), fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
trimLengthMM: segments.filter(sg => !sg.fascia).reduce((t, sg) => t + segLen(sg), 0), trimLengthMM: segments.filter(sg => !sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
soffitM2: segments.filter(sg => sg.fascia).reduce((t, sg) => t + quadArea([sg.a, sg.b, sg.outerB, sg.outerA]), 0) / 1e6, soffitM2: segments.filter(sg => sg.fascia).reduce((t, sg) => t + quadArea([sg.a, sg.b, sg.outerB, sg.outerA]), 0) / 1e6,
@@ -186,9 +385,40 @@ window.RoofModel = (() => {
}; };
} }
// 3D van de dakrand: per ribbe een geëxtrudeerd vierhoek (dakrand -> // Convexe veelhoek (plattegrond, mm) knippen met een halfvlak
// buitenrand, in verstek), naar beneden over de hoogte van boeideel resp. // (Sutherland-Hodgman): houd de punten waar f(p) >= 0.
// daktrim. Plus de boeideel-spots (edge.spots) in de onderplaat. function clipPoly(poly, f) {
const out = [];
poly.forEach((p, k) => {
const q = poly[(k + 1) % poly.length], 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, z: p.z + (q.z - p.z) * t });
}
});
return out;
}
// Prisma onder een convexe veelhoek: bovenkant per hoekpunt op topFn(p),
// onderkant h lager (mm -> meters).
function prismGeometry(poly, topFn, h) {
const toM = 0.001, n = poly.length, pos = [];
const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]);
const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]);
const tri = (a, b, c) => pos.push(...a, ...b, ...c);
for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); tri(B[0], B[k + 1], B[k]); }
for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); tri(T[k], B[j], T[j]); }
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.computeVertexNormals();
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") } // opts: { edgeVisible (volgt "Beplating"), spotsVisible (volgt "Lampen") }
function buildEdge3D(edge, materials, opts) { function buildEdge3D(edge, materials, opts) {
const o = opts || {}; const o = opts || {};
@@ -196,23 +426,24 @@ window.RoofModel = (() => {
const group = new THREE.Group(); const group = new THREE.Group();
if (!edge) return group; if (!edge) return group;
edge.segments.forEach(sg => { edge.segments.forEach(sg => {
const pts = [sg.a, sg.b, sg.outerB, sg.outerA]; let polys = [[sg.a, sg.b, sg.outerB, sg.outerA]];
const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.z * toM))); sg.ridges.forEach(r => {
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);
});
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight; const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight;
const mesh = new THREE.Mesh( const topFn = sg.fascia ? sg.topAt : (p) => sg.topAt(p) + edge.trimAbove;
new THREE.ExtrudeGeometry(shape, { steps: 1, depth: h * toM, bevelEnabled: false }), polys.forEach(pl => {
sg.fascia ? materials.fasciaMaterial : materials.trimMaterial const mesh = new THREE.Mesh(prismGeometry(pl, topFn, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial);
);
mesh.rotation.x = Math.PI / 2; // shape-y -> wereld-Z, extrusie naar beneden
mesh.position.y = (sg.fascia ? edge.top : edge.trimTop) * toM;
mesh.visible = o.edgeVisible !== false; mesh.visible = o.edgeVisible !== false;
group.add(mesh); group.add(mesh);
}); });
});
const spotR = 0.035, spotH = 0.012; const spotR = 0.035, spotH = 0.012;
edge.spots.forEach(p => { edge.spots.forEach(p => {
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, (edge.top - edge.fasciaHeight) * 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;
group.add(spot); group.add(spot);
}); });
@@ -229,6 +460,7 @@ window.RoofModel = (() => {
return { return {
DAKLEER_MM, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, DAKLEER_MM, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs, createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D,
computeEdge, buildEdge3D, normalizeSettings computeEdge, buildEdge3D, normalizeSettings
}; };
})(); })();
+44 -3
View File
@@ -174,7 +174,12 @@ window.UnitModel = (() => {
const fullConfig = WallModel.configFromState(wallState); const fullConfig = WallModel.configFromState(wallState);
const W = wallLayout.width; const W = wallLayout.width;
const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight; const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight;
const heightAt = (u) => hL + (u / W) * (hR - hL); // Middenpunt (nok) midden op de wand; een stuk krijgt de nok alleen als
// die erin valt, anders de daklijn ter plaatse als gewone helling.
const hM = wallState.wall.heightMid > 0 ? Math.max(wallState.wall.heightMid, hL, hR) : null;
const pU = W / 2;
const heightAt = (u) => hM === null ? hL + (u / W) * (hR - hL)
: u <= pU ? hL + (u / pU) * (hM - hL) : hM + ((u - pU) / (W - pU)) * (hR - hM);
const multi = wallLayout.pieces.length > 1; const multi = wallLayout.pieces.length > 1;
const used = new Set(); const used = new Set();
@@ -182,8 +187,10 @@ window.UnitModel = (() => {
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, w: op.w, h: op.h, 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
}); });
const config = WallModel.buildConfig(params, openings); const config = WallModel.buildConfig(params, openings);
return { return {
@@ -197,6 +204,40 @@ 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 = {
voor: { left: 'rechts', right: 'links' },
achter: { left: 'links', right: 'rechts' },
links: { left: 'voor', right: 'achter' },
rechts: { left: 'achter', right: 'voor' }
};
const OPPOSITE = { voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' };
function syncWallHeights(state, side) {
const w = state.walls[side].wall;
const mid = w.heightMid > 0 ? w.heightMid : null;
const set = (key, hL, hR, hM) => {
const t = state.walls[key].wall;
t.heightLeft = hL; t.heightRight = hR; t.heightMid = hM;
t.heightAll = hM === null && hL === hR ? hL : w.heightAll;
};
set(OPPOSITE[side], w.heightRight, w.heightLeft, mid);
set(WALL_ENDS[side].left, w.heightLeft, w.heightLeft, null);
set(WALL_ENDS[side].right, w.heightRight, w.heightRight, null);
}
// Hoogste punt van een wand (incl. nok).
function wallTopOf(wallState) {
const w = wallState.wall;
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0);
}
function validateState(design) { function validateState(design) {
if (!design.floor) throw new Error('veld "floor" ontbreekt'); if (!design.floor) throw new Error('veld "floor" ontbreekt');
try { FloorModel.validateState(design.floor); } catch (err) { throw new Error('vloer: ' + err.message); } try { FloorModel.validateState(design.floor); } catch (err) { throw new Error('vloer: ' + err.message); }
@@ -210,5 +251,5 @@ window.UnitModel = (() => {
} }
} }
return { WALL_SIDES, CORNER_MODES, clone, createDefaultState, localToWorld, computeLayout, wallPieces, validateState }; return { WALL_SIDES, CORNER_MODES, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, validateState };
})(); })();
+313 -263
View File
@@ -14,7 +14,7 @@
// voor zowel de losse wand als de unit gelden. // voor zowel de losse wand als de unit gelden.
// //
// Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export): // Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export):
// { wall: {width, heightAll, heightLeft, heightRight, studWidth, ...}, // { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...},
// materials: {interior, exterior}, openings: [{id,w,h,y,anchor,offset}] } // materials: {interior, exterior}, openings: [{id,w,h,y,anchor,offset}] }
// //
// Plain script (geen ES-module), moet na core/geometry.js geladen worden. // Plain script (geen ES-module), moet na core/geometry.js geladen worden.
@@ -95,10 +95,90 @@ window.WallModel = (() => {
deur: { label: 'Deur', w: 900, h: 2150, y: 0 }, deur: { label: 'Deur', w: 900, h: 2150, y: 0 },
voordeur: { label: 'Voordeur', w: 1150, h: 2150, y: 0 } voordeur: { label: 'Voordeur', w: 1150, h: 2150, y: 0 }
}; };
// Deuren krijgen aan weerszijden een tweede stijl direct tegen de
// koningsstijl (stevigheid, computeFrame).
const isDoor = (op) => op.type === 'deur' || op.type === 'voordeur';
// Buitenzijde-materialen (sleutel -> weergavenaam). 'spaanplaat' is voor // Buitenzijde-materialen (sleutel -> weergavenaam). 'spaanplaat' is voor
// binnenwanden (aan beide zijden spaanplaat). // binnenwanden (aan beide zijden spaanplaat).
const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat' }; // 'geen': geen beplating aan de buitenzijde — naadwand-deel dat tegen het
// deel van de andere unit aan staat (HouseModel, naadwanden).
const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat', geen: 'Geen (naadzijde)' };
// Bovenrand van de wand (daklijn): één recht stuk van links naar rechts,
// of met een middenpunt (nok, wallPeak) twee stukken die elkaar daar
// raken. De nok ligt nooit lager dan de goten (buildConfig), dus de
// bovenrand is overal het MINIMUM van de (doorgetrokken) stukken — dat
// houdt at/under/rangeAbove simpel en maakt "laagste punt boven een
// opening" gelijk aan het laagste van de twee uiteinden.
// x t.o.v. het wandmidden, alles in mm x k (k = 0.001: meters).
// at(x) bovenkant (daklijn)
// under(x) onderkant bovenregel(s); knee = hun snijpunt (verstek)
// rangeAbove(y) [x0, x1] waar de daklijn boven y ligt (of null)
// crossings(y) x-posities waar de daklijn y kruist (knikpunten)
// underPts(a, b) onderkant bovenregel van a naar b, incl. knie
function topLine(CONFIG, k) {
k = k || 1;
const halfW = CONFIG.wallWidth / 2 * k;
const L = { x: -halfW, y: CONFIG.wallHeightLeft * k }, R = { x: halfW, y: CONFIG.wallHeightRight * k };
const pk = CONFIG.wallPeak ? { x: (CONFIG.wallPeak.x - CONFIG.wallWidth / 2) * k, y: CONFIG.wallPeak.h * k } : null;
const pts = pk ? [L, pk, R] : [L, R];
const segs = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1], slope = (b.y - a.y) / (b.x - a.x);
segs.push({ a, b, slope, vo: CONFIG.plateThick * k * Math.sqrt(1 + slope * slope) });
}
const lineY = (sg, x) => sg.a.y + (x - sg.a.x) * sg.slope;
const at = (x) => Math.min(...segs.map(sg => lineY(sg, x)));
const under = (x) => Math.min(...segs.map(sg => lineY(sg, x) - sg.vo));
let knee = null;
if (segs.length === 2) {
const [s0, s1] = segs, ds = s0.slope - s1.slope;
// s0.a.y + (x - s0.a.x) s0.slope - vo0 = s1.a.y + (x - s1.a.x) s1.slope - vo1
const x = Math.abs(ds) > 1e-12
? ((s1.a.y - s1.a.x * s1.slope - s1.vo) - (s0.a.y - s0.a.x * s0.slope - s0.vo)) / ds
: pk.x;
knee = { x, y: under(x) };
}
const rangeAbove = (y) => {
let x0 = -halfW, x1 = halfW;
segs.forEach(sg => {
if (Math.abs(sg.slope) < 1e-12) { if (sg.a.y <= y) x1 = x0 - 1; return; }
const xc = sg.a.x + (y - sg.a.y) / sg.slope;
if (sg.slope > 0) x0 = Math.max(x0, xc); else x1 = Math.min(x1, xc);
});
return x1 > x0 ? [x0, x1] : null;
};
const crossings = (y) => segs.filter(sg => Math.abs(sg.slope) > 1e-12)
.map(sg => ({ sg, x: sg.a.x + (y - sg.a.y) / sg.slope }))
.filter(c => c.x > c.sg.a.x && c.x < c.sg.b.x).map(c => c.x);
const underPts = (xa, xb) => {
const out = [{ x: xa, y: under(xa) }];
if (knee && knee.x > xa && knee.x < xb) out.push({ x: knee.x, y: knee.y });
out.push({ x: xb, y: under(xb) });
return out;
};
return {
pts, segs, peak: pk, knee, at, under, rangeAbove, crossings, underPts,
maxY: Math.max(...pts.map(p => p.y)), minY: Math.min(L.y, R.y),
kinks: pk ? [pk.x] : []
};
}
// Omtrek(ken) van de bovenregel(s) (zelfde eenheid als topLine): één
// parallellogram, of met nok twee stukken in verstek (nok boven, knie onder).
function topPlateOutlines(tl) {
const [L, R] = [tl.pts[0], tl.pts[tl.pts.length - 1]];
if (!tl.peak) {
const vo = tl.segs[0].vo;
return [[L, R, { x: R.x, y: R.y - vo }, { x: L.x, y: L.y - vo }]];
}
const P = tl.peak, K = tl.knee;
return [
[L, P, K, { x: L.x, y: L.y - tl.segs[0].vo }],
[P, R, { x: R.x, y: R.y - tl.segs[1].vo }, K]
];
}
// Heuristiek (geen volwaardige constraint-solver): elke opening wordt eerst // Heuristiek (geen volwaardige constraint-solver): elke opening wordt eerst
// individueel gepositioneerd op basis van zijn anker, dan van links naar // individueel gepositioneerd op basis van zijn anker, dan van links naar
@@ -114,7 +194,8 @@ window.WallModel = (() => {
// het laagste punt van de hele wand. Bij een hellende wand mag een opening // het laagste punt van de hele wand. Bij een hellende wand mag een opening
// dicht bij de hoge kant dus hoger/groter zijn dan een opening dicht bij // dicht bij de hoge kant dus hoger/groter zijn dan een opening dicht bij
// de lage kant. // de lage kant.
function resolveOpenings(rawOpenings, wallWidth, studWidth, wallHeightLeft, wallHeightRight, plateThick) { // roofAt(xLocal): daklijn, xLocal vanaf de linker wandrand (0..wallWidth).
function resolveOpenings(rawOpenings, wallWidth, studWidth, roofAt, plateThick) {
// 2850mm is de vaste referentie-bovengrens (bijv. max. sparingbreedte in // 2850mm is de vaste referentie-bovengrens (bijv. max. sparingbreedte in
// dit constructiesysteem); daarnaast blijft de wand zelf ook een harde // dit constructiesysteem); daarnaast blijft de wand zelf ook een harde
// grens (er moet nog stijlruimte overblijven). // grens (er moet nog stijlruimte overblijven).
@@ -142,34 +223,44 @@ window.WallModel = (() => {
o.x = Math.max(edgeMargin, Math.min(o.x, wallWidth - o.w - edgeMargin)); o.x = Math.max(edgeMargin, Math.min(o.x, wallWidth - o.w - edgeMargin));
}); });
// Links-naar-rechts: duw overlappende openingen uit elkaar. 2x stijldikte // Openingen die elkaar in de hoogte raken (incl. boven-/onderdorpel en
// i.p.v. 1x: de OSB-uitsparing rond elke opening steekt aan weerszijden // 2x stijldikte ertussen) moeten naast elkaar; staan ze boven elkaar
// al 1x stijldikte uit (om de koningsstijl zichtbaar te laten), dus bij // (bijv. een raam boven een deur), dan mogen ze in x overlappen.
// minder afstand tussen twee openingen zouden hun uitsparingen elkaar // Hoogte hier op basis van de invoer; na fase 2 wordt het nog gecontroleerd.
// kunnen overlappen (gecontroleerd, zie eerdere test). const vBand = (o, h, y) => [y > plateThick ? y - studWidth : 0, y + h + studWidth];
const minGap = studWidth * 2; const stacked = (a, b, useFinal) => {
const [a0, a1] = useFinal ? vBand(a, a.h, a.y) : vBand(a, Math.max(400, a.rawH), a.rawY);
const [b0, b1] = useFinal ? vBand(b, b.h, b.y) : vBand(b, Math.max(400, b.rawH), b.rawY);
return a1 <= b0 + 0.5 || b1 <= a0 + 0.5;
};
// Tussenruimte naast elkaar: 2x stijldikte i.p.v. 1x: de OSB-uitsparing
// rond elke opening steekt aan weerszijden al 1x stijldikte uit (om de
// koningsstijl zichtbaar te laten), dus bij minder afstand zouden hun
// uitsparingen elkaar kunnen overlappen. Naast een deur 1x extra voor
// de dubbele stijl.
const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0));
// Links-naar-rechts: duw botsende openingen uit elkaar.
for (let i = 1; i < resolved.length; i++) { for (let i = 1; i < resolved.length; i++) {
const prev = resolved[i - 1];
const cur = resolved[i]; const cur = resolved[i];
const minX = prev.x + prev.w + minGap; for (let j = 0; j < i; j++) {
if (cur.x < minX) cur.x = minX; const prev = resolved[j];
if (stacked(prev, cur)) continue;
cur.x = Math.max(cur.x, prev.x + prev.w + gapOf(prev, cur));
}
} }
// Rechts-naar-links correctie als de laatste opening nu over de rand steekt // Rechts-naar-links: wat over de rechterrand steekt terugtrekken, en de
// botsende openingen links ervan mee.
const rightLimit = wallWidth - edgeMargin; const rightLimit = wallWidth - edgeMargin;
let overflow = 0;
if (resolved.length) {
const last = resolved[resolved.length - 1];
overflow = Math.max(0, (last.x + last.w) - rightLimit);
}
if (overflow > 0) {
for (let i = resolved.length - 1; i >= 0; i--) { for (let i = resolved.length - 1; i >= 0; i--) {
resolved[i].x -= overflow; const cur = resolved[i];
if (i > 0) { let maxX = rightLimit - cur.w;
const maxX = resolved[i].x - minGap - resolved[i - 1].w; for (let j = i + 1; j < resolved.length; j++) {
if (resolved[i - 1].x > maxX) resolved[i - 1].x = maxX; const next = resolved[j];
} if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gapOf(cur, next) - cur.w);
} }
if (cur.x > maxX) cur.x = maxX;
} }
// Fase 2: hoogte (h) en verticale positie (y), per opening geklemd tegen // Fase 2: hoogte (h) en verticale positie (y), per opening geklemd tegen
@@ -177,8 +268,9 @@ window.WallModel = (() => {
// die opening — het punt waar een verticale lijn op die plek de // die opening — het punt waar een verticale lijn op die plek de
// (eventueel hellende) horizontale topbalk zou snijden. Bij een // (eventueel hellende) horizontale topbalk zou snijden. Bij een
// opening die zelf een breedte heeft, wordt het laagste van de twee // opening die zelf een breedte heeft, wordt het laagste van de twee
// uiteinden aangehouden (de daklijn is lineair, dus dat is voldoende). // uiteinden aangehouden (de daklijn is lineair of heeft een nok als
const localRoofHeightAt = (xLocal) => wallHeightLeft + (xLocal / wallWidth) * (wallHeightRight - wallHeightLeft); // hoogste punt, dus dat is voldoende).
const localRoofHeightAt = roofAt;
resolved.forEach(o => { resolved.forEach(o => {
const localCeiling = Math.min(localRoofHeightAt(o.x), localRoofHeightAt(o.x + o.w)); const localCeiling = Math.min(localRoofHeightAt(o.x), localRoofHeightAt(o.x + o.w));
@@ -191,14 +283,16 @@ window.WallModel = (() => {
}); });
// Detecteer of het na dit alles nog steeds niet past (te veel/te brede // Detecteer of het na dit alles nog steeds niet past (te veel/te brede
// openingen voor deze wandbreedte) zodat de UI dit kan tonen. // openingen voor deze wandbreedte, of boven elkaar zonder ruimte
// ertussen) zodat de UI dit kan tonen.
resolved.forEach(o => { resolved.forEach(o => {
o.overflowing = (o.x < edgeMargin - 0.5) || (o.x + o.w > wallWidth - edgeMargin + 0.5); o.overflowing = (o.x < edgeMargin - 0.5) || (o.x + o.w > wallWidth - edgeMargin + 0.5);
}); });
for (let i = 1; i < resolved.length; i++) { for (let i = 0; i < resolved.length; i++) {
if (resolved[i].x < resolved[i - 1].x + resolved[i - 1].w + minGap - 0.5) { for (let j = i + 1; j < resolved.length; j++) {
resolved[i].overflowing = true; const a = resolved[i], b = resolved[j];
resolved[i - 1].overflowing = true; const [l, r] = a.x <= b.x ? [a, b] : [b, a];
if (!stacked(a, b, true) && r.x < l.x + l.w + gapOf(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
} }
} }
@@ -214,11 +308,24 @@ window.WallModel = (() => {
const hRight = Math.round(params.heightRight || 2900); const hRight = Math.round(params.heightRight || 2900);
const studWidth = Math.round(params.studWidth || STANDARD_BEAM.width); const studWidth = Math.round(params.studWidth || STANDARD_BEAM.width);
const plateThick = Math.round(params.plateThick || STANDARD_BEAM.width); const plateThick = Math.round(params.plateThick || STANDARD_BEAM.width);
// Middenpunt (nok): standaard midden op de wand, of op peakX (mm vanaf
// links; een wandstuk uit een unit waar de nok niet in het midden
// valt). Nooit lager dan de hoogste goot; gelijk = geen nok.
let wallPeak = null;
if (params.heightMid > 0) {
const px = params.peakX !== undefined ? params.peakX : wallWidth / 2;
const h = Math.round(Math.max(params.heightMid, hLeft, hRight));
if (px > 1 && px < wallWidth - 1 && h > hLeft + (px / wallWidth) * (hRight - hLeft) + 0.5) wallPeak = { x: px, h };
}
const roofAt = (x) => wallPeak
? (x <= wallPeak.x ? hLeft + x / wallPeak.x * (wallPeak.h - hLeft) : wallPeak.h + (x - wallPeak.x) / (wallWidth - wallPeak.x) * (hRight - wallPeak.h))
: hLeft + (x / wallWidth) * (hRight - hLeft);
return { return {
wallWidth, wallWidth,
wallHeightLeft: hLeft, wallHeightLeft: hLeft,
wallHeightRight: hRight, wallHeightRight: hRight,
wallPeak,
studWidth, studWidth,
studDepth: Math.round(params.studDepth || STANDARD_BEAM.depth), studDepth: Math.round(params.studDepth || STANDARD_BEAM.depth),
plateThick, plateThick,
@@ -226,7 +333,7 @@ window.WallModel = (() => {
sheathingThick: Math.round(params.sheathingThick || 10), sheathingThick: Math.round(params.sheathingThick || 10),
minSpacing: Math.round(params.minSpacing || 240), minSpacing: Math.round(params.minSpacing || 240),
maxSpacing: Math.round(params.maxSpacing || 480), maxSpacing: Math.round(params.maxSpacing || 480),
openings: resolveOpenings(rawOpenings, wallWidth, studWidth, hLeft, hRight, plateThick) openings: resolveOpenings(rawOpenings, wallWidth, studWidth, roofAt, plateThick)
}; };
} }
@@ -234,17 +341,23 @@ window.WallModel = (() => {
return buildConfig(state.wall, state.openings || []); return buildConfig(state.wall, state.openings || []);
} }
// Totale wanddikte (mm): regelwerk + beplating aan beide zijden. Gebruikt // Totale wanddikte (mm): regelwerk + beplating aan beide zijden (bij
// door de unit om wanden met hun buitenkant op de vloerrand te zetten. // buitenMateriaal 'geen' alleen aan de binnenzijde). Gebruikt door de unit
function thicknessOf(config) { // om wanden met hun buitenkant op de vloerrand te zetten.
return Math.max(config.studDepth, config.plateDepth) + 2 * (config.sheathingThick + SKIN_GAP_MM); function thicknessOf(config, buitenMateriaal) {
const sides = buitenMateriaal === 'geen' ? 1 : 2;
return Math.max(config.studDepth, config.plateDepth) + sides * (config.sheathingThick + SKIN_GAP_MM);
} }
// Regelwerk: volledige stijlen (incl. koningsstijlen) + kreupele stijlen. // Regelwerk: volledige stijlen (incl. koningsstijlen) + kreupele stijlen.
function computeFrame(CONFIG) { function computeFrame(CONFIG) {
const startX = -CONFIG.wallWidth / 2; const startX = -CONFIG.wallWidth / 2;
const slope = (CONFIG.wallHeightRight - CONFIG.wallHeightLeft) / CONFIG.wallWidth; const top = topLine(CONFIG);
const angleDeg = Math.round(Math.atan(slope) * (180 / Math.PI)); const slope = top.segs[0].slope;
// Met nok: de steilste helling (beide kanten lopen dan op naar de nok).
const angleDeg = top.peak
? Math.round(Math.max(...top.segs.map(sg => Math.atan(Math.abs(sg.slope)))) * (180 / Math.PI))
: Math.round(Math.atan(slope) * (180 / Math.PI));
// Dubbele hoekstijlen: de buitenste staat op de wandrand, de tweede er // Dubbele hoekstijlen: de buitenste staat op de wandrand, de tweede er
// direct tegenaan. De regelmatige verdeling begint bij de binnenste. // direct tegenaan. De regelmatige verdeling begint bij de binnenste.
@@ -258,52 +371,88 @@ window.WallModel = (() => {
cornerStuds.push(outerLeftStudX + i * CONFIG.studWidth, outerRightStudX - i * CONFIG.studWidth); cornerStuds.push(outerLeftStudX + i * CONFIG.studWidth, outerRightStudX - i * CONFIG.studWidth);
} }
let fullStuds = []; // Stijlen als stukken { x, y0, y1, kind } (mm; y1 null = tot de
let jackStuds = []; // daklijn). Eerst kandidaat-posities, daarna wordt elke kandidaat
// opgeknipt door de openingen (incl. boven-/onderdorpel) die hij kruist.
// Zo kunnen openingen ook boven elkaar staan (raam boven een deur): de
// stijlen ertussen lopen van dorpel tot dorpel.
// hoek dubbele hoekstijl
// koning koningsstijl direct naast een opening
// dubbel tweede stijl direct tegen de koningsstijl van een deur
// regel regelmatige verdeling in de vrije zones
// kreupel boven/onder een opening, binnen de openingbreedte
const sw = CONFIG.studWidth;
const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings const ops = CONFIG.openings; // al gesorteerd op x door resolveOpenings
const bands = ops.map(op => ({
if (ops.length === 0) { op, x0: startX + op.x, x1: startX + op.x + op.w,
fullStuds.push(...cornerStuds); y0: op.y > CONFIG.plateThick ? op.y - sw : 0, y1: op.y + op.h + sw
const availWidth = wallRightStudX - wallLeftStudX; }));
calculateSegmentPositions(availWidth, CONFIG.minSpacing, CONFIG.maxSpacing) const cands = [];
.forEach(p => fullStuds.push(wallLeftStudX + p)); cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
} else {
// Hoekstijlen van de wand horen er, met of zonder opening, altijd bij.
fullStuds.push(...cornerStuds);
// Randen van de "vrije zones" tussen wand en openingen, per paar verwerkt
const edges = [wallLeftStudX];
ops.forEach(op => { ops.forEach(op => {
const opStartX = startX + op.x; const x0 = startX + op.x, x1 = x0 + op.w;
const kingLeftX = opStartX - CONFIG.studWidth / 2; cands.push({ x: x0 - sw / 2, kind: 'koning' }, { x: x1 + sw / 2, kind: 'koning' });
const kingRightX = opStartX + op.w + CONFIG.studWidth / 2; if (isDoor(op)) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' }, { x: x1 + 1.5 * sw, kind: 'dubbel' });
fullStuds.push(kingLeftX, kingRightX);
edges.push(kingLeftX, kingRightX);
// Kreupele stijlen binnen de opening-breedte zelf
calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing)
.forEach(p => jackStuds.push({ x: opStartX + p, opening: op }));
}); });
// Kolommen: openingen (incl. hun stijlen) die elkaar in x overlappen.
// Tussen de kolommen en de hoekstijlen: regelmatige verdeling.
const cols = [];
ops.map(op => {
const extra = (isDoor(op) ? 2 : 1) * sw;
return [startX + op.x - extra + sw / 2, startX + op.x + op.w + extra - sw / 2]; // hartlijnen buitenste stijlen
}).sort((p, q) => p[0] - q[0]).forEach(r => {
const last = cols[cols.length - 1];
if (last && r[0] < last[1] + sw) last[1] = Math.max(last[1], r[1]);
else cols.push(r.slice());
});
const edges = [wallLeftStudX];
cols.forEach(c => edges.push(c[0], c[1]));
edges.push(wallRightStudX); edges.push(wallRightStudX);
for (let i = 0; i < edges.length; i += 2) { for (let i = 0; i < edges.length; i += 2) {
const a = edges[i]; const a = edges[i], b = edges[i + 1];
const b = edges[i + 1]; if (b - a > sw) calculateSegmentPositions(b - a, CONFIG.minSpacing, CONFIG.maxSpacing).forEach(p => cands.push({ x: a + p, kind: 'regel' }));
const span = (b - CONFIG.studWidth / 2) - (a + CONFIG.studWidth / 2);
if (span > 0) {
calculateSegmentPositions(span + CONFIG.studWidth, CONFIG.minSpacing, CONFIG.maxSpacing)
.forEach(p => fullStuds.push(a + p));
}
}
} }
ops.forEach(op => calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing)
.forEach(p => cands.push({ x: startX + op.x + p, kind: 'kreupel', owner: op })));
fullStuds = [...new Set(fullStuds.map(x => Math.round(x)))].sort((a, b) => a - b); // Knippen op de openingen.
jackStuds.sort((a, b) => a.x - b.x); const PRIO = { hoek: 0, koning: 1, dubbel: 2, regel: 3, kreupel: 4 };
const roofMin = (x) => Math.min(...top.underPts(x - sw / 2, x + sw / 2).map(p => p.y));
const segs = [];
cands.forEach(c => {
const x = Math.round(c.x);
const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0);
let cur = CONFIG.plateThick, below = null;
const push = (y0, y1, above) => {
if (c.kind === 'kreupel') {
// Alleen direct boven of onder de eigen opening; onder de opening
// niet als hij op een andere opening uitkomt (dat stuk vult de
// kreupele stijl van de onderste opening al).
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return;
}
segs.push({ x, y0, y1, kind: c.kind });
};
hit.forEach(bd => {
if (bd.y0 > cur + 1) push(cur, bd.y0, bd);
if (bd.y1 > cur) { cur = bd.y1; below = bd; }
});
if (roofMin(x) > cur + 1) push(cur, null, null);
});
// Dubbelingen (bijv. koningsstijl op de hoekstijl) weg: hoogste prioriteit wint.
const studs = [];
segs.sort((p, q) => PRIO[p.kind] - PRIO[q.kind]).forEach(sg => {
const top1 = sg.y1 === null ? Infinity : sg.y1;
const clash = studs.some(o => Math.abs(o.x - sg.x) < sw - 0.5 &&
Math.min(top1, o.y1 === null ? Infinity : o.y1) - Math.max(sg.y0, o.y0) > 1);
if (!clash) studs.push(sg);
});
studs.sort((p, q) => p.x - q.x || p.y0 - q.y0);
// Volle stijlen (onderregel tot daklijn), o.a. voor de maatvoering.
const fullStuds = [...new Set(studs.filter(sg => sg.y1 === null && sg.y0 <= CONFIG.plateThick).map(sg => sg.x))].sort((a, b) => a - b);
return { fullStuds, jackStuds, slope, angleDeg }; return { studs, fullStuds, slope, angleDeg };
} }
// Uitsparing in de beplating/het vochtdoek per opening (mm; x t.o.v. het // Uitsparing in de beplating/het vochtdoek per opening (mm; x t.o.v. het
@@ -315,8 +464,7 @@ window.WallModel = (() => {
// Gebruikt door build3D (fill) én de 2D-tekening (naad-/baanlijnen). // Gebruikt door build3D (fill) én de 2D-tekening (naad-/baanlijnen).
function sheathingHoles(CONFIG) { function sheathingHoles(CONFIG) {
const halfW = CONFIG.wallWidth / 2; const halfW = CONFIG.wallWidth / 2;
const slope = (CONFIG.wallHeightRight - CONFIG.wallHeightLeft) / CONFIG.wallWidth; const topYAt = topLine(CONFIG).at;
const topYAt = (x) => CONFIG.wallHeightLeft + (x + halfW) * slope;
const roofSafetyMargin = 10; const roofSafetyMargin = 10;
return CONFIG.openings.map(op => { return CONFIG.openings.map(op => {
const hx0 = -halfW + op.x - CONFIG.studWidth; const hx0 = -halfW + op.x - CONFIG.studWidth;
@@ -332,16 +480,11 @@ window.WallModel = (() => {
// liggen: binnen de wand-omtrek (incl. schuine top) en buiten de // liggen: binnen de wand-omtrek (incl. schuine top) en buiten de
// uitsparingen. Geeft [[x0, x1], ...] terug (mm, t.o.v. het wandmidden). // uitsparingen. Geeft [[x0, x1], ...] terug (mm, t.o.v. het wandmidden).
function skinSegmentsAtY(CONFIG, y) { function skinSegmentsAtY(CONFIG, y) {
const halfW = CONFIG.wallWidth / 2; if (y <= 0) return [];
const hL = CONFIG.wallHeightLeft, hR = CONFIG.wallHeightRight;
if (y <= 0 || y >= Math.max(hL, hR)) return [];
// Deel van de breedte waar de daklijn boven y ligt. // Deel van de breedte waar de daklijn boven y ligt.
let x0 = -halfW, x1 = halfW; const range = topLine(CONFIG).rangeAbove(y);
if (hL !== hR) { if (!range) return [];
const xAtY = -halfW + (y - hL) / (hR - hL) * CONFIG.wallWidth; let segments = [range];
if (hR > hL) x0 = Math.max(x0, xAtY); else x1 = Math.min(x1, xAtY);
} else if (y >= hL) return [];
let segments = [[x0, x1]];
sheathingHoles(CONFIG).filter(h => y > h.hy0 && y < h.hy1).forEach(h => { sheathingHoles(CONFIG).filter(h => y > h.hy0 && y < h.hy1).forEach(h => {
segments = segments.flatMap(([a, b]) => { segments = segments.flatMap(([a, b]) => {
if (h.hx1 <= a || h.hx0 >= b) return [[a, b]]; if (h.hx1 <= a || h.hx0 >= b) return [[a, b]];
@@ -365,22 +508,23 @@ window.WallModel = (() => {
// linkerrand van dit stuk. Het verticale latten-raster volgt dan de // linkerrand van dit stuk. Het verticale latten-raster volgt dan de
// hele gevel i.p.v. per stuk opnieuw te beginnen; een eindlat komt // hele gevel i.p.v. per stuk opnieuw te beginnen; een eindlat komt
// alleen aan het einde van de gevel. // alleen aan het einde van de gevel.
function computeBattens(CONFIG, cladding, trasraam, skinExt, facade) { // skinFrom (optioneel, mm): buitenafwerking pas vanaf deze hoogte (het
const y0 = skinStartY(trasraam); // boven het trasraam // blootliggende deel van een naadwand boven het dak van de andere unit).
function computeBattens(CONFIG, cladding, trasraam, skinExt, facade, skinFrom) {
const y0 = Math.max(skinStartY(trasraam), skinFrom || 0); // boven het trasraam
const ext = cornerExt(skinExt, 2 * BATTEN.thick); // over de hoekstijlen van de doorlopende wand const ext = cornerExt(skinExt, 2 * BATTEN.thick); // over de hoekstijlen van de doorlopende wand
const xL = -CONFIG.wallWidth / 2 - ext.left, xR = CONFIG.wallWidth / 2 + ext.right; const xL = -CONFIG.wallWidth / 2 - ext.left, xR = CONFIG.wallWidth / 2 + ext.right;
const gridX0 = facade ? -CONFIG.wallWidth / 2 - facade.u0 - (facade.extStart ? 2 * BATTEN.thick : 0) : xL; const gridX0 = facade ? -CONFIG.wallWidth / 2 - facade.u0 - (facade.extStart ? 2 * BATTEN.thick : 0) : xL;
const isEnd = facade ? facade.isEnd : true; const isEnd = facade ? facade.isEnd : true;
const halfW = CONFIG.wallWidth / 2; const halfW = CONFIG.wallWidth / 2;
const hL = CONFIG.wallHeightLeft, hR = CONFIG.wallHeightRight; const tl = topLine(CONFIG), topYAt = tl.at;
const topYAt = (x) => hL + (x + halfW) * (hR - hL) / CONFIG.wallWidth;
const holes = sheathingHoles(CONFIG); const holes = sheathingHoles(CONFIG);
const w = BATTEN.width, sp = BATTEN.spacing; const w = BATTEN.width, sp = BATTEN.spacing;
const top = (cladding === 'verticaal') ? 'horizontaal' : 'verticaal'; const top = (cladding === 'verticaal') ? 'horizontaal' : 'verticaal';
// Horizontale latten: vanaf de onderkant elke 400 mm (hartmaat). // Horizontale latten: vanaf de onderkant elke 400 mm (hartmaat).
const horizontaal = []; const horizontaal = [];
for (let y = y0 + w / 2; y < Math.max(hL, hR) - w / 2; y += sp) { for (let y = y0 + w / 2; y < tl.maxY - w / 2; y += sp) {
skinSegmentsAtY(CONFIG, y).forEach(([x0, x1]) => { skinSegmentsAtY(CONFIG, y).forEach(([x0, x1]) => {
if (x0 <= -halfW + 0.5) x0 = xL; if (x0 <= -halfW + 0.5) x0 = xL;
if (x1 >= halfW - 0.5) x1 = xR; if (x1 >= halfW - 0.5) x1 = xR;
@@ -475,24 +619,19 @@ window.WallModel = (() => {
? new THREE.MeshStandardMaterial({ color: 0xffffff, map: doekMap, roughness: 0.7, side: THREE.DoubleSide }) ? new THREE.MeshStandardMaterial({ color: 0xffffff, map: doekMap, roughness: 0.7, side: THREE.DoubleSide })
: new THREE.MeshStandardMaterial({ color: DOEK_COLOR, roughness: 0.7, side: THREE.DoubleSide }), : new THREE.MeshStandardMaterial({ color: DOEK_COLOR, roughness: 0.7, side: THREE.DoubleSide }),
cementplaatMaterial: new THREE.MeshStandardMaterial({ color: 0x808285, roughness: 0.8, side: THREE.DoubleSide }), cementplaatMaterial: new THREE.MeshStandardMaterial({ color: 0x808285, roughness: 0.8, side: THREE.DoubleSide }),
// Zelfde uitstraling als de vloer-isolatie (FloorModel). battenMaterial: new THREE.MeshStandardMaterial({ color: 0xd9b77e, roughness: 0.7 })
battenMaterial: new THREE.MeshStandardMaterial({ color: 0xd9b77e, roughness: 0.7 }),
isolatieMaterial: new THREE.MeshStandardMaterial({
color: 0xffe08c, roughness: 0.9, transparent: true, opacity: 0.5, side: THREE.DoubleSide, depthWrite: false
})
}; };
} }
// Bouwt de volledige 3D-wand (in meters, lokaal assenstelsel: X = breedte // Bouwt de volledige 3D-wand (in meters, lokaal assenstelsel: X = breedte
// gecentreerd, Y = hoogte vanaf onderkant onderregel, +Z = buitenzijde/zuid) // gecentreerd, Y = hoogte vanaf onderkant onderregel, +Z = buitenzijde/zuid)
// en geeft daarnaast de volume-boekhouding terug voor de specificaties. // en geeft daarnaast de volume-boekhouding terug voor de specificaties.
// opts: { materials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible, isolatieVisible } // opts: { materials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible }
// trasraam: { bottom, top } in mm (wand-coördinaten, zie trasraamFor) of null. // trasraam: { bottom, top } in mm (wand-coördinaten, zie trasraamFor) of null.
// skinExt: { left, right } in mm (zie cornerExt) of null. // skinExt: { left, right } in mm (zie cornerExt) of null.
// facade: { u0, extStart, isEnd } (zie computeBattens) of null. // facade: { u0, extStart, isEnd } (zie computeBattens) of null.
function build3D(CONFIG, calcData, opts) { function build3D(CONFIG, calcData, opts) {
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, isolatieMaterial, battenMaterial } = opts.materials; const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
const isolatieVisible = opts.isolatieVisible !== false;
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek'; const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
const spaanplaatVisible = opts.spaanplaatVisible !== false; const spaanplaatVisible = opts.spaanplaatVisible !== false;
const buitenVisible = opts.buitenVisible !== false; const buitenVisible = opts.buitenVisible !== false;
@@ -500,6 +639,10 @@ window.WallModel = (() => {
const trasraam = buitenMateriaal === 'vochtdoek' ? (opts.trasraam || null) : null; const trasraam = buitenMateriaal === 'vochtdoek' ? (opts.trasraam || null) : null;
const skinExt = opts.skinExt || null; const skinExt = opts.skinExt || null;
const facade = opts.facade || null; const facade = opts.facade || null;
const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0; // buitenafwerking vanaf (mm), zie computeBattens
// spaanplaatBelow: onder skinFrom aan de buitenzijde spaanplaat (naadwand
// geheel in één unit: onder het dak van de andere unit is het een binnenwand).
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
// Watermerk-UV's (doek) in gevel-coördinaten, zodat het patroon over // Watermerk-UV's (doek) in gevel-coördinaten, zodat het patroon over
// naden/wandstukken doorloopt (UV = vormcoördinaat in m + verschuiving). // naden/wandstukken doorloopt (UV = vormcoördinaat in m + verschuiving).
const uvShiftM = facade ? (CONFIG.wallWidth / 2 + facade.u0) * 0.001 : 0; const uvShiftM = facade ? (CONFIG.wallWidth / 2 + facade.u0) * 0.001 : 0;
@@ -552,10 +695,11 @@ window.WallModel = (() => {
const hRightM = CONFIG.wallHeightRight * toM; const hRightM = CONFIG.wallHeightRight * toM;
const halfW = wWidthM / 2; const halfW = wWidthM / 2;
const slope = (hRightM - hLeftM) / wWidthM; const tl = topLine(CONFIG, toM);
const vertOffset = pThickM * Math.sqrt(1 + slope * slope);
const startXM = -halfW; const startXM = -halfW;
const topYAt = (x) => hLeftM + (x + halfW) * slope; const topYAt = tl.at;
// Onderkant bovenregel van xLeft naar xRight (incl. verstek bij de nok).
const underPts = tl.underPts;
// Openingen naar meter-coordinaten. LET OP: xMin/xMax hier zijn al naar // Openingen naar meter-coordinaten. LET OP: xMin/xMax hier zijn al naar
// wereld-coordinaten verschoven (dezelfde as als de rest van de 3D-opbouw, // wereld-coordinaten verschoven (dezelfde as als de rest van de 3D-opbouw,
@@ -604,70 +748,27 @@ window.WallModel = (() => {
// B. TOPBALK WAND (ongewijzigd door openingen) — dit is zelf ook een // B. TOPBALK WAND (ongewijzigd door openingen) — dit is zelf ook een
// "horizontale balk" (liggend, ook al loopt hij mee met de dakhelling). // "horizontale balk" (liggend, ook al loopt hij mee met de dakhelling).
const topPlateShape = new THREE.Shape(); // Met nok: twee bovenregels die in verstek op de nok samenkomen.
topPlateShape.moveTo(-halfW, hLeftM); topPlateOutlines(tl).forEach(pts => {
topPlateShape.lineTo(halfW, hRightM); wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
topPlateShape.lineTo(halfW, hRightM - vertOffset);
topPlateShape.lineTo(-halfW, hLeftM - vertOffset);
topPlateShape.closePath();
wallGroup3D.add(createExtrudedBeam(topPlateShape, pDepthM, -pDepthM / 2));
horizBeamCount++; horizBeamCount++;
// C. VOLLEDIGE VERTICALE STIJLEN (incl. de "koningsstijlen" naast elke opening)
calcData.fullStuds.forEach((xPosMM) => {
const xM = xPosMM * toM;
const xLeft = xM - (sWidthM / 2);
const xRight = xM + (sWidthM / 2);
const yTopLeft = (hLeftM + (xLeft + halfW) * slope) - vertOffset;
const yTopRight = (hLeftM + (xRight + halfW) * slope) - vertOffset;
const studShape = new THREE.Shape();
studShape.moveTo(xLeft, pThickM);
studShape.lineTo(xRight, pThickM);
studShape.lineTo(xRight, yTopRight);
studShape.lineTo(xLeft, yTopLeft);
studShape.closePath();
wallGroup3D.add(createExtrudedBeam(studShape, sDepthM, -sDepthM / 2));
}); });
// D. KREUPELE STIJLEN & DORPELS, per opening // C. VERTICALE STIJLEN (vol, koning, dubbel bij deuren, kreupel): elk
// stuk van y0 tot y1, of tot onder de daklijn (y1 null), zie computeFrame.
calcData.studs.forEach(st => {
const xM = st.x * toM;
const xLeft = xM - (sWidthM / 2);
const xRight = xM + (sWidthM / 2);
const y0 = st.y0 * toM;
const pts = st.y1 === null
? [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, ...underPts(xLeft, xRight).reverse()]
: [{ x: xLeft, y: y0 }, { x: xRight, y: y0 }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }];
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2));
});
// D. DORPELS, per opening
opsM.forEach(op => { opsM.forEach(op => {
calcData.jackStuds
.filter(j => j.opening.id === op.id)
.forEach(j => {
const xM = j.x * toM;
const xLeft = xM - (sWidthM / 2);
const xRight = xM + (sWidthM / 2);
const yTopLeft = (hLeftM + (xLeft + halfW) * slope) - vertOffset;
const yTopRight = (hLeftM + (xRight + halfW) * slope) - vertOffset;
// Onderste kreupele stijl mag niet door de onderdorpel heen lopen
// (die neemt zelf de band net onder op.yMin in beslag).
const sillBottomM = op.yMin - sWidthM;
if (sillBottomM > pThickM) {
const studBotShape = new THREE.Shape();
studBotShape.moveTo(xLeft, pThickM);
studBotShape.lineTo(xRight, pThickM);
studBotShape.lineTo(xRight, sillBottomM);
studBotShape.lineTo(xLeft, sillBottomM);
studBotShape.closePath();
wallGroup3D.add(createExtrudedBeam(studBotShape, sDepthM, -sDepthM / 2));
}
const actualOpTop = op.yMax + sWidthM;
if (actualOpTop < yTopLeft || actualOpTop < yTopRight) {
const studTopShape = new THREE.Shape();
studTopShape.moveTo(xLeft, actualOpTop);
studTopShape.lineTo(xRight, actualOpTop);
studTopShape.lineTo(xRight, yTopRight);
studTopShape.lineTo(xLeft, yTopLeft);
studTopShape.closePath();
wallGroup3D.add(createExtrudedBeam(studTopShape, sDepthM, -sDepthM / 2));
}
});
// Bovendorpel / linteel (horizontale balk) // Bovendorpel / linteel (horizontale balk)
const opTopShape = new THREE.Shape(); const opTopShape = new THREE.Shape();
opTopShape.moveTo(op.xMin, op.yMax); opTopShape.moveTo(op.xMin, op.yMax);
@@ -692,48 +793,7 @@ window.WallModel = (() => {
} }
}); });
// D2. ISOLATIE — vult elk vak tussen twee opeenvolgende stijlen (vol of // (Isolatie wordt niet getekend; de spouwinhoud zit in computeSpecs.)
// kreupel) over de stijldiepte, van bovenkant onderregel tot onderkant
// bovenregel (volgt de dakhelling). Binnen de breedte van een opening
// alleen onder de onderdorpel en boven de bovendorpel. Puur visueel,
// zelfde transparante laag als bij de vloer.
const members = calcData.fullStuds.map(x => x * toM)
.concat(calcData.jackStuds.map(j => j.x * toM))
.map(x => [x - sWidthM / 2, x + sWidthM / 2])
.sort((a, b) => a[0] - b[0]);
const merged = [];
members.forEach(m => {
const last = merged[merged.length - 1];
if (last && m[0] <= last[1] + 1e-9) last[1] = Math.max(last[1], m[1]);
else merged.push([m[0], m[1]]);
});
function addIsolatieBlock(x0, x1, yBottom, yTopLeft, yTopRight) {
if (x1 - x0 <= 1e-6 || Math.min(yTopLeft, yTopRight) - yBottom <= 1e-6) return;
const shape = new THREE.Shape();
shape.moveTo(x0, yBottom);
shape.lineTo(x1, yBottom);
shape.lineTo(x1, yTopRight);
shape.lineTo(x0, yTopLeft);
shape.closePath();
const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: sDepthM, bevelEnabled: false }), isolatieMaterial);
mesh.position.set(0, 0, -sDepthM / 2);
mesh.visible = isolatieVisible;
wallGroup3D.add(mesh);
}
for (let i = 0; i < merged.length - 1; i++) {
const x0 = merged[i][1], x1 = merged[i + 1][0];
const yTopL = topYAt(x0) - vertOffset, yTopR = topYAt(x1) - vertOffset;
const mid = (x0 + x1) / 2;
const op = opsM.find(o => mid > o.xMin && mid < o.xMax);
if (!op) {
addIsolatieBlock(x0, x1, pThickM, yTopL, yTopR);
} else {
const sillBottom = op.yMin - sWidthM;
if (sillBottom > pThickM) addIsolatieBlock(x0, x1, pThickM, sillBottom, sillBottom);
const headerTop = op.yMax + sWidthM;
addIsolatieBlock(x0, x1, headerTop, Math.max(headerTop, yTopL), Math.max(headerTop, yTopR));
}
}
// E. VERTICALE PLATEN — gedeelde opbouw voor spaanplaat (binnenzijde, // E. VERTICALE PLATEN — gedeelde opbouw voor spaanplaat (binnenzijde,
// altijd) EN cement-gebonden platen (buitenzijde, als die gekozen is): // altijd) EN cement-gebonden platen (buitenzijde, als die gekozen is):
@@ -823,7 +883,8 @@ window.WallModel = (() => {
// over een plaatnaad wordt een inkeping in de plaatrand, zodat er // over een plaatnaad wordt een inkeping in de plaatrand, zodat er
// geen reepje plaat (met randlijnen) door de opening blijft lopen. // geen reepje plaat (met randlijnen) door de opening blijft lopen.
const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt, const pieces = columnMinusRects(ex0, ex1, yB, skinTopAt,
holeExtents.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })), range ? { yCap } : undefined); holeExtents.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })),
{ yCap: range ? yCap : undefined, extraXs: range ? tl.kinks.concat(tl.crossings(yCap)) : tl.kinks });
let boardShapes; let boardShapes;
if (pieces) { if (pieces) {
boardShapes = pieces.map(piece => { boardShapes = pieces.map(piece => {
@@ -838,6 +899,7 @@ window.WallModel = (() => {
boardShape.moveTo(ex0, yB); boardShape.moveTo(ex0, yB);
boardShape.lineTo(ex1, yB); boardShape.lineTo(ex1, yB);
boardShape.lineTo(ex1, skinTopAt(ex1)); boardShape.lineTo(ex1, skinTopAt(ex1));
tl.kinks.filter(x => x > ex0 && x < ex1).forEach(x => boardShape.lineTo(x, skinTopAt(x)));
boardShape.lineTo(ex0, skinTopAt(ex0)); boardShape.lineTo(ex0, skinTopAt(ex0));
boardShape.closePath(); boardShape.closePath();
holeExtents.forEach(h => { holeExtents.forEach(h => {
@@ -907,11 +969,18 @@ window.WallModel = (() => {
// Buitenzijde (zuid): cement-gebonden platen (zelfde plaatlogica als // Buitenzijde (zuid): cement-gebonden platen (zelfde plaatlogica als
// hierboven) OF vochtdoek (horizontale banen, zie onder) — nooit beide. // hierboven) OF vochtdoek (horizontale banen, zie onder) — nooit beide.
if (spaanplaatBelow) {
const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible, { y0: 0, y1: skinFrom * 0.001 });
spaanplaatBuitenAreaM2 = spaanOnder.areaM2;
spaanplaatBuitenVolumeM3 = spaanOnder.volumeM3;
}
if (buitenMateriaal === 'spaanplaat') { if (buitenMateriaal === 'spaanplaat') {
// Binnenwand: aan beide zijden spaanplaat. // Binnenwand: aan beide zijden spaanplaat.
const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible); const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible);
spaanplaatBuitenAreaM2 = spaanBuiten.areaM2; spaanplaatBuitenAreaM2 = spaanBuiten.areaM2;
spaanplaatBuitenVolumeM3 = spaanBuiten.volumeM3; spaanplaatBuitenVolumeM3 = spaanBuiten.volumeM3;
} else if (buitenMateriaal === 'geen') {
// Naadzijde: geen beplating (het deel van de andere unit staat ertegen).
} else if (buitenMateriaal === 'cementplaat') { } else if (buitenMateriaal === 'cementplaat') {
const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, null, const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, null,
Object.assign(extM(0), { zOut: sThickM + 2 * skinGapM })); Object.assign(extM(0), { zOut: sThickM + 2 * skinGapM }));
@@ -932,13 +1001,10 @@ window.WallModel = (() => {
// (wat flikkering/z-fighting zou geven nu de platen niet meer // (wat flikkering/z-fighting zou geven nu de platen niet meer
// transparant zijn). // transparant zijn).
const doekZStepM = 0.0002; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte const doekZStepM = 0.0002; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte
const wallOutlinePts = [ const wallOutlinePts = [{ x: -halfW, y: 0 }, { x: halfW, y: 0 }, ...tl.pts.slice().reverse()];
{ x: -halfW, y: 0 }, { x: halfW, y: 0 }, const maxRoofY = tl.maxY;
{ x: halfW, y: hRightM }, { x: -halfW, y: hLeftM }
];
const maxRoofY = Math.max(hLeftM, hRightM);
let rowY0 = 0; let rowY0 = skinFrom * toM;
let rowIndex = 0; let rowIndex = 0;
while (rowY0 < maxRoofY - 1e-9) { while (rowY0 < maxRoofY - 1e-9) {
const rowY1 = rowY0 + doekHeightM; const rowY1 = rowY0 + doekHeightM;
@@ -946,15 +1012,9 @@ window.WallModel = (() => {
// Baan = strook [rowY0, min(rowY1, daklijn)] min uitsparingen // Baan = strook [rowY0, min(rowY1, daklijn)] min uitsparingen
// (columnMinusRects, zelfde als de platen): een opening over de // (columnMinusRects, zelfde als de platen): een opening over de
// baanrand wordt een inkeping, geen reepje doek door de opening. // baanrand wordt een inkeping, geen reepje doek door de opening.
const roofAt = (x) => hLeftM + (x + halfW) * slope; const roofAt = topYAt;
let rx0 = -halfW, rx1 = halfW; const [rx0, rx1] = tl.rangeAbove(rowY0) || [0, 0];
const extraXs = []; const extraXs = tl.crossings(rowY1).concat(tl.kinks);
if (Math.abs(slope) > 1e-12) {
const xAtRowBottom = -halfW + (rowY0 - hLeftM) / slope;
const xAtRowTop = -halfW + (rowY1 - hLeftM) / slope;
if (slope > 0) rx0 = Math.max(rx0, xAtRowBottom); else rx1 = Math.min(rx1, xAtRowBottom);
extraXs.push(xAtRowTop);
}
const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)), const pieces = rx1 - rx0 > edgeEps ? columnMinusRects(rx0, rx1, rowY0, (x) => Math.min(rowY1, roofAt(x)),
holeExtents.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })), { yCap: rowY1, extraXs }) : []; holeExtents.map(h => ({ x0: h.hx0, x1: h.hx1, y0: h.hy0, y1: h.hy1 })), { yCap: rowY1, extraXs }) : [];
@@ -1040,7 +1100,7 @@ window.WallModel = (() => {
// G. KRUISLAT op het vochtdoek (computeBattens): onderste laag direct op // G. KRUISLAT op het vochtdoek (computeBattens): onderste laag direct op
// het doek, bovenste laag (haaks op de gevelbekleding) daaroverheen. // het doek, bovenste laag (haaks op de gevelbekleding) daaroverheen.
const bat = computeBattens(CONFIG, cladding, trasraam, skinExt, facade); const bat = computeBattens(CONFIG, cladding, trasraam, skinExt, facade, skinFrom);
const bT = BATTEN.thick * toM, bW = BATTEN.width * toM; const bT = BATTEN.thick * toM, bW = BATTEN.width * toM;
const zDoek = (maxDepth / 2) + skinGapM + sThickM + rowIndex * doekZStepM + skinGapM; const zDoek = (maxDepth / 2) + skinGapM + sThickM + rowIndex * doekZStepM + skinGapM;
const addLayer = (orientation, z) => { const addLayer = (orientation, z) => {
@@ -1082,10 +1142,7 @@ window.WallModel = (() => {
// Omhullende van de wand (trapezium, incl. schuine top) als referentie // Omhullende van de wand (trapezium, incl. schuine top) als referentie
// voor de leegte-berekening — spouwdiepte wordt gelijkgesteld aan de // voor de leegte-berekening — spouwdiepte wordt gelijkgesteld aan de
// stijldiepte (vereenvoudiging, zie toelichting in de UI). // stijldiepte (vereenvoudiging, zie toelichting in de UI).
const envelopePoints = [ const envelopePoints = [{ x: -halfW, y: 0 }, { x: halfW, y: 0 }, ...topLine(CONFIG, toM).pts.slice().reverse()];
{ x: -halfW, y: 0 }, { x: halfW, y: 0 },
{ x: halfW, y: hRightM }, { x: -halfW, y: hLeftM }
];
const envelopeAreaM2 = polygonArea(envelopePoints); const envelopeAreaM2 = polygonArea(envelopePoints);
const envelopeVolumeM3 = envelopeAreaM2 * sDepthM; const envelopeVolumeM3 = envelopeAreaM2 * sDepthM;
@@ -1104,6 +1161,8 @@ window.WallModel = (() => {
buitenAreaM2 = stats.spaanplaatBuitenAreaM2; buitenAreaM2 = stats.spaanplaatBuitenAreaM2;
buitenKg = stats.spaanplaatBuitenVolumeM3 * DENSITY_SPAANPLAAT_KGM3; buitenKg = stats.spaanplaatBuitenVolumeM3 * DENSITY_SPAANPLAAT_KGM3;
buitenVolumeM3 = stats.spaanplaatBuitenVolumeM3; buitenVolumeM3 = stats.spaanplaatBuitenVolumeM3;
} else if (buitenMateriaal === 'geen') {
buitenAreaM2 = 0; buitenKg = 0; buitenVolumeM3 = 0;
} else if (buitenMateriaal === 'cementplaat') { } else if (buitenMateriaal === 'cementplaat') {
buitenAreaM2 = stats.cementplaatAreaM2; buitenAreaM2 = stats.cementplaatAreaM2;
buitenKg = stats.cementplaatVolumeM3 * DENSITY_CEMENTPLAAT_KGM3; buitenKg = stats.cementplaatVolumeM3 * DENSITY_CEMENTPLAAT_KGM3;
@@ -1126,9 +1185,7 @@ window.WallModel = (() => {
function computeBOM(CONFIG, calcData) { function computeBOM(CONFIG, calcData) {
const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight; const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight;
const halfW = wallWidth / 2; const halfW = wallWidth / 2;
const slope = (hRight - hLeft) / wallWidth; const tl = topLine(CONFIG);
const topYAt = (xWorld) => hLeft + (xWorld + halfW) * slope;
const vertOffset = CONFIG.plateThick * Math.sqrt(1 + slope * slope);
const startX = -halfW; const startX = -halfW;
const pieces = []; const pieces = [];
@@ -1147,8 +1204,15 @@ window.WallModel = (() => {
pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2 }); pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2 });
} }
const topLength = Math.sqrt(wallWidth * wallWidth + (hRight - hLeft) * (hRight - hLeft)); // Bovenregel(s): met nok twee stukken (verstek). outline = werkelijke
pieces.push({ length: topLength, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'bovenregel', x: 0, y: (hLeft + hRight) / 2 }); // omtrek (PDF-tekening).
topPlateOutlines(tl).forEach((outline, i) => {
const sg = tl.segs[i];
pieces.push({
length: Math.hypot(sg.b.x - sg.a.x, sg.b.y - sg.a.y), width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'bovenregel',
x: (sg.a.x + sg.b.x) / 2, y: (sg.a.y + sg.b.y) / 2 - CONFIG.plateThick / 2, outline
});
});
// Verticale stukken krijgen, naast de bounding-box velden (length/y — // Verticale stukken krijgen, naast de bounding-box velden (length/y —
// gebruikt voor de materiaallijst: de lengte van het rechte stuk hout // gebruikt voor de materiaallijst: de lengte van het rechte stuk hout
@@ -1158,43 +1222,29 @@ window.WallModel = (() => {
// i.p.v. als platte rechthoek tot het hoogste punt — anders komt alleen // i.p.v. als platte rechthoek tot het hoogste punt — anders komt alleen
// de hoogste kant van de balk goed op de daklijn te staan, en blijft de // de hoogste kant van de balk goed op de daklijn te staan, en blijft de
// laagste kant er (visueel) los van. // laagste kant er (visueel) los van.
calcData.fullStuds.forEach(x => { // (outline: ook de knik onder de nok, als de stijl daar staat.)
const xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2; calcData.studs.forEach(st => {
const yTopLeft = topYAt(xLeft) - vertOffset, yTopRight = topYAt(xRight) - vertOffset; const x = st.x, xLeft = x - CONFIG.studWidth / 2, xRight = x + CONFIG.studWidth / 2;
const yBottom = CONFIG.plateThick; const yBottom = st.y0;
const length = Math.max(yTopLeft, yTopRight) - yBottom; const kind = st.kind === 'kreupel' ? 'kreupele stijl' : 'verticale balk';
if (st.y1 !== null) {
pieces.push({ pieces.push({
length, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'verticale balk', x, length: st.y1 - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + st.y1) / 2,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft: st.y1, yTopRight: st.y1
});
return;
}
const topPts = tl.underPts(xLeft, xRight);
const yTopLeft = topPts[0].y, yTopRight = topPts[topPts.length - 1].y;
pieces.push({
length: Math.max(...topPts.map(p => p.y)) - yBottom, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind, x,
y: (yBottom + yBottom + yTopLeft + yTopRight) / 4, y: (yBottom + yBottom + yTopLeft + yTopRight) / 4,
yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft, yTopRight yBottomLeft: yBottom, yBottomRight: yBottom, yTopLeft, yTopRight,
outline: [{ x: xLeft, y: yBottom }, { x: xRight, y: yBottom }, ...topPts.slice().reverse()]
}); });
}); });
calcData.jackStuds.forEach(j => {
const op = j.opening;
const opYMin = op.y, opYMax = op.y + op.h;
const xLeft = j.x - CONFIG.studWidth / 2, xRight = j.x + CONFIG.studWidth / 2;
const yTopLeft = topYAt(xLeft) - vertOffset, yTopRight = topYAt(xRight) - vertOffset;
// De kreupele stijl mag niet door de onderdorpel heen lopen — die
// neemt zelf al de onderste stijldikte in vanaf opYMin in beslag.
const sillBottom = opYMin - CONFIG.studWidth;
if (sillBottom > CONFIG.plateThick) {
pieces.push({
length: sillBottom - CONFIG.plateThick, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'kreupele stijl', x: j.x,
y: (CONFIG.plateThick + sillBottom) / 2,
yBottomLeft: CONFIG.plateThick, yBottomRight: CONFIG.plateThick, yTopLeft: sillBottom, yTopRight: sillBottom
});
}
const actualOpTop = opYMax + CONFIG.studWidth;
if (actualOpTop < yTopLeft || actualOpTop < yTopRight) {
pieces.push({
length: Math.max(yTopLeft, yTopRight) - actualOpTop, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'kreupele stijl', x: j.x,
y: (actualOpTop + actualOpTop + yTopLeft + yTopRight) / 4,
yBottomLeft: actualOpTop, yBottomRight: actualOpTop, yTopLeft, yTopRight
});
}
});
CONFIG.openings.forEach(op => { CONFIG.openings.forEach(op => {
const opXWorld = startX + op.x; const opXWorld = startX + op.x;
pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + op.h + CONFIG.studWidth / 2 }); pieces.push({ length: op.w, width: CONFIG.studWidth, depth: CONFIG.studDepth, kind: 'bovendorpel', x: opXWorld + op.w / 2, y: op.y + op.h + CONFIG.studWidth / 2 });
@@ -1286,7 +1336,7 @@ window.WallModel = (() => {
return { return {
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, trasraamFor, computeBattens, planPurchase, purchaseText, STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, trasraamFor, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines,
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
createMaterials, build3D, computeSpecs createMaterials, build3D, computeSpecs
}; };
+19 -24
View File
@@ -67,7 +67,7 @@ window.WallPdf = (() => {
// --- Materiaal binnen/buiten + isolatie --- // --- Materiaal binnen/buiten + isolatie ---
let my = 240; let my = 240;
const midH = Math.round((CONFIG.wallHeightLeft + CONFIG.wallHeightRight) / 2); const midH = Math.round(WallModel.topLine(CONFIG).maxY);
doc.setFontSize(8.5); doc.setFontSize(8.5);
doc.text('Materiaal binnen', tblX[0], my); my += 4.5; doc.text('Materiaal binnen', tblX[0], my); my += 4.5;
doc.setFontSize(6.5); doc.setFontSize(6.5);
@@ -80,7 +80,7 @@ window.WallPdf = (() => {
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). // 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); 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;
doc.text(`Latwerk ${B.thick}x${B.width}: ${bat.stock.count} st. \u00e0 ${B.length} mm, kruislat ${B.spacing} h.o.h.`, tblX[0], my); doc.text(`Latwerk ${B.thick}x${B.width}: ${bat.stock.count} st. \u00e0 ${B.length} mm, kruislat ${B.spacing} h.o.h.`, tblX[0], my);
@@ -103,7 +103,8 @@ window.WallPdf = (() => {
const availW = (drawX1 - drawX0) - marginSides * 2; const availW = (drawX1 - drawX0) - marginSides * 2;
const availH = (drawY1 - drawY0) - marginTop - marginBottom; const availH = (drawY1 - drawY0) - marginTop - marginBottom;
const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight; const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight;
const maxH = Math.max(hLeft, hRight); const tl = WallModel.topLine(CONFIG);
const maxH = tl.maxY;
const scale = Math.min(availW / wallWidth, availH / maxH); const scale = Math.min(availW / wallWidth, availH / maxH);
const originX = drawX0 + marginSides; const originX = drawX0 + marginSides;
@@ -115,15 +116,18 @@ window.WallPdf = (() => {
doc.setLineWidth(0.25); doc.setLineWidth(0.25);
doc.line(px(-wallWidth / 2), py(0), px(wallWidth / 2), py(0)); doc.line(px(-wallWidth / 2), py(0), px(wallWidth / 2), py(0));
doc.line(px(wallWidth / 2), py(0), px(wallWidth / 2), py(hRight)); doc.line(px(wallWidth / 2), py(0), px(wallWidth / 2), py(hRight));
doc.line(px(wallWidth / 2), py(hRight), px(-wallWidth / 2), py(hLeft)); for (let i = 0; i < tl.pts.length - 1; i++) doc.line(px(tl.pts[i].x), py(tl.pts[i].y), px(tl.pts[i + 1].x), py(tl.pts[i + 1].y));
doc.line(px(-wallWidth / 2), py(hLeft), px(-wallWidth / 2), py(0)); doc.line(px(-wallWidth / 2), py(hLeft), px(-wallWidth / 2), py(0));
doc.setDrawColor(...RED); doc.setDrawColor(...RED);
doc.setLineWidth(0.3); doc.setLineWidth(0.3);
pieces.forEach(p => { pieces.forEach(p => {
if (p.kind === 'bovenregel') return; // apart getekend hieronder, volgt de helling if (p.outline) {
const isVertical = p.kind === 'verticale balk' || p.kind === 'kreupele stijl'; // Werkelijke omtrek: schuine kop (volgt de daklijn, evt. met knik
if (isVertical) { // onder de nok) resp. bovenregel in verstek.
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]); });
} else if (p.kind === 'verticale balk' || p.kind === 'kreupele stijl') {
// Viervlak i.p.v. rechthoek: de bovenkant loopt schuin mee met de // Viervlak i.p.v. rechthoek: de bovenkant loopt schuin mee met de
// daklijn (yTopLeft/yTopRight kunnen verschillen), zodat zowel de // daklijn (yTopLeft/yTopRight kunnen verschillen), zodat zowel de
// hoogste als de laagste kant van de balk op de juiste plek komt. // hoogste als de laagste kant van de balk op de juiste plek komt.
@@ -143,22 +147,6 @@ window.WallPdf = (() => {
doc.text(String(p.posnum), px(p.x), py(p.y), { align: 'center' }); doc.text(String(p.posnum), px(p.x), py(p.y), { align: 'center' });
}); });
// Bovenregel: volgt de dakhelling, dus als schuin parallellogram
// getekend (niet als vlakke rechthoek zoals de rest hierboven).
const topPlatePiece = pieces.find(p => p.kind === 'bovenregel');
if (topPlatePiece) {
const slope = (hRight - hLeft) / wallWidth;
const vOff = CONFIG.plateThick * Math.sqrt(1 + slope * slope);
const tp = [
[px(-wallWidth / 2), py(hLeft)], [px(wallWidth / 2), py(hRight)],
[px(wallWidth / 2), py(hRight - vOff)], [px(-wallWidth / 2), py(hLeft - vOff)]
];
for (let i = 0; i < 4; i++) doc.line(tp[i][0], tp[i][1], tp[(i + 1) % 4][0], tp[(i + 1) % 4][1]);
doc.setTextColor(...BLACK);
doc.setFontSize(5);
doc.text(String(topPlatePiece.posnum), px(topPlatePiece.x), py(topPlatePiece.y), { align: 'center' });
}
doc.setDrawColor(...BLACK); doc.setDrawColor(...BLACK);
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
CONFIG.openings.forEach(op => { CONFIG.openings.forEach(op => {
@@ -188,6 +176,13 @@ 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) {
// Nokhoogte: stippellijn van vloer tot nok.
doc.setLineDashPattern([1, 1], 0);
doc.line(px(tl.peak.x), py(0), px(tl.peak.x), py(tl.peak.y));
doc.setLineDashPattern([], 0);
doc.text('nok ' + Math.round(tl.peak.y), px(tl.peak.x) + 1.5, py(tl.peak.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)));
@@ -256,7 +251,7 @@ window.WallPdf = (() => {
// Stijlen (vol + kreupel) als doorsneden: rechthoek met kruis. // Stijlen (vol + kreupel) als doorsneden: rechthoek met kruis.
doc.setDrawColor(...RED); doc.setDrawColor(...RED);
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
const studXs = calcData.fullStuds.concat(calcData.jackStuds.map(j => j.x)); const studXs = [...new Set(calcData.studs.map(st => st.x))];
studXs.forEach(x => { studXs.forEach(x => {
const x0 = px(x - CONFIG.studWidth / 2), x1 = px(x + CONFIG.studWidth / 2); const x0 = px(x - CONFIG.studWidth / 2), x1 = px(x + CONFIG.studWidth / 2);
doc.rect(x0, tvFrame0, x1 - x0, tvFrame1 - tvFrame0, 'S'); doc.rect(x0, tvFrame0, x1 - x0, tvFrame1 - tvFrame0, 'S');
+138 -110
View File
@@ -25,16 +25,16 @@ const WALL_TEMPLATE = `
<div id="left-column"> <div id="left-column">
<div id="pane-2d-container"> <div id="pane-2d-container">
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front"> <div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
<div class="pane-label">2D BUITENAANZICHT</div> <div class="pane-label">BUITENAANZICHT</div>
<canvas id="canvas-2d-front"></canvas> <canvas id="canvas-2d-front"></canvas>
</div> </div>
<div class="pane-2d-sub pane-2d-row" id="pane-2d-bottom-row"> <div class="pane-2d-sub pane-2d-row" id="pane-2d-bottom-row">
<div class="pane-2d-half" id="pane-2d-top"> <div class="pane-2d-half" id="pane-2d-top">
<div class="pane-label">2D BOVENAANZICHT</div> <div class="pane-label">BOVENAANZICHT</div>
<canvas id="canvas-2d-top"></canvas> <canvas id="canvas-2d-top"></canvas>
</div> </div>
<div class="pane-2d-half" id="pane-2d-side"> <div class="pane-2d-half" id="pane-2d-side">
<div class="pane-label">2D ZIJAANZICHT</div> <div class="pane-label">ZIJAANZICHT</div>
<canvas id="canvas-2d-side"></canvas> <canvas id="canvas-2d-side"></canvas>
</div> </div>
</div> </div>
@@ -46,100 +46,105 @@ const WALL_TEMPLATE = `
<span id="io-status" class="io-status"></span> <span id="io-status" class="io-status"></span>
</div> </div>
<div class="tab-bar"> <div class="tab-bar">
<button class="tab-btn active" data-tab="tab-wall">1. Wand Opbouw</button> <button class="tab-btn active" data-tab="tab-wall"><i class="bi bi-rulers"></i> Opbouw</button>
<button class="tab-btn" data-tab="tab-opening">2. Openingen</button> <button class="tab-btn" data-tab="tab-opening"><i class="bi bi-door-open"></i> Openingen</button>
<button class="tab-btn" data-tab="tab-materials">3. Materiaalkeuze</button> <button class="tab-btn" data-tab="tab-materials"><i class="bi bi-layers"></i> Materiaal</button>
</div> </div>
<!-- TAB 1: WAND --> <!-- TAB 1: WAND -->
<div id="tab-wall" class="tab-content active"> <div id="tab-wall" class="tab-content active">
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<h1>Wand Dimensies (mm)</h1> <h1><i class="bi bi-rulers"></i> Afmetingen</h1>
<div class="control-group"> <div class="control-group">
<label for="inp-wall-w">Wand Breedte:</label> <label for="inp-wall-w">Breedte</label>
<div><input type="number" id="inp-wall-w" value="6000" step="50" min="800" max="10000"><span class="unit">mm</span></div> <div><input type="number" id="inp-wall-w" value="6000" step="50" min="800" max="10000"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label for="inp-wall-h-all">Totale Hoogte:</label> <label for="inp-wall-h-all">Hoogte</label>
<div><input type="number" id="inp-wall-h-all" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div> <div><input type="number" id="inp-wall-h-all" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label for="inp-wall-h-left">Hoogte Links ($Y_1$):</label> <label for="inp-wall-h-left" id="lbl-wall-h-left">Hoogte links</label>
<div><input type="number" id="inp-wall-h-left" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div> <div><input type="number" id="inp-wall-h-left" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label for="inp-wall-h-right">Hoogte Rechts ($Y_2$):</label> <label for="inp-wall-h-right" id="lbl-wall-h-right">Hoogte rechts</label>
<div><input type="number" id="inp-wall-h-right" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div> <div><input type="number" id="inp-wall-h-right" value="2400" step="50" min="500" max="5000"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group" title="Middenpunt van de bovenrand verhogen (nok): links en rechts worden dan goothoogtes.">
<label for="inp-wall-peak">Middenpunt (nok)</label>
<div><label class="ui-switch"><input type="checkbox" id="inp-wall-peak"><span></span></label></div>
</div>
<div class="control-group" id="grp-wall-h-mid" style="display:none">
<label for="inp-wall-h-mid">Nokhoogte</label>
<div><input type="number" id="inp-wall-h-mid" value="3400" step="50" min="500" max="8000"><span class="unit">mm</span></div>
</div>
<div class="divider"></div> <div class="divider"></div>
<h1>Verticale Balken & Dorpels</h1> <h1><i class="bi bi-columns-gap"></i> Stijlen</h1>
<div class="control-group"> <div class="control-group">
<label for="inp-stud-w">Verticale Balk Dikte/Breedte:</label> <label for="inp-stud-w">Dikte</label>
<div><input type="number" id="inp-stud-w" value="38" step="5" min="20" max="300"><span class="unit">mm</span></div> <div><input type="number" id="inp-stud-w" value="38" step="5" min="20" max="300"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label for="inp-stud-d">Verticale Balk Diepte:</label> <label for="inp-stud-d">Diepte</label>
<div><input type="number" id="inp-stud-d" value="235" step="5" min="20" max="400"><span class="unit">mm</span></div> <div><input type="number" id="inp-stud-d" value="235" step="5" min="20" max="400"><span class="unit">mm</span></div>
</div> </div>
</div> </div>
<div> <div>
<h1>Muur Liggers (Top & Onder)</h1> <h1><i class="bi bi-distribute-vertical"></i> Regels (boven en onder)</h1>
<div class="control-group"> <div class="control-group">
<label for="inp-plate-w">Ligger Dikte:</label> <label for="inp-plate-w">Dikte</label>
<div><input type="number" id="inp-plate-w" value="38" step="5" min="20" max="200"><span class="unit">mm</span></div> <div><input type="number" id="inp-plate-w" value="38" step="5" min="20" max="200"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label for="inp-plate-d">Ligger Diepte:</label> <label for="inp-plate-d">Diepte</label>
<div><input type="number" id="inp-plate-d" value="235" step="5" min="20" max="400"><span class="unit">mm</span></div> <div><input type="number" id="inp-plate-d" value="235" step="5" min="20" max="400"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label for="inp-sheathing-t">Beplating Dikte:</label> <label for="inp-sheathing-t">Dikte beplating</label>
<div><input type="number" id="inp-sheathing-t" value="10" step="1" min="3" max="30"><span class="unit">mm</span></div> <div><input type="number" id="inp-sheathing-t" value="10" step="1" min="3" max="30"><span class="unit">mm</span></div>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<h1>Regelwerk Afstand (h.o.h.)</h1> <h1><i class="bi bi-arrows"></i> Regelwerk</h1>
<div class="control-group"> <div class="control-group">
<label for="inp-min-sp">Min. h.o.h.:</label> <label for="inp-min-sp">Afstand min.</label>
<div><input type="number" id="inp-min-sp" value="240" step="10" min="100" max="1000"><span class="unit">mm</span></div> <div><input type="number" id="inp-min-sp" value="240" step="10" min="100" max="1000"><span class="unit">mm</span></div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label for="inp-max-sp">Max. h.o.h.:</label> <label for="inp-max-sp">Afstand max.</label>
<div><input type="number" id="inp-max-sp" value="480" step="10" min="150" max="1500"><span class="unit">mm</span></div> <div><input type="number" id="inp-max-sp" value="480" step="10" min="150" max="1500"><span class="unit">mm</span></div>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<div class="result-row"><span>Totaal verticale balken:</span><span data-spec="vert-beams" class="highlight">-</span></div> <div class="result-row"><span>Verticale balken</span><span data-spec="vert-beams" class="highlight">-</span></div>
<div class="result-row"><span>Totaal horizontale balken:</span><span data-spec="horiz-beams" class="highlight">-</span></div> <div class="result-row"><span>Horizontale balken</span><span data-spec="horiz-beams" class="highlight">-</span></div>
<div class="result-row"><span>Dakhelling:</span><span data-spec="roof-angle" class="highlight">-</span></div> <div class="result-row"><span>Dakhelling</span><span data-spec="roof-angle" class="highlight">-</span></div>
</div> </div>
</div> </div>
</div> </div>
<!-- TAB 2: OPENINGEN (meerdere, ankerbaar) --> <!-- TAB 2: OPENINGEN (meerdere, ankerbaar) -->
<div id="tab-opening" class="tab-content"> <div id="tab-opening" class="tab-content">
<button id="btn-add-opening" class="add-opening-btn">+ Opening Toevoegen</button> <button id="btn-add-opening" class="add-opening-btn"><i class="bi bi-plus-lg"></i> Opening toevoegen</button>
<div id="openings-global-warning" class="global-warning" style="display:none;"></div> <div id="openings-global-warning" class="global-warning" style="display:none;"></div>
<div id="openings-list"></div> <div id="openings-list"></div>
</div> </div>
<!-- TAB 3: MATERIAALKEUZE --> <!-- TAB 3: MATERIAALKEUZE -->
<div id="tab-materials" class="tab-content"> <div id="tab-materials" class="tab-content">
<h1>Binnenzijde</h1> <h1><i class="bi bi-layers"></i> Buitenzijde</h1>
<div class="divider"></div>
<h1>Buitenzijde</h1>
<div class="material-toggle-group"> <div class="material-toggle-group">
<button type="button" id="btn-mat-vochtdoek" class="material-toggle-btn active" data-material="vochtdoek">Vochtdoek</button> <button type="button" id="btn-mat-vochtdoek" class="material-toggle-btn active" data-material="vochtdoek">Vochtdoek</button>
<button type="button" id="btn-mat-cementplaat" class="material-toggle-btn" data-material="cementplaat">Cement-gebonden platen</button> <button type="button" id="btn-mat-cementplaat" class="material-toggle-btn" data-material="cementplaat">Cementplaat</button>
<button type="button" id="btn-mat-spaanplaat" class="material-toggle-btn" data-material="spaanplaat">Spaanplaat</button> <button type="button" id="btn-mat-spaanplaat" class="material-toggle-btn" data-material="spaanplaat">Spaanplaat</button>
</div> </div>
<div id="cladding-section"> <div id="cladding-section">
<div class="divider"></div> <div class="divider"></div>
<h1>Gevelbekleding (WEO/Novalit) &mdash; richting</h1> <h1><i class="bi bi-grip-horizontal"></i> Richting gevelbekleding</h1>
<div class="material-toggle-group"> <div class="material-toggle-group">
<button type="button" class="material-toggle-btn active" data-cladding="horizontaal">Horizontaal</button> <button type="button" class="material-toggle-btn active" data-cladding="horizontaal">Horizontaal</button>
<button type="button" class="material-toggle-btn" data-cladding="verticaal">Verticaal</button> <button type="button" class="material-toggle-btn" data-cladding="verticaal">Verticaal</button>
@@ -153,7 +158,7 @@ const WALL_TEMPLATE = `
<div id="right-column"> <div id="right-column">
<div id="pane-3d"> <div id="pane-3d">
<div class="pane-label">3D PERSPECTIEF</div> <div class="pane-label">3D</div>
<div class="toolbar-3d" id="wall-toolbar-3d"></div> <div class="toolbar-3d" id="wall-toolbar-3d"></div>
</div> </div>
@@ -161,37 +166,37 @@ const WALL_TEMPLATE = `
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<div class="spec-section"> <div class="spec-section">
<h1>Balken &amp; Dak</h1> <h1><i class="bi bi-columns-gap"></i> Balken</h1>
<div class="result-row"><span>Verticale balken:</span><span data-spec="vert-beams" class="highlight">-</span></div> <div class="result-row"><span>Verticale balken</span><span data-spec="vert-beams" class="highlight">-</span></div>
<div class="result-row"><span>Horizontale balken:</span><span data-spec="horiz-beams" class="highlight">-</span></div> <div class="result-row"><span>Horizontale balken</span><span data-spec="horiz-beams" class="highlight">-</span></div>
<div class="result-row"><span>Dakhelling:</span><span data-spec="roof-angle" class="highlight">-</span></div> <div class="result-row"><span>Dakhelling</span><span data-spec="roof-angle" class="highlight">-</span></div>
</div> </div>
<div class="spec-section"> <div class="spec-section">
<h1>Houtwerk</h1> <h1><i class="bi bi-tree"></i> Houtwerk</h1>
<div class="result-row"><span>Volume hout:</span><span data-spec="wood-m3" class="highlight">-</span></div> <div class="result-row"><span>Volume hout</span><span data-spec="wood-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht hout:</span><span data-spec="wood-kg" class="highlight">-</span></div> <div class="result-row"><span>Gewicht hout</span><span data-spec="wood-kg" class="highlight">-</span></div>
<div class="result-row"><span>Inkoop balken:</span><span data-spec="stock-list" class="highlight">-</span></div> <div class="result-row"><span>Inkoop balken</span><span data-spec="stock-list" class="highlight">-</span></div>
<div class="result-row"><span>Snijverlies:</span><span data-spec="stock-waste" class="highlight">-</span></div> <div class="result-row"><span>Snijverlies</span><span data-spec="stock-waste" class="highlight">-</span></div>
</div> </div>
<div class="spec-section"> <div class="spec-section">
<h1>Spaanplaat (binnenzijde)</h1> <h1><i class="bi bi-layers"></i> Binnenzijde: spaanplaat</h1>
<div class="result-row"><span>Oppervlak:</span><span data-spec="spaanplaat-m2" class="highlight">-</span></div> <div class="result-row"><span>Oppervlak</span><span data-spec="spaanplaat-m2" class="highlight">-</span></div>
<div class="result-row"><span>Volume:</span><span data-spec="spaanplaat-m3" class="highlight">-</span></div> <div class="result-row"><span>Volume</span><span data-spec="spaanplaat-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht:</span><span data-spec="spaanplaat-kg" class="highlight">-</span></div> <div class="result-row"><span>Gewicht</span><span data-spec="spaanplaat-kg" class="highlight">-</span></div>
</div> </div>
</div> </div>
<div> <div>
<div class="spec-section"> <div class="spec-section">
<h1>Buitenzijde (<span data-spec="buiten-materiaalnaam">Vochtdoek</span>)</h1> <h1><i class="bi bi-layers-half"></i> Buitenzijde: <span data-spec="buiten-materiaalnaam">Vochtdoek</span></h1>
<div class="result-row"><span>Oppervlak:</span><span data-spec="buiten-m2" class="highlight">-</span></div> <div class="result-row"><span>Oppervlak</span><span data-spec="buiten-m2" class="highlight">-</span></div>
<div class="result-row"><span>Volume:</span><span data-spec="buiten-m3" class="highlight">-</span></div> <div class="result-row"><span>Volume</span><span data-spec="buiten-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht:</span><span data-spec="buiten-kg" class="highlight">-</span></div> <div class="result-row"><span>Gewicht</span><span data-spec="buiten-kg" class="highlight">-</span></div>
<div class="result-row"><span>Latwerk 20x30 (kruislat):</span><span data-spec="latten" class="highlight">-</span></div> <div class="result-row"><span>Latwerk 20×30</span><span data-spec="latten" class="highlight">-</span></div>
</div> </div>
<div class="spec-section"> <div class="spec-section">
<h1>Isolatie &amp; totaal</h1> <h1><i class="bi bi-speedometer2"></i> Isolatie en gewicht</h1>
<div class="result-row"><span>Inhoud leegtes:</span><span data-spec="void-m3" class="highlight">-</span></div> <div class="result-row"><span>Isolatie</span><span data-spec="void-m3" class="highlight">-</span></div>
<div class="result-row"><span>Totaal gewicht wand:</span><span data-spec="total-kg" class="highlight">-</span></div> <div class="result-row"><span>Totaal gewicht</span><span data-spec="total-kg" class="highlight">-</span></div>
</div> </div>
</div> </div>
</div> </div>
@@ -207,23 +212,23 @@ const WALL_TEMPLATE = `
</div> </div>
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<div class="control-group"><label for="inp-proj-naam">Projectnaam:</label> <div class="control-group"><label for="inp-proj-naam">Projectnaam</label>
<div><input type="text" id="inp-proj-naam" value=""></div></div> <div><input type="text" id="inp-proj-naam" value=""></div></div>
<div class="control-group"><label for="inp-proj-plaats">Plaats:</label> <div class="control-group"><label for="inp-proj-plaats">Plaats</label>
<div><input type="text" id="inp-proj-plaats" value=""></div></div> <div><input type="text" id="inp-proj-plaats" value=""></div></div>
<div class="control-group"><label for="inp-proj-straat">Straat:</label> <div class="control-group"><label for="inp-proj-straat">Straat</label>
<div><input type="text" id="inp-proj-straat" value=""></div></div> <div><input type="text" id="inp-proj-straat" value=""></div></div>
<div class="control-group"><label for="inp-proj-nummer">Projectnummer:</label> <div class="control-group"><label for="inp-proj-nummer">Projectnummer</label>
<div><input type="text" id="inp-proj-nummer" value=""></div></div> <div><input type="text" id="inp-proj-nummer" value=""></div></div>
</div> </div>
<div> <div>
<div class="control-group"><label for="inp-proj-tekenaar">Tekenaar:</label> <div class="control-group"><label for="inp-proj-tekenaar">Tekenaar</label>
<div><input type="text" id="inp-proj-tekenaar" value=""></div></div> <div><input type="text" id="inp-proj-tekenaar" value=""></div></div>
<div class="control-group"><label for="inp-proj-titel">Titel:</label> <div class="control-group"><label for="inp-proj-titel">Titel</label>
<div><input type="text" id="inp-proj-titel" value="BuitenWand"></div></div> <div><input type="text" id="inp-proj-titel" value="BuitenWand"></div></div>
<div class="control-group"><label for="inp-proj-element">Element nummer:</label> <div class="control-group"><label for="inp-proj-element">Elementnummer</label>
<div><input type="text" id="inp-proj-element" value=""></div></div> <div><input type="text" id="inp-proj-element" value=""></div></div>
<div class="control-group"><label for="inp-proj-opmerking">Opmerking:</label> <div class="control-group"><label for="inp-proj-opmerking">Opmerking</label>
<div><input type="text" id="inp-proj-opmerking" value=""></div></div> <div><input type="text" id="inp-proj-opmerking" value=""></div></div>
</div> </div>
</div> </div>
@@ -269,7 +274,7 @@ function mountWallEditor(container, callbacks) {
// Dichtheden/gewichten: zie WallModel (wall/wall-model.js). // Dichtheden/gewichten: zie WallModel (wall/wall-model.js).
let CONFIG = {}; let CONFIG = {};
let calcData = { fullStuds: [], jackStuds: [] }; let calcData = { fullStuds: [], studs: [] };
let wallGroup3D = null; let wallGroup3D = null;
let lastBaseHeight = 2400; let lastBaseHeight = 2400;
@@ -338,9 +343,9 @@ function mountWallEditor(container, callbacks) {
// OPENINGEN: UI (lijst met kaarten, ankerkeuze, toevoegen/verwijderen) // OPENINGEN: UI (lijst met kaarten, ankerkeuze, toevoegen/verwijderen)
// ============================================================ // ============================================================
function anchorOffsetLabel(anchor) { function anchorOffsetLabel(anchor) {
if (anchor === 'right') return 'Afstand vanaf 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';
return 'Afstand vanaf links:'; return 'Afstand tot links';
} }
function addOpening() { function addOpening() {
@@ -360,16 +365,12 @@ function mountWallEditor(container, callbacks) {
} }
function toggleFullscreen3D() { function toggleFullscreen3D() {
const el = document.getElementById('app-container'); UI.cycle3D(document.getElementById('app-container')); // T: standaard -> 3D groot -> alleen render
el.classList.remove('fullscreen-2d');
el.classList.toggle('fullscreen-3d');
requestAnimationFrame(onWindowResize); requestAnimationFrame(onWindowResize);
} }
function toggleFullscreen2D() { function toggleFullscreen2D() {
const el = document.getElementById('app-container'); UI.toggle2D(document.getElementById('app-container'));
el.classList.remove('fullscreen-3d');
el.classList.toggle('fullscreen-2d');
requestAnimationFrame(onWindowResize); requestAnimationFrame(onWindowResize);
} }
@@ -448,7 +449,7 @@ function mountWallEditor(container, callbacks) {
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<div class="control-group"> <div class="control-group">
<label>Type:</label> <label>Type</label>
<div> <div>
<select data-field="type" data-id="${op.id}"> <select data-field="type" data-id="${op.id}">
${Object.entries(WallModel.OPENING_TYPES).map(([k, t]) => `<option value="${k}" ${(op.type || 'raam') === k ? 'selected' : ''}>${t.label}</option>`).join('')} ${Object.entries(WallModel.OPENING_TYPES).map(([k, t]) => `<option value="${k}" ${(op.type || 'raam') === k ? 'selected' : ''}>${t.label}</option>`).join('')}
@@ -456,21 +457,21 @@ function mountWallEditor(container, callbacks) {
</div> </div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label>Breedte:</label> <label>Breedte</label>
<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>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>
<div class="control-group"> <div class="control-group">
<label>Hoogte vloer (Y):</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>
</div> </div>
</div> </div>
<div> <div>
<div class="control-group"> <div class="control-group">
<label>Ankerpunt:</label> <label>Ankerpunt</label>
<div> <div>
<select data-field="anchor" data-id="${op.id}"> <select data-field="anchor" data-id="${op.id}">
<option value="left" ${op.anchor === 'left' ? 'selected' : ''}>Links</option> <option value="left" ${op.anchor === 'left' ? 'selected' : ''}>Links</option>
@@ -550,6 +551,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,
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),
@@ -573,6 +576,9 @@ function mountWallEditor(container, callbacks) {
document.getElementById('inp-wall-h-all').value = state.wall.heightAll; document.getElementById('inp-wall-h-all').value = state.wall.heightAll;
document.getElementById('inp-wall-h-left').value = state.wall.heightLeft; document.getElementById('inp-wall-h-left').value = state.wall.heightLeft;
document.getElementById('inp-wall-h-right').value = state.wall.heightRight; document.getElementById('inp-wall-h-right').value = state.wall.heightRight;
document.getElementById('inp-wall-peak').checked = state.wall.heightMid > 0;
document.getElementById('inp-wall-h-mid').value = state.wall.heightMid > 0 ? state.wall.heightMid
: Math.max(state.wall.heightLeft, state.wall.heightRight) + 800;
document.getElementById('inp-stud-w').value = state.wall.studWidth; document.getElementById('inp-stud-w').value = state.wall.studWidth;
document.getElementById('inp-stud-d').value = state.wall.studDepth; document.getElementById('inp-stud-d').value = state.wall.studDepth;
document.getElementById('inp-plate-w').value = state.wall.plateThick; document.getElementById('inp-plate-w').value = state.wall.plateThick;
@@ -709,24 +715,35 @@ function mountWallEditor(container, callbacks) {
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 hMidInput = document.getElementById('inp-wall-h-mid');
const peakOn = document.getElementById('inp-wall-peak').checked;
let hAll = Math.round(parseFloat(hAllInput.value) || 2400); let hAll = Math.round(parseFloat(hAllInput.value) || 2400);
let hLeft = Math.round(parseFloat(hLeftInput.value) || 2400); let hLeft = Math.round(parseFloat(hLeftInput.value) || 2400);
let hRight = Math.round(parseFloat(hRightInput.value) || 2900); let hRight = Math.round(parseFloat(hRightInput.value) || 2900);
let hMid = Math.round(parseFloat(hMidInput.value) || 0);
if (hAll !== lastBaseHeight) { if (hAll !== lastBaseHeight) {
const delta = hAll - lastBaseHeight; const delta = hAll - lastBaseHeight;
hLeft = Math.max(500, hLeft + delta); hLeft = Math.max(500, hLeft + delta);
hRight = Math.max(500, hRight + delta); hRight = Math.max(500, hRight + delta);
hMid = Math.max(500, hMid + delta);
hLeftInput.value = hLeft; hLeftInput.value = hLeft;
hRightInput.value = hRight; hRightInput.value = hRight;
hMidInput.value = hMid;
lastBaseHeight = hAll; lastBaseHeight = hAll;
} }
// Met nok: links/rechts zijn goothoogtes.
document.getElementById('grp-wall-h-mid').style.display = peakOn ? '' : 'none';
document.getElementById('lbl-wall-h-left').textContent = peakOn ? 'Goothoogte links' : 'Hoogte links';
document.getElementById('lbl-wall-h-right').textContent = peakOn ? 'Goothoogte rechts' : 'Hoogte rechts';
// Afronding/fallbacks + ankerresolutie van de openingen: WallModel. // 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,
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),
@@ -742,7 +759,7 @@ function mountWallEditor(container, callbacks) {
readInputs(); readInputs();
calcData = WallModel.computeFrame(CONFIG); calcData = WallModel.computeFrame(CONFIG);
setSpecAll('vert-beams', (calcData.fullStuds.length + calcData.jackStuds.length) + ' stuks'); setSpecAll('vert-beams', calcData.studs.length + ' stuks');
setSpecAll('roof-angle', calcData.angleDeg + '°'); setSpecAll('roof-angle', calcData.angleDeg + '°');
} }
@@ -794,8 +811,10 @@ function mountWallEditor(container, callbacks) {
ctx.strokeRect(screenX, screenY, studWidthPx, studDepthPx); ctx.strokeRect(screenX, screenY, studWidthPx, studDepthPx);
}; };
calcData.fullStuds.forEach(x => drawStud2D(x, false)); // Bovenaanzicht: één doorsnede per stijlpositie (kreupel alleen als er
calcData.jackStuds.forEach(j => drawStud2D(j.x, true)); // op die plek geen volle stijl staat).
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)));
const startX = -CONFIG.wallWidth / 2; const startX = -CONFIG.wallWidth / 2;
CONFIG.openings.forEach(op => { CONFIG.openings.forEach(op => {
@@ -834,10 +853,8 @@ function mountWallEditor(container, callbacks) {
const hLeftMM = CONFIG.wallHeightLeft; const hLeftMM = CONFIG.wallHeightLeft;
const hRightMM = CONFIG.wallHeightRight; const hRightMM = CONFIG.wallHeightRight;
const halfWMM = wallWidthMM / 2; const halfWMM = wallWidthMM / 2;
const slopeMM = (hRightMM - hLeftMM) / wallWidthMM; const tl = WallModel.topLine(CONFIG);
const topYAtMM = (x) => hLeftMM + (x + halfWMM) * slopeMM; const maxRoofMM = tl.maxY;
const vertOffsetMM = CONFIG.plateThick * Math.sqrt(1 + slopeMM * slopeMM);
const maxRoofMM = Math.max(hLeftMM, hRightMM);
const scale = Math.min(w / (wallWidthMM * 1.3), h / (maxRoofMM * 1.3)); const scale = Math.min(w / (wallWidthMM * 1.3), h / (maxRoofMM * 1.3));
const centerX = w / 2; const centerX = w / 2;
@@ -889,8 +906,7 @@ function mountWallEditor(container, callbacks) {
ctxFront.beginPath(); ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(0)); ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(0));
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(0)); ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(0));
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(hRightMM)); tl.pts.slice().reverse().forEach(p => ctxFront.lineTo(toCanvasX(p.x), toCanvasY(p.y)));
ctxFront.lineTo(toCanvasX(-halfWMM), toCanvasY(hLeftMM));
ctxFront.closePath(); ctxFront.closePath();
ctxFront.stroke(); ctxFront.stroke();
@@ -905,14 +921,13 @@ function mountWallEditor(container, callbacks) {
ctxFront.fill(); ctxFront.fill();
ctxFront.stroke(); ctxFront.stroke();
WallModel.topPlateOutlines(tl).forEach(pts => {
ctxFront.beginPath(); ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(-halfWMM), toCanvasY(hLeftMM)); pts.forEach((p, i) => (i ? ctxFront.lineTo : ctxFront.moveTo).call(ctxFront, toCanvasX(p.x), toCanvasY(p.y)));
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(hRightMM));
ctxFront.lineTo(toCanvasX(halfWMM), toCanvasY(hRightMM - vertOffsetMM));
ctxFront.lineTo(toCanvasX(-halfWMM), toCanvasY(hLeftMM - vertOffsetMM));
ctxFront.closePath(); ctxFront.closePath();
ctxFront.fill(); ctxFront.fill();
ctxFront.stroke(); ctxFront.stroke();
});
// X/Y-assen (lijn + label), altijd binnen het canvas gehouden zodat er // X/Y-assen (lijn + label), altijd binnen het canvas gehouden zodat er
// niks buiten het zichtbare venster valt. Stijl: core/canvas-labels.js. // niks buiten het zichtbare venster valt. Stijl: core/canvas-labels.js.
@@ -927,26 +942,31 @@ 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 pxC = toCanvasX(tl.peak.x);
ctxFront.fillStyle = '#9ab';
ctxFront.font = '11px sans-serif';
ctxFront.textAlign = 'center';
ctxFront.fillText('nok ' + Math.round(tl.peak.y) + 'mm', pxC, Math.max(12, toCanvasY(tl.peak.y) - 6));
ctxFront.textAlign = 'start';
}
const drawStudFront = (xMM, isJack) => { // Stijlstuk van y0 tot y1 (of tot de daklijn), zie WallModel.computeFrame.
const xLeft = xMM - CONFIG.studWidth / 2; const drawStudFront = (st) => {
const xRight = xMM + CONFIG.studWidth / 2; const xLeft = st.x - CONFIG.studWidth / 2;
const yTopLeft = topYAtMM(xLeft) - vertOffsetMM; const xRight = st.x + CONFIG.studWidth / 2;
const yTopRight = topYAtMM(xRight) - vertOffsetMM; ctxFront.fillStyle = st.kind === 'kreupel' ? '#a37c45' : '#c29b62';
const yBot = CONFIG.plateThick;
ctxFront.fillStyle = isJack ? '#a37c45' : '#c29b62';
ctxFront.strokeStyle = '#8a6836'; ctxFront.strokeStyle = '#8a6836';
ctxFront.beginPath(); ctxFront.beginPath();
ctxFront.moveTo(toCanvasX(xLeft), toCanvasY(yBot)); ctxFront.moveTo(toCanvasX(xLeft), toCanvasY(st.y0));
ctxFront.lineTo(toCanvasX(xRight), toCanvasY(yBot)); ctxFront.lineTo(toCanvasX(xRight), toCanvasY(st.y0));
ctxFront.lineTo(toCanvasX(xRight), toCanvasY(yTopRight)); if (st.y1 === null) tl.underPts(xLeft, xRight).reverse().forEach(p => ctxFront.lineTo(toCanvasX(p.x), toCanvasY(p.y)));
ctxFront.lineTo(toCanvasX(xLeft), toCanvasY(yTopLeft)); else { ctxFront.lineTo(toCanvasX(xRight), toCanvasY(st.y1)); ctxFront.lineTo(toCanvasX(xLeft), toCanvasY(st.y1)); }
ctxFront.closePath(); ctxFront.closePath();
ctxFront.fill(); ctxFront.fill();
ctxFront.stroke(); ctxFront.stroke();
}; };
calcData.fullStuds.forEach(x => drawStudFront(x, false)); calcData.studs.forEach(drawStudFront);
calcData.jackStuds.forEach(j => drawStudFront(j.x, true));
const startXMM = -halfWMM; const startXMM = -halfWMM;
frontOpeningRects = []; frontOpeningRects = [];
@@ -1135,7 +1155,7 @@ function mountWallEditor(container, callbacks) {
const buitenThick = CONFIG.sheathingThick; const buitenThick = CONFIG.sheathingThick;
const spaanThick = CONFIG.sheathingThick; const spaanThick = CONFIG.sheathingThick;
const totalDepth = buitenThick + CONFIG.studDepth + spaanThick; const totalDepth = buitenThick + CONFIG.studDepth + spaanThick;
const maxH = Math.max(CONFIG.wallHeightLeft, CONFIG.wallHeightRight); const maxH = WallModel.topLine(CONFIG).maxY;
const scale = Math.min((w * 0.45) / totalDepth, (h * 0.8) / maxH); const scale = Math.min((w * 0.45) / totalDepth, (h * 0.8) / maxH);
const originX = w / 2 - (totalDepth * scale) / 2; const originX = w / 2 - (totalDepth * scale) / 2;
@@ -1204,13 +1224,15 @@ function mountWallEditor(container, callbacks) {
controls.update(); controls.update();
} }
scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 8, 5); dirLight.position.set(5, 8, 5);
dirLight.castShadow = true; dirLight.castShadow = true;
scene.add(dirLight); scene.add(dirLight);
scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x222222)); const grid = new THREE.GridHelper(10, 20, 0x444444, 0x222222);
scene.add(grid);
const wallMaterials = WallModel.createMaterials(); const wallMaterials = WallModel.createMaterials();
@@ -1236,18 +1258,23 @@ function mountWallEditor(container, callbacks) {
let spaanplaatVisible = true; let spaanplaatVisible = true;
let buitenVisible = true; let buitenVisible = true;
let isolatieVisible = true;
// Weergave-filter (core/view-filter.js): beplating binnen/buiten + isolatie. // 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' }
{ key: 'isolatie', label: 'Isolatie' }
], (v) => { ], (v) => {
spaanplaatVisible = v.binnen; spaanplaatVisible = v.binnen;
buitenVisible = v.buiten; buitenVisible = v.buiten;
isolatieVisible = v.isolatie;
update3DScene(); update3DScene();
}); });
// Omgeving (core/skybox.js: geen/dag/nacht); de wand staat op de tegelvloer.
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => {
skyEnv.fitTo(wallGroup3D, 0);
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
};
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
// 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld // 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld
// met de unit-editor). // met de unit-editor).
@@ -1256,11 +1283,12 @@ function mountWallEditor(container, callbacks) {
function update3DScene() { function update3DScene() {
if (wallGroup3D) scene.remove(wallGroup3D); if (wallGroup3D) scene.remove(wallGroup3D);
const result = WallModel.build3D(CONFIG, calcData, { const result = WallModel.build3D(CONFIG, calcData, {
materials: wallMaterials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible, isolatieVisible materials: wallMaterials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible
}); });
wallGroup3D = result.group; wallGroup3D = result.group;
lastWallStats = result.stats; lastWallStats = result.stats;
scene.add(wallGroup3D); scene.add(wallGroup3D);
applySky(); // schaduw van de nieuwe wand
updateSpecificaties(); updateSpecificaties();
} }
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Tectum lokaal starten: statische webserver in deze map (Python 3).
# Gebruik: ./start.sh [poort] (standaard 8000), daarna http://localhost:8000
cd "$(dirname "$0")" || exit 1
PORT="${1:-8000}"
echo "Tectum: http://localhost:$PORT (Ctrl+C om te stoppen)"
exec python3 -m http.server "$PORT" --bind 127.0.0.1