Tectum v0.4.0 — mijlpaal gevelopbouw

Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units,
woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op
vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-09-30 11:38:54 +02:00
co-authored by Claude Opus 5.5
commit 3876257075
36 changed files with 9954 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Klant-/referentiemateriaal en screenshots niet in de repo
examples/
*.png
!assets/**
# Lokale exports
*.pdf
.DS_Store
+65
View File
@@ -0,0 +1,65 @@
# Licentie en auteursrecht
Copyright © 2026 Machiel Heinen. Alle rechten voorbehouden.
## 1. Rechthebbende
De software Tectum, inclusief broncode, objectcode, ontwerp, rekenlogica,
documentatie, afbeeldingen en alle bijbehorende bestanden (hierna: "de Software"), is
auteursrechtelijk beschermd. Alle auteursrechten en overige intellectuele-eigendomsrechten
op de Software berusten uitsluitend bij Machiel Heinen, maker van de Software (hierna:
"de Rechthebbende").
De Software is ontwikkeld in opdracht van Van Emous Mantelzorgwoningen, gevestigd te
Putten (KvK 91908671). Deze opdracht houdt geen overdracht van rechten in; gebruik door
Van Emous Mantelzorgwoningen vindt plaats op basis van toestemming van de Rechthebbende.
## 2. Gebruik uitsluitend met toestemming
Zonder voorafgaande, uitdrukkelijke en schriftelijke toestemming van de Rechthebbende
is het niet toegestaan de Software geheel of gedeeltelijk te verveelvoudigen, openbaar te
maken, te wijzigen, te bewerken, te vertalen, te decompileren, te verspreiden, te verhuren,
in sublicentie te geven of op enige andere wijze te gebruiken, behoudens voor zover de wet
dit dwingend toestaat.
## 3. Aard van de toestemming
Toestemming wordt uitsluitend schriftelijk verleend door de Rechthebbende. Tenzij
schriftelijk anders overeengekomen, is toestemming niet-exclusief, niet-overdraagbaar,
niet sublicentieerbaar, beperkt tot het daarin omschreven doel en de daarin omschreven
duur, en kan zij bij overtreding van de voorwaarden met onmiddellijke ingang worden
ingetrokken. Toestemming houdt nimmer een overdracht van rechten in.
## 4. Aanpassingen en afgeleide werken
Alle rechten op aanpassingen, uitbreidingen en afgeleide werken van de Software, ongeacht
door wie deze zijn gemaakt of in wiens opdracht, berusten bij de Rechthebbende.
## 5. Geen garantie
De Software wordt ter beschikking gesteld "zoals deze is" (as is), zonder enige garantie,
uitdrukkelijk of stilzwijgend, waaronder begrepen garanties omtrent werking,
foutloosheid, geschiktheid voor een bepaald doel, nauwkeurigheid van berekeningen,
maatvoering of prijzen, en het niet inbreuk maken op rechten van derden. Uitkomsten van
de Software zijn indicatief en vervangen geen constructieve berekening of controle door
een deskundige.
## 6. Aansprakelijkheid
Voor zover wettelijk toegestaan, is de Rechthebbende niet aansprakelijk voor enige
directe of indirecte schade voortvloeiend uit of verband houdend met het gebruik van, of
het niet kunnen gebruiken van, de Software, behoudens opzet of bewuste roekeloosheid van
de Rechthebbende.
## 7. Vermeldingen
Het is niet toegestaan copyright-, licentie- of eigendomsvermeldingen in of op de
Software te verwijderen, te wijzigen of onleesbaar te maken.
## 8. Toepasselijk recht en bevoegde rechter
Op deze licentie en op alle toestemmingen die op grond daarvan worden verleend is
Nederlands recht van toepassing. Geschillen worden uitsluitend voorgelegd aan de
bevoegde rechter van de Rechtbank Gelderland.
Contact en aanvragen voor toestemming: machiel@start-it.nl
+237
View File
@@ -0,0 +1,237 @@
# Tectum — configurator Van Emous Mantelzorgwoningen (intern)
Losse, meerdere-bestanden-opzet van de eerdere single-file wand-tool, nu met
een menubalk om tussen objecttypen (muur/vloer) te schakelen.
Copyright © 2026 Machiel Heinen. Alle rechten
voorbehouden; gebruik uitsluitend met schriftelijke toestemming. Zie
[LICENTIE.md](LICENTIE.md). Elk bronbestand begint met deze copyright-kop.
## 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
python3 -m http.server 8000
```
en open `http://localhost:8000`.
## Structuur
```
index.html app-shell: menubalk, splashscreen, over-deze-software
css/
app.css alle stijl, in één bestand
js/
vendor/
three.min.js three.js r128, lokaal gevendord (geen CDN meer)
OrbitControls.js eigen, compacte OrbitControls-vervanging (zie
"Bewuste keuzes" hieronder) — leunt op three.min.js
core/
geometry.js gedeelde, pure functies (regelwerk-verdeling,
polygon-oppervlaktes, clip-functies) — gebruikt
door zowel wall.js als floor.js, niet gedupliceerd
canvas-labels.js gedeelde stijl voor maatlabels ("X: 6000mm") in de
2D-canvassen van wand, vloer en unit
pdf-sheet.js gedeelde PDF-bouwstenen: titelblok, maatlijn, tabel
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
- **Volledig CDN-vrij.** three.js (r128) staat lokaal in `js/vendor/three.min.js`.
OrbitControls is een eigen, compacte implementatie (`js/vendor/OrbitControls.js`)
— het originele addon-bestand was niet beschikbaar om te vendoren, dus is de
gebruikte subset (roteren via slepen, zoomen via scrollwiel, pannen via
rechts-slepen, `target`/`update()`) opnieuw geschreven en losstaand getest
(rotatie/zoom-richting/grenzen/pannen/dispose). Functioneel gelijkwaardig
voor wat deze tool nodig heeft, maar geen 1-op-1 kopie van alle opties van
de officiële addon (bijv. geen damping/inertia).
- **Geen ES-modules.** Alle JS-bestanden zijn gewone `<script>`-tags, geladen
in een vaste volgorde (`geometry.js` → `canvas-labels.js` → jsPDF →
`pdf-sheet.js` → `wall-model.js` → `wall-pdf.js` → `floor-model.js` →
`floor-pdf.js` → `roof-model.js` → `lighting-model.js` → `wall.js` → `floor.js` → `unit-model.js` → `house-model.js`
→ `house-pdf.js` → `house.js` → `app.js`).
Reden: ES-module-imports (`import ... from`) breken bij rechtstreeks openen
via `file://` door browser-CORS-beleid. Dit was expliciet een vereiste
("gewoon via een index file"), dus is hier bewust voor gekozen.
- **`json/defaults/*.json` worden niet gefetcht.** Om dezelfde reden (fetch()
van lokale bestanden werkt ook niet betrouwbaar via `file://`): de
standaardwaarden van "Nieuw" staan hardcoded in `wall.js`/`floor.js` zelf,
en komen overeen met deze JSON-bestanden. De JSON's zijn dus referentie/
documentatie, en (voor wanden) een bestand dat je via "Importeer JSON" kan
inladen — geen runtime-configuratiebron.
- **Alleen wall.js heeft Exporteer/Importeer JSON.** floor.js nog niet.
- **Floor.js heeft nu dezelfde soorten logica als wall.js.** 2D-bovenaanzicht
+ doorsnede, regelwerk (vloerbalken), cirkelvormige doorvoeren, een
onderlaag van OSB-plaat (standaard 22mm, dikte + oppervlak + volume +
gewicht), een isolatielaag (leegte tussen de balken, analytisch bepaald —
net als bij de wand krijgt isolatie zelf bewust geen dichtheid/gewicht),
een volwaardig 3D-perspectief (balken/isolatie/OSB met doorvoer-gaten/
chassis-wielen, met aan/uit-toggles voor OSB en isolatie, plus fullscreen-
2D/3D) en een specificaties-paneel met een gewichtstotaal. Losse,
optionele chassis-laag (4 transportwielen in een "dobbelsteen 4"-indeling,
exact gecentreerd op de vloer — spoorbreedte/wielbasis/wieldiameter
instelbaar, geen interactie met de balkposities).
- **Single point of truth wand/vloer/unit.** Alle reken- en 3D-logica zit in
`WallModel`/`FloorModel`. De losse editors én de unit roepen die aan, dus
een wijziging in de wand- of vloerlogica geldt automatisch overal. De unit
bewaart per onderdeel een gewone wand-/vloer-state; wandbreedtes worden
afgeleid uit vloermaat + wanddiktes + hoekaansluiting, openingen houden
hun anker (links/midden/rechts). "Opslaan als" bij een unit geeft JSON +
één samenhangende PDF: bovenaanzicht unit (U-1), vloer (V-1), elke wand
(W-1…W-4, via WallPdf/FloorPdf) en een totalenblad (balken, zaaglijst,
glaswol, plaatwerk, gewichten). Projectgegevens van de unit staan in
`state.project` en gelden voor alle bladen.
- **Standaarden (WallModel).** Muurbalk 38 x 235 mm (stijlen én regels),
ingekocht in 3600/4200/4800/5400 mm; een zaagplan (`planStockCutting` in
geometry.js) kiest per staaf de lengte met het minste snijverlies — bij een
unit over alle wanden samen. Stukken langer dan 5400 worden opgeknipt.
Elke wandkop heeft een dubbele hoekstijl (2 x 38 mm). De app start met een
unit.
- **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/
eind + verschuiving). Op de naad vervalt de wand van beide units over het
gedeelde stuk; een wand kan daardoor in meerdere stukken uiteenvallen
(bijv. A-W2a/b), haakse wanden lopen dan door tot de vloerrand. Binnen-
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.
+2
View File
@@ -0,0 +1,2 @@
Bestemmingsmap voor toekomstige assets: GLB-modellen (ramen/deuren),
texturen/bumpmaps, e.d. Nog leeg.
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="280"
height="64"
viewBox="0 0 74.083333 16.933333"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg">
<style>
/* Algemene instellingen voor de tekenfase */
path {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: drawOutline 3s ease-out forwards, fillContent 1.5s ease-in forwards;
fill-opacity: 0;
stroke-width: 0.1; /* Dunne outline tijdens tekenen */
}
@keyframes drawOutline {
to { stroke-dashoffset: 0; }
}
@keyframes fillContent {
to { fill-opacity: 1; stroke-width: 0; }
}
/* --- LOGO --- */
#logo-outline, #logo-7-2 {
animation-delay: 0s;
stroke: #b95913;
}
#logo-7-2 { animation-delay: 0.2s; }
/* --- NAAM (LTR, 50ms delay) --- */
/* Start na logo (+/- 0.5s) */
#name-letter-1 { animation-delay: 0.20s; }
#name-letter-2 { animation-delay: 0.25s; }
#name-letter-3 { animation-delay: 0.30s; }
#name-letter-4 { animation-delay: 0.35s; }
#name-letter-5 { animation-delay: 0.40s; }
#name-letter-6 { animation-delay: 0.45s; }
#name-letter-7 { animation-delay: 0.50s; }
#name-letter-8 { animation-delay: 0.55s; }
/* --- SLOGAN (TTB, 20ms delay) --- */
/* Start halverwege de naam cycle (halverwege 0.5s en 0.85s = ~0.67s) */
[id^="slogan-letter-"] {
animation-duration: 0.1s, .4s; /* Iets sneller dan de naam */
}
#slogan-letter-1 { animation-delay: 0.71s; }
#slogan-letter-2 { animation-delay: 0.73s; }
#slogan-letter-3 { animation-delay: 0.75s; }
#slogan-letter-4 { animation-delay: 0.77s; }
#slogan-letter-5 { animation-delay: 0.79s; }
#slogan-letter-6 { animation-delay: 0.81s; }
#slogan-letter-7 { animation-delay: 0.83s; }
#slogan-letter-8 { animation-delay: 0.85s; }
#slogan-letter-9 { animation-delay: 0.87s; }
#slogan-letter-10 { animation-delay: 0.89s; }
#slogan-letter-11 { animation-delay: 0.91s; }
#slogan-letter-12 { animation-delay: 0.93s; }
#slogan-letter-13 { animation-delay: 0.95s; }
#slogan-letter-14 { animation-delay: 0.97s; }
#slogan-letter-15 { animation-delay: 0.99s; }
#slogan-letter-16 { animation-delay: 1.01s; }
#slogan-letter-17 { animation-delay: 1.03s; }
#slogan-letter-18 { animation-delay: 1.05s; }
@media (prefers-reduced-motion: reduce) {
path {
animation: none !important;
stroke-dashoffset: 0 !important;
fill-opacity: 1 !important;
stroke-width: 0 !important;
}
}
</style>
<g id="layer1" transform="translate(-77.434081,-81.735929)">
<g id="g1" transform="translate(0.95639945,0.52918133)">
<path id="logo-7-2" d="m 83.642791,97.475092 c -0.93832,-0.17693 -1.83655,-0.52324 -2.65081,-1.02195 -1.62088,-0.99092 -2.83313,-2.52974 -3.41708,-4.33755 -0.0454,-0.14079 -0.0873,-0.28504 -0.12482,-0.42834 l -0.016,-0.003 0.012,-0.0127 c 0,-7.9e-4 -4.2e-4,-0.002 -6.9e-4,-0.003 l 0.006,-0.003 1.02264,-1.07916 5.3088,-5.48508 3.76811,3.71864 v -2.78921 h 1.48503 l -0.0186,4.31398 -1.50353,0.65111 -3.74954,-3.73779 -4.58605,4.72363 c 0.0181,0.0466 0.0367,0.0929 0.0559,0.13899 0.34067,0.81896 0.8479,1.55813 1.48949,2.17058 0.64159,0.61246 1.40354,1.0848 2.23742,1.38706 0.92885,0.33324 1.92231,0.44651 2.9023,0.33088 0.98001,-0.11562 1.91984,-0.45696 2.7456,-0.99721 0.82577,-0.54028 1.51498,-1.26471 2.01343,-2.11635 0.49845,-0.85164 0.79256,-1.80732 0.85924,-2.79186 0.0667,-0.98454 -0.0959,-1.97114 -0.47504,-2.88221 -0.37912,-0.91106 -0.96435,-1.7218 -1.70976,-2.36844 -0.74541,-0.64663 -1.63063,-1.11156 -2.58612,-1.35824 -0.95546,-0.24667 -1.95514,-0.26835 -2.92039,-0.0634 -0.84206,0.17993 -1.63859,0.52969 -2.34088,1.02792 -0.34557,0.24469 -0.66614,0.5229 -0.9571,0.83053 l -1.13864,-1.0635 c 0.72713,-0.77237 1.60164,-1.39127 2.57186,-1.82018 0.49369,-0.21778 1.00844,-0.38424 1.53617,-0.49677 1.0718,-0.22718 2.17887,-0.23135 3.25234,-0.0123 1.01375,0.20805 1.97652,0.61391 2.83327,1.19437 1.27431,0.86214 2.27147,2.07516 2.87072,3.49224 0.20262,0.48019 0.35725,0.97928 0.4616,1.48995 0.21465,1.05564 0.21465,2.14368 0,3.19932 -0.20738,1.0151 -0.61241,1.97948 -1.19203,2.83823 -0.85931,1.27575 -2.07034,2.27476 -3.48618,2.87584 -0.47929,0.20296 -0.9775,0.35793 -1.48738,0.46257 -1.01243,0.20648 -2.05531,0.21468 -3.07086,0.0242 z" fill="#b95913" />
<path id="logo-outline" stroke="#b95913" fill="none" d="m 85.087381,81.890712 c -0.53445,0.002 -1.06871,0.0586 -1.59422,0.17002 -0.51737,0.11032 -1.02177,0.27377 -1.50585,0.48731 -0.90343,0.39944 -1.7132,0.97782 -2.40398,1.68258 l 0.91312,0.85318 c 0.26882,-0.27113 0.55183,-0.52862 0.86351,-0.74931 0.71939,-0.51036 1.53526,-0.86835 2.39779,-1.05265 0.98857,-0.20995 2.013,-0.18803 2.99155,0.0646 0.9785,0.25261 1.88497,0.72834 2.64841,1.39061 0.76345,0.66229 1.36254,1.49318 1.7508,2.42621 0.38825,0.93306 0.55508,1.94343 0.48679,2.95176 -0.0683,1.00831 -0.36956,1.98704 -0.88005,2.85926 -0.51046,0.87217 -1.21615,1.61448 -2.06189,2.16782 -0.84576,0.55334 -1.80863,0.90221 -2.81223,1.02061 -1.00348,0.1184 -2.02075,0.003 -2.97191,-0.33848 l 0.0512,0.009 c -0.0177,-8e-4 -0.0353,-0.004 -0.0522,-0.009 -0.85377,-0.30954 -1.63439,-0.79295 -2.29134,-1.42007 -0.65703,-0.6272 -1.17661,-1.38441 -1.52548,-2.22312 -0.0162,-0.039 -0.0326,-0.0786 -0.0486,-0.11937 -0.0274,-0.0699 -0.0114,-0.14924 0.0408,-0.20309 l 4.5434,-4.67929 c 0.0743,-0.0764 0.19681,-0.0774 0.27233,-0.002 l 3.64887,3.63698 1.31517,-0.56947 0.0176,-4.05867 h -1.17667 v 2.5461 c -8e-5,0.16968 -0.20455,0.25536 -0.32556,0.13643 l -3.59358,-3.54707 -5.2002,5.3728 c 0,0 -1.03907,0.92966 -1.00183,1.04527 v 0 c 0.57256,1.77256 1.89934,3.61056 3.48848,4.58229 h 5.3e-4 c 0.798,0.48876 1.67933,0.82849 2.59881,1.00201 0.99842,0.19675 2.01563,0.15935 3.01171,-0.0238 0.49966,-0.10255 0.98795,-0.2548 1.4578,-0.45372 1.3884,-0.58943 2.57626,-1.56898 3.41891,-2.81998 0.56831,-0.84201 0.96559,-1.78801 1.16892,-2.7833 0.21054,-1.03538 0.21054,-2.10189 0,-3.13727 -0.1023,-0.50065 -0.254,-0.99001 -0.45269,-1.4609 -0.58766,-1.38966 -1.5657,-2.57916 -2.81533,-3.42459 -0.83997,-0.56909 -1.78368,-0.96701 -2.77761,-1.17099 -0.52634,-0.10742 -1.06079,-0.16015 -1.59525,-0.15813 z" />
<g id="name_group" fill="#eee" stroke="#eee">
<path id="name-letter-1" d="m 98.642021,82.589862 1.16049,3.55882 c 0.0368,0.11789 0.0811,0.25971 0.13261,0.42552 0.0515,0.16579 0.103099,0.33711 0.154699,0.51396 0.0518,0.17685 0.10155,0.35369 0.14917,0.53054 0.0476,0.17685 0.0863,0.3352 0.11602,0.47509 h 0.0332 c 0.059,-0.27999 0.14555,-0.60249 0.25974,-0.96745 0.1142,-0.365 0.21929,-0.69459 0.31528,-0.9888 l 1.17123,-3.54768 h 1.55837 l -2.48668,6.96279 h -1.646819 l -2.51987,-6.96279 z" />
<path id="name-letter-2" d="m 104.02446,84.745032 c 0.31592,-0.1838 0.65379,-0.32678 1.00568,-0.42551 0.36698,-0.10117 0.74639,-0.15128 1.12713,-0.14892 0.29289,-0.003 0.58499,0.0341 0.86757,0.11048 0.24712,0.0662 0.47704,0.18498 0.67389,0.34812 0.19182,0.16383 0.33999,0.37267 0.43101,0.60785 0.1061,0.28432 0.15663,0.58632 0.14922,0.88969 v 3.42614 h -1.13823 l -0.13256,-0.54149 h -0.0442 c -0.17489,0.21026 -0.39608,0.37679 -0.64664,0.4863 -0.28786,0.12317 -0.59875,0.18343 -0.91175,0.17682 -0.25982,0.005 -0.51832,-0.0384 -0.76253,-0.12705 -0.20823,-0.0769 -0.39952,-0.1932 -0.56383,-0.34258 -0.15372,-0.14243 -0.27284,-0.31824 -0.348,-0.51396 -0.0784,-0.20454 -0.11772,-0.42198 -0.116,-0.64103 -0.008,-0.27247 0.0594,-0.54181 0.19341,-0.77914 0.13169,-0.22019 0.31584,-0.40434 0.53597,-0.53608 0.24791,-0.14755 0.51885,-0.25217 0.80142,-0.30948 0.32517,-0.0683 0.6567,-0.10168 0.98901,-0.0995 h 0.66305 v -0.32057 c 0,-0.24312 -0.0754,-0.42177 -0.22648,-0.53596 -0.15108,-0.1142 -0.36275,-0.1713 -0.63553,-0.1713 -0.22278,0.001 -0.44424,0.0348 -0.65723,0.0993 -0.25929,0.0809 -0.50906,0.18828 -0.74612,0.32046 z m 2.78501,2.49775 -0.47493,0.011 c -0.44926,0.0148 -0.76253,0.0903 -0.93954,0.22659 -0.0804,0.0546 -0.14657,0.12785 -0.19288,0.21347 -0.046,0.0856 -0.0709,0.1811 -0.0725,0.27836 0,0.25051 0.0757,0.42183 0.22675,0.51396 0.15504,0.0932 0.33311,0.1411 0.51382,0.13822 0.19473,-8e-4 0.3847,-0.0585 0.54715,-0.16576 0.15902,-0.0966 0.29343,-0.22906 0.39238,-0.38683 z" />
<path id="name-letter-3" d="m 110.99835,89.552652 h -1.47002 v -5.27183 h 1.14935 l 0.177,0.67376 c 0.2294,-0.24063 0.50086,-0.43727 0.80116,-0.58021 0.30348,-0.13899 0.63368,-0.20883 0.96758,-0.20446 0.58208,0 1.02764,0.15836 1.33721,0.47509 0.30956,0.31673 0.46407,0.77724 0.46407,1.38154 v 3.52566 h -1.47055 v -3.37043 c 0.013,-0.2323 -0.0577,-0.46159 -0.1987,-0.64656 -0.13256,-0.1584 -0.32782,-0.23762 -0.58579,-0.23762 -0.21061,0.005 -0.41698,0.0598 -0.60246,0.16023 -0.21431,0.1102 -0.40692,0.25808 -0.56911,0.43656 z" />
<path id="name-letter-4" d="m 118.40298,82.589862 h 4.53152 v 1.17156 h -3.01731 v 1.59155 h 2.72971 v 1.16049 h -2.72971 v 1.8678 h 3.05038 v 1.17154 h -4.56459 z" />
<path id="name-letter-5" d="m 125.48773,89.552652 h -1.47003 v -5.27183 h 1.14935 l 0.17674,0.67376 c 0.22358,-0.23402 0.48499,-0.42833 0.77365,-0.57468 0.2958,-0.14356 0.6215,-0.21549 0.95011,-0.20999 0.75169,0 1.26365,0.27263 1.53644,0.81783 0.22701,-0.25141 0.50112,-0.45601 0.80671,-0.6023 0.30401,-0.1439 0.63659,-0.21733 0.97288,-0.21484 0.55986,0 0.98927,0.15105 1.28746,0.45315 0.29845,0.3021 0.44767,0.74049 0.44767,1.31522 v 3.61402 h -1.46976 v -3.44826 c 0.0122,-0.21259 -0.0526,-0.42246 -0.18256,-0.59127 -0.0672,-0.0741 -0.15028,-0.13208 -0.24315,-0.16949 -0.0929,-0.0374 -0.19288,-0.0533 -0.29289,-0.0465 -0.20902,0.002 -0.41461,0.0514 -0.6022,0.14364 -0.20293,0.096 -0.38232,0.23567 -0.52493,0.40892 v 3.70247 h -1.47002 v -3.44826 c 0.0122,-0.21259 -0.0527,-0.42246 -0.1823,-0.59127 -0.0672,-0.0741 -0.15055,-0.13205 -0.24342,-0.16946 -0.0926,-0.0374 -0.19288,-0.0533 -0.29263,-0.0465 -0.21087,0.003 -0.41777,0.0581 -0.60245,0.16023 -0.2032,0.10747 -0.38206,0.25614 -0.52494,0.43656 z" />
<path id="name-letter-6" d="m 138.58434,86.878002 c 0.006,0.40039 -0.0648,0.79825 -0.20982,1.17155 -0.12964,0.33044 -0.32729,0.62984 -0.58023,0.87865 -0.24897,0.24091 -0.54425,0.42897 -0.86757,0.55282 -0.3421,0.13087 -0.70591,0.19648 -1.07209,0.19341 -0.36301,0.003 -0.7239,-0.0565 -1.06653,-0.17682 -0.32094,-0.11088 -0.61384,-0.29046 -0.85805,-0.52617 -0.24447,-0.23572 -0.43418,-0.52192 -0.55668,-0.83863 -0.14155,-0.36443 -0.21114,-0.75295 -0.20452,-1.14392 -0.007,-0.40945 0.0643,-0.81645 0.21008,-1.19912 0.12488,-0.32822 0.31882,-0.62582 0.56858,-0.8727 0.24977,-0.24691 0.54954,-0.43736 0.87922,-0.55851 0.3429,-0.12727 0.70617,-0.19097 1.07182,-0.18796 0.36301,-0.003 0.7239,0.0565 1.06654,0.17682 0.31617,0.11078 0.60589,0.28575 0.8509,0.51395 0.24791,0.23702 0.44026,0.52568 0.56382,0.84548 0.14235,0.3737 0.21194,0.77131 0.20453,1.17123 z m -1.51395,0.0553 c 0.005,-0.26138 -0.0283,-0.52213 -0.0997,-0.77361 -0.0534,-0.18889 -0.1434,-0.36542 -0.26511,-0.51949 -0.10345,-0.12734 -0.23627,-0.2278 -0.38682,-0.29292 -0.14314,-0.0617 -0.29739,-0.0937 -0.45323,-0.094 -0.15134,-5.3e-4 -0.30136,0.0275 -0.44212,0.0829 -0.14605,0.0598 -0.27517,0.15473 -0.37571,0.27644 -0.12224,0.15153 -0.21272,0.32639 -0.26511,0.51395 -0.0728,0.26255 -0.10636,0.53441 -0.0995,0.80677 -0.005,0.25231 0.0286,0.50387 0.0995,0.74607 0.0545,0.18788 0.14658,0.36275 0.27067,0.51396 0.10583,0.12623 0.23786,0.22804 0.38682,0.29845 0.14023,0.0655 0.29289,0.0995 0.44767,0.0995 0.15135,7.9e-4 0.30136,-0.0275 0.44212,-0.0829 0.14711,-0.0606 0.27649,-0.15756 0.37571,-0.28184 0.12092,-0.15248 0.21114,-0.32705 0.26511,-0.51398 0.0722,-0.25325 0.10584,-0.51596 0.0995,-0.77922 z" />
<path id="name-letter-7" d="m 143.03833,84.280822 h 1.47003 v 5.27183 h -1.09432 l -0.15478,-0.5968 h -0.0442 c -0.0892,0.0882 -0.18336,0.17121 -0.28179,0.24868 -0.11668,0.0907 -0.24262,0.16854 -0.37597,0.2321 -0.15531,0.0742 -0.31856,0.13163 -0.4863,0.17129 -0.19738,0.0461 -0.39952,0.0684 -0.60219,0.0663 -0.56727,0 -1.01495,-0.15835 -1.34277,-0.47509 -0.32808,-0.3167 -0.49186,-0.78457 -0.49186,-1.40356 v -3.51478 h 1.46976 v 3.39299 c 0,0.31671 0.0757,0.54512 0.22675,0.68525 0.15849,0.14287 0.36672,0.2182 0.57997,0.20997 0.2376,0.007 0.47255,-0.0503 0.67998,-0.16584 0.17357,-0.0979 0.3257,-0.22942 0.44767,-0.38682 z" />
<path id="name-letter-8" d="m 149.01739,85.540742 c -0.39159,-0.19592 -0.82206,-0.30168 -1.25995,-0.30948 -0.18997,-0.0109 -0.37941,0.0312 -0.54689,0.12152 -0.0593,0.0327 -0.10848,0.0807 -0.14261,0.13909 -0.0339,0.0584 -0.0516,0.12486 -0.0508,0.19244 -0.001,0.0667 0.0138,0.13266 0.0437,0.19229 0.0296,0.0596 0.0736,0.11121 0.12753,0.15029 0.1143,0.0884 0.34449,0.1842 0.69109,0.28739 l 0.33153,0.0995 c 0.45667,0.13245 0.80301,0.32401 1.03875,0.57467 0.2413,0.26501 0.3683,0.61447 0.35375,0.97256 0.008,0.24132 -0.0429,0.48098 -0.14764,0.69847 -0.10504,0.21749 -0.26088,0.40635 -0.45482,0.55039 -0.40137,0.30948 -0.94482,0.46421 -1.63009,0.46421 -0.69983,0 -1.30043,-0.12525 -1.80155,-0.37576 l 0.33179,-1.0831 c 0.21537,0.11131 0.44106,0.20198 0.67362,0.2708 0.2331,0.0696 0.4752,0.10493 0.71835,0.10493 0.29474,0 0.51038,-0.0461 0.64664,-0.13819 0.0624,-0.037 0.11404,-0.0895 0.14975,-0.1525 0.036,-0.063 0.0548,-0.1342 0.0548,-0.20667 0.002,-0.0722 -0.0153,-0.14359 -0.0503,-0.20677 -0.0349,-0.0632 -0.0863,-0.11581 -0.14843,-0.1524 -0.13256,-0.0847 -0.38285,-0.17865 -0.75142,-0.28183 l -0.34263,-0.0995 c -0.44212,-0.14738 -0.76623,-0.34261 -0.97261,-0.58577 -0.20637,-0.24312 -0.30956,-0.56729 -0.30956,-0.97252 -0.006,-0.22376 0.0402,-0.44585 0.13388,-0.64903 0.0939,-0.20317 0.23362,-0.38198 0.40772,-0.52255 0.36089,-0.30209 0.86572,-0.45314 1.51421,-0.45314 0.58526,-0.0114 1.16628,0.10557 1.70154,0.34257 z" />
</g>
<g id="slogan_group" transform="matrix(0.26458333,0,0,0.26458333,60.500771,34.111722)" fill="#eee" stroke="#eee">
<path id="slogan-letter-1" d="m 138.2497,224.2423 h 1.9696 l 2.1665,6.598 c 0.1088,0.3174 0.2127,0.6264 0.3118,0.9272 0.099,0.3008 0.192,0.6044 0.2791,0.9109 h 0.0326 c 0.0871,-0.3065 0.1774,-0.6019 0.271,-0.8864 0.0936,-0.2845 0.2002,-0.6018 0.32,-0.952 l 2.1171,-6.598 h 1.9696 l 0.9027,10.3404 h -1.5099 l -0.4267,-5.4828 c -0.0435,-0.5361 -0.0762,-1.0693 -0.0979,-1.5997 -0.0218,-0.5304 -0.0436,-1.0693 -0.0653,-1.6167 h -0.0327 c -0.1523,0.536 -0.3083,1.0722 -0.4678,1.6085 -0.1596,0.5363 -0.3268,1.0723 -0.5018,1.6079 l -1.8217,5.4821 h -1.3787 l -1.8224,-5.4975 c -0.1861,-0.558 -0.3557,-1.0913 -0.509,-1.5997 -0.1532,-0.5084 -0.3064,-1.0416 -0.4596,-1.5997 h -0.0327 c -0.0217,0.5361 -0.0519,1.0723 -0.0904,1.6085 -0.0385,0.5363 -0.0794,1.0717 -0.1224,1.6063 l -0.3938,5.4831 h -1.4773 z" />
<path id="slogan-letter-2" d="m 154.1212,227.5577 c 0.2627,-0.1306 0.5088,-0.2483 0.7385,-0.353 0.4427,-0.2019 0.9127,-0.3374 1.395,-0.4022 0.2612,-0.0341 0.5243,-0.0506 0.7878,-0.0493 0.3955,-0.004 0.79,0.043 1.1736,0.1394 0.338,0.084 0.6523,0.244 0.919,0.4679 0.2675,0.2335 0.4733,0.5294 0.5991,0.8615 0.1547,0.4288 0.2271,0.8829 0.2135,1.3386 v 5.0221 h -1.1981 l -0.1806,-0.8534 h -0.0326 c -0.3078,0.3396 -0.6898,0.6035 -1.1162,0.7715 -0.431,0.1572 -0.887,0.2347 -1.3457,0.2285 -0.3481,0.004 -0.6945,-0.0491 -1.0258,-0.1561 -0.2963,-0.0953 -0.572,-0.2456 -0.8126,-0.443 -0.2282,-0.1894 -0.4105,-0.4279 -0.5335,-0.6977 -0.129,-0.2889 -0.1934,-0.6026 -0.1887,-0.919 -0.0171,-0.3652 0.0627,-0.7284 0.2314,-1.0527 0.1686,-0.3244 0.4201,-0.5984 0.7288,-0.7941 0.7651,-0.4544 1.6468,-0.6741 2.5357,-0.6321 h 1.4763 v -0.6549 c 0,-0.5581 -0.1422,-0.9383 -0.4267,-1.1407 -0.3419,-0.2185 -0.7436,-0.3247 -1.1488,-0.3036 -0.33,0.006 -0.658,0.0531 -0.9765,0.1394 -0.4396,0.1235 -0.8616,0.3028 -1.2556,0.5334 z m 4.3822,3.4632 -1.2145,0.0326 c -0.8207,0.0218 -1.4034,0.1667 -1.7479,0.4349 -0.1581,0.1095 -0.2875,0.2553 -0.3775,0.4252 -0.09,0.17 -0.1379,0.359 -0.1396,0.5513 -0.006,0.2004 0.0332,0.3996 0.1149,0.5827 0.0673,0.1475 0.172,0.2747 0.3036,0.3693 0.134,0.091 0.2845,0.1551 0.443,0.1887 0.1752,0.0389 0.3541,0.0581 0.5335,0.0574 0.3806,-0.004 0.7557,-0.0912 1.0995,-0.2543 0.3907,-0.1849 0.7291,-0.4641 0.9847,-0.8126 z" />
<path id="slogan-letter-3" d="m 166.79199,234.5827 h -1.444 v -7.7142 h 1.132 l 0.214,1.0669 c 0.35,-0.359 0.758,-0.656 1.208,-0.8782 0.455,-0.2097 0.951,-0.3135 1.452,-0.3037 0.338,-0.0192 0.676,0.0298 0.995,0.144 0.319,0.1142 0.611,0.2913 0.86,0.5207 0.232,0.2457 0.411,0.5362 0.527,0.8538 0.116,0.3175 0.165,0.6554 0.146,0.9928 v 5.3179 h -1.447 v -5.1047 c 0.021,-0.4077 -0.11,-0.8087 -0.369,-1.1244 -0.146,-0.1475 -0.322,-0.2614 -0.516,-0.3337 -0.194,-0.0723 -0.402,-0.1012 -0.609,-0.0848 -0.41,0.0143 -0.813,0.1136 -1.183,0.2916 -0.37,0.178 -0.699,0.4307 -0.967,0.7423 z" />
<path id="slogan-letter-4" d="m 181.36699,227.9517 h -2.265 v 4.4808 c -0.019,0.1423 -0.003,0.2871 0.047,0.4216 0.051,0.1345 0.133,0.2546 0.24,0.3499 0.223,0.1397 0.484,0.208 0.747,0.1959 0.242,-0.002 0.484,-0.0271 0.722,-0.0738 0.233,-0.0419 0.464,-0.0995 0.69,-0.1724 l 0.228,1.0996 c -0.271,0.1018 -0.552,0.1783 -0.837,0.2285 -0.352,0.0667 -0.709,0.0995 -1.067,0.098 -0.766,0 -1.324,-0.1669 -1.674,-0.5005 -0.184,-0.1923 -0.325,-0.4208 -0.416,-0.6709 -0.09,-0.2501 -0.128,-0.5163 -0.109,-0.7816 v -4.6751 h -1.149 v -1.0832 h 1.149 v -2.462 h 1.428 v 2.462 h 2.265 z" />
<path id="slogan-letter-5" d="m 193.13499,230.89 h -5.531 v 0.0653 c -0.024,0.3584 0.026,0.7179 0.146,1.0565 0.12,0.3387 0.308,0.6493 0.552,0.9131 0.253,0.2364 0.552,0.4193 0.877,0.5378 0.326,0.1184 0.672,0.17 1.018,0.1517 0.372,0.004 0.743,-0.0286 1.108,-0.098 0.362,-0.0776 0.717,-0.1874 1.059,-0.3281 l 0.29401,1.0669 c -0.361,0.1559 -0.738,0.2715 -1.124,0.3448 -0.457,0.0897 -0.922,0.1334 -1.387,0.1306 -0.531,0.004 -1.06,-0.0727 -1.567,-0.2285 -0.475,-0.1462 -0.913,-0.3893 -1.289,-0.714 -0.384,-0.3403 -0.685,-0.7649 -0.878,-1.2406 -0.226,-0.5744 -0.335,-1.1883 -0.32,-1.8054 -0.007,-0.5588 0.085,-1.1144 0.271,-1.6412 0.165,-0.4668 0.425,-0.8943 0.763,-1.2556 0.335,-0.3494 0.741,-0.6234 1.19,-0.8041 0.499,-0.1978 1.031,-0.2954 1.567,-0.2873 0.458,-0.007 0.913,0.077 1.339,0.2461 0.38599,0.1555 0.736,0.3904 1.026,0.6895 0.291,0.3059 0.514,0.6694 0.656,1.067 0.157,0.4424 0.235,0.9092 0.229,1.3787 z m -1.493,-1.034 c 0.002,-0.269 -0.034,-0.537 -0.107,-0.7959 -0.066,-0.2356 -0.177,-0.4559 -0.328,-0.6484 -0.152,-0.1894 -0.346,-0.341 -0.566,-0.443 -0.264,-0.116 -0.55,-0.1718 -0.837,-0.1633 -0.272,-0.0104 -0.542,0.0352 -0.795,0.1341 -0.253,0.0988 -0.482,0.2488 -0.674,0.4405 -0.392,0.4132 -0.637,0.9438 -0.698,1.51 z" />
<path id="slogan-letter-6" d="m 198.18999,234.583 v -11.6538 h 1.412 v 11.6535 z" />
<path id="slogan-letter-7" d="m 210.33499,227.7059 -4.086,5.7936 h 4.169 v 1.0832 h -5.942 v -0.8864 l 4.087,-5.7459 h -3.807 v -1.0819 h 5.58001 z" />
<path id="slogan-letter-8" d="m 222.12099,230.6441 c 0.01,0.59 -0.09,1.1767 -0.294,1.7303 -0.179,0.4809 -0.456,0.9196 -0.812,1.2886 -0.342,0.348 -0.754,0.6193 -1.208,0.796 -0.477,0.1837 -0.984,0.2756 -1.494,0.2709 -0.506,0.005 -1.01,-0.0809 -1.486,-0.2543 -0.444,-0.1631 -0.847,-0.4208 -1.181,-0.7551 -0.345,-0.3514 -0.614,-0.7706 -0.788,-1.2311 -0.201,-0.5378 -0.298,-1.1085 -0.287,-1.6824 -0.011,-0.5878 0.089,-1.1724 0.293,-1.7234 0.18,-0.4764 0.456,-0.9101 0.813,-1.2733 0.343,-0.3437 0.755,-0.612 1.208,-0.7877 0.476,-0.1837 0.983,-0.2757 1.494,-0.271 0.505,-0.005 1.008,0.0782 1.485,0.2462 0.443,0.1571 0.846,0.4091 1.182,0.7384 0.346,0.3473 0.614,0.764 0.788,1.2227 0.2,0.5385 0.298,1.11 0.287,1.6846 z m -1.50999,0.082 c 0.011,-0.4457 -0.055,-0.89 -0.196,-1.3131 -0.111,-0.3271 -0.287,-0.6285 -0.517,-0.8864 -0.201,-0.2214 -0.451,-0.3928 -0.73,-0.5005 -0.267,-0.1028 -0.551,-0.1557 -0.837,-0.156 -0.285,-3e-4 -0.567,0.0438 -0.838,0.1306 -0.276,0.0891 -0.524,0.2473 -0.722,0.4596 -0.228,0.2535 -0.398,0.553 -0.5,0.8783 -0.138,0.449 -0.202,0.9174 -0.189,1.3868 -0.01,0.4484 0.056,0.8952 0.196,1.3213 0.11,0.33 0.285,0.6343 0.516,0.8945 0.202,0.2222 0.452,0.3961 0.731,0.509 0.265,0.1076 0.55,0.163 0.837,0.1632 0.284,0 0.567,-0.0471 0.837,-0.1394 0.27699,-0.0953 0.525,-0.259 0.722,-0.476 0.227,-0.2573 0.397,-0.5593 0.5,-0.8864 0.138,-0.4487 0.202,-0.9169 0.19,-1.3862 z" />
<path id="slogan-letter-9" d="m 227.12699,234.5827 v -7.7142 h 1.083 l 0.24601,1.5429 c 0.179,-0.4829 0.499,-0.9005 0.919,-1.1982 0.431,-0.3076 0.949,-0.4687 1.478,-0.4597 0.237,-0.005 0.474,0.0171 0.706,0.0653 0.174,0.0396 0.344,0.0942 0.509,0.1633 l -0.28,1.2638 c -0.13901,-0.0684 -0.285,-0.1231 -0.434,-0.1633 -0.185,-0.0468 -0.37601,-0.0688 -0.56701,-0.0653 -0.3,-0.003 -0.598,0.052 -0.87799,0.1633 -0.269,0.1105 -0.51,0.2794 -0.706,0.4946 -0.212,0.2356 -0.374,0.5121 -0.476,0.8126 -0.12,0.3567 -0.179,0.7315 -0.172,1.108 v 3.9869 z" />
<path id="slogan-letter-10" d="m 236.023,229.26791 c -0.007,-0.3923 0.071,-0.7815 0.229,-1.1407 0.149,-0.3277 0.37,-0.6168 0.648,-0.8452 0.293,-0.2372 0.631,-0.4131 0.993,-0.5172 0.406,-0.118 0.826,-0.1761 1.248,-0.1724 0.159,-4e-4 0.318,0.0106 0.476,0.0327 0.152,0.0218 0.306,0.0491 0.46,0.0819 h 3.332 v 1.067 H 242.9 l -1.313,-0.1806 c 0.424,0.4475 0.659,1.0413 0.656,1.6578 0.008,0.3924 -0.07,0.7816 -0.228,1.1407 -0.148,0.3262 -0.367,0.615 -0.641,0.8453 -0.289,0.2381 -0.624,0.4142 -0.984,0.5171 -0.405,0.118 -0.826,0.1761 -1.248,0.1724 -0.297,0.003 -0.593,-0.0185 -0.886,-0.0653 -0.205,0.0566 -0.388,0.1739 -0.525,0.3366 -0.095,0.1253 -0.147,0.2778 -0.148,0.4349 -0.012,0.1004 0.005,0.2021 0.051,0.2925 0.045,0.0904 0.116,0.1657 0.203,0.2164 0.214,0.0964 0.447,0.1411 0.681,0.1306 h 2.035 c 0.703,-0.0473 1.399,0.1595 1.962,0.5828 0.226,0.1953 0.404,0.4393 0.522,0.7136 0.118,0.2743 0.172,0.5718 0.159,0.8701 0.006,0.4005 -0.09,0.7959 -0.279,1.1488 -0.194,0.3481 -0.467,0.646 -0.796,0.8701 -0.382,0.2573 -0.808,0.4435 -1.256,0.5498 -0.543,0.1317 -1.099,0.1951 -1.658,0.1887 -0.927,0.0449 -1.848,-0.1621 -2.667,-0.5991 -0.312,-0.1737 -0.57,-0.4303 -0.745,-0.7414 -0.176,-0.3111 -0.261,-0.6647 -0.248,-1.0216 -0.011,-0.3767 0.128,-0.7425 0.385,-1.0176 0.28,-0.2878 0.627,-0.502 1.01,-0.6235 -0.194,-0.1282 -0.361,-0.2926 -0.493,-0.4842 -0.118,-0.2067 -0.175,-0.4431 -0.163,-0.681 -0.008,-0.3173 0.1,-0.6266 0.304,-0.8701 0.225,-0.2566 0.51,-0.4539 0.829,-0.5746 -0.419,-0.1809 -0.775,-0.4804 -1.026,-0.8615 -0.263,-0.4381 -0.391,-0.9435 -0.37,-1.4538 z m 1.757,5.1537 c -0.246,0.0845 -0.459,0.244 -0.609,0.4561 -0.151,0.212 -0.23,0.4659 -0.229,0.7257 0,0.9629 0.777,1.4443 2.331,1.4443 0.393,0.008 0.784,-0.0357 1.165,-0.1305 0.276,-0.0686 0.537,-0.1853 0.772,-0.3448 0.179,-0.1231 0.328,-0.286 0.435,-0.476 0.09,-0.1662 0.138,-0.3523 0.139,-0.5416 0.023,-0.1813 -0.009,-0.3654 -0.093,-0.528 -0.083,-0.1626 -0.214,-0.2961 -0.375,-0.3829 -0.37,-0.1593 -0.771,-0.235 -1.173,-0.2216 z m 3.003,-5.1211 c 0.02,-0.2331 -0.01,-0.4679 -0.088,-0.6886 -0.078,-0.2207 -0.202,-0.4223 -0.363,-0.5915 -0.34,-0.2658 -0.759,-0.4103 -1.19,-0.4103 -0.432,0 -0.851,0.1445 -1.19,0.4103 -0.158,0.1583 -0.28,0.3487 -0.358,0.5582 -0.078,0.2095 -0.11,0.4334 -0.094,0.6563 -0.019,0.2295 0.011,0.4604 0.089,0.6771 0.078,0.2167 0.201,0.4141 0.363,0.5785 0.331,0.2791 0.756,0.4228 1.19,0.4023 0.431,0.0206 0.855,-0.1199 1.19,-0.3941 0.158,-0.1543 0.281,-0.3417 0.359,-0.5488 0.078,-0.207 0.109,-0.4288 0.092,-0.6494 z" />
<path id="slogan-letter-11" d="m 247.36499,226.8685 h 1.54301 l 1.31299,4.8582 0.29401,1.7235 h 0.065 l 0.36099,-1.7235 1.329,-4.8582 H 253.83 l 1.36199,4.8582 0.345,1.7235 h 0.065 l 0.361,-1.7235 1.281,-4.8582 h 1.477 l -2.347,7.7142 h -1.641 l -1.31299,-4.727 -0.37701,-1.904 -0.394,1.904 -1.347,4.727 h -1.559 z" />
<path id="slogan-letter-12" d="m 270.26099,230.6435 c 0.01,0.59 -0.089,1.1767 -0.294,1.7303 -0.179,0.4809 -0.455,0.9196 -0.812,1.2886 -0.342,0.3479 -0.753,0.6193 -1.208,0.7959 -0.476,0.1837 -0.983,0.2756 -1.494,0.271 -0.506,0.005 -1.009,-0.0809 -1.485,-0.2543 -0.444,-0.1632 -0.847,-0.4208 -1.182,-0.7552 -0.345,-0.3513 -0.613,-0.7706 -0.788,-1.2311 -0.2,-0.5378 -0.298,-1.1085 -0.287,-1.6823 -0.011,-0.5878 0.089,-1.1724 0.294,-1.7235 0.179,-0.4763 0.456,-0.91 0.812,-1.2732 0.344,-0.3438 0.755,-0.6121 1.208,-0.7878 0.477,-0.1837 0.983,-0.2756 1.494,-0.271 -0.506,-0.005 1.008,0.0782 1.485,0.2462 0.443,0.1571 0.847,0.4092 1.182,0.7385 0.346,0.3473 0.615,0.764 0.788,1.2226 0.201,0.5387 0.298,1.1105 0.287,1.6853 z m -1.51,0.0819 c 0.011,-0.4457 -0.055,-0.89 -0.196,-1.3131 -0.11099,-0.3271 -0.287,-0.6285 -0.51699,-0.8863 -0.201,-0.2214 -0.451,-0.3928 -0.73,-0.5005 -0.267,-0.1028 -0.551,-0.1557 -0.837,-0.1561 -0.284,-3e-4 -0.567,0.0438 -0.837,0.1306 -0.276,0.0891 -0.525,0.2474 -0.722,0.4596 -0.228,0.2534 -0.399,0.553 -0.501,0.8782 -0.137,0.449 -0.201,0.9174 -0.188,1.3869 -0.011,0.4483 0.055,0.8952 0.195,1.3212 0.11,0.3301 0.286,0.6345 0.518,0.8945 0.202,0.2222 0.451,0.3961 0.73,0.509 0.266,0.1076 0.55,0.1631 0.837,0.1632 0.285,0 0.568,-0.0471 0.83699,-0.1394 0.277,-0.0952 0.525,-0.259 0.72201,-0.476 0.227,-0.2573 0.39699,-0.5592 0.501,-0.8863 0.137,-0.4486 0.20099,-0.9166 0.18799,-1.3856 z" />
<path id="slogan-letter-13" d="m 276.74499,234.5824 h -1.447 v -7.7139 h 1.13301 l 0.21299,1.0669 c 0.35,-0.359 0.759,-0.656 1.208,-0.8782 0.45501,-0.2097 0.952,-0.3135 1.453,-0.3037 0.338,-0.0192 0.676,0.0298 0.994,0.144 0.319,0.1142 0.612,0.2913 0.86001,0.5207 0.232,0.2457 0.412,0.5362 0.528,0.8538 0.115,0.3175 0.16499,0.6554 0.145,0.9928 v 5.3179 h -1.444 v -5.1047 c 0.021,-0.4077 -0.111,-0.8087 -0.369,-1.1244 -0.146,-0.1475 -0.322,-0.2614 -0.516,-0.3337 -0.194,-0.0723 -0.402,-0.1012 -0.609,-0.0848 -0.41,0.0143 -0.813,0.1136 -1.183,0.2916 -0.37,0.178 -0.699,0.4307 -0.967,0.7423 z" />
<path id="slogan-letter-14" d="m 287.789,234.5824 v -7.7139 h 1.44399 v 7.7142 z m 0.722,-9.7002 c -0.24101,0.004 -0.474,-0.0873 -0.649,-0.2543 -0.087,-0.08 -0.157,-0.1777 -0.203,-0.2867 -0.047,-0.109 -0.07,-0.2268 -0.068,-0.3454 -0.002,-0.1197 0.022,-0.2385 0.068,-0.3488 0.047,-0.1102 0.116,-0.2095 0.203,-0.2914 0.173,-0.1684 0.404,-0.2627 0.645,-0.2627 0.241,0 0.472,0.0943 0.644,0.2627 0.089,0.0807 0.161,0.1796 0.209,0.29 0.048,0.1104 0.072,0.2298 0.07,0.3502 0.002,0.1193 -0.021,0.2377 -0.07,0.3469 -0.048,0.1091 -0.119,0.2065 -0.209,0.2852 -0.173,0.1645 -0.402,0.2558 -0.641,0.2546 z" />
<path id="slogan-letter-15" d="m 296.32299,234.5827 h -1.444 v -7.7142 h 1.132 l 0.214,1.0669 c 0.35001,-0.359 0.75801,-0.656 1.20801,-0.8782 0.45499,-0.2097 0.95099,-0.3135 1.452,-0.3037 0.33799,-0.0192 0.67599,0.0298 0.995,0.144 0.31899,0.1142 0.611,0.2913 0.86,0.5207 0.23199,0.2457 0.411,0.5362 0.527,0.8538 0.11599,0.3175 0.16499,0.6554 0.14599,0.9928 v 5.3179 h -1.445 v -5.1047 c 0.022,-0.4077 -0.11,-0.8087 -0.36899,-1.1244 -0.14501,-0.1475 -0.322,-0.2614 -0.51601,-0.3336 -0.19399,-0.0723 -0.40199,-0.1013 -0.608,-0.0849 -0.411,0.0141 -0.81399,0.1133 -1.185,0.2913 -0.37,0.1779 -0.699,0.4308 -0.967,0.7426 z" />
<path id="slogan-letter-16" d="m 306.729,229.429 c -0.007,-0.3923 0.071,-0.7815 0.22899,-1.1407 0.14801,-0.3277 0.37,-0.6168 0.64801,-0.8452 0.293,-0.2372 0.63099,-0.4131 0.993,-0.5172 0.40499,-0.118 0.82499,-0.1761 1.247,-0.1724 0.15999,-4e-4 0.31899,0.0106 0.476,0.0327 0.15299,0.0218 0.306,0.0491 0.46,0.0819 h 3.334 v 1.067 h -0.509 l -1.313,-0.1806 c 0.424,0.4475 0.659,1.0413 0.657,1.6578 0.007,0.3924 -0.071,0.7816 -0.229,1.1407 -0.147,0.3262 -0.366,0.615 -0.64,0.8453 -0.289,0.2381 -0.625,0.4142 -0.985,0.5171 -0.405,0.118 -0.825,0.1761 -1.247,0.1724 -0.297,0.003 -0.594,-0.0185 -0.887,-0.0653 -0.20501,0.0566 -0.388,0.1739 -0.525,0.3366 -0.094,0.1254 -0.146,0.2778 -0.147,0.4349 -0.012,0.1004 0.005,0.2021 0.051,0.2925 0.045,0.0904 0.115,0.1657 0.203,0.2164 0.21299,0.0964 0.447,0.1411 0.681,0.1306 h 2.035 c 0.703,-0.0473 1.399,0.1595 1.962,0.5828 0.22499,0.1953 0.404,0.4393 0.522,0.7136 0.118,0.2743 0.17199,0.5718 0.159,0.8701 0.006,0.4005 -0.09,0.7959 -0.28001,1.1488 -0.19399,0.3481 -0.466,0.646 -0.796,0.8701 -0.38299,0.2578 -0.80899,0.4442 -1.25899,0.5501 -0.542,0.1317 -1.099,0.1951 -1.658,0.1887 -0.926,0.0449 -1.848,-0.1621 -2.667,-0.5991 -0.312,-0.1736 -0.57,-0.4302 -0.745,-0.7414 -0.175,-0.3111 -0.261,-0.6647 -0.248,-1.0215 -0.01,-0.3779 0.129,-0.7445 0.389,-1.0196 0.28,-0.2878 0.626,-0.502 1.009,-0.6236 -0.194,-0.1282 -0.361,-0.2926 -0.492,-0.4841 -0.119,-0.2067 -0.176,-0.4431 -0.164,-0.681 -0.007,-0.3173 0.1,-0.6266 0.304,-0.8701 0.225,-0.2566 0.51,-0.454 0.829,-0.5746 -0.419,-0.1809 -0.775,-0.4804 -1.026,-0.8616 -0.263,-0.4373 -0.392,-0.9422 -0.371,-1.4521 z m 1.75599,5.1537 c -0.246,0.0845 -0.459,0.244 -0.609,0.4561 -0.15,0.212 -0.23,0.4659 -0.228,0.7257 0,0.9629 0.777,1.4443 2.331,1.4443 0.392,0.008 0.784,-0.0357 1.165,-0.1305 0.275,-0.0686 0.537,-0.1853 0.771,-0.3448 0.18,-0.1231 0.329,-0.286 0.435,-0.476 0.091,-0.1662 0.139,-0.3523 0.14,-0.5416 0.023,-0.1813 -0.01,-0.3654 -0.093,-0.528 -0.084,-0.1626 -0.214,-0.2961 -0.375,-0.3829 -0.37,-0.1593 -0.771,-0.235 -1.174,-0.2216 z m 3.004,-5.1211 c 0.02,-0.2332 -0.01,-0.4679 -0.088,-0.6886 -0.078,-0.2207 -0.202,-0.4223 -0.364,-0.5915 -0.34,-0.2659 -0.758,-0.4104 -1.19,-0.4104 -0.431,0 -0.85,0.1445 -1.18999,0.4104 -0.15801,0.1583 -0.279,0.3487 -0.35701,0.5583 -0.078,0.2095 -0.11,0.4333 -0.093,0.6562 -0.02,0.2295 0.01,0.4604 0.088,0.6772 0.078,0.2167 0.202,0.414 0.363,0.5784 0.332,0.2791 0.757,0.4228 1.19,0.4023 0.432,0.0206 0.856,-0.1199 1.19,-0.3941 0.159,-0.1544 0.281,-0.3417 0.35901,-0.5488 0.078,-0.2071 0.10899,-0.4288 0.092,-0.6494 z" />
<path id="slogan-letter-17" d="m 325.24299,230.8896 h -5.531 v 0.0653 c -0.024,0.3585 0.026,0.718 0.146,1.0566 0.12,0.3386 0.308,0.6493 0.552,0.913 0.253,0.2365 0.55201,0.4194 0.877,0.5378 0.32601,0.1185 0.67201,0.1701 1.018,0.1518 0.372,0.004 0.743,-0.0286 1.108,-0.098 0.362,-0.0776 0.717,-0.1874 1.059,-0.3281 l 0.294,1.0669 c -0.361,0.1558 -0.738,0.2715 -1.124,0.3448 -0.457,0.0896 -0.922,0.1334 -1.387,0.1306 -0.531,0.004 -1.06,-0.0728 -1.567,-0.2286 -0.475,-0.1462 -0.913,-0.3893 -1.289,-0.714 -0.38399,-0.3403 -0.685,-0.7649 -0.878,-1.2406 -0.22699,-0.5743 -0.335,-1.1882 -0.32,-1.8054 -0.007,-0.5587 0.084,-1.1143 0.271,-1.6411 0.165,-0.4668 0.424,-0.8944 0.763,-1.2557 0.33501,-0.3493 0.741,-0.6234 1.19,-0.8041 0.498,-0.1977 1.031,-0.2953 1.567,-0.2873 0.45801,-0.007 0.913,0.0771 1.33801,0.2462 0.387,0.1555 0.73599,0.3904 1.026,0.6895 0.291,0.3059 0.51499,0.6693 0.65699,1.0669 0.157,0.4424 0.234,0.9093 0.22801,1.3787 z m -1.494,-1.0339 c 0.002,-0.269 -0.034,-0.537 -0.106,-0.7959 -0.066,-0.2356 -0.178,-0.456 -0.329,-0.6484 -0.152,-0.1894 -0.345,-0.341 -0.566,-0.443 -0.263,-0.116 -0.549,-0.1718 -0.837,-0.1633 -0.271,-0.0105 -0.541,0.0351 -0.794,0.134 -0.253,0.0989 -0.483,0.2489 -0.675,0.4406 -0.392,0.4132 -0.637,0.9437 -0.697,1.5099 z" />
<path id="slogan-letter-18" d="m 331.67699,234.5827 h -1.445 v -7.7142 h 1.133 l 0.213,1.0669 c 0.351,-0.359 0.75901,-0.656 1.20801,-0.8782 0.455,-0.2097 0.952,-0.3135 1.45299,-0.3037 0.33801,-0.0192 0.676,0.0298 0.995,0.144 0.318,0.1142 0.611,0.2913 0.86001,0.5207 0.232,0.2457 0.411,0.5362 0.52699,0.8538 0.116,0.3175 0.16501,0.6554 0.145,0.9928 v 5.3179 h -1.444 v -5.1047 c 0.021,-0.4077 -0.11,-0.8087 -0.369,-1.1244 -0.146,-0.1475 -0.322,-0.2614 -0.516,-0.3337 -0.194,-0.0723 -0.402,-0.1012 -0.608,-0.0848 -0.411,0.0141 -0.814,0.1133 -1.185,0.2913 -0.37,0.1779 -0.7,0.4308 -0.967,0.7426 z" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 30 KiB

