Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7215b9e62 | ||
|
|
0da1769f51 | ||
|
|
4bb1548125 | ||
|
|
73248a69c9 | ||
|
|
b8cc39275b | ||
|
|
118ccf9098 | ||
|
|
432e0f494a | ||
|
|
decbbb4b72 | ||
|
|
51921650e3 | ||
|
|
89cadbebf7 | ||
|
|
ab0dca9bb5 | ||
|
|
adb3c74f85 | ||
|
|
86fcd57b96 | ||
|
|
b137a458d4 | ||
|
|
bd084e4a96 | ||
|
|
ceda0b554d | ||
|
|
b5510d56c7 | ||
|
|
e1a688728c | ||
|
|
8cdfa21f46 |
@@ -18,20 +18,40 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
||||
## Structuur
|
||||
|
||||
```
|
||||
index.html app-shell: menubalk, splashscreen
|
||||
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
||||
css/app.css alle stijl
|
||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF
|
||||
js/core/ gedeelde helpers: geometrie, UI-kit, PDF-bladen, labels,
|
||||
camerageheugen, weergavefilter, skybox, licentie, logo
|
||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF),
|
||||
model-viewer 4.3.1 (Apache-2.0, Google; UMD-bundel met eigen three.js, AR in de light-versie)
|
||||
js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel
|
||||
(inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen,
|
||||
DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver
|
||||
(obj-writer.js), GLB-schrijver (glb-writer.js), ZIP (zip-writer.js), labels, camerageheugen,
|
||||
skybox, licentie, logo
|
||||
js/wall/ WallModel (logica) + wand-PDF + wand-editor
|
||||
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
||||
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
|
||||
PostModel: palen onder een overkapping/veranda (dak per unit te verlengen), of een dichte
|
||||
zijwand op een vrije hoek
|
||||
js/house/chimney-model.js ChimneyModel: schoorsteen tegen een buitenwand (hol, sparing in de wand zoals een raam,
|
||||
ook over een naad, zonder hout erin; sparing in het dak met raveelwerk (stijlen, balken, kreupele
|
||||
balken), de beplating eindigt op het kader; onderbreekt de dakrand; een nokbalk stopt ervoor;
|
||||
buiten steenstrip/stuc, binnen als de wanden of eigen; in de plattegrond
|
||||
te verslepen langs de buitengevel)
|
||||
js/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots
|
||||
js/elektra/ ElektraModel + ElektraPdf: meterkast, schakelaars (letters), wcd, groepen, leidingen
|
||||
js/sandwich/ SandwichModel: sandwichpanelen rondom
|
||||
js/room/ RoomModel: ruimtes (verfemmer), functie, netto m², afwerking, vals plafond
|
||||
js/interior/ FixtureModel: inrichting (sanitair, keuken, kasten, meubels, boiler)
|
||||
js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
|
||||
js/house/ HouseModel (woning) + woning-PDF + woning-editor
|
||||
js/house/ HouseModel (woning) + woning-PDF + CAD-export (house-dxf.js) + BIM-export (house-ifc.js) + woning-editor
|
||||
js/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
|
||||
js/app.js menubalk, versienummer, actieve editor
|
||||
js/crm.js koppeling met de Van Emous CRM (alleen in de Docker-stack, zie "CRM")
|
||||
light.html light-versie (modelpagina's), los te openen: light.html?model=amstel
|
||||
js/light/ LightModel (keuzes op een model toepassen, indelingscontrole, kerncijfers), LightPdf (klant-PDF) + light-UI
|
||||
css/light.css stijl light-versie, alles onder .tectum-light
|
||||
json/defaults/ referentie-JSON's (niet runtime ingeladen)
|
||||
kosten/ intern.csv (eigen waarden) + leveranciers.csv (live prijzen), bij het starten ingeladen
|
||||
assets/ logo, texturen, skybox
|
||||
```
|
||||
|
||||
@@ -44,6 +64,17 @@ assets/ logo, texturen, skybox
|
||||
- **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
|
||||
links = west, rechts = oost (interne sleutels blijven voor/achter/…).
|
||||
- **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
|
||||
- **Weergavelagen** (`ViewLayers`): regelwerk, elektra, beplating, vochtdoek,
|
||||
latwerk, gevelbekleding, kozijnen, dakbedekking, afwerking, verlichting,
|
||||
inrichting, ruimtes, dak en maten, elk los aan/uit; de snelkeuze Fase zet
|
||||
alles tot en met een bouwfase aan (de volgorde in de hal). Lagen die aan
|
||||
beide zijden zijn ingesloten worden niet
|
||||
gebouwd (stijlen tussen spaanplaat en doek, latten en doek achter de rabat,
|
||||
sporen tussen plafond en dakbedekking). Specificaties en totalen rekenen
|
||||
altijd met alle lagen.
|
||||
- **Invoer volgt de logica:** de dakvorm is een eigenschap van de unit
|
||||
(plat/lessenaar/zadel, richting, goot- en nokhoogte of helling); de
|
||||
wanden volgen.
|
||||
|
||||
## Functionaliteit
|
||||
|
||||
@@ -52,9 +83,108 @@ assets/ logo, texturen, skybox
|
||||
38x235 met dubbele hoekstijlen en een dubbele stijl naast deuren, zaagplan
|
||||
op handelslengtes, spaanplaat binnen, vochtdoek (met kruislat en trasraam)
|
||||
of cementplaat buiten.
|
||||
- **Wandhoogte:** schuine bovenrand of middenpunt (nok) met goothoogtes. In
|
||||
een unit bepaalt de laatst gewijzigde wand de dakvorm: de tegenoverliggende
|
||||
wand spiegelt mee, de haakse wanden worden vlak op de hoekhoogtes.
|
||||
- **Elektra** (`elektra/`): meterkast (3×25 A) bij de voordeur, schakelaars
|
||||
met letter (A binnen, B buiten, per unit een eigen; dezelfde letter bij de
|
||||
lampen erop), dubbele wcd's per unit, apparaten met eigen groep (kookgroep
|
||||
perilex, wasmachine, droger, warmtepomp, optioneel oven/vaatwasser).
|
||||
Groepenverklaring met fasen en aardlekschakelaars, leidingen via stijlen
|
||||
en plafond naar de meterkast (strekkende meters per type, draad, buis,
|
||||
steekverbinders op module-naden), materiaallijst en eigen PDF-blad.
|
||||
Buizen minstens 50 mm uit elkaar (gebundeld alleen het laatste stuk naar
|
||||
de meterkast). Nooit door een raam of deur (`makeOpeningTest`): een
|
||||
stijgleiding boven/onder een opening (wcd onder een raam) gaat eerst op
|
||||
dezelfde hoogte opzij tot naast de opening, langs dezelfde wand met een
|
||||
deur of raam ertussen via het plafond, gevellampen om de gevelhoeken heen,
|
||||
en uit elkaar schuiven van buizen eindigt nooit in een opening. Vloer-wcd's met leiding door de vloer. Elektra ook op de
|
||||
PDF-bladen per wand, vloer en dakvlak (plafond). Alles verplaatsbaar door
|
||||
te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per
|
||||
meter, boorgaten (geteld op stijlen, regels en balken), dozen,
|
||||
afmonteren, groepenkast en keuring.
|
||||
- **Kozijnen** (`wall/kozijn-model.js`): per opening een vulling (geen, raam
|
||||
met vast glas en optioneel ventilatierooster, of deur met dorpel en
|
||||
optionele glasstrook of als glasdeur; zij- en bovenlicht vullen de rest van een grote
|
||||
opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de
|
||||
maat van de opening, ook een schuine bovenzijde. Deur: kant van het blad,
|
||||
scharnierzijde en draairichting (naar binnen/buiten); openslaande deuren
|
||||
met zijlichten = twee deuren naast elkaar. Koppelen: een opening die tegen
|
||||
zijn buur aan staat (zelfde onderkant en hoogte, samen max. 2850 mm) vormt
|
||||
daarmee één sparing zonder stijlen, dagkanten, latten of kozijnstijlen
|
||||
ertussen; twee draaideuren worden stolp (bladen tegen elkaar), anders één
|
||||
gedeelde tussenstijl. Schuifdeur: 1 of 2 bladen
|
||||
(uit elkaar), in de wand of op de wand aan een rail (binnen- of
|
||||
buitenzijde), glazen of dicht blad. In de plattegrond (app en PDF) een
|
||||
draaicirkel per deur en de schuifrichting per schuifdeur; in de loopmodus
|
||||
gaan deuren open (dubbele schuifdeur samen). Dagkanten (afgewerkte
|
||||
fase): binnen een geschilderde omranding, dagkantplaten tot het kozijn en
|
||||
een vensterbank onder ramen; buiten een omranding in de kozijnkleur en
|
||||
een waterslag onder ramen (`WallModel.build3D`).
|
||||
- **Plafond:** in de afgewerkte fase is de onderkant van het dak
|
||||
geschilderd in de plafondkleur (Binnen › Wanden › Plafond), zolang
|
||||
"Geschilderd" aan staat; anders kale OSB.
|
||||
- **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat,
|
||||
lessenaar, zadel of wolfsdak, met schuiven en hellingshoek).
|
||||
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij een
|
||||
wolfsdak afgeknot op het wolfseind, met
|
||||
daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een
|
||||
rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het
|
||||
bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers,
|
||||
nokbalk en kopbalken (ook in zaaglijst, specificaties en kosten).
|
||||
Een dubbel lessenaardak (twee units, elk hoog aan hun gezamenlijke naad,
|
||||
op de mm gelijke goot, nok en hoogte op de naad: samen een zadeldak)
|
||||
krijgt net als een zadeldak een stalen nokbalk onder de naad; één
|
||||
lessenaar of twee die zo niet aansluiten niet. Staat er een schoorsteen
|
||||
op de nok, dan stopt elke nokbalk vóór de schoorsteen.
|
||||
Goot en nok gelden over de volle vloermaat (goot boven de buitenkant van
|
||||
de gevel); staat de kopgevel tussen de haakse wanden, dan loopt zijn
|
||||
daklijn daar precies op aan. Het wolfseind schaalt mee met goot en
|
||||
nok (verhouding). De PDF heeft voor een wolfsdak een eigen blad met het
|
||||
bovenste deel (bovenaanzicht houtwerk + stuklijst).
|
||||
Grenzen (`UnitModel.LIMITS`): nok max 5 m, goot / plat dak max 3 m (vanaf
|
||||
bovenkant vloer tot onderkant dak), unit max 4,7 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen
|
||||
boeideel (versprongen, het lage loopt door tot onder het hoge). Wijzig je een
|
||||
wandhoogte in de wand-editor, dan geldt dezelfde regel vanuit die wand.
|
||||
De daklijn van een wand hoort bij de wand zonder naden: loopt een wand
|
||||
door een naad langer door (bijv. de kopgevel naast een gekoppelde unit),
|
||||
dan blijven nok en knikken op hun plaats (`UnitModel.topLineOn`).
|
||||
- **Dakaansluiting (L/T):** staat een unit met nokdak (zadel of
|
||||
wolfsdak) met zijn nok haaks op de langszijde van een buur met nokdak
|
||||
(vleugel tegen hoofdvolume), dan loopt de vleugel het hoofddak in tot
|
||||
zijn nok, met kilkepers in de binnenhoeken (bij gelijke goot, nok en
|
||||
overspanning onder 45°); het
|
||||
wolfseind van de vleugel aan de naad vervalt. Ligt de vleugel gelijk met
|
||||
een kop van het hoofdvolume, dan wordt die kop een schild in de vlakken
|
||||
van de vleugel (hoekkeper, de goot loopt door; de kopgevel staat dan op
|
||||
goothoogte, een wolfseind daar vervalt). Een vleugel die in een wolfseind
|
||||
van het hoofdvolume zou steken sluit niet aan. Elke unit houdt zijn eigen
|
||||
dakdeel (sporen, kreupele sporen, kepers, knik- en nokbalken; stalen
|
||||
nokbalk alleen over de eigen nok) met een eigen PDF-blad
|
||||
(`HouseModel.roofJoins`, `RoofModel.computeJoinedRoof`). Niet bij dak
|
||||
over de hele woning; dakramen in zo'n dakdeel doen nog niet mee.
|
||||
- **Overkapping:** bij een dak over de hele woning met `cover` loopt het
|
||||
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
|
||||
terras naast een kortere module), met een afgewerkte onderplaat in de
|
||||
kleur van het boeideel (`HouseModel.coverExtensions`). Palen
|
||||
(Woning › Dak › Palen overkapping) komen onder de vrije dakrand; met
|
||||
"Zijwand" krijgt een vrije hoek geen paal maar een gestuukte wand van
|
||||
200 mm van de dakhoek terug tot de gevel, langs de zijkant van de
|
||||
overkapping (bij een hoek tussen twee vrije randen de kant met de kortste
|
||||
weg naar de gevel); in 3D, plattegrond, PDF, DXF en kosten
|
||||
(`PostModel`, `dak.zijwandM2`/`dak.zijwandMontage`). Ligt de buitenkant
|
||||
in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat,
|
||||
steen) erover door, gelijk met de gevel en tot onder de dakrand.
|
||||
Bij een overkapping (veranda, inpandig of uitpandig: het dak van een unit
|
||||
steekt buiten zijn vloer, `HouseModel.hasCover`) is er geen wolfsdak: de
|
||||
keuze verdwijnt en een bestaand wolfsdak wordt een zadeldak met dezelfde
|
||||
goot en nok.
|
||||
- **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één
|
||||
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle
|
||||
units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden
|
||||
de wolfseinden kilkepers vragen, dan wordt het een zadeldak), bijv. een
|
||||
grote wolfskap over twee units naast elkaar. Elke unit
|
||||
houdt zijn eigen dakdeel (dekken, bovenste deel van het wolfsdak geknipt
|
||||
op de unit, PDF-bladen, kosten); de wanden van alle units volgen het
|
||||
woningdak. De daken per unit blijven bewaard (`roof.unitWalls`) en komen
|
||||
terug als je het weer uitzet.
|
||||
- **Vloer:** balken, cirkelvormige doorvoeren, OSB, optioneel chassis;
|
||||
in de woning een vloerafwerking (textuur, bijv. visgraat).
|
||||
- **Woning:** units koppelen aan een zijde (uitlijning + verschuiving); op
|
||||
@@ -70,36 +200,336 @@ assets/ logo, texturen, skybox
|
||||
- **Dak:** per unit opgebouwd als gespiegelde vloer + dakleer, in de vorm
|
||||
die de wanden aangeven: plat, lessenaar (schuine kopgevels) of zadel
|
||||
(nok). Elk dakvlak is een eigen dek met de balken als sporen en een eigen
|
||||
PDF-blad; doorvoeren gaan naar het vlak waarin ze vallen. Bedekking per
|
||||
unit: dakleer, of bij een hellend dak riet (180 mm, 3,8 kg/m²); de
|
||||
dakrand schuift dan mee omhoog. De dakrand
|
||||
PDF-blad; doorvoeren gaan naar het vlak waarin ze vallen. Opbouw:
|
||||
plafondplaat (OSB) binnen, sporen, dakbeschot (OSB 18 mm) bovenop en
|
||||
daarop de bedekking per unit: dakleer, of bij een hellend dak riet
|
||||
(180 mm, 3,8 kg/m²) of felsplaten (staande naad, zink/antraciet/zwart);
|
||||
de dakrand schuift dan mee omhoog. De dakrand
|
||||
(boeidelen/daktrim, spots) loopt als één rand door en volgt de helling.
|
||||
Is een dak langs de naad minstens een boeideelhoogte hoger dan het
|
||||
aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak
|
||||
zonder spots). De rand van het lage dak loopt dan door tot de gevel van
|
||||
het hoge, of langs een zijgevel tot de volle overstek (onder de hoge
|
||||
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit.
|
||||
- **Gevel:** gevelbekleding per woning, met override per wand: rabat
|
||||
(vochtdoek + kruislat) of steen (181 texturen). Steen is overal een
|
||||
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit. Hebben
|
||||
dak én gevel onder een rand felsplaten, dan komt er geen boeideel: de
|
||||
dakplaat zet om over de rand, net vóór de gevelbekleding en zonder
|
||||
opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de
|
||||
felsplaten); een boeideel ernaast stopt gelijk met de buitenkant van zo'n
|
||||
rand (ook naast daktrim). Onder de boeidelen zit op de gevel een
|
||||
ventilatierooster van 40 mm (direct onder de onderplaat, op de
|
||||
gevelbekleding; niet boven een veranda), zwart/antraciet/wit
|
||||
(`RoofModel.VENT`, Woning › Dak › Boeideel; kosten `dak.ventRoosterM`).
|
||||
- **Gevel:** gevelbekleding per woning, met override per wand en per
|
||||
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
|
||||
getekend als plankentextuur die over naden doorloopt), steen (181
|
||||
texturen), stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
|
||||
per woning, `WallModel.STUC`) of felsplaten (staande naad, banen van
|
||||
500 mm, zink/antraciet/zwart; opbouw als verticale rabat, kleuren gedeeld
|
||||
met de felsplaten op het dak, `WallModel.FELS`; kosten `gevel.felsM2` en
|
||||
`gevel.felsMontage`). Een zijde kan gesplitst worden (wand ›
|
||||
Wandstukken › Splitsen op, gemeten vanaf de linker vloerhoek van buitenaf):
|
||||
elk stuk wordt een eigen wand-element en kan een eigen gevel krijgen
|
||||
(bijv. stuc links, hout rechts). Steen is overal een
|
||||
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
|
||||
Het trasraam (cementplaat of een eigen steen) komt onder beide, dus
|
||||
twee stenen kan. Latwerk, doek en metselwerk lopen per gevel door over
|
||||
twee stenen kan. Op een stenen trasraam onder rabat, felsplaten of stuc
|
||||
(minder ver uitstekend) komt een afdekker: zetwerk 5° aflopend met een
|
||||
neus van 25 mm over de steen, onderbroken bij openingen, zwart/antraciet/wit
|
||||
(`WallModel.TRAS_CAP`, `TRIM_COLORS`; kosten `gevel.afdekkerM`). Latwerk, doek en metselwerk lopen per gevel door over
|
||||
unit-naden. Sandwichpanelen rondom, 600 mm de grond in.
|
||||
- **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is
|
||||
een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door
|
||||
een wand te gaan (deuren en ramen tellen als wand). Exact op het raster van
|
||||
de wandvlakken, dus de netto vloer tot op de mm; kiertjes tot 30 mm tussen
|
||||
wanden worden dichtgezet. Gereedschap *Ruimte vullen* (Start/Binnen): kies
|
||||
een functie (woonkamer, keuken, slaapkamer, badkamer, toilet, hal, ...) en
|
||||
klik in de plattegrond; of selecteer een gebied en kies daar de functie
|
||||
(nog eens klikken = de unit eronder). Per ruimte: naam, vloerafwerking
|
||||
(o.a. tegels), FIBO-wandpanelen (decor, tot een hoogte of tot het plafond),
|
||||
eigen verfkleur en een vals plafond op een vaste hoogte (alleen waar het
|
||||
dak hoger ligt; wandafwerking en binnenspots volgen). Laag in de
|
||||
plattegrond (Lagen › Ruimtes), label met netto m², in de structuur,
|
||||
het rapport, de kosten (vals plafond, FIBO) en op de PDF-plattegrond met
|
||||
een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee.
|
||||
- **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset
|
||||
aan een voorzetwand), fontein, badmeubel met spiegel, inloopdouche
|
||||
(douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met
|
||||
koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat,
|
||||
kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil,
|
||||
salontafel, tv-meubel, boiler en wasmachine. Gereedschap Inrichting (I): kies
|
||||
een stuk in de werkbalk en klik in de plattegrond; slepen, bij een wand snapt de rug op
|
||||
het wandvlak (Alt = vrij), D = draaien, Del = verwijderen. Per stuk maten
|
||||
en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D
|
||||
(afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep
|
||||
Inrichting; los meubilair telt niet mee). Het stuk hoort bij een unit en
|
||||
schuift mee.
|
||||
- **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
|
||||
override per wand) en binnenspots in een raster per unit.
|
||||
- **3D:** weergavefilters (o.a. Vloerplan: wanden afgesneden op 500 mm,
|
||||
snede wit afgedekt), binnenzijde van de wanden in een verfkleur (globaal),
|
||||
omgeving Ontwerp/Dag/Nacht, Render met lampen als echte lichtbronnen.
|
||||
- **Indeling** (zelfde patroon als CAD/BIM-pakketten; elke functie op één plek):
|
||||
bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren,
|
||||
projectgegevens, kosten & tijden) · Beeld · Help, de geopende ontwerpen als
|
||||
tabs en de knop Opslaan; links de structuur (groepen in-/uitklapbaar en
|
||||
onthouden; de groep met de selectie klapt vanzelf open); in het midden een werkbalk met
|
||||
gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, met de
|
||||
opties van het actieve gereedschap ernaast; Unit toevoegen) en weergave
|
||||
(Lagen, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D /
|
||||
3D met 1/2/3); rechts het eigenschappenpaneel: de selectie (unit,
|
||||
wandstuk, binnenwand, ruimte, inrichting, elektra) met kruimelpad terug,
|
||||
of zonder selectie de woning (kerncijfers en inklapbare secties Gevel,
|
||||
Dak, Binnen, Verlichting, Elektra, Constructie); onderin een statusbalk
|
||||
met de hint van het gereedschap en meldingen. Wand- en vloer/dak-editor
|
||||
hebben dezelfde indeling (werkbalk boven, paneel met tabbladen en
|
||||
Resultaat rechts; binnen de woning met "← Woning" of Esc terug). Help ›
|
||||
Sneltoetsen (?) toont alle toetsen. Rapport toont transport per unit,
|
||||
gewicht, afmetingen, kosten, hout en plaatwerk in plaats van de
|
||||
plattegrond, als masonry (kaart in de kortste kolom, leesvolgorde blijft).
|
||||
- **3D en plattegrond:** één lagenmenu (Lagen in de werkbalk, met bouwfase
|
||||
als snelkeuze) voor 3D en plattegrond; plattegrond zoomt met het
|
||||
scrollwiel en de knoppen rechtsonder (F = passend), verschuiven met
|
||||
slepen; Doorsnede (wanden afgesneden op een instelbare hoogte, schuif in
|
||||
de werkbalk, snede wit afgedekt), binnenzijde in een verfkleur (afgewerkte
|
||||
fase), omgeving Ontwerp/Dag/Nacht, Render met lampen als echte
|
||||
lichtbronnen.
|
||||
- **Transport:** per unit buitenmaat (incl. gevelafwerking), hoogte en
|
||||
gewicht (vloer/wanden/dak) met een indicatieve breedteklasse.
|
||||
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
|
||||
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
|
||||
vloeren, wandstukken, binnenwanden, totalen).
|
||||
- **CAD-export (DXF):** Bestand › Exporteren › CAD (DXF); los van
|
||||
Opslaan/Ctrl+S. DXF R12 (AC1009), opent in elk
|
||||
CAD-pakket (AutoCAD, BricsCAD, DraftSight, LibreCAD, …); DWG maakt het
|
||||
CAD-pakket zelf met Opslaan als (echte DWG schrijven vergt een GPL- of
|
||||
commerciële bibliotheek). Modelruimte 1:1 in mm: plattegrond met noord
|
||||
boven en de zuidwesthoek van de units op 0,0, eronder de vier gevels in een
|
||||
2×2-raster (maaiveld als basis). Plattegrond: wanden per stuk als gesloten
|
||||
polylijn, onderbroken bij sparingen die onder het snijvlak (1500 mm) beginnen
|
||||
(hogere gestreept), stijlzone en gevelbekleding als eigen lagen, kozijnen
|
||||
(raam: kader + glaslijn; deur: stijlen, blad en draaicirkel als boog;
|
||||
schuifdeur: bladen, pijlen, rail), ruimtegrenzen als gesloten polylijn met
|
||||
naam/m²/plafond, inrichting, dakrand, palen, schoorsteen, verlichting,
|
||||
elektra, naden en elementcodes. Gevels: dezelfde vlakken als de
|
||||
PDF-aanzichten (`HousePdf.viewProjections`) als lijntekening met verborgen
|
||||
lijnen weg, kozijnen met kader, blad, zij-/bovenlicht en glas
|
||||
(`KozijnModel.viewLines`). Maatvoering als echte DIMENSION (maatstijl
|
||||
TECTUM): ketting per buitenwandstuk langs de sparingen, totaal per stuk en
|
||||
van de woning, hoogtes maaiveld/peil/top per gevel. Lagen `PL-*`
|
||||
(plattegrond), `AZ-*` (aanzichten), `MAATVOERING`, `TEKST`; tekst,
|
||||
maatvoering en lijntypes afgestemd op 1:50 (`$LTSCALE`/`DIMSCALE` 50).
|
||||
- **BIM-export (IFC):** Bestand › Exporteren › BIM (IFC), ook in Woning ›
|
||||
Uitvoer. IFC4 met alleen de bouwconstructie: hout (stijlen/regels als
|
||||
IfcMember, vloerbalken als IfcBeam, dakbalken als IfcMember), stalen
|
||||
nokbalk, palen, platen (spaanplaat, OSB, cementplaat als IfcPlate) en
|
||||
elektra (buis per leiding, inbouw-/lasdozen, meterkast); geen
|
||||
gevelafwerking, latwerk, isolatie, kozijnen, dakbedekking of inrichting.
|
||||
Geometrie uit de 3D-opbouw zelf (`HouseIfc`: fase *elektra* voor alle balken
|
||||
en leidingen, fase *beplating* voor de platen; herkend aan het materiaal),
|
||||
als driehoeksnet. Opbouw: unit › element (A-W1, A-V1, A-D) › onderdelen;
|
||||
binnenwanden en elektra los in de bouwlaag. Per onderdeel `Pset_Tectum`
|
||||
(element, onderdeel, afmeting, lengte, materiaal). Kleur per soort (op het
|
||||
vlak en het materiaal): stijl lichthout, regel oranje, vloerbalk bruin,
|
||||
dakbalk roodbruin, paal donkerbruin, stalen nokbalk staalblauw, spaanplaat
|
||||
groen, OSB geel, cementplaat grijs; elektra in de app-kleuren
|
||||
(`HouseIfc` COLORS). X = oost, Y = noord,
|
||||
Z = 0 op peil; zuidwesthoek van de units = 0,0 (zelfde als de DXF).
|
||||
- **Render-export (OBJ):** Bestand › Exporteren › Render (OBJ), ook in Woning ›
|
||||
Uitvoer. Voor een sfeerimpressie: alleen wat in de afgewerkte woning te zien
|
||||
is (dak, dakrand, lampen, ruimtes, inrichting; los van de gekozen lagen,
|
||||
doorsnede en weergave-opties; zonder buizen en dozen, binnenste vloerbalken,
|
||||
latten en isolatie achter de gevel, lichtkegels, logobord, grond en lucht)
|
||||
als ZIP met `.obj`, `.mtl`, `textures/*.png` (rabat, steen, riet, tegels) en
|
||||
`<naam>_lampen.py`: in Blender uitvoeren importeert de OBJ en zet elke lamp
|
||||
(gevellamp, dakrandspot, binnenspot) als echte spotlight met dezelfde plek,
|
||||
richting en kegel als in de app (`LightingModel.lightSources`). Meters, Y omhoog (de
|
||||
standaard OBJ-import van Blender zet dat goed: noord = +Y), oorsprong op
|
||||
maaiveld onder het midden. Kleuren omgerekend van sRGB naar lineair (de app
|
||||
heeft geen kleurbeheer, Blender wel), PBR via `Pr`/`Pm`/`d`/`Ke` (ruwheid,
|
||||
metaal, doorzichtigheid glas, gloed lampen); textuurherhaling zit in de
|
||||
UV's (`ObjWriter`).
|
||||
|
||||
## Light-versie (modelpagina's)
|
||||
|
||||
Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via
|
||||
`light.html?model=<sleutel uit HouseModel.MODELS>`). Inbedden: de stylesheets en scripts uit `light.html`
|
||||
plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
|
||||
`TectumLight.mount(el, { model, frames, url, modelViewer, save })`. De engine hoeft niet naast de pagina te staan:
|
||||
texturen, kleurstalen en `kosten/*.csv` laden via `tectumUrl()` (`js/core/geometry.js`) vanaf de map van de scripts
|
||||
(bijv. `/configurator/` op een modelpagina van de site); paden in de data blijven relatief.
|
||||
|
||||
- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont
|
||||
alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
|
||||
inhoud en gewicht. Geen uren, zaaglijst, inkoop of kostenopbouw.
|
||||
- **Weergave:** 3D (afgewerkt, dag-omgeving), Doorsnede (wanden op een instelbare hoogte, standaard 1,5 m en hoogstens de laagste goot of de hoogte van een plat dak, afgesneden en wit afgedekt
|
||||
met `HouseModel.sectionCaps`, dezelfde als de configurator; dak, dakrand, palen en lampen weg) en een
|
||||
2D-plattegrond (ruimtes met netto m², wanden, kozijnen met draairichting, inrichting, dakrand, buitenmaten).
|
||||
- **Keuzes:** per unit breedte, diepte, goothoogte en dakvorm met richting (lessenaar: hoge kant noord/zuid/
|
||||
west/oost; zadel- en wolfsdak: nok noord–zuid of west–oost); bij een hellend dak de nokhoogte (lessenaar:
|
||||
hoge kant, met de helling eronder; de goot omhoog neemt de nok mee), bij een wolfsdak ook de hoogte van het
|
||||
wolfseind (bij een dak over de hele woning één keer), dakbedekking, gevelbekleding (hout met richting, felsplaten, stuc, steenstrips; kleur),
|
||||
het accent van het model (de afwijkende gevel; of geen), trasraam (geen of steen, met hoogte; een cementplaat-trasraam van het model wordt geen), boeidelen of daktrim
|
||||
(met kleur boeidelen; een model met beide houdt zijn indeling tot je kiest), zetwerk-kleur (afdekker trasraam en
|
||||
ventilatierooster), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele
|
||||
woning alleen als de units samen een rechthoek vormen en nooit bij een overkapping (zoals de configurator). Geen openingen,
|
||||
binnenwanden, inrichting of elektra; die volgen het model.
|
||||
- **Keuzes op een schone kopie:** `LightModel.build` past de keuzes elke keer toe op het model zelf; wat niet
|
||||
afwijkt blijft precies zoals in het model. Bij een andere maat schuiven binnenwanden mee met hun unit en
|
||||
schuift wat tegen de oost- of zuidgevel staat (binnenwand-eindpunt, inrichting) mee met die gevel (`BAND`).
|
||||
Bij een andere hoogte gaan de binnenwanden van die unit evenveel mee. Een andere dakvorm dan die van het
|
||||
model krijgt de helling uit `LightModel.PITCH` (lessenaar 10°, zadel 30°, wolfsdak 35°).
|
||||
- **Kaders:** standaard `UnitModel.LIMITS` (maat 2,4–14 m, max 4,7 m in één richting; hoogte 2,4–3 m), per
|
||||
model te beperken in `LightModel.FRAMES` of per pagina met `frames` (zelfde vorm; `null` = maat staat vast).
|
||||
- **Indelingscontrole** (`LightModel.check`): een keuze waarbij ramen of deuren niet meer passen
|
||||
(verschoven, ingeklemd, te hoog of over een naad), een binnenwand buiten de units valt, inrichting door
|
||||
een gevel steekt, ruimtes verdwijnen of units overlappen, wordt geweigerd; vergeleken met het model zelf.
|
||||
Bij een maat wordt de dichtstbijzijnde maat gezocht die nog past.
|
||||
- **Indeling:** paneel als accordeon (Afmetingen, Dak, Gevel, Verlichting; één open, de kop vat de keuze samen),
|
||||
kerncijfers en prijs met PDF-knop onderaan. Responsief op de breedte van de tool zelf (container queries; in de
|
||||
site in een Bootstrap col-12): vanaf 720 px naast elkaar, smaller 3D boven en de keuzes eronder met prijs en
|
||||
PDF vast onderin beeld. Stijl alles onder `.tectum-light` (Bootstrap-reboot geneutraliseerd); Bootstrap Icons
|
||||
nodig (`css/vendor/bootstrap-icons.min.css`). Het scrollwiel scrolt de pagina, tenzij je in de 3D hebt
|
||||
geklikt of Ctrl/⌘ ingedrukt houdt. Zijwaarts schuiven: rechts of middelste knop slepen, Shift/Ctrl/⌘ +
|
||||
slepen, of de pijltjestoetsen met de muis boven de 3D. Touch: één vinger draaien, twee vingers knijpen/schuiven (eigen
|
||||
OrbitControls, geldt ook voor de configurator).
|
||||
- **Opslaan = PDF** (`LightPdf`): voorblad "Uw woningontwerp" (beeld uit de 3D-weergave, kerncijfers, keuzes
|
||||
in woorden via `LightModel.describe`, indicatieve prijs, contactgegevens en een QR-code naar de modelpagina
|
||||
op de site, of `data-url`/`url` van de pagina), dan aanzichten en plattegrond
|
||||
(`HousePdf.generate(state, { customer: true, doc })`: zonder onderdelenlijst, elementcodes, elektra en
|
||||
kosten) en als laatste het stappenplan van de site ("Hoe verloopt het traject?") met een blok over maatwerk
|
||||
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
|
||||
in `LightPdf.COMPANY` en `STEPS`.
|
||||
- **Eigen opslaan** (`save: { label, icon, onSave }`): vervangt de PDF-knop, bijv. "Ontwerp aanvragen" op de site.
|
||||
`onSave` krijgt `{ design, label, key, info, choices, url }`: `design` is de house-design-JSON zoals de
|
||||
configurator die opslaat (de CRM maakt daar het gekozen model van), `info` de kerncijfers met prijs, `choices` de
|
||||
keuzes in woorden. Zonder `save` (en via `el.tectumLight.design()`) hetzelfde ontwerp.
|
||||
- **AR / schermvullend:** knop rechtsboven in de weergave. Op een telefoon of tablet: het ontwerp als GLB
|
||||
(`GlbWriter`, afgewerkte woning zoals in 3D, één primitive per materiaal, texturen als JPEG/PNG tot 1024 px,
|
||||
lichtbundels weg; maaiveld onder het midden = oorsprong, `HouseModel.groundOrigin`) in model-viewer, over de
|
||||
hele pagina, met de knop "Bekijk in je tuin" (ware grootte; `ar-modes="webxr scene-viewer quick-look"`, op iOS
|
||||
maakt model-viewer zelf de USDZ). model-viewer wordt pas bij de eerste keer geladen
|
||||
(`js/vendor/model-viewer-umd.min.js` naast `js/light/`, of `modelViewer`/`data-model-viewer`). AR vraagt
|
||||
https; Scene Viewer (Android zonder WebXR) heeft bovendien een publieke URL van de GLB nodig, een blob-URL
|
||||
werkt daar niet. Op de desktop: de weergave schermvullend (Fullscreen API; het scrollwiel zoomt dan direct).
|
||||
Per ontwerp wordt de GLB één keer gemaakt; `el.tectumLight.glb()` geeft `{ blob, url, stats }` (op
|
||||
`light.html` de testknop GLB).
|
||||
|
||||
## CRM (woningen van leads en klanten)
|
||||
|
||||
Alleen in de Docker-stack (`../docker`), waar Tectum op `tectum.<domein>` achter het wachtwoord staat. Caddy zet
|
||||
`api/crm/*` daar door naar de CRM (`/api/tectum/*`) met de sleutel `TECTUM_CRM_KEY`; de browser ziet die nooit.
|
||||
|
||||
- **Nieuw** toont onder de standaardmodellen de woningen uit de CRM (zoeken op woning, naam, relatie of plaats);
|
||||
kiezen opent de nieuwste versie in een eigen tab (wolkje in de tab, naam "woning · vN").
|
||||
- **`?woning=<id>`** opent die woning direct (de knop "Bewerken in Tectum" bij een woning in de CRM).
|
||||
- **Opslaan in CRM** (bovenbalk, Bestand, Ctrl+S in een CRM-tab) vraagt wat er veranderd is en zet het ontwerp als
|
||||
nieuwe versie bij de woning in de CRM. Staat er intussen een nieuwere versie (iemand anders), dan vraagt Tectum
|
||||
eerst of het toch moet. **Downloaden (JSON + PDF)** doet in een CRM-tab wat Opslaan anders doet.
|
||||
- Sluiten van een CRM-tab met wijzigingen die nog niet in de CRM staan vraagt om bevestiging.
|
||||
|
||||
Zonder stack (`start.sh`) bestaat `api/crm/` niet en is van de CRM niets te zien.
|
||||
|
||||
## Kosten en prijzen (CSV)
|
||||
|
||||
Alle waarden van het kostenmodel staan in twee CSV-bestanden in `kosten/`, die
|
||||
de app bij het starten ophaalt. Elke waarde staat op één plek; de CSV's zijn
|
||||
leidend en de app bewaart niets in de browser.
|
||||
|
||||
- `kosten/intern.csv`: eigen waarden: tijden, uurloon, bezetting, kosten per
|
||||
uur, opslagen, marge (alles behalve prijzen).
|
||||
- `kosten/leveranciers.csv`: alle inkoopprijzen (bijv. uit API-scripts van
|
||||
leveranciers), ook constructiehout per balkmaat.
|
||||
- `js/cost/cost-defaults.js`: welke posten er zijn (labels, eenheden, keuzes)
|
||||
en noodwaarden voor wat een CSV mist.
|
||||
|
||||
```
|
||||
# intern.csv
|
||||
code;omschrijving;eenheid;waarde
|
||||
arbeid.uurloon;Uurloon productie (all-in);€/u;45
|
||||
zagen.methode;Zaagmethode;;robot
|
||||
|
||||
# leveranciers.csv
|
||||
code;omschrijving;eenheid;prijs;leverancier;artikelnummer;bijgewerkt
|
||||
elektra.wcdEnkel;Wandcontactdoos enkel met randaarde;€/st.;6,00;Groothandel X;123456;2026-10-09
|
||||
hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026-10-09
|
||||
```
|
||||
|
||||
- Verplicht: `code` en `waarde` (of `prijs`), excl. btw; `6,00`, `6.00`,
|
||||
`1.234,50` en `€ 6` mogen; bij een keuze de sleutel of de tekst.
|
||||
Scheidingsteken `;`, `,` of tab; regels met `#` worden overgeslagen.
|
||||
- `code` = `<groep>.<sleutel>` uit `cost-defaults.js`, of `hout.balk.<b>x<h>`
|
||||
voor constructiehout per balkmaat in €/m (zonder balkmaat: m³-prijs × volume).
|
||||
- `omschrijving` is de naam van de post in de app (leeg = die uit `cost-defaults.js`).
|
||||
- `eenheid` is optioneel; ingevuld moet die gelijk zijn aan die van de post,
|
||||
anders wordt de regel geweigerd.
|
||||
- Een prijs in intern.csv of een tijd in leveranciers.csv wordt geweigerd
|
||||
("hoort in …"). Meerdere regels met dezelfde code in leveranciers.csv
|
||||
(leveranciers): de laagste prijs telt.
|
||||
- Onbekende codes (bijv. onderdelen die de app nog niet rekent) en fouten staan
|
||||
bovenaan in Kosten & tijden.
|
||||
- Kosten & tijden: **Toepassen** geldt tot herladen (om te proberen);
|
||||
**intern.csv downloaden** om het bestand in `kosten/` te vervangen.
|
||||
Prijsvelden staan vast. Bij Leveranciers: **Sjabloon** (alle prijscodes),
|
||||
**Tijdelijk laden** (tot herladen of **Terug naar server**).
|
||||
|
||||
## Bekende beperkingen
|
||||
|
||||
- DXF: geen arceringen en geen lijndiktes (R12); kleuren per laag (ACI), dus
|
||||
pendiktes via de plotstijl. Geen doorsnede en geen dakaanzicht. Eenheid
|
||||
staat niet in het bestand: in CAD op millimeters zetten (UNITS) als het
|
||||
pakket erom vraagt.
|
||||
- OBJ: lichtbronnen alleen via het meegeleverde Blender-script, geen
|
||||
reliëf-/normaalkaarten; glas als doorzichtig materiaal, in Blender eventueel
|
||||
op Glass BSDF zetten.
|
||||
- IFC: onderdelen zijn driehoeksnetten (niet parametrisch); geen
|
||||
verbindingsmiddelen, isolatie of bekabeling (alleen buizen).
|
||||
|
||||
- Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij
|
||||
2400 m/u, zaagsneden, hoeken) in de app en op het totalenblad (`SAW_M_PER_H`).
|
||||
- Tijdelijk: een nieuwe woning is het testontwerp (L-vorm, lessenaar met
|
||||
riet); uit te zetten met `TEST_DEFAULT` in `js/house/house-model.js`.
|
||||
- Tijdelijk: een nieuwe woning is het voorbeeldontwerp "Vlinderwoning"
|
||||
(92 m², drie units, vlinderdak met riet + platte tuinkamer, ook als
|
||||
`json/premade/vlinderwoning.json`); uit te zetten met `TEST_DEFAULT` in
|
||||
`js/house/house-model.js`. Bij het starten opent model **Amstel**
|
||||
(`HouseModel.createAmstel`, naar `examples/ontwerp_amstel.pdf`): 11,24 ×
|
||||
7,2 m, twee modules onder één plat dak dat over het terras doorloopt,
|
||||
stucwerk wit met verticaal hout aan de slaapkamerkant, binnenwanden,
|
||||
dubbele schuifdeur tussen woonkamer en hal, en complete inrichting.
|
||||
Langer maken: beide units breder (alles is vanaf de westgevel gemaat).
|
||||
Model **Wetering** (`HouseModel.createWetering`, naar
|
||||
`examples/ontwerp_wetering.pdf`): 10,5 × 4,8 m, twee units van 2,6 en
|
||||
2,2 m zonder naadwand (open woonkamer over de volle diepte), verticaal
|
||||
rabat grijs, plat dak met zwart boeideel, hoekkeuken met kookplaat,
|
||||
badkamer, slaapkamer met kledingkast. Model **IJssel**
|
||||
(`HouseModel.createIJssel`, naar `examples/ontwerp_ijssel.pdf`): L-vorm
|
||||
14 × 4,6 m met een vleugel van 4,6 × 3 m, twee units met zadeldak en
|
||||
riet die met een kil en een hoekkeper op elkaar aansluiten, stucwerk wit,
|
||||
openslaande deuren in de westgevel. Model **Waal**
|
||||
(`HouseModel.createWaal`, naar `examples/ontwerp_waal.pdf`): 12,85 × 7 m,
|
||||
flauw zadeldak dat aan de oostkop 1,5 m doorloopt als veranda op eiken
|
||||
palen (Overkapping per unit). Model **Maas** (`HouseModel.createMaas`,
|
||||
naar `examples/ontwerp_maas.pdf`): vierkant 9,2 × 9,2 m, flauw zadeldak
|
||||
over de hele woning; de overkapping maakt van de noordwesthoek een
|
||||
inpandige veranda (2,5 × 4,6 m) op eiken palen. Model **Rijn**
|
||||
(`HouseModel.createRijn`, naar `examples/ontwerp_rijn.pdf`): L-vorm
|
||||
13 × 10 m, hoofdvolume met flauw zadeldak (twee lessenaars die op de naad
|
||||
de nok vormen) en een vleugel met plat dak, steenstrips rondom met een
|
||||
schoorsteen (800 mm, hart op de naad) in de oostgevel, kookeiland, logeerkamer en
|
||||
apart toilet in de vleugel. Model **Reest** (`HouseModel.createReest`,
|
||||
naar `examples/ontwerp_reest.pdf`): 12,9 × 7,85 m, hoofdvolume
|
||||
11,5 × 4,8 m onder één lessenaarsdak (hoog aan de noordgevel, twee units
|
||||
van 3,4 en 1,4 m met lessenaars in hetzelfde vlak) dat aan de westkop
|
||||
1,4 m doorloopt als overkapping bij de voordeur, dicht met een witte
|
||||
zijwand; aanbouw met plat dak (toilet, berging, entree met open doorgang
|
||||
naar de woonkamer, daktrim i.p.v. boeidelen); zuidgevel en dak in
|
||||
antraciet felsplaten (zuidrand zonder boeideel, de felsplaten lopen over
|
||||
de zijwand door), verder verticale rabat. Model **Vecht** (`HouseModel.createVecht`): U-vorm
|
||||
13 × 9,5 m, drie units (slaapvleugel west, tussenlid met entree, leefvleugel oost) met de holte open naar
|
||||
het noorden; de vleugels lessenaars die naar de holte aflopen, het tussenlid plat op dezelfde goot, de
|
||||
overkapping van het tussenlid dekt de holte (palen niet nodig); zwarte verticale rabat, wit stucwerk aan de
|
||||
holte. De standaardmodellen staan in
|
||||
`HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden
|
||||
daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die
|
||||
`modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft
|
||||
beschikbaar als "Woning L-vorm".
|
||||
- Netto m² per ruimte telt de hele vloer, ook onder een laag schuin dak
|
||||
(geen NEN 2580-correctie voor hoogte < 1,5 m).
|
||||
- Units kunnen niet gedraaid worden.
|
||||
- Getekende binnenwanden volgen de dakvorm (nog) niet.
|
||||
- De dak-editor toont het dek in de plattegrond (niet per schuin dakvlak).
|
||||
|
||||
+425
-232
@@ -4,13 +4,7 @@
|
||||
* 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.
|
||||
============================================================ */
|
||||
/* De 2D-/3D-weergaven blijven donker (--viewport-bg): de canvas-tekeningen gebruiken lichte lijnen. */
|
||||
:root {
|
||||
--van-emous-blue: #dce4ee;
|
||||
--van-emous-orange: #b95913;
|
||||
@@ -20,7 +14,7 @@
|
||||
|
||||
--primary-hover: #9c4a0f;
|
||||
--primary-soft: rgba(185, 89, 19, 0.10);
|
||||
--primary-on-dark: #e8883d; /* oranje dat leesbaar blijft op de donkere weergaven */
|
||||
--primary-on-dark: #e8883d;
|
||||
--text: var(--nav-link-color);
|
||||
--text-muted: #5b6673;
|
||||
--text-faint: #8a94a0;
|
||||
@@ -45,30 +39,6 @@
|
||||
}
|
||||
|
||||
|
||||
#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;
|
||||
@@ -85,20 +55,12 @@
|
||||
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-primary { flex: 2; }
|
||||
.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);
|
||||
@@ -154,39 +116,6 @@
|
||||
}
|
||||
.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;
|
||||
@@ -226,7 +155,6 @@
|
||||
.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);
|
||||
@@ -236,6 +164,7 @@
|
||||
position: relative;
|
||||
}
|
||||
.opening-card.opening-selected { background: var(--primary-soft); border-color: var(--primary); }
|
||||
.opening-fill { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border); }
|
||||
.opening-card.opening-warn {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 1px var(--danger) inset;
|
||||
@@ -285,7 +214,6 @@
|
||||
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; }
|
||||
@@ -297,84 +225,15 @@
|
||||
}
|
||||
.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 { width: 100vw; flex: 1; min-height: 0; 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) --- */
|
||||
/* 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;
|
||||
@@ -385,7 +244,6 @@ html, body { height: 100%; }
|
||||
#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;
|
||||
@@ -435,6 +293,37 @@ html, body { height: 100%; }
|
||||
#wall-project-modal .modal-footer, #house-project-modal .modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
|
||||
#wall-project-modal .control-group input, #house-project-modal .control-group input { width: 150px; text-align: left; }
|
||||
|
||||
#cost-settings-overlay {
|
||||
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
|
||||
display: none; align-items: center; justify-content: center;
|
||||
}
|
||||
#cost-settings-overlay.open { display: flex; }
|
||||
#cost-settings-modal {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px;
|
||||
width: 760px; max-width: 94%; max-height: 90vh; display: flex; flex-direction: column;
|
||||
box-shadow: 0 12px 30px rgba(33, 33, 33, .2);
|
||||
}
|
||||
#cost-settings-modal .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
#cost-settings-modal .modal-header h2 { margin: 0; font-size: 15px; color: var(--primary); }
|
||||
#cost-settings-modal .modal-close-x { background: none; border: none; color: var(--text-faint); font-size: 19px; line-height: 1; cursor: pointer; padding: 0 4px; }
|
||||
#cost-settings-modal .modal-close-x:hover { color: var(--danger); }
|
||||
#cost-settings-modal .modal-footer { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||||
#cost-settings-modal .cs-body { overflow-y: auto; min-height: 0; flex: 1; columns: 2 340px; column-gap: 24px; }
|
||||
#cost-settings-modal .cs-group { break-inside: avoid; margin-bottom: 14px; }
|
||||
#cost-settings-modal .cs-group h3 { font-size: 12px; color: var(--primary); text-transform: uppercase; letter-spacing: .5px; margin: 0 0 6px; }
|
||||
#cost-settings-modal .cs-row { display: grid; grid-template-columns: 1fr 84px 48px; gap: 6px; align-items: center; font-size: 12px; padding: 2px 0; }
|
||||
#cost-settings-modal .cs-row input, #cost-settings-modal .cs-row select { width: 100%; text-align: right; }
|
||||
#cost-settings-modal .cs-row select { text-align: left; }
|
||||
#cost-settings-modal .cs-unit { color: var(--text-faint); font-size: 11px; }
|
||||
#cost-settings-modal .cs-row.changed .cs-label { color: var(--primary); font-weight: bold; }
|
||||
#cost-settings-modal .cs-row.from-list input { color: var(--primary); font-weight: bold; }
|
||||
#cost-settings-modal .cs-row.from-list .cs-unit::after { content: ' CSV'; color: var(--primary); font-size: 9px; }
|
||||
#cost-settings-modal .cs-pricelist { font-size: 12px; margin-bottom: 10px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 4px; }
|
||||
#cost-settings-modal .cs-pricelist .io-btn { padding: 1px 8px; margin-left: 6px; }
|
||||
#cost-settings-modal .cs-pricelist .cs-list + .cs-list { margin-top: 4px; }
|
||||
#cost-settings-modal .cs-pricelist ul { margin: 4px 0 0; padding-left: 18px; max-height: 120px; overflow-y: auto; }
|
||||
#cost-settings-modal .cs-pricelist li.err { color: var(--danger); }
|
||||
|
||||
#new-overlay {
|
||||
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
|
||||
display: none; align-items: center; justify-content: center;
|
||||
@@ -452,43 +341,28 @@ html, body { height: 100%; }
|
||||
color: var(--nav-link-color); cursor: pointer; width: 110px;
|
||||
}
|
||||
.new-option svg { width: 40px; height: 40px; }
|
||||
.new-option .bi { font-size: 36px; line-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; }
|
||||
.new-crm { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-soft); max-width: 640px; }
|
||||
.new-crm h3 { margin: 0 0 10px; font-size: 13px; color: var(--primary); }
|
||||
.new-crm-q { width: 100%; box-sizing: border-box; font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.new-crm-list { list-style: none; margin: 8px 0 0; padding: 0; max-height: 260px; overflow-y: auto; }
|
||||
.new-crm-item { display: flex; gap: 10px; align-items: center; width: 100%; text-align: left; background: none; border: 0; border-radius: 6px; padding: 6px 8px; cursor: pointer; color: var(--text); font: inherit; }
|
||||
.new-crm-item:hover, .new-crm-item:focus-visible { background: var(--primary-soft); color: var(--primary); }
|
||||
.new-crm-item .bi { font-size: 18px; }
|
||||
.new-crm-item strong { display: block; font-size: 13px; }
|
||||
.new-crm-item small { display: block; font-size: 12px; color: var(--text-muted); }
|
||||
.new-crm-empty { font-size: 12px; color: var(--text-muted); padding: 6px 8px; }
|
||||
.doc-tab .bi-cloud { margin-right: 4px; color: var(--primary); }
|
||||
#crm-toast { position: fixed; left: 50%; bottom: 24px; transform: translate(-50%, 20px); opacity: 0; pointer-events: none; z-index: 900;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; font-size: 13px;
|
||||
box-shadow: 0 12px 30px rgba(33, 33, 33, .2); transition: opacity .2s, transform .2s; max-width: 90vw; }
|
||||
#crm-toast.open { opacity: 1; transform: translate(-50%, 0); }
|
||||
#crm-toast.ok { border-color: var(--ok); background: var(--ok-soft); }
|
||||
#crm-toast.err { border-color: var(--danger); background: var(--danger-soft); }
|
||||
|
||||
/* ============================================================
|
||||
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; }
|
||||
@@ -508,47 +382,20 @@ html, body { height: 100%; }
|
||||
.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-report { display: none; flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 14px 18px 20px; background: var(--van-emous-ultra-light); border-right: 1px solid var(--border); }
|
||||
#house-overview.show-report .ho-report { display: block; }
|
||||
#house-overview.show-report #ho-pane-plan { display: none; }
|
||||
.ho-report-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
|
||||
.ho-report-head h1 { margin: 0; font-size: 14px; }
|
||||
.ho-report-head span { font-size: 12px; color: var(--text-muted); }
|
||||
.ho-warn { position: absolute; top: 52px; left: 15px; right: 15px; z-index: 9; pointer-events: none; }
|
||||
.ho-warn .ui-warn { margin: 0 0 6px; max-width: 560px; }
|
||||
.ho-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; }
|
||||
|
||||
/* ============================================================
|
||||
UI-KIT (core/ui-kit.js): velden, schakelaars, secties, tabbladen,
|
||||
specificatie-kaarten. Iconen: Bootstrap Icons (css/vendor).
|
||||
============================================================ */
|
||||
.bi { line-height: 1; vertical-align: -0.125em; }
|
||||
.ui-section { margin-bottom: 14px; }
|
||||
.ui-section h2, .ui-spec-card h2 {
|
||||
@@ -560,7 +407,7 @@ html, body { height: 100%; }
|
||||
.ui-field label { font-size: 12.5px; color: var(--text); display: flex; align-items: center; gap: 5px; }
|
||||
.ui-field.is-disabled label { color: var(--text-faint); }
|
||||
.ui-control { display: flex; align-items: center; flex: none; }
|
||||
.ui-control input[type=number], .ui-control select {
|
||||
.ui-control input[type=number], .ui-control input[type=text], .ui-control select {
|
||||
width: 118px; background: var(--surface); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 4px 6px; border-radius: 4px; font-size: 12.5px; font-family: inherit;
|
||||
}
|
||||
@@ -604,7 +451,6 @@ html, body { height: 100%; }
|
||||
.ui-tab-page { display: none; }
|
||||
.ui-tab-page.active { display: block; }
|
||||
|
||||
.ui-spec-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
|
||||
.ui-spec-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 6px; padding: 10px 12px; }
|
||||
.ui-spec-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); }
|
||||
.ui-spec-row:last-child { border-bottom: none; }
|
||||
@@ -613,15 +459,11 @@ html, body { height: 100%; }
|
||||
.ui-spec-total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 2px; }
|
||||
.ui-spec-total .ui-spec-val { color: var(--primary); }
|
||||
|
||||
.btn-3d-toggle { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.hn-item .bi { margin-right: 6px; color: var(--text-muted); }
|
||||
.hn-item.active .bi, .hn-item:hover .bi { color: var(--primary); }
|
||||
.hn-label { display: inline-flex; align-items: center; }
|
||||
|
||||
/* Weergave-cyclus (T): render-only = ook het info-deel onder de 3D weg. */
|
||||
.render-only #ho-specs, .render-only #specs-panel-3d, .render-only #floor-specs-panel-3d { display: none; }
|
||||
|
||||
/* Bestaande editor-panelen (wand/vloer) in dezelfde stijl als de UI-kit. */
|
||||
.control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; }
|
||||
.control-group label { font-size: 12.5px; color: var(--text); }
|
||||
.control-group input, .control-group select { width: 118px; font-size: 12.5px; padding: 4px 6px; font-family: inherit; }
|
||||
@@ -638,19 +480,370 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
||||
.hn-title { display: flex; align-items: center; gap: 6px; }
|
||||
.file-protocol-warning { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 9000; max-width: 640px; box-shadow: 0 8px 20px rgba(33,33,33,.2); }
|
||||
.file-protocol-warning code { background: rgba(0,0,0,.06); padding: 0 4px; border-radius: 3px; }
|
||||
.vf-option { gap: 9px; }
|
||||
.vf-option.active { color: var(--primary-on-dark); font-weight: 600; }
|
||||
.vf-menu .vf-option:hover { color: var(--primary-on-dark); }
|
||||
|
||||
/* Voorvertoning steen trasraam (woning > Gevel) */
|
||||
.hs-stone-preview { display: block; width: 128px; height: auto; margin: 4px 0 8px; border: 1px solid var(--border, #ccc); border-radius: 3px; }
|
||||
|
||||
/* Lopen (first person, derde weergavestand): uitleg onderin de 3D. */
|
||||
.walk-hint { display: none; position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); padding: 6px 12px; border-radius: 4px;
|
||||
.walk-hint { display: none; position: absolute; left: 50%; top: 14px; transform: translateX(-50%); padding: 6px 12px; border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; pointer-events: none; white-space: nowrap; z-index: 5; }
|
||||
.walk-hint.show { display: block; }
|
||||
.walk-prompt { display: none; position: absolute; left: 50%; top: 58%; transform: translateX(-50%); padding: 5px 11px; border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.65); color: #fff; font-size: 13px; font-weight: 600; pointer-events: none; white-space: nowrap; z-index: 5; }
|
||||
.walk-prompt.show { display: block; }
|
||||
|
||||
/* Beeld > Camera: uitleg (geen actie) */
|
||||
#app-bar .submenu .menu-info { padding: 6px 12px 2px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted, #8a8f96); }
|
||||
#app-bar .submenu .menu-help { cursor: default; opacity: .85; }
|
||||
#app-bar .submenu .menu-help:hover { background: transparent; color: var(--nav-link-color); }
|
||||
.opening-roof-btn {
|
||||
display: inline-flex; align-items: center; gap: 5px; margin-top: 2px; padding: 4px 8px; font-size: 11.5px;
|
||||
background: var(--surface); border: 1px solid var(--border); color: var(--primary); border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.opening-roof-btn:hover { background: var(--primary-soft); border-color: var(--primary); }
|
||||
|
||||
.ui-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 11px; color: var(--text-faint); margin-bottom: 4px; }
|
||||
.ui-crumbs .bi { font-size: 9px; }
|
||||
.ui-crumbs span:last-child { color: var(--text-muted); }
|
||||
.ui-field-range { flex-wrap: wrap; row-gap: 2px; padding: 2px 0 4px; }
|
||||
.ui-field-range label { flex: 1 1 100%; }
|
||||
.ui-range { flex: 1 1 100%; gap: 8px; }
|
||||
.ui-range input[type=range] { flex: 1; min-width: 0; accent-color: var(--primary); }
|
||||
.ui-range input[type=number] { width: 84px; }
|
||||
.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
|
||||
|
||||
.plan-zoom { position: absolute; right: 12px; bottom: 12px; z-index: 10; display: flex; flex-direction: column; gap: 4px; }
|
||||
.plan-zoom button {
|
||||
width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
background: rgba(0, 0, 0, 0.75); border: 1px solid #555; border-radius: 4px; color: #eee; font-size: 14px;
|
||||
}
|
||||
.plan-zoom button:hover { border-color: var(--primary-on-dark); color: var(--primary-on-dark); }
|
||||
.plan-zoom-val { text-align: center; font-size: 10px; color: #aaa; }
|
||||
|
||||
.viz { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; }
|
||||
.viz-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; }
|
||||
.viz-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.viz-key { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
.viz-key.s1, .viz-seg.s1 { background: var(--series-1); }
|
||||
.viz-key.s2, .viz-seg.s2 { background: var(--series-2); }
|
||||
.viz-key.s3, .viz-seg.s3 { background: var(--series-3); }
|
||||
.viz-row { display: grid; grid-template-columns: 96px 1fr auto; align-items: center; gap: 8px; padding: 4px 0; }
|
||||
.viz-label { font-size: 12px; color: var(--text); line-height: 1.25; }
|
||||
.viz-label small { display: block; font-size: 10px; color: var(--text-faint); }
|
||||
.viz-row:has(.viz-label small) { grid-template-columns: 1fr auto; }
|
||||
.viz-row:has(.viz-label small) .viz-label { grid-column: 1 / -1; }
|
||||
.viz-track { height: 12px; }
|
||||
.viz-bar { display: flex; gap: 2px; height: 100%; min-width: 2px; }
|
||||
.viz-seg { height: 100%; min-width: 2px; }
|
||||
.viz-seg:last-child { border-radius: 0 4px 4px 0; }
|
||||
.viz-seg:hover { filter: brightness(1.12); }
|
||||
.viz-val { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text); text-align: right; white-space: nowrap; }
|
||||
.viz-note { font-size: 11px; color: var(--text-faint); line-height: 1.45; margin-top: 6px; }
|
||||
|
||||
/* ===== Shell: bovenbalk (menu + documenten), editorindeling, werkbalk, eigenschappen, statusbalk ===== */
|
||||
:root { --panel-w: 340px; --nav-w: 230px; }
|
||||
body { display: flex; flex-direction: column; }
|
||||
|
||||
#topbar {
|
||||
flex: none; height: 40px; display: flex; align-items: stretch; gap: 4px; padding: 0 10px 0 4px;
|
||||
background: var(--van-emous-blue); border-bottom: 1px solid var(--border); position: relative; z-index: 200; user-select: none;
|
||||
}
|
||||
.tb-brand { display: flex; align-items: center; padding: 0 12px 0 8px; font-weight: 700; color: var(--primary); letter-spacing: .5px; }
|
||||
#menubar { display: flex; align-items: center; }
|
||||
.mb-menu { position: relative; height: 100%; display: flex; align-items: center; }
|
||||
.mb-top { height: 28px; padding: 0 10px; border: none; background: none; border-radius: 4px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer; }
|
||||
.mb-top:hover, .mb-menu.open .mb-top { background: rgba(255, 255, 255, .7); color: var(--primary); }
|
||||
.mb-drop {
|
||||
display: none; position: absolute; top: calc(100% - 3px); left: 0; min-width: 260px; padding: 4px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 10px 28px rgba(33, 33, 33, .18); z-index: 300;
|
||||
}
|
||||
.mb-menu.open .mb-drop { display: block; }
|
||||
.mb-item {
|
||||
display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 6px; width: 100%; padding: 6px 8px;
|
||||
border: none; background: none; border-radius: 4px; font: inherit; font-size: 13px; color: var(--text); text-align: left; cursor: pointer;
|
||||
}
|
||||
.mb-item:hover:not(:disabled) { background: var(--primary-soft); color: var(--primary); }
|
||||
.mb-item:disabled { color: var(--text-faint); cursor: default; }
|
||||
.mb-ico .bi { color: var(--text-muted); }
|
||||
.mb-item:hover:not(:disabled) .mb-ico .bi, .mb-item.checked .mb-ico .bi { color: var(--primary); }
|
||||
.mb-item:disabled .mb-ico .bi { color: inherit; }
|
||||
.mb-keys { font-size: 11.5px; color: var(--text-faint); padding-left: 18px; white-space: nowrap; }
|
||||
.mb-sep { height: 1px; background: var(--border-soft); margin: 4px 6px; }
|
||||
.mb-head { font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); padding: 6px 8px 2px 36px; }
|
||||
|
||||
#doc-tabs { display: flex; align-items: flex-end; gap: 2px; flex: 1; min-width: 0; margin-left: 14px; overflow: hidden; }
|
||||
.doc-tab {
|
||||
display: flex; align-items: center; gap: 4px; flex: 0 1 auto; min-width: 70px; max-width: 220px; height: 32px; padding: 0 4px 0 12px;
|
||||
border: 1px solid transparent; border-bottom: none; border-radius: 6px 6px 0 0; font-size: 12.5px; color: var(--text-muted); cursor: pointer;
|
||||
}
|
||||
.doc-tab:hover { background: rgba(255, 255, 255, .5); color: var(--text); }
|
||||
.doc-tab.active { background: var(--surface); border-color: var(--border); color: var(--text); font-weight: 600; height: 33px; margin-bottom: -1px; }
|
||||
.doc-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.doc-close { width: 20px; height: 20px; flex: none; border: none; background: none; border-radius: 4px; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; }
|
||||
.doc-close:hover { background: var(--danger-soft); color: var(--danger); }
|
||||
.top-right { display: flex; align-items: center; gap: 12px; margin-left: auto; padding-left: 12px; font-size: 11px; color: var(--text-muted); white-space: nowrap; }
|
||||
.top-save {
|
||||
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border: 1px solid var(--primary); border-radius: 4px;
|
||||
background: var(--primary); color: #fff; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.top-save:hover:not(:disabled) { background: var(--primary-hover); }
|
||||
.top-save:disabled { opacity: .45; cursor: default; }
|
||||
|
||||
/* Editoren: [werkbalk + viewports] | paneel rechts. Dezelfde indeling voor woning, wand en vloer/dak. */
|
||||
.ed-app, #house-overview { display: flex; width: 100%; height: 100%; min-height: 0; }
|
||||
.ed-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.ed-views, .ho-views { flex: 1; min-height: 0; min-width: 0; display: flex; }
|
||||
#left-column, #right-column, #floor-left-column, #floor-right-column { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
#left-column, #floor-left-column { border-right: 1px solid var(--border); }
|
||||
#floor-pane-2d-container { flex: 1; display: flex; flex-direction: column; min-height: 0; width: 100%; }
|
||||
#pane-3d, #floor-pane-3d { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); }
|
||||
.ed-app.fullscreen-3d #left-column, .ed-app.fullscreen-3d #floor-left-column { display: none; }
|
||||
.ed-app.fullscreen-2d #right-column, .ed-app.fullscreen-2d #floor-right-column { display: none; }
|
||||
.ed-panel { width: var(--panel-w); flex: none; min-height: 0; display: flex; flex-direction: column; background: var(--van-emous-ultra-light); border-left: 1px solid var(--border); }
|
||||
.ed-panel .tab-content { padding: 12px 14px; }
|
||||
.ed-panel .controls-grid { grid-template-columns: 1fr; gap: 4px; }
|
||||
.ed-panel .tab-btn { padding: 9px 4px; font-size: 12px; gap: 5px; }
|
||||
.ed-panel .io-toolbar:has(.io-status:empty) { display: none; }
|
||||
#specs-panel-3d, #floor-specs-panel-3d { padding: 0; }
|
||||
body.panels-hidden .ed-panel, body.panels-hidden #house-nav, body.panels-hidden .statusbar { display: none; }
|
||||
|
||||
#house-app { display: flex; flex-direction: column; width: 100%; height: 100%; }
|
||||
.house-body { flex: 1; min-height: 0; display: flex; }
|
||||
#house-nav {
|
||||
width: var(--nav-w); flex: none; display: flex; flex-direction: column; gap: 6px; padding: 10px 8px; overflow-y: auto;
|
||||
background: var(--van-emous-blue); border-right: 1px solid var(--border);
|
||||
}
|
||||
#house-part-host { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
#house-part-host > .ed-app, #house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; }
|
||||
.ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); }
|
||||
#ho-pane-plan { border-right: 1px solid var(--border); }
|
||||
#house-overview.fullscreen-3d #ho-pane-plan, #house-overview.fullscreen-3d .ho-report { display: none !important; }
|
||||
#house-overview.fullscreen-2d #ho-pane-3d { display: none; }
|
||||
@media (max-width: 1250px) {
|
||||
:root { --panel-w: 300px; --nav-w: 190px; }
|
||||
.ho-views, .ed-views { flex-direction: column; }
|
||||
#ho-pane-plan, .ho-report, #left-column, #floor-left-column { border-right: none; border-bottom: 1px solid var(--border); }
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
flex: none; height: 24px; display: flex; align-items: center; gap: 16px; padding: 0 12px;
|
||||
background: var(--van-emous-blue); border-top: 1px solid var(--border); font-size: 11.5px; color: var(--text-muted);
|
||||
}
|
||||
.sb-hint { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.statusbar .io-status { white-space: nowrap; }
|
||||
|
||||
/* Werkbalk boven de viewports */
|
||||
.ed-toolbar {
|
||||
flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-height: 40px; padding: 4px 8px;
|
||||
background: var(--surface); border-bottom: 1px solid var(--border); position: relative; z-index: 20; container-type: inline-size;
|
||||
}
|
||||
.tb-group { display: flex; align-items: center; gap: 2px; }
|
||||
.tb-spacer { flex: 1; }
|
||||
.tb-sep { width: 1px; height: 22px; background: var(--border-soft); margin: 0 6px; }
|
||||
.tb-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 9px; border: 1px solid transparent; border-radius: 5px;
|
||||
background: none; font: inherit; font-size: 12.5px; color: var(--text); cursor: pointer; white-space: nowrap; position: relative;
|
||||
}
|
||||
.tb-btn .bi { font-size: 15px; color: var(--text-muted); }
|
||||
.tb-btn .tb-caret { font-size: 10px; }
|
||||
.tb-btn:hover { background: var(--primary-soft); color: var(--primary); }
|
||||
.tb-btn:hover .bi, .tb-btn.active .bi, .tb-btn.open .bi { color: var(--primary); }
|
||||
.tb-btn.active, .tb-btn.open { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
|
||||
.tb-btn.flag::after { content: ''; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--primary); }
|
||||
.tb-title { font-size: 12.5px; font-weight: 600; padding: 0 6px; white-space: nowrap; }
|
||||
.tb-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
|
||||
.tb-seg .tb-btn { height: 28px; padding: 0 8px; border: none; border-radius: 0; }
|
||||
.tb-seg .tb-btn + .tb-btn { border-left: 1px solid var(--border-soft); }
|
||||
.tb-seg .tb-btn.active { background: var(--primary); color: #fff; }
|
||||
.tb-seg .tb-btn.active .bi { color: #fff; }
|
||||
.tb-options:empty { display: none; }
|
||||
.tb-opt { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; padding-left: 10px; border-left: 1px solid var(--border-soft); font-size: 12px; color: var(--text-muted); }
|
||||
.tb-opt select { height: 28px; max-width: 220px; padding: 0 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; color: var(--text); }
|
||||
.tb-range { display: none; align-items: center; gap: 6px; margin-left: 2px; font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.tb-section.on .tb-range { display: inline-flex; }
|
||||
.tb-range input { width: 110px; accent-color: var(--primary); }
|
||||
#ho-section-val { min-width: 54px; }
|
||||
/* Smalle werkbalk: eerst de weergaveknoppen zonder tekst, daarna ook het gereedschap (titel blijft als tooltip). */
|
||||
@container (max-width: 1240px) { .tb-view .tb-btn > span, .tb-section .tb-btn > span, [data-tb="report"] > span { display: none; } }
|
||||
@container (max-width: 860px) { .tb-btn > span, .tb-opt > span { display: none; } }
|
||||
|
||||
.tb-pop-wrap { position: relative; }
|
||||
.tb-pop {
|
||||
display: none; position: absolute; top: calc(100% + 6px); right: 0; min-width: 170px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 10px 28px rgba(33, 33, 33, .18); z-index: 50;
|
||||
}
|
||||
.tb-pop.open { display: block; }
|
||||
.pop-option {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border: none; border-radius: 4px; background: none;
|
||||
font: inherit; font-size: 12.5px; color: var(--text); text-align: left; cursor: pointer;
|
||||
}
|
||||
.pop-option:hover { background: var(--primary-soft); color: var(--primary); }
|
||||
.pop-option.active { color: var(--primary); font-weight: 600; }
|
||||
.pop-layers { width: max-content; max-width: min(440px, 90vw); padding: 10px 12px; }
|
||||
.pop-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||
.pop-phase { flex: 1; height: 28px; padding: 0 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; }
|
||||
.pop-groups { display: grid; grid-template-columns: repeat(2, minmax(170px, 1fr)); gap: 0 18px; }
|
||||
.pop-head { margin: 6px 0 2px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); }
|
||||
.pop-check { display: flex; align-items: center; gap: 7px; padding: 3px 2px; font-size: 12.5px; cursor: pointer; white-space: nowrap; }
|
||||
.pop-check:hover { color: var(--primary); }
|
||||
.pop-check input { margin: 0; accent-color: var(--primary); }
|
||||
.pop-check .bi { width: 14px; color: var(--text-muted); }
|
||||
|
||||
/* Eigenschappenpaneel */
|
||||
.inspector { overflow-y: auto; background: var(--surface); }
|
||||
.ins-head { position: sticky; top: 0; z-index: 2; padding: 12px 14px 10px; background: var(--surface); border-bottom: 1px solid var(--border-soft); }
|
||||
.ins-crumbs { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; font-size: 11.5px; color: var(--text-faint); }
|
||||
.ins-crumbs .bi { font-size: 9px; }
|
||||
.ins-crumb { padding: 0; border: none; background: none; font: inherit; color: var(--primary); cursor: pointer; }
|
||||
.ins-crumb:hover { text-decoration: underline; }
|
||||
.ins-head h2 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; font-weight: 600; color: var(--text); }
|
||||
.ins-head h2 .bi { color: var(--primary); }
|
||||
.ins-sub { margin-top: 2px; font-size: 11.5px; color: var(--text-muted); }
|
||||
.ins-top { padding: 8px 14px 10px; border-bottom: 1px solid var(--border-soft); }
|
||||
.ins-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||
.ins-summary .ins-wide { grid-column: 1 / -1; }
|
||||
.ins-kv { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); font-size: 12px; color: var(--text-muted); }
|
||||
.ins-kv b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.ins-kv small { margin-left: 2px; font-weight: normal; color: var(--text-faint); }
|
||||
.ins-section { border-bottom: 1px solid var(--border-soft); }
|
||||
.ins-section-head {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 14px; border: none; background: none;
|
||||
font: inherit; font-size: 13px; font-weight: 600; color: var(--text); text-align: left; cursor: pointer;
|
||||
}
|
||||
.ins-section-head:hover { background: var(--van-emous-ultra-light); color: var(--primary); }
|
||||
.ins-section-head > .bi:first-child { width: 16px; color: var(--primary); }
|
||||
.ins-section-head .bi-chevron-down { margin-left: auto; font-size: 11px; color: var(--text-faint); transition: transform .15s; }
|
||||
.ins-section.open .ins-section-head .bi-chevron-down { transform: rotate(180deg); }
|
||||
.ins-collapsible:not(.open) .ins-section-body { display: none; }
|
||||
.ins-section-body { padding: 2px 14px 12px; }
|
||||
.ins-group + .ins-group { margin-top: 8px; }
|
||||
.ins-group h3 { margin: 8px 0 4px; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); }
|
||||
.ins-group-body { display: flex; flex-direction: column; gap: 2px; }
|
||||
.ins-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px; align-self: flex-start; margin: 3px 0; padding: 5px 11px;
|
||||
border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; color: var(--text); cursor: pointer;
|
||||
}
|
||||
.ins-btn .bi { color: var(--primary); }
|
||||
.ins-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
|
||||
.ins-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
|
||||
.ins-btn:disabled { opacity: .5; cursor: default; }
|
||||
.ins-btn.ins-danger { color: var(--danger); border-color: #e3b4b0; }
|
||||
.ins-btn.ins-danger .bi { color: var(--danger); }
|
||||
.ins-btn.ins-danger:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
|
||||
.ins-group-body > .ins-btn + .ins-btn { margin-top: 0; }
|
||||
.ins-note { margin: 4px 0; font-size: 11.5px; line-height: 1.45; color: var(--text-muted); }
|
||||
.ins-note.ins-warn { padding: 6px 8px; border-radius: 4px; background: var(--danger-soft); color: var(--danger); }
|
||||
/* Staal of foto in de eenheid-kolom, zodat het veld uitlijnt met de rest. */
|
||||
.ins-pair { position: relative; }
|
||||
.ins-pair > .ins-thumb, .ins-pair > .ins-swatch { position: absolute; right: 0; top: 50%; transform: translateY(-50%); }
|
||||
.ins-thumb { width: 26px; height: 26px; display: block; border: 1px solid var(--border); border-radius: 3px; object-fit: cover; }
|
||||
.ins-swatch { width: 26px; height: 22px; border: 1px solid var(--border); border-radius: 3px; }
|
||||
.ins-check { display: flex; align-items: center; gap: 7px; padding: 3px 0; font-size: 12.5px; cursor: pointer; }
|
||||
.ins-check input { margin: 0; accent-color: var(--primary); }
|
||||
.ins-cols { display: flex; flex-direction: column; }
|
||||
.ins-cols:empty { display: none; }
|
||||
.inspector .ui-field { min-height: 30px; }
|
||||
.inspector .ui-control input[type=number] { width: 96px; }
|
||||
.inspector .ui-control select, .inspector .ui-control input[type=text] { width: 150px; }
|
||||
.inspector .ui-control input[type=color] { width: 46px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 3px; background: none; }
|
||||
.inspector .ui-switch { margin-right: 0; }
|
||||
.inspector .ui-range input[type=number] { width: 80px; }
|
||||
|
||||
/* Dialogen */
|
||||
.dlg-overlay { position: fixed; inset: 0; z-index: 500; display: none; align-items: center; justify-content: center; background: rgba(33, 33, 33, .45); }
|
||||
.dlg-overlay.open { display: flex; }
|
||||
.dlg {
|
||||
display: flex; flex-direction: column; width: 780px; max-width: 94%; max-height: 88vh;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 12px 30px rgba(33, 33, 33, .2);
|
||||
}
|
||||
.dlg-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px 8px; }
|
||||
.dlg-head h2 { margin: 0; font-size: 15px; color: var(--primary); }
|
||||
.dlg-x { border: none; background: none; font-size: 20px; line-height: 1; color: var(--text-faint); cursor: pointer; }
|
||||
.dlg-x:hover { color: var(--danger); }
|
||||
.keys-body { overflow-y: auto; padding: 4px 20px 18px; columns: 2 300px; column-gap: 28px; }
|
||||
.keys-body section { break-inside: avoid; margin-bottom: 14px; }
|
||||
.keys-body h3 { margin: 0 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); }
|
||||
.keys-row { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); font-size: 12.5px; }
|
||||
.keys-row span:last-child { color: var(--text-muted); text-align: right; }
|
||||
kbd {
|
||||
display: inline-block; min-width: 20px; padding: 1px 6px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px;
|
||||
background: var(--van-emous-ultra-light); font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); text-align: center;
|
||||
}
|
||||
#new-modal { max-width: 92vw; }
|
||||
.new-options { flex-wrap: wrap; max-width: 640px; }
|
||||
.keys-mouse { font-weight: 600; color: var(--text); }
|
||||
#house-nav { overflow-x: hidden; }
|
||||
.hn-item { min-width: 0; }
|
||||
.hn-label { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hn-meta { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Structuur: inklapbare groepen */
|
||||
.hn-item { align-items: center; }
|
||||
.hn-item .hn-label { flex: 1; }
|
||||
.hn-group:not(.open) .hn-children { display: none; }
|
||||
.hn-head { cursor: pointer; }
|
||||
.hn-head:hover { color: var(--primary); background: rgba(255, 255, 255, .6); }
|
||||
.hn-toggle {
|
||||
flex: none; width: 18px; height: 18px; margin: 0 2px 0 -4px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
|
||||
border: none; border-radius: 3px; background: none; color: var(--text-faint); cursor: pointer;
|
||||
}
|
||||
.hn-toggle .bi { margin: 0 !important; font-size: 10px; color: inherit !important; transition: transform .15s; }
|
||||
.hn-group.open > .hn-item .hn-toggle .bi { transform: rotate(90deg); }
|
||||
.hn-toggle:hover { background: rgba(255, 255, 255, .8); color: var(--primary); }
|
||||
.hn-sub { padding-left: 30px; }
|
||||
|
||||
/* Masonry: kaarten in de kortste kolom (leesvolgorde blijft links → rechts) */
|
||||
.ui-masonry { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.ui-masonry-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* ===== Gelijktrekken oude onderdelen met de nieuwe stijl ===== */
|
||||
.io-btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; font-family: inherit; font-size: 12px; }
|
||||
.ui-btn.ui-btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
|
||||
|
||||
/* Viewportlabels: herkenbaar maar niet dominant */
|
||||
.pane-label {
|
||||
top: 10px; left: 10px; padding: 3px 8px; border: none; border-radius: 4px; background: rgba(0, 0, 0, .55);
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .8px; color: #c9ced6;
|
||||
}
|
||||
.ho-warn { top: 40px; left: 10px; right: 10px; }
|
||||
|
||||
/* Eigenschappenpaneel: getal naast schuif lijnt uit met gewone getalvelden; bredere keuzelijsten */
|
||||
.inspector .ui-range { gap: 0; }
|
||||
.inspector .ui-range input[type=range] { margin-right: 10px; }
|
||||
.inspector .ui-range input[type=number], .inspector .ui-control input[type=number] { width: 96px; }
|
||||
.inspector .ui-control select, .inspector .ui-control input[type=text] { width: 170px; }
|
||||
.inspector .ui-field label { flex: 1; min-width: 0; }
|
||||
|
||||
/* Rapport- en resultaatkaarten: lange waarde schuift onder het label i.p.v. het label te pletten */
|
||||
.ui-spec-row { flex-wrap: wrap; }
|
||||
.ui-spec-val { margin-left: auto; }
|
||||
|
||||
/* Wand-/vloerpaneel: koppen, velden en knoppen zoals het eigenschappenpaneel */
|
||||
.ed-panel h1 { margin: 12px 0 4px; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); }
|
||||
.ed-panel h1 .bi { display: none; }
|
||||
.ed-panel .tab-content > :first-child h1:first-child, .ed-panel .controls-grid > div:first-child > h1:first-child { margin-top: 2px; }
|
||||
.ed-panel .spec-section h1 { margin: 0 0 6px; font-size: 12px; color: var(--primary); letter-spacing: .6px; }
|
||||
.ed-panel .spec-section h1 .bi { display: inline; }
|
||||
.ed-panel .divider { border: none; margin: 4px 0; }
|
||||
.ed-panel .control-group label { flex: 1; min-width: 0; line-height: 1.3; }
|
||||
.ed-panel .control-group > div { display: flex; align-items: center; flex: none; white-space: nowrap; }
|
||||
.ed-panel .control-group .unit { display: inline-block; width: 22px; margin-left: 3px; }
|
||||
.ed-panel .control-group > div > select:last-child, .ed-panel .control-group > div > input:last-child { margin-right: 25px; }
|
||||
.ed-panel .control-group .ui-switch { margin-right: 25px; }
|
||||
.ed-panel .control-group input[type=number], .ed-panel .control-group input[type=text], .ed-panel .control-group select { width: 110px; }
|
||||
.ed-panel .material-info { font-size: 11.5px; }
|
||||
|
||||
.material-toggle-group { flex-wrap: wrap; }
|
||||
.material-toggle-btn {
|
||||
flex: 1 1 auto; min-width: 0; padding: 6px 10px; border-radius: 4px; text-transform: none; letter-spacing: 0;
|
||||
font-family: inherit; font-size: 12.5px; font-weight: 500;
|
||||
}
|
||||
.material-toggle-btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
.add-opening-btn {
|
||||
width: 100%; justify-content: center; padding: 7px 10px; background: var(--surface); border: 1px dashed var(--border);
|
||||
color: var(--text); font-weight: 500; font-family: inherit;
|
||||
}
|
||||
.add-opening-btn .bi { color: var(--primary); }
|
||||
.add-opening-btn:hover { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
|
||||
.opening-card-title { text-transform: none; letter-spacing: 0; font-size: 13px; color: var(--text); }
|
||||
.opening-remove-btn { border-color: transparent; color: var(--text-faint); }
|
||||
.opening-remove-btn:hover { border-color: var(--danger); color: var(--danger); }
|
||||
#cost-settings-modal .cs-row input, #cost-settings-modal .cs-row select {
|
||||
padding: 4px 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; color: var(--text);
|
||||
}
|
||||
#cost-settings-modal .cs-row input:focus, #cost-settings-modal .cs-row select:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px var(--primary-soft); }
|
||||
#cost-settings-modal .cs-row input:disabled, #cost-settings-modal .cs-row input[readonly] { background: var(--border-soft); }
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
/* Light-versie (modelpagina's). Alles onder .tectum-light, zodat de stijl van de site eromheen (Bootstrap) er niet
|
||||
mee botst; koppen, labels en knoppen krijgen daarom expliciet marge, gewicht en regelhoogte. De indeling volgt de
|
||||
breedte van de tool zelf (container queries), niet die van het scherm: in de site staat hij in een col-12. */
|
||||
.tectum-light {
|
||||
--tl-primary: #b95913;
|
||||
--tl-primary-hover: #9c4a0f;
|
||||
--tl-primary-soft: rgba(185, 89, 19, 0.10);
|
||||
--tl-text: #212121;
|
||||
--tl-muted: #5b6673;
|
||||
--tl-faint: #8a94a0;
|
||||
--tl-surface: #ffffff;
|
||||
--tl-bg: #f9faf5;
|
||||
--tl-border: #c5ced9;
|
||||
--tl-border-soft: #e2e7ee;
|
||||
--tl-radius: 10px;
|
||||
container: tl / inline-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--tl-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
}
|
||||
.tectum-light *, .tectum-light *::before, .tectum-light *::after { box-sizing: border-box; }
|
||||
.tectum-light button { font: inherit; color: inherit; line-height: 1.2; margin: 0; text-transform: none; }
|
||||
.tectum-light h2, .tectum-light h3 { margin: 0; font-weight: 700; line-height: 1.2; }
|
||||
.tectum-light label { display: block; margin: 0; }
|
||||
.tectum-light small { font-size: 11px; color: var(--tl-faint); font-weight: 400; }
|
||||
|
||||
.tectum-light .tl-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
height: clamp(560px, 80vh, 800px);
|
||||
border: 1px solid var(--tl-border-soft);
|
||||
border-radius: var(--tl-radius);
|
||||
overflow: hidden;
|
||||
background: var(--tl-surface);
|
||||
}
|
||||
|
||||
/* 3D / plattegrond */
|
||||
.tectum-light .tl-stage { position: relative; min-width: 0; min-height: 0; background: #dfe6ec; }
|
||||
.tectum-light .tl-3d { position: absolute; inset: 0; }
|
||||
.tectum-light .tl-3d canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||
.tectum-light .tl-plan { position: absolute; inset: 0; width: 100%; height: 100%; display: none; background: #fff; }
|
||||
.tectum-light.view-plan .tl-plan { display: block; }
|
||||
.tectum-light.view-plan .tl-3d { visibility: hidden; }
|
||||
.tectum-light .tl-viewbar, .tectum-light .tl-cutbar, .tectum-light .tl-busy, .tectum-light .tl-hint {
|
||||
position: absolute; background: rgba(255, 255, 255, 0.94); border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.tectum-light .tl-viewbar { top: 10px; left: 10px; display: flex; gap: 2px; padding: 3px; }
|
||||
.tectum-light .tl-viewbar button {
|
||||
display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 6px 10px;
|
||||
border-radius: 6px; cursor: pointer; color: var(--tl-muted); white-space: nowrap;
|
||||
}
|
||||
.tectum-light .tl-viewbar button:hover { color: var(--tl-text); }
|
||||
.tectum-light .tl-viewbar button.on { background: var(--tl-primary-soft); color: var(--tl-primary); font-weight: 600; }
|
||||
.tectum-light .tl-cutbar { top: 52px; left: 10px; display: none; align-items: center; gap: 10px; padding: 6px 12px; font-size: 13px; }
|
||||
.tectum-light.view-cut .tl-cutbar { display: flex; }
|
||||
.tectum-light .tl-cutbar input { width: 150px; accent-color: var(--tl-primary); }
|
||||
.tectum-light .tl-cutbar output { min-width: 4.2em; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
/* Rechtsboven: AR (telefoon/tablet) of schermvullend (desktop). */
|
||||
.tectum-light .tl-xbtn {
|
||||
position: absolute; top: 10px; right: 10px; width: 35px; height: 35px; padding: 0; border: 0; border-radius: 8px;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); color: var(--tl-text);
|
||||
}
|
||||
.tectum-light .tl-xbtn:hover { color: var(--tl-primary); }
|
||||
.tectum-light .tl-xbtn[hidden] { display: none; }
|
||||
.tectum-light.has-xbtn .tl-busy { right: 53px; }
|
||||
.tectum-light .tl-hint { left: 10px; bottom: 10px; font-size: 12px; color: var(--tl-muted); padding: 3px 8px; box-shadow: none; pointer-events: none; }
|
||||
.tectum-light .tl-busy { top: 10px; right: 10px; display: none; align-items: center; gap: 8px; padding: 6px 12px; }
|
||||
.tectum-light.is-busy .tl-busy { display: inline-flex; }
|
||||
.tectum-light.is-busy .tl-panel { pointer-events: none; }
|
||||
.tectum-light .tl-spinner {
|
||||
width: 14px; height: 14px; border: 2px solid var(--tl-border); border-top-color: var(--tl-primary);
|
||||
border-radius: 50%; animation: tl-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes tl-spin { to { transform: rotate(360deg); } }
|
||||
.tectum-light .tl-toast {
|
||||
position: absolute; left: 50%; bottom: 42px; transform: translate(-50%, 8px); width: max-content; max-width: calc(100% - 24px);
|
||||
background: #2b2f33; color: #fff; padding: 9px 14px; border-radius: 8px; font-size: 13px;
|
||||
opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none;
|
||||
}
|
||||
.tectum-light .tl-toast.on { opacity: 1; transform: translate(-50%, 0); }
|
||||
|
||||
/* Paneel */
|
||||
.tectum-light .tl-panel { display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--tl-border-soft); background: var(--tl-surface); }
|
||||
.tectum-light .tl-head { padding: 14px 16px 10px; }
|
||||
.tectum-light .tl-title { font-size: 18px; }
|
||||
.tectum-light .tl-sub { margin-top: 3px; color: var(--tl-muted); font-size: 12px; }
|
||||
.tectum-light .tl-sections { flex: 1; min-height: 0; overflow-y: auto; border-top: 1px solid var(--tl-border-soft); }
|
||||
|
||||
/* Accordeon */
|
||||
.tectum-light .tl-sec { border-bottom: 1px solid var(--tl-border-soft); }
|
||||
.tectum-light .tl-sec-head {
|
||||
display: flex; align-items: center; gap: 9px; width: 100%; padding: 11px 16px; border: 0; background: none; cursor: pointer; text-align: left;
|
||||
}
|
||||
.tectum-light .tl-sec-head:hover { background: var(--tl-bg); }
|
||||
.tectum-light .tl-sec-head > .bi:first-child { color: var(--tl-primary); }
|
||||
.tectum-light .tl-sec-title { font-weight: 600; }
|
||||
.tectum-light .tl-sec-sum { flex: 1; min-width: 0; text-align: right; font-size: 12px; color: var(--tl-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tectum-light .tl-chev { font-size: 12px; color: var(--tl-faint); transition: transform 0.15s; }
|
||||
.tectum-light .tl-sec.open .tl-chev { transform: rotate(180deg); }
|
||||
.tectum-light .tl-sec.open .tl-sec-sum { visibility: hidden; }
|
||||
.tectum-light .tl-sec-body { padding: 0 16px 12px; }
|
||||
|
||||
/* Velden */
|
||||
.tectum-light .tl-field { margin: 8px 0; }
|
||||
.tectum-light .tl-label { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--tl-muted); margin-bottom: 4px; }
|
||||
.tectum-light .tl-val { color: var(--tl-text); font-weight: 600; text-align: right; }
|
||||
.tectum-light .tl-note { font-size: 11px; color: var(--tl-faint); }
|
||||
.tectum-light .tl-seg { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tectum-light .tl-seg button {
|
||||
flex: 1 1 auto; border: 1px solid var(--tl-border); background: var(--tl-surface); border-radius: 7px;
|
||||
padding: 6px 8px; cursor: pointer; white-space: nowrap; font-size: 13px;
|
||||
}
|
||||
.tectum-light .tl-seg button:hover:not(:disabled) { border-color: var(--tl-primary); }
|
||||
.tectum-light .tl-seg button.on { border-color: var(--tl-primary); background: var(--tl-primary-soft); color: var(--tl-primary); font-weight: 600; }
|
||||
.tectum-light .tl-units .tl-seg button { flex: 0 0 auto; min-width: 40px; }
|
||||
.tectum-light button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.tectum-light button:focus-visible, .tectum-light input:focus-visible, .tectum-light .tl-map-u:focus-visible { outline: 2px solid var(--tl-primary); outline-offset: 2px; }
|
||||
|
||||
.tectum-light .tl-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tectum-light .tl-sw {
|
||||
width: 30px; height: 30px; padding: 0; border-radius: 50%; border: 1px solid rgba(0, 0, 0, 0.18);
|
||||
background: var(--sw); cursor: pointer; overflow: hidden; box-shadow: 0 0 0 2px var(--tl-surface);
|
||||
}
|
||||
.tectum-light .tl-sw img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.tectum-light .tl-sw.on { box-shadow: 0 0 0 2px var(--tl-surface), 0 0 0 4px var(--tl-primary); }
|
||||
|
||||
.tectum-light .tl-slider { margin: 8px 0; }
|
||||
.tectum-light .tl-sl-head { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; }
|
||||
.tectum-light .tl-sl-head output { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.tectum-light .tl-slider input { display: block; width: 100%; margin: 4px 0 0; accent-color: var(--tl-primary); }
|
||||
|
||||
.tectum-light .tl-mapbox { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
|
||||
.tectum-light .tl-map { display: block; flex: 1; min-width: 0; max-height: 96px; }
|
||||
.tectum-light .tl-map-u { cursor: pointer; outline: none; }
|
||||
.tectum-light .tl-map-u rect { fill: var(--tl-bg); stroke: var(--tl-border); stroke-width: 40; }
|
||||
.tectum-light .tl-map-u text { fill: var(--tl-muted); font-weight: 600; pointer-events: none; }
|
||||
.tectum-light .tl-map-u:hover rect { stroke: var(--tl-primary); }
|
||||
.tectum-light .tl-map-u.on rect { fill: var(--tl-primary-soft); stroke: var(--tl-primary); stroke-width: 60; }
|
||||
.tectum-light .tl-map-u.on text { fill: var(--tl-primary); }
|
||||
|
||||
/* Kerncijfers en prijs */
|
||||
.tectum-light .tl-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 14px; padding: 10px 16px; font-size: 12px; border-top: 1px solid var(--tl-border-soft); background: var(--tl-bg); }
|
||||
.tectum-light .tl-kv { display: flex; justify-content: space-between; gap: 6px; }
|
||||
.tectum-light .tl-kv span { color: var(--tl-muted); }
|
||||
.tectum-light .tl-kv b { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.tectum-light .tl-buy { display: flex; align-items: center; gap: 8px; padding: 10px 16px 12px; background: var(--tl-bg); }
|
||||
.tectum-light .tl-price { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.tectum-light .tl-price-val { font-size: 21px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.tectum-light .tl-price-note { font-size: 11px; color: var(--tl-muted); }
|
||||
.tectum-light .tl-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 40px; padding: 0 14px;
|
||||
border: 1px solid var(--tl-border); border-radius: 8px; background: var(--tl-surface); cursor: pointer;
|
||||
}
|
||||
.tectum-light .tl-btn:hover { border-color: var(--tl-primary); }
|
||||
.tectum-light .tl-btn.tl-icon { width: 40px; padding: 0; }
|
||||
.tectum-light .tl-btn.tl-primary { background: var(--tl-primary); border-color: var(--tl-primary); color: #fff; font-weight: 600; }
|
||||
.tectum-light .tl-btn.tl-primary:hover { background: var(--tl-primary-hover); }
|
||||
|
||||
/* Middel (bijv. tablet, of een smallere kolom): paneel iets smaller. */
|
||||
@container tl (max-width: 980px) {
|
||||
.tectum-light .tl-wrap { grid-template-columns: minmax(0, 1fr) 300px; }
|
||||
.tectum-light .tl-viewbar button { padding: 6px 8px; }
|
||||
}
|
||||
|
||||
/* Smal (telefoon): 3D boven, keuzes eronder; prijs en PDF blijven onderin beeld zolang de keuzes in beeld zijn. */
|
||||
@container tl (max-width: 720px) {
|
||||
.tectum-light .tl-wrap { grid-template-columns: 1fr; height: auto; overflow: visible; }
|
||||
.tectum-light .tl-stage { height: min(62vh, 115cqw); border-radius: var(--tl-radius) var(--tl-radius) 0 0; overflow: hidden; }
|
||||
.tectum-light .tl-panel { border-left: 0; border-top: 1px solid var(--tl-border-soft); border-radius: 0 0 var(--tl-radius) var(--tl-radius); }
|
||||
.tectum-light .tl-sections { overflow: visible; }
|
||||
.tectum-light .tl-buy { position: sticky; bottom: 0; z-index: 2; border-top: 1px solid var(--tl-border-soft); box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06); border-radius: 0 0 var(--tl-radius) var(--tl-radius); }
|
||||
.tectum-light .tl-hint { display: none; }
|
||||
.tectum-light .tl-viewbar { left: 8px; right: 8px; top: 8px; }
|
||||
.tectum-light .tl-viewbar button { flex: 1; justify-content: center; }
|
||||
.tectum-light.has-xbtn .tl-viewbar { right: 51px; }
|
||||
.tectum-light .tl-xbtn { top: 8px; right: 8px; width: 37px; height: 37px; }
|
||||
.tectum-light .tl-cutbar { left: 8px; right: 8px; top: 50px; }
|
||||
.tectum-light .tl-cutbar input { flex: 1; width: auto; }
|
||||
.tectum-light .tl-busy { top: auto; bottom: 10px; right: 10px; }
|
||||
/* Grotere tikvlakken */
|
||||
.tectum-light .tl-seg button { padding: 9px 8px; }
|
||||
.tectum-light .tl-sw { width: 36px; height: 36px; }
|
||||
.tectum-light .tl-sec-head { padding: 13px 16px; }
|
||||
.tectum-light .tl-slider input { height: 28px; }
|
||||
}
|
||||
@container tl (max-width: 380px) {
|
||||
.tectum-light .tl-viewbar button .bi { display: none; }
|
||||
.tectum-light .tl-facts { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* AR-weergave (model-viewer) over de hele pagina. Staat buiten .tectum-light: die is een container en zou het kader van
|
||||
position: fixed worden. Kleur van de knop = --tl-primary. */
|
||||
html.tl-noscroll, html.tl-noscroll body { overflow: hidden; }
|
||||
.tl-arview {
|
||||
position: fixed; inset: 0; z-index: 2147483000; display: none; background: #dfe6ec; color: #212121;
|
||||
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.tl-arview.on { display: block; }
|
||||
.tl-arview *, .tl-arview *::before, .tl-arview *::after { box-sizing: border-box; }
|
||||
.tl-arview model-viewer { display: block; width: 100%; height: 100%; background: #dfe6ec; --poster-color: transparent; }
|
||||
.tl-arview .tl-ar-top {
|
||||
position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: max(10px, env(safe-area-inset-top)) 12px 10px 16px; pointer-events: none;
|
||||
}
|
||||
.tl-arview .tl-ar-title { font-size: 17px; font-weight: 700; }
|
||||
.tl-arview .tl-ar-close {
|
||||
pointer-events: auto; width: 44px; height: 44px; margin: 0; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; color: inherit;
|
||||
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.tl-arview .tl-ar-go {
|
||||
position: absolute; left: 50%; bottom: calc(44px + env(safe-area-inset-bottom)); transform: translateX(-50%);
|
||||
display: inline-flex; align-items: center; gap: 9px; height: 50px; margin: 0; padding: 0 24px; border: 0; border-radius: 25px;
|
||||
background: #b95913; color: #fff; font-family: inherit; font-size: 16px; font-weight: 600; line-height: 1; white-space: nowrap; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.tl-arview .tl-ar-go .bi { font-size: 22px; }
|
||||
.tl-arview .tl-ar-note {
|
||||
position: absolute; left: 16px; right: 16px; bottom: calc(14px + env(safe-area-inset-bottom));
|
||||
text-align: center; font-size: 12px; color: #5b6673; pointer-events: none;
|
||||
}
|
||||
+36
-62
@@ -22,62 +22,20 @@
|
||||
<div class="splash-version"><span class="splash-name">Tectum</span> — 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…</span><span class="menu-hotkey">Ctrl+S</span></div>
|
||||
<div class="menu-item" id="menu-importeren">Importeren…</div>
|
||||
<div class="menu-item" id="menu-project-props">Project eigenschappen…</div>
|
||||
</div>
|
||||
<header id="topbar">
|
||||
<div class="tb-brand"><span class="splash-name">Tectum</span></div>
|
||||
<nav id="menubar" aria-label="Menu"></nav>
|
||||
<div id="doc-tabs" role="tablist" aria-label="Geopende ontwerpen"></div>
|
||||
<div class="top-right">
|
||||
<button type="button" id="top-save" class="top-save" title="Opslaan als JSON + PDF (Ctrl+S)"><i class="bi bi-save"></i><span>Opslaan</span></button>
|
||||
<span class="app-version-slot"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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 groot / alleen render</span><span class="menu-hotkey">T</span></div>
|
||||
<div class="menu-item" id="menu-2d-fullscreen"><span>2D groot</span><span class="menu-hotkey">R</span></div>
|
||||
<div class="menu-item" id="menu-camera-item">
|
||||
<span>Camera</span><span class="menu-hotkey">›</span>
|
||||
<div class="submenu">
|
||||
<div class="menu-item" id="menu-walk"><span>Lopen (first person)</span><span class="menu-hotkey">Shift+T</span></div>
|
||||
<div class="menu-info">Rondom (standaard)</div>
|
||||
<div class="menu-item menu-help"><span>Draaien</span><span class="menu-hotkey">Slepen</span></div>
|
||||
<div class="menu-item menu-help"><span>Verschuiven</span><span class="menu-hotkey">Rechts slepen</span></div>
|
||||
<div class="menu-item menu-help"><span>Zoomen</span><span class="menu-hotkey">Scrollwiel</span></div>
|
||||
<div class="menu-info">Lopen</div>
|
||||
<div class="menu-item menu-help"><span>Vooruit / achteruit</span><span class="menu-hotkey">W / S, ↑ / ↓</span></div>
|
||||
<div class="menu-item menu-help"><span>Opzij stappen</span><span class="menu-hotkey">A / D</span></div>
|
||||
<div class="menu-item menu-help"><span>Draaien</span><span class="menu-hotkey">← / →</span></div>
|
||||
<div class="menu-item menu-help"><span>Rondkijken</span><span class="menu-hotkey">Slepen</span></div>
|
||||
<div class="menu-item menu-help"><span>Rennen</span><span class="menu-hotkey">Shift</span></div>
|
||||
<div class="menu-item menu-help"><span>Terug</span><span class="menu-hotkey">Shift+T</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app-bar-right">
|
||||
<span id="app-version-tag" class="app-version-slot"></span>
|
||||
<button id="btn-about">Over deze software</button>
|
||||
<div id="keys-overlay" class="dlg-overlay">
|
||||
<div class="dlg" role="dialog" aria-labelledby="keys-title">
|
||||
<div class="dlg-head"><h2 id="keys-title">Sneltoetsen en bediening</h2><button type="button" class="dlg-x" data-close>×</button></div>
|
||||
<div class="keys-body" id="keys-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,14 +50,14 @@
|
||||
<div class="license-box" id="about-license"></div>
|
||||
<div class="about-footer">
|
||||
<span>© 2026 Machiel Heinen · ontwikkeld in opdracht van Van Emous Mantelzorgwoningen</span>
|
||||
<button id="about-close" class="about-close">Sluiten</button>
|
||||
<button id="about-close" class="about-close" data-esc>Sluiten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="new-overlay">
|
||||
<div id="new-modal">
|
||||
<h2>Nieuw</h2>
|
||||
<h2>Nieuw ontwerp</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>
|
||||
@@ -117,41 +75,57 @@
|
||||
<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 class="new-crm" hidden></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/view-layers.js"></script>
|
||||
<script src="js/core/canvas-labels.js"></script>
|
||||
<script src="js/core/camera-memory.js"></script>
|
||||
<script src="js/core/walk-controls.js"></script>
|
||||
<script src="js/core/view-filter.js"></script>
|
||||
<script src="js/core/ui-kit.js"></script>
|
||||
<script src="js/core/menubar.js"></script>
|
||||
<script src="js/core/inspector.js"></script>
|
||||
<script src="js/core/skybox.js"></script>
|
||||
<script src="js/vendor/jspdf.umd.min.js"></script>
|
||||
<script src="js/core/pdf-sheet.js"></script>
|
||||
<script src="js/core/dxf-writer.js"></script>
|
||||
<script src="js/core/ifc-writer.js"></script>
|
||||
<script src="js/core/zip-writer.js"></script>
|
||||
<script src="js/core/obj-writer.js"></script>
|
||||
<script src="js/wall/kozijn-model.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/roof/post-model.js"></script>
|
||||
<script src="js/lighting/lighting-model.js"></script>
|
||||
<script src="js/elektra/elektra-model.js"></script>
|
||||
<script src="js/sandwich/sandwich-model.js"></script>
|
||||
<script src="js/wall/wall.js"></script>
|
||||
<script src="js/floor/floor.js"></script>
|
||||
<script src="js/unit/unit-model.js"></script>
|
||||
<script src="js/room/room-model.js"></script>
|
||||
<script src="js/interior/fixture-model.js"></script>
|
||||
<script src="js/house/chimney-model.js"></script>
|
||||
<script src="js/house/house-model.js"></script>
|
||||
<script src="js/cost/cost-defaults.js"></script>
|
||||
<script src="js/cost/cost-csv.js"></script>
|
||||
<script src="js/cost/cost-model.js"></script>
|
||||
<script src="js/cost/cost-settings.js"></script>
|
||||
<script src="js/elektra/elektra-pdf.js"></script>
|
||||
<script src="js/house/house-pdf.js"></script>
|
||||
<script src="js/house/house-dxf.js"></script>
|
||||
<script src="js/house/house-ifc.js"></script>
|
||||
<script src="js/house/house.js"></script>
|
||||
<script src="js/crm.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
const APP_VERSION = '0.4.16';
|
||||
const APP_VERSION = '0.7.9';
|
||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||
|
||||
let tabs = [];
|
||||
@@ -28,29 +28,132 @@ function defaultNameFor(kind) {
|
||||
function setWorkspaceEmpty() {
|
||||
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
|
||||
<div>Geen ontwerp geopend.</div>
|
||||
<div style="font-size:11px;">Bestand → Nieuw → kies een objecttype</div>
|
||||
<div style="font-size:12px;">Bestand › Nieuw (Ctrl+N)</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-walk').classList.toggle('disabled', !has('toggleWalk'));
|
||||
document.getElementById('menu-project-props').classList.toggle('disabled', !has('openProjectProperties'));
|
||||
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function' && (!currentEditor.can || currentEditor.can(fn)));
|
||||
const editorCall = (fn, ...args) => { if (editorHas(fn)) return currentEditor[fn](...args); };
|
||||
const viewState = () => editorCall('viewState') || {};
|
||||
|
||||
// Menu met de sneltoets per item; KEY_HELP hieronder is het overzicht (Help › Sneltoetsen). Editor-acties werken alleen als de editor ze kent.
|
||||
const MENUS = [
|
||||
{ label: 'Bestand', items: [
|
||||
{ label: 'Nieuw…', icon: 'file-earmark-plus', keys: 'Ctrl+N', run: openNewModal },
|
||||
{ label: 'Openen…', icon: 'folder2-open', keys: 'Ctrl+O', run: () => editorCall('triggerImport'), enabled: () => editorHas('triggerImport') },
|
||||
{ label: 'Opslaan (JSON + PDF)', icon: 'save', keys: 'Ctrl+S', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') && !activeCrm() },
|
||||
{ label: 'Opslaan in CRM (nieuwe versie)', icon: 'cloud-upload', keys: 'Ctrl+S', run: saveToCrm, enabled: () => !!activeCrm() },
|
||||
{ label: 'Downloaden (JSON + PDF)', icon: 'download', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') && !!activeCrm() },
|
||||
{ sep: true }, { head: 'Exporteren' },
|
||||
{ label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') },
|
||||
{ label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') },
|
||||
{ label: 'Render (OBJ)', icon: 'badge-3d', run: () => editorCall('exportObj'), enabled: () => editorHas('exportObj') },
|
||||
{ sep: true },
|
||||
{ label: 'Projectgegevens…', icon: 'card-text', run: () => editorCall('openProjectProperties'), enabled: () => editorHas('openProjectProperties') },
|
||||
{ label: 'Kosten & tijden…', icon: 'calculator', run: () => CostSettings.open() },
|
||||
{ sep: true },
|
||||
{ label: 'Ontwerp sluiten', icon: 'x-lg', run: () => activeTabId !== null && closeTab(activeTabId), enabled: () => activeTabId !== null }
|
||||
] },
|
||||
{ label: 'Beeld', items: [
|
||||
...UI.VIEWS.map(v => ({ label: v.label, icon: v.icon, keys: v.key1, run: () => editorCall('setView', v.key),
|
||||
enabled: () => editorHas('setView'), checked: () => viewState().view === v.key })),
|
||||
{ label: 'Rapport', icon: 'clipboard-data', keys: '4', run: () => editorCall('toggleReport'), enabled: () => editorHas('toggleReport'), checked: () => !!viewState().report },
|
||||
{ sep: true },
|
||||
{ label: 'Doorsnede', icon: 'scissors', keys: 'X', run: () => editorCall('toggleSection'), enabled: () => editorHas('toggleSection'), checked: () => !!viewState().section },
|
||||
{ label: 'Plattegrond passend', icon: 'aspect-ratio', keys: 'F', run: () => editorCall('fitView'), enabled: () => editorHas('fitView') },
|
||||
{ label: 'Lopen', icon: 'person-walking', keys: 'L', run: () => editorCall('toggleWalk'), enabled: () => editorHas('toggleWalk'), checked: () => !!viewState().walk },
|
||||
{ sep: true },
|
||||
{ label: 'Panelen verbergen', icon: 'layout-sidebar-inset', keys: 'Ctrl+\\', run: togglePanels, checked: () => document.body.classList.contains('panels-hidden') },
|
||||
{ label: 'Volledig scherm', icon: 'fullscreen', keys: 'F11', run: toggleBrowserFullscreen, checked: () => !!document.fullscreenElement }
|
||||
] },
|
||||
{ label: 'Help', items: [
|
||||
{ label: 'Sneltoetsen en bediening', icon: 'keyboard', keys: '?', run: openKeys },
|
||||
{ label: 'Over Tectum', icon: 'info-circle', run: () => document.getElementById('about-overlay').classList.add('open') }
|
||||
] }
|
||||
];
|
||||
|
||||
const KEY_HELP = [
|
||||
['Bestand', [['Ctrl+N', 'Nieuw ontwerp (ook uit de CRM)'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF; woning uit de CRM: nieuwe versie in de CRM)']]],
|
||||
['Gereedschap (woning)', [['V', 'Selecteren'], ['W', 'Binnenwand tekenen'], ['R', 'Ruimte vullen'], ['I', 'Inrichting plaatsen'],
|
||||
['Esc', 'Gereedschap stoppen, daarna selectie opheffen'], ['Del', 'Selectie verwijderen'], ['D / Shift+D', 'Inrichting draaien'], ['Dubbelklik', 'Onderdeel bewerken (wand, vloer)']]],
|
||||
['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'],
|
||||
['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]],
|
||||
['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]],
|
||||
['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts of middelste knop slepen', 'Zijwaarts verschuiven'], ['Shift/Ctrl + slepen', 'Zijwaarts verschuiven'],
|
||||
['Pijltjestoetsen (muis boven de 3D)', 'Verschuiven (Shift = grotere stap)'], ['Scrollwiel', 'Zoomen']], true],
|
||||
['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true],
|
||||
['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]],
|
||||
['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true]
|
||||
];
|
||||
|
||||
function openKeys() {
|
||||
// Muisacties als tekst, toetsen als <kbd>.
|
||||
document.getElementById('keys-body').innerHTML = KEY_HELP.map(([title, rows, mouse]) =>
|
||||
`<section><h3>${title}</h3>${rows.map(([k, d]) => `<div class="keys-row"><span>${mouse ? `<span class="keys-mouse">${k}</span>`
|
||||
: k.split(' / ').map(x => `<kbd>${x}</kbd>`).join(' / ')}</span><span>${d}</span></div>`).join('')}</section>`).join('');
|
||||
document.getElementById('keys-overlay').classList.add('open');
|
||||
}
|
||||
|
||||
function togglePanels() {
|
||||
document.body.classList.toggle('panels-hidden');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
|
||||
function renderTabBar() {
|
||||
const bar = document.getElementById('tab-bar');
|
||||
const bar = document.getElementById('doc-tabs');
|
||||
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}">×</span>
|
||||
<div class="doc-tab ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}" role="tab" aria-selected="${tab.id === activeTabId}" title="${UI.esc(tab.name)}">
|
||||
${tab.crm ? '<i class="bi bi-cloud" title="Woning uit de CRM"></i>' : ''}<span class="doc-name">${UI.esc(tab.name)}</span>
|
||||
<button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
const save = document.getElementById('top-save');
|
||||
save.disabled = !editorHas('exportDesign');
|
||||
save.querySelector('span').textContent = activeCrm() ? 'Opslaan in CRM' : 'Opslaan';
|
||||
save.title = activeCrm() ? 'Opslaan als nieuwe versie in de CRM (Ctrl+S)' : 'Opslaan als JSON + PDF (Ctrl+S)';
|
||||
}
|
||||
|
||||
// ---- CRM (js/crm.js): woning uit de CRM in een tab (tab.crm = { id, versie, titel }), terug als nieuwe versie
|
||||
const activeCrm = () => { const t = tabs.find(x => x.id === activeTabId); return t && t.crm ? t : null; };
|
||||
const crmName = (crm) => `${crm.titel} · v${crm.versie}`;
|
||||
|
||||
function saveActive() {
|
||||
if (activeCrm()) saveToCrm(); else editorCall('exportDesign');
|
||||
}
|
||||
|
||||
async function openCrmWoning(id) {
|
||||
try {
|
||||
const { woning, state } = await TectumCrm.load(id);
|
||||
const open = tabs.find(t => t.crm && t.crm.id === woning.id);
|
||||
if (open && !window.confirm(`${woning.titel} is al geopend. Nog een keer openen (versie ${woning.versie} uit de CRM)?`)) {
|
||||
switchToTab(open.id);
|
||||
return;
|
||||
}
|
||||
const crm = { id: woning.id, versie: woning.versie, titel: woning.titel };
|
||||
openNew('woning', null, { stateJson: state, name: crmName(crm), crm });
|
||||
TectumCrm.toast(`${woning.titel} geladen uit de CRM (versie ${woning.versie}${woning.contact ? ', ' + woning.contact.naam : ''}).`, 'ok');
|
||||
} catch (err) {
|
||||
TectumCrm.toast('Laden uit de CRM mislukt: ' + err.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToCrm() {
|
||||
const tab = activeCrm();
|
||||
if (!tab || !currentEditor) return;
|
||||
const note = window.prompt(`Opslaan als nieuwe versie van ${tab.crm.titel} in de CRM.\nWat is er veranderd?`, '');
|
||||
if (note === null) return;
|
||||
const state = currentEditor.getStateJson();
|
||||
try {
|
||||
const versie = await TectumCrm.saveVersion(tab.crm.id, state, tab.crm.versie, note.trim());
|
||||
if (versie === null) return;
|
||||
tab.crm.versie = versie;
|
||||
tab.baseJson = JSON.stringify(state);
|
||||
tab.name = crmName(tab.crm);
|
||||
renderTabBar();
|
||||
TectumCrm.toast(`Opgeslagen in de CRM als versie ${versie}.`, 'ok');
|
||||
} catch (err) {
|
||||
TectumCrm.toast('Opslaan in de CRM mislukt: ' + err.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function onTabSaved(tab, filenameWithExt) {
|
||||
@@ -80,12 +183,11 @@ function switchToTab(tabId) {
|
||||
|
||||
activeTabId = tabId;
|
||||
mountEditorForTab(tabs.find(t => t.id === tabId));
|
||||
updateEditMenuState();
|
||||
renderTabBar();
|
||||
}
|
||||
|
||||
function openNew(kind, template) {
|
||||
if (!editorModuleFor(kind)) { closeAllMenus(); return; } // 'dak' nog niet beschikbaar
|
||||
function openNew(kind, template, opts) {
|
||||
if (!editorModuleFor(kind)) return;
|
||||
|
||||
const oldTab = tabs.find(t => t.id === activeTabId);
|
||||
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
|
||||
@@ -94,13 +196,14 @@ function openNew(kind, template) {
|
||||
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 };
|
||||
const tab = { id: tabIdCounter++, kind, template: template || null, name: (opts && opts.name) || defaultNameFor(kind), savedName: null,
|
||||
stateJson: (opts && opts.stateJson) || null, crm: (opts && opts.crm) || null };
|
||||
tabs.push(tab);
|
||||
activeTabId = tab.id;
|
||||
mountEditorForTab(tab);
|
||||
updateEditMenuState();
|
||||
// geladen ontwerp (CRM): wijzigingen tellen vanaf hier, niet vanaf het standaardmodel
|
||||
if (tab.stateJson && currentEditor && typeof currentEditor.getStateJson === 'function') tab.baseJson = JSON.stringify(currentEditor.getStateJson());
|
||||
renderTabBar();
|
||||
closeAllMenus();
|
||||
}
|
||||
|
||||
function isTabDirty(tab) {
|
||||
@@ -110,6 +213,7 @@ function isTabDirty(tab) {
|
||||
? currentEditor.getStateJson()
|
||||
: tab.stateJson;
|
||||
if (!state) return false;
|
||||
if (tab.baseJson) return JSON.stringify(state) !== tab.baseJson;
|
||||
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
|
||||
}
|
||||
|
||||
@@ -126,7 +230,9 @@ function closeTab(tabId) {
|
||||
switchToTab(tabId);
|
||||
}
|
||||
|
||||
if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
|
||||
if (tab.crm && isTabDirty(tab)) {
|
||||
if (!window.confirm(`De wijzigingen aan ${tab.crm.titel} staan nog niet in de CRM (Opslaan in CRM). Toch sluiten?`)) return;
|
||||
} else if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
|
||||
const saved = currentEditor.exportDesign();
|
||||
if (!saved) return;
|
||||
}
|
||||
@@ -140,84 +246,19 @@ function closeTab(tabId) {
|
||||
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');
|
||||
document.getElementById('doc-tabs').addEventListener('click', (e) => {
|
||||
const closeBtn = e.target.closest('.doc-close');
|
||||
if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; }
|
||||
const item = e.target.closest('.tab-item');
|
||||
const item = e.target.closest('.doc-tab');
|
||||
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-camera-item').addEventListener('click', (e) => e.stopPropagation());
|
||||
document.getElementById('menu-walk').addEventListener('click', () => {
|
||||
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk();
|
||||
closeAllMenus();
|
||||
});
|
||||
document.getElementById('menu-2d-fullscreen').addEventListener('click', () => {
|
||||
if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D();
|
||||
closeAllMenus();
|
||||
});
|
||||
document.getElementById('menu-project-props').addEventListener('click', () => {
|
||||
if (currentEditor && typeof currentEditor.openProjectProperties === 'function') currentEditor.openProjectProperties();
|
||||
closeAllMenus();
|
||||
});
|
||||
|
||||
document.addEventListener('click', closeAllMenus);
|
||||
}
|
||||
|
||||
// Minimale markdown-weergave voor de licentietekst (koppen + alinea's).
|
||||
function renderLicenseHtml(md) {
|
||||
const esc = (t) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return md.trim().split(/\n\s*\n/).map(block => {
|
||||
@@ -231,16 +272,14 @@ function renderLicenseHtml(md) {
|
||||
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.querySelector('.new-options').insertAdjacentHTML('afterbegin', Object.entries(HouseModel.MODELS).map(([key, m]) =>
|
||||
`<button class="new-option" data-kind="woning" data-template="${key}"><i class="bi bi-${m.icon}"></i><span>${m.label}</span></button>`).join(''));
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
|
||||
document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => {
|
||||
opt.addEventListener('click', () => {
|
||||
@@ -248,11 +287,16 @@ function wireNewModal() {
|
||||
overlay.classList.remove('open');
|
||||
});
|
||||
});
|
||||
newCrmPicker = TectumCrm.mountPicker(overlay.querySelector('.new-crm'), (id) => {
|
||||
overlay.classList.remove('open');
|
||||
openCrmWoning(id);
|
||||
});
|
||||
}
|
||||
|
||||
let newCrmPicker = null;
|
||||
function openNewModal() {
|
||||
closeAllMenus();
|
||||
document.getElementById('new-overlay').classList.add('open');
|
||||
if (newCrmPicker) newCrmPicker.refresh();
|
||||
}
|
||||
|
||||
function toggleBrowserFullscreen() {
|
||||
@@ -260,52 +304,67 @@ function toggleBrowserFullscreen() {
|
||||
else document.documentElement.requestFullscreen();
|
||||
}
|
||||
|
||||
// Alle dialogen; Esc gebruikt hun Annuleren-knop (data-esc), zodat bijv. projectgegevens worden teruggezet.
|
||||
const OVERLAYS = '.dlg-overlay.open, #about-overlay.open, #new-overlay.open, #house-project-overlay.open, #wall-project-overlay.open, #cost-settings-overlay.open';
|
||||
function closeOverlays() {
|
||||
const open = document.querySelector(OVERLAYS);
|
||||
if (open) {
|
||||
const cancel = open.querySelector('[data-esc]');
|
||||
if (cancel) cancel.click(); else open.classList.remove('open');
|
||||
return true;
|
||||
}
|
||||
return Menubar.close() || UI.closePopovers();
|
||||
}
|
||||
|
||||
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();
|
||||
const k = e.key.toLowerCase();
|
||||
// Dialoog open: alleen Esc (sluiten); geen sneltoetsen naar de editor erachter. Typen in de dialoog blijft werken.
|
||||
if (document.querySelector(OVERLAYS)) {
|
||||
if (e.key === 'Escape') closeOverlays();
|
||||
e.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault();
|
||||
openNewModal();
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const act = { s: saveActive, n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
|
||||
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
|
||||
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' && e.shiftKey) {
|
||||
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk();
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
if (e.key === 'Escape' && closeOverlays()) { e.stopImmediatePropagation(); return; }
|
||||
if (inField || e.altKey || viewState().walk && !['l', 'escape'].includes(k)) return;
|
||||
const view = UI.VIEWS.find(v => v.key1 === e.key);
|
||||
if (view) editorCall('setView', view.key);
|
||||
else if (e.key === '4') editorCall('toggleReport');
|
||||
else if (k === 'x') editorCall('toggleSection');
|
||||
else if (k === 'f') editorCall('fitView');
|
||||
else if (k === 'l') editorCall('toggleWalk');
|
||||
else if (k === 'h' && e.shiftKey) editorCall('mirrorHorizontal');
|
||||
else if (e.key === '?') openKeys();
|
||||
}, true);
|
||||
}
|
||||
|
||||
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();
|
||||
Menubar.init(document.getElementById('menubar'), MENUS);
|
||||
document.getElementById('top-save').addEventListener('click', saveActive);
|
||||
document.getElementById('keys-overlay').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'keys-overlay' || e.target.closest('[data-close]')) document.getElementById('keys-overlay').classList.remove('open');
|
||||
});
|
||||
wireAbout();
|
||||
wireNewModal();
|
||||
wireHotkeys();
|
||||
wireTabBar();
|
||||
// Tectum draait via een webserver (texturen, lucht); file:// werkt niet volledig.
|
||||
if (location.protocol === 'file:') {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'ui-warn file-protocol-warning';
|
||||
note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>';
|
||||
document.body.appendChild(note);
|
||||
}
|
||||
openNew('woning', 'L');
|
||||
// ?woning=<id>: die woning uit de CRM openen (knop "Bewerken in Tectum" in de CRM), anders het standaardmodel
|
||||
const crmId = Number(new URLSearchParams(location.search).get('woning'));
|
||||
if (crmId) openCrmWoning(crmId); else openNew('woning', 'amstel');
|
||||
|
||||
setTimeout(() => {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
* 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();
|
||||
@@ -21,7 +13,6 @@ window.CameraMemory = (() => {
|
||||
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;
|
||||
|
||||
@@ -4,17 +4,9 @@
|
||||
* 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;
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// DXF R12 (AC1009): het formaat dat elk CAD-pakket opent; DWG maakt het CAD-pakket zelf (Opslaan als).
|
||||
// Eenheid mm, tekst in ANSI_1252. Maten zijn echte DIMENSION-entiteiten (met blok), dus aanpasbaar in CAD.
|
||||
|
||||
window.DxfWriter = (() => {
|
||||
const CP1252 = { 0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e,
|
||||
0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f };
|
||||
|
||||
const LTYPES = {
|
||||
CONTINUOUS: { desc: 'Solid line', dashes: [] },
|
||||
STREEP: { desc: '__ __ __', dashes: [4, -2] },
|
||||
STIP: { desc: '. . . .', dashes: [0.8, -0.8] },
|
||||
STREEPSTIP: { desc: '__ . __ .', dashes: [6, -1.5, 0.5, -1.5] }
|
||||
};
|
||||
|
||||
const num = (v) => {
|
||||
const r = Math.round(v * 1000) / 1000;
|
||||
return Object.is(r, -0) || !isFinite(r) ? '0' : String(r);
|
||||
};
|
||||
const deg = (rad) => ((rad * 180 / Math.PI) % 360 + 360) % 360;
|
||||
|
||||
// Een groepswaarde mag geen regeleinde bevatten.
|
||||
const clean = (s) => String(s == null ? '' : s).replace(/[\r\n]+/g, ' ');
|
||||
|
||||
function create(opts) {
|
||||
const o = opts || {};
|
||||
const scale = o.scale || 50; // plotschaal: tekst/maatvoering/lijntypes in papier-mm × scale
|
||||
const layers = new Map([['0', { color: 7, ltype: 'CONTINUOUS' }]]);
|
||||
const entities = [];
|
||||
const blocks = [];
|
||||
let dimCount = 0;
|
||||
const ext = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
|
||||
const grow = (x, y) => {
|
||||
if (x < ext.x0) ext.x0 = x; if (x > ext.x1) ext.x1 = x;
|
||||
if (y < ext.y0) ext.y0 = y; if (y > ext.y1) ext.y1 = y;
|
||||
};
|
||||
|
||||
function layer(name, color, ltype) {
|
||||
if (!layers.has(name)) layers.set(name, { color: color || 7, ltype: LTYPES[ltype] ? ltype : 'CONTINUOUS' });
|
||||
return name;
|
||||
}
|
||||
|
||||
function common(out, lay, eo) {
|
||||
out.push(8, lay || '0');
|
||||
if (eo && eo.ltype && LTYPES[eo.ltype]) out.push(6, eo.ltype);
|
||||
if (eo && eo.color !== undefined && eo.color !== null) out.push(62, eo.color);
|
||||
}
|
||||
function emit(target, type, lay, eo, body) {
|
||||
const out = [0, type];
|
||||
common(out, lay, eo);
|
||||
target.push(out.concat(body));
|
||||
}
|
||||
|
||||
function line(a, b, lay, eo, target) {
|
||||
if (Math.hypot(b.x - a.x, b.y - a.y) < 0.01) return;
|
||||
emit(target || entities, 'LINE', lay, eo, [10, num(a.x), 20, num(a.y), 30, 0, 11, num(b.x), 21, num(b.y), 31, 0]);
|
||||
if (!target) { grow(a.x, a.y); grow(b.x, b.y); }
|
||||
}
|
||||
|
||||
function polyline(pts, closed, lay, eo, target) {
|
||||
const p = pts.filter((q, i) => i === 0 || Math.hypot(q.x - pts[i - 1].x, q.y - pts[i - 1].y) > 0.01);
|
||||
if (closed && p.length > 2 && Math.hypot(p[0].x - p[p.length - 1].x, p[0].y - p[p.length - 1].y) < 0.01) p.pop();
|
||||
if (p.length < 2) return;
|
||||
if (p.length === 2 && !closed) { line(p[0], p[1], lay, eo, target); return; }
|
||||
const t = target || entities;
|
||||
emit(t, 'POLYLINE', lay, eo, [66, 1, 10, 0, 20, 0, 30, 0, 70, closed ? 1 : 0]);
|
||||
p.forEach(q => { emit(t, 'VERTEX', lay, null, [10, num(q.x), 20, num(q.y), 30, 0]); if (!target) grow(q.x, q.y); });
|
||||
emit(t, 'SEQEND', lay, null, []);
|
||||
}
|
||||
|
||||
function circle(c, r, lay, eo, target) {
|
||||
emit(target || entities, 'CIRCLE', lay, eo, [10, num(c.x), 20, num(c.y), 30, 0, 40, num(r)]);
|
||||
if (!target) { grow(c.x - r, c.y - r); grow(c.x + r, c.y + r); }
|
||||
}
|
||||
|
||||
// Hoeken in radialen, tegen de klok in van a0 naar a1.
|
||||
function arc(c, r, a0, a1, lay, eo, target) {
|
||||
emit(target || entities, 'ARC', lay, eo, [10, num(c.x), 20, num(c.y), 30, 0, 40, num(r), 50, num(deg(a0)), 51, num(deg(a1))]);
|
||||
if (!target) { grow(c.x - r, c.y - r); grow(c.x + r, c.y + r); }
|
||||
}
|
||||
|
||||
// h = teksthoogte in model-mm; align: left|center|right; rot in graden.
|
||||
function text(p, h, str, lay, to, target) {
|
||||
const s = clean(str);
|
||||
if (!s) return;
|
||||
const t = to || {};
|
||||
const hj = t.align === 'center' ? 1 : t.align === 'right' ? 2 : 0;
|
||||
const body = [10, num(p.x), 20, num(p.y), 30, 0, 40, num(h), 1, s];
|
||||
if (t.rot) body.push(50, num(t.rot));
|
||||
body.push(7, 'STANDARD');
|
||||
if (hj) body.push(72, hj, 11, num(p.x), 21, num(p.y), 31, 0);
|
||||
emit(target || entities, 'TEXT', lay, t, body);
|
||||
if (!target) { grow(p.x - s.length * h * 0.35, p.y); grow(p.x + s.length * h * 0.35, p.y + h); }
|
||||
}
|
||||
|
||||
// Lineaire maat tussen p1 en p2, maatlijn door 'at' (evenwijdig aan p1-p2 tenzij dir gegeven).
|
||||
const DIM = { txt: 2.5, exo: 1.5, exe: 1.5, tsz: 1.25, gap: 0.8, dle: 1.5 };
|
||||
function dim(p1, p2, at, lay, dopts) {
|
||||
const d = dopts || {};
|
||||
let ux = p2.x - p1.x, uy = p2.y - p1.y;
|
||||
if (d.dir) { ux = d.dir.x; uy = d.dir.y; }
|
||||
const ul = Math.hypot(ux, uy);
|
||||
if (ul < 1e-9) return;
|
||||
ux /= ul; uy /= ul;
|
||||
const nx = -uy, ny = ux;
|
||||
const off = (at.x - p1.x) * nx + (at.y - p1.y) * ny;
|
||||
const s1 = (p1.x - at.x) * ux + (p1.y - at.y) * uy, s2 = (p2.x - at.x) * ux + (p2.y - at.y) * uy;
|
||||
const measured = Math.abs(s2 - s1);
|
||||
if (measured < 1) return;
|
||||
const q1 = { x: at.x + ux * s1, y: at.y + uy * s1 }, q2 = { x: at.x + ux * s2, y: at.y + uy * s2 };
|
||||
const sg = Math.sign(s2 - s1) || 1, k = scale;
|
||||
const name = `*D${++dimCount}`;
|
||||
const be = [];
|
||||
const BB = { color: 0 };
|
||||
// Maatlijn voorbij de streepjes, hulplijnen met afstand tot het object.
|
||||
line({ x: q1.x - ux * sg * DIM.dle * k, y: q1.y - uy * sg * DIM.dle * k }, { x: q2.x + ux * sg * DIM.dle * k, y: q2.y + uy * sg * DIM.dle * k }, '0', BB, be);
|
||||
[[p1, q1], [p2, q2]].forEach(([p, q]) => {
|
||||
const side = Math.sign(off) || 1;
|
||||
const a = { x: p.x + nx * side * DIM.exo * k, y: p.y + ny * side * DIM.exo * k };
|
||||
const b = { x: q.x + nx * side * DIM.exe * k, y: q.y + ny * side * DIM.exe * k };
|
||||
if (Math.abs(off) > DIM.exo * k) line(a, b, '0', BB, be);
|
||||
const t = DIM.tsz * k * Math.SQRT1_2;
|
||||
line({ x: q.x - (ux + nx) * t, y: q.y - (uy + ny) * t }, { x: q.x + (ux + nx) * t, y: q.y + (uy + ny) * t }, '0', BB, be);
|
||||
});
|
||||
let ang = Math.atan2(uy, ux) * 180 / Math.PI;
|
||||
// Tekst leesbaar: van onder of van rechts.
|
||||
let tnx = nx, tny = ny;
|
||||
if (ang > 90.01 || ang <= -89.99) { ang += 180; tnx = -nx; tny = -ny; }
|
||||
ang = (ang + 360) % 360;
|
||||
const mid = { x: (q1.x + q2.x) / 2, y: (q1.y + q2.y) / 2 };
|
||||
const label = d.text !== undefined ? d.text : String(Math.round(measured));
|
||||
const tp = { x: mid.x + tnx * DIM.gap * k, y: mid.y + tny * DIM.gap * k };
|
||||
text(tp, DIM.txt * k, label, '0', { align: 'center', rot: ang, color: 0 }, be);
|
||||
blocks.push({ name, entities: be });
|
||||
const tm = { x: mid.x + tnx * (DIM.gap + DIM.txt / 2) * k, y: mid.y + tny * (DIM.gap + DIM.txt / 2) * k };
|
||||
const body = [2, name, 3, 'TECTUM', 10, num(q2.x), 20, num(q2.y), 30, 0, 11, num(tm.x), 21, num(tm.y), 31, 0, 70, 0];
|
||||
if (d.text !== undefined) body.push(1, clean(d.text));
|
||||
body.push(13, num(p1.x), 23, num(p1.y), 33, 0, 14, num(p2.x), 24, num(p2.y), 34, 0, 50, num(deg(Math.atan2(uy, ux))));
|
||||
emit(entities, 'DIMENSION', lay, null, body);
|
||||
[p1, p2, q1, q2, tm].forEach(p => grow(p.x, p.y));
|
||||
}
|
||||
|
||||
function header() {
|
||||
const has = isFinite(ext.x0);
|
||||
const e = has ? ext : { x0: 0, y0: 0, x1: 10000, y1: 10000 };
|
||||
const v = [
|
||||
['$ACADVER', 1, 'AC1009'], ['$DWGCODEPAGE', 3, 'ANSI_1252'],
|
||||
['$INSBASE', 10, 0, 20, 0, 30, 0],
|
||||
['$EXTMIN', 10, num(e.x0), 20, num(e.y0), 30, 0], ['$EXTMAX', 10, num(e.x1), 20, num(e.y1), 30, 0],
|
||||
['$LIMMIN', 10, num(e.x0), 20, num(e.y0)], ['$LIMMAX', 10, num(e.x1), 20, num(e.y1)],
|
||||
['$LTSCALE', 40, scale], ['$TEXTSIZE', 40, 2.5 * scale], ['$TEXTSTYLE', 7, 'STANDARD'], ['$CLAYER', 8, '0'],
|
||||
['$LUNITS', 70, 2], ['$LUPREC', 70, 0], ['$AUNITS', 70, 0], ['$AUPREC', 70, 0],
|
||||
['$DIMSTYLE', 2, 'TECTUM'], ['$DIMSCALE', 40, scale], ['$DIMTXT', 40, DIM.txt], ['$DIMTSZ', 40, DIM.tsz], ['$DIMASZ', 40, 2.5],
|
||||
['$DIMEXO', 40, DIM.exo], ['$DIMEXE', 40, DIM.exe], ['$DIMDLE', 40, DIM.dle], ['$DIMGAP', 40, DIM.gap],
|
||||
['$DIMTAD', 70, 1], ['$DIMTIH', 70, 0], ['$DIMTOH', 70, 0], ['$DIMZIN', 70, 0]
|
||||
];
|
||||
const out = [0, 'SECTION', 2, 'HEADER'];
|
||||
v.forEach(([name, ...rest]) => out.push(9, name, ...rest));
|
||||
out.push(0, 'ENDSEC');
|
||||
return out;
|
||||
}
|
||||
|
||||
function table(name, rows) {
|
||||
const out = [0, 'TABLE', 2, name, 70, rows.length];
|
||||
rows.forEach(r => out.push(...r));
|
||||
out.push(0, 'ENDTAB');
|
||||
return out;
|
||||
}
|
||||
|
||||
function tables() {
|
||||
const has = isFinite(ext.x0);
|
||||
const e = has ? ext : { x0: 0, y0: 0, x1: 10000, y1: 10000 };
|
||||
const w = Math.max(1000, e.x1 - e.x0), h = Math.max(1000, e.y1 - e.y0);
|
||||
const vport = [0, 'VPORT', 2, '*ACTIVE', 70, 0, 10, 0, 20, 0, 11, 1, 21, 1,
|
||||
12, num((e.x0 + e.x1) / 2), 22, num((e.y0 + e.y1) / 2), 13, 0, 23, 0, 14, 10, 24, 10, 15, 100, 25, 100,
|
||||
16, 0, 26, 0, 36, 1, 17, 0, 27, 0, 37, 0, 40, num(Math.max(h, w / 1.6) * 1.08), 41, 1.6, 42, 50, 43, 0, 44, 0,
|
||||
50, 0, 51, 0, 71, 0, 72, 100, 73, 1, 74, 3, 75, 0, 76, 0, 77, 0, 78, 0];
|
||||
const ltypes = Object.entries(LTYPES).map(([name, lt]) => {
|
||||
const total = lt.dashes.reduce((a, d) => a + Math.abs(d), 0);
|
||||
const r = [0, 'LTYPE', 2, name, 70, 0, 3, lt.desc, 72, 65, 73, lt.dashes.length, 40, num(total)];
|
||||
lt.dashes.forEach(d => r.push(49, num(d)));
|
||||
return r;
|
||||
});
|
||||
const lay = [...layers.entries()].map(([name, l]) => [0, 'LAYER', 2, name, 70, 0, 62, l.color, 6, l.ltype]);
|
||||
const style = [[0, 'STYLE', 2, 'STANDARD', 70, 0, 40, 0, 41, 1, 50, 0, 71, 0, 42, 2.5 * scale, 3, 'arial.ttf', 4, '']];
|
||||
const dimstyle = [[0, 'DIMSTYLE', 2, 'TECTUM', 70, 0, 3, '', 4, '', 5, '', 6, '', 7, '',
|
||||
40, scale, 41, 2.5, 42, DIM.exo, 43, 7, 44, DIM.exe, 45, 0, 46, DIM.dle, 47, 0, 48, 0,
|
||||
140, DIM.txt, 141, 0, 142, DIM.tsz, 143, 25.4, 144, 1, 145, 0, 146, 1, 147, DIM.gap,
|
||||
71, 0, 72, 0, 73, 0, 74, 0, 75, 0, 76, 0, 77, 1, 78, 0,
|
||||
170, 0, 171, 2, 172, 1, 173, 0, 174, 0, 175, 0, 176, 0, 177, 0, 178, 0]];
|
||||
return [0, 'SECTION', 2, 'TABLES',
|
||||
...table('VPORT', [vport]), ...table('LTYPE', ltypes), ...table('LAYER', lay), ...table('STYLE', style),
|
||||
...table('VIEW', []), ...table('UCS', []), ...table('APPID', [[0, 'APPID', 2, 'ACAD', 70, 0]]), ...table('DIMSTYLE', dimstyle),
|
||||
0, 'ENDSEC'];
|
||||
}
|
||||
|
||||
function toString() {
|
||||
const out = [...header(), ...tables(), 0, 'SECTION', 2, 'BLOCKS'];
|
||||
blocks.forEach(b => {
|
||||
out.push(0, 'BLOCK', 8, '0', 2, b.name, 70, 1, 10, 0, 20, 0, 30, 0, 3, b.name);
|
||||
b.entities.forEach(en => out.push(...en));
|
||||
out.push(0, 'ENDBLK', 8, '0');
|
||||
});
|
||||
out.push(0, 'ENDSEC', 0, 'SECTION', 2, 'ENTITIES');
|
||||
entities.forEach(en => out.push(...en));
|
||||
out.push(0, 'ENDSEC', 0, 'EOF');
|
||||
const lines = [];
|
||||
for (let i = 0; i < out.length; i += 2) lines.push(String(out[i]).padStart(3, ' '), String(out[i + 1]));
|
||||
return lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
function toBytes() {
|
||||
const s = toString();
|
||||
const bytes = new Uint8Array(s.length);
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i);
|
||||
bytes[i] = c < 0x80 || (c >= 0xa0 && c <= 0xff) ? c : (CP1252[c] || 0x3f);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function save(filename) {
|
||||
const blob = new Blob([toBytes()], { type: 'application/dxf' });
|
||||
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);
|
||||
}
|
||||
|
||||
return { scale, layer, line, polyline, circle, arc, text, dim, toString, toBytes, save, extents: () => Object.assign({}, ext) };
|
||||
}
|
||||
|
||||
// jsPDF-achtige tekenlaag voor bestaande PDF-tekencode (papier-mm, y omlaag) op schaal 1:scale in een laag.
|
||||
// Kleur zwart = BYLAYER, anders de dichtstbijzijnde ACI-kleur; vullingen worden alleen omtrek.
|
||||
const ACI = [[1, 255, 0, 0], [2, 255, 255, 0], [3, 0, 255, 0], [4, 0, 255, 255], [5, 0, 0, 255], [6, 255, 0, 255],
|
||||
[8, 128, 128, 128], [9, 192, 192, 192], [30, 255, 127, 0], [40, 255, 191, 0], [32, 165, 82, 0], [94, 0, 127, 0], [12, 165, 0, 0]];
|
||||
function aciOf(r, g, b) {
|
||||
if (r + g + b < 60) return null;
|
||||
let best = null, bd = Infinity;
|
||||
ACI.forEach(([i, R, G, B]) => { const d = (r - R) ** 2 + (g - G) ** 2 + (b - B) ** 2; if (d < bd) { bd = d; best = i; } });
|
||||
return best;
|
||||
}
|
||||
function pdfAdapter(dxf, lay, map) {
|
||||
const k = dxf.scale;
|
||||
const P = map || ((x, y) => ({ x: x * k, y: -y * k }));
|
||||
let draw = null, txt = null, fontPt = 10, dashed = false;
|
||||
const eo = (c) => ({ color: c, ltype: dashed ? 'STIP' : undefined });
|
||||
const ptToMm = 0.3528 * 0.72;
|
||||
const doc = {
|
||||
setDrawColor(r, g, b) { draw = aciOf(r, g === undefined ? r : g, b === undefined ? r : b); },
|
||||
setTextColor(r, g, b) { txt = aciOf(r, g === undefined ? r : g, b === undefined ? r : b); },
|
||||
setFillColor() {}, setLineWidth() {}, setFont() {},
|
||||
setFontSize(pt) { fontPt = pt; },
|
||||
setLineDashPattern(p) { dashed = !!(p && p.length); },
|
||||
getTextWidth(s) { return String(s).length * fontPt * 0.3528 * 0.5; },
|
||||
line(x1, y1, x2, y2) { dxf.line(P(x1, y1), P(x2, y2), lay, eo(draw)); },
|
||||
rect(x, y, w, h) { dxf.polyline([P(x, y), P(x + w, y), P(x + w, y + h), P(x, y + h)], true, lay, eo(draw)); },
|
||||
circle(x, y, r) { const c = P(x, y); dxf.circle(c, r * k, lay, eo(draw)); },
|
||||
triangle(x1, y1, x2, y2, x3, y3) { dxf.polyline([P(x1, y1), P(x2, y2), P(x3, y3)], true, lay, eo(draw)); },
|
||||
lines(segs, x, y, sc, style, closed) {
|
||||
const pts = [P(x, y)];
|
||||
let cx = x, cy = y;
|
||||
segs.forEach(([dx, dy]) => { cx += dx; cy += dy; pts.push(P(cx, cy)); });
|
||||
dxf.polyline(pts, !!closed, lay, eo(draw));
|
||||
},
|
||||
text(str, x, y, to) {
|
||||
const t = to || {};
|
||||
dxf.text(P(x, y), fontPt * ptToMm * k, Array.isArray(str) ? str.join(' ') : str, lay, { align: t.align, rot: t.angle || 0, color: txt });
|
||||
}
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
||||
return { create, pdfAdapter, LTYPES };
|
||||
})();
|
||||
+92
-107
@@ -4,20 +4,19 @@
|
||||
* 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.
|
||||
// ============================================================
|
||||
// Plain script, geen ES-module: imports breken bij file:// (CORS).
|
||||
|
||||
// Map van de engine (met assets/ en kosten/), afgeleid van de plek van dit script. Texturen, kleurstalen en de
|
||||
// kosten-CSV's laden via tectumUrl(), zodat ze ook werken als de engine in een andere pagina staat (light-versie op
|
||||
// een modelpagina van de site). Paden in de data blijven relatief.
|
||||
const TECTUM_BASE = (() => {
|
||||
const s = typeof document !== 'undefined' && document.currentScript && document.currentScript.src;
|
||||
return s ? new URL('../../', s).href : '';
|
||||
})();
|
||||
function tectumUrl(path) {
|
||||
return path && !/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(path) ? TECTUM_BASE + path : path;
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -35,7 +34,6 @@ function calculateSegmentPositions(spanWidth, minSp, maxSp) {
|
||||
return positions;
|
||||
}
|
||||
|
||||
// Oppervlakte van een simpele polygon (shoelace-formule).
|
||||
function polygonArea(points) {
|
||||
let area = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
@@ -46,11 +44,7 @@ function polygonArea(points) {
|
||||
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];
|
||||
@@ -71,7 +65,6 @@ function clipPolygonYLE(points, 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];
|
||||
@@ -92,26 +85,17 @@ function clipPolygonYGE(points, 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.
|
||||
// Zaagsnede-breedte wordt verwaarloosd.
|
||||
function planStockCutting(pieceLengths, stockLengths) {
|
||||
const stocks = stockLengths.slice().sort((a, b) => a - b);
|
||||
const maxStock = stocks[stocks.length - 1];
|
||||
@@ -142,7 +126,6 @@ function planStockCutting(pieceLengths, stockLengths) {
|
||||
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));
|
||||
@@ -153,88 +136,103 @@ function summarizeStockPlan(bars) {
|
||||
};
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Een uitsparing over de rand wordt een inkeping, geen afgekapt gat (dat liet een haarfijn
|
||||
// reepje materiaal met randlijnen door de opening lopen). Schuin (y1r) alleen bovenin.
|
||||
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 top = (x) => Math.min(yCap, topYAt(x));
|
||||
const uniq = (arr) => arr.sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > eps);
|
||||
|
||||
const 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])]);
|
||||
.filter(r => Math.min(x1, r.x1) - Math.max(x0, r.x0) > eps)
|
||||
.map(r => {
|
||||
const y1r = r.y1r !== undefined ? r.y1r : r.y1, s = (y1r - r.y1) / (r.x1 - r.x0);
|
||||
return { x0: Math.max(x0, r.x0), x1: Math.min(x1, r.x1), y0: r.y0, topAt: (x) => r.y1 + (x - r.x0) * s };
|
||||
});
|
||||
const inRect = (x, y) => rs.some(r => x > r.x0 && x < r.x1 && y > r.y0 && y < r.topAt(x));
|
||||
|
||||
// Laagste bovenrand binnen [a, b] (topYAt is lineair tussen de knikpunten).
|
||||
const 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 raw = [() => yBottom, top];
|
||||
rs.forEach(r => raw.push(() => r.y0, r.topAt));
|
||||
const clamp = (f) => (x) => Math.min(top(x), Math.max(yBottom, f(x)));
|
||||
const lines = raw.map(clamp);
|
||||
|
||||
const ys = uniq([yBottom, ...rs.flatMap(r => reachesTop(r) ? [r.y0] : [r.y0, r.y1]).filter(y => y > yBottom + eps)]);
|
||||
const nCols = xs.length - 1, nRows = ys.length; // bovenste rij loopt tot de daklijn
|
||||
let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]);
|
||||
const cuts = [];
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const a = xs[i], b = xs[i + 1];
|
||||
for (let p = 0; p < raw.length; p++) {
|
||||
for (let q = p + 1; q < raw.length; q++) {
|
||||
const da = raw[p](a) - raw[q](a), db = raw[p](b) - raw[q](b);
|
||||
if ((da > eps && db < -eps) || (da < -eps && db > eps)) cuts.push(a + (b - a) * da / (da - db));
|
||||
}
|
||||
}
|
||||
}
|
||||
xs = uniq(xs.concat(cuts));
|
||||
|
||||
const vertex = (i, j) => ({ x: xs[i], y: j < nRows ? ys[j] : topYAt(xs[i]) });
|
||||
const 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);
|
||||
const cols = [];
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const a = xs[i], b = xs[i + 1], m = (a + b) / 2;
|
||||
const vals = lines.map(f => ({ a: f(a), b: f(b), m: f(m) }))
|
||||
.sort((p, q) => p.m - q.m)
|
||||
.filter((v, k, arr) => k === 0 || v.m - arr[k - 1].m > eps || Math.abs(v.a - arr[k - 1].a) > eps || Math.abs(v.b - arr[k - 1].b) > eps);
|
||||
const cells = [];
|
||||
for (let k = 0; k < vals.length - 1; k++) {
|
||||
const lo = vals[k], hi = vals[k + 1];
|
||||
if (hi.m - lo.m <= eps) continue;
|
||||
cells.push({ lo, hi, filled: !inRect(m, (lo.m + hi.m) / 2) });
|
||||
}
|
||||
cols.push({ a, b, cells });
|
||||
}
|
||||
|
||||
const verts = [];
|
||||
const vid = (x, y) => {
|
||||
let k = verts.findIndex(v => Math.abs(v.x - x) < 1e-7 && Math.abs(v.y - y) < 1e-7);
|
||||
if (k < 0) { k = verts.length; verts.push({ x, y }); }
|
||||
return k;
|
||||
};
|
||||
|
||||
// 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);
|
||||
const edges = new Map();
|
||||
const addEdge = (p, q) => {
|
||||
const a = vid(p.x, p.y), b = vid(q.x, q.y);
|
||||
if (a === b) return;
|
||||
if (!edges.has(a)) edges.set(a, []);
|
||||
edges.get(a).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]);
|
||||
cols.forEach(col => col.cells.forEach((c, k) => {
|
||||
if (!c.filled) return;
|
||||
if (!(k > 0 && col.cells[k - 1].filled && col.cells[k - 1].hi === c.lo)) addEdge({ x: col.a, y: c.lo.a }, { x: col.b, y: c.lo.b });
|
||||
if (!(k < col.cells.length - 1 && col.cells[k + 1].filled && col.cells[k + 1].lo === c.hi)) addEdge({ x: col.b, y: c.hi.b }, { x: col.a, y: c.hi.a });
|
||||
}));
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
const x = xs[i];
|
||||
const left = i > 0 ? cols[i - 1].cells.filter(c => c.filled).map(c => [c.lo.b, c.hi.b]) : [];
|
||||
const right = i < cols.length ? cols[i].cells.filter(c => c.filled).map(c => [c.lo.a, c.hi.a]) : [];
|
||||
const ys = uniq(left.concat(right).flat());
|
||||
for (let k = 0; k < ys.length - 1; k++) {
|
||||
const ya = ys[k], yb = ys[k + 1], ym = (ya + yb) / 2;
|
||||
if (yb - ya <= eps) continue;
|
||||
const lf = left.some(([p, q]) => ym > p && ym < q), rf = right.some(([p, q]) => ym > p && ym < q);
|
||||
if (lf && !rf) addEdge({ x, y: ya }, { x, y: yb });
|
||||
else if (rf && !lf) addEdge({ x, y: yb }, { x, y: ya });
|
||||
}
|
||||
}
|
||||
|
||||
// Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
|
||||
const loops = [];
|
||||
edges.forEach((list, startKey) => {
|
||||
edges.forEach((list, start) => {
|
||||
while (list.length) {
|
||||
const loop = [];
|
||||
let cur = startKey.split(',').map(Number);
|
||||
let next = list.pop();
|
||||
loop.push(cur);
|
||||
while (next.join(',') !== startKey) {
|
||||
const loop = [start];
|
||||
let next = list.pop(), guard = 0;
|
||||
while (next !== start && guard++ < 100000) {
|
||||
loop.push(next);
|
||||
cur = next;
|
||||
const outs = edges.get(cur.join(','));
|
||||
const outs = edges.get(next);
|
||||
if (!outs || !outs.length) break;
|
||||
next = outs.pop();
|
||||
}
|
||||
loops.push(loop);
|
||||
if (next === start) 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; });
|
||||
const pts = loop.map(k => verts[k]);
|
||||
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;
|
||||
@@ -254,18 +252,15 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
return c;
|
||||
};
|
||||
const outers = polys.filter(p => p.length >= 3 && signedArea(p) > 1e-12).map(p => ({ outer: p, holes: [] }));
|
||||
outers.sort((p, q) => signedArea(p.outer) - signedArea(q.outer));
|
||||
polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => {
|
||||
const owner = outers.find(o => inside(hole[0], o.outer)) || outers[0];
|
||||
const mid = { x: (hole[0].x + hole[1].x) / 2, y: (hole[0].y + hole[1].y) / 2 };
|
||||
const owner = outers.find(o => inside(mid, o.outer)) || outers[0];
|
||||
if (owner) owner.holes.push(hole);
|
||||
});
|
||||
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);
|
||||
@@ -308,16 +303,11 @@ function rectUnionLoops(rects) {
|
||||
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) => {
|
||||
@@ -328,17 +318,12 @@ function offsetRectilinearLoop(pts, d) {
|
||||
});
|
||||
}
|
||||
|
||||
// Gevels: groepeert lijnstukken in de plattegrond tot doorlopende gevels
|
||||
// (zelfde as + normaal + gevelvlak, aansluitend of overlappend). segs: [{ axis ('x'|'z'),
|
||||
// normal {x, z}, line (positie op de andere as), s0, s1 (s0 < s1) }, ...].
|
||||
// Geeft [{ axis, normal, line, s0, s1, segs (op s0 gesorteerd) }].
|
||||
// Gebruikt door de woning (latwerk/doek-patroon) en de buitenverlichting.
|
||||
function groupFacadeRuns(segs, opts) {
|
||||
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 && sg.s0 <= r.s1 + gapTol); // aansluitend of overlappend
|
||||
Math.abs(r.line - sg.line) < lineTol && sg.s0 <= r.s1 + gapTol);
|
||||
if (run) { run.segs.push(sg); run.s1 = Math.max(run.s1, sg.s1); }
|
||||
else runs.push(Object.assign({}, sg, { s0: sg.s0, s1: sg.s1, segs: [sg] }));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// glTF 2.0 binair (GLB) uit three.js-objecten, in meters, Y omhoog; bedoeld voor AR (model-viewer: WebXR, Scene Viewer,
|
||||
// Quick Look) en andere viewers. Alles in wereldcoördinaten samengevoegd tot één mesh met één primitive per materiaal
|
||||
// (weinig draw calls op een telefoon). Texturen: herhaling/verschuiving (texture.matrix) zit in de UV's, beelden als
|
||||
// JPEG (of PNG bij doorzichtig), maximaal MAX_TEX px. Lichteffecten (bundels, vegen: onbelicht en doorschijnend zonder
|
||||
// dieptebuffer, of additief) blijven weg; die horen bij de eigen belichting, niet in een model.
|
||||
|
||||
window.GlbWriter = (() => {
|
||||
const MAX_TEX = 1024;
|
||||
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||
const WRAP = { 1000: 10497, 1001: 33071, 1002: 33648 }; // three Repeat/Clamp/Mirrored -> glTF
|
||||
|
||||
const isEffect = (m) => m.blending !== THREE.NormalBlending || (m.isMeshBasicMaterial && m.transparent && m.depthWrite === false);
|
||||
|
||||
// Beeld als JPEG/PNG-bytes, afmetingen een macht van 2 (mipmaps en herhalen ook op oudere toestellen), hoogstens
|
||||
// MAX_TEX; null als het (nog) niet te tekenen is. UV's zijn 0..1, dus het rekken verandert niets aan het beeld.
|
||||
const pot = (n) => Math.min(MAX_TEX, Math.pow(2, Math.max(0, Math.round(Math.log2(n)))));
|
||||
function imageBytes(image, png) {
|
||||
try {
|
||||
const w0 = image.naturalWidth || image.videoWidth || image.width, h0 = image.naturalHeight || image.videoHeight || image.height;
|
||||
if (!w0 || !h0) return null;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = pot(w0); c.height = pot(h0);
|
||||
c.getContext('2d').drawImage(image, 0, 0, c.width, c.height);
|
||||
const bin = atob(c.toDataURL(png ? 'image/png' : 'image/jpeg', 0.85).split(',')[1]);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return { data: out, mime: png ? 'image/png' : 'image/jpeg' };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; clipBelow: alles onder deze hoogte (m, na
|
||||
// de verschuiving) valt weg, driehoeken die de snede kruisen worden afgesneden (AR zet de onderkant van het model op de
|
||||
// grond: met clipBelow 0 op het maaiveld valt die samen met de oorsprong). Geeft { blob, stats }.
|
||||
function build(objects, opts) {
|
||||
const o = opts || {};
|
||||
const origin = o.origin || { x: 0, y: 0, z: 0 };
|
||||
const clip = typeof o.clipBelow === 'number', clipY = o.clipBelow, EPS = 1e-6;
|
||||
const originV = new THREE.Vector3(origin.x, origin.y, origin.z);
|
||||
const buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
|
||||
const images = new Map(), samplers = new Map(), textures = new Map();
|
||||
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
|
||||
const bin = [];
|
||||
let binLength = 0, triCount = 0;
|
||||
function addView(data, target) {
|
||||
const pad = (4 - binLength % 4) % 4;
|
||||
if (pad) { bin.push(new Uint8Array(pad)); binLength += pad; }
|
||||
const view = { buffer: 0, byteOffset: binLength, byteLength: data.byteLength };
|
||||
if (target) view.target = target;
|
||||
bin.push(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
binLength += data.byteLength;
|
||||
return view;
|
||||
}
|
||||
const views = [];
|
||||
|
||||
function textureIndex(tex, png) {
|
||||
if (!tex || !tex.image) return null;
|
||||
if (!images.has(tex.image)) {
|
||||
const img = imageBytes(tex.image, png);
|
||||
if (img) { views.push(addView(img.data)); gltfImages.push({ bufferView: views.length - 1, mimeType: img.mime }); }
|
||||
images.set(tex.image, img ? gltfImages.length - 1 : null);
|
||||
}
|
||||
const source = images.get(tex.image);
|
||||
if (source === null) return null;
|
||||
const sKey = `${tex.wrapS}/${tex.wrapT}`;
|
||||
if (!samplers.has(sKey)) {
|
||||
gltfSamplers.push({ magFilter: 9729, minFilter: 9987, wrapS: WRAP[tex.wrapS] || 10497, wrapT: WRAP[tex.wrapT] || 10497 });
|
||||
samplers.set(sKey, gltfSamplers.length - 1);
|
||||
}
|
||||
const tKey = `${source}/${samplers.get(sKey)}`;
|
||||
if (!textures.has(tKey)) { gltfTextures.push({ source, sampler: samplers.get(sKey) }); textures.set(tKey, gltfTextures.length - 1); }
|
||||
return textures.get(tKey);
|
||||
}
|
||||
function bucketOf(m) {
|
||||
if (!buckets.has(m)) {
|
||||
const map = m.map && m.map.image ? m.map : null;
|
||||
const tex = map ? textureIndex(map, !!m.transparent) : null;
|
||||
buckets.set(m, { mat: m, map: tex !== null ? map : null, tex, pos: [], nrm: [], uv: [], idx: [], n: 0 });
|
||||
}
|
||||
return buckets.get(m);
|
||||
}
|
||||
|
||||
const v = new THREE.Vector3(), nm = new THREE.Matrix3(), uv = new THREE.Vector2();
|
||||
const pa = new THREE.Vector3(), pb = new THREE.Vector3(), pc = new THREE.Vector3(), face = new THREE.Vector3();
|
||||
const visit = (root) => root && (root.updateMatrixWorld(true), root.traverseVisible(mesh => {
|
||||
if (!mesh.isMesh || !mesh.geometry || !mesh.geometry.attributes.position) return;
|
||||
let g = mesh.geometry;
|
||||
if (!g.attributes.normal) { g = g.clone(); g.computeVertexNormals(); }
|
||||
const pos = g.attributes.position, nrm = g.attributes.normal, uvA = g.attributes.uv, idx = g.index;
|
||||
nm.getNormalMatrix(mesh.matrixWorld);
|
||||
const flip = mesh.matrixWorld.determinant() < 0;
|
||||
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
const groups = g.groups && g.groups.length ? g.groups : [{ start: 0, count: idx ? idx.count : pos.count, materialIndex: 0 }];
|
||||
const remaps = new Map(); // bucket -> bronhoekpunt -> index in de bucket
|
||||
groups.forEach(gr => {
|
||||
const m = list[gr.materialIndex] || list[0];
|
||||
if (!m || m.visible === false || isEffect(m)) return;
|
||||
const b = bucketOf(m);
|
||||
if (!remaps.has(b)) remaps.set(b, new Int32Array(pos.count).fill(-1));
|
||||
const remap = remaps.get(b);
|
||||
if (b.map) b.map.updateMatrix();
|
||||
const add = (ci) => {
|
||||
if (remap[ci] >= 0) return remap[ci];
|
||||
v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld);
|
||||
b.pos.push(v.x - origin.x, v.y - origin.y, v.z - origin.z);
|
||||
// Een lege normaal (bron met nul-driehoeken) wordt de vlaknormaal: glTF eist lengte 1.
|
||||
v.fromBufferAttribute(nrm, ci).applyMatrix3(nm);
|
||||
if (v.lengthSq() < 1e-12) v.copy(face); else v.normalize();
|
||||
b.nrm.push(v.x, v.y, v.z);
|
||||
if (b.map) {
|
||||
if (uvA) uv.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix); else uv.set(0, 0);
|
||||
// three (flipY) heeft v = 0 onderaan het beeld, glTF bovenaan.
|
||||
b.uv.push(uv.x, b.map.flipY ? 1 - uv.y : uv.y);
|
||||
}
|
||||
return (remap[ci] = b.n++);
|
||||
};
|
||||
// Afgesneden driehoeken: losse hoekpunten (geïnterpoleerd), niet gedeeld via de remap.
|
||||
const raw = (ci) => {
|
||||
const p = new THREE.Vector3().fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).sub(originV);
|
||||
const n = new THREE.Vector3().fromBufferAttribute(nrm, ci).applyMatrix3(nm);
|
||||
if (n.lengthSq() < 1e-12) n.copy(face); else n.normalize();
|
||||
const t = new THREE.Vector2();
|
||||
if (b.map && uvA) t.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix);
|
||||
return { p, n, t };
|
||||
};
|
||||
const lerp = (A, B, f) => ({
|
||||
p: new THREE.Vector3().lerpVectors(A.p, B.p, f), n: new THREE.Vector3().lerpVectors(A.n, B.n, f).normalize(),
|
||||
t: new THREE.Vector2().lerpVectors(A.t, B.t, f)
|
||||
});
|
||||
const push = (q) => {
|
||||
b.pos.push(q.p.x, q.p.y, q.p.z);
|
||||
b.nrm.push(q.n.x, q.n.y, q.n.z);
|
||||
if (b.map) b.uv.push(q.t.x, b.map.flipY ? 1 - q.t.y : q.t.y);
|
||||
return b.n++;
|
||||
};
|
||||
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
|
||||
for (let k = gr.start; k + 2 < end; k += 3) {
|
||||
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
|
||||
// Driehoeken zonder oppervlakte (< 0,1 mm²) overslaan.
|
||||
pa.fromBufferAttribute(pos, s[0]).applyMatrix4(mesh.matrixWorld);
|
||||
pb.fromBufferAttribute(pos, s[1]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||
pc.fromBufferAttribute(pos, s[2]).applyMatrix4(mesh.matrixWorld).sub(pa);
|
||||
face.crossVectors(pb, pc);
|
||||
const len = face.length();
|
||||
if (len < 2e-7) continue;
|
||||
face.multiplyScalar(flip ? -1 / len : 1 / len);
|
||||
if (clip) {
|
||||
const ys = s.map(ci => v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).y - origin.y);
|
||||
const inside = ys.map(y => y >= clipY - EPS), nIn = inside.filter(Boolean).length;
|
||||
if (!nIn) continue;
|
||||
if (nIn < 3) {
|
||||
const q = s.map(raw), poly = [];
|
||||
for (let j = 0; j < 3; j++) {
|
||||
const k2 = (j + 1) % 3;
|
||||
if (inside[j]) poly.push(q[j]);
|
||||
if (inside[j] !== inside[k2]) poly.push(lerp(q[j], q[k2], (clipY - ys[j]) / (ys[k2] - ys[j])));
|
||||
}
|
||||
const c = poly.map(push);
|
||||
for (let j = 1; j + 1 < c.length; j++) {
|
||||
if (flip) b.idx.push(c[0], c[j + 1], c[j]); else b.idx.push(c[0], c[j], c[j + 1]);
|
||||
triCount++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const c = s.map(add);
|
||||
if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
|
||||
triCount++;
|
||||
}
|
||||
});
|
||||
}));
|
||||
objects.forEach(visit);
|
||||
|
||||
// Accessors en materialen per bucket.
|
||||
const accessors = [], materials = [], primitives = [];
|
||||
let unlit = false;
|
||||
const accessor = (arr, type, comps, ctype, target, minMax) => {
|
||||
views.push(addView(arr, target));
|
||||
const a = { bufferView: views.length - 1, componentType: ctype, count: arr.length / comps, type };
|
||||
if (minMax) Object.assign(a, minMax);
|
||||
accessors.push(a);
|
||||
return accessors.length - 1;
|
||||
};
|
||||
buckets.forEach(b => {
|
||||
if (!b.idx.length) return;
|
||||
const m = b.mat, pos = new Float32Array(b.pos);
|
||||
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
||||
for (let i = 0; i < pos.length; i++) { const a = i % 3; if (pos[i] < min[a]) min[a] = pos[i]; if (pos[i] > max[a]) max[a] = pos[i]; }
|
||||
const attributes = {
|
||||
POSITION: accessor(pos, 'VEC3', 3, 5126, 34962, { min, max }),
|
||||
NORMAL: accessor(new Float32Array(b.nrm), 'VEC3', 3, 5126, 34962)
|
||||
};
|
||||
if (b.map) attributes.TEXCOORD_0 = accessor(new Float32Array(b.uv), 'VEC2', 2, 5126, 34962);
|
||||
const big = b.n >= 65535; // 65535 zelf mag niet in Uint16 (glTF)
|
||||
const indices = accessor(big ? new Uint32Array(b.idx) : new Uint16Array(b.idx), 'SCALAR', 1, big ? 5125 : 5123, 34963);
|
||||
|
||||
// De app toont kleurwaarden zoals ze zijn (geen kleurbeheer); glTF-factoren zijn lineair, beelden sRGB.
|
||||
const c = m.color || { r: 0.6, g: 0.6, b: 0.6 };
|
||||
const alpha = m.transparent ? (m.opacity !== undefined ? m.opacity : 1) : 1;
|
||||
const pbr = {
|
||||
baseColorFactor: [lin(c.r), lin(c.g), lin(c.b), alpha],
|
||||
metallicFactor: m.metalness !== undefined ? m.metalness : 0,
|
||||
roughnessFactor: m.roughness !== undefined ? m.roughness : 0.85
|
||||
};
|
||||
if (b.tex !== null) pbr.baseColorTexture = { index: b.tex };
|
||||
const mat = { name: m.name || m.type, pbrMetallicRoughness: pbr };
|
||||
if (m.emissive && (m.emissive.r || m.emissive.g || m.emissive.b)) {
|
||||
const k = m.emissiveIntensity !== undefined ? m.emissiveIntensity : 1;
|
||||
mat.emissiveFactor = [m.emissive.r, m.emissive.g, m.emissive.b].map(x => Math.min(1, lin(x) * k));
|
||||
}
|
||||
if (m.transparent && (alpha < 1 || b.tex !== null)) mat.alphaMode = 'BLEND';
|
||||
if (m.side !== THREE.FrontSide) mat.doubleSided = true;
|
||||
if (m.isMeshBasicMaterial) { mat.extensions = { KHR_materials_unlit: {} }; unlit = true; }
|
||||
materials.push(mat);
|
||||
primitives.push({ attributes, indices, material: materials.length - 1, mode: 4 });
|
||||
});
|
||||
|
||||
const json = {
|
||||
asset: { version: '2.0', generator: `Tectum${o.app ? ' ' + o.app : ''}` },
|
||||
scene: 0,
|
||||
scenes: [{ name: o.name || 'woning', nodes: [0] }],
|
||||
nodes: [{ name: o.name || 'woning', mesh: 0 }],
|
||||
meshes: [{ name: o.name || 'woning', primitives }],
|
||||
materials, accessors, bufferViews: views,
|
||||
buffers: [{ byteLength: 0 }]
|
||||
};
|
||||
if (gltfImages.length) Object.assign(json, { images: gltfImages, samplers: gltfSamplers, textures: gltfTextures });
|
||||
if (unlit) json.extensionsUsed = ['KHR_materials_unlit'];
|
||||
const binPad = (4 - binLength % 4) % 4;
|
||||
if (binPad) { bin.push(new Uint8Array(binPad)); binLength += binPad; }
|
||||
json.buffers[0].byteLength = binLength;
|
||||
|
||||
// GLB: kop (12 bytes), JSON-blok (opgevuld met spaties), BIN-blok (opgevuld met nullen).
|
||||
let jsonBytes = new TextEncoder().encode(JSON.stringify(json));
|
||||
const jsonPad = (4 - jsonBytes.length % 4) % 4;
|
||||
if (jsonPad) { const p = new Uint8Array(jsonBytes.length + jsonPad).fill(0x20); p.set(jsonBytes); jsonBytes = p; }
|
||||
const total = 12 + 8 + jsonBytes.length + 8 + binLength;
|
||||
const head = new DataView(new ArrayBuffer(20));
|
||||
head.setUint32(0, 0x46546C67, true); head.setUint32(4, 2, true); head.setUint32(8, total, true);
|
||||
head.setUint32(12, jsonBytes.length, true); head.setUint32(16, 0x4E4F534A, true);
|
||||
const binHead = new DataView(new ArrayBuffer(8));
|
||||
binHead.setUint32(0, binLength, true); binHead.setUint32(4, 0x004E4942, true);
|
||||
const blob = new Blob([head, jsonBytes, binHead].concat(bin), { type: 'model/gltf-binary' });
|
||||
return { blob, stats: { triangles: triCount, materials: materials.length, textures: gltfImages.length, bytes: total } };
|
||||
}
|
||||
|
||||
return { build };
|
||||
})();
|
||||
@@ -0,0 +1,187 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// IFC4 (STEP, ISO 10303-21) met driehoeksnetten (IfcTriangulatedFaceSet), eenheid mm, Z omhoog.
|
||||
// Ruimtelijke opbouw: project > terrein > gebouw > bouwlaag; elementen eronder of in een samenstelling.
|
||||
|
||||
window.IfcWriter = (() => {
|
||||
const B64 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$';
|
||||
function guid() {
|
||||
const b = new Uint8Array(16);
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(b);
|
||||
else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
|
||||
b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
|
||||
let out = B64[b[0] >> 6] + B64[b[0] & 63];
|
||||
for (let i = 1; i < 16; i += 3) {
|
||||
const n = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2];
|
||||
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Tekst: ' verdubbelen, \ escapen, alles buiten ASCII als \X2\hhhh\X0\.
|
||||
function str(s) {
|
||||
if (s === null || s === undefined) return '$';
|
||||
let out = '', ext = '';
|
||||
const flush = () => { if (ext) { out += '\\X2\\' + ext + '\\X0\\'; ext = ''; } };
|
||||
for (const ch of String(s)) {
|
||||
const c = ch.codePointAt(0);
|
||||
if (c >= 32 && c < 127) {
|
||||
flush();
|
||||
out += ch === "'" ? "''" : ch === '\\' ? '\\\\' : ch;
|
||||
} else if (c >= 127 && c <= 0xffff) ext += c.toString(16).toUpperCase().padStart(4, '0');
|
||||
else if (c > 0xffff) ext += '003F';
|
||||
}
|
||||
flush();
|
||||
return `'${out}'`;
|
||||
}
|
||||
function real(v) {
|
||||
const r = Math.round(v * 100) / 100;
|
||||
const s = Object.is(r, -0) || !isFinite(r) ? '0' : String(r);
|
||||
return /[.eE]/.test(s) ? s : s + '.';
|
||||
}
|
||||
const list = (a) => `(${a.join(',')})`;
|
||||
const ref = (n) => `#${n}`;
|
||||
|
||||
function create(opts) {
|
||||
const o = opts || {};
|
||||
const lines = [];
|
||||
let next = 1;
|
||||
const add = (type, args) => { const id = next++; lines.push(`#${id}=${type}(${args.join(',')});`); return id; };
|
||||
|
||||
const mm = add('IFCSIUNIT', ['*', '.LENGTHUNIT.', '.MILLI.', '.METRE.']);
|
||||
const m2 = add('IFCSIUNIT', ['*', '.AREAUNIT.', '$', '.SQUARE_METRE.']);
|
||||
const m3 = add('IFCSIUNIT', ['*', '.VOLUMEUNIT.', '$', '.CUBIC_METRE.']);
|
||||
const rad = add('IFCSIUNIT', ['*', '.PLANEANGLEUNIT.', '$', '.RADIAN.']);
|
||||
const units = add('IFCUNITASSIGNMENT', [list([mm, m2, m3, rad].map(ref))]);
|
||||
const origin = add('IFCCARTESIANPOINT', [list(['0.', '0.', '0.'])]);
|
||||
const axis = add('IFCAXIS2PLACEMENT3D', [ref(origin), '$', '$']);
|
||||
const ctx = add('IFCGEOMETRICREPRESENTATIONCONTEXT', ['$', str('Model'), '3', '1.E-05', ref(axis), '$']);
|
||||
const body = add('IFCGEOMETRICREPRESENTATIONSUBCONTEXT', [str('Body'), str('Model'), '*', '*', '*', '*', ref(ctx), '$', '.MODEL_VIEW.', '$']);
|
||||
const project = add('IFCPROJECT', [str(guid()), '$', str(o.project || 'Woning'), str(o.description || null), '$', '$', '$', list([ref(ctx)]), ref(units)]);
|
||||
const sitePl = add('IFCLOCALPLACEMENT', ['$', ref(axis)]);
|
||||
const site = add('IFCSITE', [str(guid()), '$', str('Terrein'), '$', '$', ref(sitePl), '$', '$', '.ELEMENT.', '$', '$', '$', '$', '$']);
|
||||
const bldPl = add('IFCLOCALPLACEMENT', [ref(sitePl), ref(axis)]);
|
||||
const a = o.address || {};
|
||||
const addr = a.lines || a.town ? add('IFCPOSTALADDRESS', ['$', '$', '$', '$', a.lines ? list([str(a.lines)]) : '$', '$', str(a.town || null), '$', '$', str('Nederland')]) : null;
|
||||
const building = add('IFCBUILDING', [str(guid()), '$', str(o.building || 'Woning'), '$', '$', ref(bldPl), '$', '$', '.ELEMENT.', '$', '$', addr ? ref(addr) : '$']);
|
||||
const stPl = add('IFCLOCALPLACEMENT', [ref(bldPl), ref(axis)]);
|
||||
const storey = add('IFCBUILDINGSTOREY', [str(guid()), '$', str(o.storey || 'Begane grond'), '$', '$', ref(stPl), '$', '$', '.ELEMENT.', '0.']);
|
||||
add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(project), list([ref(site)])]);
|
||||
add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(site), list([ref(building)])]);
|
||||
add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(building), list([ref(storey)])]);
|
||||
|
||||
// Kleur [r, g, b] (0..1) als oppervlaktestijl, één per kleur.
|
||||
const styles = new Map();
|
||||
function style(rgb) {
|
||||
const k = rgb.map(c => Math.round(c * 1000)).join(',');
|
||||
if (!styles.has(k)) {
|
||||
const col = add('IFCCOLOURRGB', ['$', ...rgb.map(real)]);
|
||||
const shading = add('IFCSURFACESTYLESHADING', [ref(col), '0.']);
|
||||
styles.set(k, add('IFCSURFACESTYLE', ['$', '.BOTH.', list([ref(shading)])]));
|
||||
}
|
||||
return styles.get(k);
|
||||
}
|
||||
|
||||
const contained = [];
|
||||
const materials = new Map(); // naam -> { id, els: [] }
|
||||
const psets = new Map(); // sleutel -> { props, els: [] }
|
||||
|
||||
// Driehoeken als vlakke lijst [x,y,z, x,y,z, x,y,z, ...] in mm; punten worden samengevoegd.
|
||||
function shape(tri, rgb) {
|
||||
const idx = new Map(), pts = [], faces = [];
|
||||
const key = (i) => `${Math.round(tri[i] * 10)},${Math.round(tri[i + 1] * 10)},${Math.round(tri[i + 2] * 10)}`;
|
||||
const vid = (i) => {
|
||||
const k = key(i);
|
||||
if (!idx.has(k)) { idx.set(k, pts.length + 1); pts.push(`(${real(tri[i])},${real(tri[i + 1])},${real(tri[i + 2])})`); }
|
||||
return idx.get(k);
|
||||
};
|
||||
for (let i = 0; i + 8 < tri.length; i += 9) {
|
||||
const f = [vid(i), vid(i + 3), vid(i + 6)];
|
||||
if (f[0] !== f[1] && f[1] !== f[2] && f[0] !== f[2]) faces.push(`(${f.join(',')})`);
|
||||
}
|
||||
if (!faces.length) return null;
|
||||
const pl = add('IFCCARTESIANPOINTLIST3D', [`(${pts.join(',')})`]);
|
||||
const fs = add('IFCTRIANGULATEDFACESET', [ref(pl), '$', '$', `(${faces.join(',')})`, '$']);
|
||||
if (rgb) add('IFCSTYLEDITEM', [ref(fs), list([ref(style(rgb))]), '$']);
|
||||
const rep = add('IFCSHAPEREPRESENTATION', [ref(body), str('Body'), str('Tessellation'), list([ref(fs)])]);
|
||||
return add('IFCPRODUCTDEFINITIONSHAPE', ['$', '$', list([ref(rep)])]);
|
||||
}
|
||||
|
||||
// el: { type: 'IFCMEMBER', predefined: 'STUD', name, tag, tri, color: [r, g, b], material, props: {naam: waarde}, objectType }
|
||||
function element(el) {
|
||||
const rep = el.tri ? shape(el.tri, el.color) : null;
|
||||
if (el.tri && !rep) return null;
|
||||
const pl = add('IFCLOCALPLACEMENT', [ref(stPl), ref(axis)]);
|
||||
const args = [str(guid()), '$', str(el.name), str(el.description || null), str(el.objectType || null), ref(pl), rep ? ref(rep) : '$', str(el.tag || null)];
|
||||
if (el.type === 'IFCELEMENTASSEMBLY') args.push('.FACTORY.', `.${el.predefined || 'NOTDEFINED'}.`);
|
||||
else args.push(`.${el.predefined || 'NOTDEFINED'}.`);
|
||||
const id = add(el.type, args);
|
||||
if (el.material) {
|
||||
if (!materials.has(el.material)) {
|
||||
const id = add('IFCMATERIAL', [str(el.material), '$', '$']);
|
||||
// Ook aan het materiaal, voor viewers die op materiaal kleuren.
|
||||
if (el.color) {
|
||||
const si = add('IFCSTYLEDITEM', ['$', list([ref(style(el.color))]), '$']);
|
||||
const sr = add('IFCSTYLEDREPRESENTATION', [ref(ctx), str('Style'), str('Material'), list([ref(si)])]);
|
||||
add('IFCMATERIALDEFINITIONREPRESENTATION', ['$', '$', list([ref(sr)]), ref(id)]);
|
||||
}
|
||||
materials.set(el.material, { id, els: [] });
|
||||
}
|
||||
materials.get(el.material).els.push(id);
|
||||
}
|
||||
if (el.props) {
|
||||
const k = JSON.stringify(el.props);
|
||||
if (!psets.has(k)) psets.set(k, { props: el.props, els: [] });
|
||||
psets.get(k).els.push(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function contain(id) { if (id) contained.push(id); }
|
||||
function aggregate(parent, children) {
|
||||
const c = children.filter(Boolean);
|
||||
if (parent && c.length) add('IFCRELAGGREGATES', [str(guid()), '$', '$', '$', ref(parent), list(c.map(ref))]);
|
||||
}
|
||||
|
||||
const value = (v) => typeof v === 'number' ? `IFCLENGTHMEASURE(${real(v)})` : `IFCLABEL(${str(v)})`;
|
||||
function toString(filename) {
|
||||
if (contained.length) add('IFCRELCONTAINEDINSPATIALSTRUCTURE', [str(guid()), '$', '$', '$', list(contained.map(ref)), ref(storey)]);
|
||||
materials.forEach(m => add('IFCRELASSOCIATESMATERIAL', [str(guid()), '$', '$', '$', list(m.els.map(ref)), ref(m.id)]));
|
||||
psets.forEach(p => {
|
||||
const props = Object.entries(p.props).filter(([, v]) => v !== null && v !== undefined && v !== '')
|
||||
.map(([n, v]) => add('IFCPROPERTYSINGLEVALUE', [str(n), '$', value(v), '$']));
|
||||
if (!props.length) return;
|
||||
const ps = add('IFCPROPERTYSET', [str(guid()), '$', str(o.psetName || 'Pset_Tectum'), '$', list(props.map(ref))]);
|
||||
add('IFCRELDEFINESBYPROPERTIES', [str(guid()), '$', '$', '$', list(p.els.map(ref)), ref(ps)]);
|
||||
});
|
||||
contained.length = 0; materials.clear(); psets.clear();
|
||||
const now = new Date().toISOString().slice(0, 19);
|
||||
return [
|
||||
'ISO-10303-21;', 'HEADER;',
|
||||
"FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]'),'2;1');",
|
||||
`FILE_NAME(${str(filename || 'woning.ifc')},${str(now)},(${str(o.author || '')}),(${str(o.organization || '')}),${str(o.app || 'Tectum')},${str(o.app || 'Tectum')},'');`,
|
||||
"FILE_SCHEMA(('IFC4'));", 'ENDSEC;', 'DATA;', ...lines, 'ENDSEC;', 'END-ISO-10303-21;', ''
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function save(filename) {
|
||||
const blob = new Blob([toString(filename)], { type: 'application/x-step' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const el = document.createElement('a');
|
||||
el.href = url;
|
||||
el.download = filename;
|
||||
document.body.appendChild(el);
|
||||
el.click();
|
||||
document.body.removeChild(el);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return { element, contain, aggregate, toString, save, count: () => next - 1 };
|
||||
}
|
||||
|
||||
return { create, guid };
|
||||
})();
|
||||
@@ -0,0 +1,70 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Eigenschappenpaneel rechts: eigenschappen van de selectie, of van de woning als er niets geselecteerd is.
|
||||
// Een sectie = { key, label, icon, groups() → [{ label, html }], wire(el) }; elke sectie krijgt een eigen element,
|
||||
// zodat wire() alleen de eigen knoppen koppelt.
|
||||
|
||||
window.Inspector = (() => {
|
||||
const { icon, esc } = UI;
|
||||
const big = UI.btnOf('ins-btn ins-big');
|
||||
const small = UI.btnOf('ins-btn');
|
||||
const kv = (label, value, unit) =>
|
||||
`<div class="ins-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
|
||||
|
||||
const PREF_KEY = 'tectum.inspector.open';
|
||||
let open;
|
||||
try { open = new Set(JSON.parse(localStorage.getItem(PREF_KEY) || 'null') || ['gevel']); } catch (e) { open = new Set(['gevel']); }
|
||||
const saveOpen = () => { try { localStorage.setItem(PREF_KEY, JSON.stringify([...open])); } catch (e) { } };
|
||||
|
||||
// Een groepskop gelijk aan de sectienaam (bijv. Elektra › Elektra) vervalt.
|
||||
const groupsHtml = (groups, secLabel) => groups.filter(Boolean).map(g =>
|
||||
`<div class="ins-group${g.cls ? ' ' + g.cls : ''}">${g.label && g.label.toLowerCase() !== String(secLabel || '').toLowerCase() ? `<h3>${g.label}</h3>` : ''}<div class="ins-group-body">${g.html}</div></div>`).join('');
|
||||
|
||||
// view = { crumbs: [{ label, attrs }], title, icon, sub, top: html, sections: [...], collapsible, onBack }
|
||||
function render(el, view) {
|
||||
const scrollKey = view.title + '|' + (view.sub || '');
|
||||
const keepScroll = el._insKey === scrollKey ? el.scrollTop : 0;
|
||||
el._insKey = scrollKey;
|
||||
el.innerHTML = `<div class="ins-head">
|
||||
${view.crumbs && view.crumbs.length ? `<div class="ins-crumbs">${view.crumbs.map(c => `<button type="button" class="ins-crumb" ${c.attrs || ''}>${esc(c.label)}</button>`).join(icon('chevron-right'))}${icon('chevron-right')}</div>` : ''}
|
||||
<h2>${view.icon ? icon(view.icon) : ''}<span>${esc(view.title)}</span></h2>
|
||||
${view.sub ? `<div class="ins-sub">${esc(view.sub)}</div>` : ''}
|
||||
</div>
|
||||
${view.top ? `<div class="ins-top">${view.top}</div>` : ''}
|
||||
<div class="ins-sections"></div>`;
|
||||
const host = el.querySelector('.ins-sections');
|
||||
view.sections.filter(Boolean).forEach(sec => {
|
||||
const node = document.createElement('section');
|
||||
const collapsible = view.collapsible;
|
||||
const isOpen = !collapsible || open.has(sec.key);
|
||||
node.className = 'ins-section' + (collapsible ? ' ins-collapsible' : '') + (isOpen ? ' open' : '');
|
||||
node.innerHTML = (collapsible ? `<button type="button" class="ins-section-head" aria-expanded="${isOpen}">${sec.icon ? icon(sec.icon) : ''}<span>${sec.label}</span>${icon('chevron-down')}</button>` : '') +
|
||||
'<div class="ins-section-body"></div>';
|
||||
host.appendChild(node);
|
||||
// Een dichte sectie wordt pas opgebouwd (en gekoppeld) als hij opengaat.
|
||||
let built = false;
|
||||
const build = () => {
|
||||
if (built) return;
|
||||
built = true;
|
||||
node.querySelector('.ins-section-body').innerHTML = groupsHtml(sec.groups ? sec.groups() : [], sec.label);
|
||||
if (sec.wire) sec.wire(node);
|
||||
};
|
||||
if (isOpen) build();
|
||||
if (collapsible) node.querySelector('.ins-section-head').addEventListener('click', () => {
|
||||
const now = !node.classList.contains('open');
|
||||
if (now) build();
|
||||
node.classList.toggle('open', now);
|
||||
node.querySelector('.ins-section-head').setAttribute('aria-expanded', now);
|
||||
if (now) open.add(sec.key); else open.delete(sec.key);
|
||||
saveOpen();
|
||||
});
|
||||
});
|
||||
el.scrollTop = keepScroll;
|
||||
}
|
||||
|
||||
return { big, small, kv, render };
|
||||
})();
|
||||
+1
-6
@@ -4,12 +4,7 @@
|
||||
* 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.
|
||||
// ============================================================
|
||||
// Kopie van LICENTIE.md (leidend): bij een wijziging beide aanpassen.
|
||||
|
||||
window.TECTUM_LICENSE_MD = `# Licentie en auteursrecht
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,62 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Menubalk: menu = { label, items: [{ label, icon, keys, run, enabled(), checked() } | { sep: true } | { head }] }.
|
||||
// Beschikbaarheid wordt bij het openen bepaald; is een menu open, dan opent hover het buurmenu.
|
||||
|
||||
window.Menubar = (() => {
|
||||
let root = null, menus = [], openIdx = -1;
|
||||
|
||||
function itemHtml(it, mi, ii) {
|
||||
if (it.sep) return '<div class="mb-sep"></div>';
|
||||
if (it.head) return `<div class="mb-head">${it.head}</div>`;
|
||||
const off = it.enabled && !it.enabled();
|
||||
const on = it.checked && it.checked();
|
||||
return `<button type="button" class="mb-item${on ? ' checked' : ''}" data-mi="${mi}" data-ii="${ii}"${off ? ' disabled' : ''}>
|
||||
<span class="mb-ico">${on ? '<i class="bi bi-check2"></i>' : it.icon ? `<i class="bi bi-${it.icon}"></i>` : ''}</span>
|
||||
<span class="mb-label">${it.label}</span><span class="mb-keys">${it.keys || ''}</span></button>`;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (openIdx < 0) return false;
|
||||
openIdx = -1;
|
||||
UI.release(close);
|
||||
root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open'));
|
||||
root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; });
|
||||
return true;
|
||||
}
|
||||
|
||||
function openMenu(i) {
|
||||
if (openIdx < 0) UI.claim(close);
|
||||
else { root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open')); root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; }); }
|
||||
openIdx = i;
|
||||
const wrap = root.querySelectorAll('.mb-menu')[i];
|
||||
wrap.classList.add('open');
|
||||
wrap.querySelector('.mb-drop').innerHTML = menus[i].items.map((it, ii) => itemHtml(it, i, ii)).join('');
|
||||
}
|
||||
|
||||
function init(el, list) {
|
||||
root = el;
|
||||
menus = list;
|
||||
el.innerHTML = menus.map((m, i) => `<div class="mb-menu"><button type="button" class="mb-top" data-menu="${i}">${m.label}</button><div class="mb-drop" role="menu"></div></div>`).join('');
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const top = e.target.closest('[data-menu]');
|
||||
if (top) { const i = Number(top.dataset.menu); if (openIdx === i) close(); else openMenu(i); return; }
|
||||
const item = e.target.closest('.mb-item');
|
||||
if (!item || item.disabled) return;
|
||||
const it = menus[Number(item.dataset.mi)].items[Number(item.dataset.ii)];
|
||||
close();
|
||||
it.run();
|
||||
});
|
||||
el.addEventListener('mouseover', (e) => {
|
||||
const top = e.target.closest('[data-menu]');
|
||||
if (top && openIdx >= 0 && Number(top.dataset.menu) !== openIdx) openMenu(Number(top.dataset.menu));
|
||||
});
|
||||
}
|
||||
|
||||
return { init, close };
|
||||
})();
|
||||
@@ -0,0 +1,198 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Wavefront OBJ + MTL + texturen (PNG) uit three.js-objecten, in meters, Y omhoog (Blender: standaard import).
|
||||
// Texturen: herhaling/verschuiving (texture.matrix) zit in de UV's, de MTL verwijst naar textures/*.png.
|
||||
// PBR via de MTL-uitbreiding (Pr ruwheid, Pm metaal, d doorzichtigheid, Ke gloed), die Blender inleest.
|
||||
|
||||
window.ObjWriter = (() => {
|
||||
const f4 = (v) => { const r = Math.round(v * 10000) / 10000; return Object.is(r, -0) ? '0' : String(r); };
|
||||
const f3 = (v) => { const r = Math.round(v * 1000) / 1000; return Object.is(r, -0) ? '0' : String(r); };
|
||||
const safe = (s) => String(s || '').replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 40);
|
||||
|
||||
function pngBytes(image) {
|
||||
try {
|
||||
const w = image.naturalWidth || image.videoWidth || image.width, h = image.naturalHeight || image.videoHeight || image.height;
|
||||
if (!w || !h) return null;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
c.getContext('2d').drawImage(image, 0, 0);
|
||||
const bin = atob(c.toDataURL('image/png').split(',')[1]);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; name: basisnaam van de bestanden.
|
||||
function build(objects, opts) {
|
||||
const o = opts || {};
|
||||
const name = o.name || 'woning', origin = o.origin || { x: 0, y: 0, z: 0 };
|
||||
const enc = new TextEncoder();
|
||||
const obj = [`# Tectum${o.app ? ' ' + o.app : ''}\n# meters, Y omhoog\nmtllib ${name}.mtl\n`], chunks = [];
|
||||
const mats = new Map(), textures = new Map(), files = [];
|
||||
let vBase = 1, tBase = 1, nBase = 1, meshNr = 0, triCount = 0;
|
||||
const flush = () => { if (obj.length > 2000) { chunks.push(enc.encode(obj.join(''))); obj.length = 0; } };
|
||||
|
||||
function textureFile(tex) {
|
||||
if (!tex || !tex.image) return null;
|
||||
const key = tex.image;
|
||||
if (!textures.has(key)) {
|
||||
const bytes = pngBytes(tex.image);
|
||||
const file = bytes ? `textures/tex_${textures.size + 1}.png` : null;
|
||||
if (bytes) files.push({ name: file, data: bytes });
|
||||
textures.set(key, file);
|
||||
}
|
||||
return textures.get(key);
|
||||
}
|
||||
function matName(m) {
|
||||
if (!mats.has(m)) mats.set(m, { name: `m${mats.size + 1}_${safe(m.name || m.type)}`, mat: m, map: textureFile(m.map) });
|
||||
return mats.get(m);
|
||||
}
|
||||
|
||||
const v = new THREE.Vector3(), nm = new THREE.Matrix3(), uv = new THREE.Vector2();
|
||||
// Eerst de hele boom bijwerken: een mesh alleen gebruikt de (verouderde) matrix van zijn groep.
|
||||
const visit = (root) => root && (root.updateMatrixWorld(true), root.traverseVisible(mesh => {
|
||||
if (!mesh.isMesh || !mesh.geometry || !mesh.geometry.attributes.position) return;
|
||||
const g = mesh.geometry, pos = g.attributes.position, nrm = g.attributes.normal, uvA = g.attributes.uv, idx = g.index;
|
||||
nm.getNormalMatrix(mesh.matrixWorld);
|
||||
const flip = mesh.matrixWorld.determinant() < 0;
|
||||
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
const groups = g.groups && g.groups.length ? g.groups : [{ start: 0, count: idx ? idx.count : pos.count, materialIndex: 0 }];
|
||||
const out = [`o ${safe(mesh.parent && mesh.parent.name) || 'deel'}_${++meshNr}\n`];
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
v.fromBufferAttribute(pos, i).applyMatrix4(mesh.matrixWorld);
|
||||
out.push(`v ${f4(v.x - origin.x)} ${f4(v.y - origin.y)} ${f4(v.z - origin.z)}\n`);
|
||||
}
|
||||
if (nrm) for (let i = 0; i < nrm.count; i++) {
|
||||
v.fromBufferAttribute(nrm, i).applyMatrix3(nm).normalize();
|
||||
out.push(`vn ${f3(v.x)} ${f3(v.y)} ${f3(v.z)}\n`);
|
||||
}
|
||||
let tCount = 0;
|
||||
groups.forEach(gr => {
|
||||
const m = list[gr.materialIndex] || list[0];
|
||||
if (!m || m.visible === false) return;
|
||||
const mi = matName(m);
|
||||
out.push(`usemtl ${mi.name}\n`);
|
||||
const map = mi.map && m.map ? m.map : null;
|
||||
if (map) map.updateMatrix();
|
||||
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
|
||||
for (let k = gr.start; k + 2 < end; k += 3) {
|
||||
let c = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
|
||||
if (flip) c = [c[0], c[2], c[1]];
|
||||
const corner = c.map(ci => {
|
||||
let s = `${vBase + ci}`;
|
||||
if (uvA && map) {
|
||||
uv.fromBufferAttribute(uvA, ci).applyMatrix3(map.matrix);
|
||||
out.push(`vt ${f4(uv.x)} ${f4(uv.y)}\n`);
|
||||
s += `/${tBase + tCount++}`;
|
||||
} else if (nrm) s += '/';
|
||||
if (nrm) s += `/${nBase + ci}`;
|
||||
return s;
|
||||
});
|
||||
out.push(`f ${corner.join(' ')}\n`);
|
||||
triCount++;
|
||||
}
|
||||
});
|
||||
vBase += pos.count; tBase += tCount; if (nrm) nBase += nrm.count;
|
||||
obj.push(out.join(''));
|
||||
flush();
|
||||
}));
|
||||
objects.forEach(visit);
|
||||
chunks.push(enc.encode(obj.join('')));
|
||||
|
||||
// De app toont kleurwaarden zoals ze zijn (geen kleurbeheer); Blender rekent lineair: sRGB -> lineair.
|
||||
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||
const col = (c) => c ? `${f3(lin(c.r))} ${f3(lin(c.g))} ${f3(lin(c.b))}` : '0.6 0.6 0.6';
|
||||
const mtl = [`# Tectum materialen\n`];
|
||||
mats.forEach(({ name: n, mat: m, map }) => {
|
||||
mtl.push(`\nnewmtl ${n}\n`);
|
||||
mtl.push(`Kd ${col(m.color)}\n`);
|
||||
mtl.push('Ks 0.5 0.5 0.5\n');
|
||||
if (m.emissive && (m.emissive.r || m.emissive.g || m.emissive.b)) {
|
||||
const k = m.emissiveIntensity !== undefined ? m.emissiveIntensity : 1;
|
||||
mtl.push(`Ke ${f3(lin(m.emissive.r) * k)} ${f3(lin(m.emissive.g) * k)} ${f3(lin(m.emissive.b) * k)}\n`);
|
||||
}
|
||||
if (m.roughness !== undefined) mtl.push(`Pr ${f3(m.roughness)}\n`);
|
||||
if (m.metalness !== undefined) mtl.push(`Pm ${f3(m.metalness)}\n`);
|
||||
const d = m.transparent ? (m.opacity !== undefined ? m.opacity : 1) : 1;
|
||||
mtl.push(`d ${f3(d)}\nillum 2\n`);
|
||||
if (map) mtl.push(`map_Kd ${map}\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
files: [{ name: `${name}.obj`, data: chunks }, { name: `${name}.mtl`, data: enc.encode(mtl.join('')) }].concat(files),
|
||||
stats: { meshes: meshNr, triangles: triCount, materials: mats.size, textures: files.length }
|
||||
};
|
||||
}
|
||||
|
||||
// OBJ kent geen lampen: een Blender-script dat de OBJ importeert (als die er nog niet is) en de lampen als echte
|
||||
// spotlights plaatst. sources: LightingModel.lightSources (m, Y omhoog); Blender is Z omhoog: (x, -z, y).
|
||||
const BLENDER_WATT = { wandlamp: 10, dakrandspot: 8, binnenspot: 15 };
|
||||
function blenderLights(sources, opts) {
|
||||
const o = opts || {}, origin = o.origin || { x: 0, y: 0, z: 0 }, name = o.name || 'woning';
|
||||
const c = new THREE.Color(o.color !== undefined ? o.color : 0xffd9a0);
|
||||
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||
const t = (v) => `(${v.map(f4).join(', ')})`;
|
||||
const rows = sources.map(s => ` (${JSON.stringify(s.kind)}, ${t([s.x - origin.x, -(s.z - origin.z), s.y - origin.y])}, ${t([s.dir.x, -s.dir.z, s.dir.y])}, ` +
|
||||
`${f3(Math.min(Math.PI, s.angle * 2))}, ${f3(s.penumbra)}, ${BLENDER_WATT[s.kind] || 10}),\n`).join('');
|
||||
return `# Tectum${o.app ? ' ' + o.app : ''}: lampen voor ${name}.obj
|
||||
# Blender: Scripting > Open > dit bestand > Run Script. Importeert ${name}.obj uit dezelfde map
|
||||
# (IMPORTEER = False als je de OBJ al zelf hebt geïmporteerd) en zet elke lamp als spotlight in de
|
||||
# collectie "Tectum lampen"; opnieuw uitvoeren vervangt de lampen.
|
||||
# HELDERHEID schaalt alle lampen; vermogens in watt per soort staan in de lijst (soort, plek, richting,
|
||||
# kegel, zachte rand, watt).
|
||||
import bpy, os
|
||||
from mathutils import Vector
|
||||
|
||||
IMPORTEER = True
|
||||
HELDERHEID = 1.0
|
||||
KLEUR = (${f3(lin(c.r))}, ${f3(lin(c.g))}, ${f3(lin(c.b))})
|
||||
OBJ = "${name}.obj"
|
||||
LAMPEN = [
|
||||
${rows}]
|
||||
|
||||
def map_van_script():
|
||||
for area in bpy.context.screen.areas if bpy.context.screen else []:
|
||||
if area.type == 'TEXT_EDITOR' and area.spaces.active.text and area.spaces.active.text.filepath:
|
||||
return os.path.dirname(bpy.path.abspath(area.spaces.active.text.filepath))
|
||||
return os.path.dirname(bpy.data.filepath) if bpy.data.filepath else ""
|
||||
|
||||
pad = os.path.join(map_van_script(), OBJ)
|
||||
if IMPORTEER and not any(ob.get("tectum") == OBJ for ob in bpy.data.objects):
|
||||
if not os.path.exists(pad):
|
||||
raise Exception(f"{OBJ} niet gevonden naast dit script; zet IMPORTEER = False na een eigen import")
|
||||
if hasattr(bpy.ops.wm, "obj_import"):
|
||||
bpy.ops.wm.obj_import(filepath=pad)
|
||||
else:
|
||||
bpy.ops.import_scene.obj(filepath=pad)
|
||||
for ob in bpy.context.selected_objects:
|
||||
ob["tectum"] = OBJ
|
||||
|
||||
oud = bpy.data.collections.get("Tectum lampen")
|
||||
if oud:
|
||||
for ob in list(oud.objects):
|
||||
bpy.data.objects.remove(ob, do_unlink=True)
|
||||
bpy.data.collections.remove(oud)
|
||||
col = bpy.data.collections.new("Tectum lampen")
|
||||
bpy.context.scene.collection.children.link(col)
|
||||
for i, (soort, plek, richting, kegel, rand, watt) in enumerate(LAMPEN):
|
||||
licht = bpy.data.lights.new(f"{soort}_{i + 1}", 'SPOT')
|
||||
licht.energy = watt * HELDERHEID
|
||||
licht.color = KLEUR
|
||||
licht.spot_size = kegel
|
||||
licht.spot_blend = rand
|
||||
licht.shadow_soft_size = 0.03
|
||||
ob = bpy.data.objects.new(licht.name, licht)
|
||||
ob.location = plek
|
||||
ob.rotation_euler = Vector(richting).to_track_quat('-Z', 'Y').to_euler()
|
||||
col.objects.link(ob)
|
||||
print(f"Tectum: {len(LAMPEN)} lampen geplaatst")
|
||||
`;
|
||||
}
|
||||
|
||||
return { build, blenderLights };
|
||||
})();
|
||||
+3
-25
@@ -4,28 +4,16 @@
|
||||
* 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'
|
||||
};
|
||||
|
||||
// Afbeeldingen (bijv. textuur-voorvertoningen) voor in de PDF. generate()
|
||||
// is synchroon, dus vooraf laden (preloadImage, bijv. zodra een materiaal
|
||||
// gekozen is); imageData geeft de data-URL, of null als hij (nog) niet
|
||||
// geladen is (dan tekent de aanroeper een kleurvlak).
|
||||
// generate() is synchroon: afbeeldingen vooraf laden met preloadImage.
|
||||
const imageCache = {};
|
||||
function preloadImage(url) {
|
||||
if (!url || url in imageCache || typeof Image === 'undefined' || typeof document === 'undefined') return;
|
||||
@@ -37,7 +25,7 @@ window.PdfSheet = (() => {
|
||||
c.width = img.naturalWidth; c.height = img.naturalHeight;
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
imageCache[url] = c.toDataURL('image/png');
|
||||
} catch (e) { /* laat null: kleurvlak */ }
|
||||
} catch (e) { }
|
||||
};
|
||||
img.src = url;
|
||||
}
|
||||
@@ -57,9 +45,6 @@ window.PdfSheet = (() => {
|
||||
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);
|
||||
@@ -70,10 +55,6 @@ window.PdfSheet = (() => {
|
||||
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 || {};
|
||||
@@ -104,7 +85,6 @@ window.PdfSheet = (() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Scheidingslijn + onderstrook: blad links, logo rechts.
|
||||
const stripY = tbY0 + 25;
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
@@ -116,7 +96,7 @@ window.PdfSheet = (() => {
|
||||
}
|
||||
const logoH = 7.5, logoW = logoH * (window.TECTUM_LOGO ? window.TECTUM_LOGO.aspect : 4.55);
|
||||
if (window.TECTUM_LOGO) {
|
||||
doc.addImage(window.TECTUM_LOGO.png, 'PNG', tbX0 + tbW - 3 - logoW, tbY0 + tbH - 2 - logoH, logoW, logoH, 'tectum-logo', 'FAST'); // alias: 1x in het bestand, gecomprimeerd
|
||||
doc.addImage(window.TECTUM_LOGO.png, 'PNG', tbX0 + tbW - 3 - logoW, tbY0 + tbH - 2 - logoH, logoW, logoH, 'tectum-logo', 'FAST'); // alias: logo 1x in het bestand
|
||||
} else {
|
||||
doc.setTextColor(...GREY);
|
||||
doc.setFontSize(6);
|
||||
@@ -124,8 +104,6 @@ window.PdfSheet = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Eenvoudige tabel: headers + rijen (arrays van strings) vanaf (x, y) met
|
||||
// vaste kolom-x'en. Geeft de y-positie onder de tabel terug.
|
||||
function table(doc, x, y, colX, headers, rows, opts) {
|
||||
const { BLACK, GREY } = COLORS;
|
||||
const o = opts || {};
|
||||
|
||||
@@ -4,18 +4,6 @@
|
||||
* 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 = [];
|
||||
@@ -46,7 +34,6 @@ function createPdfDocument() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ruwe schatting van Helvetica-tekstbreedte (geen AFM-metrics embedded).
|
||||
function estimateTextWidth(str, size) {
|
||||
return str.length * size * 0.55;
|
||||
}
|
||||
@@ -145,9 +132,6 @@ function createPdfDocument() {
|
||||
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;
|
||||
|
||||
+21
-48
@@ -4,39 +4,26 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/skybox.js — omgeving voor de 3D-weergaven (Ontwerp / Dag / Nacht).
|
||||
// Een panorama (assets/skybox.webp of skybox-night.webp) wordt op de grond
|
||||
// geprojecteerd, zodat het object op de grond staat; onder het object ligt
|
||||
// een ronde tegelvloer (assets/tiles.jpg) met een witte opstaande rand, groot
|
||||
// genoeg voor het hele object. Vloer en rand reageren op het licht en vangen
|
||||
// de schaduw op. Nacht: omgevingslicht op NIGHT_FACTOR (lampen blijven 100%).
|
||||
//
|
||||
// Grondprojectie: port van three.js GroundedSkybox (r157+, MIT; wij draaien
|
||||
// r128): een binnenstebuiten bol waarvan de onderhelft plat gedrukt wordt op
|
||||
// een vloer op CAMERA_HEIGHT_M onder het midden van de bol.
|
||||
//
|
||||
// Vereist een webserver (texturen via TextureLoader). Plain script.
|
||||
// ============================================================
|
||||
// Grondprojectie: port van three.js GroundedSkybox (r157+, MIT); wij draaien r128.
|
||||
|
||||
window.Skybox = (() => {
|
||||
const PREF_KEY = 'tectum.sky';
|
||||
const MODES = ['none', 'day', 'night'];
|
||||
const SKY_URLS = { day: 'assets/skybox.webp', night: 'assets/skybox-night.webp' };
|
||||
const TILES_URL = 'assets/tiles.jpg';
|
||||
const CAMERA_HEIGHT_M = 1.7; // hoogte van de panoramacamera boven de grond
|
||||
const RADIUS_M = 60; // straal van de omgevingsbol
|
||||
const TILE_M = 2.4; // één tegeltextuur (1024 px) beslaat zoveel meter
|
||||
const MARGIN_M = 1.5; // vrije rand tussen object en opstaande rand
|
||||
const SIZE_FACTOR = 1.2; // tegelvloer 20% ruimer dan object + marge
|
||||
const CAMERA_HEIGHT_M = 1.7;
|
||||
const RADIUS_M = 60;
|
||||
const TILE_M = 2.4;
|
||||
const MARGIN_M = 1.5;
|
||||
const SIZE_FACTOR = 1.2;
|
||||
const RIM_HEIGHT_M = 0.10, RIM_THICK_M = 0.025;
|
||||
const NIGHT_FACTOR = 0.15; // omgevingslicht 's nachts
|
||||
const NIGHT_FACTOR = 0.15;
|
||||
const SUN_OFFSET = { x: 8, y: 14, z: 6 };
|
||||
|
||||
const cache = {};
|
||||
function loadTexture(url) {
|
||||
if (!cache[url]) {
|
||||
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(url, resolve, undefined, reject));
|
||||
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(tectumUrl(url), resolve, undefined, reject));
|
||||
cache[url].catch(() => { delete cache[url]; });
|
||||
}
|
||||
return cache[url];
|
||||
@@ -51,7 +38,6 @@ window.Skybox = (() => {
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
v.fromBufferAttribute(pos, i);
|
||||
if (v.y < 0) {
|
||||
// Onderhelft plat op y = -h; vloeiende overgang naar de bol.
|
||||
const f = v.y < y1 ? -h / v.y : (1 - v.y * v.y / (3 * y1 * y1));
|
||||
v.multiplyScalar(f);
|
||||
pos.setXYZ(i, v.x, v.y, v.z);
|
||||
@@ -59,12 +45,11 @@ window.Skybox = (() => {
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map, depthWrite: false }));
|
||||
mesh.position.y = h - 0.01; // vlakke vloer net onder de tegelvloer
|
||||
mesh.position.y = h - 0.01;
|
||||
mesh.renderOrder = -1;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
// Ronde tegelvloer (straal r) + witte opstaande rand erlangs.
|
||||
function buildTerrace(map, r) {
|
||||
const group = new THREE.Group();
|
||||
const tex = map.clone();
|
||||
@@ -86,13 +71,12 @@ window.Skybox = (() => {
|
||||
new THREE.ExtrudeGeometry(ring, { depth: RIM_HEIGHT_M, bevelEnabled: false, curveSegments: 128 }),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 })
|
||||
);
|
||||
rim.rotation.x = -Math.PI / 2; // extrusie omhoog
|
||||
rim.rotation.x = -Math.PI / 2;
|
||||
rim.castShadow = rim.receiveShadow = true;
|
||||
group.add(rim);
|
||||
return group;
|
||||
}
|
||||
|
||||
// Omgeving voor één scene: { group, setMode(mode), fitTo(object3D, groundY) }.
|
||||
function createEnvironment(scene) {
|
||||
const group = new THREE.Group();
|
||||
group.visible = false;
|
||||
@@ -124,7 +108,6 @@ window.Skybox = (() => {
|
||||
return {
|
||||
group,
|
||||
setMode(m) { mode = m; group.visible = m !== 'none'; showSky(); },
|
||||
// Midden en straal uit de omhullende van het object (horizontaal), vloer op groundY (m).
|
||||
fitTo(object3D, groundY) {
|
||||
if (!object3D) return;
|
||||
const box = new THREE.Box3().setFromObject(object3D);
|
||||
@@ -144,14 +127,11 @@ window.Skybox = (() => {
|
||||
} catch (e) { return 'none'; }
|
||||
}
|
||||
function remember(m) {
|
||||
try { localStorage.setItem(PREF_KEY, m); } catch (e) { /* geen opslag: alleen deze sessie */ }
|
||||
try { localStorage.setItem(PREF_KEY, m); } catch (e) { }
|
||||
}
|
||||
|
||||
const lightFactor = (m) => m === 'night' ? NIGHT_FACTOR : 1;
|
||||
|
||||
// Omgeving toepassen: lucht/vloer, raster, camera niet onder de grond, zon
|
||||
// met schaduw, en het licht (nacht: NIGHT_FACTOR).
|
||||
// o: { env, grid, controls, sun, ambient, base: { ambient, sun }, shadowRoot }
|
||||
function apply(m, o) {
|
||||
const on = m !== 'none';
|
||||
o.env.setMode(m);
|
||||
@@ -177,40 +157,33 @@ window.Skybox = (() => {
|
||||
if (o.shadowRoot) o.shadowRoot.traverse(obj => { if (obj.isMesh) obj.castShadow = on; });
|
||||
}
|
||||
|
||||
// Keuzemenu in een 3D-werkbalk: Ontwerp (geen omgeving) / Dag / Nacht.
|
||||
const LABELS = { none: ['pencil-square', 'Ontwerp'], day: ['sun', 'Dag'], night: ['moon-stars', 'Nacht'] };
|
||||
function createToggle(toolbar, onChange) {
|
||||
let m = getMode();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'view-filter';
|
||||
wrap.innerHTML = `<button type="button" class="btn-3d-toggle vf-btn" title="Omgeving"></button>
|
||||
<div class="vf-menu">${MODES.map(k => `<div class="vf-item vf-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</div>`).join('')}</div>`;
|
||||
wrap.className = 'tb-pop-wrap';
|
||||
wrap.innerHTML = `<button type="button" class="tb-btn" title="Omgeving in 3D"></button>
|
||||
<div class="tb-pop">${MODES.map(k => `<button type="button" class="pop-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</button>`).join('')}</div>`;
|
||||
toolbar.appendChild(wrap);
|
||||
const btn = wrap.querySelector('.vf-btn'), menu = wrap.querySelector('.vf-menu');
|
||||
const btn = wrap.querySelector('.tb-btn'), menu = wrap.querySelector('.tb-pop');
|
||||
const close = UI.popover(btn, menu);
|
||||
const sync = () => {
|
||||
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i>${LABELS[m][1]}<i class="bi bi-chevron-down"></i>`;
|
||||
menu.querySelectorAll('.vf-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
|
||||
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i><span>${LABELS[m][1]}</span><i class="bi bi-chevron-down tb-caret"></i>`;
|
||||
menu.querySelectorAll('.pop-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
|
||||
};
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('open'); });
|
||||
menu.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const opt = e.target.closest('.vf-option');
|
||||
const opt = e.target.closest('.pop-option');
|
||||
if (!opt) return;
|
||||
menu.classList.remove('open');
|
||||
close();
|
||||
if (opt.dataset.mode === m) return;
|
||||
m = opt.dataset.mode;
|
||||
remember(m);
|
||||
sync();
|
||||
onChange(m);
|
||||
});
|
||||
const closeOnOutside = () => {
|
||||
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
|
||||
menu.classList.remove('open');
|
||||
};
|
||||
document.addEventListener('click', closeOnOutside);
|
||||
sync();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, createEnvironment, apply, getMode, lightFactor, createToggle };
|
||||
return { CAMERA_HEIGHT_M, NIGHT_FACTOR, MODES, LABELS, createEnvironment, apply, getMode, setMode: remember, lightFactor, createToggle };
|
||||
})();
|
||||
|
||||
+112
-34
@@ -4,17 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/ui-kit.js — gedeelde UI-bouwstenen (window.UI): iconen (Bootstrap
|
||||
// Icons, css/vendor), invoervelden, schakelaars, secties, tabbladen,
|
||||
// specificatie-kaarten en de 3-staps weergave-cyclus (sneltoets T).
|
||||
// Eén plek voor opmaak en woordgebruik, zodat alle panelen gelijk ogen.
|
||||
//
|
||||
// Woordgebruik labels: zelfstandig naamwoord met hoofdletter, geen dubbele
|
||||
// punt, geen uitleg tussen haakjes (die gaat in een hint), eenheid rechts
|
||||
// van het veld. Bijvoorbeeld "Afstand min." i.p.v. "Lampen min. h.o.h.".
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
// Labels: zelfstandig naamwoord met hoofdletter, geen dubbele punt, uitleg in een hint, eenheid rechts.
|
||||
|
||||
window.UI = (() => {
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
@@ -25,7 +15,6 @@ window.UI = (() => {
|
||||
return `<div class="ui-field${o && o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o && o.hint)}</label><div class="ui-control">${control}</div></div>`;
|
||||
}
|
||||
|
||||
// Getal. o: { step, unit ('mm' standaard, '' = geen), hint, min, disabled }
|
||||
function num(id, label, value, o) {
|
||||
o = o || {};
|
||||
const unit = o.unit === undefined ? 'mm' : o.unit;
|
||||
@@ -34,13 +23,37 @@ window.UI = (() => {
|
||||
`<span class="ui-unit">${unit}</span>`, o);
|
||||
}
|
||||
|
||||
// Keuzelijst. options: { waarde: 'Label' }.
|
||||
// Het getal mag buiten het schuifbereik; het model klemt zelf.
|
||||
function range(id, label, value, o) {
|
||||
o = o || {};
|
||||
const unit = o.unit === undefined ? 'mm' : o.unit;
|
||||
const lim = `min="${o.min}" max="${o.max}" step="${o.step || 10}"`;
|
||||
return `<div class="ui-field ui-field-range${o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o.hint)}</label>` +
|
||||
`<div class="ui-control ui-range"><input type="range" id="${id}-r" ${lim} value="${value}"${o.disabled ? ' disabled' : ''}>` +
|
||||
`<input type="number" id="${id}" step="${o.step || 10}" value="${value}"${o.disabled ? ' disabled' : ''}><span class="ui-unit">${unit}</span></div></div>`;
|
||||
}
|
||||
function onRange(id, fn, after) {
|
||||
const n = document.getElementById(id), r = document.getElementById(id + '-r');
|
||||
if (!n || !r) return;
|
||||
r.addEventListener('input', () => { n.value = r.value; fn(parseFloat(r.value)); after && after(); });
|
||||
n.addEventListener('input', () => {
|
||||
const v = parseFloat(n.value);
|
||||
if (!isFinite(v)) return;
|
||||
r.value = v;
|
||||
fn(v); after && after();
|
||||
});
|
||||
}
|
||||
function setRange(id, v) {
|
||||
const n = document.getElementById(id), r = document.getElementById(id + '-r');
|
||||
if (n && document.activeElement !== n) n.value = v;
|
||||
if (r) r.value = v;
|
||||
}
|
||||
|
||||
function sel(id, label, options, value, o) {
|
||||
const opts = Object.entries(options).map(([k, v]) => `<option value="${k}"${k === value ? ' selected' : ''}>${v}</option>`).join('');
|
||||
return field(id, label, `<select id="${id}">${opts}</select><span class="ui-unit"></span>`, o);
|
||||
}
|
||||
|
||||
// Kleurkiezer (waarde '#rrggbb'); onColor reageert pas bij loslaten.
|
||||
function color(id, label, value, o) {
|
||||
return field(id, label, `<input type="color" id="${id}" value="${value}"><span class="ui-unit"></span>`, o);
|
||||
}
|
||||
@@ -49,7 +62,14 @@ window.UI = (() => {
|
||||
if (el) el.addEventListener('change', (e) => { fn(e.target.value); after && after(); });
|
||||
}
|
||||
|
||||
// Aan/uit-schakelaar.
|
||||
function text(id, label, value, o) {
|
||||
return field(id, label, `<input type="text" id="${id}" value="${esc(value || '')}"${o && o.placeholder ? ` placeholder="${esc(o.placeholder)}"` : ''}><span class="ui-unit"></span>`, o);
|
||||
}
|
||||
function onText(id, fn, after) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', (e) => { fn(e.target.value); after && after(); });
|
||||
}
|
||||
|
||||
function toggle(id, label, checked, o) {
|
||||
return field(id, label,
|
||||
`<label class="ui-switch"><input type="checkbox" id="${id}"${checked ? ' checked' : ''}><span></span></label><span class="ui-unit"></span>`, o);
|
||||
@@ -60,12 +80,12 @@ window.UI = (() => {
|
||||
const info = (text) => `<div class="ui-info">${icon('info-circle')}<span>${text}</span></div>`;
|
||||
const warn = (text) => `<div class="ui-warn">${icon('exclamation-triangle')}<span>${text}</span></div>`;
|
||||
const grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`;
|
||||
const button = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
|
||||
// Eén knopbouwer; base = de knopsoort (ui-btn, tb-btn, ins-btn …), cls = extra klassen.
|
||||
const btnOf = (base) => (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="${base}${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
|
||||
const button = btnOf('ui-btn');
|
||||
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
|
||||
|
||||
// Tabbladen. group = sleutel waaronder het actieve tabblad onthouden wordt
|
||||
// (bijv. 'woning', 'unit'); tabs = [{ key, label, icon, html }].
|
||||
const activeTabs = {};
|
||||
function tabs(group, list) {
|
||||
const active = list.some(t => t.key === activeTabs[group]) ? activeTabs[group] : list[0].key;
|
||||
@@ -74,7 +94,6 @@ window.UI = (() => {
|
||||
${list.map(t => `<div class="ui-tab-page${t.key === active ? ' active' : ''}" data-tab-page="${t.key}">${t.html}</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
// Eenmalig per container: klik op een tab wisselt de pagina.
|
||||
function wireTabs(container) {
|
||||
if (container._uiTabsWired) return;
|
||||
container._uiTabsWired = true;
|
||||
@@ -88,14 +107,12 @@ window.UI = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
// Specificaties: kaart met regels [label, waarde, eenheid].
|
||||
function specCard(title, iconName, rows, total) {
|
||||
const row = ([label, value, unit]) =>
|
||||
`<div class="ui-spec-row"><span>${label}</span><span class="ui-spec-val">${value}${unit ? `<small>${unit}</small>` : ''}</span></div>`;
|
||||
return `<div class="ui-spec-card"><h2>${icon(iconName)}${title}</h2>${rows.map(row).join('')}${total ? `<div class="ui-spec-total">${row(total)}</div>` : ''}</div>`;
|
||||
}
|
||||
|
||||
// Invoer koppelen: onNum/onSel/onToggle(root, id, fn, after).
|
||||
function onNum(id, fn, after, o) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
@@ -113,18 +130,79 @@ window.UI = (() => {
|
||||
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
|
||||
}
|
||||
|
||||
// Weergave-cyclus 3D (sneltoets T): standaard -> 3D groot -> alleen de
|
||||
// render (ook het info-/specificatiedeel eronder weg) -> standaard.
|
||||
function cycle3D(el) {
|
||||
el.classList.remove('fullscreen-2d');
|
||||
if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d');
|
||||
else if (!el.classList.contains('render-only')) el.classList.add('render-only');
|
||||
else el.classList.remove('fullscreen-3d', 'render-only');
|
||||
}
|
||||
function toggle2D(el) {
|
||||
el.classList.remove('fullscreen-3d', 'render-only');
|
||||
el.classList.toggle('fullscreen-2d');
|
||||
// Weergave van een editor: klassen fullscreen-2d/-3d op het editor-element (de looptocht bewaart en herstelt className).
|
||||
const VIEWS = [
|
||||
{ key: 'split', icon: 'layout-split', label: '2D + 3D', key1: '1' },
|
||||
{ key: '2d', icon: 'square', label: 'Alleen 2D', key1: '2' },
|
||||
{ key: '3d', icon: 'box', label: 'Alleen 3D', key1: '3' }
|
||||
];
|
||||
const viewOf = (el) => el.classList.contains('fullscreen-3d') ? '3d' : el.classList.contains('fullscreen-2d') ? '2d' : 'split';
|
||||
function setView(el, mode) {
|
||||
el.classList.toggle('fullscreen-3d', mode === '3d');
|
||||
el.classList.toggle('fullscreen-2d', mode === '2d');
|
||||
el.classList.remove('render-only');
|
||||
el.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode));
|
||||
}
|
||||
|
||||
return { esc, icon, hint, num, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
|
||||
const tb = btnOf('tb-btn');
|
||||
const tbSep = () => '<span class="tb-sep"></span>';
|
||||
const viewSeg = (mode) => `<div class="tb-seg" role="group" aria-label="Weergave">${VIEWS.map(v =>
|
||||
tb('', v.icon, `data-view="${v.key}" title="${v.label} (${v.key1})"`, v.key === mode ? 'active' : '')).join('')}</div>`;
|
||||
|
||||
// Werkbalk van een editor: [left] … [Lagen, Omgeving] [after] | [2D + 3D, 2D, 3D]. Gedeeld door woning, wand en vloer.
|
||||
// o: left/after (html), onResize, layers (opties voor ViewLayers.createMenu), onSky, beforeView(mode).
|
||||
function editorBar(toolbar, root, o) {
|
||||
toolbar.innerHTML = `${o.left || ''}<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${o.after || ''}${tbSep()}${viewSeg(viewOf(root))}`;
|
||||
const group = toolbar.querySelector('.tb-view');
|
||||
if (o.layers) ViewLayers.createMenu(group, o.layers);
|
||||
if (o.onSky) Skybox.createToggle(group, o.onSky);
|
||||
const bar = {
|
||||
setView(mode) {
|
||||
if (o.beforeView) o.beforeView(mode);
|
||||
setView(root, mode);
|
||||
requestAnimationFrame(o.onResize);
|
||||
},
|
||||
viewState: () => ({ view: viewOf(root) })
|
||||
};
|
||||
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => bar.setView(b.dataset.view)));
|
||||
return bar;
|
||||
}
|
||||
|
||||
// Eén uitklapper tegelijk (popover of menu): wie opent, sluit de vorige. claim bij openen, release bij sluiten.
|
||||
let openPop = null;
|
||||
function claim(close) { if (openPop && openPop !== close) openPop(); openPop = close; }
|
||||
function release(close) { if (openPop === close) openPop = null; }
|
||||
const closePopovers = () => { if (openPop) { openPop(); return true; } return false; };
|
||||
document.addEventListener('click', () => closePopovers());
|
||||
|
||||
// Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere uitklapper.
|
||||
function popover(btn, panel) {
|
||||
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); release(close); };
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (panel.classList.contains('open')) { close(); return; }
|
||||
claim(close);
|
||||
panel.classList.add('open');
|
||||
btn.classList.add('open');
|
||||
});
|
||||
panel.addEventListener('click', (e) => e.stopPropagation());
|
||||
return close;
|
||||
}
|
||||
|
||||
// Kaarten verdelen over kolommen van min. minW breed; telkens in de kortste kolom. Opnieuw aanroepen bij een andere breedte.
|
||||
function masonry(el, minW, gap) {
|
||||
if (!el) return;
|
||||
gap = gap === undefined ? 12 : gap;
|
||||
const cards = el._cards || (el._cards = [...el.children]);
|
||||
const n = Math.max(1, Math.floor((el.clientWidth + gap) / (minW + gap)));
|
||||
if (el._cols === n) return;
|
||||
el._cols = n;
|
||||
el.classList.add('ui-masonry');
|
||||
const cols = Array.from({ length: n }, () => { const c = document.createElement('div'); c.className = 'ui-masonry-col'; return c; });
|
||||
el.replaceChildren(...cols);
|
||||
cards.forEach(card => cols.reduce((a, b) => b.offsetHeight < a.offsetHeight ? b : a).appendChild(card));
|
||||
}
|
||||
|
||||
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, btnOf,
|
||||
VIEWS, viewOf, setView, tb, tbSep, viewSeg, editorBar, popover, claim, release, closePopovers, masonry };
|
||||
})();
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/*!
|
||||
* 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"><i class="bi bi-eye"></i>Weergave<i class="bi bi-chevron-down"></i></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) };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
window.ViewLayers = (() => {
|
||||
// phase: bouwvolgorde voor de snelkeuze "Fase" (en IFC); lagen zonder phase blijven daarbij aan.
|
||||
const LIST = [
|
||||
{ key: 'regelwerk', label: 'Regelwerk', icon: 'grid-3x3', phase: 0, hint: 'Vloer-, wand- en dakbalken, stalen nokbalk' },
|
||||
{ key: 'elektra', label: 'Elektra', icon: 'lightning-charge', phase: 1, hint: 'Meterkast, buizen, dozen, schakelaars en wcd\'s' },
|
||||
{ key: 'beplating', label: 'Beplating', icon: 'square', phase: 2, hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
|
||||
{ key: 'doek', label: 'Vochtdoek', icon: 'droplet', phase: 3, hint: 'Vochtdoek op de buitenzijde' },
|
||||
{ key: 'latwerk', label: 'Latwerk', icon: 'distribute-vertical', phase: 4, hint: 'Kruislat en trasraamplaat' },
|
||||
{ key: 'gevel', label: 'Gevelbekleding', icon: 'bricks', phase: 5, hint: 'Rabat, felsplaten, steenstrips, stuc, schoorsteen, sandwichpanelen' },
|
||||
{ key: 'kozijnen', label: 'Kozijnen', icon: 'window', phase: 5, hint: 'Kozijnen, deuren en glas' },
|
||||
{ key: 'dakbedekking', label: 'Dakbedekking', icon: 'house-up', phase: 5, hint: 'Dakleer of riet' },
|
||||
{ key: 'afwerking', label: 'Afwerking', icon: 'brush', phase: 6, hint: 'Vloerafwerking, verf, plafond, dakrand, dagkanten, logobord' },
|
||||
{ key: 'lampen', label: 'Verlichting', icon: 'lightbulb', phase: 6, hint: 'Gevellampen, spots en binnenverlichting' },
|
||||
{ key: 'inrichting', label: 'Inrichting', icon: 'lamp', phase: 6, hint: 'Sanitair, keuken, kasten, bed, zithoek' },
|
||||
{ key: 'ruimtes', label: 'Ruimtes', icon: 'bounding-box', phase: 6, hint: 'Ruimtes met functie, vloer en wandpanelen' },
|
||||
{ key: 'dak', label: 'Dak', icon: 'house', hint: 'Het hele dak met dakrand, overkapping en palen; uit om van boven in de woning te kijken' },
|
||||
{ key: 'maten', label: 'Maten', icon: 'rulers', hint: 'Maatvoering in de plattegrond' }
|
||||
];
|
||||
const KEYS = LIST.map(l => l.key);
|
||||
const PHASES = ['Regelwerk', 'Elektra', 'Beplating', 'Vochtdoek', 'Latwerk', 'Gevel & dak', 'Afgewerkt'];
|
||||
|
||||
const all = () => Object.fromEntries(KEYS.map(k => [k, true]));
|
||||
const normalize = (layers) => Object.fromEntries(KEYS.map(k => [k, !layers || layers[k] !== false]));
|
||||
const shows = (layers, key) => !layers || layers[key] !== false;
|
||||
// Alles tot en met een bouwfase (0..6), zoals de vroegere bouwfase-schuif.
|
||||
const upTo = (phase) => Object.fromEntries(LIST.map(l => [l.key, l.phase === undefined || l.phase <= phase]));
|
||||
const phaseOf = (layers) => PHASES.findIndex((_, p) => LIST.every(l => l.phase === undefined || (l.phase <= p) === shows(layers, l.key)));
|
||||
|
||||
// Verbergt meshes met een van de materialen (ook afgeleide met userData.base), bijv. het regelwerk.
|
||||
function hideByMaterial(group, materials) {
|
||||
if (!group) return;
|
||||
const set = new Set(materials.filter(Boolean));
|
||||
group.traverse(o => {
|
||||
if (!o.isMesh) return;
|
||||
const m0 = Array.isArray(o.material) ? o.material[0] : o.material;
|
||||
if (m0 && (set.has(m0) || set.has(m0.userData && m0.userData.base))) o.visible = false;
|
||||
});
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
['Casco', ['regelwerk', 'elektra', 'beplating', 'doek', 'latwerk']],
|
||||
['Buiten', ['gevel', 'kozijnen', 'dakbedekking', 'dak']],
|
||||
['Binnen', ['afwerking', 'lampen', 'inrichting', 'ruimtes']],
|
||||
['Plattegrond', ['maten']]
|
||||
];
|
||||
|
||||
// De enige plek voor zichtbaarheid: knop "Lagen" in de werkbalk met een uitklappaneel.
|
||||
// keys beperkt de lagen (wand-editor); extras zijn editor-eigen schakelaars, bijv. beplating binnen/buiten.
|
||||
function createMenu(toolbar, o) {
|
||||
const items = o.keys ? LIST.filter(l => o.keys.includes(l.key)) : LIST;
|
||||
const extras = o.extras || [];
|
||||
const extra = Object.fromEntries(extras.map(x => [x.key, x.checked !== false]));
|
||||
const check = (attr, key, label, hint, ic) =>
|
||||
`<label class="pop-check" title="${hint || ''}"><input type="checkbox" ${attr}="${key}">${ic ? `<i class="bi bi-${ic}"></i>` : ''}<span>${label}</span></label>`;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'tb-pop-wrap';
|
||||
wrap.innerHTML = `<button type="button" class="tb-btn" title="Zichtbaarheid van lagen in 2D en 3D"><i class="bi bi-layers"></i><span>Lagen</span><i class="bi bi-chevron-down tb-caret"></i></button>
|
||||
<div class="tb-pop pop-layers">
|
||||
${items.length ? `<div class="pop-row">
|
||||
<select class="pop-phase" title="Alles tot en met een bouwfase; Dak en Maten blijven zoals ze staan">
|
||||
<option value="">Bouwfase…</option>${PHASES.map((p, i) => `<option value="${i}">${i + 1}. ${p}</option>`).join('')}
|
||||
</select>
|
||||
<button type="button" class="ui-btn" data-all>Alles</button>
|
||||
</div>
|
||||
<div class="pop-groups">${GROUPS.map(([label, keys]) => {
|
||||
const g = items.filter(l => keys.includes(l.key));
|
||||
return g.length ? `<div class="pop-group"><div class="pop-head">${label}</div>${g.map(l => check('data-layer', l.key, l.label, l.hint, l.icon)).join('')}</div>` : '';
|
||||
}).join('')}</div>` : ''}
|
||||
${extras.length ? `<div class="pop-group"><div class="pop-head">${o.extrasLabel || 'Weergave'}</div>${extras.map(x => check('data-extra', x.key, x.label, x.hint)).join('')}</div>` : ''}
|
||||
</div>`;
|
||||
toolbar.appendChild(wrap);
|
||||
const btn = wrap.querySelector('.tb-btn'), panel = wrap.querySelector('.tb-pop');
|
||||
UI.popover(btn, panel);
|
||||
let cur = normalize(o.layers);
|
||||
const sel = panel.querySelector('.pop-phase');
|
||||
const sync = () => {
|
||||
panel.querySelectorAll('[data-layer]').forEach(c => { c.checked = cur[c.dataset.layer]; });
|
||||
panel.querySelectorAll('[data-extra]').forEach(c => { c.checked = extra[c.dataset.extra]; });
|
||||
if (sel) { const p = phaseOf(cur); sel.value = p >= 0 ? String(p) : ''; }
|
||||
const hidden = items.some(l => !cur[l.key]) || extras.some(x => !extra[x.key]);
|
||||
btn.classList.toggle('flag', hidden);
|
||||
};
|
||||
const emit = () => { sync(); o.onChange && o.onChange(Object.assign({}, cur)); };
|
||||
panel.addEventListener('change', (e) => {
|
||||
const t = e.target;
|
||||
if (t.dataset.layer) { cur = Object.assign({}, cur, { [t.dataset.layer]: t.checked }); emit(); }
|
||||
else if (t.dataset.extra) { extra[t.dataset.extra] = t.checked; sync(); o.onExtra && o.onExtra(Object.assign({}, extra)); }
|
||||
else if (t === sel && sel.value !== '') {
|
||||
const keep = Object.fromEntries(LIST.filter(l => l.phase === undefined).map(l => [l.key, cur[l.key]]));
|
||||
cur = Object.assign(upTo(Number(sel.value)), keep);
|
||||
emit();
|
||||
}
|
||||
});
|
||||
// Alles = ook de editor-eigen schakelaars weer aan.
|
||||
const all = panel.querySelector('[data-all]');
|
||||
if (all) all.addEventListener('click', () => {
|
||||
const extraOff = extras.some(x => !extra[x.key]);
|
||||
extras.forEach(x => { extra[x.key] = true; });
|
||||
cur = ViewLayers.all();
|
||||
emit();
|
||||
if (extraOff && o.onExtra) o.onExtra(Object.assign({}, extra));
|
||||
});
|
||||
sync();
|
||||
return { el: wrap, set: (l) => { cur = normalize(l); sync(); } };
|
||||
}
|
||||
|
||||
return { LIST, KEYS, PHASES, GROUPS, all, normalize, shows, upTo, phaseOf, hideByMaterial, createMenu };
|
||||
})();
|
||||
@@ -4,25 +4,9 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/walk-controls.js — lopen in de 3D-weergave (first person).
|
||||
// W/S of pijl omhoog/omlaag vooruit/achteruit
|
||||
// A/D opzij stappen
|
||||
// pijl links/rechts draaien
|
||||
// Shift rennen
|
||||
// slepen met de muis rondkijken
|
||||
// Botsen (optioneel) en vloerhoogte komen van de aanroeper:
|
||||
// opts.blocked(x, z) -> true als je daar niet kunt staan (m); standaard nooit
|
||||
// opts.floorAt(x, z) -> hoogte van de ondergrond (m)
|
||||
// opts.eye -> ooghoogte boven de ondergrond (m, standaard 1,65)
|
||||
// Plain script (geen ES-module); gebruikt THREE.
|
||||
//
|
||||
// const walk = createWalkControls(camera, domElement, opts);
|
||||
// walk.start({ x, z, yaw }); walk.update(dt); walk.stop();
|
||||
// ============================================================
|
||||
|
||||
function createWalkControls(camera, domElement, opts) {
|
||||
const o = Object.assign({ eye: 1.65, speed: 1.68, run: 3.84, turn: Math.PI / 2, blocked: () => false, floorAt: () => 0 }, opts || {});
|
||||
const o = Object.assign({ eye: 1.65, speed: 2.1, run: 4.8, turn: Math.PI / 2, blocked: () => false, floorAt: () => 0 }, opts || {});
|
||||
const keys = new Set();
|
||||
let active = false;
|
||||
let x = 0, z = 0, yaw = 0, pitch = 0;
|
||||
@@ -60,7 +44,6 @@ function createWalkControls(camera, domElement, opts) {
|
||||
camera.lookAt(camera.position.clone().add(dir));
|
||||
}
|
||||
|
||||
// Stap met botsen: eerst de hele stap, anders per as (langs een muur glijden).
|
||||
function step(dx, dz) {
|
||||
if (!o.blocked(x + dx, z + dz)) { x += dx; z += dz; return; }
|
||||
if (!o.blocked(x + dx, z)) x += dx;
|
||||
@@ -85,8 +68,8 @@ function createWalkControls(camera, domElement, opts) {
|
||||
const r = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
|
||||
if (f || r) {
|
||||
const v = (keys.has('shift') ? o.run : o.speed) * t / Math.hypot(f, r);
|
||||
const fx = Math.sin(yaw), fz = Math.cos(yaw); // vooruit
|
||||
const rx = -fz, rz = fx; // rechts (kijkend langs +Z is rechts -X)
|
||||
const fx = Math.sin(yaw), fz = Math.cos(yaw);
|
||||
const rx = -fz, rz = fx;
|
||||
step((fx * f + rx * r) * v, (fz * f + rz * r) * v);
|
||||
}
|
||||
apply();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ZIP zonder compressie (stored): meerdere bestanden in één download.
|
||||
|
||||
window.ZipWriter = (() => {
|
||||
const CRC = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
CRC[n] = c >>> 0;
|
||||
}
|
||||
function crc32(parts) {
|
||||
let c = 0xffffffff;
|
||||
parts.forEach(b => { for (let i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 0xff] ^ (c >>> 8); });
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
// files: [{ name, data: Uint8Array | Uint8Array[] }]
|
||||
function build(files) {
|
||||
const enc = new TextEncoder();
|
||||
const out = [], central = [];
|
||||
let offset = 0;
|
||||
const d = new Date();
|
||||
const time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
|
||||
const date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
|
||||
files.forEach(f => {
|
||||
const parts = Array.isArray(f.data) ? f.data : [f.data];
|
||||
const size = parts.reduce((a, b) => a + b.length, 0);
|
||||
const name = enc.encode(f.name), crc = crc32(parts);
|
||||
const head = (sig, extra) => {
|
||||
const h = new DataView(new ArrayBuffer(extra ? 46 : 30));
|
||||
let p = 0;
|
||||
const u32 = (v) => { h.setUint32(p, v, true); p += 4; }, u16 = (v) => { h.setUint16(p, v, true); p += 2; };
|
||||
u32(sig);
|
||||
if (extra) u16(20);
|
||||
u16(20); u16(0x0800); u16(0); u16(time); u16(date); u32(crc); u32(size); u32(size); u16(name.length); u16(0);
|
||||
if (extra) { u16(0); u16(0); u16(0); u32(0); u32(offset); }
|
||||
return new Uint8Array(h.buffer);
|
||||
};
|
||||
const local = head(0x04034b50, false);
|
||||
central.push(head(0x02014b50, true), name);
|
||||
out.push(local, name, ...parts);
|
||||
offset += local.length + name.length + size;
|
||||
});
|
||||
const cdSize = central.reduce((a, b) => a + b.length, 0);
|
||||
const end = new DataView(new ArrayBuffer(22));
|
||||
end.setUint32(0, 0x06054b50, true);
|
||||
end.setUint16(8, files.length, true); end.setUint16(10, files.length, true);
|
||||
end.setUint32(12, cdSize, true); end.setUint32(16, offset, true);
|
||||
return out.concat(central, [new Uint8Array(end.buffer)]);
|
||||
}
|
||||
|
||||
function save(files, filename) {
|
||||
const blob = new Blob(build(files), { type: 'application/zip' });
|
||||
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);
|
||||
}
|
||||
|
||||
return { build, save };
|
||||
})();
|
||||
@@ -0,0 +1,199 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
window.CostCsv = (() => {
|
||||
const BALK_RE = /^hout\.balk\.(\d+)x(\d+)$/;
|
||||
const LISTS = {
|
||||
intern: { url: 'kosten/intern.csv', label: 'Intern', pricesOnly: false },
|
||||
leveranciers: { url: 'kosten/leveranciers.csv', label: 'Leveranciers', pricesOnly: true }
|
||||
};
|
||||
const listeners = [];
|
||||
const empty = () => ({ source: null, name: null, loadedAt: null, values: {}, unknown: [], errors: [] });
|
||||
const state = { intern: empty(), leveranciers: empty() };
|
||||
|
||||
function splitLine(line, sep) {
|
||||
const out = [];
|
||||
let cur = '', q = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (q) {
|
||||
if (ch === '"' && line[i + 1] === '"') { cur += '"'; i++; }
|
||||
else if (ch === '"') q = false;
|
||||
else cur += ch;
|
||||
} else if (ch === '"') q = true;
|
||||
else if (ch === sep) { out.push(cur.trim()); cur = ''; }
|
||||
else cur += ch;
|
||||
}
|
||||
out.push(cur.trim());
|
||||
return out;
|
||||
}
|
||||
function detectSep(header) {
|
||||
const n = (c) => header.split(c).length;
|
||||
return [';', '\t', ','].reduce((best, c) => n(c) > n(best) ? c : best, ';');
|
||||
}
|
||||
function parseNumber(s) {
|
||||
let t = String(s || '').replace(/[^\d,.\-]/g, '');
|
||||
if (!t) return NaN;
|
||||
const c = t.lastIndexOf(','), d = t.lastIndexOf('.');
|
||||
if (c >= 0 && d >= 0) t = c > d ? t.replace(/\./g, '').replace(',', '.') : t.replace(/,/g, '');
|
||||
else if (c >= 0) t = t.replace(',', '.');
|
||||
return parseFloat(t);
|
||||
}
|
||||
const normUnit = (u) => String(u || '').toLowerCase().replace(/\s+/g, '').replace(/^eur(o)?/, '€').replace(/\.$/, '').replace('m2', 'm²').replace('m3', 'm³');
|
||||
const COLS = {
|
||||
code: ['code', 'sleutel', 'id'],
|
||||
value: ['waarde', 'prijs', 'value', 'price'],
|
||||
unit: ['eenheid', 'unit'],
|
||||
label: ['omschrijving', 'naam', 'description'],
|
||||
supplier: ['leverancier', 'supplier'],
|
||||
article: ['artikelnummer', 'artikel', 'sku'],
|
||||
updated: ['bijgewerkt', 'datum', 'date', 'updated']
|
||||
};
|
||||
|
||||
const isPrice = (it) => !it.options && /^€/.test(it.unit || '') && it.unit !== '€/u';
|
||||
function itemFor(code) {
|
||||
if (BALK_RE.test(code)) return { unit: '€/m', price: true };
|
||||
const [g, k] = code.split('.');
|
||||
const it = window.COST_DEFAULTS[g] && window.COST_DEFAULTS[g].items[k];
|
||||
return it ? Object.assign({ price: isPrice(it) }, it) : null;
|
||||
}
|
||||
function optionKey(it, s) {
|
||||
const t = String(s || '').trim().toLowerCase();
|
||||
return Object.keys(it.options).find(k => k.toLowerCase() === t || String(it.options[k]).toLowerCase() === t);
|
||||
}
|
||||
|
||||
function parse(text, list) {
|
||||
const pricesOnly = LISTS[list].pricesOnly;
|
||||
const lines = String(text).replace(/^/, '').split(/\r?\n/);
|
||||
const hi = lines.findIndex(l => l.trim() && !l.trim().startsWith('#'));
|
||||
if (hi < 0) return { values: {}, unknown: [], errors: [] };
|
||||
const sep = detectSep(lines[hi]);
|
||||
const head = splitLine(lines[hi], sep).map(h => h.toLowerCase());
|
||||
const col = {};
|
||||
Object.entries(COLS).forEach(([k, names]) => { col[k] = head.findIndex(h => names.includes(h)); });
|
||||
if (col.code < 0 || col.value < 0) throw new Error('kolommen "code" en "waarde" (of "prijs") ontbreken');
|
||||
const values = {}, unknown = [], errors = [];
|
||||
for (let i = hi + 1; i < lines.length; i++) {
|
||||
if (!lines[i].trim() || lines[i].trim().startsWith('#')) continue;
|
||||
const f = splitLine(lines[i], sep), get = (k) => col[k] >= 0 ? (f[col[k]] || '') : '';
|
||||
const code = get('code'), raw = get('value'), line = i + 1;
|
||||
if (!code) continue;
|
||||
const it = itemFor(code);
|
||||
if (!it) { unknown.push({ code, line, label: get('label') }); continue; }
|
||||
if (it.price !== pricesOnly) { errors.push(`regel ${line}: ${code} hoort in ${it.price ? 'leveranciers.csv' : 'intern.csv'}`); continue; }
|
||||
let value;
|
||||
if (it.options) {
|
||||
value = optionKey(it, raw);
|
||||
if (value === undefined) { errors.push(`regel ${line}: ${code}: "${raw}" is geen keuze (${Object.keys(it.options).join(', ')})`); continue; }
|
||||
} else {
|
||||
value = parseNumber(raw);
|
||||
if (!(isFinite(value) && value >= 0)) { errors.push(`regel ${line}: ${code}: geen geldig getal "${raw}"`); continue; }
|
||||
if (get('unit') && normUnit(get('unit')) !== normUnit(it.unit)) { errors.push(`regel ${line}: ${code}: eenheid "${get('unit')}", verwacht ${it.unit}`); continue; }
|
||||
}
|
||||
const prev = values[code];
|
||||
if (prev && (!pricesOnly || prev.value <= value)) {
|
||||
if (!pricesOnly) errors.push(`regel ${line}: ${code} staat ook op regel ${prev.line}; die telt`);
|
||||
continue;
|
||||
}
|
||||
values[code] = { value, unit: it.unit || '', label: get('label'), supplier: get('supplier'), article: get('article'), updated: get('updated'), line };
|
||||
}
|
||||
return { values, unknown, errors };
|
||||
}
|
||||
|
||||
const notify = () => listeners.forEach(fn => fn());
|
||||
function apply(list, text, source, name, loadedAt) {
|
||||
try {
|
||||
state[list] = Object.assign({ source, name, loadedAt }, parse(text, list));
|
||||
} catch (e) {
|
||||
state[list] = Object.assign(empty(), { source, name, loadedAt, errors: [e.message] });
|
||||
}
|
||||
}
|
||||
function loadList(list) {
|
||||
const url = LISTS[list].url;
|
||||
return fetch(tectumUrl(url), { cache: 'no-store' })
|
||||
.then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
|
||||
.catch(() => null);
|
||||
}
|
||||
function load() {
|
||||
return Promise.all(Object.keys(LISTS).map(loadList)).then(notify);
|
||||
}
|
||||
function loadText(list, text, name) {
|
||||
parse(text, list);
|
||||
apply(list, text, 'tijdelijk', name, new Date().toISOString());
|
||||
notify();
|
||||
}
|
||||
function reload(list) {
|
||||
state[list] = empty();
|
||||
return loadList(list).then(notify);
|
||||
}
|
||||
|
||||
const entry = (list, code) => state[list].values[code] || null;
|
||||
const labelOf = (code, fallback) => (entry('intern', code) || entry('leveranciers', code) || {}).label || fallback;
|
||||
const balkKey = (w, h) => `${Math.round(Math.min(w, h))}x${Math.round(Math.max(w, h))}`;
|
||||
function overlay(list, values) {
|
||||
Object.entries(state[list].values).forEach(([code, e]) => {
|
||||
const [g, k] = code.split('.');
|
||||
if (values[g] && k in values[g]) values[g][k] = e.value;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
function balken(list) {
|
||||
const out = {};
|
||||
Object.entries(state[list].values).forEach(([code, e]) => {
|
||||
const m = code.match(BALK_RE);
|
||||
if (m) out[balkKey(+m[1], +m[2])] = e.value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function status(list) {
|
||||
const s = state[list];
|
||||
return { url: LISTS[list].url, label: LISTS[list].label, source: s.source, name: s.name, loadedAt: s.loadedAt, count: Object.keys(s.values).length, unknown: s.unknown.slice(), errors: s.errors.slice() };
|
||||
}
|
||||
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
|
||||
|
||||
function template(list, values, balkMaten) {
|
||||
const pricesOnly = LISTS[list].pricesOnly;
|
||||
const num = (v) => typeof v === 'number' ? String(Math.round(v * 10000) / 10000).replace('.', ',') : v;
|
||||
const rows = [pricesOnly ? ['code', 'omschrijving', 'eenheid', 'prijs', 'leverancier', 'artikelnummer', 'bijgewerkt'] : ['code', 'omschrijving', 'eenheid', 'waarde']];
|
||||
const row = (code, label, unit, v) => {
|
||||
const e = entry(list, code) || {};
|
||||
rows.push(pricesOnly ? [code, label, unit, num(v), e.supplier || '', e.article || '', e.updated || ''] : [code, label, unit, num(v)]);
|
||||
};
|
||||
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => Object.entries(grp.items).forEach(([k, it]) => {
|
||||
if (pricesOnly === isPrice(it)) row(`${g}.${k}`, labelOf(`${g}.${k}`, it.label), it.unit || '', values[g][k]);
|
||||
}));
|
||||
if (!pricesOnly) return finish(rows);
|
||||
const have = balken(list), maten = (balkMaten || []).concat(Object.keys(have).map(k => k.split('x').map(Number)));
|
||||
const seen = new Set();
|
||||
maten.forEach(([w, h]) => {
|
||||
const key = balkKey(w, h);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const perM = have[key] !== undefined ? have[key] : w * h / 1e6 * values.hout.constructieM3;
|
||||
row(`hout.balk.${key}`, labelOf(`hout.balk.${key}`, `Constructiehout vuren C24 ${key.replace('x', '×')}`), '€/m', perM);
|
||||
});
|
||||
return finish(rows);
|
||||
}
|
||||
function changes(values, base) {
|
||||
const num = (v) => typeof v === 'number' ? String(Math.round(v * 10000) / 10000).replace('.', ',') : v;
|
||||
const rows = [['code', 'omschrijving', 'eenheid', 'waarde', 'standaard']];
|
||||
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => Object.entries(grp.items).forEach(([k, it]) => {
|
||||
if (isPrice(it) || values[g][k] === base[g][k]) return;
|
||||
rows.push([`${g}.${k}`, labelOf(`${g}.${k}`, it.label), it.unit || '', num(values[g][k]), num(base[g][k])]);
|
||||
}));
|
||||
return rows.length > 1 ? finish(rows) : null;
|
||||
}
|
||||
// BOM: Excel/LibreOffice herkennen dan UTF-8 (€, ², ×).
|
||||
function finish(rows) {
|
||||
const q = (s) => /[;"\n]/.test(s) ? `"${String(s).replace(/"/g, '""')}"` : s;
|
||||
return '\uFEFF' + rows.map(r => r.map(q).join(';')).join('\n') + '\n';
|
||||
}
|
||||
|
||||
return { LISTS, isPrice, load, loadText, reload, entry, labelOf, overlay, balken, balkKey, status, onChange, template, changes, parse };
|
||||
})();
|
||||
|
||||
CostCsv.load();
|
||||
@@ -0,0 +1,270 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Alleen noodwaarden: de echte waarden staan in kosten/intern.csv en kosten/leveranciers.csv.
|
||||
|
||||
window.COST_DEFAULTS = {
|
||||
arbeid: {
|
||||
label: 'Arbeid',
|
||||
items: {
|
||||
uurloon: { label: 'Uurloon productie (all-in)', unit: '€/u', value: 55 },
|
||||
bezetting: { label: 'Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel', unit: 'man', value: 2.2 },
|
||||
toeslagUren: { label: 'Toeslag indirecte uren (klaarleggen, opruimen, intern transport)', unit: '%', value: 10 },
|
||||
qa: { label: 'QA (laatste aanpassingen, klantcontact) over de totale werktijd', unit: '%', value: 4 }
|
||||
}
|
||||
},
|
||||
|
||||
zagen: {
|
||||
label: 'Zagen',
|
||||
items: {
|
||||
methode: { label: 'Zaagmethode', options: { robot: 'Zaagrobot', hand: 'Handmatig' }, value: 'robot' },
|
||||
robotSnede: { label: 'Robot: tijd per haakse snede', unit: 's', value: 5 },
|
||||
robotSchuin: { label: 'Robot: extra tijd per schuine snede', unit: 's', value: 2.5 },
|
||||
robotStuk: { label: 'Robot: handling per stuk (invoer, uitvoer, etiket)', unit: 's', value: 15 },
|
||||
handFactor: { label: 'Handmatig: factor op de robottijd', unit: '×', value: 7 }
|
||||
}
|
||||
},
|
||||
|
||||
verbinden: {
|
||||
label: 'Verbindingen (regelwerk)',
|
||||
items: {
|
||||
schroevenPerVerbinding: { label: 'Schroeven per verbinding (stijl/dorpel op regel)', unit: 'st.', value: 3 },
|
||||
eersteSchroef: { label: 'Tijd eerste schroef (gat + schroef + uitlijnen)', unit: 's', value: 10 },
|
||||
volgendeSchroef: { label: 'Tijd volgende schroef (gat + schroef)', unit: 's', value: 5 },
|
||||
prijsPer1000: { label: 'Inkoop constructieschroeven (6×120)', unit: '€/1000', value: 45 },
|
||||
verbindingenPerVloerbalk: { label: 'Verbindingen per vloer-/dakbalk', unit: 'st.', value: 2 },
|
||||
dubbelAfstand: { label: 'Dubbele stijl (deur, hoek): schroef verticaal elke', unit: 'mm', value: 100 },
|
||||
monteerFactor: { label: 'Monteren: factor op de schroeftijd (incl. lijmen, positioneren)', unit: '×', value: 6 },
|
||||
personeel: { label: 'Personeel bij monteren constructie (vloer, wand, dak; i.p.v. de bezetting)', unit: 'man', value: 2 },
|
||||
dakFactor: { label: 'Dak: factor op de tijd per schroef (moeilijker werken)', unit: '×', value: 4 }
|
||||
}
|
||||
},
|
||||
|
||||
montage: {
|
||||
label: 'Montage',
|
||||
items: {
|
||||
muurVastzetten: { label: 'Muursectie plaatsen en vastzetten', unit: 'min', value: 10 },
|
||||
muurPersoneel: { label: 'Personeel bij plaatsen muursectie (i.p.v. de bezetting)', unit: 'man', value: 3 }
|
||||
}
|
||||
},
|
||||
|
||||
hout: {
|
||||
label: 'Hout',
|
||||
items: {
|
||||
constructieM3: { label: 'Constructiehout vuren C24 (regelwerk, balken)', unit: '€/m³', value: 550 },
|
||||
latwerkM: { label: 'Latwerk (kruislat)', unit: '€/m', value: 0.9 },
|
||||
latStukMontage: { label: 'Latwerk: per gezaagd stuk (meten, zagen op locatie, passen)', unit: 's', value: 240 },
|
||||
latMeterMontage: { label: 'Latwerk: bevestigen per strekkende meter', unit: 's/m', value: 30 }
|
||||
}
|
||||
},
|
||||
|
||||
plaat: {
|
||||
label: 'Plaatwerk',
|
||||
items: {
|
||||
spaanplaatM2: { label: 'Spaanplaat', unit: '€/m²', value: 9 },
|
||||
spaanplaatMontage: { label: 'Montage spaanplaat (passen, zagen, plaatsen)', unit: 's/m²', value: 120 },
|
||||
osbM2: { label: 'OSB (vloeren, plafonds dak)', unit: '€/m²', value: 12 },
|
||||
osbMontage: { label: 'Montage OSB', unit: 's/m²', value: 90 },
|
||||
cementplaatM2: { label: 'Cementgebonden plaat (gevel, trasraam)', unit: '€/m²', value: 28 },
|
||||
cementplaatMontage: { label: 'Montage cementplaat', unit: 's/m²', value: 240 },
|
||||
schroevenPerM2: { label: 'Plaatschroeven per m²', unit: 'st./m²', value: 15 },
|
||||
tijdPerPlaatschroef: { label: 'Tijd per plaatschroef', unit: 's', value: 4 },
|
||||
plaatschroefPer1000: { label: 'Inkoop plaatschroeven (4×40)', unit: '€/1000', value: 15 }
|
||||
}
|
||||
},
|
||||
|
||||
isolatie: {
|
||||
label: 'Isolatie',
|
||||
items: {
|
||||
glaswolM3: { label: 'Glaswol', unit: '€/m³', value: 40 },
|
||||
glaswolMontage: { label: 'Montage glaswol', unit: 's/m³', value: 900 },
|
||||
steenIsolatieM3: { label: 'Isolatieblok steengevel', unit: '€/m³', value: 120 }
|
||||
}
|
||||
},
|
||||
|
||||
gevel: {
|
||||
label: 'Gevel',
|
||||
items: {
|
||||
vochtdoekM2: { label: 'Vochtdoek', unit: '€/m²', value: 1.5 },
|
||||
vochtdoekMontageM: { label: 'Montage vochtdoek per strekkende meter wand', unit: 's/m', value: 600 },
|
||||
rabatM2: { label: 'Rabat (gevelbekleding)', unit: '€/m²', value: 35 },
|
||||
rabatMontage: { label: 'Montage rabat', unit: 's/m²', value: 600 },
|
||||
steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 },
|
||||
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 },
|
||||
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
|
||||
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 },
|
||||
felsM2: { label: 'Felsplaten gevel, staande naad (staal, klangen, ondergrond)', unit: '€/m²', value: 70 },
|
||||
felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 },
|
||||
afdekkerM: { label: 'Afdekker trasraam (aluminium zetwerk, gepoedercoat)', unit: '€/m', value: 14 },
|
||||
afdekkerMontage: { label: 'Montage afdekker trasraam', unit: 's/m', value: 300 }
|
||||
}
|
||||
},
|
||||
|
||||
dak: {
|
||||
label: 'Dak & dakrand',
|
||||
items: {
|
||||
dakleerM2: { label: 'Dakleer (EPDM)', unit: '€/m²', value: 18 },
|
||||
dakleerMontage: { label: 'Montage dakleer', unit: 's/m²', value: 600 },
|
||||
rietM2: { label: 'Riet', unit: '€/m²', value: 90 },
|
||||
rietMontage: { label: 'Montage riet', unit: 's/m²', value: 3600 },
|
||||
beschotM2: { label: 'Dakbeschot OSB 18 mm (bovenop de sporen)', unit: '€/m²', value: 14 },
|
||||
beschotMontage: { label: 'Montage dakbeschot', unit: 's/m²', value: 150 },
|
||||
felsM2: { label: 'Felsplaten staande naad (zink/staal, klangen, scheidingslaag)', unit: '€/m²', value: 65 },
|
||||
felsMontage: { label: 'Montage felsplaten', unit: 's/m²', value: 1800 },
|
||||
boeideelM: { label: 'Boeideel', unit: '€/m', value: 25 },
|
||||
boeideelMontage: { label: 'Montage boeideel', unit: 's/m', value: 600 },
|
||||
onderplaatM2: { label: 'Onderplaat boeideel', unit: '€/m²', value: 30 },
|
||||
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
|
||||
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
|
||||
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 },
|
||||
ventRoosterM: { label: 'Ventilatierooster onder boeideel (40 mm, aluminium)', unit: '€/m', value: 6 },
|
||||
ventRoosterMontage: { label: 'Montage ventilatierooster', unit: 's/m', value: 120 },
|
||||
staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 },
|
||||
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
|
||||
nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 },
|
||||
nokVulhoutMontage: { label: 'Vulhout in de holling van de nokbalk aanbrengen (beide zijden)', unit: 's/m', value: 240 },
|
||||
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 },
|
||||
paalHoutM3: { label: 'Paalhout overkapping (eiken, geschaafd)', unit: '€/m³', value: 2400 },
|
||||
paalvoet: { label: 'Paalvoet verzinkt, verstelbaar (incl. ankers)', unit: '€/st.', value: 40 },
|
||||
paalMontage: { label: 'Paal plaatsen: stellen, aftoppen, vastzetten aan het dak', unit: 's/st.', value: 2700 },
|
||||
zijwandM2: { label: 'Zijwand overkapping (regelwerk, cementgebonden plaat en stuc aan beide kanten, fundering)', unit: '€/m²', value: 140 },
|
||||
zijwandMontage: { label: 'Zijwand overkapping maken en plaatsen', unit: 's/m²', value: 5400 }
|
||||
}
|
||||
},
|
||||
|
||||
vloer: {
|
||||
label: 'Vloer & fundering',
|
||||
items: {
|
||||
afwerkingM2: { label: 'Vloerafwerking', unit: '€/m²', value: 35 },
|
||||
afwerkingMontage: { label: 'Montage vloerafwerking', unit: 's/m²', value: 1200 },
|
||||
sandwichM2: { label: 'Sandwichpaneel', unit: '€/m²', value: 35 },
|
||||
sandwichMontage: { label: 'Montage per sandwichpaneel', unit: 's', value: 600 }
|
||||
}
|
||||
},
|
||||
|
||||
afwerking: {
|
||||
label: 'Afwerking ruimtes',
|
||||
items: {
|
||||
valsPlafondM2: { label: 'Vals plafond (regelwerk + gipsplaat, afgewerkt)', unit: '€/m²', value: 38 },
|
||||
valsPlafondMontage: { label: 'Montage vals plafond', unit: 's/m²', value: 1800 },
|
||||
fiboM2: { label: 'FIBO-wandpaneel (incl. profielen en kit)', unit: '€/m²', value: 70 },
|
||||
fiboMontage: { label: 'Montage FIBO-wandpaneel', unit: 's/m²', value: 1500 }
|
||||
}
|
||||
},
|
||||
|
||||
kozijnen: {
|
||||
label: 'Kozijnen',
|
||||
items: {
|
||||
raamM2: { label: 'Raamkozijn incl. glas', unit: '€/m²', value: 450 },
|
||||
deur: { label: 'Binnen-/buitendeur incl. kozijn', unit: '€/st.', value: 900 },
|
||||
voordeur: { label: 'Voordeur incl. kozijn', unit: '€/st.', value: 1800 },
|
||||
schuifdeurBlad: { label: 'Schuifdeur per blad (blad, rail of pocket, beslag, aftimmering)', unit: '€/st.', value: 650 },
|
||||
montage: { label: 'Montage per kozijn', unit: 's', value: 5400 }
|
||||
}
|
||||
},
|
||||
|
||||
inrichting: {
|
||||
label: 'Inrichting',
|
||||
items: {
|
||||
personeel: { label: 'Personeel bij inrichting (installateur/monteur, i.p.v. de bezetting)', unit: 'man', value: 1 },
|
||||
toilet: { label: 'Wandcloset met inbouwreservoir, voorzetwand en bedieningsplaat', unit: '€/st.', value: 650 },
|
||||
toiletMontage: { label: 'Montage wandcloset (reservoir, voorzetwand, afmonteren)', unit: 's', value: 10800 },
|
||||
fontein: { label: 'Fontein met kraan en sifon', unit: '€/st.', value: 220 },
|
||||
fonteinMontage: { label: 'Montage fontein', unit: 's', value: 3600 },
|
||||
wastafel: { label: 'Badmeubel met wastafel, kraan en spiegel', unit: '€/st.', value: 850 },
|
||||
wastafelMontage: { label: 'Montage badmeubel', unit: 's', value: 5400 },
|
||||
douche: { label: 'Inloopdouche (douchegoot, glaswand, regendouche-set)', unit: '€/st.', value: 1450 },
|
||||
doucheMontage: { label: 'Montage inloopdouche (goot, afschot, glaswand, kraan)', unit: 's', value: 14400 },
|
||||
keukenM: { label: 'Keukenblok (kasten, werkblad, spoelbak, apparatuur) per strekkende meter', unit: '€/m', value: 1500 },
|
||||
keukenMontage: { label: 'Montage keukenblok', unit: 's/m', value: 3600 },
|
||||
kookeilandM: { label: 'Kookeiland (kasten, werkblad) per strekkende meter', unit: '€/m', value: 1300 },
|
||||
kookeilandMontage: { label: 'Montage kookeiland', unit: 's/m', value: 3600 },
|
||||
kookplaat: { label: 'Inductiekookplaat 4 zones', unit: '€/st.', value: 650 },
|
||||
kastM: { label: 'Kledingkast / inbouwkast per strekkende meter', unit: '€/m', value: 550 },
|
||||
kastMontage: { label: 'Montage kast', unit: 's/m', value: 1800 },
|
||||
boiler: { label: 'Elektrische boiler 80 l incl. inlaatcombinatie', unit: '€/st.', value: 600 },
|
||||
boilerMontage: { label: 'Montage boiler', unit: 's', value: 7200 }
|
||||
}
|
||||
},
|
||||
|
||||
verlichting: {
|
||||
label: 'Verlichting',
|
||||
items: {
|
||||
gevellamp: { label: 'Gevellamp', unit: '€/st.', value: 45 },
|
||||
boeideelSpot: { label: 'Boeideel-spot', unit: '€/st.', value: 25 },
|
||||
binnenspot: { label: 'Binnenspot', unit: '€/st.', value: 20 },
|
||||
montage: { label: 'Montage per lamp/spot (armatuur plaatsen en aansluiten; leidingen en dozen onder Elektra)', unit: 's', value: 300 }
|
||||
}
|
||||
},
|
||||
|
||||
elektra: {
|
||||
label: 'Elektra',
|
||||
items: {
|
||||
personeel: { label: 'Personeel bij elektra (i.p.v. de bezetting)', unit: 'man', value: 1 },
|
||||
boorgat: { label: 'Boorgat door stijl, regel of balk (aftekenen, boren, ontbramen)', unit: 's', value: 90 },
|
||||
buisLeggen: { label: 'Installatiebuis leggen en bevestigen', unit: 's/m', value: 60 },
|
||||
draadTrekken: { label: 'Draad trekken (per meter buis, alle aders)', unit: 's/m', value: 20 },
|
||||
kabelLeggen: { label: 'XMvK-kabel leggen en bevestigen (buiten)', unit: 's/m', value: 40 },
|
||||
doosPlaatsen: { label: 'Inbouwdoos plaatsen (luchtdicht, hollewand)', unit: 's', value: 240 },
|
||||
verdeeldoos: { label: 'Verdeeldoos plaatsen en verbinden', unit: 's', value: 600 },
|
||||
spotdoos: { label: 'Spotdoos plaatsen (luchtdicht plafond)', unit: 's', value: 300 },
|
||||
vloerdoos: { label: 'Vloerdoos plaatsen (gat in vloer, aansluiten)', unit: 's', value: 1800 },
|
||||
afmonteren: { label: 'Afmonteren schakelaar/wcd/apparaataansluiting', unit: 's', value: 300 },
|
||||
steekverbinderMontage: { label: 'Steekverbinder module-naad monteren (set m/v)', unit: 's', value: 300 },
|
||||
kastMontage: { label: 'Groepenkast plaatsen (kast, hoofdschakelaar, aardlek)', unit: 's', value: 3600 },
|
||||
groepMontage: { label: 'Per groep: automaat plaatsen, bedraden, labelen', unit: 's', value: 900 },
|
||||
keuring: { label: 'Meten, testen en opleveren (NEN 1010 / NEN 3140)', unit: 's', value: 10800 },
|
||||
buis16M: { label: 'Installatiebuis flexibel Ø16', unit: '€/m', value: 0.35 },
|
||||
buis19M: { label: 'Installatiebuis flexibel Ø19', unit: '€/m', value: 0.45 },
|
||||
buis25M: { label: 'Installatiebuis flexibel Ø25', unit: '€/m', value: 0.8 },
|
||||
draad15M: { label: 'VD-draad 1,5 mm² (per ader)', unit: '€/m', value: 0.28 },
|
||||
draad25M: { label: 'VD-draad 2,5 mm² (per ader)', unit: '€/m', value: 0.45 },
|
||||
xmvkM: { label: 'XMvK 3×1,5 mm²', unit: '€/m', value: 1.25 },
|
||||
inbouwdoos: { label: 'Luchtdichte hollewanddoos', unit: '€/st.', value: 1.6 },
|
||||
verdeeldoosSt: { label: 'Luchtdichte verdeeldoos', unit: '€/st.', value: 4.5 },
|
||||
spotdoosSt: { label: 'Luchtdichte spotdoos', unit: '€/st.', value: 7.5 },
|
||||
vloerdoosSt: { label: 'Vloerdoos met dubbele wcd', unit: '€/st.', value: 95 },
|
||||
schakelaar: { label: 'Schakelaar enkelpolig (inbouw)', unit: '€/st.', value: 9 },
|
||||
afdekraam1: { label: 'Afdekraam 1-voudig', unit: '€/st.', value: 3 },
|
||||
afdekraam2: { label: 'Afdekraam 2-voudig', unit: '€/st.', value: 5 },
|
||||
wcdDubbel: { label: 'Wandcontactdoos dubbel met randaarde', unit: '€/st.', value: 10 },
|
||||
wcdEnkel: { label: 'Wandcontactdoos enkel met randaarde', unit: '€/st.', value: 6 },
|
||||
perilex: { label: 'Perilex-wandcontactdoos (kookgroep)', unit: '€/st.', value: 18 },
|
||||
werkschakelaar: { label: 'Werkschakelaar (warmtepomp)', unit: '€/st.', value: 28 },
|
||||
lasklem: { label: 'Lasklem 3-voudig', unit: '€/st.', value: 0.35 },
|
||||
steekverbinder3: { label: 'Steekverbinder 3-polig (set m/v)', unit: '€/set', value: 9 },
|
||||
steekverbinder5: { label: 'Steekverbinder 5-polig (set m/v)', unit: '€/set', value: 14 },
|
||||
doorvoer: { label: 'Luchtdichte kabeldoorvoer naar buiten', unit: '€/st.', value: 3.5 },
|
||||
kast: { label: 'Groepenkast 3-fase leeg (behuizing, kammen, aansluitmateriaal)', unit: '€/st.', value: 180 },
|
||||
hoofdschakelaar: { label: 'Hoofdschakelaar 4-polig 40 A', unit: '€/st.', value: 55 },
|
||||
aardlek: { label: 'Aardlekschakelaar 2-polig 40 A / 30 mA', unit: '€/st.', value: 40 },
|
||||
automaat: { label: 'Installatieautomaat 1P+N (B10/B16)', unit: '€/st.', value: 14 },
|
||||
automaat2f: { label: 'Installatieautomaat 2-fase (kookgroep)', unit: '€/st.', value: 30 }
|
||||
}
|
||||
},
|
||||
|
||||
bedrijf: {
|
||||
label: 'Afschrijving & bedrijfskosten',
|
||||
items: {
|
||||
machines: { label: 'Afschrijving machines (zaagrobot, gereedschap, inrichting) per uur', unit: '€/u', value: 8 },
|
||||
slijtage: { label: 'Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per uur', unit: '€/u', value: 2 },
|
||||
huisvesting: { label: 'Huisvesting productiehal (huur, energie) per uur', unit: '€/u', value: 6 },
|
||||
transportUnit: { label: 'Transport per unit naar de bouwplaats', unit: '€/unit', value: 750 },
|
||||
kraan: { label: 'Kraan voor plaatsen woning', unit: '€', value: 1200 }
|
||||
}
|
||||
},
|
||||
|
||||
opslag: {
|
||||
label: 'Opslagen',
|
||||
items: {
|
||||
verlies: { label: 'Materiaalverlies (beschadiging, misgezaagd, restmateriaal; naast het berekende snijverlies balken)', unit: '%', value: 5 },
|
||||
klein: { label: 'Klein materiaal (lijm, kit, tape, nieten)', unit: '%', value: 3 },
|
||||
algemeen: { label: 'Algemene kosten (kantoor, verzekering, garantie) over de kostprijs', unit: '%', value: 5 },
|
||||
marge: { label: 'Winstmarge', unit: '%', value: 15 },
|
||||
btw: { label: 'Btw', unit: '%', value: 21 }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,427 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
window.CostModel = (() => {
|
||||
const listeners = [];
|
||||
let session = {};
|
||||
try { localStorage.removeItem('tectum.costs'); localStorage.removeItem('tectum.leveranciers'); } catch (e) { /* oude browseropslag */ }
|
||||
|
||||
function defaultValues() {
|
||||
const v = {};
|
||||
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => {
|
||||
v[g] = {};
|
||||
Object.entries(grp.items).forEach(([k, it]) => { v[g][k] = it.value; });
|
||||
});
|
||||
return CostCsv.overlay('intern', v);
|
||||
}
|
||||
function mergeValues(base, over) {
|
||||
const v = JSON.parse(JSON.stringify(base));
|
||||
Object.entries(over || {}).forEach(([g, items]) => {
|
||||
const grp = window.COST_DEFAULTS[g];
|
||||
if (!grp || !items || typeof items !== 'object') return;
|
||||
Object.entries(items).forEach(([k, val]) => {
|
||||
const it = grp.items[k];
|
||||
if (!it) return;
|
||||
if (it.options ? Object.prototype.hasOwnProperty.call(it.options, val) : typeof val === 'number' && isFinite(val)) v[g][k] = val;
|
||||
});
|
||||
});
|
||||
return v;
|
||||
}
|
||||
const listed = (g, k) => !!CostCsv.entry('leveranciers', `${g}.${k}`);
|
||||
function getSettings(opts) {
|
||||
const own = mergeValues(defaultValues(), session);
|
||||
const v = opts && opts.leveranciers === false ? own : CostCsv.overlay('leveranciers', own);
|
||||
v.balken = CostCsv.balken('leveranciers');
|
||||
return v;
|
||||
}
|
||||
function saveSettings(values) {
|
||||
const def = defaultValues(), diff = {};
|
||||
Object.entries(mergeValues(def, values)).forEach(([g, items]) => Object.entries(items).forEach(([k, val]) => {
|
||||
if (val !== def[g][k] && !listed(g, k)) (diff[g] = diff[g] || {})[k] = val;
|
||||
}));
|
||||
session = diff;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
|
||||
CostCsv.onChange(() => listeners.forEach(fn => fn()));
|
||||
|
||||
function importFile(text) {
|
||||
if (/^\s*\{/.test(text)) {
|
||||
const obj = JSON.parse(text);
|
||||
if (!obj || obj.type !== 'tectum-costs' || typeof obj.values !== 'object') throw new Error('geen Tectum-kostenbestand');
|
||||
return mergeValues(defaultValues(), obj.values);
|
||||
}
|
||||
const r = CostCsv.parse(text, 'intern'), over = {};
|
||||
Object.entries(r.values).forEach(([code, e]) => {
|
||||
const [g, k] = code.split('.');
|
||||
if (window.COST_DEFAULTS[g]) (over[g] = over[g] || {})[k] = e.value;
|
||||
});
|
||||
return mergeValues(defaultValues(), over);
|
||||
}
|
||||
|
||||
const CAT_ORDER = ['Hout', 'Staal', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Afwerking', 'Inrichting', 'Verlichting', 'Elektra'];
|
||||
const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Afwerking', 'Inrichting', 'Verlichting', 'Elektra'];
|
||||
const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl'];
|
||||
const DORPEL_KINDS = ['bovendorpel', 'onderdorpel'];
|
||||
|
||||
function compute(parts, totals, settings) {
|
||||
const S = settings || getSettings();
|
||||
const elements = [];
|
||||
const newEl = (element, label, unit) => { const el = { element, label, unit, lines: [] }; elements.push(el); return el; };
|
||||
// sec = tijd voor één persoon; opgeslagen sec = manuren (× crew).
|
||||
const add = (el, cat, act, label, qty, unit, mat, sec, crew) => {
|
||||
if (!(qty > 0)) return;
|
||||
const c = sec ? (crew !== undefined ? crew : S.arbeid.bezetting) : 0;
|
||||
// clock = doorlooptijd van de stap (klokuren), voor afschrijving/huisvesting.
|
||||
el.lines.push({ cat, act, label, qty, unit, mat: mat || 0, sec: (sec || 0) * c, clock: c ? sec : 0, crew: c });
|
||||
};
|
||||
|
||||
const Z = S.zagen, V = S.verbinden, P = S.plaat;
|
||||
const sawFactor = Z.methode === 'hand' ? Z.handFactor : 1;
|
||||
const saw = { pieces: 0, cuts: 0, angled: 0, robotSec: 0 };
|
||||
const sawSec = (cutAngles, count = 1) => {
|
||||
const ca = cutAngles || [0, 0], angled = ca.filter(a => a !== 0).length;
|
||||
const robot = Z.robotStuk + ca.length * Z.robotSnede + angled * Z.robotSchuin;
|
||||
saw.pieces += count; saw.cuts += count * ca.length; saw.angled += count * angled; saw.robotSec += count * robot;
|
||||
return count * robot * sawFactor;
|
||||
};
|
||||
const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)';
|
||||
const jointSec = V.eersteSchroef + Math.max(0, V.schroevenPerVerbinding - 1) * V.volgendeSchroef;
|
||||
const addJoints = (el, joints, label, roof) => {
|
||||
const n = joints * V.schroevenPerVerbinding;
|
||||
const f = V.monteerFactor * (roof ? V.dakFactor : 1);
|
||||
add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel);
|
||||
};
|
||||
const addSheet = (el, cat, label, m2, priceM2, montageSecM2) => {
|
||||
if (!(m2 > 0)) return;
|
||||
add(el, cat, 'Plaatwerk', label, m2, 'm²', m2 * priceM2, m2 * montageSecM2);
|
||||
const n = Math.ceil(m2 * P.schroevenPerM2);
|
||||
add(el, 'Schroeven', 'Plaatwerk', `Plaatschroeven ${label.toLowerCase()}`, n, 'st.', n * P.plaatschroefPer1000 / 1000, n * P.tijdPerPlaatschroef);
|
||||
};
|
||||
const waste = totals.purchaseStockMM > 0 ? totals.purchaseWasteMM / Math.max(1, totals.purchaseStockMM - totals.purchaseWasteMM) : 0;
|
||||
|
||||
const balken = S.balken || {};
|
||||
const woodCost = (w, h, lengthM) => {
|
||||
const perM = balken[CostCsv.balkKey(w, h)];
|
||||
return perM !== undefined ? lengthM * perM : w * h / 1e6 * lengthM * S.hout.constructieM3;
|
||||
};
|
||||
const joistWoodCost = (m3, sec) => sec && sec.joistW && sec.joistH ? woodCost(sec.joistW, sec.joistH, m3 / (sec.joistW * sec.joistH / 1e6)) : m3 * S.hout.constructieM3;
|
||||
|
||||
const fmtM = (v) => v.toLocaleString('nl-NL', { maximumFractionDigits: 1 });
|
||||
const seenOpenings = new Set(); // naadwand: openingen één keer (beide delen hebben dezelfde deur)
|
||||
function wallElement(p, unitId) {
|
||||
const el = newEl(p.element, p.label, unitId);
|
||||
const bom = WallModel.computeBOM(p.config, p.frame);
|
||||
const woodM3 = bom.reduce((a, b) => a + b.width * b.depth * b.length, 0) / 1e9;
|
||||
const woodMat = bom.reduce((a, b) => a + woodCost(b.width, b.depth, b.length / 1000), 0);
|
||||
add(el, 'Hout', 'Zagen', `Regelwerk ${bom.length} st.`, woodM3 * (1 + waste), 'm³', woodMat * (1 + waste), 0);
|
||||
const cuts = bom.reduce((a, b) => a + (b.cutAngles || [0, 0]).length, 0);
|
||||
add(el, 'Hout', 'Zagen', `${sawLabel}: ${bom.length} stuks`, cuts, 'sneden', 0, bom.reduce((a, b) => a + sawSec(b.cutAngles), 0));
|
||||
const verticals = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).length;
|
||||
const dorpels = bom.filter(b => DORPEL_KINDS.includes(b.kind)).length;
|
||||
const topPlates = bom.filter(b => b.kind === 'bovenregel').length;
|
||||
addJoints(el, 2 * verticals + 2 * dorpels + Math.max(0, topPlates - 1), 'Stijlen/dorpels');
|
||||
const studs = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).sort((a, b) => a.x - b.x);
|
||||
let dblScrews = 0, dblPairs = 0;
|
||||
for (let i = 1; i < studs.length; i++) {
|
||||
const a = studs[i - 1], b = studs[i];
|
||||
if (b.x - a.x > (a.width + b.width) / 2 + 1) continue;
|
||||
const ov = Math.min(a.y + a.length / 2, b.y + b.length / 2) - Math.max(a.y - a.length / 2, b.y - b.length / 2);
|
||||
if (ov <= 0) continue;
|
||||
dblPairs++;
|
||||
dblScrews += Math.max(2, Math.floor(ov / V.dubbelAfstand));
|
||||
}
|
||||
add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}×`, dblScrews, 'st.',
|
||||
dblScrews * V.prijsPer1000 / 1000, dblScrews * V.volgendeSchroef * V.monteerFactor, V.personeel);
|
||||
const M = S.montage;
|
||||
add(el, 'Hout', 'Wanden plaatsen', 'Plaatsen en vastzetten', 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel);
|
||||
|
||||
const st = p.stats || {};
|
||||
addSheet(el, 'Plaatwerk', 'Spaanplaat', (st.spaanplaatAreaM2 || 0) + (st.spaanplaatBuitenAreaM2 || 0), P.spaanplaatM2, P.spaanplaatMontage);
|
||||
addSheet(el, 'Plaatwerk', 'Cementplaat', (st.cementplaatAreaM2 || 0) + (st.trasraamAreaM2 || 0), P.cementplaatM2, P.cementplaatMontage);
|
||||
const I = S.isolatie;
|
||||
const wool = (p.specs && p.specs.voidVolumeM3) || 0;
|
||||
add(el, 'Isolatie', 'Isoleren', 'Glaswol', wool, 'm³', wool * I.glaswolM3, wool * I.glaswolMontage);
|
||||
const stoneIso = (st.stoneIsolatieM3 || 0) + (st.trasraamIsolatieM3 || 0);
|
||||
add(el, 'Isolatie', 'Gevel', 'Isolatieblok steen', stoneIso, 'm³', stoneIso * I.steenIsolatieM3, 0);
|
||||
|
||||
const G = S.gevel;
|
||||
const doek = st.doekAreaM2 || 0;
|
||||
const doekM = doek > 0 ? p.place.width / 1000 : 0;
|
||||
add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM);
|
||||
const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
|
||||
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
|
||||
const fels = p.buitenMateriaal === 'vochtdoek' ? (st.felsAreaM2 || 0) : 0;
|
||||
add(el, 'Gevel', 'Gevel', 'Felsplaten (staande naad)', fels, 'm²', fels * (G.felsM2 || 0), fels * (G.felsMontage || 0));
|
||||
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
|
||||
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
|
||||
const cap = (st.trasraamCapM || 0);
|
||||
add(el, 'Gevel', 'Gevel', 'Afdekker trasraam', cap, 'm', cap * (G.afdekkerM || 0), cap * (G.afdekkerMontage || 0));
|
||||
const stuc = st.stucAreaM2 || 0;
|
||||
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0));
|
||||
const latM = st.battenLengthM || 0;
|
||||
add(el, 'Hout', 'Latwerk', `Latwerk (${st.battenCount || 0} latten inkoop, ${st.battenPieces || 0} stukken)`, latM, 'm', latM * S.hout.latwerkM,
|
||||
(st.battenPieces || 0) * S.hout.latStukMontage + latM * S.hout.latMeterMontage);
|
||||
|
||||
const K = S.kozijnen;
|
||||
const opKey = p.seam ? `iw${p.id}` : null;
|
||||
if (!opKey || !seenOpenings.has(opKey)) {
|
||||
if (opKey) seenOpenings.add(opKey);
|
||||
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||
const fill = KozijnModel.normalize(op.fill);
|
||||
if (fill && fill.type === 'schuifdeur') {
|
||||
add(el, 'Kozijnen', 'Kozijnen', `Schuifdeur ${op.w}×${op.h} (${fill.leaves} blad${fill.leaves > 1 ? 'en' : ''}, ${fill.mount === 'opbouw' ? 'op de wand' : 'in de wand'})`,
|
||||
fill.leaves, 'blad', fill.leaves * (K.schuifdeurBlad || K.deur), K.montage);
|
||||
} else if (op.type === 'raam') {
|
||||
const m2 = WallModel.openingArea(op) / 1e6;
|
||||
add(el, 'Kozijnen', 'Kozijnen', `Raam ${op.w}×${op.h}`, m2, 'm²', m2 * K.raamM2, K.montage);
|
||||
} else {
|
||||
add(el, 'Kozijnen', 'Kozijnen', `${op.type === 'voordeur' ? 'Voordeur' : 'Deur'} ${op.w}×${op.h}`, 1, 'st.', op.type === 'voordeur' ? K.voordeur : K.deur, K.montage);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => wallElement(p, u.id))));
|
||||
parts.interior.forEach(p => wallElement(p, p.seamUnit || null));
|
||||
|
||||
function joistLayer(el, cd, angleDeg, what, roof) {
|
||||
const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length;
|
||||
const n = cd.joists.length;
|
||||
const ang = angleDeg || 0;
|
||||
add(el, 'Hout', 'Zagen', `${sawLabel}: ${what}balken ${n} + kopbalken ${rims}`, 2 * (n + rims), 'sneden', 0, sawSec([ang, ang], n) + sawSec([0, 0], rims));
|
||||
addJoints(el, n * V.verbindingenPerVloerbalk, `${what}balken`, roof);
|
||||
}
|
||||
|
||||
parts.units.forEach(u => {
|
||||
const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id);
|
||||
const fs = u.floorSpecs;
|
||||
add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', joistWoodCost(fs.joistWoodM3, u.floorData.calcData), 0);
|
||||
joistLayer(fl, u.floorData.calcData, 0, 'Vloer');
|
||||
addSheet(fl, 'Plaatwerk', 'OSB vloer', fs.osbAreaM2, P.osbM2, P.osbMontage);
|
||||
add(fl, 'Isolatie', 'Isoleren', 'Glaswol vloer', fs.voidM3, 'm³', fs.voidM3 * S.isolatie.glaswolM3, fs.voidM3 * S.isolatie.glaswolMontage);
|
||||
const foot = u.walls.reduce((a, w) => a + w.pieces.filter(p => !p.place.baseY).reduce((b, p) => b + p.place.width * p.place.thickness, 0), 0)
|
||||
+ parts.interior.filter(p => p.seamUnit === u.id).reduce((a, p) => a + p.place.width * p.place.thickness, 0);
|
||||
const finM2 = Math.max(0, u.rect.W * u.rect.D - foot) / 1e6;
|
||||
add(fl, 'Vloer', 'Vloer', 'Vloerafwerking', finM2, 'm²', finM2 * S.vloer.afwerkingM2, finM2 * S.vloer.afwerkingMontage);
|
||||
|
||||
const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
|
||||
const rs = u.roof.specs, D = S.dak;
|
||||
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
|
||||
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] ? u.roof.planes[0].data.calcData : cap), 0);
|
||||
u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true));
|
||||
if (cap) {
|
||||
const n = cap.beams.length, join = cap.kind === 'join';
|
||||
add(rf, 'Hout', 'Zagen', `${sawLabel}: ${join ? 'dak (sporen, kepers)' : 'wolfseinden'} ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0));
|
||||
addJoints(rf, n * V.verbindingenPerVloerbalk, join ? 'dakbalken' : 'wolfseindbalken', true);
|
||||
}
|
||||
addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage);
|
||||
addSheet(rf, 'Plaatwerk', `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm`, rs.beschotM2 || 0, D.beschotM2, D.beschotMontage);
|
||||
add(rf, 'Isolatie', 'Isoleren', 'Glaswol dak', rs.voidM3, 'm³', rs.voidM3 * S.isolatie.glaswolM3, rs.voidM3 * S.isolatie.glaswolMontage);
|
||||
add(rf, 'Dak', 'Dak', 'Dakleer (EPDM)', rs.dakleerM2 || 0, 'm²', (rs.dakleerM2 || 0) * D.dakleerM2, (rs.dakleerM2 || 0) * D.dakleerMontage);
|
||||
add(rf, 'Dak', 'Dak', 'Riet', rs.rietM2 || 0, 'm²', (rs.rietM2 || 0) * D.rietM2, (rs.rietM2 || 0) * D.rietMontage);
|
||||
add(rf, 'Dak', 'Dak', 'Felsplaten (staande naad)', rs.felsM2 || 0, 'm²', (rs.felsM2 || 0) * D.felsM2, (rs.felsM2 || 0) * D.felsMontage);
|
||||
const rb = u.roof.unitRoof && u.roof.unitRoof.ridgeBeam;
|
||||
if (rb) {
|
||||
const lm = rb.length / 1000;
|
||||
add(rf, 'Staal', 'Dak', `Stalen nokbalk ${rb.profile.name}, ${fmtM(lm)} m (+ 2 opleggingen)`, rb.steelKg, 'kg',
|
||||
rb.steelKg * D.staalKg + 2 * D.nokOplegging, D.nokPlaatsen, D.nokPersoneel);
|
||||
add(rf, 'Hout', 'Dak', `Vulhout holling nokbalk 2× ${Math.round(rb.fillW)}×${Math.round(rb.fillH)}`, rb.fillM3, 'm³',
|
||||
woodCost(Math.round(rb.fillW), Math.round(rb.fillH), 2 * lm), lm * D.nokVulhoutMontage);
|
||||
}
|
||||
});
|
||||
|
||||
const wo = newEl('WON', 'Woning (dakrand, overkapping, fundering, verlichting)', null);
|
||||
const D = S.dak;
|
||||
if (totals.roofEdge) {
|
||||
const e = totals.roofEdge;
|
||||
add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage);
|
||||
add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
|
||||
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
|
||||
add(wo, 'Dak', 'Dak', 'Ventilatierooster onder boeideel', e.ventLengthM || 0, 'm', (e.ventLengthM || 0) * (D.ventRoosterM || 0), (e.ventLengthM || 0) * (D.ventRoosterMontage || 0));
|
||||
}
|
||||
if (totals.posts) {
|
||||
const pp = totals.posts;
|
||||
add(wo, 'Hout', 'Dak', `${pp.wood} palen ${pp.size}×${pp.size} mm (${pp.count} st.)`, pp.lengthM, 'm', pp.m3 * D.paalHoutM3, pp.count * D.paalMontage);
|
||||
add(wo, 'Staal', 'Dak', 'Paalvoeten', pp.count, 'st.', pp.count * D.paalvoet, 0);
|
||||
add(wo, 'Gevel', 'Dak', `Zijwand overkapping (${pp.walls || 0} st.)`, pp.wallM2 || 0, 'm²', (pp.wallM2 || 0) * (D.zijwandM2 || 0), (pp.wallM2 || 0) * (D.zijwandMontage || 0));
|
||||
// Buitenkant zoals de gevel ernaast (stuc zit al in de zijwand).
|
||||
const G = S.gevel, oo = pp.outsideM2 || {};
|
||||
[['fels', 'felsplaten', G.felsM2, G.felsMontage], ['rabat', 'rabat', G.rabatM2, G.rabatMontage], ['steen', 'steenstrips', G.steenM2, G.steenMontage]].forEach(([k, label, m2, sec]) =>
|
||||
add(wo, 'Gevel', 'Gevel', `Zijwand overkapping buitenzijde ${label}`, oo[k] || 0, 'm²', (oo[k] || 0) * (m2 || 0), (oo[k] || 0) * (sec || 0)));
|
||||
}
|
||||
if (parts.sandwich) {
|
||||
const sw = parts.sandwich;
|
||||
add(wo, 'Vloer', 'Vloer', `Sandwichpanelen (${sw.panelCount} platen)`, sw.areaM2, 'm²', sw.areaM2 * S.vloer.sandwichM2, sw.panelCount * S.vloer.sandwichMontage);
|
||||
}
|
||||
const L = S.verlichting, lm = totals.lamps;
|
||||
add(wo, 'Verlichting', 'Verlichting', 'Gevellampen', lm.total, 'st.', lm.total * L.gevellamp, lm.total * L.montage);
|
||||
const spots = totals.roofEdge ? totals.roofEdge.spots : 0;
|
||||
add(wo, 'Verlichting', 'Verlichting', 'Boeideel-spots', spots, 'st.', spots * L.boeideelSpot, spots * L.montage);
|
||||
add(wo, 'Verlichting', 'Verlichting', 'Binnenspots', lm.indoor, 'st.', lm.indoor * L.binnenspot, lm.indoor * L.montage);
|
||||
|
||||
const elk = parts.elektra, E = S.elektra;
|
||||
if (elk && elk.enabled) {
|
||||
const unitIds = new Set(parts.units.map(u => u.id)), elOf = {};
|
||||
const elFor = (uid) => {
|
||||
const id = unitIds.has(uid) ? uid : null, k = id || '-';
|
||||
return elOf[k] || (elOf[k] = newEl(id ? `${id}-EL` : 'EL', id ? `Unit ${id} elektra` : 'Elektra', id));
|
||||
};
|
||||
const ea = (uid, label, qty, unit, mat, sec) => add(elFor(uid), 'Elektra', 'Elektra', label, qty, unit, mat, sec, E.personeel);
|
||||
const mkUnit = elk.meterkast ? elk.meterkast.unitId : null;
|
||||
const groupUnit = (nr) => (elk.groups.find(g => g.nr === nr) || {}).unitId || mkUnit;
|
||||
const buisPrijs = (d) => d >= 25 ? E.buis25M : d >= 19 ? E.buis19M : E.buis16M;
|
||||
const agg = {};
|
||||
elk.runs.forEach(r => {
|
||||
const uid = groupUnit(r.group), a = agg[uid] = agg[uid] || { types: {}, holes: 0, cross: {} };
|
||||
a.types[r.type] = (a.types[r.type] || 0) + r.lengthMM / 1000;
|
||||
a.holes += r.holes || 0;
|
||||
if (r.crossings) { const n = ElektraModel.cableInfo(r.type).cores.length; a.cross[n] = (a.cross[n] || 0) + r.crossings; }
|
||||
});
|
||||
Object.keys(agg).forEach(uid => {
|
||||
const a = agg[uid];
|
||||
Object.keys(a.types).sort().forEach(type => {
|
||||
const c = ElektraModel.cableInfo(type), m = a.types[type];
|
||||
if (c.conduit) {
|
||||
const draad = c.mm2 >= 2.5 ? E.draad25M : E.draad15M;
|
||||
ea(uid, `${c.label}: ${c.use.toLowerCase()}`, m, 'm', m * (buisPrijs(c.conduit) + c.cores.length * draad), m * (E.buisLeggen + E.draadTrekken));
|
||||
} else ea(uid, `${c.label}: ${c.use.toLowerCase()}`, m, 'm', m * E.xmvkM, m * E.kabelLeggen);
|
||||
});
|
||||
ea(uid, 'Boorgaten stijlen/regels/balken', a.holes, 'st.', 0, a.holes * E.boorgat);
|
||||
Object.keys(a.cross).forEach(n => {
|
||||
const k = a.cross[n];
|
||||
ea(uid, `Steekverbinder module-naad ${n}-polig`, k, 'set', k * (Number(n) >= 5 ? E.steekverbinder5 : E.steekverbinder3), k * E.steekverbinderMontage);
|
||||
});
|
||||
});
|
||||
const count = (list, uid) => list.filter(x => x.unitId === uid).length;
|
||||
parts.units.forEach(u => {
|
||||
const uid = u.id;
|
||||
const nSw = count(elk.switches, uid), combos = elk.combos.filter(c => c.unitId === uid);
|
||||
const raam1 = combos.filter(c => c.switches.length === 1).length, raam2 = combos.length - raam1;
|
||||
ea(uid, `Schakelaars (${raam1}× 1-voudig, ${raam2}× 2-voudig raam)`, nSw, 'st.',
|
||||
nSw * (E.schakelaar + E.inbouwdoos) + raam1 * E.afdekraam1 + raam2 * E.afdekraam2, nSw * (E.doosPlaatsen + E.afmonteren));
|
||||
const nW = count(elk.wcds, uid);
|
||||
ea(uid, 'Wandcontactdozen dubbel (2 inbouwdozen)', nW, 'st.', nW * (E.wcdDubbel + 2 * E.inbouwdoos), nW * (2 * E.doosPlaatsen + E.afmonteren));
|
||||
elk.apps.filter(a => a.unitId === uid).forEach(a => {
|
||||
const price = a.app === 'koken' ? E.perilex : a.app === 'warmtepomp' ? E.werkschakelaar : E.wcdEnkel;
|
||||
ea(uid, `Aansluitpunt ${a.label.toLowerCase()}`, 1, 'st.', price + E.inbouwdoos, E.doosPlaatsen + E.afmonteren);
|
||||
});
|
||||
const nV = count(elk.floorWcds, uid);
|
||||
ea(uid, 'Vloerdoos met dubbele wcd', nV, 'st.', nV * E.vloerdoosSt, nV * (E.vloerdoos + E.afmonteren));
|
||||
const nB = count(elk.boxes, uid), nKl = nB * 3 + nSw + nW;
|
||||
ea(uid, `Verdeeldozen plafond (+ ${nKl} lasklemmen)`, nB, 'st.', nB * E.verdeeldoosSt + nKl * E.lasklem, nB * E.verdeeldoos);
|
||||
const nSpot = elk.switches.filter(sw => sw.unitId === uid).reduce((t, sw) => t + sw.lamps.filter(l => l.kind === 'binnenspot').length, 0);
|
||||
ea(uid, 'Spotdozen plafond (luchtdicht)', nSpot, 'st.', nSpot * E.spotdoosSt, nSpot * E.spotdoos);
|
||||
const nDv = elk.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && groupUnit(r.group) === uid).length;
|
||||
ea(uid, 'Kabeldoorvoer naar buiten', nDv, 'st.', nDv * E.doorvoer, 0);
|
||||
});
|
||||
if (elk.meterkast) {
|
||||
const g1 = elk.groups.filter(g => (g.poles || 1) === 1).length, g2 = elk.groups.length - g1;
|
||||
ea(mkUnit, `Groepenkast 3×25 A: kast, hoofdschakelaar, ${elk.rcdCount} aardlek`, 1, 'st.', E.kast + E.hoofdschakelaar + elk.rcdCount * E.aardlek, E.kastMontage);
|
||||
ea(mkUnit, `Groepen: ${g1}× automaat 1P+N${g2 ? `, ${g2}× 2-fase` : ''}`, elk.groups.length, 'groep', g1 * E.automaat + g2 * E.automaat2f, elk.groups.length * E.groepMontage);
|
||||
ea(mkUnit, 'Meten, testen en opleveren', 1, 'woning', 0, E.keuring);
|
||||
}
|
||||
}
|
||||
|
||||
const AF = S.afwerking, rmOf = {};
|
||||
if (AF && parts.rooms) parts.rooms.rooms.filter(r => r.ceilM2 > 0 || r.fiboM2 > 0).forEach(r => {
|
||||
const el = rmOf[r.unitId] || (rmOf[r.unitId] = newEl(`${r.unitId}-RU`, `Unit ${r.unitId} ruimtes`, r.unitId));
|
||||
add(el, 'Afwerking', 'Afwerking', `${r.name}: vals plafond ${r.ceiling} mm`, r.ceilM2, 'm²', r.ceilM2 * AF.valsPlafondM2, r.ceilM2 * AF.valsPlafondMontage);
|
||||
add(el, 'Afwerking', 'Afwerking', `${r.name}: FIBO-wandpanelen`, r.fiboM2, 'm²', r.fiboM2 * AF.fiboM2, r.fiboM2 * AF.fiboMontage);
|
||||
});
|
||||
|
||||
const IN = S.inrichting, fxOf = {};
|
||||
if (IN && parts.fixtures) parts.fixtures.filter(f => f.cost).forEach(f => {
|
||||
const el = fxOf[f.unitId] || (fxOf[f.unitId] = newEl(`${f.unitId}-IN`, `Unit ${f.unitId} inrichting`, f.unitId));
|
||||
const m = f.w / 1000, crew = IN.personeel;
|
||||
const perM = (what, price, sec) => add(el, 'Inrichting', 'Inrichting', `${what} ${f.w} mm`, m, 'm', m * (price || 0), m * (sec || 0), crew);
|
||||
if (f.cost === 'keuken') perM('Keukenblok', IN.keukenM, IN.keukenMontage);
|
||||
else if (f.cost === 'kookeiland') perM('Kookeiland', IN.kookeilandM, IN.kookeilandMontage);
|
||||
else if (f.cost === 'kast') perM(f.label, IN.kastM, IN.kastMontage);
|
||||
else add(el, 'Inrichting', 'Inrichting', f.label, 1, 'st.', IN[f.cost] || 0, IN[f.cost + 'Montage'] || 0, crew);
|
||||
if ((f.cost === 'keuken' || f.cost === 'kookeiland') && f.opts && f.opts.kookplaat) add(el, 'Inrichting', 'Inrichting', 'Inductiekookplaat', 1, 'st.', IN.kookplaat || 0, 0);
|
||||
});
|
||||
|
||||
const sumLines = (lines) => lines.reduce((a, l) => ({ mat: a.mat + l.mat, sec: a.sec + l.sec, clock: a.clock + l.clock }), { mat: 0, sec: 0, clock: 0 });
|
||||
elements.forEach(el => Object.assign(el, sumLines(el.lines)));
|
||||
const byKey = (key, order) => {
|
||||
const m = new Map();
|
||||
elements.forEach(el => el.lines.forEach(l => {
|
||||
const r = m.get(l[key]) || { name: l[key], mat: 0, sec: 0 };
|
||||
r.mat += l.mat; r.sec += l.sec;
|
||||
m.set(l[key], r);
|
||||
}));
|
||||
return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
|
||||
};
|
||||
// Afschrijving/slijtage/huisvesting per productie-uur: klokuren (niet × personeel) + indirect, zonder QA.
|
||||
const O = S.opslag, B = S.bedrijf, A = S.arbeid;
|
||||
const perHour = B.machines + B.slijtage + B.huisvesting;
|
||||
const split = (sec) => {
|
||||
const direct = sec / 3600, indirect = direct * A.toeslagUren / 100;
|
||||
const qa = (direct + indirect) * A.qa / 100;
|
||||
return { direct, indirect, qa, total: direct + indirect + qa };
|
||||
};
|
||||
const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100);
|
||||
const costOf = (r) => mat0(r.mat) + split(r.sec).total * A.uurloon + prodHours(r.clock) * perHour;
|
||||
const mat0 = (mat) => mat * (1 + (O.verlies + O.klein) / 100);
|
||||
elements.forEach(el => { el.kostprijs = costOf(el); });
|
||||
|
||||
const units = parts.units.map(u => {
|
||||
const r = Object.assign({ id: u.id }, sumLines(elements.filter(el => el.unit === u.id).flatMap(el => el.lines)));
|
||||
r.transport = B.transportUnit;
|
||||
r.kostprijs = costOf(r) + r.transport;
|
||||
return r;
|
||||
});
|
||||
const rest = sumLines(elements.filter(el => el.unit === null).flatMap(el => el.lines));
|
||||
units.push(Object.assign({ id: null, transport: 0 }, rest, { kostprijs: costOf(rest) + B.kraan }));
|
||||
|
||||
const all = sumLines(elements.flatMap(el => el.lines));
|
||||
const h = split(all.sec);
|
||||
h.prod = prodHours(all.clock);
|
||||
const n = parts.units.length;
|
||||
const pct = (v) => String(v).replace('.', ',') + '%';
|
||||
const eur0 = (v) => eur(v);
|
||||
const posts = [
|
||||
['Materiaal', all.mat],
|
||||
[`Materiaalverlies ${pct(O.verlies)}`, all.mat * O.verlies / 100],
|
||||
[`Klein materiaal ${pct(O.klein)}`, all.mat * O.klein / 100],
|
||||
[`Arbeid ${uur(h.total)} manuur × ${eur0(A.uurloon)}`, h.total * A.uurloon],
|
||||
[`Afschrijving machines ${uur(h.prod)} uur × ${eur(B.machines, 2)}`, h.prod * B.machines],
|
||||
[`Slijtage gereedschap ${uur(h.prod)} uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage],
|
||||
[`Huisvesting productiehal ${uur(h.prod)} uur × ${eur(B.huisvesting, 2)}`, h.prod * B.huisvesting],
|
||||
[`Transport ${n} unit${n === 1 ? '' : 's'} × ${eur0(B.transportUnit)}`, n * B.transportUnit],
|
||||
['Kraan plaatsen', B.kraan]
|
||||
];
|
||||
const kostprijs = posts.reduce((a, p) => a + p[1], 0);
|
||||
const algemeen = kostprijs * O.algemeen / 100;
|
||||
const marge = (kostprijs + algemeen) * O.marge / 100;
|
||||
const exBtw = kostprijs + algemeen + marge;
|
||||
const btw = exBtw * O.btw / 100;
|
||||
const rows = posts.filter(p => p[1] > 0).map(([label, amount]) => ({ label, amount }))
|
||||
.concat([
|
||||
{ label: 'Kostprijs', amount: kostprijs, sub: true },
|
||||
{ label: `Algemene kosten ${pct(O.algemeen)}`, amount: algemeen },
|
||||
{ label: `Marge ${pct(O.marge)}`, amount: marge },
|
||||
{ label: 'Totaal excl. btw', amount: exBtw, sub: true },
|
||||
{ label: `Btw ${pct(O.btw)}`, amount: btw }
|
||||
]);
|
||||
return {
|
||||
settings: S, elements, units,
|
||||
saw: Object.assign(saw, { handSec: saw.robotSec * Z.handFactor, method: Z.methode }),
|
||||
categories: byKey('cat', CAT_ORDER), activities: byKey('act', ACT_ORDER),
|
||||
summary: { mat: all.mat, hoursDirect: h.direct, hoursIndirect: h.indirect, hoursQa: h.qa, hours: h.total, kostprijs, exBtw, btw, inclBtw: exBtw + btw, rows }
|
||||
};
|
||||
}
|
||||
|
||||
function eur(n, decimals = 0) {
|
||||
return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
function uur(hours) {
|
||||
return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
return { defaultValues, getSettings, saveSettings, onChange, importFile, compute, eur, uur };
|
||||
})();
|
||||
@@ -0,0 +1,173 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
window.CostSettings = (() => {
|
||||
let overlay = null;
|
||||
|
||||
const esc = UI.esc;
|
||||
const SJABLOON_BALKEN = [[38, 235], [70, 220]];
|
||||
|
||||
function build() {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'cost-settings-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div id="cost-settings-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Kosten & tijden</h2>
|
||||
<button type="button" class="modal-close-x" data-cs="cancel">×</button>
|
||||
</div>
|
||||
<div class="cs-pricelist"></div>
|
||||
<div class="cs-body"></div>
|
||||
<div class="material-info">Bedragen excl. btw. De CSV's in kosten/ zijn leidend: Toepassen geldt tot herladen; om een wijziging te houden "intern.csv downloaden" en het bestand in kosten/ vervangen, of met "Wijzigingen downloaden" alleen de aangepaste velden (met de standaardwaarde ernaast) opslaan. Prijzen pas je aan in leveranciers.csv (CSV = daaruit, anders noodwaarde). Een veld dat afwijkt van intern.csv is gemarkeerd.</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="ui-btn" data-cs="reset">Standaard</button>
|
||||
<button type="button" class="ui-btn" data-cs="export">intern.csv downloaden</button>
|
||||
<button type="button" class="ui-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
|
||||
<button type="button" class="ui-btn" data-cs="import">Importeren…</button>
|
||||
<span style="flex:1"></span>
|
||||
<button type="button" class="ui-btn" data-cs="cancel" data-esc>Annuleren</button>
|
||||
<button type="button" class="ui-btn ui-btn-primary" data-cs="save">Toepassen</button>
|
||||
</div>
|
||||
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
|
||||
<input type="file" data-file="leveranciers" accept=".csv,text/csv,text/plain" style="display:none">
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) return close();
|
||||
const btn = e.target.closest('[data-cs]');
|
||||
if (!btn) return;
|
||||
const a = btn.dataset.cs;
|
||||
if (a === 'cancel') close();
|
||||
else if (a === 'save') {
|
||||
CostModel.saveSettings(readForm());
|
||||
close();
|
||||
}
|
||||
else if (a === 'reset') fill(withLeveranciers(CostModel.defaultValues()));
|
||||
else if (a === 'export') exportIntern();
|
||||
else if (a === 'export-changes') exportChanges();
|
||||
else if (a === 'import') overlay.querySelector('[data-file=import]').click();
|
||||
else if (a === 'lev-load') overlay.querySelector('[data-file=leveranciers]').click();
|
||||
else if (a === 'lev-clear') CostCsv.reload('leveranciers');
|
||||
else if (a === 'lev-template') saveFile(CostCsv.template('leveranciers', readForm(), SJABLOON_BALKEN), 'leveranciers.csv');
|
||||
});
|
||||
overlay.querySelector('[data-file=import]').addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
file.text().then(txt => fill(withLeveranciers(CostModel.importFile(txt))))
|
||||
.catch(err => alert('Importeren mislukt: ' + err.message));
|
||||
});
|
||||
overlay.querySelector('[data-file=leveranciers]').addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
file.text().then(txt => CostCsv.loadText('leveranciers', txt, file.name))
|
||||
.catch(err => alert('Leverancierslijst laden mislukt: ' + err.message));
|
||||
});
|
||||
CostCsv.onChange(() => {
|
||||
if (!overlay.classList.contains('open')) return;
|
||||
const v = readForm();
|
||||
renderForm();
|
||||
fill(withLeveranciers(v));
|
||||
});
|
||||
overlay.addEventListener('input', markChanged);
|
||||
}
|
||||
|
||||
const levEntry = (g, k) => CostCsv.entry('leveranciers', `${g}.${k}`);
|
||||
const withLeveranciers = (values) => CostCsv.overlay('leveranciers', values);
|
||||
|
||||
function exportIntern() {
|
||||
saveFile(CostCsv.template('intern', readForm(CostModel.getSettings({ leveranciers: false }))), 'intern.csv');
|
||||
}
|
||||
|
||||
function exportChanges() {
|
||||
const text = CostCsv.changes(readForm(CostModel.getSettings({ leveranciers: false })), CostModel.defaultValues());
|
||||
if (!text) return alert('Geen wijzigingen ten opzichte van intern.csv.');
|
||||
const d = new Date(), p = (n) => String(n).padStart(2, '0');
|
||||
saveFile(text, `wijzigingen-intern-${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}.csv`);
|
||||
}
|
||||
|
||||
function renderStatus() {
|
||||
const when = (st) => st.loadedAt ? new Date(st.loadedAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) : '';
|
||||
const block = (list, extra) => {
|
||||
const st = CostCsv.status(list);
|
||||
const head = !st.source
|
||||
? `<b>${esc(st.label)}:</b> geen ${esc(st.url)} gevonden`
|
||||
: `<b>${esc(st.label)}:</b> ${esc(st.name || '')}${st.source === 'tijdelijk' ? ' (tijdelijk ingeladen, tot herladen)' : ''} · ${st.count} waarden · ${esc(when(st))}`;
|
||||
const issues = st.errors.map(e => `<li class="err">${esc(e)}</li>`)
|
||||
.concat(st.unknown.map(u => `<li>regel ${u.line}: code ${esc(u.code)}${u.label ? ` (${esc(u.label)})` : ''} wordt (nog) niet gebruikt</li>`));
|
||||
return `<div class="cs-list"><div>${head}${extra ? extra(st) : ''}</div>`
|
||||
+ (issues.length ? `<details><summary>${st.errors.length} fout(en), ${st.unknown.length} onbekende code(s)</summary><ul>${issues.join('')}</ul></details>` : '') + '</div>';
|
||||
};
|
||||
overlay.querySelector('.cs-pricelist').innerHTML = block('intern')
|
||||
+ block('leveranciers', (st) => ` <button type="button" class="io-btn" data-cs="lev-load">Tijdelijk laden…</button>`
|
||||
+ `<button type="button" class="io-btn" data-cs="lev-template">Sjabloon</button>`
|
||||
+ (st.source === 'tijdelijk' ? `<button type="button" class="io-btn" data-cs="lev-clear">Terug naar server</button>` : ''));
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
renderStatus();
|
||||
const body = overlay.querySelector('.cs-body');
|
||||
body.innerHTML = Object.entries(window.COST_DEFAULTS).map(([g, grp]) => `
|
||||
<section class="cs-group">
|
||||
<h3>${esc(grp.label)}</h3>
|
||||
${Object.entries(grp.items).map(([k, it]) => {
|
||||
const lev = levEntry(g, k), int = CostCsv.entry('intern', `${g}.${k}`), price = CostCsv.isPrice(it);
|
||||
const tip = lev ? `Uit leveranciers.csv${lev.supplier ? ': ' + lev.supplier : ''}${lev.article ? ', art. ' + lev.article : ''}${lev.updated ? ', ' + lev.updated : ''} (regel ${lev.line})`
|
||||
: price ? `${g}.${k}: niet in leveranciers.csv, noodwaarde uit cost-defaults.js`
|
||||
: `code ${g}.${k}${int ? ` (intern.csv regel ${int.line})` : ': niet in intern.csv, noodwaarde uit cost-defaults.js'}`;
|
||||
return `
|
||||
<label class="cs-row${lev ? ' from-list' : ''}" title="${esc(tip)}">
|
||||
<span class="cs-label">${esc(CostCsv.labelOf(`${g}.${k}`, it.label))}</span>
|
||||
${it.options
|
||||
? `<select data-g="${g}" data-k="${k}">${Object.entries(it.options).map(([v, t]) => `<option value="${esc(v)}">${esc(t)}</option>`).join('')}</select>`
|
||||
: `<input type="number" step="any" data-g="${g}" data-k="${k}"${price ? ' disabled' : ''}>`}
|
||||
<span class="cs-unit">${esc(it.unit || '')}</span>
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</section>`).join('');
|
||||
}
|
||||
|
||||
function fill(values) {
|
||||
overlay.querySelectorAll('[data-g]').forEach(el => { el.value = values[el.dataset.g][el.dataset.k]; });
|
||||
markChanged();
|
||||
}
|
||||
function readForm(base) {
|
||||
const v = base || CostModel.getSettings();
|
||||
overlay.querySelectorAll('[data-g]:not(:disabled)').forEach(el => {
|
||||
const it = window.COST_DEFAULTS[el.dataset.g].items[el.dataset.k];
|
||||
const val = it.options ? el.value : parseFloat(el.value);
|
||||
if (it.options || isFinite(val)) v[el.dataset.g][el.dataset.k] = val;
|
||||
});
|
||||
return v;
|
||||
}
|
||||
function markChanged() {
|
||||
const def = CostModel.defaultValues();
|
||||
overlay.querySelectorAll('[data-g]').forEach(el => {
|
||||
const d = def[el.dataset.g][el.dataset.k];
|
||||
el.closest('.cs-row').classList.toggle('changed', !el.disabled && typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d);
|
||||
});
|
||||
}
|
||||
|
||||
function saveFile(text, name) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: 'text/csv' }));
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!overlay) build();
|
||||
renderForm();
|
||||
fill(CostModel.getSettings());
|
||||
overlay.classList.add('open');
|
||||
}
|
||||
function close() { if (overlay) overlay.classList.remove('open'); }
|
||||
|
||||
return { open, close };
|
||||
})();
|
||||
@@ -0,0 +1,102 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Koppeling met de Van Emous CRM (alleen in de Docker-stack): woningen van leads en klanten laden (Nieuw › Uit de
|
||||
// CRM, of ?woning=<id> in de URL) en een gewijzigde woning als nieuwe versie terugzetten (Opslaan in CRM).
|
||||
// De API staat op api/crm/ naast deze pagina: Caddy stuurt dat na het Tectum-wachtwoord door naar de CRM, met de
|
||||
// sleutel erbij. Zonder stack (start.sh) geeft api/crm/ een 404 en blijft alles van de CRM verborgen.
|
||||
window.TectumCrm = (function () {
|
||||
const API = 'api/crm/woningen';
|
||||
let available = null;
|
||||
|
||||
async function call(url, opts) {
|
||||
const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json', ...(opts && opts.body ? { 'Content-Type': 'application/json' } : {}) }, ...opts });
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { /* geen JSON: geen CRM */ }
|
||||
if (!data) throw new Error(`CRM niet bereikbaar (HTTP ${res.status})`);
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
// Eén keer kijken of de CRM bereikbaar is (de lijst ophalen); onthoudt de uitkomst
|
||||
function isAvailable() {
|
||||
if (!available) available = list('').then(() => true, () => false);
|
||||
return available;
|
||||
}
|
||||
|
||||
async function list(q) {
|
||||
const { data } = await call(`${API}?q=${encodeURIComponent(q || '')}`);
|
||||
if (!data.ok) throw new Error(data.error || 'CRM-fout');
|
||||
return data.woningen;
|
||||
}
|
||||
|
||||
// Ontwerp van een woning (nieuwste versie, of `versie`), klaar voor de editor: gecontroleerd en zonder de
|
||||
// bestandsvelden (type, version, exportedAt), zoals bij Openen.
|
||||
async function load(id, versie) {
|
||||
const { data } = await call(`${API}/${encodeURIComponent(id)}${versie ? `?versie=${versie}` : ''}`);
|
||||
if (!data.ok) throw new Error(data.error || 'Woning niet gevonden');
|
||||
HouseModel.validateState(data.ontwerp);
|
||||
const { type, version, exportedAt, ...state } = data.ontwerp;
|
||||
return { woning: data.woning, state };
|
||||
}
|
||||
|
||||
// Nieuwe versie bewaren. basis = de versie die geladen was; is er intussen een nieuwere, dan vraagt dit eerst.
|
||||
async function saveVersion(id, state, basis, opmerking) {
|
||||
const ontwerp = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), configurator: APP_VERSION, ...state };
|
||||
const send = (forceer) => call(`${API}/${encodeURIComponent(id)}/versies`, { method: 'POST', body: JSON.stringify({ ontwerp, opmerking, basis, forceer }) });
|
||||
let { status, data } = await send(false);
|
||||
if (status === 409) {
|
||||
if (!window.confirm(`In de CRM staat intussen versie ${data.versie} van deze woning (u bewerkt versie ${basis}). Toch opslaan als nieuwe versie?`)) return null;
|
||||
({ data } = await send(true));
|
||||
}
|
||||
if (!data.ok) throw new Error(data.error || 'Opslaan mislukt');
|
||||
return data.versie;
|
||||
}
|
||||
|
||||
function toast(msg, cls) {
|
||||
let el = document.getElementById('crm-toast');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'crm-toast';
|
||||
el.setAttribute('role', 'status');
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent = msg;
|
||||
el.className = 'open ' + (cls || '');
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => { el.className = ''; }, 5000);
|
||||
}
|
||||
|
||||
const date = (s) => (s || '').slice(0, 10).split('-').reverse().join('-');
|
||||
|
||||
// Sectie "Woningen uit de CRM" in het venster Nieuw: zoeken en kiezen; verborgen zolang de CRM er niet is
|
||||
function mountPicker(host, onPick) {
|
||||
host.innerHTML = `<h3>Woningen van leads en klanten (CRM)</h3>
|
||||
<input type="search" class="new-crm-q" placeholder="Zoek op woning, naam of plaats" aria-label="Zoek in de CRM">
|
||||
<ul class="new-crm-list" role="list"></ul>`;
|
||||
const q = host.querySelector('.new-crm-q'), ul = host.querySelector('.new-crm-list');
|
||||
let req = 0, timer = null;
|
||||
const render = (rows) => {
|
||||
ul.innerHTML = rows.length ? rows.map(w => `<li><button type="button" class="new-crm-item" data-id="${w.id}">
|
||||
<i class="bi bi-house"></i><span><strong>${UI.esc(w.titel)}</strong>
|
||||
<small>${UI.esc([w.contact && w.contact.naam, w.relatie, w.contact && w.contact.plaats].filter(Boolean).join(' · '))}
|
||||
· versie ${w.versie} · ${date(w.datum)}</small></span></button></li>`).join('')
|
||||
: '<li class="new-crm-empty">Geen woningen gevonden.</li>';
|
||||
};
|
||||
const search = () => {
|
||||
const id = ++req;
|
||||
list(q.value.trim()).then(rows => { if (id === req) render(rows); }, err => { if (id === req) ul.innerHTML = `<li class="new-crm-empty">${UI.esc(err.message)}</li>`; });
|
||||
};
|
||||
q.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(search, 250); });
|
||||
ul.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('.new-crm-item');
|
||||
if (b) onPick(Number(b.dataset.id));
|
||||
});
|
||||
isAvailable().then(ok => { host.hidden = !ok; if (ok) search(); });
|
||||
return { refresh: () => { if (!host.hidden) search(); } };
|
||||
}
|
||||
|
||||
return { isAvailable, list, load, saveVersion, mountPicker, toast };
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
window.ElektraPdf = (() => {
|
||||
const fmt = (n, d = 1) => Number(n).toFixed(d).replace('.', ',');
|
||||
const RUN_COLORS = { licht: [215, 160, 0], wcd: [205, 55, 35], buiten: [40, 140, 60], schakel: [150, 95, 20] };
|
||||
const runColor = (type) => RUN_COLORS[type] || RUN_COLORS.schakel;
|
||||
|
||||
function drawPlan(doc, el, px, py) {
|
||||
if (!el || !el.enabled) return;
|
||||
const BLACK = PdfSheet.COLORS.BLACK;
|
||||
doc.setLineWidth(0.15);
|
||||
doc.setLineDashPattern([0.8, 0.6], 0);
|
||||
el.runs.forEach(r => {
|
||||
doc.setDrawColor(...runColor(r.type));
|
||||
r.path.slice(1).forEach((b, i) => {
|
||||
const a = r.path[i];
|
||||
if (Math.hypot(px(b.x) - px(a.x), py(b.z) - py(a.z)) > 0.05) doc.line(px(a.x), py(a.z), px(b.x), py(b.z));
|
||||
});
|
||||
});
|
||||
doc.setLineDashPattern([], 0);
|
||||
|
||||
const k = el.meterkast;
|
||||
if (k) {
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(90, 90, 90);
|
||||
doc.setLineWidth(0.2);
|
||||
const c = k.corners;
|
||||
doc.lines(c.slice(1).map((p, i) => [px(p.x) - px(c[i].x), py(p.z) - py(c[i].z)]), px(c[0].x), py(c[0].z), [1, 1], 'FD', true);
|
||||
doc.setFontSize(5);
|
||||
doc.setTextColor(...BLACK);
|
||||
const m = { x: (c[0].x + c[2].x) / 2 + k.face.n.x * 350, z: (c[0].z + c[2].z) / 2 + k.face.n.z * 350 };
|
||||
doc.text('MK', px(m.x), py(m.z) + 0.8, { align: 'center' });
|
||||
}
|
||||
|
||||
doc.setDrawColor(...runColor('schakel'));
|
||||
doc.setLineWidth(0.15);
|
||||
el.boxes.forEach(b => doc.rect(px(b.x) - 0.6, py(b.z) - 0.6, 1.2, 1.2, 'S'));
|
||||
|
||||
doc.setFontSize(5);
|
||||
el.combos.forEach(c => {
|
||||
const q = { x: c.pos.x + c.face.n.x * 260, z: c.pos.z + c.face.n.z * 260 };
|
||||
const text = c.switches.map(s => s.letter).join(' ');
|
||||
const w = 1.6 + text.length * 1.0;
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.rect(px(q.x) - w / 2, py(q.z) - 1.2, w, 2.4, 'FD');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text(text, px(q.x), py(q.z) + 0.7, { align: 'center' });
|
||||
});
|
||||
|
||||
el.wcds.forEach(w => {
|
||||
const q = { x: w.pos.x + w.face.n.x * 120, z: w.pos.z + w.face.n.z * 120 };
|
||||
doc.setDrawColor(...RUN_COLORS.wcd);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.circle(px(q.x), py(q.z), 0.9, 'FD');
|
||||
doc.setTextColor(...RUN_COLORS.wcd);
|
||||
doc.setFontSize(4);
|
||||
doc.text(String(w.group), px(q.x + w.face.n.x * 250), py(q.z + w.face.n.z * 250) + 0.6, { align: 'center' });
|
||||
});
|
||||
|
||||
el.apps.forEach(a => {
|
||||
const it = el.items.find(x => x.key === a.key), q = it.marker;
|
||||
doc.setDrawColor(...RUN_COLORS.wcd);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.rect(px(q.x) - 1.6, py(q.z) - 1.1, 3.2, 2.2, 'FD');
|
||||
doc.setTextColor(...RUN_COLORS.wcd);
|
||||
doc.setFontSize(3.6);
|
||||
doc.text(a.code, px(q.x), py(q.z) + 0.5, { align: 'center' });
|
||||
doc.text(String(a.group), px(q.x + a.face.n.x * 330), py(q.z + a.face.n.z * 330) + 0.5, { align: 'center' });
|
||||
});
|
||||
|
||||
el.floorWcds.forEach(w => {
|
||||
doc.setDrawColor(...RUN_COLORS.wcd);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.rect(px(w.x) - 1.1, py(w.z) - 1.1, 2.2, 2.2, 'FD');
|
||||
doc.setTextColor(...RUN_COLORS.wcd);
|
||||
doc.setFontSize(3.6);
|
||||
doc.text('V' + w.group, px(w.x), py(w.z) + 0.5, { align: 'center' });
|
||||
});
|
||||
|
||||
doc.setFontSize(3.6);
|
||||
doc.setTextColor(...BLACK);
|
||||
el.switches.forEach(s => s.lamps.forEach(l => doc.text(s.letter, px(l.x) + 0.9, py(l.z) - 0.7)));
|
||||
}
|
||||
const PLAN_LEGEND = [
|
||||
'Elektra: MK = meterkast, kadertje = schakelaar(s) met letter (dezelfde letter staat bij de lampen erop), rood rondje = dubbele wcd (groepnummer), vierkantje = verdeeldoos in plafond;',
|
||||
'rood kadertje = apparaat (KK kookgroep, WM wasmachine, DR droger, WP warmtepomp, OV oven, VW vaatwasser) met groepnummer, V = vloer-wcd; gestippeld = leidingen (geel licht, bruin schakeldraad, rood wcd/apparaat, groen buiten).'
|
||||
];
|
||||
|
||||
function drawSheet(doc, el, sheetOpts) {
|
||||
const BLACK = PdfSheet.COLORS.BLACK;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(14);
|
||||
doc.text('Elektra', 15, 18);
|
||||
const t = (title) => ({ title, fontSize: 7, rowH: 4.4, lastColW: 16 });
|
||||
let y = 30;
|
||||
y = PdfSheet.table(doc, 15, y, [0, 12, 62, 74, 88, 104, 140, 156], ['Groep', 'Omschrijving', 'Fase', 'Aardlek', 'Automaat', 'Leiding', 'Punten', 'Lengte'],
|
||||
el.groups.map(g => [String(g.nr), g.label, g.phase, `AL${g.rcd}`, g.breaker, ElektraModel.cableInfo(g.cable).label.replace(/ in buis.*/, ''),
|
||||
g.kind === 'app' ? 'apparaat' : `${g.points} ${g.kind === 'wcd' ? 'wcd' : 'lamp'}`, fmt(g.meters) + ' m']),
|
||||
t('Groepenverklaring (aansluiting 3×25 A)'));
|
||||
y += 4;
|
||||
y = PdfSheet.table(doc, 15, y, [0, 14, 60, 76, 92], ['Letter', 'Functie', 'Unit', 'Groep', 'Lampen'],
|
||||
el.switches.map(s => [s.letter, s.label, s.unitId, String(s.group || '-'),
|
||||
`${s.lamps.length} (${[['binnenspot', 'binnenspot'], ['boeideelspot', 'boeideel-spot'], ['gevellamp', 'gevellamp']]
|
||||
.map(([k, l]) => [s.lamps.filter(x => x.kind === k).length, l]).filter(([n]) => n).map(([n, l]) => `${n} ${l}`).join(', ') || '-'})`]),
|
||||
t('Schakelaars (letter staat ook bij de lampen op de plattegrond)'));
|
||||
y += 4;
|
||||
const k = el.meterkast;
|
||||
const notes = [
|
||||
k ? `Meterkast ${k.w}×${k.h}×${k.d} mm (b×h×d) in unit ${k.unitId}, wand ${k.element}, onderkant ${k.y} mm boven vloer.` : 'Geen meterkast geplaatst.',
|
||||
`Schakelaars op ${el.settings.switchHeight} mm, wcd op ${el.settings.wcdHeight} mm (hart, boven bovenkant vloer).`,
|
||||
`Leidinglengte = route via stijlen/plafond + ${el.settings.reserve} mm reserve (meterkast +${el.settings.meterkastReserve} mm), + ${el.settings.slack}% marge.`,
|
||||
'Groepen per unit: licht B10 (1,5 mm²), wcd B16 (2,5 mm²); buitenlicht eigen groep B10 (XMvK), zelfde aardlek als het binnenlicht in dezelfde schakeldoos.',
|
||||
'Per schakeldoos één verdeeldoos per groep; fasen en aardlekschakelaars om en om.',
|
||||
'Apparaten (kookgroep, wasmachine, ...) automatisch geplaatst zolang er geen keuken/bijkeuken is; verplaatsbaar in de plattegrond.',
|
||||
'Indicatief eerste ontwerp; uitvoering en controle volgens NEN 1010 door de installateur.'
|
||||
].concat(el.warnings.map(w => 'Let op: ' + w));
|
||||
doc.setFontSize(7);
|
||||
notes.forEach(n => { doc.text(n, 15, y); y += 4.2; });
|
||||
|
||||
let y2 = 30;
|
||||
y2 = PdfSheet.table(doc, 205, y2, [0, 46, 102], ['Leidingtype', 'Gebruik', 'Lengte'],
|
||||
el.cables.map(c => [c.label, c.use, fmt(c.meters) + ' m']), t('Leidingen (strekkende meter)'));
|
||||
y2 += 4;
|
||||
y2 = PdfSheet.table(doc, 205, y2, [0, 102], ['Draad', 'Lengte'], el.wires.map(w => [w.label, fmt(w.meters) + ' m']), t('Draad (per ader)'));
|
||||
y2 += 4;
|
||||
PdfSheet.table(doc, 205, y2, [0, 102], ['Buis', 'Lengte'], el.conduits.map(c => [c.label, fmt(c.meters) + ' m']), t('Installatiebuis'));
|
||||
|
||||
PdfSheet.table(doc, 330, 30, [0, 66], ['Onderdeel', 'Aantal'], el.materials.map(m => [m.naam, `${m.aantal} ${m.eenheid}`]), Object.assign(t('Materiaal'), { maxY: 240 }));
|
||||
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
const lineCol = (doc, type) => doc.setDrawColor(...runColor(type));
|
||||
const clip = (p, q, b) => ElektraModel.clipPlan(p, q, b);
|
||||
const conduitSegs = (el, test) => {
|
||||
const out = [];
|
||||
el.runs.forEach(r => {
|
||||
if (!ElektraModel.cableInfo(r.type).conduit) return;
|
||||
r.path.slice(1).forEach((q, i) => { const p = r.path[i]; if (test(p, q)) out.push({ type: r.type, p, q }); });
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const labelBox = (doc, x, y, text, color) => {
|
||||
const w = 1.6 + text.length * 1.05;
|
||||
doc.setDrawColor(...color);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.rect(x - w / 2, y - 1.2, w, 2.4, 'FD');
|
||||
doc.setTextColor(...color);
|
||||
doc.setFontSize(5);
|
||||
doc.text(text, x, y + 0.7, { align: 'center' });
|
||||
};
|
||||
|
||||
function wallOverlay(el, piece) {
|
||||
if (!el || !el.enabled) return null;
|
||||
const on = (p) => p.wall && p.wall.piece === piece;
|
||||
const segs = conduitSegs(el, (p, q) => on(p) && on(q));
|
||||
const combos = el.combos.filter(c => c.face.piece === piece), wcds = el.wcds.filter(w => w.face.piece === piece);
|
||||
const apps = el.apps.filter(a => a.face.piece === piece), mk = el.meterkast && el.meterkast.face.piece === piece ? el.meterkast : null;
|
||||
if (!segs.length && !combos.length && !wcds.length && !apps.length && !mk) return null;
|
||||
const BLACK = PdfSheet.COLORS.BLACK;
|
||||
return (doc, pu, py) => {
|
||||
doc.setLineWidth(0.35);
|
||||
segs.forEach(sg => {
|
||||
const a = ElektraModel.wallCoords(sg.p.wall, sg.p), b = ElektraModel.wallCoords(sg.q.wall, sg.q);
|
||||
lineCol(doc, sg.type);
|
||||
doc.line(pu(a.u), py(a.y), pu(b.u), py(b.y));
|
||||
});
|
||||
if (mk) {
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(110, 110, 110);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.rect(pu(mk.u - mk.w / 2), py(mk.y + mk.h), (pu(mk.u + mk.w / 2) - pu(mk.u - mk.w / 2)), py(mk.y) - py(mk.y + mk.h), 'FD');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(6);
|
||||
doc.text('MK', pu(mk.u), py(mk.y + mk.h / 2) + 1, { align: 'center' });
|
||||
}
|
||||
combos.forEach(c => labelBox(doc, pu(c.u), py(c.y), c.switches.map(x => x.letter).join(' '), BLACK));
|
||||
wcds.forEach(w => labelBox(doc, pu(w.u), py(w.y), `wcd ${w.group}`, RUN_COLORS.wcd));
|
||||
apps.forEach(a => labelBox(doc, pu(a.u), py(a.y), `${a.code} ${a.group}`, RUN_COLORS.wcd));
|
||||
};
|
||||
}
|
||||
|
||||
function floorOverlay(el, unit) {
|
||||
if (!el || !el.enabled) return null;
|
||||
const r = unit.rect, b = { x0: r.x0, x1: r.x1, z0: r.z0, z1: r.z1 };
|
||||
const segs = conduitSegs(el, (p, q) => p.floor && q.floor).map(sg => Object.assign(sg, { c: clip(sg.p, sg.q, b) })).filter(sg => sg.c);
|
||||
const vw = el.floorWcds.filter(w => w.unitId === unit.id);
|
||||
if (!segs.length && !vw.length) return null;
|
||||
return (doc, px, py) => {
|
||||
doc.setLineWidth(0.35);
|
||||
segs.forEach(sg => {
|
||||
const [a, c] = sg.c;
|
||||
if (Math.hypot(c.x - a.x, c.z - a.z) < 1) return;
|
||||
lineCol(doc, sg.type);
|
||||
doc.line(px(a.x - r.x0), py(a.z - r.z0), px(c.x - r.x0), py(c.z - r.z0));
|
||||
});
|
||||
vw.forEach(w => labelBox(doc, px(w.x - r.x0), py(w.z - r.z0), `vloer-wcd ${w.group}`, RUN_COLORS.wcd));
|
||||
};
|
||||
}
|
||||
|
||||
function roofOverlay(el, unit, pl) {
|
||||
if (!el || !el.enabled) return null;
|
||||
const dm = ElektraModel.roofDeckMap(unit, pl), map = dm.map, b = dm.bounds;
|
||||
const inside = (p) => p.x >= b.x0 - 0.5 && p.x <= b.x1 + 0.5 && p.z >= b.z0 - 0.5 && p.z <= b.z1 + 0.5;
|
||||
const segs = conduitSegs(el, (p, q) => p.ceil && q.ceil).map(sg => Object.assign(sg, { c: clip(sg.p, sg.q, b) })).filter(sg => sg.c);
|
||||
const boxes = el.boxes.filter(bx => inside(bx));
|
||||
const lamps = [];
|
||||
el.switches.forEach(sw => sw.lamps.forEach(l => { if (l.kind === 'binnenspot' && inside(l)) lamps.push({ l, letter: sw.letter }); }));
|
||||
if (!segs.length && !boxes.length && !lamps.length) return null;
|
||||
const BLACK = PdfSheet.COLORS.BLACK;
|
||||
return (doc, px, py) => {
|
||||
doc.setLineWidth(0.35);
|
||||
segs.forEach(sg => {
|
||||
const a = map(sg.c[0]), c = map(sg.c[1]);
|
||||
if (Math.hypot(c.sx - a.sx, c.sy - a.sy) < 1) return;
|
||||
lineCol(doc, sg.type);
|
||||
doc.line(px(a.sx), py(a.sy), px(c.sx), py(c.sy));
|
||||
});
|
||||
doc.setLineWidth(0.2);
|
||||
boxes.forEach(bx => { const m = map(bx); lineCol(doc, 'schakel'); doc.rect(px(m.sx) - 0.9, py(m.sy) - 0.9, 1.8, 1.8, 'S'); });
|
||||
doc.setFontSize(4);
|
||||
lamps.forEach(({ l, letter }) => {
|
||||
const m = map(l);
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 233, 168);
|
||||
doc.circle(px(m.sx), py(m.sy), 0.8, 'FD');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.text(letter, px(m.sx) + 1.1, py(m.sy) - 0.8);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return { RUN_COLORS, drawPlan, drawSheet, PLAN_LEGEND, wallOverlay, floorOverlay, roofOverlay };
|
||||
})();
|
||||
+177
-127
@@ -4,33 +4,13 @@
|
||||
* 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
|
||||
const DENSITY_WOOD_KGM3 = 450;
|
||||
const DENSITY_OSB_KGM3 = 650;
|
||||
const BAY_MARGIN_MM = 15;
|
||||
const MIN_DIAMETER_MM = 20;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Balkvakken: de ruimte tussen twee opeenvolgende balken (in wereld-
|
||||
// coordinaten, zelfde stelsel als calcData.joists). Een doorvoer mag
|
||||
// nooit een balk kruisen, dus wordt altijd binnen zo'n vak geklemd.
|
||||
// ----------------------------------------------------------
|
||||
function computeBays(joists, joistW) {
|
||||
const bays = [];
|
||||
for (let i = 0; i < joists.length - 1; i++) {
|
||||
@@ -52,19 +32,9 @@ window.FloorModel = (() => {
|
||||
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.
|
||||
// Past de diameter niet (ook niet verkleind tot MIN_DIAMETER_MM), dan vervalt de doorvoer alleen uit
|
||||
// tekening en telling: hij blijft in de invoer, zodat een andere balkafstand hem terugbrengt.
|
||||
// Overlap van twee doorvoeren in hetzelfde vak wordt gemeld, niet opgelost (bewust).
|
||||
function resolveFloorOpenings(rawOpenings, width, depth, joists, joistW) {
|
||||
const bays = computeBays(joists, joistW);
|
||||
|
||||
@@ -111,14 +81,6 @@ window.FloorModel = (() => {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Balkverdeling: zelfde algoritme (calculateSegmentPositions) als de wand.
|
||||
// params = state.floor (of de ruwe invoerwaarden uit de editor).
|
||||
// rims (optioneel): { front, back } — kopbalk langs de voor-/achterrand
|
||||
// (haaks op de balken), bijv. op een naad tussen twee units waar de
|
||||
// modules weer aan elkaar gemonteerd worden. De langsbalken worden daar
|
||||
// met één balkbreedte ingekort. Langs de zijranden ligt altijd al een balk.
|
||||
// ----------------------------------------------------------
|
||||
function computeFrame(params, rims) {
|
||||
const width = params.width || 2000;
|
||||
const depth = params.depth || 2000;
|
||||
@@ -140,18 +102,106 @@ window.FloorModel = (() => {
|
||||
return { joists, width, depth, joistW, joistH, underlayThick, rims: r, joistLen };
|
||||
}
|
||||
|
||||
// Bovenkant vloer (mm) = waar de wanden van een unit op staan.
|
||||
// Rechthoekige sparingen (schoorsteen door het dak): x t.o.v. het midden (-width/2..width/2), y 0..depth.
|
||||
// Raveelwerk zodat de plaat overal op hout eindigt: raveelstijlen over de volle lengte langs de zijkanten (tenzij de
|
||||
// sparing tot de rand loopt), raveelbalken dwars ervoor en erna tussen die stijlen, kreupele balken daartussen tot
|
||||
// de raveelbalk. Een gewone balk die een raveelstijl zou raken vervalt; de randbalk is onderbroken.
|
||||
function normalizeCuts(cuts, width, depth) {
|
||||
return (cuts || []).map(c => ({
|
||||
x0: Math.max(-width / 2, Math.min(c.x0, c.x1)), x1: Math.min(width / 2, Math.max(c.x0, c.x1)),
|
||||
y0: Math.max(0, Math.min(c.y0, c.y1)), y1: Math.min(depth, Math.max(c.y0, c.y1))
|
||||
})).filter(c => c.x1 - c.x0 > 1 && c.y1 - c.y0 > 1);
|
||||
}
|
||||
function joistLayout(calcData) {
|
||||
const { joists, joistW, depth, width } = calcData, rims = calcData.rims || {}, cuts = calcData.cuts || [];
|
||||
const half = width / 2, ya = rims.front ? joistW : 0, yb = depth - (rims.back ? joistW : 0);
|
||||
const frames = cuts.map(c => ({
|
||||
c,
|
||||
sides: [c.x0 > -half + joistW ? c.x0 - joistW / 2 : null, c.x1 < half - joistW ? c.x1 + joistW / 2 : null].filter(x => x !== null),
|
||||
head: c.y0 > ya + joistW, tail: c.y1 < yb - joistW
|
||||
}));
|
||||
const overlaps = (x, a, b) => x + joistW / 2 > a + 0.5 && x - joistW / 2 < b - 0.5;
|
||||
const pieces = [];
|
||||
joists.forEach(x => {
|
||||
if (frames.some(f => f.sides.some(sx => overlaps(x, sx - joistW / 2, sx + joistW / 2)))) return;
|
||||
let segs = [[ya, yb]];
|
||||
frames.filter(f => overlaps(x, f.c.x0, f.c.x1)).forEach(f => {
|
||||
const a0 = f.head ? f.c.y0 - joistW : f.c.y0, b0 = f.tail ? f.c.y1 + joistW : f.c.y1;
|
||||
segs = segs.flatMap(([a, b]) => [[a, Math.min(b, a0)], [Math.max(a, b0), b]]).filter(([a, b]) => b - a > 1);
|
||||
});
|
||||
pieces.push({ x, segs });
|
||||
});
|
||||
const trimmers = [];
|
||||
frames.forEach(f => {
|
||||
f.sides.forEach(sx => { if (!pieces.some(p => overlaps(p.x, sx - joistW / 2, sx + joistW / 2))) pieces.push({ x: sx, segs: [[ya, yb]], frame: true }); });
|
||||
const x0 = f.c.x0 > -half + joistW ? f.c.x0 : -half, x1 = f.c.x1 < half - joistW ? f.c.x1 : half;
|
||||
if (f.head) trimmers.push({ x0, x1, y0: f.c.y0 - joistW, y1: f.c.y0 });
|
||||
if (f.tail) trimmers.push({ x0, x1, y0: f.c.y1, y1: f.c.y1 + joistW });
|
||||
});
|
||||
pieces.sort((a, b) => a.x - b.x);
|
||||
// Randbalken (dwars, voor en achter): onderbroken waar een sparing tot in de rand loopt.
|
||||
const rimRuns = [['front', 0, joistW], ['back', depth - joistW, depth]].filter(([k]) => rims[k]).map(([k, y0, y1]) => {
|
||||
let segs = [[-half, half]];
|
||||
cuts.filter(c => c.y1 > y0 + 0.5 && c.y0 < y1 - 0.5).forEach(c => {
|
||||
segs = segs.flatMap(([a, b]) => [[a, Math.min(b, c.x0)], [Math.max(a, c.x1), b]]).filter(([a, b]) => b - a > 1);
|
||||
});
|
||||
return { key: k, y0, y1, segs };
|
||||
});
|
||||
return { pieces, trimmers, rimRuns };
|
||||
}
|
||||
// Bovenvlak (plaat, afwerking) als vormen in x/(y - depth/2), met de doorvoeren als gaten.
|
||||
function surfaceShapes(calcData, resolvedOpenings, k) {
|
||||
const s = k || 1, W = calcData.width, D = calcData.depth, cuts = calcData.cuts || [];
|
||||
const pts = (loop) => loop.map(p => new THREE.Vector2(p.x * s, (p.z - D / 2) * s));
|
||||
let regions;
|
||||
if (!cuts.length) regions = [{ outer: [{ x: -W / 2, z: 0 }, { x: W / 2, z: 0 }, { x: W / 2, z: D }, { x: -W / 2, z: D }], holes: [] }];
|
||||
else {
|
||||
const xs = [...new Set([-W / 2, W / 2, ...cuts.flatMap(c => [c.x0, c.x1])])].sort((a, b) => a - b);
|
||||
const ys = [...new Set([0, D, ...cuts.flatMap(c => [c.y0, c.y1])])].sort((a, b) => a - b);
|
||||
const cells = [];
|
||||
for (let i = 0; i < xs.length - 1; i++) for (let j = 0; j < ys.length - 1; j++) {
|
||||
const xm = (xs[i] + xs[i + 1]) / 2, ym = (ys[j] + ys[j + 1]) / 2;
|
||||
if (!cuts.some(c => xm > c.x0 && xm < c.x1 && ym > c.y0 && ym < c.y1)) cells.push({ x0: xs[i], x1: xs[i + 1], z0: ys[j], z1: ys[j + 1] });
|
||||
}
|
||||
const loops = rectUnionLoops(cells);
|
||||
const inside = (pt, poly) => {
|
||||
let c = false;
|
||||
for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) {
|
||||
if ((poly[a].z > pt.z) !== (poly[b].z > pt.z) && pt.x < (poly[b].x - poly[a].x) * (pt.z - poly[a].z) / (poly[b].z - poly[a].z) + poly[a].x) c = !c;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
const sign = Math.sign(loops[0].area);
|
||||
regions = loops.filter(l => Math.sign(l.area) === sign).map(l => ({ outer: l.pts, holes: [] }));
|
||||
loops.filter(l => Math.sign(l.area) !== sign).forEach(l => {
|
||||
const owner = regions.find(r => inside(l.pts[0], r.outer));
|
||||
if (owner) owner.holes.push(l.pts);
|
||||
});
|
||||
}
|
||||
return regions.map(rg => {
|
||||
const shape = new THREE.Shape(pts(rg.outer));
|
||||
rg.holes.forEach(h => shape.holes.push(new THREE.Path(pts(h))));
|
||||
resolvedOpenings.filter(o => !o.removed && o.r > 0 && inside2(o, rg.outer)).forEach(o => {
|
||||
const hole = new THREE.Path();
|
||||
hole.absarc(o.x * s, (o.y - D / 2) * s, o.r * s, 0, Math.PI * 2, false);
|
||||
shape.holes.push(hole);
|
||||
});
|
||||
return shape;
|
||||
});
|
||||
function inside2(o, poly) {
|
||||
let c = false;
|
||||
for (let a = 0, b = poly.length - 1; a < poly.length; b = a++) {
|
||||
if ((poly[a].z > o.y) !== (poly[b].z > o.y) && o.x < (poly[b].x - poly[a].x) * (o.y - poly[a].z) / (poly[b].z - poly[a].z) + poly[a].x) c = !c;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// ----------------------------------------------------------
|
||||
// Isolatie krijgt bewust geen gewicht: de materiaalkeuze is aan de gebruiker.
|
||||
function computeSpecs(calcData, resolvedOpenings) {
|
||||
const toM = 0.001;
|
||||
const { width, depth, joists, joistW, joistH, underlayThick } = calcData;
|
||||
@@ -160,7 +210,12 @@ window.FloorModel = (() => {
|
||||
const rimCount = calcData.rims ? (calcData.rims.front ? 1 : 0) + (calcData.rims.back ? 1 : 0) : 0;
|
||||
const joistLenM = (calcData.joistLen || depth) * toM;
|
||||
|
||||
const joistWoodM3 = joists.length * joistWM * joistHM * joistLenM + rimCount * joistWM * joistHM * widthM;
|
||||
const cuts = calcData.cuts || [];
|
||||
const lay = cuts.length ? joistLayout(calcData) : null;
|
||||
const joistMM = lay ? lay.pieces.reduce((t, p) => t + p.segs.reduce((u, [a, b]) => u + b - a, 0), 0) + lay.trimmers.reduce((t, tr) => t + tr.x1 - tr.x0, 0)
|
||||
: joists.length * (calcData.joistLen || depth);
|
||||
const rimMM = lay ? lay.rimRuns.reduce((t, r) => t + r.segs.reduce((u, [a, b]) => u + b - a, 0), 0) : rimCount * width;
|
||||
const joistWoodM3 = joistMM * toM * joistWM * joistHM + rimMM * toM * joistWM * joistHM;
|
||||
const joistWoodKg = joistWoodM3 * DENSITY_WOOD_KGM3;
|
||||
|
||||
const grossAreaM2 = widthM * depthM;
|
||||
@@ -170,6 +225,7 @@ window.FloorModel = (() => {
|
||||
holesAreaM2 += Math.PI * rM * rM;
|
||||
holesVoidM3 += Math.PI * rM * rM * joistHM;
|
||||
});
|
||||
cuts.forEach(c => { holesAreaM2 += (c.x1 - c.x0) * (c.y1 - c.y0) * toM * toM; });
|
||||
const osbAreaM2 = Math.max(0, grossAreaM2 - holesAreaM2);
|
||||
const osbVolM3 = osbAreaM2 * underlayM;
|
||||
const osbKg = osbVolM3 * DENSITY_OSB_KGM3;
|
||||
@@ -183,13 +239,6 @@ window.FloorModel = (() => {
|
||||
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;
|
||||
@@ -204,34 +253,61 @@ window.FloorModel = (() => {
|
||||
return { wheels, outOfBounds };
|
||||
}
|
||||
|
||||
// Alles in één keer vanuit een vloer-state (handig voor de unit).
|
||||
// rims: zie computeFrame.
|
||||
function computeFromState(state, rims) {
|
||||
const calcData = computeFrame(state.floor, rims);
|
||||
calcData.cuts = normalizeCuts(state.cuts, calcData.width, calcData.depth);
|
||||
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 };
|
||||
}
|
||||
|
||||
// Vloerafwerking (bovenop de OSB). Texturen in assets/textures/vloer/,
|
||||
// naadloos herhalend; tileMM = maat van één tegel van de textuur.
|
||||
const FINISHES = {
|
||||
geen: { label: 'Geen (OSB)', color: '#cda86a' },
|
||||
visgraat_001: { label: 'Visgraat (Hongaarse punt)', color: '#a28670', file: 'assets/textures/vloer/visgraat_001.jpg', tileMM: 3000 }
|
||||
visgraat_001: { label: 'Visgraat (Hongaarse punt)', color: '#a28670', file: 'assets/textures/vloer/visgraat_001.jpg', tileMM: 3000 },
|
||||
tegel_wit: { label: 'Tegel wit 300×300', color: '#ecebe6', tile: { w: 300, h: 300, joint: 3 }, jointColor: '#b9b6ad' },
|
||||
tegel_grijs: { label: 'Tegel grijs 600×600', color: '#a9a8a3', tile: { w: 600, h: 600, joint: 3 }, jointColor: '#7d7c78' },
|
||||
tegel_antraciet: { label: 'Tegel antraciet 600×600', color: '#4a4b4d', tile: { w: 600, h: 600, joint: 3 }, jointColor: '#2c2d2e' }
|
||||
};
|
||||
// Voorvertoning (assets/textures/vloer/thumb/<px>/<naam>.png) of null.
|
||||
function tileTexture(tile, color, jointColor) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const k = 512 / Math.max(tile.w, tile.h);
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = Math.max(8, Math.round(tile.w * k));
|
||||
cv.height = Math.max(8, Math.round(tile.h * k));
|
||||
const c = cv.getContext('2d');
|
||||
c.fillStyle = color;
|
||||
c.fillRect(0, 0, cv.width, cv.height);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
c.fillStyle = `rgba(${Math.random() < 0.5 ? '255,255,255' : '0,0,0'},0.025)`;
|
||||
c.fillRect(Math.random() * cv.width, Math.random() * cv.height, 6 + Math.random() * 20, 6 + Math.random() * 20);
|
||||
}
|
||||
const j = Math.max(1, Math.round(tile.joint * k));
|
||||
c.fillStyle = jointColor;
|
||||
c.fillRect(0, 0, cv.width, j);
|
||||
c.fillRect(0, 0, j, cv.height);
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(1000 / tile.w, 1000 / tile.h);
|
||||
tex.anisotropy = 8;
|
||||
return tex;
|
||||
}
|
||||
function finishThumbUrl(key, px) {
|
||||
const f = FINISHES[key];
|
||||
return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null;
|
||||
return f && f.file ? tectumUrl(f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png'))) : null;
|
||||
}
|
||||
const finishMaterials = {};
|
||||
function finishMaterial(key) {
|
||||
const f = FINISHES[key];
|
||||
if (!f || !f.file) return null;
|
||||
if (!f || !(f.file || f.tile)) return null;
|
||||
if (finishMaterials[key]) return finishMaterials[key];
|
||||
if (f.tile) {
|
||||
const tex = tileTexture(f.tile, f.color, f.jointColor);
|
||||
finishMaterials[key] = new THREE.MeshStandardMaterial(tex ? { map: tex, roughness: 0.45 } : { color: f.color, roughness: 0.45 });
|
||||
return finishMaterials[key];
|
||||
}
|
||||
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
|
||||
if (typeof document !== 'undefined' && THREE.TextureLoader) {
|
||||
new THREE.TextureLoader().load(f.file, (tex) => {
|
||||
new THREE.TextureLoader().load(tectumUrl(f.file), (tex) => {
|
||||
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
||||
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
|
||||
tex.anisotropy = 8;
|
||||
@@ -252,16 +328,7 @@ window.FloorModel = (() => {
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 3D-opbouw (meters): X = breedte, Z = diepte (y=0 "voor" ligt op
|
||||
// Z = -diepte/2), Y = dikte (balkhoogte + OSB). Balken en isolatie zijn
|
||||
// simpele dozen; de OSB-onderlaag is een geëxtrudeerd Shape met ronde
|
||||
// uitsparingen voor de doorvoeren.
|
||||
// opts: { materials, osbVisible, finish }
|
||||
// finish (optioneel): { key (FINISHES), originX, originZ } — afwerking als
|
||||
// dun vlak op de OSB; UV's in meters vanaf origin (m, woning-coördinaten
|
||||
// van het vloermidden), zodat het patroon over units doorloopt.
|
||||
// ----------------------------------------------------------
|
||||
// finish.originX/Z: UV's in woning-meters, zodat het patroon over units doorloopt.
|
||||
function build3D(calcData, resolvedOpenings, chassisData, opts) {
|
||||
const { woodMaterial, osbMaterial, wheelMaterial } = opts.materials;
|
||||
const osbVisible = opts.osbVisible !== false;
|
||||
@@ -272,50 +339,49 @@ window.FloorModel = (() => {
|
||||
const widthM = width * toM, depthM = depth * toM;
|
||||
const joistWM = joistW * toM, joistHM = joistH * toM, underlayM = underlayThick * toM;
|
||||
|
||||
// A. VLOERBALKEN — dozen over de diepte (ingekort bij een kopbalk), plus
|
||||
// de kopbalken (rims) over de volle breedte.
|
||||
const rims = calcData.rims || {};
|
||||
const joistLenM = (calcData.joistLen || depth) * toM;
|
||||
const joistZ = ((rims.front ? joistW : 0) - (rims.back ? joistW : 0)) / 2 * toM;
|
||||
joists.forEach(xMM => {
|
||||
if ((calcData.cuts || []).length) {
|
||||
const lay = joistLayout(calcData);
|
||||
const box = (x0, x1, y0, y1) => {
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry((x1 - x0) * toM, joistHM, (y1 - y0) * toM), woodMaterial);
|
||||
mesh.position.set((x0 + x1) / 2 * toM, joistHM / 2, ((y0 + y1) / 2 - depth / 2) * toM);
|
||||
mesh.castShadow = true;
|
||||
floorGroup3D.add(mesh);
|
||||
};
|
||||
const outer = [joists[0], joists[joists.length - 1]];
|
||||
lay.pieces.forEach(p => {
|
||||
// Plaat zichtbaar: alleen de buitenste balken en het raveelwerk (zoals zonder sparing).
|
||||
if (opts.outerJoistsOnly && !outer.includes(p.x) && !p.frame && p.segs.length === 1 && p.segs[0][1] - p.segs[0][0] > depth - 3 * joistW) return;
|
||||
p.segs.forEach(([a, b]) => box(p.x - joistW / 2, p.x + joistW / 2, a, b));
|
||||
});
|
||||
lay.trimmers.forEach(t => box(t.x0, t.x1, t.y0, t.y1));
|
||||
} else joists.forEach((xMM, i) => {
|
||||
if (opts.outerJoistsOnly && i > 0 && i < joists.length - 1) return;
|
||||
const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM);
|
||||
const mesh = new THREE.Mesh(geo, woodMaterial);
|
||||
mesh.position.set(xMM * toM, joistHM / 2, joistZ);
|
||||
mesh.castShadow = true;
|
||||
floorGroup3D.add(mesh);
|
||||
});
|
||||
[['front', -1], ['back', 1]].filter(([k]) => rims[k]).forEach(([, sign]) => {
|
||||
if ((calcData.cuts || []).length) {
|
||||
joistLayout(calcData).rimRuns.forEach(r => r.segs.forEach(([a, b]) => {
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry((b - a) * toM, joistHM, joistWM), woodMaterial);
|
||||
mesh.position.set((a + b) / 2 * toM, joistHM / 2, ((r.y0 + r.y1) / 2 - depth / 2) * toM);
|
||||
mesh.castShadow = true;
|
||||
floorGroup3D.add(mesh);
|
||||
}));
|
||||
} else [['front', -1], ['back', 1]].filter(([k]) => rims[k]).forEach(([, sign]) => {
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(widthM, joistHM, joistWM), woodMaterial);
|
||||
mesh.position.set(0, joistHM / 2, sign * (depthM - joistWM) / 2);
|
||||
mesh.castShadow = true;
|
||||
floorGroup3D.add(mesh);
|
||||
});
|
||||
|
||||
// (Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.)
|
||||
// Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.
|
||||
|
||||
// C. OSB-ONDERLAAG — één geëxtrudeerd vlak over de volle vloer, met
|
||||
// ronde uitsparingen voor de doorvoeren. Shape wordt opgebouwd in
|
||||
// (worldX, worldZ)-vlak en vervolgens 90° gekanteld zodat de dikte
|
||||
// (extrusierichting) verticaal (wereld-Y) komt te liggen, boven op de
|
||||
// balken.
|
||||
const osbShape = new THREE.Shape();
|
||||
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 osbGeo = new THREE.ExtrudeGeometry(surfaceShapes(calcData, resolvedOpenings, toM), { 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);
|
||||
@@ -332,8 +398,6 @@ window.FloorModel = (() => {
|
||||
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;
|
||||
@@ -345,22 +409,9 @@ window.FloorModel = (() => {
|
||||
floorGroup3D.add(mesh);
|
||||
});
|
||||
|
||||
// Vloerafwerking: vlak net boven de OSB, over de hele vloer (onder de
|
||||
// wanden verdwijnt het), met de doorvoeren uitgespaard.
|
||||
const fin = opts.finish && finishMaterial(opts.finish.key);
|
||||
if (fin) {
|
||||
const shape = new THREE.Shape();
|
||||
shape.moveTo(-widthM / 2, -depthM / 2);
|
||||
shape.lineTo(widthM / 2, -depthM / 2);
|
||||
shape.lineTo(widthM / 2, depthM / 2);
|
||||
shape.lineTo(-widthM / 2, depthM / 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 - depthM / 2, o.r * toM, 0, Math.PI * 2, false);
|
||||
shape.holes.push(hole);
|
||||
});
|
||||
const geo = new THREE.ShapeGeometry(shape);
|
||||
const geo = new THREE.ShapeGeometry(surfaceShapes(calcData, resolvedOpenings, toM));
|
||||
const ox = opts.finish.originX || 0, oz = opts.finish.originZ || 0;
|
||||
const pos = geo.attributes.position, uv = geo.attributes.uv;
|
||||
// Na het kantelen (-90° om X) wordt vorm-y wereld -z.
|
||||
@@ -374,7 +425,6 @@ window.FloorModel = (() => {
|
||||
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'];
|
||||
@@ -398,7 +448,7 @@ window.FloorModel = (() => {
|
||||
}
|
||||
|
||||
return {
|
||||
FINISHES, finishMaterial, finishThumbUrl, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
|
||||
computeSpecs, computeFromState, createMaterials, build3D
|
||||
FINISHES, finishMaterial, finishThumbUrl, tileTexture, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
|
||||
computeSpecs, computeFromState, createMaterials, build3D, surfaceShapes, joistLayout
|
||||
};
|
||||
})();
|
||||
|
||||
+6
-29
@@ -4,25 +4,10 @@
|
||||
* 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.
|
||||
// ------------------------------------------------------------
|
||||
// Bewust altijd één doorlopende module (de voorbeelden in examples/ splitsen grote vloeren).
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -31,8 +16,7 @@ window.FloorPdf = (() => {
|
||||
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. ---
|
||||
// Eén groep: alle vloerbalken hebben dezelfde doorsnede en lengte.
|
||||
const tblX = [8, 26, 42, 58, 76, 92];
|
||||
let ty = 14;
|
||||
doc.setTextColor(...BLACK);
|
||||
@@ -51,12 +35,11 @@ window.FloorPdf = (() => {
|
||||
[String(1), String(joistW), String(joistH), String(Math.round(joistLen)), String(joists.length)]
|
||||
.forEach((v, i) => doc.text(v, tblX[i], ty));
|
||||
if (rimCount) {
|
||||
ty += 4.3; // kopbalk(en) op een naad (FloorModel.computeFrame)
|
||||
ty += 4.3;
|
||||
[String(2), String(joistW), String(joistH), String(Math.round(width)), String(rimCount)]
|
||||
.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;
|
||||
@@ -73,10 +56,8 @@ window.FloorPdf = (() => {
|
||||
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;
|
||||
@@ -86,7 +67,7 @@ window.FloorPdf = (() => {
|
||||
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
|
||||
const py = (ymm) => topY + ymm * scale;
|
||||
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.25);
|
||||
@@ -117,6 +98,8 @@ window.FloorPdf = (() => {
|
||||
doc.text('#' + o.id, cx, cy - o.r * scale - 2, { align: 'center' });
|
||||
});
|
||||
|
||||
if (opts.overlay) opts.overlay(doc, px, py);
|
||||
|
||||
const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2);
|
||||
|
||||
doc.setDrawColor(...BLUE);
|
||||
@@ -131,8 +114,6 @@ window.FloorPdf = (() => {
|
||||
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);
|
||||
@@ -151,8 +132,6 @@ window.FloorPdf = (() => {
|
||||
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;
|
||||
@@ -160,8 +139,6 @@ window.FloorPdf = (() => {
|
||||
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);
|
||||
|
||||
+39
-149
@@ -4,31 +4,12 @@
|
||||
* 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-app" class="ed-app">
|
||||
<div class="ed-main">
|
||||
<div class="ed-toolbar" id="floor-toolbar"></div>
|
||||
<div class="ed-views">
|
||||
<div id="floor-left-column">
|
||||
<div id="floor-pane-2d-container">
|
||||
<div class="pane-2d-sub" id="floor-pane-top">
|
||||
@@ -40,7 +21,15 @@ const FLOOR_TEMPLATE = `
|
||||
<canvas id="floor-canvas-section"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div id="floor-control-panel">
|
||||
</div>
|
||||
<div id="floor-right-column">
|
||||
<div id="floor-pane-3d">
|
||||
<div class="pane-label">3D</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="floor-control-panel" class="ed-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>
|
||||
@@ -49,6 +38,7 @@ const FLOOR_TEMPLATE = `
|
||||
<button class="tab-btn active" data-tab="floor-tab-main"><i class="bi bi-rulers"></i> Opbouw</button>
|
||||
<button class="tab-btn" data-tab="floor-tab-openings"><i class="bi bi-bullseye"></i> Doorvoeren</button>
|
||||
<button class="tab-btn" data-tab="floor-tab-chassis"><i class="bi bi-truck"></i> Chassis</button>
|
||||
<button class="tab-btn" data-tab="floor-tab-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
|
||||
</div>
|
||||
|
||||
<div id="floor-tab-main" class="tab-content active">
|
||||
@@ -79,20 +69,6 @@ const FLOOR_TEMPLATE = `
|
||||
<div class="control-group"><label>Afstand max.</label>
|
||||
<div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="result-row"><span>Vloerbalken</span><span data-spec="joist-count" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Vloeroppervlak</span><span data-spec="floor-area" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>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 oppervlak</span><span data-spec="osb-m2" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>OSB volume</span><span data-spec="osb-m3" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>OSB gewicht</span><span data-spec="osb-kg" class="highlight">-</span></div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="result-row"><span>Isolatie</span><span data-spec="void-m3" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Totaal gewicht</span><span data-spec="total-kg" class="highlight">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,9 +80,8 @@ const FLOOR_TEMPLATE = `
|
||||
</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="control-group"><label for="f-chassis-on">Chassis-laag</label>
|
||||
<div><label class="ui-switch"><input type="checkbox" id="f-chassis-on"><span></span></label></div></div>
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<div class="control-group"><label>Wieldiameter</label>
|
||||
@@ -121,17 +96,9 @@ const FLOOR_TEMPLATE = `
|
||||
</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</div>
|
||||
<div class="toolbar-3d" id="floor-toolbar-3d"></div>
|
||||
</div>
|
||||
|
||||
<div id="floor-tab-specs" class="tab-content">
|
||||
<div id="floor-specs-panel-3d">
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<div>
|
||||
<div class="spec-section">
|
||||
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
||||
@@ -161,6 +128,7 @@ const FLOOR_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wall-project-overlay">
|
||||
<div id="wall-project-modal">
|
||||
@@ -191,16 +159,13 @@ const FLOOR_TEMPLATE = `
|
||||
</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>
|
||||
<button type="button" id="wall-project-cancel" class="ui-btn" data-esc>Annuleren</button>
|
||||
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">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 = [
|
||||
[' Vloerbalken</h1>', ' Dakbalken</h1>'],
|
||||
@@ -220,8 +185,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
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);
|
||||
@@ -230,25 +193,12 @@ function mountFloorEditor(container, callbacks) {
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// State
|
||||
// ----------------------------------------------------------
|
||||
// Dichtheden, balkvak-marge en minimale doorvoer-diameter: zie FloorModel.
|
||||
|
||||
// Geen residuele demo-doorvoer meer — leeg beginnen, gebruiker voegt zelf toe.
|
||||
let openings = [];
|
||||
let 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).
|
||||
// Chassis wordt niet geklemd (anders dan doorvoeren), alleen gewaarschuwd buiten de vloerrand.
|
||||
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'));
|
||||
@@ -325,16 +275,14 @@ function mountFloorEditor(container, callbacks) {
|
||||
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-chassis-on').checked = chassis.enabled;
|
||||
}
|
||||
document.getElementById('f-btn-chassis-toggle').addEventListener('click', () => {
|
||||
chassis.enabled = !chassis.enabled;
|
||||
document.getElementById('f-chassis-on').addEventListener('change', (e) => {
|
||||
chassis.enabled = e.target.checked;
|
||||
syncChassisToggleLabel();
|
||||
updateAll();
|
||||
});
|
||||
document.querySelectorAll('#floor-tab-chassis input').forEach(inp => inp.addEventListener('input', () => {
|
||||
document.querySelectorAll('#floor-tab-chassis input[type=number]').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;
|
||||
@@ -342,11 +290,7 @@ function mountFloorEditor(container, callbacks) {
|
||||
}));
|
||||
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).
|
||||
// ------------------------------------------------------------
|
||||
// Bewust dezelfde id's als wall.js: er is nooit meer dan één editor tegelijk gemount.
|
||||
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;
|
||||
@@ -375,12 +319,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// JSON export/import — zelfde patroon als wall.js: sla het volledige
|
||||
// vloer-ontwerp op (parameters + chassis + ruwe doorvoeren) als JSON, en
|
||||
// laad het weer terug. Bij "Opslaan als" wordt ook automatisch de
|
||||
// bijbehorende PDF-tekening gegenereerd (zie generateFloorPdf).
|
||||
// ------------------------------------------------------------
|
||||
function setIoStatus(msg, cls) {
|
||||
const el = document.getElementById('f-io-status');
|
||||
el.textContent = msg;
|
||||
@@ -456,12 +394,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
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) {
|
||||
@@ -483,8 +415,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
});
|
||||
}
|
||||
|
||||
// Specificaties: FloorModel.computeSpecs (balkhout, OSB, isolatie-leegte,
|
||||
// gewichtstotaal).
|
||||
function updateSpecificaties(resolvedOpenings) {
|
||||
const specs = roofMode ? RoofModel.computeSpecs(calcData, resolvedOpenings) : FloorModel.computeSpecs(calcData, resolvedOpenings);
|
||||
if (roofMode) {
|
||||
@@ -508,17 +438,12 @@ function mountFloorEditor(container, callbacks) {
|
||||
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');
|
||||
|
||||
@@ -570,7 +495,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
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));
|
||||
@@ -579,10 +503,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
'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');
|
||||
|
||||
@@ -594,7 +514,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
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;
|
||||
@@ -610,9 +529,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
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));
|
||||
|
||||
@@ -622,7 +538,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
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));
|
||||
@@ -642,7 +557,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
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));
|
||||
@@ -651,9 +565,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
'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');
|
||||
@@ -672,14 +583,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// THREE.JS 3D RENDERER — zelfde opzet als wall.js (scene/camera/renderer/
|
||||
// OrbitControls/licht/grid), maar plat gelegd: X = breedte, Z = diepte,
|
||||
// Y = dikte (balkhoogte + OSB), in plaats van wall.js' X/Y/Z (breedte/
|
||||
// hoogte/diepte). Balken en isolatie zijn simpele dozen; de OSB-onderlaag
|
||||
// is — net als de platen bij wall.js — een geëxtrudeerd Shape met ronde
|
||||
// uitsparingen voor de doorvoeren.
|
||||
// ----------------------------------------------------------
|
||||
const pane3D = document.getElementById('floor-pane-3d');
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a1a);
|
||||
@@ -717,22 +620,21 @@ function mountFloorEditor(container, callbacks) {
|
||||
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)' }
|
||||
], (v) => {
|
||||
osbVisible = v.beplating;
|
||||
update3DScene();
|
||||
});
|
||||
// Omgeving (core/skybox.js: geen/dag/nacht); de vloer staat op de tegelvloer.
|
||||
let skyMode = Skybox.getMode();
|
||||
const skyEnv = Skybox.createEnvironment(scene);
|
||||
const applySky = () => {
|
||||
skyEnv.fitTo(floorGroup3D, 0);
|
||||
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient: floorAmbient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: floorGroup3D });
|
||||
};
|
||||
Skybox.createToggle(document.getElementById('floor-toolbar-3d'), (m) => { skyMode = m; applySky(); });
|
||||
const bar = UI.editorBar(document.getElementById('floor-toolbar'), document.getElementById('floor-app'), {
|
||||
onResize: () => onWindowResize(),
|
||||
layers: {
|
||||
layers: {}, keys: [],
|
||||
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
|
||||
onExtra: (v) => { osbVisible = v.beplating; update3DScene(); }
|
||||
},
|
||||
onSky: (m) => { skyMode = m; applySky(); }
|
||||
});
|
||||
|
||||
function update3DScene() {
|
||||
if (floorGroup3D) scene.remove(floorGroup3D);
|
||||
@@ -740,13 +642,9 @@ function mountFloorEditor(container, callbacks) {
|
||||
? RoofModel.build3D(calcData, lastResolvedOpenings, { materials: floorMaterials, osbVisible, dakleerVisible: osbVisible })
|
||||
: FloorModel.build3D(calcData, lastResolvedOpenings, lastChassisData, { materials: floorMaterials, osbVisible });
|
||||
scene.add(floorGroup3D);
|
||||
applySky(); // schaduw van de nieuwe vloer
|
||||
applySky();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// PDF-export: het tekenblad zelf komt uit FloorPdf (floor/floor-pdf.js),
|
||||
// gedeeld met de unit-PDF.
|
||||
// ------------------------------------------------------------
|
||||
function generateFloorPdf() {
|
||||
recalculate();
|
||||
const resolvedOpenings = FloorModel.resolveOpenings(openings, calcData.width, calcData.depth, calcData.joists, calcData.joistW);
|
||||
@@ -783,14 +681,6 @@ function mountFloorEditor(container, callbacks) {
|
||||
}
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
function toggleFullscreen3D() {
|
||||
UI.cycle3D(document.getElementById('floor-app')); // T: standaard -> 3D groot -> alleen render
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
function toggleFullscreen2D() {
|
||||
UI.toggle2D(document.getElementById('floor-app'));
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
|
||||
function getPureState() {
|
||||
return {
|
||||
@@ -840,8 +730,8 @@ function mountFloorEditor(container, callbacks) {
|
||||
},
|
||||
exportDesign,
|
||||
triggerImport,
|
||||
toggleFullscreen3D,
|
||||
toggleFullscreen2D,
|
||||
setView: bar.setView,
|
||||
viewState: bar.viewState,
|
||||
openProjectProperties,
|
||||
getStateJson: getPureState,
|
||||
loadStateJson: applyPureState
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Schoorsteen tegen een buitenwand: hol, binnen vlak met de wand, buiten (iets) voor de gevel. Hij maakt in elke
|
||||
// buitenwand in hetzelfde vlak die hij raakt een sparing zoals een raam (ook over een naad tussen units heen) en
|
||||
// onderbreekt de dakrand; aan de voorkant steekt hij minstens tot voorbij de dakoverstek uit.
|
||||
// Lokaal assenstelsel per schoorsteen: t langs de wand (wereld-x of -z), s loodrecht erop, 0 = buitenkant regelwerk
|
||||
// van de unit (rand van de vloer), positief naar buiten.
|
||||
|
||||
window.ChimneyModel = (() => {
|
||||
const EPS = 0.5;
|
||||
const SHELL_MM = 100; // wand rond het rookkanaal
|
||||
const CAP_MM = 60, CAP_OVER_MM = 30;
|
||||
const FLUE_SHOW_MM = 500; // zichtbare diepte van het rookkanaal onder de kap
|
||||
const INSET_MM = 2; // sparingen in wand, dak en dakrand net binnen de schoorsteen (geen z-fighting)
|
||||
const BEAR_MM = -4; // nokbalk stopt net vóór de binnenkant van de schoorsteen
|
||||
const MIN_CUT_MM = 100;
|
||||
const CORNER_CLEAR_MM = 200; // slepen: zoveel vrij van een buitenhoek (gevelbekleding van de haakse wand)
|
||||
const INSIDE_TYPES = { wand: 'Zoals de wanden', stuc: 'Stucwerk', steen: 'Steenstrip' };
|
||||
const OUTSIDE_TYPES = { steen: 'Steenstrip', stuc: 'Stucwerk' };
|
||||
const DEFAULTS = { w: 800, out: 180, above: 600, inside: { type: 'wand', stone: 'R777', stuc: 'wit' }, outside: { type: 'steen', stone: 'R777', stuc: 'wit' } };
|
||||
const SIDES = {
|
||||
voor: { axis: 'x', n: { x: 0, z: -1 } }, achter: { axis: 'x', n: { x: 0, z: 1 } },
|
||||
links: { axis: 'z', n: { x: -1, z: 0 } }, rechts: { axis: 'z', n: { x: 1, z: 0 } }
|
||||
};
|
||||
|
||||
const skinOf = (finish) => finish.type === 'steen' ? WallModel.STONE.STRIP_MM : finish.type === 'stuc' ? WallModel.STUC.THICK_MM : 0;
|
||||
|
||||
function normalizeFinish(f, types, def) {
|
||||
const o = Object.assign({}, def, f || {});
|
||||
if (!types[o.type]) o.type = def.type;
|
||||
if (!WallModel.isStoneFinish(o.stone)) o.stone = def.stone;
|
||||
if (!WallModel.isStucColor(o.stuc)) o.stuc = def.stuc;
|
||||
return { type: o.type, stone: o.stone, stuc: o.stuc };
|
||||
}
|
||||
|
||||
function normalize(list, unitIds) {
|
||||
const used = new Set();
|
||||
const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def);
|
||||
return (Array.isArray(list) ? list : []).filter(c => c && unitIds.includes(c.unitId) && SIDES[c.side]).map(c => {
|
||||
let id = Number(c.id) > 0 ? Math.round(c.id) : 1;
|
||||
while (used.has(id)) id++;
|
||||
used.add(id);
|
||||
return {
|
||||
id, unitId: c.unitId, side: c.side, u: num(c.u, 0, -20000, 40000),
|
||||
w: num(c.w, DEFAULTS.w, 400, 2000), out: num(c.out, DEFAULTS.out, 0, 1000), above: num(c.above, DEFAULTS.above, 0, 3000),
|
||||
inside: normalizeFinish(c.inside, INSIDE_TYPES, DEFAULTS.inside),
|
||||
outside: normalizeFinish(c.outside, OUTSIDE_TYPES, DEFAULTS.outside)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function create(list, unitId, side, u) {
|
||||
const id = (list || []).reduce((m, c) => Math.max(m, c.id || 0), 0) + 1;
|
||||
return normalize([{ id, unitId, side, u }], [unitId])[0];
|
||||
}
|
||||
|
||||
// Vlak van een zijde van een unit; u = hart vanaf de linker vloerhoek, van buitenaf gezien.
|
||||
const planeOf = (r, side) => ({ voor: r.z0, achter: r.z1, links: r.x0, rechts: r.x1 })[side];
|
||||
// opts: { wallThickness(unitId, side), faceDepth(unitId, side) (gevelbekleding), overhang } voor de voetafdruk
|
||||
// (sIn..sFront), die het dak al nodig heeft voordat de gevels gebouwd zijn.
|
||||
function place(list, rects, opts) {
|
||||
const o = opts || {};
|
||||
return list.map(c => {
|
||||
const r = rects.find(q => q.id === c.unitId);
|
||||
if (!r || r.error) return null;
|
||||
const S = SIDES[c.side];
|
||||
const t = { voor: r.x1 - c.u, achter: r.x0 + c.u, links: r.z0 + c.u, rechts: r.z1 - c.u }[c.side];
|
||||
const T = o.wallThickness ? o.wallThickness(c.unitId, c.side) : 260, face = o.faceDepth ? o.faceDepth(c.unitId, c.side) : 0;
|
||||
return Object.assign({}, c, {
|
||||
axis: S.axis, n: S.n, plane: planeOf(r, c.side), t0: t - c.w / 2, t1: t + c.w / 2,
|
||||
sIn: -T, sFront: Math.max(face + c.out, (o.overhang || 0) + 20)
|
||||
});
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
// Sparingen in het dak van een unit (plattegrondmaten t.o.v. het unitmidden, zoals RoofModel.computeUnitRoof):
|
||||
// de hele voetafdruk binnen het dak; een nokbalk stopt ervoor (BEAR_MM).
|
||||
function roofCuts(placed, rect, roofRect) {
|
||||
const out = [];
|
||||
placed.forEach(c => {
|
||||
const S = SIDES[c.side];
|
||||
const k = S.axis === 'z' ? S.n.x : S.n.z, sA = c.plane + k * (c.sIn + INSET_MM), sB = c.plane + k * c.sFront;
|
||||
const w = S.axis === 'z'
|
||||
? { x0: Math.min(sA, sB), x1: Math.max(sA, sB), z0: c.t0 + INSET_MM, z1: c.t1 - INSET_MM }
|
||||
: { x0: c.t0 + INSET_MM, x1: c.t1 - INSET_MM, z0: Math.min(sA, sB), z1: Math.max(sA, sB) };
|
||||
const x0 = Math.max(w.x0, roofRect.x0), x1 = Math.min(w.x1, roofRect.x1), z0 = Math.max(w.z0, roofRect.z0), z1 = Math.min(w.z1, roofRect.z1);
|
||||
if (x1 - x0 < 1 || z1 - z0 < 1) return;
|
||||
out.push({ x0: x0 - rect.cx, x1: x1 - rect.cx, z0: z0 - rect.cz, z1: z1 - rect.cz, bear: BEAR_MM, chimney: c.id });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ruwe sparingen voor één unitwand (wl uit UnitModel.computeLayout; rect en rects in wereld-mm).
|
||||
// Loopt de schoorsteen over het wandeinde heen en gaat de wand daar over in die van een andere unit in hetzelfde
|
||||
// vlak (naad), dan loopt de sparing tot het einde door: geen hoekstijlen in de schoorsteen.
|
||||
function wallCuts(placed, rect, wl, studWidth, rects) {
|
||||
const S = SIDES[wl.side], sw = studWidth, plane = planeOf(rect, wl.side);
|
||||
const dir = UnitModel.WALL_SIDES.find(x => x.key === wl.side).dir;
|
||||
const mid = S.axis === 'x' ? wl.cx : wl.cz, c = S.axis === 'x' ? rect.cx : rect.cz;
|
||||
const s0 = mid - wl.width / 2, s1 = mid + wl.width / 2;
|
||||
const toU = (t) => { const p = t - c; return dir > 0 ? p - s0 : s1 - p; };
|
||||
const toT = (u) => c + (dir > 0 ? u + s0 : s1 - u);
|
||||
const range = (r) => S.axis === 'x' ? [r.x0, r.x1] : [r.z0, r.z1];
|
||||
const seamEnd = (u) => {
|
||||
const t = toT(u);
|
||||
return (rects || []).some(r => r !== rect && r.id !== rect.id && !r.error && Math.abs(planeOf(r, wl.side) - plane) < EPS &&
|
||||
range(r)[0] <= t + EPS && range(r)[1] >= t - EPS);
|
||||
};
|
||||
const edge = sw * WallModel.CORNER_STUDS;
|
||||
const out = [];
|
||||
placed.filter(ch => ch.side === wl.side && Math.abs(ch.plane - plane) < EPS).forEach(ch => {
|
||||
const ua = toU(ch.t0 + INSET_MM), ub = toU(ch.t1 - INSET_MM);
|
||||
const atStart = Math.min(ua, ub) < edge && Math.max(toU(ch.t0), toU(ch.t1)) > 0 && seamEnd(0);
|
||||
const atEnd = Math.max(ua, ub) > wl.width - edge && Math.min(toU(ch.t0), toU(ch.t1)) < wl.width && seamEnd(wl.width);
|
||||
const a = atStart ? 0 : Math.max(edge, Math.min(ua, ub)), b = atEnd ? wl.width : Math.min(wl.width - edge, Math.max(ua, ub));
|
||||
if (b - a < MIN_CUT_MM) return;
|
||||
out.push({ id: 1000 + ch.id, type: 'schoorsteen', w: Math.round(b - a), h: 99999, y: 0, anchor: 'left', offset: Math.round(a), fill: null,
|
||||
chimney: { id: ch.id, inside: ch.inside.type, atStart, atEnd } });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// units: parts.units (met walls/pieces en roof), roofEdge: RoofModel.computeEdge (krijgt gaps), groundY absoluut.
|
||||
function resolve(placed, units, roofEdge, groundY, overhang) {
|
||||
return placed.map(ch => {
|
||||
const S = SIDES[ch.side];
|
||||
const toW = (t, s) => S.axis === 'z' ? { x: ch.plane + S.n.x * s, z: t } : { x: t, z: ch.plane + S.n.z * s };
|
||||
const sOf = (p) => S.axis === 'z' ? (p.x - ch.plane) * S.n.x : (p.z - ch.plane) * S.n.z;
|
||||
const tOf = (p) => S.axis === 'z' ? p.z : p.x;
|
||||
let T = 0, face = 0, floorTop = null;
|
||||
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
|
||||
if (p.baseY || !p.place.normal || p.place.normal.x !== S.n.x || p.place.normal.y !== S.n.z) return;
|
||||
const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2);
|
||||
if (Math.abs(sOf(m)) > 2) return;
|
||||
const a = UnitModel.localToWorld(p.place, -p.place.width / 2, 0), b = UnitModel.localToWorld(p.place, p.place.width / 2, 0);
|
||||
if (Math.min(ch.t1, Math.max(tOf(a), tOf(b))) - Math.max(ch.t0, Math.min(tOf(a), tOf(b))) < EPS) return;
|
||||
T = Math.max(T, p.place.thickness);
|
||||
face = Math.max(face, WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc));
|
||||
if (floorTop === null) floorTop = p.place.floorTop;
|
||||
})));
|
||||
if (floorTop === null) {
|
||||
const u0 = units.find(u => u.id === ch.unitId);
|
||||
floorTop = u0 ? u0.layout.floorTop : 0;
|
||||
T = T || (u0 && u0.walls.find(w => w.side === ch.side) || { thickness: 260 }).thickness;
|
||||
}
|
||||
// De dakrand onderbreken en de voorkant tot voorbij de overstek.
|
||||
let roofOut = 0;
|
||||
if (roofEdge) roofEdge.segments.forEach(sg => {
|
||||
if (Math.abs(sg.normal.x - S.n.x) > 0.01 || Math.abs(sg.normal.z - S.n.z) > 0.01) return;
|
||||
if (Math.abs(sOf(sg.a)) > Math.max(overhang, 50) + face + 5) return;
|
||||
const ta = tOf(sg.outerA), tb = tOf(sg.outerB);
|
||||
if (Math.min(ch.t1, Math.max(ta, tb)) - Math.max(ch.t0, Math.min(ta, tb)) < EPS) return;
|
||||
sg.gaps = (sg.gaps || []).concat([[ch.t0 + INSET_MM, ch.t1 - INSET_MM]]);
|
||||
roofOut = Math.max(roofOut, sOf(sg.outerA) + 20);
|
||||
});
|
||||
const sIn = -T, sFace = face, sFront = Math.max(face + ch.out, roofOut);
|
||||
|
||||
const roofs = units.map(u => u.roof);
|
||||
const roofAt = (p) => roofs.filter(r => p.x > r.rect.x0 - EPS && p.x < r.rect.x1 + EPS && p.z > r.rect.z0 - EPS && p.z < r.rect.z1 + EPS);
|
||||
const ridgeTs = roofs.flatMap(r => r.ridges.filter(rd => rd.axis === (S.axis === 'x' ? 'x' : 'z') && rd.value > ch.t0 && rd.value < ch.t1).map(rd => rd.value));
|
||||
// Ook de randen van de daken: twee lessenaars die op een naad de nok vormen hebben geen eigen nok.
|
||||
const edgeTs = roofs.flatMap(r => (S.axis === 'x' ? [r.rect.x0, r.rect.x1] : [r.rect.z0, r.rect.z1])).filter(t => t > ch.t0 + 1 && t < ch.t1 - 1);
|
||||
const ts = [...new Set([ch.t0, ch.t1, ...ridgeTs, ...edgeTs].map(Math.round))].sort((a, b) => a - b);
|
||||
let roofTop = -Infinity;
|
||||
ts.forEach(t => [sIn, 0, sFront].forEach(s => roofAt(toW(t, s)).forEach(r => { roofTop = Math.max(roofTop, r.topAt(toW(t, s).x, toW(t, s).z)); })));
|
||||
// Binnenkant tot de onderkant van het dak (vlak achter de wand gemeten).
|
||||
const insideTop = ts.map(t => {
|
||||
const p = toW(t, sIn - 1), r = roofAt(p);
|
||||
return { t, y: r.length ? Math.min(...r.map(q => q.ceilingAt(p.x, p.z))) : floorTop + 2600 };
|
||||
});
|
||||
const under = Math.max(...insideTop.map(q => q.y));
|
||||
if (!isFinite(roofTop)) roofTop = under + 300;
|
||||
const yTop = Math.round(roofTop + ch.above);
|
||||
const flue = {
|
||||
t0: ch.t0 + SHELL_MM, t1: Math.max(ch.t0 + SHELL_MM + MIN_CUT_MM, ch.t1 - SHELL_MM),
|
||||
s0: sIn + SHELL_MM, s1: Math.max(sIn + SHELL_MM + MIN_CUT_MM, sFront - SHELL_MM)
|
||||
};
|
||||
const corners = (q) => [toW(q.t0, q.s0), toW(q.t1, q.s0), toW(q.t1, q.s1), toW(q.t0, q.s1)];
|
||||
return Object.assign({}, ch, {
|
||||
T, sIn, sFace, sFront, yGround: groundY, yFloor: floorTop, yTop, yUnder: under, insideTop, flue,
|
||||
footprint: corners({ t0: ch.t0, t1: ch.t1, s0: sIn, s1: sFront }), flueFootprint: corners(flue),
|
||||
protrudes: Math.round(sFront - sFace), height: yTop - groundY
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Ligt een punt (wereld-mm) in of vlak voor een schoorsteen?
|
||||
function covers(list, x, z, margin) {
|
||||
const m = margin || 0;
|
||||
return list.some(c => {
|
||||
const S = SIDES[c.side];
|
||||
const t = S.axis === 'z' ? z : x, s = S.axis === 'z' ? (x - c.plane) * S.n.x : (z - c.plane) * S.n.z;
|
||||
return t > c.t0 - m && t < c.t1 + m && s > c.sIn - m && s < c.sFront + m;
|
||||
});
|
||||
}
|
||||
|
||||
function hit(list, x, z, margin) {
|
||||
return list.find(c => covers([c], x, z, margin)) || null;
|
||||
}
|
||||
|
||||
// Slepen: dichtstbijzijnde stuk buitengevel (omtrek van de units samen), hart van de schoorsteen erlangs zodat hij
|
||||
// er helemaal tegen staat; ook over een naad tussen units heen. Geeft { unitId, side, u, t, plane, n } of null.
|
||||
function snapToFacade(rects, x, z, w) {
|
||||
const rs = rects.filter(r => !r.error);
|
||||
if (!rs.length) return null;
|
||||
const loop = rectUnionLoops(rs)[0];
|
||||
if (!loop) return null;
|
||||
const pts = loop.pts, n = pts.length;
|
||||
let best = null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i], b = pts[(i + 1) % n], nrm = rectilinearNormal(a, b);
|
||||
const side = Object.keys(SIDES).find(k => Math.abs(SIDES[k].n.x - nrm.x) < 1e-6 && Math.abs(SIDES[k].n.z - nrm.z) < 1e-6);
|
||||
if (!side) continue;
|
||||
const axis = SIDES[side].axis, lo = Math.min(axis === 'z' ? a.z : a.x, axis === 'z' ? b.z : b.x), hi = Math.max(axis === 'z' ? a.z : a.x, axis === 'z' ? b.z : b.x);
|
||||
if (hi - lo < w + 2 * CORNER_CLEAR_MM) continue;
|
||||
const plane = axis === 'z' ? a.x : a.z, along = axis === 'z' ? z : x, across = axis === 'z' ? x : z;
|
||||
const t = Math.max(lo + w / 2 + CORNER_CLEAR_MM, Math.min(hi - w / 2 - CORNER_CLEAR_MM, along));
|
||||
const d = Math.hypot(along - t, across - plane);
|
||||
if (best && d >= best.d) continue;
|
||||
const owner = rs.find(r => Math.abs(planeOf(r, side) - plane) < EPS && (axis === 'z' ? r.z0 <= t + EPS && r.z1 >= t - EPS : r.x0 <= t + EPS && r.x1 >= t - EPS));
|
||||
if (!owner) continue;
|
||||
const u = { voor: owner.x1 - t, achter: t - owner.x0, links: t - owner.z0, rechts: owner.z1 - t }[side];
|
||||
best = { d, unitId: owner.id, side, u: Math.round(u), t, plane, n: SIDES[side].n };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function createMaterials() {
|
||||
return {
|
||||
cap: new THREE.MeshStandardMaterial({ color: 0x77736d, roughness: 0.85 }),
|
||||
flue: new THREE.MeshStandardMaterial({ color: 0x1b1a19, roughness: 1, side: THREE.DoubleSide }),
|
||||
plain: new THREE.MeshStandardMaterial({ color: 0xf2efe9, roughness: 0.9 })
|
||||
};
|
||||
}
|
||||
|
||||
const finishMaterial = (f, fallback) => f.type === 'steen' ? WallModel.stoneMaterial(f.stone) : f.type === 'stuc' ? WallModel.stucMaterial(f.stuc) : fallback;
|
||||
|
||||
// opts: { materials, wallMaterial (binnenkant 'wand': hetzelfde materiaal als de wanden) }
|
||||
function build3D(list, opts) {
|
||||
const o = opts || {}, m = o.materials || createMaterials(), toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
list.forEach(c => {
|
||||
const S = SIDES[c.side];
|
||||
const W = (t, s) => S.axis === 'z' ? { x: c.plane + S.n.x * s, z: t } : { x: t, z: c.plane + S.n.z * s };
|
||||
const buckets = new Map();
|
||||
const add = (mat, tri) => { if (!buckets.has(mat)) buckets.set(mat, { pos: [], uv: [] }); const b = buckets.get(mat); tri.forEach(v => { b.pos.push(v.x * toM, v.y * toM, v.z * toM); b.uv.push(v.u, v.v); }); };
|
||||
// Verticaal vlak tussen p0 en p1 (wereld), buitennormaal nrm; hoogtes per kant. UV in meters (steen/stuc).
|
||||
const quad = (mat, p0, p1, y0a, y1a, y0b, y1b, nrm) => {
|
||||
const right = { x: nrm.z, z: -nrm.x };
|
||||
if ((p1.x - p0.x) * right.x + (p1.z - p0.z) * right.z < 0) { [p0, p1] = [p1, p0]; [y0a, y0b] = [y0b, y0a]; [y1a, y1b] = [y1b, y1a]; }
|
||||
const v = (p, y) => ({ x: p.x, y, z: p.z, u: (p.x * right.x + p.z * right.z) * toM, v: (y - c.yFloor) * toM });
|
||||
const BL = v(p0, y0a), BR = v(p1, y0b), TR = v(p1, y1b), TL = v(p0, y1a);
|
||||
add(mat, [BL, BR, TR]); add(mat, [BL, TR, TL]);
|
||||
};
|
||||
const nOut = S.n, nIn = { x: -S.n.x, z: -S.n.z };
|
||||
const tDir = S.axis === 'z' ? { x: 0, z: 1 } : { x: 1, z: 0 }, tNeg = { x: -tDir.x, z: -tDir.z };
|
||||
const outMat = finishMaterial(c.outside, m.plain);
|
||||
const yG = c.yGround, yT = c.yTop;
|
||||
quad(outMat, W(c.t0, c.sFront), W(c.t1, c.sFront), yG, yT, yG, yT, nOut);
|
||||
quad(outMat, W(c.t0, c.sIn), W(c.t0, c.sFront), yG, yT, yG, yT, tNeg);
|
||||
quad(outMat, W(c.t1, c.sIn), W(c.t1, c.sFront), yG, yT, yG, yT, tDir);
|
||||
quad(outMat, W(c.t0, c.sIn), W(c.t1, c.sIn), c.yUnder, yT, c.yUnder, yT, nIn); // achterkant boven het dak
|
||||
|
||||
// Binnenkant: van de vloer tot de onderkant van het dak.
|
||||
const k = skinOf(c.inside), inMat = finishMaterial(c.inside, o.wallMaterial || m.plain), sI = c.sIn - k;
|
||||
for (let i = 0; i < c.insideTop.length - 1; i++) {
|
||||
const a = c.insideTop[i], b = c.insideTop[i + 1];
|
||||
quad(inMat, W(a.t, sI), W(b.t, sI), c.yFloor, a.y, c.yFloor, b.y, nIn);
|
||||
}
|
||||
if (k > 0) {
|
||||
const first = c.insideTop[0], last = c.insideTop[c.insideTop.length - 1];
|
||||
quad(inMat, W(c.t0, sI), W(c.t0, c.sIn), c.yFloor, first.y, c.yFloor, first.y, tNeg);
|
||||
quad(inMat, W(c.t1, sI), W(c.t1, c.sIn), c.yFloor, last.y, c.yFloor, last.y, tDir);
|
||||
}
|
||||
|
||||
// Rookkanaal: zwarte binnenwanden en bodem tot FLUE_SHOW_MM onder de kap.
|
||||
const f = c.flue, yF = yT - Math.min(FLUE_SHOW_MM, c.above + 100);
|
||||
quad(m.flue, W(f.t0, f.s0), W(f.t1, f.s0), yF, yT, yF, yT, nOut);
|
||||
quad(m.flue, W(f.t0, f.s1), W(f.t1, f.s1), yF, yT, yF, yT, nIn);
|
||||
quad(m.flue, W(f.t0, f.s0), W(f.t0, f.s1), yF, yT, yF, yT, tDir);
|
||||
quad(m.flue, W(f.t1, f.s0), W(f.t1, f.s1), yF, yT, yF, yT, tNeg);
|
||||
const fp = [W(f.t0, f.s0), W(f.t1, f.s0), W(f.t1, f.s1), W(f.t0, f.s1)].map(p => ({ x: p.x, y: yF, z: p.z, u: 0, v: 0 }));
|
||||
add(m.flue, [fp[0], fp[1], fp[2]]); add(m.flue, [fp[0], fp[2], fp[3]]);
|
||||
|
||||
buckets.forEach((b, mat) => {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(b.pos, 3));
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(b.uv, 2));
|
||||
geo.computeVertexNormals();
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.castShadow = mat !== m.flue;
|
||||
mesh.receiveShadow = true;
|
||||
group.add(mesh);
|
||||
});
|
||||
|
||||
// Afdekplaat rond het rookkanaal (vier stroken).
|
||||
const capY = (yT + CAP_MM / 2) * toM;
|
||||
const strips = [
|
||||
[c.t0 - CAP_OVER_MM, c.t1 + CAP_OVER_MM, c.sIn - CAP_OVER_MM, f.s0],
|
||||
[c.t0 - CAP_OVER_MM, c.t1 + CAP_OVER_MM, f.s1, c.sFront + CAP_OVER_MM],
|
||||
[c.t0 - CAP_OVER_MM, f.t0, f.s0, f.s1],
|
||||
[f.t1, c.t1 + CAP_OVER_MM, f.s0, f.s1]
|
||||
];
|
||||
strips.forEach(([ta, tb, sa, sb]) => {
|
||||
if (tb - ta < 1 || sb - sa < 1) return;
|
||||
const p = W((ta + tb) / 2, (sa + sb) / 2);
|
||||
const [dx, dz] = S.axis === 'z' ? [sb - sa, tb - ta] : [tb - ta, sb - sa];
|
||||
const box = new THREE.Mesh(new THREE.BoxGeometry(dx * toM, CAP_MM * toM, dz * toM), m.cap);
|
||||
box.position.set(p.x * toM, capY, p.z * toM);
|
||||
box.castShadow = box.receiveShadow = true;
|
||||
group.add(box);
|
||||
});
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
return { SHELL_MM, INSIDE_TYPES, OUTSIDE_TYPES, DEFAULTS, SIDES, normalize, create, place, wallCuts, roofCuts, resolve, covers, hit, snapToFacade, createMaterials, build3D };
|
||||
})();
|
||||
@@ -0,0 +1,393 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// CAD-export van de woning (DXF): plattegrond en vier gevels in modelruimte, 1:1 in mm, met lagen per onderdeel.
|
||||
// Plattegrond: noord boven, zuidwesthoek van de units = 0,0. Gevels in een 2×2-raster eronder, maaiveld als basis.
|
||||
// Tekst, maatvoering en lijntypes zijn afgestemd op plotten op 1:50.
|
||||
|
||||
window.HouseDxf = (() => {
|
||||
const SCALE = 50;
|
||||
const CUT = 1500; // snijvlak plattegrond boven de vloer: openingen die lager beginnen zijn doorgesneden
|
||||
const LAYERS = {
|
||||
'PL-WAND-BUITEN': [7], 'PL-WAND-OPBOUW': [8], 'PL-GEVELBEKLEDING': [32], 'PL-WAND-BINNEN': [7],
|
||||
'PL-KOZIJNEN': [4], 'PL-DRAAIRICHTING': [151], 'PL-SPARING-BOVEN': [4, 'STREEP'],
|
||||
'PL-RUIMTE-GRENS': [3], 'PL-RUIMTE-TEKST': [7], 'PL-INRICHTING': [8], 'PL-DAKRAND': [8, 'STREEP'],
|
||||
'PL-PALEN': [32], 'PL-SCHOORSTEEN': [8], 'PL-VERLICHTING': [2], 'PL-ELEKTRA': [6], 'PL-NADEN': [30, 'STREEPSTIP'],
|
||||
'PL-ELEMENTCODES': [8],
|
||||
'AZ-GEVEL': [7], 'AZ-TRASRAAM': [7], 'AZ-KOZIJNEN': [4], 'AZ-DAK': [7], 'AZ-DAKRAND': [8], 'AZ-SCHOORSTEEN': [8], 'AZ-MAAIVELD': [7],
|
||||
'MAATVOERING': [5], 'TEKST': [7]
|
||||
};
|
||||
const FACE_LAYERS = { wand: 'AZ-GEVEL', opening: 'AZ-GEVEL', trasraam: 'AZ-TRASRAAM', dak: 'AZ-DAK', dakrand: 'AZ-DAKRAND', schoorsteen: 'AZ-SCHOORSTEEN' };
|
||||
const mm = (paper) => paper * SCALE;
|
||||
|
||||
// Intervallen [a, b]: samenvoegen en aftrekken.
|
||||
function mergeIv(list) {
|
||||
const s = list.filter(([a, b]) => b > a).sort((p, q) => p[0] - q[0]);
|
||||
const out = [];
|
||||
s.forEach(([a, b]) => { const l = out[out.length - 1]; if (l && a <= l[1] + 1e-9) l[1] = Math.max(l[1], b); else out.push([a, b]); });
|
||||
return out;
|
||||
}
|
||||
function subtractIv(from, cuts) {
|
||||
let out = from.map(iv => iv.slice());
|
||||
mergeIv(cuts).forEach(([c0, c1]) => {
|
||||
out = out.flatMap(([a, b]) => {
|
||||
if (c1 <= a || c0 >= b) return [[a, b]];
|
||||
const r = [];
|
||||
if (c0 > a) r.push([a, c0]);
|
||||
if (c1 < b) r.push([c1, b]);
|
||||
return r;
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Randen van een ruimte (RoomModel: as x/z, waarde v, s0..s1) tot gesloten lussen.
|
||||
function roomLoops(edges) {
|
||||
const key = (p) => Math.round(p.x) + ',' + Math.round(p.z);
|
||||
const segs = edges.map(e => e.axis === 'x' ? [{ x: e.v, z: e.s0 }, { x: e.v, z: e.s1 }] : [{ x: e.s0, z: e.v }, { x: e.s1, z: e.v }]);
|
||||
const at = new Map();
|
||||
segs.forEach((s, i) => s.forEach(p => { const k = key(p); if (!at.has(k)) at.set(k, []); at.get(k).push(i); }));
|
||||
const used = new Set(), out = [];
|
||||
segs.forEach((s, i) => {
|
||||
if (used.has(i)) return;
|
||||
used.add(i);
|
||||
const loop = [s[0], s[1]];
|
||||
let cur = s[1];
|
||||
while (key(cur) !== key(loop[0])) {
|
||||
const j = (at.get(key(cur)) || []).find(n => !used.has(n));
|
||||
if (j === undefined) break;
|
||||
used.add(j);
|
||||
cur = key(segs[j][0]) === key(cur) ? segs[j][1] : segs[j][0];
|
||||
loop.push(cur);
|
||||
}
|
||||
const closed = key(loop[loop.length - 1]) === key(loop[0]);
|
||||
if (closed) loop.pop();
|
||||
const n = loop.length;
|
||||
const pts = loop.filter((p, k) => {
|
||||
if (!closed && (k === 0 || k === n - 1)) return true;
|
||||
const a = loop[(k - 1 + n) % n], b = loop[(k + 1) % n];
|
||||
return !((Math.abs(a.x - p.x) < 0.5 && Math.abs(b.x - p.x) < 0.5) || (Math.abs(a.z - p.z) < 0.5 && Math.abs(b.z - p.z) < 0.5));
|
||||
});
|
||||
out.push({ pts, closed });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Cirkelboog door het eerste, middelste en laatste punt van een polylijn (draaicirkel van een deur).
|
||||
function arcThrough(pts) {
|
||||
const a = pts[0], b = pts[Math.floor(pts.length / 2)], c = pts[pts.length - 1];
|
||||
const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
|
||||
if (Math.abs(d) < 1e-9) return null;
|
||||
const s = (p) => p.x * p.x + p.y * p.y;
|
||||
const ctr = { x: (s(a) * (b.y - c.y) + s(b) * (c.y - a.y) + s(c) * (a.y - b.y)) / d, y: (s(a) * (c.x - b.x) + s(b) * (a.x - c.x) + s(c) * (b.x - a.x)) / d };
|
||||
const ang = (p) => Math.atan2(p.y - ctr.y, p.x - ctr.x);
|
||||
const ccw = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) > 0;
|
||||
return { c: ctr, r: Math.hypot(a.x - ctr.x, a.y - ctr.y), a0: ccw ? ang(a) : ang(c), a1: ccw ? ang(c) : ang(a) };
|
||||
}
|
||||
|
||||
// Tekstrichting langs een wand, altijd leesbaar (van onder of van rechts).
|
||||
const readable = (deg) => { let a = ((deg % 360) + 360) % 360; if (a > 90.01 && a <= 270.01) a -= 180; return a; };
|
||||
|
||||
function drawPlan(dxf, parts, T) {
|
||||
const W2 = (place, lx, lz) => T(UnitModel.localToWorld(place, lx, lz));
|
||||
const ext = new Set();
|
||||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.add(p))));
|
||||
const P = KozijnModel.PROFILE;
|
||||
|
||||
HouseModel.allWallPieces(parts).forEach(p => {
|
||||
const pl = p.place;
|
||||
if (pl.baseY) return;
|
||||
const isExt = ext.has(p);
|
||||
const W = pl.width, zi = -pl.thickness / 2, zo = pl.thickness / 2;
|
||||
const ops = (p.config.openings || []).filter(op => op.type !== 'schoorsteen');
|
||||
const cutOps = ops.filter(op => op.y < CUT);
|
||||
const holes = mergeIv(cutOps.map(op => [Math.max(-W / 2, -W / 2 + op.x), Math.min(W / 2, -W / 2 + op.x + op.w)]));
|
||||
const solid = subtractIv([[-W / 2, W / 2]], holes);
|
||||
const lay = isExt ? 'PL-WAND-BUITEN' : 'PL-WAND-BINNEN';
|
||||
const box = (a, b, z0, z1, layer, eo) => dxf.polyline([W2(pl, a, z0), W2(pl, b, z0), W2(pl, b, z1), W2(pl, a, z1)], true, layer, eo);
|
||||
solid.forEach(([a, b]) => box(a, b, zi, zo, lay));
|
||||
|
||||
const c = isExt ? WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc) : 0;
|
||||
if (isExt) {
|
||||
const sh = p.config.sheathingThick || 0;
|
||||
if (sh > 0) solid.forEach(([a, b]) => [zi + sh, zo - sh].forEach(z => dxf.line(W2(pl, a, z), W2(pl, b, z), 'PL-WAND-OPBOUW')));
|
||||
if (c > 0) {
|
||||
// Hoek: buitenhoek loopt de bekleding door over de buurwand, binnenhoek stopt ze ervoor (skinExt < 0).
|
||||
const e = p.skinExt || {};
|
||||
const xl = -W / 2 - (e.left > 0 ? e.left + c : (e.left || 0)), xr = W / 2 + (e.right > 0 ? e.right + c : (e.right || 0));
|
||||
subtractIv([[xl, xr]], holes).forEach(([a, b]) => box(a, b, zo, zo + c, 'PL-GEVELBEKLEDING'));
|
||||
}
|
||||
}
|
||||
|
||||
cutOps.forEach(op => {
|
||||
const f = KozijnModel.normalize(op.fill);
|
||||
if (!f) return;
|
||||
const x0 = -W / 2 + op.x, x1 = x0 + op.w;
|
||||
const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat';
|
||||
const kz1 = zo, kz0 = exterior ? Math.max(zi, zo - P.depth) : zi;
|
||||
const a = x0 + P.gap, b = x1 - P.gap;
|
||||
if (f.type === 'raam') {
|
||||
box(a, b, kz0, kz1, 'PL-KOZIJNEN');
|
||||
[a + P.kader, b - P.kader].forEach(x => dxf.line(W2(pl, x, kz0), W2(pl, x, kz1), 'PL-KOZIJNEN'));
|
||||
dxf.line(W2(pl, a + P.kader, kz1 - 50), W2(pl, b - P.kader, kz1 - 50), 'PL-KOZIJNEN');
|
||||
return;
|
||||
}
|
||||
box(a, a + P.kader, kz0, kz1, 'PL-KOZIJNEN');
|
||||
box(b - P.kader, b, kz0, kz1, 'PL-KOZIJNEN');
|
||||
KozijnModel.planSymbol(op, op.fill, { zIn: zi, zOut: zo }).forEach(sh => {
|
||||
const pts = sh.pts.map(q => W2(pl, x0 + q.x, q.z));
|
||||
if (sh.kind === 'arc') {
|
||||
const arc = arcThrough(pts);
|
||||
if (arc) dxf.arc(arc.c, arc.r, arc.a0, arc.a1, 'PL-DRAAIRICHTING');
|
||||
} else if (sh.kind === 'rail') dxf.polyline(pts, !!sh.closed, 'PL-DRAAIRICHTING', { ltype: 'STIP' });
|
||||
else dxf.polyline(pts, !!sh.closed, sh.kind === 'arrow' ? 'PL-DRAAIRICHTING' : 'PL-KOZIJNEN');
|
||||
});
|
||||
});
|
||||
// Sparingen boven het snijvlak (bovenlichten, hoge ramen) gestreept, tenzij ze boven een doorgesneden opening liggen.
|
||||
ops.filter(op => op.y >= CUT).forEach(op => {
|
||||
const x0 = -W / 2 + op.x, x1 = x0 + op.w;
|
||||
if (holes.some(([a, b]) => x0 >= a - 1 && x1 <= b + 1)) return;
|
||||
box(x0, x1, zi, zo, 'PL-SPARING-BOVEN');
|
||||
});
|
||||
|
||||
// Elementcode binnen langs de wand, verticaal gecentreerd op 2,5 mm (papier) van het binnenvlak.
|
||||
const rot = readable(Math.atan2(W2(pl, 1, 0).y - W2(pl, 0, 0).y, W2(pl, 1, 0).x - W2(pl, 0, 0).x) * 180 / Math.PI) * Math.PI / 180;
|
||||
const mid = W2(pl, 0, zi - mm(2.5)), th = mm(2);
|
||||
dxf.text({ x: mid.x + Math.sin(rot) * th / 2, y: mid.y - Math.cos(rot) * th / 2 }, th, p.element, 'PL-ELEMENTCODES', { align: 'center', rot: rot * 180 / Math.PI });
|
||||
|
||||
if (isExt) {
|
||||
const xs = [-W / 2, W / 2];
|
||||
holes.forEach(([a, b]) => xs.push(a, b));
|
||||
const ticks = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((m, n) => m - n);
|
||||
const zf = zo + c, row1 = zf + 700, row2 = row1 + 450;
|
||||
for (let i = 0; i < ticks.length - 1; i++) {
|
||||
dxf.dim(W2(pl, ticks[i], zf), W2(pl, ticks[i + 1], zf), W2(pl, (ticks[i] + ticks[i + 1]) / 2, row1), 'MAATVOERING');
|
||||
}
|
||||
if (ticks.length > 2) dxf.dim(W2(pl, -W / 2, zf), W2(pl, W / 2, zf), W2(pl, 0, row2), 'MAATVOERING');
|
||||
}
|
||||
});
|
||||
|
||||
const R = parts.rooms;
|
||||
R.rooms.concat(R.free).forEach(r => {
|
||||
roomLoops(r.edges || []).forEach(lp => dxf.polyline(lp.pts.map(T), lp.closed, 'PL-RUIMTE-GRENS'));
|
||||
const named = !!r.name;
|
||||
const lines = [[named ? r.name : 'Ruimte (geen functie)', 3.5], [`${r.areaM2.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} m²`, 2.5]];
|
||||
if (r.ceilingActive) lines.push([`plafond ${r.ceiling}`, 2]);
|
||||
const c = T(r.label), step = mm(4.5);
|
||||
lines.forEach(([t, size], i) => dxf.text({ x: c.x, y: c.y + ((lines.length - 1) / 2 - i) * step - mm(size) / 2 }, mm(size), t, 'PL-RUIMTE-TEKST', { align: 'center' }));
|
||||
});
|
||||
|
||||
(parts.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
|
||||
const eo = sh.kind === 'dash' ? { ltype: 'STREEP' } : null;
|
||||
if (sh.circle) dxf.circle(T(sh.circle), sh.circle.r, 'PL-INRICHTING', eo);
|
||||
else if (sh.text) { const q = T(sh.at); dxf.text({ x: q.x, y: q.y - mm(0.7) }, mm(1.4), sh.text, 'PL-INRICHTING', { align: 'center' }); }
|
||||
else dxf.polyline(sh.pts.map(T), !!sh.closed, 'PL-INRICHTING', eo);
|
||||
}));
|
||||
|
||||
if (parts.roofEdge) {
|
||||
parts.roofEdge.segments.forEach(sg => dxf.line(T(sg.outerA), T(sg.outerB), 'PL-DAKRAND'));
|
||||
parts.roofEdge.spots.forEach(sp => dxf.circle(T(sp), mm(0.8), 'PL-VERLICHTING'));
|
||||
} else {
|
||||
parts.units.forEach(u => {
|
||||
const r = u.roof.rect;
|
||||
dxf.polyline([{ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 }].map(T), true, 'PL-DAKRAND');
|
||||
});
|
||||
}
|
||||
parts.posts.posts.forEach(pp => {
|
||||
const s = pp.size / 2;
|
||||
dxf.polyline([{ x: pp.x - s, z: pp.z - s }, { x: pp.x + s, z: pp.z - s }, { x: pp.x + s, z: pp.z + s }, { x: pp.x - s, z: pp.z + s }].map(T), true, 'PL-PALEN');
|
||||
});
|
||||
parts.posts.walls.forEach(w => dxf.polyline([{ x: w.x0, z: w.z0 }, { x: w.x1, z: w.z0 }, { x: w.x1, z: w.z1 }, { x: w.x0, z: w.z1 }].map(T), true, 'PL-PALEN'));
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
dxf.polyline(ch.footprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||
dxf.polyline(ch.flueFootprint.map(T), true, 'PL-SCHOORSTEEN');
|
||||
});
|
||||
parts.lighting.lamps.forEach(l => {
|
||||
const q = T({ x: l.x + l.normal.x * 80, z: l.z + l.normal.z * 80 }), s = mm(0.9);
|
||||
dxf.polyline([{ x: q.x - s, y: q.y - s }, { x: q.x + s, y: q.y - s }, { x: q.x + s, y: q.y + s }, { x: q.x - s, y: q.y + s }], true, 'PL-VERLICHTING');
|
||||
});
|
||||
const o = T({ x: 0, z: 0 });
|
||||
ElektraPdf.drawPlan(DxfWriter.pdfAdapter(dxf, 'PL-ELEKTRA'), parts.elektra, (x) => (x + o.x) / SCALE, (z) => (z - o.y) / SCALE);
|
||||
parts.seams.forEach(s => dxf.line(T({ x: s.x0, z: s.z0 }), T({ x: s.x1, z: s.z1 }), 'PL-NADEN'));
|
||||
parts.rects.forEach(r => {
|
||||
const q = T({ x: r.x0 + 450, z: r.z0 + 450 });
|
||||
dxf.text({ x: q.x, y: q.y - mm(8) }, mm(8), r.id, 'PL-ELEMENTCODES');
|
||||
});
|
||||
|
||||
// Totaalmaten en noordpijl buiten alles.
|
||||
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 e = dxf.extents();
|
||||
dxf.dim(T({ x: bx0, z: bz0 }), T({ x: bx1, z: bz0 }), { x: 0, y: e.y1 + 700 }, 'MAATVOERING');
|
||||
dxf.dim(T({ x: bx0, z: bz1 }), T({ x: bx0, z: bz0 }), { x: e.x0 - 700, y: 0 }, 'MAATVOERING');
|
||||
const na = { x: e.x1 + mm(10), y: e.y1 - mm(4) }, ns = mm(4);
|
||||
dxf.polyline([{ x: na.x, y: na.y + ns }, { x: na.x - ns * 0.4, y: na.y - ns * 0.6 }, { x: na.x, y: na.y - ns * 0.3 }, { x: na.x + ns * 0.4, y: na.y - ns * 0.6 }], true, 'TEKST');
|
||||
dxf.text({ x: na.x, y: na.y - ns * 0.6 - mm(5) }, mm(3.5), 'N', 'TEKST', { align: 'center' });
|
||||
}
|
||||
|
||||
function simplifyPoly(pts) {
|
||||
let p = pts.filter((q, i) => { const r = pts[(i + 1) % pts.length]; return Math.hypot(r.x - q.x, r.y - q.y) > 0.5; });
|
||||
let changed = true;
|
||||
while (changed && p.length > 3) {
|
||||
changed = false;
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
const a = p[(i - 1 + p.length) % p.length], b = p[i], c = p[(i + 1) % p.length];
|
||||
const len = Math.hypot(c.x - a.x, c.y - a.y);
|
||||
if (len > 0 && Math.abs((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)) / len < 0.5) { p.splice(i, 1); changed = true; break; }
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const insidePoly = (q, poly) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const a = poly[i], b = poly[j];
|
||||
if ((a.y > q.y) !== (b.y > q.y) && q.x < (b.x - a.x) * (q.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const distSeg = (q, a, b) => {
|
||||
const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy;
|
||||
const t = l2 ? Math.max(0, Math.min(1, ((q.x - a.x) * dx + (q.y - a.y) * dy) / l2)) : 0;
|
||||
return Math.hypot(q.x - a.x - t * dx, q.y - a.y - t * dy);
|
||||
};
|
||||
// Echt binnen (niet op de rand): lijnen die samenvallen met de rand van een vlak ervoor blijven zichtbaar.
|
||||
const strictlyInside = (q, poly) => insidePoly(q, poly) && poly.every((a, i) => distSeg(q, a, poly[(i + 1) % poly.length]) > 0.5);
|
||||
|
||||
// Delen [t0, t1] van segment a-b die door poly worden afgedekt.
|
||||
function coveredBy(a, b, poly) {
|
||||
const ts = [0, 1], dx = b.x - a.x, dy = b.y - a.y;
|
||||
poly.forEach((c, i) => {
|
||||
const d = poly[(i + 1) % poly.length], ex = d.x - c.x, ey = d.y - c.y;
|
||||
const den = dx * ey - dy * ex;
|
||||
if (Math.abs(den) < 1e-9) return;
|
||||
const t = ((c.x - a.x) * ey - (c.y - a.y) * ex) / den, s = ((c.x - a.x) * dy - (c.y - a.y) * dx) / den;
|
||||
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t);
|
||||
});
|
||||
ts.sort((m, n) => m - n);
|
||||
const out = [];
|
||||
for (let i = 0; i < ts.length - 1; i++) {
|
||||
const t0 = ts[i], t1 = ts[i + 1];
|
||||
if (t1 - t0 < 1e-7) continue;
|
||||
const tm = (t0 + t1) / 2;
|
||||
if (!strictlyInside({ x: a.x + dx * tm, y: a.y + dy * tm }, poly)) continue;
|
||||
const l = out[out.length - 1];
|
||||
if (l && Math.abs(l[1] - t0) < 1e-7) l[1] = t1; else out.push([t0, t1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Verborgen lijnen weg: vlakken van achter naar voor, elke rand wordt afgedekt door de vlakken die later komen.
|
||||
function hiddenLines(faces) {
|
||||
const boxOf = (pts) => ({ x0: Math.min(...pts.map(p => p.x)), x1: Math.max(...pts.map(p => p.x)), y0: Math.min(...pts.map(p => p.y)), y1: Math.max(...pts.map(p => p.y)) });
|
||||
const hit = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
||||
faces.forEach(f => { f.box = boxOf(f.poly); });
|
||||
const out = [];
|
||||
faces.forEach((f, i) => {
|
||||
const segs = [];
|
||||
const ring = (pts, layer) => pts.forEach((a, k) => segs.push({ a, b: pts[(k + 1) % pts.length], layer }));
|
||||
ring(f.poly, f.layer);
|
||||
f.detail.forEach(pl => ring(pl, 'AZ-KOZIJNEN'));
|
||||
segs.forEach(sg => {
|
||||
const len = Math.hypot(sg.b.x - sg.a.x, sg.b.y - sg.a.y);
|
||||
if (len < 1) return;
|
||||
const sb = boxOf([sg.a, sg.b]);
|
||||
sb.x0 -= 1; sb.y0 -= 1; sb.x1 += 1; sb.y1 += 1;
|
||||
let vis = [[0, 1]];
|
||||
for (let j = i + 1; j < faces.length && vis.length; j++) {
|
||||
if (!hit(sb, faces[j].box)) continue;
|
||||
const cov = coveredBy(sg.a, sg.b, faces[j].poly);
|
||||
if (cov.length) vis = subtractIv(vis, cov);
|
||||
}
|
||||
const at = (t) => ({ x: sg.a.x + (sg.b.x - sg.a.x) * t, y: sg.a.y + (sg.b.y - sg.a.y) * t });
|
||||
vis.forEach(([t0, t1]) => { if ((t1 - t0) * len > 1) out.push({ a: at(t0), b: at(t1), layer: sg.layer }); });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Samenvallende en in elkaars verlengde liggende stukken per laag samenvoegen (gedeelde randen maar één keer).
|
||||
function mergeCollinear(segs) {
|
||||
const groups = new Map();
|
||||
segs.forEach(s => {
|
||||
let dx = s.b.x - s.a.x, dy = s.b.y - s.a.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
dx /= len; dy /= len;
|
||||
if (dx < -1e-9 || (Math.abs(dx) < 1e-9 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
const off = -dy * s.a.x + dx * s.a.y;
|
||||
const key = `${s.layer}|${Math.round(Math.atan2(dy, dx) * 2000)}|${Math.round(off)}`;
|
||||
if (!groups.has(key)) groups.set(key, { dx, dy, off, layer: s.layer, iv: [] });
|
||||
const g = groups.get(key);
|
||||
const ta = s.a.x * dx + s.a.y * dy, tb = s.b.x * dx + s.b.y * dy;
|
||||
g.iv.push([Math.min(ta, tb), Math.max(ta, tb)]);
|
||||
});
|
||||
const out = [];
|
||||
groups.forEach(g => mergeIv(g.iv).forEach(([t0, t1]) => {
|
||||
const p = (t) => ({ x: g.dx * t - g.dy * g.off, y: g.dy * t + g.dx * g.off });
|
||||
out.push({ a: p(t0), b: p(t1), layer: g.layer });
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
function drawViews(dxf, parts, state, planExt) {
|
||||
const { groundY, views } = HousePdf.viewProjections(parts, state);
|
||||
if (!views.length) return;
|
||||
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
|
||||
const maxW = Math.max(...views.map(w => w.u1 - w.u0)), maxH = Math.max(...views.map(w => w.v1 - groundY));
|
||||
const gapX = 5000, cellH = maxH + 4500;
|
||||
views.forEach((w, i) => {
|
||||
const col = i % 2, row = Math.floor(i / 2);
|
||||
const ox = planExt.x0 + col * (maxW + gapX) - w.u0;
|
||||
const base = planExt.y0 - 3500 - maxH - row * cellH;
|
||||
const P = (u, v) => ({ x: ox + u, y: base + (v - groundY) });
|
||||
|
||||
const faces = w.list.map(l => ({
|
||||
poly: simplifyPoly(l.pts.map(p => ({ x: p.u, y: p.v }))),
|
||||
layer: FACE_LAYERS[l.f.kind] || 'AZ-GEVEL',
|
||||
detail: (l.f.detail || []).map(pl => simplifyPoly(pl.map(p => ({ x: w.v.u(p), y: p.y }))))
|
||||
})).filter(f => f.poly.length >= 3);
|
||||
mergeCollinear(hiddenLines(faces)).forEach(s => dxf.line(P(s.a.x, s.a.y), P(s.b.x, s.b.y), s.layer));
|
||||
|
||||
dxf.line(P(w.u0 - 1000, groundY), P(w.u1 + 1000, groundY), 'AZ-MAAIVELD');
|
||||
const left = P(w.u0, groundY), xd = left.x - 900;
|
||||
if (floorTop - groundY > 1) dxf.dim(P(w.u0, groundY), P(w.u0, floorTop), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } });
|
||||
dxf.dim(P(w.u0, floorTop), P(w.u0, w.v1), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } });
|
||||
const peil = P(w.u0, floorTop);
|
||||
dxf.line({ x: xd - mm(10), y: peil.y }, { x: xd - mm(2), y: peil.y }, 'MAATVOERING');
|
||||
dxf.text({ x: xd - mm(10), y: peil.y + mm(1) }, mm(2), 'peil', 'MAATVOERING');
|
||||
dxf.dim(P(w.u0, groundY), P(w.u1, groundY), { x: 0, y: base - 700 }, 'MAATVOERING');
|
||||
dxf.text(P((w.u0 + w.u1) / 2, groundY - 1300 - mm(5)), mm(5), w.v.title.toUpperCase(), 'TEKST', { align: 'center' });
|
||||
});
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const parts = HouseModel.computeParts(state, { skip3D: true });
|
||||
if (!parts.units.length) throw new Error('geen units in het ontwerp');
|
||||
const dxf = DxfWriter.create({ scale: SCALE });
|
||||
Object.entries(LAYERS).forEach(([name, [color, ltype]]) => dxf.layer(name, color, ltype));
|
||||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||||
const T = (p) => ({ x: p.x - bx0, y: bz1 - p.z });
|
||||
|
||||
drawPlan(dxf, parts, T);
|
||||
const planExt = dxf.extents();
|
||||
dxf.text({ x: planExt.x0, y: planExt.y0 - mm(12) }, mm(5), 'PLATTEGROND', 'TEKST');
|
||||
drawViews(dxf, parts, state, planExt);
|
||||
|
||||
const pr = state.project || {};
|
||||
const version = typeof APP_VERSION !== 'undefined' ? ` ${APP_VERSION}` : '';
|
||||
const info = [
|
||||
pr.naam, [pr.straat, pr.nummer].filter(Boolean).join(' '), pr.plaats,
|
||||
`Tectum${version}, ${new Date().toLocaleDateString('nl-NL')}`,
|
||||
'Maten in mm, peil = bovenkant vloer; tekst en maatvoering voor 1:50.'
|
||||
].filter(Boolean);
|
||||
info.forEach((t, i) => dxf.text({ x: planExt.x1 + mm(30), y: planExt.y1 - mm(5) - i * mm(4.5) }, mm(i === 0 && pr.naam ? 3.5 : 2.5), t, 'TEKST'));
|
||||
return dxf;
|
||||
}
|
||||
|
||||
return { generate, LAYERS };
|
||||
})();
|
||||
@@ -0,0 +1,187 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// BIM-export van de bouwconstructie (IFC4): balken, platen en elektra, geen afwerking of inrichting.
|
||||
// De geometrie komt uit de 3D-opbouw (dezelfde als in de app), in twee fasen: 'elektra' (alle balken en
|
||||
// leidingen, nog zonder platen, dus niets weggelaten) en 'beplating' (alleen de platen).
|
||||
// Assen: X = oost, Y = noord, Z = omhoog; 0,0 = zuidwesthoek van de units, Z = 0 op peil (bovenkant vloer).
|
||||
// Opbouw: unit (samenstelling) > element A-W1 / A-V / A-D (samenstelling) > onderdelen; binnenwanden los.
|
||||
|
||||
window.HouseIfc = (() => {
|
||||
const WOOD = 'Hout', ELEKTRA_KIND = { kast: 1, doos: 1, buis: 1 };
|
||||
// Productiekleuren per soort (elektra houdt de kleur uit de app).
|
||||
const COLORS = {
|
||||
Stijl: [0.93, 0.74, 0.42], Regel: [0.85, 0.48, 0.18], Vloerbalk: [0.62, 0.40, 0.20], Dakbalk: [0.78, 0.30, 0.22],
|
||||
Paal: [0.45, 0.28, 0.15], 'Stalen nokbalk': [0.35, 0.42, 0.55],
|
||||
Spaanplaat: [0.40, 0.68, 0.42], OSB: [0.95, 0.86, 0.50], Cementplaat: [0.62, 0.64, 0.68]
|
||||
};
|
||||
const rgbOf = (mesh) => { const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; return m && m.color ? [m.color.r, m.color.g, m.color.b] : null; };
|
||||
|
||||
function materialsSet() {
|
||||
return {
|
||||
wall: WallModel.createMaterials(), floor: FloorModel.createMaterials(), roof: RoofModel.createMaterials(),
|
||||
elektra: ElektraModel.createMaterials(), post: PostModel.createMaterials()
|
||||
};
|
||||
}
|
||||
|
||||
// Materiaal -> soort onderdeel (alles wat hier niet in staat, gaat niet mee).
|
||||
function classifier(M) {
|
||||
const map = new Map();
|
||||
map.set(M.wall.woodMaterial, { kind: 'hout', where: 'wand' });
|
||||
map.set(M.floor.woodMaterial, { kind: 'hout', where: 'vloer' });
|
||||
map.set(M.roof.woodMaterial, { kind: 'hout', where: 'dak' });
|
||||
map.set(M.roof.steelMaterial, { kind: 'staal' });
|
||||
map.set(M.wall.spaanplaatMaterial, { kind: 'plaat', label: 'Spaanplaat' });
|
||||
map.set(M.wall.cementplaatMaterial, { kind: 'plaat', label: 'Cementplaat' });
|
||||
map.set(M.floor.osbMaterial, { kind: 'plaat', label: 'OSB' });
|
||||
map.set(M.roof.osbMaterial, { kind: 'plaat', label: 'OSB' });
|
||||
Object.entries(M.elektra.conduit).forEach(([type, m]) => map.set(m, { kind: 'buis', type }));
|
||||
map.set(M.elektra.boxMaterial, { kind: 'doos' });
|
||||
map.set(M.elektra.kastMaterial, { kind: 'kast' });
|
||||
map.set(M.elektra.deurMaterial, { kind: 'kast' });
|
||||
Object.values(M.post.wood).forEach(m => map.set(m, { kind: 'paal' }));
|
||||
return (mesh) => {
|
||||
const m0 = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
|
||||
return m0 ? map.get((m0.userData && m0.userData.base) || m0) || null : null;
|
||||
};
|
||||
}
|
||||
|
||||
// rough: buizen en dozen bouwen; de plaatfase verzamelt alleen platen en heeft ze niet nodig.
|
||||
function build(state, layers, rough) {
|
||||
const M = materialsSet();
|
||||
const parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
|
||||
layers, elektraRough: rough
|
||||
});
|
||||
HouseModel.placeGroups(parts);
|
||||
return { parts, classify: classifier(M) };
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const frame = build(state, ViewLayers.upTo(1), true);
|
||||
const sheet = build(state, ViewLayers.upTo(2), false);
|
||||
const parts = frame.parts;
|
||||
if (!parts.units.length) throw new Error('geen units in het ontwerp');
|
||||
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
|
||||
const peil = Math.max(...parts.units.map(u => u.layout.floorTop));
|
||||
|
||||
const v = new THREE.Vector3(), ax = new THREE.Vector3(), box = new THREE.Box3(), size = new THREE.Vector3();
|
||||
function triangles(mesh, out) {
|
||||
const g = mesh.geometry, pos = g.attributes.position, idx = g.index;
|
||||
const n = idx ? idx.count : pos.count;
|
||||
for (let i = 0; i < n; i++) {
|
||||
v.fromBufferAttribute(pos, idx ? idx.getX(i) : i).applyMatrix4(mesh.matrixWorld);
|
||||
out.push(v.x * 1000 - bx0, bz1 - v.z * 1000, v.y * 1000 - peil);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Maat in de eigen assen (afgeschuinde sporen houden zo lengte en dikte), oplopend in mm.
|
||||
function dims(mesh) {
|
||||
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
|
||||
box.copy(mesh.geometry.boundingBox).getSize(size);
|
||||
const s = [size.x * Math.abs(mesh.scale.x), size.y * Math.abs(mesh.scale.y), size.z * Math.abs(mesh.scale.z)].map(d => Math.round(d * 1000));
|
||||
const longest = s.indexOf(Math.max(...s));
|
||||
ax.set(longest === 0 ? 1 : 0, longest === 1 ? 1 : 0, longest === 2 ? 1 : 0).transformDirection(mesh.matrixWorld);
|
||||
return { sorted: s.slice().sort((a, b) => a - b), vertical: Math.abs(ax.y) > 0.7 };
|
||||
}
|
||||
const label = (d) => d.sorted.join(' × ');
|
||||
|
||||
const groups = new Map(); // sleutel -> { name, tag, objectType, unit, members: [] }
|
||||
const loose = [];
|
||||
const merged = new Map(); // leidingen per run, meterkast
|
||||
const groupOf = (key, info) => { if (!groups.has(key)) groups.set(key, Object.assign({ members: [] }, info)); return groups.get(key); };
|
||||
|
||||
function member(mesh, c, tag) {
|
||||
const d = dims(mesh);
|
||||
if (d.sorted[0] < 1) return null; // vlakken zonder dikte (kopse afdekking)
|
||||
const tri = triangles(mesh, []);
|
||||
const props = (name, material) => ({ Element: tag || null, Onderdeel: name, Afmeting: label(d), Lengte: d.sorted[2], Materiaal: material });
|
||||
if (c.kind === 'plaat') {
|
||||
const name = `${c.label} ${d.sorted[0]} mm`;
|
||||
return { type: 'IFCPLATE', predefined: 'SHEET', name, material: c.label, tri, color: COLORS[c.label], props: props(name, c.label) };
|
||||
}
|
||||
const el = (type, predefined, name, material) => ({ type, predefined, name, material, tri, color: COLORS[name], props: props(name, material) });
|
||||
if (c.kind === 'staal') return el('IFCBEAM', 'BEAM', 'Stalen nokbalk', 'Staal');
|
||||
if (c.kind === 'paal') return el('IFCCOLUMN', 'COLUMN', 'Paal', WOOD);
|
||||
if (c.where === 'vloer') return el('IFCBEAM', 'JOIST', 'Vloerbalk', WOOD);
|
||||
if (c.where === 'dak') return el('IFCMEMBER', 'RAFTER', 'Dakbalk', WOOD);
|
||||
return d.vertical ? el('IFCMEMBER', 'STUD', 'Stijl', WOOD) : el('IFCMEMBER', 'PLATE', 'Regel', WOOD);
|
||||
}
|
||||
|
||||
function collect(src, group, plates, target) {
|
||||
if (!group) return;
|
||||
group.updateMatrixWorld(true);
|
||||
group.traverseVisible(o => {
|
||||
if (!o.isMesh) return;
|
||||
const c = src.classify(o);
|
||||
if (!c || (c.kind === 'plaat') !== plates) return;
|
||||
if (ELEKTRA_KIND[c.kind]) {
|
||||
if (c.kind === 'doos') {
|
||||
loose.push({ type: 'IFCJUNCTIONBOX', predefined: 'POWER', name: 'Inbouwdoos', material: 'Kunststof', tri: triangles(o, []), color: rgbOf(o), props: { Onderdeel: 'Inbouwdoos', Materiaal: 'Kunststof' } });
|
||||
return;
|
||||
}
|
||||
const key = c.kind === 'kast' ? 'kast' : `buis:${o.userData.run}`;
|
||||
if (!merged.has(key)) {
|
||||
merged.set(key, c.kind === 'kast'
|
||||
? { type: 'IFCELECTRICDISTRIBUTIONBOARD', predefined: 'DISTRIBUTIONBOARD', name: 'Meterkast', material: 'Kunststof', tri: [], color: rgbOf(o), props: { Onderdeel: 'Meterkast' } }
|
||||
: { type: 'IFCCABLECARRIERSEGMENT', predefined: 'CONDUITSEGMENT', name: `Installatiebuis ${c.type}`, material: `PVC ${c.type}`, tri: [], len: 0, color: rgbOf(o), props: { Onderdeel: `Installatiebuis ${c.type}`, Materiaal: 'PVC' } });
|
||||
}
|
||||
const e = merged.get(key);
|
||||
triangles(o, e.tri);
|
||||
if (e.len !== undefined && o.geometry.type === 'CylinderGeometry') e.len += o.geometry.parameters.height * 1000;
|
||||
return;
|
||||
}
|
||||
const m = member(o, c, target && target.tag);
|
||||
if (m) (target ? target.members : loose).push(m);
|
||||
});
|
||||
}
|
||||
|
||||
[[frame, false], [sheet, true]].forEach(([src, plates]) => {
|
||||
const P = src.parts;
|
||||
P.units.forEach(u => {
|
||||
collect(src, u.floorGroup, plates, groupOf(u.floorElement, { name: `Unit ${u.id} vloer`, tag: u.floorElement, objectType: 'Vloerelement', unit: u.id }));
|
||||
u.walls.forEach(w => w.pieces.forEach(p => {
|
||||
collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Wandelement', unit: u.id }));
|
||||
}));
|
||||
collect(src, u.roof.group, plates, groupOf(`${u.id}-D`, { name: `Unit ${u.id} dak`, tag: `${u.id}-D`, objectType: 'Dakelement', unit: u.id }));
|
||||
});
|
||||
P.interior.forEach(p => collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Binnenwand', unit: null })));
|
||||
collect(src, P.postsGroup, plates, null);
|
||||
collect(src, P.elektraGroup, plates, null);
|
||||
});
|
||||
merged.forEach(e => {
|
||||
if (e.len) e.props.Lengte = Math.round(e.len);
|
||||
delete e.len;
|
||||
loose.push(e);
|
||||
});
|
||||
|
||||
const pr = state.project || {};
|
||||
const ifc = IfcWriter.create({
|
||||
project: pr.naam || 'Woning', building: pr.naam || 'Woning', author: pr.tekenaar || '',
|
||||
app: `Tectum${typeof APP_VERSION !== 'undefined' ? ' ' + APP_VERSION : ''}`,
|
||||
address: { lines: [pr.straat, pr.nummer].filter(Boolean).join(' '), town: pr.plaats || '' }
|
||||
});
|
||||
const unitParts = new Map(parts.units.map(u => [u.id, []]));
|
||||
groups.forEach(g => {
|
||||
const ids = g.members.map(m => ifc.element(m)).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: g.name, tag: g.tag, objectType: g.objectType, props: { Element: g.tag } });
|
||||
ifc.aggregate(asm, ids);
|
||||
if (g.unit && unitParts.has(g.unit)) unitParts.get(g.unit).push(asm);
|
||||
else ifc.contain(asm);
|
||||
});
|
||||
unitParts.forEach((ids, id) => {
|
||||
if (!ids.length) return;
|
||||
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: `Unit ${id}`, tag: id, objectType: 'Unit' });
|
||||
ifc.aggregate(asm, ids);
|
||||
ifc.contain(asm);
|
||||
});
|
||||
loose.forEach(e => ifc.contain(ifc.element(e)));
|
||||
return ifc;
|
||||
}
|
||||
|
||||
return { generate };
|
||||
})();
|
||||
+1746
-294
File diff suppressed because it is too large
Load Diff
+410
-97
@@ -4,23 +4,12 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// house/house-pdf.js — HousePdf: de samenhangende woning-PDF.
|
||||
// Blad 1 aanzichten noord/oost/zuid/west (vlakke "render", vectorvormen)
|
||||
// Blad 2 plattegrond woning (units, wandstukken, naden, binnenwanden)
|
||||
// daarna per unit: vloer (FloorPdf) + elk wandstuk (WallPdf)
|
||||
// daarna elke binnenwand (WallPdf)
|
||||
// daarna totalen (balken, inkoop, glaswol, plaatwerk, gewicht, dakrand)
|
||||
// laatste zaaglijst vuren + zaagmachine-overzicht (tijdelijk)
|
||||
// De losse bladen komen uit de gedeelde WallPdf/FloorPdf.
|
||||
// Plain script, geladen na house-model.js.
|
||||
// ============================================================
|
||||
|
||||
window.HousePdf = (() => {
|
||||
const KIND_LABELS = {
|
||||
'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel',
|
||||
'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl',
|
||||
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel'
|
||||
'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel', 'schuine regel': 'Schuine regel'
|
||||
};
|
||||
const PLAN_ELEMENT = 'PL-1';
|
||||
const VIEWS_ELEMENT = 'AZ-1';
|
||||
@@ -29,14 +18,11 @@ window.HousePdf = (() => {
|
||||
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: 'views', element: VIEWS_ELEMENT, label: 'Aanzichten woning' },
|
||||
@@ -46,18 +32,23 @@ window.HousePdf = (() => {
|
||||
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
|
||||
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
|
||||
u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
|
||||
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
|
||||
if (cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: cap.kind === 'join' ? `Unit ${u.id} dak (aansluiting)` : `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u });
|
||||
});
|
||||
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
|
||||
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
|
||||
list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' });
|
||||
if (parts.elektra && parts.elektra.enabled) list.push({ kind: 'elektra', element: 'EL', label: 'Elektra' });
|
||||
return list;
|
||||
}
|
||||
|
||||
// customer (light-versie): zonder onderdelenlijst, elementcodes en elektra; de tekening schuift dan naar links.
|
||||
function drawPlanSheet(doc, parts, sheets, sheetOpts) {
|
||||
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
||||
const customer = !!sheetOpts.customer;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
|
||||
if (!customer) PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
|
||||
sheets.map((s, i) => [s.element, s.label, String(i + 1)]).filter(r => r[0] !== PLAN_ELEMENT),
|
||||
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
@@ -65,7 +56,7 @@ window.HousePdf = (() => {
|
||||
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 drawX0 = customer ? 15 : 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;
|
||||
@@ -75,7 +66,6 @@ window.HousePdf = (() => {
|
||||
doc.lines(pts.slice(1).map((p, i) => [px(p.x) - px(pts[i].x), py(p.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], style, true);
|
||||
};
|
||||
|
||||
// Vloeren + unit-letter
|
||||
rects.forEach(r => {
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.2);
|
||||
@@ -85,7 +75,13 @@ window.HousePdf = (() => {
|
||||
doc.text(r.id, px(r.cx), py(r.cz) + 2, { align: 'center' });
|
||||
});
|
||||
|
||||
// Wandstukken (buiten + binnen) met openingen
|
||||
const R = parts.rooms;
|
||||
const light = (hex, k) => [1, 3, 5].map(i => Math.round(255 - (255 - parseInt(hex.slice(i, i + 2), 16)) * k));
|
||||
R.rooms.forEach(r => {
|
||||
doc.setFillColor(...light(r.color, 0.35));
|
||||
r.rects.forEach(c => doc.rect(px(c.x0), py(c.z0), (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale, 'F'));
|
||||
});
|
||||
|
||||
const drawPiece = (p) => {
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.3);
|
||||
@@ -93,16 +89,38 @@ window.HousePdf = (() => {
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setLineWidth(0.2);
|
||||
p.config.openings.forEach(op => {
|
||||
p.config.openings.filter(op => op.type !== 'schoorsteen').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));
|
||||
if (op.fill) {
|
||||
const W = p.place.width, T = p.place.thickness;
|
||||
KozijnModel.planSymbol(op, op.fill, { zIn: -T / 2, zOut: T / 2 }).forEach(sh => {
|
||||
doc.setLineWidth(sh.kind === 'leaf' ? 0.25 : 0.12);
|
||||
doc.setLineDashPattern(sh.kind === 'arc' || sh.kind === 'rail' ? [0.8, 0.8] : [], 0);
|
||||
pline(sh.pts.map(pt => UnitModel.localToWorld(p.place, -W / 2 + op.x + pt.x, pt.z)), !!sh.closed);
|
||||
});
|
||||
doc.setLineDashPattern([], 0);
|
||||
doc.setLineWidth(0.2);
|
||||
}
|
||||
});
|
||||
};
|
||||
const pline = (pts, closed) => {
|
||||
if (pts.length < 2) return;
|
||||
doc.lines(pts.slice(1).map((q, i) => [px(q.x) - px(pts[i].x), py(q.z) - py(pts[i].z)]), px(pts[0].x), py(pts[0].z), [1, 1], 'S', closed);
|
||||
};
|
||||
doc.setDrawColor(110, 110, 110);
|
||||
(parts.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
|
||||
doc.setLineWidth(sh.kind === 'thin' ? 0.1 : 0.18);
|
||||
doc.setLineDashPattern(sh.kind === 'dash' ? [0.8, 0.6] : [], 0);
|
||||
if (sh.circle) doc.circle(px(sh.circle.x), py(sh.circle.z), Math.max(0.2, sh.circle.r * scale), 'S');
|
||||
else if (sh.text) { doc.setFontSize(4); doc.setTextColor(110, 110, 110); doc.text(sh.text, px(sh.at.x), py(sh.at.z) + 0.6, { align: 'center' }); }
|
||||
else pline(sh.pts, !!sh.closed);
|
||||
}));
|
||||
doc.setLineDashPattern([], 0);
|
||||
HouseModel.allWallPieces(parts).forEach(drawPiece);
|
||||
|
||||
// Dak: dakranden (grijs gestippeld), boeideel/daktrim-buitenrand, spots
|
||||
doc.setLineDashPattern([1, 1.2], 0);
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
@@ -119,8 +137,19 @@ window.HousePdf = (() => {
|
||||
parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD'));
|
||||
}
|
||||
doc.setLineDashPattern([], 0);
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.setFillColor(160, 124, 84);
|
||||
parts.posts.posts.forEach(pp => doc.rect(px(pp.x - pp.size / 2), py(pp.z - pp.size / 2), pp.size * scale, pp.size * scale, 'FD'));
|
||||
doc.setFillColor(235, 235, 232);
|
||||
parts.posts.walls.forEach(w => doc.rect(px(w.x0), py(w.z0), (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale, 'FD'));
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
doc.setFillColor(150, 150, 150);
|
||||
poly(ch.footprint, 'FD');
|
||||
doc.setFillColor(30, 30, 30);
|
||||
poly(ch.flueFootprint, 'F');
|
||||
});
|
||||
|
||||
// Gevellampen
|
||||
doc.setDrawColor(...BLACK);
|
||||
doc.setFillColor(255, 211, 107);
|
||||
doc.setLineWidth(0.15);
|
||||
@@ -128,14 +157,14 @@ window.HousePdf = (() => {
|
||||
doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD');
|
||||
});
|
||||
|
||||
// Naden: gestippeld oranje
|
||||
if (!customer) ElektraPdf.drawPlan(doc, parts.elektra, px, py);
|
||||
|
||||
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);
|
||||
@@ -146,17 +175,40 @@ window.HousePdf = (() => {
|
||||
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 text = customer ? String(p.place.width) : `${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 => {
|
||||
if (!customer) parts.interior.forEach(p => {
|
||||
doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: Math.abs(Math.sin(p.place.rotY)) < 0.5 ? 0 : 90 });
|
||||
});
|
||||
|
||||
// Buitenmaten woning
|
||||
R.rooms.concat(R.free).forEach(r => {
|
||||
const x = px(r.label.x), y = py(r.label.z), named = !!r.name;
|
||||
const lines = [[named ? r.name : 'Ruimte (geen functie)', named ? 'bold' : 'italic', 7], [`${fmt(r.areaM2, 2)} m²`, 'normal', 6.5]];
|
||||
if (r.ceilingActive) lines.push([`plafond ${r.ceiling}`, 'normal', 5.5]);
|
||||
doc.setTextColor(...(named ? BLACK : GREY));
|
||||
lines.forEach(([t, style, size], i) => {
|
||||
doc.setFont('helvetica', style);
|
||||
doc.setFontSize(size);
|
||||
doc.text(t, x, y + (i - (lines.length - 1) / 2) * 3 + 1, { align: 'center' });
|
||||
});
|
||||
});
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
if (R.rooms.length || R.free.length) {
|
||||
doc.setFontSize(6);
|
||||
const fit = (t, mm) => { let v = String(t); while (v.length > 3 && doc.getTextWidth(v) > mm) v = v.slice(0, -2); return v === String(t) ? v : v + '.'; };
|
||||
const rows = R.rooms.map(r => [fit(r.name, 32), fit(r.typeLabel, 26), fmt(r.areaM2, 2), fit(RoomModel.ceilingLabel(r), 30), fit(RoomModel.floorLabel(r), 30), fit(RoomModel.wallLabel(r), 38)])
|
||||
.concat(R.free.map(r => ['Ruimte', '(geen functie)', fmt(r.areaM2, 2), '', '', '']));
|
||||
rows.push(['Totaal netto', '', fmt(R.rooms.concat(R.free).reduce((a, r) => a + r.areaM2, 0), 2), '', '', '']);
|
||||
const many = rows.length > 8;
|
||||
PdfSheet.table(doc, 105, 242, [0, 34, 62, 78, 110, 142], ['Ruimte', 'Functie', 'Netto m²', 'Plafond', 'Vloer', 'Wanden'], rows,
|
||||
{ title: 'Ruimtestaat', titleSize: 8, lastColW: 40, maxY: 287, rowH: many ? 3.1 : 3.8, fontSize: many ? 5.5 : 6 });
|
||||
}
|
||||
|
||||
doc.setDrawColor(...BLUE);
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setFontSize(7);
|
||||
@@ -166,7 +218,6 @@ window.HousePdf = (() => {
|
||||
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);
|
||||
@@ -176,15 +227,10 @@ window.HousePdf = (() => {
|
||||
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);
|
||||
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp, kleurvlak = ruimte (netto m²)', drawX0, drawY1 + 4);
|
||||
if (!customer && parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Aanzichten: per windrichting een vlakke "render" van de gevels. Alle
|
||||
// zichtbare vlakken (gevels incl. trasraam en openingen, boeideel/daktrim,
|
||||
// hellende dakvlakken) als polygonen in het aanzicht, van achter naar voren
|
||||
// getekend (schildersalgoritme), in egale kleuren uit het model.
|
||||
// ------------------------------------------------------------
|
||||
const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
|
||||
const numRgb = (n) => [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
const VIEW_COLORS = {
|
||||
@@ -198,52 +244,72 @@ window.HousePdf = (() => {
|
||||
{ key: 'west', title: 'Westgevel', d: { x: -1, z: 0 }, u: (p) => p.z }
|
||||
];
|
||||
|
||||
// Alle vlakken in wereldcoördinaten: { pts: [{x, y, z}], n: {x, z} | null (dakvlak: nH), color, bias }.
|
||||
function viewFaces(parts, state) {
|
||||
const faces = [];
|
||||
const wallFace = (p, y0, color, depthBias) => {
|
||||
const W = p.place.width, T = p.place.thickness;
|
||||
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone), e = p.skinExt || {};
|
||||
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), e = p.skinExt || {};
|
||||
const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0);
|
||||
const tl = WallModel.topLine(p.config), up = p.skinAbove || 0;
|
||||
const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up;
|
||||
const tl = WallModel.topLine(WallModel.skinConfig(p.config, p.skinAbove || 0, p.skinTop));
|
||||
const top = (x) => Math.min(tl.at(x), tl.at(Math.max(-W / 2, Math.min(W / 2, x))));
|
||||
const loc = [{ x: xl, y: y0 }, { x: xr, y: y0 }, { x: xr, y: top(xr) }]
|
||||
.concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y + up })))
|
||||
.concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y })))
|
||||
.concat([{ x: xl, y: top(xl) }]);
|
||||
return loc;
|
||||
};
|
||||
const toWorld = (p, lx, y, lz) => { const w = UnitModel.localToWorld(p.place, lx, lz); return { x: w.x, y: p.place.floorTop + y, z: w.z }; };
|
||||
const addWall = (p, y0) => {
|
||||
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
||||
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone);
|
||||
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : VIEW_COLORS.rabat;
|
||||
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
||||
// Trasraam (strook onderaan, iets naar voren)
|
||||
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : p.fels ? WallModel.felsHex(p.fels)
|
||||
: WallModel.rabatHex(state.settings.facade.rabatColor);
|
||||
faces.push({ kind: 'wand', pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
||||
const tr = p.trasraam;
|
||||
if (tr && tr.top > y0) {
|
||||
const W = p.place.width;
|
||||
const band = wallFace(p, y0).map(q => ({ x: q.x, y: Math.min(q.y, tr.top) }));
|
||||
const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat;
|
||||
const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12);
|
||||
faces.push({ pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
|
||||
faces.push({ kind: 'trasraam', pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 });
|
||||
// Afdekker: neus over de bovenkant van de steen (alleen in 3D onderbroken bij openingen).
|
||||
if (p.stats && p.stats.trasraamCapM > 0) {
|
||||
const C = WallModel.TRAS_CAP, yLo = tr.top - C.DROP_MM, yHi = tr.top + C.THICK_MM;
|
||||
faces.push({ kind: 'trasraam', pts: [[-W / 2, yLo], [W / 2, yLo], [W / 2, yHi], [-W / 2, yHi]].map(([lx, ly]) => toWorld(p, lx, ly, zT + C.GAP_MM + C.THICK_MM)),
|
||||
n, color: WallModel.trimHex(tr.capColor), bias: 1.5 });
|
||||
}
|
||||
}
|
||||
// Openingen (glas / deur), vóór de gevel
|
||||
(p.config.openings || []).forEach(op => {
|
||||
if (op.y + op.h <= y0) return;
|
||||
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y), yb = op.y + op.h;
|
||||
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||
if (WallModel.openingTopMax(op) <= y0) return;
|
||||
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y);
|
||||
const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR);
|
||||
const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
|
||||
faces.push({ pts: [[x0, ya], [x1, ya], [x1, yb], [x0, yb]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true });
|
||||
const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat';
|
||||
const detail = op.fill ? KozijnModel.viewLines(op, op.fill, exterior).map(pl => pl.map(q => toWorld(p, x0 + q.x, op.y + q.y, zOut))) : [];
|
||||
faces.push({ kind: 'opening', pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true, detail });
|
||||
});
|
||||
};
|
||||
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0))));
|
||||
parts.interior.filter(p => p.skinFrom !== undefined && p.place.normal).forEach(p => addWall(p, p.skinFrom));
|
||||
|
||||
// Hellende dakvlakken (bovenkant, egaal in de kleur van de bedekking).
|
||||
parts.units.forEach(u => {
|
||||
const r = u.roof, sh = r.shape;
|
||||
if (sh.type === 'plat') return;
|
||||
const cov = RoofModel.COVERINGS[r.covering] || RoofModel.COVERINGS.dakleer;
|
||||
const ctr = sh.axis === 'x' ? u.rect.cx : u.rect.cz;
|
||||
const pushRoof = (pts) => {
|
||||
const [A, B, C] = pts;
|
||||
const ux = B.x - A.x, uy = B.y - A.y, uz = B.z - A.z, vx = C.x - A.x, vy = C.y - A.y, vz = C.z - A.z;
|
||||
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
||||
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
|
||||
const len = Math.hypot(nx, ny, nz) || 1;
|
||||
faces.push({ kind: 'dak', pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
|
||||
};
|
||||
RoofModel.capRegions(r.unitRoof).forEach(rg => {
|
||||
pushRoof(rg.poly.map(pt => {
|
||||
const ax = r.unitRoof.alongX, x = u.rect.cx + (ax ? pt.x : pt.z), z = u.rect.cz + (ax ? pt.z : pt.x);
|
||||
return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z };
|
||||
}));
|
||||
});
|
||||
r.planes.forEach(pl => {
|
||||
const a0 = ctr + pl.p0, a1 = ctr + pl.p1, mid = (a0 + a1) / 2;
|
||||
// Hoogte net binnen het vlak (op de nok geeft topAt het hoogste vlak).
|
||||
@@ -251,53 +317,82 @@ window.HousePdf = (() => {
|
||||
const corners = sh.axis === 'x'
|
||||
? [[a0, r.rect.z0], [a1, r.rect.z0], [a1, r.rect.z1], [a0, r.rect.z1]]
|
||||
: [[r.rect.x0, a0], [r.rect.x1, a0], [r.rect.x1, a1], [r.rect.x0, a1]];
|
||||
const pts = corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z }));
|
||||
// Normaal (omhoog) uit het vlak.
|
||||
const [A, B, C] = pts;
|
||||
const ux = B.x - A.x, uy = B.y - A.y, uz = B.z - A.z, vx = C.x - A.x, vy = C.y - A.y, vz = C.z - A.z;
|
||||
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
||||
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
|
||||
const len = Math.hypot(nx, ny, nz) || 1;
|
||||
faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
|
||||
pushRoof(corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z })));
|
||||
});
|
||||
});
|
||||
|
||||
// Dakrand: buitenvlak van elk boeideel / elke daktrim.
|
||||
const edge = parts.roofEdge;
|
||||
if (edge) {
|
||||
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
|
||||
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, z: a.z + (b.z - a.z) * t });
|
||||
edge.segments.forEach(sg => {
|
||||
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
||||
const A = sg.outerA, B = sg.outerB, tA = sg.topAt(A), tB = sg.topAt(B);
|
||||
const pts = [{ x: A.x, y: tA, z: A.z }, { x: B.x, y: tB, z: B.z }, { x: B.x, y: tB - h, z: B.z }, { x: A.x, y: tA - h, z: A.z }];
|
||||
faces.push({ pts, n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
||||
const h = RoofModel.edgeHeight(edge, sg);
|
||||
const n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100));
|
||||
const top = [], bottom = [];
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const t = k / n, I = lerp(sg.a, sg.b, t), O = lerp(sg.outerA, sg.outerB, t);
|
||||
const tI = sg.topAt(I), tO = sg.topAt(O);
|
||||
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
|
||||
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
|
||||
}
|
||||
// Rooster op de gevel, onder de onderplaat.
|
||||
if (sg.fascia && sg.facadeUnder && edge.vent) {
|
||||
const V = RoofModel.VENT, d = RoofModel.ventFace(sg) + V.THICK_MM - (sg.a.x * sg.normal.x + sg.a.z * sg.normal.z);
|
||||
const vTop = [];
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const I = lerp(sg.a, sg.b, k / n), q = { x: I.x + sg.normal.x * d, z: I.z + sg.normal.z * d };
|
||||
vTop.push({ x: q.x, y: sg.topAt(q) - h, z: q.z });
|
||||
}
|
||||
faces.push({ kind: 'dakrand', pts: vTop.concat(vTop.map(q => ({ x: q.x, y: q.y - V.HEIGHT_MM, z: q.z })).reverse()),
|
||||
n: { x: sg.normal.x, z: sg.normal.z }, color: WallModel.trimHex(edge.ventColor), bias: 2.5 });
|
||||
}
|
||||
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: RoofModel.edgeColor(edge, sg), bias: 3 });
|
||||
});
|
||||
}
|
||||
(parts.chimneys || []).forEach(ch => {
|
||||
const S = ChimneyModel.SIDES[ch.side];
|
||||
const W = (t, s, y) => S.axis === 'z' ? { x: ch.plane + S.n.x * s, y, z: t } : { x: t, y, z: ch.plane + S.n.z * s };
|
||||
const color = ch.outside.type === 'steen' ? (WallModel.STONE_COLORS[ch.outside.stone] || '#8a6a5c') : WallModel.stucHex(ch.outside.stuc);
|
||||
const tN = S.axis === 'z' ? { x: 0, z: 1 } : { x: 1, z: 0 };
|
||||
const side = (a, b, ya, n) => faces.push({ kind: 'schoorsteen', pts: [W(a.t, a.s, ya), W(b.t, b.s, ya), W(b.t, b.s, ch.yTop), W(a.t, a.s, ch.yTop)], n, color, bias: 4 });
|
||||
side({ t: ch.t0, s: ch.sFront }, { t: ch.t1, s: ch.sFront }, ch.yGround, S.n);
|
||||
side({ t: ch.t0, s: ch.sIn }, { t: ch.t0, s: ch.sFront }, ch.yGround, { x: -tN.x, z: -tN.z });
|
||||
side({ t: ch.t1, s: ch.sIn }, { t: ch.t1, s: ch.sFront }, ch.yGround, tN);
|
||||
side({ t: ch.t0, s: ch.sIn }, { t: ch.t1, s: ch.sIn }, ch.yUnder, { x: -S.n.x, z: -S.n.z });
|
||||
});
|
||||
return faces;
|
||||
}
|
||||
|
||||
// Legenda "Afwerking": gevel(s) (incl. overrides), trasraam, dak per unit,
|
||||
// vloer, boeideel/daktrim en binnenwanden. Per item een voorvertoning
|
||||
// (thumb, via PdfSheet.preloadImage vooraf geladen) of een kleurvlak.
|
||||
function materialLegend(state, parts) {
|
||||
const items = [];
|
||||
const st = state.settings;
|
||||
const seen = new Set();
|
||||
const facades = [];
|
||||
state.units.forEach(u => Object.keys(u.unit.walls).forEach(side => {
|
||||
const fc = HouseModel.facadeFor(state, u, side);
|
||||
const key = fc.type === 'steen' ? 'steen:' + fc.stone : 'rabat';
|
||||
facades.push(HouseModel.facadeFor(state, u, side));
|
||||
UnitModel.normalizeSplits(u.unit.walls[side].splits).filter(sp => sp.facade).forEach(sp => facades.push(HouseModel.facadeFor(state, u, side, sp.facade)));
|
||||
}));
|
||||
facades.forEach(fc => {
|
||||
const key = fc.type === 'steen' ? 'steen:' + fc.stone : fc.type === 'stuc' ? 'stuc:' + fc.stuc : fc.type === 'fels' ? 'fels:' + fc.fels : 'rabat';
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
items.push(fc.type === 'steen'
|
||||
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
||||
: { label: 'Gevel', value: `Rabat (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: VIEW_COLORS.rabat });
|
||||
}));
|
||||
: fc.type === 'stuc'
|
||||
? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) }
|
||||
: fc.type === 'fels'
|
||||
? { label: 'Gevel', value: `Felsplaten ${((WallModel.FELS.COLORS[fc.fels] || {}).label || '').toLowerCase()} (staande naad)`, color: WallModel.felsHex(fc.fels) }
|
||||
: { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) });
|
||||
});
|
||||
if (st.trasraam.enabled) {
|
||||
const tf = st.trasraam.finish;
|
||||
items.push(WallModel.isStoneFinish(tf)
|
||||
? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] }
|
||||
: { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat });
|
||||
const tc = WallModel.TRIM_COLORS[st.trasraam.capColor];
|
||||
if (parts && tc && HouseModel.allWallPieces(parts).some(p => p.stats && p.stats.trasraamCapM > 0)) items.push({ label: 'Afdekker trasraam', value: tc.label, color: tc.hex });
|
||||
}
|
||||
const vc = parts && parts.roofEdge && parts.roofEdge.vent && parts.roofEdge.ventLengthMM > 0 && WallModel.TRIM_COLORS[parts.roofEdge.ventColor];
|
||||
if (vc) items.push({ label: 'Ventilatierooster', value: vc.label, color: vc.hex });
|
||||
(parts ? parts.units : state.units).forEach(u => {
|
||||
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
|
||||
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
|
||||
@@ -316,7 +411,6 @@ window.HousePdf = (() => {
|
||||
: { label: 'Binnenwanden', value: 'Spaanplaat', color: '#1e4620' });
|
||||
return items;
|
||||
}
|
||||
// Voorvertoningen die de legenda gebruikt (om vooraf te laden).
|
||||
function materialThumbUrls(state) {
|
||||
return materialLegend(state, null).map(i => i.thumb).filter(Boolean);
|
||||
}
|
||||
@@ -350,7 +444,6 @@ window.HousePdf = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
// Afmetingen (zelfde rijen als het infopaneel in de app), in twee kolommen.
|
||||
function drawDimensions(doc, rows, x0, y0, x1) {
|
||||
const { BLACK, GREY } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'bold');
|
||||
@@ -370,11 +463,10 @@ window.HousePdf = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
function drawViewsSheet(doc, parts, state, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
// Per gevel de zichtbare vlakken in (u, hoogte), van achter naar voor.
|
||||
function viewProjections(parts, state) {
|
||||
const faces = viewFaces(parts, state);
|
||||
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
|
||||
// Per aanzicht de zichtbare vlakken in (u, v) mm, met diepte.
|
||||
const views = VIEWS.map(v => {
|
||||
const dot = (p) => p.x * v.d.x + p.z * v.d.z;
|
||||
const list = faces.filter(f => f.roof ? (f.n.x * v.d.x + f.n.z * v.d.z) > 0.01 : (f.n.x * v.d.x + f.n.z * v.d.z) > 0.5)
|
||||
@@ -383,8 +475,13 @@ window.HousePdf = (() => {
|
||||
const us = list.flatMap(l => l.pts.map(p => p.u)), vs = list.flatMap(l => l.pts.map(p => p.v)).concat([groundY]);
|
||||
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
|
||||
}).filter(x => x.list.length);
|
||||
return { groundY, views };
|
||||
}
|
||||
|
||||
function drawViewsSheet(doc, parts, state, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
const { groundY, views } = viewProjections(parts, state);
|
||||
|
||||
// Raster 2 x 2 boven het titelblok; één schaal voor alle aanzichten.
|
||||
const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7;
|
||||
const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2;
|
||||
const scale = Math.min(...views.map(w => Math.min((cellW - 16) / (w.u1 - w.u0), (cellH - titleH - 10) / (w.v1 - w.v0))));
|
||||
@@ -392,7 +489,7 @@ window.HousePdf = (() => {
|
||||
views.forEach((w, i) => {
|
||||
const cx = X0 + (i % 2) * (cellW + gap), cy = Y0 + Math.floor(i / 2) * (cellH + gap);
|
||||
const ox = cx + cellW / 2 - (w.u0 + w.u1) / 2 * scale;
|
||||
const base = cy + cellH - 6; // grondlijn
|
||||
const base = cy + cellH - 6;
|
||||
const P = (p) => [ox + p.u * scale, base - (p.v - groundY) * scale];
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(11);
|
||||
@@ -405,7 +502,6 @@ window.HousePdf = (() => {
|
||||
doc.setFillColor(...col);
|
||||
doc.lines(pts.slice(1).map((q, k) => [q[0] - pts[k][0], q[1] - pts[k][1]]), pts[0][0], pts[0][1], [1, 1], 'FD', true);
|
||||
});
|
||||
// Grondlijn
|
||||
doc.setDrawColor(...VIEW_COLORS.grond);
|
||||
doc.setLineWidth(0.4);
|
||||
doc.line(ox + w.u0 * scale - 6, base, ox + w.u1 * scale + 6, base);
|
||||
@@ -418,6 +514,78 @@ window.HousePdf = (() => {
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', kilkeper: 'Kilkeper', nokbalk: 'Nokbalk', knikbalk: 'Knikbalk', kopbalk: 'Kopbalk' };
|
||||
function drawCapSheet(doc, u, sheetOpts) {
|
||||
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
||||
const ur = u.roof.unitRoof, c = ur.cap;
|
||||
const xz = (pt) => ur.alongX ? { x: pt.x, z: pt.z } : { x: pt.z, z: pt.x };
|
||||
const band = [{ x: c.pA, z: c.q0 }, { x: c.pB, z: c.q0 }, { x: c.pB, z: c.q1 }, { x: c.pA, z: c.q1 }].map(xz);
|
||||
const xs = band.map(p => p.x), zs = band.map(p => p.z);
|
||||
const X0 = 20, X1 = 275, Y0 = 32, Y1 = 230;
|
||||
const sc = Math.min((X1 - X0) / (Math.max(...xs) - Math.min(...xs)), (Y1 - Y0) / (Math.max(...zs) - Math.min(...zs)));
|
||||
const ox = (X0 + X1) / 2 - (Math.min(...xs) + Math.max(...xs)) / 2 * sc, oz = (Y0 + Y1) / 2 - (Math.min(...zs) + Math.max(...zs)) / 2 * sc;
|
||||
const P = (p) => [ox + p.x * sc, oz + p.z * sc];
|
||||
const poly = (pts, style) => {
|
||||
const q = pts.map(P);
|
||||
doc.lines(q.slice(1).map((b, k) => [b[0] - q[k][0], b[1] - q[k][1]]), q[0][0], q[0][1], [1, 1], style, true);
|
||||
};
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(11);
|
||||
const join = c.kind === 'join';
|
||||
doc.text(join ? 'Bovenaanzicht dak (aansluiting: kil- en hoekkepers, kreupele sporen)' : 'Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20);
|
||||
doc.setDrawColor(...GREY);
|
||||
doc.setLineWidth(0.15);
|
||||
c.regions.forEach(rg => poly(rg.poly.map(xz), 'S'));
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.2);
|
||||
c.beams.forEach(bm => poly(bm.poly.map(xz), 'S'));
|
||||
doc.setDrawColor(...BLUE);
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setFontSize(7);
|
||||
const bx0 = Math.min(...xs), bx1 = Math.max(...xs), bz0 = Math.min(...zs), bz1 = Math.max(...zs);
|
||||
const [ax, ay] = P({ x: bx0, z: bz1 }), [bx, by] = P({ x: bx1, z: bz1 });
|
||||
PdfSheet.dimLine(doc, ax, ay + 8, bx, by + 8);
|
||||
doc.text(`${Math.round(bx1 - bx0)}`, (ax + bx) / 2, ay + 7, { align: 'center' });
|
||||
const [cx0, cy0] = P({ x: bx1, z: bz0 }), [cx1, cy1] = P({ x: bx1, z: bz1 });
|
||||
PdfSheet.dimLine(doc, cx0 + 8, cy0, cx1 + 8, cy1);
|
||||
doc.text(`${Math.round(bz1 - bz0)}`, cx0 + 9.5, (cy0 + cy1) / 2, { angle: 90 });
|
||||
|
||||
const groups = new Map();
|
||||
c.beams.forEach(bm => {
|
||||
const key = `${bm.kind}|${Math.round(bm.length / 10) * 10}|${bm.angle}`;
|
||||
const g = groups.get(key) || { kind: bm.kind, length: Math.round(bm.length / 10) * 10, angle: bm.angle, n: 0 };
|
||||
g.n++;
|
||||
groups.set(key, g);
|
||||
});
|
||||
const order = Object.keys(CAP_KINDS);
|
||||
const rows = [...groups.values()].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.length - a.length)
|
||||
.map(g => [CAP_KINDS[g.kind] || g.kind, String(g.length), String(g.angle).replace('.', ','), String(g.n)]);
|
||||
const H = (p) => RoofModel.profileHeight(ur.shape.profile, p);
|
||||
const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ',');
|
||||
const hip = c.regions.find(rg => rg.key === 'h1');
|
||||
const facts = (join ? [
|
||||
['Goot (onderkant dak)', `${Math.round(c.eave)} mm`],
|
||||
['Nok (onderkant dak)', `${Math.round(c.ridgeH)} mm`],
|
||||
['Helling dakvlakken', `${deg(c.slope)}°`]
|
||||
] : [
|
||||
['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`],
|
||||
['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`],
|
||||
['Helling dakvlakken', `${deg(c.slope)}°`],
|
||||
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-']
|
||||
]).concat([
|
||||
['Balken', `${c.joistW} x ${c.joistH} mm`],
|
||||
['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`],
|
||||
['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`]
|
||||
]);
|
||||
doc.setTextColor(...BLACK);
|
||||
let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: join ? 'Dak met aansluiting' : 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 });
|
||||
PdfSheet.table(doc, 290, y + 6, [0, 32, 56, 78], ['Soort', 'Lengte (mm)', 'Hoek (°)', 'Aantal'], rows, { title: 'Stuklijst balken', fontSize: 7, rowH: 4.6, lastColW: 14, maxY: 240 });
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(...GREY);
|
||||
doc.text('Hoogtes vanaf bovenkant vloer. Lengte = langs de balk; hoek = helling van de balk (zaagsnede aan beide koppen). Grijs = dakvlakken, rood = houtwerk.', X0, 240);
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
function drawTotalsSheet(doc, totals, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
@@ -455,6 +623,13 @@ window.HousePdf = (() => {
|
||||
const wRows = totals.weights.map(r => [r.label, fmt(r.kg) + ' kg']);
|
||||
wRows.push(['Totaal woning', fmt(totals.totalKg) + ' kg']);
|
||||
y = PdfSheet.table(doc, 120, y, [0, 50], ['Onderdeel', 'Gewicht'], wRows, tableOpts('Gewicht'));
|
||||
if (totals.nokbalken.length) {
|
||||
y += 4;
|
||||
y = PdfSheet.table(doc, 120, y, [0, 22, 60], ['Unit', 'Profiel', 'Lengte, gewicht, vulhout'], totals.nokbalken.map(n => [
|
||||
n.unit, `${n.name} S235${n.ok ? '' : ' (te licht!)'}`,
|
||||
`${fmt(n.lengthM, 2)} m, ${fmt(n.kg)} kg, ${fmt(n.fillM3, 3)} m3${n.below ? `, ${n.below} mm onder plafond` : ''}`]),
|
||||
tableOpts('Stalen nokbalk (IPE, vulhout in de holling)'));
|
||||
}
|
||||
if (totals.roofEdge) {
|
||||
y += 4;
|
||||
const e = totals.roofEdge, L = totals.lamps;
|
||||
@@ -464,17 +639,22 @@ window.HousePdf = (() => {
|
||||
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`]);
|
||||
if (e.ventLengthM > 0) rows.push(['Ventilatierooster', `${fmt(e.ventLengthM, 2)} m op de gevel onder de boeidelen, ${RoofModel.VENT.HEIGHT_MM} mm hoog, ${e.ventColor.toLowerCase()}`]);
|
||||
if (totals.trasraamCap && totals.trasraamCap.lengthM > 0) rows.push(['Afdekker trasraam', `${fmt(totals.trasraamCap.lengthM, 2)} m, ${totals.trasraamCap.color.toLowerCase()}`]);
|
||||
rows.push(['Boeideel-spots', e.spots + ' stuks']);
|
||||
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
|
||||
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
|
||||
if (totals.posts) {
|
||||
const pp = totals.posts;
|
||||
rows.push(['Palen overkapping', `${pp.count} stuks ${pp.wood.toLowerCase()} ${pp.size}x${pp.size} mm, ${fmt(pp.lengthM, 2)} m, ${fmt(pp.m3, 3)} m3, op paalvoeten`]);
|
||||
}
|
||||
PdfSheet.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
|
||||
}
|
||||
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
// Eigen blad na de totalen: zaaglijst vuren (in kolommen) + zaagmachine.
|
||||
function drawSawSheet(doc, totals, sheetOpts) {
|
||||
function drawSawSheet(doc, totals, cost, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...BLACK);
|
||||
@@ -494,10 +674,10 @@ window.HousePdf = (() => {
|
||||
|
||||
// TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht.
|
||||
const sw = totals.saw;
|
||||
const hm = (h) => `${Math.floor(h)}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`;
|
||||
PdfSheet.table(doc, 290, 30, [0, 55], ['Onderdeel', 'Waarde'], [
|
||||
['Zaagmeters', fmt(sw.meters, 1) + ' m'],
|
||||
[`Zaagtijd (${HouseModel.SAW_M_PER_H} m/u)`, hm(sw.hours) + ' u:mm'],
|
||||
['Zaagtijd robot', CostModel.uur(cost.saw.robotSec / 3600) + ' uur'],
|
||||
[`Zaagtijd handmatig (\u00d7${cost.settings.zagen.handFactor})`, CostModel.uur(cost.saw.handSec / 3600) + ' uur'],
|
||||
['Zaagsneden', sw.cuts + ' st.'],
|
||||
['Verschillende hoeken', String(sw.angles.length)]
|
||||
].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0\u00b0)' : `Schuin ${fmt(a.deg, 1)}\u00b0`, a.count + ' sneden'])), tableOpts('Zaagmachine (tijdelijk)'));
|
||||
@@ -505,28 +685,161 @@ window.HousePdf = (() => {
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
|
||||
const COST_ROW_H = 3.4;
|
||||
const COST_COLS = [{ x: 15, yMax: 278 }, { x: 150, yMax: 278 }, { x: 285, yMax: 246 }];
|
||||
const COST_Y0 = 30;
|
||||
const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '\u2026' : t);
|
||||
function costDetailRows(cost) {
|
||||
return cost.elements.flatMap(el => elementCostRows(el));
|
||||
}
|
||||
function elementCostRows(el) {
|
||||
const rows = [];
|
||||
if (el.lines.length) {
|
||||
rows.push({ head: true, label: `${el.element} ${el.label}`, mat: CostModel.eur(el.mat), time: CostModel.uur(el.sec / 3600) });
|
||||
el.lines.forEach(l => rows.push({
|
||||
label: l.label, qty: `${fmt(l.qty, ['st.', 'sneden', 'sectie'].includes(l.unit) ? 0 : 2)} ${l.unit}`,
|
||||
mat: l.mat ? CostModel.eur(l.mat, 2) : '', time: l.sec ? CostModel.uur(l.sec / 3600) : ''
|
||||
}));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
function drawElementCost(doc, el, extraLabel) {
|
||||
if (!el || !el.lines.length) return;
|
||||
const x = 132, y = 236, yMax = 287;
|
||||
const rows = elementCostRows(el);
|
||||
rows[0] = Object.assign({}, rows[0], { label: `Kosten ${el.element}${extraLabel ? ' (' + extraLabel + ')' : ''}` });
|
||||
rows.push({ head: true, label: 'Kostprijs', mat: CostModel.eur(el.kostprijs), time: '' });
|
||||
const fit = Math.floor((yMax - y - 5) / COST_ROW_H);
|
||||
let shown = rows;
|
||||
if (rows.length > fit) {
|
||||
shown = rows.slice(0, fit - 2).concat([{ label: `+ ${rows.length - fit + 1} regels: zie kostenblad detail`, mat: '', time: '' }, rows[rows.length - 1]]);
|
||||
}
|
||||
drawCostTable(doc, x, y, shown);
|
||||
}
|
||||
function paginateCostRows(rows) {
|
||||
const pages = [];
|
||||
let i = 0;
|
||||
while (i < rows.length) {
|
||||
const cols = COST_COLS.map(c => {
|
||||
const n = Math.floor((c.yMax - COST_Y0 - 6) / COST_ROW_H);
|
||||
const chunk = rows.slice(i, i + n);
|
||||
i += chunk.length;
|
||||
return chunk;
|
||||
});
|
||||
pages.push(cols);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
function drawCostTable(doc, x, y, rows) {
|
||||
const { BLACK, GREY } = PdfSheet.COLORS;
|
||||
doc.setFontSize(6.5);
|
||||
doc.setTextColor(...GREY);
|
||||
doc.text('Omschrijving', x, y); doc.text('Hoeveelheid', x + 84, y, { align: 'right' });
|
||||
doc.text('Materiaal', x + 104, y, { align: 'right' }); doc.text('Manuur', x + 124, y, { align: 'right' });
|
||||
doc.setDrawColor(...GREY); doc.setLineWidth(0.15);
|
||||
doc.line(x, y + 1.5, x + 126, y + 1.5);
|
||||
y += 5;
|
||||
rows.forEach(r => {
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFont('helvetica', r.head ? 'bold' : 'normal');
|
||||
doc.text(clip(r.label, r.head ? 60 : 52), r.head ? x : x + 2, y);
|
||||
if (r.qty) doc.text(r.qty, x + 84, y, { align: 'right' });
|
||||
doc.text(r.mat, x + 104, y, { align: 'right' });
|
||||
doc.text(r.time, x + 124, y, { align: 'right' });
|
||||
y += COST_ROW_H;
|
||||
});
|
||||
const totals = HouseModel.computeTotals(parts);
|
||||
const sheets = sheetList(parts);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
}
|
||||
|
||||
function drawCostSheet(doc, cost, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
const s = cost.summary, S = cost.settings, eur = (n) => CostModel.eur(n);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(14);
|
||||
doc.text('Kostenraming (indicatief)', 15, 18);
|
||||
const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 24 });
|
||||
let y = PdfSheet.table(doc, 15, 30, [0, 82], ['Post', 'Bedrag'],
|
||||
s.rows.map(r => [r.label, eur(r.amount)]).concat([['Totaal incl. btw', eur(s.inclBtw)]]), tableOpts('Samenvatting'));
|
||||
y = PdfSheet.table(doc, 15, y + 6, [0, 82], ['Groep', 'Materiaal'],
|
||||
cost.categories.filter(r => r.mat > 0).map(r => [r.name, eur(r.mat)]), tableOpts('Materiaal per groep'));
|
||||
PdfSheet.table(doc, 15, y + 6, [0, 82], ['Werkzaamheid', 'Manuur'],
|
||||
cost.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600)])
|
||||
.concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect)], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa)], ['Totaal', CostModel.uur(s.hours)]]),
|
||||
tableOpts('Arbeid per werkzaamheid'));
|
||||
|
||||
PdfSheet.table(doc, 130, 30, [0, 72, 97, 122], ['Element / onderdeel', 'Materiaal', 'Manuur', 'Kostprijs*'],
|
||||
cost.elements.filter(el => el.lines.length).map(el => [clip(`${el.element} ${el.label}`, 48), eur(el.mat), CostModel.uur(el.sec / 3600), eur(el.kostprijs)]),
|
||||
Object.assign(tableOpts('Per element'), { maxY: 278 }));
|
||||
PdfSheet.table(doc, 290, 30, [0, 40, 65, 88], ['Unit', 'Materiaal', 'Manuur', 'Kostprijs*'],
|
||||
cost.units.map(u => [u.id ? `Unit ${u.id}` : 'Woning-breed', eur(u.mat), CostModel.uur(u.sec / 3600), eur(u.kostprijs)]), tableOpts('Per unit'));
|
||||
doc.setFontSize(6.5);
|
||||
doc.text('* Kostprijs: materiaal incl. verlies en klein materiaal + arbeid (manuren incl. bezetting, indirect en QA) + afschrijving/huisvesting', 290, 236);
|
||||
doc.text(' (+ transport per unit, kraan bij woning-breed); zonder algemene kosten, marge en btw.', 290, 240);
|
||||
doc.text('Schattingen op basis van Instellingen > Kosten & tijden; bedragen excl. btw tenzij vermeld.', 290, 244);
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
function drawCostDetailSheet(doc, cols, sheetOpts) {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...PdfSheet.COLORS.BLACK);
|
||||
doc.setFontSize(14);
|
||||
doc.text('Kostenraming per element (detail)', 15, 18);
|
||||
cols.forEach((rows, i) => { if (rows.length) drawCostTable(doc, COST_COLS[i].x, COST_Y0, rows); });
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
// opts.customer (light-versie): alleen aanzichten en plattegrond, zonder productie-informatie en kosten.
|
||||
// opts.doc: verder in een bestaand document (nieuwe pagina's erachter); opts.pageOffset/pageTotal: bladnummering
|
||||
// als er pagina's omheen komen (voorblad e.d.).
|
||||
function generate(state, opts) {
|
||||
const o = opts || {};
|
||||
const customer = !!o.customer;
|
||||
const parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
|
||||
elektraRough: false
|
||||
});
|
||||
const sheets = sheetList(parts).filter(s => !customer || s.kind === 'views' || s.kind === 'plan');
|
||||
const totals = customer ? null : HouseModel.computeTotals(parts);
|
||||
const cost = customer ? null : CostModel.compute(parts, totals);
|
||||
const costOf = (element) => cost.elements.find(el => el.element === element);
|
||||
const drawWallWithCost = (sheet, opts) => {
|
||||
WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [],
|
||||
overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts));
|
||||
drawElementCost(doc, costOf(sheet.element));
|
||||
};
|
||||
if (!customer) {
|
||||
const detailPages = paginateCostRows(costDetailRows(cost));
|
||||
sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' });
|
||||
detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols }));
|
||||
}
|
||||
const project = state.project;
|
||||
const doc = PdfSheet.createDoc();
|
||||
const doc = o.doc || PdfSheet.createDoc();
|
||||
const offset = o.pageOffset || 0, total = o.pageTotal || sheets.length;
|
||||
|
||||
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 (i > 0 || o.doc) doc.addPage('a3', 'landscape');
|
||||
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${offset + i + 1} / ${total}`, customer };
|
||||
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
|
||||
else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
|
||||
else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts);
|
||||
else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true }, opts));
|
||||
else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts));
|
||||
else if (sheet.kind === 'saw') drawSawSheet(doc, totals, opts);
|
||||
else if (sheet.kind === 'floor') {
|
||||
FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, Object.assign({ overlay: ElektraPdf.floorOverlay(parts.elektra, sheet.unit) }, opts));
|
||||
drawElementCost(doc, costOf(sheet.element));
|
||||
} else if (sheet.kind === 'roof') {
|
||||
const pu = parts.units.find(u => u.roof.planes.includes(sheet.plane));
|
||||
FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true, overlay: pu && ElektraPdf.roofOverlay(parts.elektra, pu, sheet.plane) }, opts));
|
||||
if (/-D1$/.test(sheet.element)) drawElementCost(doc, costOf(sheet.element.replace(/\d+$/, '')), 'alle dakvlakken');
|
||||
}
|
||||
else if (sheet.kind === 'cap') drawCapSheet(doc, sheet.unit, opts);
|
||||
else if (sheet.kind === 'wall') drawWallWithCost(sheet, opts);
|
||||
else if (sheet.kind === 'saw') drawSawSheet(doc, totals, cost, opts);
|
||||
else if (sheet.kind === 'elektra') ElektraPdf.drawSheet(doc, parts.elektra, opts);
|
||||
else if (sheet.kind === 'cost') drawCostSheet(doc, cost, opts);
|
||||
else if (sheet.kind === 'costdetail') drawCostDetailSheet(doc, sheet.cols, opts);
|
||||
else drawTotalsSheet(doc, totals, opts);
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
return { generate, sheetList, materialThumbUrls };
|
||||
return { generate, sheetList, materialThumbUrls, viewFaces, viewProjections, VIEWS };
|
||||
})();
|
||||
|
||||
+2174
-582
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,531 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Lokaal (mm): x langs de breedte (midden 0), z van de rug (-d/2) naar de voorkant, y vanaf het loopvlak.
|
||||
// rot in graden met de klok mee, 0 = rug naar het noorden. anchor 'oost': x vanaf de oostrand.
|
||||
|
||||
window.FixtureModel = (() => {
|
||||
const toM = 0.001;
|
||||
const CATS = { sanitair: 'Sanitair', keuken: 'Keuken', kasten: 'Kasten', wonen: 'Wonen', slapen: 'Slapen', techniek: 'Techniek' };
|
||||
|
||||
const mats = {};
|
||||
const M = (key, make) => mats[key] || (mats[key] = make());
|
||||
const std = (color, rough, metal, extra) => new THREE.MeshStandardMaterial(Object.assign({ color, roughness: rough, metalness: metal || 0 }, extra || {}));
|
||||
const MAT = {
|
||||
keramiek: () => M('keramiek', () => std(0xf7f7f4, 0.18, 0.05)),
|
||||
chroom: () => M('chroom', () => std(0xd8dadc, 0.15, 0.95)),
|
||||
zwartMetaal: () => M('zwartMetaal', () => std(0x1d1e20, 0.4, 0.6)),
|
||||
glas: () => M('glas', () => std(0xb9d4dc, 0.05, 0.1, { transparent: true, opacity: 0.25, depthWrite: false, side: THREE.DoubleSide })),
|
||||
spiegel: () => M('spiegel', () => std(0xdfe6ea, 0.03, 1)),
|
||||
voorzet: () => M('voorzet', () => std(0xeceae5, 0.6)),
|
||||
tegel: () => M('tegel', () => std(0x8d8f91, 0.5)),
|
||||
rubber: () => M('rubber', () => std(0x1b1b1b, 0.8)),
|
||||
werkblad: () => M('werkblad', () => std(0x2f3133, 0.35)),
|
||||
kookplaat: () => M('kookplaat', () => std(0x0e0e10, 0.08, 0.2)),
|
||||
ring: () => M('ring', () => std(0x5a5c60, 0.3)),
|
||||
plint: () => M('plint', () => std(0x202122, 0.7)),
|
||||
stof: () => M('stof', () => std(0x7f8487, 0.95)),
|
||||
stofDonker: () => M('stofDonker', () => std(0x5c6064, 0.95)),
|
||||
stofGroen: () => M('stofGroen', () => std(0x6e7d68, 0.95)),
|
||||
beddengoed: () => M('beddengoed', () => std(0xf2f0ea, 0.9)),
|
||||
kussen: () => M('kussen', () => std(0xe4e1d8, 0.9)),
|
||||
hout: () => M('hout', () => std(0x9b7a55, 0.6)),
|
||||
houtDonker: () => M('houtDonker', () => std(0x4a3a2c, 0.6)),
|
||||
wit: () => M('wit', () => std(0xf0efea, 0.5)),
|
||||
scherm: () => M('scherm', () => std(0x0b0c0e, 0.1, 0.3))
|
||||
};
|
||||
const FRONTS = {
|
||||
wit: { label: 'Wit mat', hex: 0xefeeea },
|
||||
grijs: { label: 'Lichtgrijs', hex: 0xc4c5c2 },
|
||||
antraciet: { label: 'Antraciet', hex: 0x3b3e42 },
|
||||
zwart: { label: 'Zwart', hex: 0x1d1e20 },
|
||||
eiken: { label: 'Eiken', hex: 0xa8865e }
|
||||
};
|
||||
const frontMat = (k) => M('front:' + k, () => std((FRONTS[k] || FRONTS.wit).hex, 0.55));
|
||||
|
||||
function box(g, mat, x, y, z, w, h, d) {
|
||||
if (w <= 0 || h <= 0 || d <= 0) return null;
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
|
||||
m.position.set(x * toM, y * toM, z * toM);
|
||||
m.castShadow = true; m.receiveShadow = true;
|
||||
g.add(m);
|
||||
return m;
|
||||
}
|
||||
const boxAt = (g, mat, x0, x1, y0, y1, z0, z1) => box(g, mat, (x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2, x1 - x0, y1 - y0, z1 - z0);
|
||||
function ecyl(g, mat, x, z, rx, rz, y0, y1, taper) {
|
||||
const geo = new THREE.CylinderGeometry(1, taper || 1, 1, 40);
|
||||
const m = new THREE.Mesh(geo, mat);
|
||||
m.scale.set(rx * toM, (y1 - y0) * toM, rz * toM);
|
||||
m.position.set(x * toM, (y0 + y1) / 2 * toM, z * toM);
|
||||
m.castShadow = true; m.receiveShadow = true;
|
||||
g.add(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
const rectP = (x0, x1, z0, z1, kind, fill) => ({ pts: [{ x: x0, z: z0 }, { x: x1, z: z0 }, { x: x1, z: z1 }, { x: x0, z: z1 }], closed: true, kind, fill });
|
||||
const lineP = (x0, z0, x1, z1, kind) => ({ pts: [{ x: x0, z: z0 }, { x: x1, z: z1 }], kind });
|
||||
function ellP(cx, cz, rx, rz, kind, a0, a1) {
|
||||
const pts = [], n = 32, s = a0 === undefined ? 0 : a0, e = a1 === undefined ? Math.PI * 2 : a1;
|
||||
for (let i = 0; i <= n; i++) { const t = s + (e - s) * i / n; pts.push({ x: cx + rx * Math.cos(t), z: cz + rz * Math.sin(t) }); }
|
||||
return { pts, closed: a0 === undefined, kind };
|
||||
}
|
||||
const circP = (x, z, r, kind) => ({ circle: { x, z, r }, kind });
|
||||
function rrectP(x0, x1, z0, z1, r, kind) {
|
||||
const pts = [], q = (cx, cz, a) => { for (let i = 0; i <= 6; i++) { const t = a + i / 6 * Math.PI / 2; pts.push({ x: cx + r * Math.cos(t), z: cz + r * Math.sin(t) }); } };
|
||||
q(x1 - r, z1 - r, 0); q(x0 + r, z1 - r, Math.PI / 2); q(x0 + r, z0 + r, Math.PI); q(x1 - r, z0 + r, Math.PI * 1.5);
|
||||
return { pts, closed: true, kind };
|
||||
}
|
||||
|
||||
const TYPES = {
|
||||
toilet: {
|
||||
label: 'Toilet (zwevend)', cat: 'sanitair', w: 600, d: 690, h: 1200, resize: ['w', 'h'], minW: 450, wall: true, cost: 'toilet',
|
||||
hint: 'Wandcloset aan een inbouwreservoir in een voorzetwand (150 mm), zitting 420 mm',
|
||||
build(f, g) {
|
||||
const D = f.d, zb = -D / 2, zf = zb + 150;
|
||||
boxAt(g, MAT.voorzet(), -f.w / 2, f.w / 2, 0, f.h, zb, zf); // voorzetwand
|
||||
boxAt(g, MAT.chroom(), -120, 120, 920, 1080, zf, zf + 8); // bedieningsplaat
|
||||
boxAt(g, MAT.wit(), -110, 110, 930, 1070, zf + 8, zf + 10);
|
||||
const cz = zf + 270;
|
||||
ecyl(g, MAT.keramiek(), 0, cz, 180, 270, 250, 400, 0.72); // pot
|
||||
boxAt(g, MAT.keramiek(), -150, 150, 300, 400, zf, zf + 120); // aansluiting op de wand
|
||||
ecyl(g, MAT.keramiek(), 0, cz + 5, 185, 272, 400, 420); // zitting
|
||||
ecyl(g, MAT.keramiek(), 0, cz + 5, 180, 265, 422, 436); // deksel
|
||||
},
|
||||
plan(f) {
|
||||
const zb = -f.d / 2, zf = zb + 150, cz = zf + 270;
|
||||
return [rectP(-f.w / 2, f.w / 2, zb, zf, 'solid', true), ellP(0, cz, 180, 270), ellP(0, cz + 30, 115, 190, 'thin'), rectP(-150, 150, zf, zf + 70, 'thin')];
|
||||
}
|
||||
},
|
||||
fontein: {
|
||||
label: 'Fontein', cat: 'sanitair', w: 400, d: 250, h: 850, resize: ['w', 'd', 'h'], wall: true, cost: 'fontein',
|
||||
hint: 'Handwasbak met kraan; h = bovenkant',
|
||||
build(f, g) {
|
||||
const zb = -f.d / 2, zf = f.d / 2, top = f.h;
|
||||
boxAt(g, MAT.keramiek(), -f.w / 2, f.w / 2, top - 130, top, zb, zf);
|
||||
boxAt(g, MAT.rubber(), -f.w / 2 + 40, f.w / 2 - 40, top - 4, top + 0.5, zb + 70, zf - 30);
|
||||
tap(g, f.w / 2 - 70, top, zb + 35, 120);
|
||||
boxAt(g, MAT.chroom(), -25, 25, 300, top - 130, zb, zb + 50); // sifon
|
||||
},
|
||||
plan(f) { const zb = -f.d / 2; return [rectP(-f.w / 2, f.w / 2, zb, f.d / 2), rrectP(-f.w / 2 + 40, f.w / 2 - 40, zb + 70, f.d / 2 - 30, 40, 'thin'), circP(f.w / 2 - 70, zb + 35, 18, 'thin')]; }
|
||||
},
|
||||
wastafel: {
|
||||
label: 'Badmeubel met spiegel', cat: 'sanitair', w: 800, d: 460, h: 850, resize: ['w', 'd', 'h'], wall: true, cost: 'wastafel',
|
||||
opts: { front: 'eiken', spiegel: true }, hint: 'Zwevend onderkastje met wastafel; h = bovenkant blad',
|
||||
build(f, g) {
|
||||
const zb = -f.d / 2, zf = f.d / 2, top = f.h;
|
||||
boxAt(g, frontMat(f.opts.front), -f.w / 2, f.w / 2, top - 520, top - 50, zb, zf - 5);
|
||||
boxAt(g, MAT.rubber(), -f.w / 2 + 5, f.w / 2 - 5, top - 288, top - 284, zf - 5, zf - 3); // greep-naad
|
||||
boxAt(g, MAT.keramiek(), -f.w / 2, f.w / 2, top - 50, top, zb, zf);
|
||||
boxAt(g, MAT.rubber(), -Math.min(250, f.w / 2 - 60), Math.min(250, f.w / 2 - 60), top - 3, top + 0.5, zb + 110, zf - 40);
|
||||
tap(g, 0, top, zb + 60, 160);
|
||||
if (f.opts.spiegel) {
|
||||
boxAt(g, MAT.zwartMetaal(), -f.w / 2, f.w / 2, top + 250, top + 1000, zb, zb + 22);
|
||||
boxAt(g, MAT.spiegel(), -f.w / 2 + 12, f.w / 2 - 12, top + 262, top + 988, zb + 22, zb + 24);
|
||||
}
|
||||
},
|
||||
plan(f) {
|
||||
const zb = -f.d / 2, bw = Math.min(250, f.w / 2 - 60);
|
||||
return [rectP(-f.w / 2, f.w / 2, zb, f.d / 2), ellP(0, (zb + 110 + f.d / 2 - 40) / 2, bw, (f.d - 150) / 2, 'thin'), circP(0, zb + 60, 18, 'thin')];
|
||||
}
|
||||
},
|
||||
douche: {
|
||||
label: 'Inloopdouche', cat: 'sanitair', w: 900, d: 1200, h: 2100, resize: ['w', 'd', 'h'], wall: true, cost: 'douche',
|
||||
opts: { glas: 'rechts', glasLengte: 1000 }, hint: 'Betegelde vloer met douchegoot, glaswand opzij, regendouche en thermostaatkraan aan de rugwand',
|
||||
build(f, g) {
|
||||
const zb = -f.d / 2, zf = f.d / 2;
|
||||
boxAt(g, MAT.tegel(), -f.w / 2, f.w / 2, 0, 6, zb, zf); // vloer
|
||||
boxAt(g, MAT.zwartMetaal(), -f.w / 2 + 60, f.w / 2 - 60, 6, 8, zb + 60, zb + 110); // douchegoot
|
||||
// Thermostaatkraan + glijstang
|
||||
boxAt(g, MAT.zwartMetaal(), -170, 170, 1050, 1110, zb, zb + 70);
|
||||
boxAt(g, MAT.zwartMetaal(), 140, 165, 1110, 2050, zb + 30, zb + 55); // stang naar de regendouche
|
||||
boxAt(g, MAT.zwartMetaal(), 140, 165, 2030, 2055, zb + 30, zb + 330); // arm
|
||||
ecyl(g, MAT.zwartMetaal(), 152, zb + 330, 150, 150, 2005, 2025); // regendouche Ø300
|
||||
// Glaswand opzij (zwart profiel aan de wand)
|
||||
if (f.opts.glas !== 'geen') {
|
||||
const x = f.opts.glas === 'links' ? -f.w / 2 : f.w / 2, L = Math.min(f.d, f.opts.glasLengte || 1000);
|
||||
boxAt(g, MAT.glas(), x - 4, x + 4, 30, f.h, zb, zb + L);
|
||||
boxAt(g, MAT.zwartMetaal(), x - 12, x + 12, 30, f.h, zb, zb + 25);
|
||||
boxAt(g, MAT.zwartMetaal(), x - 10, x + 10, f.h - 20, f.h, zb, zb + L);
|
||||
}
|
||||
},
|
||||
plan(f) {
|
||||
const zb = -f.d / 2, out = [rectP(-f.w / 2, f.w / 2, zb, f.d / 2, 'dash'), rectP(-f.w / 2 + 60, f.w / 2 - 60, zb + 60, zb + 110, 'thin'), circP(152, zb + 330, 150, 'thin')];
|
||||
if (f.opts.glas !== 'geen') {
|
||||
const x = f.opts.glas === 'links' ? -f.w / 2 : f.w / 2, L = Math.min(f.d, f.opts.glasLengte || 1000);
|
||||
out.push(rectP(x - 15, x + 15, zb, zb + L, 'solid', true));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
},
|
||||
keuken: {
|
||||
label: 'Keukenblok', cat: 'keuken', w: 2660, d: 600, h: 910, resize: ['w', 'd', 'h'], minW: 600, wall: true, cost: 'keuken',
|
||||
opts: { front: 'antraciet', spoel: true, spoelbak: 0.6, kookplaat: false, kookplaatPlek: 0.2, hogeKast: 'links', vaatwasser: true, bovenkasten: false },
|
||||
hint: 'Onderkasten met werkblad, spoelbak en eventueel kookplaat (plek: 0 = links, 1 = rechts, van voren gezien); hoge kast 600 mm met inbouwkoelkast en oven',
|
||||
build(f, g) { kitchenRun(g, f, false); },
|
||||
plan(f) { return kitchenPlan(f, false); }
|
||||
},
|
||||
kookeiland: {
|
||||
label: 'Kookeiland', cat: 'keuken', w: 2660, d: 600, h: 910, resize: ['w', 'd', 'h'], minW: 600, wall: false, cost: 'kookeiland',
|
||||
opts: { front: 'antraciet', kookplaat: true },
|
||||
hint: 'Vrijstaand, met inductiekookplaat (4 zones) in het midden',
|
||||
build(f, g) { kitchenRun(g, f, true); },
|
||||
plan(f) { return kitchenPlan(f, true); }
|
||||
},
|
||||
kledingkast: {
|
||||
label: 'Kledingkast', cat: 'kasten', w: 3020, d: 600, h: 2400, resize: ['w', 'd', 'h'], minW: 400, wall: true, cost: 'kast',
|
||||
opts: { front: 'wit' }, hint: 'Hang/leg, deuren van ca. 750 mm (twee per vak)',
|
||||
build(f, g) {
|
||||
const zb = -f.d / 2, zf = f.d / 2, n = Math.max(1, Math.round(f.w / 750));
|
||||
boxAt(g, MAT.wit(), -f.w / 2, f.w / 2, 0, f.h, zb, zf - 20);
|
||||
const dw = f.w / n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x0 = -f.w / 2 + i * dw;
|
||||
[[x0 + 2, x0 + dw / 2 - 2], [x0 + dw / 2 + 2, x0 + dw - 2]].forEach(([a, b], k) => {
|
||||
boxAt(g, frontMat(f.opts.front), a, b, 80, f.h - 4, zf - 20, zf);
|
||||
const hx = k === 0 ? b - 40 : a + 40;
|
||||
boxAt(g, MAT.zwartMetaal(), hx - 6, hx + 6, 900, 1300, zf, zf + 18);
|
||||
});
|
||||
}
|
||||
boxAt(g, MAT.plint(), -f.w / 2, f.w / 2, 0, 80, zf - 60, zf - 20);
|
||||
},
|
||||
plan(f) {
|
||||
const zb = -f.d / 2, n = Math.max(1, Math.round(f.w / 750)), out = [rectP(-f.w / 2, f.w / 2, zb, f.d / 2)];
|
||||
for (let i = 1; i < n; i++) out.push(lineP(-f.w / 2 + i * f.w / n, zb, -f.w / 2 + i * f.w / n, f.d / 2, 'thin'));
|
||||
out.push(lineP(-f.w / 2 + 30, 0, f.w / 2 - 30, 0, 'dash')); // roede
|
||||
return out;
|
||||
}
|
||||
},
|
||||
bed: {
|
||||
label: 'Bed', cat: 'slapen', w: 1600, d: 2100, h: 520, resize: ['w', 'd'], minW: 800, wall: true, cost: null,
|
||||
opts: { nachtkastjes: true }, hint: 'Hoofdbord tegen de wand; 2 kussens vanaf 1200 mm breed',
|
||||
build(f, g) {
|
||||
const zb = -f.d / 2, zf = f.d / 2;
|
||||
boxAt(g, MAT.stofDonker(), -f.w / 2, f.w / 2, 0, 1050, zb, zb + 80); // hoofdbord
|
||||
boxAt(g, MAT.stofDonker(), -f.w / 2, f.w / 2, 60, 330, zb + 80, zf); // boxspring
|
||||
boxAt(g, MAT.plint(), -f.w / 2 + 40, f.w / 2 - 40, 0, 60, zb + 120, zf - 40);
|
||||
boxAt(g, MAT.beddengoed(), -f.w / 2 + 10, f.w / 2 - 10, 330, f.h, zb + 85, zf - 5); // matras
|
||||
boxAt(g, MAT.kussen(), -f.w / 2 + 5, f.w / 2 - 5, f.h - 10, f.h + 40, zb + 650, zf); // dekbed
|
||||
const nk = f.w >= 1200 ? 2 : 1, kw = (f.w - 60 * (nk + 1)) / nk;
|
||||
for (let i = 0; i < nk; i++) boxAt(g, MAT.wit(), -f.w / 2 + 60 + i * (kw + 60), -f.w / 2 + 60 + i * (kw + 60) + kw, f.h - 5, f.h + 110, zb + 110, zb + 500);
|
||||
if (f.opts.nachtkastjes) [-1, 1].forEach(s => {
|
||||
const x0 = s < 0 ? -f.w / 2 - 470 : f.w / 2 + 20;
|
||||
boxAt(g, MAT.houtDonker(), x0, x0 + 450, 150, 520, zb + 10, zb + 410);
|
||||
});
|
||||
},
|
||||
plan(f) {
|
||||
const zb = -f.d / 2, out = [rectP(-f.w / 2, f.w / 2, zb, f.d / 2), lineP(-f.w / 2, zb + 80, f.w / 2, zb + 80, 'thin'), lineP(-f.w / 2, zb + 650, f.w / 2, zb + 650, 'thin')];
|
||||
const nk = f.w >= 1200 ? 2 : 1, kw = (f.w - 60 * (nk + 1)) / nk;
|
||||
for (let i = 0; i < nk; i++) out.push(rrectP(-f.w / 2 + 60 + i * (kw + 60), -f.w / 2 + 60 + i * (kw + 60) + kw, zb + 110, zb + 500, 60, 'thin'));
|
||||
out.push(lineP(-f.w / 2, zb + 650, f.w / 2 - 300, f.d / 2, 'thin'));
|
||||
if (f.opts.nachtkastjes) [-1, 1].forEach(s => { const x0 = s < 0 ? -f.w / 2 - 470 : f.w / 2 + 20; out.push(rectP(x0, x0 + 450, zb + 10, zb + 410, 'thin')); });
|
||||
return out;
|
||||
}
|
||||
},
|
||||
eettafel: {
|
||||
label: 'Eettafel met stoelen', cat: 'wonen', w: 2000, d: 1100, h: 750, resize: ['w', 'd'], minW: 600, wall: false, cost: null,
|
||||
opts: { vorm: 'ovaal', stoelen: 6 }, hint: 'Ovaal of rechthoekig; stoelen verdeeld over de lange zijden',
|
||||
build(f, g) {
|
||||
const ov = f.opts.vorm === 'ovaal';
|
||||
if (ov) { ecyl(g, MAT.houtDonker(), 0, 0, f.w / 2, f.d / 2, f.h - 30, f.h); ecyl(g, MAT.zwartMetaal(), 0, 0, 60, 60, 20, f.h - 30); ecyl(g, MAT.zwartMetaal(), 0, 0, Math.min(f.w, f.d) * 0.3, Math.min(f.w, f.d) * 0.22, 0, 20); }
|
||||
else {
|
||||
boxAt(g, MAT.houtDonker(), -f.w / 2, f.w / 2, f.h - 30, f.h, -f.d / 2, f.d / 2);
|
||||
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => boxAt(g, MAT.zwartMetaal(), a * (f.w / 2 - 80) - 25, a * (f.w / 2 - 80) + 25, 0, f.h - 30, b * (f.d / 2 - 80) - 25, b * (f.d / 2 - 80) + 25));
|
||||
}
|
||||
chairSpots(f).forEach(c => chair(g, c.x, c.z, c.rot));
|
||||
},
|
||||
plan(f) {
|
||||
const out = [f.opts.vorm === 'ovaal' ? ellP(0, 0, f.w / 2, f.d / 2) : rectP(-f.w / 2, f.w / 2, -f.d / 2, f.d / 2)];
|
||||
chairSpots(f).forEach(c => {
|
||||
const pts = [[-225, -220], [225, -220], [225, 220], [-225, 220]].map(([x, z]) => rot2(x, z, c.rot)).map(p => ({ x: p.x + c.x, z: p.z + c.z }));
|
||||
out.push({ pts, closed: true, kind: 'thin' });
|
||||
const b = [[-225, 190], [225, 190]].map(([x, z]) => rot2(x, z, c.rot)).map(p => ({ x: p.x + c.x, z: p.z + c.z }));
|
||||
out.push({ pts: b, kind: 'thin' });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
},
|
||||
bank: {
|
||||
label: 'Bank', cat: 'wonen', w: 2600, d: 950, h: 820, resize: ['w', 'd'], minW: 1200, wall: false, cost: null,
|
||||
opts: { chaise: 'rechts', chaiseLengte: 1700 }, hint: 'Rug aan de achterzijde; chaise longue links/rechts (van voren gezien)',
|
||||
build(f, g) {
|
||||
const zb = -f.d / 2, zf = f.d / 2, mat = MAT.stof();
|
||||
boxAt(g, mat, -f.w / 2, f.w / 2, 80, 430, zb, zf); // zitting
|
||||
boxAt(g, mat, -f.w / 2, f.w / 2, 80, f.h, zb, zb + 220); // rug
|
||||
const ch = f.opts.chaise, L = Math.max(f.d, f.opts.chaiseLengte || 1700);
|
||||
if (ch === 'links' || ch === 'rechts') {
|
||||
const x0 = ch === 'links' ? -f.w / 2 : f.w / 2 - 900, x1 = x0 + 900;
|
||||
boxAt(g, mat, x0, x1, 80, 430, zf, zb + L);
|
||||
boxAt(g, MAT.stofDonker(), x0 + 10, x1 - 10, 430, 480, zf, zb + L - 10);
|
||||
}
|
||||
[-1, 1].forEach(s => { if (!((s < 0 && ch === 'links') || (s > 0 && ch === 'rechts'))) boxAt(g, mat, s * f.w / 2 - (s > 0 ? 180 : 0), s * f.w / 2 + (s < 0 ? 180 : 0), 80, 620, zb, zf); });
|
||||
const nz = Math.max(1, Math.round((f.w - 360) / 800)), cw = (f.w - 360) / nz;
|
||||
for (let i = 0; i < nz; i++) boxAt(g, MAT.stofDonker(), -f.w / 2 + 180 + i * cw + 10, -f.w / 2 + 180 + (i + 1) * cw - 10, 430, 480, zb + 230, zf - 10);
|
||||
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => boxAt(g, MAT.zwartMetaal(), a * (f.w / 2 - 60) - 15, a * (f.w / 2 - 60) + 15, 0, 80, b * (f.d / 2 - 60) - 15, b * (f.d / 2 - 60) + 15));
|
||||
},
|
||||
plan(f) {
|
||||
const zb = -f.d / 2, zf = f.d / 2, ch = f.opts.chaise, L = Math.max(f.d, f.opts.chaiseLengte || 1700);
|
||||
let pts = [{ x: -f.w / 2, z: zb }, { x: f.w / 2, z: zb }, { x: f.w / 2, z: zf }, { x: -f.w / 2, z: zf }];
|
||||
if (ch === 'rechts') pts = [{ x: -f.w / 2, z: zb }, { x: f.w / 2, z: zb }, { x: f.w / 2, z: zb + L }, { x: f.w / 2 - 900, z: zb + L }, { x: f.w / 2 - 900, z: zf }, { x: -f.w / 2, z: zf }];
|
||||
if (ch === 'links') pts = [{ x: -f.w / 2, z: zb }, { x: f.w / 2, z: zb }, { x: f.w / 2, z: zf }, { x: -f.w / 2 + 900, z: zf }, { x: -f.w / 2 + 900, z: zb + L }, { x: -f.w / 2, z: zb + L }];
|
||||
const out = [{ pts, closed: true }, lineP(-f.w / 2, zb + 220, f.w / 2, zb + 220, 'thin')];
|
||||
const nz = Math.max(1, Math.round((f.w - 360) / 800)), cw = (f.w - 360) / nz;
|
||||
for (let i = 1; i < nz; i++) out.push(lineP(-f.w / 2 + 180 + i * cw, zb + 220, -f.w / 2 + 180 + i * cw, zf, 'thin'));
|
||||
return out;
|
||||
}
|
||||
},
|
||||
fauteuil: {
|
||||
label: 'Fauteuil', cat: 'wonen', w: 850, d: 850, h: 800, resize: ['w', 'd'], wall: false, cost: null, opts: {},
|
||||
build(f, g) {
|
||||
const zb = -f.d / 2, zf = f.d / 2;
|
||||
boxAt(g, MAT.stofGroen(), -f.w / 2 + 110, f.w / 2 - 110, 150, 430, zb + 150, zf);
|
||||
boxAt(g, MAT.stofGroen(), -f.w / 2, f.w / 2, 150, f.h, zb, zb + 160);
|
||||
[-1, 1].forEach(s => boxAt(g, MAT.stofGroen(), s > 0 ? f.w / 2 - 120 : -f.w / 2, s > 0 ? f.w / 2 : -f.w / 2 + 120, 150, 600, zb, zf));
|
||||
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => ecyl(g, MAT.houtDonker(), a * (f.w / 2 - 70), b * (f.d / 2 - 70), 18, 18, 0, 150, 0.7));
|
||||
},
|
||||
plan(f) { const zb = -f.d / 2; return [rrectP(-f.w / 2, f.w / 2, zb, f.d / 2, 80), lineP(-f.w / 2, zb + 160, f.w / 2, zb + 160, 'thin'), lineP(-f.w / 2 + 120, zb + 160, -f.w / 2 + 120, f.d / 2, 'thin'), lineP(f.w / 2 - 120, zb + 160, f.w / 2 - 120, f.d / 2, 'thin')]; }
|
||||
},
|
||||
salontafel: {
|
||||
label: 'Salontafel', cat: 'wonen', w: 800, d: 800, h: 400, resize: ['w', 'd', 'h'], wall: false, cost: null, opts: { vorm: 'rond' },
|
||||
build(f, g) {
|
||||
if (f.opts.vorm === 'rond') { ecyl(g, MAT.hout(), 0, 0, f.w / 2, f.d / 2, f.h - 30, f.h); ecyl(g, MAT.zwartMetaal(), 0, 0, f.w / 6, f.d / 6, 0, f.h - 30, 1.3); }
|
||||
else { boxAt(g, MAT.hout(), -f.w / 2, f.w / 2, f.h - 30, f.h, -f.d / 2, f.d / 2); boxAt(g, MAT.zwartMetaal(), -f.w / 2 + 40, f.w / 2 - 40, 0, f.h - 30, -f.d / 2 + 40, f.d / 2 - 40); }
|
||||
},
|
||||
plan(f) { return [f.opts.vorm === 'rond' ? ellP(0, 0, f.w / 2, f.d / 2) : rectP(-f.w / 2, f.w / 2, -f.d / 2, f.d / 2)]; }
|
||||
},
|
||||
tvmeubel: {
|
||||
label: 'Tv-meubel', cat: 'wonen', w: 1800, d: 400, h: 450, resize: ['w', 'd', 'h'], wall: true, cost: null, opts: { tv: true },
|
||||
build(f, g) {
|
||||
const zb = -f.d / 2;
|
||||
boxAt(g, MAT.houtDonker(), -f.w / 2, f.w / 2, 150, f.h, zb, f.d / 2);
|
||||
if (f.opts.tv) { const tw = Math.min(1450, f.w - 200); boxAt(g, MAT.scherm(), -tw / 2, tw / 2, 1000, 1000 + tw * 0.5625, zb, zb + 40); }
|
||||
},
|
||||
plan(f) { return [rectP(-f.w / 2, f.w / 2, -f.d / 2, f.d / 2), lineP(-f.w / 2, 0, f.w / 2, 0, 'thin')]; }
|
||||
},
|
||||
boiler: {
|
||||
label: 'Elektrische boiler', cat: 'techniek', w: 450, d: 450, h: 800, resize: ['w', 'h'], wall: true, cost: 'boiler',
|
||||
opts: { liter: 80 }, hint: 'Hangend aan de wand (onderkant 900 mm); w = diameter',
|
||||
build(f, g) {
|
||||
const r = f.w / 2;
|
||||
ecyl(g, MAT.wit(), 0, 0, r, Math.min(r, f.d / 2), 900, 900 + f.h);
|
||||
boxAt(g, MAT.zwartMetaal(), -40, 40, 860, 900, -20, 20);
|
||||
[-60, 60].forEach(x => boxAt(g, MAT.chroom(), x - 10, x + 10, 400, 870, -f.d / 2 + 20, -f.d / 2 + 40));
|
||||
},
|
||||
plan(f) { return [ellP(0, 0, f.w / 2, f.d / 2), ellP(0, 0, f.w / 2 - 25, f.d / 2 - 25, 'thin'), { text: `EB ${f.opts.liter || 80}L`, at: { x: 0, z: 0 } }]; }
|
||||
},
|
||||
wasmachine: {
|
||||
label: 'Wasmachine', cat: 'techniek', w: 600, d: 600, h: 850, resize: [], wall: true, cost: null, opts: {},
|
||||
build(f, g) {
|
||||
boxAt(g, MAT.wit(), -300, 300, 0, 850, -300, 280);
|
||||
const door = ecyl(g, MAT.zwartMetaal(), 0, 0, 160, 160, 0, 10);
|
||||
door.rotation.x = Math.PI / 2;
|
||||
door.position.set(0, 0.45, 0.285);
|
||||
},
|
||||
plan() { return [rectP(-300, 300, -300, 300), circP(0, 0, 200, 'thin'), { text: 'WM', at: { x: 0, z: 0 } }]; }
|
||||
}
|
||||
};
|
||||
|
||||
function tap(g, x, y, z, reach) {
|
||||
ecyl(g, MAT.chroom(), x, z, 18, 18, y, y + 180);
|
||||
boxAt(g, MAT.chroom(), x - 10, x + 10, y + 160, y + 180, z, z + reach);
|
||||
}
|
||||
function rot2(x, z, deg) {
|
||||
const a = deg * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
|
||||
return { x: x * c - z * s, z: x * s + z * c };
|
||||
}
|
||||
function chairSpots(f) {
|
||||
const n = Math.max(0, Math.min(12, Math.round(f.opts.stoelen || 0)));
|
||||
const out = [];
|
||||
const perSide = Math.ceil(n / 2);
|
||||
[[-1, 180], [1, 0]].forEach(([s, r], k) => {
|
||||
const m = k === 0 ? perSide : n - perSide;
|
||||
for (let i = 0; i < m; i++) {
|
||||
const x = -f.w / 2 + f.w * (i + 0.5) / m * 0.86 + f.w * 0.07;
|
||||
const edge = f.opts.vorm === 'ovaal' ? (f.d / 2) * Math.sqrt(Math.max(0, 1 - (x / (f.w / 2)) ** 2)) : f.d / 2;
|
||||
out.push({ x, z: s * (edge + 120), rot: r });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function chair(g, x, z, rotDeg) {
|
||||
const c = new THREE.Group();
|
||||
boxAt(c, MAT.stofDonker(), -225, 225, 430, 480, -220, 220);
|
||||
boxAt(c, MAT.stofDonker(), -225, 225, 480, 880, 170, 220);
|
||||
[[-1, -1], [1, -1], [-1, 1], [1, 1]].forEach(([a, b]) => boxAt(c, MAT.zwartMetaal(), a * 195 - 12, a * 195 + 12, 0, 430, b * 190 - 12, b * 190 + 12));
|
||||
c.rotation.y = -rotDeg * Math.PI / 180;
|
||||
c.position.set(x * toM, 0, z * toM);
|
||||
g.add(c);
|
||||
}
|
||||
|
||||
// Van voren gezien: links = -x.
|
||||
function kitchenModules(f, island) {
|
||||
const tall = !island && (f.opts.hogeKast === 'links' || f.opts.hogeKast === 'rechts') && f.w >= 1200 ? f.opts.hogeKast : null;
|
||||
const x0 = -f.w / 2 + (tall === 'links' ? 600 : 0), x1 = f.w / 2 - (tall === 'rechts' ? 600 : 0);
|
||||
const n = Math.max(1, Math.round((x1 - x0) / 600));
|
||||
return { tall, x0, x1, n, mw: (x1 - x0) / n };
|
||||
}
|
||||
function kitchenRun(g, f, island) {
|
||||
const zb = -f.d / 2, zf = f.d / 2, top = f.h, front = frontMat(f.opts.front);
|
||||
const K = kitchenModules(f, island);
|
||||
boxAt(g, MAT.plint(), K.x0, K.x1, 0, 100, zb + (island ? 60 : 0), zf - 60);
|
||||
boxAt(g, front, K.x0, K.x1, 100, top - 40, zb + (island ? 0 : 0), zf - 22);
|
||||
for (let i = 0; i < K.n; i++) {
|
||||
const a = K.x0 + i * K.mw + 1.5, b = K.x0 + (i + 1) * K.mw - 1.5;
|
||||
boxAt(g, front, a, b, 105, top - 45, zf - 22, zf - 2);
|
||||
if (island) boxAt(g, front, a, b, 105, top - 45, zb + 2, zb + 22);
|
||||
}
|
||||
boxAt(g, MAT.werkblad(), K.x0 - (island ? 20 : 0), K.x1 + (island ? 20 : 0), top - 40, top, zb - (island ? 20 : 0), zf + 20);
|
||||
if (K.tall) {
|
||||
const a = K.tall === 'links' ? -f.w / 2 : f.w / 2 - 600, b = a + 600;
|
||||
boxAt(g, front, a, b, 0, 2200, zb, zf - 22);
|
||||
boxAt(g, front, a + 2, b - 2, 105, 1190, zf - 22, zf - 2); // koelkastdeur
|
||||
boxAt(g, MAT.scherm(), a + 20, b - 20, 1200, 1780, zf - 22, zf - 2); // oven
|
||||
boxAt(g, MAT.zwartMetaal(), a + 30, b - 30, 1730, 1750, zf - 2, zf + 15);
|
||||
boxAt(g, front, a + 2, b - 2, 1790, 2195, zf - 22, zf - 2);
|
||||
}
|
||||
if (!island) {
|
||||
if (f.opts.spoel !== false) {
|
||||
const sx = spoelX(f, K);
|
||||
boxAt(g, MAT.chroom(), sx - 380, sx + 380, top - 2, top + 0.5, zb + 90, zf - 70);
|
||||
boxAt(g, MAT.rubber(), sx - 360, sx + 360, top - 1, top + 1, zb + 105, zf - 85);
|
||||
ecyl(g, MAT.zwartMetaal(), sx + 200, zb + 55, 22, 22, top, top + 300);
|
||||
boxAt(g, MAT.zwartMetaal(), sx + 190, sx + 210, top + 280, top + 300, zb + 55, zb + 280);
|
||||
}
|
||||
if (f.opts.bovenkasten) boxAt(g, front, K.x0, K.x1, 1450, 2150, zb, zb + 350);
|
||||
}
|
||||
if (f.opts.kookplaat) {
|
||||
const { hx, hw, hd } = hobOf(f, K, island);
|
||||
boxAt(g, MAT.kookplaat(), hx - hw / 2, hx + hw / 2, top, top + 4, -hd / 2, hd / 2);
|
||||
hobZones(hw, hd).forEach(z => ecyl(g, MAT.ring(), hx + z.x, z.z, z.r, z.r, top + 4, top + 5));
|
||||
}
|
||||
}
|
||||
const hobZones = (hw, hd) => [[-0.25, -0.25, 0.19], [0.25, -0.25, 0.14], [-0.25, 0.25, 0.14], [0.25, 0.25, 0.19]]
|
||||
.map(([a, b, r]) => ({ x: a * hw, z: b * hd, r: r * Math.min(hw, hd * 1.4) }));
|
||||
function plekX(K, v, def) {
|
||||
const t = Math.max(0, Math.min(1, v === undefined ? def : v));
|
||||
return Math.max(K.x0 + 400, Math.min(K.x1 - 400, K.x0 + t * (K.x1 - K.x0)));
|
||||
}
|
||||
const spoelX = (f, K) => plekX(K, f.opts.spoelbak, 0.6);
|
||||
// Kookeiland: kookplaat in het midden; keukenblok: op kookplaatPlek.
|
||||
const hobOf = (f, K, island) => ({
|
||||
hx: island ? 0 : plekX(K, f.opts.kookplaatPlek, 0.2), hw: Math.min(780, K.x1 - K.x0 - 100), hd: Math.min(520, f.d - 80)
|
||||
});
|
||||
function kitchenPlan(f, island) {
|
||||
const zb = -f.d / 2, zf = f.d / 2, K = kitchenModules(f, island), out = [];
|
||||
out.push(rectP(K.x0, K.x1, zb, zf));
|
||||
for (let i = 1; i < K.n; i++) out.push(lineP(K.x0 + i * K.mw, zb, K.x0 + i * K.mw, zf, 'thin'));
|
||||
if (K.tall) {
|
||||
const a = K.tall === 'links' ? -f.w / 2 : f.w / 2 - 600;
|
||||
out.push(rectP(a, a + 600, zb, zf), lineP(a, zb, a + 600, zf, 'thin'), lineP(a, zf, a + 600, zb, 'thin'));
|
||||
}
|
||||
if (!island && f.opts.spoel !== false) {
|
||||
const sx = spoelX(f, K);
|
||||
out.push(rrectP(sx - 360, sx + 360, zb + 105, zf - 85, 40, 'thin'), circP(sx + 200, zb + 55, 22, 'thin'));
|
||||
}
|
||||
if (f.opts.kookplaat) {
|
||||
const { hx, hw, hd } = hobOf(f, K, island);
|
||||
out.push(rectP(hx - hw / 2, hx + hw / 2, -hd / 2, hd / 2, 'thin'));
|
||||
hobZones(hw, hd).forEach(z => out.push(circP(hx + z.x, z.z, z.r, 'thin')));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ROTS = [0, 90, 180, 270];
|
||||
function create(state, type, unitId, x, z, rot) {
|
||||
const T = TYPES[type] || TYPES.bed;
|
||||
const id = (state.fixtures || []).reduce((m, f) => Math.max(m, f.id || 0), 0) + 1;
|
||||
return normalizeOne({ id, type, unitId, x: Math.round(x), z: Math.round(z), rot: rot || 0, w: T.w, d: T.d, h: T.h, opts: Object.assign({}, T.opts || {}) });
|
||||
}
|
||||
function normalizeOne(f, i) {
|
||||
const T = TYPES[f.type];
|
||||
if (!T) return null;
|
||||
const num = (v, def, min) => (isFinite(v) && v > 0 ? Math.max(min || 50, Math.round(v)) : def);
|
||||
return {
|
||||
id: f.id > 0 ? f.id : (i || 0) + 1, type: f.type, unitId: String(f.unitId || 'A'),
|
||||
x: Math.round(Number(f.x) || 0), z: Math.round(Number(f.z) || 0), anchor: f.anchor === 'oost' ? 'oost' : 'west', rot: ((Math.round(Number(f.rot) || 0) % 360) + 360) % 360,
|
||||
w: T.resize.includes('w') ? num(f.w, T.w, T.minW) : T.w, d: T.resize.includes('d') ? num(f.d, T.d) : T.d, h: T.resize.includes('h') ? num(f.h, T.h) : T.h,
|
||||
opts: Object.assign({}, T.opts || {}, f.opts || {})
|
||||
};
|
||||
}
|
||||
function normalize(list, unitIds) {
|
||||
return (Array.isArray(list) ? list : []).map(normalizeOne).filter(f => f && (!unitIds || unitIds.includes(f.unitId)));
|
||||
}
|
||||
|
||||
function toWorld(item, lx, lz) {
|
||||
const p = rot2(lx, lz, item.rot);
|
||||
return { x: item.cx + p.x, z: item.cz + p.z };
|
||||
}
|
||||
function compute(list, units) {
|
||||
return (list || []).map(f => {
|
||||
const u = units.find(q => q.id === f.unitId);
|
||||
if (!u) return null;
|
||||
const T = TYPES[f.type];
|
||||
const item = { id: f.id, type: f.type, label: T.label, cat: T.cat, cost: T.cost, state: f, w: f.w, d: f.d, h: f.h, opts: f.opts, rot: f.rot,
|
||||
unitId: f.unitId, cx: f.anchor === 'oost' ? u.rect.x1 - f.x : u.rect.x0 + f.x, cz: u.rect.z0 + f.z, floorTop: u.layout.floorTop };
|
||||
item.corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([a, b]) => toWorld(item, a * f.w / 2, b * f.d / 2));
|
||||
return item;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
function planShapes(item) {
|
||||
const T = TYPES[item.type];
|
||||
const f = { w: item.w, d: item.d, h: item.h, opts: item.opts };
|
||||
return T.plan(f).map(p => {
|
||||
if (p.circle) { const c = toWorld(item, p.circle.x, p.circle.z); return Object.assign({}, p, { circle: { x: c.x, z: c.z, r: p.circle.r } }); }
|
||||
if (p.text) return Object.assign({}, p, { at: toWorld(item, p.at.x, p.at.z) });
|
||||
return Object.assign({}, p, { pts: p.pts.map(q => toWorld(item, q.x, q.z)) });
|
||||
});
|
||||
}
|
||||
function contains(item, x, z, tol) {
|
||||
const p = rot2(x - item.cx, z - item.cz, -item.rot), t = tol || 0;
|
||||
return Math.abs(p.x) <= item.w / 2 + t && Math.abs(p.z) <= item.d / 2 + t;
|
||||
}
|
||||
function hit(items, x, z, tol) {
|
||||
// Kleinste eerst (een fontein op een groter stuk blijft selecteerbaar).
|
||||
return items.filter(it => contains(it, x, z, tol)).sort((a, b) => a.w * a.d - b.w * b.d)[0] || null;
|
||||
}
|
||||
|
||||
function snap(item, x, z, pieces, reach) {
|
||||
const T = TYPES[item.type], R = reach || 350;
|
||||
const grid = (v) => Math.round(v / 10) * 10;
|
||||
if (!T.wall) return { x: grid(x), z: grid(z), rot: item.rot };
|
||||
let best = null;
|
||||
(pieces || []).forEach(p => {
|
||||
if (p.place.baseY) return;
|
||||
const pl = p.place, alongX = Math.abs(Math.sin(pl.rotY)) < 0.5;
|
||||
const half = pl.width / 2, t = pl.thickness / 2;
|
||||
const s0 = (alongX ? pl.cx : pl.cz) - half, s1 = (alongX ? pl.cx : pl.cz) + half;
|
||||
const s = alongX ? x : z, n = alongX ? z : x, c = alongX ? pl.cz : pl.cx;
|
||||
if (s < s0 - 50 || s > s1 + 50) return;
|
||||
[-1, 1].forEach(side => {
|
||||
const face = c + side * t, dist = (n - face) * side;
|
||||
if (dist < -50 || dist > R + item.d / 2) return;
|
||||
const gap = Math.abs(dist - item.d / 2);
|
||||
if (best && gap >= best.gap) return;
|
||||
const rot = alongX ? (side > 0 ? 0 : 180) : (side > 0 ? 270 : 90);
|
||||
const sC = s1 - s0 > item.w ? Math.max(s0 + item.w / 2, Math.min(s1 - item.w / 2, s)) : (s0 + s1) / 2;
|
||||
const nC = face + side * item.d / 2;
|
||||
best = { gap, x: alongX ? sC : nC, z: alongX ? nC : sC, rot };
|
||||
});
|
||||
});
|
||||
return best ? { x: grid(best.x), z: grid(best.z), rot: best.rot } : { x: grid(x), z: grid(z), rot: item.rot };
|
||||
}
|
||||
|
||||
function build3D(items) {
|
||||
const group = new THREE.Group();
|
||||
items.forEach(it => {
|
||||
const g = new THREE.Group();
|
||||
TYPES[it.type].build({ w: it.w, d: it.d, h: it.h, opts: it.opts }, g);
|
||||
g.rotation.y = -it.rot * Math.PI / 180;
|
||||
g.position.set(it.cx * toM, it.floorTop * toM, it.cz * toM);
|
||||
g.userData.fixture = it.id;
|
||||
group.add(g);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
const blocks = (item) => item.type !== 'douche';
|
||||
|
||||
return { TYPES, CATS, FRONTS, ROTS, create, normalize, compute, planShapes, contains, hit, snap, build3D, blocks, toWorld };
|
||||
})();
|
||||
@@ -0,0 +1,349 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
// Light-versie (modelpagina's): hetzelfde model en dezelfde berekeningen als de configurator, met een beperkte set
|
||||
// keuzes. Keuzes worden altijd opnieuw toegepast op een schone kopie van het model, zodat terug naar de
|
||||
// oorspronkelijke keuze precies het model geeft. Een keuze die de indeling breekt (ramen en deuren die niet meer
|
||||
// passen, binnenwanden of inrichting buiten de unit, ruimtes die verdwijnen) wordt geweigerd (check).
|
||||
window.LightModel = (() => {
|
||||
const LIM = UnitModel.LIMITS;
|
||||
// Kaders (mm) als het model of de pagina niets opgeeft; altijd binnen UnitModel.LIMITS. Per model te
|
||||
// beperken met FRAMES, per pagina met mount-optie frames (zelfde vorm).
|
||||
const DEFAULT_FRAME = { width: [2400, LIM.long], depth: [2400, LIM.long], height: [2400, LIM.eave] };
|
||||
const FRAMES = {
|
||||
// amstel: { units: { A: { width: [9000, 13000], depth: null } }, height: [2600, 3000] }
|
||||
};
|
||||
const STEP = 50;
|
||||
// Helling (graden) bij een andere dakvorm dan die van het model.
|
||||
const PITCH = { lessenaar: 10, zadel: 30, wolf: 35 };
|
||||
const FACADES = { rabat: 'Hout', fels: 'Felsplaten', stuc: 'Stucwerk', steen: 'Steenstrips' };
|
||||
const STONES = ['K490', 'K915', 'K911', 'K764', 'K518', 'K706'];
|
||||
const LIGHTING = { geen: 'Geen', spots: 'Spots in de dakrand', gevel: 'Gevellampen' };
|
||||
// Punten tot zo ver van de oost- of zuidrand van een unit horen bij die gevel en schuiven mee.
|
||||
const BAND = 450;
|
||||
|
||||
const clone = HouseModel.clone;
|
||||
const rectMap = (st) => Object.fromEntries(HouseModel.unitRects(st).map(r => [r.id, r]));
|
||||
const houseRoof = (st) => !!(st.settings.roof.house.enabled && st.settings.roof.house.form);
|
||||
|
||||
// Alle gevel-overrides van het model (per wand en per wandstuk): het "accent".
|
||||
function accents(st) {
|
||||
const out = [];
|
||||
st.units.forEach(u => Object.entries(u.unit.walls).forEach(([side, ws]) => {
|
||||
if (u.facade.walls[side]) out.push({ get: () => u.facade.walls[side], set: (v) => { if (v) u.facade.walls[side] = v; else delete u.facade.walls[side]; } });
|
||||
(ws.splits || []).forEach(sp => { if (sp && sp.facade) out.push({ get: () => sp.facade, set: (v) => { sp.facade = v || null; } }); });
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
const colorOf = (o) => o.type === 'steen' ? o.stone : o.type === 'stuc' ? o.stuc : o.type === 'fels' ? o.fels : null;
|
||||
|
||||
function frameOf(key, override) {
|
||||
const f = Object.assign({}, FRAMES[key] || {}, override || {});
|
||||
const clampR = (r, lim) => r && [Math.max(1000, Math.min(lim, r[0])), Math.min(lim, r[1])];
|
||||
return {
|
||||
unit: (id) => {
|
||||
const u = (f.units || {})[id] || {};
|
||||
const pick = (k, lim) => u[k] === null ? null : clampR(u[k] || DEFAULT_FRAME[k], lim);
|
||||
return { width: pick('width', LIM.long), depth: pick('depth', LIM.long) };
|
||||
},
|
||||
height: clampR(f.height || DEFAULT_FRAME.height, LIM.eave)
|
||||
};
|
||||
}
|
||||
|
||||
// Model inladen: schone basis, oorspronkelijke dakvormen en de keuzes zoals het model ze heeft.
|
||||
function create(key, opts) {
|
||||
const def = HouseModel.MODELS[key];
|
||||
if (!def) throw new Error(`onbekend model "${key}"`);
|
||||
const base = def.create();
|
||||
const forms = Object.fromEntries(base.units.map(u => [u.id, UnitModel.roofForm(u.unit)]));
|
||||
if (houseRoof(base)) forms.house = Object.assign({}, base.settings.roof.house.form);
|
||||
const f = base.settings.facade, acc = accents(base).map(a => a.get());
|
||||
const covering = (base.units.find(u => RoofModel.COVERINGS[u.roof.covering] && RoofModel.COVERINGS[u.roof.covering].sloped) || base.units[0]).roof.covering;
|
||||
const choices = {
|
||||
units: Object.fromEntries(base.units.map(u => {
|
||||
const fl = u.unit.floor.floor;
|
||||
return [u.id, { width: fl.width, depth: fl.depth, low: forms[u.id].low, high: highOf(forms[u.id]), type: forms[u.id].type, cut: cutOf(forms[u.id]), dir: forms[u.id].dir || null, ridge: forms[u.id].ridge || null }];
|
||||
})),
|
||||
house: forms.house ? { low: forms.house.low, high: highOf(forms.house), type: forms.house.type, cut: cutOf(forms.house), dir: forms.house.dir || null, ridge: forms.house.ridge || null } : null,
|
||||
covering: RoofModel.COVERINGS[covering] ? covering : 'dakleer',
|
||||
facade: { type: f.type, rabat: f.rabatColor, cladding: base.settings.cladding, stuc: f.stucColor, fels: f.felsColor, stone: f.stone },
|
||||
accent: acc.length ? { type: acc[0].type, color: colorOf(acc[0]) } : null,
|
||||
// Trasraam: alleen geen of steen; een cementplaat-trasraam van het model wordt geen.
|
||||
trasraam: (() => {
|
||||
const t = base.settings.trasraam, stone = WallModel.isStoneFinish(t.finish) && t.finish !== 'cementplaat';
|
||||
return { on: !!t.enabled && stone, finish: stone ? t.finish : null, height: t.height };
|
||||
})(),
|
||||
lighting: { mode: base.settings.lighting.mode, direction: base.settings.lighting.direction, indoor: base.settings.lighting.indoor !== false },
|
||||
// Boeidelen (aan = boeidelen, uit = daktrim) met kleur; een model met beide houdt zijn eigen indeling tot je kiest.
|
||||
fascia: { on: base.units.some(u => u.roof.fascia !== false), color: RoofModel.FASCIA_COLORS[base.settings.roof.fasciaColor] ? base.settings.roof.fasciaColor : 'antraciet' },
|
||||
// Zetwerk: één kleur voor de afdekker op het trasraam en het ventilatierooster onder de boeidelen.
|
||||
trim: WallModel.isTrimColor(base.settings.roof.ventColor) ? base.settings.roof.ventColor : 'antraciet'
|
||||
};
|
||||
const model = { key, label: def.label, base, forms, hasAccent: acc.length > 0, frame: frameOf(key, opts && opts.frames), initial: clone(choices) };
|
||||
const st = build(model, choices);
|
||||
model.reference = issuesOf(st, HouseModel.computeParts(st, { skip3D: true }));
|
||||
return model;
|
||||
}
|
||||
|
||||
// Wolfseind als verhouding tussen goot (0) en nok (1), zoals de configurator: schaalt mee met goot en nok.
|
||||
const WOLF_CUT = 0.6;
|
||||
const cutOf = (form) => form.type !== 'wolf' ? null
|
||||
: form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - form.low) / Math.max(1, form.high - form.low) : WOLF_CUT;
|
||||
|
||||
// Nok (of hoge kant) als keuze; null = afgeleid (plat dak, of een andere dakvorm dan die van het model).
|
||||
const RISE_MIN = 300;
|
||||
const highOf = (form) => form.type === 'plat' ? null : form.high;
|
||||
|
||||
// Dakvorm bij een keuze (c: type, low, high, cut, dir, ridge): dezelfde vorm als het model houdt richting en
|
||||
// dakhoogte boven de goot, een andere krijgt de helling uit PITCH over de korte kant (lessenaar hoog aan de noord-
|
||||
// of westgevel, nok in de lengte). Een gekozen richting (c.dir, c.ridge) en nok (c.high) gaan voor.
|
||||
function formFor(orig, c, W, D) {
|
||||
const { type, low } = c;
|
||||
const dir = (f) => {
|
||||
if (type === 'lessenaar') f.dir = c.dir || orig.dir || (W >= D ? 'voor' : 'links');
|
||||
if (UnitModel.hasRidge(type)) f.ridge = c.ridge || orig.ridge || (W >= D ? 'x' : 'z');
|
||||
return f;
|
||||
};
|
||||
let f;
|
||||
if (type === orig.type) {
|
||||
f = dir(Object.assign({}, orig, { low }));
|
||||
f.high = type === 'plat' ? low : Math.min(LIM.ridge, orig.high + (low - orig.low));
|
||||
} else {
|
||||
f = { type, low, high: low };
|
||||
if (type === 'plat') return f;
|
||||
dir(f);
|
||||
const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D;
|
||||
const run = UnitModel.hasRidge(type) ? span / 2 : span;
|
||||
f.high = Math.round(Math.min(LIM.ridge, low + Math.tan(PITCH[type] * Math.PI / 180) * run));
|
||||
}
|
||||
if (type !== 'plat' && c.high > 0) f.high = Math.round(Math.min(LIM.ridge, Math.max(low + RISE_MIN, c.high)));
|
||||
if (type === 'wolf') { f.cutRatio = c.cut > 0 ? c.cut : cutOf(orig) || WOLF_CUT; delete f.cut; }
|
||||
return f;
|
||||
}
|
||||
// Helling (graden) van een dakvorm over zijn overspanning (W/D: maten van de unit of de woning).
|
||||
function pitchOf(f, W, D) {
|
||||
if (f.type === 'plat') return 0;
|
||||
const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D;
|
||||
return Math.atan((f.high - f.low) / ((UnitModel.hasRidge(f.type) ? span / 2 : span) || 1)) * 180 / Math.PI;
|
||||
}
|
||||
function houseSize(st) {
|
||||
const xs = HouseModel.unitRects(st);
|
||||
return [Math.max(...xs.map(r => r.x1)) - Math.min(...xs.map(r => r.x0)), Math.max(...xs.map(r => r.z1)) - Math.min(...xs.map(r => r.z0))];
|
||||
}
|
||||
// Dakvorm zoals build hem toepast: per unit (id), of over de hele woning (id null; maten uit de gebouwde st).
|
||||
// Met pitch: de helling in graden.
|
||||
function formOf(model, ch, id, st) {
|
||||
const [W, D] = id ? [ch.units[id].width, ch.units[id].depth] : houseSize(st);
|
||||
const f = formFor(id ? model.forms[id] : model.forms.house, id ? ch.units[id] : ch.house, W, D);
|
||||
return Object.assign(f, { pitch: pitchOf(f, W, D) });
|
||||
}
|
||||
|
||||
// Unit van een punt (woningcoördinaten): de rechthoek waar het punt het diepst in ligt.
|
||||
function ownerOf(rects, x, z) {
|
||||
let best = null, depth = -1;
|
||||
Object.values(rects).forEach(r => {
|
||||
const d = Math.min(x - r.x0, r.x1 - x, z - r.z0, r.z1 - z);
|
||||
if (d > -1 && d > depth) { best = r.id; depth = d; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
// Na een maatwijziging: binnenwanden (woningcoördinaten) schuiven mee met hun unit; een eindpunt tegen de oost- of
|
||||
// zuidgevel van een gewijzigde unit schuift mee met die gevel (de wand rekt mee). Inrichting staat al t.o.v. de
|
||||
// noordwesthoek (of de oostgevel) van zijn unit; staat een stuk tegen de oost- of zuidgevel, dan schuift het mee.
|
||||
function follow(st, before, after) {
|
||||
const shift = (id, x, z) => {
|
||||
const b = before[id], a = after[id];
|
||||
let dx = a.x0 - b.x0, dz = a.z0 - b.z0;
|
||||
if (a.W !== b.W && x >= b.x1 - BAND) dx = a.x1 - b.x1;
|
||||
if (a.D !== b.D && z >= b.z1 - BAND) dz = a.z1 - b.z1;
|
||||
return { x: Math.round(x + dx), z: Math.round(z + dz) };
|
||||
};
|
||||
st.interiorWalls.forEach(iw => {
|
||||
if (iw.seam) return;
|
||||
[['x1', 'z1'], ['x2', 'z2']].forEach(([kx, kz]) => {
|
||||
const id = ownerOf(before, iw[kx], iw[kz]);
|
||||
if (!id) return;
|
||||
const p = shift(id, iw[kx], iw[kz]);
|
||||
iw[kx] = p.x; iw[kz] = p.z;
|
||||
});
|
||||
});
|
||||
st.fixtures.forEach(f => {
|
||||
const b = before[f.unitId], a = after[f.unitId];
|
||||
if (!b || !a) return;
|
||||
const r = f.rot * Math.PI / 180, c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
|
||||
const ex = c * f.w / 2 + s * f.d / 2, ez = s * f.w / 2 + c * f.d / 2;
|
||||
if (f.anchor !== 'oost' && a.W !== b.W && f.x + ex >= b.W - BAND) f.x += a.W - b.W;
|
||||
if (a.D !== b.D && f.z + ez >= b.D - BAND) f.z += a.D - b.D;
|
||||
});
|
||||
}
|
||||
|
||||
// Keuzes toepassen op een schone kopie van het model.
|
||||
function build(model, ch) {
|
||||
const st = clone(model.base);
|
||||
const before = rectMap(st);
|
||||
st.units.forEach(u => {
|
||||
const c = ch.units[u.id], fl = u.unit.floor.floor;
|
||||
fl.width = c.width; fl.depth = c.depth;
|
||||
UnitModel.clampSize(fl);
|
||||
});
|
||||
const after = rectMap(st);
|
||||
follow(st, before, after);
|
||||
|
||||
// Dakvorm en hoogte; binnenwanden gaan evenveel mee omhoog of omlaag als de goot.
|
||||
const lift = {};
|
||||
if (model.forms.house) {
|
||||
st.settings.roof.house.form = formOf(model, ch, null, st);
|
||||
st.units.forEach(u => { lift[u.id] = ch.house.low - model.forms.house.low; });
|
||||
} else {
|
||||
// Alleen opnieuw opbouwen wat afwijkt van het model; de rest blijft precies zoals het model het heeft.
|
||||
st.units.forEach(u => {
|
||||
const c = ch.units[u.id], c0 = model.initial.units[u.id], fl = u.unit.floor.floor;
|
||||
lift[u.id] = c.low - model.forms[u.id].low;
|
||||
if (['type', 'low', 'high', 'cut', 'dir', 'ridge', 'width', 'depth'].every(k => c[k] === c0[k])) return;
|
||||
UnitModel.applyRoofForm(u.unit, formFor(model.forms[u.id], c, fl.width, fl.depth));
|
||||
});
|
||||
}
|
||||
st.interiorWalls.forEach(iw => {
|
||||
if (iw.seam) return;
|
||||
const id = ownerOf(after, (iw.x1 + iw.x2) / 2, (iw.z1 + iw.z2) / 2), dh = id ? lift[id] : 0;
|
||||
if (!dh) return;
|
||||
const w = iw.wall.wall;
|
||||
['heightAll', 'heightLeft', 'heightRight'].forEach(k => { if (w[k] > 0) w[k] += dh; });
|
||||
});
|
||||
|
||||
if (ch.covering !== model.initial.covering) st.units.forEach(u => { u.roof.covering = ch.covering; });
|
||||
const fc = ch.facade, F = st.settings.facade;
|
||||
Object.assign(F, { type: fc.type, rabatColor: fc.rabat, stucColor: fc.stuc, felsColor: fc.fels, stone: fc.stone });
|
||||
st.settings.cladding = fc.cladding;
|
||||
Object.assign(st.settings.trasraam, { enabled: ch.trasraam.on }, ch.trasraam.on ? { finish: ch.trasraam.finish, height: ch.trasraam.height } : {});
|
||||
if (model.hasAccent && JSON.stringify(ch.accent) !== JSON.stringify(model.initial.accent)) {
|
||||
const a = ch.accent;
|
||||
accents(st).forEach(o => o.set(a ? Object.assign({ type: a.type }, a.type === 'steen' ? { stone: a.color } : a.type === 'stuc' ? { stuc: a.color } : a.type === 'fels' ? { fels: a.color } : {}) : null));
|
||||
}
|
||||
Object.assign(st.settings.lighting, { mode: ch.lighting.mode, direction: ch.lighting.direction, indoor: ch.lighting.indoor });
|
||||
st.settings.trasraam.capColor = st.settings.roof.ventColor = ch.trim;
|
||||
if (ch.fascia.on !== model.initial.fascia.on) st.units.forEach(u => { u.roof.fascia = ch.fascia.on; });
|
||||
st.settings.roof.fasciaColor = ch.fascia.color;
|
||||
return st;
|
||||
}
|
||||
|
||||
// Wat er aan de indeling schort: per opening (verschoven, ingeklemd, te hoog voor de wand of in geen enkel
|
||||
// wandstuk meer), binnenwand buiten de units, inrichting door een gevel, verdwenen ruimtes en overlappende units.
|
||||
// check vergelijkt met het model zelf, zodat wat daar al zo is geen keuze blokkeert.
|
||||
function issuesOf(st, parts) {
|
||||
const out = new Set();
|
||||
parts.overlaps.forEach(p => out.add('units ' + p.join('+')));
|
||||
parts.rects.forEach(r => { if (r.error) out.add('units ' + r.id); });
|
||||
const ops = (key, config, placed) => config.openings.forEach(o => {
|
||||
if (o.type === 'schoorsteen') return;
|
||||
const W = config.wallWidth;
|
||||
const want = o.anchor === 'right' ? W - o.offset - o.w : o.anchor === 'middle' ? (W - o.w) / 2 + o.offset : o.offset;
|
||||
if (o.overflowing || Math.abs(o.x - want) > 2) out.add(`opening ${key}.${o.id} plek`);
|
||||
if (o.h < o.rawH - 2 || o.hR < o.rawHR - 2 || o.y < o.rawY - 2) out.add(`opening ${key}.${o.id} hoogte`);
|
||||
if (placed && !placed.has(o.id)) out.add(`opening ${key}.${o.id} kwijt`);
|
||||
});
|
||||
// Gevelopeningen staan t.o.v. de hele wand en worden daarna over de wandstukken verdeeld (UnitModel.wallPieces).
|
||||
st.units.forEach(su => {
|
||||
const pu = parts.units.find(u => u.id === su.id);
|
||||
Object.entries(su.unit.walls).forEach(([side, ws]) => {
|
||||
const w = pu && pu.walls.find(x => x.side === side);
|
||||
ops(`${su.id}-${side}`, WallModel.configFromState(ws), new Set(w ? w.pieces.flatMap(p => p.config.openings.map(o => o.id)) : []));
|
||||
});
|
||||
});
|
||||
parts.interior.forEach(p => ops(`bw${p.id}${p.seamUnit || ''}`, p.config));
|
||||
const inside = (r, x, z, m) => r && x >= r.x0 + m && x <= r.x1 - m && z >= r.z0 + m && z <= r.z1 - m;
|
||||
st.interiorWalls.filter(iw => !iw.seam).forEach(iw => {
|
||||
if (![[iw.x1, iw.z1], [iw.x2, iw.z2]].every(([x, z]) => parts.rects.some(r => inside(r, x, z, -1)))) out.add(`indeling bw${iw.id}`);
|
||||
});
|
||||
parts.fixtures.forEach(it => {
|
||||
const r = parts.rects.find(q => q.id === it.unitId);
|
||||
if (it.corners.some(c => !inside(r, c.x, c.z, 100))) out.add(`indeling fx${it.id}`);
|
||||
});
|
||||
return { keys: out, rooms: parts.rooms.rooms.length };
|
||||
}
|
||||
|
||||
// null = in orde, anders een melding voor de klant.
|
||||
function check(model, st, parts) {
|
||||
const now = issuesOf(st, parts), ref = model.reference;
|
||||
const added = [...now.keys].filter(k => !ref.keys.has(k));
|
||||
if (now.rooms < ref.rooms) added.push('indeling ruimtes');
|
||||
if (!added.length) return null;
|
||||
if (added.some(k => k.startsWith('units'))) return 'De units komen dan over elkaar heen.';
|
||||
if (added.some(k => k.startsWith('opening'))) return 'De ramen en deuren van dit model passen dan niet meer.';
|
||||
return 'De indeling van dit model past dan niet meer.';
|
||||
}
|
||||
|
||||
// Grootste of kleinste maat tussen een geldige (ok) en een gevraagde waarde (want) die nog past; rekent zonder 3D.
|
||||
function fit(model, ch, setter, ok, want) {
|
||||
let good = ok, bad = want;
|
||||
while (Math.abs(bad - good) > STEP) {
|
||||
const mid = Math.round((good + bad) / 2 / STEP) * STEP;
|
||||
if (mid === good || mid === bad) break;
|
||||
const c = clone(ch);
|
||||
setter(c, mid);
|
||||
const st = build(model, c);
|
||||
if (check(model, st, HouseModel.computeParts(st, { skip3D: true }))) bad = mid; else good = mid;
|
||||
}
|
||||
return good;
|
||||
}
|
||||
|
||||
// Kerncijfers voor de klant: geen uren, zaagstukken of inkoop.
|
||||
function summary(st, parts) {
|
||||
const totals = HouseModel.computeTotals(parts);
|
||||
const cost = CostModel.compute(parts, totals);
|
||||
const d = HouseModel.computeDimensions(parts, st.settings.groundLevel);
|
||||
return {
|
||||
price: cost.summary.inclBtw, kg: totals.totalKg, units: st.units.length,
|
||||
lengthX: d.lengthX, lengthZ: d.lengthZ, height: d.height, brutoM2: d.brutoM2, leefbaarM2: d.leefbaarM2, inhoudM3: d.inhoudM3
|
||||
};
|
||||
}
|
||||
|
||||
// De keuzes in woorden (voorblad van de PDF): [onderwerp, tekst].
|
||||
// Lessenaar: hoge kant; zadel/wolf: richting van de nok (UnitModel.RIDGES).
|
||||
const SIDES = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' };
|
||||
const SIDE_NAMES = { voor: 'noord', achter: 'zuid', links: 'west', rechts: 'oost' };
|
||||
const ROOF_NAMES = { plat: 'plat dak', lessenaar: 'lessenaardak', zadel: 'zadeldak', wolf: 'wolfsdak' };
|
||||
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
||||
function colorName(type, key) {
|
||||
if (type === 'steen') return `steen ${key}`;
|
||||
const list = type === 'rabat' ? WallModel.RABAT_COLORS : type === 'stuc' ? WallModel.STUC_COLORS : WallModel.FELS.COLORS;
|
||||
return list[key] ? list[key].label.toLowerCase() : '';
|
||||
}
|
||||
function describe(model, ch, st) {
|
||||
const fc = ch.facade, ids = Object.keys(ch.units), multi = ids.length > 1;
|
||||
const facade = (type, color) => [FACADES[type].toLowerCase(), type === 'rabat' ? fc.cladding : null, colorName(type, color)].filter(Boolean).join(', ');
|
||||
const perUnit = (fn) => {
|
||||
const vals = ids.map(id => fn(ch.units[id], id));
|
||||
return !multi || vals.every(v => v === vals[0]) ? vals[0] : ids.map((id, i) => `unit ${id}: ${vals[i]}`).join(', ');
|
||||
};
|
||||
const sloped = ch.house ? ch.house.type !== 'plat' : ids.some(id => ch.units[id].type !== 'plat');
|
||||
const nok = (f) => f.type === 'plat' ? '' : `, ${f.type === 'lessenaar' ? 'hoge kant' : 'nok'} ${nl(f.high / 1000)} m`;
|
||||
const way = (f) => f.type === 'lessenaar' ? ` (hoog aan de ${SIDE_NAMES[f.dir]}kant)` : UnitModel.hasRidge(f.type) ? ` (nok ${UnitModel.RIDGES[f.ridge].toLowerCase()})` : '';
|
||||
const rows = [
|
||||
['Dakvorm', cap(ch.house ? `${ROOF_NAMES[ch.house.type]}${way(formOf(model, ch, null, st))} over de hele woning`
|
||||
: perUnit((u, id) => ROOF_NAMES[u.type] + way(formOf(model, ch, id))))],
|
||||
['Hoogte', cap(ch.house ? `goot ${nl(ch.house.low / 1000)} m${nok(formOf(model, ch, null, st))}` : perUnit((u, id) => `goot ${nl(u.low / 1000)} m${nok(formOf(model, ch, id))}`))],
|
||||
['Dakbedekking', (RoofModel.COVERINGS[sloped ? ch.covering : 'dakleer'] || RoofModel.COVERINGS.dakleer).label],
|
||||
['Gevel', cap(facade(fc.type, ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type]))]
|
||||
];
|
||||
const tr = ch.trasraam;
|
||||
rows.push(['Trasraam', tr.on ? `Steen ${tr.finish}, ${nl(tr.height / 1000)} m hoog` : 'Geen']);
|
||||
rows.push(['Dakrand', ch.fascia.on ? `Boeidelen ${RoofModel.FASCIA_COLORS[ch.fascia.color].label.toLowerCase()}` : 'Daktrim']);
|
||||
const trimUse = trimUses(ch);
|
||||
if (trimUse) rows.push(['Zetwerk', `${WallModel.TRIM_COLORS[ch.trim].label} (${trimUse})`]);
|
||||
if (model.hasAccent) rows.push(['Accentgevel', ch.accent ? cap(facade(ch.accent.type, ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : 'Geen']);
|
||||
rows.push(['Buitenverlichting', LIGHTING[ch.lighting.mode] + (ch.lighting.mode === 'gevel' ? ` (${LightingModel.DIRECTIONS[ch.lighting.direction].toLowerCase()})` : '')]);
|
||||
rows.push(['Binnenverlichting', ch.lighting.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
|
||||
return rows;
|
||||
}
|
||||
const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
// Waar de zetwerk-kleur voor geldt (leeg: nergens).
|
||||
const trimUses = (ch) => [ch.trasraam.on ? 'afdekker trasraam' : null, ch.fascia.on ? 'ventilatierooster' : null].filter(Boolean).join(' en ');
|
||||
|
||||
return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, trimUses, create, build, check, fit, summary, describe, colorName };
|
||||
})();
|
||||
@@ -0,0 +1,232 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs tenzij
|
||||
// d.price === false, contact en QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
|
||||
window.LightPdf = (() => {
|
||||
const COMPANY = {
|
||||
naam: 'Van Emous Mantelzorgwoningen',
|
||||
adres: ['Keizerswoert 19', '3881 LE Putten'],
|
||||
telefoon: '0341 - 275 147',
|
||||
email: 'info@vanemousmantelzorgwoningen.nl',
|
||||
web: 'https://www.vanemousmantelzorgwoningen.nl',
|
||||
open: 'Ma t/m vr 08:00 - 17:00 uur, za en zo op afspraak'
|
||||
};
|
||||
// Modellen met een eigen pagina op de site; andere verwijzen naar het overzicht.
|
||||
const SITE_MODELS = ['amstel', 'ijssel', 'maas', 'reest', 'rijn', 'waal', 'wetering', 'eem'];
|
||||
const modelUrl = (key) => SITE_MODELS.includes(key) ? `${COMPANY.web}/modellen/model-${key}/` : `${COMPANY.web}/modellen/`;
|
||||
|
||||
// Werkwijze, naar de site (werkwijze/stappenplan).
|
||||
const STEPS = [
|
||||
['Mag u plaatsen?', 'Wij toetsen vrijblijvend uw perceel aan de regels van uw gemeente en geven een eerste indicatie van de mogelijkheden. Voor zekerheid helpen wij u graag bij het overleg met de gemeente.'],
|
||||
['Uw ontwerp uitwerken', 'U start vanuit een van onze standaardmodellen of vanuit uw eigen ontwerp. Onze specialisten werken uw wensen uit tot een productietekening. Omdat wij in onze eigen fabriek bouwen, is vrijwel elk ontwerp technisch haalbaar.'],
|
||||
['Wij gaan bouwen', 'Zodra het ontwerp definitief is en de omgevingsvergunning er is, start de productie in onze fabriek. Tijdens openingstijden bent u welkom om de voortgang te komen bekijken.'],
|
||||
['Transport en plaatsing', 'De afgebouwde modules worden naar uw perceel gebracht, op de fundering geplaatst en aangesloten op de nutsvoorzieningen. Na de laatste afwerking ontvangt u de sleutel.'],
|
||||
['Oplevering en service', 'Samen lopen we de woning na, zodat alles klopt met het ontwerp en uw wensen. Ook na de oplevering blijven wij beschikbaar voor service en onderhoud.']
|
||||
];
|
||||
|
||||
const MAATWERK = ['Dit is pas het begin: alles kan op maat',
|
||||
'Dit ontwerp is gemaakt met een vereenvoudigde versie van onze configurator. Met onze eigen ontwerpsoftware werken wij uw woning ' +
|
||||
'samen met u tot in detail uit: een keuken op maat, een andere indeling met extra binnenwanden, of ramen en deuren op een andere ' +
|
||||
'plek en in een ander formaat. U bent van harte welkom voor een vrijblijvend adviesgesprek, waarin we samen uw wensen en ' +
|
||||
'mogelijkheden doornemen. Ook staat onze fabriek in Putten open voor bezoek: kom kijken hoe onze woningen gebouwd worden en ervaar ' +
|
||||
'de kwaliteit met eigen ogen. Maak gerust een afspraak; wij denken graag met u mee.'];
|
||||
|
||||
const ORANGE = [185, 89, 19], INK = [33, 33, 33], MUTED = [91, 102, 115], SOFT = [249, 246, 240], LINE = [226, 231, 238];
|
||||
const PW = 420, PH = 297, M = 20;
|
||||
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
||||
|
||||
// QR-code als vlakken (scherp bij elke vergroting), met witte rand.
|
||||
function drawQr(doc, text, x, y, size) {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(text);
|
||||
qr.make();
|
||||
const n = qr.getModuleCount(), quiet = 2, s = size / (n + 2 * quiet);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.rect(x, y, size, size, 'F');
|
||||
doc.setFillColor(0, 0, 0);
|
||||
for (let r = 0; r < n; r++) {
|
||||
for (let c = 0; c < n; c++) {
|
||||
if (!qr.isDark(r, c)) continue;
|
||||
let e = c;
|
||||
while (e + 1 < n && qr.isDark(r, e + 1)) e++;
|
||||
doc.rect(x + (quiet + c) * s, y + (quiet + r) * s, (e - c + 1) * s + 0.02, s + 0.02, 'F');
|
||||
c = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logo(doc, x, y, h) {
|
||||
const L = window.TECTUM_LOGO;
|
||||
if (L) doc.addImage(L.png, 'PNG', x, y, h * L.aspect, h, 'tectum-logo', 'FAST');
|
||||
else { doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(...INK); doc.text(COMPANY.naam, x, y + h * 0.7); }
|
||||
}
|
||||
|
||||
// Contactblok met QR-code; y = bovenkant.
|
||||
function contactBand(doc, y, url, label) {
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(M, y, PW - M, y);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.text(COMPANY.naam, M, y + 9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
const col = (x, lines) => lines.forEach((t, i) => doc.text(t, x, y + 16 + i * 5.2));
|
||||
col(M, COMPANY.adres.concat([COMPANY.open]));
|
||||
col(M + 95, [`Telefoon ${COMPANY.telefoon}`, COMPANY.email, COMPANY.web.replace(/^https:\/\//, '')]);
|
||||
const q = 34;
|
||||
drawQr(doc, url, PW - M - q, y + 4, q);
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(...INK);
|
||||
doc.text(doc.splitTextToSize(label, 70), PW - M - q - 4, y + 12, { align: 'right' });
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFontSize(8);
|
||||
doc.text(url.replace(/^https:\/\//, ''), PW - M - q - 4, y + 30, { align: 'right' });
|
||||
}
|
||||
|
||||
function keyValues(doc, x, y, w, rows, title) {
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(...INK);
|
||||
doc.text(title, x, y);
|
||||
let yy = y + 8;
|
||||
rows.forEach(([k, v]) => {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(k, x, yy);
|
||||
doc.setTextColor(...INK);
|
||||
const lines = doc.splitTextToSize(String(v), w - 42);
|
||||
doc.text(lines, x + 42, yy);
|
||||
yy += 5.4 * lines.length + 0.8;
|
||||
});
|
||||
return yy;
|
||||
}
|
||||
|
||||
function cover(doc, d) {
|
||||
doc.setFillColor(...SOFT);
|
||||
doc.rect(0, 0, PW, PH, 'F');
|
||||
logo(doc, M, 18, 14);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(34);
|
||||
doc.text('Uw woningontwerp', M, 62);
|
||||
doc.setTextColor(...ORANGE);
|
||||
doc.setFontSize(20);
|
||||
doc.text(d.label, M, 74);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(`Samengesteld op ${new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })}`, M, 82);
|
||||
|
||||
const i = d.info;
|
||||
let y = keyValues(doc, M, 98, 150, [
|
||||
['Buitenmaten', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`], ['Bouwhoogte', meters(i.height)],
|
||||
['Vloeroppervlak', `${nl(i.brutoM2, 1)} m² bruto, ${nl(i.leefbaarM2, 1)} m² leefbaar`], ['Inhoud', `${nl(i.inhoudM3, 0)} m³`],
|
||||
['Modules', String(i.units)]
|
||||
], 'Kerncijfers');
|
||||
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
|
||||
|
||||
// Prijs: rustig, als de rest van de pagina; alleen het bedrag groter en vet.
|
||||
const py = Math.max(y + 4, 196), price = d.price !== false;
|
||||
if (price) {
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.line(M, py, M + 150, py);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.text('Indicatieve prijs', M, py + 9);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(20);
|
||||
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text('incl. btw, onder voorbehoud', M, py + 25);
|
||||
}
|
||||
|
||||
// Beeld van de woning
|
||||
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
|
||||
if (d.image) {
|
||||
const ar = d.image.width / d.image.height, w = Math.min(bw, bh * ar), h = w / ar;
|
||||
doc.addImage(d.image.url, 'JPEG', bx + (bw - w) / 2, by + (bh - h) / 2, w, h);
|
||||
}
|
||||
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(`Dit ontwerp${price ? ' en de prijs zijn' : ' is'} indicatief; hieraan kunnen geen rechten worden ontleend.`, M, PH - 6);
|
||||
}
|
||||
|
||||
function process(doc, d) {
|
||||
doc.setFillColor(...SOFT);
|
||||
doc.rect(0, 0, PW, PH, 'F');
|
||||
logo(doc, M, 18, 14);
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(28);
|
||||
doc.text('Hoe verloopt het traject?', M, 62);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(doc.splitTextToSize('Van het eerste idee tot de sleuteloverdracht: wij begeleiden u in duidelijke stappen naar een mantelzorgwoning die bij u past. Dit ontwerp is de start van stap 2.', 260), M, 74);
|
||||
|
||||
const gap = 8, w = (PW - 2 * M - gap * (STEPS.length - 1)) / STEPS.length, y = 92, h = 98;
|
||||
STEPS.forEach(([title, text], k) => {
|
||||
const x = M + k * (w + gap);
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.roundedRect(x, y, w, h, 3, 3, 'FD');
|
||||
doc.setFillColor(...ORANGE);
|
||||
doc.circle(x + 12, y + 14, 6.5, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(13);
|
||||
doc.text(String(k + 1), x + 12, y + 14, { align: 'center', baseline: 'middle' });
|
||||
doc.setTextColor(...INK);
|
||||
doc.setFontSize(13);
|
||||
doc.text(doc.splitTextToSize(title, w - 12), x + 6, y + 32);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(doc.splitTextToSize(text, w - 12), x + 6, y + 44, { lineHeightFactor: 1.4 });
|
||||
});
|
||||
// Maatwerk: dit ontwerp is een begin, de eigen software kan veel verder.
|
||||
const by = 195, bh = 40;
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.roundedRect(M, by, PW - 2 * M, bh, 3, 3, 'FD');
|
||||
doc.setFillColor(...ORANGE);
|
||||
doc.rect(M, by + 3, 2.5, bh - 6, 'F');
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(13);
|
||||
doc.setTextColor(...INK);
|
||||
doc.text(MAATWERK[0], M + 9, by + 10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(10.5);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(doc.splitTextToSize(MAATWERK[1], PW - 2 * M - 18), M + 9, by + 17, { lineHeightFactor: 1.35 });
|
||||
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
|
||||
}
|
||||
|
||||
// d: { st, label, info, choices: [[label, tekst]], url, image: { url, width, height } | null }
|
||||
function generate(d) {
|
||||
const doc = PdfSheet.createDoc();
|
||||
cover(doc, d);
|
||||
HousePdf.generate(d.st, { customer: true, doc, pageOffset: 1, pageTotal: 4 });
|
||||
doc.addPage('a3', 'landscape');
|
||||
process(doc, d);
|
||||
return doc;
|
||||
}
|
||||
|
||||
return { COMPANY, modelUrl, generate };
|
||||
})();
|
||||
@@ -0,0 +1,809 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
|
||||
// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met
|
||||
// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen).
|
||||
// Inbedden: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
|
||||
// TectumLight.mount(el, { model, frames, url, modelViewer, save, price }); url = adres voor de QR-code in de PDF (standaard
|
||||
// de modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script), price: false (of
|
||||
// data-price="false") = geen prijs in beeld en in de PDF; design().info.price blijft er wel in.
|
||||
// save = { label, icon, onSave(ontwerp) } vervangt de PDF-knop (bijv. ontwerp aanvragen op de site); onSave krijgt
|
||||
// { design, label, key, info, choices, url }: design = house-design-JSON zoals de configurator die opslaat, choices = de
|
||||
// keuzes in woorden (zoals op het voorblad van de PDF). Zonder save blijft het de PDF.
|
||||
// Knop rechtsboven in de weergave: op een telefoon/tablet het ontwerp als GLB in model-viewer, met AR (WebXR, Scene
|
||||
// Viewer, Quick Look); op de desktop schermvullend.
|
||||
window.TectumLight = (() => {
|
||||
const esc = UI.esc;
|
||||
// model-viewer (Apache-2.0, eigen three.js ingebakken) pas laden bij de eerste keer AR.
|
||||
const SCRIPT_SRC = (document.currentScript && document.currentScript.src) || location.href;
|
||||
let mvLoading = null;
|
||||
function loadModelViewer(src) {
|
||||
if (window.customElements.get('model-viewer')) return Promise.resolve();
|
||||
if (!mvLoading) mvLoading = new Promise((ok, fail) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = ok;
|
||||
s.onerror = () => { mvLoading = null; fail(new Error(`model-viewer niet geladen (${src})`)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return mvLoading;
|
||||
}
|
||||
const AR_DEVICE = /android|iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
// Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator.
|
||||
const SECTION = { start: 1500, min: 300, max: 3000 };
|
||||
const HINTS = { '3d': 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' };
|
||||
|
||||
// tl-wrap is het raster; .tectum-light zelf is de container voor de container queries (breedte van de tool, niet
|
||||
// van het scherm: in de HUGO-site staat hij in een Bootstrap col-12).
|
||||
const TEMPLATE = `<div class="tl-wrap">
|
||||
<div class="tl-stage">
|
||||
<div class="tl-3d"></div>
|
||||
<canvas class="tl-plan" aria-label="Plattegrond, noord boven"></canvas>
|
||||
<div class="tl-viewbar" role="group" aria-label="Weergave">
|
||||
<button type="button" data-view="3d" class="on"><i class="bi bi-badge-3d"></i>3D</button>
|
||||
<button type="button" data-view="cut" title="Wanden afgesneden op een hoogte boven de vloer"><i class="bi bi-scissors"></i>Doorsnede</button>
|
||||
<button type="button" data-view="plan"><i class="bi bi-grid-1x2"></i>Plattegrond</button>
|
||||
</div>
|
||||
<label class="tl-cutbar"><span>Snijhoogte</span>
|
||||
<input type="range" min="${SECTION.min}" max="${SECTION.max}" step="50" value="${SECTION.start}" aria-label="Snijhoogte boven de vloer">
|
||||
<output>${meters(SECTION.start)}</output></label>
|
||||
<button type="button" class="tl-xbtn" hidden></button>
|
||||
<div class="tl-hint"></div>
|
||||
<div class="tl-busy" aria-live="polite"><span class="tl-spinner"></span>Berekenen…</div>
|
||||
<div class="tl-toast" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<aside class="tl-panel">
|
||||
<header class="tl-head"><h2 class="tl-title"></h2><div class="tl-sub"></div></header>
|
||||
<div class="tl-sections"></div>
|
||||
<div class="tl-facts"></div>
|
||||
<div class="tl-buy"></div>
|
||||
</aside></div>`;
|
||||
|
||||
function mount(root, opts) {
|
||||
const o = opts || {};
|
||||
const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames });
|
||||
const showPrice = o.price !== false && root.dataset.price !== 'false';
|
||||
let ch = clone(model.initial);
|
||||
let unitSel = model.base.units[0].id;
|
||||
let cur = null; // { st, parts, info }
|
||||
root.classList.add('tectum-light');
|
||||
root.innerHTML = TEMPLATE;
|
||||
const $ = (sel) => root.querySelector(sel);
|
||||
$('.tl-title').textContent = model.label;
|
||||
|
||||
// 3D: afgewerkte woning (zoals de render-export), dag-omgeving.
|
||||
const stage = $('.tl-3d');
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xdfe6ec);
|
||||
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.localClippingEnabled = true;
|
||||
stage.appendChild(renderer.domElement);
|
||||
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
sun.position.set(5, 8, 5);
|
||||
scene.add(ambient, sun);
|
||||
const sky = Skybox.createEnvironment(scene);
|
||||
const mats = {
|
||||
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
|
||||
lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(),
|
||||
chimneyMaterials: ChimneyModel.createMaterials(), elektraMaterials: ElektraModel.createMaterials()
|
||||
};
|
||||
const S0 = model.base.settings;
|
||||
if (S0.interiorPaint) mats.wallMaterials.spaanplaatMaterial.color.set(S0.interiorColor);
|
||||
mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef');
|
||||
const compute = (st) => HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
|
||||
const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); });
|
||||
const rootsOf = HouseModel.rootGroups;
|
||||
|
||||
// Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond.
|
||||
let group = null, caps = null, view = '3d', fitted = false, center = { x: 0, z: 0, size: 10 }, sectionMM = SECTION.start;
|
||||
const poses = {};
|
||||
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
||||
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
function clip(g, planes) {
|
||||
if (g) g.traverse(x => (Array.isArray(x.material) ? x.material : x.material ? [x.material] : []).forEach(m => {
|
||||
if ((m.clippingPlanes || []).length === planes.length) return;
|
||||
m.clippingPlanes = planes;
|
||||
m.needsUpdate = true;
|
||||
}));
|
||||
}
|
||||
function applyView() {
|
||||
if (!cur) return;
|
||||
const p = cur.parts, cut = view === 'cut';
|
||||
p.units.forEach(u => { if (u.roof.group) u.roof.group.visible = !cut; });
|
||||
[p.roofEdgeGroup, p.postsGroup].forEach(g => { if (g) g.visible = !cut; });
|
||||
if (p.roomsGroup && p.roomsGroup.userData.ceilings) p.roomsGroup.userData.ceilings.visible = !cut;
|
||||
const planes = cut ? [cutPlane] : [];
|
||||
HouseModel.allWallPieces(p).forEach(x => clip(x.group, planes));
|
||||
// Lampen snijden mee: binnenspots onder de snijhoogte blijven zichtbaar, niets zweeft.
|
||||
[p.elektraGroup, p.chimneysGroup, p.roomsGroup, p.fixturesGroup, p.lightingGroup].forEach(g => clip(g, planes));
|
||||
placeCut();
|
||||
if (view === 'plan') drawPlan();
|
||||
}
|
||||
// fn in de gewone 3D-weergave (dak erop, niets afgesneden), daarna terug naar de gekozen weergave.
|
||||
function as3d(fn) {
|
||||
const prev = view;
|
||||
if (prev !== '3d') { view = '3d'; applyView(); }
|
||||
try { return fn(); } finally { if (prev !== '3d') { view = prev; applyView(); } }
|
||||
}
|
||||
// Snijvlak en witte afdekking op de snijhoogte; alleen dit bij het schuiven.
|
||||
function placeCut() {
|
||||
const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop));
|
||||
cutPlane.constant = (floorTop + sectionMM) * 0.001;
|
||||
if (caps) { group.remove(caps); disposeGeo(caps); caps = null; }
|
||||
if (view === 'cut') { caps = HouseModel.sectionCaps(p, floorTop + sectionMM, capMaterial, ViewLayers.all()); group.add(caps); }
|
||||
}
|
||||
const cutInput = $('.tl-cutbar input');
|
||||
// Niet hoger dan de goot (plat dak: de dakhoogte); bij units met een andere goot de laagste, zodat de snede altijd
|
||||
// door alle gevels gaat. Loopt mee met de gekozen hoogte.
|
||||
function fitCutRange() {
|
||||
const lows = ch.house ? [ch.house.low] : Object.values(ch.units).map(u => u.low);
|
||||
const max = Math.max(SECTION.min, Math.floor(Math.min(...lows) / 50) * 50);
|
||||
cutInput.max = max;
|
||||
if (sectionMM > max) sectionMM = max;
|
||||
cutInput.value = sectionMM;
|
||||
$('.tl-cutbar output').textContent = meters(sectionMM);
|
||||
}
|
||||
cutInput.addEventListener('input', () => {
|
||||
sectionMM = Number(cutInput.value);
|
||||
$('.tl-cutbar output').textContent = meters(sectionMM);
|
||||
if (cur) placeCut();
|
||||
});
|
||||
// Elke weergave onthoudt zijn eigen camerastand; de doorsnede begint schuin van boven.
|
||||
function setView(v) {
|
||||
if (v === view) return;
|
||||
if (view !== 'plan') poses[view] = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||
view = v;
|
||||
if (v !== 'plan') {
|
||||
const pose = poses[v];
|
||||
if (pose) { camera.position.copy(pose.pos); controls.target.copy(pose.target); }
|
||||
else {
|
||||
controls.target.set(center.x, 0.6, center.z);
|
||||
camera.position.set(center.x - center.size * 0.45, center.size * 1.05 + 2, center.z + center.size * 0.75);
|
||||
}
|
||||
controls.update();
|
||||
}
|
||||
root.classList.toggle('view-plan', v === 'plan');
|
||||
root.classList.toggle('view-cut', v === 'cut');
|
||||
root.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === v));
|
||||
$('.tl-hint').textContent = HINTS[v];
|
||||
applyView();
|
||||
}
|
||||
$('.tl-hint').textContent = HINTS['3d'];
|
||||
root.querySelector('.tl-viewbar').addEventListener('click', (e) => { const b = e.target.closest('[data-view]'); if (b) setView(b.dataset.view); });
|
||||
|
||||
function show(st, parts) {
|
||||
if (group) { scene.remove(group); disposeGeo(group); caps = null; }
|
||||
fitCutRange();
|
||||
HouseModel.placeGroups(parts);
|
||||
group = new THREE.Group();
|
||||
rootsOf(parts).forEach(g => group.add(g));
|
||||
scene.add(group);
|
||||
cur = { st, parts, info: LightModel.summary(st, parts) };
|
||||
const rs = parts.rects, toM = 0.001;
|
||||
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
|
||||
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
|
||||
center = { x: (bx0 + bx1) / 2 * toM, z: (bz0 + bz1) / 2 * toM, size: Math.max(bx1 - bx0, bz1 - bz0) * toM };
|
||||
const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - st.settings.groundLevel - 2) * toM;
|
||||
sky.fitTo(group, groundY);
|
||||
Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group });
|
||||
if (!fitted) {
|
||||
controls.target.set(center.x, 1.2, center.z);
|
||||
camera.position.set(center.x - center.size * 0.9, center.size * 0.7, center.z - center.size * 1.3);
|
||||
controls.update();
|
||||
fitted = true;
|
||||
}
|
||||
applyView();
|
||||
HousePdf.materialThumbUrls(st).forEach(PdfSheet.preloadImage);
|
||||
renderPanel();
|
||||
renderSummary();
|
||||
}
|
||||
|
||||
// Plattegrond (noord boven): ruimtes met naam en netto m², wanden met kozijnen en draairichting, inrichting,
|
||||
// dakrand, palen en de buitenmaten. Zelfde gegevens als de PDF-plattegrond.
|
||||
function drawPlan() {
|
||||
const cv = $('.tl-plan'), W = cv.clientWidth, H = cv.clientHeight;
|
||||
if (!cur || !W || !H) return;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
const p = cur.parts, rs = p.rects;
|
||||
const ext = rs.concat(p.units.map(u => u.roof.rect));
|
||||
const bx0 = Math.min(...ext.map(r => r.x0)), bx1 = Math.max(...ext.map(r => r.x1));
|
||||
const bz0 = Math.min(...ext.map(r => r.z0)), bz1 = Math.max(...ext.map(r => r.z1));
|
||||
const top = 64, side = 56;
|
||||
const scale = Math.min((W - 2 * side) / (bx1 - bx0), (H - top - side) / (bz1 - bz0));
|
||||
const ox = W / 2 - (bx0 + bx1) / 2 * scale, oy = top + (H - top - side) / 2 - (bz0 + bz1) / 2 * scale;
|
||||
const P = (x, z) => [ox + x * scale, oy + z * scale];
|
||||
const path = (pts, closed) => { ctx.beginPath(); pts.forEach((q, i) => ctx[i ? 'lineTo' : 'moveTo'](...P(q.x, q.z))); if (closed) ctx.closePath(); };
|
||||
const corners = (pl, lx0, lx1) => { const t = pl.thickness / 2; return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([a, b]) => UnitModel.localToWorld(pl, a, b)); };
|
||||
const rgba = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`;
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Dakrand
|
||||
ctx.setLineDash([5, 4]); ctx.strokeStyle = '#9aa3ad'; ctx.lineWidth = 1;
|
||||
if (p.roofEdge) p.roofEdge.segments.forEach(sg => { path([sg.outerA, sg.outerB]); ctx.stroke(); });
|
||||
else p.units.forEach(u => { const [x, y] = P(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(x, y, u.roof.rect.W * scale, u.roof.rect.D * scale); });
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Ruimtes
|
||||
const R = p.rooms;
|
||||
R.rooms.forEach(r => { ctx.fillStyle = rgba(r.color, 0.22); r.rects.forEach(c => { const [x, y] = P(c.x0, c.z0); ctx.fillRect(x, y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); });
|
||||
|
||||
// Inrichting
|
||||
(p.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
|
||||
ctx.strokeStyle = '#8b939b';
|
||||
ctx.lineWidth = sh.kind === 'thin' ? 0.6 : 1;
|
||||
ctx.setLineDash(sh.kind === 'dash' ? [3, 3] : []);
|
||||
if (sh.circle) { ctx.beginPath(); ctx.arc(...P(sh.circle.x, sh.circle.z), Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke(); return; }
|
||||
if (sh.text) return;
|
||||
path(sh.pts, sh.closed);
|
||||
if (sh.fill) { ctx.fillStyle = 'rgba(139, 147, 155, 0.18)'; ctx.fill(); }
|
||||
ctx.stroke();
|
||||
}));
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Wanden met openingen en kozijnsymbolen
|
||||
const piece = (x, fill) => {
|
||||
const pl = x.place, Wp = pl.width;
|
||||
path(corners(pl, -Wp / 2, Wp / 2), true);
|
||||
ctx.fillStyle = fill; ctx.fill();
|
||||
x.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||
path(corners(pl, -Wp / 2 + op.x, -Wp / 2 + op.x + op.w), true);
|
||||
ctx.fillStyle = '#ffffff'; ctx.fill();
|
||||
ctx.strokeStyle = '#33383d'; ctx.lineWidth = 0.8; ctx.stroke();
|
||||
if (!op.fill) return;
|
||||
KozijnModel.planSymbol(op, op.fill, { zIn: -pl.thickness / 2, zOut: pl.thickness / 2 }).forEach(sh => {
|
||||
ctx.lineWidth = sh.kind === 'leaf' ? 1.3 : 0.8;
|
||||
ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []);
|
||||
path(sh.pts.map(q => UnitModel.localToWorld(pl, -Wp / 2 + op.x + q.x, q.z)), !!sh.closed);
|
||||
ctx.stroke();
|
||||
});
|
||||
ctx.setLineDash([]);
|
||||
});
|
||||
};
|
||||
p.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(x => piece(x, '#3d4247'))));
|
||||
p.interior.forEach(x => piece(x, '#7a828a'));
|
||||
ctx.fillStyle = '#a07c54';
|
||||
p.posts.posts.forEach(pp => { const [x, y] = P(pp.x - pp.size / 2, pp.z - pp.size / 2); ctx.fillRect(x, y, pp.size * scale, pp.size * scale); });
|
||||
p.posts.walls.forEach(w => { const [x, y] = P(w.x0, w.z0); ctx.fillStyle = '#3d4247'; ctx.fillRect(x, y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); });
|
||||
(p.chimneys || []).forEach(c => { path(c.footprint, true); ctx.fillStyle = '#8b8f93'; ctx.fill(); });
|
||||
// Binnenspots
|
||||
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#a07a1e'; ctx.lineWidth = 0.8;
|
||||
(p.lighting.indoor || []).forEach(sp => { ctx.beginPath(); ctx.arc(...P(sp.x, sp.z), Math.max(2.5, 90 * scale), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); });
|
||||
|
||||
// Ruimtenamen
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
const fs = Math.max(9, Math.min(13, scale * 260));
|
||||
R.rooms.forEach(r => {
|
||||
const [x, y] = P(r.label.x, r.label.z);
|
||||
ctx.fillStyle = '#212121'; ctx.font = `600 ${fs}px sans-serif`;
|
||||
ctx.fillText(r.name, x, y - fs * 0.6);
|
||||
ctx.fillStyle = '#5b6673'; ctx.font = `${fs * 0.9}px sans-serif`;
|
||||
ctx.fillText(`${nl(r.areaM2, 1)} m²`, x, y + fs * 0.6);
|
||||
});
|
||||
|
||||
// Buitenmaten (units) en noordpijl
|
||||
const ux0 = Math.min(...rs.map(r => r.x0)), ux1 = Math.max(...rs.map(r => r.x1));
|
||||
const uz0 = Math.min(...rs.map(r => r.z0)), uz1 = Math.max(...rs.map(r => r.z1));
|
||||
ctx.strokeStyle = '#1d5f99'; ctx.fillStyle = '#1d5f99'; ctx.lineWidth = 1; ctx.font = '12px sans-serif';
|
||||
const dim = (a, b, horiz, at) => {
|
||||
const [x0, y0] = horiz ? P(a, 0) : P(0, a), [x1, y1] = horiz ? P(b, 0) : P(0, b);
|
||||
const A = horiz ? [x0, at] : [at, y0], B = horiz ? [x1, at] : [at, y1];
|
||||
ctx.beginPath(); ctx.moveTo(...A); ctx.lineTo(...B);
|
||||
[A, B].forEach(([x, y]) => { ctx.moveTo(x - 4, y + 4); ctx.lineTo(x + 4, y - 4); ctx.moveTo(...(horiz ? [x, y - 6] : [x - 6, y])); ctx.lineTo(...(horiz ? [x, y + 6] : [x + 6, y])); });
|
||||
ctx.stroke();
|
||||
ctx.save();
|
||||
ctx.translate((A[0] + B[0]) / 2, (A[1] + B[1]) / 2);
|
||||
if (!horiz) ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText(nl(Math.round(b - a)), 0, -9);
|
||||
ctx.restore();
|
||||
};
|
||||
dim(ux0, ux1, true, P(0, bz0)[1] - 22);
|
||||
dim(uz0, uz1, false, P(bx0, 0)[0] - 22);
|
||||
const nx = W - 28, ny = top + 6;
|
||||
ctx.beginPath(); ctx.moveTo(nx, ny); ctx.lineTo(nx - 7, ny + 18); ctx.lineTo(nx + 7, ny + 18); ctx.closePath(); ctx.fill();
|
||||
ctx.fillText('N', nx, ny + 30);
|
||||
}
|
||||
|
||||
// Keuze doorrekenen; past de indeling niet, dan blijft de vorige keuze staan. Bij een maat (fit) eerst de
|
||||
// dichtstbijzijnde maat zoeken die nog wel past.
|
||||
let toastTimer = null;
|
||||
function toast(msg) {
|
||||
const t = $('.tl-toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('on');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.classList.remove('on'), 5000);
|
||||
}
|
||||
let busy = false;
|
||||
function tryApply(next, fit) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
root.classList.add('is-busy');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
let st = LightModel.build(model, next), parts = compute(st);
|
||||
let msg = LightModel.check(model, st, parts), note = null;
|
||||
if (msg && fit) {
|
||||
rootsOf(parts).forEach(disposeGeo);
|
||||
const v = LightModel.fit(model, next, fit.set, fit.ok, fit.want);
|
||||
if (v !== fit.ok) {
|
||||
fit.set(next, v);
|
||||
st = LightModel.build(model, next);
|
||||
parts = compute(st);
|
||||
note = `${msg} Dichtstbijzijnde maat die past: ${meters(v)}.`;
|
||||
msg = LightModel.check(model, st, parts);
|
||||
} else parts = null;
|
||||
}
|
||||
if (msg) {
|
||||
if (parts) rootsOf(parts).forEach(disposeGeo);
|
||||
toast(msg);
|
||||
renderPanel();
|
||||
} else {
|
||||
ch = next;
|
||||
show(st, parts);
|
||||
if (note) toast(note);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast('Dit kon niet worden doorgerekend.');
|
||||
renderPanel();
|
||||
}
|
||||
busy = false;
|
||||
root.classList.remove('is-busy');
|
||||
}, 30);
|
||||
}
|
||||
|
||||
// Paneel: alleen keuzes, geen vrije invoer.
|
||||
const field = (label, html, extra, cls) => `<div class="tl-field${cls ? ' ' + cls : ''}"><div class="tl-label"><span>${label}</span>${extra ? `<span class="tl-val">${extra}</span>` : ''}</div>${html}</div>`;
|
||||
// disabled(k) geeft false of de reden (tooltip).
|
||||
const seg = (key, options, value, disabled) => `<div class="tl-seg" role="radiogroup">${Object.entries(options).map(([k, label]) => {
|
||||
const why = disabled && disabled(k);
|
||||
return `<button type="button" role="radio" aria-checked="${k === value}" class="${k === value ? 'on' : ''}" data-set="${key}" data-val="${esc(k)}"${why ? ` disabled title="${esc(why)}"` : ''}>${esc(label)}</button>`;
|
||||
}).join('')}</div>`;
|
||||
// Kleurstalen; de naam van de gekozen kleur komt in de labelregel (swatchField).
|
||||
const swatches = (key, items, value, disabled) => {
|
||||
const sel = items.find(it => it.key === value);
|
||||
return `<div class="tl-swatches" role="radiogroup" data-name="${sel ? esc(sel.label) : ''}">${items.map(it => {
|
||||
const why = disabled && disabled(it.key);
|
||||
return `<button type="button" role="radio" aria-checked="${it.key === value}" class="tl-sw${it.key === value ? ' on' : ''}" data-set="${key}" data-val="${esc(it.key)}" title="${esc(why ? `${it.label}: ${why}` : it.label)}" aria-label="${esc(it.label)}" style="--sw:${it.hex}"${why ? ' disabled' : ''}>${it.img ? `<img src="${esc(it.img)}" alt="" loading="lazy">` : ''}</button>`;
|
||||
}).join('')}</div>`;
|
||||
};
|
||||
const swatchField = (label, html) => field(label, html, (html.match(/data-name="([^"]*)"/) || [])[1]);
|
||||
// Het bereik omvat altijd de maat van het model zelf; extra staat achter de waarde (bijv. de helling).
|
||||
const slider = (key, label, value, range, hint, extra) => {
|
||||
const lo = Math.min(range[0], value), hi = Math.max(range[1], value);
|
||||
return `<label class="tl-slider" title="${esc(hint || '')}">
|
||||
<span class="tl-sl-head"><span>${label}</span><output>${meters(value)}${extra || ''}</output></span>
|
||||
<input type="range" data-num="${key}" min="${lo}" max="${hi}" step="${LightModel.STEP}" value="${value}" aria-label="${esc(label.replace(/<[^>]+>/g, ''))}">
|
||||
</label>`;
|
||||
};
|
||||
// Wolfseind: hoogte waarop de kopgevels stoppen (tussen goot en nok); daarboven de schuine wolfseinden.
|
||||
const wolfSlider = (key, f) => {
|
||||
const at = (r) => Math.round((f.low + r * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP;
|
||||
return slider(key, 'Wolfseind', at(f.cutRatio), [at(0.05), at(0.95)], 'Hoogte waarop de kopgevels stoppen; daarboven de schuine wolfseinden tot de nok');
|
||||
};
|
||||
const colorItems = (obj) => Object.entries(obj).map(([key, c]) => ({ key, label: c.label, hex: c.hex }));
|
||||
const facadeColors = (type, current) => {
|
||||
if (type === 'rabat') return colorItems(WallModel.RABAT_COLORS);
|
||||
if (type === 'stuc') return colorItems(WallModel.STUC_COLORS);
|
||||
if (type === 'fels') return colorItems(WallModel.FELS.COLORS);
|
||||
const codes = LightModel.STONES.includes(current) || !current ? LightModel.STONES : [current].concat(LightModel.STONES);
|
||||
return codes.map(code => ({ key: code, label: `Steenstrip ${code}`, hex: WallModel.STONE_COLORS[code] || '#a08070', img: WallModel.stoneThumbUrl(code, 64) }));
|
||||
};
|
||||
const facadeColor = (fc) => ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type];
|
||||
const roofTypes = UnitModel.ROOF_TYPES;
|
||||
|
||||
// Plattegrondje om een unit te kiezen (noord boven).
|
||||
function unitMap() {
|
||||
const rs = cur.parts.rects, pad = 300;
|
||||
const x0 = Math.min(...rs.map(r => r.x0)) - pad, x1 = Math.max(...rs.map(r => r.x1)) + pad;
|
||||
const z0 = Math.min(...rs.map(r => r.z0)) - pad, z1 = Math.max(...rs.map(r => r.z1)) + pad;
|
||||
const fs = Math.min(x1 - x0, z1 - z0) * 0.16;
|
||||
return `<svg class="tl-map" viewBox="${x0} ${z0} ${x1 - x0} ${z1 - z0}" role="group" aria-label="Units, noord boven">${rs.map(r =>
|
||||
`<g class="tl-map-u${r.id === unitSel ? ' on' : ''}" data-set="unit" data-val="${esc(r.id)}" role="button" tabindex="0" aria-label="Unit ${esc(r.id)}">
|
||||
<rect x="${r.x0 + 40}" y="${r.z0 + 40}" width="${r.W - 80}" height="${r.D - 80}" rx="${fs * 0.2}"/>
|
||||
<text x="${r.cx}" y="${r.cz}" font-size="${fs}" dy="0.35em" text-anchor="middle">${esc(r.id)}</text></g>`).join('')}</svg>`;
|
||||
}
|
||||
|
||||
// Accordeon: één sectie open, in de kop een samenvatting van de keuze.
|
||||
let openSec = 'maten';
|
||||
const roofName = (t) => ({ plat: 'Plat dak', lessenaar: 'Lessenaardak', zadel: 'Zadeldak', wolf: 'Wolfsdak' })[t];
|
||||
function renderPanel() {
|
||||
if (!cur) return;
|
||||
const multi = model.base.units.length > 1, houseRoof = !!ch.house;
|
||||
const u = ch.units[unitSel], fr = model.frame.unit(unitSel);
|
||||
const sloped = cur.parts.units.some(x => x.roof.shape.type !== 'plat');
|
||||
const short = UnitModel.LIMITS.short;
|
||||
// Een unit mag maar in één richting langer zijn dan de transportmaat.
|
||||
const range = (r, other) => r && [r[0], other > short ? Math.min(r[1], short) : r[1]];
|
||||
const wR = range(fr.width, u.depth), dR = range(fr.depth, u.width);
|
||||
|
||||
const sizeHtml = (multi ? `<div class="tl-mapbox">${unitMap()}<span class="tl-note">Klik op een unit</span></div>` : '') +
|
||||
(wR ? slider('width', 'Breedte <small>west–oost</small>', u.width, wR) : '') +
|
||||
(dR ? slider('depth', 'Diepte <small>noord–zuid</small>', u.depth, dR) : '');
|
||||
|
||||
// Dak: per unit (of één keer bij een dak over de hele woning) vorm, richting en hoogtes; de bedekking geldt overal.
|
||||
const wolfOk = !houseRoof || HouseModel.houseRoofFits(cur.st);
|
||||
const roofTypes = HouseModel.hasCover(cur.st, houseRoof ? null : unitSel)
|
||||
? Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')) : UnitModel.ROOF_TYPES;
|
||||
const K = houseRoof ? 'house' : 'unit', c = houseRoof ? ch.house : u, f = NUM[K + 'High'].form(ch);
|
||||
const covItems = Object.entries(RoofModel.COVERINGS).map(([key, cv]) => ({ key, label: cv.label, hex: cv.color, img: RoofModel.coveringThumbUrl(key, 64) }));
|
||||
const roofHtml = (multi && !houseRoof ? field('Unit', seg('unit', Object.fromEntries(model.base.units.map(x => [x.id, x.id])), unitSel), null, 'tl-units') : '') +
|
||||
field('Dakvorm', seg(K + 'Type', roofTypes, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) +
|
||||
(c.type === 'lessenaar' ? field('Hoge kant', seg(K + 'Dir', LightModel.SIDES, f.dir)) : '') +
|
||||
(UnitModel.hasRidge(c.type) ? field('Nok', seg(K + 'Ridge', UnitModel.RIDGES, f.ridge)) : '') +
|
||||
slider(K + 'Low', c.type === 'plat' ? 'Hoogte' : 'Goothoogte', c.low, model.frame.height, 'Vanaf de vloer tot onderkant dak, aan de lage kant') +
|
||||
(c.type !== 'plat' ? slider(K + 'High', c.type === 'lessenaar' ? 'Hoge kant' : 'Nokhoogte', f.high,
|
||||
[Math.ceil((f.low + 300) / LightModel.STEP) * LightModel.STEP, UnitModel.LIMITS.ridge], 'Vanaf de vloer tot onderkant dak', ` · ${nl(f.pitch, 0)}°`) : '') +
|
||||
(c.type === 'wolf' ? wolfSlider(K + 'Cut', f) : '') +
|
||||
swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer')) +
|
||||
field('Dakrand', seg('fascia', { aan: 'Boeidelen', uit: 'Daktrim' }, ch.fascia.on ? 'aan' : 'uit')) +
|
||||
(ch.fascia.on ? swatchField('Kleur boeidelen', swatches('fasciaColor',
|
||||
Object.entries(RoofModel.FASCIA_COLORS).map(([key, c]) => ({ key, label: c.label, hex: '#' + c.hex.toString(16).padStart(6, '0') })), ch.fascia.color)) : '');
|
||||
|
||||
const fc = ch.facade;
|
||||
const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) +
|
||||
(fc.type === 'rabat' ? field('Richting', seg('cladding', WallModel.CLADDING_DIRECTIONS, fc.cladding)) : '') +
|
||||
swatchField('Kleur', swatches('facadeColor', facadeColors(fc.type, fc.stone), facadeColor(fc))) +
|
||||
(model.hasAccent ? field('Accent <small>afwijkende gevel van het model</small>', seg('accentType', Object.assign({ geen: 'Geen' }, LightModel.FACADES), ch.accent ? ch.accent.type : 'geen')) +
|
||||
(ch.accent ? swatchField('Kleur accent', swatches('accentColor', facadeColors(ch.accent.type, ch.accent.color), ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : '') : '');
|
||||
|
||||
// Trasraam: strook onderaan de gevel (onder rabat én steen).
|
||||
const tr = ch.trasraam;
|
||||
const trasraamHtml = field('Trasraam <small>stenen strook onderaan de gevel</small>', seg('trasraamType', { geen: 'Geen', steen: 'Steen' }, tr.on ? 'steen' : 'geen')) +
|
||||
(tr.on ? swatchField('Steen trasraam', swatches('trasraamStone', facadeColors('steen', tr.finish), tr.finish)) : '') +
|
||||
(tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : '') +
|
||||
(LightModel.trimUses(ch) ? swatchField(`Zetwerk <small>${LightModel.trimUses(ch)}</small>`,
|
||||
swatches('trim', Object.entries(WallModel.TRIM_COLORS).map(([key, c]) => ({ key, label: c.label, hex: c.hex })), ch.trim)) : '');
|
||||
|
||||
const L = ch.lighting;
|
||||
const lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) +
|
||||
field('Buiten', seg('lightMode', LightModel.LIGHTING, L.mode)) +
|
||||
(L.mode === 'gevel' ? field('Lichtrichting', seg('lightDir', LightingModel.DIRECTIONS, L.direction)) : '');
|
||||
|
||||
const i = cur.info;
|
||||
const types = houseRoof ? [ch.house.type] : [...new Set(Object.values(ch.units).map(x => x.type))];
|
||||
const covLabel = sloped ? RoofModel.COVERINGS[ch.covering].label : 'Dakleer';
|
||||
const facadeSum = `${LightModel.FACADES[fc.type]} · ${LightModel.colorName(fc.type, facadeColor(fc))}${tr.on ? ' · trasraam' : ''}`;
|
||||
const lightSum = [L.indoor ? 'Spots binnen' : null, L.mode === 'geen' ? 'geen buitenlampen' : LightModel.LIGHTING[L.mode].toLowerCase()].filter(Boolean).join(' · ');
|
||||
const sec = (key, title, icon, sum, html) => {
|
||||
const open = openSec === key;
|
||||
return `<section class="tl-sec${open ? ' open' : ''}">
|
||||
<button type="button" class="tl-sec-head" data-sec="${key}" aria-expanded="${open}"><i class="bi bi-${icon}"></i><span class="tl-sec-title">${title}</span>
|
||||
<span class="tl-sec-sum">${esc(sum)}</span><i class="bi bi-chevron-down tl-chev"></i></button>
|
||||
${open ? `<div class="tl-sec-body">${html}</div>` : ''}</section>`;
|
||||
};
|
||||
const el = $('.tl-sections'), scroll = el.scrollTop;
|
||||
el.innerHTML = sec('maten', 'Afmetingen', 'rulers', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`, sizeHtml) +
|
||||
sec('dak', 'Dak', 'house', `${types.length > 1 ? 'Gemengd' : roofName(types[0])} · ${covLabel}`, roofHtml) +
|
||||
sec('gevel', 'Gevel', 'bricks', facadeSum, facadeHtml + trasraamHtml) +
|
||||
sec('licht', 'Verlichting', 'lightbulb', lightSum, lightHtml);
|
||||
el.scrollTop = scroll;
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const i = cur.info;
|
||||
$('.tl-sub').textContent = `${meters(i.lengthX)} × ${meters(i.lengthZ)} · ${nl(i.brutoM2, 1)} m² · ${meters(i.height)} hoog`;
|
||||
const kv = (k, v) => `<div class="tl-kv"><span>${k}</span><b>${v}</b></div>`;
|
||||
$('.tl-facts').innerHTML = kv('Vloer bruto', `${nl(i.brutoM2, 1)} m²`) + kv('Leefbaar', `${nl(i.leefbaarM2, 1)} m²`) +
|
||||
kv('Inhoud', `${nl(i.inhoudM3, 0)} m³`) + kv('Gewicht', `${nl(Math.round(i.kg / 10) * 10, 0)} kg`);
|
||||
$('.tl-buy').innerHTML = `<div class="tl-price">${showPrice ? `<span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span>` : ''}</div>
|
||||
<button type="button" class="tl-btn tl-icon" data-act="reset" title="Alle keuzes terug naar het model" aria-label="Terug naar het model"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||||
${o.save ? `<button type="button" class="tl-btn tl-primary" data-act="save"><i class="bi bi-${esc(o.save.icon || 'send')}"></i>${esc(o.save.label || 'Opslaan')}</button>`
|
||||
: '<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>'}`;
|
||||
}
|
||||
|
||||
// Keuzes (knoppen en kleuren).
|
||||
const panel = $('.tl-panel');
|
||||
function choose(key, val) {
|
||||
if (key === 'unit') { unitSel = val; renderPanel(); return; }
|
||||
const R = (k) => k.startsWith('house') ? next.house : next.units[unitSel];
|
||||
const next = clone(ch), fc = next.facade;
|
||||
// Andere dakvorm: de nok volgt weer uit de helling (of uit het model, bij zijn eigen dakvorm).
|
||||
if (key === 'unitType' || key === 'houseType') Object.assign(R(key), { type: val, high: null });
|
||||
else if (key === 'unitDir' || key === 'houseDir') R(key).dir = val;
|
||||
else if (key === 'unitRidge' || key === 'houseRidge') R(key).ridge = val;
|
||||
else if (key === 'indoor') next.lighting.indoor = val === 'aan';
|
||||
else if (key === 'covering') next.covering = val;
|
||||
else if (key === 'facadeType') fc.type = val;
|
||||
else if (key === 'cladding') fc.cladding = val;
|
||||
else if (key === 'facadeColor') fc[{ rabat: 'rabat', stuc: 'stuc', fels: 'fels', steen: 'stone' }[fc.type]] = val;
|
||||
else if (key === 'accentType') next.accent = val === 'geen' ? null : { type: val, color: val === 'rabat' ? null : facadeColor(Object.assign({}, fc, { type: val })) };
|
||||
else if (key === 'accentColor') { if (next.accent.type === 'rabat') fc.rabat = val; else next.accent.color = val; }
|
||||
else if (key === 'trasraamType') {
|
||||
const t = next.trasraam;
|
||||
t.on = val === 'steen';
|
||||
if (t.on && !t.finish) t.finish = fc.type === 'steen' ? fc.stone : LightModel.STONES[0];
|
||||
} else if (key === 'trasraamStone') next.trasraam.finish = val;
|
||||
else if (key === 'trim') next.trim = val;
|
||||
else if (key === 'fascia') next.fascia.on = val === 'aan';
|
||||
else if (key === 'fasciaColor') next.fascia.color = val;
|
||||
else if (key === 'lightMode') next.lighting.mode = val;
|
||||
else if (key === 'lightDir') next.lighting.direction = val;
|
||||
else return;
|
||||
tryApply(next);
|
||||
}
|
||||
panel.addEventListener('click', (e) => {
|
||||
const h = e.target.closest('[data-sec]');
|
||||
if (h) { openSec = openSec === h.dataset.sec ? null : h.dataset.sec; renderPanel(); return; }
|
||||
const b = e.target.closest('[data-set]');
|
||||
if (b && !b.disabled) { choose(b.dataset.set, b.dataset.val); return; }
|
||||
const a = e.target.closest('[data-act]');
|
||||
if (!a || !cur) return;
|
||||
if (a.dataset.act === 'pdf') savePdf();
|
||||
else if (a.dataset.act === 'save') o.save.onSave(design());
|
||||
else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
|
||||
});
|
||||
panel.addEventListener('keydown', (e) => {
|
||||
const g = e.target.closest('g[data-set]');
|
||||
if (g && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); choose(g.dataset.set, g.dataset.val); }
|
||||
});
|
||||
// Maten: label volgt het schuiven, doorrekenen bij loslaten.
|
||||
const NUM = {
|
||||
width: { get: (c) => c.units[unitSel].width, set: (c, v) => { c.units[unitSel].width = v; } },
|
||||
depth: { get: (c) => c.units[unitSel].depth, set: (c, v) => { c.units[unitSel].depth = v; } },
|
||||
// De goot omhoog of omlaag neemt een gekozen nok mee (zelfde dak, hoger of lager).
|
||||
unitLow: { get: (c) => c.units[unitSel].low, set: (c, v) => lift(c.units[unitSel], v) },
|
||||
houseLow: { get: (c) => c.house.low, set: (c, v) => lift(c.house, v) },
|
||||
trasraamHigh: { get: (c) => c.trasraam.height, set: (c, v) => { c.trasraam.height = v; } }
|
||||
};
|
||||
function lift(x, v) { if (x.high > 0) x.high += v - x.low; x.low = v; }
|
||||
// Wolfseind: de keuze is een verhouding (goot 0, nok 1), de schuif een hoogte.
|
||||
const unitForm = (c) => LightModel.formOf(model, c, unitSel), houseForm = (c) => LightModel.formOf(model, c, null, cur.st);
|
||||
NUM.unitHigh = { form: unitForm, get: (c) => unitForm(c).high, set: (c, v) => { c.units[unitSel].high = v; } };
|
||||
NUM.houseHigh = { form: houseForm, get: (c) => houseForm(c).high, set: (c, v) => { c.house.high = v; } };
|
||||
[['unitCut', (c) => c.units[unitSel], unitForm], ['houseCut', (c) => c.house, houseForm]].forEach(([k, of, form]) => {
|
||||
NUM[k] = {
|
||||
form,
|
||||
get: (c) => { const f = form(c); return Math.round((f.low + f.cutRatio * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP; },
|
||||
set: (c, v) => { const f = form(c); of(c).cut = Math.max(0.05, Math.min(0.95, (v - f.low) / Math.max(1, f.high - f.low))); }
|
||||
};
|
||||
});
|
||||
panel.addEventListener('input', (e) => {
|
||||
const r = e.target.closest('[data-num]');
|
||||
if (r) r.closest('.tl-slider').querySelector('output').textContent = meters(Number(r.value));
|
||||
});
|
||||
panel.addEventListener('change', (e) => {
|
||||
const r = e.target.closest('[data-num]');
|
||||
if (!r) return;
|
||||
const n = NUM[r.dataset.num], v = Number(r.value), old = n.get(ch);
|
||||
if (v === old) return;
|
||||
const next = clone(ch);
|
||||
n.set(next, v);
|
||||
tryApply(next, { set: n.set, ok: old, want: v });
|
||||
});
|
||||
|
||||
// Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie.
|
||||
function snapshot() {
|
||||
const W = 1600, H = 1500, other = view !== '3d', pr = renderer.getPixelRatio();
|
||||
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
|
||||
const url = as3d(() => {
|
||||
if (other && poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setSize(W, H, false);
|
||||
camera.aspect = W / H;
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
return renderer.domElement.toDataURL('image/jpeg', 0.88);
|
||||
});
|
||||
renderer.setPixelRatio(pr);
|
||||
if (other) {
|
||||
camera.position.copy(keep.pos);
|
||||
controls.target.copy(keep.target);
|
||||
controls.update();
|
||||
}
|
||||
resize();
|
||||
return { url, width: W, height: H };
|
||||
}
|
||||
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
|
||||
|
||||
// Het huidige ontwerp voor buiten de tool (save.onSave, el.tectumLight.design()).
|
||||
function design() {
|
||||
const st = clone(cur.st);
|
||||
st.project.naam = model.label;
|
||||
st.project.opmerking = 'Ontwerp uit de light-versie';
|
||||
return {
|
||||
design: Object.assign({ type: 'house-design', version: 1, exportedAt: new Date().toISOString() }, st),
|
||||
label: model.label, key: model.key, info: Object.assign({}, cur.info),
|
||||
choices: LightModel.describe(model, ch, cur.st), url: pageUrl
|
||||
};
|
||||
}
|
||||
|
||||
function savePdf() {
|
||||
const st = clone(cur.st);
|
||||
st.project.naam = model.label;
|
||||
st.project.opmerking = 'Indicatief ontwerp';
|
||||
try {
|
||||
let image = null;
|
||||
try { image = snapshot(); } catch (err) { console.warn(err); }
|
||||
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image, price: showPrice })
|
||||
.save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast('De PDF kon niet worden gemaakt.');
|
||||
}
|
||||
}
|
||||
|
||||
// Prijzen komen uit de CSV's (async geladen): bijwerken zodra ze er zijn.
|
||||
const offCosts = CostModel.onChange(() => { if (cur && cur.parts) { cur.info = LightModel.summary(cur.st, cur.parts); renderSummary(); } });
|
||||
|
||||
let visible = true, raf = 0;
|
||||
function resize() {
|
||||
const w = stage.clientWidth, h = stage.clientHeight;
|
||||
if (!w || !h) return;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(w, h);
|
||||
}
|
||||
const ro = new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); });
|
||||
ro.observe(stage);
|
||||
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
|
||||
const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); });
|
||||
// In een pagina: het scrollwiel scrolt de pagina, tenzij je in de 3D hebt geklikt (tot je de muis eruit haalt) of
|
||||
// Ctrl/⌘ ingedrukt houdt (of schermvullend); dan zoomt het.
|
||||
let engaged = false;
|
||||
const stageEl = $('.tl-stage');
|
||||
stageEl.addEventListener('pointerdown', () => { engaged = true; });
|
||||
stageEl.addEventListener('pointerleave', () => { engaged = false; });
|
||||
stageEl.addEventListener('wheel', (e) => {
|
||||
if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() === stageEl) return;
|
||||
e.stopPropagation();
|
||||
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
|
||||
}, { capture: true });
|
||||
io.observe(stage);
|
||||
|
||||
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
|
||||
// Alles onder het maaiveld (sandwichpanelen, vloerbalken) valt weg: AR zet het laagste punt op de grond.
|
||||
let glb = null;
|
||||
function makeGlb() {
|
||||
if (glb && glb.parts === cur.parts) return glb;
|
||||
if (glb) URL.revokeObjectURL(glb.url);
|
||||
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts).filter(g => g !== cur.parts.sandwichGroup),
|
||||
{ name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts), clipBelow: 0 }));
|
||||
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
|
||||
return glb;
|
||||
}
|
||||
|
||||
// Knop rechtsboven: AR op een telefoon/tablet, schermvullend op de desktop.
|
||||
const xbtn = $('.tl-xbtn');
|
||||
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
|
||||
const canFull = !!(stageEl.requestFullscreen || stageEl.webkitRequestFullscreen);
|
||||
function setXbtn() {
|
||||
const full = fsEl() === stageEl;
|
||||
xbtn.innerHTML = `<i class="bi bi-${AR_DEVICE ? 'badge-ar' : full ? 'fullscreen-exit' : 'fullscreen'}"></i>`;
|
||||
xbtn.title = AR_DEVICE ? 'Bekijk het ontwerp in je eigen tuin (AR)' : full ? 'Schermvullend uit (Esc)' : 'Schermvullend';
|
||||
xbtn.setAttribute('aria-label', xbtn.title);
|
||||
}
|
||||
if (AR_DEVICE || canFull) { xbtn.hidden = false; root.classList.add('has-xbtn'); setXbtn(); }
|
||||
function toggleFull() {
|
||||
const r = fsEl() === stageEl ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
|
||||
: (stageEl.requestFullscreen || stageEl.webkitRequestFullscreen).call(stageEl);
|
||||
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
|
||||
}
|
||||
const onFull = () => { root.classList.toggle('is-full', fsEl() === stageEl); setXbtn(); };
|
||||
document.addEventListener('fullscreenchange', onFull);
|
||||
document.addEventListener('webkitfullscreenchange', onFull);
|
||||
|
||||
// AR: model-viewer in een eigen laag over de hele pagina (buiten de tool: die is een container en dus het
|
||||
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan.
|
||||
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
|
||||
let arView = null, arOpen = false;
|
||||
const AR_NOTE = 'Ware grootte · met twee vingers draaien, met één vinger verplaatsen';
|
||||
function closeAr() {
|
||||
if (!arOpen) return;
|
||||
arOpen = false;
|
||||
arView.classList.remove('on');
|
||||
document.documentElement.classList.remove('tl-noscroll');
|
||||
}
|
||||
const arKey = (e) => { if (arOpen && e.key === 'Escape') closeAr(); };
|
||||
document.addEventListener('keydown', arKey);
|
||||
function showAr(url) {
|
||||
if (!arView) {
|
||||
arView = document.createElement('div');
|
||||
arView.className = 'tl-arview';
|
||||
arView.setAttribute('role', 'dialog');
|
||||
arView.setAttribute('aria-modal', 'true');
|
||||
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
|
||||
ar ar-modes="webxr scene-viewer quick-look" ar-scale="fixed" ar-placement="floor" xr-environment>
|
||||
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
|
||||
</model-viewer>
|
||||
<div class="tl-ar-top"><b class="tl-ar-title"></b><button type="button" class="tl-ar-close" aria-label="Sluiten"><i class="bi bi-x-lg"></i></button></div>
|
||||
<div class="tl-ar-note"></div>`;
|
||||
document.body.appendChild(arView);
|
||||
const mv = arView.querySelector('model-viewer'), note = arView.querySelector('.tl-ar-note');
|
||||
arView.querySelector('.tl-ar-close').addEventListener('click', closeAr);
|
||||
mv.addEventListener('load', () => {
|
||||
note.textContent = mv.canActivateAR ? AR_NOTE : 'AR werkt niet op dit toestel of in deze browser; draaien en zoomen kan wel.';
|
||||
});
|
||||
mv.addEventListener('ar-status', (e) => { if (e.detail.status === 'failed') note.textContent = 'AR kon niet starten op dit toestel.'; });
|
||||
}
|
||||
const mv = arView.querySelector('model-viewer');
|
||||
arView.setAttribute('aria-label', `${model.label} in 3D en AR`);
|
||||
arView.querySelector('.tl-ar-title').textContent = model.label;
|
||||
mv.alt = `3D-model van ${model.label}`;
|
||||
if (mv.getAttribute('src') !== url) { arView.querySelector('.tl-ar-note').textContent = ''; mv.setAttribute('src', url); }
|
||||
arView.classList.add('on');
|
||||
document.documentElement.classList.add('tl-noscroll');
|
||||
arOpen = true;
|
||||
}
|
||||
async function openAr() {
|
||||
if (!cur || busy) return;
|
||||
busy = true;
|
||||
root.classList.add('is-busy');
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 30)); // eerst de spinner tekenen
|
||||
const g = makeGlb();
|
||||
await loadModelViewer(mvSrc);
|
||||
showAr(g.url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast('De AR-weergave kon niet worden geopend.');
|
||||
}
|
||||
busy = false;
|
||||
root.classList.remove('is-busy');
|
||||
}
|
||||
xbtn.addEventListener('click', () => { if (AR_DEVICE) openAr(); else toggleFull(); });
|
||||
|
||||
(function loop() {
|
||||
raf = requestAnimationFrame(loop);
|
||||
if (visible && !arOpen && view !== 'plan') renderer.render(scene, camera);
|
||||
})();
|
||||
|
||||
root.classList.add('is-busy');
|
||||
setTimeout(() => {
|
||||
const st = LightModel.build(model, ch);
|
||||
show(st, compute(st));
|
||||
root.classList.remove('is-busy');
|
||||
}, 30);
|
||||
|
||||
return {
|
||||
unmount() {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect(); io.disconnect(); offCosts(); controls.dispose();
|
||||
document.removeEventListener('keydown', arKey);
|
||||
document.removeEventListener('fullscreenchange', onFull);
|
||||
document.removeEventListener('webkitfullscreenchange', onFull);
|
||||
closeAr();
|
||||
if (arView) arView.remove();
|
||||
if (glb) URL.revokeObjectURL(glb.url);
|
||||
if (group) disposeGeo(group);
|
||||
renderer.dispose();
|
||||
root.innerHTML = '';
|
||||
},
|
||||
getChoices: () => clone(ch),
|
||||
design: () => cur && design(),
|
||||
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
|
||||
glb: () => cur && makeGlb()
|
||||
};
|
||||
}
|
||||
|
||||
function autoMount() {
|
||||
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
|
||||
if (el.dataset.mounted) return;
|
||||
el.dataset.mounted = '1';
|
||||
try { el.tectumLight = mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; }
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount);
|
||||
else autoMount();
|
||||
|
||||
return { mount, autoMount };
|
||||
})();
|
||||
+236
-154
@@ -4,61 +4,26 @@
|
||||
* 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'.
|
||||
//
|
||||
// Binnenspots (computeIndoor) worden PER UNIT berekend, net als de spots in
|
||||
// het boeideel verdeeld tussen min/max h.o.h.: een raster in het plafond
|
||||
// binnen de unit, minstens indoorOffset van de binnenzijde van elke wand (of
|
||||
// van de unitrand waar een naad met een andere unit is), met in beide
|
||||
// richtingen indoorMin..indoorMax h.o.h. Past er in een richting geen
|
||||
// indoorMin tussen, dan één spot in het midden. Spots te dicht bij een
|
||||
// binnenwand vervallen.
|
||||
//
|
||||
// Plain script, geladen na core/geometry.js en roof-model.js.
|
||||
// ============================================================
|
||||
|
||||
window.LightingModel = (() => {
|
||||
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
|
||||
mode: 'spots',
|
||||
direction: 'both',
|
||||
lampHeight: 1950,
|
||||
lampMin: 500,
|
||||
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
|
||||
// Binnenspots in het plafond, per unit (computeIndoor).
|
||||
minWallWidth: 2000,
|
||||
endOffset: 300,
|
||||
openingOffset: 500,
|
||||
indoor: true,
|
||||
indoorMin: 800, // mm h.o.h., in beide richtingen
|
||||
indoorMin: 800,
|
||||
indoorMax: 1500,
|
||||
indoorOffset: 600 // min. afstand tot wand (binnenzijde) of unitrand (naad)
|
||||
indoorOffset: 600
|
||||
};
|
||||
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: {} };
|
||||
}
|
||||
@@ -67,7 +32,6 @@ window.LightingModel = (() => {
|
||||
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 });
|
||||
@@ -75,16 +39,16 @@ window.LightingModel = (() => {
|
||||
calculateSegmentPositions(len, min, max).forEach(p => out.push({ x: A.x + (B.x - A.x) * p / len, z: A.z + (B.z - A.z) * p / len }));
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Te dicht bij elkaar: samen naar het midden, tenzij daar een opening zit (dan vervalt de tweede).
|
||||
function enforceMinSpacing(positions, min, blockedAt) {
|
||||
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);
|
||||
const mid = (pts[i] + pts[i + 1]) / 2;
|
||||
pts.splice(i, 2, blockedAt && blockedAt(mid) ? pts[i] : mid);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
@@ -93,7 +57,27 @@ window.LightingModel = (() => {
|
||||
return pts;
|
||||
}
|
||||
|
||||
// Aftrekken van intervallen: [lo, hi] min blocked -> vrije intervallen.
|
||||
// Naast openingen: op openingOffset van elke opening; is de tussenruimte daarvoor te klein, dan één lamp in
|
||||
// het midden ervan (of geen, als die tegen de kozijnen zou hangen). Posities in u (mm vanaf de linkerkant).
|
||||
function besideOpenings(cfg, s) {
|
||||
const W = cfg.wallWidth, off = s.openingOffset, clear = cfg.studWidth + 150;
|
||||
const ops = cfg.openings.map(op => [op.x, op.x + op.w]).sort((p, q) => p[0] - q[0]);
|
||||
const out = [];
|
||||
ops.forEach(([x0, x1], i) => {
|
||||
const next = ops[i + 1];
|
||||
if (i === 0) { const u = Math.max(x0 - off, s.endOffset); if (x0 - u >= clear) out.push(u); }
|
||||
if (next) {
|
||||
const gap = next[0] - x1;
|
||||
if (gap >= 2 * off + 1000) out.push(x1 + off, next[0] - off);
|
||||
else if (gap >= 2 * clear) out.push((x1 + next[0]) / 2);
|
||||
} else {
|
||||
const u = Math.min(x1 + off, W - s.endOffset);
|
||||
if (u - x1 >= clear) out.push(u);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function freeIntervals(lo, hi, blocked) {
|
||||
const free = [];
|
||||
let cursor = lo;
|
||||
@@ -105,40 +89,26 @@ window.LightingModel = (() => {
|
||||
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 = [];
|
||||
if (p.baseY) return; // opbouw boven een lager dak: geen gevellampen
|
||||
const W = p.place.width;
|
||||
// Lampen hangen op de afgewerkte gevel: wandvlak + kruislat + bekleding.
|
||||
const T = p.place.thickness + 2 * WallModel.claddingOffset(p.buitenMateriaal, p.stone);
|
||||
const T = p.place.thickness + 2 * WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
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';
|
||||
// Binnenhoek-doorloop (negatieve skinExt): de gevel begint pas daarna.
|
||||
const tL = Math.max(0, -((p.skinExt || {}).left || 0)), tR = Math.max(0, -((p.skinExt || {}).right || 0));
|
||||
const q0 = UnitModel.localToWorld(p.place, -W / 2 + tL, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2 - tR, T / 2);
|
||||
// Gevel loopt tot de afgewerkte hoek, zodat endOffset altijd vanaf de
|
||||
// zichtbare hoek telt: elk uiteinde + afwerking (latwerk + bekleding van
|
||||
// de haakse wand die eroverheen loopt), en waar dit stuk de kopse kant
|
||||
// van een doorlopende wand bedekt (skinExt) ook nog die wanddikte.
|
||||
// Binnenhoeken en naden: zie de voetafdrukken in 3 en de overlap in 2.
|
||||
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone), e = p.skinExt || {};
|
||||
// Gevel loopt tot de afgewerkte hoek, zodat endOffset vanaf de zichtbare hoek telt.
|
||||
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), e = p.skinExt || {};
|
||||
const extL = (e.left > 0 ? e.left : 0) + c, extR = (e.right > 0 ? e.right : 0) + c;
|
||||
const up = q1[axis] >= q0[axis];
|
||||
// Voetafdruk van dit stuk incl. afwerking (om bedekte delen van andere
|
||||
// gevels af te trekken, zie 3).
|
||||
const corners = [[-W / 2, -p.place.thickness / 2], [W / 2, -p.place.thickness / 2], [-W / 2, T / 2], [W / 2, T / 2]]
|
||||
.map(([lx, lz]) => UnitModel.localToWorld(p.place, lx, lz));
|
||||
segs.push({
|
||||
@@ -152,10 +122,8 @@ window.LightingModel = (() => {
|
||||
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
|
||||
const runs = groupFacadeRuns(segs);
|
||||
|
||||
// 3. Lampen per gevel.
|
||||
const lamps = [];
|
||||
const H = s.lampHeight;
|
||||
const addLamp = (run, v) => {
|
||||
@@ -168,12 +136,9 @@ window.LightingModel = (() => {
|
||||
};
|
||||
if (s.mode === 'gevel') {
|
||||
runs.forEach(run => {
|
||||
const blocked = [];
|
||||
const pos = []; // lampposities langs deze gevel (wereld-mm)
|
||||
// Delen van deze gevel die achter een andere wand liggen (bijv. het
|
||||
// stuk van een wand dat de naad in loopt om een binnenhoek te sluiten)
|
||||
// zijn geen zichtbare gevel: de gevel begint pas bij de afgewerkte
|
||||
// buitenkant van die andere wand.
|
||||
const blocked = [], openings = [];
|
||||
const pos = [];
|
||||
// Delen achter een andere wand (bijv. een stuk dat de naad in loopt) zijn geen zichtbare gevel.
|
||||
segs.filter(o => !run.segs.includes(o)).forEach(o => {
|
||||
const f = o.foot;
|
||||
const pMin = run.axis === 'x' ? f.z0 : f.x0, pMax = run.axis === 'x' ? f.z1 : f.x1;
|
||||
@@ -184,48 +149,46 @@ window.LightingModel = (() => {
|
||||
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; }
|
||||
const own = [];
|
||||
cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + sW >= H).forEach(op => {
|
||||
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
|
||||
own.push([Math.min(a, b), Math.max(a, b)]);
|
||||
});
|
||||
openings.push(...own);
|
||||
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))));
|
||||
besideOpenings(cfg, s).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)]);
|
||||
});
|
||||
blocked.push(...own);
|
||||
});
|
||||
// Automatisch: per vrij wandstuk gelijk verdeeld, midden in gelijke delen van ca. lampMax (één lamp
|
||||
// midden tussen twee ramen), niet tegen de kozijnen of de hoek aan.
|
||||
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);
|
||||
if (b - a < s.minWallWidth) return;
|
||||
const k = Math.max(1, Math.round((b - a) / s.lampMax));
|
||||
for (let i = 0; i < k; i++) pos.push(a + (b - a) * (i + 0.5) / k);
|
||||
});
|
||||
// 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));
|
||||
// Min. h.o.h. geldt over alle lampen van de gevel samen (ook overrides en de grens tussen wanden).
|
||||
const inOpening = (v) => openings.some(([a, b]) => v > a && v < b);
|
||||
enforceMinSpacing(pos, s.lampMin, inOpening).filter(v => !inOpening(v)).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 && !sg.overRoof).forEach(sg => { // niet boven een lager dak
|
||||
edge.segments.filter(sg => sg.fascia && !sg.overRoof).forEach(sg => {
|
||||
const from = spots.length;
|
||||
spotsBetween(sg.spotA, sg.spotB, rs.spotMin, rs.spotMax, spots);
|
||||
for (let k = from; k < spots.length; k++) {
|
||||
spots[k].normal = { x: sg.normal.x, z: sg.normal.z };
|
||||
spots[k].y = sg.topAt(spots[k]) - edge.fasciaHeight; // onderkant boeideel (volgt de dakvorm)
|
||||
spots[k].y = sg.topAt(spots[k]) - edge.fasciaHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Dubbele spots (hoeken) samenvoegen; de buitenrichting wordt dan de
|
||||
// som van beide gevels (diagonaal naar buiten).
|
||||
edge.spots = [];
|
||||
spots.forEach(p => {
|
||||
const q = edge.spots.find(e => Math.hypot(e.x - p.x, e.z - p.z) < 5);
|
||||
@@ -236,44 +199,41 @@ window.LightingModel = (() => {
|
||||
return { lamps };
|
||||
}
|
||||
|
||||
// Posities op [a, b]: beide uiteinden + verdeeld (calculateSegmentPositions);
|
||||
// korter dan min (of negatief) = één positie in het midden.
|
||||
function gridPositions(a, b, min, max) {
|
||||
const L = b - a;
|
||||
if (L < min) return [(a + b) / 2];
|
||||
return [a, ...calculateSegmentPositions(L, min, max).map(p => a + p), b];
|
||||
}
|
||||
|
||||
// Afstand van punt p tot lijnstuk a-b (mm).
|
||||
function distToSegment(p, a, b) {
|
||||
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1;
|
||||
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.z - a.z) * dz) / len2));
|
||||
return Math.hypot(p.x - (a.x + t * dx), p.z - (a.z + t * dz));
|
||||
}
|
||||
|
||||
// Binnenspots per unit. units = HouseModel-parts.units (rect, walls, roof.ceilingAt),
|
||||
// interior = parts.interior (binnenwanden met place). Geeft
|
||||
// [{ unitId, x, z, y, floorY }] (mm, y = onderkant plafond, floorY = loopvlak).
|
||||
function unitInnerRect(u, interior) {
|
||||
const T = {};
|
||||
u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; });
|
||||
const r = u.rect;
|
||||
interior.filter(iw => iw.seamUnit === u.id).forEach(iw => {
|
||||
const p = iw.place, horiz = Math.abs(Math.sin(p.rotY)) < 0.5;
|
||||
const [side, d] = horiz
|
||||
? (Math.abs(p.cz - r.z0) < Math.abs(p.cz - r.z1) ? ['voor', p.cz - r.z0] : ['achter', r.z1 - p.cz])
|
||||
: (Math.abs(p.cx - r.x0) < Math.abs(p.cx - r.x1) ? ['links', p.cx - r.x0] : ['rechts', r.x1 - p.cx]);
|
||||
T[side] = Math.max(T[side] || 0, d + p.thickness / 2);
|
||||
});
|
||||
return { x0: r.x0 + (T.links || 0), x1: r.x1 - (T.rechts || 0), z0: r.z0 + (T.voor || 0), z1: r.z1 - (T.achter || 0) };
|
||||
}
|
||||
|
||||
function computeIndoor(settings, units, interior) {
|
||||
const s = normalizeSettings(settings);
|
||||
if (!s.indoor) return [];
|
||||
const out = [];
|
||||
units.forEach(u => {
|
||||
const T = {};
|
||||
u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; });
|
||||
const r = u.rect, off = s.indoorOffset;
|
||||
// Naadwanddelen van deze unit staan binnen de rect tegen de naad: het
|
||||
// raster begint dan vanaf hun binnenzijde (net als bij een buitenwand).
|
||||
const off = s.indoorOffset, inner = unitInnerRect(u, interior);
|
||||
const own = interior.filter(iw => !iw.seamUnit || iw.seamUnit === u.id);
|
||||
own.filter(iw => iw.seamUnit).forEach(iw => {
|
||||
const p = iw.place, horiz = Math.abs(Math.sin(p.rotY)) < 0.5;
|
||||
const [side, d] = horiz
|
||||
? (Math.abs(p.cz - r.z0) < Math.abs(p.cz - r.z1) ? ['voor', p.cz - r.z0] : ['achter', r.z1 - p.cz])
|
||||
: (Math.abs(p.cx - r.x0) < Math.abs(p.cx - r.x1) ? ['links', p.cx - r.x0] : ['rechts', r.x1 - p.cx]);
|
||||
T[side] = Math.max(T[side] || 0, d + p.thickness / 2);
|
||||
});
|
||||
const xs = gridPositions(r.x0 + (T.links || 0) + off, r.x1 - (T.rechts || 0) - off, s.indoorMin, s.indoorMax);
|
||||
const zs = gridPositions(r.z0 + (T.voor || 0) + off, r.z1 - (T.achter || 0) - off, s.indoorMin, s.indoorMax);
|
||||
const xs = gridPositions(inner.x0 + off, inner.x1 - off, s.indoorMin, s.indoorMax);
|
||||
const zs = gridPositions(inner.z0 + off, inner.z1 - off, s.indoorMin, s.indoorMax);
|
||||
xs.forEach(x => zs.forEach(z => {
|
||||
const tooClose = own.some(iw => {
|
||||
const half = iw.place.width / 2, c = Math.cos(iw.place.rotY), sn = Math.sin(iw.place.rotY);
|
||||
@@ -286,6 +246,36 @@ window.LightingModel = (() => {
|
||||
return out;
|
||||
}
|
||||
|
||||
const ROOM_SPOT_MIN_M2 = 0.6;
|
||||
function roomSpots(settings, indoor, rooms, units) {
|
||||
const s = normalizeSettings(settings);
|
||||
if (!s.indoor || !rooms) return indoor;
|
||||
rooms.rooms.concat(rooms.free).forEach(r => {
|
||||
if (r.areaM2 < ROOM_SPOT_MIN_M2) return;
|
||||
if (indoor.some(p => { const g = rooms.regionAt(p.x, p.z); return g && g.idx === r.key; })) return;
|
||||
const c = r.label, u = units.find(q => c.x >= q.rect.x0 && c.x <= q.rect.x1 && c.z >= q.rect.z0 && c.z <= q.rect.z1);
|
||||
if (!u) return;
|
||||
indoor.push({ unitId: u.id, x: c.x, z: c.z, y: u.roof.ceilingAt(c.x, c.z), floorY: u.layout.floorTop, room: true });
|
||||
});
|
||||
return indoor;
|
||||
}
|
||||
|
||||
let offMaterial = null;
|
||||
function applySwitches(group, isOff) {
|
||||
if (!group) return;
|
||||
offMaterial = offMaterial || new THREE.MeshStandardMaterial({ color: 0xcfcbc0, roughness: 0.5 });
|
||||
group.traverse(o => {
|
||||
const ref = o.userData.ref;
|
||||
if (!ref) return;
|
||||
const off = !!isOff(ref);
|
||||
if (o.userData.glow) {
|
||||
if (!o.userData.onMat) o.userData.onMat = o.material;
|
||||
o.material = off ? offMaterial : o.userData.onMat;
|
||||
} else if (o.isLight || o.userData.wash) o.visible = !off;
|
||||
else if (o.userData.beam) { if (off) o.visible = false; }
|
||||
});
|
||||
}
|
||||
|
||||
function createMaterials() {
|
||||
return {
|
||||
housingMaterial: new THREE.MeshStandardMaterial({ color: 0x2b2f33, roughness: 0.5, metalness: 0.3 }),
|
||||
@@ -295,8 +285,6 @@ window.LightingModel = (() => {
|
||||
};
|
||||
}
|
||||
|
||||
// 3D: behuizing op het gevelvlak + lichtbundel(s) omhoog/omlaag, en de
|
||||
// binnenspots (indoor, zie computeIndoor) als schijfje in het plafond.
|
||||
function build3D(lamps, materials, indoor) {
|
||||
const toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
@@ -310,10 +298,11 @@ window.LightingModel = (() => {
|
||||
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);
|
||||
g.userData = { ref: l, glow: true };
|
||||
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.userData = { beam: true, ref: l };
|
||||
if (sign > 0) cone.rotation.x = Math.PI;
|
||||
cone.position.set(cx, cy + sign * (h / 2 + beamH / 2), cz);
|
||||
group.add(cone);
|
||||
};
|
||||
@@ -323,59 +312,152 @@ window.LightingModel = (() => {
|
||||
(indoor || []).forEach(p => {
|
||||
const spot = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.01, 16), materials.indoorMaterial);
|
||||
spot.position.set(p.x * toM, p.y * toM - 0.006, p.z * toM);
|
||||
spot.userData = { ref: p, glow: true };
|
||||
group.add(spot);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
// Render: elke gevellamp (per lichtrichting), boeideel-spot en binnenspot
|
||||
// wordt een echte SpotLight. Echte schaduw per lamp is te zwaar (één
|
||||
// schaduwkaart per lamp; de browser kan er maar ~16), dus licht door muren
|
||||
// wordt geometrisch voorkomen:
|
||||
// - buitenlampen en boeideel-spots zijn half hun bundelhoek naar buiten
|
||||
// gekanteld: de binnenrand van de bundel loopt verticaal langs de gevel;
|
||||
// - binnenspots hebben een harde reikwijdte net voorbij de vloer
|
||||
// (INDOOR_REACH), zodat ze niet tot de grond buiten reiken.
|
||||
// spots = [{ x, z, y }] (mm, boeideel; y = onderkant boeideel, anders spotY),
|
||||
// indoor = [{ x, z, y, floorY }] (mm). o.outdoorFactor schaalt de buiten-
|
||||
// lampen (overdag lager: dan vallen ze nauwelijks op).
|
||||
// Zacht en breed i.p.v. fel en smal: lagere intensiteit, grotere bundel
|
||||
// en een brede zachte rand (penumbra), zodat naast elkaar liggende spots
|
||||
// niet optellen tot felle vlekken.
|
||||
// Geen schaduw per lamp (één schaduwkaart per lamp, de browser kan er ~16), dus licht door muren
|
||||
// geometrisch voorkomen: buitenlampen en boeideel-spots half hun bundelhoek naar buiten gekanteld,
|
||||
// binnenspots met een harde reikwijdte net voorbij de vloer (INDOOR_REACH).
|
||||
const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
|
||||
const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 };
|
||||
const DAY_OUTDOOR_FACTOR = 0.35;
|
||||
function buildLights3D(lamps, spots, spotY, indoor, o) {
|
||||
const toM = 0.001;
|
||||
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
|
||||
const group = new THREE.Group();
|
||||
// Spot op (x, y, z) die naar (x + tx, y + ty, z + tz) schijnt.
|
||||
const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra) => {
|
||||
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay);
|
||||
light.position.set(x, y, z);
|
||||
light.target.position.set(x + tx, y + ty, z + tz);
|
||||
group.add(light);
|
||||
group.add(light.target);
|
||||
// Lichtbronnen in meters: positie, richting (genormaliseerd), halve kegelhoek; gedeeld door 3D en render-export.
|
||||
function lightSources(lamps, spots, spotY, indoor) {
|
||||
const toM = 0.001, out = [];
|
||||
const add = (kind, x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
|
||||
const len = Math.hypot(tx, ty, tz) || 1;
|
||||
out.push({ kind, x, y, z, dir: { x: tx / len, y: ty / len, z: tz / len }, intensity, angle, distance, decay, penumbra, ref });
|
||||
};
|
||||
const tilt = Math.tan(OUTDOOR.angle); // bundelas half de bundelhoek naar buiten
|
||||
const tilt = Math.tan(OUTDOOR.angle);
|
||||
lamps.forEach(l => {
|
||||
const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM;
|
||||
const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
|
||||
if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra);
|
||||
if (l.direction !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra);
|
||||
if (l.direction !== 'up') add('wandlamp', x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
||||
if (l.direction !== 'down') add('wandlamp', x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
||||
});
|
||||
// Boeideel-spots: naar buiten gekanteld (normaal van de dakrand; op een
|
||||
// hoek diagonaal).
|
||||
(spots || []).forEach(p => {
|
||||
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
|
||||
add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra);
|
||||
add('dakrandspot', p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra, p);
|
||||
});
|
||||
(indoor || []).forEach(p => {
|
||||
const drop = (p.y - p.floorY) * toM; // plafond -> loopvlak
|
||||
add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra);
|
||||
const drop = (p.y - p.floorY) * toM;
|
||||
add('binnenspot', p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra, p);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildLights3D(lamps, spots, spotY, indoor, o) {
|
||||
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
|
||||
const group = new THREE.Group();
|
||||
lightSources(lamps, spots, spotY, indoor).forEach(s => {
|
||||
const light = new THREE.SpotLight(0xffd9a0, s.kind === 'binnenspot' ? s.intensity : s.intensity * fo, s.distance, s.angle, s.penumbra, s.decay);
|
||||
light.userData.ref = s.ref;
|
||||
light.position.set(s.x, s.y, s.z);
|
||||
light.target.position.set(s.x + s.dir.x, s.y + s.dir.y, s.z + s.dir.z);
|
||||
group.add(light);
|
||||
group.add(light.target);
|
||||
});
|
||||
if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo));
|
||||
return group;
|
||||
}
|
||||
|
||||
// Nep-lichtvlekken op de gevel: echte spots zonder schaduw zouden door de muur naar binnen schijnen.
|
||||
const WASH = { color: 0xffd9a0, spot: 0.55, lamp: 0.45, spread: 0.5, lampReach: 1800 };
|
||||
let washTexture = null;
|
||||
function getWashTexture() {
|
||||
if (washTexture) return washTexture;
|
||||
const W = 128, H = 256;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W; canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = ctx.createImageData(W, H);
|
||||
for (let j = 0; j < H; j++) {
|
||||
const t = j / (H - 1);
|
||||
const half = 0.12 + 0.88 * Math.pow(t, 0.6);
|
||||
const fade = Math.min(1, t * 12) * Math.pow(1 - t, 1.6);
|
||||
for (let i = 0; i < W; i++) {
|
||||
const x = Math.abs((i + 0.5) / W * 2 - 1) / half;
|
||||
const v = x >= 1 ? 0 : fade * Math.pow(1 - x * x, 2);
|
||||
const k = (j * W + i) * 4;
|
||||
img.data[k] = img.data[k + 1] = img.data[k + 2] = Math.round(255 * v);
|
||||
img.data[k + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
washTexture = new THREE.CanvasTexture(canvas);
|
||||
return washTexture;
|
||||
}
|
||||
function washMesh(widthM, heightM, opacity, up) {
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: getWashTexture(), color: WASH.color, transparent: true, opacity,
|
||||
blending: THREE.AdditiveBlending, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2
|
||||
});
|
||||
const geo = new THREE.PlaneGeometry(widthM, heightM);
|
||||
geo.translate(0, up ? heightM / 2 : -heightM / 2, 0);
|
||||
if (up) {
|
||||
const uv = geo.attributes.uv;
|
||||
for (let i = 0; i < uv.count; i++) uv.setY(i, 1 - uv.getY(i));
|
||||
}
|
||||
const m = new THREE.Mesh(geo, mat);
|
||||
m.renderOrder = 2;
|
||||
return m;
|
||||
}
|
||||
function buildWash3D(lamps, spots, spotY, walls, groundY, fo) {
|
||||
const toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
const facadeBehind = (q, n) => {
|
||||
let best = null;
|
||||
walls.forEach(p => {
|
||||
const pl = p.place;
|
||||
if (pl.normal.x * n.x + pl.normal.y * n.z < 0.9) return;
|
||||
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
|
||||
const dx = q.x - pl.cx, dz = q.z - pl.cz;
|
||||
const lx = dx * c - dz * sn, lz = dx * sn + dz * c;
|
||||
const face = pl.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
||||
const d = lz - face;
|
||||
if (Math.abs(lx) > pl.width / 2 + 50 || d < -5 || d > 1500) return;
|
||||
if (!best || d < best.d) best = { d, piece: p, lx, face };
|
||||
});
|
||||
return best;
|
||||
};
|
||||
const place = (mesh, pl, lx, lz, yMM, ref) => {
|
||||
mesh.userData.ref = ref;
|
||||
mesh.userData.wash = true;
|
||||
const w = UnitModel.localToWorld(pl, lx, lz);
|
||||
mesh.position.set(w.x * toM, yMM * toM, w.z * toM);
|
||||
mesh.rotation.y = pl.rotY;
|
||||
group.add(mesh);
|
||||
};
|
||||
(spots || []).forEach(sp => {
|
||||
const n = sp.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
|
||||
const nn = { x: n.x / len, z: n.z / len };
|
||||
if (Math.abs(nn.x) > 0.1 && Math.abs(nn.z) > 0.1) return;
|
||||
const top = (sp.y !== undefined ? sp.y : spotY) - 30;
|
||||
const hit = facadeBehind(sp, nn);
|
||||
if (!hit || top - groundY < 300) return;
|
||||
const H = (top - groundY) * toM;
|
||||
place(washMesh(2 * (0.1 + H * WASH.spread), H, WASH.spot * fo), hit.piece.place, hit.lx, hit.face + 6, top, sp);
|
||||
});
|
||||
(lamps || []).forEach(l => {
|
||||
const n = { x: l.normal.x, z: l.normal.z };
|
||||
const hit = facadeBehind({ x: l.x + n.x * 20, z: l.z + n.z * 20 }, n);
|
||||
if (!hit) return;
|
||||
const pl = hit.piece.place;
|
||||
if (l.direction !== 'up') {
|
||||
const H = Math.min(WASH.lampReach, l.y - 90 - groundY) * toM;
|
||||
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo), pl, hit.lx, hit.face + 6, l.y - 90, l);
|
||||
}
|
||||
if (l.direction !== 'down') {
|
||||
const wallTop = pl.floorTop + WallModel.topLine(hit.piece.config).maxY + (hit.piece.skinAbove || 0);
|
||||
const H = Math.min(WASH.lampReach, wallTop - l.y - 90) * toM;
|
||||
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo, true), pl, hit.lx, hit.face + 6, l.y + 90, l);
|
||||
}
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, createMaterials, build3D, buildLights3D };
|
||||
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, unitInnerRect, freeIntervals, createMaterials, build3D, lightSources, buildLights3D };
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Palen onder een overkapping: steekt een dak minstens minExt buiten de vloeren uit (veranda), dan komen er
|
||||
// palen onder de dakrand. Hoekpalen staan met hun buitenkant gelijk met de dakhoek; een eind dat op een gevel
|
||||
// uitkomt rust op die wand. Daartussen zo weinig palen dat de h.o.h.-afstand onder maxSpan blijft.
|
||||
// sideWall: een vrije hoek krijgt geen paal maar een dichte zijwand (gestuukt) van de dakhoek terug tot de gevel,
|
||||
// met de buitenkant gelijk met de zijkant van het dak. Ligt die buitenkant in het vlak van een gevel, dan loopt de
|
||||
// bekleding van die gevel erover door (outsideOf, bijv. felsplaten) en staat de wand gelijk met de bekleding.
|
||||
|
||||
window.PostModel = (() => {
|
||||
const EPS = 0.5;
|
||||
const SHOE_MM = 100; // verzinkte paalvoet tussen maaiveld en paal
|
||||
const WOODS = {
|
||||
eiken: { label: 'Eiken', hex: 0xa07c54, kgM3: 750 },
|
||||
douglas: { label: 'Douglas', hex: 0xb98a5e, kgM3: 530 }
|
||||
};
|
||||
const DEFAULT_SETTINGS = { enabled: true, size: 200, wood: 'eiken', maxSpan: 3600, minExt: 1000, sideWall: false, sideColor: 'wit' };
|
||||
const SIDE_MM = 200; // dikte zijwand (regelwerk, plaat en stuc aan beide kanten)
|
||||
const SIDE_TOP_BELOW_MM = 40; // doorlopende gevelbekleding: tot zo ver onder de bovenkant van het dak
|
||||
|
||||
function normalizeSettings(settings) {
|
||||
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
||||
s.enabled = s.enabled !== false;
|
||||
s.sideWall = !!s.sideWall;
|
||||
if (!WOODS[s.wood]) s.wood = DEFAULT_SETTINGS.wood;
|
||||
if (!WallModel.isStucColor(s.sideColor)) s.sideColor = DEFAULT_SETTINGS.sideColor;
|
||||
const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def);
|
||||
s.size = num(s.size, DEFAULT_SETTINGS.size, 100, 400);
|
||||
s.maxSpan = num(s.maxSpan, DEFAULT_SETTINGS.maxSpan, 1000, 8000);
|
||||
s.minExt = num(s.minExt, DEFAULT_SETTINGS.minExt, 400, 5000);
|
||||
return s;
|
||||
}
|
||||
|
||||
// units: [{ rect (vloer), roof: { rect, ceilingAt } }] in wereld-mm; groundY = maaiveld (absoluut).
|
||||
// outsideOf(wall, out): { finish, kind, depth } van de gevel in het vlak van de buitenkant, of null.
|
||||
function compute(settings, units, groundY, outsideOf) {
|
||||
const s = normalizeSettings(settings);
|
||||
const out = { posts: [], count: 0, size: s.size, wood: s.wood, lengthMM: 0, m3: 0, kg: 0, walls: [], wallM2: 0, sideColor: s.sideColor };
|
||||
if (!s.enabled || !units.length) return out;
|
||||
const floors = units.map(u => u.rect);
|
||||
const onFloor = (x, z, m) => floors.some(f => x > f.x0 - m && x < f.x1 + m && z > f.z0 - m && z < f.z1 + m);
|
||||
const underRoof = (x, z) => units.some(u => { const r = u.roof.rect; return x > r.x0 && x < r.x1 && z > r.z0 && z < r.z1; });
|
||||
|
||||
// Vrije stukken dakrand: buitenrand van de daken samen, recht tegenover een gevel op minstens minExt (loodrecht
|
||||
// op de rand gemeten); de zijkanten van een veranda krijgen alleen de hoekpalen. In een binnenhoek (veranda
|
||||
// tussen twee units) loopt het stuk door tot de andere gevel en rust daar op.
|
||||
const runs = [];
|
||||
units.forEach(u => {
|
||||
const R = u.roof.rect;
|
||||
[
|
||||
{ axis: 'z', at: R.x1, n: 1, s0: R.z0, s1: R.z1 }, { axis: 'z', at: R.x0, n: -1, s0: R.z0, s1: R.z1 },
|
||||
{ axis: 'x', at: R.z1, n: 1, s0: R.x0, s1: R.x1 }, { axis: 'x', at: R.z0, n: -1, s0: R.x0, s1: R.x1 }
|
||||
].forEach(e => {
|
||||
const pt = (t, d) => (e.axis === 'z' ? { x: e.at - e.n * d, z: t } : { x: t, z: e.at - e.n * d });
|
||||
const facing = (p) => Math.min(...floors.map(f => {
|
||||
const [lo, hi, c0, c1, v, c] = e.axis === 'z' ? [f.z0, f.z1, f.x0, f.x1, p.z, p.x] : [f.x0, f.x1, f.z0, f.z1, p.x, p.z];
|
||||
if (!(v > lo + EPS && v < hi - EPS)) return Infinity;
|
||||
const d = e.n > 0 ? c - c1 : c0 - c;
|
||||
return d > -EPS ? d : Infinity;
|
||||
}));
|
||||
const free = (t) => {
|
||||
const p = pt(t, 0), o = pt(t, -1), d = facing(p);
|
||||
return !underRoof(o.x, o.z) && d >= s.minExt - EPS && d < Infinity;
|
||||
};
|
||||
const cuts = [e.s0, e.s1].concat(floors.flatMap(f => (e.axis === 'z' ? [f.z0, f.z1] : [f.x0, f.x1]))
|
||||
.filter(t => t > e.s0 + EPS && t < e.s1 - EPS)).sort((a, b) => a - b);
|
||||
for (let i = 0; i < cuts.length - 1; i++) {
|
||||
const a = cuts[i], b = cuts[i + 1];
|
||||
if (b - a < EPS || !free((a + b) / 2)) continue;
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.axis === e.axis && last.n === e.n && Math.abs(last.at - e.at) < EPS && Math.abs(last.b - a) < EPS) last.b = b;
|
||||
else runs.push({ axis: e.axis, at: e.at, n: e.n, a, b });
|
||||
}
|
||||
});
|
||||
});
|
||||
// Aansluitende stukken van verschillende units (bijv. twee units met dezelfde veranda) samenvoegen.
|
||||
runs.sort((p, q) => p.axis.localeCompare(q.axis) || p.n - q.n || Math.round(p.at) - Math.round(q.at) || p.a - q.a);
|
||||
const merged = [];
|
||||
runs.forEach(r => {
|
||||
const m = merged.find(o => o.axis === r.axis && o.n === r.n && Math.abs(o.at - r.at) < EPS && Math.abs(o.b - r.a) < EPS);
|
||||
if (m) m.b = r.b; else merged.push(Object.assign({}, r));
|
||||
});
|
||||
|
||||
const half = s.size / 2;
|
||||
const pts = [];
|
||||
const roofAt = (x, z) => units.map(u => u.roof).find(rf => x > rf.rect.x0 - EPS && x < rf.rect.x1 + EPS && z > rf.rect.z0 - EPS && z < rf.rect.z1 + EPS);
|
||||
const endFreeOf = (r, t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); };
|
||||
// Zijwand op het vrije eind t van run r (dir +1: wand van t de run in, -1: andersom), terug tot de gevel waar de
|
||||
// run naar kijkt. Delen twee runs een vrije hoek (veranda in een binnenhoek), dan is de run met de kortste weg
|
||||
// naar de gevel de voorkant en komt de wand langs de zijkant; het andere eind rust op die wand.
|
||||
const sideCands = [];
|
||||
if (s.sideWall) merged.forEach(r => [[r.a, 1], [r.b, -1]].forEach(([t, dir]) => {
|
||||
if (!endFreeOf(r, t)) return;
|
||||
const lo = Math.min(t, t + dir * SIDE_MM), hi = Math.max(t, t + dir * SIDE_MM);
|
||||
let reach = Infinity;
|
||||
floors.forEach(f => {
|
||||
const [s0, s1, c0, c1] = r.axis === 'z' ? [f.z0, f.z1, f.x0, f.x1] : [f.x0, f.x1, f.z0, f.z1];
|
||||
if (s0 > hi - EPS || s1 < lo + EPS) return;
|
||||
const d = r.n > 0 ? r.at - c1 : c0 - r.at;
|
||||
if (d > -EPS && d < reach) reach = d;
|
||||
});
|
||||
if (!isFinite(reach) || reach < s.size) return;
|
||||
const a = Math.min(r.at, r.at - r.n * reach), b = Math.max(r.at, r.at - r.n * reach);
|
||||
const corner = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at };
|
||||
const outward = r.axis === 'z' ? { x: 0, z: -dir } : { x: -dir, z: 0 };
|
||||
sideCands.push({ r, t, corner, reach, out: outward, w: r.axis === 'z' ? { x0: a, x1: b, z0: lo, z1: hi } : { x0: lo, x1: hi, z0: a, z1: b } });
|
||||
}));
|
||||
const sameCorner = (p, q) => Math.abs(p.x - q.x) < EPS && Math.abs(p.z - q.z) < EPS;
|
||||
const sideAt = (r, t) => { const c = sideCands.find(d => d.r === r && d.t === t); return !!c && sideCands.some(d => d.built && sameCorner(d.corner, c.corner)); };
|
||||
sideCands.forEach(c => {
|
||||
if (sideCands.some(d => d !== c && sameCorner(d.corner, c.corner) && (d.reach < c.reach - EPS || (Math.abs(d.reach - c.reach) < EPS && sideCands.indexOf(d) < sideCands.indexOf(c))))) return;
|
||||
const w = c.w;
|
||||
const tops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]]
|
||||
.map(([x, z]) => { const rf = roofAt(x, z); return rf ? rf.ceilingAt(x, z) : null; }).filter(v => v !== null);
|
||||
if (!tops.length || !(Math.max(...tops) > groundY)) return;
|
||||
const wall = Object.assign({}, w, { y0: groundY, y1: Math.round(Math.max(...tops)), color: s.sideColor, out: c.out, outside: null });
|
||||
const o = outsideOf ? outsideOf(wall, c.out) : null;
|
||||
if (o) {
|
||||
if (c.out.x > 0) wall.x1 += o.depth; else if (c.out.x < 0) wall.x0 -= o.depth;
|
||||
if (c.out.z > 0) wall.z1 += o.depth; else if (c.out.z < 0) wall.z0 -= o.depth;
|
||||
Object.assign(wall, { outside: o.finish, outsideKind: o.kind });
|
||||
// Zoals de gevel: de bekleding loopt tot onder de dakrand door (over de kant van het dakpakket).
|
||||
const roofTops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]]
|
||||
.map(([x, z]) => { const rf = roofAt(x, z); return rf && rf.topAt ? rf.topAt(x, z) : null; }).filter(v => v !== null);
|
||||
if (roofTops.length) wall.y1 = Math.max(wall.y1, Math.round(Math.min(...roofTops) - SIDE_TOP_BELOW_MM));
|
||||
}
|
||||
out.walls.push(wall);
|
||||
c.built = true;
|
||||
});
|
||||
merged.forEach(r => {
|
||||
const pos = (t) => (r.axis === 'z' ? { x: r.at - r.n * half, z: t } : { x: t, z: r.at - r.n * half });
|
||||
// Een eind dat op of tegen een vloer uitkomt rust op die gevel.
|
||||
const endFree = (t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); };
|
||||
// Een dichte zijwand neemt de plaats van de hoekpaal in; het eind rust dan op die wand.
|
||||
const fa = endFree(r.a) && !sideAt(r, r.a), fb = endFree(r.b) && !sideAt(r, r.b);
|
||||
const pa = fa ? r.a + half : r.a, pb = fb ? r.b - half : r.b;
|
||||
if (pb - pa < EPS) return;
|
||||
const n = Math.max(1, Math.ceil((pb - pa) / s.maxSpan - 1e-9));
|
||||
for (let k = 0; k <= n; k++) {
|
||||
if ((k === 0 && !fa) || (k === n && !fb)) continue;
|
||||
pts.push(pos(pa + (pb - pa) * k / n));
|
||||
}
|
||||
});
|
||||
pts.forEach(p => {
|
||||
if (out.posts.some(q => Math.abs(q.x - p.x) < s.size && Math.abs(q.z - p.z) < s.size)) return;
|
||||
if (out.walls.some(w => p.x > w.x0 - half && p.x < w.x1 + half && p.z > w.z0 - half && p.z < w.z1 + half)) return;
|
||||
const roof = roofAt(p.x, p.z);
|
||||
if (!roof) return;
|
||||
// Hoogste hoek: bij een hellend dak verdwijnt de paal aan de lage kant in het dakpakket, zonder kier.
|
||||
const top = Math.max(...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([a, b]) => roof.ceilingAt(p.x + a * (half - 1), p.z + b * (half - 1))));
|
||||
if (!(top > groundY + SHOE_MM)) return;
|
||||
out.posts.push({ x: Math.round(p.x), z: Math.round(p.z), y0: groundY, y1: Math.round(top), size: s.size });
|
||||
});
|
||||
out.count = out.posts.length;
|
||||
out.lengthMM = out.posts.reduce((t, p) => t + p.y1 - p.y0 - SHOE_MM, 0);
|
||||
out.m3 = out.lengthMM * s.size * s.size / 1e9;
|
||||
out.kg = out.m3 * WOODS[s.wood].kgM3;
|
||||
const wallArea = (w) => Math.max(w.x1 - w.x0, w.z1 - w.z0) * (w.y1 - w.y0) / 1e6;
|
||||
out.wallM2 = out.walls.reduce((t, w) => t + wallArea(w), 0);
|
||||
out.outsideM2 = {};
|
||||
out.walls.filter(w => w.outsideKind).forEach(w => { out.outsideM2[w.outsideKind] = (out.outsideM2[w.outsideKind] || 0) + wallArea(w); });
|
||||
return out;
|
||||
}
|
||||
|
||||
function createMaterials() {
|
||||
return {
|
||||
wood: Object.fromEntries(Object.entries(WOODS).map(([k, w]) => [k, new THREE.MeshStandardMaterial({ color: w.hex, roughness: 0.78 })])),
|
||||
shoe: new THREE.MeshStandardMaterial({ color: 0x9ba0a4, roughness: 0.45, metalness: 0.6 })
|
||||
};
|
||||
}
|
||||
|
||||
function build3D(data, materials) {
|
||||
const toM = 0.001, group = new THREE.Group();
|
||||
if (!data || !(data.posts.length || (data.walls || []).length)) return group;
|
||||
const m = materials || createMaterials();
|
||||
const woodMat = m.wood[data.wood] || m.wood.eiken;
|
||||
data.posts.forEach(p => {
|
||||
const h = p.y1 - p.y0 - SHOE_MM;
|
||||
const post = new THREE.Mesh(new THREE.BoxGeometry(p.size * toM, h * toM, p.size * toM), woodMat);
|
||||
post.position.set(p.x * toM, (p.y0 + SHOE_MM + h / 2) * toM, p.z * toM);
|
||||
group.add(post);
|
||||
const plate = new THREE.Mesh(new THREE.BoxGeometry((p.size + 40) * toM, 10 * toM, (p.size + 40) * toM), m.shoe);
|
||||
plate.position.set(p.x * toM, (p.y0 + 5) * toM, p.z * toM);
|
||||
group.add(plate);
|
||||
const stem = new THREE.Mesh(new THREE.BoxGeometry(p.size * 0.35 * toM, (SHOE_MM - 10) * toM, p.size * 0.35 * toM), m.shoe);
|
||||
stem.position.set(p.x * toM, (p.y0 + 10 + (SHOE_MM - 10) / 2) * toM, p.z * toM);
|
||||
group.add(stem);
|
||||
});
|
||||
(data.walls || []).forEach(w => {
|
||||
const c = [(w.x0 + w.x1) / 2 * toM, (w.y0 + w.y1) / 2 * toM, (w.z0 + w.z1) / 2 * toM];
|
||||
const geo = new THREE.BoxGeometry((w.x1 - w.x0) * toM, (w.y1 - w.y0) * toM, (w.z1 - w.z0) * toM);
|
||||
// UV in wereldmeters, zoals de gevels: texturen op dezelfde schaal.
|
||||
const pos = geo.attributes.position, nrm = geo.attributes.normal, uv = geo.attributes.uv;
|
||||
for (let k = 0; k < pos.count; k++) {
|
||||
const x = pos.getX(k) + c[0], y = pos.getY(k) + c[1], z = pos.getZ(k) + c[2];
|
||||
if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y);
|
||||
}
|
||||
uv.needsUpdate = true;
|
||||
// Volgorde van de vlakken in BoxGeometry: +x, -x, +y, -y, +z, -z.
|
||||
const stuc = WallModel.stucMaterial(w.color), mats = [stuc, stuc, stuc, stuc, stuc, stuc];
|
||||
const outMat = w.outside && w.out ? WallModel.claddingMaterial(w.outside) : null;
|
||||
if (outMat) mats[w.out.x > 0 ? 0 : w.out.x < 0 ? 1 : w.out.z > 0 ? 4 : 5] = outMat;
|
||||
const box = new THREE.Mesh(geo, mats);
|
||||
box.position.set(...c);
|
||||
box.castShadow = true;
|
||||
box.receiveShadow = true;
|
||||
group.add(box);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
return { WOODS, SHOE_MM, SIDE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
|
||||
})();
|
||||
+838
-220
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Deuren en ramen tellen als wand. Wanduiteinden tellen END_TOL langer, zodat een kiertje bij
|
||||
// een getekende binnenwand de ruimte niet laat doorlopen.
|
||||
|
||||
window.RoomModel = (() => {
|
||||
const EPS = 0.5;
|
||||
const END_TOL = 15;
|
||||
const MIN_AREA_MM2 = 0.05e6;
|
||||
const FACE_OFF = 2;
|
||||
const CEIL_STEP = 300;
|
||||
const CEIL_MIN = 1800, CEIL_MAX = 4500;
|
||||
|
||||
const TYPES = {
|
||||
woonkamer: { label: 'Woonkamer', color: '#e6c36a' },
|
||||
keuken: { label: 'Keuken', color: '#f0a05a', floor: 'tegel_grijs' },
|
||||
woonkeuken: { label: 'Woonkeuken', color: '#e9b35f' },
|
||||
slaapkamer: { label: 'Slaapkamer', color: '#8fb3e8' },
|
||||
badkamer: { label: 'Badkamer', color: '#5fc9c0', floor: 'tegel_antraciet', fibo: { decor: 'wit_tegel', height: null }, ceiling: 2600 },
|
||||
toilet: { label: 'Toilet', color: '#7cc7e3', floor: 'tegel_antraciet', fibo: { decor: 'wit_tegel', height: 1200 }, ceiling: 2600 },
|
||||
hal: { label: 'Hal / entree', color: '#c9bfae' },
|
||||
berging: { label: 'Berging', color: '#a99a86' },
|
||||
techniek: { label: 'Techniek', color: '#9a9aa6' },
|
||||
wasruimte: { label: 'Wasruimte', color: '#9fd3a4', floor: 'tegel_grijs' },
|
||||
werkkamer: { label: 'Werkkamer', color: '#c3a6e0' },
|
||||
overig: { label: 'Overig', color: '#b8b8b8' }
|
||||
};
|
||||
|
||||
const FIBO = {
|
||||
wit_tegel: { label: 'Wit, tegel 300×600', color: '#f1f0ec', jointColor: '#cfcdc6', tile: { w: 600, h: 300, joint: 3 } },
|
||||
grijs_tegel: { label: 'Lichtgrijs, tegel 300×600', color: '#c9c8c3', jointColor: '#a3a29d', tile: { w: 600, h: 300, joint: 3 } },
|
||||
antraciet_tegel: { label: 'Antraciet, tegel 300×600', color: '#55575a', jointColor: '#3a3b3d', tile: { w: 600, h: 300, joint: 3 } },
|
||||
wit_metro: { label: 'Wit, metrotegel 100×200', color: '#f3f2ee', jointColor: '#c8c6be', tile: { w: 200, h: 100, joint: 2 } },
|
||||
beton: { label: 'Betonlook (naadloos)', color: '#a6a39c' }
|
||||
};
|
||||
|
||||
const isHex = (v) => /^#[0-9a-f]{6}$/i.test(v || '');
|
||||
|
||||
function applyType(room, type) {
|
||||
const T = TYPES[type] || TYPES.overig;
|
||||
room.type = TYPES[type] ? type : 'overig';
|
||||
room.floor = T.floor || null;
|
||||
room.fibo = T.fibo ? Object.assign({}, T.fibo) : null;
|
||||
room.ceiling = T.ceiling || null;
|
||||
room.paint = null;
|
||||
return room;
|
||||
}
|
||||
|
||||
function create(state, rect, x, z, type) {
|
||||
const id = (state.rooms || []).reduce((m, r) => Math.max(m, r.id || 0), 0) + 1;
|
||||
return applyType({ id, unitId: rect.id, x: Math.round(x - rect.x0), z: Math.round(z - rect.z0), name: '' }, type);
|
||||
}
|
||||
|
||||
function normalizeRoom(r, i) {
|
||||
return {
|
||||
id: r.id > 0 ? r.id : i + 1,
|
||||
unitId: String(r.unitId || 'A'), x: Number(r.x) || 0, z: Number(r.z) || 0,
|
||||
type: TYPES[r.type] ? r.type : 'overig',
|
||||
name: typeof r.name === 'string' ? r.name : '',
|
||||
floor: r.floor && FloorModel.FINISHES[r.floor] ? r.floor : null,
|
||||
paint: isHex(r.paint) ? r.paint : null,
|
||||
fibo: r.fibo && FIBO[r.fibo.decor] ? { decor: r.fibo.decor, height: r.fibo.height > 0 ? Math.round(r.fibo.height) : null } : null,
|
||||
ceiling: r.ceiling > 0 ? Math.max(CEIL_MIN, Math.min(CEIL_MAX, Math.round(r.ceiling))) : null
|
||||
};
|
||||
}
|
||||
function normalize(list, unitIds) {
|
||||
return (Array.isArray(list) ? list : []).map(normalizeRoom).filter(r => !unitIds || unitIds.includes(r.unitId));
|
||||
}
|
||||
|
||||
const q = (v) => Math.round(v * 10) / 10;
|
||||
function bsearch(arr, v) {
|
||||
if (!arr.length || v < arr[0] || v > arr[arr.length - 1]) return -1;
|
||||
let lo = 0, hi = arr.length - 1;
|
||||
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (arr[m] <= v) lo = m; else hi = m; }
|
||||
return lo;
|
||||
}
|
||||
|
||||
function buildGrid(pieces, rects) {
|
||||
const blockers = [];
|
||||
pieces.forEach(p => {
|
||||
if (p.place.baseY) return; // opbouw boven een lager dak staat niet op de vloer
|
||||
const pl = p.place, alongX = Math.abs(Math.sin(pl.rotY)) < 0.5;
|
||||
const hw = pl.width / 2, ht = pl.thickness / 2;
|
||||
const r = alongX ? [pl.cx - hw, pl.cx + hw, pl.cz - ht, pl.cz + ht] : [pl.cx - ht, pl.cx + ht, pl.cz - hw, pl.cz + hw];
|
||||
const ext = alongX ? [r[0] - END_TOL, r[1] + END_TOL, r[2], r[3]] : [r[0], r[1], r[2] - END_TOL, r[3] + END_TOL];
|
||||
blockers.push({ piece: p, r: r.map(q), ext: ext.map(q), alongX });
|
||||
});
|
||||
const sx = new Set(), sz = new Set();
|
||||
const addR = (r) => { sx.add(r[0]); sx.add(r[1]); sz.add(r[2]); sz.add(r[3]); };
|
||||
const unitR = rects.map(r => [r.x0, r.x1, r.z0, r.z1].map(q));
|
||||
unitR.forEach(addR);
|
||||
blockers.forEach(b => { addR(b.r); addR(b.ext); });
|
||||
const xs = [...sx].sort((a, b) => a - b), zs = [...sz].sort((a, b) => a - b);
|
||||
const nx = Math.max(0, xs.length - 1), nz = Math.max(0, zs.length - 1);
|
||||
const ix = new Map(xs.map((v, i) => [v, i])), iz = new Map(zs.map((v, i) => [v, i]));
|
||||
const unitOf = new Int16Array(nx * nz).fill(-1);
|
||||
const block = new Int32Array(nx * nz).fill(-1);
|
||||
const mark = (arr, r, val, onlyEmpty) => {
|
||||
const i0 = ix.get(r[0]), i1 = ix.get(r[1]), j0 = iz.get(r[2]), j1 = iz.get(r[3]);
|
||||
for (let j = j0; j < j1; j++) for (let i = i0; i < i1; i++) {
|
||||
const k = j * nx + i;
|
||||
if (!onlyEmpty || arr[k] < 0) arr[k] = val;
|
||||
}
|
||||
};
|
||||
unitR.forEach((r, i) => mark(unitOf, r, i, true));
|
||||
blockers.forEach((b, i) => mark(block, b.r, i, true));
|
||||
blockers.forEach((b, i) => mark(block, b.ext, i, true));
|
||||
return { xs, zs, nx, nz, unitOf, block, blockers };
|
||||
}
|
||||
|
||||
function fill(g, rects) {
|
||||
const { xs, zs, nx, nz, unitOf, block } = g;
|
||||
const region = new Int32Array(nx * nz).fill(-1);
|
||||
const regions = [];
|
||||
for (let k0 = 0; k0 < nx * nz; k0++) {
|
||||
if (unitOf[k0] < 0 || block[k0] >= 0 || region[k0] >= 0) continue;
|
||||
const idx = regions.length, cells = [], units = new Set();
|
||||
let area = 0, mx = 0, mz = 0;
|
||||
const stack = [k0];
|
||||
region[k0] = idx;
|
||||
while (stack.length) {
|
||||
const k = stack.pop(), i = k % nx, j = (k - i) / nx;
|
||||
const w = xs[i + 1] - xs[i], d = zs[j + 1] - zs[j], a = w * d;
|
||||
cells.push(k);
|
||||
units.add(rects[unitOf[k]].id);
|
||||
area += a; mx += a * (xs[i] + w / 2); mz += a * (zs[j] + d / 2);
|
||||
const nb = [i > 0 ? k - 1 : -1, i < nx - 1 ? k + 1 : -1, j > 0 ? k - nx : -1, j < nz - 1 ? k + nx : -1];
|
||||
nb.forEach(m => {
|
||||
if (m < 0 || region[m] >= 0 || unitOf[m] < 0 || block[m] >= 0) return;
|
||||
region[m] = idx;
|
||||
stack.push(m);
|
||||
});
|
||||
}
|
||||
regions.push({ idx, cells, units: [...units], area, cx: mx / area, cz: mz / area });
|
||||
}
|
||||
return { region, regions };
|
||||
}
|
||||
|
||||
function outline(g, region, reg) {
|
||||
const { xs, zs, nx, nz, block } = g;
|
||||
const inReg = (k) => region[k] === reg.idx;
|
||||
const rows = new Map();
|
||||
reg.cells.forEach(k => { const i = k % nx, j = (k - i) / nx; (rows.get(j) || rows.set(j, []).get(j)).push(i); });
|
||||
let open = [];
|
||||
const rects = [];
|
||||
[...rows.keys()].sort((a, b) => a - b).forEach(j => {
|
||||
const is = rows.get(j).sort((a, b) => a - b), runs = [];
|
||||
is.forEach(i => { const last = runs[runs.length - 1]; if (last && last[1] === i) last[1] = i + 1; else runs.push([i, i + 1]); });
|
||||
const next = [];
|
||||
runs.forEach(([a, b]) => {
|
||||
const r = open.find(o => o.a === a && o.b === b && o.j1 === j);
|
||||
if (r) { r.j1 = j + 1; next.push(r); } else { const n = { a, b, j0: j, j1: j + 1 }; rects.push(n); next.push(n); }
|
||||
});
|
||||
open = next;
|
||||
});
|
||||
const out = rects.map(r => ({ x0: xs[r.a], x1: xs[r.b], z0: zs[r.j0], z1: zs[r.j1] }));
|
||||
const raw = [];
|
||||
reg.cells.forEach(k => {
|
||||
const i = k % nx, j = (k - i) / nx;
|
||||
const side = (m, axis, v, s0, s1, n) => {
|
||||
if (m >= 0 && inReg(m)) return;
|
||||
raw.push({ axis, v, s0, s1, n, owner: m >= 0 ? block[m] : -1 });
|
||||
};
|
||||
side(i < nx - 1 ? k + 1 : -1, 'x', xs[i + 1], zs[j], zs[j + 1], { x: -1, z: 0 });
|
||||
side(i > 0 ? k - 1 : -1, 'x', xs[i], zs[j], zs[j + 1], { x: 1, z: 0 });
|
||||
side(j < nz - 1 ? k + nx : -1, 'z', zs[j + 1], xs[i], xs[i + 1], { x: 0, z: -1 });
|
||||
side(j > 0 ? k - nx : -1, 'z', zs[j], xs[i], xs[i + 1], { x: 0, z: 1 });
|
||||
});
|
||||
const groups = new Map();
|
||||
raw.forEach(e => { const key = `${e.axis}|${e.v}|${e.n.x}|${e.n.z}|${e.owner}`; (groups.get(key) || groups.set(key, []).get(key)).push(e); });
|
||||
const edges = [];
|
||||
groups.forEach(list => {
|
||||
list.sort((a, b) => a.s0 - b.s0);
|
||||
list.forEach(e => {
|
||||
const last = edges[edges.length - 1];
|
||||
if (last && last.key === list && Math.abs(last.s1 - e.s0) < EPS) last.s1 = e.s1;
|
||||
else edges.push(Object.assign({ key: list }, e));
|
||||
});
|
||||
});
|
||||
edges.forEach(e => delete e.key);
|
||||
return { rects: out, edges };
|
||||
}
|
||||
|
||||
function labelPoint(g, region, reg, rects) {
|
||||
const i = bsearch(g.xs, reg.cx), j = bsearch(g.zs, reg.cz);
|
||||
if (i >= 0 && j >= 0 && i < g.nx && j < g.nz && region[j * g.nx + i] === reg.idx) return { x: reg.cx, z: reg.cz };
|
||||
const big = rects.reduce((b, r) => (r.x1 - r.x0) * (r.z1 - r.z0) > (b.x1 - b.x0) * (b.z1 - b.z0) ? r : b, rects[0]);
|
||||
return { x: (big.x0 + big.x1) / 2, z: (big.z0 + big.z1) / 2 };
|
||||
}
|
||||
|
||||
function bandOnSide(p, e, lo, H) {
|
||||
const pl = p.place, cfg = p.config, W = cfg.wallWidth, tl = WallModel.topLine(cfg);
|
||||
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
|
||||
const toS = (lx) => e.axis === 'z' ? pl.cx + lx * c : pl.cz - lx * sn;
|
||||
const toLx = (s) => e.axis === 'z' ? (s - pl.cx) / c : -(s - pl.cz) / sn;
|
||||
const la = Math.max(-W / 2, Math.min(toLx(e.s0), toLx(e.s1))), lb = Math.min(W / 2, Math.max(toLx(e.s0), toLx(e.s1)));
|
||||
if (lb - la < 1) return { quads: [], area: 0 };
|
||||
const hiAt = (lx) => Math.min(tl.at(lx), H);
|
||||
// Schoorsteen met binnenkant 'zoals de wanden': de afwerking van de ruimte loopt eroverheen.
|
||||
const ops = (cfg.openings || []).filter(op => !(op.chimney && op.chimney.inside === 'wand')).map(op => ({ x0: -W / 2 + op.x, x1: -W / 2 + op.x + op.w, y0: op.y, top: (lx) => WallModel.openingTopAt(op, lx + W / 2) }));
|
||||
const bs = new Set([la, lb]);
|
||||
tl.pts.forEach(pt => bs.add(pt.x));
|
||||
for (let i = 0; i < tl.pts.length - 1; i++) {
|
||||
const a = tl.pts[i], b = tl.pts[i + 1];
|
||||
if (isFinite(H) && (a.y - H) * (b.y - H) < 0) bs.add(a.x + (H - a.y) / (b.y - a.y) * (b.x - a.x));
|
||||
}
|
||||
ops.forEach(o => {
|
||||
bs.add(o.x0); bs.add(o.x1);
|
||||
const t0 = o.top(o.x0), t1 = o.top(o.x1);
|
||||
[lo, H].forEach(L => { if (isFinite(L) && (t0 - L) * (t1 - L) < 0) bs.add(o.x0 + (L - t0) / (t1 - t0) * (o.x1 - o.x0)); });
|
||||
});
|
||||
const xsB = [...bs].filter(x => x >= la - 0.01 && x <= lb + 0.01).sort((a, b) => a - b);
|
||||
const quads = [];
|
||||
let area = 0;
|
||||
for (let k = 0; k < xsB.length - 1; k++) {
|
||||
const a = xsB[k], b = xsB[k + 1];
|
||||
if (b - a < 1) continue;
|
||||
const m = (a + b) / 2;
|
||||
if (hiAt(m) <= lo + 1) continue;
|
||||
let bot = () => lo;
|
||||
const ivs = [];
|
||||
ops.filter(o => o.x0 <= m && o.x1 >= m).sort((u, v) => u.y0 - v.y0).forEach(o => {
|
||||
const bf = bot;
|
||||
ivs.push([bf, () => o.y0]);
|
||||
bot = (x) => Math.max(bf(x), o.top(x));
|
||||
});
|
||||
ivs.push([bot, hiAt]);
|
||||
ivs.forEach(([bf, tf]) => {
|
||||
const at = (x) => { const y0 = Math.max(lo, bf(x)), y1 = Math.min(hiAt(x), tf(x)); return [y0, Math.max(y0, y1)]; };
|
||||
const [a0, a1] = at(a), [b0, b1] = at(b);
|
||||
if (a1 - a0 < 0.5 && b1 - b0 < 0.5) return;
|
||||
quads.push([[toS(a), a0], [toS(b), b0], [toS(b), b1], [toS(a), a1]]);
|
||||
area += (b - a) * ((a1 - a0) + (b1 - b0)) / 2;
|
||||
});
|
||||
}
|
||||
return { quads, area };
|
||||
}
|
||||
function bandOnEnd(p, e, lo, H) {
|
||||
const tl = WallModel.topLine(p.config), W = p.config.wallWidth;
|
||||
const top = Math.min(H, Math.min(tl.at(-W / 2), tl.at(W / 2)));
|
||||
if (top - lo < 1) return { quads: [], area: 0 };
|
||||
return { quads: [[[e.s0, lo], [e.s1, lo], [e.s1, top], [e.s0, top]]], area: (e.s1 - e.s0) * (top - lo) };
|
||||
}
|
||||
|
||||
function compute(roomStates, units, pieces) {
|
||||
const rects = units.map(u => u.rect);
|
||||
const g = buildGrid(pieces, rects);
|
||||
const { region, regions } = fill(g, rects);
|
||||
const cellAt = (x, z) => {
|
||||
const i = bsearch(g.xs, x), j = bsearch(g.zs, z);
|
||||
return i < 0 || j < 0 || i >= g.nx || j >= g.nz ? -1 : j * g.nx + i;
|
||||
};
|
||||
const valid = (r) => r && r.area >= MIN_AREA_MM2;
|
||||
const regionAt = (x, z) => { const k = cellAt(x, z); return k >= 0 && region[k] >= 0 && valid(regions[region[k]]) ? regions[region[k]] : null; };
|
||||
// Zaadpunt in een wand (wand verschoven): het dichtstbijzijnde gebied binnen 400 mm.
|
||||
const seedRegion = (x, z) => {
|
||||
const r0 = regionAt(x, z);
|
||||
if (r0) return r0;
|
||||
for (let d = 50; d <= 400; d += 50) {
|
||||
for (let a = 0; a < 8; a++) {
|
||||
const r = regionAt(x + d * Math.cos(a * Math.PI / 4), z + d * Math.sin(a * Math.PI / 4));
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const owner = new Map();
|
||||
const lost = [];
|
||||
(roomStates || []).forEach(rs => {
|
||||
const u = units.find(x => x.id === rs.unitId);
|
||||
const reg = u ? seedRegion(u.rect.x0 + rs.x, u.rect.z0 + rs.z) : null;
|
||||
if (!reg) lost.push({ id: rs.id, state: rs, reason: u ? 'Geen afgesloten gebied op deze plek' : `Unit ${rs.unitId} bestaat niet` });
|
||||
else if (owner.has(reg.idx)) lost.push({ id: rs.id, state: rs, reason: `Valt samen met een andere ruimte (wand weg?)` });
|
||||
else owner.set(reg.idx, rs);
|
||||
});
|
||||
|
||||
const pieceIdx = new Map(g.blockers.map((b, i) => [i, b]));
|
||||
const unitAt = (x, z) => units.find(u => x >= u.rect.x0 - EPS && x <= u.rect.x1 + EPS && z >= u.rect.z0 - EPS && z <= u.rect.z1 + EPS);
|
||||
const sameType = {};
|
||||
owner.forEach(rs => { sameType[rs.type] = (sameType[rs.type] || 0) + 1; });
|
||||
const seenType = {};
|
||||
|
||||
const rooms = [], free = [];
|
||||
regions.filter(valid).forEach(reg => {
|
||||
const { rects: rr, edges } = outline(g, region, reg);
|
||||
const lp = labelPoint(g, region, reg, rr);
|
||||
const u0 = units.find(u => u.id === reg.units[0]);
|
||||
const floorTop = u0 ? u0.layout.floorTop : 0;
|
||||
const base = { key: reg.idx, areaM2: reg.area / 1e6, rects: rr, edges, label: lp, units: reg.units, floorTop };
|
||||
const rs = owner.get(reg.idx);
|
||||
if (!rs) { free.push(base); return; }
|
||||
const T = TYPES[rs.type] || TYPES.overig;
|
||||
seenType[rs.type] = (seenType[rs.type] || 0) + 1;
|
||||
const name = rs.name || (sameType[rs.type] > 1 ? `${T.label} ${seenType[rs.type]}` : T.label);
|
||||
const share = {};
|
||||
rr.forEach(r => { const u = unitAt((r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2); if (u) share[u.id] = (share[u.id] || 0) + (r.x1 - r.x0) * (r.z1 - r.z0); });
|
||||
const unitId = Object.keys(share).sort((a, b) => share[b] - share[a])[0] || reg.units[0];
|
||||
|
||||
const ceilQuads = [];
|
||||
let ceilMM2 = 0;
|
||||
if (rs.ceiling) {
|
||||
const yAbs = floorTop + rs.ceiling;
|
||||
rr.forEach(r => {
|
||||
const nX = Math.max(1, Math.ceil((r.x1 - r.x0) / CEIL_STEP)), nZ = Math.max(1, Math.ceil((r.z1 - r.z0) / CEIL_STEP));
|
||||
for (let a = 0; a < nX; a++) for (let b = 0; b < nZ; b++) {
|
||||
const x0 = r.x0 + (r.x1 - r.x0) * a / nX, x1 = r.x0 + (r.x1 - r.x0) * (a + 1) / nX;
|
||||
const z0 = r.z0 + (r.z1 - r.z0) * b / nZ, z1 = r.z0 + (r.z1 - r.z0) * (b + 1) / nZ;
|
||||
const u = unitAt((x0 + x1) / 2, (z0 + z1) / 2);
|
||||
if (!u) continue;
|
||||
const ok = [[x0 + 1, z0 + 1], [x1 - 1, z0 + 1], [x1 - 1, z1 - 1], [x0 + 1, z1 - 1]].every(([x, z]) => u.roof.ceilingAt(x, z) > yAbs + 10);
|
||||
if (!ok) continue;
|
||||
ceilQuads.push({ x0, x1, z0, z1, y: yAbs });
|
||||
ceilMM2 += (x1 - x0) * (z1 - z0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const H = rs.ceiling && ceilQuads.length ? rs.ceiling : Infinity;
|
||||
const fiboH = rs.fibo ? (rs.fibo.height ? Math.min(rs.fibo.height, H) : H) : 0;
|
||||
const faces = [];
|
||||
let fiboMM2 = 0, paintMM2 = 0;
|
||||
edges.forEach(e => {
|
||||
const b = pieceIdx.get(e.owner);
|
||||
if (!b) return;
|
||||
const side = b.alongX === (e.axis === 'z');
|
||||
const band = (lo, hi) => side ? bandOnSide(b.piece, e, lo, hi) : bandOnEnd(b.piece, e, lo, hi);
|
||||
const world = ([s, y]) => e.axis === 'z'
|
||||
? { x: s, y: b.piece.place.floorTop + y, z: e.v + e.n.z * FACE_OFF, u: s }
|
||||
: { x: e.v + e.n.x * FACE_OFF, y: b.piece.place.floorTop + y, z: s, u: s };
|
||||
if (rs.fibo) {
|
||||
const r = band(0, fiboH);
|
||||
fiboMM2 += r.area;
|
||||
r.quads.forEach(qd => faces.push({ kind: 'fibo', pts: qd.map(world) }));
|
||||
}
|
||||
if (rs.paint && (!rs.fibo || fiboH < H)) {
|
||||
const r = band(rs.fibo ? fiboH : 0, H);
|
||||
paintMM2 += r.area;
|
||||
r.quads.forEach(qd => faces.push({ kind: 'paint', pts: qd.map(world) }));
|
||||
}
|
||||
});
|
||||
|
||||
rooms.push(Object.assign(base, {
|
||||
id: rs.id, state: rs, type: rs.type, typeLabel: T.label, name, color: T.color, unitId,
|
||||
floor: rs.floor, paint: rs.paint, fibo: rs.fibo, ceiling: rs.ceiling,
|
||||
ceilingActive: ceilQuads.length > 0, ceilQuads, ceilM2: ceilMM2 / 1e6,
|
||||
faces, fiboM2: fiboMM2 / 1e6, paintM2: paintMM2 / 1e6
|
||||
}));
|
||||
});
|
||||
|
||||
const roomAt = (x, z) => { const reg = regionAt(x, z); return reg ? rooms.find(r => r.key === reg.idx) || null : null; };
|
||||
const freeAt = (x, z) => { const reg = regionAt(x, z); return reg ? free.find(r => r.key === reg.idx) || null : null; };
|
||||
return { rooms, free, lost, roomAt, freeAt, regionAt };
|
||||
}
|
||||
|
||||
function lowerSpots(result, spots) {
|
||||
(spots || []).forEach(sp => {
|
||||
const r = result.roomAt(sp.x, sp.z);
|
||||
if (r && r.ceilingActive && sp.floorY + r.ceiling < sp.y) sp.y = sp.floorY + r.ceiling;
|
||||
});
|
||||
}
|
||||
|
||||
const matCache = {};
|
||||
const offset = (m) => Object.assign(m, { polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -2, side: THREE.DoubleSide });
|
||||
function fiboMaterial(decor) {
|
||||
const key = 'fibo:' + decor;
|
||||
if (matCache[key]) return matCache[key];
|
||||
const f = FIBO[decor] || FIBO.wit_tegel;
|
||||
const tex = f.tile && FloorModel.tileTexture ? FloorModel.tileTexture(f.tile, f.color, f.jointColor) : null;
|
||||
return (matCache[key] = offset(new THREE.MeshStandardMaterial(tex ? { map: tex, roughness: 0.3 } : { color: f.color, roughness: 0.5 })));
|
||||
}
|
||||
function paintMaterial(hex) {
|
||||
const key = 'paint:' + hex;
|
||||
return matCache[key] || (matCache[key] = offset(new THREE.MeshStandardMaterial({ color: hex, roughness: 0.9 })));
|
||||
}
|
||||
// Kopie van de vloerafwerking, zodat hij boven de woningvloer wint.
|
||||
function floorMaterial(key) {
|
||||
const f = FloorModel.FINISHES[key] || FloorModel.FINISHES.geen;
|
||||
const base = FloorModel.finishMaterial(key);
|
||||
const ck = 'floor:' + key;
|
||||
if (!matCache[ck]) matCache[ck] = offset(base ? base.clone() : new THREE.MeshStandardMaterial({ color: f.color, roughness: 0.75 }));
|
||||
const m = matCache[ck];
|
||||
if (base && base.map && m.map !== base.map) { m.map = base.map; m.color.copy(base.color); m.needsUpdate = true; }
|
||||
return m;
|
||||
}
|
||||
let ceilMat = null;
|
||||
const ceilingMaterial = () => ceilMat || (ceilMat = new THREE.MeshStandardMaterial({ color: 0xf4f3ef, roughness: 0.95, side: THREE.DoubleSide }));
|
||||
|
||||
function quadGeometry(quads, uvOf) {
|
||||
const pos = [], uv = [];
|
||||
quads.forEach(qd => [0, 1, 2, 0, 2, 3].forEach(i => {
|
||||
const p = qd[i];
|
||||
pos.push(p.x * 0.001, p.y * 0.001, p.z * 0.001);
|
||||
const t = uvOf(p);
|
||||
uv.push(t[0], t[1]);
|
||||
}));
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
|
||||
geo.computeVertexNormals();
|
||||
return geo;
|
||||
}
|
||||
|
||||
function build3D(result, opts) {
|
||||
const o = opts || {};
|
||||
const group = new THREE.Group(), ceilings = new THREE.Group();
|
||||
group.add(ceilings);
|
||||
group.userData.ceilings = ceilings;
|
||||
result.rooms.forEach(r => {
|
||||
if (r.floor) {
|
||||
const y = r.floorTop + FACE_OFF;
|
||||
const quads = r.rects.map(c => [{ x: c.x0, z: c.z0 }, { x: c.x1, z: c.z0 }, { x: c.x1, z: c.z1 }, { x: c.x0, z: c.z1 }].map(p => Object.assign(p, { y })));
|
||||
const m = new THREE.Mesh(quadGeometry(quads, p => [p.x * 0.001, p.z * 0.001]), floorMaterial(r.floor));
|
||||
m.receiveShadow = true;
|
||||
group.add(m);
|
||||
}
|
||||
const byKind = { fibo: [], paint: [] };
|
||||
r.faces.forEach(f => byKind[f.kind].push(f.pts));
|
||||
if (byKind.fibo.length) group.add(new THREE.Mesh(quadGeometry(byKind.fibo, p => [p.u * 0.001, p.y * 0.001]), fiboMaterial(r.fibo.decor)));
|
||||
if (byKind.paint.length) group.add(new THREE.Mesh(quadGeometry(byKind.paint, p => [p.u * 0.001, p.y * 0.001]), paintMaterial(r.paint)));
|
||||
if (r.ceilQuads.length) {
|
||||
const quads = r.ceilQuads.map(c => [{ x: c.x0, z: c.z0 }, { x: c.x1, z: c.z0 }, { x: c.x1, z: c.z1 }, { x: c.x0, z: c.z1 }].map(p => Object.assign(p, { y: c.y })));
|
||||
ceilings.add(new THREE.Mesh(quadGeometry(quads, p => [p.x * 0.001, p.z * 0.001]), ceilingMaterial()));
|
||||
}
|
||||
});
|
||||
ceilings.visible = o.ceilings !== false;
|
||||
return group;
|
||||
}
|
||||
|
||||
function floorLabel(r) { return r.floor ? (FloorModel.FINISHES[r.floor] || {}).label || r.floor : 'Woning'; }
|
||||
function wallLabel(r) {
|
||||
const parts = [];
|
||||
if (r.fibo) parts.push(`FIBO ${(FIBO[r.fibo.decor] || {}).label.toLowerCase()}${r.fibo.height ? ` tot ${r.fibo.height} mm` : ''}`);
|
||||
if (r.paint) parts.push(`verf ${r.paint}`);
|
||||
return parts.join(', ') || 'Woning';
|
||||
}
|
||||
function ceilingLabel(r) { return r.ceiling ? (r.ceilingActive ? `vals plafond ${r.ceiling}` : `${r.ceiling} (dak lager)`) : 'volgt dak'; }
|
||||
|
||||
return {
|
||||
TYPES, FIBO, CEIL_MIN, CEIL_MAX, applyType, create, normalize, compute, lowerSpots, build3D,
|
||||
floorLabel, wallLabel, ceilingLabel
|
||||
};
|
||||
})();
|
||||
@@ -4,21 +4,10 @@
|
||||
* 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 DEPTH_MM = 600;
|
||||
const THICK_MM = 40;
|
||||
const PANEL_WIDTH_MM = 495;
|
||||
const PANEL_LENGTH_MM = 1000;
|
||||
const PANEL_KG = 9.0;
|
||||
@@ -30,9 +19,6 @@ window.SandwichModel = (() => {
|
||||
};
|
||||
}
|
||||
|
||||
// rects: unit-vloeren { x0, x1, z0, z1 } (mm, woning-assen); topY =
|
||||
// loopvlak (mm). Per ribbe van elke omtreklus een vierhoek (vloerrand ->
|
||||
// buitenkant paneel, in verstek, ook bij binnenhoeken).
|
||||
function compute(rects, topY) {
|
||||
if (!rects.length) return null;
|
||||
const segments = [];
|
||||
@@ -43,18 +29,16 @@ window.SandwichModel = (() => {
|
||||
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
|
||||
lengthMM, areaM2, panelCount, kg: panelCount * PANEL_KG,
|
||||
wasteMM: PANEL_LENGTH_MM - DEPTH_MM
|
||||
};
|
||||
}
|
||||
|
||||
// opts: { visible (volgt "Beplating") }
|
||||
function build3D(data, materials, opts) {
|
||||
const o = opts || {};
|
||||
const toM = 0.001;
|
||||
@@ -65,15 +49,14 @@ window.SandwichModel = (() => {
|
||||
const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.z * toM)));
|
||||
const 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.rotation.x = Math.PI / 2;
|
||||
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 t = d / len, eps = 0.5;
|
||||
const nx = sg.outerA.x - sg.a.x, nz = sg.outerA.z - sg.a.z, nl = Math.hypot(nx, nz) || 1;
|
||||
const 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;
|
||||
|
||||
+218
-126
@@ -4,38 +4,10 @@
|
||||
* 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.
|
||||
// rotY draait lokaal +Z naar buiten; "links" = gezien van buitenaf. normal: plattegrond, x rechts, y omlaag.
|
||||
// De sleutels voor/achter/links/rechts blijven intern (compatibele JSON); weergave als windrichting.
|
||||
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 } },
|
||||
@@ -43,8 +15,6 @@ window.UnitModel = (() => {
|
||||
{ key: 'rechts', label: 'Wand oost', nr: 4, rotY: Math.PI / 2, axis: 'z', dir: -1, normal: { x: 1, y: 0 } }
|
||||
];
|
||||
|
||||
// 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'
|
||||
@@ -52,9 +22,8 @@ window.UnitModel = (() => {
|
||||
|
||||
const DEFAULT_HEIGHT_MM = 2600;
|
||||
|
||||
// Kortere wandstukken (restje naast een naad) worden weggelaten.
|
||||
const MIN_PIECE_MM = 300;
|
||||
const EPS = 0.5; // mm
|
||||
const EPS = 0.5;
|
||||
|
||||
function clone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||
|
||||
@@ -66,7 +35,6 @@ window.UnitModel = (() => {
|
||||
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 = [];
|
||||
@@ -77,17 +45,12 @@ window.UnitModel = (() => {
|
||||
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.
|
||||
// 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);
|
||||
@@ -96,38 +59,31 @@ window.UnitModel = (() => {
|
||||
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 spans = (tE) => state.layout.corners === 'zijkanten' ? {
|
||||
links: [-D / 2, D / 2],
|
||||
rechts: [-D / 2, D / 2],
|
||||
voor: [-W / 2 + tE('links', -D / 2), W / 2 - tE('rechts', -D / 2)],
|
||||
achter: [-W / 2 + tE('links', D / 2), W / 2 - tE('rechts', D / 2)]
|
||||
} : {
|
||||
voor: [-W / 2, W / 2],
|
||||
achter: [-W / 2, W / 2],
|
||||
links: [-D / 2 + tE('voor', -W / 2), D / 2 - tE('achter', -W / 2)],
|
||||
rechts: [-D / 2 + tE('voor', W / 2), D / 2 - tE('achter', W / 2)]
|
||||
};
|
||||
const span = spans(tEff);
|
||||
const span0 = spans((side) => T[side]);
|
||||
|
||||
const walls = WALL_SIDES.map(s => {
|
||||
const [s0, s1] = span[s.key];
|
||||
const [s0, s1] = span[s.key], [r0, r1] = span0[s.key];
|
||||
const width = Math.max(MIN_PIECE_MM, Math.round(s1 - s0));
|
||||
const 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]) => {
|
||||
@@ -138,10 +94,10 @@ window.UnitModel = (() => {
|
||||
.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.
|
||||
// [u0, u1, inL, inR]: inL/inR = hoever het stuk aan die kant de naad
|
||||
// in loopt (binnenhoek dicht, zie computeSeams).
|
||||
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 };
|
||||
|
||||
const pieces = [];
|
||||
let cursor = 0, inL = 0;
|
||||
rawU.forEach(([a, b, e0, e1]) => {
|
||||
@@ -151,72 +107,107 @@ window.UnitModel = (() => {
|
||||
inL = b - cursor;
|
||||
});
|
||||
if (width - cursor >= MIN_PIECE_MM) pieces.push([cursor, width, inL, 0]);
|
||||
// Werkelijk vervallende zones (voor de weergave in de wand-editor).
|
||||
const splitsU = normalizeSplits(state.walls[s.key].splits).map(sp => Object.assign({}, sp, { u: sp.u - skinExt.left }))
|
||||
.filter(sp => sp.u > MIN_PIECE_MM && sp.u < width - MIN_PIECE_MM);
|
||||
splitsU.forEach(sp => {
|
||||
const k = pieces.findIndex(([a, b]) => sp.u >= a + MIN_PIECE_MM - EPS && sp.u <= b - MIN_PIECE_MM + EPS);
|
||||
if (k < 0) return;
|
||||
const [a, b, inL, inR] = pieces[k];
|
||||
pieces.splice(k, 1, [a, sp.u, inL, 0], [sp.u, b, 0, inR]);
|
||||
});
|
||||
const cutsU = rawU.map(([a, b, e0, e1]) => [a + e0, b - e1]).filter(([a, b]) => b - a > EPS);
|
||||
|
||||
state.walls[s.key].wall.width = width;
|
||||
// Hoekdoorloop buitenlagen (WallModel.cornerExt): een tussenpassende
|
||||
// wand bedekt ook de kopse kant van de doorlopende wand ernaast.
|
||||
const full = s.axis === 'x' ? W / 2 : D / 2;
|
||||
const ext0 = Math.max(0, Math.round(s0 + full)), ext1 = Math.max(0, Math.round(full - s1));
|
||||
const skinExt = s.dir > 0 ? { left: ext0, right: ext1 } : { left: ext1, right: ext0 };
|
||||
// Doorlopende wand die op een hoek vervalt: de haakse wand loopt daar
|
||||
// tot de vloerrand door en staat dus op deze wandlijn (endInset, u-mm
|
||||
// vanaf links/rechts). Een opbouw in die naad-zone begint erna.
|
||||
const through = state.layout.corners === 'zijkanten' ? s.axis === 'z' : s.axis === 'x';
|
||||
const pL = s.dir > 0 ? s0 : s1, pR = s.dir > 0 ? s1 : s0;
|
||||
const endInset = {
|
||||
left: through && cutAt(s.key, pL) ? T[WALL_ENDS[s.key].left] : 0,
|
||||
right: through && cutAt(s.key, pR) ? T[WALL_ENDS[s.key].right] : 0
|
||||
};
|
||||
const ref = { w: Math.max(MIN_PIECE_MM, Math.round(r1 - r0)), u0: s.dir > 0 ? s0 - r0 : r1 - s1 };
|
||||
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, endInset
|
||||
thickness: T[s.key], width, cx, cz, cutsU, pieces, skinExt, endInset, ref, splitsU
|
||||
};
|
||||
});
|
||||
|
||||
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).
|
||||
// raised (optioneel): [{ u0, u1, base }] = opbouw in een naad-zone: daar
|
||||
// staat de wand pas vanaf hoogte `base` (mm vanaf het loopvlak, bijv. boven
|
||||
// het lagere dak van de buur-unit) tot de daklijn. Zo'n stuk heeft
|
||||
// baseY = base; openingen die er volledig boven base in vallen horen erbij
|
||||
// (y t.o.v. de onderkant van het stuk). Te lage opbouw vervalt.
|
||||
function normalizeSplits(list) {
|
||||
return (Array.isArray(list) ? list : []).map(sp => typeof sp === 'number' ? { u: sp, facade: null } : sp)
|
||||
.filter(sp => sp && isFinite(sp.u) && sp.u > 0)
|
||||
.map(sp => ({ u: Math.round(sp.u), facade: sp.facade && sp.facade.type ? Object.assign({}, sp.facade) : null }))
|
||||
.sort((a, b) => a.u - b.u);
|
||||
}
|
||||
function splitFacadeAt(wallLayout, u0, u1) {
|
||||
const m = (u0 + u1) / 2;
|
||||
let f = null;
|
||||
(wallLayout.splitsU || []).forEach(sp => { if (sp.u <= m) f = sp.facade; });
|
||||
return f;
|
||||
}
|
||||
|
||||
// Openingen eerst op de volle zijde oplossen (ankers gelden voor de hele zijde), dan toewijzen;
|
||||
// openingen in een naad vervallen.
|
||||
const MIN_RAISE_MM = 150;
|
||||
|
||||
function topLineOn(wallLayout, wall) {
|
||||
const ref = wallLayout.ref || { w: wallLayout.width, u0: 0 };
|
||||
const W = wallLayout.width;
|
||||
const pts = [{ x: 0, h: wall.heightLeft }, ...WallModel.wallTopPoints(wall, ref.w, wall.heightLeft, wall.heightRight), { x: ref.w, h: wall.heightRight }]
|
||||
.map(q => ({ x: q.x - ref.u0, h: q.h }));
|
||||
const at = (x) => {
|
||||
let i = 0;
|
||||
while (i < pts.length - 2 && x > pts[i + 1].x) i++;
|
||||
const a = pts[i], b = pts[i + 1];
|
||||
return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h);
|
||||
};
|
||||
return { hL: Math.round(at(0)), hR: Math.round(at(W)), tops: pts.slice(1, -1).filter(q => q.x > 1 && q.x < W - 1) };
|
||||
}
|
||||
function topParamsOn(wallLayout, wall) {
|
||||
const { hL, hR, tops } = topLineOn(wallLayout, wall), W = wallLayout.width;
|
||||
const mid = tops.length === 1 && Math.abs(tops[0].x - W / 2) < 0.5;
|
||||
return { heightLeft: hL, heightRight: hR, heightMid: mid ? tops[0].h : null, topPts: tops.length && !mid ? tops.map(q => ({ f: q.x / W, h: q.h })) : undefined };
|
||||
}
|
||||
function topParamsFromLayout(wallLayout, wall) {
|
||||
const ref = wallLayout.ref;
|
||||
if (!ref || (ref.u0 === 0 && ref.w === wallLayout.width)) return wall;
|
||||
const back = { width: ref.w, ref: { w: wallLayout.width, u0: -ref.u0 } };
|
||||
const p = topParamsOn(back, Object.assign({}, wall, { width: wallLayout.width }));
|
||||
Object.assign(wall, p, { heightAll: Math.min(p.heightLeft, p.heightRight) });
|
||||
if (!p.topPts) delete wall.topPts;
|
||||
return wall;
|
||||
}
|
||||
|
||||
function wallPieces(wallLayout, wallState, raised) {
|
||||
const fullConfig = WallModel.configFromState(wallState);
|
||||
const W = wallLayout.width;
|
||||
const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight;
|
||||
// Middenpunt (nok) midden op de wand; een stuk krijgt de nok alleen als
|
||||
// die erin valt, anders de daklijn ter plaatse als gewone helling.
|
||||
const hM = wallState.wall.heightMid > 0 ? Math.max(wallState.wall.heightMid, hL, hR) : null;
|
||||
const pU = W / 2;
|
||||
const heightAt = (u) => hM === null ? hL + (u / W) * (hR - hL)
|
||||
: u <= pU ? hL + (u / pU) * (hM - hL) : hM + ((u - pU) / (W - pU)) * (hR - hM);
|
||||
const { hL, hR, tops } = topLineOn(wallLayout, wallState.wall);
|
||||
const tp = [{ x: 0, h: hL }, ...tops, { x: W, h: hR }];
|
||||
const heightAt = (u) => {
|
||||
let i = 0;
|
||||
while (i < tp.length - 2 && u > tp[i + 1].x) i++;
|
||||
const a = tp[i], b = tp[i + 1];
|
||||
return a.h + (u - a.x) / (b.x - a.x) * (b.h - a.h);
|
||||
};
|
||||
const topsIn = (a, b, base) => {
|
||||
const inner = tops.filter(q => q.x > a + 1 && q.x < b - 1);
|
||||
return inner.length ? { heightMid: null, topPts: inner.map(q => ({ f: (q.x - a) / (b - a), h: q.h - base })) } : { heightMid: null, topPts: undefined };
|
||||
};
|
||||
const multi = wallLayout.pieces.length > 1;
|
||||
const used = new Set();
|
||||
|
||||
const pieces = wallLayout.pieces.map(([a, b, inL, inR], 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 hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
|
||||
.map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, chimney: op.chimney, koppel: op.koppel, w: op.w, h: op.h, hR: op.hR, 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)),
|
||||
heightMid: hasPeak ? hM : null, peakX: hasPeak ? pU - a : undefined
|
||||
});
|
||||
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b))
|
||||
}, topsIn(a, b, 0));
|
||||
const config = WallModel.buildConfig(params, openings);
|
||||
return {
|
||||
suffix: multi ? String.fromCharCode(97 + i) : '', // a, b, ...
|
||||
suffix: multi ? String.fromCharCode(97 + i) : '',
|
||||
u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2,
|
||||
// Loopt het stuk een naad in (binnenhoek), dan ligt dat deel binnen
|
||||
// de wand van de buur-unit: buitenlagen daar niet (negatieve
|
||||
// doorloop, zie WallModel.cornerExt) en een binnenplaat op de kopse kant.
|
||||
skinExt: {
|
||||
left: inL > EPS ? -inL : a < EPS ? wallLayout.skinExt.left : 0,
|
||||
right: inR > EPS ? -inR : b > W - EPS ? wallLayout.skinExt.right : 0
|
||||
@@ -228,22 +219,18 @@ window.UnitModel = (() => {
|
||||
const ins = wallLayout.endInset || { left: 0, right: 0 };
|
||||
raisedList.forEach((r, i) => {
|
||||
const { u0: a, u1: b, base } = r;
|
||||
const hasPeak = hM !== null && a < pU - 1 && b > pU + 1;
|
||||
const hA = heightAt(a) - base, hB = heightAt(b) - base;
|
||||
if (Math.min(hA, hB) < MIN_RAISE_MM) return;
|
||||
const openings = fullConfig.openings
|
||||
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS)
|
||||
.map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, y: op.y - base, anchor: 'left', offset: op.x - a }; });
|
||||
.map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, chimney: op.chimney, koppel: op.koppel, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; });
|
||||
const params = Object.assign({}, wallState.wall, {
|
||||
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB),
|
||||
heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined
|
||||
});
|
||||
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB)
|
||||
}, topsIn(a, b, base));
|
||||
const config = WallModel.buildConfig(params, openings);
|
||||
pieces.push({
|
||||
suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''), // opbouw
|
||||
suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''),
|
||||
u0: a, u1: b, lxCenter: -W / 2 + (a + b) / 2, baseY: Math.round(base),
|
||||
// Naast de haakse wand in de hoek (endInset) bedekken de buitenlagen
|
||||
// ook diens kopse kant, die boven het lage dak bloot ligt.
|
||||
skinExt: {
|
||||
left: ins.left > 0 && a <= ins.left + EPS ? ins.left : 0,
|
||||
right: ins.right > 0 && b >= W - ins.right - EPS ? ins.right : 0
|
||||
@@ -255,15 +242,6 @@ window.UnitModel = (() => {
|
||||
return { pieces, dropped, fullConfig };
|
||||
}
|
||||
|
||||
// Wandhoogtes koppelen, zodat er altijd een logisch dak op past. De
|
||||
// bewerkte wand (side) bepaalt de dakvorm:
|
||||
// - de tegenoverliggende wand krijgt dezelfde hoogtes gespiegeld (van
|
||||
// buitenaf gezien wisselen links en rechts), incl. middenpunt (nok);
|
||||
// - de twee haakse wanden worden vlak op de hoogte van de hoek waar ze
|
||||
// op aansluiten (goothoogte bij een lessenaar- of zadeldak).
|
||||
// Voorbeeld: west zuid hoger dan noord -> oost ook, noordwand vlak op de
|
||||
// lage, zuidwand vlak op de hoge hoogte. Muteert state.
|
||||
// Welke haakse wand zit aan de linker/rechterkant (van buitenaf gezien).
|
||||
const WALL_ENDS = {
|
||||
voor: { left: 'rechts', right: 'links' },
|
||||
achter: { left: 'links', right: 'rechts' },
|
||||
@@ -271,22 +249,136 @@ window.UnitModel = (() => {
|
||||
rechts: { left: 'achter', right: 'voor' }
|
||||
};
|
||||
const OPPOSITE = { voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' };
|
||||
function gableSpan(state, side) {
|
||||
const layout = computeLayout(state, {});
|
||||
const wl = layout.walls.find(x => x.side === side);
|
||||
const s = WALL_SIDES.find(x => x.key === side);
|
||||
const span = s.axis === 'x' ? layout.floorWidth : layout.floorDepth;
|
||||
const c = s.axis === 'x' ? wl.cx : wl.cz;
|
||||
const left = c - s.dir * wl.width / 2, right = c + s.dir * wl.width / 2;
|
||||
const e0 = Math.max(0, Math.abs(left + s.dir * span / 2)), e1 = Math.max(0, Math.abs(s.dir * span / 2 - right));
|
||||
return { e0, e1, w: wl.width, span, layout };
|
||||
}
|
||||
function fullTopLine(wall, g) {
|
||||
const pts = [{ u: g.e0, h: wall.heightLeft }]
|
||||
.concat(WallModel.wallTopPoints(wall, g.w, wall.heightLeft, wall.heightRight).map(q => ({ u: g.e0 + q.x, h: q.h })))
|
||||
.concat([{ u: g.e0 + g.w, h: wall.heightRight }]);
|
||||
const n = pts.length, s0 = (pts[1].h - pts[0].h) / (pts[1].u - pts[0].u), s1 = (pts[n - 1].h - pts[n - 2].h) / (pts[n - 1].u - pts[n - 2].u);
|
||||
pts[0] = { u: 0, h: pts[0].h - g.e0 * s0 };
|
||||
pts[n - 1] = { u: g.span, h: pts[n - 1].h + g.e1 * s1 };
|
||||
return pts;
|
||||
}
|
||||
function setWallTopLine(wall, g, line) {
|
||||
const at = (u) => {
|
||||
let i = 0;
|
||||
while (i < line.length - 2 && u > line[i + 1].u) i++;
|
||||
const a = line[i], b = line[i + 1];
|
||||
return a.h + (u - a.u) / (b.u - a.u) * (b.h - a.h);
|
||||
};
|
||||
const hL = Math.round(at(g.e0)), hR = Math.round(at(g.e0 + g.w));
|
||||
const inner = line.slice(1, -1).filter(q => q.u > g.e0 + 1 && q.u < g.e0 + g.w - 1).map(q => ({ f: (q.u - g.e0) / g.w, h: Math.round(q.h) }));
|
||||
const mid = inner.length === 1 && Math.abs(inner[0].f - 0.5) < 1e-3;
|
||||
Object.assign(wall, { heightLeft: hL, heightRight: hR, heightMid: mid ? inner[0].h : null, heightAll: Math.min(hL, hR) });
|
||||
if (inner.length && !mid) wall.topPts = inner; else delete wall.topPts;
|
||||
}
|
||||
function syncWallHeights(state, side) {
|
||||
const w = state.walls[side].wall;
|
||||
const mid = w.heightMid > 0 ? w.heightMid : null;
|
||||
const set = (key, hL, hR, hM) => {
|
||||
const pts = Array.isArray(w.topPts) && w.topPts.length ? w.topPts : null;
|
||||
// De laagste van de twee: zo steekt de wand nooit door het dak (de wig vult een schuine regel op).
|
||||
const g = gableSpan(state, side), full = fullTopLine(w, g), n = full.length;
|
||||
const thick = (key) => (g.layout.walls.find(x => x.side === key) || {}).thickness || 0;
|
||||
const s0 = (full[1].h - full[0].h) / (full[1].u - full[0].u), s1 = (full[n - 1].h - full[n - 2].h) / (full[n - 1].u - full[n - 2].u);
|
||||
const edgeL = Math.round(Math.min(full[0].h, full[0].h + thick(WALL_ENDS[side].left) * s0));
|
||||
const edgeR = Math.round(Math.min(full[n - 1].h, full[n - 1].h - thick(WALL_ENDS[side].right) * s1));
|
||||
const set = (key, hL, hR, hM, tp) => {
|
||||
const t = state.walls[key].wall;
|
||||
t.heightLeft = hL; t.heightRight = hR; t.heightMid = hM;
|
||||
t.heightAll = hM === null && hL === hR ? hL : w.heightAll;
|
||||
if (tp) t.topPts = tp; else delete t.topPts;
|
||||
t.heightAll = hM === null && !tp && hL === hR ? hL : w.heightAll;
|
||||
};
|
||||
set(OPPOSITE[side], w.heightRight, w.heightLeft, mid);
|
||||
set(WALL_ENDS[side].left, w.heightLeft, w.heightLeft, null);
|
||||
set(WALL_ENDS[side].right, w.heightRight, w.heightRight, null);
|
||||
set(OPPOSITE[side], w.heightRight, w.heightLeft, mid, pts && pts.map(q => ({ f: 1 - q.f, h: q.h })).reverse());
|
||||
set(WALL_ENDS[side].left, edgeL, edgeL, null);
|
||||
set(WALL_ENDS[side].right, edgeR, edgeR, null);
|
||||
}
|
||||
// Hoogste punt van een wand (incl. nok).
|
||||
function wallTopOf(wallState) {
|
||||
const w = wallState.wall;
|
||||
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0);
|
||||
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h));
|
||||
}
|
||||
|
||||
// cutRatio gaat voor cut, zodat ze meeschalen als goot of nok verandert.
|
||||
const LIMITS = { ridge: 5000, eave: 3000, short: 4700, long: 14000 };
|
||||
function clampSize(floor, keep) {
|
||||
floor.width = Math.min(LIMITS.long, floor.width);
|
||||
floor.depth = Math.min(LIMITS.long, floor.depth);
|
||||
if (Math.min(floor.width, floor.depth) > LIMITS.short) {
|
||||
const k = keep || (floor.width <= floor.depth ? 'width' : 'depth');
|
||||
floor[k] = LIMITS.short;
|
||||
}
|
||||
return floor;
|
||||
}
|
||||
const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', wolf: 'Wolfsdak' };
|
||||
const RIDGES = { z: 'Noord–zuid', x: 'West–oost' };
|
||||
const hasRidge = (type) => type === 'zadel' || type === 'wolf';
|
||||
function gableOf(form) {
|
||||
if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter';
|
||||
if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links';
|
||||
return 'achter';
|
||||
}
|
||||
function roofForm(unitState) {
|
||||
const W = unitState.walls;
|
||||
for (const side of ['achter', 'voor', 'links', 'rechts']) {
|
||||
const w = W[side].wall;
|
||||
const ridge = side === 'voor' || side === 'achter' ? 'z' : 'x';
|
||||
const full = fullTopLine(w, gableSpan(unitState, side));
|
||||
const F = full[full.length - 1].u, inner = full.slice(1, -1);
|
||||
const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h));
|
||||
// v0.6.2: mansarde vervallen; een opgeslagen mansarde wordt een zadeldak met dezelfde nok.
|
||||
if (inner.length === 3) return { type: 'zadel', ridge, low, high: Math.round(inner[1].h) };
|
||||
if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) {
|
||||
const slope = (inner[0].h - L.h) / Math.max(1, inner[0].u);
|
||||
const high = Math.round(L.h + slope * F / 2);
|
||||
return { type: 'wolf', ridge, low, high, cut: Math.round(inner[0].h), cutRatio: (inner[0].h - low) / Math.max(1, high - low) };
|
||||
}
|
||||
if (inner.length === 1) return { type: 'zadel', ridge, low, high: Math.round(inner[0].h) };
|
||||
if (Math.abs(L.h - R.h) > 0.5) {
|
||||
const dir = R.h > L.h ? WALL_ENDS[side].right : WALL_ENDS[side].left;
|
||||
return { type: 'lessenaar', dir, low, high: Math.round(Math.max(L.h, R.h)) };
|
||||
}
|
||||
}
|
||||
const h = Math.max(...Object.values(W).map(wallTopOf));
|
||||
return { type: 'plat', low: h, high: h };
|
||||
}
|
||||
function dropMansarde(unitState) {
|
||||
const f = roofForm(unitState), side = f.type === 'zadel' ? gableOf(f) : null;
|
||||
if (side && fullTopLine(unitState.walls[side].wall, gableSpan(unitState, side)).length === 5) applyRoofForm(unitState, f);
|
||||
}
|
||||
function applyRoofForm(unitState, form) {
|
||||
const side = form.type !== 'plat' ? gableOf(form) : 'achter';
|
||||
const g = gableSpan(unitState, side);
|
||||
const line = formLine(form, g.span, Math.max(g.e0, g.e1), WALL_ENDS[side].left === form.dir);
|
||||
setWallTopLine(unitState.walls[side].wall, g, line);
|
||||
syncWallHeights(unitState, side);
|
||||
}
|
||||
function formLine(form, F, eMin, highAtStart) {
|
||||
const low = Math.round(Math.min(LIMITS.eave, form.low));
|
||||
const high = Math.round(Math.min(LIMITS.ridge, Math.max(low, form.high || low)));
|
||||
const sloped = form.type !== 'plat' && high > low;
|
||||
const half = F / 2;
|
||||
let line = [{ u: 0, h: low }, { u: F, h: low }];
|
||||
if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
||||
else if (sloped && form.type === 'wolf') {
|
||||
const minR = (eMin + 50) / half;
|
||||
const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6;
|
||||
const cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low))));
|
||||
const uc = (cut - low) / (high - low) * half;
|
||||
line = high - low > 100
|
||||
? [{ u: 0, h: low }, { u: uc, h: cut }, { u: F - uc, h: cut }, { u: F, h: low }]
|
||||
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
||||
} else if (sloped) {
|
||||
line = [{ u: 0, h: highAtStart ? high : low }, { u: F, h: highAtStart ? low : high }];
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function validateState(design) {
|
||||
@@ -302,5 +394,5 @@ window.UnitModel = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
return { WALL_SIDES, CORNER_MODES, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, validateState };
|
||||
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, dropMansarde, applyRoofForm, formLine, validateState };
|
||||
})();
|
||||
|
||||
Vendored
+79
-21
@@ -4,7 +4,9 @@
|
||||
// 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.
|
||||
// links-slepen = roteren, scrollwiel = zoomen; zijwaarts schuiven (pannen) met rechts- of middelste-knop-slepen,
|
||||
// Shift/Ctrl/⌘ + links-slepen, of de pijltjestoetsen terwijl de muis boven de weergave staat;
|
||||
// touch: één vinger = roteren, twee vingers = knijpen (zoomen) en samen schuiven (pannen).
|
||||
//
|
||||
// Moet NA three.min.js geladen worden (leunt op window.THREE).
|
||||
// ============================================================
|
||||
@@ -49,7 +51,9 @@
|
||||
};
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (event.button === 2) {
|
||||
const panButton = event.button === 2 || event.button === 1 || (event.button === 0 && (event.shiftKey || event.ctrlKey || event.metaKey));
|
||||
if (event.button === 1) event.preventDefault(); // geen autoscroll
|
||||
if (panButton) {
|
||||
if (!scope.enablePan) return;
|
||||
isPanning = true;
|
||||
} else {
|
||||
@@ -60,13 +64,7 @@
|
||||
lastY = event.clientY;
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (isRotating) {
|
||||
const dx = event.clientX - lastX;
|
||||
const dy = event.clientY - lastY;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
|
||||
function rotate(dx, dy) {
|
||||
const w = scope.domElement.clientWidth || 1;
|
||||
const h = scope.domElement.clientHeight || 1;
|
||||
|
||||
@@ -79,12 +77,8 @@
|
||||
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;
|
||||
|
||||
}
|
||||
function pan(dx, dy) {
|
||||
const distance = scope.object.position.distanceTo(scope.target);
|
||||
const fov = (scope.object.fov || 50) * Math.PI / 180;
|
||||
const h = scope.domElement.clientHeight || 1;
|
||||
@@ -95,7 +89,22 @@
|
||||
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 zoom(scale) {
|
||||
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 onPointerMove(event) {
|
||||
if (!isRotating && !isPanning) return;
|
||||
const dx = event.clientX - lastX;
|
||||
const dy = event.clientY - lastY;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
if (isRotating) rotate(dx, dy);
|
||||
else pan(dx, dy);
|
||||
}
|
||||
|
||||
function onPointerUp() { isRotating = false; isPanning = false; }
|
||||
@@ -103,20 +112,62 @@
|
||||
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);
|
||||
zoom(event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale());
|
||||
}
|
||||
|
||||
// Touch: midden en afstand van de vingers; bij een wissel van het aantal vingers opnieuw beginnen.
|
||||
let touch = null;
|
||||
function touchState(list) {
|
||||
const a = list[0], b = list[1] || list[0];
|
||||
return { n: list.length, x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2, d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) };
|
||||
}
|
||||
function onTouchStart(event) {
|
||||
event.preventDefault();
|
||||
touch = touchState(event.touches);
|
||||
}
|
||||
function onTouchMove(event) {
|
||||
if (!touch) return;
|
||||
event.preventDefault();
|
||||
const t = touchState(event.touches);
|
||||
if (t.n !== touch.n) { touch = t; return; }
|
||||
if (t.n === 1) { if (scope.enableRotate) rotate(t.x - touch.x, t.y - touch.y); }
|
||||
else {
|
||||
if (scope.enablePan) pan(t.x - touch.x, t.y - touch.y);
|
||||
if (scope.enableZoom && t.d > 0 && touch.d > 0) zoom(touch.d / t.d);
|
||||
}
|
||||
touch = t;
|
||||
}
|
||||
function onTouchEnd(event) { touch = event.touches.length ? touchState(event.touches) : null; }
|
||||
|
||||
function onContextMenu(event) { event.preventDefault(); }
|
||||
|
||||
// Pijltjestoetsen: schuiven terwijl de muis boven de weergave staat (niet in een invoerveld).
|
||||
let hover = false;
|
||||
const onEnter = () => { hover = true; };
|
||||
const onLeave = () => { hover = false; };
|
||||
const KEY_PAN = { ArrowLeft: [1, 0], ArrowRight: [-1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
|
||||
function onKeyDown(event) {
|
||||
const d = KEY_PAN[event.key];
|
||||
if (!d || !hover || !scope.enablePan || event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
const t = event.target, tag = t && t.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (t && t.isContentEditable)) return;
|
||||
event.preventDefault();
|
||||
const step = (scope.domElement.clientHeight || 400) * (event.shiftKey ? 0.25 : 0.08);
|
||||
pan(d[0] * step, d[1] * step);
|
||||
}
|
||||
|
||||
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.domElement.addEventListener('mouseenter', onEnter);
|
||||
this.domElement.addEventListener('mouseleave', onLeave);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
this.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
|
||||
this.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
this.domElement.addEventListener('touchend', onTouchEnd);
|
||||
this.domElement.addEventListener('touchcancel', onTouchEnd);
|
||||
|
||||
this.dispose = function () {
|
||||
scope.domElement.removeEventListener('mousedown', onPointerDown);
|
||||
@@ -124,6 +175,13 @@
|
||||
window.removeEventListener('mouseup', onPointerUp);
|
||||
scope.domElement.removeEventListener('wheel', onWheel);
|
||||
scope.domElement.removeEventListener('contextmenu', onContextMenu);
|
||||
scope.domElement.removeEventListener('mouseenter', onEnter);
|
||||
scope.domElement.removeEventListener('mouseleave', onLeave);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
scope.domElement.removeEventListener('touchstart', onTouchStart);
|
||||
scope.domElement.removeEventListener('touchmove', onTouchMove);
|
||||
scope.domElement.removeEventListener('touchend', onTouchEnd);
|
||||
scope.domElement.removeEventListener('touchcancel', onTouchEnd);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1117
File diff suppressed because one or more lines are too long
Vendored
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Vendored
+2297
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// Kunststof kozijnen naar de Aluplast Ideal 7000 (examples/Buiten kozijnen.pdf).
|
||||
// links/rechts (leaf, hinge, dir) zijn van buitenaf gezien = vanaf +z.
|
||||
// userData.door: open/target in radialen (draaien) of meters (schuiven); de loopmodus (house.js) zet target.
|
||||
|
||||
window.KozijnModel = (() => {
|
||||
const COLORS = {
|
||||
wit: { label: 'Wit', ral: 'RAL 9016', hex: 0xf1f0ea },
|
||||
grijs: { label: 'Grijs', ral: 'RAL 7038', hex: 0xaeb0aa },
|
||||
antraciet: { label: 'Antraciet', ral: 'RAL 7016', hex: 0x383e42 },
|
||||
zwart: { label: 'Zwart', ral: 'RAL 9005', hex: 0x151515 }
|
||||
};
|
||||
const FILL_TYPES = { geen: 'Geen (open)', raam: 'Raam (vast glas)', deur: 'Deur', schuifdeur: 'Schuifdeur' };
|
||||
const HINGES = { links: 'Links', rechts: 'Rechts' };
|
||||
const SWINGS = { binnen: 'Naar binnen', buiten: 'Naar buiten' };
|
||||
const SLIDE_MOUNTS = { inbouw: 'In de wand', opbouw: 'Op de wand (rail)' };
|
||||
const SLIDE_SIDES = { binnen: 'Binnenzijde', buiten: 'Buitenzijde' };
|
||||
const SLIDE_DIRS = { links: 'Naar links', rechts: 'Naar rechts' };
|
||||
const DEFAULT_OUT = 'antraciet', DEFAULT_IN = 'wit';
|
||||
|
||||
const P = {
|
||||
gap: 5,
|
||||
kader: 70,
|
||||
depth: 120,
|
||||
dorpel: 30,
|
||||
tussen: 70, // tussenstijl/-regel (deur naast zijlicht, onder bovenlicht)
|
||||
rooster: 80,
|
||||
glass: 24,
|
||||
leafW: 930, // deurblad (binnenmaat kader) als er een zijlicht naast past
|
||||
leafH: 2200, // deurblad als er een bovenlicht boven past
|
||||
leafT: 68,
|
||||
minLight: 200
|
||||
};
|
||||
|
||||
const colorKey = (k, def) => COLORS[k] ? k : def;
|
||||
const pick = (v, opts, def) => opts[v] ? v : def;
|
||||
function normalize(fill) {
|
||||
if (!fill || !FILL_TYPES[fill.type] || fill.type === 'geen') return null;
|
||||
const f = {
|
||||
type: fill.type, rooster: !!fill.rooster, glas: fill.glas !== false,
|
||||
out: colorKey(fill.out, DEFAULT_OUT), in: colorKey(fill.in, DEFAULT_IN)
|
||||
};
|
||||
if (f.type === 'deur') {
|
||||
f.hinge = pick(fill.hinge, HINGES, 'links');
|
||||
f.leaf = pick(fill.leaf, HINGES, f.hinge);
|
||||
f.swing = pick(fill.swing, SWINGS, 'binnen');
|
||||
f.volglas = !!fill.volglas;
|
||||
}
|
||||
if (f.type === 'schuifdeur') Object.assign(f, {
|
||||
leaves: Number(fill.leaves) === 2 ? 2 : 1, mount: pick(fill.mount, SLIDE_MOUNTS, 'inbouw'),
|
||||
side: pick(fill.side, SLIDE_SIDES, 'binnen'), dir: pick(fill.dir, SLIDE_DIRS, 'links')
|
||||
});
|
||||
return f;
|
||||
}
|
||||
function defaultFill(openingType, like) {
|
||||
const l = normalize(like) || {};
|
||||
const type = openingType === 'deur' || openingType === 'voordeur' ? 'deur' : 'raam';
|
||||
return { type, rooster: false, glas: openingType === 'voordeur', out: l.out || DEFAULT_OUT, in: l.in || DEFAULT_IN };
|
||||
}
|
||||
|
||||
function insetPoly(pts, d) {
|
||||
const n = pts.length;
|
||||
const lines = pts.map((p, i) => {
|
||||
const q = pts[(i + 1) % n];
|
||||
const len = Math.hypot(q.x - p.x, q.y - p.y) || 1;
|
||||
const dx = (q.x - p.x) / len, dy = (q.y - p.y) / len;
|
||||
return { x: p.x - dy * d[i], y: p.y + dx * d[i], dx, dy };
|
||||
});
|
||||
return pts.map((p, i) => {
|
||||
const a = lines[(i - 1 + n) % n], b = lines[i];
|
||||
const den = a.dx * b.dy - a.dy * b.dx;
|
||||
if (Math.abs(den) < 1e-9) return { x: b.x, y: b.y };
|
||||
const t = ((b.x - a.x) * b.dy - (b.y - a.y) * b.dx) / den;
|
||||
return { x: a.x + a.dx * t, y: a.y + a.dy * t };
|
||||
});
|
||||
}
|
||||
function clipPoly(pts, f) {
|
||||
const out = [];
|
||||
pts.forEach((p, i) => {
|
||||
const q = pts[(i + 1) % pts.length];
|
||||
const fp = f(p), fq = f(q);
|
||||
if (fp >= 0) out.push(p);
|
||||
if ((fp >= 0) !== (fq >= 0)) {
|
||||
const t = fp / (fp - fq);
|
||||
out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
// >= 0 onder de bovenrand q[3]-q[2] van een vierhoek.
|
||||
const below = (q) => (p) => {
|
||||
const a = q[3], b = q[2];
|
||||
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
|
||||
};
|
||||
const rect = (x0, x1, y0, y1) => [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }];
|
||||
const areaOf = (pts) => Math.abs(pts.reduce((a, p, i) => { const q = pts[(i + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2;
|
||||
|
||||
const mats = {};
|
||||
function colorMat(key) {
|
||||
const k = 'c:' + colorKey(key, DEFAULT_OUT);
|
||||
return mats[k] || (mats[k] = new THREE.MeshStandardMaterial({ color: COLORS[colorKey(key, DEFAULT_OUT)].hex, roughness: 0.45 }));
|
||||
}
|
||||
function mat(key) {
|
||||
if (mats[key]) return mats[key];
|
||||
if (key === 'glass') mats[key] = new THREE.MeshStandardMaterial({ color: 0x9fbfcc, roughness: 0.05, metalness: 0.2, transparent: true, opacity: 0.28, depthWrite: false, side: THREE.DoubleSide });
|
||||
else if (key === 'rubber') mats[key] = new THREE.MeshStandardMaterial({ color: 0x1b1b1b, roughness: 0.8 });
|
||||
else mats[key] = new THREE.MeshStandardMaterial({ color: 0x9a9da0, roughness: 0.3, metalness: 0.7 });
|
||||
return mats[key];
|
||||
}
|
||||
|
||||
const toM = 0.001;
|
||||
const toShape = (pts, holes) => {
|
||||
const s = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.y * toM)));
|
||||
(holes || []).forEach(h => s.holes.push(new THREE.Path(h.map(p => new THREE.Vector2(p.x * toM, p.y * toM)))));
|
||||
return s;
|
||||
};
|
||||
function slab(g, pts, holes, z0, z1, matIn, matOut, shadow) {
|
||||
if (pts.length < 3 || areaOf(pts) < 1) return;
|
||||
const FOIL = matOut && matOut !== matIn ? 1 : 0;
|
||||
const add = (a, b, m) => {
|
||||
const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(toShape(pts, holes), { depth: (b - a) * toM, bevelEnabled: false }), m);
|
||||
mesh.position.z = a * toM;
|
||||
mesh.castShadow = shadow !== false;
|
||||
mesh.receiveShadow = true;
|
||||
g.add(mesh);
|
||||
};
|
||||
add(z0, z1 - FOIL, matIn);
|
||||
if (FOIL) add(z1 - FOIL, z1, matOut);
|
||||
}
|
||||
const box = (g, m, x, y, z, w, h, d) => {
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), m);
|
||||
mesh.position.set(x * toM, y * toM, z * toM);
|
||||
mesh.castShadow = true;
|
||||
g.add(mesh);
|
||||
};
|
||||
|
||||
// Gekoppelde zijde (op.joinL/joinR, zie WallModel.resolveOpenings): geen speling en geen kaderstijl;
|
||||
// 'stijl' = de helft van een tussenstijl die het kozijn ernaast afmaakt, 'stolp' = niets (bladen tegen elkaar).
|
||||
const sideGap = (join) => join ? 0 : P.gap;
|
||||
const sideKader = (join) => join === 'stolp' ? 0 : join ? P.tussen / 2 : P.kader;
|
||||
|
||||
// Lokaal: x vanaf links, y vanaf de onderkant, +z = buiten (mm in, meters uit).
|
||||
function build3D(op, fill, place) {
|
||||
const f = normalize(fill);
|
||||
if (!f) return null;
|
||||
const g = new THREE.Group();
|
||||
const mIn = colorMat(f.in), mOut = colorMat(f.out);
|
||||
const depth = Math.min(P.depth, place.depth || P.depth);
|
||||
const z1 = place.zOut, z0 = z1 - depth;
|
||||
const hR = op.hR !== undefined ? op.hR : op.h;
|
||||
const kL = sideKader(op.joinL), kR = sideKader(op.joinR);
|
||||
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, sideGap(op.joinR), P.gap, sideGap(op.joinL)]);
|
||||
const glass = (pts) => slab(g, pts, null, z1 - 50 - P.glass / 2, z1 - 50 + P.glass / 2, mat('glass'), null, false);
|
||||
|
||||
if (f.type === 'raam') {
|
||||
const inner = insetPoly(outer, [P.kader, kR, P.kader, kL]);
|
||||
slab(g, outer, [inner], z0, z1, mIn, mOut);
|
||||
let pane = inner;
|
||||
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) {
|
||||
pane = insetPoly(inner, [0, 0, P.rooster, 0]);
|
||||
const band = [pane[3], pane[2], inner[2], inner[3]];
|
||||
slab(g, [band[0], band[1], band[2], band[3]], null, z1 - 105, z1 - 8, mIn, mOut);
|
||||
[0.3, 0.55, 0.8].forEach(t => {
|
||||
const a = insetPoly(inner, [0, 20, P.rooster * t - 4, 20]), b = insetPoly(inner, [0, 20, P.rooster * t + 4, 20]);
|
||||
const slot = [b[3], b[2], a[2], a[3]];
|
||||
slab(g, slot, null, z1 - 8, z1 - 6, mat('rubber'));
|
||||
slab(g, slot, null, z1 - 107, z1 - 105, mat('rubber'));
|
||||
});
|
||||
}
|
||||
glass(pane);
|
||||
return g;
|
||||
}
|
||||
|
||||
const sill = place.exterior !== false ? P.dorpel : 0;
|
||||
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
|
||||
const inner = insetPoly(frameOuter, [0, kR, P.kader, kL]);
|
||||
const u = [...(kL ? [frameOuter[0], inner[0]] : []), inner[3], inner[2], ...(kR ? [inner[1], frameOuter[1]] : []), frameOuter[2], frameOuter[3]];
|
||||
slab(g, u, null, z0, z1, mIn, mOut);
|
||||
if (sill) box(g, mat('rubber'), (outer[0].x + outer[1].x) / 2, outer[0].y + sill / 2, z1 - depth / 2 + 15, outer[1].x - outer[0].x, sill, depth + 30);
|
||||
|
||||
const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y;
|
||||
const top = below(inner);
|
||||
const topAt = (x) => inner[3].y + (x - inner[3].x) / (inner[2].x - inner[3].x) * (inner[2].y - inner[3].y);
|
||||
const region = (x0, x1, y0, y1) => clipPoly(rect(x0, x1, y0, Math.min(y1, 1e6)), top);
|
||||
const bar = (pts) => slab(g, pts, null, z0 + 10, z1 - 10, mIn, mOut);
|
||||
const link = `${Math.round(op.x || 0)}|${Math.round(op.y || 0)}|${op.w}`;
|
||||
if (f.type === 'schuifdeur') {
|
||||
buildSlideLeaves(g, f, { ix0, ix1, iy0, topAt, z0, z1, mIn, mOut, sill, place, link, op });
|
||||
return g;
|
||||
}
|
||||
|
||||
const hingeR = f.hinge === 'rechts';
|
||||
const d = doorSplit(f, ix0, ix1, iy0, Math.min(topAt(ix0), topAt(ix1)));
|
||||
const { side, leafR, la, lb, transom, ly1 } = d;
|
||||
if (side) {
|
||||
const [t0, g0, g1] = leafR ? [la - P.tussen, ix0, la - P.tussen] : [lb, lb + P.tussen, ix1];
|
||||
bar(region(t0, t0 + P.tussen, iy0, ly1));
|
||||
bar(region(g0, g1, iy0, iy0 + P.kader));
|
||||
glass(region(g0, g1, iy0 + P.kader, ly1));
|
||||
}
|
||||
if (transom) {
|
||||
bar(region(ix0, ix1, ly1, ly1 + P.tussen));
|
||||
glass(region(ix0, ix1, ly1 + P.tussen, Infinity));
|
||||
}
|
||||
|
||||
const L = doorLeaf(f, d, iy0, sill, top, topAt);
|
||||
if (!L) return g;
|
||||
const { leaf, lx0, lxR, lb0, lt, lw, holes } = L;
|
||||
const lzOut = z1 - 15, lzIn = lzOut - P.leafT;
|
||||
const outward = f.swing === 'buiten';
|
||||
const hx = hingeR ? lxR : lx0, hz = outward ? lzOut : lzIn;
|
||||
const pivot = new THREE.Group(), lg = new THREE.Group();
|
||||
pivot.position.set(hx * toM, 0, hz * toM);
|
||||
lg.position.set(-hx * toM, 0, -hz * toM);
|
||||
pivot.add(lg);
|
||||
g.add(pivot);
|
||||
if (holes) slab(lg, holes[0], null, (lzIn + lzOut) / 2 - P.glass / 2, (lzIn + lzOut) / 2 + P.glass / 2, mat('glass'), null, false);
|
||||
slab(lg, leaf, holes, lzIn, lzOut, mIn, mOut);
|
||||
const hy = Math.min(lb0 + 1050, (lb0 + lt) / 2);
|
||||
if (lw >= 400) {
|
||||
const kx = hingeR ? lx0 + 75 : lxR - 75, gd = hingeR ? 60 : -60;
|
||||
[[lzOut, 1], [lzIn, -1]].forEach(([z, s]) => {
|
||||
box(lg, mat('beslag'), kx, hy - 30, z + s * 5, 34, 210, 10); // schild
|
||||
box(lg, mat('beslag'), kx, hy, z + s * 30, 20, 20, 50); // nek
|
||||
box(lg, mat('beslag'), kx + gd, hy, z + s * 55, 140, 20, 20); // greep
|
||||
});
|
||||
}
|
||||
const dir = (hingeR ? -1 : 1) * (outward ? -1 : 1);
|
||||
pivot.userData.door = {
|
||||
kind: 'swing', dir, max: Math.PI / 2, open: 0, target: 0, link,
|
||||
base: pivot.position.x, center: new THREE.Vector3((lx0 + lxR) / 2 * toM, hy * toM, (lzIn + lzOut) / 2 * toM)
|
||||
};
|
||||
return g;
|
||||
}
|
||||
|
||||
const SLIDE = { overlap: 50, gap: 12, railH: 40, railD: 45, leafT: 40, frame: 70 };
|
||||
// Deurblad en zij-/bovenlicht binnen de kaderopening ix0..ix1 vanaf iy0 (lowTop = laagste bovenkant).
|
||||
function doorSplit(f, ix0, ix1, iy0, lowTop) {
|
||||
const leafR = f.leaf === 'rechts';
|
||||
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
|
||||
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
|
||||
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
|
||||
return { side, leafR, la, lb, transom, ly1: transom ? iy0 + P.leafH : Infinity };
|
||||
}
|
||||
|
||||
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer) met het glas erin.
|
||||
function doorLeaf(f, d, iy0, sill, top, topAt) {
|
||||
const leaf = clipPoly(rect(d.la + 3, d.lb - 3, iy0 + (sill ? 3 : 10), Math.min(d.ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 }));
|
||||
if (leaf.length < 3) return null;
|
||||
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
|
||||
const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb0 = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), d.ly1) - 3;
|
||||
const lw = lxR - lx0;
|
||||
let holes = null;
|
||||
if (f.volglas && lw >= 400 && lt - lb0 >= 800) {
|
||||
holes = [rect(lx0 + 110, lxR - 110, lb0 + 140, lt - 110)]; // glasdeur: smal kader rondom
|
||||
} else if (f.glas && lw >= 500 && lt - lb0 >= 1400) {
|
||||
const gx = f.hinge === 'rechts' ? lxR - lw * 0.3 : lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
|
||||
holes = [rect(gx - gw / 2, gx + gw / 2, lb0 + 350, lt - 300)];
|
||||
}
|
||||
return { leaf, lx0, lxR, lb0, lt, lw, holes };
|
||||
}
|
||||
|
||||
function slideLeaves(f, ix0, ix1, iy0, sill, topAt) {
|
||||
const n = f.leaves, opbouw = f.mount === 'opbouw';
|
||||
const ov = opbouw ? SLIDE.overlap : -3;
|
||||
const xa = ix0 - ov, xb = ix1 + ov;
|
||||
const yb = iy0 + (sill ? 3 : 10);
|
||||
const yt = Math.min(topAt(ix0), topAt(ix1)) + (opbouw ? SLIDE.overlap : -3);
|
||||
const lw = (xb - xa) / n;
|
||||
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
|
||||
const leaves = dirs.map((dir, i) => ({
|
||||
dir, x0: xa + i * lw + (n === 2 ? (i ? 1 : 0) : 0), x1: xa + (i + 1) * lw - (n === 2 && !i ? 1 : 0)
|
||||
}));
|
||||
const pane = (l) => f.glas && l.x1 - l.x0 > 2 * SLIDE.frame + 100 ? rect(l.x0 + SLIDE.frame, l.x1 - SLIDE.frame, yb + SLIDE.frame + 30, yt - SLIDE.frame) : null;
|
||||
return { n, opbouw, xa, xb, yb, yt, lw, dirs, leaves, pane };
|
||||
}
|
||||
|
||||
function buildSlideLeaves(g, f, o) {
|
||||
const S = slideLeaves(f, o.ix0, o.ix1, o.iy0, o.sill, o.topAt);
|
||||
const { n, opbouw, xa, xb, yb, yt, lw, dirs } = S;
|
||||
const out = f.side === 'buiten';
|
||||
const faceIn = o.place.faceIn !== undefined ? o.place.faceIn : o.z0;
|
||||
const faceOut = o.place.faceOut !== undefined ? o.place.faceOut : o.z1;
|
||||
const lt = opbouw ? SLIDE.leafT : P.leafT;
|
||||
const [lz0, lz1] = opbouw
|
||||
? (out ? [faceOut + SLIDE.gap, faceOut + SLIDE.gap + lt] : [faceIn - SLIDE.gap - lt, faceIn - SLIDE.gap])
|
||||
: [(faceIn + faceOut) / 2 - lt / 2, (faceIn + faceOut) / 2 + lt / 2];
|
||||
S.leaves.forEach(l => {
|
||||
const { dir, x0, x1 } = l;
|
||||
const lg = new THREE.Group();
|
||||
g.add(lg);
|
||||
const pts = rect(x0, x1, yb, yt);
|
||||
const pane = S.pane(l);
|
||||
if (pane) {
|
||||
slab(lg, pts, [pane], lz0, lz1, colorMat(f.in), opbouw ? null : colorMat(f.out));
|
||||
slab(lg, pane, null, (lz0 + lz1) / 2 - 4, (lz0 + lz1) / 2 + 4, mat('glass'), null, false);
|
||||
} else {
|
||||
slab(lg, pts, null, lz0, lz1, colorMat(f.in), opbouw ? null : colorMat(f.out));
|
||||
}
|
||||
const hx = dir < 0 ? x1 - 60 : x0 + 60, hy = Math.min(yb + 1050, (yb + yt) / 2);
|
||||
if (opbouw) [lz0 - 18, lz1 + 18].forEach(z => box(lg, mat('beslag'), hx, hy, z, 20, 400, 20));
|
||||
else [lz0 - 1, lz1 + 1].forEach(z => box(lg, mat('beslag'), hx, hy, z, 24, 160, 2));
|
||||
lg.userData.door = {
|
||||
kind: 'slide', dir, max: (x1 - x0 - (opbouw ? 2 * SLIDE.overlap : 60)) * toM, open: 0, target: 0, link: o.link,
|
||||
base: 0, center: new THREE.Vector3((x0 + x1) / 2 * toM, hy * toM, (lz0 + lz1) / 2 * toM)
|
||||
};
|
||||
});
|
||||
if (opbouw) {
|
||||
const ra = n === 2 ? xa - lw : (dirs[0] < 0 ? xa - lw : xa), rb = n === 2 ? xb + lw : (dirs[0] < 0 ? xb : xb + lw);
|
||||
const rz = out ? faceOut + SLIDE.railD / 2 : faceIn - SLIDE.railD / 2;
|
||||
box(g, mat('beslag'), (ra + rb) / 2, yt + SLIDE.railH / 2 + 5, rz, rb - ra, SLIDE.railH, SLIDE.railD);
|
||||
}
|
||||
}
|
||||
|
||||
function planSymbol(op, fill, faces) {
|
||||
const f = normalize(fill);
|
||||
if (!f || f.type === 'raam') return [];
|
||||
const zIn = faces.zIn, zOut = faces.zOut;
|
||||
const ix0 = sideGap(op.joinL) + sideKader(op.joinL), ix1 = op.w - sideGap(op.joinR) - sideKader(op.joinR);
|
||||
const out = [];
|
||||
if (f.type === 'deur') {
|
||||
const hingeR = f.hinge === 'rechts';
|
||||
const { la, lb } = doorSplit(f, ix0, ix1, 0, 0);
|
||||
const L = lb - la, hx = hingeR ? lb : la, s = hingeR ? -1 : 1;
|
||||
const outward = f.swing === 'buiten', hz = outward ? zOut : zIn, dz = outward ? 1 : -1;
|
||||
out.push({ kind: 'leaf', pts: [{ x: hx, z: hz }, { x: hx, z: hz + dz * L }] });
|
||||
const arc = [];
|
||||
for (let k = 0; k <= 16; k++) { const t = k / 16 * Math.PI / 2; arc.push({ x: hx + s * L * Math.cos(t), z: hz + dz * L * Math.sin(t) }); }
|
||||
out.push({ kind: 'arc', pts: arc });
|
||||
return out;
|
||||
}
|
||||
const n = f.leaves, opbouw = f.mount === 'opbouw', sideOut = f.side === 'buiten';
|
||||
const ov = opbouw ? SLIDE.overlap : -3;
|
||||
const xa = ix0 - ov, xb = ix1 + ov, lw = (xb - xa) / n;
|
||||
const lt = opbouw ? SLIDE.leafT : P.leafT;
|
||||
const [z0, z1] = opbouw
|
||||
? (sideOut ? [zOut + SLIDE.gap, zOut + SLIDE.gap + lt] : [zIn - SLIDE.gap - lt, zIn - SLIDE.gap])
|
||||
: [(zIn + zOut) / 2 - lt / 2, (zIn + zOut) / 2 + lt / 2];
|
||||
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
|
||||
const za = sideOut ? Math.max(zOut, z1) + 140 : Math.min(zIn, z0) - 140;
|
||||
dirs.forEach((dir, i) => {
|
||||
const x0 = xa + i * lw, x1 = x0 + lw;
|
||||
out.push({ kind: 'leaf', closed: true, pts: [{ x: x0, z: z0 }, { x: x1, z: z0 }, { x: x1, z: z1 }, { x: x0, z: z1 }] });
|
||||
const c = (x0 + x1) / 2, len = Math.min(500, lw * 0.6);
|
||||
const a = { x: c - dir * len / 2, z: za }, b = { x: c + dir * len / 2, z: za };
|
||||
out.push({ kind: 'arrow', pts: [a, b] });
|
||||
out.push({ kind: 'arrow', pts: [{ x: b.x - dir * 90, z: za - 45 }, b, { x: b.x - dir * 90, z: za + 45 }] });
|
||||
out.push({ kind: 'rail', closed: true, pts: [{ x: x0 + dir * lw, z: z0 }, { x: x1 + dir * lw, z: z0 }, { x: x1 + dir * lw, z: z1 }, { x: x0 + dir * lw, z: z1 }] });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Aanzicht van buiten, lokaal als build3D (x vanaf links, y vanaf de onderkant van de opening): gesloten omtrekken.
|
||||
function viewLines(op, fill, exterior) {
|
||||
const f = normalize(fill);
|
||||
if (!f) return [];
|
||||
const hR = op.hR !== undefined ? op.hR : op.h;
|
||||
const kL = sideKader(op.joinL), kR = sideKader(op.joinR);
|
||||
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, sideGap(op.joinR), P.gap, sideGap(op.joinL)]);
|
||||
const out = [outer];
|
||||
const add = (pts) => { if (pts && pts.length > 2 && areaOf(pts) > 1) out.push(pts); };
|
||||
if (f.type === 'raam') {
|
||||
const inner = insetPoly(outer, [P.kader, kR, P.kader, kL]);
|
||||
add(inner);
|
||||
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) add(insetPoly(inner, [0, 0, P.rooster, 0]));
|
||||
return out;
|
||||
}
|
||||
const sill = exterior !== false ? P.dorpel : 0;
|
||||
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
|
||||
const inner = insetPoly(frameOuter, [0, kR, P.kader, kL]);
|
||||
if (sill) add(frameOuter);
|
||||
add(inner);
|
||||
const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y;
|
||||
const top = below(inner);
|
||||
const topAt = (x) => inner[3].y + (x - inner[3].x) / (inner[2].x - inner[3].x) * (inner[2].y - inner[3].y);
|
||||
const region = (x0, x1, y0, y1) => clipPoly(rect(x0, x1, y0, Math.min(y1, 1e6)), top);
|
||||
if (f.type === 'schuifdeur') {
|
||||
const S = slideLeaves(f, ix0, ix1, iy0, sill, topAt);
|
||||
if (!S.opbouw || f.side === 'buiten') S.leaves.forEach(l => { add(region(l.x0, l.x1, S.yb, S.yt)); add(S.pane(l)); });
|
||||
return out;
|
||||
}
|
||||
const d = doorSplit(f, ix0, ix1, iy0, Math.min(topAt(ix0), topAt(ix1)));
|
||||
if (d.side) {
|
||||
const t0 = d.leafR ? d.la - P.tussen : d.lb;
|
||||
add(region(t0, t0 + P.tussen, iy0, d.ly1));
|
||||
const [g0, g1] = d.leafR ? [ix0, d.la - P.tussen] : [d.lb + P.tussen, ix1];
|
||||
add(region(g0, g1, iy0, iy0 + P.kader));
|
||||
}
|
||||
if (d.transom) add(region(ix0, ix1, d.ly1, d.ly1 + P.tussen));
|
||||
const L = doorLeaf(f, d, iy0, sill, top, topAt);
|
||||
if (L) { add(L.leaf); (L.holes || []).forEach(add); }
|
||||
return out;
|
||||
}
|
||||
|
||||
return { COLORS, FILL_TYPES, HINGES, SWINGS, SLIDE_MOUNTS, SLIDE_SIDES, SLIDE_DIRS, PROFILE: P, normalize, defaultFill, build3D, planSymbol, viewLines, colorMaterial: colorMat };
|
||||
})();
|
||||
+812
-656
File diff suppressed because it is too large
Load Diff
+15
-44
@@ -4,18 +4,8 @@
|
||||
* 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);
|
||||
@@ -26,7 +16,6 @@ window.WallPdf = (() => {
|
||||
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);
|
||||
@@ -41,13 +30,12 @@ window.WallPdf = (() => {
|
||||
doc.line(tblX[0], ty, tblX[4] + 12, ty);
|
||||
ty += 4;
|
||||
groups.forEach(g => {
|
||||
if (ty > 232) return; // laat ruimte vrij voor materiaaltabellen eronder
|
||||
if (ty > 232) return;
|
||||
[String(g.posnum), String(g.width), String(g.depth), String(g.length), String(g.count)]
|
||||
.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);
|
||||
@@ -65,7 +53,6 @@ window.WallPdf = (() => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Materiaal binnen/buiten + isolatie ---
|
||||
let my = 240;
|
||||
const midH = Math.round(WallModel.topLine(CONFIG).maxY);
|
||||
doc.setFontSize(8.5);
|
||||
@@ -79,7 +66,6 @@ window.WallPdf = (() => {
|
||||
doc.text(`${CONFIG.wallWidth} x ${CONFIG.sheathingThick} x ${midH} ${buitenNaam} 1`, tblX[0], my); my += 6;
|
||||
doc.text('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, opts.skinFrom);
|
||||
const B = WallModel.BATTEN;
|
||||
my += 4.5;
|
||||
@@ -95,12 +81,8 @@ window.WallPdf = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Titelblok (rechtsonder) ---
|
||||
PdfSheet.titleBlock(doc, opts.project, { titel: opts.titel, element: opts.element, blad: opts.blad });
|
||||
|
||||
// --- 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;
|
||||
@@ -126,14 +108,9 @@ window.WallPdf = (() => {
|
||||
doc.setLineWidth(0.3);
|
||||
pieces.forEach(p => {
|
||||
if (p.outline) {
|
||||
// Werkelijke omtrek: schuine kop (volgt de daklijn, evt. met knik
|
||||
// onder de nok) resp. bovenregel in verstek.
|
||||
const c = p.outline.map(q => [px(q.x), py(q.y)]);
|
||||
c.forEach((q, i) => { const n = c[(i + 1) % c.length]; doc.line(q[0], q[1], n[0], n[1]); });
|
||||
} else if (p.kind === 'verticale balk' || p.kind === 'kreupele stijl') {
|
||||
// Viervlak i.p.v. rechthoek: de bovenkant loopt schuin mee met de
|
||||
// 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)],
|
||||
@@ -154,16 +131,14 @@ window.WallPdf = (() => {
|
||||
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');
|
||||
const x0 = px(opXWorld), y0 = py(op.y + op.h), y0r = py(op.y + op.hR), x1 = px(opXWorld + op.w), y1 = py(op.y);
|
||||
[[x0, y1, x1, y1], [x1, y1, x1, y0r], [x1, y0r, x0, y0], [x0, y0, x0, y1]].forEach(l => doc.line(...l));
|
||||
doc.line(x0, y0, x1, y1);
|
||||
doc.line(x0, y1, x1, y0);
|
||||
doc.line(x0, y1, x1, y0r);
|
||||
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' });
|
||||
// Label binnen de opening: erboven overlapte het met de maatketen.
|
||||
doc.text(op.hR !== op.h ? `${op.w}x${op.h}/${op.hR}` : `${op.w}x${op.h}`, (x0 + x1) / 2, Math.max(y0, y0r) + 5, { align: 'center' });
|
||||
});
|
||||
|
||||
const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2);
|
||||
@@ -179,13 +154,14 @@ window.WallPdf = (() => {
|
||||
doc.text(String(Math.round(hLeft)), px(-wallWidth / 2) - 9, (py(0) + py(hLeft)) / 2, { align: 'center', angle: 90 });
|
||||
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 });
|
||||
if (tl.peak) {
|
||||
// Nokhoogte: stippellijn van vloer tot nok.
|
||||
const inner = tl.pts.slice(1, -1);
|
||||
inner.forEach(q => {
|
||||
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
|
||||
doc.setLineDashPattern([1, 1], 0);
|
||||
doc.line(px(tl.peak.x), py(0), px(tl.peak.x), py(tl.peak.y));
|
||||
doc.line(px(q.x), py(0), px(q.x), py(q.y));
|
||||
doc.setLineDashPattern([], 0);
|
||||
doc.text('nok ' + Math.round(tl.peak.y), px(tl.peak.x) + 1.5, py(tl.peak.y) + 4);
|
||||
}
|
||||
doc.text((isNok ? 'nok ' : 'knik ') + Math.round(q.y), px(q.x) + 1.5, py(q.y) + 4);
|
||||
});
|
||||
|
||||
const studEdges = new Set([0, wallWidth]);
|
||||
calcData.fullStuds.forEach(x => studEdges.add(Math.round(x - CONFIG.studWidth / 2 + wallWidth / 2)));
|
||||
@@ -215,7 +191,6 @@ window.WallPdf = (() => {
|
||||
doc.text(String(Math.round(openingPoints[i + 1] - openingPoints[i])), px(mid - wallWidth / 2), openY, { align: 'center' });
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
@@ -227,9 +202,9 @@ window.WallPdf = (() => {
|
||||
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.
|
||||
if (opts.overlay) opts.overlay(doc, (u) => px(-wallWidth / 2 + u), py);
|
||||
|
||||
// Binnenzijde boven, buitenzijde onder (de kant van het vooraanzicht).
|
||||
const sT = CONFIG.sheathingThick;
|
||||
const frameD = Math.max(CONFIG.studDepth, CONFIG.plateDepth);
|
||||
const tvY0 = floorY + 34;
|
||||
@@ -238,7 +213,6 @@ window.WallPdf = (() => {
|
||||
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;
|
||||
@@ -251,7 +225,6 @@ window.WallPdf = (() => {
|
||||
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 = [...new Set(calcData.studs.map(st => st.x))];
|
||||
@@ -280,8 +253,6 @@ window.WallPdf = (() => {
|
||||
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;
|
||||
|
||||
+230
-200
@@ -4,24 +4,12 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// wall/wall.js — Wand-editor (ruwbouw compleet: regelwerk, openingen, OSB
|
||||
// achterzijde, vochtdoek voorzijde, isolatie/specificaties). Ongewijzigde
|
||||
// logica t.o.v. de vorige single-file versie, alleen verpakt als los te
|
||||
// laden/lossen module via mount()/unmount(), en met de gedeelde geometrie-
|
||||
// functies (calculateSegmentPositions, polygonArea, clip*, shapeAreaWithHoles)
|
||||
// verplaatst naar core/geometry.js (moet VOOR dit bestand geladen worden).
|
||||
//
|
||||
// De rekenlogica (ankerresolutie, regelwerk, 3D-opbouw, specificaties) staat
|
||||
// in wall/wall-model.js (WallModel) — gedeeld met de unit-editor. Dit bestand
|
||||
// bevat alleen de UI: invoer, 2D-tekeningen, interactie, JSON/PDF-export.
|
||||
//
|
||||
// Plain script (geen ES-module) — werkt zo ook bij rechtstreeks openen van
|
||||
// index.html via file://.
|
||||
// ============================================================
|
||||
|
||||
const WALL_TEMPLATE = `
|
||||
<div id="app-container">
|
||||
<div id="app-container" class="ed-app">
|
||||
<div class="ed-main">
|
||||
<div class="ed-toolbar" id="wall-toolbar"></div>
|
||||
<div class="ed-views">
|
||||
<div id="left-column">
|
||||
<div id="pane-2d-container">
|
||||
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
|
||||
@@ -39,8 +27,16 @@ const WALL_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="right-column">
|
||||
<div id="pane-3d">
|
||||
<div class="pane-label">3D</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="control-panel">
|
||||
<div id="control-panel" class="ed-panel">
|
||||
<div class="io-toolbar">
|
||||
<input type="file" id="file-import-json" accept="application/json" style="display:none;">
|
||||
<span id="io-status" class="io-status"></span>
|
||||
@@ -49,6 +45,7 @@ const WALL_TEMPLATE = `
|
||||
<button class="tab-btn active" data-tab="tab-wall"><i class="bi bi-rulers"></i> Opbouw</button>
|
||||
<button class="tab-btn" data-tab="tab-opening"><i class="bi bi-door-open"></i> Openingen</button>
|
||||
<button class="tab-btn" data-tab="tab-materials"><i class="bi bi-layers"></i> Materiaal</button>
|
||||
<button class="tab-btn" data-tab="tab-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1: WAND -->
|
||||
@@ -119,10 +116,6 @@ const WALL_TEMPLATE = `
|
||||
<div><input type="number" id="inp-max-sp" value="480" step="10" min="150" max="1500"><span class="unit">mm</span></div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="result-row"><span>Verticale balken</span><span data-spec="vert-beams" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Horizontale balken</span><span data-spec="horiz-beams" class="highlight">-</span></div>
|
||||
<div class="result-row"><span>Dakhelling</span><span data-spec="roof-angle" class="highlight">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,17 +146,10 @@ const WALL_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="right-column">
|
||||
<div id="pane-3d">
|
||||
<div class="pane-label">3D</div>
|
||||
<div class="toolbar-3d" id="wall-toolbar-3d"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-specs" class="tab-content">
|
||||
<div id="specs-panel-3d">
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<div>
|
||||
<div class="spec-section">
|
||||
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
||||
@@ -203,6 +189,7 @@ const WALL_TEMPLATE = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wall-project-overlay">
|
||||
<div id="wall-project-modal">
|
||||
@@ -233,8 +220,8 @@ const WALL_TEMPLATE = `
|
||||
</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>
|
||||
<button type="button" id="wall-project-cancel" class="ui-btn" data-esc>Annuleren</button>
|
||||
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,19 +231,18 @@ function mountWallEditor(container, callbacks) {
|
||||
const onSaved = callbacks && callbacks.onSaved;
|
||||
container.innerHTML = WALL_TEMPLATE;
|
||||
|
||||
// Binnen een unit volgt de wandbreedte uit de vloer (zie unit/unit.js);
|
||||
// callbacks.lockWidth is dan de uitleg-tekst voor de tooltip.
|
||||
if (callbacks && callbacks.lockWidth) {
|
||||
const widthInput = document.getElementById('inp-wall-w');
|
||||
widthInput.disabled = true;
|
||||
widthInput.title = callbacks.lockWidth;
|
||||
}
|
||||
// Binnen een woning is de richting van de gevelbekleding (voorlopig) globaal.
|
||||
const lockCladding = callbacks && callbacks.lockCladding;
|
||||
// ... en de buitenzijde (gevelbekleding per woning, override per wand).
|
||||
const lockExterior = callbacks && callbacks.lockExterior;
|
||||
if (lockExterior && lockCladding) {
|
||||
const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]');
|
||||
if (tabBtn) tabBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand.
|
||||
if (callbacks && callbacks.lockSpacing) {
|
||||
['inp-min-sp', 'inp-max-sp'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
@@ -264,61 +250,46 @@ function mountWallEditor(container, callbacks) {
|
||||
el.title = callbacks.lockSpacing;
|
||||
});
|
||||
}
|
||||
// Binnen een woning: delen van de wand die op een naad met een andere unit
|
||||
// vallen (en dus vervallen), als [vanaf-links, tot] in mm.
|
||||
const seamCuts = (callbacks && callbacks.seamCuts) || [];
|
||||
// Binnen een woning: gevellampen op deze wand ({ u vanaf links, y }), zie LightingModel.
|
||||
const lampMarks = (callbacks && callbacks.lamps) || [];
|
||||
|
||||
// ============================================================
|
||||
// STATE
|
||||
// ============================================================
|
||||
// Dichtheden/gewichten: zie WallModel (wall/wall-model.js).
|
||||
|
||||
let CONFIG = {};
|
||||
let calcData = { fullStuds: [], studs: [] };
|
||||
let wallGroup3D = null;
|
||||
let lastBaseHeight = 2400;
|
||||
let wallTopPts = null;
|
||||
|
||||
// Elke opening: { id, w, h, y, anchor: 'left'|'middle'|'right', offset }
|
||||
// 'offset' betekent afhankelijk van anchor:
|
||||
// left -> afstand vanaf linker wandrand tot linkerkant opening
|
||||
// right -> afstand vanaf rechter wandrand tot rechterkant opening
|
||||
// middle -> verschuiving t.o.v. het midden (0 = gecentreerd, + = naar rechts)
|
||||
// offset: left = vanaf de linker wandrand, right = vanaf de rechter, middle = t.o.v. het midden (+ = rechts).
|
||||
// hR = hoogte rechts bij een schuine bovenzijde; de onderkant is altijd waterpas.
|
||||
let openings = [
|
||||
{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }
|
||||
{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }
|
||||
];
|
||||
let openingIdCounter = 2;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Interactieve selectie/verslepen van openingen in het vooraanzicht
|
||||
// (nu het primaire aanzicht). Snap op 50mm, net als de stapgrootte van
|
||||
// de invoervelden. N/Z-handles wijzigen alleen de hoogte, O/W-handles
|
||||
// alleen de breedte — precies zoals bij de meeste 2D-tekentools.
|
||||
// ------------------------------------------------------------
|
||||
let selectedOpeningId = null;
|
||||
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH }
|
||||
let frontView = { scale: 1, centerX: 0, floorY: 0 }; // bijgewerkt door draw2DFront()
|
||||
let frontOpeningRects = []; // hit-test data, bijgewerkt door draw2DFront()
|
||||
let frontHandleRects = []; // idem, alleen voor de geselecteerde opening
|
||||
let dragInfo = null;
|
||||
let frontView = { scale: 1, centerX: 0, floorY: 0 };
|
||||
let frontOpeningRects = [];
|
||||
let frontHandleRects = [];
|
||||
|
||||
function snap50(mm) { return Math.round(mm / 50) * 50; }
|
||||
|
||||
// Zet offset (en evt. breedte) zodat de opening's linkerrand op
|
||||
// `leftLocalX` komt te liggen met breedte `w`, ongeacht welk ankertype
|
||||
// actief is — dit is de kern die verslepen/resizen mogelijk maakt zonder
|
||||
// dat het ankertype zelf hoeft te veranderen.
|
||||
function applyOpeningPosition(op, leftLocalX, w, wallWidth) {
|
||||
if (op.anchor === 'right') op.offset = wallWidth - leftLocalX - w;
|
||||
else if (op.anchor === 'middle') op.offset = leftLocalX - (wallWidth - w) / 2;
|
||||
else op.offset = leftLocalX; // 'left'
|
||||
else op.offset = leftLocalX;
|
||||
op.w = w;
|
||||
}
|
||||
|
||||
const isSloped = (op) => op.hR !== undefined;
|
||||
const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset },
|
||||
o.hR !== undefined ? { hR: o.hR } : {}, o.fill ? { fill: Object.assign({}, o.fill) } : {});
|
||||
|
||||
function syncOpeningCardInputs(op) {
|
||||
const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`);
|
||||
if (!card) return;
|
||||
['w', 'h', 'y', 'offset'].forEach(field => {
|
||||
if (!!card.querySelector('input[data-field="hR"]') !== isSloped(op)) { renderOpeningsList(); return; }
|
||||
['w', 'h', 'hR', 'y', 'offset'].forEach(field => {
|
||||
const input = card.querySelector(`input[data-field="${field}"]`);
|
||||
if (input && document.activeElement !== input) input.value = Math.round(op[field]);
|
||||
});
|
||||
@@ -341,9 +312,6 @@ function mountWallEditor(container, callbacks) {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// OPENINGEN: UI (lijst met kaarten, ankerkeuze, toevoegen/verwijderen)
|
||||
// ============================================================
|
||||
function anchorOffsetLabel(anchor) {
|
||||
if (anchor === 'right') return 'Afstand tot rechts';
|
||||
if (anchor === 'middle') return 'Verschuiving t.o.v. midden';
|
||||
@@ -360,22 +328,13 @@ function mountWallEditor(container, callbacks) {
|
||||
h: preset.h,
|
||||
y: preset.y,
|
||||
anchor: 'left',
|
||||
offset: 300 + n * 1400
|
||||
offset: 300 + n * 1400,
|
||||
fill: KozijnModel.defaultFill('raam', lastFill())
|
||||
});
|
||||
renderOpeningsList();
|
||||
updateAll();
|
||||
}
|
||||
|
||||
function toggleFullscreen3D() {
|
||||
UI.cycle3D(document.getElementById('app-container')); // T: standaard -> 3D groot -> alleen render
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
|
||||
function toggleFullscreen2D() {
|
||||
UI.toggle2D(document.getElementById('app-container'));
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -411,24 +370,17 @@ function mountWallEditor(container, callbacks) {
|
||||
updateAll();
|
||||
}
|
||||
|
||||
// Horizontaal spiegelen (Bewerken-menu): wisselt links/rechts om. De
|
||||
// hoogte-links/-rechts worden verwisseld (de schuine kant "klapt om"),
|
||||
// en elke opening wordt zo herankerd dat 'ie fysiek op dezelfde plek
|
||||
// blijft t.o.v. zijn (nu omgewisselde) referentierand:
|
||||
// - anchor 'left' <-> 'right', offset ONGEWIJZIGD (het is en blijft
|
||||
// een afstand-tot-een-rand, alleen de rand zelf wisselt)
|
||||
// - anchor 'middle': offset wordt genegeerd (spiegelbeeld t.o.v. midden)
|
||||
// Verticaal spiegelen is bewust niet geïmplementeerd: een wand mag aan de
|
||||
// onderzijde niet scheef worden, dus dat menu-item blijft permanent
|
||||
// disabled (zie index.html/app.js).
|
||||
// Verticaal spiegelen bewust niet: een wand mag onderin niet scheef worden (menu-item blijft uit).
|
||||
function mirrorHorizontal() {
|
||||
const hLeftInput = document.getElementById('inp-wall-h-left');
|
||||
const hRightInput = document.getElementById('inp-wall-h-right');
|
||||
const tmp = hLeftInput.value;
|
||||
hLeftInput.value = hRightInput.value;
|
||||
hRightInput.value = tmp;
|
||||
if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse();
|
||||
|
||||
openings = openings.map(o => {
|
||||
if (isSloped(o)) o = { ...o, h: o.hR, hR: o.h };
|
||||
if (o.anchor === 'left') return { ...o, anchor: 'right' };
|
||||
if (o.anchor === 'right') return { ...o, anchor: 'left' };
|
||||
if (o.anchor === 'middle') return { ...o, offset: -o.offset };
|
||||
@@ -440,6 +392,51 @@ function mountWallEditor(container, callbacks) {
|
||||
setIoStatus('Horizontaal gespiegeld.', 'ok');
|
||||
}
|
||||
|
||||
function fillControls(op) {
|
||||
const f = KozijnModel.normalize(op.fill);
|
||||
const type = f ? f.type : 'geen';
|
||||
const opts = (entries, cur) => entries.map(([k, label]) => `<option value="${k}" ${k === cur ? 'selected' : ''}>${label}</option>`).join('');
|
||||
const colors = Object.entries(KozijnModel.COLORS).map(([k, c]) => [k, `${c.label} (${c.ral})`]);
|
||||
const sw = (field, label, on, title) => `<div class="control-group"${title ? ` title="${title}"` : ''}>
|
||||
<label>${label}</label>
|
||||
<div><label class="ui-switch"><input type="checkbox" data-field="${field}" data-id="${op.id}" ${on ? 'checked' : ''}><span></span></label></div>
|
||||
</div>`;
|
||||
const pick = (field, label, entries, cur, title) => `<div class="control-group"${title ? ` title="${title}"` : ''}>
|
||||
<label>${label}</label>
|
||||
<div><select data-field="${field}" data-id="${op.id}">${opts(Object.entries(entries), cur)}</select></div>
|
||||
</div>`;
|
||||
return `<div class="controls-grid opening-fill">
|
||||
<div>
|
||||
<div class="control-group">
|
||||
<label>Vulling</label>
|
||||
<div><select data-field="fill" data-id="${op.id}">${opts(Object.entries(KozijnModel.FILL_TYPES), type)}</select></div>
|
||||
</div>
|
||||
${type === 'raam' ? sw('fillRooster', 'Rooster', f.rooster, 'Ventilatierooster bovenin het kozijn') : ''}
|
||||
${type === 'deur' ? sw('fillGlas', 'Glasstrook', f.glas, 'Glasstrook in het deurblad') : ''}
|
||||
${type === 'schuifdeur' ? sw('fillGlas', 'Glazen blad', f.glas, 'Glas in een smal kader; uit = dicht blad') : ''}
|
||||
${type === 'deur' ? sw('fillVolglas', 'Glasdeur', f.volglas, 'Glas in het hele blad (smal kader)') +
|
||||
pick('fillLeaf', 'Blad', KozijnModel.HINGES, f.leaf, 'Kant van het deurblad (van buitenaf gezien) als er een zijlicht naast past') +
|
||||
pick('fillHinge', 'Scharnier', KozijnModel.HINGES, f.hinge, 'Van buitenaf gezien. Openslaande deuren: twee deuren naast elkaar en gekoppeld, bladen naar het midden, scharnieren buiten') +
|
||||
pick('fillSwing', 'Draait', KozijnModel.SWINGS, f.swing) : ''}
|
||||
${type === 'schuifdeur' ? pick('fillLeaves', 'Bladen', { 1: '1 blad', 2: '2 bladen (uit elkaar)' }, String(f.leaves)) +
|
||||
pick('fillMount', 'Montage', KozijnModel.SLIDE_MOUNTS, f.mount) +
|
||||
(f.mount === 'opbouw' ? pick('fillSide', 'Rail aan', KozijnModel.SLIDE_SIDES, f.side, 'Buitenzijde = +z (bij een binnenwand de "buiten"-kant)') : '') +
|
||||
(f.leaves === 1 ? pick('fillDir', 'Schuift', KozijnModel.SLIDE_DIRS, f.dir, 'Van buitenaf gezien') : '') : ''}
|
||||
</div>
|
||||
<div>
|
||||
${f ? `<div class="control-group">
|
||||
<label>Kleur buiten</label>
|
||||
<div><select data-field="fillOut" data-id="${op.id}">${opts(colors, f.out)}</select></div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Kleur binnen</label>
|
||||
<div><select data-field="fillIn" data-id="${op.id}">${opts(colors, f.in)}</select></div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; };
|
||||
|
||||
function renderOpeningsList() {
|
||||
const list = document.getElementById('openings-list');
|
||||
list.innerHTML = openings.map(op => `
|
||||
@@ -463,9 +460,13 @@ function mountWallEditor(container, callbacks) {
|
||||
<div><input type="number" data-field="w" data-id="${op.id}" value="${op.w}" step="50" min="400" max="2850"><span class="unit">mm</span></div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Hoogte</label>
|
||||
<label>${isSloped(op) ? 'Hoogte links' : 'Hoogte'}</label>
|
||||
<div><input type="number" data-field="h" data-id="${op.id}" value="${op.h}" step="50" min="400"><span class="unit">mm</span></div>
|
||||
</div>
|
||||
${isSloped(op) ? `<div class="control-group">
|
||||
<label>Hoogte rechts</label>
|
||||
<div><input type="number" data-field="hR" data-id="${op.id}" value="${op.hR}" step="50" min="400"><span class="unit">mm</span></div>
|
||||
</div>` : ''}
|
||||
<div class="control-group">
|
||||
<label>Afstand tot vloer</label>
|
||||
<div><input type="number" data-field="y" data-id="${op.id}" value="${op.y}" step="50" min="0"><span class="unit">mm</span></div>
|
||||
@@ -486,16 +487,24 @@ function mountWallEditor(container, callbacks) {
|
||||
<label>${anchorOffsetLabel(op.anchor)}</label>
|
||||
<div><input type="number" data-field="offset" data-id="${op.id}" value="${op.offset}" step="50"><span class="unit">mm</span></div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Schuine bovenzijde</label>
|
||||
<div><label class="ui-switch"><input type="checkbox" data-field="sloped" data-id="${op.id}" ${isSloped(op) ? 'checked' : ''}><span></span></label></div>
|
||||
</div>
|
||||
<div class="control-group" title="Tegen de opening ernaast aan: één sparing zonder stijlen, dagkanten of kozijnstijlen ertussen. Twee draaideuren worden openslaand (bladen tegen elkaar), anders één tussenstijl. Onderkant en hoogte gelijk aan de buur; samen max. 2850 mm.">
|
||||
<label>Koppelen</label>
|
||||
<div><label class="ui-switch"><input type="checkbox" data-field="koppel" data-id="${op.id}" ${op.koppel ? 'checked' : ''}><span></span></label></div>
|
||||
</div>
|
||||
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${fillControls(op)}
|
||||
<div class="opening-warning-text" data-warning-for="${op.id}" style="display:none;"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
highlightSelectedCard();
|
||||
}
|
||||
|
||||
// Klik op de titelbalk van een kaart selecteert 'm ook (symmetrisch met
|
||||
// klikken op de opening in het vooraanzicht).
|
||||
document.getElementById('openings-list').addEventListener('click', (e) => {
|
||||
const header = e.target.closest('.opening-card-header');
|
||||
if (header && !e.target.closest('.opening-remove-btn')) {
|
||||
@@ -506,8 +515,6 @@ function mountWallEditor(container, callbacks) {
|
||||
}
|
||||
});
|
||||
|
||||
// Event delegation: 1 listener voor alle inputs/knoppen in de openingen-lijst,
|
||||
// in plaats van opnieuw listeners koppelen na elke re-render.
|
||||
document.getElementById('openings-list').addEventListener('input', (e) => {
|
||||
const id = parseInt(e.target.dataset.id, 10);
|
||||
const field = e.target.dataset.field;
|
||||
@@ -515,14 +522,31 @@ function mountWallEditor(container, callbacks) {
|
||||
const op = openings.find(o => o.id === id);
|
||||
if (!op) return;
|
||||
if (field === 'type') {
|
||||
// Type kiezen = standaardmaat van dat type overnemen (daarna vrij aan te passen).
|
||||
const preset = WallModel.OPENING_TYPES[e.target.value];
|
||||
op.type = e.target.value;
|
||||
op.w = preset.w; op.h = preset.h; op.y = preset.y;
|
||||
delete op.hR;
|
||||
if (op.fill) op.fill = KozijnModel.defaultFill(op.type, op.fill);
|
||||
renderOpeningsList();
|
||||
} else if (field === 'fill') {
|
||||
const v = e.target.value;
|
||||
if (v === 'geen') delete op.fill;
|
||||
else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' || v === 'schuifdeur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v });
|
||||
renderOpeningsList();
|
||||
} else if (/^fill(Rooster|Glas|Volglas|Out|In|Hinge|Leaf|Swing|Leaves|Mount|Side|Dir)$/.test(field)) {
|
||||
if (!op.fill) return;
|
||||
const key = { fillRooster: 'rooster', fillGlas: 'glas', fillVolglas: 'volglas', fillOut: 'out', fillIn: 'in', fillHinge: 'hinge', fillLeaf: 'leaf', fillSwing: 'swing',
|
||||
fillLeaves: 'leaves', fillMount: 'mount', fillSide: 'side', fillDir: 'dir' }[field];
|
||||
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : key === 'leaves' ? Number(e.target.value) : e.target.value;
|
||||
if (key === 'leaves' || key === 'mount') renderOpeningsList();
|
||||
} else if (field === 'koppel') {
|
||||
if (e.target.checked) op.koppel = true; else delete op.koppel;
|
||||
} else if (field === 'sloped') {
|
||||
if (e.target.checked) op.hR = op.h; else delete op.hR;
|
||||
renderOpeningsList();
|
||||
} else if (field === 'anchor') {
|
||||
op.anchor = e.target.value;
|
||||
renderOpeningsList(); // label van 'offset' hangt af van anchor
|
||||
renderOpeningsList();
|
||||
} else {
|
||||
op[field] = parseFloat(e.target.value) || 0;
|
||||
}
|
||||
@@ -532,13 +556,21 @@ function mountWallEditor(container, callbacks) {
|
||||
if (e.target.dataset.action === 'remove') {
|
||||
removeOpening(parseInt(e.target.dataset.id, 10));
|
||||
}
|
||||
const roofBtn = e.target.closest('[data-action="follow-roof"]');
|
||||
if (roofBtn) followRoof(parseInt(roofBtn.dataset.id, 10));
|
||||
});
|
||||
document.getElementById('btn-add-opening').addEventListener('click', addOpening);
|
||||
|
||||
// ============================================================
|
||||
// JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters +
|
||||
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug.
|
||||
// ============================================================
|
||||
function followRoof(id) {
|
||||
const op = openings.find(o => o.id === id);
|
||||
const res = (CONFIG.openings || []).find(o => o.id === id);
|
||||
if (!op || !res) return;
|
||||
const at = WallModel.topLine(CONFIG).at, x0 = -CONFIG.wallWidth / 2 + res.x;
|
||||
op.hR = Math.max(400, Math.round(op.h + at(x0 + res.w) - at(x0)));
|
||||
syncOpeningCardInputs(op);
|
||||
updateAll();
|
||||
}
|
||||
|
||||
function setIoStatus(msg, cls) {
|
||||
const el = document.getElementById('io-status');
|
||||
el.textContent = msg;
|
||||
@@ -553,8 +585,8 @@ function mountWallEditor(container, callbacks) {
|
||||
heightAll: parseFloat(document.getElementById('inp-wall-h-all').value),
|
||||
heightLeft: parseFloat(document.getElementById('inp-wall-h-left').value),
|
||||
heightRight: parseFloat(document.getElementById('inp-wall-h-right').value),
|
||||
// Middenpunt (nok); null = rechte bovenrand.
|
||||
heightMid: document.getElementById('inp-wall-peak').checked ? parseFloat(document.getElementById('inp-wall-h-mid').value) : null,
|
||||
topPts: wallTopPts ? wallTopPts.map(q => Object.assign({}, q)) : undefined,
|
||||
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
|
||||
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
|
||||
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
|
||||
@@ -568,8 +600,8 @@ function mountWallEditor(container, callbacks) {
|
||||
function getPureState() {
|
||||
return {
|
||||
wall: collectWallParams(),
|
||||
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone },
|
||||
openings: openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }))
|
||||
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels },
|
||||
openings: openings.map(rawOpening)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -589,14 +621,18 @@ function mountWallEditor(container, callbacks) {
|
||||
document.getElementById('inp-min-sp').value = state.wall.minSpacing;
|
||||
document.getElementById('inp-max-sp').value = state.wall.maxSpacing;
|
||||
lastBaseHeight = state.wall.heightAll;
|
||||
wallTopPts = Array.isArray(state.wall.topPts) && state.wall.topPts.length ? state.wall.topPts.map(q => Object.assign({}, q)) : null;
|
||||
|
||||
buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek';
|
||||
cladding = (state.materials && state.materials.cladding) || 'horizontaal';
|
||||
trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling
|
||||
stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen)
|
||||
trasraam = (state.materials && state.materials.trasraam) || null;
|
||||
stone = (state.materials && state.materials.stone) || null;
|
||||
stuc = (state.materials && state.materials.stuc) || null;
|
||||
rabatColor = (state.materials && state.materials.rabatColor) || null;
|
||||
fels = (state.materials && state.materials.fels) || null;
|
||||
syncMaterialButtons();
|
||||
|
||||
openings = state.openings.map(o => ({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }));
|
||||
openings = state.openings.map(rawOpening);
|
||||
openingIdCounter = openings.length ? Math.max(...openings.map(o => o.id)) + 1 : 1;
|
||||
selectedOpeningId = null;
|
||||
|
||||
@@ -604,10 +640,6 @@ function mountWallEditor(container, callbacks) {
|
||||
updateAll();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// PDF-export: het tekenblad zelf komt uit WallPdf (wall/wall-pdf.js),
|
||||
// gedeeld met de unit-PDF.
|
||||
// ------------------------------------------------------------
|
||||
function generateWallPdf() {
|
||||
recalculate();
|
||||
const doc = PdfSheet.createDoc();
|
||||
@@ -683,11 +715,6 @@ function mountWallEditor(container, callbacks) {
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// OPENINGEN: ankerresolutie + overlap-handling — zie
|
||||
// WallModel.resolveOpenings (wall/wall-model.js).
|
||||
// ============================================================
|
||||
|
||||
function updateOpeningWarnings(resolvedOpenings) {
|
||||
const anyOverflow = resolvedOpenings.some(o => o.overflowing);
|
||||
const globalWarn = document.getElementById('openings-global-warning');
|
||||
@@ -702,12 +729,9 @@ function mountWallEditor(container, callbacks) {
|
||||
const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`);
|
||||
if (!card || !warnEl) return;
|
||||
card.classList.toggle('opening-warn', !!o.overflowing);
|
||||
if (o.overflowing) {
|
||||
warnEl.style.display = 'block';
|
||||
warnEl.textContent = 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.';
|
||||
} else {
|
||||
warnEl.style.display = 'none';
|
||||
}
|
||||
const msg = o.overflowing ? 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.' : o.koppelWarn ? `Koppelen: ${o.koppelWarn}.` : '';
|
||||
warnEl.style.display = msg ? 'block' : 'none';
|
||||
warnEl.textContent = msg;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -741,12 +765,12 @@ function mountWallEditor(container, callbacks) {
|
||||
document.getElementById('lbl-wall-h-left').textContent = peakOn ? 'Goothoogte links' : 'Hoogte links';
|
||||
document.getElementById('lbl-wall-h-right').textContent = peakOn ? 'Goothoogte rechts' : 'Hoogte rechts';
|
||||
|
||||
// Afronding/fallbacks + ankerresolutie van de openingen: WallModel.
|
||||
CONFIG = WallModel.buildConfig({
|
||||
width: wallWidth,
|
||||
heightLeft: hLeft,
|
||||
heightRight: hRight,
|
||||
heightMid: peakOn ? hMid : null,
|
||||
topPts: wallTopPts || undefined,
|
||||
studWidth: parseFloat(document.getElementById('inp-stud-w').value),
|
||||
studDepth: parseFloat(document.getElementById('inp-stud-d').value),
|
||||
plateThick: parseFloat(document.getElementById('inp-plate-w').value),
|
||||
@@ -766,7 +790,6 @@ function mountWallEditor(container, callbacks) {
|
||||
setSpecAll('roof-angle', calcData.angleDeg + '°');
|
||||
}
|
||||
|
||||
// --- 2D CANVAS RENDERER: BOVENAANZICHT ---
|
||||
const canvas2DTop = document.getElementById('canvas-2d-top');
|
||||
const ctx = canvas2DTop.getContext('2d');
|
||||
|
||||
@@ -814,8 +837,6 @@ function mountWallEditor(container, callbacks) {
|
||||
ctx.strokeRect(screenX, screenY, studWidthPx, studDepthPx);
|
||||
};
|
||||
|
||||
// Bovenaanzicht: één doorsnede per stijlpositie (kreupel alleen als er
|
||||
// op die plek geen volle stijl staat).
|
||||
const fullXs = new Set(calcData.studs.filter(st => st.kind !== 'kreupel').map(st => st.x));
|
||||
[...new Set(calcData.studs.map(st => st.x))].forEach(x => drawStud2D(x, !fullXs.has(x)));
|
||||
|
||||
@@ -839,7 +860,6 @@ function mountWallEditor(container, callbacks) {
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2D CANVAS RENDERER: VOORAANZICHT (ZUID) ---
|
||||
const canvas2DFront = document.getElementById('canvas-2d-front');
|
||||
const ctxFront = canvas2DFront.getContext('2d');
|
||||
|
||||
@@ -873,9 +893,6 @@ function mountWallEditor(container, callbacks) {
|
||||
for (let x = 0; x < w; x += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(x, 0); ctxFront.lineTo(x, h); ctxFront.stroke(); }
|
||||
for (let y = 0; y < h; y += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(0, y); ctxFront.lineTo(w, y); ctxFront.stroke(); }
|
||||
|
||||
// Referentielijnen vochtdoek-banen (1500mm h.o.h. vanaf de vloer).
|
||||
// Zelfde uitsparingen als de fill (WallModel.skinSegmentsAtY): de
|
||||
// lijnen stoppen bij openingen en bij de (schuine) bovenrand.
|
||||
ctxFront.strokeStyle = '#555';
|
||||
ctxFront.setLineDash([4, 4]);
|
||||
for (let bandY = 1500; buitenMateriaal === 'vochtdoek' && bandY < maxRoofMM; bandY += 1500) {
|
||||
@@ -888,7 +905,6 @@ function mountWallEditor(container, callbacks) {
|
||||
}
|
||||
ctxFront.setLineDash([]);
|
||||
|
||||
// Bovenkant trasraam (cement-gebonden plaat, woning-instelling).
|
||||
if (buitenMateriaal === 'vochtdoek' && trasraam && trasraam.top > 0) {
|
||||
ctxFront.strokeStyle = '#a0a3a8';
|
||||
ctxFront.lineWidth = 1.5;
|
||||
@@ -903,7 +919,6 @@ function mountWallEditor(container, callbacks) {
|
||||
ctxFront.fillText('trasraam', toCanvasX(-halfWMM) + 4, toCanvasY(trasraam.top) - 3);
|
||||
}
|
||||
|
||||
// Wand-omtrek (incl. schuine bovenrand)
|
||||
ctxFront.strokeStyle = '#666';
|
||||
ctxFront.lineWidth = 2;
|
||||
ctxFront.beginPath();
|
||||
@@ -913,9 +928,6 @@ function mountWallEditor(container, callbacks) {
|
||||
ctxFront.closePath();
|
||||
ctxFront.stroke();
|
||||
|
||||
// Onder- en bovenregel als volwaardig gevuld vlak (was alleen de
|
||||
// omtreklijn hierboven — dat liet lege ruimtes zien i.p.v. de
|
||||
// daadwerkelijke leggers).
|
||||
ctxFront.fillStyle = '#c29b62';
|
||||
ctxFront.strokeStyle = '#8a6836';
|
||||
ctxFront.lineWidth = 1;
|
||||
@@ -932,8 +944,6 @@ function mountWallEditor(container, callbacks) {
|
||||
ctxFront.stroke();
|
||||
});
|
||||
|
||||
// X/Y-assen (lijn + label), altijd binnen het canvas gehouden zodat er
|
||||
// niks buiten het zichtbare venster valt. Stijl: core/canvas-labels.js.
|
||||
const xAxisY = Math.min(h - 6, toCanvasY(0) + 16);
|
||||
drawCanvasDim(ctxFront, toCanvasX(-halfWMM), xAxisY, toCanvasX(halfWMM), xAxisY,
|
||||
'X: ' + Math.round(wallWidthMM) + 'mm', (toCanvasX(-halfWMM) + toCanvasX(halfWMM)) / 2, Math.min(h - 2, xAxisY + 11));
|
||||
@@ -945,16 +955,16 @@ function mountWallEditor(container, callbacks) {
|
||||
const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14);
|
||||
drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM),
|
||||
'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2);
|
||||
if (tl.peak) {
|
||||
const pxC = toCanvasX(tl.peak.x);
|
||||
const inner = tl.pts.slice(1, -1);
|
||||
inner.forEach(q => {
|
||||
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
|
||||
ctxFront.fillStyle = '#9ab';
|
||||
ctxFront.font = '11px sans-serif';
|
||||
ctxFront.textAlign = 'center';
|
||||
ctxFront.fillText('nok ' + Math.round(tl.peak.y) + 'mm', pxC, Math.max(12, toCanvasY(tl.peak.y) - 6));
|
||||
ctxFront.fillText((isNok ? 'nok ' : 'knik ') + Math.round(q.y) + 'mm', toCanvasX(q.x), Math.max(12, toCanvasY(q.y) - 6));
|
||||
ctxFront.textAlign = 'start';
|
||||
}
|
||||
});
|
||||
|
||||
// Stijlstuk van y0 tot y1 (of tot de daklijn), zie WallModel.computeFrame.
|
||||
const drawStudFront = (st) => {
|
||||
const xLeft = st.x - CONFIG.studWidth / 2;
|
||||
const xRight = st.x + CONFIG.studWidth / 2;
|
||||
@@ -981,38 +991,51 @@ function mountWallEditor(container, callbacks) {
|
||||
const x1 = toCanvasX(opLeftMM + op.w);
|
||||
const y0 = toCanvasY(op.y);
|
||||
const y1 = toCanvasY(op.y + op.h);
|
||||
const y1r = toCanvasY(op.y + op.hR);
|
||||
const isSelected = op.id === selectedOpeningId;
|
||||
|
||||
frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, localX: op.x, y: op.y, w: op.w, h: op.h });
|
||||
frontOpeningRects.push({ id: op.id, x0, y0, x1, y1, y1r, localX: op.x, y: op.y, w: op.w, h: op.h, hR: op.hR });
|
||||
|
||||
const outline = () => {
|
||||
ctxFront.beginPath();
|
||||
ctxFront.moveTo(x0, y0); ctxFront.lineTo(x1, y0); ctxFront.lineTo(x1, y1r); ctxFront.lineTo(x0, y1);
|
||||
ctxFront.closePath();
|
||||
};
|
||||
outline();
|
||||
ctxFront.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.45)' : 'rgba(255, 50, 50, 0.25)';
|
||||
ctxFront.fillRect(x0, y1, x1 - x0, y0 - y1);
|
||||
ctxFront.fill();
|
||||
ctxFront.strokeStyle = isSelected ? '#4da6ff' : '#ff3333';
|
||||
ctxFront.lineWidth = isSelected ? 2.5 : 1;
|
||||
ctxFront.setLineDash(isSelected ? [] : [3, 3]);
|
||||
ctxFront.strokeRect(x0, y1, x1 - x0, y0 - y1);
|
||||
ctxFront.stroke();
|
||||
ctxFront.setLineDash([]);
|
||||
ctxFront.lineWidth = 1;
|
||||
|
||||
ctxFront.fillStyle = '#fff';
|
||||
ctxFront.font = '9px sans-serif';
|
||||
ctxFront.textAlign = 'center';
|
||||
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, y1 - 4);
|
||||
ctxFront.fillText('#' + op.id, (x0 + x1) / 2, (y1 + y1r) / 2 - 4);
|
||||
|
||||
if (isSelected) {
|
||||
const midX = (x0 + x1) / 2, midY = (y0 + y1) / 2;
|
||||
const midX = (x0 + x1) / 2, midTop = (y1 + y1r) / 2;
|
||||
const handles = [
|
||||
{ handle: 'n', cx: midX, cy: y1 },
|
||||
{ handle: 'nw', cx: x0, cy: y1 },
|
||||
{ handle: 'n', cx: midX, cy: midTop },
|
||||
{ handle: 'ne', cx: x1, cy: y1r },
|
||||
{ handle: 'w', cx: x0, cy: (y0 + y1) / 2 },
|
||||
{ handle: 'c', cx: midX, cy: (y0 + midTop) / 2 },
|
||||
{ handle: 'e', cx: x1, cy: (y0 + y1r) / 2 },
|
||||
{ handle: 'sw', cx: x0, cy: y0 },
|
||||
{ handle: 's', cx: midX, cy: y0 },
|
||||
{ handle: 'e', cx: x1, cy: midY },
|
||||
{ handle: 'w', cx: x0, cy: midY }
|
||||
{ handle: 'se', cx: x1, cy: y0 }
|
||||
];
|
||||
handles.forEach(hd => {
|
||||
frontHandleRects.push({ id: op.id, handle: hd.handle, cx: hd.cx, cy: hd.cy });
|
||||
ctxFront.fillStyle = '#4da6ff';
|
||||
ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff';
|
||||
ctxFront.strokeStyle = '#0a1a2a';
|
||||
ctxFront.beginPath();
|
||||
ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
|
||||
if (hd.handle === 'c') ctxFront.arc(hd.cx, hd.cy, 5, 0, Math.PI * 2);
|
||||
else ctxFront.rect(hd.cx - 5, hd.cy - 5, 10, 10);
|
||||
ctxFront.fill();
|
||||
ctxFront.stroke();
|
||||
});
|
||||
@@ -1021,7 +1044,6 @@ function mountWallEditor(container, callbacks) {
|
||||
|
||||
drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM);
|
||||
|
||||
// Gevellampen (buitenverlichting woning)
|
||||
lampMarks.forEach(l => {
|
||||
const x = toCanvasX(-halfWMM + l.u), y = toCanvasY(l.y);
|
||||
ctxFront.fillStyle = '#ffd36b'; ctxFront.strokeStyle = '#1a1a1a'; ctxFront.lineWidth = 1;
|
||||
@@ -1030,8 +1052,6 @@ function mountWallEditor(container, callbacks) {
|
||||
});
|
||||
}
|
||||
|
||||
// Naad-zones (wand vervalt daar, zie woning) als gearceerde overlay.
|
||||
// Met upTo (mm): opbouw boven het lagere dak, alleen gearceerd tot daar.
|
||||
function drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM) {
|
||||
seamCuts.forEach(([u0, u1, upTo]) => {
|
||||
const x0 = toCanvasX(-halfWMM + u0), x1 = toCanvasX(-halfWMM + u1);
|
||||
@@ -1062,9 +1082,11 @@ function mountWallEditor(container, callbacks) {
|
||||
return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8);
|
||||
}
|
||||
function frontOpeningAt(mx, my) {
|
||||
return frontOpeningRects.find(r =>
|
||||
mx >= Math.min(r.x0, r.x1) && mx <= Math.max(r.x0, r.x1) &&
|
||||
my >= Math.min(r.y0, r.y1) && my <= Math.max(r.y0, r.y1));
|
||||
return frontOpeningRects.find(r => {
|
||||
if (mx < r.x0 || mx > r.x1) return false;
|
||||
const yTop = r.y1 + (mx - r.x0) / ((r.x1 - r.x0) || 1) * (r.y1r - r.y1);
|
||||
return my <= r.y0 && my >= yTop;
|
||||
});
|
||||
}
|
||||
|
||||
canvas2DFront.classList.add('interactive');
|
||||
@@ -1077,9 +1099,9 @@ function mountWallEditor(container, callbacks) {
|
||||
if (hit) {
|
||||
const resolved = frontOpeningRects.find(r => r.id === hit.id);
|
||||
dragInfo = {
|
||||
type: 'resize', handle: hit.handle, openingId: hit.id,
|
||||
type: hit.handle === 'c' ? 'move' : 'resize', handle: hit.handle, openingId: hit.id,
|
||||
startMouseX: e.clientX, startMouseY: e.clientY,
|
||||
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h
|
||||
startLocalX: resolved.localX, startY: resolved.y, startW: resolved.w, startH: resolved.h, startHR: resolved.hR
|
||||
};
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -1092,7 +1114,7 @@ function mountWallEditor(container, callbacks) {
|
||||
dragInfo = {
|
||||
type: 'move', openingId: bodyHit.id,
|
||||
startMouseX: e.clientX, startMouseY: e.clientY,
|
||||
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h
|
||||
startLocalX: bodyHit.localX, startY: bodyHit.y, startW: bodyHit.w, startH: bodyHit.h, startHR: bodyHit.hR
|
||||
};
|
||||
highlightSelectedCard();
|
||||
draw2D();
|
||||
@@ -1121,17 +1143,28 @@ function mountWallEditor(container, callbacks) {
|
||||
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
|
||||
op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
|
||||
} else if (dragInfo.type === 'resize') {
|
||||
if (dragInfo.handle === 'n') {
|
||||
op.h = Math.max(400, snap50(dragInfo.startH + dyMM));
|
||||
} else if (dragInfo.handle === 's') {
|
||||
const top = dragInfo.startY + dragInfo.startH;
|
||||
const newY = Math.min(snap50(dragInfo.startY + dyMM), top - 400);
|
||||
const hd = dragInfo.handle;
|
||||
const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
|
||||
if (hd === 'n') {
|
||||
const dh = snap50(dyMM);
|
||||
op.h = Math.max(400, dragInfo.startH + dh);
|
||||
if (sloped) op.hR = Math.max(400, dragInfo.startHR + dh);
|
||||
} else if (hd === 'nw' || hd === 'ne') {
|
||||
op.h = hd === 'nw' ? Math.max(400, snap50(dragInfo.startH + dyMM)) : dragInfo.startH;
|
||||
op.hR = hd === 'ne' ? Math.max(400, snap50(dragInfo.startHR + dyMM)) : dragInfo.startHR;
|
||||
if (op.h === op.hR) delete op.hR;
|
||||
}
|
||||
if (hd === 's' || hd === 'sw' || hd === 'se') {
|
||||
const topL = dragInfo.startY + dragInfo.startH, topR = dragInfo.startY + dragInfo.startHR;
|
||||
const newY = Math.min(snap50(dragInfo.startY + dyMM), Math.min(topL, topR) - 400);
|
||||
op.y = Math.max(0, newY);
|
||||
op.h = top - op.y;
|
||||
} else if (dragInfo.handle === 'e') {
|
||||
op.h = topL - op.y;
|
||||
if (isSloped(op)) op.hR = topR - op.y;
|
||||
}
|
||||
if (hd === 'e' || hd === 'ne' || hd === 'se') {
|
||||
const newW = Math.max(400, snap50(dragInfo.startW + dxMM));
|
||||
applyOpeningPosition(op, dragInfo.startLocalX, newW, wallWidth);
|
||||
} else if (dragInfo.handle === 'w') {
|
||||
} else if (hd === 'w' || hd === 'nw' || hd === 'sw') {
|
||||
const right = dragInfo.startLocalX + dragInfo.startW;
|
||||
const newLocalX = Math.min(snap50(dragInfo.startLocalX + dxMM), right - 400);
|
||||
applyOpeningPosition(op, newLocalX, right - newLocalX, wallWidth);
|
||||
@@ -1207,7 +1240,6 @@ function mountWallEditor(container, callbacks) {
|
||||
draw2DSide();
|
||||
}
|
||||
|
||||
// --- THREE.JS 3D RENDERER ---
|
||||
const pane3D = document.getElementById('pane-3d');
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a1a);
|
||||
@@ -1240,14 +1272,14 @@ function mountWallEditor(container, callbacks) {
|
||||
|
||||
const wallMaterials = WallModel.createMaterials();
|
||||
|
||||
// Buitenzijde-materiaalkeuze (tab 3): 'vochtdoek' of 'cementplaat',
|
||||
// wederzijds exclusief. Binnenzijde is altijd spaanplaat (geen alternatief).
|
||||
// (Alleen knoppen met data-material/-cladding: de "Opslaan"-knop van
|
||||
// Project eigenschappen heeft ook de klasse material-toggle-btn.)
|
||||
let buitenMateriaal = 'vochtdoek';
|
||||
let cladding = 'horizontaal'; // richting gevelbekleding (alleen bij vochtdoek)
|
||||
let trasraam = null; // { bottom, top } mm, komt uit de woning-instellingen (WallModel.trasraamFor)
|
||||
let stone = null; // steencode bij cementplaat, komt uit de woning-instellingen
|
||||
let cladding = 'horizontaal';
|
||||
let trasraam = null;
|
||||
let stone = null;
|
||||
let stuc = null;
|
||||
let rabatColor = null;
|
||||
let fels = null;
|
||||
let layers = ViewLayers.all();
|
||||
function syncMaterialButtons() {
|
||||
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
||||
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
|
||||
@@ -1264,44 +1296,41 @@ function mountWallEditor(container, callbacks) {
|
||||
|
||||
let spaanplaatVisible = true;
|
||||
let buitenVisible = true;
|
||||
// Weergave-filter (core/view-filter.js): beplating binnen/buiten + isolatie.
|
||||
createViewFilter(document.getElementById('wall-toolbar-3d'), [
|
||||
{ key: 'binnen', label: 'Beplating binnenzijde' },
|
||||
{ key: 'buiten', label: 'Beplating buitenzijde' }
|
||||
], (v) => {
|
||||
spaanplaatVisible = v.binnen;
|
||||
buitenVisible = v.buiten;
|
||||
update3DScene();
|
||||
});
|
||||
// Omgeving (core/skybox.js: geen/dag/nacht); de wand staat op de tegelvloer.
|
||||
let skyMode = Skybox.getMode();
|
||||
const skyEnv = Skybox.createEnvironment(scene);
|
||||
const applySky = () => {
|
||||
skyEnv.fitTo(wallGroup3D, 0);
|
||||
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
|
||||
};
|
||||
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
|
||||
const toolbar = document.getElementById('wall-toolbar');
|
||||
const bar = UI.editorBar(toolbar, document.getElementById('app-container'), {
|
||||
left: `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>`,
|
||||
onResize: onWindowResize,
|
||||
layers: {
|
||||
layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'],
|
||||
onChange: (l) => { layers = l; update3DScene(); },
|
||||
extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }],
|
||||
onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); }
|
||||
},
|
||||
onSky: (m) => { skyMode = m; applySky(); }
|
||||
});
|
||||
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
|
||||
|
||||
// 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld
|
||||
// met de unit-editor).
|
||||
let lastWallStats = null;
|
||||
|
||||
function update3DScene() {
|
||||
if (wallGroup3D) scene.remove(wallGroup3D);
|
||||
const result = WallModel.build3D(CONFIG, calcData, {
|
||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, spaanplaatVisible, buitenVisible
|
||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels, spaanplaatVisible, buitenVisible, layers
|
||||
});
|
||||
wallGroup3D = result.group;
|
||||
lastWallStats = result.stats;
|
||||
scene.add(wallGroup3D);
|
||||
applySky(); // schaduw van de nieuwe wand
|
||||
applySky();
|
||||
|
||||
updateSpecificaties();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SPECIFICATIES (tab 3)
|
||||
// ============================================================
|
||||
function fmt(n, decimals = 1) {
|
||||
return n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
@@ -1335,7 +1364,6 @@ function mountWallEditor(container, callbacks) {
|
||||
setSpecAll('void-m3', fmt(specs.voidVolumeM3, 3) + ' m3');
|
||||
setSpecAll('horiz-beams', lastWallStats.horizBeamCount + ' stuks');
|
||||
|
||||
// Inkoop in handelslengtes (zaagplan uit WallModel).
|
||||
const purchase = WallModel.planPurchase(WallModel.computeBOM(CONFIG, calcData));
|
||||
const wasteMM = purchase.reduce((a, sec) => a + sec.wasteMM, 0);
|
||||
const stockMM = purchase.reduce((a, sec) => a + sec.stockMM, 0);
|
||||
@@ -1351,6 +1379,8 @@ function mountWallEditor(container, callbacks) {
|
||||
|
||||
const inputs = document.querySelectorAll('#tab-wall input');
|
||||
inputs.forEach(input => {
|
||||
// Hoogte met de hand gewijzigd: de knikken van een wolfsdak vervallen.
|
||||
if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; });
|
||||
input.addEventListener('input', updateAll);
|
||||
});
|
||||
|
||||
@@ -1385,8 +1415,8 @@ function mountWallEditor(container, callbacks) {
|
||||
exportDesign,
|
||||
triggerImport,
|
||||
mirrorHorizontal,
|
||||
toggleFullscreen3D,
|
||||
toggleFullscreen2D,
|
||||
setView: bar.setView,
|
||||
viewState: bar.viewState,
|
||||
openProjectProperties,
|
||||
getStateJson: getPureState,
|
||||
loadStateJson: applyPureState
|
||||
@@ -1402,6 +1432,6 @@ window.WallEditor = {
|
||||
sheathingThick: 10, minSpacing: 240, maxSpacing: 480
|
||||
},
|
||||
materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' },
|
||||
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }]
|
||||
openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }]
|
||||
})
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
code;omschrijving;eenheid;waarde
|
||||
arbeid.uurloon;Uurloon productie (all-in);€/u;55
|
||||
arbeid.bezetting;Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel;man;2,2
|
||||
arbeid.toeslagUren;Toeslag indirecte uren (klaarleggen, opruimen, intern transport);%;10
|
||||
arbeid.qa;QA (laatste aanpassingen, klantcontact) over de totale werktijd;%;4
|
||||
zagen.methode;Zaagmethode;;robot
|
||||
zagen.robotSnede;Robot: tijd per haakse snede;s;5
|
||||
zagen.robotSchuin;Robot: extra tijd per schuine snede;s;2,5
|
||||
zagen.robotStuk;Robot: handling per stuk (invoer, uitvoer, etiket);s;15
|
||||
zagen.handFactor;Handmatig: factor op de robottijd;×;7
|
||||
verbinden.schroevenPerVerbinding;Schroeven per verbinding (stijl/dorpel op regel);st.;3
|
||||
verbinden.eersteSchroef;Tijd eerste schroef (gat + schroef + uitlijnen);s;10
|
||||
verbinden.volgendeSchroef;Tijd volgende schroef (gat + schroef);s;5
|
||||
verbinden.verbindingenPerVloerbalk;Verbindingen per vloer-/dakbalk;st.;2
|
||||
verbinden.dubbelAfstand;Dubbele stijl (deur, hoek): schroef verticaal elke;mm;100
|
||||
verbinden.monteerFactor;Monteren: factor op de schroeftijd (incl. lijmen, positioneren);×;6
|
||||
verbinden.personeel;"Personeel bij monteren constructie (vloer, wand, dak; i.p.v. de bezetting)";man;2
|
||||
verbinden.dakFactor;Dak: factor op de tijd per schroef (moeilijker werken);×;4
|
||||
montage.muurVastzetten;Muursectie plaatsen en vastzetten;min;10
|
||||
montage.muurPersoneel;Personeel bij plaatsen muursectie (i.p.v. de bezetting);man;3
|
||||
hout.latStukMontage;Latwerk: per gezaagd stuk (meten, zagen op locatie, passen);s;240
|
||||
hout.latMeterMontage;Latwerk: bevestigen per strekkende meter;s/m;30
|
||||
plaat.spaanplaatMontage;Montage spaanplaat (passen, zagen, plaatsen);s/m²;120
|
||||
plaat.osbMontage;Montage OSB;s/m²;90
|
||||
plaat.cementplaatMontage;Montage cementplaat;s/m²;240
|
||||
plaat.schroevenPerM2;Plaatschroeven per m²;st./m²;15
|
||||
plaat.tijdPerPlaatschroef;Tijd per plaatschroef;s;4
|
||||
isolatie.glaswolMontage;Montage glaswol;s/m³;900
|
||||
gevel.vochtdoekMontageM;Montage vochtdoek per strekkende meter wand;s/m;600
|
||||
gevel.rabatMontage;Montage rabat;s/m²;600
|
||||
gevel.steenMontage;Montage steenstrips;s/m²;1800
|
||||
gevel.stucMontage;Stucwerk aanbrengen (wapenen, pleisteren);s/m²;1500
|
||||
gevel.felsMontage;Montage felsplaten gevel (ook dagkanten en hoeken);s/m²;2100
|
||||
dak.dakleerMontage;Montage dakleer;s/m²;600
|
||||
dak.rietMontage;Montage riet;s/m²;3600
|
||||
dak.beschotMontage;Montage dakbeschot;s/m²;150
|
||||
dak.felsMontage;Montage felsplaten;s/m²;1800
|
||||
dak.boeideelMontage;Montage boeideel;s/m;600
|
||||
dak.onderplaatMontage;Montage onderplaat;s/m²;900
|
||||
dak.daktrimMontage;Montage daktrim;s/m;300
|
||||
dak.nokPlaatsen;Stalen nokbalk plaatsen, stellen en vastzetten;s;2700
|
||||
dak.nokVulhoutMontage;Vulhout in de holling van de nokbalk aanbrengen (beide zijden);s/m;240
|
||||
dak.nokPersoneel;Personeel bij plaatsen nokbalk (i.p.v. de bezetting);man;3
|
||||
dak.paalMontage;Paal plaatsen: stellen, aftoppen, vastzetten aan het dak;s/st.;2700
|
||||
dak.zijwandMontage;Zijwand overkapping maken en plaatsen;s/m²;5400
|
||||
vloer.afwerkingMontage;Montage vloerafwerking;s/m²;1200
|
||||
vloer.sandwichMontage;Montage per sandwichpaneel;s;600
|
||||
afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800
|
||||
afwerking.fiboMontage;Montage FIBO-wandpaneel;s/m²;1500
|
||||
inrichting.personeel;Personeel bij inrichting (installateur/monteur, i.p.v. de bezetting);man;1
|
||||
inrichting.toiletMontage;Montage wandcloset (reservoir, voorzetwand, afmonteren);s;10800
|
||||
inrichting.fonteinMontage;Montage fontein;s;3600
|
||||
inrichting.wastafelMontage;Montage badmeubel;s;5400
|
||||
inrichting.doucheMontage;Montage inloopdouche (goot, afschot, glaswand, kraan);s;14400
|
||||
inrichting.keukenMontage;Montage keukenblok;s/m;3600
|
||||
inrichting.kookeilandMontage;Montage kookeiland;s/m;3600
|
||||
inrichting.kastMontage;Montage kast;s/m;1800
|
||||
inrichting.boilerMontage;Montage boiler;s;7200
|
||||
kozijnen.montage;Montage per kozijn;s;5400
|
||||
verlichting.montage;"Montage per lamp/spot (armatuur plaatsen en aansluiten; leidingen en dozen onder Elektra)";s;300
|
||||
elektra.personeel;Personeel bij elektra (i.p.v. de bezetting);man;1
|
||||
elektra.boorgat;Boorgat door stijl, regel of balk (aftekenen, boren, ontbramen);s;90
|
||||
elektra.buisLeggen;Installatiebuis leggen en bevestigen;s/m;60
|
||||
elektra.draadTrekken;Draad trekken (per meter buis, alle aders);s/m;20
|
||||
elektra.kabelLeggen;XMvK-kabel leggen en bevestigen (buiten);s/m;40
|
||||
elektra.doosPlaatsen;Inbouwdoos plaatsen (luchtdicht, hollewand);s;240
|
||||
elektra.verdeeldoos;Verdeeldoos plaatsen en verbinden;s;600
|
||||
elektra.spotdoos;Spotdoos plaatsen (luchtdicht plafond);s;300
|
||||
elektra.vloerdoos;Vloerdoos plaatsen (gat in vloer, aansluiten);s;1800
|
||||
elektra.afmonteren;Afmonteren schakelaar/wcd/apparaataansluiting;s;300
|
||||
elektra.steekverbinderMontage;Steekverbinder module-naad monteren (set m/v);s;300
|
||||
elektra.kastMontage;Groepenkast plaatsen (kast, hoofdschakelaar, aardlek);s;3600
|
||||
elektra.groepMontage;Per groep: automaat plaatsen, bedraden, labelen;s;900
|
||||
elektra.keuring;Meten, testen en opleveren (NEN 1010 / NEN 3140);s;10800
|
||||
bedrijf.machines;Afschrijving machines (zaagrobot, gereedschap, inrichting) per uur;€/u;8
|
||||
bedrijf.slijtage;Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per uur;€/u;2
|
||||
bedrijf.huisvesting;Huisvesting productiehal (huur, energie) per uur;€/u;6
|
||||
opslag.verlies;"Materiaalverlies (beschadiging, misgezaagd, restmateriaal; naast het berekende snijverlies balken)";%;5
|
||||
opslag.klein;Klein materiaal (lijm, kit, tape, nieten);%;3
|
||||
opslag.algemeen;Algemene kosten (kantoor, verzekering, garantie) over de kostprijs;%;5
|
||||
opslag.marge;Winstmarge;%;15
|
||||
opslag.btw;Btw;%;21
|
||||
|
@@ -0,0 +1,77 @@
|
||||
code;omschrijving;eenheid;prijs;leverancier;artikelnummer;bijgewerkt
|
||||
verbinden.prijsPer1000;Inkoop constructieschroeven (6×120);€/1000;45;;;
|
||||
hout.constructieM3;Constructiehout vuren C24 (regelwerk, balken);€/m³;550;;;
|
||||
hout.latwerkM;Latwerk (kruislat);€/m;0,9;;;
|
||||
plaat.spaanplaatM2;Spaanplaat;€/m²;9;;;
|
||||
plaat.osbM2;OSB (vloeren, plafonds dak);€/m²;12;;;
|
||||
plaat.cementplaatM2;Cementgebonden plaat (gevel, trasraam);€/m²;28;;;
|
||||
plaat.plaatschroefPer1000;Inkoop plaatschroeven (4×40);€/1000;15;;;
|
||||
isolatie.glaswolM3;Glaswol;€/m³;40;;;
|
||||
isolatie.steenIsolatieM3;Isolatieblok steengevel;€/m³;120;;;
|
||||
gevel.vochtdoekM2;Vochtdoek;€/m²;1,5;;;
|
||||
gevel.rabatM2;Rabat (gevelbekleding);€/m²;35;;;
|
||||
gevel.steenM2;Steenstrips (gevel + trasraam);€/m²;60;;;
|
||||
gevel.stucM2;Stucwerk gevel (wapening + sierpleister op cementgebonden plaat);€/m²;45;;;
|
||||
gevel.felsM2;Felsplaten gevel, staande naad (staal, klangen, ondergrond);€/m²;70;;;
|
||||
dak.dakleerM2;Dakleer (EPDM);€/m²;18;;;
|
||||
dak.rietM2;Riet;€/m²;90;;;
|
||||
dak.beschotM2;Dakbeschot OSB 18 mm (bovenop de sporen);€/m²;14;;;
|
||||
dak.felsM2;Felsplaten staande naad (zink/staal, klangen, scheidingslaag);€/m²;65;;;
|
||||
dak.boeideelM;Boeideel;€/m;25;;;
|
||||
dak.onderplaatM2;Onderplaat boeideel;€/m²;30;;;
|
||||
dak.daktrimM;Daktrim;€/m;12;;;
|
||||
dak.staalKg;Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond);€/kg;2,9;;;
|
||||
dak.nokOplegging;Oplegging nokbalk (stalen schoen + bouten), 2 per balk;€/st.;45;;;
|
||||
dak.paalHoutM3;Paalhout overkapping (eiken, geschaafd);€/m³;2400;;;
|
||||
dak.paalvoet;Paalvoet verzinkt, verstelbaar (incl. ankers);€/st.;40;;;
|
||||
dak.zijwandM2;Zijwand overkapping (regelwerk, cementgebonden plaat en stuc aan beide kanten, fundering);€/m²;140;;;
|
||||
vloer.afwerkingM2;Vloerafwerking;€/m²;35;;;
|
||||
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
|
||||
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
|
||||
afwerking.fiboM2;FIBO-wandpaneel (incl. profielen en kit);€/m²;70;;;
|
||||
inrichting.toilet;Wandcloset met inbouwreservoir, voorzetwand en bedieningsplaat;€/st.;650;;;
|
||||
inrichting.fontein;Fontein met kraan en sifon;€/st.;220;;;
|
||||
inrichting.wastafel;Badmeubel met wastafel, kraan en spiegel;€/st.;850;;;
|
||||
inrichting.douche;Inloopdouche (douchegoot, glaswand, regendouche-set);€/st.;1450;;;
|
||||
inrichting.keukenM;Keukenblok (kasten, werkblad, spoelbak, apparatuur) per strekkende meter;€/m;1500;;;
|
||||
inrichting.kookeilandM;Kookeiland (kasten, werkblad) per strekkende meter;€/m;1300;;;
|
||||
inrichting.kookplaat;Inductiekookplaat 4 zones;€/st.;650;;;
|
||||
inrichting.kastM;Kledingkast / inbouwkast per strekkende meter;€/m;550;;;
|
||||
inrichting.boiler;Elektrische boiler 80 l incl. inlaatcombinatie;€/st.;600;;;
|
||||
kozijnen.raamM2;Raamkozijn incl. glas;€/m²;450;;;
|
||||
kozijnen.deur;Binnen-/buitendeur incl. kozijn;€/st.;900;;;
|
||||
kozijnen.voordeur;Voordeur incl. kozijn;€/st.;1800;;;
|
||||
kozijnen.schuifdeurBlad;Schuifdeur per blad (blad, rail of pocket, beslag, aftimmering);€/st.;650;;;
|
||||
verlichting.gevellamp;Gevellamp;€/st.;45;;;
|
||||
verlichting.boeideelSpot;Boeideel-spot;€/st.;25;;;
|
||||
verlichting.binnenspot;Binnenspot;€/st.;20;;;
|
||||
elektra.buis16M;Installatiebuis flexibel Ø16;€/m;0,35;;;
|
||||
elektra.buis19M;Installatiebuis flexibel Ø19;€/m;0,45;;;
|
||||
elektra.buis25M;Installatiebuis flexibel Ø25;€/m;0,8;;;
|
||||
elektra.draad15M;VD-draad 1,5 mm² (per ader);€/m;0,28;;;
|
||||
elektra.draad25M;VD-draad 2,5 mm² (per ader);€/m;0,45;;;
|
||||
elektra.xmvkM;XMvK 3×1,5 mm²;€/m;1,25;;;
|
||||
elektra.inbouwdoos;Luchtdichte hollewanddoos;€/st.;1,6;;;
|
||||
elektra.verdeeldoosSt;Luchtdichte verdeeldoos;€/st.;4,5;;;
|
||||
elektra.spotdoosSt;Luchtdichte spotdoos;€/st.;7,5;;;
|
||||
elektra.vloerdoosSt;Vloerdoos met dubbele wcd;€/st.;95;;;
|
||||
elektra.schakelaar;Schakelaar enkelpolig (inbouw);€/st.;9;;;
|
||||
elektra.afdekraam1;Afdekraam 1-voudig;€/st.;3;;;
|
||||
elektra.afdekraam2;Afdekraam 2-voudig;€/st.;5;;;
|
||||
elektra.wcdDubbel;Wandcontactdoos dubbel met randaarde;€/st.;10;;;
|
||||
elektra.wcdEnkel;Wandcontactdoos enkel met randaarde;€/st.;6;;;
|
||||
elektra.perilex;Perilex-wandcontactdoos (kookgroep);€/st.;18;;;
|
||||
elektra.werkschakelaar;Werkschakelaar (warmtepomp);€/st.;28;;;
|
||||
elektra.lasklem;Lasklem 3-voudig;€/st.;0,35;;;
|
||||
elektra.steekverbinder3;Steekverbinder 3-polig (set m/v);€/set;9;;;
|
||||
elektra.steekverbinder5;Steekverbinder 5-polig (set m/v);€/set;14;;;
|
||||
elektra.doorvoer;Luchtdichte kabeldoorvoer naar buiten;€/st.;3,5;;;
|
||||
elektra.kast;Groepenkast 3-fase leeg (behuizing, kammen, aansluitmateriaal);€/st.;180;;;
|
||||
elektra.hoofdschakelaar;Hoofdschakelaar 4-polig 40 A;€/st.;55;;;
|
||||
elektra.aardlek;Aardlekschakelaar 2-polig 40 A / 30 mA;€/st.;40;;;
|
||||
elektra.automaat;Installatieautomaat 1P+N (B10/B16);€/st.;14;;;
|
||||
elektra.automaat2f;Installatieautomaat 2-fase (kookgroep);€/st.;30;;;
|
||||
bedrijf.transportUnit;Transport per unit naar de bouwplaats;€/unit;750;;;
|
||||
bedrijf.kraan;Kraan voor plaatsen woning;€;1200;;;
|
||||
hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,9115;;;
|
||||
hout.balk.70x220;Constructiehout vuren C24 70×220;€/m;8,47;;;
|
||||
|
+98
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Tectum
|
||||
Copyright © 2026 Machiel Heinen.
|
||||
Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
Zie LICENTIE.md.
|
||||
-->
|
||||
<!-- Light-versie (modelpagina's), voorlopig los: light.html?model=amstel. Op een modelpagina is alleen het
|
||||
<div class="tectum-light" data-model="…"> plus de stylesheets en scripts hieronder nodig. -->
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Woning samenstellen — Van Emous Mantelzorgwoningen</title>
|
||||
<link rel="stylesheet" href="css/vendor/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="css/light.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 24px; background: #f9faf5; color: #212121; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
.page { max-width: 1400px; margin: 0 auto; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.page-head img { height: 34px; }
|
||||
.page-head select, .page-head button { font: inherit; padding: 6px 10px; border: 1px solid #c5ced9; border-radius: 8px; background: #fff; }
|
||||
@media (max-width: 820px) { body { padding: 12px; } }
|
||||
</style>
|
||||
<script src="js/vendor/three.min.js"></script>
|
||||
<script src="js/vendor/OrbitControls.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<img src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
||||
<!-- Alleen voor het losse testen: op een modelpagina staat het model vast. -->
|
||||
<div>
|
||||
<select id="model-pick" aria-label="Model"></select>
|
||||
<!-- Alleen voor het testen: de GLB die op een telefoon in AR wordt getoond (bijv. in Blender of een glTF-viewer). -->
|
||||
<button type="button" id="glb-dl" title="De GLB die AR gebruikt downloaden">GLB</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="configurator" class="tectum-light"></div>
|
||||
</div>
|
||||
|
||||
<script src="js/core/logo-data.js"></script>
|
||||
<script src="js/core/geometry.js"></script>
|
||||
<script src="js/core/view-layers.js"></script>
|
||||
<script src="js/core/ui-kit.js"></script>
|
||||
<script src="js/core/skybox.js"></script>
|
||||
<script src="js/core/glb-writer.js"></script>
|
||||
<script src="js/vendor/jspdf.umd.min.js"></script>
|
||||
<script src="js/vendor/qrcode.js"></script>
|
||||
<script src="js/core/pdf-sheet.js"></script>
|
||||
<script src="js/wall/kozijn-model.js"></script>
|
||||
<script src="js/wall/wall-model.js"></script>
|
||||
<script src="js/wall/wall-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/roof/post-model.js"></script>
|
||||
<script src="js/lighting/lighting-model.js"></script>
|
||||
<script src="js/elektra/elektra-model.js"></script>
|
||||
<script src="js/sandwich/sandwich-model.js"></script>
|
||||
<script src="js/wall/wall.js"></script>
|
||||
<script src="js/floor/floor.js"></script>
|
||||
<script src="js/unit/unit-model.js"></script>
|
||||
<script src="js/room/room-model.js"></script>
|
||||
<script src="js/interior/fixture-model.js"></script>
|
||||
<script src="js/house/chimney-model.js"></script>
|
||||
<script src="js/house/house-model.js"></script>
|
||||
<script src="js/cost/cost-defaults.js"></script>
|
||||
<script src="js/cost/cost-csv.js"></script>
|
||||
<script src="js/cost/cost-model.js"></script>
|
||||
<script src="js/elektra/elektra-pdf.js"></script>
|
||||
<script src="js/house/house-pdf.js"></script>
|
||||
<script src="js/light/light-model.js"></script>
|
||||
<script src="js/light/light-pdf.js"></script>
|
||||
<script>
|
||||
// Model uit de URL (?model=amstel); de keuzelijst herlaadt de pagina met een ander model.
|
||||
(function () {
|
||||
const key = new URLSearchParams(location.search).get('model');
|
||||
const model = HouseModel.MODELS[key] ? key : Object.keys(HouseModel.MODELS)[0];
|
||||
const pick = document.getElementById('model-pick');
|
||||
pick.innerHTML = Object.entries(HouseModel.MODELS).map(([k, m]) => `<option value="${k}"${k === model ? ' selected' : ''}>${m.label}</option>`).join('');
|
||||
pick.addEventListener('change', () => { location.search = '?model=' + pick.value; });
|
||||
const el = document.getElementById('configurator'), btn = document.getElementById('glb-dl');
|
||||
el.dataset.model = model;
|
||||
btn.addEventListener('click', () => {
|
||||
const g = el.tectumLight && el.tectumLight.glb();
|
||||
if (!g) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = g.url;
|
||||
a.download = `${model}.glb`;
|
||||
a.click();
|
||||
btn.textContent = `GLB · ${(g.stats.bytes / 1e6).toLocaleString('nl-NL', { maximumFractionDigits: 1 })} MB · ${g.stats.triangles.toLocaleString('nl-NL')} driehoeken`;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="js/light/light.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user