+50
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

+546
View File
@@ -0,0 +1,546 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
/* ============================================================
HUISSTIJL Van Emous — kleuren als CSS-variabelen (bron: SCSS-variabelen
$van-emous-blue / -orange / -ultra-light, $nav-link-color, $primary).
De bediening (menu's, panelen, navigatie) is licht; de 2D-/3D-weergaven
houden een donkere achtergrond (--viewport-bg), omdat de canvas-tekeningen
daar lichte lijnen/teksten op gebruiken.
============================================================ */
:root {
--van-emous-blue: #dce4ee;
--van-emous-orange: #b95913;
--van-emous-ultra-light: #f9faf5;
--nav-link-color: #212121;
--primary: var(--van-emous-orange);
--primary-hover: #9c4a0f;
--primary-soft: rgba(185, 89, 19, 0.10);
--primary-on-dark: #e8883d; /* oranje dat leesbaar blijft op de donkere weergaven */
--text: var(--nav-link-color);
--text-muted: #5b6673;
--text-faint: #8a94a0;
--surface: #ffffff;
--border: #c5ced9;
--border-soft: #e2e7ee;
--viewport-bg: #1a1a1a;
--danger: #b3261e;
--danger-soft: #fdecea;
--ok: #2e7d32;
--ok-soft: #e8f3e9;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: var(--van-emous-ultra-light);
color: var(--text);
}
#app-container { display: flex; width: 100%; height: 100%; }
#left-column {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
border-right: 1px solid var(--border);
}
#right-column {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
}
#app-container.fullscreen-3d #left-column { display: none; }
#app-container.fullscreen-2d #right-column { display: none; }
#pane-2d-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
.pane-2d-sub {
flex: 1;
position: relative;
width: 100%;
min-height: 0;
background: var(--viewport-bg);
}
.pane-2d-sub + .pane-2d-sub { border-top: 1px solid var(--border); }
.pane-2d-primary { flex: 2; } /* primair aanzicht (bijv. vooraanzicht bij een wand) krijgt meer ruimte */
.pane-2d-sub canvas.interactive { cursor: default; }
.pane-2d-row { display: flex; flex-direction: row; padding: 0; }
.pane-2d-half { flex: 1; position: relative; min-width: 0; min-height: 0; background: var(--viewport-bg); }
.pane-2d-half + .pane-2d-half { border-left: 1px solid var(--border); }
#control-panel {
height: 380px;
background: var(--van-emous-ultra-light);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.tab-bar {
display: flex;
background: var(--van-emous-blue);
border-bottom: 1px solid var(--border);
}
.io-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--van-emous-ultra-light);
border-bottom: 1px solid var(--border-soft);
}
.io-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.io-btn:hover { border-color: var(--primary); color: var(--primary); }
.io-status { font-size: 11px; color: var(--text-faint); margin-left: auto; }
.io-status.err { color: var(--danger); }
.io-status.ok { color: var(--ok); }
.tab-btn {
flex: 1;
padding: 10px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active {
color: var(--primary);
border-bottom: 2px solid var(--primary);
background: var(--van-emous-ultra-light);
}
.tab-content {
padding: 15px 20px;
overflow-y: auto;
flex: 1;
display: none;
}
.tab-content.active { display: block; }
#pane-3d { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); }
#specs-panel-3d {
height: 380px;
background: var(--van-emous-ultra-light);
border-top: 1px solid var(--border);
overflow-y: auto;
padding: 15px 20px;
}
#specs-panel-3d .controls-grid { grid-template-columns: 1fr 1fr; }
.toolbar-3d {
position: absolute;
top: 15px;
right: 15px;
z-index: 10;
display: flex;
gap: 8px;
}
.btn-3d-toggle {
background: rgba(0, 0, 0, 0.75);
border: 1px solid var(--primary-on-dark);
color: var(--primary-on-dark);
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
}
.btn-3d-toggle.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.pane-label {
position: absolute;
top: 15px;
left: 15px;
background: rgba(0, 0, 0, 0.75);
padding: 6px 14px;
border-radius: 4px;
border: 1px solid #444;
font-size: 13px;
font-weight: bold;
letter-spacing: 0.5px;
color: var(--primary-on-dark);
z-index: 10;
pointer-events: none;
}
.controls-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
h1 { font-size: 13px; margin: 0 0 8px 0; color: var(--primary); text-transform: uppercase; letter-spacing: 0.8px; }
.control-group { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; }
.control-group label { font-size: 12px; color: var(--text-muted); }
.control-group input, .control-group select {
width: 96px; background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 3px 5px; border-radius: 4px; font-size: 12px; text-align: right;
}
.control-group select { text-align: left; }
.control-group input:focus, .control-group select:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px var(--primary-soft); }
.btn-toggle {
width: 100%; padding: 8px; margin-bottom: 12px; background: var(--surface); border: 1px solid var(--primary); color: var(--primary);
font-weight: bold; border-radius: 4px; cursor: pointer; text-transform: uppercase; font-size: 12px;
}
.btn-toggle.active { background: var(--primary); color: #fff; }
.unit { font-size: 11px; color: var(--text-faint); margin-left: 3px; }
.divider { border-top: 1px solid var(--border-soft); margin: 8px 0; }
.result-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 3px; }
.highlight { color: var(--primary); font-weight: bold; }
canvas { display: block; width: 100%; height: 100%; }
/* --- Openingen tab (nieuw) --- */
.opening-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 10px;
position: relative;
}
.opening-card.opening-selected { background: var(--primary-soft); border-color: var(--primary); }
.opening-card.opening-warn {
border-color: var(--danger);
box-shadow: 0 0 0 1px var(--danger) inset;
}
.opening-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.opening-card-title {
font-size: 12px;
font-weight: bold;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.opening-remove-btn {
background: none;
border: 1px solid #e3b4b0;
color: var(--danger);
border-radius: 4px;
width: 22px;
height: 22px;
line-height: 1;
cursor: pointer;
font-size: 13px;
}
.opening-remove-btn:hover { background: var(--danger-soft); }
.opening-warning-text {
font-size: 11px;
color: var(--danger);
margin-top: 6px;
}
.add-opening-btn {
width: 100%; padding: 8px; margin-bottom: 12px; background: var(--ok-soft); border: 1px solid var(--ok); color: var(--ok);
font-weight: bold; border-radius: 4px; cursor: pointer; text-transform: uppercase; font-size: 12px;
}
.add-opening-btn:hover { background: #d7ebd9; }
.global-warning {
background: var(--danger-soft);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: 4px;
padding: 8px 10px;
font-size: 12px;
margin-bottom: 10px;
}
/* --- Specificaties tab (nieuw) --- */
.spec-section { margin-bottom: 14px; }
.material-info { font-size: 12px; color: var(--text-muted); line-height: 1.5; margin: 4px 0 10px; }
.material-toggle-group { display: flex; gap: 8px; margin-bottom: 8px; }
.material-toggle-btn {
flex: 1; padding: 10px 8px; background: var(--surface); border: 1px solid var(--border); color: var(--text-muted);
border-radius: 4px; font-size: 12px; font-weight: bold; text-transform: uppercase;
letter-spacing: .3px; cursor: pointer;
}
.material-toggle-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); }
/* ============================================================
APP SHELL: menubalk, workspace-wrapper, splashscreen, about-modal
============================================================ */
html, body { height: 100%; }
#app-bar {
height: 40px;
background: var(--van-emous-blue);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 4px;
position: relative;
z-index: 100;
user-select: none;
}
#app-bar .menu-group { position: relative; }
#app-bar .menu-btn {
background: none; border: none; color: var(--nav-link-color); font-size: 13px;
padding: 8px 14px; cursor: pointer; border-radius: 4px;
}
#app-bar .menu-btn:hover, #app-bar .menu-btn.open { background: var(--surface); color: var(--primary); }
#app-bar .menu-dropdown {
position: absolute; top: 100%; left: 0; background: var(--surface); border: 1px solid var(--border);
border-radius: 0 0 6px 6px; min-width: 220px; display: none; box-shadow: 0 8px 20px rgba(33, 33, 33, .15);
}
#app-bar .menu-dropdown.open { display: block; }
#app-bar .menu-item {
padding: 9px 16px; font-size: 13px; color: var(--nav-link-color); cursor: pointer; position: relative;
display: flex; justify-content: space-between; align-items: center;
}
#app-bar .menu-item:hover { background: var(--primary-soft); color: var(--primary); }
#app-bar .menu-hotkey { color: var(--text-faint); font-size: 11px; margin-left: 16px; }
#app-bar .menu-item.disabled { color: #b0b8c2; cursor: not-allowed; pointer-events: none; }
#app-bar .menu-item.disabled:hover { background: none; color: #b0b8c2; }
#app-bar .submenu {
position: absolute; left: 100%; top: -1px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; min-width: 200px; display: none; box-shadow: 0 8px 20px rgba(33, 33, 33, .15);
}
#app-bar .menu-item:hover .submenu, #app-bar .submenu.open { display: block; }
#app-bar .submenu .menu-item { color: var(--nav-link-color); }
#app-bar .submenu .menu-item:hover { background: var(--primary-soft); color: var(--primary); }
#app-bar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; padding-right: 6px; }
#app-version-tag { font-size: 11px; color: var(--text-muted); }
#btn-about {
background: var(--surface); border: 1px solid var(--border); color: var(--nav-link-color); font-size: 12px;
padding: 5px 10px; border-radius: 4px; cursor: pointer;
}
#btn-about:hover { border-color: var(--primary); color: var(--primary); }
#tab-bar {
height: 34px;
background: var(--van-emous-ultra-light);
border-bottom: 1px solid var(--border);
display: flex;
align-items: stretch;
overflow-x: auto;
}
.tab-item {
display: flex; align-items: center; gap: 8px;
padding: 0 12px; font-size: 12px; color: var(--text-muted);
border-right: 1px solid var(--border-soft); border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap;
}
.tab-item:hover { color: var(--text); }
.tab-item.active { background: var(--surface); color: var(--text); border-bottom: 2px solid var(--primary); }
.tab-item .tab-close {
color: var(--text-faint); font-size: 14px; line-height: 1; padding: 2px;
}
.tab-item .tab-close:hover { color: var(--danger); }
#workspace { width: 100vw; height: calc(100vh - 40px - 34px); position: relative; }
#workspace-empty {
height: 100%; display: flex; align-items: center; justify-content: center;
color: var(--text-faint); font-size: 14px; flex-direction: column; gap: 10px;
}
/* --- Splashscreen (blijft donker: het logo bevat lichte delen) --- */
#splash-screen {
position: fixed; inset: 0; background: #0d0d0d; z-index: 9999;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; animation: splashFadeOut .5s ease-in-out 3.8s forwards;
}
#splash-logo { width: 320px; height: auto; }
#splash-screen .splash-version { font-size: 13px; color: #888; margin-top: 14px; letter-spacing: .5px; }
#splash-screen .splash-name { color: #eee; font-weight: bold; letter-spacing: 1px; }
@keyframes splashFadeOut { 0% { opacity: 1; } 100% { opacity: 0; visibility: hidden; pointer-events: none; } }
/* --- "Over deze software"-modal --- */
#about-overlay {
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
display: none; align-items: center; justify-content: center;
}
#about-overlay.open { display: flex; }
#about-modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 22px 26px;
max-width: 680px; width: 92%; max-height: 90vh; display: flex; flex-direction: column;
box-shadow: 0 12px 30px rgba(33, 33, 33, .2);
}
#about-modal .about-logo { width: 220px; height: auto; margin-bottom: 14px; }
#about-modal .about-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
#about-modal .about-head h2 { font-size: 20px; letter-spacing: .5px; }
#about-modal .about-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 11px; color: var(--text-faint); }
#about-modal .license-box { overflow-y: auto; min-height: 0; flex: 1; }
#about-modal .license-box h3 { font-size: 14px; color: var(--text); margin: 0 0 8px; }
#about-modal .license-box h4 { font-size: 12px; color: var(--primary); margin: 12px 0 4px; text-transform: uppercase; letter-spacing: .5px; }
#about-modal .license-box p { font-size: 12px; color: var(--text-muted); margin: 0 0 6px; line-height: 1.55; }
#about-modal h2 { margin: 0 0 6px; font-size: 15px; color: var(--primary); }
#about-modal .about-version { font-size: 12px; color: var(--text-muted); }
#about-modal p { font-size: 13px; color: var(--text); line-height: 1.6; margin: 0 0 10px; }
#about-modal .license-box {
background: var(--van-emous-ultra-light); border: 1px solid var(--border-soft); border-radius: 6px; padding: 10px 12px;
font-size: 12px; color: var(--text-muted); line-height: 1.6; margin-bottom: 16px;
}
#about-modal .about-close {
background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 6px 14px;
border-radius: 4px; font-size: 13px; cursor: pointer; float: right;
}
#about-modal .about-close:hover { border-color: var(--primary); color: var(--primary); }
#wall-project-overlay, #house-project-overlay {
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
display: none; align-items: center; justify-content: center;
}
#wall-project-overlay.open, #house-project-overlay.open { display: flex; }
#wall-project-modal, #house-project-modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px;
width: 540px; max-width: 92%; box-shadow: 0 12px 30px rgba(33, 33, 33, .2);
}
#wall-project-modal .modal-header, #house-project-modal .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
#wall-project-modal .modal-header h2, #house-project-modal .modal-header h2 { margin: 0; font-size: 15px; color: var(--primary); }
#wall-project-modal .modal-close-x, #house-project-modal .modal-close-x {
background: none; border: none; color: var(--text-faint); font-size: 19px; line-height: 1; cursor: pointer; padding: 0 4px;
}
#wall-project-modal .modal-close-x:hover, #house-project-modal .modal-close-x:hover { color: var(--danger); }
#wall-project-modal .modal-footer, #house-project-modal .modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
#wall-project-modal .control-group input, #house-project-modal .control-group input { width: 150px; text-align: left; }
#new-overlay {
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
display: none; align-items: center; justify-content: center;
}
#new-overlay.open { display: flex; }
#new-modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 22px 26px;
box-shadow: 0 12px 30px rgba(33, 33, 33, .2);
}
#new-modal h2 { margin: 0 0 16px; font-size: 15px; color: var(--primary); }
.new-options { display: flex; gap: 14px; }
.new-option {
display: flex; flex-direction: column; align-items: center; gap: 8px;
background: var(--van-emous-ultra-light); border: 1px solid var(--border); border-radius: 8px; padding: 18px 22px;
color: var(--nav-link-color); cursor: pointer; width: 110px;
}
.new-option svg { width: 40px; height: 40px; }
.new-option span { font-size: 13px; }
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
.new-option.disabled { opacity: .4; cursor: not-allowed; }
/* ============================================================
VLOER-EDITOR (floor.js) — hergebruikt bestaande klassen als
.opening-card/.add-opening-btn/.global-warning/.controls-grid/.control-group/
.tab-bar/.tab-btn/.tab-content/.result-row/.highlight/.divider (identiek
aan de wand-editor, dus geen dubbele stijl-definities nodig).
============================================================ */
#floor-app { display: flex; width: 100%; height: 100%; }
#floor-left-column {
flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0;
border-right: 1px solid var(--border);
}
#floor-right-column { flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; }
#floor-app.fullscreen-3d #floor-left-column { display: none; }
#floor-app.fullscreen-2d #floor-right-column { display: none; }
#floor-pane-2d-container { flex: 1; display: flex; flex-direction: column; min-height: 0; width: 100%; }
#floor-control-panel { height: 380px; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); display: flex; flex-direction: column; }
#floor-control-panel .tab-content { padding: 15px 20px; overflow-y: auto; flex: 1; display: none; }
#floor-control-panel .tab-content.active { display: block; }
#floor-pane-3d { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); }
#floor-specs-panel-3d {
height: 380px; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-y: auto; padding: 15px 20px;
}
#floor-specs-panel-3d .controls-grid { grid-template-columns: 1fr 1fr; }
/* ============================================================
WONING-EDITOR (house/house.js) — navigatieboom links, actief onderdeel
rechts (overzicht of de ECHTE wand-/vloer-editor in #house-part-host).
============================================================ */
#house-app { display: flex; width: 100%; height: 100%; }
#house-nav {
width: 240px; flex: none; display: flex; flex-direction: column; gap: 6px;
background: var(--van-emous-blue); border-right: 1px solid var(--border); padding: 10px 8px; overflow-y: auto;
}
.hn-title { font-size: 12px; font-weight: bold; color: var(--primary); text-transform: uppercase; letter-spacing: .8px; padding: 2px 6px 6px; }
#house-nav-tree { display: flex; flex-direction: column; gap: 2px; }
.hn-group { margin-top: 6px; }
.hn-item {
display: flex; justify-content: space-between; align-items: baseline; gap: 6px;
padding: 5px 8px; border-radius: 4px; font-size: 12px; color: var(--nav-link-color); cursor: pointer;
}
.hn-item:hover { background: rgba(255, 255, 255, .6); color: var(--primary); }
.hn-item.active { background: var(--surface); color: var(--primary); box-shadow: inset 3px 0 0 var(--primary); }
.hn-unit { font-weight: bold; text-transform: uppercase; letter-spacing: .5px; }
.hn-static { cursor: default; }
.hn-static:hover { background: none; color: var(--nav-link-color); }
.hn-sub { padding-left: 18px; }
.hn-meta { font-size: 10px; color: var(--text-muted); font-weight: normal; text-transform: none; letter-spacing: 0; text-align: right; }
.hn-removed span:first-child { text-decoration: line-through; color: var(--text-faint); }
.hn-item.disabled { color: #9aa4b0; cursor: not-allowed; }
.hn-item.disabled:hover { background: none; color: #9aa4b0; }
.hn-actions { display: flex; gap: 6px; margin-top: 10px; }
.hn-actions .io-btn { flex: 1; }
.hn-soon { margin-top: 10px; border-top: 1px solid var(--border); padding-top: 6px; }
#house-nav .io-status { margin: auto 0 0; padding: 6px; }
#house-part-host { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
#house-part-host > #app-container,
#house-part-host > #floor-app,
#house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; }
#house-overview { display: flex; width: 100%; }
.ho-col { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.ho-left { border-right: 1px solid var(--border); }
#house-overview.fullscreen-3d .ho-left { display: none; }
#house-overview.fullscreen-2d .ho-right { display: none; }
.ho-pane { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); }
.ho-hint {
position: absolute; left: 15px; bottom: 10px; font-size: 11px; color: #bbb;
background: rgba(0, 0, 0, .6); padding: 3px 8px; border-radius: 4px; pointer-events: none;
}
#ho-props, #ho-specs {
height: 340px; flex: none; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-y: auto; padding: 15px 20px;
}
#ho-specs .controls-grid { grid-template-columns: 1fr 1fr; }
.ho-prop-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.io-btn-danger { border-color: #e3b4b0; color: var(--danger); }
.io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.control-group input:disabled { opacity: .5; cursor: not-allowed; background: var(--border-soft); }
/* --- Weergave-filter (core/view-filter.js): dropdown met vinkjes op de 3D-weergave --- */
.view-filter { position: relative; }
.vf-menu {
position: absolute; right: 0; top: 100%; margin-top: 6px; min-width: 210px; display: none;
background: rgba(0, 0, 0, 0.88); border: 1px solid #444; border-radius: 4px; padding: 8px 12px; z-index: 20;
}
.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 input { accent-color: var(--primary-on-dark); width: 15px; height: 15px; cursor: pointer; }
+136
View File
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<!--
Tectum
Copyright © 2026 Machiel Heinen.
Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
Zie LICENTIE.md.
-->
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tectum — Van Emous Mantelzorgwoningen</title>
<link rel="stylesheet" href="css/app.css">
<script src="js/vendor/three.min.js"></script>
<script src="js/vendor/OrbitControls.js"></script>
</head>
<body>
<div id="splash-screen">
<img id="splash-logo" src="assets/logo-animated.svg" alt="Van Emous Mantelzorgwoningen">
<div class="splash-version"><span class="splash-name">Tectum</span> &mdash; Versie <span class="app-version-number">0.0.0</span></div>
</div>
<div id="app-bar">
<div class="menu-group">
<button id="menu-bestand-btn" class="menu-btn">Bestand</button>
<div id="menu-bestand-dropdown" class="menu-dropdown">
<div class="menu-item" id="menu-nieuw-item">
<span>Nieuw</span><span class="menu-hotkey">Ctrl+N</span>
<div class="submenu">
<div class="menu-item menu-nieuw-option" data-kind="woning" data-template="single">Woning (1 unit)</div>
<div class="menu-item menu-nieuw-option" data-kind="woning" data-template="L">Woning L-vorm (2 units)</div>
<div class="menu-item menu-nieuw-option" data-kind="muur">Los element: muur</div>
<div class="menu-item menu-nieuw-option" data-kind="vloer">Los element: vloer</div>
</div>
</div>
<div class="menu-item" id="menu-opslaan-als"><span>Opslaan als&hellip;</span><span class="menu-hotkey">Ctrl+S</span></div>
<div class="menu-item" id="menu-importeren">Importeren&hellip;</div>
<div class="menu-item" id="menu-project-props">Project eigenschappen&hellip;</div>
</div>
</div>
<div class="menu-group">
<button id="menu-bewerken-btn" class="menu-btn">Bewerken</button>
<div id="menu-bewerken-dropdown" class="menu-dropdown">
<div class="menu-item" id="menu-mirror-h"><span>Horizontaal spiegelen</span><span class="menu-hotkey">H</span></div>
<div class="menu-item disabled" id="menu-mirror-v" title="Nog niet beschikbaar: een wand mag aan de onderzijde niet scheef worden.">Verticaal spiegelen</div>
</div>
</div>
<div class="menu-group">
<button id="menu-beeld-btn" class="menu-btn">Beeld</button>
<div id="menu-beeld-dropdown" class="menu-dropdown">
<div class="menu-item" id="menu-fullscreen"><span>Volledig scherm</span><span class="menu-hotkey">F</span></div>
<div class="menu-item" id="menu-3d-fullscreen"><span>3D volledig scherm</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>
</div>
<div id="app-bar-right">
<span id="app-version-tag" class="app-version-slot"></span>
<button id="btn-about">Over deze software</button>
</div>
</div>
<div id="about-overlay">
<div id="about-modal">
<img class="about-logo" src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
<div class="about-head">
<h2>Tectum</h2>
<div class="about-version app-version-slot">v0.0.0</div>
</div>
<p>Configurator voor mantelzorgwoningen: woningen opgebouwd uit units (vloer, wanden, dak), met binnenwanden, dakrand en buitenverlichting, 3D-weergave en complete productietekeningen (PDF).</p>
<div class="license-box" id="about-license"></div>
<div class="about-footer">
<span>&copy; 2026 Machiel Heinen &middot; ontwikkeld in opdracht van Van Emous Mantelzorgwoningen</span>
<button id="about-close" class="about-close">Sluiten</button>
</div>
</div>
</div>
<div id="new-overlay">
<div id="new-modal">
<h2>Nieuw</h2>
<div class="new-options">
<button class="new-option" data-kind="woning" data-template="single">
<svg viewBox="0 0 48 48"><rect x="8" y="14" width="32" height="20" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="24" x2="40" y2="24" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/></svg>
<span>Woning</span>
</button>
<button class="new-option" data-kind="woning" data-template="L">
<svg viewBox="0 0 48 48"><path d="M8 8 L24 8 L24 26 L40 26 L40 40 L8 40 Z" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="26" x2="24" y2="26" stroke="currentColor" stroke-width="1.5" stroke-dasharray="3 2"/></svg>
<span>Woning L-vorm</span>
</button>
<button class="new-option" data-kind="muur">
<svg viewBox="0 0 48 48"><rect x="6" y="6" width="36" height="36" fill="none" stroke="currentColor" stroke-width="2"/><line x1="16" y1="6" x2="16" y2="42" stroke="currentColor" stroke-width="2"/><line x1="32" y1="6" x2="32" y2="42" stroke="currentColor" stroke-width="2"/><rect x="20" y="16" width="8" height="10" fill="none" stroke="currentColor" stroke-width="2"/></svg>
<span>Los: muur</span>
</button>
<button class="new-option" data-kind="vloer">
<svg viewBox="0 0 48 48"><rect x="6" y="6" width="36" height="36" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="18" x2="42" y2="18" stroke="currentColor" stroke-width="2"/><line x1="6" y1="30" x2="42" y2="30" stroke="currentColor" stroke-width="2"/><line x1="18" y1="6" x2="18" y2="42" stroke="currentColor" stroke-width="2"/><line x1="30" y1="6" x2="30" y2="42" stroke="currentColor" stroke-width="2"/></svg>
<span>Los: vloer</span>
</button>
<button class="new-option disabled" data-kind="dak" disabled title="Nog niet beschikbaar">
<svg viewBox="0 0 48 48"><path d="M4 30 L24 8 L44 30" fill="none" stroke="currentColor" stroke-width="2"/><line x1="10" y1="30" x2="38" y2="30" stroke="currentColor" stroke-width="2"/></svg>
<span>Dak</span>
</button>
</div>
</div>
</div>
<div id="tab-bar"></div>
<div id="workspace"></div>
<script src="js/core/license.js"></script>
<script src="js/core/logo-data.js"></script>
<script src="js/core/geometry.js"></script>
<script src="js/core/canvas-labels.js"></script>
<script src="js/core/camera-memory.js"></script>
<script src="js/core/view-filter.js"></script>
<script src="js/vendor/jspdf.umd.min.js"></script>
<script src="js/core/pdf-sheet.js"></script>
<script src="js/wall/wall-model.js"></script>
<script src="js/wall/wall-pdf.js"></script>
<script src="js/floor/floor-model.js"></script>
<script src="js/floor/floor-pdf.js"></script>
<script src="js/roof/roof-model.js"></script>
<script src="js/lighting/lighting-model.js"></script>
<script src="js/sandwich/sandwich-model.js"></script>
<script src="js/wall/wall.js"></script>
<script src="js/floor/floor.js"></script>
<script src="js/unit/unit-model.js"></script>
<script src="js/house/house-model.js"></script>
<script src="js/house/house-pdf.js"></script>
<script src="js/house/house.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+299
View File
@@ -0,0 +1,299 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
const APP_VERSION = '0.4.0';
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
let tabs = [];
let activeTabId = null;
let tabIdCounter = 1;
let kindCounters = {};
let currentEditor = null;
function editorModuleFor(kind) {
if (kind === 'muur') return window.WallEditor;
if (kind === 'vloer') return window.FloorEditor;
if (kind === 'woning') return window.HouseEditor;
return null;
}
function defaultNameFor(kind) {
kindCounters[kind] = (kindCounters[kind] || 0) + 1;
return `${kind} ${String(kindCounters[kind]).padStart(2, '0')}`;
}
function setWorkspaceEmpty() {
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
<div>Geen ontwerp geopend.</div>
<div style="font-size:11px;">Bestand &rarr; Nieuw &rarr; kies een objecttype</div>
</div>`;
}
function updateEditMenuState() {
const has = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
document.getElementById('menu-opslaan-als').classList.toggle('disabled', !has('exportDesign'));
document.getElementById('menu-importeren').classList.toggle('disabled', !has('triggerImport'));
document.getElementById('menu-mirror-h').classList.toggle('disabled', !has('mirrorHorizontal'));
document.getElementById('menu-3d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen3D'));
document.getElementById('menu-2d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen2D'));
document.getElementById('menu-project-props').classList.toggle('disabled', !has('openProjectProperties'));
}
function renderTabBar() {
const bar = document.getElementById('tab-bar');
bar.innerHTML = tabs.map(tab => `
<div class="tab-item ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}">
<span class="tab-name">${tab.name}</span>
<span class="tab-close" data-close-id="${tab.id}">&times;</span>
</div>
`).join('');
}
function onTabSaved(tab, filenameWithExt) {
const base = filenameWithExt.replace(/\.json$/i, '');
tab.name = base;
tab.savedName = base;
renderTabBar();
}
function mountEditorForTab(tab) {
const ws = document.getElementById('workspace');
ws.innerHTML = '';
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
currentEditor.loadStateJson(tab.stateJson);
}
}
function switchToTab(tabId) {
if (activeTabId === tabId) return;
const oldTab = tabs.find(t => t.id === activeTabId);
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
oldTab.stateJson = currentEditor.getStateJson();
}
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
currentEditor = null;
activeTabId = tabId;
mountEditorForTab(tabs.find(t => t.id === tabId));
updateEditMenuState();
renderTabBar();
}
function openNew(kind, template) {
if (!editorModuleFor(kind)) { closeAllMenus(); return; } // 'dak' nog niet beschikbaar
const oldTab = tabs.find(t => t.id === activeTabId);
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
oldTab.stateJson = currentEditor.getStateJson();
}
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
currentEditor = null;
const tab = { id: tabIdCounter++, kind, template: template || null, name: defaultNameFor(kind), savedName: null, stateJson: null };
tabs.push(tab);
activeTabId = tab.id;
mountEditorForTab(tab);
updateEditMenuState();
renderTabBar();
closeAllMenus();
}
function isTabDirty(tab) {
const module = editorModuleFor(tab.kind);
if (!module || typeof module.getDefaultState !== 'function') return false;
const state = (tab.id === activeTabId && currentEditor && typeof currentEditor.getStateJson === 'function')
? currentEditor.getStateJson()
: tab.stateJson;
if (!state) return false;
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
}
function closeTab(tabId) {
const tab = tabs.find(t => t.id === tabId);
if (!tab) return;
if (tab.id !== activeTabId) {
if (!isTabDirty(tab)) {
tabs = tabs.filter(t => t.id !== tabId);
renderTabBar();
return;
}
switchToTab(tabId);
}
if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
const saved = currentEditor.exportDesign();
if (!saved) return;
}
tabs = tabs.filter(t => t.id !== tabId);
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
currentEditor = null;
activeTabId = null;
if (tabs.length > 0) {
switchToTab(tabs[tabs.length - 1].id);
} else {
setWorkspaceEmpty();
updateEditMenuState();
renderTabBar();
}
}
function wireTabBar() {
document.getElementById('tab-bar').addEventListener('click', (e) => {
const closeBtn = e.target.closest('.tab-close');
if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; }
const item = e.target.closest('.tab-item');
if (item) switchToTab(parseInt(item.dataset.tabId, 10));
});
}
function closeAllMenus() {
document.querySelectorAll('.menu-dropdown, .submenu').forEach(el => el.classList.remove('open'));
document.querySelectorAll('.menu-btn').forEach(el => el.classList.remove('open'));
}
function wireTopLevelMenu(btnId, dropdownId) {
const btn = document.getElementById(btnId);
const dropdown = document.getElementById(dropdownId);
btn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = !dropdown.classList.contains('open');
closeAllMenus();
if (willOpen) { dropdown.classList.add('open'); btn.classList.add('open'); }
});
}
function wireMenu() {
wireTopLevelMenu('menu-bestand-btn', 'menu-bestand-dropdown');
wireTopLevelMenu('menu-bewerken-btn', 'menu-bewerken-dropdown');
wireTopLevelMenu('menu-beeld-btn', 'menu-beeld-dropdown');
document.getElementById('menu-nieuw-item').addEventListener('click', (e) => e.stopPropagation());
document.querySelectorAll('.menu-nieuw-option').forEach(opt => {
opt.addEventListener('click', () => openNew(opt.dataset.kind, opt.dataset.template));
});
document.getElementById('menu-opslaan-als').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
closeAllMenus();
});
document.getElementById('menu-importeren').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.triggerImport === 'function') currentEditor.triggerImport();
closeAllMenus();
});
document.getElementById('menu-mirror-h').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
closeAllMenus();
});
document.getElementById('menu-fullscreen').addEventListener('click', () => {
toggleBrowserFullscreen();
closeAllMenus();
});
document.getElementById('menu-3d-fullscreen').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
closeAllMenus();
});
document.getElementById('menu-2d-fullscreen').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
closeAllMenus();
});
document.getElementById('menu-project-props').addEventListener('click', () => {
if (currentEditor && typeof currentEditor.openProjectProperties === 'function') currentEditor.openProjectProperties();
closeAllMenus();
});
document.addEventListener('click', closeAllMenus);
}
// Minimale markdown-weergave voor de licentietekst (koppen + alinea's).
function renderLicenseHtml(md) {
const esc = (t) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return md.trim().split(/\n\s*\n/).map(block => {
const b = block.trim();
if (b.startsWith('## ')) return `<h4>${esc(b.slice(3))}</h4>`;
if (b.startsWith('# ')) return `<h3>${esc(b.slice(2))}</h3>`;
return `<p>${esc(b.replace(/\s*\n\s*/g, ' '))}</p>`;
}).join('');
}
function wireAbout() {
const overlay = document.getElementById('about-overlay');
document.getElementById('about-license').innerHTML = renderLicenseHtml(window.TECTUM_LICENSE_MD || '');
document.getElementById('btn-about').addEventListener('click', (e) => {
e.stopPropagation();
overlay.classList.add('open');
});
document.getElementById('about-close').addEventListener('click', () => overlay.classList.remove('open'));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
}
function wireNewModal() {
const overlay = document.getElementById('new-overlay');
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => {
opt.addEventListener('click', () => {
openNew(opt.dataset.kind, opt.dataset.template);
overlay.classList.remove('open');
});
});
}
function openNewModal() {
closeAllMenus();
document.getElementById('new-overlay').classList.add('open');
}
function toggleBrowserFullscreen() {
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen();
}
function wireHotkeys() {
document.addEventListener('keydown', (e) => {
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
if (e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault();
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
return;
}
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
e.preventDefault();
openNewModal();
return;
}
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key.toLowerCase() === 'f') {
toggleBrowserFullscreen();
} else if (e.key.toLowerCase() === 'h') {
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
} else if (e.key.toLowerCase() === 't') {
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D();
} else if (e.key.toLowerCase() === 'r') {
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
}
});
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
wireMenu();
wireAbout();
wireNewModal();
wireHotkeys();
wireTabBar();
openNew('woning', 'L');
setTimeout(() => {
const splash = document.getElementById('splash-screen');
if (splash) splash.remove();
}, 4300);
});
+35
View File
@@ -0,0 +1,35 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/camera-memory.js — onthoudt de 3D-camerastand (positie + draaipunt)
// per weergave, zodat wisselen tussen onderdelen/tabbladen of een her-mount
// na een wijziging niet terugspringt naar de standaardcamera.
// Sleutels komen van de aanroeper (bijv. "tab3:wall:A:voor"). Alleen in het
// geheugen van deze sessie.
// Plain script (geen ES-module).
// ============================================================
window.CameraMemory = (() => {
const store = new Map();
function save(key, camera, controls) {
if (!key) return;
store.set(key, { position: camera.position.toArray(), target: controls.target.toArray() });
}
// true als er een opgeslagen stand was (en die is toegepast).
function restore(key, camera, controls) {
const m = key && store.get(key);
if (!m) return false;
camera.position.fromArray(m.position);
controls.target.fromArray(m.target);
controls.update();
return true;
}
return { save, restore };
})();
+41
View File
@@ -0,0 +1,41 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/canvas-labels.js — gedeelde stijl voor maat-labels in de 2D-canvassen
// (blauwe maatlijn + "X: 6000mm"-tekst, zoals het wand-vooraanzicht). Gebruikt
// door wand, vloer en unit, zodat alle labels er hetzelfde uitzien.
// Plain script (geen ES-module).
// ============================================================
const CANVAS_LABEL_COLOR = '#4da6ff';
// Tekent een maatlijn van (x1,y1) naar (x2,y2) plus tekst op (textX,textY).
// Een verticale lijn (x1 === x2) krijgt de tekst 90° gedraaid.
function drawCanvasDim(ctx, x1, y1, x2, y2, text, textX, textY) {
ctx.save();
ctx.strokeStyle = CANVAS_LABEL_COLOR;
ctx.fillStyle = CANVAS_LABEL_COLOR;
ctx.lineWidth = 1;
ctx.setLineDash([]);
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
if (Math.abs(x1 - x2) < 0.5 && Math.abs(y1 - y2) >= 0.5) {
ctx.translate(textX, textY);
ctx.rotate(-Math.PI / 2);
ctx.fillText(text, 0, 0);
} else {
ctx.fillText(text, textX, textY);
}
ctx.restore();
}
+346
View File
@@ -0,0 +1,346 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/geometry.js — gedeelde, pure math. Geen DOM, geen THREE-afhankelijkheid
// behalve shapeAreaWithHoles (die werkt met elk object dat .getPoints()/.holes
// heeft, dus ook met THREE.Shape). Plain <script> (geen module), zodat dit
// ook werkt bij rechtstreeks openen van index.html (file://) — daar breken
// ES-module-imports door browser-CORS.
//
// Wordt geladen VOOR wall.js en floor.js: de functies hieronder zijn dus
// gewoon globaal beschikbaar in beide, zonder duplicatie.
// ============================================================
// Regelwerk-verdeling (h.o.h.): bepaalt hoeveel balken/stijlen er in een
// gegeven overspanning passen, gegeven een minimale/maximale hart-op-hart-
// afstand. Gebruikt door wand-stijlen EN vloerbalken (zelfde algoritme).
function calculateSegmentPositions(spanWidth, minSp, maxSp) {
if (spanWidth <= 0) return [];
let numBays = Math.ceil(spanWidth / maxSp);
let spacing = spanWidth / numBays;
if (spacing < minSp && numBays > 1) {
numBays = Math.floor(spanWidth / minSp);
spacing = spanWidth / numBays;
}
const positions = [];
for (let i = 1; i < numBays; i++) {
positions.push(i * spacing);
}
return positions;
}
// Oppervlakte van een simpele polygon (shoelace-formule).
function polygonArea(points) {
let area = 0;
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
area += p1.x * p2.y - p2.x * p1.y;
}
return Math.abs(area) / 2;
}
// Sutherland-Hodgman clip van een (convexe) polygon tegen een horizontale
// halfvlak-grens. Gebruikt om bijv. de wand-omtrek (het schuine trapezium) te
// knippen tot losse horizontale banen voor het vochtdoek.
function clipPolygonYLE(points, yLimit) {
// Houdt het deel met y <= yLimit
const result = [];
for (let i = 0; i < points.length; i++) {
const cur = points[i];
const prev = points[(i - 1 + points.length) % points.length];
const curIn = cur.y <= yLimit + 1e-12;
const prevIn = prev.y <= yLimit + 1e-12;
if (curIn) {
if (!prevIn) {
const t = (yLimit - prev.y) / (cur.y - prev.y);
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
}
result.push(cur);
} else if (prevIn) {
const t = (yLimit - prev.y) / (cur.y - prev.y);
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
}
}
return result;
}
function clipPolygonYGE(points, yLimit) {
// Houdt het deel met y >= yLimit
const result = [];
for (let i = 0; i < points.length; i++) {
const cur = points[i];
const prev = points[(i - 1 + points.length) % points.length];
const curIn = cur.y >= yLimit - 1e-12;
const prevIn = prev.y >= yLimit - 1e-12;
if (curIn) {
if (!prevIn) {
const t = (yLimit - prev.y) / (cur.y - prev.y);
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
}
result.push(cur);
} else if (prevIn) {
const t = (yLimit - prev.y) / (cur.y - prev.y);
result.push({ x: prev.x + t * (cur.x - prev.x), y: yLimit });
}
}
return result;
}
// Oppervlakte van een THREE.Shape inclusief aftrek van zijn holes.
function shapeAreaWithHoles(shape) {
let area = polygonArea(shape.getPoints());
(shape.holes || []).forEach(hole => { area -= polygonArea(hole.getPoints()); });
return area;
}
// Simpele euclidische afstand — gebruikt door floor.js voor de
// cirkel-overlapcheck (2D, dus geen 1D-ankerlogica zoals bij wand-openingen).
function dist2D(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
// Zaagplan: verdeelt stukken (lengtes in mm) over handelslengtes met zo min
// mogelijk snijverlies. Stukken langer dan de langste handelslengte worden
// eerst opgeknipt (las/stuik in het werk). Daarna best-fit-decreasing: elk
// stuk gaat in de staaf waar het het krapst past; tot slot krijgt elke staaf
// de kortste handelslengte waar zijn stukken nog in passen.
// Resultaat: [{ stock, cuts: [lengtes], used, waste }]. Zaagsnede-breedte
// wordt verwaarloosd.
function planStockCutting(pieceLengths, stockLengths) {
const stocks = stockLengths.slice().sort((a, b) => a - b);
const maxStock = stocks[stocks.length - 1];
const parts = [];
pieceLengths.forEach(len => {
let rest = Math.round(len);
while (rest > maxStock) { parts.push(maxStock); rest -= maxStock; }
if (rest > 0) parts.push(rest);
});
parts.sort((a, b) => b - a);
const bars = [];
parts.forEach(p => {
let best = null, bestRest = Infinity;
bars.forEach(bar => {
const r = maxStock - bar.used - p;
if (r >= 0 && r < bestRest) { best = bar; bestRest = r; }
});
if (!best) { best = { used: 0, cuts: [] }; bars.push(best); }
best.cuts.push(p);
best.used += p;
});
bars.forEach(bar => {
bar.stock = stocks.find(s => s >= bar.used);
bar.waste = bar.stock - bar.used;
});
return bars;
}
// Samenvatting van een zaagplan: aantal staven per handelslengte + totalen.
function summarizeStockPlan(bars) {
const byStock = new Map();
bars.forEach(b => byStock.set(b.stock, (byStock.get(b.stock) || 0) + 1));
return {
perStock: Array.from(byStock.entries()).sort((a, b) => a[0] - b[0]).map(([stock, count]) => ({ stock, count })),
stockMM: bars.reduce((a, b) => a + b.stock, 0),
wasteMM: bars.reduce((a, b) => a + b.waste, 0)
};
}
// Verticale strook [x0, x1] van y = yBottom tot een (evt. schuine, lineaire)
// bovenrand topYAt(x), MIN een set rechthoekige uitsparingen
// ({ x0, x1, y0, y1 }). Uitsparingen die over de rand van de strook vallen
// worden een echte inkeping in de omtrek (i.p.v. een gat dat net binnen de
// rand wordt afgekapt — dat liet een haarfijn reepje materiaal met zichtbare
// randlijnen door de opening lopen). Een uitsparing over de volle breedte
// splitst de strook in losse delen.
// opts (optioneel):
// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan);
// topYAt mag dan min(yCap, daklijn) zijn. Uitsparingen die tot
// boven yCap reiken worden er netjes op afgekapt (inkeping in de
// bovenrand).
// extraXs — knikpunten van topYAt (waar de daklijn yCap kruist), zodat de
// bovenrand tussen twee hoekpunten steeds lineair is.
// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }], of null
// als een uitsparing tot in de schuine bovenrand reikt (niet ondersteund).
function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
const eps = 1e-9;
const yCap = (opts && opts.yCap !== undefined) ? opts.yCap : Infinity;
const extraXs = ((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps);
const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
const rs = rects
.map(r => ({ x0: Math.max(x0, r.x0), x1: Math.min(x1, r.x1), y0: Math.max(yBottom, r.y0), y1: Math.min(yCap, r.y1) }))
.filter(r => r.x1 - r.x0 > eps && r.y1 - r.y0 > eps);
const xs = uniq([x0, x1, ...extraXs, ...rs.flatMap(r => [r.x0, r.x1])]);
// Laagste bovenrand binnen [a, b] (topYAt is lineair tussen de knikpunten).
const minTopIn = (a, b) => Math.min(...xs.filter(x => x >= a - eps && x <= b + eps).map(topYAt));
const reachesTop = (r) => r.y1 >= yCap - eps && minTopIn(r.x0, r.x1) >= yCap - eps; // tot de vlakke bovenrand
if (rs.some(r => !reachesTop(r) && r.y1 >= minTopIn(r.x0, r.x1) - eps)) return null;
const ys = uniq([yBottom, ...rs.flatMap(r => reachesTop(r) ? [r.y0] : [r.y0, r.y1]).filter(y => y > yBottom + eps)]);
const nCols = xs.length - 1, nRows = ys.length; // bovenste rij loopt tot de daklijn
const vertex = (i, j) => ({ x: xs[i], y: j < nRows ? ys[j] : topYAt(xs[i]) });
const filled = (i, j) => {
if (i < 0 || i >= nCols || j < 0 || j >= nRows) return false;
const xm = (xs[i] + xs[i + 1]) / 2;
const ym = j < nRows - 1 ? (ys[j] + ys[j + 1]) / 2 : (ys[j] + topYAt(xm)) / 2;
return !rs.some(r => xm > r.x0 && xm < r.x1 && ym > r.y0 && ym < r.y1);
};
// Gerichte randen van gevulde cellen (CCW), alleen waar de buur leeg is.
const edges = new Map(); // "i,j" -> [[i2,j2], ...]
const addEdge = (a, b) => {
const key = a.join(',');
if (!edges.has(key)) edges.set(key, []);
edges.get(key).push(b);
};
for (let i = 0; i < nCols; i++) {
for (let j = 0; j < nRows; j++) {
if (!filled(i, j)) continue;
if (!filled(i, j - 1)) addEdge([i, j], [i + 1, j]);
if (!filled(i + 1, j)) addEdge([i + 1, j], [i + 1, j + 1]);
if (!filled(i, j + 1)) addEdge([i + 1, j + 1], [i, j + 1]);
if (!filled(i - 1, j)) addEdge([i, j + 1], [i, j]);
}
}
// Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
const loops = [];
edges.forEach((list, startKey) => {
while (list.length) {
const loop = [];
let cur = startKey.split(',').map(Number);
let next = list.pop();
loop.push(cur);
while (next.join(',') !== startKey) {
loop.push(next);
cur = next;
const outs = edges.get(cur.join(','));
next = outs.pop();
}
loops.push(loop);
}
});
const polys = loops.map(loop => {
// Eerst dubbele punten weg (bijv. een spits toelopend uiteinde waar de
// bovenrand de onderrand raakt), dan collineaire tussenpunten.
const pts = loop.map(([i, j]) => vertex(i, j))
.filter((p, k, arr) => { const q = arr[(k + 1) % arr.length]; return Math.hypot(p.x - q.x, p.y - q.y) > 1e-9; });
return pts.filter((p, k) => {
const a = pts[(k - 1 + pts.length) % pts.length], b = pts[(k + 1) % pts.length];
return Math.abs((p.x - a.x) * (b.y - a.y) - (p.y - a.y) * (b.x - a.x)) > 1e-12;
});
});
const signedArea = (pts) => pts.reduce((s, p, k) => {
const q = pts[(k + 1) % pts.length];
return s + (p.x * q.y - q.x * p.y);
}, 0) / 2;
const inside = (pt, poly) => {
let c = false;
for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) {
if ((poly[a].y > pt.y) !== (poly[b].y > pt.y) &&
pt.x < (poly[b].x - poly[a].x) * (pt.y - poly[a].y) / (poly[b].y - poly[a].y) + poly[a].x) c = !c;
}
return c;
};
const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] }));
polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => {
const owner = outers.find(o => inside(hole[0], o.outer)) || outers[0];
if (owner) owner.holes.push(hole);
});
return outers;
}
// Omtrek van de vereniging van as-evenwijdige rechthoeken ({x0, x1, z0, z1}),
// als gesloten lussen van punten {x, z}. Buitenlussen zijn tegen de klok in
// (positieve oppervlakte in het x/z-vlak), gaten met de klok mee. Collineaire
// tussenpunten zijn verwijderd. Gesorteerd op oppervlakte (grootste eerst).
// Gebruikt voor de dakrand (boeidelen/daktrim) over meerdere units heen.
function rectUnionLoops(rects) {
const eps = 1e-6;
const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
const xs = uniq(rects.flatMap(r => [r.x0, r.x1]));
const zs = uniq(rects.flatMap(r => [r.z0, r.z1]));
const nx = xs.length - 1, nz = zs.length - 1;
const filled = (i, j) => {
if (i < 0 || j < 0 || i >= nx || j >= nz) return false;
const xm = (xs[i] + xs[i + 1]) / 2, zm = (zs[j] + zs[j + 1]) / 2;
return rects.some(r => xm > r.x0 && xm < r.x1 && zm > r.z0 && zm < r.z1);
};
const edges = new Map();
const add = (a, b) => { const k = a.join(','); if (!edges.has(k)) edges.set(k, []); edges.get(k).push(b); };
for (let i = 0; i < nx; i++) {
for (let j = 0; j < nz; j++) {
if (!filled(i, j)) continue;
if (!filled(i, j - 1)) add([i, j], [i + 1, j]);
if (!filled(i + 1, j)) add([i + 1, j], [i + 1, j + 1]);
if (!filled(i, j + 1)) add([i + 1, j + 1], [i, j + 1]);
if (!filled(i - 1, j)) add([i, j + 1], [i, j]);
}
}
const loops = [];
edges.forEach((list, startKey) => {
while (list.length) {
const loop = [startKey.split(',').map(Number)];
let next = list.pop();
while (next.join(',') !== startKey) {
loop.push(next);
next = edges.get(next.join(',')).pop();
}
const pts = loop.map(([i, j]) => ({ x: xs[i], z: zs[j] }));
loops.push(pts.filter((p, k) => {
const a = pts[(k - 1 + pts.length) % pts.length], b = pts[(k + 1) % pts.length];
return Math.abs((p.x - a.x) * (b.z - a.z) - (p.z - a.z) * (b.x - a.x)) > eps;
}));
}
});
const area = (pts) => 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 loops.map(pts => ({ pts, area: area(pts) })).sort((a, b) => Math.abs(b.area) - Math.abs(a.area));
}
// Buitenwaartse normaal (as-evenwijdig, lengte 1) van ribbe a -> b van een
// lus tegen de klok in (positieve oppervlakte in x/z).
function rectilinearNormal(a, b) {
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
return { x: dz / len, z: -dx / len };
}
// Verschuift een rechthoekige (as-evenwijdige) lus tegen de klok in over
// afstand d naar buiten. Collineaire tussenpunten (bijv. een unit-hoek midden
// in een rechte gevel) blijven behouden en schuiven loodrecht mee.
function offsetRectilinearLoop(pts, d) {
const n = pts.length;
return pts.map((p, k) => {
const prev = pts[(k - 1 + n) % n], next = pts[(k + 1) % n];
const n1 = rectilinearNormal(prev, p), n2 = rectilinearNormal(p, next);
const same = Math.abs(n1.x - n2.x) < 1e-9 && Math.abs(n1.z - n2.z) < 1e-9;
return same ? { x: p.x + n1.x * d, z: p.z + n1.z * d } : { x: p.x + (n1.x + n2.x) * d, z: p.z + (n1.z + n2.z) * d };
});
}
// Gevels: groepeert lijnstukken in de plattegrond tot doorlopende gevels
// (zelfde as + normaal + gevelvlak, aansluitend). segs: [{ axis ('x'|'z'),
// normal {x, z}, line (positie op de andere as), s0, s1 (s0 < s1) }, ...].
// Geeft [{ axis, normal, line, s0, s1, segs (op s0 gesorteerd) }].
// Gebruikt door de woning (latwerk/doek-patroon) en de buitenverlichting.
function groupFacadeRuns(segs, opts) {
const lineTol = (opts && opts.lineTol) || 1, gapTol = (opts && opts.gapTol) || 5;
const runs = [];
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 &&
Math.abs(r.line - sg.line) < lineTol && Math.abs(r.s1 - sg.s0) < gapTol);
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] }));
});
return runs;
}
+79
View File
@@ -0,0 +1,79 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/license.js — de licentietekst voor "Over deze software". Kopie van
// LICENTIE.md (dat bestand is leidend; bij een wijziging beide aanpassen).
// Als JS-string opgenomen omdat fetch() van lokale bestanden via file://
// niet betrouwbaar werkt.
// ============================================================
window.TECTUM_LICENSE_MD = `# Licentie en auteursrecht
Copyright © 2026 Machiel Heinen. Alle rechten voorbehouden.
## 1. Rechthebbende
De software Tectum, inclusief broncode, objectcode, ontwerp, rekenlogica,
documentatie, afbeeldingen en alle bijbehorende bestanden (hierna: "de Software"), is
auteursrechtelijk beschermd. Alle auteursrechten en overige intellectuele-eigendomsrechten
op de Software berusten uitsluitend bij Machiel Heinen, maker van de Software (hierna:
"de Rechthebbende").
De Software is ontwikkeld in opdracht van Van Emous Mantelzorgwoningen, gevestigd te
Putten (KvK 91908671). Deze opdracht houdt geen overdracht van rechten in; gebruik door
Van Emous Mantelzorgwoningen vindt plaats op basis van toestemming van de Rechthebbende.
## 2. Gebruik uitsluitend met toestemming
Zonder voorafgaande, uitdrukkelijke en schriftelijke toestemming van de Rechthebbende
is het niet toegestaan de Software geheel of gedeeltelijk te verveelvoudigen, openbaar te
maken, te wijzigen, te bewerken, te vertalen, te decompileren, te verspreiden, te verhuren,
in sublicentie te geven of op enige andere wijze te gebruiken, behoudens voor zover de wet
dit dwingend toestaat.
## 3. Aard van de toestemming
Toestemming wordt uitsluitend schriftelijk verleend door de Rechthebbende. Tenzij
schriftelijk anders overeengekomen, is toestemming niet-exclusief, niet-overdraagbaar,
niet sublicentieerbaar, beperkt tot het daarin omschreven doel en de daarin omschreven
duur, en kan zij bij overtreding van de voorwaarden met onmiddellijke ingang worden
ingetrokken. Toestemming houdt nimmer een overdracht van rechten in.
## 4. Aanpassingen en afgeleide werken
Alle rechten op aanpassingen, uitbreidingen en afgeleide werken van de Software, ongeacht
door wie deze zijn gemaakt of in wiens opdracht, berusten bij de Rechthebbende.
## 5. Geen garantie
De Software wordt ter beschikking gesteld "zoals deze is" (as is), zonder enige garantie,
uitdrukkelijk of stilzwijgend, waaronder begrepen garanties omtrent werking,
foutloosheid, geschiktheid voor een bepaald doel, nauwkeurigheid van berekeningen,
maatvoering of prijzen, en het niet inbreuk maken op rechten van derden. Uitkomsten van
de Software zijn indicatief en vervangen geen constructieve berekening of controle door
een deskundige.
## 6. Aansprakelijkheid
Voor zover wettelijk toegestaan, is de Rechthebbende niet aansprakelijk voor enige
directe of indirecte schade voortvloeiend uit of verband houdend met het gebruik van, of
het niet kunnen gebruiken van, de Software, behoudens opzet of bewuste roekeloosheid van
de Rechthebbende.
## 7. Vermeldingen
Het is niet toegestaan copyright-, licentie- of eigendomsvermeldingen in of op de
Software te verwijderen, te wijzigen of onleesbaar te maken.
## 8. Toepasselijk recht en bevoegde rechter
Op deze licentie en op alle toestemmingen die op grond daarvan worden verleend is
Nederlands recht van toepassing. Geschillen worden uitsluitend voorgelegd aan de
bevoegde rechter van de Rechtbank Gelderland.
Contact en aanvragen voor toestemming: machiel@start-it.nl
`;
File diff suppressed because one or more lines are too long
+134
View File
@@ -0,0 +1,134 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/pdf-sheet.js — gedeelde bouwstenen voor de A3-tekenbladen (jsPDF):
// kleuren, nieuw document, maatlijn en titelblok. Gebruikt door WallPdf,
// FloorPdf en de unit-PDF, zodat elk blad er identiek uitziet.
// Plain script (geen ES-module), geladen na jspdf.umd.min.js.
// ============================================================
window.PdfSheet = (() => {
// Kleurlogica: rood = houtwerk-omtrekken, blauw = hoofdmaatvoering,
// zwart = opening-kruis, posnum-labels en de onderste maatketens.
const COLORS = { RED: [190, 25, 25], BLUE: [25, 50, 180], BLACK: [0, 0, 0], GREY: [130, 130, 130] };
// Veldnamen van de "Project eigenschappen"-modal van de wand-/vloer-editor.
const PROJECT_DOM_IDS = {
naam: 'inp-proj-naam', plaats: 'inp-proj-plaats', straat: 'inp-proj-straat', nummer: 'inp-proj-nummer',
tekenaar: 'inp-proj-tekenaar', titel: 'inp-proj-titel', element: 'inp-proj-element', opmerking: 'inp-proj-opmerking'
};
function createDoc() {
const { jsPDF } = window.jspdf;
return new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a3' });
}
function readProjectFromDom() {
const project = {};
Object.entries(PROJECT_DOM_IDS).forEach(([key, id]) => {
const el = document.getElementById(id);
project[key] = (el && el.value) || '';
});
return project;
}
// Maatlijn met kleine loodrechte eindstreepjes ("|") op beide
// uiteinden, zodat duidelijk is waar de maat precies begint/eindigt
// i.p.v. een lijn die zonder markering in de lucht lijkt te eindigen.
const DIM_TICK = 1.5;
function dimLine(doc, x1, y1, x2, y2) {
doc.line(x1, y1, x2, y2);
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
const tx = (-dy / len) * (DIM_TICK / 2), ty = (dx / len) * (DIM_TICK / 2);
doc.line(x1 - tx, y1 - ty, x1 + tx, y1 + ty);
doc.line(x2 - tx, y2 - ty, x2 + tx, y2 + ty);
}
// Titelblok (rechtsonder), in twee kolommen zodat het weinig hoogte
// inneemt, met het logo (core/logo-data.js) rechtsonder.
// project: { naam, plaats, straat, nummer, tekenaar, opmerking };
// opts: { titel, element, blad } (blad bijv. "2 / 7").
function titleBlock(doc, project, opts) {
const { BLACK, GREY } = COLORS;
const p = project || {};
const o = opts || {};
const tbW = 118, tbH = 36;
const tbX0 = 290, tbY0 = 289 - tbH;
doc.setDrawColor(...BLACK);
doc.setLineWidth(0.25);
doc.rect(tbX0, tbY0, tbW, tbH, 'S');
const today = new Date().toLocaleDateString('nl-NL');
const columns = [
[['Projectnaam:', p.naam], ['Plaats:', p.plaats], ['Straat:', p.straat], ['Projectnummer:', p.nummer], ['Tekenaar:', p.tekenaar]],
[['Titel:', o.titel], ['Element nummer:', o.element], ['Datum getekend:', today], ['Schaal:', '1:1'], ['Opmerking:', p.opmerking]]
];
const colW = tbW / 2, labelW = 19;
doc.setFontSize(6.2);
columns.forEach((rows, c) => {
const x = tbX0 + 3 + c * colW;
let rowY = tbY0 + 5;
rows.forEach(([label, value]) => {
doc.setTextColor(...GREY);
doc.text(label, x, rowY);
doc.setTextColor(...BLACK);
const text = String(value || '-');
doc.text(doc.splitTextToSize(text, colW - labelW - 5)[0], x + labelW, rowY);
rowY += 4.2;
});
});
// Scheidingslijn + onderstrook: blad links, logo rechts.
const stripY = tbY0 + 25;
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
doc.line(tbX0, stripY, tbX0 + tbW, stripY);
if (o.blad) {
doc.setTextColor(...GREY);
doc.setFontSize(6);
doc.text('Blad ' + o.blad, tbX0 + 3, tbY0 + tbH - 3.5);
}
const logoH = 7.5, logoW = logoH * (window.TECTUM_LOGO ? window.TECTUM_LOGO.aspect : 4.55);
if (window.TECTUM_LOGO) {
doc.addImage(window.TECTUM_LOGO.png, 'PNG', tbX0 + tbW - 3 - logoW, tbY0 + tbH - 2 - logoH, logoW, logoH, 'tectum-logo', 'FAST'); // alias: 1x in het bestand, gecomprimeerd
} else {
doc.setTextColor(...GREY);
doc.setFontSize(6);
doc.text('Van Emous Mantelzorgwoningen', tbX0 + tbW - 3, tbY0 + tbH - 3.5, { align: 'right' });
}
}
// Eenvoudige tabel: headers + rijen (arrays van strings) vanaf (x, y) met
// vaste kolom-x'en. Geeft de y-positie onder de tabel terug.
function table(doc, x, y, colX, headers, rows, opts) {
const { BLACK, GREY } = COLORS;
const o = opts || {};
const rowH = o.rowH || 4.3;
doc.setTextColor(...BLACK);
if (o.title) {
doc.setFontSize(o.titleSize || 9);
doc.text(o.title, x, y);
y += 6;
}
doc.setFontSize(o.fontSize || 6.5);
headers.forEach((h, i) => doc.text(h, x + colX[i], y));
y += 1.5;
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
doc.line(x, y, x + colX[colX.length - 1] + (o.lastColW || 14), y);
y += 4;
rows.forEach(r => {
if (o.maxY && y > o.maxY) return;
r.forEach((v, i) => doc.text(String(v), x + colX[i], y, i > 0 && o.alignRight ? { align: 'right' } : undefined));
y += rowH;
});
return y;
}
return { COLORS, createDoc, readProjectFromDom, dimLine, titleBlock, table };
})();
+163
View File
@@ -0,0 +1,163 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// js/core/pdf-writer.js — minimale, dependency-vrije PDF-generator.
// Schrijft geldige PDF 1.4 bytes rechtstreeks (geen jsPDF: geen
// netwerktoegang om dat te vendoren). Ondersteunt lijnen, rechthoeken,
// tekst (standaard Helvetica, geen embedding nodig) en kleuren, over
// meerdere pagina's. Coordinaten in PDF-punten (1pt = 1/72 inch),
// oorsprong linksonder — precies zoals PDF dat zelf verwacht.
//
// Puur string/byte-opbouw, geen browser-specifieke API's nodig om het
// DOCUMENT te bouwen (alleen het downloaden ervan is browser-specifiek,
// zie savePdfBlob hieronder).
// ============================================================
function createPdfDocument() {
const objects = [];
let nextObjId = 1;
const pageDefs = [];
let fontId, catalogId, pagesId;
function newObj(content) {
const id = nextObjId++;
objects.push({ id, content });
return id;
}
function fmt(n) {
return Math.round(n * 100) / 100;
}
function escapePdfText(str) {
return str.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
function toLatin1(str) {
let out = '';
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
out += code <= 255 ? str[i] : '?';
}
return out;
}
// Ruwe schatting van Helvetica-tekstbreedte (geen AFM-metrics embedded).
function estimateTextWidth(str, size) {
return str.length * size * 0.55;
}
fontId = newObj('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>');
function addPage(widthPt, heightPt) {
const ops = [];
let strokeKey = null, fillKey = null, widthKey = null;
function setStroke(r, g, b) {
const key = r + ',' + g + ',' + b;
if (key === strokeKey) return;
strokeKey = key;
ops.push(`${fmt(r)} ${fmt(g)} ${fmt(b)} RG`);
}
function setFill(r, g, b) {
const key = r + ',' + g + ',' + b;
if (key === fillKey) return;
fillKey = key;
ops.push(`${fmt(r)} ${fmt(g)} ${fmt(b)} rg`);
}
function setLineWidth(w) {
if (w === widthKey) return;
widthKey = w;
ops.push(`${fmt(w)} w`);
}
function line(x1, y1, x2, y2) {
ops.push(`${fmt(x1)} ${fmt(y1)} m ${fmt(x2)} ${fmt(y2)} l S`);
}
function rect(x, y, w, h, mode) {
ops.push(`${fmt(x)} ${fmt(y)} ${fmt(w)} ${fmt(h)} re ${mode || 'S'}`);
}
function polyline(points, close) {
if (!points.length) return;
ops.push(`${fmt(points[0][0])} ${fmt(points[0][1])} m`);
for (let i = 1; i < points.length; i++) ops.push(`${fmt(points[i][0])} ${fmt(points[i][1])} l`);
ops.push(close ? 's' : 'S');
}
function text(x, y, size, str, align) {
const s = escapePdfText(toLatin1(str));
let tx = x;
if (align === 'center') tx = x - estimateTextWidth(str, size) / 2;
else if (align === 'right') tx = x - estimateTextWidth(str, size);
ops.push(`BT /F1 ${fmt(size)} Tf ${fmt(tx)} ${fmt(y)} Td (${s}) Tj ET`);
}
function textRotated(x, y, size, str, degrees) {
const rad = (degrees * Math.PI) / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
const s = escapePdfText(toLatin1(str));
ops.push(`BT /F1 ${fmt(size)} Tf ${fmt(cos)} ${fmt(sin)} ${fmt(-sin)} ${fmt(cos)} ${fmt(x)} ${fmt(y)} Tm (${s}) Tj ET`);
}
const contentId = newObj('');
const pageId = newObj('');
pageDefs.push({ pageId, contentId, ops, widthPt, heightPt });
return { setStroke, setFill, setLineWidth, line, rect, polyline, text, textRotated, estimateTextWidth };
}
function build() {
pagesId = newObj('');
catalogId = newObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
pageDefs.forEach(p => {
const streamBody = p.ops.join('\n');
objects.find(o => o.id === p.contentId).content =
`<< /Length ${streamBody.length} >>\nstream\n${streamBody}\nendstream`;
objects.find(o => o.id === p.pageId).content =
`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${fmt(p.widthPt)} ${fmt(p.heightPt)}] ` +
`/Resources << /Font << /F1 ${fontId} 0 R >> >> /Contents ${p.contentId} 0 R >>`;
});
objects.find(o => o.id === pagesId).content =
`<< /Type /Pages /Kids [${pageDefs.map(p => p.pageId + ' 0 R').join(' ')}] /Count ${pageDefs.length} >>`;
objects.sort((a, b) => a.id - b.id);
let out = '%PDF-1.4\n%\xE2\xE3\xCF\xD3\n';
const offsets = [0];
objects.forEach(obj => {
offsets[obj.id] = out.length;
out += `${obj.id} 0 obj\n${obj.content}\nendobj\n`;
});
const xrefOffset = out.length;
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) {
out += String(offsets[i]).padStart(10, '0') + ' 00000 n \n';
}
out += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
return out;
}
return { addPage, build };
}
// Zet een Latin1-string (elk char = 1 byte, zie hierboven) om naar een
// Blob en start een download. Browser-specifiek, puur I/O — de opbouw
// hierboven is dat niet.
function savePdfBlob(latin1String, filename) {
const bytes = new Uint8Array(latin1String.length);
for (let i = 0; i < latin1String.length; i++) bytes[i] = latin1String.charCodeAt(i) & 0xff;
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+53
View File
@@ -0,0 +1,53 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// core/view-filter.js — gedeelde "Weergave"-dropdown met vinkjes voor de
// 3D-weergaven (woning, wand, vloer/dak). Vervangt de losse Aan/Uit-knoppen.
// Plain script (geen ES-module).
//
// const filter = createViewFilter(toolbarEl, [
// { key: 'beplating', label: 'Beplating', checked: true }, ...
// ], (state) => { ... }); // state = { beplating: true, ... }
// ============================================================
function createViewFilter(toolbar, items, onChange) {
const state = {};
items.forEach(it => { state[it.key] = it.checked !== false; });
const wrap = document.createElement('div');
wrap.className = 'view-filter';
wrap.innerHTML = `
<button type="button" class="btn-3d-toggle vf-btn">Weergave &#9662;</button>
<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('')}
</div>`;
toolbar.insertBefore(wrap, toolbar.firstChild);
const menu = wrap.querySelector('.vf-menu');
const btn = wrap.querySelector('.vf-btn');
btn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('open');
btn.classList.toggle('active', menu.classList.contains('open'));
});
menu.addEventListener('click', (e) => e.stopPropagation());
menu.addEventListener('change', (e) => {
const key = e.target.dataset.key;
if (!key) return;
state[key] = e.target.checked;
onChange(Object.assign({}, state));
});
// Klik buiten het menu sluit het (listener verdwijnt met het element).
const closeOnOutside = () => {
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
menu.classList.remove('open');
btn.classList.remove('active');
};
document.addEventListener('click', closeOnOutside);
return { state: () => Object.assign({}, state) };
}
+341
View File
@@ -0,0 +1,341 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// floor/floor-model.js — ENIGE bron van waarheid voor de vloer-logica:
// balkverdeling, doorvoer-resolutie (tussen de balken), chassis-wielen,
// specificaties en de 3D-opbouw. Geen DOM. Wordt gebruikt door:
// - floor/floor.js (de losse vloer-editor)
// - unit/unit.js (unit = vloer + 4 wanden)
// Wijzigingen aan de vloer-logica horen dus HIER.
//
// Werkt met een "vloer-state" (zelfde formaat als getStateJson/JSON-export):
// { floor: {width, depth, joistWidth, joistHeight, minSpacing, maxSpacing, underlayThick},
// chassis: {enabled, wheelDiameter, trackWidth, wheelbase},
// openings: [{id, diameter, anchorX, offsetX, offsetY}] }
//
// Plain script (geen ES-module), moet na core/geometry.js geladen worden.
// ============================================================
window.FloorModel = (() => {
const DENSITY_WOOD_KGM3 = 450; // zelfde referentiewaarde als WallModel
const DENSITY_OSB_KGM3 = 650; // OSB-plaat, zelfde referentiewaarde als voorheen "onderlaag"
const BAY_MARGIN_MM = 15; // vrije marge tussen doorvoer-rand en balkflank
const MIN_DIAMETER_MM = 20; // kleinste zinnige doorvoer; past dit niet, dan vervalt de doorvoer
// ----------------------------------------------------------
// Balkvakken: de ruimte tussen twee opeenvolgende balken (in wereld-
// coordinaten, zelfde stelsel als calcData.joists). Een doorvoer mag
// nooit een balk kruisen, dus wordt altijd binnen zo'n vak geklemd.
// ----------------------------------------------------------
function computeBays(joists, joistW) {
const bays = [];
for (let i = 0; i < joists.length - 1; i++) {
bays.push({ left: joists[i] + joistW / 2, right: joists[i + 1] - joistW / 2 });
}
return bays;
}
function findNearestBay(bays, x) {
for (const bay of bays) {
if (x >= bay.left && x <= bay.right) return bay;
}
let nearest = bays[0], nearestDist = Infinity;
bays.forEach(b => {
const center = (b.left + b.right) / 2;
const d = Math.abs(x - center);
if (d < nearestDist) { nearestDist = d; nearest = b; }
});
return nearest;
}
// Resolutie: X via hetzelfde anker+offset-patroon als de wand-openingen,
// maar het resultaat wordt altijd teruggeklemd tot het balkvak waar het in
// valt (nooit een balk kruisend). Past de gevraagde diameter niet — zelfs
// niet na verkleinen tot het minimum — dan vervalt de doorvoer (blijft wel
// gewoon in de invoerlijst staan, verdwijnt alleen uit de tekening/telling,
// zodat een latere wijziging aan de balkafstand 'm weer terug kan brengen).
//
// Y is een simpele afstand vanaf de voorrand — balken lopen over de volle
// diepte door, dus Y heeft geen balk-interactie zoals X die wel heeft.
//
// Overlap tussen twee doorvoeren IN HETZELFDE vak is een apart, 2D-
// probleem (afstand tussen middelpunten) — dat wordt gedetecteerd en
// gemeld, niet automatisch opgelost. Bewuste, kleinere scope, geen bug.
function resolveFloorOpenings(rawOpenings, width, depth, joists, joistW) {
const bays = computeBays(joists, joistW);
const resolved = rawOpenings.map(raw => {
let requestedX;
if (raw.anchorX === 'right') requestedX = width - raw.offsetX;
else if (raw.anchorX === 'middle') requestedX = width / 2 + raw.offsetX;
else requestedX = raw.offsetX;
const worldX = requestedX - width / 2;
const bay = bays.length ? findNearestBay(bays, worldX) : null;
const bayClearWidth = bay ? (bay.right - bay.left) - BAY_MARGIN_MM * 2 : 0;
let removed = !bay || bayClearWidth < MIN_DIAMETER_MM;
let diameter = raw.diameter, r = 0, x = worldX;
let shrunk = false;
if (!removed) {
r = diameter / 2;
if (diameter > bayClearWidth) { diameter = bayClearWidth; shrunk = true; }
r = diameter / 2;
const xMin = bay.left + BAY_MARGIN_MM + r;
const xMax = bay.right - BAY_MARGIN_MM - r;
x = Math.max(xMin, Math.min(worldX, xMax));
}
const requestedY = raw.offsetY;
const y = Math.max(10, Math.min(requestedY, depth - 10));
return {
id: raw.id, requestedDiameter: raw.diameter, diameter, r, x, y,
removed, shrunk, repositioned: !removed && x !== worldX, overlapping: false
};
});
const active = resolved.filter(o => !o.removed);
for (let i = 0; i < active.length; i++) {
for (let j = i + 1; j < active.length; j++) {
const a = active[i], b = active[j];
if (dist2D(a, b) < a.r + b.r + 30) { a.overlapping = true; b.overlapping = true; }
}
}
resolved.forEach(o => { o.overflowing = o.removed || o.shrunk || o.overlapping; });
return resolved;
}
// ----------------------------------------------------------
// Balkverdeling: zelfde algoritme (calculateSegmentPositions) als de wand.
// params = state.floor (of de ruwe invoerwaarden uit de editor).
// ----------------------------------------------------------
function computeFrame(params) {
const width = params.width || 2000;
const depth = params.depth || 2000;
const joistW = params.joistWidth || 70;
const joistH = params.joistHeight || 220;
const minSp = params.minSpacing || 300;
const maxSp = params.maxSpacing || 600;
const underlayThick = params.underlayThick || 22;
const startX = -width / 2;
const leftJoistX = startX + joistW / 2;
const rightJoistX = startX + width - joistW / 2;
let joists = [leftJoistX, rightJoistX];
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);
return { joists, width, depth, joistW, joistH, underlayThick };
}
// Bovenkant vloer (mm) = waar de wanden van een unit op staan.
function topHeight(calcData) {
return calcData.joistH + calcData.underlayThick;
}
// ----------------------------------------------------------
// Specificaties: balkhout, OSB-onderlaag, isolatie-leegte (zelfde soort
// "envelope minus houtwerk minus openingen"-logica als wall.js) en het
// gewichtstotaal. Isolatie krijgt — net als bij de wand — bewust geen
// eigen dichtheid/gewicht toegekend (materiaalkeuze is aan de gebruiker),
// alleen de resterende inhoud wordt gerapporteerd.
// ----------------------------------------------------------
function computeSpecs(calcData, resolvedOpenings) {
const toM = 0.001;
const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
const widthM = width * toM, depthM = depth * toM;
const joistWM = joistW * toM, joistHM = joistH * toM, underlayM = underlayThick * toM;
const joistWoodM3 = joists.length * joistWM * joistHM * depthM;
const joistWoodKg = joistWoodM3 * DENSITY_WOOD_KGM3;
const grossAreaM2 = widthM * depthM;
let holesAreaM2 = 0, holesVoidM3 = 0;
resolvedOpenings.filter(o => !o.removed).forEach(o => {
const rM = o.r * toM;
holesAreaM2 += Math.PI * rM * rM;
holesVoidM3 += Math.PI * rM * rM * joistHM;
});
const osbAreaM2 = Math.max(0, grossAreaM2 - holesAreaM2);
const osbVolM3 = osbAreaM2 * underlayM;
const osbKg = osbVolM3 * DENSITY_OSB_KGM3;
const envelopeM3 = widthM * depthM * joistHM;
let voidM3 = envelopeM3 - joistWoodM3 - holesVoidM3;
if (voidM3 < 0) voidM3 = 0;
const totalKg = joistWoodKg + osbKg;
return { joistWoodM3, joistWoodKg, osbAreaM2, osbVolM3, osbKg, voidM3, totalKg };
}
// ----------------------------------------------------------
// Chassis-laag: 4 wielen in een "dobbelsteen 4"-indeling, exact
// gecentreerd op de vloer (X: midden van de breedte, Y: midden van de
// diepte). Losse laag t.o.v. het regelwerk — wordt niet tegen balken
// geklemd zoals de doorvoeren, alleen gewaarschuwd als een wiel buiten de
// vloerrand zou uitsteken.
// ----------------------------------------------------------
function computeChassisWheels(chassis, width, depth) {
if (!chassis || !chassis.enabled) return { wheels: [], outOfBounds: false };
const r = chassis.wheelDiameter / 2;
const dx = chassis.trackWidth / 2, dy = chassis.wheelbase / 2;
const cy = depth / 2;
const wheels = [
{ x: -dx, y: cy - dy }, { x: dx, y: cy - dy },
{ x: -dx, y: cy + dy }, { x: dx, y: cy + dy }
].map(p => ({ ...p, r }));
const outOfBounds = wheels.some(w =>
(w.x - w.r) < -width / 2 || (w.x + w.r) > width / 2 || (w.y - w.r) < 0 || (w.y + w.r) > depth);
return { wheels, outOfBounds };
}
// Alles in één keer vanuit een vloer-state (handig voor de unit).
function computeFromState(state) {
const calcData = computeFrame(state.floor);
const resolvedOpenings = resolveFloorOpenings(state.openings || [], calcData.width, calcData.depth, calcData.joists, calcData.joistW);
const chassisData = computeChassisWheels(state.chassis, calcData.width, calcData.depth);
return { calcData, resolvedOpenings, chassisData };
}
function createMaterials() {
return {
woodMaterial: new THREE.MeshStandardMaterial({ color: 0xc29b62, roughness: 0.6 }),
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 })
};
}
// ----------------------------------------------------------
// 3D-opbouw (meters): X = breedte, Z = diepte (y=0 "voor" ligt op
// Z = -diepte/2), Y = dikte (balkhoogte + OSB). Balken en isolatie zijn
// simpele dozen; de OSB-onderlaag is een geëxtrudeerd Shape met ronde
// uitsparingen voor de doorvoeren.
// opts: { materials, osbVisible, isolatieVisible }
// ----------------------------------------------------------
function build3D(calcData, resolvedOpenings, chassisData, opts) {
const { woodMaterial, osbMaterial, isolatieMaterial, wheelMaterial } = opts.materials;
const osbVisible = opts.osbVisible !== false;
const isolatieVisible = opts.isolatieVisible !== false;
const floorGroup3D = new THREE.Group();
const toM = 0.001;
const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
const widthM = width * toM, depthM = depth * toM;
const joistWM = joistW * toM, joistHM = joistH * toM, underlayM = underlayThick * toM;
// A. VLOERBALKEN — dozen, lopen over de volle diepte door.
joists.forEach(xMM => {
const geo = new THREE.BoxGeometry(joistWM, joistHM, depthM);
const mesh = new THREE.Mesh(geo, woodMaterial);
mesh.position.set(xMM * toM, joistHM / 2, 0);
mesh.castShadow = true;
floorGroup3D.add(mesh);
});
// B. ISOLATIE — vult elk balkvak (tussen twee opeenvolgende balken) over
// 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
// ronde uitsparingen voor de doorvoeren. Shape wordt opgebouwd in
// (worldX, worldZ)-vlak en vervolgens 90° gekanteld zodat de dikte
// (extrusierichting) verticaal (wereld-Y) komt te liggen, boven op de
// balken.
const osbShape = new THREE.Shape();
osbShape.moveTo(-widthM / 2, -depthM / 2);
osbShape.lineTo(widthM / 2, -depthM / 2);
osbShape.lineTo(widthM / 2, depthM / 2);
osbShape.lineTo(-widthM / 2, depthM / 2);
osbShape.closePath();
resolvedOpenings.filter(o => !o.removed).forEach(o => {
const cx = o.x * toM;
const cz = o.y * toM - depthM / 2;
const rM = o.r * toM;
if (rM <= 0) return;
const hole = new THREE.Path();
hole.absarc(cx, cz, rM, 0, Math.PI * 2, false);
osbShape.holes.push(hole);
});
const osbGeo = new THREE.ExtrudeGeometry(osbShape, { steps: 1, depth: underlayM, bevelEnabled: false });
const osbMesh = new THREE.Mesh(osbGeo, osbMaterial);
osbMesh.rotation.x = Math.PI / 2;
osbMesh.position.set(0, joistHM + underlayM, 0);
osbMesh.visible = osbVisible;
osbMesh.castShadow = true;
floorGroup3D.add(osbMesh);
const osbEdges = new THREE.LineSegments(
new THREE.EdgesGeometry(osbGeo),
new THREE.LineBasicMaterial({ color: 0x6b5636 })
);
osbEdges.rotation.x = Math.PI / 2;
osbEdges.position.copy(osbMesh.position);
osbEdges.visible = osbVisible;
floorGroup3D.add(osbEdges);
// D. CHASSIS-WIELEN — schematische cilinders onder de vloer, alleen als
// de chassis-laag aan staat (zelfde brondata als de 2D-tekening).
(chassisData ? chassisData.wheels : []).forEach(wheel => {
const rM = wheel.r * toM;
const thicknessM = 0.08;
const geo = new THREE.CylinderGeometry(rM, rM, thicknessM, 24);
const mesh = new THREE.Mesh(geo, wheelMaterial);
mesh.rotation.z = Math.PI / 2;
mesh.position.set(wheel.x * toM, -rM, wheel.y * toM - depthM / 2);
mesh.castShadow = true;
floorGroup3D.add(mesh);
});
return floorGroup3D;
}
// Structuurcontrole van een vloer-state (bijv. bij JSON-import).
function validateState(design) {
if (!design.floor || typeof design.floor !== 'object') throw new Error('veld "floor" ontbreekt');
const requiredFloorFields = ['width', 'depth', 'joistWidth', 'joistHeight', 'minSpacing', 'maxSpacing', 'underlayThick'];
requiredFloorFields.forEach(f => {
if (typeof design.floor[f] !== 'number' || !isFinite(design.floor[f])) {
throw new Error(`floor.${f} ontbreekt of is geen geldig getal`);
}
});
if (design.chassis !== undefined && (typeof design.chassis !== 'object' || design.chassis === null)) {
throw new Error('veld "chassis" is aanwezig maar geen object');
}
if (!Array.isArray(design.openings)) throw new Error('veld "openings" ontbreekt of is geen lijst');
design.openings.forEach((o, i) => {
if (typeof o.diameter !== 'number' || typeof o.offsetX !== 'number' || typeof o.offsetY !== 'number') {
throw new Error(`doorvoer #${i + 1}: diameter/offsetX/offsetY moeten getallen zijn`);
}
if (!['left', 'middle', 'right'].includes(o.anchorX)) {
throw new Error(`doorvoer #${i + 1}: anchorX moet 'left', 'middle' of 'right' zijn (was: ${o.anchorX})`);
}
});
}
return {
validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
computeSpecs, computeFromState, createMaterials, build3D
};
})();
+176
View File
@@ -0,0 +1,176 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// floor/floor-pdf.js — ENIGE bron van het vloer-tekenblad (A3 liggend):
// frame- en plaatmateriaal, bovenaanzicht met balken/doorvoeren en
// maatketens, doorsnede en titelblok. Gebruikt door de losse vloer-editor en
// door de unit-PDF.
// Plain script, geladen na floor-model.js en core/pdf-sheet.js.
// ============================================================
window.FloorPdf = (() => {
// ------------------------------------------------------------
// Zelfde opzet als WallPdf (frame-materiaallijst, plaatmateriaallijst,
// titelblok, hoofdaanzicht met maatketens). Layout/kolommen zijn
// afgestemd op de bestaande "Vloer^V-x"-tekeningen (zie examples/) —
// die voorbeelden splitsen een grote vloer in meerdere modules; hier
// wordt bewust altijd maar één, doorlopende module getekend.
// ------------------------------------------------------------
// Tekent één volledig vloerblad op de HUIDIGE pagina van `doc`.
// opts: { project, titel, element, blad, roof }
function drawSheet(doc, calcData, resolvedOpenings, opts) {
const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
const PAGE_H = doc.internal.pageSize.getHeight();
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal');
// --- Frame materiaal-tabel (linksboven) — één groep: alle vloerbalken
// hebben in dit model dezelfde doorsnede/lengte. ---
const tblX = [8, 26, 42, 58, 76, 92];
let ty = 14;
doc.setTextColor(...BLACK);
doc.setFontSize(9);
doc.text('Frame materiaal', tblX[0], ty);
ty += 6;
doc.setFontSize(6.5);
['Posnum', 'Dikte', 'Hoogte', 'Lengte', 'Aantal'].forEach((h, i) => doc.text(h, tblX[i], ty));
ty += 1.5;
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
doc.line(tblX[0], ty, tblX[4] + 12, ty);
ty += 4;
[String(1), String(joistW), String(joistH), String(Math.round(depth)), String(joists.length)]
.forEach((v, i) => doc.text(v, tblX[i], ty));
// --- Plaatmateriaal + isolatie ---
let my = 240;
doc.setFontSize(8.5);
doc.text('Plaat materiaal', tblX[0], my); my += 4.5;
doc.setFontSize(6.5);
['Dikte', 'Hoogte', 'Lengte', 'Materiaal', 'Aantal'].forEach((h, i) => doc.text(h, tblX[i], my));
my += 4;
[String(underlayThick), String(Math.round(depth)), String(Math.round(width)), opts.roof ? 'OSB (plafond)' : 'OSB', '1']
.forEach((v, i) => doc.text(v, tblX[i], my));
if (opts.roof) {
my += 4;
[String(RoofModel.DAKLEER_MM), String(Math.round(depth)), String(Math.round(width)), 'Dakleer', '1']
.forEach((v, i) => doc.text(v, tblX[i], my));
}
my += 6;
doc.text('Isolatie: leegte tussen de balken (spouwvullend)', tblX[0], my);
// --- Titelblok (rechtsonder) ---
PdfSheet.titleBlock(doc, opts.project, { titel: opts.titel, element: opts.element, blad: opts.blad });
// --- Hoofdaanzicht (bovenaanzicht) ---
const drawX0 = 95, drawX1 = 410, drawY0 = 15, drawY1 = 178;
const marginSides = 25, marginTop = 15, marginBottom = 30;
const availW = (drawX1 - drawX0) - marginSides * 2;
const availH = (drawY1 - drawY0) - marginTop - marginBottom;
const scale = Math.min(availW / width, availH / depth);
const originX = drawX0 + marginSides;
const topY = drawY0 + marginTop;
const px = (xmm) => originX + xmm * scale;
const py = (ymm) => topY + ymm * scale; // y=0 (voor) boven, groeit naar beneden
doc.setDrawColor(...GREY);
doc.setLineWidth(0.25);
doc.rect(px(0), py(0), width * scale, depth * scale, 'S');
doc.setDrawColor(...RED);
doc.setLineWidth(0.3);
doc.setTextColor(...BLACK);
doc.setFontSize(5);
joists.forEach(x => {
const xLeft = x + width / 2 - joistW / 2;
doc.rect(px(xLeft), py(0), joistW * scale, depth * scale, 'S');
doc.text('1', px(xLeft + joistW / 2), py(depth / 2), { align: 'center' });
});
doc.setDrawColor(...RED);
doc.setLineWidth(0.2);
resolvedOpenings.filter(o => !o.removed).forEach(o => {
const cx = px(o.x + width / 2), cy = py(o.y);
doc.circle(cx, cy, o.r * scale, 'S');
doc.setTextColor(...BLACK);
doc.setFontSize(5);
doc.text('#' + o.id, cx, cy - o.r * scale - 2, { align: 'center' });
});
const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2);
doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE);
doc.setLineWidth(0.15);
doc.setFontSize(7);
const dimTopY = py(0) - 7;
drawDimLine(px(0), dimTopY, px(width), dimTopY);
doc.text(String(Math.round(width)), (px(0) + px(width)) / 2, dimTopY - 1.5, { align: 'center' });
const dimLeftX = px(0) - 7;
drawDimLine(dimLeftX, py(0), dimLeftX, py(depth));
doc.text(String(Math.round(depth)), dimLeftX - 2, (py(0) + py(depth)) / 2, { align: 'center', angle: 90 });
// H.o.h.-segmenten + cumulatieve maten onder de tekening — zelfde
// opbouw als de stijl-maatketens in wall.js, nu op de balkposities.
const joistEdges = new Set([0, width]);
joists.forEach(x => joistEdges.add(Math.round(x + width / 2 - joistW / 2)));
const cumPoints = [...joistEdges].filter(v => v >= 0 && v <= width).sort((a, b) => a - b);
doc.setDrawColor(...BLACK);
doc.setTextColor(...BLACK);
doc.setLineWidth(0.1);
doc.setFontSize(5.5);
const segY = py(depth) + 9, cumY = py(depth) + 17;
for (let i = 0; i < cumPoints.length - 1; i++) {
const mid = (cumPoints[i] + cumPoints[i + 1]) / 2;
doc.text(String(cumPoints[i + 1] - cumPoints[i]), px(mid), segY, { align: 'center' });
}
cumPoints.forEach(v => {
doc.text(String(v), px(v), cumY, { align: 'center' });
doc.line(px(v), cumY - 2.5, px(v), py(depth) + 2);
});
// --- Doorsnede (balkprofiel) — kleine profielweergave onderaan,
// analoog aan de bestaande 2D-doorsnede-canvas. ---
const secY0 = Math.min(PAGE_H - 20, py(depth) + 30);
const secScale = Math.min((drawX1 - drawX0 - 40) / width, 18 / (joistH + underlayThick));
const secOriginX = drawX0 + 20;
const secFloorY = secY0 + (joistH + underlayThick) * secScale;
const spx = (xmm) => secOriginX + xmm * secScale;
const spy = (ymm) => secFloorY - ymm * secScale;
// Dak (opts.roof): 180° gespiegeld — OSB onder (plafond), balken
// erboven, dakleer bovenop (RoofModel).
const joistY0 = opts.roof ? underlayThick : 0;
const osbY0 = opts.roof ? 0 : joistH;
doc.setDrawColor(...RED);
doc.setLineWidth(0.2);
joists.forEach(x => {
doc.rect(spx(x + width / 2 - joistW / 2), spy(joistY0 + joistH), joistW * secScale, joistH * secScale, 'S');
});
doc.setDrawColor(...GREY);
doc.rect(spx(0), spy(osbY0 + underlayThick), width * secScale, underlayThick * secScale, 'S');
doc.setTextColor(...BLACK);
doc.setFontSize(6);
if (opts.roof) {
doc.setLineWidth(0.5);
doc.line(spx(0), spy(joistH + underlayThick), spx(width), spy(joistH + underlayThick));
doc.text('Dakleer', spx(width / 2), spy(joistH + underlayThick) - 3, { align: 'center' });
doc.text(`${underlayThick}mm OSB (plafond) — isolatie tussen de balken`, spx(width / 2), spy(0) + 6, { align: 'center' });
} else {
doc.text(`${underlayThick}mm OSB`, spx(width / 2), spy(joistH + underlayThick) - 3, { align: 'center' });
doc.text('Isolatie: leegte tussen de balken', spx(width / 2), spy(0) + 6, { align: 'center' });
}
}
return { drawSheet };
})();
+856
View File
@@ -0,0 +1,856 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// floor/floor.js — Vloer-editor. Hergebruikt calculateSegmentPositions uit
// core/geometry.js (moet voor dit bestand geladen worden) voor exact
// dezelfde h.o.h.-balkverdeling als de wand, nu toegepast op vloerbalken.
// Vloeren krijgen UITSLUITEND cirkelvormige openingen (leidingdoorvoer,
// bijv. riool/water) — en die mogen alleen TUSSEN twee balken vallen, nooit
// een balk kruisen: een doorvoer wordt daarom automatisch verkleind tot een
// maat die wél binnen zijn balkvak past, en pas verwijderd (uit de
// weergave, niet uit de invoer) als zelfs een minimale diameter nergens past.
//
// Zelfde soorten logica als wall.js: een isolatielaag (leegte tussen de
// balken, analytisch bepaald — geen gewicht toegekend, precies zoals bij de
// wand), een 3D-perspectief (balken, OSB-onderlaag met doorvoer-gaten,
// isolatie, chassis-wielen) en een specificaties-paneel met gewichten.
// Onderlaag is standaard OSB-plaat van 22mm.
//
// De rekenlogica (balken, doorvoeren, chassis, specs, 3D-opbouw) staat in
// floor/floor-model.js (FloorModel) — gedeeld met de unit-editor.
//
// Plain script (geen ES-module), zelfde mount()/unmount()-patroon als
// wall.js.
// ============================================================
const FLOOR_TEMPLATE = `
<div id="floor-app">
<div id="floor-left-column">
<div id="floor-pane-2d-container">
<div class="pane-2d-sub" id="floor-pane-top">
<div class="pane-label">2D BOVENAANZICHT</div>
<canvas id="floor-canvas-top"></canvas>
</div>
<div class="pane-2d-sub" id="floor-pane-section">
<div class="pane-label">DOORSNEDE (BALKPROFIEL)</div>
<canvas id="floor-canvas-section"></canvas>
</div>
</div>
<div id="floor-control-panel">
<div class="io-toolbar">
<input type="file" id="f-file-import-json" accept="application/json" style="display:none;">
<span id="f-io-status" class="io-status"></span>
</div>
<div class="tab-bar">
<button class="tab-btn active" data-tab="floor-tab-main">1. Vloer Opbouw</button>
<button class="tab-btn" data-tab="floor-tab-openings">2. Doorvoeren</button>
<button class="tab-btn" data-tab="floor-tab-chassis">3. Chassis</button>
</div>
<div id="floor-tab-main" class="tab-content active">
<div class="controls-grid">
<div>
<h1>Vloer Dimensies (mm)</h1>
<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 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 class="divider"></div>
<h1>Vloerbalken</h1>
<div class="control-group"><label>Balk Breedte:</label>
<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><input type="number" id="f-inp-joist-h" value="220" step="5"><span class="unit">mm</span></div></div>
<div class="divider"></div>
<h1>Onderlaag (OSB-plaat)</h1>
<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>
<div>
<h1>Regelwerk Afstand (h.o.h.)</h1>
<div class="control-group"><label>Min. h.o.h.:</label>
<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><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="result-row"><span>Totaal 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>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="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-plaat 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="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>Totaal gewicht vloer:</span><span data-spec="total-kg" class="highlight">-</span></div>
</div>
</div>
</div>
<div id="floor-tab-openings" class="tab-content">
<button id="f-btn-add-opening" class="add-opening-btn">+ Doorvoer Toevoegen</button>
<div id="f-openings-global-warn" class="global-warning" style="display:none;"></div>
<div id="f-openings-list"></div>
</div>
<div id="floor-tab-chassis" class="tab-content">
<div class="material-toggle-group">
<button type="button" id="f-btn-chassis-toggle" class="material-toggle-btn">Chassis-laag: Uit</button>
</div>
<div class="controls-grid">
<div>
<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>
<div>
<div class="control-group"><label>Spoorbreedte (X):</label>
<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><input type="number" id="f-inp-wheelbase" value="1200" step="50" min="100"><span class="unit">mm</span></div></div>
</div>
</div>
<div id="f-chassis-warning" class="global-warning" style="display:none;"></div>
</div>
</div>
</div>
<div id="floor-right-column">
<div id="floor-pane-3d">
<div class="pane-label">3D PERSPECTIEF</div>
<div class="toolbar-3d" id="floor-toolbar-3d"></div>
</div>
<div id="floor-specs-panel-3d">
<div class="controls-grid">
<div>
<div class="spec-section">
<h1>Balken</h1>
<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>
<div class="spec-section">
<h1>Houtwerk</h1>
<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>
</div>
<div>
<div class="spec-section">
<h1>OSB-plaat (onderlaag, 22mm)</h1>
<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>Gewicht:</span><span data-spec="osb-kg" class="highlight">-</span></div>
</div>
<div class="spec-section">
<h1>Isolatie &amp; totaal</h1>
<div class="result-row"><span>Inhoud leegtes:</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>
</div>
</div>
</div>
</div>
</div>
<div id="wall-project-overlay">
<div id="wall-project-modal">
<div class="modal-header">
<h2>Project eigenschappen</h2>
<button type="button" id="wall-project-close-x" class="modal-close-x">&times;</button>
</div>
<div class="controls-grid">
<div>
<div class="control-group"><label for="inp-proj-naam">Projectnaam:</label>
<div><input type="text" id="inp-proj-naam" value=""></div></div>
<div class="control-group"><label for="inp-proj-plaats">Plaats:</label>
<div><input type="text" id="inp-proj-plaats" value=""></div></div>
<div class="control-group"><label for="inp-proj-straat">Straat:</label>
<div><input type="text" id="inp-proj-straat" value=""></div></div>
<div class="control-group"><label for="inp-proj-nummer">Projectnummer:</label>
<div><input type="text" id="inp-proj-nummer" value=""></div></div>
</div>
<div>
<div class="control-group"><label for="inp-proj-tekenaar">Tekenaar:</label>
<div><input type="text" id="inp-proj-tekenaar" value=""></div></div>
<div class="control-group"><label for="inp-proj-titel">Titel:</label>
<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><input type="text" id="inp-proj-element" value=""></div></div>
<div class="control-group"><label for="inp-proj-opmerking">Opmerking:</label>
<div><input type="text" id="inp-proj-opmerking" value=""></div></div>
</div>
</div>
<div class="modal-footer">
<button type="button" id="wall-project-cancel" class="io-btn">Annuleren</button>
<button type="button" id="wall-project-save" class="material-toggle-btn active" style="flex:none; padding:6px 18px;">Opslaan</button>
</div>
</div>
</div>
`;
// Dakmodus: een plat dak is een vloer die 180° gespiegeld is (OSB-plaat aan
// de onderzijde als plafond, balken erboven) met dakleer als buitenlaag
// (RoofModel). Zelfde editor, andere teksten + gespiegelde doorsnede/3D.
function floorTemplateForRoof() {
const r = [
['1. Vloer Opbouw', '1. Dak Opbouw'],
['Vloer Dimensies (mm)', 'Dak Dimensies (mm)'],
['<h1>Vloerbalken</h1>', '<h1>Dakbalken</h1>'],
['Onderlaag (OSB-plaat)', 'Plafondplaat (OSB, onderzijde)'],
['Totaal vloerbalken:', 'Totaal dakbalken:'],
['OSB-plaat (onderlaag, 22mm)', 'OSB-plaat (plafond)'],
['<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;
r.forEach(([a, b]) => { t = t.split(a).join(b); });
return t.split('Vloeroppervlak:').join('Dakoppervlak:').split('Totaal gewicht vloer:').join('Totaal gewicht dak:');
}
function mountFloorEditor(container, callbacks) {
const onSaved = callbacks && callbacks.onSaved;
const roofMode = !!(callbacks && callbacks.mode === 'roof');
container.innerHTML = roofMode ? floorTemplateForRoof() : FLOOR_TEMPLATE;
// Binnen een woning kan de maat vastliggen (dak volgt de vloer);
// callbacks.lockSize is dan de uitleg-tekst voor de tooltip.
if (callbacks && callbacks.lockSize) {
['f-inp-width', 'f-inp-depth'].forEach(id => {
const el = document.getElementById(id);
el.disabled = true;
el.title = callbacks.lockSize;
});
}
// ----------------------------------------------------------
// State
// ----------------------------------------------------------
// Dichtheden, balkvak-marge en minimale doorvoer-diameter: zie FloorModel.
// Geen residuele demo-doorvoer meer — leeg beginnen, gebruiker voegt zelf toe.
let openings = [];
let openingIdCounter = 1;
// Chassis-laag: een losse, schematische onderconstructie (transportwielen)
// die NIET met de balkpositie hoeft te overlappen — in tegenstelling tot de
// doorvoeren hierboven wordt hier niets geklemd/verkleind, alleen buiten de
// vloerrand gewaarschuwd (zie computeChassisWheels).
let chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
// Tab-navigatie (1. Vloer Opbouw / 2. Doorvoeren / 3. Chassis) — dezelfde
// generieke .tab-btn/.tab-content-wiring als wall.js. Ontbrak hier eerder
// (residu van vóór de tab-bar werd toegevoegd), waardoor de knoppen wel
// getekend maar niet klikbaar waren.
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(ct => ct.classList.remove('active'));
document.querySelector(`.tab-btn[data-tab="${tabId}"]`).classList.add('active');
document.getElementById(tabId).classList.add('active');
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
function anchorLabel(a) {
if (a === 'right') return 'Afstand vanaf oostzijde (X)';
if (a === 'middle') return 'Verschuiving t.o.v. midden (X)';
return 'Afstand vanaf westzijde (X)';
}
function renderOpeningsList() {
const el = document.getElementById('f-openings-list');
el.innerHTML = openings.map(op => `
<div class="opening-card" data-opening-id="${op.id}">
<div class="opening-card-header">
<span class="opening-card-title">Doorvoer #${op.id}</span>
<button class="opening-remove-btn" data-action="remove" data-id="${op.id}">&times;</button>
</div>
<div class="controls-grid">
<div>
<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 class="control-group"><label>Afstand vanaf noordzijde (Y):</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>
<div>
<div class="control-group"><label>Anker (X):</label>
<div>
<select data-field="anchorX" data-id="${op.id}">
<option value="left" ${op.anchorX === 'left' ? 'selected' : ''}>West</option>
<option value="middle" ${op.anchorX === 'middle' ? 'selected' : ''}>Midden</option>
<option value="right" ${op.anchorX === 'right' ? 'selected' : ''}>Oost</option>
</select>
</div>
</div>
<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>
</div>
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
</div>
`).join('');
}
document.getElementById('f-openings-list').addEventListener('input', (e) => {
const id = parseInt(e.target.dataset.id, 10);
const field = e.target.dataset.field;
if (!field) return;
const op = openings.find(o => o.id === id);
if (!op) return;
if (field === 'anchorX') { op.anchorX = e.target.value; renderOpeningsList(); }
else op[field] = parseFloat(e.target.value) || 0;
updateAll();
});
document.getElementById('f-openings-list').addEventListener('click', (e) => {
if (e.target.dataset.action === 'remove') {
openings = openings.filter(o => o.id !== parseInt(e.target.dataset.id, 10));
renderOpeningsList();
updateAll();
}
});
document.getElementById('f-btn-add-opening').addEventListener('click', () => {
openings.push({ id: openingIdCounter++, diameter: 110, anchorX: 'left', offsetX: 500 + openings.length * 600, offsetY: 500 });
renderOpeningsList();
updateAll();
});
document.querySelectorAll('#floor-tab-main input').forEach(inp => inp.addEventListener('input', updateAll));
function syncChassisToggleLabel() {
const btn = document.getElementById('f-btn-chassis-toggle');
btn.classList.toggle('active', chassis.enabled);
btn.textContent = 'Chassis-laag: ' + (chassis.enabled ? 'Aan' : 'Uit');
}
document.getElementById('f-btn-chassis-toggle').addEventListener('click', () => {
chassis.enabled = !chassis.enabled;
syncChassisToggleLabel();
updateAll();
});
document.querySelectorAll('#floor-tab-chassis input').forEach(inp => inp.addEventListener('input', () => {
chassis.wheelDiameter = parseFloat(document.getElementById('f-inp-wheel-d').value) || 400;
chassis.trackWidth = parseFloat(document.getElementById('f-inp-track').value) || 1200;
chassis.wheelbase = parseFloat(document.getElementById('f-inp-wheelbase').value) || 1200;
updateAll();
}));
syncChassisToggleLabel();
// ------------------------------------------------------------
// Project eigenschappen — zelfde modal/velden als wall.js (bewust
// dezelfde id's: er is nooit meer dan één editor gelijktijdig gemount,
// dus hergebruik hiervan is veilig en voorkomt dubbele CSS/HTML).
// ------------------------------------------------------------
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
let projectPropsSnapshot = null;
function openProjectProperties() {
projectPropsSnapshot = {};
PROJECT_PROP_IDS.forEach(id => { projectPropsSnapshot[id] = document.getElementById(id).value; });
document.getElementById('wall-project-overlay').classList.add('open');
}
function closeProjectProperties() {
document.getElementById('wall-project-overlay').classList.remove('open');
}
document.getElementById('wall-project-save').addEventListener('click', closeProjectProperties);
document.getElementById('wall-project-cancel').addEventListener('click', () => {
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
closeProjectProperties();
});
document.getElementById('wall-project-close-x').addEventListener('click', () => {
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
closeProjectProperties();
});
document.getElementById('wall-project-overlay').addEventListener('click', (e) => {
if (e.target.id === 'wall-project-overlay') {
if (projectPropsSnapshot) PROJECT_PROP_IDS.forEach(id => { document.getElementById(id).value = projectPropsSnapshot[id]; });
closeProjectProperties();
}
});
// ------------------------------------------------------------
// JSON export/import — zelfde patroon als wall.js: sla het volledige
// vloer-ontwerp op (parameters + chassis + ruwe doorvoeren) als JSON, en
// laad het weer terug. Bij "Opslaan als" wordt ook automatisch de
// bijbehorende PDF-tekening gegenereerd (zie generateFloorPdf).
// ------------------------------------------------------------
function setIoStatus(msg, cls) {
const el = document.getElementById('f-io-status');
el.textContent = msg;
el.className = 'io-status ' + (cls || '');
clearTimeout(setIoStatus._t);
setIoStatus._t = setTimeout(() => { el.textContent = ''; }, 4000);
}
function buildDesignObject() {
return { type: 'floor-design', version: 1, exportedAt: new Date().toISOString(), ...getPureState() };
}
function exportDesign() {
const suggested = 'vloer-ontwerp-' + new Date().toISOString().slice(0, 10);
let filename = window.prompt('Bestandsnaam voor het opgeslagen ontwerp:', suggested);
if (filename === null) { setIoStatus('Opslaan geannuleerd.', ''); return null; }
filename = filename.trim();
if (!filename) filename = suggested;
if (!filename.toLowerCase().endsWith('.json')) filename += '.json';
const design = buildDesignObject();
const blob = new Blob([JSON.stringify(design, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
const pdfBase = filename.replace(/\.json$/i, '');
try {
const pdfDoc = generateFloorPdf();
pdfDoc.save(pdfBase + '.pdf');
setIoStatus(`Opgeslagen als "${filename}" + PDF.`, 'ok');
} catch (err) {
setIoStatus(`Opgeslagen als "${filename}" (PDF mislukt: ${err.message}).`, 'err');
}
if (typeof onSaved === 'function') onSaved(filename);
return filename;
}
function validateDesignJson(design) {
if (!design || design.type !== 'floor-design') throw new Error('geen geldig vloer-ontwerp (type ontbreekt of onjuist)');
FloorModel.validateState(design);
}
function importDesign(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const design = JSON.parse(e.target.result);
validateDesignJson(design);
applyPureState(design);
setIoStatus(`Geïmporteerd (${openings.length} doorvoer(en)).`, 'ok');
if (typeof onSaved === 'function') onSaved(file.name);
} catch (err) {
setIoStatus('Import mislukt: ' + err.message, 'err');
}
};
reader.onerror = () => setIoStatus('Kon bestand niet lezen.', 'err');
reader.readAsText(file);
}
function triggerImport() {
document.getElementById('f-file-import-json').click();
}
document.getElementById('f-file-import-json').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) importDesign(file);
e.target.value = '';
});
// Balkvakken + doorvoer-resolutie (nooit een balk kruisen): zie
// FloorModel.resolveOpenings (floor/floor-model.js).
// ----------------------------------------------------------
// Balkverdeling: FloorModel.computeFrame (zelfde algoritme als de wand).
// ----------------------------------------------------------
let calcData = { joists: [] };
function fmt(n, decimals = 1) {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
function setSpecAll(key, text) {
document.querySelectorAll(`[data-spec="${key}"]`).forEach(el => { el.textContent = text; });
}
function recalculate() {
calcData = FloorModel.computeFrame({
width: parseFloat(document.getElementById('f-inp-width').value),
depth: parseFloat(document.getElementById('f-inp-depth').value),
joistWidth: parseFloat(document.getElementById('f-inp-joist-w').value),
joistHeight: parseFloat(document.getElementById('f-inp-joist-h').value),
minSpacing: parseFloat(document.getElementById('f-inp-min-sp').value),
maxSpacing: parseFloat(document.getElementById('f-inp-max-sp').value),
underlayThick: parseFloat(document.getElementById('f-inp-underlay-t').value)
});
}
// Specificaties: FloorModel.computeSpecs (balkhout, OSB, isolatie-leegte,
// gewichtstotaal).
function updateSpecificaties(resolvedOpenings) {
const specs = roofMode ? RoofModel.computeSpecs(calcData, resolvedOpenings) : FloorModel.computeSpecs(calcData, resolvedOpenings);
if (roofMode) {
setSpecAll('dakleer-m2', fmt(specs.dakleerM2, 2) + ' m2');
setSpecAll('dakleer-kg', fmt(specs.dakleerKg) + ' kg');
}
setSpecAll('joist-count', calcData.joists.length + ' stuks');
setSpecAll('floor-area', fmt((calcData.width * calcData.depth) / 1e6, 2) + ' m2');
setSpecAll('joist-m3', fmt(specs.joistWoodM3, 3) + ' m3');
setSpecAll('joist-kg', fmt(specs.joistWoodKg) + ' kg');
setSpecAll('osb-m2', fmt(specs.osbAreaM2, 2) + ' m2');
setSpecAll('osb-m3', fmt(specs.osbVolM3, 3) + ' m3');
setSpecAll('osb-kg', fmt(specs.osbKg) + ' kg');
setSpecAll('void-m3', fmt(specs.voidM3, 3) + ' m3');
setSpecAll('total-kg', fmt(specs.totalKg) + ' kg');
return specs;
}
// Chassis-laag (4 wielen, "dobbelsteen 4"): FloorModel.computeChassisWheels.
function updateChassisWarning(outOfBounds) {
const warnBox = document.getElementById('f-chassis-warning');
warnBox.style.display = outOfBounds ? 'block' : 'none';
warnBox.textContent = 'Let op: één of meer wielen steken buiten de vloerrand — verklein de spoorbreedte/wielbasis of vergroot de vloer.';
}
// ----------------------------------------------------------
// Canvas: bovenaanzicht
// ----------------------------------------------------------
const canvasTop = document.getElementById('floor-canvas-top');
const ctxTop = canvasTop.getContext('2d');
function drawTop(resolvedOpenings, chassisData) {
const { width, depth } = calcData;
const rect = canvasTop.parentElement.getBoundingClientRect();
canvasTop.width = rect.width;
canvasTop.height = rect.height;
const w = canvasTop.width, h = canvasTop.height;
ctxTop.clearRect(0, 0, w, h);
const scale = Math.min(w / (width * 1.35), h / (depth * 1.35));
const cx = w / 2, cy = h / 2;
const toX = (xmm) => cx + xmm * scale;
const toY = (ymm) => cy - (depth / 2 - ymm) * scale; // y=0 (voor) boven, y=depth (achter) onder
ctxTop.strokeStyle = '#222'; ctxTop.lineWidth = 1;
const gridSize = 200 * scale;
for (let x = 0; x < w; x += gridSize) { ctxTop.beginPath(); ctxTop.moveTo(x, 0); ctxTop.lineTo(x, h); ctxTop.stroke(); }
for (let y = 0; y < h; y += gridSize) { ctxTop.beginPath(); ctxTop.moveTo(0, y); ctxTop.lineTo(w, y); ctxTop.stroke(); }
ctxTop.strokeStyle = '#666'; ctxTop.lineWidth = 2;
ctxTop.strokeRect(toX(-width / 2), toY(0), width * scale, depth * scale);
calcData.joists.forEach(x => {
ctxTop.fillStyle = '#c29b62'; ctxTop.strokeStyle = '#8a6836';
const jw = calcData.joistW * scale;
ctxTop.fillRect(toX(x) - jw / 2, toY(0), jw, depth * scale);
ctxTop.strokeRect(toX(x) - jw / 2, toY(0), jw, depth * scale);
});
resolvedOpenings.filter(o => !o.removed).forEach(op => {
ctxTop.beginPath();
ctxTop.arc(toX(op.x), toY(op.y), op.r * scale, 0, Math.PI * 2);
ctxTop.fillStyle = op.overflowing ? 'rgba(255,170,60,.4)' : 'rgba(102,170,255,.35)';
ctxTop.strokeStyle = op.overflowing ? '#ffaa33' : '#4da6ff';
ctxTop.lineWidth = 2;
ctxTop.fill(); ctxTop.stroke();
ctxTop.fillStyle = '#fff'; ctxTop.font = '10px sans-serif'; ctxTop.textAlign = 'center';
ctxTop.fillText('#' + op.id, toX(op.x), toY(op.y) - op.r * scale - 6);
});
(chassisData ? chassisData.wheels : []).forEach(wheel => {
ctxTop.beginPath();
ctxTop.arc(toX(wheel.x), toY(wheel.y), wheel.r * scale, 0, Math.PI * 2);
ctxTop.fillStyle = chassisData.outOfBounds ? 'rgba(255,60,60,.35)' : 'rgba(30,30,30,.55)';
ctxTop.strokeStyle = chassisData.outOfBounds ? '#ff3c3c' : '#000';
ctxTop.lineWidth = 1.5;
ctxTop.fill(); ctxTop.stroke();
});
// Maatlabels (zelfde stijl als het wand-vooraanzicht, core/canvas-labels.js)
const xAxisY = Math.max(14, toY(0) - 14);
drawCanvasDim(ctxTop, toX(-width / 2), xAxisY, toX(width / 2), xAxisY,
'X: ' + Math.round(width) + 'mm', cx, Math.max(10, xAxisY - 4));
const yAxisX = Math.max(14, toX(-width / 2) - 14);
drawCanvasDim(ctxTop, yAxisX, toY(0), yAxisX, toY(depth),
'Y: ' + Math.round(depth) + 'mm', Math.max(10, yAxisX - 4), (toY(0) + toY(depth)) / 2);
}
// ----------------------------------------------------------
// Canvas: doorsnede (balkprofiel — breedte x hoogte, aanvullende kijkhoek
// naast het bovenaanzicht, analoog aan de vooraanzicht-doorsnede bij wall.js)
// ----------------------------------------------------------
const canvasSection = document.getElementById('floor-canvas-section');
const ctxSection = canvasSection.getContext('2d');
function drawSection() {
const { width, joistW, joistH, underlayThick } = calcData;
const rect = canvasSection.parentElement.getBoundingClientRect();
canvasSection.width = rect.width;
canvasSection.height = rect.height;
const w = canvasSection.width, h = canvasSection.height;
ctxSection.clearRect(0, 0, w, h);
// Dakmodus: 180° gespiegeld — OSB onder, balken erboven, dakleer bovenop.
const leerT = roofMode ? RoofModel.DAKLEER_MM : 0;
const totalH = joistH + underlayThick + leerT;
const joistY0 = roofMode ? underlayThick : 0;
const osbY0 = roofMode ? 0 : joistH;
const scale = Math.min(w / (width * 1.3), h / (totalH * 3));
const cx = w / 2, floorY = h - 20;
const toX = (xmm) => cx + xmm * scale;
const toY = (ymm) => floorY - ymm * scale;
ctxSection.strokeStyle = '#666'; ctxSection.lineWidth = 1;
ctxSection.beginPath();
ctxSection.moveTo(toX(-width / 2), toY(0));
ctxSection.lineTo(toX(width / 2), toY(0));
ctxSection.stroke();
// Isolatie (leegte tussen de balken) — geschematiseerd als een
// doorlopende, licht transparante band ter hoogte van de balken, zodat
// ook in de doorsnede duidelijk is wát er tussen de balken zit.
ctxSection.fillStyle = 'rgba(255, 224, 140, 0.25)';
ctxSection.fillRect(toX(-width / 2), toY(joistY0 + joistH), width * scale, toY(joistY0) - toY(joistY0 + joistH));
calcData.joists.forEach(x => {
ctxSection.fillStyle = '#c29b62'; ctxSection.strokeStyle = '#8a6836';
ctxSection.fillRect(toX(x - joistW / 2), toY(joistY0 + joistH), joistW * scale, joistH * scale);
ctxSection.strokeRect(toX(x - joistW / 2), toY(joistY0 + joistH), joistW * scale, joistH * scale);
});
// OSB-plaat: bij een vloer boven op de balken, bij een dak eronder (plafond).
ctxSection.fillStyle = '#cda86a'; ctxSection.strokeStyle = '#8a6836';
ctxSection.fillRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick));
ctxSection.strokeRect(toX(-width / 2), toY(osbY0 + underlayThick), width * scale, toY(osbY0) - toY(osbY0 + underlayThick));
ctxSection.fillStyle = '#fff'; ctxSection.font = '9px sans-serif'; ctxSection.textAlign = 'center';
if (roofMode) {
const topY = toY(totalH);
ctxSection.fillStyle = '#2a2a2a'; ctxSection.strokeStyle = '#888';
ctxSection.fillRect(toX(-width / 2), topY - 2, width * scale, 3);
ctxSection.strokeRect(toX(-width / 2), topY - 2, width * scale, 3);
ctxSection.fillStyle = '#fff';
ctxSection.fillText('Dakleer', cx, topY - 8);
ctxSection.textAlign = 'right';
ctxSection.fillText(underlayThick + 'mm OSB (plafond)', toX(-width / 2) - 6, toY(underlayThick / 2) + 3);
ctxSection.textAlign = 'center';
} else {
ctxSection.fillText(underlayThick + 'mm OSB', cx, toY(joistH + underlayThick) - 6);
}
// Maatlabels (zelfde stijl als het wand-vooraanzicht)
const xAxisY = Math.min(h - 12, toY(0) + 8);
drawCanvasDim(ctxSection, toX(-width / 2), xAxisY, toX(width / 2), xAxisY,
'X: ' + Math.round(width) + 'mm', cx, Math.min(h - 2, xAxisY + 11));
const zAxisX = Math.min(w - 14, toX(width / 2) + 14);
drawCanvasDim(ctxSection, zAxisX, toY(0), zAxisX, toY(totalH),
'Z: ' + Math.round(totalH) + 'mm', Math.min(w - 4, zAxisX + 12), (toY(0) + toY(totalH)) / 2);
}
// ----------------------------------------------------------
// Waarschuwingen naar de openingen-kaarten
// ----------------------------------------------------------
function updateWarnings(resolved) {
const anyWarn = resolved.some(o => o.overflowing);
const warnBox = document.getElementById('f-openings-global-warn');
warnBox.style.display = anyWarn ? 'block' : 'none';
warnBox.textContent = 'Let op: één of meer doorvoeren zijn verkleind/verwijderd zodat ze geen balk kruisen, of overlappen elkaar.';
resolved.forEach(o => {
const card = document.querySelector(`.opening-card[data-opening-id="${o.id}"]`);
const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`);
if (!card || !warnEl) return;
card.classList.toggle('opening-warn', !!o.overflowing);
warnEl.style.display = o.overflowing ? 'block' : 'none';
if (o.removed) warnEl.textContent = 'Past nergens tussen twee balken (ook niet verkleind) — niet getekend.';
else if (o.shrunk) warnEl.textContent = `Verkleind naar ${Math.round(o.diameter)}mm om niet over een balk heen te vallen (gevraagd: ${o.requestedDiameter}mm).`;
else if (o.overlapping) warnEl.textContent = 'Overlapt een andere doorvoer in hetzelfde balkvak.';
else warnEl.textContent = '';
});
}
// ----------------------------------------------------------
// THREE.JS 3D RENDERER — zelfde opzet als wall.js (scene/camera/renderer/
// OrbitControls/licht/grid), maar plat gelegd: X = breedte, Z = diepte,
// Y = dikte (balkhoogte + OSB), in plaats van wall.js' X/Y/Z (breedte/
// hoogte/diepte). Balken en isolatie zijn simpele dozen; de OSB-onderlaag
// is — net als de platen bij wall.js — een geëxtrudeerd Shape met ronde
// uitsparingen voor de doorvoeren.
// ----------------------------------------------------------
const pane3D = document.getElementById('floor-pane-3d');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
const camera = new THREE.PerspectiveCamera(45, pane3D.clientWidth / pane3D.clientHeight, 0.1, 100);
camera.position.set(2.6, 2.3, 3.4);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
pane3D.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
const cameraKey = callbacks && callbacks.cameraKey;
if (!CameraMemory.restore(cameraKey, camera, controls)) {
controls.target.set(0, 0.15, 0);
controls.update();
}
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 8, 5);
dirLight.castShadow = true;
scene.add(dirLight);
scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x222222));
const floorMaterials = roofMode ? RoofModel.createMaterials() : FloorModel.createMaterials();
let osbVisible = true;
let isolatieVisible = true;
let floorGroup3D = null;
let lastResolvedOpenings = [];
let lastChassisData = { wheels: [], outOfBounds: false };
// Weergave-filter (core/view-filter.js). Beplating = OSB-plaat (bij een
// dak: plafondplaat + dakleer).
createViewFilter(document.getElementById('floor-toolbar-3d'), [
{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' },
{ key: 'isolatie', label: 'Isolatie' }
], (v) => {
osbVisible = v.beplating;
isolatieVisible = v.isolatie;
update3DScene();
});
function update3DScene() {
if (floorGroup3D) scene.remove(floorGroup3D);
floorGroup3D = roofMode
? RoofModel.build3D(calcData, lastResolvedOpenings, { materials: floorMaterials, osbVisible, isolatieVisible, dakleerVisible: osbVisible })
: FloorModel.build3D(calcData, lastResolvedOpenings, lastChassisData, { materials: floorMaterials, osbVisible, isolatieVisible });
scene.add(floorGroup3D);
}
// ------------------------------------------------------------
// PDF-export: het tekenblad zelf komt uit FloorPdf (floor/floor-pdf.js),
// gedeeld met de unit-PDF.
// ------------------------------------------------------------
function generateFloorPdf() {
recalculate();
const resolvedOpenings = FloorModel.resolveOpenings(openings, calcData.width, calcData.depth, calcData.joists, calcData.joistW);
const doc = PdfSheet.createDoc();
const project = PdfSheet.readProjectFromDom();
FloorPdf.drawSheet(doc, calcData, resolvedOpenings, { project, titel: project.titel, element: project.element, roof: roofMode });
return doc;
}
function updateAll() {
recalculate();
lastResolvedOpenings = FloorModel.resolveOpenings(openings, calcData.width, calcData.depth, calcData.joists, calcData.joistW);
lastChassisData = FloorModel.computeChassisWheels(chassis, calcData.width, calcData.depth);
drawTop(lastResolvedOpenings, lastChassisData);
drawSection();
updateWarnings(lastResolvedOpenings);
updateChassisWarning(lastChassisData.outOfBounds);
updateSpecificaties(lastResolvedOpenings);
update3DScene();
}
let __rafId = null;
function animate() {
__rafId = requestAnimationFrame(animate);
renderer.render(scene, camera);
}
function onWindowResize() {
camera.aspect = pane3D.clientWidth / pane3D.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(pane3D.clientWidth, pane3D.clientHeight);
drawTop(lastResolvedOpenings, lastChassisData);
drawSection();
}
window.addEventListener('resize', onWindowResize);
function toggleFullscreen3D() {
const el = document.getElementById('floor-app');
el.classList.remove('fullscreen-2d');
el.classList.toggle('fullscreen-3d');
requestAnimationFrame(onWindowResize);
}
function toggleFullscreen2D() {
const el = document.getElementById('floor-app');
el.classList.remove('fullscreen-3d');
el.classList.toggle('fullscreen-2d');
requestAnimationFrame(onWindowResize);
}
function getPureState() {
return {
floor: {
width: parseFloat(document.getElementById('f-inp-width').value),
depth: parseFloat(document.getElementById('f-inp-depth').value),
joistWidth: parseFloat(document.getElementById('f-inp-joist-w').value),
joistHeight: parseFloat(document.getElementById('f-inp-joist-h').value),
minSpacing: parseFloat(document.getElementById('f-inp-min-sp').value),
maxSpacing: parseFloat(document.getElementById('f-inp-max-sp').value),
underlayThick: parseFloat(document.getElementById('f-inp-underlay-t').value)
},
chassis: { ...chassis },
openings: openings.map(o => ({ id: o.id, diameter: o.diameter, anchorX: o.anchorX, offsetX: o.offsetX, offsetY: o.offsetY }))
};
}
function applyPureState(state) {
document.getElementById('f-inp-width').value = state.floor.width;
document.getElementById('f-inp-depth').value = state.floor.depth;
document.getElementById('f-inp-joist-w').value = state.floor.joistWidth;
document.getElementById('f-inp-joist-h').value = state.floor.joistHeight;
document.getElementById('f-inp-min-sp').value = state.floor.minSpacing;
document.getElementById('f-inp-max-sp').value = state.floor.maxSpacing;
document.getElementById('f-inp-underlay-t').value = state.floor.underlayThick ?? 22;
chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200, ...(state.chassis || {}) };
document.getElementById('f-inp-wheel-d').value = chassis.wheelDiameter;
document.getElementById('f-inp-track').value = chassis.trackWidth;
document.getElementById('f-inp-wheelbase').value = chassis.wheelbase;
syncChassisToggleLabel();
openings = state.openings.map(o => ({ id: o.id, diameter: o.diameter, anchorX: o.anchorX, offsetX: o.offsetX, offsetY: o.offsetY }));
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
renderOpeningsList();
updateAll();
}
renderOpeningsList();
updateAll();
animate();
return {
unmount() {
window.removeEventListener('resize', onWindowResize);
if (__rafId !== null) cancelAnimationFrame(__rafId);
CameraMemory.save(cameraKey, camera, controls);
renderer.dispose();
},
exportDesign,
triggerImport,
toggleFullscreen3D,
toggleFullscreen2D,
openProjectProperties,
getStateJson: getPureState,
loadStateJson: applyPureState
};
}
window.FloorEditor = {
mount: mountFloorEditor,
getDefaultState: () => ({
floor: { width: 2000, depth: 2000, joistWidth: 70, joistHeight: 220, minSpacing: 300, maxSpacing: 600, underlayThick: 22 },
chassis: { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 },
openings: []
})
};
+569
View File
@@ -0,0 +1,569 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// house/house-model.js — HouseModel: ENIGE bron van de woning-logica. Een
// woning bestaat uit units (UnitModel) die aan elkaar gekoppeld zijn, plus
// binnenwanden. Geen DOM. Alle wand-/vloerlogica komt uit WallModel/
// FloorModel/UnitModel.
//
// Woning-state:
// { project: {...},
// units: [{ id: 'A', attach: null | { to, side, align, offset }, unit: <unit-state> }],
// interiorWalls: [{ id, x1, z1, x2, z2, wall: <wand-state> }] }
//
// Assenstelsel (mm, woning): X = breedte (rechts), Z = diepte (naar achter),
// de eerste unit heeft zijn linker-voorhoek op (0, 0).
//
// Koppelen werkt als de ankers van openingen: een unit hangt aan een zijde
// van een andere unit, uitgelijnd op start/midden/eind + verschuiving. Wordt
// een unit groter/kleiner, dan schuiven de gekoppelde units mee.
// Waar twee units tegen elkaar staan (naad) vervalt de wand van BEIDE units
// over dat stuk (UnitModel.computeLayout met cuts).
//
// Plain script, geladen na unit-model.js.
// ============================================================
window.HouseModel = (() => {
const EPS = 0.5; // mm
const ALIGN_LABELS = {
x: { start: 'West uitlijnen', middle: 'Midden', end: 'Oost uitlijnen' },
z: { start: 'Noord uitlijnen', middle: 'Midden', end: 'Zuid uitlijnen' }
};
// Windrichtingen t.o.v. het 2D-bovenaanzicht (boven = noord).
const SIDE_LABELS = { voor: 'Noordzijde', achter: 'Zuidzijde', links: 'Westzijde', rechts: 'Oostzijde' };
const DEFAULT_INTERIOR_THICKNESS = 100;
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
function createEmptyProject() {
return { naam: '', plaats: '', straat: '', nummer: '', tekenaar: '', opmerking: '' };
}
function nextUnitId(state) {
const used = new Set(state.units.map(u => u.id));
for (let i = 0; i < 26; i++) {
const id = String.fromCharCode(65 + i);
if (!used.has(id)) return id;
}
return 'U' + (state.units.length + 1);
}
// template: 'single' (1 unit) of 'L' (2 units in L-vorm).
// Woning-brede instellingen: regelwerk-h.o.h. voor ALLE buiten- resp.
// binnenwanden (niet per wand), en de dakrand (RoofModel).
function createDefaultSettings() {
return {
spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } },
cladding: 'horizontaal', // richting gevelbekleding, voorlopig voor alle buitenwanden
// Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte
// vanaf het beloopbare vloervlak (WallModel.trasraamFor).
trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM },
sandwich: true, // sandwichpanelen rondom (SandwichModel), vanaf loopvlak 600 mm de grond in
roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS),
lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS)
};
}
function createUnitEntry(id, attach, unitOpts) {
const unit = UnitModel.createDefaultState(unitOpts);
return { id, attach, unit, roof: RoofModel.createDefaultRoof(unit.floor), lighting: LightingModel.createDefaultUnitLighting() };
}
function createDefaultState(template) {
const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] };
state.units.push(createUnitEntry('A', null, { width: 6000, depth: 3000 }));
if (template === 'L') {
state.units.push(createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 3000, depth: 3000, windowOn: [] }));
// Standaard L-woning: voordeur midden in de noordgevel van A, drie
// ramen in de zuidgevel van B (bovenkanten gelijk met de voordeur).
state.units[0].unit.walls.voor.openings = [
{ id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0 }
];
state.units[1].unit.walls.achter.openings = [
{ id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450 },
{ id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0 },
{ id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450 }
];
}
computeParts(state, { skip3D: true });
return state;
}
// ------------------------------------------------------------
// Posities (vloer-rechthoeken) uit de koppelingen.
// ------------------------------------------------------------
function unitRects(state) {
const size = {};
state.units.forEach(u => {
const f = FloorModel.computeFrame(u.unit.floor.floor);
size[u.id] = { W: f.width, D: f.depth };
});
const rects = {};
const make = (id, x0, z0, error) => {
const { W, D } = size[id];
rects[id] = { id, x0, z0, x1: x0 + W, z1: z0 + D, cx: x0 + W / 2, cz: z0 + D / 2, W, D, error: error || null };
};
let pending = state.units.slice();
let progress = true;
while (pending.length && progress) {
progress = false;
pending = pending.filter(u => {
if (!u.attach) {
if (Object.keys(rects).length === 0) { make(u.id, 0, 0); progress = true; return false; }
return true; // tweede ongekoppelde unit: hieronder als fout geplaatst
}
const P = rects[u.attach.to];
if (!P) return true;
const { W, D } = size[u.id];
const a = u.attach, off = a.offset || 0;
const alongX = (align) => align === 'middle' ? P.cx - W / 2 + off : align === 'end' ? P.x1 - off - W : P.x0 + off;
const alongZ = (align) => align === 'middle' ? P.cz - D / 2 + off : align === 'end' ? P.z1 - off - D : P.z0 + off;
if (a.side === 'achter') make(u.id, alongX(a.align), P.z1);
else if (a.side === 'voor') make(u.id, alongX(a.align), P.z0 - D);
else if (a.side === 'rechts') make(u.id, P.x1, alongZ(a.align));
else make(u.id, P.x0 - W, alongZ(a.align));
progress = true;
return false;
});
}
// Onoplosbare koppelingen (kring/ontbrekende unit): rechts naast de rest.
pending.forEach(u => {
const maxX = Math.max(0, ...Object.values(rects).map(r => r.x1));
make(u.id, maxX + 1000, 0, 'Koppeling ongeldig (verwijst naar zichzelf of een ontbrekende unit).');
});
return state.units.map(u => rects[u.id]);
}
// Naden: zijden die precies tegen elkaar liggen en (deels) overlappen.
// Elke cut is [a, b, extA, extB]: extA/extB = hoever het aangrenzende
// wandstuk de naad in doorloopt. Eindigt de naad op de rand van de BUUR-unit
// (binnenhoek, bijv. bij een L-vorm), dan staat daar een haakse wand van die
// buur; het wandstuk loopt dan met diens dikte door, zodat de binnenhoek
// dicht is i.p.v. een gat van wanddikte x wanddikte.
// thickness(id, side) = dikte van die wand (mm).
function computeSeams(rects, thickness) {
const T = thickness || (() => 0);
const cuts = {};
rects.forEach(r => { cuts[r.id] = { voor: [], achter: [], links: [], rechts: [] }; });
const seams = [];
const overlap = (a0, a1, b0, b1) => [Math.max(a0, b0), Math.min(a1, b1)];
for (let i = 0; i < rects.length; i++) {
for (let j = 0; j < rects.length; j++) {
if (i === j) continue;
const A = rects[i], B = rects[j];
if (Math.abs(A.z1 - B.z0) < EPS) { // A achter | B voor
const [o0, o1] = overlap(A.x0, A.x1, B.x0, B.x1);
if (o1 - o0 > EPS) {
cuts[A.id].achter.push([o0 - A.cx, o1 - A.cx, B.x0 > A.x0 + EPS ? T(B.id, 'links') : 0, B.x1 < A.x1 - EPS ? T(B.id, 'rechts') : 0]);
cuts[B.id].voor.push([o0 - B.cx, o1 - B.cx, A.x0 > B.x0 + EPS ? T(A.id, 'links') : 0, A.x1 < B.x1 - EPS ? T(A.id, 'rechts') : 0]);
seams.push({ a: A.id, b: B.id, x0: o0, z0: A.z1, x1: o1, z1: A.z1 });
}
}
if (Math.abs(A.x1 - B.x0) < EPS) { // A rechts | B links
const [o0, o1] = overlap(A.z0, A.z1, B.z0, B.z1);
if (o1 - o0 > EPS) {
cuts[A.id].rechts.push([o0 - A.cz, o1 - A.cz, B.z0 > A.z0 + EPS ? T(B.id, 'voor') : 0, B.z1 < A.z1 - EPS ? T(B.id, 'achter') : 0]);
cuts[B.id].links.push([o0 - B.cz, o1 - B.cz, A.z0 > B.z0 + EPS ? T(A.id, 'voor') : 0, A.z1 < B.z1 - EPS ? T(A.id, 'achter') : 0]);
seams.push({ a: A.id, b: B.id, x0: A.x1, z0: o0, x1: A.x1, z1: o1 });
}
}
}
}
return { cuts, seams };
}
function overlappingPairs(rects) {
const pairs = [];
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const A = rects[i], B = rects[j];
if (Math.min(A.x1, B.x1) - Math.max(A.x0, B.x0) > EPS && Math.min(A.z1, B.z1) - Math.max(A.z0, B.z0) > EPS) {
pairs.push([A.id, B.id]);
}
}
}
return pairs;
}
// ------------------------------------------------------------
// Binnenwanden: een getekend lijnstuk (hartlijn, as-evenwijdig) met een
// gewone wand-state. Standaard 100 mm dik, aan beide zijden spaanplaat.
// ------------------------------------------------------------
function interiorWallDefaults(thickness, height) {
const wall = WallEditor.getDefaultState();
const t = thickness || DEFAULT_INTERIOR_THICKNESS;
const h = height || wall.wall.heightAll;
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = h;
wall.materials = { interior: 'spaanplaat', exterior: 'spaanplaat' };
wall.openings = [];
setThickness(wall, t);
return wall;
}
// Dikte = regelwerk + 2x beplating (WallModel.thicknessOf); de diepte van
// stijlen en regels wordt daarop afgestemd.
function setThickness(wallState, thickness) {
const depth = Math.max(20, Math.round(thickness - 2 * wallState.wall.sheathingThick));
wallState.wall.studDepth = depth;
wallState.wall.plateDepth = depth;
}
function thicknessOf(wallState) {
return Math.max(wallState.wall.studDepth, wallState.wall.plateDepth) + 2 * wallState.wall.sheathingThick;
}
function interiorLayout(iw) {
const dx = iw.x2 - iw.x1, dz = iw.z2 - iw.z1;
const alongX = Math.abs(dx) >= Math.abs(dz);
const width = Math.max(300, Math.round(alongX ? Math.abs(dx) : Math.abs(dz)));
iw.wall.wall.width = width;
return {
width, alongX,
cx: (iw.x1 + iw.x2) / 2, cz: (iw.z1 + iw.z2) / 2,
rotY: alongX ? 0 : -Math.PI / 2 // lokaal +X langs +X resp. +Z
};
}
// ------------------------------------------------------------
// Alle onderdelen doorgerekend (+ 3D, tenzij opts.skip3D).
// opts: { wallMaterials, floorMaterials, beplatingVisible, isolatieVisible, skip3D }
// ------------------------------------------------------------
// 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.
function applyGlobalSettings(state) {
const sp = state.settings.spacing;
const tr = state.settings.trasraam;
state.units.forEach(u => {
const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height) : null;
Object.values(u.unit.walls).forEach(ws => {
ws.wall.minSpacing = sp.exterior.min;
ws.wall.maxSpacing = sp.exterior.max;
ws.materials = Object.assign({}, ws.materials, { cladding: state.settings.cladding, trasraam });
});
if (u.roof.followFloor) {
u.roof.deck.floor.width = u.unit.floor.floor.width;
u.roof.deck.floor.depth = u.unit.floor.floor.depth;
u.roof.offsetX = 0;
u.roof.offsetZ = 0;
}
});
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
}
// Doorlopende gevels over alle units: collineaire, aansluitende buiten-
// wandstukken (groupFacadeRuns, core/geometry.js). Zet per stuk p.facade =
// { u0, extStart, isEnd } (zie WallModel.computeBattens): u0 = afstand
// langs het wandvlak van het gevelbegin (links, van buitenaf gezien, incl.
// hoekdoorloop van het eerste stuk) tot de linkerrand van het stuk.
function assignFacades(units) {
const segs = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
const W = p.place.width, T = p.place.thickness;
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 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]) });
})));
groupFacadeRuns(segs).forEach(run => {
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 first = run.segs.reduce((best, sg) => Math.abs(sg.sAt0 - sLeft) < Math.abs(best.sAt0 - sLeft) ? sg : best);
const extLeft = (first.piece.skinExt && first.piece.skinExt.left) || 0;
run.segs.forEach(sg => {
sg.piece.facade = { u0: Math.abs(sg.sAt0 - sLeft) + extLeft, extStart: extLeft > 0, isEnd: Math.abs(sg.sAtW - sRight) < 5 };
});
});
}
function computeParts(state, opts) {
const o = opts || {};
const build = !o.skip3D;
applyGlobalSettings(state);
const rects = unitRects(state);
const rectById = {};
rects.forEach(r => { rectById[r.id] = r; });
const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side]));
const { cuts, seams } = computeSeams(rects, wallThickness);
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade) {
if (!build) return { group: null, stats: null, specs: null };
const built = WallModel.build3D(config, frame, {
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade,
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) };
}
const units = state.units.map(u => {
const rect = rectById[u.id];
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
const floorData = FloorModel.computeFromState(u.unit.floor);
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
materials: o.floorMaterials, osbVisible: o.beplatingVisible !== false, isolatieVisible: o.isolatieVisible !== false
}) : null;
const walls = layout.walls.map(wl => {
const ws = u.unit.walls[wl.side];
const buitenMateriaal = (ws.materials || {}).exterior || 'vochtdoek';
const cladding = (ws.materials || {}).cladding || 'horizontaal';
const trasraam = buitenMateriaal === 'vochtdoek' ? ((ws.materials || {}).trasraam || null) : null;
const wp = UnitModel.wallPieces(wl, ws);
const pieces = wp.pieces.map(p => {
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
return Object.assign(p, {
element: `${u.id}-W${wl.nr}${p.suffix}`,
label: `Unit ${u.id} ${wl.label.toLowerCase()}${p.suffix ? ' (' + p.suffix + ')' : ''}`,
buitenMateriaal, cladding, trasraam,
place: { cx: rect.cx + c.x, cz: rect.cz + c.z, rotY: wl.rotY, width: p.config.wallWidth, thickness: wl.thickness, normal: wl.normal, floorTop: layout.floorTop }
}); // 3D volgt na assignFacades (latwerk/doek per gevel)
});
return Object.assign({}, wl, { pieces, dropped: wp.dropped, removed: pieces.length === 0 });
});
// Dak: dek = vloer-state (180° gespiegeld, RoofModel), op de hoogste
// wandbovenkant van deze unit.
const roofData = FloorModel.computeFromState(u.roof.deck);
const roofSpecs = RoofModel.computeSpecs(roofData.calcData, roofData.resolvedOpenings);
const wallTop = Math.max(...Object.values(u.unit.walls).map(ws => Math.max(ws.wall.heightLeft, ws.wall.heightRight)));
const roofW = roofData.calcData.width, roofD = roofData.calcData.depth;
const roofCx = rect.cx + (u.roof.offsetX || 0), roofCz = rect.cz + (u.roof.offsetZ || 0);
const roof = {
element: `${u.id}-D1`, data: roofData, specs: roofSpecs,
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,
top: layout.floorTop + wallTop + RoofModel.thickness(roofData.calcData),
// Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM)
// horen bij "Beplating", de dakisolatie bij "Isolatie".
group: build ? RoofModel.build3D(roofData.calcData, roofData.resolvedOpenings, {
materials: o.roofMaterials, isolatieVisible: o.isolatieVisible !== false,
osbVisible: o.beplatingVisible !== false,
dakleerVisible: o.beplatingVisible !== false
}) : null
};
return {
id: u.id, rect, layout, floorData, floorSpecs, floorGroup, walls, roof,
floorElement: `${u.id}-V1`
};
});
// 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.
const interior = state.interiorWalls.map((iw, i) => {
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 floorTop = host ? host.layout.floorTop : Math.max(...units.map(u => u.layout.floorTop));
const config = WallModel.configFromState(iw.wall);
const frame = WallModel.computeFrame(config);
const buitenMateriaal = (iw.wall.materials || {}).exterior || 'spaanplaat';
return Object.assign({
id: iw.id, element: `BW-${i + 1}`, label: `Binnenwand ${i + 1}`, config, frame, buitenMateriaal,
place: { cx: lay.cx, cz: lay.cz, rotY: lay.rotY, width: lay.width, thickness: WallModel.thicknessOf(config), floorTop }
}, buildWall(config, frame, buitenMateriaal));
});
// Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken;
// boeidelen aan/uit per unit.
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 })),
Math.max(...units.map(u => u.roof.top))) : null;
// Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots).
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
// Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen".
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
edgeVisible: o.beplatingVisible !== false, spotsVisible: o.lampenVisible !== false
}) : null;
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials()) : null;
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false;
// Sandwichpanelen rondom de vloeren, vanaf het loopvlak naar beneden.
const sandwich = state.settings.sandwich && units.length
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
const sandwichGroup = build && sandwich ? SandwichModel.build3D(sandwich, o.sandwichMaterials || SandwichModel.createMaterials(), {
visible: o.beplatingVisible !== false
}) : null;
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, sandwich, sandwichGroup };
}
// Alle wandstukken (buiten + binnen) plat op een rij.
function allWallPieces(parts) {
const ext = [];
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p))));
return ext.concat(parts.interior);
}
// ------------------------------------------------------------
// Totalen over de hele woning (overzicht + laatste PDF-blad).
// ------------------------------------------------------------
function computeTotals(parts) {
const pieces = allWallPieces(parts);
const kinds = new Map();
const sawList = new Map();
function addPiece(kind, width, depth, length) {
const k = kinds.get(kind) || { kind, count: 0, lengthMM: 0 };
k.count++; k.lengthMM += length;
kinds.set(kind, k);
const L = Math.round(length);
const key = `${width}|${depth}|${L}`;
const g = sawList.get(key) || { width, depth, length: L, count: 0 };
g.count++;
sawList.set(key, g);
}
parts.units.forEach(u => {
const fc = u.floorData.calcData;
fc.joists.forEach(() => addPiece('vloerbalk', fc.joistW, fc.joistH, fc.depth));
const rc = u.roof.data.calcData;
rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.depth));
});
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); }));
const purchase = WallModel.planPurchase(wallBom);
const sumP = (fn) => pieces.reduce((acc, p) => acc + fn(p), 0);
const sumU = (fn) => parts.units.reduce((acc, u) => acc + fn(u), 0);
const byMat = (mat) => pieces.filter(p => p.buitenMateriaal === mat);
const weights = parts.units.map(u => ({
label: `Unit ${u.id}`, element: u.id,
kg: u.floorSpecs.totalKg + u.roof.specs.totalKg + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.totalKg, 0), 0)
}));
if (parts.interior.length) weights.push({ label: 'Binnenwanden', element: 'BW', kg: parts.interior.reduce((a, p) => a + p.specs.totalKg, 0) });
const isolatie = parts.units.map(u => ({
label: `Unit ${u.id}`, element: u.id,
m3: u.floorSpecs.voidM3 + u.roof.specs.voidM3 + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.voidVolumeM3, 0), 0)
}));
if (parts.interior.length) isolatie.push({ label: 'Binnenwanden', element: 'BW', m3: parts.interior.reduce((a, p) => a + p.specs.voidVolumeM3, 0) });
const kindList = Array.from(kinds.values());
return {
kinds: kindList,
sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width),
beamCount: kindList.reduce((a, k) => a + k.count, 0),
purchase,
purchaseWasteMM: purchase.reduce((a, s) => a + s.wasteMM, 0),
purchaseStockMM: purchase.reduce((a, s) => a + s.stockMM, 0),
woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3),
woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg),
battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) },
isolatie,
isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0),
plaatwerk: [
{
naam: 'Spaanplaat (wanden)',
m2: sumP(p => p.stats.spaanplaatAreaM2 + p.stats.spaanplaatBuitenAreaM2),
m3: sumP(p => p.stats.spaanplaatVolumeM3 + p.stats.spaanplaatBuitenVolumeM3),
kg: sumP(p => p.specs.spaanplaatKg) + byMat('spaanplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
},
{ naam: 'OSB-plaat (vloeren + plafonds dak)', m2: sumU(u => u.floorSpecs.osbAreaM2 + u.roof.specs.osbAreaM2), m3: sumU(u => u.floorSpecs.osbVolM3 + u.roof.specs.osbVolM3), kg: sumU(u => u.floorSpecs.osbKg + u.roof.specs.osbKg) },
{ naam: 'Dakleer (EPDM)', m2: sumU(u => u.roof.specs.dakleerM2), m3: null, kg: sumU(u => u.roof.specs.dakleerKg) },
{
naam: 'Cementplaat (buitenzijde)', m2: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatAreaM2, 0),
m3: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatVolumeM3, 0), kg: byMat('cementplaat').reduce((a, p) => a + p.specs.buitenKg, 0)
},
{
naam: 'Cementplaat (trasraam)', m2: sumP(p => p.stats.trasraamAreaM2 || 0),
m3: sumP(p => p.stats.trasraamVolumeM3 || 0), kg: sumP(p => p.specs.trasraamKg || 0)
},
{
naam: parts.sandwich ? `Sandwichpanelen ${SandwichModel.PANEL_WIDTH_MM}x${SandwichModel.PANEL_LENGTH_MM}x${SandwichModel.THICK_MM} (${parts.sandwich.panelCount} platen)` : 'Sandwichpanelen', m2: parts.sandwich ? parts.sandwich.areaM2 : 0, m3: null,
kg: parts.sandwich ? parts.sandwich.kg : 0
},
{
naam: 'Vochtdoek (buitenzijde)', m2: byMat('vochtdoek').reduce((a, p) => a + p.stats.doekAreaM2, 0),
m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + p.specs.buitenKg, 0)
}
],
weights,
totalKg: weights.reduce((a, r) => a + r.kg, 0),
roofEdge: parts.roofEdge ? {
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
fasciaHeight: parts.roofEdge.fasciaHeight,
overhang: parts.roofEdge.overhang,
soffitM2: parts.roofEdge.soffitM2,
trimLengthM: parts.roofEdge.trimLengthMM / 1000,
trimHeight: parts.roofEdge.trimHeight,
spots: parts.roofEdge.spots.length
} : null,
lamps: {
total: parts.lighting.lamps.length,
both: parts.lighting.lamps.filter(l => l.direction === 'both').length,
down: parts.lighting.lamps.filter(l => l.direction === 'down').length,
up: parts.lighting.lamps.filter(l => l.direction === 'up').length
}
};
}
// ------------------------------------------------------------
// Validatie / conversie
// ------------------------------------------------------------
function validateState(design) {
if (!Array.isArray(design.units) || !design.units.length) throw new Error('veld "units" ontbreekt of is leeg');
const ids = new Set();
design.units.forEach((u, i) => {
if (!u.id || ids.has(u.id)) throw new Error(`unit #${i + 1}: id ontbreekt of is dubbel`);
ids.add(u.id);
if (u.attach && !['voor', 'achter', 'links', 'rechts'].includes(u.attach.side)) throw new Error(`unit ${u.id}: ongeldige koppelzijde`);
try { UnitModel.validateState(u.unit || {}); } catch (err) { throw new Error(`unit ${u.id}: ${err.message}`); }
});
(design.interiorWalls || []).forEach((iw, i) => {
['x1', 'z1', 'x2', 'z2'].forEach(f => { if (typeof iw[f] !== 'number') throw new Error(`binnenwand #${i + 1}: ${f} ontbreekt`); });
try { WallModel.validateState(iw.wall || {}); } catch (err) { throw new Error(`binnenwand #${i + 1}: ${err.message}`); }
});
}
// Oud unit-ontwerp (type 'unit-design') -> woning met één unit.
function fromUnitDesign(design) {
const { type, version, exportedAt, project, ...unit } = design;
return normalize({ project: Object.assign(createEmptyProject(), project || {}), units: [{ id: 'A', attach: null, unit }], interiorWalls: [] });
}
function normalize(state) {
state.project = Object.assign(createEmptyProject(), state.project || {});
state.interiorWalls = state.interiorWalls || [];
const def = createDefaultSettings();
state.settings = state.settings || {};
state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {});
if (!WallModel.CLADDING_DIRECTIONS[state.settings.cladding]) state.settings.cladding = def.cladding;
state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {});
state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false;
if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM;
state.settings.sandwich = state.settings.sandwich !== false;
const oldRoof = state.settings.roof || {}; // v0.3.0: edge/spots waren woning-breed
state.settings.roof = RoofModel.normalizeSettings(oldRoof);
// v0.3.1: type/richting van de verlichting stonden per unit; nu woning-breed.
const legacyUnitLight = (state.units.find(u => u.lighting && u.lighting.mode) || {}).lighting;
const lightIn = Object.assign({}, state.settings.lighting || {});
if (!lightIn.mode) lightIn.mode = legacyUnitLight ? legacyUnitLight.mode : (oldRoof.spots === false ? 'geen' : 'spots');
if (!lightIn.direction && legacyUnitLight && legacyUnitLight.direction) lightIn.direction = legacyUnitLight.direction;
state.settings.lighting = LightingModel.normalizeSettings(lightIn);
state.units.forEach(u => {
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; }
if (u.roof.fascia === undefined) u.roof.fascia = oldRoof.edge !== 'daktrim';
u.lighting = { walls: (u.lighting && u.lighting.walls) || {} };
});
return state;
}
return {
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS,
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs,
interiorWallDefaults, setThickness, thicknessOf, interiorLayout,
computeParts, allWallPieces, computeTotals, validateState, fromUnitDesign, normalize
};
})();
+255
View File
@@ -0,0 +1,255 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// house/house-pdf.js — HousePdf: de samenhangende woning-PDF.
// Blad 1 plattegrond woning (units, wandstukken, naden, binnenwanden)
// daarna per unit: vloer (FloorPdf) + elk wandstuk (WallPdf)
// daarna elke binnenwand (WallPdf)
// laatste totalen (balken, inkoop, zaaglijst, glaswol, plaatwerk, gewicht)
// De losse bladen komen uit de gedeelde WallPdf/FloorPdf.
// Plain script, geladen na house-model.js.
// ============================================================
window.HousePdf = (() => {
const KIND_LABELS = {
'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel',
'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl',
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel'
};
const PLAN_ELEMENT = 'PL-1';
function fmt(n, decimals = 1) {
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
// Rechthoek van een wandstuk (bovenaanzicht) als 4 hoekpunten.
function pieceCorners(place, lx0, lx1) {
const t = place.thickness / 2;
return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([lx, lz]) => UnitModel.localToWorld(place, lx, lz));
}
// Bladvolgorde + elementnummers; gedeeld door generate() en de
// onderdelenlijst op blad 1.
function sheetList(parts) {
const list = [{ kind: 'plan', element: PLAN_ELEMENT, label: 'Plattegrond woning' }];
parts.units.forEach(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 })));
list.push({ kind: 'roof', element: u.roof.element, label: `Unit ${u.id} dak (plat)`, unit: u });
});
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
return list;
}
function drawPlanSheet(doc, parts, sheets, sheetOpts) {
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal');
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
sheets.slice(1).map((s, i) => [s.element, s.label, String(i + 2)]),
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
const rects = parts.rects;
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
const drawX0 = 105, drawX1 = 405, drawY0 = 20, drawY1 = 228;
const margin = 32;
const scale = Math.min((drawX1 - drawX0 - margin * 2) / (bx1 - bx0), (drawY1 - drawY0 - margin * 2) / (bz1 - bz0));
const ox = (drawX0 + drawX1) / 2 - ((bx0 + bx1) / 2) * scale;
const oy = (drawY0 + drawY1) / 2 - ((bz0 + bz1) / 2) * scale;
const px = (x) => ox + x * scale, py = (z) => oy + z * scale;
const poly = (pts, style) => {
doc.lines(pts.slice(1).map((p, i) => [px(p.x) - px(pts[i].x), py(p.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], style, true);
};
// Vloeren + unit-letter
rects.forEach(r => {
doc.setDrawColor(...GREY);
doc.setLineWidth(0.2);
doc.rect(px(r.x0), py(r.z0), r.W * scale, r.D * scale, 'S');
doc.setTextColor(...GREY);
doc.setFontSize(16);
doc.text(r.id, px(r.cx), py(r.cz) + 2, { align: 'center' });
});
// Wandstukken (buiten + binnen) met openingen
const drawPiece = (p) => {
doc.setDrawColor(...RED);
doc.setLineWidth(0.3);
poly(pieceCorners(p.place, -p.place.width / 2, p.place.width / 2), 'S');
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 255, 255);
doc.setLineWidth(0.2);
p.config.openings.forEach(op => {
const c = pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w);
poly(c, 'FD');
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
doc.line(px(c[1].x), py(c[1].z), px(c[3].x), py(c[3].z));
});
};
HouseModel.allWallPieces(parts).forEach(drawPiece);
// Dak: dakranden (grijs gestippeld), boeideel/daktrim-buitenrand, spots
doc.setLineDashPattern([1, 1.2], 0);
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
parts.units.forEach(u => doc.rect(px(u.roof.rect.x0), py(u.roof.rect.z0), u.roof.rect.W * scale, u.roof.rect.D * scale, 'S'));
if (parts.roofEdge) {
doc.setLineWidth(0.25);
parts.roofEdge.segments.forEach(sg => {
doc.setDrawColor(...(sg.fascia ? BLACK : GREY));
doc.line(px(sg.outerA.x), py(sg.outerA.z), px(sg.outerB.x), py(sg.outerB.z));
});
doc.setLineDashPattern([], 0);
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 255, 255);
parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD'));
}
doc.setLineDashPattern([], 0);
// Gevellampen
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 211, 107);
doc.setLineWidth(0.15);
parts.lighting.lamps.forEach(l => {
doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD');
});
// Naden: gestippeld oranje
doc.setDrawColor(230, 130, 30);
doc.setLineWidth(0.4);
doc.setLineDashPattern([1.5, 1], 0);
parts.seams.forEach(s => doc.line(px(s.x0), py(s.z0), px(s.x1), py(s.z1)));
doc.setLineDashPattern([], 0);
// Maatlijnen buiten elk buiten-wandstuk + labels binnenwanden
doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE);
doc.setLineWidth(0.15);
doc.setFontSize(6);
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
const n = p.place.normal, off = 6;
const a = UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2);
const b = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
const x0 = px(a.x) + n.x * off, y0 = py(a.z) + n.y * off, x1 = px(b.x) + n.x * off, y1 = py(b.z) + n.y * off;
PdfSheet.dimLine(doc, x0, y0, x1, y1);
const text = `${p.element}: ${p.place.width}`;
const tx = (x0 + x1) / 2 + n.x * 2.5, ty = (y0 + y1) / 2 + n.y * 2.5 + (n.y > 0 ? 1.5 : 0);
if (n.y === 0) doc.text(text, tx, ty, { align: 'center', angle: 90 });
else doc.text(text, tx, ty, { align: 'center' });
})));
doc.setTextColor(...BLACK);
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 });
});
// Buitenmaten woning
doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE);
doc.setFontSize(7);
const topY = py(bz0) - 20, leftX = px(bx0) - 20;
PdfSheet.dimLine(doc, px(bx0), topY, px(bx1), topY);
doc.text('X: ' + Math.round(bx1 - bx0), (px(bx0) + px(bx1)) / 2, topY - 1.5, { align: 'center' });
PdfSheet.dimLine(doc, leftX, py(bz0), leftX, py(bz1));
doc.text('Y: ' + Math.round(bz1 - bz0), leftX - 2, (py(bz0) + py(bz1)) / 2, { align: 'center', angle: 90 });
// Noordpijl (boven = noord)
doc.setFillColor(...BLUE);
doc.triangle(drawX1 - 8, drawY0, drawX1 - 11, drawY0 + 9, drawX1 - 5, drawY0 + 9, 'F');
doc.setTextColor(...BLUE);
doc.setFontSize(8);
doc.text('N', drawX1 - 8, drawY0 + 14, { align: 'center' });
doc.setTextColor(...GREY);
doc.setFontSize(6);
doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' +
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4);
}
function drawTotalsSheet(doc, totals, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal');
doc.setTextColor(...BLACK);
doc.setFontSize(14);
doc.text('Totalen woning', 15, 18);
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 16 });
let y = 30;
const kindRows = totals.kinds.map(k => [KIND_LABELS[k.kind] || k.kind, String(k.count), fmt(k.lengthMM / 1000, 2)]);
kindRows.push(['Totaal', String(totals.beamCount), fmt(totals.kinds.reduce((a, k) => a + k.lengthMM, 0) / 1000, 2)]);
y = PdfSheet.table(doc, 15, y, [0, 45, 65], ['Soort', 'Aantal', 'Lengte (m)'], kindRows, tableOpts('Houtwerk (balken)'));
y += 4;
y = PdfSheet.table(doc, 15, y, [0, 45], ['', ''], [
['Volume hout', fmt(totals.woodM3, 3) + ' m3'],
['Gewicht hout', fmt(totals.woodKg) + ' kg'],
['Latwerk 20x30 (kruislat)', `${totals.battens.count} st. \u00e0 ${WallModel.BATTEN.length} (${fmt(totals.battens.lengthM, 1)} m)`]
], tableOpts('Houtwerk totaal'));
y += 4;
const purchaseRows = [];
totals.purchase.forEach(sec => sec.perStock.forEach(st => purchaseRows.push([`${sec.width}x${sec.depth}`, String(st.stock), String(st.count)])));
purchaseRows.push(['Snijverlies', fmt(totals.purchaseWasteMM / 1000, 2) + ' m',
fmt(totals.purchaseStockMM ? totals.purchaseWasteMM / totals.purchaseStockMM * 100 : 0) + '%']);
PdfSheet.table(doc, 15, y, [0, 30, 55], ['Doorsnede', 'Lengte', 'Aantal'], purchaseRows, tableOpts('Inkoop wandbalken (alle wanden)'));
PdfSheet.table(doc, 120, 30, [0, 20, 40, 62], ['Dikte', 'Hoogte', 'Lengte', 'Aantal'],
totals.sawList.map(g => [String(g.width), String(g.depth), String(g.length), String(g.count)]),
Object.assign(tableOpts('Zaaglijst vuren (totaal)'), { maxY: 285 }));
y = 30;
const isoRows = totals.isolatie.map(r => [r.label, fmt(r.m3, 3) + ' m3']);
isoRows.push(['Totaal glaswol', fmt(totals.isolatieM3, 3) + ' m3']);
y = PdfSheet.table(doc, 215, y, [0, 50], ['Onderdeel', 'Inhoud'], isoRows, tableOpts('Isolatie (glaswol, spouwvullend)'));
y += 4;
y = PdfSheet.table(doc, 215, y, [0, 58, 80, 102], ['Materiaal', 'Opp. (m2)', 'Vol. (m3)', 'Gewicht'],
totals.plaatwerk.map(p => [p.naam, fmt(p.m2, 2), p.m3 === null ? 'n.v.t.' : fmt(p.m3, 3), fmt(p.kg) + ' kg']),
tableOpts('Plaatwerk'));
y += 4;
const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']);
wRows.push(['Totaal woning', fmt(totals.totalKg) + ' kg']);
y = PdfSheet.table(doc, 215, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht'));
if (totals.roofEdge) {
y += 4;
const e = totals.roofEdge, L = totals.lamps;
const rows = [];
if (e.fasciaLengthM > 0) {
rows.push(['Boeidelen', `${fmt(e.fasciaLengthM, 2)} m, ${e.fasciaHeight} mm hoog, overstek ${e.overhang} mm`]);
rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']);
}
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(['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'));
}
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
}
function generate(state) {
const parts = HouseModel.computeParts(state, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
});
const totals = HouseModel.computeTotals(parts);
const sheets = sheetList(parts);
const project = state.project;
const doc = PdfSheet.createDoc();
sheets.forEach((sheet, i) => {
if (i > 0) doc.addPage('a3', 'landscape');
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` };
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 === 'roof') FloorPdf.drawSheet(doc, sheet.unit.roof.data.calcData, sheet.unit.roof.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 drawTotalsSheet(doc, totals, opts);
});
return doc;
}
return { generate, sheetList };
})();
+1103
View File
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// lighting/lighting-model.js — LightingModel: ENIGE bron van de
// buitenverlichting van een woning. Geen DOM.
//
// Woning-breed (settings.lighting): type, lichtrichting en alle afstanden.
// mode: 'geen' | 'spots' (boeideel-spots, alleen waar boeidelen zijn) |
// 'gevel' (gevellampen), direction: 'both' | 'down' | 'up'.
//
// Gevellampen worden per GEVEL verdeeld, niet per wand: wandstukken in
// hetzelfde gevelvlak die op elkaar aansluiten (ook over een naad tussen
// units) vormen samen één gevel van hoek tot hoek. De balklogica per wand
// blijft ongewijzigd; alleen de lampen lopen uniform door.
// Op lamphoogte (standaard 1950 mm) worden openingen incl. raamwerk
// (koningsstijl/dorpels) van de gevel afgetrokken. Op elk vrij stuk van
// minstens minWallWidth (2000 mm) komen lampen: de eerste en laatste op
// endOffset (300 mm) van het uiteinde (hoek of opening), daartussen
// verdeeld met min/max h.o.h. (zelfde verdeling als stijlen). Smallere vrije
// stukken krijgen geen lampen. Gevellampen en boeideel-spots sluiten elkaar
// uit: bij 'gevel' komen er nooit spots.
// Per wand(zijde) blijft een harde override mogelijk
// (unit.lighting.walls[side]): 'auto' (standaard), 'openingen' (lampen op
// openingOffset = 500 mm naast elke opening, bijv. slaapkamers) of 'geen'.
//
// Plain script, geladen na core/geometry.js en roof-model.js.
// ============================================================
window.LightingModel = (() => {
const DEFAULT_SETTINGS = {
mode: 'spots', // 'geen' | 'spots' | 'gevel'
direction: 'both', // gevellampen: 'both' | 'down' | 'up'
lampHeight: 1950, // mm boven vloer
lampMin: 1500, // mm h.o.h. gevellampen
lampMax: 3000,
minWallWidth: 2000, // kleinste vrije gevelbreedte voor gevellampen
endOffset: 300, // eerste/laatste lamp vanaf hoek/uiteinde
openingOffset: 500 // override: lampen zoveel naast elke opening
};
const MODES = { geen: 'Geen', spots: 'Boeideel-spots', gevel: 'Gevellampen' };
const DIRECTIONS = { both: 'Boven én onder', down: 'Alleen onder', up: 'Alleen boven' };
const WALL_OVERRIDES = { auto: 'Automatisch', openingen: 'Naast openingen (override)', geen: 'Geen lampen' };
// Per unit alleen nog de override per wandzijde.
function createDefaultUnitLighting() {
return { walls: {} };
}
function normalizeSettings(settings) {
return Object.assign({}, DEFAULT_SETTINGS, settings || {});
}
// Spots op een lijnstuk A -> B: op beide uiteinden + daartussen verdeeld.
function spotsBetween(A, B, min, max, out) {
const len = Math.hypot(B.x - A.x, B.z - A.z);
out.push({ x: A.x, z: A.z }, { x: B.x, z: B.z });
if (len < 1) return;
calculateSegmentPositions(len, min, max).forEach(p => out.push({ x: A.x + (B.x - A.x) * p / len, z: A.z + (B.z - A.z) * p / len }));
}
// Lampen die dichter dan `min` bij elkaar staan worden samengevoegd tot één
// lamp in het midden, tot alle onderlinge afstanden >= min zijn.
function enforceMinSpacing(positions, min) {
let pts = positions.slice().sort((a, b) => a - b);
let changed = true;
while (changed && pts.length > 1) {
changed = false;
for (let i = 0; i < pts.length - 1; i++) {
if (pts[i + 1] - pts[i] < min - 0.5) {
pts.splice(i, 2, (pts[i] + pts[i + 1]) / 2);
changed = true;
break;
}
}
}
return pts;
}
// Aftrekken van intervallen: [lo, hi] min blocked -> vrije intervallen.
function freeIntervals(lo, hi, blocked) {
const free = [];
let cursor = lo;
blocked.slice().sort((p, q) => p[0] - q[0]).forEach(([a, b]) => {
if (a > cursor) free.push([cursor, Math.min(a, hi)]);
cursor = Math.max(cursor, b);
});
if (cursor < hi) free.push([cursor, hi]);
return free.filter(([a, b]) => b - a > 1);
}
// Alles voor de hele woning. units = HouseModel-parts.units (met
// walls[].pieces[].config/place), stateUnits = state.units (overrides per
// wand), edge = RoofModel.computeEdge-resultaat (edge.spots wordt hier
// gevuld). Zet per wandstuk p.lamps = [{ u, y }] (voor editor/PDF).
// Geeft { lamps: [{ x, y, z, normal, direction, element, u, yLocal }] }.
function compute(settings, roofSettings, units, stateUnits, edge) {
const s = normalizeSettings(settings);
const overrides = {};
stateUnits.forEach(u => { overrides[u.id] = (u.lighting && u.lighting.walls) || {}; });
// 1. Buiten-wandstukken als lijnstukken in hun gevelvlak (wereld-mm).
const segs = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
p.lamps = [];
const W = p.place.width;
// Lampen hangen op de afgewerkte gevel: wandvlak + kruislat + bekleding.
const T = p.place.thickness + 2 * WallModel.claddingOffset(p.buitenMateriaal);
const normal = { x: p.place.normal.x, z: p.place.normal.y }; // place.normal is {x, y} (plattegrond)
const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z';
const perp = axis === 'x' ? 'z' : 'x';
const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2);
segs.push({
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]),
override: overrides[u.id][w.side] || 'auto'
});
})));
const uToS = (sg, u) => sg.sAt0 + (sg.sAtW - sg.sAt0) * (u / sg.piece.place.width);
const sToU = (sg, v) => (v - sg.sAt0) / (sg.sAtW - sg.sAt0) * sg.piece.place.width;
// 2. Gevels: zelfde normaal + zelfde gevelvlak + aansluitend.
const runs = groupFacadeRuns(segs); // core/geometry.js
// 3. Lampen per gevel.
const lamps = [];
const H = s.lampHeight;
const addLamp = (run, v) => {
const sg = run.segs.find(x => v >= x.s0 - 0.5 && v <= x.s1 + 0.5);
if (!sg) return;
const u = sToU(sg, v);
const pt = run.axis === 'x' ? { x: v, z: run.line } : { x: run.line, z: v };
sg.piece.lamps.push({ u, y: H });
lamps.push({ x: pt.x, z: pt.z, y: sg.piece.place.floorTop + H, normal: run.normal, direction: s.direction, element: sg.piece.element, u, yLocal: H });
};
if (s.mode === 'gevel') {
runs.forEach(run => {
const blocked = [];
const pos = []; // lampposities langs deze gevel (wereld-mm)
run.segs.forEach(sg => {
const cfg = sg.piece.config, sW = cfg.studWidth;
const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100;
if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; }
if (sg.override === 'openingen') {
// Harde override: lampen naast elke opening, dit stuk doet verder niet mee.
blocked.push([sg.s0, sg.s1]);
cfg.openings.forEach(op => [op.x - s.openingOffset, op.x + op.w + s.openingOffset]
.filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
return;
}
cfg.openings.filter(op => op.y - sW <= H && op.y + op.h + sW >= H).forEach(op => {
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
blocked.push([Math.min(a, b), Math.max(a, b)]);
});
});
freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => {
if (b - a < s.minWallWidth) return; // te smal: geen lampen
const first = a + s.endOffset, last = b - s.endOffset;
pos.push(first);
calculateSegmentPositions(last - first, s.lampMin, s.lampMax).forEach(p => pos.push(first + p));
pos.push(last);
});
// Min. h.o.h. geldt over ALLE lampen van de gevel samen (ook bij
// afstand hoek/opening, overrides en de grens tussen wanden).
enforceMinSpacing(pos, s.lampMin).forEach(v => addLamp(run, v));
});
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => p.lamps.sort((a, b) => a.u - b.u))));
}
// 4. Boeideel-spots per dakrand-ribbe (alleen bij type 'spots').
if (edge) {
const spots = [];
if (s.mode === 'spots') {
const rs = RoofModel.normalizeSettings(roofSettings);
edge.segments.filter(sg => sg.fascia).forEach(sg => spotsBetween(sg.spotA, sg.spotB, rs.spotMin, rs.spotMax, spots));
}
edge.spots = spots.filter((p, i) => spots.findIndex(q => Math.hypot(q.x - p.x, q.z - p.z) < 5) === i);
}
return { lamps };
}
function createMaterials() {
return {
housingMaterial: new THREE.MeshStandardMaterial({ color: 0x2b2f33, roughness: 0.5, metalness: 0.3 }),
glowMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe2a0, emissiveIntensity: 1 }),
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.
function build3D(lamps, materials) {
const toM = 0.001;
const group = new THREE.Group();
const w = 0.08, h = 0.16, d = 0.07, beamH = 0.7, beamR = 0.22;
lamps.forEach(l => {
const cx = l.x * toM + l.normal.x * d / 2, cz = l.z * toM + l.normal.z * d / 2, cy = l.y * toM;
const alongX = Math.abs(l.normal.z) > 0.5;
const box = new THREE.Mesh(new THREE.BoxGeometry(alongX ? w : d, h, alongX ? d : w), materials.housingMaterial);
box.position.set(cx, cy, cz);
group.add(box);
const addGlow = (sign) => {
const g = new THREE.Mesh(new THREE.BoxGeometry(alongX ? w * 0.8 : d * 0.8, 0.006, alongX ? d * 0.8 : w * 0.8), materials.glowMaterial);
g.position.set(cx, cy + sign * h / 2, cz);
group.add(g);
const cone = new THREE.Mesh(new THREE.ConeGeometry(beamR, beamH, 20, 1, true), materials.beamMaterial);
cone.userData.beam = true; // verborgen in de volle render (dan schijnt echt licht)
if (sign > 0) cone.rotation.x = Math.PI; // punt bij de lamp, breed naar boven
cone.position.set(cx, cy + sign * (h / 2 + beamH / 2), cz);
group.add(cone);
};
if (l.direction !== 'up') addGlow(-1);
if (l.direction !== 'down') addGlow(1);
});
return group;
}
// Volle render: elke gevellamp (per lichtrichting) en elke boeideel-spot
// wordt een echte SpotLight. Gevellampen staan net buiten het gevelvlak en
// schijnen langs de gevel omhoog/omlaag; spots schijnen recht naar beneden.
// spots = [{ x, z }] (mm), spotY = hoogte onderkant boeideel (mm).
function buildLights3D(lamps, spots, spotY) {
const toM = 0.001;
const group = new THREE.Group();
const add = (x, y, z, dirY, intensity, angle, distance) => {
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, 0.6, 1.5);
light.position.set(x, y, z);
light.target.position.set(x, y + dirY * 2, z);
group.add(light);
group.add(light.target);
};
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;
if (l.direction !== 'up') add(x, y - 0.09, z, -1, 3, 0.55, 4.5);
if (l.direction !== 'down') add(x, y + 0.09, z, 1, 3, 0.55, 4.5);
});
(spots || []).forEach(p => add(p.x * toM, spotY * toM - 0.02, p.z * toM, -1, 2.5, 0.6, 5.5));
return group;
}
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, normalizeSettings, compute, createMaterials, build3D, buildLights3D };
})();
+234
View File
@@ -0,0 +1,234 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// roof/roof-model.js — RoofModel: ENIGE bron van de dak-logica (voorlopig
// alleen plat dak). Geen DOM.
//
// Een plat dak is opgebouwd als een vloer (FloorModel: balken, isolatie,
// doorvoeren, OSB-plaat), maar 180° gespiegeld: de OSB-plaat zit aan de
// onderzijde (plafond), de balken erboven, met dakleer als buitenlaag
// bovenop. Het "dek" van een dak is dus gewoon een vloer-state.
//
// Dakrand (over de hele woning, doorlopend over naden tussen units): de
// omtrek van alle daken samen, opgeknipt per ribbe (en op elke unit-hoek).
// Per ribbe bepaalt de unit waar die ribbe bij hoort of het een boeideel of
// een daktrim is (boeidelen aan/uit per unit). De ribben sluiten in verstek
// op elkaar aan, dus op een naad tussen units is niets te zien.
// - boeidelen: een doos van 200..400 mm overstek (gemeten vanaf de
// dakrand = standaard de buitenkant gevel), standaard 300 mm hoog.
// Spots in de onderplaat staan op 40% van de overstek (spotLine); welke
// spots er komen bepaalt LightingModel.
// - zonder boeidelen: een daktrim van 80 mm hoog.
//
// Plain script, geladen na floor-model.js en core/geometry.js.
// ============================================================
window.RoofModel = (() => {
const DAKLEER_MM = 2; // visuele dikte van het dakleer
const DENSITY_DAKLEER_KGM2 = 1.4; // EPDM ~1,4 kg/m2
const TRIM_THICK_MM = 25; // daktrim-profiel, uitstekend buiten de dakrand
const TRIM_ABOVE_MM = 30; // daktrim steekt zoveel boven het dakleer uit
const SPOT_RATIO = 0.4; // spots op 40% van de overstek, gemeten vanaf de gevel
const OVERHANG_MIN = 200, OVERHANG_MAX = 400;
// Woning-brede maten van de dakrand (aan/uit per unit: roof.fascia).
const DEFAULT_SETTINGS = {
overhang: 300, // mm, 200..400
fasciaHeight: 300, // mm
spotMin: 800, // mm h.o.h. boeideel-spots
spotMax: 1200,
trimHeight: 80 // mm
};
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
// Dak-state per unit: volgt standaard de vloermaat (followFloor), maar mag
// groter/kleiner en verschoven worden (override).
function createDefaultRoof(floorState) {
const deck = clone(floorState);
deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
deck.openings = [];
return { followFloor: true, offsetX: 0, offsetZ: 0, fascia: true, deck };
}
function thickness(calcData) {
return calcData.joistH + calcData.underlayThick + DAKLEER_MM;
}
function createMaterials() {
return Object.assign(FloorModel.createMaterials(), {
dakleerMaterial: new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9, side: THREE.DoubleSide }),
fasciaMaterial: new THREE.MeshStandardMaterial({ color: 0x3a3f44, roughness: 0.6, side: THREE.DoubleSide }),
trimMaterial: new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.5, side: THREE.DoubleSide }),
spotOnMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe9a8, emissiveIntensity: 0.9 })
});
}
// 3D van één dak (meters, gecentreerd op het dakmidden, Y = 0 onderkant
// plafondplaat). Het vloer-3D (FloorModel.build3D) wordt gespiegeld
// (scale.y = -1; three.js corrigeert de vlakoriëntatie zelf), daarna
// komt het dakleer er bovenop.
function build3D(calcData, resolvedOpenings, opts) {
const m = opts.materials;
const toM = 0.001;
const group = new THREE.Group();
const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, {
materials: m, osbVisible: opts.osbVisible !== false, isolatieVisible: opts.isolatieVisible !== false
});
const deckH = (calcData.joistH + calcData.underlayThick) * toM;
deck.scale.y = -1;
deck.position.y = deckH;
group.add(deck);
// Dakleer: vlak over het hele dak, met dezelfde ronde uitsparingen als
// de doorvoeren (zelfde opbouw als de OSB-plaat in FloorModel).
const wM = calcData.width * toM, dM = calcData.depth * toM;
const shape = new THREE.Shape();
shape.moveTo(-wM / 2, -dM / 2);
shape.lineTo(wM / 2, -dM / 2);
shape.lineTo(wM / 2, dM / 2);
shape.lineTo(-wM / 2, dM / 2);
shape.closePath();
resolvedOpenings.filter(o => !o.removed && o.r > 0).forEach(o => {
const hole = new THREE.Path();
hole.absarc(o.x * toM, o.y * toM - dM / 2, o.r * toM, 0, Math.PI * 2, false);
shape.holes.push(hole);
});
const leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: DAKLEER_MM * toM, bevelEnabled: false }), m.dakleerMaterial);
leer.rotation.x = Math.PI / 2;
leer.position.y = deckH + DAKLEER_MM * toM;
leer.visible = opts.dakleerVisible !== false;
group.add(leer);
return group;
}
// Specificaties: die van het dek (FloorModel) + dakleer.
function computeSpecs(calcData, resolvedOpenings) {
const deck = FloorModel.computeSpecs(calcData, resolvedOpenings);
const dakleerM2 = deck.osbAreaM2; // zelfde vlak minus doorvoeren
const dakleerKg = dakleerM2 * DENSITY_DAKLEER_KGM2;
return Object.assign({}, deck, { dakleerM2, dakleerKg, totalKg: deck.totalKg + dakleerKg });
}
// ------------------------------------------------------------
// Dakrand over de hele woning
// ------------------------------------------------------------
// Splits de ribben van de omtrek op elke hoek van een dak-rechthoek die op
// die ribbe ligt ("hoek van een unit"), zodat spots daar ook beginnen/eindigen.
function splitAtCorners(pts, rects) {
const corners = [];
rects.forEach(r => corners.push({ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 }));
const out = [];
pts.forEach((a, k) => {
const b = pts[(k + 1) % pts.length];
out.push(a);
const len = Math.hypot(b.x - a.x, b.z - a.z);
corners
.map(c => ({ c, t: ((c.x - a.x) * (b.x - a.x) + (c.z - a.z) * (b.z - a.z)) / (len * len) }))
.filter(({ c, t }) => t > 1e-6 && t < 1 - 1e-6 &&
Math.abs((c.x - a.x) * (b.z - a.z) - (c.z - a.z) * (b.x - a.x)) / len < 0.5)
.sort((p, q) => p.t - q.t)
.forEach(({ c }) => { if (!out.some(o => Math.abs(o.x - c.x) < 0.5 && Math.abs(o.z - c.z) < 0.5)) out.push({ x: c.x, z: c.z }); });
});
return out;
}
function onRectEdge(r, p) {
const e = 0.5;
const inX = p.x >= r.x0 - e && p.x <= r.x1 + e, inZ = p.z >= r.z0 - e && p.z <= r.z1 + e;
return ((Math.abs(p.x - r.x0) < e || Math.abs(p.x - r.x1) < e) && inZ) ||
((Math.abs(p.z - r.z0) < e || Math.abs(p.z - r.z1) < e) && inX);
}
function quadArea(pts) {
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-
// assen); topY = bovenkant dakleer (mm). Geeft per ribbe (segments) de
// geometrie terug; edge.spots wordt door LightingModel gevuld.
function computeEdge(settings, roofs, topY) {
const s = normalizeSettings(settings);
const rects = roofs.map(r => r.rect);
const loops = rectUnionLoops(rects);
if (!loops.length) return null;
const base = splitAtCorners(loops[0].pts, rects);
const offBoei = offsetRectilinearLoop(base, s.overhang);
const offTrim = offsetRectilinearLoop(base, TRIM_THICK_MM);
const offSpot = offsetRectilinearLoop(base, s.overhang * SPOT_RATIO);
const n = base.length;
const segments = base.map((a, i) => {
const j = (i + 1) % n, b = base[j];
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 fascia = owner.fascia !== false;
const off = fascia ? offBoei : offTrim;
return {
a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b),
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j]
};
});
const segLen = (sg) => Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z);
return {
base, segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight,
top: topY, trimTop: topY + TRIM_ABOVE_MM,
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),
soffitM2: segments.filter(sg => sg.fascia).reduce((t, sg) => t + quadArea([sg.a, sg.b, sg.outerB, sg.outerA]), 0) / 1e6,
spots: []
};
}
// 3D van de dakrand: per ribbe een geëxtrudeerd vierhoek (dakrand ->
// buitenrand, in verstek), naar beneden over de hoogte van boeideel resp.
// daktrim. Plus de boeideel-spots (edge.spots) in de onderplaat.
// opts: { edgeVisible (volgt "Beplating"), spotsVisible (volgt "Lampen") }
function buildEdge3D(edge, materials, opts) {
const o = opts || {};
const toM = 0.001;
const group = new THREE.Group();
if (!edge) return group;
edge.segments.forEach(sg => {
const pts = [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)));
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight;
const mesh = new THREE.Mesh(
new THREE.ExtrudeGeometry(shape, { steps: 1, depth: h * toM, bevelEnabled: false }),
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;
group.add(mesh);
});
const spotR = 0.035, spotH = 0.012;
edge.spots.forEach(p => {
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.visible = o.spotsVisible !== false;
group.add(spot);
});
return group;
}
function normalizeSettings(settings) {
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang));
delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit
return s;
}
return {
DAKLEER_MM, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeEdge, buildEdge3D, normalizeSettings
};
})();
+89
View File
@@ -0,0 +1,89 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// sandwich/sandwich-model.js — sandwichpanelen rondom de woning (SandwichModel).
// De vloer staat gedeeltelijk in de grond; de panelen werken de vloerrand af.
// Ze beginnen op Y=0 van de vloer (het beloopbare vlak = bovenkant vloer) en
// lopen DEPTH_MM naar beneden, tegen de buitenkant van de vloerrand, in één
// doorlopende rand (in verstek) om de vereniging van alle unit-vloeren.
// Plain script (geen ES-module), geladen na core/geometry.js.
// ============================================================
window.SandwichModel = (() => {
const DEPTH_MM = 600; // vanaf het loopvlak naar beneden (de grond in)
const THICK_MM = 40; // paneeldikte
// Handelsmaat plaat: 495 breed x 1000 lang x 40 dik, 9 kg per plaat.
// Verticaal geplaatst (495 = werkende breedte langs de gevel), lengte
// 1000 op DEPTH_MM afgezaagd: één plaat per 495 mm gevel.
const PANEL_WIDTH_MM = 495;
const PANEL_LENGTH_MM = 1000;
const PANEL_KG = 9.0;
function createMaterials() {
return {
panelMaterial: new THREE.MeshStandardMaterial({ color: 0x3b3e42, roughness: 0.55, metalness: 0.2 }),
seamMaterial: new THREE.LineBasicMaterial({ color: 0x24262a })
};
}
// rects: unit-vloeren { x0, x1, z0, z1 } (mm, woning-assen); topY =
// loopvlak (mm). Per ribbe van elke omtreklus een vierhoek (vloerrand ->
// buitenkant paneel, in verstek, ook bij binnenhoeken).
function compute(rects, topY) {
if (!rects.length) return null;
const segments = [];
rectUnionLoops(rects).forEach(loop => {
const base = loop.pts, off = offsetRectilinearLoop(base, THICK_MM), n = base.length;
base.forEach((a, i) => {
const j = (i + 1) % n;
segments.push({ a, b: base[j], outerA: off[i], outerB: off[j] });
});
});
// Lengte gemeten over de hartlijn van het paneel.
const lengthMM = segments.reduce((t, sg) => t + (Math.hypot(sg.b.x - sg.a.x, sg.b.z - sg.a.z) + Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z)) / 2, 0);
const areaM2 = lengthMM * DEPTH_MM / 1e6;
const panelCount = segments.reduce((t, sg) => t + Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / PANEL_WIDTH_MM - 1e-9), 0);
return {
segments, top: topY, bottom: topY - DEPTH_MM, depth: DEPTH_MM, thick: THICK_MM,
lengthMM, areaM2, panelCount, kg: panelCount * PANEL_KG, // inkoop: hele platen
wasteMM: PANEL_LENGTH_MM - DEPTH_MM // afzaagstuk per plaat
};
}
// opts: { visible (volgt "Beplating") }
function build3D(data, materials, opts) {
const o = opts || {};
const toM = 0.001;
const group = new THREE.Group();
if (!data) return group;
data.segments.forEach(sg => {
const pts = [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)));
const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: data.depth * toM, bevelEnabled: false });
const mesh = new THREE.Mesh(geo, materials.panelMaterial);
mesh.rotation.x = Math.PI / 2; // shape-y -> wereld-Z, extrusie naar beneden
mesh.position.y = data.top * toM;
group.add(mesh);
// Paneelnaden op de buitenkant, elke PANEL_WIDTH_MM vanaf ribbe-start.
const len = Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z);
const pts3 = [];
for (let d = PANEL_WIDTH_MM; d < len - 1; d += PANEL_WIDTH_MM) {
const t = d / len, eps = 0.5; // net buiten het paneelvlak
const nx = sg.outerA.x - sg.a.x, nz = sg.outerA.z - sg.a.z, nl = Math.hypot(nx, nz) || 1;
const x = (sg.outerA.x + (sg.outerB.x - sg.outerA.x) * t + nx / nl * eps) * toM;
const z = (sg.outerA.z + (sg.outerB.z - sg.outerA.z) * t + nz / nl * eps) * toM;
pts3.push(new THREE.Vector3(x, data.top * toM, z), new THREE.Vector3(x, data.bottom * toM, z));
}
if (pts3.length) group.add(new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(pts3), materials.seamMaterial));
});
group.visible = o.visible !== false;
return group;
}
return { DEPTH_MM, THICK_MM, PANEL_WIDTH_MM, PANEL_LENGTH_MM, PANEL_KG, createMaterials, compute, build3D };
})();
+214
View File
@@ -0,0 +1,214 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// unit/unit-model.js — UnitModel: ENIGE bron van de unit-logica (1 vloer +
// 4 wanden). Geen DOM. Bevat zelf geen wand-/vloerlogica: alles gaat via
// WallModel/FloorModel. Gebruikt door de woning (house/*).
//
// Een unit-state is:
// { layout: { corners }, floor: <vloer-state>, walls: { voor, achter, links, rechts: <wand-state> } }
//
// Assenstelsel (mm, unit-lokaal): X = breedte, Z = diepte, vloermidden op
// (0, 0), "voor" op Z = -diepte/2 (zelfde als FloorModel.build3D).
//
// Naden: wanneer een andere unit tegen een zijde aan staat, vervalt de wand
// over dat stuk (cuts: [a, b, extA, extB], zie HouseModel.computeSeams —
// extA/extB laten het aangrenzende wandstuk de naad in doorlopen om een
// binnenhoek te sluiten). Een wand kan daardoor in meerdere stukken uiteenvallen
// (bijv. de achterwand bij een L-vorm); elk stuk is een volwaardige wand
// (eigen regelwerk, hoekstijlen, PDF-blad). Een wand die haaks op een
// vervallen hoek staat, loopt door tot de vloerrand, zodat de buitengevel
// over de naad heen doorloopt.
//
// Plain script, geladen na wall-model.js en floor-model.js.
// ============================================================
window.UnitModel = (() => {
// Elke wand staat met zijn buitenzijde (+Z lokaal, "zuid" in de wand-
// editor) naar buiten. rotY draait lokaal +Z naar de buitenrichting; "links"
// in de wand-editor = links gezien van buitenaf. axis = de as waarlangs de
// wand loopt; dir = +1 als lokaal +X met die as meeloopt.
// normal = buitenrichting in de plattegrond (x rechts, y omlaag; voor boven).
// Weergave in windrichtingen t.o.v. het 2D-bovenaanzicht (boven = noord):
// voor = noord, achter = zuid, links = west, rechts = oost. De sleutels
// (voor/achter/links/rechts) blijven intern, voor compatibele JSON.
const WALL_SIDES = [
{ key: 'voor', label: 'Wand noord', nr: 1, rotY: Math.PI, axis: 'x', dir: -1, normal: { x: 0, y: -1 } },
{ key: 'achter', label: 'Wand zuid', nr: 2, rotY: 0, axis: 'x', dir: 1, normal: { x: 0, y: 1 } },
{ key: 'links', label: 'Wand west', nr: 3, rotY: -Math.PI / 2, axis: 'z', dir: 1, normal: { x: -1, y: 0 } },
{ key: 'rechts', label: 'Wand oost', nr: 4, rotY: Math.PI / 2, axis: 'z', dir: -1, normal: { x: 1, y: 0 } }
];
// Hoekaansluiting: welke wanden lopen door over de volle vloermaat, en welke
// passen er tussenin.
const CORNER_MODES = {
'voor-achter': 'Noord/zuid doorlopend',
'zijkanten': 'West/oost doorlopend'
};
const DEFAULT_HEIGHT_MM = 2600;
// Kortere wandstukken (restje naast een naad) worden weggelaten.
const MIN_PIECE_MM = 300;
const EPS = 0.5; // mm
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
function createDefaultState(opts) {
const o = opts || {};
const floor = FloorEditor.getDefaultState();
floor.floor.width = o.width || 6000;
floor.floor.depth = o.depth || 3000;
const walls = {};
WALL_SIDES.forEach(s => {
const wall = WallEditor.getDefaultState();
// Standaard unithoogte (los van de standaard van een losse wand).
const h = o.height || DEFAULT_HEIGHT_MM;
wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = h;
if (!(o.windowOn || ['voor']).includes(s.key)) wall.openings = [];
walls[s.key] = wall;
});
const state = { layout: { corners: 'voor-achter' }, floor, walls };
computeLayout(state, {});
return state;
}
// Lokaal wand-punt (mm, x langs de wand gecentreerd, z dwars) -> X/Z in het
// omliggende stelsel. Zelfde rotatie als THREE's rotation.y.
function localToWorld(w, lx, lz) {
const c = Math.cos(w.rotY), s = Math.sin(w.rotY);
return { x: w.cx + lx * c + lz * s, z: w.cz - lx * s + lz * c };
}
// Maat + positie van elke wand t.o.v. het vloermidden, rekening houdend met
// naden. cuts = { voor: [[a, b], ...], ... } in unit-lokale as-coördinaten
// (X voor voor/achter, Z voor links/rechts). Zet ook de afgeleide
// wandbreedte in de wand-states (muteert state).
function computeLayout(state, cuts) {
const c = cuts || {};
const floorFrame = FloorModel.computeFrame(state.floor.floor);
const W = floorFrame.width, D = floorFrame.depth;
const T = {};
WALL_SIDES.forEach(s => { T[s.key] = WallModel.thicknessOf(WallModel.configFromState(state.walls[s.key])); });
// Vervalt de wand van `side` op as-positie p (bijv. een hoek)?
const cutAt = (side, p) => (c[side] || []).some(([a, b]) => a <= p + EPS && b >= p - EPS);
// Effectieve dikte van de doorlopende wand op een hoek: 0 als die daar vervalt.
const tEff = (side, p) => cutAt(side, p) ? 0 : T[side];
let span; // per zijde [s0, s1] langs de eigen as
if (state.layout.corners === 'zijkanten') {
span = {
links: [-D / 2, D / 2],
rechts: [-D / 2, D / 2],
voor: [-W / 2 + tEff('links', -D / 2), W / 2 - tEff('rechts', -D / 2)],
achter: [-W / 2 + tEff('links', D / 2), W / 2 - tEff('rechts', D / 2)]
};
} else {
span = {
voor: [-W / 2, W / 2],
achter: [-W / 2, W / 2],
links: [-D / 2 + tEff('voor', -W / 2), D / 2 - tEff('achter', -W / 2)],
rechts: [-D / 2 + tEff('voor', W / 2), D / 2 - tEff('achter', W / 2)]
};
}
const walls = WALL_SIDES.map(s => {
const [s0, s1] = span[s.key];
const width = Math.max(MIN_PIECE_MM, Math.round(s1 - s0));
const mid = (s0 + s1) / 2;
const off = { voor: -D / 2 + T.voor / 2, achter: D / 2 - T.achter / 2, links: -W / 2 + T.links / 2, rechts: W / 2 - T.rechts / 2 }[s.key];
const cx = s.axis === 'x' ? mid : off;
const cz = s.axis === 'x' ? off : mid;
// Naden omgerekend naar "vanaf links gezien van buitenaf" (u, 0..width),
// incl. doorloop (ext) van het wandstuk vóór resp. ná de naad.
const toU = (p) => s.dir > 0 ? p - s0 : s1 - p;
const rawU = (c[s.key] || [])
.map(([a, b, ea, eb]) => {
const ua = toU(a), ub = toU(b);
const [u0, u1, e0, e1] = ua <= ub ? [ua, ub, ea || 0, eb || 0] : [ub, ua, eb || 0, ea || 0];
return [Math.max(0, u0), Math.min(width, u1), e0, e1];
})
.filter(([a, b]) => b - a > EPS)
.sort((p, q) => p[0] - q[0]);
// Resterende stukken wand; een stuk dat aan een naad grenst loopt de
// naad in door met de doorloop (ext) van die kant.
const pieces = [];
let cursor = 0;
rawU.forEach(([a, b, e0, e1]) => {
const end = Math.min(b, a + e0);
if (end - cursor >= MIN_PIECE_MM) pieces.push([cursor, end]);
cursor = Math.max(cursor, b - e1);
});
if (width - cursor >= MIN_PIECE_MM) pieces.push([cursor, width]);
// Werkelijk vervallende zones (voor de weergave in de wand-editor).
const cutsU = rawU.map(([a, b, e0, e1]) => [a + e0, b - e1]).filter(([a, b]) => b - a > EPS);
state.walls[s.key].wall.width = width;
// Hoekdoorloop buitenlagen (WallModel.cornerExt): een tussenpassende
// wand bedekt ook de kopse kant van de doorlopende wand ernaast.
const full = s.axis === 'x' ? W / 2 : D / 2;
const ext0 = Math.max(0, Math.round(s0 + full)), ext1 = Math.max(0, Math.round(full - s1));
const skinExt = s.dir > 0 ? { left: ext0, right: ext1 } : { left: ext1, right: ext0 };
return {
side: s.key, label: s.label, nr: s.nr, rotY: s.rotY, normal: s.normal, axis: s.axis,
thickness: T[s.key], width, cx, cz, cutsU, pieces, skinExt
};
});
return { floorWidth: W, floorDepth: D, floorTop: FloorModel.topHeight(floorFrame), walls };
}
// Wandstukken van één zijde als losse wand-configs. Openingen worden eerst
// op de volle zijde opgelost (ankers gelden voor de hele zijde) en daarna
// aan het stuk toegewezen waar ze volledig in vallen; openingen die in een
// naad vallen vervallen (dropped).
function wallPieces(wallLayout, wallState) {
const fullConfig = WallModel.configFromState(wallState);
const W = wallLayout.width;
const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight;
const heightAt = (u) => hL + (u / W) * (hR - hL);
const multi = wallLayout.pieces.length > 1;
const used = new Set();
const pieces = wallLayout.pieces.map(([a, b], i) => {
const openings = fullConfig.openings
.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 }; });
const params = Object.assign({}, wallState.wall, {
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b))
});
const config = WallModel.buildConfig(params, openings);
return {
suffix: multi ? String.fromCharCode(97 + i) : '', // a, b, ...
u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2,
skinExt: { left: a < EPS ? wallLayout.skinExt.left : 0, right: b > W - EPS ? wallLayout.skinExt.right : 0 },
config, frame: WallModel.computeFrame(config)
};
});
const dropped = fullConfig.openings.filter(op => !used.has(op.id));
return { pieces, dropped, fullConfig };
}
function validateState(design) {
if (!design.floor) throw new Error('veld "floor" ontbreekt');
try { FloorModel.validateState(design.floor); } catch (err) { throw new Error('vloer: ' + err.message); }
if (!design.walls || typeof design.walls !== 'object') throw new Error('veld "walls" ontbreekt');
WALL_SIDES.forEach(s => {
if (!design.walls[s.key]) throw new Error(`walls.${s.key} ontbreekt`);
try { WallModel.validateState(design.walls[s.key]); } catch (err) { throw new Error(`${s.label.toLowerCase()}: ${err.message}`); }
});
if (design.layout && !CORNER_MODES[design.layout.corners]) {
throw new Error(`layout.corners moet ${Object.keys(CORNER_MODES).join(' of ')} zijn`);
}
}
return { WALL_SIDES, CORNER_MODES, clone, createDefaultState, localToWorld, computeLayout, wallPieces, validateState };
})();
+131
View File
@@ -0,0 +1,131 @@
// ============================================================
// js/vendor/OrbitControls.js — eigen, compacte vervanging van de officiële
// three.js OrbitControls-addon. Geschreven omdat we geen netwerktoegang
// hebben om het origineel te vendoren; dekt precies de subset die wall.js
// gebruikt (`new THREE.OrbitControls(camera, domElement)`,
// `controls.target.set(...)`, `controls.update()`), plus interactie:
// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen.
//
// Moet NA three.min.js geladen worden (leunt op window.THREE).
// ============================================================
(function () {
if (typeof THREE === 'undefined') {
console.error('OrbitControls.js (eigen versie): THREE is niet geladen — controleer de scriptvolgorde.');
return;
}
function OrbitControls(camera, domElement) {
const scope = this;
this.object = camera;
this.domElement = domElement || document;
this.target = new THREE.Vector3();
this.minDistance = 0.1;
this.maxDistance = 1000;
this.minPolarAngle = 0.001;
this.maxPolarAngle = Math.PI - 0.001;
this.rotateSpeed = 1.0;
this.zoomSpeed = 1.0;
this.panSpeed = 1.0;
this.enableRotate = true;
this.enableZoom = true;
this.enablePan = true;
const offset = new THREE.Vector3();
const spherical = new THREE.Spherical();
let isRotating = false;
let isPanning = false;
let lastX = 0, lastY = 0;
function getZoomScale() { return Math.pow(0.95, scope.zoomSpeed); }
this.update = function () {
offset.copy(scope.object.position).sub(scope.target);
spherical.setFromVector3(offset);
offset.setFromSpherical(spherical);
scope.object.position.copy(scope.target).add(offset);
scope.object.lookAt(scope.target);
return true;
};
function onPointerDown(event) {
if (event.button === 2) {
if (!scope.enablePan) return;
isPanning = true;
} else {
if (!scope.enableRotate) return;
isRotating = true;
}
lastX = event.clientX;
lastY = event.clientY;
}
function onPointerMove(event) {
if (isRotating) {
const dx = event.clientX - lastX;
const dy = event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
const w = scope.domElement.clientWidth || 1;
const h = scope.domElement.clientHeight || 1;
offset.copy(scope.object.position).sub(scope.target);
spherical.setFromVector3(offset);
spherical.theta -= (dx / w) * Math.PI * scope.rotateSpeed;
spherical.phi -= (dy / h) * Math.PI * scope.rotateSpeed;
spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi));
spherical.makeSafe();
offset.setFromSpherical(spherical);
scope.object.position.copy(scope.target).add(offset);
scope.object.lookAt(scope.target);
} else if (isPanning) {
const dx = event.clientX - lastX;
const dy = event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
const distance = scope.object.position.distanceTo(scope.target);
const fov = (scope.object.fov || 50) * Math.PI / 180;
const h = scope.domElement.clientHeight || 1;
const factor = 2 * Math.tan(fov / 2) * distance / h;
const te = scope.object.matrix.elements;
const xAxis = new THREE.Vector3(te[0], te[1], te[2]).multiplyScalar(-dx * factor * scope.panSpeed);
const yAxis = new THREE.Vector3(te[4], te[5], te[6]).multiplyScalar(dy * factor * scope.panSpeed);
scope.target.add(xAxis).add(yAxis);
scope.object.position.add(xAxis).add(yAxis);
}
}
function onPointerUp() { isRotating = false; isPanning = false; }
function onWheel(event) {
if (!scope.enableZoom) return;
event.preventDefault();
const scale = event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale();
offset.copy(scope.object.position).sub(scope.target);
const distance = Math.max(scope.minDistance, Math.min(scope.maxDistance, offset.length() * scale));
offset.setLength(distance);
scope.object.position.copy(scope.target).add(offset);
}
function onContextMenu(event) { event.preventDefault(); }
this.domElement.addEventListener('mousedown', onPointerDown);
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
this.domElement.addEventListener('wheel', onWheel, { passive: false });
this.domElement.addEventListener('contextmenu', onContextMenu);
this.dispose = function () {
scope.domElement.removeEventListener('mousedown', onPointerDown);
window.removeEventListener('mousemove', onPointerMove);
window.removeEventListener('mouseup', onPointerUp);
scope.domElement.removeEventListener('wheel', onWheel);
scope.domElement.removeEventListener('contextmenu', onContextMenu);
};
}
THREE.OrbitControls = OrbitControls;
})();
+373
View File
File diff suppressed because one or more lines are too long
+6
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// wall/wall-pdf.js — ENIGE bron van het wand-tekenblad (A3 liggend):
// frame-materiaallijst, plaatmateriaal, vooraanzicht met posnums en
// maatketens, bovenaanzicht (doorsnede regelwerk + beplating), zijaanzicht
// (laagopbouw) en titelblok. Gebruikt door de losse wand-editor (1 blad) en
// door de unit (1 blad per wand in de gezamenlijke PDF).
// Plain script, geladen na wall-model.js en core/pdf-sheet.js.
// ============================================================
window.WallPdf = (() => {
// Tekent één volledig wandblad op de HUIDIGE pagina van `doc`.
// opts: { buitenMateriaal, cladding, trasraam, skinExt, project, titel, element, blad, lamps: [{ u, y }] }
function drawSheet(doc, CONFIG, calcData, opts) {
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
const pieces = WallModel.computeBOM(CONFIG, calcData);
const groups = WallModel.groupBOM(pieces);
const PAGE_W = doc.internal.pageSize.getWidth(), PAGE_H = doc.internal.pageSize.getHeight();
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
doc.setFont('helvetica', 'normal');
// --- Frame materiaal-tabel (linksboven) ---
const tblX = [8, 26, 42, 58, 76, 92];
let ty = 14;
doc.setTextColor(...BLACK);
doc.setFontSize(9);
doc.text('Frame materiaal', tblX[0], ty);
ty += 6;
doc.setFontSize(6.5);
['Posnum', 'Dikte', 'Hoogte', 'Lengte', 'Aantal'].forEach((h, i) => doc.text(h, tblX[i], ty));
ty += 1.5;
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
doc.line(tblX[0], ty, tblX[4] + 12, ty);
ty += 4;
groups.forEach(g => {
if (ty > 232) return; // laat ruimte vrij voor materiaaltabellen eronder
[String(g.posnum), String(g.width), String(g.depth), String(g.length), String(g.count)]
.forEach((v, i) => doc.text(v, tblX[i], ty));
ty += 4.3;
});
// --- Inkoop balken (handelslengtes, zaagplan uit WallModel) ---
const purchase = WallModel.planPurchase(pieces);
ty += 3;
doc.setFontSize(9);
doc.text('Inkoop balken', tblX[0], ty);
ty += 5;
doc.setFontSize(6.5);
['Doorsnede', 'Lengte', 'Aantal', 'Verlies'].forEach((h, i) => doc.text(h, tblX[i], ty));
ty += 4;
purchase.forEach(sec => {
sec.perStock.forEach(st => {
if (ty > 232) return;
const waste = sec.bars.filter(b => b.stock === st.stock).reduce((a, b) => a + b.waste, 0);
[`${sec.width}x${sec.depth}`, String(st.stock), String(st.count), String(waste)].forEach((v, i) => doc.text(v, tblX[i], ty));
ty += 4.3;
});
});
// --- Materiaal binnen/buiten + isolatie ---
let my = 240;
const midH = Math.round((CONFIG.wallHeightLeft + CONFIG.wallHeightRight) / 2);
doc.setFontSize(8.5);
doc.text('Materiaal binnen', tblX[0], my); my += 4.5;
doc.setFontSize(6.5);
doc.text(`${CONFIG.wallWidth} x ${CONFIG.sheathingThick} x ${midH} Spaanplaat 1`, tblX[0], my); my += 6;
doc.setFontSize(8.5);
doc.text('Materiaal buiten', tblX[0], my); my += 4.5;
doc.setFontSize(6.5);
const buitenNaam = WallModel.EXTERIOR_MATERIALS[buitenMateriaal] || 'Vochtdoek';
doc.text(`${CONFIG.wallWidth} x ${CONFIG.sheathingThick} x ${midH} ${buitenNaam} 1`, tblX[0], my); my += 6;
doc.text('Isolatie: glaswol (spouwvullend)', tblX[0], my);
if (buitenMateriaal === 'vochtdoek') {
// Kruislat op het vochtdoek (zelfde berekening als de 3D: WallModel.computeBattens).
const bat = WallModel.computeBattens(CONFIG, opts.cladding || 'horizontaal', opts.trasraam, opts.skinExt, opts.facade);
const B = WallModel.BATTEN;
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);
my += 4;
doc.text(`Bovenste lat ${bat.top}, gevelbekleding ${opts.cladding || 'horizontaal'} (WEO/Novalit)`, tblX[0], my);
if (opts.trasraam) {
my += 4;
doc.text(`Trasraam: cement-gebonden plaat tot ${opts.trasraam.top} mm boven bovenkant vloer`, tblX[0], my);
}
}
// --- Titelblok (rechtsonder) ---
PdfSheet.titleBlock(doc, opts.project, { titel: opts.titel, element: opts.element, blad: opts.blad });
// --- Hoofdaanzicht ---
// drawX1/marginBottom laten ruimte vrij voor het zijaanzicht (rechts)
// en het bovenaanzicht (onder de maatketens).
const drawX0 = 95, drawX1 = 380, drawY0 = 15, drawY1 = 225;
const marginSides = 25, marginTop = 15, marginBottom = 68;
const availW = (drawX1 - drawX0) - marginSides * 2;
const availH = (drawY1 - drawY0) - marginTop - marginBottom;
const wallWidth = CONFIG.wallWidth, hLeft = CONFIG.wallHeightLeft, hRight = CONFIG.wallHeightRight;
const maxH = Math.max(hLeft, hRight);
const scale = Math.min(availW / wallWidth, availH / maxH);
const originX = drawX0 + marginSides;
const floorY = drawY1 - marginBottom;
const px = (xmm) => originX + (xmm + wallWidth / 2) * scale;
const py = (ymm) => floorY - ymm * scale;
doc.setDrawColor(...GREY);
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(hRight));
doc.line(px(wallWidth / 2), py(hRight), px(-wallWidth / 2), py(hLeft));
doc.line(px(-wallWidth / 2), py(hLeft), px(-wallWidth / 2), py(0));
doc.setDrawColor(...RED);
doc.setLineWidth(0.3);
pieces.forEach(p => {
if (p.kind === 'bovenregel') return; // apart getekend hieronder, volgt de helling
const isVertical = p.kind === 'verticale balk' || p.kind === 'kreupele stijl';
if (isVertical) {
// Viervlak i.p.v. rechthoek: de bovenkant loopt schuin mee met de
// daklijn (yTopLeft/yTopRight kunnen verschillen), zodat zowel de
// hoogste als de laagste kant van de balk op de juiste plek komt.
const wx0 = p.x - p.width / 2, wx1 = p.x + p.width / 2;
const corners = [
[px(wx0), py(p.yBottomLeft)], [px(wx1), py(p.yBottomRight)],
[px(wx1), py(p.yTopRight)], [px(wx0), py(p.yTopLeft)]
];
for (let i = 0; i < 4; i++) doc.line(corners[i][0], corners[i][1], corners[(i + 1) % 4][0], corners[(i + 1) % 4][1]);
} else {
const wx0 = p.x - p.length / 2, wx1 = p.x + p.length / 2;
const wy0 = p.y - p.width / 2, wy1 = p.y + p.width / 2;
doc.rect(px(wx0), py(wy1), px(wx1) - px(wx0), py(wy0) - py(wy1), 'S');
}
doc.setTextColor(...BLACK);
doc.setFontSize(5);
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.setLineWidth(0.2);
CONFIG.openings.forEach(op => {
const opXWorld = -wallWidth / 2 + op.x;
const x0 = px(opXWorld), y0 = py(op.y + op.h), x1 = px(opXWorld + op.w), y1 = py(op.y);
doc.rect(x0, y0, x1 - x0, y1 - y0, 'S');
doc.line(x0, y0, x1, y1);
doc.line(x0, y1, x1, y0);
doc.setTextColor(...BLACK);
doc.setFontSize(6);
// Dagmaat-label BINNEN de opening zetten (net onder de bovenkant),
// i.p.v. erboven — daar overlapte het met de maatketen/omtrek
// erboven.
doc.text(`${op.w}x${op.h}`, (x0 + x1) / 2, y0 + 5, { align: 'center' });
});
const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2);
doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE);
doc.setLineWidth(0.15);
doc.setFontSize(7);
const dimTopY = py(maxH) - 7;
drawDimLine(px(-wallWidth / 2), dimTopY, px(wallWidth / 2), dimTopY);
doc.text(String(Math.round(wallWidth)), (px(-wallWidth / 2) + px(wallWidth / 2)) / 2, dimTopY - 1.5, { align: 'center' });
drawDimLine(px(-wallWidth / 2) - 7, py(0), px(-wallWidth / 2) - 7, py(hLeft));
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));
doc.text(String(Math.round(hRight)), px(wallWidth / 2) + 11, (py(0) + py(hRight)) / 2, { align: 'center', angle: 90 });
const studEdges = new Set([0, wallWidth]);
calcData.fullStuds.forEach(x => studEdges.add(Math.round(x - CONFIG.studWidth / 2 + wallWidth / 2)));
const cumPoints = [...studEdges].filter(v => v >= 0 && v <= wallWidth).sort((a, b) => a - b);
doc.setDrawColor(...BLACK);
doc.setTextColor(...BLACK);
doc.setLineWidth(0.1);
doc.setFontSize(5.5);
const segY = floorY + 9, cumY = floorY + 17;
for (let i = 0; i < cumPoints.length - 1; i++) {
const mid = (cumPoints[i] + cumPoints[i + 1]) / 2;
doc.text(String(cumPoints[i + 1] - cumPoints[i]), px(mid - wallWidth / 2), segY, { align: 'center' });
}
cumPoints.forEach(v => {
doc.text(String(v), px(v - wallWidth / 2), cumY, { align: 'center' });
doc.line(px(v - wallWidth / 2), cumY - 2.5, px(v - wallWidth / 2), floorY + 2);
});
const openingPoints = [0];
CONFIG.openings.slice().sort((a, b) => a.x - b.x).forEach(op => { openingPoints.push(op.x, op.x + op.w); });
openingPoints.push(wallWidth);
const openY = floorY + 25;
doc.text('Opening', drawX0, openY);
for (let i = 0; i < openingPoints.length - 1; i++) {
const mid = (openingPoints[i] + openingPoints[i + 1]) / 2;
doc.text(String(Math.round(openingPoints[i + 1] - openingPoints[i])), px(mid - wallWidth / 2), openY, { align: 'center' });
}
// --- Gevellampen (woning): rechthoekje met "L" op lamphoogte ---
(opts.lamps || []).forEach(l => {
const lx = px(-wallWidth / 2 + l.u), ly = py(l.y);
doc.setDrawColor(...BLACK);
doc.setFillColor(255, 211, 107);
doc.setLineWidth(0.2);
doc.rect(lx - 1.2, ly - 2, 2.4, 4, 'FD');
doc.setTextColor(...BLACK);
doc.setFontSize(5);
doc.text('L', lx + 2, ly + 1);
});
// --- Bovenaanzicht (doorsnede op stijlhoogte, zoals de voorbeeld-
// tekeningen): binnenzijde boven, buitenzijde onder (= de kant van
// waaruit het vooraanzicht bekeken wordt). Zelfde horizontale schaal.
const sT = CONFIG.sheathingThick;
const frameD = Math.max(CONFIG.studDepth, CONFIG.plateDepth);
const tvY0 = floorY + 34;
const tvFrame0 = tvY0 + sT * scale;
const tvFrame1 = tvFrame0 + frameD * scale;
const tvY1 = tvFrame1 + sT * scale;
const halfW = wallWidth / 2;
// Beplating, onderbroken ter plaatse van de openingen.
const skinCuts = CONFIG.openings.map(op => [op.x, op.x + op.w]).sort((a, b) => a[0] - b[0]);
const skinSegments = [];
let skinCursor = 0;
skinCuts.forEach(([a, b]) => { if (a > skinCursor) skinSegments.push([skinCursor, a]); skinCursor = Math.max(skinCursor, b); });
if (skinCursor < wallWidth) skinSegments.push([skinCursor, wallWidth]);
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
skinSegments.forEach(([a, b]) => {
doc.rect(px(a - halfW), tvY0, (b - a) * scale, sT * scale, 'S');
doc.rect(px(a - halfW), tvFrame1, (b - a) * scale, sT * scale, 'S');
});
// Stijlen (vol + kreupel) als doorsneden: rechthoek met kruis.
doc.setDrawColor(...RED);
doc.setLineWidth(0.2);
const studXs = calcData.fullStuds.concat(calcData.jackStuds.map(j => j.x));
studXs.forEach(x => {
const x0 = px(x - CONFIG.studWidth / 2), x1 = px(x + CONFIG.studWidth / 2);
doc.rect(x0, tvFrame0, x1 - x0, tvFrame1 - tvFrame0, 'S');
doc.line(x0, tvFrame0, x1, tvFrame1);
doc.line(x0, tvFrame1, x1, tvFrame0);
});
doc.setTextColor(...GREY);
doc.setFontSize(6);
doc.text('Bovenaanzicht', drawX0, (tvY0 + tvY1) / 2 + 1);
doc.text('binnen', px(-halfW) - 2, tvY0 + 1, { align: 'right' });
doc.text('buiten', px(-halfW) - 2, tvY1 + 1, { align: 'right' });
doc.setDrawColor(...BLUE);
doc.setTextColor(...BLUE);
doc.setLineWidth(0.15);
doc.setFontSize(6);
const tvDimX = px(halfW) + 5;
drawDimLine(tvDimX, tvY0, tvDimX, tvY1);
doc.text(`${sT} / ${frameD} / ${sT}`, tvDimX + 2, (tvY0 + tvY1) / 2 + 1);
const tvDimY = tvY1 + 6;
drawDimLine(px(-halfW), tvDimY, px(halfW), tvDimY);
doc.setFontSize(7);
doc.text(String(Math.round(wallWidth)), px(0), tvDimY + 3.5, { align: 'center' });
// --- Zijaanzicht (rechterkopse kant): laagopbouw binnen | regelwerk |
// buiten, met onder- en bovenregel, op dezelfde schaal.
const svX0 = 392;
const svFrame0 = svX0 + sT * scale;
const svFrame1 = svFrame0 + frameD * scale;
const svX1 = svFrame1 + sT * scale;
const svSlope = (hRight - hLeft) / wallWidth;
const svVOff = CONFIG.plateThick * Math.sqrt(1 + svSlope * svSlope);
doc.setDrawColor(...GREY);
doc.setLineWidth(0.15);
doc.rect(svX0, py(hRight), sT * scale, hRight * scale, 'S');
doc.rect(svFrame1, py(hRight), sT * scale, hRight * scale, 'S');
doc.setDrawColor(...RED);
doc.setLineWidth(0.2);
doc.rect(svFrame0, py(hRight), svFrame1 - svFrame0, hRight * scale, 'S');
doc.rect(svFrame0, py(CONFIG.plateThick), svFrame1 - svFrame0, CONFIG.plateThick * scale, 'S');
doc.rect(svFrame0, py(hRight), svFrame1 - svFrame0, svVOff * scale, 'S');
doc.setTextColor(...GREY);
doc.setFontSize(6);
doc.text('Zijaanzicht', (svX0 + svX1) / 2, py(maxH) - 4, { align: 'center' });
doc.text('buiten', svX1 + 1.5, floorY - 1);
doc.setTextColor(...BLUE);
doc.setFontSize(5.5);
doc.text(`${sT} ${frameD} ${sT}`, (svX0 + svX1) / 2, floorY + 5, { align: 'center' });
}
return { drawSheet };
})();
+1373
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"type": "floor-design",
"version": 1,
"note": "Referentieformaat — floor.js heeft nog geen Importeer/Exporteer-knoppen zoals wall.js. Dit bestand documenteert alvast het formaat waarop dat straks kan aansluiten.",
"floor": {
"width": 4000,
"depth": 4000,
"joistWidth": 70,
"joistHeight": 220,
"minSpacing": 300,
"maxSpacing": 600,
"underlayThick": 22
},
"chassis": {
"enabled": false,
"wheelDiameter": 400,
"trackWidth": 1200,
"wheelbase": 1200
},
"openings": []
}
+31
View File
@@ -0,0 +1,31 @@
{
"type": "wall-design",
"version": 1,
"wall": {
"width": 6000,
"heightAll": 2400,
"heightLeft": 2400,
"heightRight": 2400,
"studWidth": 38,
"studDepth": 235,
"plateThick": 38,
"plateDepth": 235,
"sheathingThick": 10,
"minSpacing": 240,
"maxSpacing": 480
},
"materials": {
"interior": "spaanplaat",
"exterior": "vochtdoek"
},
"openings": [
{
"id": 1,
"w": 1200,
"h": 1200,
"y": 600,
"anchor": "middle",
"offset": 0
}
]
}
+4
View File
@@ -0,0 +1,4 @@
Hier komen door gebruikers opgeslagen/geëxporteerde ontwerpen (wand-designs
via de "Exporteer JSON"-knop, en later ook vloer-/dak-designs).
Nog leeg — dit is puur de bestemmingsmap.