Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab0dca9bb5 | ||
|
|
adb3c74f85 | ||
|
|
86fcd57b96 | ||
|
|
b137a458d4 | ||
|
|
bd084e4a96 | ||
|
|
ceda0b554d | ||
|
|
b5510d56c7 |
@@ -18,21 +18,34 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
||||
## Structuur
|
||||
|
||||
```
|
||||
index.html app-shell: ribbon, 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, bouwfasen, ribbon, UI-kit, PDF-bladen,
|
||||
labels, camerageheugen, weergavefilter, skybox, licentie, logo
|
||||
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), 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)
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
@@ -45,9 +58,11 @@ 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).
|
||||
- **Bouwfasen** (`BuildStages`): regelwerk → elektra (buizen, dozen, meterkast) → beplating → vochtdoek →
|
||||
latwerk → gevel & dak → afgewerkt, de volgorde in de hal. De 3D toont de
|
||||
gekozen fase; lagen die daarin aan beide zijden zijn ingesloten worden niet
|
||||
- **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.
|
||||
@@ -69,30 +84,85 @@ assets/ logo, texturen, skybox
|
||||
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.
|
||||
Alles verplaatsbaar door te slepen in de plattegrond.
|
||||
Buizen minstens 50 mm uit elkaar (gebundeld alleen het laatste stuk naar
|
||||
de meterkast). Vloer-wcd's met leiding door de vloer. Elektra ook op de
|
||||
PDF-bladen per wand, vloer en dakvlak (plafond). Alles verplaatsbaar door
|
||||
te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per
|
||||
meter, boorgaten (geteld op stijlen, regels en balken), dozen,
|
||||
afmonteren, groepenkast en keuring.
|
||||
- **Kozijnen** (`wall/kozijn-model.js`): per opening een vulling (geen, raam
|
||||
met vast glas en optioneel ventilatierooster, of deur met dorpel en
|
||||
optionele glasstrook; zij- en bovenlicht vullen de rest van een grote
|
||||
optionele glasstrook of als glasdeur; zij- en bovenlicht vullen de rest van een grote
|
||||
opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de
|
||||
maat van de opening, ook een schuine bovenzijde.
|
||||
maat van de opening, ook een schuine bovenzijde. Deur: kant van het blad,
|
||||
scharnierzijde en draairichting (naar binnen/buiten); openslaande deuren
|
||||
met zijlichten = twee deuren naast elkaar. 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, mansarde of wolfsdak, met schuiven en hellingshoek).
|
||||
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde
|
||||
met twee knikken, bij een wolfsdak afgeknot op het wolfseind, met
|
||||
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. Knik en wolfseind schalen mee met goot en
|
||||
nok (verhouding). Bij een knik (mansarde) sluiten de dakvlakken in
|
||||
verstek aan. De PDF heeft voor een wolfsdak een eigen blad met het
|
||||
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,3 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen
|
||||
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`).
|
||||
- **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
|
||||
@@ -108,51 +178,230 @@ 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 + rabat 12 mm, kleur per woning, getekend als
|
||||
plankentextuur die over naden doorloopt) of steen (181 texturen). Steen is overal een
|
||||
- **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) of stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
|
||||
per woning, `WallModel.STUC`). Een zijde kan gesplitst worden (wand ›
|
||||
Wandstukken › Splitsen op, gemeten vanaf de linker vloerhoek van buitenaf):
|
||||
elk stuk wordt een eigen wand-element en kan een eigen gevel krijgen
|
||||
(bijv. stuc links, hout rechts). Steen is overal een
|
||||
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
|
||||
Het trasraam (cementplaat of een eigen steen) komt onder beide, dus
|
||||
twee stenen kan. 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.
|
||||
- **Ribbon** (`core/ribbon.js`, vervangt de menubalk): Bestand · Start ·
|
||||
Gevel · Dak · Binnen · Constructie · Verlichting · Rapport · Beeld · Extra,
|
||||
plus een gekleurd tabblad voor de selectie (unit, wandstuk, binnenwand of
|
||||
naadwand) dat vanzelf opent. Velden staan in groepen naast elkaar, met
|
||||
schuiven voor maten met een logisch bereik. Dubbelklik op een tabblad
|
||||
klapt de ribbon in. Plattegrond en 3D gebruiken de volle breedte; tabblad
|
||||
Rapport toont het rapport (transport per unit, gewicht, afmetingen,
|
||||
kosten, hout, plaatwerk, met staafdiagrammen) in plaats van de plattegrond.
|
||||
- **3D:** schuif Bouwfase linksonder; Beeld › Tonen: Dak/Lampen; Beeld ›
|
||||
Doorsnede (wanden afgesneden op een instelbare hoogte, schuif in de ribbon
|
||||
en onder de 3D, snede wit afgedekt), binnenzijde in een
|
||||
verfkleur (afgewerkte fase), omgeving Ontwerp/Dag/Nacht, Render met lampen
|
||||
als echte lichtbronnen.
|
||||
- **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`).
|
||||
|
||||
## 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 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`.
|
||||
`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. 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).
|
||||
|
||||
@@ -7,6 +7,3 @@ Assets voor de 3D-weergave en de PDF:
|
||||
naadloos), voorvertoning in thumb/32|64|128/*.png (WallModel.TRASRAAM_STONES)
|
||||
- textures/vloer/ vloerafwerking (FloorModel.FINISHES), bijv. visgraat_001.jpg (1500x1500, tegel 3 m); thumb/32|64|128/*.png
|
||||
- textures/dak/ dakbedekking: riet_<nr>.jpg (1024x1024, tegel 1 m); nummers in RoofModel RIET.nrs; thumb/32|64|128/*.png
|
||||
- audio/five-armies.mp3 soundtrack easter egg (Ctrl+Shift+Alt+L shooter; core/granny-kit.js):
|
||||
"Five Armies" Kevin MacLeod (incompetech.com), licensed under
|
||||
Creative Commons: By Attribution 4.0 — https://creativecommons.org/licenses/by/4.0/
|
||||
|
||||
Binary file not shown.
+335
-301
@@ -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);
|
||||
@@ -286,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; }
|
||||
@@ -298,40 +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: ribbon, workspace-wrapper, splashscreen, about-modal
|
||||
============================================================ */
|
||||
html, body { height: 100%; }
|
||||
|
||||
#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); }
|
||||
|
||||
body { display: flex; flex-direction: column; }
|
||||
#tab-bar { flex: none; }
|
||||
#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;
|
||||
@@ -342,7 +244,6 @@ body { display: flex; flex-direction: column; }
|
||||
#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;
|
||||
@@ -392,7 +293,6 @@ body { display: flex; flex-direction: column; }
|
||||
#wall-project-modal .modal-footer, #house-project-modal .modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
|
||||
#wall-project-modal .control-group input, #house-project-modal .control-group input { width: 150px; text-align: left; }
|
||||
|
||||
/* --- Kosten & tijden (CostSettings) --- */
|
||||
#cost-settings-overlay {
|
||||
position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500;
|
||||
display: none; align-items: center; justify-content: center;
|
||||
@@ -416,6 +316,13 @@ body { display: flex; flex-direction: column; }
|
||||
#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;
|
||||
@@ -434,43 +341,11 @@ body { display: flex; flex-direction: column; }
|
||||
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; }
|
||||
|
||||
/* ============================================================
|
||||
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; }
|
||||
@@ -490,57 +365,20 @@ body { display: flex; flex-direction: column; }
|
||||
.hn-item.disabled:hover { background: none; color: #9aa4b0; }
|
||||
.hn-actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.hn-actions .io-btn { flex: 1; }
|
||||
#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; }
|
||||
|
||||
/* Overzicht: plattegrond en 3D over de volle breedte (smal: onder elkaar);
|
||||
bediening in de ribbon. Tabblad Rapport: rapport i.p.v. plattegrond. */
|
||||
#house-overview { display: flex; width: 100%; }
|
||||
.ho-views { flex: 1; display: flex; min-width: 0; min-height: 0; }
|
||||
.ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); }
|
||||
#ho-pane-plan { border-right: 1px solid var(--border); }
|
||||
@media (max-width: 1100px) {
|
||||
.ho-views { flex-direction: column; }
|
||||
#ho-pane-plan, .ho-report { border-right: none; border-bottom: 1px solid var(--border); }
|
||||
}
|
||||
#house-overview.fullscreen-3d #ho-pane-plan, #house-overview.fullscreen-3d .ho-report { display: none !important; }
|
||||
#house-overview.fullscreen-2d #ho-pane-3d { display: none; }
|
||||
.ho-report { display: none; flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 14px 18px 20px; background: var(--van-emous-ultra-light); border-right: 1px solid var(--border); }
|
||||
#house-overview.show-report .ho-report { display: block; }
|
||||
#house-overview.show-report #ho-pane-plan { display: none; }
|
||||
.ho-report .ui-spec-grid { grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); align-items: start; }
|
||||
.ho-report-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
|
||||
.ho-report-head h1 { margin: 0; font-size: 14px; }
|
||||
.ho-report-head span { font-size: 12px; color: var(--text-muted); }
|
||||
.ho-warn { position: absolute; top: 52px; left: 15px; right: 15px; z-index: 9; pointer-events: none; }
|
||||
.ho-warn .ui-warn { margin: 0 0 6px; max-width: 560px; }
|
||||
.ho-hint {
|
||||
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-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 {
|
||||
@@ -552,7 +390,7 @@ body { display: flex; flex-direction: column; }
|
||||
.ui-field label { font-size: 12.5px; color: var(--text); display: flex; align-items: center; gap: 5px; }
|
||||
.ui-field.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;
|
||||
}
|
||||
@@ -596,7 +434,6 @@ body { display: flex; flex-direction: column; }
|
||||
.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; }
|
||||
@@ -605,15 +442,11 @@ body { display: flex; flex-direction: column; }
|
||||
.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 #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; }
|
||||
@@ -630,48 +463,22 @@ 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; }
|
||||
|
||||
/* Easter egg: schieten (Ctrl+L) */
|
||||
.shooter-hud { display: none; position: absolute; inset: 0; pointer-events: none; z-index: 6; font-family: monospace; color: #ffd27a; }
|
||||
.shooter-hud.show { display: block; }
|
||||
.shooter-cross { position: absolute; left: 50%; top: 50%; width: 18px; height: 18px; margin: -9px 0 0 -9px;
|
||||
background: linear-gradient(#ffd27a, #ffd27a) center/2px 100% no-repeat, linear-gradient(#ffd27a, #ffd27a) center/100% 2px no-repeat; opacity: .85; }
|
||||
.shooter-weapon { position: absolute; right: 18px; bottom: 16px; font-size: 18px; font-weight: 700; text-shadow: 0 0 4px #000; }
|
||||
.shooter-credit { position: absolute; left: 14px; top: 12px; font-size: 11px; color: #fff; opacity: .7; text-shadow: 0 0 3px #000; }
|
||||
.shooter-kills { position: absolute; right: 18px; top: 12px; font-size: 18px; font-weight: 700; text-shadow: 0 0 4px #000; }
|
||||
.shooter-shout { position: absolute; left: 50%; top: 22%; transform: translateX(-50%); font-size: 20px; font-weight: 700; color: #fff;
|
||||
text-shadow: 0 0 4px #000, 2px 2px 0 #8040a0; opacity: 0; transition: opacity .2s; white-space: nowrap; }
|
||||
.shooter-shout.show { opacity: 1; }
|
||||
.shooter-ouch { position: absolute; inset: 0; background: rgba(255, 0, 0, 0.45); opacity: 0; }
|
||||
.shooter-ouch.show { animation: shooter-ouch .4s ease-out; }
|
||||
@keyframes shooter-ouch { from { opacity: 1; } to { opacity: 0; } }
|
||||
.shooter-announce { position: absolute; left: 50%; top: 34%; transform: translateX(-50%); font: italic 900 42px Impact, 'Arial Black', sans-serif;
|
||||
color: #ff2a1a; text-shadow: 0 0 8px #000, 3px 3px 0 #400; letter-spacing: 2px; white-space: nowrap; opacity: 0; }
|
||||
.shooter-announce.show { opacity: 1; animation: shooter-announce .35s ease-out; }
|
||||
@keyframes shooter-announce { from { transform: translateX(-50%) scale(2.2); opacity: 0; } to { transform: translateX(-50%) scale(1); opacity: 1; } }
|
||||
.shooter-help { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); padding: 6px 12px; border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; white-space: nowrap; }
|
||||
|
||||
|
||||
/* Openingen: "Volg dakhelling" (schuine bovenzijde) */
|
||||
.opening-roof-btn {
|
||||
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); }
|
||||
|
||||
/* Inspector: kruimelpad, schuiven, kleurstaal. */
|
||||
.ui-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 11px; color: var(--text-faint); margin-bottom: 4px; }
|
||||
.ui-crumbs .bi { font-size: 9px; }
|
||||
.ui-crumbs span:last-child { color: var(--text-muted); }
|
||||
@@ -682,26 +489,14 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
||||
.ui-range input[type=number] { width: 84px; }
|
||||
.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
|
||||
|
||||
/* Bouwfase (core/build-stages.js): schuif linksonder in de 3D. */
|
||||
.stage-bar {
|
||||
position: absolute; left: 15px; bottom: 14px; z-index: 10; min-width: 300px; max-width: calc(100% - 30px);
|
||||
background: rgba(0, 0, 0, 0.75); border: 1px solid #444; border-radius: 6px; padding: 7px 12px 6px; color: #eee; font-size: 12px;
|
||||
.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;
|
||||
}
|
||||
.stage-row, .stage-extra { display: flex; align-items: center; gap: 10px; }
|
||||
.stage-title { display: inline-flex; align-items: center; gap: 6px; color: var(--primary-on-dark); font-weight: 600; white-space: nowrap; }
|
||||
.stage-range { flex: 1; min-width: 90px; accent-color: var(--primary-on-dark); }
|
||||
.stage-name { min-width: 84px; font-weight: 600; white-space: nowrap; }
|
||||
.stage-track { flex: 1; display: flex; flex-direction: column; min-width: 120px; }
|
||||
.stage-track .stage-range { width: 100%; margin: 0; }
|
||||
.stage-ticks { display: flex; justify-content: space-between; padding: 0 3px; font-size: 10px; color: #888; }
|
||||
.stage-ticks span { cursor: pointer; padding: 0 3px; }
|
||||
.stage-ticks span.on { color: var(--primary-on-dark); }
|
||||
.stage-extra { display: none; margin-top: 6px; padding-top: 6px; border-top: 1px solid #444; }
|
||||
.stage-extra.show { display: flex; }
|
||||
.walking .stage-bar { display: none; }
|
||||
.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; }
|
||||
|
||||
/* Grafieken in het rapport (staafdiagrammen, HTML). Categorische kleuren
|
||||
in vaste volgorde: vloer, wanden, dak. */
|
||||
.viz { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; }
|
||||
.viz-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; }
|
||||
@@ -722,77 +517,316 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
||||
.viz-val { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text); text-align: right; white-space: nowrap; }
|
||||
.viz-note { font-size: 11px; color: var(--text-faint); line-height: 1.45; margin-top: 6px; }
|
||||
|
||||
/* ============================================================
|
||||
RIBBON (core/ribbon.js): tabbladen + groepen van 3 rijen hoog.
|
||||
============================================================ */
|
||||
#ribbon { flex: none; background: var(--van-emous-blue); border-bottom: 1px solid var(--border); position: relative; z-index: 100; user-select: none; }
|
||||
.rb-top { display: flex; align-items: flex-end; height: 34px; padding: 0 8px; }
|
||||
.rb-brand { display: flex; align-items: center; height: 100%; padding: 0 14px 0 4px; font-weight: 700; color: var(--primary); letter-spacing: .5px; }
|
||||
.rb-tabs { display: flex; gap: 2px; height: 100%; align-items: flex-end; overflow-x: auto; scrollbar-width: none; }
|
||||
.rb-tab {
|
||||
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 14px; border: 1px solid transparent; border-bottom: none;
|
||||
border-radius: 5px 5px 0 0; background: none; font: inherit; font-size: 12.5px; color: var(--nav-link-color); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.rb-tab:hover { background: rgba(255, 255, 255, .55); color: var(--primary); }
|
||||
.rb-tab.active { background: var(--van-emous-ultra-light); border-color: var(--border); color: var(--primary); font-weight: 600; margin-bottom: -1px; height: 29px; }
|
||||
.rb-tab.rb-ctx { color: #fff; background: var(--primary); margin-left: 10px; }
|
||||
.rb-tab.rb-ctx:hover { background: var(--primary-hover); color: #fff; }
|
||||
.rb-tab.rb-ctx.active { background: var(--van-emous-ultra-light); color: var(--primary); box-shadow: inset 0 3px 0 var(--primary); }
|
||||
.rb-right { margin-left: auto; display: flex; align-items: center; height: 100%; gap: 10px; font-size: 11px; color: var(--text-muted); padding-left: 12px; }
|
||||
.rb-body {
|
||||
position: relative; display: flex; align-items: stretch; height: 104px; padding: 6px 40px 4px 6px;
|
||||
background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-x: auto; overflow-y: hidden;
|
||||
}
|
||||
#ribbon.rb-collapsed .rb-body { display: none; }
|
||||
.rb-collapse { position: absolute; right: 8px; bottom: 6px; border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 2px 4px; }
|
||||
.rb-collapse:hover { color: var(--primary); }
|
||||
.rb-group { display: flex; flex-direction: column; flex: none; padding: 0 12px; border-right: 1px solid var(--border-soft); }
|
||||
.rb-group:last-of-type { border-right: none; }
|
||||
.rb-group-body { flex: 1; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 16px; align-items: center; justify-content: start; }
|
||||
.rb-group-label { text-align: center; font-size: 10.5px; color: var(--text-faint); padding-top: 2px; white-space: nowrap; }
|
||||
.rb-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; border: 1px solid transparent; background: none; border-radius: 4px;
|
||||
font: inherit; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.rb-btn:hover:not(:disabled) { background: var(--primary-soft); border-color: rgba(185, 89, 19, .25); color: var(--primary); }
|
||||
.rb-btn:disabled { color: #a9b2bd; cursor: default; }
|
||||
.rb-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
|
||||
.rb-btn.rb-danger:hover:not(:disabled) { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
|
||||
.rb-small { height: 24px; padding: 0 7px; }
|
||||
.rb-small .bi { font-size: 14px; color: var(--primary); }
|
||||
.rb-btn:disabled .bi { color: inherit; }
|
||||
.rb-big { grid-row: span 3; flex-direction: column; justify-content: center; gap: 4px; min-width: 64px; height: 76px; padding: 4px 8px; text-align: center; }
|
||||
.rb-big .bi { font-size: 24px; color: var(--primary); }
|
||||
.rb-big span { white-space: normal; max-width: 86px; line-height: 1.2; }
|
||||
.rb-kv { display: flex; justify-content: space-between; gap: 12px; font-size: 11.5px; color: var(--text-muted); min-width: 150px; }
|
||||
.rb-kv b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.rb-kv small { font-weight: normal; color: var(--text-faint); margin-left: 2px; }
|
||||
.rb-note { grid-row: span 3; max-width: 260px; font-size: 11px; line-height: 1.4; color: var(--text-muted); white-space: normal; align-self: center; }
|
||||
.rb-note.rb-warn { color: var(--danger); }
|
||||
/* Velden (UI-kit) compact in de ribbon: label links, invoer rechts, één rij. */
|
||||
.rb-group .ui-field { min-height: 0; height: 26px; gap: 8px; }
|
||||
.rb-group .ui-field label { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
|
||||
.rb-group .ui-control input[type=number] { width: 70px; padding: 2px 5px; font-size: 12px; }
|
||||
.rb-group .ui-control select { width: 132px; padding: 2px 4px; font-size: 12px; }
|
||||
.rb-group .ui-control input[type=color] { width: 46px; height: 22px; padding: 0; border: 1px solid var(--border); border-radius: 3px; background: none; }
|
||||
.rb-group .ui-unit { width: 20px; margin-left: 3px; font-size: 10.5px; }
|
||||
.rb-group .ui-switch { margin-right: 0; }
|
||||
.rb-group .ui-field-range { flex-wrap: nowrap; padding: 0; }
|
||||
.rb-group .ui-field-range label { flex: none; }
|
||||
.rb-group .ui-range { flex: none; gap: 6px; }
|
||||
.rb-group .ui-range input[type=range] { width: 96px; flex: none; }
|
||||
.rb-group .ui-range input[type=number] { width: 62px; }
|
||||
.rb-thumb { width: 26px; height: 26px; border-radius: 3px; border: 1px solid var(--border); object-fit: cover; display: block; }
|
||||
.rb-swatch { width: 26px; height: 22px; border-radius: 3px; border: 1px solid var(--border); }
|
||||
.rb-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; height: 24px; }
|
||||
.rb-seg .rb-btn { border-radius: 0; border: none; border-right: 1px solid var(--border-soft); height: 22px; padding: 0 8px; }
|
||||
.rb-seg .rb-btn:last-child { border-right: none; }
|
||||
.rb-seg .rb-btn.active { background: var(--primary); color: #fff; }
|
||||
.rb-seg .rb-btn.active .bi { color: #fff; }
|
||||
.rb-check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap; }
|
||||
.rb-check input { accent-color: var(--primary); }
|
||||
/* ===== Shell: bovenbalk (menu + documenten), editorindeling, werkbalk, eigenschappen, statusbalk ===== */
|
||||
:root { --panel-w: 340px; --nav-w: 230px; }
|
||||
body { display: flex; flex-direction: column; }
|
||||
|
||||
/* Ribbon: hulpklassen voor de woning-tabbladen. */
|
||||
.rb-pair { display: flex; align-items: center; gap: 6px; }
|
||||
.rb-cols { grid-row: span 3; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 18px; align-items: center; }
|
||||
.rb-cols:empty { display: none; }
|
||||
#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); }
|
||||
|
||||
+29
-17
@@ -22,15 +22,23 @@
|
||||
<div class="splash-version"><span class="splash-name">Tectum</span> — Versie <span class="app-version-number">0.0.0</span></div>
|
||||
</div>
|
||||
|
||||
<header id="ribbon">
|
||||
<div class="rb-top">
|
||||
<div class="rb-brand"><span class="splash-name">Tectum</span></div>
|
||||
<div class="rb-tabs" role="tablist"></div>
|
||||
<div class="rb-right"><span class="app-version-slot"></span></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>
|
||||
<div class="rb-body"></div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
<div id="about-overlay">
|
||||
<div id="about-modal">
|
||||
<img class="about-logo" src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
||||
@@ -49,7 +57,7 @@
|
||||
|
||||
<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>
|
||||
@@ -67,50 +75,54 @@
|
||||
<svg viewBox="0 0 48 48"><rect x="6" y="6" width="36" height="36" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="18" x2="42" y2="18" stroke="currentColor" stroke-width="2"/><line x1="6" y1="30" x2="42" y2="30" stroke="currentColor" stroke-width="2"/><line x1="18" y1="6" x2="18" y2="42" stroke="currentColor" stroke-width="2"/><line x1="30" y1="6" x2="30" y2="42" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<span>Los: vloer</span>
|
||||
</button>
|
||||
<button class="new-option disabled" data-kind="dak" disabled title="Nog niet beschikbaar">
|
||||
<svg viewBox="0 0 48 48"><path d="M4 30 L24 8 L44 30" fill="none" stroke="currentColor" stroke-width="2"/><line x1="10" y1="30" x2="38" y2="30" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<span>Dak</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-bar"></div>
|
||||
<div id="workspace"></div>
|
||||
|
||||
<script src="js/core/license.js"></script>
|
||||
<script src="js/core/logo-data.js"></script>
|
||||
<script src="js/core/geometry.js"></script>
|
||||
<script src="js/core/build-stages.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/granny-kit.js"></script>
|
||||
<script src="js/core/shooter.js"></script>
|
||||
<script src="js/core/view-filter.js"></script>
|
||||
<script src="js/core/ui-kit.js"></script>
|
||||
<script src="js/core/ribbon.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/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
const APP_VERSION = '0.5.2';
|
||||
const APP_VERSION = '0.7.1';
|
||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||
|
||||
let tabs = [];
|
||||
@@ -28,70 +28,83 @@ 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>`;
|
||||
}
|
||||
|
||||
// Ribbon (core/ribbon.js): app-tabbladen Bestand, Beeld en Extra; de
|
||||
// editor voegt er zijn eigen tabbladen aan toe. Knoppen die de huidige
|
||||
// editor niet ondersteunt staan uit.
|
||||
function updateEditMenuState() {
|
||||
Ribbon.refresh();
|
||||
}
|
||||
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') || {};
|
||||
|
||||
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
|
||||
const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); };
|
||||
const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled';
|
||||
|
||||
function appRibbonTabs() {
|
||||
const { big, small } = Ribbon;
|
||||
const wireClicks = (body) => body.querySelectorAll('[data-app]').forEach(b => b.addEventListener('click', () => {
|
||||
const a = b.dataset.app;
|
||||
if (a === 'new') openNewModal();
|
||||
else if (a === 'new-kind') openNew(b.dataset.kind, b.dataset.template);
|
||||
else if (a === 'fullscreen') toggleBrowserFullscreen();
|
||||
else if (a === 'costs') CostSettings.open();
|
||||
else if (a === 'about') document.getElementById('about-overlay').classList.add('open');
|
||||
else editorCall(a);
|
||||
}));
|
||||
return [
|
||||
{ key: 'bestand', label: 'Bestand', order: 0, wire: wireClicks, groups: () => [
|
||||
{ label: 'Document', html:
|
||||
big('Nieuw', 'file-earmark-plus', 'data-app="new" title="Nieuw ontwerp (Ctrl+N)"') +
|
||||
big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) +
|
||||
big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) },
|
||||
{ label: 'Snel nieuw', html:
|
||||
small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') +
|
||||
small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') +
|
||||
small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') +
|
||||
small('Los: vloer', 'grid-3x3', 'data-app="new-kind" data-kind="vloer"') },
|
||||
{ label: 'Project', html: big('Eigenschappen', 'card-text', `data-app="openProjectProperties" title="Projectgegevens voor de PDF" ${disabledUnless('openProjectProperties')}`) }
|
||||
// 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') },
|
||||
{ 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 }
|
||||
] },
|
||||
{ key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [
|
||||
{ label: 'Venster', html:
|
||||
small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') +
|
||||
small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) +
|
||||
small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) },
|
||||
{ label: 'Camera', html:
|
||||
big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) +
|
||||
small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') }
|
||||
{ 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 }
|
||||
] },
|
||||
{ key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [
|
||||
{ label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) },
|
||||
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') },
|
||||
{ label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') }
|
||||
{ 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'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF)']]],
|
||||
['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 slepen', 'Verschuiven'], ['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)}">
|
||||
<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('');
|
||||
document.getElementById('top-save').disabled = !editorHas('exportDesign');
|
||||
}
|
||||
|
||||
function onTabSaved(tab, filenameWithExt) {
|
||||
@@ -104,7 +117,6 @@ function onTabSaved(tab, filenameWithExt) {
|
||||
function mountEditorForTab(tab) {
|
||||
const ws = document.getElementById('workspace');
|
||||
ws.innerHTML = '';
|
||||
Ribbon.setSource('editor', []);
|
||||
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
|
||||
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
|
||||
currentEditor.loadStateJson(tab.stateJson);
|
||||
@@ -122,12 +134,11 @@ function switchToTab(tabId) {
|
||||
|
||||
activeTabId = tabId;
|
||||
mountEditorForTab(tabs.find(t => t.id === tabId));
|
||||
updateEditMenuState();
|
||||
renderTabBar();
|
||||
}
|
||||
|
||||
function openNew(kind, template) {
|
||||
if (!editorModuleFor(kind)) return; // 'dak' nog niet beschikbaar
|
||||
if (!editorModuleFor(kind)) return;
|
||||
|
||||
const oldTab = tabs.find(t => t.id === activeTabId);
|
||||
if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') {
|
||||
@@ -140,7 +151,6 @@ function openNew(kind, template) {
|
||||
tabs.push(tab);
|
||||
activeTabId = tab.id;
|
||||
mountEditorForTab(tab);
|
||||
updateEditMenuState();
|
||||
renderTabBar();
|
||||
}
|
||||
|
||||
@@ -181,22 +191,19 @@ function closeTab(tabId) {
|
||||
switchToTab(tabs[tabs.length - 1].id);
|
||||
} else {
|
||||
setWorkspaceEmpty();
|
||||
Ribbon.setSource('editor', []);
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
// 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 => {
|
||||
@@ -216,6 +223,8 @@ function wireAbout() {
|
||||
|
||||
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', () => {
|
||||
@@ -234,59 +243,53 @@ function toggleBrowserFullscreen() {
|
||||
else document.documentElement.requestFullscreen();
|
||||
}
|
||||
|
||||
function closeOverlays() {
|
||||
const open = document.querySelector('.dlg-overlay.open, #about-overlay.open, #new-overlay.open');
|
||||
if (open) { 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();
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const act = { s: () => editorCall('exportDesign'), n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
|
||||
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault();
|
||||
openNewModal();
|
||||
return;
|
||||
}
|
||||
// Easter egg (shooter); e.code omdat Alt/Shift e.key per toetsenbord verandert.
|
||||
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyL') {
|
||||
e.preventDefault();
|
||||
if (currentEditor && typeof currentEditor.toggleShooter === 'function') currentEditor.toggleShooter();
|
||||
return;
|
||||
}
|
||||
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
if (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; });
|
||||
Ribbon.init(document.getElementById('ribbon'));
|
||||
Ribbon.setSource('app', appRibbonTabs());
|
||||
Menubar.init(document.getElementById('menubar'), MENUS);
|
||||
document.getElementById('top-save').addEventListener('click', () => editorCall('exportDesign'));
|
||||
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');
|
||||
openNew('woning', 'amstel');
|
||||
|
||||
setTimeout(() => {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/build-stages.js — Bouwfasen (window.BuildStages): de volgorde waarin
|
||||
// een unit in de hal wordt opgebouwd. Eén begrip voor zowel de weergave
|
||||
// (schuif "Bouwfase" in de 3D) als het weglaten van verborgen delen:
|
||||
//
|
||||
// 0 regelwerk vloer-, wand- en dakbalken
|
||||
// 1 elektra installatiebuizen, dozen en meterkast (ElektraModel)
|
||||
// 2 beplating spaanplaat binnen, OSB vloer/plafond, cementplaat buiten
|
||||
// 3 doek vochtdoek
|
||||
// 4 latwerk kruislat, trasraamplaat
|
||||
// 5 gevel rabat, steen, kozijnen, dakbedekking, sandwichpanelen
|
||||
// 6 afwerking dakrand, verlichting, vloerafwerking, verf binnen
|
||||
//
|
||||
// Fasen zijn cumulatief: fase n toont alles t/m n. Een laag die in de
|
||||
// gekozen fase aan beide zijden is ingesloten door latere lagen wordt niet
|
||||
// gebouwd (bijv. stijlen tussen spaanplaat en doek, latten achter de
|
||||
// rabat). De specificaties rekenen altijd met alle lagen.
|
||||
//
|
||||
// Plain script (geen ES-module); het model-deel heeft geen DOM nodig.
|
||||
// ============================================================
|
||||
|
||||
window.BuildStages = (() => {
|
||||
const LIST = [
|
||||
{ key: 'regelwerk', label: 'Regelwerk', hint: 'Vloer-, wand- en dakbalken' },
|
||||
{ key: 'elektra', label: 'Elektra', hint: 'Installatiebuizen door wanden en plafond, dozen en meterkast' },
|
||||
{ key: 'beplating', label: 'Beplating', hint: 'Spaanplaat binnen, OSB vloer en plafond, cementplaat buiten' },
|
||||
{ key: 'doek', label: 'Vochtdoek', hint: 'Vochtdoek op de buitenzijde' },
|
||||
{ key: 'latwerk', label: 'Latwerk', hint: 'Kruislat en trasraamplaat' },
|
||||
{ key: 'gevel', label: 'Gevel & dak', hint: 'Rabat, steen, kozijnen, dakbedekking, sandwichpanelen' },
|
||||
{ key: 'afwerking', label: 'Afgewerkt', hint: 'Dakrand, verlichting, vloerafwerking, verf binnen' }
|
||||
];
|
||||
const FINAL = LIST.length - 1;
|
||||
const INDEX = Object.fromEntries(LIST.map((s, i) => [s.key, i]));
|
||||
|
||||
// Fase (getal of undefined = afgewerkt) normaliseren.
|
||||
const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL;
|
||||
// Is laag `key` zichtbaar in fase `stage`?
|
||||
const shows = (stage, key) => clamp(stage) >= INDEX[key];
|
||||
|
||||
// Schuif onderin een 3D-weergave (DOM). pane = de weergave (position:
|
||||
// relative); onChange(stage) bij elke stap. Geeft { set(stage), el,
|
||||
// extra } terug; extra = vrije rij onder de schuif (bijv. doorsnede).
|
||||
function createBar(pane, stage, onChange) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'stage-bar';
|
||||
el.innerHTML = `
|
||||
<div class="stage-row">
|
||||
<span class="stage-title"><i class="bi bi-layers"></i>Bouwfase</span>
|
||||
<div class="stage-track">
|
||||
<input type="range" class="stage-range" min="0" max="${FINAL}" step="1" value="${clamp(stage)}" aria-label="Bouwfase">
|
||||
<div class="stage-ticks">${LIST.map((s, i) => `<span data-i="${i}" title="${s.label}: ${s.hint}">${i + 1}</span>`).join('')}</div>
|
||||
</div>
|
||||
<span class="stage-name"></span>
|
||||
</div>
|
||||
<div class="stage-extra"></div>`;
|
||||
pane.appendChild(el);
|
||||
const range = el.querySelector('.stage-range'), name = el.querySelector('.stage-name');
|
||||
const sync = () => {
|
||||
const i = clamp(Number(range.value));
|
||||
name.textContent = LIST[i].label;
|
||||
name.title = LIST[i].hint;
|
||||
el.querySelectorAll('.stage-ticks span').forEach(t => t.classList.toggle('on', Number(t.dataset.i) <= i));
|
||||
};
|
||||
range.addEventListener('input', () => { sync(); onChange(clamp(Number(range.value))); });
|
||||
el.querySelector('.stage-ticks').addEventListener('click', (e) => {
|
||||
const t = e.target.closest('[data-i]');
|
||||
if (!t) return;
|
||||
range.value = t.dataset.i;
|
||||
sync();
|
||||
onChange(clamp(Number(range.value)));
|
||||
});
|
||||
// Toetsen in de weergave mogen de schuif niet bedienen (T, R, ...).
|
||||
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
|
||||
sync();
|
||||
return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } };
|
||||
}
|
||||
|
||||
return { LIST, FINAL, INDEX, clamp, shows, createBar };
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
+6
-74
@@ -4,20 +4,8 @@
|
||||
* 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).
|
||||
|
||||
// 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 +23,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 +33,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 +54,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 +74,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 +115,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,25 +125,8 @@ function summarizeStockPlan(bars) {
|
||||
};
|
||||
}
|
||||
|
||||
// Verticale strook [x0, x1] van y = yBottom tot een (evt. schuine, lineaire)
|
||||
// bovenrand topYAt(x), MIN een set uitsparingen ({ x0, x1, y0, y1, y1r? }):
|
||||
// rechthoeken, of met y1r een schuine bovenkant (lineair van (x0, y1) naar
|
||||
// (x1, y1r); schuin bovenin mag, onderin niet). Uitsparingen die over de
|
||||
// rand van de strook vallen worden een echte inkeping in de omtrek (i.p.v.
|
||||
// een gat dat net binnen de rand wordt afgekapt — dat liet een haarfijn
|
||||
// reepje materiaal met zichtbare randlijnen door de opening lopen). Een
|
||||
// uitsparing over de volle breedte splitst de strook in losse delen; een
|
||||
// uitsparing tot in de (schuine) bovenrand wordt een inkeping in de bovenrand.
|
||||
// opts (optioneel):
|
||||
// yCap — vlakke bovengrens (bijv. bovenkant van een vochtdoek-baan).
|
||||
// extraXs — knikpunten van topYAt (bijv. waar de daklijn yCap kruist), zodat
|
||||
// de bovenrand tussen twee hoekpunten steeds lineair is.
|
||||
// Werkwijze: de strook wordt in kolommen geknipt (op alle x-randen, knikken
|
||||
// en snijpunten van de lijnen), zodat binnen een kolom elke lijn (onderkant,
|
||||
// onder-/bovenkant uitsparing, bovenrand) lineair is en ze elkaar niet
|
||||
// kruisen; de cellen tussen twee lijnen zijn vol of leeg, en de randen
|
||||
// tussen vol en leeg worden tot lussen aaneengeregen.
|
||||
// Resultaat: [{ outer: [{x,y}...] (CCW), holes: [[{x,y}...] (CW)] }].
|
||||
// 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;
|
||||
@@ -186,14 +141,12 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
});
|
||||
const inRect = (x, y) => rs.some(r => x > r.x0 && x < r.x1 && y > r.y0 && y < r.topAt(x));
|
||||
|
||||
// Lijnen (ongeklemd); geklemd tussen yBottom en de bovenrand.
|
||||
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);
|
||||
|
||||
let xs = uniq([x0, x1, ...((opts && opts.extraXs) || []).filter(x => x > x0 + eps && x < x1 - eps), ...rs.flatMap(r => [r.x0, r.x1])]);
|
||||
// Snijpunten van de lijnen (ook met de bovenrand/onderkant) als extra kolomranden.
|
||||
const cuts = [];
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const a = xs[i], b = xs[i + 1];
|
||||
@@ -206,7 +159,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
}
|
||||
xs = uniq(xs.concat(cuts));
|
||||
|
||||
// Per kolom: cellen tussen opeenvolgende (verschillende) lijnen.
|
||||
const cols = [];
|
||||
for (let i = 0; i < xs.length - 1; i++) {
|
||||
const a = xs[i], b = xs[i + 1], m = (a + b) / 2;
|
||||
@@ -222,27 +174,24 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
cols.push({ a, b, cells });
|
||||
}
|
||||
|
||||
// Hoekpunten gedeeld via een register (bijna-gelijke punten = hetzelfde punt).
|
||||
const verts = [];
|
||||
const vid = (x, y) => {
|
||||
let k = verts.findIndex(v => Math.abs(v.x - x) < 1e-7 && Math.abs(v.y - y) < 1e-7);
|
||||
if (k < 0) { k = verts.length; verts.push({ x, y }); }
|
||||
return k;
|
||||
};
|
||||
const edges = new Map(); // van -> [naar, ...]
|
||||
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);
|
||||
};
|
||||
// Onder-/bovenranden binnen een kolom (CCW: onder naar rechts, boven naar links).
|
||||
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 });
|
||||
}));
|
||||
// Verticale randen op elke kolomgrens: waar links vol en rechts leeg is (of andersom).
|
||||
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]) : [];
|
||||
@@ -257,7 +206,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// Randen aaneenrijgen tot gesloten lussen (collineaire tussenpunten weg).
|
||||
const loops = [];
|
||||
edges.forEach((list, start) => {
|
||||
while (list.length) {
|
||||
@@ -293,7 +241,6 @@ 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: [] }));
|
||||
// Gat bij de kleinste buitenlus die het bevat.
|
||||
outers.sort((p, q) => signedArea(p.outer) - signedArea(q.outer));
|
||||
polys.filter(p => p.length >= 3 && signedArea(p) < -1e-12).forEach(hole => {
|
||||
const mid = { x: (hole[0].x + hole[1].x) / 2, y: (hole[0].y + hole[1].y) / 2 };
|
||||
@@ -303,11 +250,6 @@ function columnMinusRects(x0, x1, yBottom, topYAt, rects, opts) {
|
||||
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);
|
||||
@@ -350,16 +292,11 @@ function rectUnionLoops(rects) {
|
||||
return loops.map(pts => ({ pts, area: area(pts) })).sort((a, b) => Math.abs(b.area) - Math.abs(a.area));
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
@@ -370,17 +307,12 @@ function offsetRectilinearLoop(pts, d) {
|
||||
});
|
||||
}
|
||||
|
||||
// Gevels: groepeert lijnstukken in de plattegrond tot doorlopende gevels
|
||||
// (zelfde as + normaal + gevelvlak, aansluitend of overlappend). segs: [{ axis ('x'|'z'),
|
||||
// normal {x, z}, line (positie op de andere as), s0, s1 (s0 < s1) }, ...].
|
||||
// Geeft [{ axis, normal, line, s0, s1, segs (op s0 gesorteerd) }].
|
||||
// Gebruikt door de woning (latwerk/doek-patroon) en de buitenverlichting.
|
||||
function groupFacadeRuns(segs, opts) {
|
||||
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] }));
|
||||
});
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/granny-kit.js — onderdelen van de easter egg (core/shooter.js):
|
||||
// oma-sprite, gibs, kreten,
|
||||
// geluidseffecten, soundtrack en de omroeper à la Unreal Tournament.
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
|
||||
const GrannyKit = (() => {
|
||||
// Oud vrouwtje met rollator, 16x25 pixels (twee loopbeelden).
|
||||
const LADY = [
|
||||
'......hHHh......',
|
||||
'.....HHHHHH.....',
|
||||
'....HHHHHHHH....',
|
||||
'....HSSSSSSH....',
|
||||
'....SGEsSGES....',
|
||||
'....SSSssSSS....',
|
||||
'.....SsrrsS.....',
|
||||
'......SSSS......',
|
||||
'.....WWWWWW.....',
|
||||
'...DDDDWWDDDD...',
|
||||
'..DDDDDDDDDDDD..',
|
||||
'..DdDDDDDDDDdD..',
|
||||
'..DdDDDDDDDDdD..',
|
||||
'MSSMMMMMMMMMMSSM',
|
||||
'.M.DDDDDDDDDD.M.',
|
||||
'.MRRRRRRRRRRRRM.',
|
||||
'.MRrRrRrRrRrRRM.',
|
||||
'.MRRRRRRRRRRRRM.',
|
||||
'.M..dDDDDDDd..M.',
|
||||
'.M...DDDDDD...M.',
|
||||
'.M....LL.LL...M.',
|
||||
'.M....LL..LL..M.',
|
||||
'.M....BB..BB..M.',
|
||||
'KKK..........KKK',
|
||||
'KkK..........KkK'
|
||||
];
|
||||
const LADY_B = LADY.slice(0, 20).concat([
|
||||
'.M...LL..LL...M.',
|
||||
'.M...LL...LL..M.',
|
||||
'.M...BB...BB..M.',
|
||||
'KkK..........KkK',
|
||||
'KKK..........KKK'
|
||||
]);
|
||||
const PAL = { h: '#9a9aa8', H: '#d0d0da', S: '#f0c8a0', s: '#d09878', E: '#101010', G: '#707080', r: '#a04040',
|
||||
W: '#f4f4f4', D: '#8040a0', d: '#5a2a78', M: '#b8c0c8', R: '#c03030', L: '#c8a888', B: '#402018', K: '#202020', k: '#606060' };
|
||||
|
||||
// Gibs: 8-bit stukjes oma (vlees, botjes, gebit, bril, handtas, breibol, rollatorwiel).
|
||||
const GIBS = [
|
||||
['..rr....', '.rRRr...', 'rRRRRr..', 'rRrRRRr.', '.rRRRRr.', '..rRRr..', '...rr...'],
|
||||
['.rr.rr..', 'rRRrRRr.', 'rRRRRRr.', '.rRRRr..', '..rrr...'],
|
||||
['ww....ww', 'wWw..wWw', '.wWwwWw.', '..wWWw..', '.wWwwWw.', 'wWw..wWw', 'ww....ww'],
|
||||
['.pppppp.', 'pWWWWWWp', 'pWkWkWkp', 'pWWWWWWp', '.pppppp.'],
|
||||
['GG..GG..', 'GbG.GbG.', 'GGGGGGG.', '........'],
|
||||
['..nn....', '.n..n...', 'nNNNNNn.', 'NNNNNNN.', 'NNyNNNN.', 'NNNNNNN.'],
|
||||
['.YYYY...', 'YyYYyY..', 'YYyYYy..', 'YyYYyY..', '.YYYY...', '.....Y..', '......Y.'],
|
||||
['.KKKK...', 'KkkkkK..', 'KkMMkK..', 'KkMMkK..', 'KkkkkK..', '.KKKK...']
|
||||
];
|
||||
const GIB_PAL = { r: '#6a0808', R: '#c01818', w: '#a09880', W: '#f0ecdc', p: '#e07888', k: '#303030', G: '#606070', b: '#a0c8f0',
|
||||
n: '#402018', N: '#7a4a22', y: '#d0b040', Y: '#c050a0', K: '#202020', M: '#b8c0c8' };
|
||||
|
||||
const SHOUTS = ['"Jongeman! Voeten vegen!"', '"In mijn tijd hadden we geen raketten!"', '"Ik bel je moeder!"',
|
||||
'"Waar is de bingo?"', '"Dat dak lekt, hoor!"', '"Is dit wel vergund?"', '"Ik heb een abonnement op de bus!"'];
|
||||
const DEATH_SHOUTS = ['"Oeeehh, mijn heup!"', '"Dat zeg ik tegen de wijkagent!"', '"Aaargh, de bingo..."'];
|
||||
const HIT_SHOUTS = ['*BAM* met de handtas!', '*klets* met de rollator!', '"Neem een koekje, en dan wegwezen!"'];
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
// Pixelraster -> canvas (1 px per teken, optioneel met rand en één tintkleur;
|
||||
// bottom = figuur op de onderrand, voor sprites die op de grond staan).
|
||||
function pixelCanvas(rows, pal, opts) {
|
||||
const o = Object.assign({ pad: 0, size: 0, tint: null, bottom: false }, opts || {});
|
||||
const w = Math.max(...rows.map(r => r.length)), h = rows.length;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = o.size || w + o.pad * 2;
|
||||
c.height = o.size || h + o.pad * 2;
|
||||
const g = c.getContext('2d');
|
||||
const ox = Math.floor((c.width - w) / 2), oy = o.bottom ? c.height - h : Math.floor((c.height - h) / 2);
|
||||
rows.forEach((row, y) => [...row].forEach((ch, x) => {
|
||||
if (!pal[ch]) return;
|
||||
g.fillStyle = o.tint || pal[ch];
|
||||
g.fillRect(ox + x, oy + y, 1, 1);
|
||||
}));
|
||||
return c;
|
||||
}
|
||||
|
||||
// Geluidseffecten: gesynthetiseerde ruis via WebAudio.
|
||||
function createSfx() {
|
||||
let audio = null;
|
||||
return {
|
||||
bang(len, freq, vol) {
|
||||
try {
|
||||
audio = audio || new (window.AudioContext || window.webkitAudioContext)();
|
||||
const n = Math.floor(audio.sampleRate * len), buf = audio.createBuffer(1, n, audio.sampleRate), d = buf.getChannelData(0);
|
||||
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 3);
|
||||
const src = audio.createBufferSource(), f = audio.createBiquadFilter(), g = audio.createGain();
|
||||
src.buffer = buf; f.type = 'lowpass'; f.frequency.value = freq; g.gain.value = vol;
|
||||
src.connect(f).connect(g).connect(audio.destination);
|
||||
src.start();
|
||||
} catch (e) { /* geen geluid, jammer */ }
|
||||
},
|
||||
close() { if (audio) audio.close(); audio = null; }
|
||||
};
|
||||
}
|
||||
|
||||
// Soundtrack: "Five Armies" Kevin MacLeod (incompetech.com), CC BY 4.0.
|
||||
const MUSIC_CREDIT = '♪ "Five Armies" Kevin MacLeod (incompetech.com) · CC BY 4.0';
|
||||
function createMusic() {
|
||||
const music = new Audio('assets/audio/five-armies.mp3');
|
||||
music.loop = true;
|
||||
music.volume = 0.5;
|
||||
return {
|
||||
play() { music.currentTime = 0; music.play().catch(() => {}); }, // toetsdruk = gebruikersactie
|
||||
stop() { music.pause(); }
|
||||
};
|
||||
}
|
||||
|
||||
// Omroeper (Web Speech API + grote tekst in labelEl).
|
||||
// Multi-kill: kills binnen MULTI_WINDOW s na elkaar. Spree: kills zonder geraakt te worden.
|
||||
const MULTI_WINDOW = 6;
|
||||
const MULTI = { 2: 'Double Granny!', 3: 'Multi Granny!', 4: 'Mega Granny!', 5: 'Ultra Granny!', 6: 'M M M MONSTER GRANNY!' };
|
||||
const SPREE = { 5: 'Bingo Spree!', 10: 'Rollator Rampage!', 15: 'Dominating the retirement home!', 20: 'Unstoppable!', 25: 'Godlike!', 30: 'Wicked Sick... of grannies!' };
|
||||
function createAnnouncer(labelEl) {
|
||||
let kills = 0, multi = 0, lastKillAt = -99, spree = 0, clock = 0, timer = 0;
|
||||
function say(text) {
|
||||
labelEl.textContent = text.replace(/^M M M /, 'M-M-M-');
|
||||
labelEl.classList.remove('show'); void labelEl.offsetWidth; labelEl.classList.add('show');
|
||||
timer = 2.2;
|
||||
try {
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
const v = speechSynthesis.getVoices().find(x => /^en(-|_)(US|GB)/i.test(x.lang) && /male|david|daniel|george/i.test(x.name))
|
||||
|| speechSynthesis.getVoices().find(x => /^en/i.test(x.lang));
|
||||
if (v) u.voice = v;
|
||||
u.lang = 'en-US'; u.pitch = 0.1; u.rate = 0.8; u.volume = 1;
|
||||
speechSynthesis.cancel();
|
||||
speechSynthesis.speak(u);
|
||||
} catch (e) { /* geen stem, dan alleen tekst */ }
|
||||
}
|
||||
return {
|
||||
say,
|
||||
get kills() { return kills; },
|
||||
kill() {
|
||||
kills++;
|
||||
multi = clock - lastKillAt <= MULTI_WINDOW ? multi + 1 : 1;
|
||||
lastKillAt = clock;
|
||||
spree++;
|
||||
if (kills === 1) say('First Blood!');
|
||||
else if (multi >= 2) say(MULTI[Math.min(multi, 6)]);
|
||||
else if (SPREE[spree]) say(SPREE[spree]);
|
||||
return kills;
|
||||
},
|
||||
hurt() {
|
||||
if (spree >= 5) say('Spree ended by a handbag!');
|
||||
spree = 0;
|
||||
},
|
||||
reset() { kills = multi = spree = 0; lastKillAt = -99; },
|
||||
update(dt) {
|
||||
clock += dt;
|
||||
if (timer > 0 && (timer -= dt) <= 0) labelEl.classList.remove('show');
|
||||
},
|
||||
stop() {
|
||||
try { speechSynthesis.cancel(); } catch (e) { /* */ }
|
||||
labelEl.classList.remove('show');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { LADY, LADY_B, PAL, GIBS, GIB_PAL, SHOUTS, DEATH_SHOUTS, HIT_SHOUTS, MUSIC_CREDIT,
|
||||
pick, pixelCanvas, createSfx, createMusic, createAnnouncer };
|
||||
})();
|
||||
@@ -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,63 @@
|
||||
/*!
|
||||
* 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 = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
|
||||
const big = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="ins-btn ins-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||
const small = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="ins-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||
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 || ''}>${c.label}</button>`).join(icon('chevron-right'))}${icon('chevron-right')}</div>` : ''}
|
||||
<h2>${view.icon ? icon(view.icon) : ''}<span>${view.title}</span></h2>
|
||||
${view.sub ? `<div class="ins-sub">${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">${groupsHtml(sec.groups ? sec.groups() : [], sec.label)}</div>`;
|
||||
host.appendChild(node);
|
||||
if (collapsible) node.querySelector('.ins-section-head').addEventListener('click', () => {
|
||||
const now = !node.classList.contains('open');
|
||||
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();
|
||||
});
|
||||
if (sec.wire) sec.wire(node);
|
||||
});
|
||||
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,61 @@
|
||||
/*!
|
||||
* 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;
|
||||
root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open'));
|
||||
root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; });
|
||||
return true;
|
||||
}
|
||||
|
||||
function openMenu(i) {
|
||||
close();
|
||||
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));
|
||||
});
|
||||
document.addEventListener('click', close);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/ribbon.js — Ribbon (window.Ribbon): de bedieningsbalk bovenin. Eén
|
||||
// plek voor alle commando's en instellingen; de weergaven eronder blijven
|
||||
// vrij.
|
||||
//
|
||||
// Tabbladen komen uit bronnen: 'app' (Bestand, Beeld, Extra) en 'editor'
|
||||
// (de actieve editor, bijv. Start, Gevel, Dak, ... en een contextueel
|
||||
// tabblad voor de selectie). Tabbladen met dezelfde sleutel uit meerdere
|
||||
// bronnen worden samengevoegd (bijv. Beeld: weergave-opties van de woning
|
||||
// + vensterknoppen van de app).
|
||||
//
|
||||
// Ribbon.setSource('editor', [{
|
||||
// key, label, order (sortering), contextual (gekleurd, achteraan),
|
||||
// groups: () => [{ label, html }], // inhoud, opnieuw bij elke weergave
|
||||
// wire: (body) => {}, // events koppelen na het tekenen
|
||||
// onShow: () => {} // tabblad wordt actief
|
||||
// }]);
|
||||
// Ribbon.activate(key) · Ribbon.refresh() · Ribbon.active() · Ribbon.onChange(fn)
|
||||
//
|
||||
// Groepen: een raster van 3 rijen hoog; velden (UI-kit) staan per kolom
|
||||
// onder elkaar. Grote knop: Ribbon.big(...), kleine: Ribbon.small(...).
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
|
||||
window.Ribbon = (() => {
|
||||
const sources = {}; // naam -> [tab]
|
||||
const SOURCE_ORDER = ['app', 'editor'];
|
||||
let root = null, tabsEl = null, bodyEl = null;
|
||||
let activeKey = null, lastPlainKey = null;
|
||||
let auto = true; // tabblad nog niet door de gebruiker gekozen: voorkeur 'start'
|
||||
let collapsed = false, notified = null;
|
||||
const listeners = [];
|
||||
const PREF_KEY = 'tectum.ribbon.collapsed';
|
||||
|
||||
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
|
||||
// Grote knop (3 rijen hoog): icoon boven het label.
|
||||
const big = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||
// Kleine knop (1 rij).
|
||||
const small = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
||||
// Waarde-regel (alleen lezen) binnen een groep.
|
||||
const kv = (label, value, unit) =>
|
||||
`<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
|
||||
|
||||
function allTabs() {
|
||||
const byKey = new Map();
|
||||
SOURCE_ORDER.concat(Object.keys(sources).filter(n => !SOURCE_ORDER.includes(n))).forEach(name => {
|
||||
(sources[name] || []).forEach(t => {
|
||||
if (!byKey.has(t.key)) byKey.set(t.key, { key: t.key, label: t.label, order: t.order || 0, contextual: !!t.contextual, icon: t.icon, parts: [] });
|
||||
const m = byKey.get(t.key);
|
||||
if (t.order !== undefined && name !== 'app') m.order = t.order;
|
||||
if (t.label && name !== 'app') m.label = t.label;
|
||||
if (t.contextual) m.contextual = true;
|
||||
m.parts.push(t);
|
||||
});
|
||||
});
|
||||
return Array.from(byKey.values()).sort((a, b) => (a.contextual - b.contextual) || (a.order - b.order));
|
||||
}
|
||||
|
||||
function renderTabs(list) {
|
||||
tabsEl.innerHTML = list.map(t =>
|
||||
`<button type="button" class="rb-tab${t.key === activeKey ? ' active' : ''}${t.contextual ? ' rb-ctx' : ''}" data-rb-tab="${t.key}">${t.contextual && t.icon ? icon(t.icon) : ''}<span>${t.label}</span></button>`).join('');
|
||||
}
|
||||
|
||||
function renderBody(list) {
|
||||
const tab = list.find(t => t.key === activeKey);
|
||||
if (!tab) { bodyEl.innerHTML = ''; return; }
|
||||
const groups = [];
|
||||
tab.parts.forEach(p => (p.groups ? p.groups() : []).forEach(g => g && groups.push(g)));
|
||||
bodyEl.innerHTML = groups.map(g =>
|
||||
`<section class="rb-group${g.cls ? ' ' + g.cls : ''}"><div class="rb-group-body">${g.html}</div><div class="rb-group-label">${g.label}</div></section>`).join('') +
|
||||
`<button type="button" class="rb-collapse" title="Ribbon inklappen (dubbelklik op een tabblad)">${icon('chevron-up')}</button>`;
|
||||
tab.parts.forEach(p => p.wire && p.wire(bodyEl));
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!root) return;
|
||||
const list = allTabs();
|
||||
if (!list.some(t => t.key === activeKey) || (auto && !list.some(t => t.key === activeKey && t.contextual))) {
|
||||
const pick = [lastPlainKey, 'start'].find(k => list.some(t => t.key === k));
|
||||
activeKey = pick || (list.find(t => !t.contextual) || {}).key || null;
|
||||
}
|
||||
renderTabs(list);
|
||||
renderBody(list);
|
||||
root.classList.toggle('rb-collapsed', collapsed);
|
||||
if (notified !== activeKey) { notified = activeKey; listeners.forEach(fn => fn(activeKey)); }
|
||||
}
|
||||
|
||||
function activate(key) {
|
||||
const list = allTabs();
|
||||
const tab = list.find(t => t.key === key);
|
||||
if (!tab) return;
|
||||
activeKey = key;
|
||||
auto = false;
|
||||
if (!tab.contextual) lastPlainKey = key;
|
||||
if (collapsed) { collapsed = false; savePref(); }
|
||||
render();
|
||||
tab.parts.forEach(p => p.onShow && p.onShow());
|
||||
}
|
||||
|
||||
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { /* alleen deze sessie */ } }
|
||||
|
||||
function init(el) {
|
||||
root = el;
|
||||
try { collapsed = localStorage.getItem(PREF_KEY) === '1'; } catch (e) { collapsed = false; }
|
||||
tabsEl = el.querySelector('.rb-tabs');
|
||||
bodyEl = el.querySelector('.rb-body');
|
||||
tabsEl.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-rb-tab]');
|
||||
if (!b) return;
|
||||
if (b.dataset.rbTab === activeKey && collapsed) { collapsed = false; savePref(); render(); return; }
|
||||
activate(b.dataset.rbTab);
|
||||
});
|
||||
tabsEl.addEventListener('dblclick', (e) => {
|
||||
if (!e.target.closest('[data-rb-tab]')) return;
|
||||
collapsed = !collapsed; savePref(); render();
|
||||
});
|
||||
bodyEl.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.rb-collapse')) { collapsed = true; savePref(); render(); }
|
||||
});
|
||||
render();
|
||||
}
|
||||
|
||||
// Bron vervangen. Een nieuw contextueel tabblad (andere sleutel) wordt
|
||||
// actief als opts.focus; verdwijnt het actieve, dan terug naar het vorige.
|
||||
function setSource(name, tabs, opts) {
|
||||
sources[name] = tabs || [];
|
||||
const ctx = (tabs || []).find(t => t.contextual);
|
||||
if (opts && opts.focus && ctx) { activeKey = ctx.key; if (collapsed) { collapsed = false; savePref(); } }
|
||||
render();
|
||||
if (opts && opts.focus && ctx && ctx.onShow) ctx.onShow();
|
||||
}
|
||||
|
||||
// fn(key) bij elke wissel van het actieve tabblad.
|
||||
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
|
||||
|
||||
return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv };
|
||||
})();
|
||||
@@ -1,469 +0,0 @@
|
||||
/*!
|
||||
* Tectum
|
||||
* Copyright © 2026 Machiel Heinen.
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// core/shooter.js — easter egg: schieten in de loopmodus (Ctrl+Shift+Alt+L).
|
||||
// klik in beeld muis vangen (Esc = loslaten)
|
||||
// linkermuisknop vuren
|
||||
// 1 / 2 / scrollwiel pistool / raketwerper
|
||||
// Kogelgaten en roetplekken blijven op wanden/dak/vloer achter, raketten
|
||||
// laten ook wat rondvliegend metselwerk achter. Bewust simpel en buggy.
|
||||
// Plain script (geen ES-module); gebruikt THREE.
|
||||
//
|
||||
// const gun = createShooter({ scene, camera, domElement, pane, walk, targets: () => [..] });
|
||||
// gun.start(); gun.update(dt); gun.stop(); gun.dispose();
|
||||
// ============================================================
|
||||
|
||||
function createShooter(o) {
|
||||
const { scene, camera, domElement, pane, walk } = o;
|
||||
const group = new THREE.Group();
|
||||
group.name = 'shooter';
|
||||
let active = false, weapon = 0, cooldown = 0, recoil = 0;
|
||||
const decals = [], rockets = [], blasts = [], debris = [];
|
||||
const MAX_DECALS = 250;
|
||||
|
||||
// --- Texturen (canvas) ---
|
||||
function canvasTex(size, draw) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = size;
|
||||
draw(c.getContext('2d'), size);
|
||||
const t = new THREE.CanvasTexture(c);
|
||||
return t;
|
||||
}
|
||||
const holeTex = canvasTex(64, (g, s) => {
|
||||
const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
|
||||
r.addColorStop(0, 'rgba(0,0,0,1)'); r.addColorStop(0.18, 'rgba(10,8,6,1)');
|
||||
r.addColorStop(0.3, 'rgba(60,50,40,0.8)'); r.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = r; g.fillRect(0, 0, s, s);
|
||||
g.strokeStyle = 'rgba(20,15,10,0.7)'; g.lineWidth = 1.5;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = Math.random() * Math.PI * 2, l = s * (0.25 + Math.random() * 0.2);
|
||||
g.beginPath(); g.moveTo(s / 2, s / 2); g.lineTo(s / 2 + Math.cos(a) * l, s / 2 + Math.sin(a) * l); g.stroke();
|
||||
}
|
||||
});
|
||||
const scorchTex = canvasTex(128, (g, s) => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const x = s / 2 + (Math.random() - 0.5) * s * 0.5, y = s / 2 + (Math.random() - 0.5) * s * 0.5;
|
||||
const rr = s * (0.1 + Math.random() * 0.25);
|
||||
const r = g.createRadialGradient(x, y, 0, x, y, rr);
|
||||
r.addColorStop(0, 'rgba(15,10,5,0.5)'); r.addColorStop(1, 'rgba(15,10,5,0)');
|
||||
g.fillStyle = r; g.fillRect(0, 0, s, s);
|
||||
}
|
||||
g.fillStyle = 'rgba(255,120,20,0.6)';
|
||||
for (let i = 0; i < 25; i++) g.fillRect(s / 2 + (Math.random() - 0.5) * s * 0.4, s / 2 + (Math.random() - 0.5) * s * 0.4, 2, 2);
|
||||
});
|
||||
const fireTex = canvasTex(64, (g, s) => {
|
||||
const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
|
||||
r.addColorStop(0, 'rgba(255,255,220,1)'); r.addColorStop(0.3, 'rgba(255,180,40,0.9)');
|
||||
r.addColorStop(0.7, 'rgba(200,50,0,0.4)'); r.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = r; g.fillRect(0, 0, s, s);
|
||||
});
|
||||
// Puin krijgt een willekeurige steentextuur uit de bibliotheek (als die laadt).
|
||||
const brickTex = new THREE.TextureLoader().load(['assets/textures/buitenmuur/R200 - Witte voeg.jpg', 'assets/textures/buitenmuur/K739 - Witte voeg.jpg', 'assets/textures/buitenmuur/R480 - Witte voeg.jpg'][Math.floor(Math.random() * 3)]);
|
||||
const decalMat = (map) => new THREE.MeshBasicMaterial({ map, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4 });
|
||||
const holeMat = decalMat(holeTex), scorchMat = decalMat(scorchTex);
|
||||
const debrisMat = new THREE.MeshStandardMaterial({ map: brickTex, roughness: 0.9 });
|
||||
const fireMat = new THREE.SpriteMaterial({ map: fireTex, blending: THREE.AdditiveBlending, depthWrite: false });
|
||||
|
||||
// --- Wapens (vastgeplakt aan de camera) ---
|
||||
const metal = new THREE.MeshStandardMaterial({ color: 0x2a2c30, metalness: 0.7, roughness: 0.35 });
|
||||
const olive = new THREE.MeshStandardMaterial({ color: 0x4b5a32, roughness: 0.6 });
|
||||
function box(w, h, d, m, x, y, z) { const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); b.position.set(x, y, z); return b; }
|
||||
const pistol = new THREE.Group();
|
||||
pistol.add(box(0.035, 0.045, 0.2, metal, 0, 0, -0.1), box(0.03, 0.09, 0.04, metal, 0, -0.055, -0.02));
|
||||
const launcher = new THREE.Group();
|
||||
const tube = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.7, 12, 1, true), olive);
|
||||
tube.material.side = THREE.DoubleSide;
|
||||
tube.rotation.x = Math.PI / 2; tube.position.z = -0.25;
|
||||
launcher.add(tube, box(0.03, 0.09, 0.04, metal, 0, -0.08, -0.05), box(0.02, 0.05, 0.03, metal, 0.05, 0.05, -0.2));
|
||||
const flash = new THREE.Sprite(fireMat.clone());
|
||||
flash.scale.set(0.15, 0.15, 1);
|
||||
flash.visible = false;
|
||||
const viewModel = new THREE.Group();
|
||||
viewModel.add(pistol, launcher, flash);
|
||||
const gunLight = new THREE.PointLight(0xffaa44, 0, 6);
|
||||
group.add(viewModel, gunLight);
|
||||
|
||||
// --- HUD ---
|
||||
const hud = document.createElement('div');
|
||||
hud.className = 'shooter-hud';
|
||||
hud.innerHTML = '<div class="shooter-cross"></div><div class="shooter-weapon"></div><div class="shooter-credit"></div><div class="shooter-kills"></div><div class="shooter-shout"></div><div class="shooter-ouch"></div><div class="shooter-announce"></div><div class="shooter-help">Klik = muis vangen · linkerknop = vuren · 1/2 of scroll = wapen · Esc = muis los · Ctrl+Shift+Alt+L = stoppen</div>';
|
||||
pane.appendChild(hud);
|
||||
hud.querySelector('.shooter-credit').textContent = GrannyKit.MUSIC_CREDIT;
|
||||
const weaponLabel = hud.querySelector('.shooter-weapon');
|
||||
const killsLabel = hud.querySelector('.shooter-kills');
|
||||
const shoutLabel = hud.querySelector('.shooter-shout');
|
||||
const ouch = hud.querySelector('.shooter-ouch');
|
||||
let shoutTimer = 0;
|
||||
function shout(text) { shoutLabel.textContent = text; shoutLabel.classList.add('show'); shoutTimer = 2.5; }
|
||||
function setKills(n) { killsLabel.textContent = "Oma's: " + n; }
|
||||
const announcer = GrannyKit.createAnnouncer(hud.querySelector('.shooter-announce'));
|
||||
|
||||
function selectWeapon(i) {
|
||||
weapon = (i + 2) % 2;
|
||||
pistol.visible = weapon === 0;
|
||||
launcher.visible = weapon === 1;
|
||||
weaponLabel.textContent = weapon === 0 ? '[1] Pistool ∞' : '[2] Raketwerper ∞';
|
||||
flash.position.set(0, 0, weapon === 0 ? -0.22 : -0.62);
|
||||
}
|
||||
selectWeapon(0);
|
||||
|
||||
// --- Vijand: oud vrouwtje met rollator (8-bit sprite, Doom-stijl) ---
|
||||
// Eén tegelijk: spawnt op 12-18 m, schuifelt op je af, na de kill komt de volgende.
|
||||
function spriteTex(rows, tint) {
|
||||
const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.PAL, { size: 32, tint, bottom: true }));
|
||||
t.magFilter = t.minFilter = THREE.NearestFilter;
|
||||
t.generateMipmaps = false;
|
||||
return t;
|
||||
}
|
||||
const ladyTex = [spriteTex(GrannyKit.LADY), spriteTex(GrannyKit.LADY_B)];
|
||||
const ladyHurtTex = spriteTex(GrannyKit.LADY, '#ff3030');
|
||||
const LADY_H = 1.6 * 32 / 25; // sprite-hoogte (m) zodat de figuur ~1,6 m is
|
||||
let mob = null, spawnTimer = 1.5, attackTimer = 0, animT = 0;
|
||||
function spawnMob() {
|
||||
const me = camera.position;
|
||||
let x = me.x, z = me.z;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const a = Math.random() * Math.PI * 2, d = 12 + Math.random() * 6;
|
||||
x = me.x + Math.sin(a) * d; z = me.z + Math.cos(a) * d;
|
||||
if (!o.blocked || !o.blocked(x, z)) break;
|
||||
}
|
||||
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: ladyTex[0], transparent: true, alphaTest: 0.5 }));
|
||||
sp.scale.set(LADY_H, LADY_H, 1);
|
||||
group.add(sp);
|
||||
mob = { sp, x, z, hp: 3, alive: true, hurt: 0, dead: 0 };
|
||||
placeMob();
|
||||
shout(GrannyKit.pick(GrannyKit.SHOUTS));
|
||||
}
|
||||
function mobFloor(x, z) { return o.floorAt ? o.floorAt(x, z) : 0; }
|
||||
function placeMob() { mob.sp.position.set(mob.x, mobFloor(mob.x, mob.z) + LADY_H / 2 - (mob.dead ? LADY_H * 0.3 : 0), mob.z); }
|
||||
function mobCenter() { return new THREE.Vector3(mob.x, mobFloor(mob.x, mob.z) + 0.8, mob.z); }
|
||||
// Straal tegen een staande cilinder (r 0,4 m, 1,6 m hoog) om de oma.
|
||||
function rayHitsMob(origin, dir, maxDist) {
|
||||
if (!mob || !mob.alive) return false;
|
||||
const c = mobCenter();
|
||||
const t = c.clone().sub(origin).dot(dir);
|
||||
if (t < 0 || t > maxDist) return false;
|
||||
const p = origin.clone().addScaledVector(dir, t);
|
||||
return Math.hypot(p.x - c.x, p.z - c.z) < 0.4 && Math.abs(p.y - c.y) < 0.8;
|
||||
}
|
||||
const gibTex = GrannyKit.GIBS.map(rows => {
|
||||
const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.GIB_PAL, { size: 8 }));
|
||||
t.magFilter = t.minFilter = THREE.NearestFilter;
|
||||
t.generateMipmaps = false;
|
||||
return t;
|
||||
});
|
||||
const bloodTex = canvasTex(32, (g) => {
|
||||
g.fillStyle = '#7a0a0a';
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const a = Math.random() * 6.28, d = Math.random() * 10, r = 2 + Math.random() * 5;
|
||||
g.beginPath(); g.arc(16 + Math.cos(a) * d, 16 + Math.sin(a) * d, r, 0, 6.28); g.fill();
|
||||
}
|
||||
});
|
||||
bloodTex.magFilter = THREE.NearestFilter;
|
||||
const bloodMat = decalMat(bloodTex);
|
||||
function spawnGibs(center, count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Vooral vlees, plus een paar herkenbare stukken.
|
||||
const k = i < count * 0.5 ? Math.floor(Math.random() * 2) : Math.floor(Math.random() * GrannyKit.GIBS.length);
|
||||
const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: gibTex[k], alphaTest: 0.5 }));
|
||||
const sz = 0.18 + Math.random() * 0.14;
|
||||
sp.scale.set(sz, sz, 1);
|
||||
sp.position.copy(center).add(new THREE.Vector3((Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.8, (Math.random() - 0.5) * 0.4));
|
||||
const v = new THREE.Vector3((Math.random() - 0.5) * 9, 2 + Math.random() * 6, (Math.random() - 0.5) * 9);
|
||||
group.add(sp);
|
||||
debris.push({ mesh: sp, v, spin: new THREE.Vector3((Math.random() - 0.5) * 20, 0, 0), rest: false, gib: true });
|
||||
}
|
||||
// Plas onder de plek des onheils.
|
||||
bloodSplat(center.x, center.z, 1.4);
|
||||
trimDebris();
|
||||
}
|
||||
function bloodSplat(x, z, size) {
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), bloodMat);
|
||||
m.rotation.set(-Math.PI / 2, 0, Math.random() * 6.28);
|
||||
m.position.set(x, mobFloor(x, z) + 0.006, z);
|
||||
group.add(m);
|
||||
decals.push(m);
|
||||
}
|
||||
function trimDebris() {
|
||||
while (debris.length > 200) {
|
||||
const old = debris.shift();
|
||||
group.remove(old.mesh);
|
||||
if (old.mesh.isSprite) old.mesh.material.dispose(); else old.mesh.geometry.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function damageMob(n, gib) {
|
||||
if (!mob || !mob.alive) return;
|
||||
mob.hp -= n;
|
||||
mob.hurt = 0.15;
|
||||
spawnBlast(mobCenter(), 0.4, 0.2);
|
||||
if (mob.hp > 0) { bang(0.2, 900, 0.3); return; }
|
||||
mob.alive = false;
|
||||
mob.dead = 0.001;
|
||||
mob.sp.material.map = ladyHurtTex;
|
||||
mob.sp.material.rotation = Math.PI / 2; // omgevallen
|
||||
if (gib) { mob.sp.visible = false; mob.dead = 2.4; spawnGibs(mobCenter(), 22); }
|
||||
else spawnGibs(mobCenter(), 5);
|
||||
setKills(announcer.kill());
|
||||
shout(GrannyKit.pick(GrannyKit.DEATH_SHOUTS));
|
||||
spawnTimer = 1;
|
||||
}
|
||||
function updateMob(t) {
|
||||
if (!mob) { spawnTimer -= t; if (spawnTimer <= 0) spawnMob(); return; }
|
||||
if (!mob.alive) {
|
||||
mob.dead += t;
|
||||
mob.sp.material.opacity = Math.max(0, 1 - (mob.dead - 1.5));
|
||||
mob.sp.material.transparent = true;
|
||||
placeMob();
|
||||
if (mob.dead > 2.5) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; }
|
||||
return;
|
||||
}
|
||||
const me = camera.position;
|
||||
const dx = me.x - mob.x, dz = me.z - mob.z, dist = Math.hypot(dx, dz);
|
||||
if (dist > 0.9) {
|
||||
const v = 1.3 * t / dist;
|
||||
const nx = mob.x + dx * v, nz = mob.z + dz * v;
|
||||
if (!o.blocked || !o.blocked(nx, nz)) { mob.x = nx; mob.z = nz; }
|
||||
else if (!o.blocked(nx, mob.z)) mob.x = nx;
|
||||
else if (!o.blocked(mob.x, nz)) mob.z = nz;
|
||||
animT += t;
|
||||
} else {
|
||||
attackTimer -= t;
|
||||
if (attackTimer <= 0) {
|
||||
attackTimer = 1.2;
|
||||
bang(0.1, 400, 0.6);
|
||||
ouch.classList.remove('show'); void ouch.offsetWidth; ouch.classList.add('show');
|
||||
announcer.hurt();
|
||||
shout(GrannyKit.pick(GrannyKit.HIT_SHOUTS));
|
||||
}
|
||||
}
|
||||
mob.hurt -= t;
|
||||
mob.sp.material.map = mob.hurt > 0 ? ladyHurtTex : ladyTex[Math.floor(animT * 4) % 2];
|
||||
placeMob();
|
||||
}
|
||||
|
||||
const music = GrannyKit.createMusic();
|
||||
const sfx = GrannyKit.createSfx();
|
||||
const bang = sfx.bang;
|
||||
|
||||
// --- Raken ---
|
||||
const ray = new THREE.Raycaster();
|
||||
function hitScan(origin, dir, far) {
|
||||
ray.set(origin, dir);
|
||||
ray.far = far;
|
||||
const hits = ray.intersectObjects(o.targets().filter(t => t && t.visible), true);
|
||||
return hits.find(h => h.face && h.object.isMesh && isVisible(h.object)) || null;
|
||||
}
|
||||
function isVisible(obj) {
|
||||
for (let p = obj; p; p = p.parent) if (!p.visible) return false;
|
||||
return true;
|
||||
}
|
||||
function addDecal(hit, mat, size) {
|
||||
const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
|
||||
if (n.dot(ray.ray.direction) > 0) n.negate(); // altijd de kant waar je vandaan schiet
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), mat);
|
||||
m.position.copy(hit.point).addScaledVector(n, 0.004);
|
||||
m.lookAt(hit.point.clone().add(n));
|
||||
m.rotateZ(Math.random() * Math.PI * 2);
|
||||
group.add(m);
|
||||
decals.push(m);
|
||||
if (decals.length > MAX_DECALS) { const old = decals.shift(); group.remove(old); old.geometry.dispose(); }
|
||||
}
|
||||
|
||||
const tmpDir = new THREE.Vector3();
|
||||
function fire() {
|
||||
if (cooldown > 0) return;
|
||||
camera.getWorldDirection(tmpDir);
|
||||
// Lichte spreiding, het blijft een configurator.
|
||||
tmpDir.x += (Math.random() - 0.5) * 0.01; tmpDir.y += (Math.random() - 0.5) * 0.01;
|
||||
tmpDir.normalize();
|
||||
flash.visible = true;
|
||||
flash.material.rotation = Math.random() * 6;
|
||||
gunLight.intensity = 2;
|
||||
if (weapon === 0) {
|
||||
cooldown = 0.18; recoil = 1;
|
||||
bang(0.15, 3000, 0.5);
|
||||
const hit = hitScan(camera.position, tmpDir, 200);
|
||||
if (rayHitsMob(camera.position, tmpDir, hit ? hit.distance : 200)) damageMob(1);
|
||||
else if (hit) {
|
||||
addDecal(hit, holeMat, 0.05 + Math.random() * 0.03);
|
||||
spawnBlast(hit.point, 0.15, 0.12);
|
||||
}
|
||||
} else {
|
||||
cooldown = 0.8; recoil = 2.5;
|
||||
bang(0.5, 600, 0.6);
|
||||
const r = new THREE.Group();
|
||||
r.add(box(0.06, 0.06, 0.3, olive, 0, 0, 0));
|
||||
const tail = new THREE.Sprite(fireMat);
|
||||
tail.scale.set(0.3, 0.3, 1); tail.position.z = 0.2;
|
||||
r.add(tail);
|
||||
r.position.copy(camera.position).addScaledVector(tmpDir, 0.6);
|
||||
r.lookAt(r.position.clone().add(tmpDir));
|
||||
group.add(r);
|
||||
rockets.push({ mesh: r, dir: tmpDir.clone(), speed: 14, life: 6 });
|
||||
}
|
||||
}
|
||||
|
||||
function spawnBlast(pos, size, life) {
|
||||
const s = new THREE.Sprite(fireMat.clone());
|
||||
s.position.copy(pos);
|
||||
s.scale.set(size, size, 1);
|
||||
group.add(s);
|
||||
blasts.push({ s, size, life, t: 0 });
|
||||
}
|
||||
|
||||
function explode(hit, point, normal) {
|
||||
bang(1.2, 300, 1);
|
||||
if (hit) {
|
||||
addDecal(hit, scorchMat, 1.2 + Math.random() * 0.6);
|
||||
point = hit.point;
|
||||
normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld);
|
||||
if (normal.dot(ray.ray.direction) > 0) normal.negate();
|
||||
}
|
||||
spawnBlast(point, 2.5, 0.5);
|
||||
const light = new THREE.PointLight(0xff8833, 6, 12);
|
||||
light.position.copy(point);
|
||||
group.add(light);
|
||||
blasts.push({ light, life: 0.5, t: 0 });
|
||||
if (mob && mob.alive && mobCenter().distanceTo(point) < 3) damageMob(99, true);
|
||||
if (!hit) return; // direct op de oma: alleen gibs, geen stenen
|
||||
const n = normal;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const sz = 0.05 + Math.random() * 0.12;
|
||||
const d = new THREE.Mesh(new THREE.BoxGeometry(sz * 2, sz, sz), debrisMat);
|
||||
d.position.copy(hit.point).addScaledVector(n, 0.1);
|
||||
d.rotation.set(Math.random() * 6, Math.random() * 6, 0);
|
||||
const v = n.clone().multiplyScalar(2 + Math.random() * 4).add(new THREE.Vector3((Math.random() - 0.5) * 6, Math.random() * 5, (Math.random() - 0.5) * 6));
|
||||
group.add(d);
|
||||
debris.push({ mesh: d, v, spin: new THREE.Vector3(Math.random() * 10, Math.random() * 10, 0), rest: false });
|
||||
}
|
||||
trimDebris();
|
||||
}
|
||||
|
||||
// --- Invoer ---
|
||||
function onClick(e) {
|
||||
if (!active) return;
|
||||
if (document.pointerLockElement !== domElement) { domElement.requestPointerLock && domElement.requestPointerLock(); return; }
|
||||
if (e.button === 0) fire();
|
||||
}
|
||||
function onMouseMove(e) {
|
||||
if (!active || document.pointerLockElement !== domElement) return;
|
||||
walk.look(-e.movementX * 0.0025, -e.movementY * 0.0025);
|
||||
}
|
||||
function onWheel(e) {
|
||||
if (!active) return;
|
||||
e.preventDefault();
|
||||
selectWeapon(weapon + (e.deltaY > 0 ? 1 : -1));
|
||||
}
|
||||
function onKey(e) {
|
||||
if (!active || e.ctrlKey) return;
|
||||
if (e.key === '1') selectWeapon(0);
|
||||
if (e.key === '2') selectWeapon(1);
|
||||
}
|
||||
domElement.addEventListener('mousedown', onClick);
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
domElement.addEventListener('wheel', onWheel, { passive: false });
|
||||
window.addEventListener('keydown', onKey);
|
||||
|
||||
function clear() {
|
||||
if (mob) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; }
|
||||
shoutLabel.classList.remove('show');
|
||||
[...decals, ...debris.map(d => d.mesh), ...rockets.map(r => r.mesh), ...blasts.map(b => b.s || b.light)].forEach(m => group.remove(m));
|
||||
decals.length = debris.length = rockets.length = blasts.length = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
get active() { return active; },
|
||||
start() {
|
||||
active = true;
|
||||
scene.add(group);
|
||||
hud.classList.add('show');
|
||||
announcer.reset();
|
||||
setKills(0);
|
||||
spawnTimer = 1.5;
|
||||
music.play();
|
||||
},
|
||||
stop() {
|
||||
active = false;
|
||||
if (document.pointerLockElement === domElement) document.exitPointerLock();
|
||||
clear();
|
||||
scene.remove(group);
|
||||
hud.classList.remove('show');
|
||||
music.stop();
|
||||
announcer.stop();
|
||||
},
|
||||
update(dt) {
|
||||
if (!active) return;
|
||||
const t = Math.min(dt, 0.05);
|
||||
cooldown -= t;
|
||||
// Wapen volgt de camera, met wat terugslag.
|
||||
recoil = Math.max(0, recoil - t * 8);
|
||||
viewModel.position.copy(camera.position);
|
||||
viewModel.quaternion.copy(camera.quaternion);
|
||||
viewModel.translateX(0.18); viewModel.translateY(-0.16 + recoil * 0.01); viewModel.translateZ(-0.25 + recoil * 0.03);
|
||||
viewModel.rotateX(recoil * 0.08);
|
||||
gunLight.position.copy(camera.position);
|
||||
if (flash.visible && cooldown < (weapon === 0 ? 0.13 : 0.72)) flash.visible = false;
|
||||
gunLight.intensity = Math.max(0, gunLight.intensity - t * 30);
|
||||
announcer.update(t);
|
||||
updateMob(t);
|
||||
if (shoutTimer > 0 && (shoutTimer -= t) <= 0) shoutLabel.classList.remove('show');
|
||||
|
||||
for (let i = rockets.length - 1; i >= 0; i--) {
|
||||
const r = rockets[i];
|
||||
r.life -= t;
|
||||
const stepLen = r.speed * t;
|
||||
r.speed += 10 * t;
|
||||
const hit = hitScan(r.mesh.position, r.dir, stepLen);
|
||||
if (rayHitsMob(r.mesh.position, r.dir, stepLen + 0.3)) {
|
||||
explode(null, mobCenter(), r.dir.clone().negate());
|
||||
group.remove(r.mesh);
|
||||
rockets.splice(i, 1);
|
||||
} else if (hit || r.life <= 0) {
|
||||
if (hit) explode(hit);
|
||||
group.remove(r.mesh);
|
||||
rockets.splice(i, 1);
|
||||
} else {
|
||||
r.mesh.position.addScaledVector(r.dir, stepLen);
|
||||
if (Math.random() < 0.5) spawnBlast(r.mesh.position.clone().addScaledVector(r.dir, -0.2), 0.2, 0.3);
|
||||
}
|
||||
}
|
||||
for (let i = blasts.length - 1; i >= 0; i--) {
|
||||
const b = blasts[i];
|
||||
b.t += t;
|
||||
const k = b.t / b.life;
|
||||
if (b.s) { b.s.scale.setScalar(b.size * (0.6 + k)); b.s.material.opacity = 1 - k; }
|
||||
if (b.light) b.light.intensity = 6 * (1 - k);
|
||||
if (k >= 1) { group.remove(b.s || b.light); if (b.s) b.s.material.dispose(); blasts.splice(i, 1); }
|
||||
}
|
||||
for (const d of debris) {
|
||||
if (d.rest) continue;
|
||||
d.v.y -= 9.81 * t;
|
||||
const next = d.mesh.position.clone().addScaledVector(d.v, t);
|
||||
const floor = (o.floorAt ? o.floorAt(next.x, next.z) : 0) + (d.gib ? d.mesh.scale.y * 0.35 : 0.03);
|
||||
if (next.y <= floor && d.v.y < 0) {
|
||||
next.y = floor;
|
||||
d.v.multiplyScalar(0.4); d.v.y = -d.v.y * 0.5; d.spin.multiplyScalar(0.5);
|
||||
if (d.gib && !d.splat) { d.splat = true; bloodSplat(next.x, next.z, 0.3 + Math.random() * 0.3); }
|
||||
if (d.v.length() < 0.3) d.rest = true;
|
||||
}
|
||||
d.mesh.position.copy(next);
|
||||
if (d.mesh.isSprite) d.mesh.material.rotation += d.spin.x * t;
|
||||
else { d.mesh.rotation.x += d.spin.x * t; d.mesh.rotation.y += d.spin.y * t; }
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
this.stop();
|
||||
domElement.removeEventListener('mousedown', onClick);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
domElement.removeEventListener('wheel', onWheel);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
hud.remove();
|
||||
sfx.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
+19
-46
@@ -4,33 +4,20 @@
|
||||
* 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 = {};
|
||||
@@ -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,37 +157,30 @@ 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;
|
||||
}
|
||||
|
||||
+59
-36
@@ -4,18 +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,
|
||||
// inklapbare secties, schuiven, specificatie-kaarten en de 3-staps
|
||||
// weergave-cyclus (sneltoets T).
|
||||
// Eén plek voor opmaak en woordgebruik, zodat alle panelen gelijk ogen.
|
||||
//
|
||||
// Woordgebruik labels: zelfstandig naamwoord met hoofdletter, geen dubbele
|
||||
// punt, geen uitleg tussen haakjes (die gaat in een hint), eenheid rechts
|
||||
// van het veld. Bijvoorbeeld "Afstand min." i.p.v. "Lampen min. h.o.h.".
|
||||
// Plain script (geen ES-module).
|
||||
// ============================================================
|
||||
// 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, '"');
|
||||
@@ -26,7 +15,6 @@ window.UI = (() => {
|
||||
return `<div class="ui-field${o && o.disabled ? ' is-disabled' : ''}"><label for="${id}">${label}${hint(o && o.hint)}</label><div class="ui-control">${control}</div></div>`;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -35,8 +23,6 @@ window.UI = (() => {
|
||||
`<span class="ui-unit">${unit}</span>`, o);
|
||||
}
|
||||
|
||||
// Schuif met gekoppeld getal (voor maten met een logisch bereik, zoals
|
||||
// hoogtes en hellingen). o: { min, max, step, unit ('mm' standaard), hint }.
|
||||
// Het getal mag buiten het schuifbereik; het model klemt zelf.
|
||||
function range(id, label, value, o) {
|
||||
o = o || {};
|
||||
@@ -46,7 +32,6 @@ window.UI = (() => {
|
||||
`<div class="ui-control ui-range"><input type="range" id="${id}-r" ${lim} value="${value}"${o.disabled ? ' disabled' : ''}>` +
|
||||
`<input type="number" id="${id}" step="${o.step || 10}" value="${value}"${o.disabled ? ' disabled' : ''}><span class="ui-unit">${unit}</span></div></div>`;
|
||||
}
|
||||
// fn(v) bij schuiven en typen; set(id, v) zet beide zonder event.
|
||||
function onRange(id, fn, after) {
|
||||
const n = document.getElementById(id), r = document.getElementById(id + '-r');
|
||||
if (!n || !r) return;
|
||||
@@ -64,13 +49,11 @@ window.UI = (() => {
|
||||
if (r) r.value = v;
|
||||
}
|
||||
|
||||
// Keuzelijst. options: { waarde: 'Label' }.
|
||||
function sel(id, label, options, value, o) {
|
||||
const opts = Object.entries(options).map(([k, v]) => `<option value="${k}"${k === value ? ' selected' : ''}>${v}</option>`).join('');
|
||||
return field(id, label, `<select id="${id}">${opts}</select><span class="ui-unit"></span>`, o);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -79,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);
|
||||
@@ -94,8 +84,6 @@ window.UI = (() => {
|
||||
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
|
||||
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
|
||||
|
||||
// Tabbladen. group = sleutel waaronder het actieve tabblad onthouden wordt
|
||||
// (bijv. 'woning', 'unit'); tabs = [{ key, label, icon, html }].
|
||||
const activeTabs = {};
|
||||
function tabs(group, list) {
|
||||
const active = list.some(t => t.key === activeTabs[group]) ? activeTabs[group] : list[0].key;
|
||||
@@ -104,7 +92,6 @@ window.UI = (() => {
|
||||
${list.map(t => `<div class="ui-tab-page${t.key === active ? ' active' : ''}" data-tab-page="${t.key}">${t.html}</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
// Eenmalig per container: klik op een tab wisselt de pagina.
|
||||
function wireTabs(container) {
|
||||
if (container._uiTabsWired) return;
|
||||
container._uiTabsWired = true;
|
||||
@@ -118,14 +105,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;
|
||||
@@ -143,18 +128,56 @@ 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, range, onRange, setRange, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D };
|
||||
const tb = (label, iconName, attrs, cls) =>
|
||||
`<button type="button" class="tb-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
|
||||
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>`;
|
||||
|
||||
// Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere popover.
|
||||
let openPop = null;
|
||||
function popover(btn, panel) {
|
||||
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); if (openPop === close) openPop = null; };
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const was = panel.classList.contains('open');
|
||||
if (openPop) openPop();
|
||||
if (!was) { panel.classList.add('open'); btn.classList.add('open'); openPop = close; }
|
||||
});
|
||||
panel.addEventListener('click', (e) => e.stopPropagation());
|
||||
return close;
|
||||
}
|
||||
const closePopovers = () => { if (openPop) { openPop(); return true; } return false; };
|
||||
document.addEventListener('click', () => closePopovers());
|
||||
|
||||
// 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,
|
||||
VIEWS, viewOf, setView, tb, tbSep, viewSeg, popover, 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,108 @@
|
||||
/*!
|
||||
* 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, 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, palen en schoorsteen; 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();
|
||||
}
|
||||
});
|
||||
const all = panel.querySelector('[data-all]');
|
||||
if (all) all.addEventListener('click', () => { cur = ViewLayers.all(); emit(); });
|
||||
sync();
|
||||
return { el: wrap, set: (l) => { cur = normalize(l); sync(); }, toggle: (key) => { cur = Object.assign({}, cur, { [key]: !cur[key] }); emit(); } };
|
||||
}
|
||||
|
||||
return { LIST, KEYS, PHASES, GROUPS, all, normalize, shows, upTo, phaseOf, hideByMaterial, createMenu };
|
||||
})();
|
||||
@@ -4,30 +4,13 @@
|
||||
* 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;
|
||||
let drag = null;
|
||||
let speedFactor = 1; // bijv. 1,3 in de shooter-modus; reset bij start()
|
||||
|
||||
const inField = () => ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement && document.activeElement.tagName);
|
||||
const MOVE_KEYS = ['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift'];
|
||||
@@ -61,7 +44,6 @@ function createWalkControls(camera, domElement, opts) {
|
||||
camera.lookAt(camera.position.clone().add(dir));
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -72,17 +54,10 @@ function createWalkControls(camera, domElement, opts) {
|
||||
get active() { return active; },
|
||||
start(pose) {
|
||||
x = pose.x; z = pose.z; yaw = pose.yaw || 0; pitch = 0;
|
||||
speedFactor = 1;
|
||||
keys.clear();
|
||||
active = true;
|
||||
apply();
|
||||
},
|
||||
// Rondkijken van buitenaf (bijv. muis gevangen in core/shooter.js).
|
||||
set speedFactor(f) { speedFactor = f; },
|
||||
look(dYaw, dPitch) {
|
||||
yaw += dYaw;
|
||||
pitch = Math.max(-1.4, Math.min(1.4, pitch + dPitch));
|
||||
},
|
||||
stop() { active = false; keys.clear(); drag = null; },
|
||||
update(dt) {
|
||||
if (!active) return;
|
||||
@@ -92,9 +67,9 @@ function createWalkControls(camera, domElement, opts) {
|
||||
const f = (keys.has('w') || keys.has('arrowup') ? 1 : 0) - (keys.has('s') || keys.has('arrowdown') ? 1 : 0);
|
||||
const r = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
|
||||
if (f || r) {
|
||||
const v = (keys.has('shift') ? o.run : o.speed) * speedFactor * 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 v = (keys.has('shift') ? o.run : o.speed) * t / Math.hypot(f, r);
|
||||
const fx = Math.sin(yaw), fz = Math.cos(yaw);
|
||||
const rx = -fz, rz = fx;
|
||||
step((fx * f + rx * r) * v, (fz * f + rz * r) * v);
|
||||
}
|
||||
apply();
|
||||
|
||||
@@ -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(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();
|
||||
+103
-26
@@ -4,30 +4,13 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// cost/cost-defaults.js — STANDAARD kosten en tijden (datafile).
|
||||
//
|
||||
// Dit zijn de fabrieksinstellingen. Aanpassen kan op twee manieren:
|
||||
// 1. in de app: menu Instellingen > Kosten & tijden (opgeslagen in deze
|
||||
// browser, exporteren/importeren als JSON);
|
||||
// 2. hier in dit bestand (geldt dan voor iedereen als nieuwe standaard).
|
||||
// Tip: exporteer in de app en neem de waarden hier over.
|
||||
//
|
||||
// Een JS-bestand i.p.v. JSON omdat de app ook via file:// draait (fetch van
|
||||
// lokale bestanden werkt daar niet).
|
||||
//
|
||||
// Opbouw: groep -> items. Elk item: { label, unit, value } of met
|
||||
// options: { sleutel: 'tekst' } voor een keuze. Nieuwe items verschijnen
|
||||
// vanzelf in het instellingenvenster; de berekening (CostModel) moet ze
|
||||
// natuurlijk wel gebruiken. Bedragen excl. btw. Tijden in seconden tenzij
|
||||
// anders vermeld. LET OP: de getallen zijn voorlopige schattingen.
|
||||
// ============================================================
|
||||
// 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: 45 },
|
||||
uurloon: { label: 'Uurloon productie (all-in)', unit: '€/u', value: 55 },
|
||||
bezetting: { label: 'Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel', unit: 'man', value: 2.2 },
|
||||
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 }
|
||||
@@ -38,7 +21,6 @@ window.COST_DEFAULTS = {
|
||||
label: 'Zagen',
|
||||
items: {
|
||||
methode: { label: 'Zaagmethode', options: { robot: 'Zaagrobot', hand: 'Handmatig' }, value: 'robot' },
|
||||
// Machinetijd testontwerp (409 stuks, 818 sneden): ~2 u 55 min.
|
||||
robotSnede: { label: 'Robot: tijd per haakse snede', unit: 's', value: 5 },
|
||||
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 },
|
||||
@@ -111,7 +93,9 @@ window.COST_DEFAULTS = {
|
||||
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 }
|
||||
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 }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -122,12 +106,24 @@ window.COST_DEFAULTS = {
|
||||
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 }
|
||||
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 },
|
||||
staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 },
|
||||
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
|
||||
nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 },
|
||||
nokVulhoutMontage: { label: 'Vulhout in de holling van de nokbalk aanbrengen (beide zijden)', unit: 's/m', value: 240 },
|
||||
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 },
|
||||
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 }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -141,32 +137,113 @@ window.COST_DEFAULTS = {
|
||||
}
|
||||
},
|
||||
|
||||
afwerking: {
|
||||
label: 'Afwerking ruimtes',
|
||||
items: {
|
||||
valsPlafondM2: { label: 'Vals plafond (regelwerk + gipsplaat, afgewerkt)', unit: '€/m²', value: 38 },
|
||||
valsPlafondMontage: { label: 'Montage vals plafond', unit: 's/m²', value: 1800 },
|
||||
fiboM2: { label: 'FIBO-wandpaneel (incl. profielen en kit)', unit: '€/m²', value: 70 },
|
||||
fiboMontage: { label: 'Montage FIBO-wandpaneel', unit: 's/m²', value: 1500 }
|
||||
}
|
||||
},
|
||||
|
||||
kozijnen: {
|
||||
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 (incl. bekabeling)', unit: 's', value: 900 }
|
||||
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 productie-uur', unit: '€/u', value: 8 },
|
||||
slijtage: { label: 'Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per productie-uur', unit: '€/u', value: 2 },
|
||||
huisvesting: { label: 'Huisvesting productiehal (huur, energie) per productie-uur', unit: '€/u', value: 6 },
|
||||
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 }
|
||||
}
|
||||
|
||||
+157
-86
@@ -4,38 +4,20 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// cost/cost-model.js — CostModel: ENIGE bron van de kostenberekening
|
||||
// (materiaal + arbeid). Geen DOM. Rekent op de doorgerekende woning
|
||||
// (HouseModel.computeParts + computeTotals) met de kosten/tijden uit
|
||||
// COST_DEFAULTS (cost-defaults.js) plus de eigen instellingen van de
|
||||
// gebruiker (localStorage, instellingenvenster cost-settings.js).
|
||||
//
|
||||
// Opbouw: per element (wandstuk, vloer, dak, woning-breed) regels
|
||||
// { cat, act, label, qty, unit, mat (€), sec (s arbeid) }
|
||||
// cat = materiaalgroep, act = werkzaamheid. Opgeteld per element, per unit
|
||||
// en voor de woning; daarna opslagen (indirecte uren, materiaal, marge, btw).
|
||||
//
|
||||
// Plain script, geladen na house-model.js en cost-defaults.js.
|
||||
// ============================================================
|
||||
|
||||
window.CostModel = (() => {
|
||||
const STORAGE_KEY = 'tectum.costs';
|
||||
const listeners = [];
|
||||
let session = {};
|
||||
try { localStorage.removeItem('tectum.costs'); localStorage.removeItem('tectum.leveranciers'); } catch (e) { /* oude browseropslag */ }
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Instellingen: standaard (COST_DEFAULTS) + eigen waarden (localStorage).
|
||||
// values = { groep: { sleutel: waarde } }.
|
||||
// ------------------------------------------------------------
|
||||
function defaultValues() {
|
||||
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 v;
|
||||
return CostCsv.overlay('intern', v);
|
||||
}
|
||||
// Alleen bekende sleutels met een geldig type overnemen.
|
||||
function mergeValues(base, over) {
|
||||
const v = JSON.parse(JSON.stringify(base));
|
||||
Object.entries(over || {}).forEach(([g, items]) => {
|
||||
@@ -49,36 +31,40 @@ window.CostModel = (() => {
|
||||
});
|
||||
return v;
|
||||
}
|
||||
function readOverrides() {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; } catch (e) { return {}; }
|
||||
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 getSettings() { return mergeValues(defaultValues(), readOverrides()); }
|
||||
// Slaat alleen de afwijkingen van de standaard op.
|
||||
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]) (diff[g] = diff[g] || {})[k] = val;
|
||||
if (val !== def[g][k] && !listed(g, k)) (diff[g] = diff[g] || {})[k] = val;
|
||||
}));
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(diff)); } catch (e) { /* geen opslag: alleen deze sessie */ }
|
||||
session = diff;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
function resetSettings() { saveSettings(defaultValues()); }
|
||||
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
|
||||
CostCsv.onChange(() => listeners.forEach(fn => fn()));
|
||||
|
||||
// Export/import (JSON-bestand): altijd de volledige set waarden.
|
||||
function exportJson(values) {
|
||||
return { type: 'tectum-costs', version: 1, exportedAt: new Date().toISOString(), values: mergeValues(defaultValues(), values) };
|
||||
}
|
||||
function importJson(obj) {
|
||||
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);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Berekening
|
||||
// ------------------------------------------------------------
|
||||
const CAT_ORDER = ['Hout', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting'];
|
||||
const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting'];
|
||||
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'];
|
||||
|
||||
@@ -86,20 +72,16 @@ window.CostModel = (() => {
|
||||
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; crew = personeel bij die stap (standaard
|
||||
// de gemiddelde bezetting per productiestap). Opgeslagen sec = manuren.
|
||||
// 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 (s), voor afschrijving/huisvesting.
|
||||
// clock = doorlooptijd van de stap (klokuren), voor afschrijving/huisvesting.
|
||||
el.lines.push({ cat, act, label, qty, unit, mat: mat || 0, sec: (sec || 0) * c, clock: c ? sec : 0, crew: c });
|
||||
};
|
||||
|
||||
// --- Gedeelde rekenregels ---
|
||||
const Z = S.zagen, V = S.verbinden, P = S.plaat;
|
||||
const sawFactor = Z.methode === 'hand' ? Z.handFactor : 1;
|
||||
// Zaagtijd van één stuk (s): handling + sneden (+ extra per schuine snede).
|
||||
// saw: totalen voor het zaagoverzicht (robot- en handtijd, los van de keuze).
|
||||
const saw = { pieces: 0, cuts: 0, angled: 0, robotSec: 0 };
|
||||
const sawSec = (cutAngles, count = 1) => {
|
||||
const ca = cutAngles || [0, 0], angled = ca.filter(a => a !== 0).length;
|
||||
@@ -109,39 +91,40 @@ window.CostModel = (() => {
|
||||
};
|
||||
const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)';
|
||||
const jointSec = V.eersteSchroef + Math.max(0, V.schroevenPerVerbinding - 1) * V.volgendeSchroef;
|
||||
// Constructie monteren (lijmen + schroeven): schroeftijd x V.monteerFactor,
|
||||
// met V.personeel man (manuren), op het dak factor V.dakFactor (roof = true).
|
||||
const addJoints = (el, joints, label, roof) => {
|
||||
const n = joints * V.schroevenPerVerbinding;
|
||||
const f = V.monteerFactor * (roof ? V.dakFactor : 1);
|
||||
add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven (${joints} verb. × ${V.schroevenPerVerbinding}, ×${V.monteerFactor}${roof ? `, dak ×${V.dakFactor}` : ''})`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel);
|
||||
add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel);
|
||||
};
|
||||
// Plaat: materiaal + montage + plaatschroeven.
|
||||
const addSheet = (el, cat, label, m2, priceM2, montageSecM2) => {
|
||||
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);
|
||||
};
|
||||
// Snijverlies wandbalken (inkoop in handelslengtes, hele woning).
|
||||
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 });
|
||||
// --- Wandstukken (buiten + binnen/naad) ---
|
||||
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;
|
||||
add(el, 'Hout', 'Zagen', `Regelwerk ${bom.length} st. (incl. ${Math.round(waste * 100)}% snijverlies)`, woodM3 * (1 + waste), 'm³', woodM3 * (1 + waste) * S.hout.constructieM3, 0);
|
||||
const woodMat = bom.reduce((a, b) => a + woodCost(b.width, b.depth, b.length / 1000), 0);
|
||||
add(el, 'Hout', 'Zagen', `Regelwerk ${bom.length} st.`, woodM3 * (1 + waste), 'm³', woodMat * (1 + waste), 0);
|
||||
const cuts = bom.reduce((a, b) => a + (b.cutAngles || [0, 0]).length, 0);
|
||||
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');
|
||||
// Dubbele stijlen (deur, hoek: twee stijlen tegen elkaar): verticaal
|
||||
// elke V.dubbelAfstand mm een schroef over de gezamenlijke lengte.
|
||||
const studs = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).sort((a, b) => a.x - b.x);
|
||||
let dblScrews = 0, dblPairs = 0;
|
||||
for (let i = 1; i < studs.length; i++) {
|
||||
@@ -152,10 +135,10 @@ window.CostModel = (() => {
|
||||
dblPairs++;
|
||||
dblScrews += Math.max(2, Math.floor(ov / V.dubbelAfstand));
|
||||
}
|
||||
add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}× (schroef elke ${V.dubbelAfstand} mm, ×${V.monteerFactor})`, dblScrews, 'st.',
|
||||
add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}×`, dblScrews, 'st.',
|
||||
dblScrews * V.prijsPer1000 / 1000, dblScrews * V.volgendeSchroef * V.monteerFactor, V.personeel);
|
||||
const M = S.montage;
|
||||
add(el, 'Hout', 'Wanden plaatsen', `Plaatsen en vastzetten (${M.muurVastzetten} min)`, 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel);
|
||||
add(el, 'Hout', 'Wanden plaatsen', 'Plaatsen en vastzetten', 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel);
|
||||
|
||||
const st = p.stats || {};
|
||||
addSheet(el, 'Plaatwerk', 'Spaanplaat', (st.spaanplaatAreaM2 || 0) + (st.spaanplaatBuitenAreaM2 || 0), P.spaanplaatM2, P.spaanplaatMontage);
|
||||
@@ -168,14 +151,14 @@ window.CostModel = (() => {
|
||||
|
||||
const G = S.gevel;
|
||||
const doek = st.doekAreaM2 || 0;
|
||||
// Montage per strekkende meter wand (breedte van het stuk).
|
||||
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);
|
||||
// Rabat over het doek, boven het trasraam (gevelvlak uit WallModel).
|
||||
const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
|
||||
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
|
||||
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
|
||||
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
|
||||
const stuc = st.stucAreaM2 || 0;
|
||||
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0));
|
||||
const latM = st.battenLengthM || 0;
|
||||
add(el, 'Hout', 'Latwerk', `Latwerk (${st.battenCount || 0} latten inkoop, ${st.battenPieces || 0} stukken)`, latM, 'm', latM * S.hout.latwerkM,
|
||||
(st.battenPieces || 0) * S.hout.latStukMontage + latM * S.hout.latMeterMontage);
|
||||
@@ -184,8 +167,12 @@ window.CostModel = (() => {
|
||||
const opKey = p.seam ? `iw${p.id}` : null;
|
||||
if (!opKey || !seenOpenings.has(opKey)) {
|
||||
if (opKey) seenOpenings.add(opKey);
|
||||
(p.config.openings || []).forEach(op => {
|
||||
if (op.type === 'raam') {
|
||||
(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 {
|
||||
@@ -197,7 +184,6 @@ window.CostModel = (() => {
|
||||
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));
|
||||
|
||||
// --- Vloer- en dakbalken (balklaag: zagen + verbindingen) ---
|
||||
function joistLayer(el, cd, angleDeg, what, roof) {
|
||||
const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length;
|
||||
const n = cd.joists.length;
|
||||
@@ -207,39 +193,44 @@ window.CostModel = (() => {
|
||||
}
|
||||
|
||||
parts.units.forEach(u => {
|
||||
// Vloer
|
||||
const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id);
|
||||
const fs = u.floorSpecs;
|
||||
add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', fs.joistWoodM3 * S.hout.constructieM3, 0);
|
||||
add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', joistWoodCost(fs.joistWoodM3, u.floorData.calcData), 0);
|
||||
joistLayer(fl, u.floorData.calcData, 0, 'Vloer');
|
||||
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);
|
||||
// Afwerking: vloer min de voetafdruk van de wanden op deze unit.
|
||||
const foot = u.walls.reduce((a, w) => a + w.pieces.filter(p => !p.place.baseY).reduce((b, p) => b + p.place.width * p.place.thickness, 0), 0)
|
||||
+ 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);
|
||||
|
||||
// Dak
|
||||
const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
|
||||
const rs = u.roof.specs, D = S.dak;
|
||||
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', rs.joistWoodM3 * S.hout.constructieM3, 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));
|
||||
// Wolfsdak: bovenste deel (sporen, hoekkepers, nokbalk, kopbalken).
|
||||
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;
|
||||
add(rf, 'Hout', 'Zagen', `${sawLabel}: wolfseinden ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0));
|
||||
addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Woning-breed: dakrand, sandwichpanelen, verlichting ---
|
||||
const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null);
|
||||
const wo = newEl('WON', 'Woning (dakrand, overkapping, fundering, verlichting)', null);
|
||||
const D = S.dak;
|
||||
if (totals.roofEdge) {
|
||||
const e = totals.roofEdge;
|
||||
@@ -247,6 +238,11 @@ window.CostModel = (() => {
|
||||
add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
|
||||
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
|
||||
}
|
||||
if (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);
|
||||
}
|
||||
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);
|
||||
@@ -257,7 +253,88 @@ window.CostModel = (() => {
|
||||
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);
|
||||
|
||||
// --- Optellen ---
|
||||
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) => {
|
||||
@@ -269,15 +346,12 @@ window.CostModel = (() => {
|
||||
}));
|
||||
return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
|
||||
};
|
||||
// Kostprijs van materiaal + directe arbeidstijd (s), overal dezelfde
|
||||
// regels: materiaal + verlies + klein materiaal; uren + indirect + QA
|
||||
// tegen het uurloon (manuren); afschrijving/slijtage/huisvesting per
|
||||
// productie-uur = doorlooptijd (klokuren, niet x personeel) + indirect, zonder QA.
|
||||
// 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; // QA: % van de totale werktijd
|
||||
const qa = (direct + indirect) * A.qa / 100;
|
||||
return { direct, indirect, qa, total: direct + indirect + qa };
|
||||
};
|
||||
const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100);
|
||||
@@ -304,10 +378,10 @@ window.CostModel = (() => {
|
||||
['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)} (incl. bezetting, indirect en QA)`, h.total * A.uurloon],
|
||||
[`Afschrijving machines ${uur(h.prod)} productie-uur × ${eur(B.machines, 2)}`, h.prod * B.machines],
|
||||
[`Slijtage gereedschap ${uur(h.prod)} productie-uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage],
|
||||
[`Huisvesting productiehal ${uur(h.prod)} productie-uur × ${eur(B.huisvesting, 2)}`, h.prod * B.huisvesting],
|
||||
[`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]
|
||||
];
|
||||
@@ -316,7 +390,6 @@ window.CostModel = (() => {
|
||||
const marge = (kostprijs + algemeen) * O.marge / 100;
|
||||
const exBtw = kostprijs + algemeen + marge;
|
||||
const btw = exBtw * O.btw / 100;
|
||||
// Overzicht als rijen (kaart en PDF): sub = subtotaal.
|
||||
const rows = posts.filter(p => p[1] > 0).map(([label, amount]) => ({ label, amount }))
|
||||
.concat([
|
||||
{ label: 'Kostprijs', amount: kostprijs, sub: true },
|
||||
@@ -333,14 +406,12 @@ window.CostModel = (() => {
|
||||
};
|
||||
}
|
||||
|
||||
// Bedrag als "€ 1.234" (of met decimalen).
|
||||
function eur(n, decimals = 0) {
|
||||
return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
// Uren als decimaal getal, bijv. "12,5".
|
||||
function uur(hours) {
|
||||
return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
return { STORAGE_KEY, defaultValues, getSettings, saveSettings, resetSettings, onChange, exportJson, importJson, compute, eur, uur };
|
||||
return { defaultValues, getSettings, saveSettings, onChange, importFile, compute, eur, uur };
|
||||
})();
|
||||
|
||||
+88
-39
@@ -4,18 +4,12 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// cost/cost-settings.js — CostSettings: venster "Kosten & tijden". Bouwt
|
||||
// het formulier automatisch uit COST_DEFAULTS (elk item een invoerveld),
|
||||
// opslaan via CostModel.saveSettings (localStorage). Exporteren/importeren
|
||||
// als JSON-bestand; "Standaard" zet alle velden terug op cost-defaults.js.
|
||||
// Alleen UI; de rekenregels staan in CostModel.
|
||||
// ============================================================
|
||||
|
||||
window.CostSettings = (() => {
|
||||
let overlay = null;
|
||||
|
||||
const esc = UI.esc;
|
||||
const SJABLOON_BALKEN = [[38, 235], [70, 220]];
|
||||
|
||||
function build() {
|
||||
overlay = document.createElement('div');
|
||||
@@ -26,17 +20,20 @@ window.CostSettings = (() => {
|
||||
<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. Opslaan bewaart in deze browser én downloadt een JSON-bestand; een gewijzigd veld is gemarkeerd. Standaardwaarden staan in js/cost/cost-defaults.js.</div>
|
||||
<div class="material-info">Bedragen excl. btw. De CSV's in kosten/ zijn leidend: Toepassen geldt tot herladen; om een wijziging te houden "intern.csv downloaden" en het bestand in kosten/ vervangen, of met "Wijzigingen downloaden" alleen de aangepaste velden (met de standaardwaarde ernaast) opslaan. Prijzen pas je aan in leveranciers.csv (CSV = daaruit, anders noodwaarde). Een veld dat afwijkt van intern.csv is gemarkeerd.</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="io-btn" data-cs="reset">Standaard</button>
|
||||
<button type="button" class="io-btn" data-cs="export">Exporteren…</button>
|
||||
<button type="button" class="io-btn" data-cs="import">Importeren…</button>
|
||||
<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="io-btn" data-cs="cancel">Annuleren</button>
|
||||
<button type="button" class="material-toggle-btn active" data-cs="save" style="flex:none; padding:6px 18px;">Opslaan</button>
|
||||
<button type="button" class="ui-btn" data-cs="cancel">Annuleren</button>
|
||||
<button type="button" class="ui-btn ui-btn-primary" data-cs="save">Toepassen</button>
|
||||
</div>
|
||||
<input type="file" accept=".json,application/json" style="display:none">
|
||||
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
|
||||
<input type="file" data-file="leveranciers" accept=".csv,text/csv,text/plain" style="display:none">
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
@@ -45,39 +42,93 @@ window.CostSettings = (() => {
|
||||
if (!btn) return;
|
||||
const a = btn.dataset.cs;
|
||||
if (a === 'cancel') close();
|
||||
else if (a === 'save') { // ook als JSON-bestand (om te delen/bewaren)
|
||||
const v = readForm();
|
||||
CostModel.saveSettings(v);
|
||||
download(CostModel.exportJson(v));
|
||||
else if (a === 'save') {
|
||||
CostModel.saveSettings(readForm());
|
||||
close();
|
||||
}
|
||||
else if (a === 'reset') fill(CostModel.defaultValues());
|
||||
else if (a === 'export') download(CostModel.exportJson(readForm()));
|
||||
else if (a === 'import') overlay.querySelector('input[type=file]').click();
|
||||
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.addEventListener('input', markChanged);
|
||||
overlay.querySelector('input[type=file]').addEventListener('change', (e) => {
|
||||
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(CostModel.importJson(JSON.parse(txt))))
|
||||
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]) => `
|
||||
<label class="cs-row">
|
||||
<span class="cs-label">${esc(it.label)}</span>
|
||||
${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}">`}
|
||||
: `<input type="number" step="any" data-g="${g}" data-k="${k}"${price ? ' disabled' : ''}>`}
|
||||
<span class="cs-unit">${esc(it.unit || '')}</span>
|
||||
</label>`).join('')}
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</section>`).join('');
|
||||
}
|
||||
|
||||
@@ -85,36 +136,34 @@ window.CostSettings = (() => {
|
||||
overlay.querySelectorAll('[data-g]').forEach(el => { el.value = values[el.dataset.g][el.dataset.k]; });
|
||||
markChanged();
|
||||
}
|
||||
function readForm() {
|
||||
const v = CostModel.getSettings();
|
||||
overlay.querySelectorAll('[data-g]').forEach(el => {
|
||||
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;
|
||||
}
|
||||
// Afwijkend van de standaard: gemarkeerd.
|
||||
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', typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d);
|
||||
el.closest('.cs-row').classList.toggle('changed', !el.disabled && typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d);
|
||||
});
|
||||
}
|
||||
|
||||
function download(obj) {
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
|
||||
function saveFile(text, name) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `tectum-kosten-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
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(); // opnieuw: nieuwe items in cost-defaults.js verschijnen vanzelf
|
||||
renderForm();
|
||||
fill(CostModel.getSettings());
|
||||
overlay.classList.add('open');
|
||||
}
|
||||
|
||||
+438
-175
@@ -4,68 +4,27 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// elektra/elektra-model.js — ElektraModel: ENIGE bron van de elektra-
|
||||
// installatie van een woning (eerste versie, automatisch). Geen DOM.
|
||||
//
|
||||
// Woning-breed (settings.elektra). Alles wordt afgeleid uit de woning:
|
||||
// - Meterkast (3×25 A, 3-fase) als kast aan de binnenzijde van een wand
|
||||
// in de unit met de voordeur, zo dicht mogelijk bij de voordeur, vrij van
|
||||
// openingen (incl. raamwerk) en van de schakelaars.
|
||||
// - Schakelaarcombinatie naast de voordeur (binnenzijde, 1500 mm), aan de
|
||||
// slotzijde (deur draait links, KozijnModel; anders de scharnierzijde):
|
||||
// A = binnenlicht van die unit, B = buitenlicht (boeideel-spots en
|
||||
// gevellampen). Elke andere unit krijgt een schakelaar naast de deur
|
||||
// waardoor je hem binnenkomt (naadwand, anders een buitendeur), C, D, ...
|
||||
// Elke lamp/spot krijgt de letter van zijn schakelaar (p.letter).
|
||||
// - Wandcontactdozen (dubbel, 400 mm): wcdPerUnit per unit, zo ver mogelijk
|
||||
// uit elkaar langs de binnenzijde van de wanden.
|
||||
// - Apparaten (settings.apparaten, APPLIANCES) met elk een eigen groep:
|
||||
// kookgroep (perilex, 2-fase, 5×2,5), wasmachine, droger, warmtepomp,
|
||||
// optioneel oven en vaatwasser. Keukenapparaten bij elkaar in de grootste
|
||||
// andere unit, de rest naast de meterkast (nog geen keuken in het model).
|
||||
// - Handmatig verplaatsen (settings.place[key] = { element, side, u, y }):
|
||||
// key 'mk' (meterkast), 'sw:<unit>' (schakelaars), 'wcd:<unit>:<nr>',
|
||||
// 'app:<apparaat>'. element = wandstuk, side = -1 (kamerzijde; bij een
|
||||
// binnenwand ±1), u = hart langs de wand (wand-x), y = hoogte (optioneel).
|
||||
// Handmatige plekken gaan voor: de automatische plaatsing wijkt uit.
|
||||
// Wandvlakken: binnenzijde van buitenwanden en naadwanddelen, beide
|
||||
// zijden van binnenwanden (in de unit waar hun midden ligt).
|
||||
// - Groepen: per unit een lichtgroep (B10, 1,5 mm²) en een wcd-groep
|
||||
// (B16, 2,5 mm²); het buitenlicht op een eigen groep (B10, XMvK 3×1,5),
|
||||
// op dezelfde aardlek als het binnenlicht van de voordeur-unit (zelfde
|
||||
// schakeldoos). Fasen L1/L2/L3 om en om; aardlekschakelaars (30 mA,
|
||||
// max. 4 groepen elk, minstens 2) om en om, zodat licht en wcd van één
|
||||
// unit niet op dezelfde aardlek zitten.
|
||||
//
|
||||
// Leidingen (HSB): binnen VD-draad in flexibele installatiebuis, via de
|
||||
// stijlen (zelfde wand) of via het plafond (andere wand/plafondpunt): eerst
|
||||
// verticaal in de wand naar het plafond, dan haaks (x, dan z) naar het
|
||||
// volgende punt. Per schakeldoos en groep een luchtdichte verdeeldoos in het
|
||||
// plafond erboven (groepen niet gemengd in één verdeeldoos); vandaar de schakeldraden omlaag en de lampen doorgelust volgens
|
||||
// een minimale opspannende boom (kortste totale lengte). Buitenverlichting in
|
||||
// XMvK. Een leiding over een naad tussen units krijgt een steekverbinder
|
||||
// (modules worden los gebouwd en op de bouwplaats gekoppeld).
|
||||
// Elke leiding: + reserve (lus in de doos, meterkast extra) en + slack %.
|
||||
//
|
||||
// Plain script, geladen na lighting-model.js en unit-model.js.
|
||||
// ============================================================
|
||||
|
||||
window.ElektraModel = (() => {
|
||||
const DEFAULT_SETTINGS = {
|
||||
enabled: true,
|
||||
meterkast: { w: 600, h: 900, d: 250, y: 1000 }, // mm; y = onderkant boven vloer
|
||||
switchHeight: 1500, // hart schakelaar boven vloer
|
||||
wcdHeight: 400, // hart wcd boven vloer
|
||||
wcdPerUnit: 2, // dubbele wcd per unit
|
||||
slack: 10, // % extra op elke leiding
|
||||
reserve: 300, // mm per leiding (lussen in de dozen)
|
||||
meterkastReserve: 500, // mm extra per leiding vanuit de meterkast
|
||||
meterkast: { w: 600, h: 900, d: 250, y: 1000 }, // y = onderkant boven vloer
|
||||
switchHeight: 1500,
|
||||
wcdHeight: 400,
|
||||
wcdPerUnit: 2,
|
||||
plateColor: 'wit', // schakelaars, wcd's, apparaat-wcd's en vloerdozen
|
||||
slack: 10, // %
|
||||
reserve: 300,
|
||||
meterkastReserve: 500,
|
||||
apparaten: { koken: true, wasmachine: true, droger: true, warmtepomp: true, oven: false, vaatwasser: false },
|
||||
place: {} // handmatige plekken (zie boven)
|
||||
place: {},
|
||||
vloerWcd: [],
|
||||
wcdExtra: {},
|
||||
removed: []
|
||||
};
|
||||
// Apparaten met een eigen groep. code = label op de plattegrond (twee
|
||||
// letters: geen verwarring met de schakelaarletters).
|
||||
const CLEAR = 50;
|
||||
const FLOOR_DEPTH = 100;
|
||||
// Codes van twee letters: geen verwarring met de schakelaarletters.
|
||||
const APPLIANCES = {
|
||||
koken: { label: 'Kookgroep (perilex)', code: 'KK', breaker: '2× B16', poles: 2, cable: 'koken', height: 600, zone: 'keuken', material: 'Perilex-wandcontactdoos (2-fase)' },
|
||||
oven: { label: 'Oven', code: 'OV', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' },
|
||||
@@ -74,14 +33,17 @@ window.ElektraModel = (() => {
|
||||
droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
|
||||
warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' }
|
||||
};
|
||||
const BOX_PITCH = 71; // h.o.h. inbouwdozen in een combinatie
|
||||
const PLATE = 80; // afdekraam (vierkant)
|
||||
const WCD_W = 150; // afdekraam dubbele wcd
|
||||
const DOOR_GAP = 100; // kozijnstijl -> hart eerste doos (bovenop de stijl)
|
||||
const VD_OFFSET = 150; // verdeeldoos: zoveel de kamer in vanaf het wandvlak
|
||||
const PLATE_COLORS = {
|
||||
wit: { label: 'Wit', hex: 0xf6f6f2 },
|
||||
antraciet: { label: 'Antraciet', hex: 0x3a3d41 },
|
||||
zwart: { label: 'Zwart', hex: 0x161718 }
|
||||
};
|
||||
const BOX_PITCH = 71;
|
||||
const PLATE = 80;
|
||||
const WCD_W = 150;
|
||||
const DOOR_GAP = 100;
|
||||
const VD_OFFSET = 150;
|
||||
|
||||
// Leidingtypen. cores: aders (kleur via CORE_COLORS); conduit: buis-Ø (mm),
|
||||
// 0 = kabel zonder buis.
|
||||
const CORE_COLORS = { L: 'bruin', L2: 'zwart', L3: 'grijs', N: 'blauw', PE: 'geel/groen', S: 'zwart' };
|
||||
const CABLES = {
|
||||
licht: { label: 'VD 3×1,5 mm² in buis Ø16', use: 'Verlichting: voeding en doorlus', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 16 },
|
||||
@@ -89,7 +51,6 @@ window.ElektraModel = (() => {
|
||||
koken: { label: 'VD 5×2,5 mm² in buis Ø25', use: 'Kookgroep (perilex, 2-fase)', mm2: 2.5, cores: ['L', 'L2', 'L3', 'N', 'PE'], conduit: 25 },
|
||||
buiten: { label: 'XMvK 3×1,5 mm²', use: 'Buitenverlichting (boeideel/gevel)', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 0 }
|
||||
};
|
||||
// Schakeldraden verdeeldoos -> schakeldoos: fase + één per schakelaar.
|
||||
function cableInfo(key) {
|
||||
if (CABLES[key]) return CABLES[key];
|
||||
const m = /^schakel(\d)$/.exec(key);
|
||||
@@ -103,7 +64,11 @@ window.ElektraModel = (() => {
|
||||
s.meterkast = Object.assign({}, DEFAULT_SETTINGS.meterkast, i.meterkast || {});
|
||||
s.apparaten = Object.assign({}, DEFAULT_SETTINGS.apparaten, i.apparaten || {});
|
||||
s.place = Object.assign({}, i.place || {});
|
||||
s.wcdExtra = Object.assign({}, i.wcdExtra || {});
|
||||
s.removed = Array.isArray(i.removed) ? i.removed.slice() : [];
|
||||
s.vloerWcd = Array.isArray(i.vloerWcd) ? i.vloerWcd.filter(o => o && o.unitId).map(o => Object.assign({}, o)) : [];
|
||||
s.enabled = s.enabled !== false;
|
||||
if (!PLATE_COLORS[s.plateColor]) s.plateColor = DEFAULT_SETTINGS.plateColor;
|
||||
// v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard).
|
||||
if (!i.v) {
|
||||
if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight;
|
||||
@@ -113,12 +78,7 @@ window.ElektraModel = (() => {
|
||||
return s;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Wandvlakken (binnenzijde) waarop iets gemonteerd kan worden.
|
||||
// ------------------------------------------------------------
|
||||
// Zijde van een wandstuk (side -1 = lokaal -Z: bij buitenwand en
|
||||
// naadwanddeel de kamerkant; binnenwand: beide zijden), begrensd tot de
|
||||
// binnenruimte van de unit.
|
||||
// side -1 = lokaal -Z = kamerkant (buitenwand, naadwanddeel); binnenwand: beide zijden.
|
||||
function faceOf(p, inner, u, side) {
|
||||
const pl = p.place, W = pl.width, lz = side * pl.thickness / 2;
|
||||
const a = UnitModel.localToWorld(pl, -W / 2, lz), b = UnitModel.localToWorld(pl, W / 2, lz);
|
||||
@@ -128,10 +88,10 @@ window.ElektraModel = (() => {
|
||||
return {
|
||||
piece: p, unit: u, a, b, axis, W, lz,
|
||||
u0: Math.max(0, Math.min(ua, ub)), u1: Math.min(W, Math.max(ua, ub)),
|
||||
n: { x: side * Math.sin(pl.rotY), z: side * Math.cos(pl.rotY) }, // naar de kamer
|
||||
n: { x: side * Math.sin(pl.rotY), z: side * Math.cos(pl.rotY) },
|
||||
side,
|
||||
sw: p.config.studWidth || 45,
|
||||
reserved: [] // [{ a, b, y0, y1 }] al bezet (meterkast, schakelaars, wcd)
|
||||
reserved: []
|
||||
};
|
||||
}
|
||||
function facesOf(u, interior) {
|
||||
@@ -145,15 +105,12 @@ window.ElektraModel = (() => {
|
||||
.forEach(p => list.push(faceOf(p, inner, u, -1), faceOf(p, inner, u, 1)));
|
||||
return list.filter(f => f.u1 - f.u0 > 200);
|
||||
}
|
||||
// Punt op een wandvlak: u langs de wand (0..W), y boven de vloer, off de
|
||||
// kamer in (negatief = de wand in).
|
||||
// off: de kamer in (negatief = de wand in).
|
||||
function facePoint(f, u, y, off) {
|
||||
const q = UnitModel.localToWorld(f.piece.place, u - f.W / 2, f.lz + f.side * (off || 0));
|
||||
return { x: q.x, y: f.piece.place.floorTop + y, z: q.z };
|
||||
}
|
||||
const uAt = (f, q) => (q[f.axis] - f.a[f.axis]) / (f.b[f.axis] - f.a[f.axis]) * f.W;
|
||||
// Vrije stukken van een vlak tussen hoogte y0..y1: openingen (incl.
|
||||
// raamwerk + margin) en wat al bezet is eraf, edge vrij van de uiteinden.
|
||||
function freeOn(f, y0, y1, margin, edge) {
|
||||
const blocked = [];
|
||||
f.piece.config.openings.forEach(op => {
|
||||
@@ -162,8 +119,6 @@ window.ElektraModel = (() => {
|
||||
f.reserved.forEach(r => { if (r.y0 < y1 && r.y1 > y0) blocked.push([r.a, r.b]); });
|
||||
return LightingModel.freeIntervals(f.u0 + edge, f.u1 - edge, blocked);
|
||||
}
|
||||
// Dichtstbijzijnde vrije plek (breedte w) op een van de vlakken bij target
|
||||
// (wereld {x, z}). Geeft { f, u, d } of null.
|
||||
function nearestFree(faces, target, w, y0, y1, margin, edge) {
|
||||
let best = null;
|
||||
faces.forEach(f => freeOn(f, y0, y1, margin, edge).forEach(([a, b]) => {
|
||||
@@ -176,9 +131,7 @@ window.ElektraModel = (() => {
|
||||
return best;
|
||||
}
|
||||
|
||||
// Schakelaarcombinatie van n dozen naast opening op op vlak f: eerst de
|
||||
// slotzijde (rechts van de opening in wand-x), dan de scharnierzijde.
|
||||
// Geeft de doos-centra (u, dichtst bij de deur eerst) of null.
|
||||
// Eerst de slotzijde (rechts van de opening in wand-x), dan de scharnierzijde.
|
||||
function besideDoor(f, op, n, height) {
|
||||
const width = PLATE + (n - 1) * BOX_PITCH;
|
||||
const free = freeOn(f, height - PLATE / 2, height + PLATE / 2, 0, f.sw);
|
||||
@@ -190,12 +143,30 @@ window.ElektraModel = (() => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Routes. Knooppunt: { x, y, z, mount: 'wand'|'plafond'|'buiten', face?, mk? }.
|
||||
// ------------------------------------------------------------
|
||||
function makeRouter(units) {
|
||||
// rooms: leidingen lopen boven een vals plafond, niet tegen de onderkant van een hoog dak.
|
||||
function makeRouter(units, rooms) {
|
||||
const unitAt = (x, z) => units.find(u => x >= u.rect.x0 - 1 && x <= u.rect.x1 + 1 && z >= u.rect.z0 - 1 && z <= u.rect.z1 + 1) || null;
|
||||
const ceilAt = (x, z) => {
|
||||
// Vals plafond (mm boven vloer) van de ruimte op (x,z): undefined = geen ruimte (binnenwand, naad), null = geen vals plafond.
|
||||
const roomPlenum = (x, z) => { const r = rooms && rooms.roomAt ? rooms.roomAt(x, z) : null; return r ? (r.ceilingActive ? r.ceiling : null) : undefined; };
|
||||
// Absoluut; in een binnenwand het laagste valse plafond ernaast: de leiding gaat daar door de wand, niet omhoog.
|
||||
const plenumAt = (x, z) => {
|
||||
const u = unitAt(x, z);
|
||||
if (!u) return null;
|
||||
let v = roomPlenum(x, z);
|
||||
if (v === undefined) {
|
||||
v = null;
|
||||
[[1, 0], [-1, 0], [0, 1], [0, -1]].forEach(([dx, dz]) => {
|
||||
for (const d of [60, 120, 200, 300]) {
|
||||
const o = roomPlenum(x + dx * d, z + dz * d);
|
||||
if (o === undefined) continue;
|
||||
if (o !== null) v = v === null ? o : Math.min(v, o);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
return v === null ? null : u.layout.floorTop + v;
|
||||
};
|
||||
const roofCeilAt = (x, z) => {
|
||||
let u = unitAt(x, z);
|
||||
if (!u) u = units.reduce((best, c) => {
|
||||
const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1);
|
||||
@@ -205,25 +176,106 @@ window.ElektraModel = (() => {
|
||||
const r = u.rect;
|
||||
return u.roof.ceilingAt(Math.max(r.x0 + 1, Math.min(r.x1 - 1, x)), Math.max(r.z0 + 1, Math.min(r.z1 - 1, z)));
|
||||
};
|
||||
// Pad als 3D-punten (mm). Per punt: wall = wandvlak waar het in de wand
|
||||
// ligt, ceil = boven het plafond (voor de buizen in de 3D, build3D).
|
||||
// Leidingvlak: het valse plafond waar dat lager ligt dan het dak, anders de onderkant van het dak.
|
||||
const plenumOn = (x, z) => { const v = plenumAt(x, z); return v !== null && v < roofCeilAt(x, z) - 1 ? v : null; };
|
||||
const ceilAt = (x, z) => { const v = plenumOn(x, z); return v !== null ? v : roofCeilAt(x, z); };
|
||||
const inPlenum = (x, z, y) => y < roofCeilAt(x, z) - 50;
|
||||
// Over het plafond het vlak volgen: knikken in het dak (nok, kil) als tussenpunt, de overgang vals plafond/dak
|
||||
// verticaal op de grens (in de wand), zodat de buis nooit los door de ruimte of buiten het dak loopt.
|
||||
function followCeiling(pts) {
|
||||
const same = (u, v) => (u === null && v === null) || (u !== null && v !== null && Math.abs(u - v) < 1);
|
||||
const out = [pts[0]];
|
||||
pts.slice(1).forEach((q, i) => {
|
||||
const p = pts[i], len = Math.hypot(q.x - p.x, q.z - p.z);
|
||||
if (p.ceil && q.ceil && !p.floor && !q.floor && len > 1) {
|
||||
const at = (t) => ({ x: p.x + (q.x - p.x) * t, z: p.z + (q.z - p.z) * t });
|
||||
const hOf = (pt, st) => st !== null ? st : roofCeilAt(pt.x, pt.z);
|
||||
const mk = (pt, y) => Object.assign({}, p, pt, { y, wall: null, ceil: true, floor: false, plenum: inPlenum(pt.x, pt.z, y) });
|
||||
const n = Math.max(2, Math.ceil(len / 100));
|
||||
const S = Array.from({ length: n + 1 }, (_, k) => { const pt = at(k / n), st = plenumOn(pt.x, pt.z); return { t: k / n, pt, st, y: hOf(pt, st) }; });
|
||||
for (let k = 1; k <= n; k++) {
|
||||
const A = S[k - 1], B = S[k];
|
||||
if (!same(A.st, B.st)) {
|
||||
let lo = A.t, hi = B.t;
|
||||
for (let it = 0; it < 10; it++) { const m = (lo + hi) / 2, pm = at(m); if (same(plenumOn(pm.x, pm.z), A.st)) lo = m; else hi = m; }
|
||||
const pb = at((lo + hi) / 2);
|
||||
out.push(mk(pb, hOf(pb, A.st)), mk(pb, hOf(pb, B.st)));
|
||||
} else if (k < n && same(B.st, S[k + 1].st) && Math.abs(A.y - 2 * B.y + S[k + 1].y) > 2) out.push(mk(B.pt, B.y));
|
||||
}
|
||||
}
|
||||
out.push(q);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; };
|
||||
const clampTo = (v, lo, hi) => lo > hi ? (lo + hi) / 2 : Math.max(lo, Math.min(hi, v));
|
||||
const segInside = (p, q) => {
|
||||
const n = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 50));
|
||||
for (let k = 0; k <= n; k++) if (!unitAt(p.x + (q.x - p.x) * k / n, p.z + (q.z - p.z) * k / n)) return false;
|
||||
return true;
|
||||
};
|
||||
// Punt buiten de woning (gevellamp, dakrandspot): eerst loodrecht naar de dichtstbijzijnde unit.
|
||||
const clampIn = (p) => {
|
||||
if (unitAt(p.x, p.z)) return null;
|
||||
let best = null;
|
||||
units.forEach(u => {
|
||||
const r = u.rect, x = clampTo(p.x, r.x0 + 1, r.x1 - 1), z = clampTo(p.z, r.z0 + 1, r.z1 - 1), d = Math.hypot(x - p.x, z - p.z);
|
||||
if (!best || d < best.d) best = { x, z, d };
|
||||
});
|
||||
return best && { x: best.x, z: best.z };
|
||||
};
|
||||
// Knikpunten in de plattegrond tussen a en b (exclusief), haaks en binnen de units: bij een L- of T-vorm mag de
|
||||
// knik niet in de lege hoek buiten de woning vallen. Eerst x dan z (zoals altijd), anders z dan x, anders via een unit.
|
||||
function corners(a, b, viaCeiling) {
|
||||
const A = clampIn(a), B = clampIn(b), pa = A || a, pb = B || b;
|
||||
const cands = [[{ x: pb.x, z: pa.z }], [{ x: pa.x, z: pb.z }]];
|
||||
units.forEach(u => {
|
||||
const r = u.rect, xm = clampTo((pa.x + pb.x) / 2, r.x0 + 150, r.x1 - 150), zm = clampTo((pa.z + pb.z) / 2, r.z0 + 150, r.z1 - 150);
|
||||
cands.push([{ x: xm, z: pa.z }, { x: xm, z: pb.z }], [{ x: pa.x, z: zm }, { x: pb.x, z: zm }]);
|
||||
});
|
||||
let best = null;
|
||||
cands.forEach(c => {
|
||||
const pts = [pa].concat(c, [pb]);
|
||||
if (!pts.slice(1).every((q, i) => segInside(pts[i], q))) return;
|
||||
let len = pts.slice(1).reduce((t, q, i) => t + Math.abs(q.x - pts[i].x) + Math.abs(q.z - pts[i].z), 0);
|
||||
// Over het plafond telt op- en neergaan mee: liever door een laag vals plafond dan over de nok.
|
||||
if (viaCeiling) pts.slice(1).forEach((q, i) => {
|
||||
const p = pts[i], m = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 200));
|
||||
let prev = ceilAt(p.x, p.z);
|
||||
for (let k = 1; k <= m; k++) { const y = ceilAt(p.x + (q.x - p.x) * k / m, p.z + (q.z - p.z) * k / m); len += Math.abs(y - prev); prev = y; }
|
||||
});
|
||||
if (!best || len < best.len - 1) best = { len, c };
|
||||
});
|
||||
return [A].concat(best ? best.c : cands[0], [B]).filter(Boolean)
|
||||
.filter(q => Math.hypot(q.x - a.x, q.z - a.z) > 0.5 && Math.hypot(q.x - b.x, q.z - b.z) > 0.5);
|
||||
}
|
||||
const strictlyInside = (x, z) => units.some(u => x > u.rect.x0 + 5 && x < u.rect.x1 - 5 && z > u.rect.z0 + 5 && z < u.rect.z1 - 5);
|
||||
function path(a, b) {
|
||||
const P = (p, wall, ceil) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil });
|
||||
if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)]; // door de stijlen
|
||||
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a), P({ x: b.x, y: a.y, z: a.z }), P(b)];
|
||||
const top = (p) => p.mount === 'plafond' ? p.y : ceilAt(p.x, p.z);
|
||||
const P = (p, wall, ceil, floor) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil, floor: !!floor, plenum: !!ceil && inPlenum(p.x, p.z, p.y) });
|
||||
if (a.mount === 'vloer' || b.mount === 'vloer') {
|
||||
const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH;
|
||||
return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true)]
|
||||
.concat(corners(a, b).map(c => P({ x: c.x, y: fa, z: c.z }, null, false, true)),
|
||||
[P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')]);
|
||||
}
|
||||
if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
|
||||
// Gevellamp naar gevellamp: langs de gevel, bij een binnenhoek niet dwars door de woning.
|
||||
if (a.mount === 'buiten' && b.mount === 'buiten') {
|
||||
const c = strictlyInside(b.x, a.z) ? { x: a.x, z: b.z } : { x: b.x, z: a.z };
|
||||
return [P(a), P({ x: c.x, y: a.y, z: c.z }), P(b)];
|
||||
}
|
||||
// Wandpunt: de plafondhoogte iets de ruimte in meten (de wand zelf hoort bij geen ruimte).
|
||||
const top = (p) => p.mount === 'plafond' ? p.y : p.face && p.face.n ? ceilAt(p.x + p.face.n.x * 50, p.z + p.face.n.z * 50) : ceilAt(p.x, p.z);
|
||||
const ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1;
|
||||
const pts = [P(a, a.face, a.mount === 'plafond')];
|
||||
if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a.x, y: ya, z: a.z }, a.face, true));
|
||||
pts.push(P({ x: b.x, y: ceilAt(b.x, a.z), z: a.z }, null, true));
|
||||
corners(a, b, true).forEach(c => pts.push(P({ x: c.x, y: ceilAt(c.x, c.z), z: c.z }, null, true)));
|
||||
pts.push(P({ x: b.x, y: yb, z: b.z }, dropB ? b.face : null, true));
|
||||
if (dropB) pts.push(P(b, b.face, false));
|
||||
return pts;
|
||||
return followCeiling(pts);
|
||||
}
|
||||
const length = (pts) => pts.slice(1).reduce((s, q, i) => s + Math.hypot(q.x - pts[i].x, q.y - pts[i].y, q.z - pts[i].z), 0);
|
||||
// Snelle schatting (voor de boom): haaks + hoogteverschil.
|
||||
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
|
||||
// Aantal keer dat het pad (in plattegrond) van de ene unit naar een andere gaat.
|
||||
function crossings(pts) {
|
||||
let last = null, n = 0;
|
||||
pts.slice(1).forEach((q, i) => {
|
||||
@@ -237,30 +289,24 @@ window.ElektraModel = (() => {
|
||||
});
|
||||
return n;
|
||||
}
|
||||
return { unitAt, ceilAt, path, length, cost, crossings };
|
||||
return { unitAt, ceilAt, floorAt, path, length, cost, crossings };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Alles voor de hele woning. units/interior = HouseModel-parts,
|
||||
// lighting = parts.lighting (lamps, indoor), roofEdge = parts.roofEdge.
|
||||
// Zet p.letter op elke lamp/spot. Geeft het elektra-resultaat (zie onder).
|
||||
// ------------------------------------------------------------
|
||||
function compute(settings, units, interior, lighting, roofEdge) {
|
||||
function compute(settings, units, interior, lighting, roofEdge, rooms) {
|
||||
const s = normalizeSettings(settings);
|
||||
const indoor = lighting.indoor || [], gevel = lighting.lamps || [], spots = (roofEdge && roofEdge.spots) || [];
|
||||
indoor.concat(gevel, spots).forEach(p => { delete p.letter; });
|
||||
const out = {
|
||||
enabled: s.enabled, settings: s, meterkast: null, combos: [], switches: [], wcds: [], apps: [], boxes: [],
|
||||
enabled: s.enabled, settings: s, meterkast: null, combos: [], switches: [], wcds: [], apps: [], floorWcds: [], boxes: [],
|
||||
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
|
||||
};
|
||||
if (!s.enabled || !units.length) return out;
|
||||
const R = makeRouter(units);
|
||||
const R = makeRouter(units, rooms);
|
||||
const faces = {};
|
||||
units.forEach(u => { faces[u.id] = facesOf(u, interior); });
|
||||
const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1);
|
||||
const allFaces = out.faces = units.reduce((a, u) => a.concat(faces[u.id]), []);
|
||||
|
||||
// 1. Voordeur (anders de eerste buitendeur, anders de eerste unit).
|
||||
let door = null;
|
||||
['voordeur', 'deur'].some(type => units.some(u => u.walls.some(w => w.pieces.some(p => {
|
||||
if (p.baseY) return false;
|
||||
@@ -278,15 +324,20 @@ window.ElektraModel = (() => {
|
||||
const mk = s.meterkast;
|
||||
const apps = Object.keys(APPLIANCES).filter(k => s.apparaten[k]);
|
||||
const num = (v, def) => typeof v === 'number' && isFinite(v) ? v : def;
|
||||
const removed = (key) => s.removed.includes(key);
|
||||
const wcdCount = (uid) => s.wcdPerUnit + Math.max(0, Math.round(num(s.wcdExtra[uid], 0)));
|
||||
|
||||
// Handmatige plekken eerst vastleggen (de automatische wijken uit).
|
||||
const comboWidth = (n) => PLATE + (n - 1) * BOX_PITCH;
|
||||
const widthOf = (key) => {
|
||||
if (key === 'mk') return mk.w;
|
||||
let m = /^sw:(.+)$/.exec(key);
|
||||
if (m) return units.some(u => u.id === m[1]) ? comboWidth(m[1] === mainUnit.id ? mainFuncs.length : 1) : 0;
|
||||
if (m) {
|
||||
if (removed(key)) return 0;
|
||||
if (m[1] === mainUnit.id) return comboWidth(mainFuncs.length);
|
||||
return units.some(u => u.id === m[1]) ? (rooms ? 0 : comboWidth(1)) : comboWidth(1);
|
||||
}
|
||||
m = /^wcd:(.+):(\d+)$/.exec(key);
|
||||
if (m) return units.some(u => u.id === m[1]) && Number(m[2]) <= s.wcdPerUnit ? WCD_W : 0;
|
||||
if (m) return units.some(u => u.id === m[1]) && Number(m[2]) <= wcdCount(m[1]) && !removed(key) ? WCD_W : 0;
|
||||
m = /^app:(.+)$/.exec(key);
|
||||
return m && apps.includes(m[1]) ? PLATE : 0;
|
||||
};
|
||||
@@ -302,12 +353,11 @@ window.ElektraModel = (() => {
|
||||
});
|
||||
const centersAt = (u, n) => Array.from({ length: n }, (_, i) => u - comboWidth(n) / 2 + PLATE / 2 + i * BOX_PITCH);
|
||||
|
||||
// 2. Schakelaars bij de voordeur (A binnen, B buiten).
|
||||
const addCombo = (unit, f, us, funcs, y, manual) => {
|
||||
const addCombo = (unit, f, us, funcs, y, manual, key, name) => {
|
||||
const yy = y === null || y === undefined ? s.switchHeight : y;
|
||||
const combo = { id: out.combos.length + 1, key: 'sw:' + unit.id, manual: !!manual, unitId: unit.id, face: f, us, y: yy, rotY: f.piece.place.rotY, switches: [] };
|
||||
const combo = { id: out.combos.length + 1, key: key || 'sw:' + unit.id, manual: !!manual, unitId: unit.id, face: f, us, y: yy, rotY: f.piece.place.rotY, switches: [] };
|
||||
funcs.forEach((func, i) => {
|
||||
const sw = { letter: nextLetter(), func, unitId: unit.id, combo, u: us[i], lamps: [], label: func === 'buiten' ? 'Buitenlicht' : `Binnenlicht unit ${unit.id}` };
|
||||
const sw = { letter: nextLetter(), func, unitId: unit.id, combo, u: us[i], lamps: [], label: func === 'buiten' ? 'Buitenlicht' : `Binnenlicht ${name || 'unit ' + unit.id}` };
|
||||
sw.pos = facePoint(f, us[i], yy, 0);
|
||||
combo.switches.push(sw);
|
||||
out.switches.push(sw);
|
||||
@@ -321,8 +371,8 @@ window.ElektraModel = (() => {
|
||||
out.combos.push(combo);
|
||||
return combo;
|
||||
};
|
||||
const fxMain = fixed['sw:' + mainUnit.id];
|
||||
if (fxMain) addCombo(mainUnit, fxMain.f, centersAt(fxMain.u, mainFuncs.length), mainFuncs, fxMain.y, true);
|
||||
const fxMain = fixed['sw:' + mainUnit.id], mainOff = removed('sw:' + mainUnit.id);
|
||||
if (mainOff) { } else if (fxMain) addCombo(mainUnit, fxMain.f, centersAt(fxMain.u, mainFuncs.length), mainFuncs, fxMain.y, true);
|
||||
else if (door) {
|
||||
const us = besideDoor(door.face, door.op, mainFuncs.length, s.switchHeight);
|
||||
if (us) addCombo(mainUnit, door.face, us, mainFuncs);
|
||||
@@ -333,13 +383,12 @@ window.ElektraModel = (() => {
|
||||
if (nf) addCombo(mainUnit, nf.f, mainFuncs.map((_, i) => nf.u - w / 2 + PLATE / 2 + i * BOX_PITCH), mainFuncs);
|
||||
}
|
||||
}
|
||||
if (!out.combos.length) {
|
||||
if (!out.combos.length && !mainOff) {
|
||||
const f = faces[mainUnit.id][0];
|
||||
if (f) addCombo(mainUnit, f, mainFuncs.map((_, i) => (f.u0 + f.u1) / 2 + i * BOX_PITCH), mainFuncs);
|
||||
}
|
||||
const entry = out.combos[0] ? out.combos[0].pos : { x: mainUnit.rect.cx, z: mainUnit.rect.cz };
|
||||
|
||||
// 3. Meterkast: zo dicht mogelijk bij de voordeur, eerst in die unit.
|
||||
const target = door ? facePoint(door.face, door.op.x + door.op.w / 2, 0, 0) : entry;
|
||||
const fxMk = fixed.mk;
|
||||
const mkY = fxMk && fxMk.y !== null ? fxMk.y : mk.y;
|
||||
@@ -355,16 +404,59 @@ window.ElektraModel = (() => {
|
||||
};
|
||||
} else out.warnings.push('Geen vrije wand voor de meterkast gevonden.');
|
||||
|
||||
// 4. Schakelaar per andere unit, naast de deur waardoor je hem binnenkomt.
|
||||
units.forEach(u => {
|
||||
if (u === mainUnit) return;
|
||||
const isDoorOp = (op) => op.type === 'deur' || op.type === 'voordeur' || (op.fill && (op.fill.type === 'deur' || op.fill.type === 'schuifdeur'));
|
||||
const regionOf = (pt) => rooms ? rooms.regionAt(pt.x, pt.z) : null;
|
||||
const roomSw = new Map();
|
||||
if (rooms) {
|
||||
const entryPt = door ? facePoint(door.face, door.op.x + door.op.w / 2, 0, 150) : entry;
|
||||
const entryReg = regionOf(entryPt);
|
||||
const mainSw = out.switches.find(x => x.func === 'binnen');
|
||||
if (entryReg && mainSw) {
|
||||
roomSw.set(entryReg.idx, mainSw);
|
||||
const r0 = rooms.rooms.find(r => r.key === entryReg.idx);
|
||||
if (r0) mainSw.label = `Binnenlicht ${r0.name.toLowerCase()}`;
|
||||
}
|
||||
const list = rooms.rooms.concat(rooms.free).filter(r => r.areaM2 >= 0.6 && !roomSw.has(r.key))
|
||||
.sort((a, b) => Math.hypot(a.label.x - entryPt.x, a.label.z - entryPt.z) - Math.hypot(b.label.x - entryPt.x, b.label.z - entryPt.z));
|
||||
list.forEach(r => {
|
||||
const key = r.id !== undefined ? 'sw:r' + r.id : `sw:g${Math.round(r.label.x / 100)}_${Math.round(r.label.z / 100)}`;
|
||||
const name = r.name ? r.name.toLowerCase() : 'ruimte';
|
||||
if (removed(key)) return;
|
||||
const inR = (f, uu) => { const g = regionOf(facePoint(f, uu, 0, 150)); return g && g.idx === r.key; };
|
||||
const fx = fixed[key];
|
||||
let combo = null;
|
||||
if (fx) combo = addCombo(fx.f.unit, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true, key, name);
|
||||
else {
|
||||
const cands = [];
|
||||
allFaces.forEach(f => f.piece.config.openings.forEach(op => {
|
||||
if (!isDoorOp(op) || !inR(f, op.x + op.w / 2)) return;
|
||||
const q = facePoint(f, op.x + op.w / 2, 0, 0);
|
||||
cands.push({ f, op, d: Math.hypot(q.x - entryPt.x, q.z - entryPt.z) });
|
||||
}));
|
||||
cands.sort((a, b) => a.d - b.d);
|
||||
for (const c of cands) {
|
||||
const us = besideDoor(c.f, c.op, 1, s.switchHeight);
|
||||
if (us) { combo = addCombo(c.f.unit, c.f, us, ['binnen'], null, false, key, name); break; }
|
||||
}
|
||||
if (!combo) {
|
||||
const own = allFaces.filter(f => inR(f, (f.u0 + f.u1) / 2));
|
||||
const nf = nearestFree(own, r.label, PLATE, s.switchHeight - PLATE / 2, s.switchHeight + PLATE / 2, 0, 100);
|
||||
if (nf && inR(nf.f, nf.u)) combo = addCombo(nf.f.unit, nf.f, [nf.u], ['binnen'], null, false, key, name);
|
||||
}
|
||||
}
|
||||
if (combo) roomSw.set(r.key, combo.switches[0]);
|
||||
else out.warnings.push(`${r.name || 'Ruimte'}: geen plek voor een schakelaar; licht op een andere schakelaar.`);
|
||||
});
|
||||
}
|
||||
if (!rooms) units.forEach(u => {
|
||||
if (u === mainUnit || removed('sw:' + u.id)) return;
|
||||
const fx = fixed['sw:' + u.id];
|
||||
if (fx) { addCombo(u, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true); return; }
|
||||
const cands = [];
|
||||
faces[u.id].forEach(f => f.piece.config.openings.forEach(op => {
|
||||
if (op.type !== 'deur' && op.type !== 'voordeur') return;
|
||||
const q = facePoint(f, op.x + op.w / 2, 0, 0);
|
||||
cands.push({ f, op, d: Math.hypot(q.x - entry.x, q.z - entry.z) + (f.piece.seamUnit ? 0 : 5000) }); // naadwand-deur eerst
|
||||
cands.push({ f, op, d: Math.hypot(q.x - entry.x, q.z - entry.z) + (f.piece.seamUnit ? 0 : 5000) });
|
||||
}));
|
||||
cands.sort((a, b) => a.d - b.d);
|
||||
for (const c of cands) {
|
||||
@@ -374,22 +466,24 @@ window.ElektraModel = (() => {
|
||||
out.warnings.push(`Unit ${u.id}: geen deur met ruimte voor een schakelaar; binnenlicht op schakelaar ${out.switches[0] ? out.switches[0].letter : '-'}.`);
|
||||
});
|
||||
|
||||
// 5. Lampen aan schakelaars koppelen (letter).
|
||||
const swOf = (unitId, func) => out.switches.find(x => x.unitId === unitId && x.func === func) || out.switches.find(x => x.func === func) || out.switches[0];
|
||||
indoor.forEach(p => { const sw = swOf(p.unitId, 'binnen'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x, y: p.y, z: p.z, mount: 'plafond', kind: 'binnenspot', ref: p }); } });
|
||||
const roomSwOf = (p) => { const g = regionOf(p); return g ? roomSw.get(g.idx) : null; };
|
||||
indoor.forEach(p => { const sw = roomSwOf(p) || swOf(p.unitId, 'binnen'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x, y: p.y, z: p.z, mount: 'plafond', kind: 'binnenspot', ref: p }); } });
|
||||
gevel.forEach(p => { const sw = swOf(mainUnit.id, 'buiten'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x + p.normal.x * 60, y: p.y, z: p.z + p.normal.z * 60, mount: 'buiten', kind: 'gevellamp', ref: p }); } });
|
||||
spots.forEach(p => { const sw = swOf(mainUnit.id, 'buiten'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x, y: p.y, z: p.z, mount: 'buiten', kind: 'boeideelspot', ref: p }); } });
|
||||
|
||||
// 6. Wandcontactdozen per unit (zo ver mogelijk uit elkaar).
|
||||
units.forEach(u => {
|
||||
const y0 = s.wcdHeight - 60, y1 = s.wcdHeight + 60, cands = [];
|
||||
faces[u.id].forEach(f => freeOn(f, y0, y1, 100, 150).forEach(([a, b]) => {
|
||||
if (b - a < WCD_W + 20) return;
|
||||
(b - a > 1200 ? [a + 300, (a + b) / 2, b - 300] : [(a + b) / 2]).forEach(uu => cands.push({ f, u: uu, len: b - a, p: facePoint(f, uu, s.wcdHeight, 0) }));
|
||||
}));
|
||||
const chosen = [], freeKeys = [];
|
||||
for (let i = 1; i <= s.wcdPerUnit; i++) {
|
||||
const chosen = [], freeKeys = [], nWcd = wcdCount(u.id);
|
||||
let want = 0;
|
||||
for (let i = 1; i <= nWcd; i++) {
|
||||
const key = `wcd:${u.id}:${i}`, fx = fixed[key];
|
||||
if (removed(key)) continue;
|
||||
want++;
|
||||
if (!fx) { freeKeys.push(key); continue; }
|
||||
const y = fx.y !== null ? fx.y : s.wcdHeight;
|
||||
chosen.push({ f: fx.f, u: fx.u, y, p: facePoint(fx.f, fx.u, y, 0), key, manual: true });
|
||||
@@ -405,14 +499,12 @@ window.ElektraModel = (() => {
|
||||
chosen.push(Object.assign({}, best, { key: freeKeys.shift(), y: s.wcdHeight }));
|
||||
best.f.reserved.push({ a: best.u - WCD_W / 2 - 75, b: best.u + WCD_W / 2 + 75, y0: 0, y1: s.wcdHeight + 200 });
|
||||
}
|
||||
if (chosen.length < s.wcdPerUnit) out.warnings.push(`Unit ${u.id}: ruimte voor ${chosen.length} van ${s.wcdPerUnit} wcd's.`);
|
||||
chosen.sort((a, b) => a.key < b.key ? -1 : 1).forEach(c => out.wcds.push({
|
||||
if (chosen.length < want) out.warnings.push(`Unit ${u.id}: ruimte voor ${chosen.length} van ${want} wcd's.`);
|
||||
chosen.sort((a, b) => Number(a.key.split(':')[2]) - Number(b.key.split(':')[2])).forEach(c => out.wcds.push({
|
||||
key: c.key, manual: !!c.manual, unitId: u.id, face: c.f, u: c.u, y: c.y, rotY: c.f.piece.place.rotY, pos: c.p, center: facePoint(c.f, c.u, c.y, 6)
|
||||
}));
|
||||
});
|
||||
|
||||
// 6b. Apparaten: keuken bij elkaar in de grootste andere unit, de rest
|
||||
// naast de meterkast.
|
||||
const others = units.filter(u => u !== mainUnit);
|
||||
const kitchen = (others.length ? others : units).reduce((a, b) => a.rect.W * a.rect.D >= b.rect.W * b.rect.D ? a : b);
|
||||
const mkUnit = out.meterkast ? units.find(u => u.id === out.meterkast.unitId) : mainUnit;
|
||||
@@ -435,11 +527,18 @@ window.ElektraModel = (() => {
|
||||
out.apps.push({ key, manual: !!fx, app: k, label: A.label, code: A.code, unitId: f.unit.id, face: f, u: uu, y, rotY: f.piece.place.rotY, pos: facePoint(f, uu, y, 0), center: facePoint(f, uu, y, 6) });
|
||||
});
|
||||
|
||||
// 7. Groepen: per unit licht (B10) + wcd (B16); fasen en aardlek om en om.
|
||||
s.vloerWcd.forEach(o => {
|
||||
const u = units.find(x => x.id === o.unitId);
|
||||
if (!u) return;
|
||||
const x = u.rect.x0 + Math.max(100, Math.min(u.rect.W - 100, num(o.x, u.rect.W / 2)));
|
||||
const z = u.rect.z0 + Math.max(100, Math.min(u.rect.D - 100, num(o.z, u.rect.D / 2)));
|
||||
out.floorWcds.push({ key: 'vwcd:' + o.id, id: o.id, unitId: u.id, x, z, y: u.layout.floorTop });
|
||||
});
|
||||
|
||||
units.forEach(u => {
|
||||
const sws = out.switches.filter(x => x.unitId === u.id && x.func === 'binnen');
|
||||
if (sws.length) out.groups.push({ kind: 'licht', unitId: u.id, label: `Verlichting unit ${u.id}`, breaker: 'B10', cable: 'licht', switches: sws.map(x => x.letter) });
|
||||
if (out.wcds.some(w => w.unitId === u.id)) out.groups.push({ kind: 'wcd', unitId: u.id, label: `Wcd unit ${u.id}`, breaker: 'B16', cable: 'wcd', switches: [] });
|
||||
if (out.wcds.some(w => w.unitId === u.id) || out.floorWcds.some(w => w.unitId === u.id)) out.groups.push({ kind: 'wcd', unitId: u.id, label: `Wcd unit ${u.id}`, breaker: 'B16', cable: 'wcd', switches: [] });
|
||||
const outSw = out.switches.filter(x => x.unitId === u.id && x.func === 'buiten');
|
||||
if (outSw.length) out.groups.push({ kind: 'buiten', unitId: u.id, label: 'Buitenverlichting', breaker: 'B10', cable: 'buiten', switches: outSw.map(x => x.letter) });
|
||||
});
|
||||
@@ -460,8 +559,6 @@ window.ElektraModel = (() => {
|
||||
// Buitenlicht zit in de schakeldoos van het binnenlicht: zelfde aardlek.
|
||||
const gOut = out.groups.find(g => g.kind === 'buiten'), gIn = gOut && groupOf('licht', gOut.unitId);
|
||||
if (gOut && gIn) gOut.rcd = gIn.rcd;
|
||||
// Max. 4 groepen per aardlek: anders een andere groep (niet die twee)
|
||||
// naar de aardlek met de minste groepen.
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const cnt = Array.from({ length: nRcd }, (_, i) => out.groups.filter(g => g.rcd === i + 1).length);
|
||||
const over = cnt.findIndex(c => c > 4);
|
||||
@@ -472,18 +569,16 @@ window.ElektraModel = (() => {
|
||||
}
|
||||
out.switches.forEach(sw => { const g = groupOf(sw.func === 'buiten' ? 'buiten' : 'licht', sw.unitId); sw.group = g ? g.nr : null; });
|
||||
out.wcds.forEach(w => { const g = groupOf('wcd', w.unitId); w.group = g ? g.nr : null; });
|
||||
out.floorWcds.forEach(w => { const g = groupOf('wcd', w.unitId); w.group = g ? g.nr : null; });
|
||||
out.apps.forEach(a => { const g = out.groups.find(x => x.kind === 'app' && x.app === a.app); a.group = g ? g.nr : null; });
|
||||
|
||||
// 8. Leidingen.
|
||||
const mkNode = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY + mk.h, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null;
|
||||
const mkLow = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null;
|
||||
const addRun = (type, a, b, group, letter) => {
|
||||
const pts = R.path(a, b);
|
||||
const raw = R.length(pts);
|
||||
const lengthMM = (raw + s.reserve + (a.mk || b.mk ? s.meterkastReserve : 0)) * (1 + s.slack / 100);
|
||||
out.runs.push({ type, group, letter: letter || null, from: a.name || '', to: b.name || '', path: pts, rawMM: raw, lengthMM, crossings: R.crossings(pts), outdoor: a.mount === 'buiten' || b.mount === 'buiten' });
|
||||
out.runs.push({ type, group, letter: letter || null, from: a.name || '', to: b.name || '', path: pts, mk: !!(a.mk || b.mk), outdoor: a.mount === 'buiten' || b.mount === 'buiten' });
|
||||
};
|
||||
// Minimale opspannende boom vanaf root (Prim, op de snelle schatting).
|
||||
const tree = (root, nodes, typeOf, group, letter) => {
|
||||
const done = [root], rest = nodes.slice();
|
||||
while (rest.length) {
|
||||
@@ -494,7 +589,6 @@ window.ElektraModel = (() => {
|
||||
done.push(n);
|
||||
}
|
||||
};
|
||||
// Per schakeldoos één verdeeldoos per groep (naast elkaar langs de wand).
|
||||
for (const combo of out.combos) {
|
||||
const byGroup = [];
|
||||
combo.switches.forEach(sw => {
|
||||
@@ -513,21 +607,31 @@ window.ElektraModel = (() => {
|
||||
vd.y = R.ceilAt(vd.x, vd.z);
|
||||
combo.vds.push(vd);
|
||||
out.boxes.push({ kind: 'verdeeldoos', combo: combo.id, group: e.group, x: vd.x, y: vd.y, z: vd.z, unitId: combo.unitId });
|
||||
if (mkNode) addRun('licht', mkNode, vd, e.group); // voeding binnen: VD in buis
|
||||
const swNode = Object.assign({}, combo.pos, { mount: 'wand', face: combo.face, name: e.switches.map(x => x.letter).join('') });
|
||||
if (mkNode) addRun('licht', mkNode, vd, e.group);
|
||||
const uSw = e.switches.reduce((t, x) => t + x.u, 0) / e.switches.length;
|
||||
const swNode = Object.assign(facePoint(combo.face, uSw, combo.y, 0), { mount: 'wand', face: combo.face, name: e.switches.map(x => x.letter).join('') });
|
||||
addRun('schakel' + (e.switches.length + 1), vd, swNode, e.group);
|
||||
e.switches.forEach(sw => tree(vd, sw.lamps.map((l, k) => Object.assign(l, { name: `${sw.letter}${k + 1}` })),
|
||||
(a, b) => a.mount === 'buiten' || b.mount === 'buiten' ? 'buiten' : 'licht', e.group, sw.letter));
|
||||
});
|
||||
}
|
||||
out.groups.filter(g => g.kind === 'wcd').forEach(g => {
|
||||
const nodes = out.wcds.filter(w => w.group === g.nr).map((w, i) => Object.assign({}, w.pos, { mount: 'wand', face: w.face, name: `WCD${g.nr}.${i + 1}` }));
|
||||
const nodes = out.wcds.filter(w => w.group === g.nr).map((w, i) => Object.assign({}, w.pos, { mount: 'wand', face: w.face, name: `WCD${g.nr}.${i + 1}` }))
|
||||
.concat(out.floorWcds.filter(w => w.group === g.nr).map((w, i) => ({ x: w.x, y: w.y, z: w.z, mount: 'vloer', name: `VWCD${g.nr}.${i + 1}` })));
|
||||
if (mkLow && nodes.length) tree(mkLow, nodes, () => 'wcd', g.nr);
|
||||
});
|
||||
// Apparaten: elk een eigen leiding vanuit de meterkast.
|
||||
if (mkLow) out.apps.forEach(a => addRun(APPLIANCES[a.app].cable, mkLow, Object.assign({}, a.pos, { mount: 'wand', face: a.face, name: a.code }), a.group));
|
||||
|
||||
// 9. Totalen: per leidingtype, draad per doorsnede/kleur, buis, groep.
|
||||
separateRuns(out.runs);
|
||||
const holesOf = makeHoleCounter(units, R);
|
||||
out.runs.forEach(r => {
|
||||
r.rawMM = R.length(r.path);
|
||||
r.lengthMM = (r.rawMM + s.reserve + (r.mk ? s.meterkastReserve : 0)) * (1 + s.slack / 100);
|
||||
r.crossings = R.crossings(r.path);
|
||||
r.holes = holesOf(r);
|
||||
});
|
||||
out.holes = out.runs.reduce((a, r) => a + r.holes, 0);
|
||||
|
||||
const byType = {};
|
||||
out.runs.forEach(r => { byType[r.type] = (byType[r.type] || 0) + r.lengthMM; });
|
||||
out.cables = Object.keys(byType).sort().map(k => ({ key: k, label: cableInfo(k).label, use: cableInfo(k).use, meters: byType[k] / 1000 }));
|
||||
@@ -554,7 +658,6 @@ window.ElektraModel = (() => {
|
||||
if ((g.kind === 'licht' || g.kind === 'buiten') && g.points > 50) out.warnings.push(`Groep ${g.nr} (${g.label}): ${g.points} lichtpunten; controleer het vermogen (LED).`);
|
||||
});
|
||||
|
||||
// 10. Materiaal (aantallen).
|
||||
const crossBy = {};
|
||||
out.runs.forEach(r => { if (r.crossings) { const n = cableInfo(r.type).cores.length; crossBy[n] = (crossBy[n] || 0) + r.crossings; } });
|
||||
const nIndoor = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'binnenspot').length, 0);
|
||||
@@ -565,6 +668,7 @@ window.ElektraModel = (() => {
|
||||
out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; });
|
||||
const m = out.materials;
|
||||
const add = (naam, aantal, eenheid) => { if (aantal > 0) m.push({ naam, aantal, eenheid: eenheid || 'st.' }); };
|
||||
const kleur = ', ' + PLATE_COLORS[s.plateColor].label.toLowerCase();
|
||||
if (out.meterkast) {
|
||||
add('Groepenkast 3-fase (aansluiting 3×25 A)', 1);
|
||||
add('Hoofdschakelaar 4-polig 40 A', 1);
|
||||
@@ -575,12 +679,13 @@ window.ElektraModel = (() => {
|
||||
add('Installatieautomaat B16 1P+N (wcd)', nWcdG);
|
||||
add('Installatieautomaat B16 1P+N (apparaten)', out.apps.filter(a => (APPLIANCES[a.app].poles || 1) === 1).length);
|
||||
add('Installatieautomaat 2× B16, 2-fase (kookgroep)', out.apps.filter(a => APPLIANCES[a.app].poles === 2).length);
|
||||
add('Schakelaar enkelpolig (inbouw)', out.switches.length);
|
||||
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig`, frames[n]));
|
||||
add('Wandcontactdoos dubbel met randaarde', out.wcds.length);
|
||||
add('Schakelaar enkelpolig (inbouw)' + kleur, out.switches.length);
|
||||
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig` + kleur, frames[n]));
|
||||
add('Wandcontactdoos dubbel met randaarde' + kleur, out.wcds.length);
|
||||
add('Vloerdoos met dubbele wcd (randaarde)' + kleur, out.floorWcds.length);
|
||||
const appMat = {};
|
||||
out.apps.forEach(a => { const n = APPLIANCES[a.app].material; appMat[n] = (appMat[n] || 0) + 1; });
|
||||
Object.keys(appMat).forEach(n => add(n, appMat[n]));
|
||||
Object.keys(appMat).forEach(n => add(n + kleur, appMat[n]));
|
||||
add('Luchtdichte hollewanddoos (schakelaar/wcd/apparaat)', out.switches.length + 2 * out.wcds.length + out.apps.length);
|
||||
add('Luchtdichte verdeeldoos (plafond)', out.boxes.length);
|
||||
add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length);
|
||||
@@ -590,8 +695,6 @@ window.ElektraModel = (() => {
|
||||
add('Luchtdichte kabeldoorvoer naar buiten', out.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && r.to !== '' && !r.to.startsWith('VD')).length);
|
||||
Object.keys(crossBy).forEach(n => add(`Steekverbinder module-naad ${n}-polig (set m/v)`, crossBy[n]));
|
||||
|
||||
// 11. Verplaatsbare onderdelen (plattegrond: slepen, selecteren).
|
||||
// marker = plek van het symbool in de plattegrond (iets de kamer in).
|
||||
const item = (key, kind, label, f, u, y, manual, off, unitId) => out.items.push({
|
||||
key, kind, label, unitId: unitId || f.unit.id, element: f.piece.element, side: f.side, u, y, manual: !!manual, marker: facePoint(f, u, 0, off)
|
||||
});
|
||||
@@ -600,34 +703,126 @@ window.ElektraModel = (() => {
|
||||
out.combos.forEach(c => item(c.key, 'sw', 'Schakelaar ' + c.switches.map(x => x.letter).join(' '), c.face, c.u, c.y, c.manual, 260, c.unitId));
|
||||
out.wcds.forEach(w => item(w.key, 'wcd', `Wcd ${w.key.split(':')[2]} unit ${w.unitId} (groep ${w.group})`, w.face, w.u, w.y, w.manual, 120, w.unitId));
|
||||
out.apps.forEach(a => item(a.key, 'app', `${a.label} (groep ${a.group})`, a.face, a.u, a.y, a.manual, 150));
|
||||
out.floorWcds.forEach(w => {
|
||||
const u = units.find(x => x.id === w.unitId);
|
||||
out.items.push({ key: w.key, kind: 'vwcd', label: `Vloer-wcd unit ${w.unitId} (groep ${w.group})`, unitId: w.unitId, x: w.x - u.rect.x0, z: w.z - u.rect.z0, manual: true, marker: { x: w.x, z: w.z } });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Uitzondering: het laatste stuk naar de meterkast; daar komen de buizen tegen elkaar aan de kast binnen.
|
||||
function separateRuns(runs) {
|
||||
const add = (p, d, k) => Object.assign({}, p, { x: p.x + d.x * k, y: p.y + d.y * k, z: p.z + d.z * k });
|
||||
const axisOf = (p, q) => {
|
||||
const dx = Math.abs(q.x - p.x), dy = Math.abs(q.y - p.y), dz = Math.abs(q.z - p.z);
|
||||
return dx >= dy && dx >= dz ? 'x' : dz >= dy ? 'z' : 'y';
|
||||
};
|
||||
const lenOf = (p, q) => Math.hypot(q.x - p.x, q.y - p.y, q.z - p.z);
|
||||
const seg = (run, p, q, r) => {
|
||||
const axis = axisOf(p, q), perp = {};
|
||||
['x', 'y', 'z'].filter(k => k !== axis).forEach(k => { perp[k] = (p[k] + q[k]) / 2; });
|
||||
return { run, axis, lo: Math.min(p[axis], q[axis]), hi: Math.max(p[axis], q[axis]), perp, r };
|
||||
};
|
||||
const placed = [];
|
||||
const conflict = (sg) => placed.some(t => t.run !== sg.run && t.axis === sg.axis && Math.min(t.hi, sg.hi) - Math.max(t.lo, sg.lo) > 1 &&
|
||||
Math.hypot(...Object.keys(sg.perp).map(k => sg.perp[k] - t.perp[k])) < CLEAR + sg.r + t.r - 0.5);
|
||||
const faceDir = (f) => { const L = Math.hypot(f.b.x - f.a.x, f.b.z - f.a.z) || 1; return { x: (f.b.x - f.a.x) / L, y: 0, z: (f.b.z - f.a.z) / L }; };
|
||||
const shiftDir = (p, q, axis) => {
|
||||
const plane = (p.ceil || p.floor) && (q.ceil || q.floor);
|
||||
if (axis === 'y') { const f = p.wall || q.wall; return f ? faceDir(f) : { x: 1, y: 0, z: 0 }; }
|
||||
if (!plane && (p.wall || q.wall)) return { x: 0, y: 1, z: 0 };
|
||||
return axis === 'x' ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
|
||||
};
|
||||
const conduit = runs.filter(r => cableInfo(r.type).conduit > 0);
|
||||
const radius = (r) => cableInfo(r.type).conduit / 2;
|
||||
|
||||
const bundles = {};
|
||||
conduit.filter(r => r.mk && r.path[0].wall).forEach(r => {
|
||||
const k = Math.round(r.path[0].y);
|
||||
(bundles[k] = bundles[k] || []).push(r);
|
||||
});
|
||||
Object.values(bundles).forEach(list => {
|
||||
const d = faceDir(list[0].path[0].wall);
|
||||
const along = (r) => { const q = r.path[Math.min(2, r.path.length - 1)]; return (q.x - r.path[0].x) * d.x + (q.z - r.path[0].z) * d.z; };
|
||||
list.sort((a, b) => along(a) - along(b));
|
||||
const total = list.reduce((a, r) => a + 2 * radius(r), 0);
|
||||
let off = -total / 2;
|
||||
list.forEach(r => {
|
||||
const o = off + radius(r);
|
||||
off += 2 * radius(r);
|
||||
const orig = r.path, dAx = Math.abs(d.x) > Math.abs(d.z) ? 'x' : 'z';
|
||||
const out = orig.map(p => p);
|
||||
out[0] = add(orig[0], d, o);
|
||||
for (let k = 0; k < orig.length - 1; k++) {
|
||||
const len = lenOf(orig[k], orig[k + 1]);
|
||||
if (len >= 1 && axisOf(orig[k], orig[k + 1]) === dAx) break;
|
||||
if (k + 1 === orig.length - 1) { out.splice(k + 1, 0, add(orig[k + 1], d, o)); break; }
|
||||
out[k + 1] = add(orig[k + 1], d, o);
|
||||
}
|
||||
r.path = out;
|
||||
placed.push(seg(r, out[0], out[1], radius(r)));
|
||||
r.fixed = 1;
|
||||
});
|
||||
});
|
||||
|
||||
conduit.forEach(r => {
|
||||
const rad = radius(r), step = CLEAR + 2 * rad;
|
||||
let pts = r.path;
|
||||
for (let i = r.fixed || 0; i < pts.length - 1; i++) {
|
||||
const p = pts[i], q = pts[i + 1];
|
||||
if (lenOf(p, q) < 1) continue;
|
||||
const sg = seg(r, p, q, rad);
|
||||
if (!conflict(sg)) { placed.push(sg); continue; }
|
||||
const dir = shiftDir(p, q, sg.axis);
|
||||
// Knikken tellen mee, behalve direct aan een doos/lamp en aan het eind van de meterkastbundel.
|
||||
const freeStart = i === 0 || (r.fixed && i === r.fixed), freeEnd = i + 1 === pts.length - 1;
|
||||
let done = false;
|
||||
for (let k = 1; k <= 12 && !done; k++) {
|
||||
for (const sign of [1, -1]) {
|
||||
const kk = sign * k * step, p2 = add(p, dir, kk), q2 = add(q, dir, kk), s2 = seg(r, p2, q2, rad);
|
||||
const j1 = seg(r, p, p2, rad), j2 = seg(r, q2, q, rad);
|
||||
if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue;
|
||||
pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1));
|
||||
placed.push(s2, j1, j2);
|
||||
i += 2;
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!done) placed.push(sg);
|
||||
}
|
||||
r.path = simplifyPath(pts);
|
||||
});
|
||||
runs.forEach(r => { delete r.fixed; });
|
||||
}
|
||||
function simplifyPath(pts) {
|
||||
const same = (a, b) => Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.z - b.z) < 0.5;
|
||||
let out = pts.filter((p, i) => i === 0 || !same(p, pts[i - 1]));
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (let i = 1; i < out.length - 1; i++) {
|
||||
if (i + 1 < out.length && same(out[i - 1], out[i + 1])) { out.splice(i, 2); changed = true; break; }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Dichtstbijzijnde wandvlak (kamerzijde) bij plattegrondpunt (x, z), voor
|
||||
// het slepen: { element, side, u, x, z, n } of null.
|
||||
function snapToWall(el, x, z) {
|
||||
let best = null;
|
||||
((el && el.faces) || []).forEach(f => {
|
||||
const u = Math.max(f.u0, Math.min(f.u1, uAt(f, { x, z })));
|
||||
const q = facePoint(f, u, 0, 0);
|
||||
if ((x - q.x) * f.n.x + (z - q.z) * f.n.z < -(f.piece.place.thickness + 50)) return; // aan de andere kant van de wand
|
||||
if ((x - q.x) * f.n.x + (z - q.z) * f.n.z < -(f.piece.place.thickness + 50)) return;
|
||||
const d = Math.hypot(q.x - x, q.z - z);
|
||||
if (!best || d < best.d) best = { d, element: f.piece.element, side: f.side, u: Math.round(u / 10) * 10, x: q.x, z: q.z, n: f.n };
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3D per bouwfase (BuildStages):
|
||||
// elektra meterkast (blijft), installatiebuizen (zeskant, kleur per
|
||||
// leidingtype) in de wanden en boven het plafond, luchtdichte
|
||||
// inbouwdozen, verdeeldozen en spotdozen. Vanaf "beplating"
|
||||
// zitten buizen en dozen achter de platen: niet meer gebouwd.
|
||||
// afwerking afdekramen van schakelaars, wcd's en apparaten.
|
||||
// Kabel zonder buis (XMvK buiten) en losse draad worden niet getekend.
|
||||
// ------------------------------------------------------------
|
||||
const WALL_IN = 35; // buis: zoveel achter het wandvlak (in de stijlruimte)
|
||||
const CEIL_UP = 35; // buis: zoveel boven de onderkant plafond (op de plafondplaat)
|
||||
const WALL_IN = 35;
|
||||
const CEIL_UP = 35;
|
||||
const CONDUIT_COLORS = { licht: 0xe0b020, wcd: 0xcc4a30, koken: 0x8a3a9a, schakel: 0x9a6a20 };
|
||||
function createMaterials() {
|
||||
const conduit = {};
|
||||
@@ -640,12 +835,13 @@ window.ElektraModel = (() => {
|
||||
conduit
|
||||
};
|
||||
}
|
||||
function build3D(el, materials, stage) {
|
||||
// rough: buizen en inbouwdozen (in de wand en boven het plafond); uit bij de render-export.
|
||||
function build3D(el, materials, layers, rough) {
|
||||
const toM = 0.001, group = new THREE.Group();
|
||||
if (!el || !el.enabled) return group;
|
||||
const mats = materials || createMaterials();
|
||||
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key);
|
||||
const rough = show('elektra') && !show('beplating');
|
||||
mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex);
|
||||
if (!ViewLayers.shows(layers, 'elektra')) return group;
|
||||
const box = (w, h, d, c, rotY, mat) => {
|
||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
|
||||
mesh.position.set(c.x * toM, c.y * toM, c.z * toM);
|
||||
@@ -654,24 +850,24 @@ window.ElektraModel = (() => {
|
||||
return mesh;
|
||||
};
|
||||
const k = el.meterkast;
|
||||
if (k && show('elektra')) {
|
||||
if (k) {
|
||||
box(k.w, k.h, k.d, k.center, k.rotY, mats.kastMaterial);
|
||||
const front = facePoint(k.face, k.u, k.y + k.h / 2, k.d + 2);
|
||||
box(k.w - 30, k.h - 30, 4, front, k.rotY, mats.deurMaterial);
|
||||
}
|
||||
if (show('afwerking')) {
|
||||
if (ViewLayers.shows(layers, 'afwerking')) {
|
||||
el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial));
|
||||
el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial));
|
||||
el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial));
|
||||
el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial));
|
||||
}
|
||||
if (!rough) return group;
|
||||
if (rough === false) return group;
|
||||
|
||||
// Installatiebuizen: zeskantige cilinders per padstuk + bolletje op elke knik.
|
||||
const up = new THREE.Vector3(0, 1, 0);
|
||||
const shift = (p) => ({
|
||||
x: p.x - (p.wall ? p.wall.n.x * WALL_IN : 0), y: p.y + (p.ceil ? CEIL_UP : 0), z: p.z - (p.wall ? p.wall.n.z * WALL_IN : 0)
|
||||
});
|
||||
el.runs.forEach(r => {
|
||||
el.runs.forEach((r, ri) => {
|
||||
const c = cableInfo(r.type);
|
||||
if (!c.conduit) return;
|
||||
const rad = c.conduit / 2 * toM;
|
||||
@@ -683,15 +879,16 @@ window.ElektraModel = (() => {
|
||||
const m = new THREE.Mesh(new THREE.CylinderGeometry(rad, rad, len * toM, 6), mat);
|
||||
m.position.set((p.x + q.x) / 2 * toM, (p.y + q.y) / 2 * toM, (p.z + q.z) / 2 * toM);
|
||||
m.quaternion.setFromUnitVectors(up, new THREE.Vector3(dx / len, dy / len, dz / len));
|
||||
m.userData.run = ri;
|
||||
group.add(m);
|
||||
});
|
||||
pts.slice(1, -1).forEach(p => {
|
||||
const j = new THREE.Mesh(new THREE.IcosahedronGeometry(rad * 1.15, 0), mat);
|
||||
j.position.set(p.x * toM, p.y * toM, p.z * toM);
|
||||
j.userData.run = ri;
|
||||
group.add(j);
|
||||
});
|
||||
});
|
||||
// Luchtdichte inbouwdozen (schakelaars, wcd, apparaten): in de wand.
|
||||
const wallBox = (f, u, y) => {
|
||||
const c = facePoint(f, u, y, -22);
|
||||
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.034, 0.034, 0.045, 12), mats.boxMaterial);
|
||||
@@ -702,7 +899,7 @@ window.ElektraModel = (() => {
|
||||
el.switches.forEach(sw => wallBox(sw.combo.face, sw.u, sw.combo.y));
|
||||
el.wcds.forEach(w => { wallBox(w.face, w.u - 36, w.y); wallBox(w.face, w.u + 36, w.y); });
|
||||
el.apps.forEach(a => wallBox(a.face, a.u, a.y));
|
||||
// Verdeeldozen en spotdozen boven het plafond.
|
||||
el.floorWcds.forEach(w => box(100, 80, 100, { x: w.x, y: w.y - 40, z: w.z }, 0, mats.boxMaterial));
|
||||
el.boxes.forEach(b => box(90, 50, 90, { x: b.x, y: b.y + CEIL_UP + 10, z: b.z }, 0, mats.boxMaterial));
|
||||
el.switches.forEach(sw => sw.lamps.filter(l => l.kind === 'binnenspot').forEach(l => {
|
||||
const m = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.07, 12), mats.boxMaterial);
|
||||
@@ -712,5 +909,71 @@ window.ElektraModel = (() => {
|
||||
return group;
|
||||
}
|
||||
|
||||
return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, cableInfo, normalizeSettings, compute, snapToWall, createMaterials, build3D };
|
||||
const wallCoords = (f, p) => ({ u: uAt(f, p), y: p.y - f.piece.place.floorTop });
|
||||
|
||||
function clipPlan(p, q, b) {
|
||||
let t0 = 0, t1 = 1;
|
||||
for (const [k, lo, hi] of [['x', b.x0, b.x1], ['z', b.z0, b.z1]]) {
|
||||
const d = q[k] - p[k];
|
||||
if (Math.abs(d) < 1e-9) { if (p[k] < lo - 0.5 || p[k] > hi + 0.5) return null; continue; }
|
||||
let a = (lo - p[k]) / d, c = (hi - p[k]) / d;
|
||||
if (a > c) [a, c] = [c, a];
|
||||
t0 = Math.max(t0, a); t1 = Math.min(t1, c);
|
||||
if (t0 > t1) return null;
|
||||
}
|
||||
const at = (t) => Object.assign({}, p, { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t, z: p.z + (q.z - p.z) * t });
|
||||
return [at(t0), at(t1)];
|
||||
}
|
||||
// Zelfde omrekening als de doorvoeren in RoofModel.computeUnitRoof: gelijk houden.
|
||||
function roofDeckMap(unit, pl) {
|
||||
const alongX = Math.abs(Math.sin(pl.yaw)) > 0.5, rr = unit.roof.rect, r = unit.rect;
|
||||
const qOf = (p) => alongX ? p.x - r.cx : p.z - r.cz;
|
||||
return {
|
||||
bounds: alongX
|
||||
? { x0: Math.max(r.x0, r.cx + pl.p0), x1: Math.min(r.x1, r.cx + pl.p1), z0: r.z0, z1: r.z1 }
|
||||
: { x0: r.x0, x1: r.x1, z0: Math.max(r.z0, r.cz + pl.p0), z1: Math.min(r.z1, r.cz + pl.p1) },
|
||||
map: (p) => ({ sx: alongX ? rr.z1 - p.z : p.x - rr.x0, sy: (qOf(p) - pl.p0) / pl.cos })
|
||||
};
|
||||
}
|
||||
const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl'];
|
||||
function makeHoleCounter(units, R) {
|
||||
const boms = new Map();
|
||||
const bomOf = (piece) => {
|
||||
if (!boms.has(piece)) boms.set(piece, piece.frame ? WallModel.computeBOM(piece.config, piece.frame) : []);
|
||||
return boms.get(piece);
|
||||
};
|
||||
const between = (v, a, b) => v > Math.min(a, b) + 1 && v < Math.max(a, b) - 1;
|
||||
const joistsBetween = (cd, a, b) => cd.joists.filter(j => between(j + cd.width / 2, a, b)).length;
|
||||
return (run) => {
|
||||
if (!cableInfo(run.type).conduit) return run.type === 'buiten' && run.from.startsWith('VD') ? 1 : 0;
|
||||
let n = 0;
|
||||
run.path.slice(1).forEach((q, i) => {
|
||||
const p = run.path[i];
|
||||
const vertical = Math.abs(q.y - p.y) > Math.hypot(q.x - p.x, q.z - p.z);
|
||||
if (p.wall && q.wall && p.wall.piece === q.wall.piece && !(p.ceil && q.ceil)) {
|
||||
const f = p.wall, a = wallCoords(f, p), b = wallCoords(f, q), bom = bomOf(f.piece);
|
||||
if (vertical) {
|
||||
const x = a.u - f.W / 2;
|
||||
n += bom.filter(bp => !VERTICAL_KINDS.includes(bp.kind) && Math.abs(x - bp.x) <= bp.length / 2 && between(bp.y, a.y, b.y)).length;
|
||||
if (p.floor !== q.floor) n += 1;
|
||||
} else {
|
||||
n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length;
|
||||
}
|
||||
} else if (!vertical && p.ceil && q.ceil && !(p.plenum && q.plenum)) {
|
||||
const mid = { x: (p.x + q.x) / 2, z: (p.z + q.z) / 2 }, u = R.unitAt(mid.x, mid.z);
|
||||
if (u) u.roof.planes.forEach(pl => {
|
||||
const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds);
|
||||
if (c) n += joistsBetween(pl.data.calcData, dm.map(c[0]).sx, dm.map(c[1]).sx);
|
||||
});
|
||||
} else if (!vertical && p.floor && q.floor) {
|
||||
const u = R.unitAt((p.x + q.x) / 2, (p.z + q.z) / 2);
|
||||
const c = u && clipPlan(p, q, u.rect);
|
||||
if (c) n += joistsBetween(u.floorData.calcData, c[0].x - u.rect.x0, c[1].x - u.rect.x0);
|
||||
} else if (vertical && (p.floor || q.floor) && !(p.wall || q.wall)) n += 1;
|
||||
});
|
||||
return n;
|
||||
};
|
||||
}
|
||||
|
||||
return { DEFAULT_SETTINGS, PLATE_COLORS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D };
|
||||
})();
|
||||
|
||||
+113
-21
@@ -4,27 +4,15 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// elektra/elektra-pdf.js — ElektraPdf: elektra in de woning-PDF.
|
||||
// drawPlan : meterkast, schakelaars (letter), wcd's (groep), lamp-
|
||||
// letters en leidingen op de plattegrond (blad Plattegrond)
|
||||
// drawSheet : eigen blad met groepenverklaring, schakelaars, strekkende
|
||||
// meters per leidingtype, draad, buis en materiaal.
|
||||
// Alle gegevens komen uit ElektraModel.compute (parts.elektra).
|
||||
// Plain script, geladen na pdf-sheet.js en elektra-model.js.
|
||||
// ============================================================
|
||||
|
||||
window.ElektraPdf = (() => {
|
||||
const fmt = (n, d = 1) => Number(n).toFixed(d).replace('.', ',');
|
||||
// Kleur per leidingtype (ook de legenda).
|
||||
const RUN_COLORS = { licht: [215, 160, 0], wcd: [205, 55, 35], buiten: [40, 140, 60], schakel: [150, 95, 20] };
|
||||
const runColor = (type) => RUN_COLORS[type] || RUN_COLORS.schakel;
|
||||
|
||||
// px/py: woning-mm -> blad-mm.
|
||||
function drawPlan(doc, el, px, py) {
|
||||
if (!el || !el.enabled) return;
|
||||
const BLACK = PdfSheet.COLORS.BLACK;
|
||||
// Leidingen (gestippeld, plattegrond-projectie van het pad).
|
||||
doc.setLineWidth(0.15);
|
||||
doc.setLineDashPattern([0.8, 0.6], 0);
|
||||
el.runs.forEach(r => {
|
||||
@@ -36,7 +24,6 @@ window.ElektraPdf = (() => {
|
||||
});
|
||||
doc.setLineDashPattern([], 0);
|
||||
|
||||
// Meterkast
|
||||
const k = el.meterkast;
|
||||
if (k) {
|
||||
doc.setDrawColor(...BLACK);
|
||||
@@ -50,12 +37,10 @@ window.ElektraPdf = (() => {
|
||||
doc.text('MK', px(m.x), py(m.z) + 0.8, { align: 'center' });
|
||||
}
|
||||
|
||||
// Verdeeldozen (plafond): klein vierkantje.
|
||||
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'));
|
||||
|
||||
// Schakelaars: kadertje met de letters, iets de kamer in.
|
||||
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 };
|
||||
@@ -69,7 +54,6 @@ window.ElektraPdf = (() => {
|
||||
doc.text(text, px(q.x), py(q.z) + 0.7, { align: 'center' });
|
||||
});
|
||||
|
||||
// Wcd: halve cirkel tegen de wand + groepnummer.
|
||||
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);
|
||||
@@ -81,7 +65,6 @@ window.ElektraPdf = (() => {
|
||||
doc.text(String(w.group), px(q.x + w.face.n.x * 250), py(q.z + w.face.n.z * 250) + 0.6, { align: 'center' });
|
||||
});
|
||||
|
||||
// Apparaten: kadertje met de code (KK, WM, ...) + groepnummer.
|
||||
el.apps.forEach(a => {
|
||||
const it = el.items.find(x => x.key === a.key), q = it.marker;
|
||||
doc.setDrawColor(...RUN_COLORS.wcd);
|
||||
@@ -94,15 +77,23 @@ window.ElektraPdf = (() => {
|
||||
doc.text(String(a.group), px(q.x + a.face.n.x * 330), py(q.z + a.face.n.z * 330) + 0.5, { align: 'center' });
|
||||
});
|
||||
|
||||
// Lampletters (zelfde letter als de schakelaar).
|
||||
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)));
|
||||
}
|
||||
// Twee regels (doc.text met een array).
|
||||
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; gestippeld = leidingen (geel licht, bruin schakeldraad, rood wcd/apparaat, groen buiten).'
|
||||
'rood kadertje = apparaat (KK kookgroep, WM wasmachine, DR droger, WP warmtepomp, OV oven, VW vaatwasser) met groepnummer, V = vloer-wcd; gestippeld = leidingen (geel licht, bruin schakeldraad, rood wcd/apparaat, groen buiten).'
|
||||
];
|
||||
|
||||
function drawSheet(doc, el, sheetOpts) {
|
||||
@@ -150,5 +141,106 @@ window.ElektraPdf = (() => {
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
return { RUN_COLORS, drawPlan, drawSheet, PLAN_LEGEND };
|
||||
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 };
|
||||
})();
|
||||
|
||||
+174
-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,22 +253,44 @@ 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;
|
||||
@@ -227,8 +298,13 @@ window.FloorModel = (() => {
|
||||
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) => {
|
||||
@@ -252,18 +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, outerJoistsOnly }
|
||||
// outerJoistsOnly: alleen de buitenste balken en kopbalken (de rest is
|
||||
// ingesloten, bijv. dakbalken tussen plafondplaat en dakbedekking).
|
||||
// finish (optioneel): { key (FINISHES), originX, originZ } — afwerking als
|
||||
// dun vlak op de OSB; UV's in meters vanaf origin (m, woning-coördinaten
|
||||
// van het vloermidden), zodat het patroon over units doorloopt.
|
||||
// ----------------------------------------------------------
|
||||
// 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;
|
||||
@@ -274,12 +339,25 @@ 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, i) => {
|
||||
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);
|
||||
@@ -287,38 +365,23 @@ window.FloorModel = (() => {
|
||||
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);
|
||||
@@ -335,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;
|
||||
@@ -348,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.
|
||||
@@ -377,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'];
|
||||
@@ -401,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);
|
||||
|
||||
+41
-146
@@ -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">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,22 @@ 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();
|
||||
const toolbar = document.getElementById('floor-toolbar');
|
||||
toolbar.innerHTML = `<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${UI.tbSep()}${UI.viewSeg('split')}`;
|
||||
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||
const viewGroup = toolbar.querySelector('.tb-view');
|
||||
ViewLayers.createMenu(viewGroup, {
|
||||
layers: {}, keys: [],
|
||||
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
|
||||
onExtra: (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(); });
|
||||
Skybox.createToggle(viewGroup, (m) => { skyMode = m; applySky(); });
|
||||
|
||||
function update3DScene() {
|
||||
if (floorGroup3D) scene.remove(floorGroup3D);
|
||||
@@ -740,13 +643,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,12 +682,8 @@ 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'));
|
||||
function setView(mode) {
|
||||
UI.setView(document.getElementById('floor-app'), mode);
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
|
||||
@@ -840,8 +735,8 @@ function mountFloorEditor(container, callbacks) {
|
||||
},
|
||||
exportDesign,
|
||||
triggerImport,
|
||||
toggleFullscreen3D,
|
||||
toggleFullscreen2D,
|
||||
setView,
|
||||
viewState: () => ({ view: UI.viewOf(document.getElementById('floor-app')) }),
|
||||
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,392 @@
|
||||
/*!
|
||||
* 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.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,186 @@
|
||||
/*!
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
|
||||
function build(state, layers) {
|
||||
const M = materialsSet();
|
||||
const parts = HouseModel.computeParts(state, {
|
||||
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
|
||||
layers: Object.assign(layers, { lampen: false })
|
||||
});
|
||||
HouseModel.placeGroups(parts);
|
||||
return { parts, classify: classifier(M) };
|
||||
}
|
||||
|
||||
function generate(state) {
|
||||
const frame = build(state, ViewLayers.upTo(1));
|
||||
const sheet = build(state, ViewLayers.upTo(2));
|
||||
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 };
|
||||
})();
|
||||
+1133
-293
File diff suppressed because it is too large
Load Diff
+143
-103
@@ -4,17 +4,6 @@
|
||||
* 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 = {
|
||||
@@ -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,8 +32,8 @@ 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 }));
|
||||
// Wolfsdak: het bovenste deel (wolfseinden, nok) op een eigen blad.
|
||||
if (u.roof.unitRoof && u.roof.unitRoof.cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u });
|
||||
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' });
|
||||
@@ -78,7 +64,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);
|
||||
@@ -88,7 +73,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);
|
||||
@@ -96,16 +87,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);
|
||||
@@ -122,8 +135,17 @@ 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'));
|
||||
(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);
|
||||
@@ -131,17 +153,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');
|
||||
});
|
||||
|
||||
// Elektra: meterkast, schakelaars (letters), wcd, leidingen (ElektraPdf).
|
||||
ElektraPdf.drawPlan(doc, parts.elektra, px, py);
|
||||
|
||||
// Naden: gestippeld oranje
|
||||
doc.setDrawColor(230, 130, 30);
|
||||
doc.setLineWidth(0.4);
|
||||
doc.setLineDashPattern([1.5, 1], 0);
|
||||
parts.seams.forEach(s => doc.line(px(s.x0), py(s.z0), px(s.x1), py(s.z1)));
|
||||
doc.setLineDashPattern([], 0);
|
||||
|
||||
// Maatlijnen buiten elk buiten-wandstuk + labels binnenwanden
|
||||
doc.setDrawColor(...BLUE);
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setLineWidth(0.15);
|
||||
@@ -162,7 +181,30 @@ window.HousePdf = (() => {
|
||||
doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: Math.abs(Math.sin(p.place.rotY)) < 0.5 ? 0 : 90 });
|
||||
});
|
||||
|
||||
// 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);
|
||||
@@ -172,7 +214,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);
|
||||
@@ -182,16 +223,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 (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 = {
|
||||
@@ -205,67 +240,62 @@ 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') : WallModel.rabatHex(state.settings.facade.rabatColor);
|
||||
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
||||
// 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) : 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 });
|
||||
}
|
||||
// Openingen (glas / deur), vóór de gevel
|
||||
(p.config.openings || []).forEach(op => {
|
||||
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||
if (WallModel.openingTopMax(op) <= y0) return;
|
||||
// Schuine bovenzijde: bovenkant links/rechts.
|
||||
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y);
|
||||
const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR);
|
||||
const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
|
||||
faces.push({ pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true });
|
||||
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) => {
|
||||
// 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 });
|
||||
faces.push({ kind: 'dak', pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
|
||||
};
|
||||
// Wolfsdak: bovenste deel per vlak (RoofModel.capRegions, unit-lokaal (p, q)).
|
||||
RoofModel.capRegions(r.unitRoof).forEach(rg => {
|
||||
pushRoof(rg.poly.map(pt => {
|
||||
const x = u.rect.cx + (sh.axis === 'x' ? pt.x : pt.z), z = u.rect.cz + (sh.axis === 'x' ? pt.z : pt.x);
|
||||
const 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 };
|
||||
}));
|
||||
});
|
||||
@@ -280,11 +310,6 @@ window.HousePdf = (() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Dakrand: elk boeideel / elke daktrim zoals je hem van buiten ziet: de
|
||||
// bovenkant volgt het dak (sg.topAt, langs de ribbe bemonsterd, zodat
|
||||
// knikken van een kopgevel - zadel, mansarde, wolfsdak - meekomen), en
|
||||
// telt zowel de dakkant als de buitenkant mee (bij een hellend dak ligt
|
||||
// de buitenkant lager, de bovenkant van het boeideel dekt de gevel af).
|
||||
const edge = parts.roofEdge;
|
||||
if (edge) {
|
||||
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
|
||||
@@ -299,28 +324,42 @@ window.HousePdf = (() => {
|
||||
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
|
||||
bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z });
|
||||
}
|
||||
faces.push({ pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 });
|
||||
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, 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 : 'rabat';
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
items.push(fc.type === 'steen'
|
||||
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
|
||||
: fc.type === 'stuc'
|
||||
? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) }
|
||||
: { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) });
|
||||
}));
|
||||
});
|
||||
if (st.trasraam.enabled) {
|
||||
const tf = st.trasraam.finish;
|
||||
items.push(WallModel.isStoneFinish(tf)
|
||||
@@ -345,7 +384,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);
|
||||
}
|
||||
@@ -379,7 +417,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');
|
||||
@@ -399,11 +436,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)
|
||||
@@ -412,8 +448,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))));
|
||||
@@ -421,7 +462,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);
|
||||
@@ -434,7 +475,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);
|
||||
@@ -447,14 +487,10 @@ window.HousePdf = (() => {
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
// Blad voor het bovenste deel van een wolfsdak (RoofModel.computeCap):
|
||||
// bovenaanzicht van het houtwerk (sporen, hoekkepers, nokbalk, kopbalken)
|
||||
// op de dakvlakken, en de stuklijst.
|
||||
const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', nokbalk: 'Nokbalk', kopbalk: 'Kopbalk' };
|
||||
const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', 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;
|
||||
// (p, q) -> plattegrond (x, z), unit-lokaal mm.
|
||||
const xz = (pt) => ur.alongX ? { x: pt.x, z: pt.z } : { x: pt.z, z: pt.x };
|
||||
const 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);
|
||||
@@ -468,16 +504,14 @@ window.HousePdf = (() => {
|
||||
};
|
||||
doc.setTextColor(...BLACK);
|
||||
doc.setFontSize(11);
|
||||
doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20);
|
||||
// Dakvlakken (grijs) en de band.
|
||||
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'));
|
||||
// Balken (rood, plattegrond: rechthoek van balkbreedte rond de hartlijn).
|
||||
doc.setDrawColor(...RED);
|
||||
doc.setLineWidth(0.2);
|
||||
c.beams.forEach(bm => poly(bm.poly.map(xz), 'S'));
|
||||
// Maten: lengte en diepte van de band, diepte van een wolfseind.
|
||||
doc.setDrawColor(...BLUE);
|
||||
doc.setTextColor(...BLUE);
|
||||
doc.setFontSize(7);
|
||||
@@ -489,7 +523,6 @@ window.HousePdf = (() => {
|
||||
PdfSheet.dimLine(doc, cx0 + 8, cy0, cx1 + 8, cy1);
|
||||
doc.text(`${Math.round(bz1 - bz0)}`, cx0 + 9.5, (cy0 + cy1) / 2, { angle: 90 });
|
||||
|
||||
// Stuklijst en kerngegevens.
|
||||
const groups = new Map();
|
||||
c.beams.forEach(bm => {
|
||||
const key = `${bm.kind}|${Math.round(bm.length / 10) * 10}|${bm.angle}`;
|
||||
@@ -503,17 +536,22 @@ window.HousePdf = (() => {
|
||||
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 = [
|
||||
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((H(c.pR) - H(c.pA)) / (c.pR - c.pA))}°`],
|
||||
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'],
|
||||
['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: 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 });
|
||||
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);
|
||||
@@ -558,6 +596,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;
|
||||
@@ -570,13 +615,16 @@ window.HousePdf = (() => {
|
||||
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, cost, sheetOpts) {
|
||||
const { BLACK } = PdfSheet.COLORS;
|
||||
doc.setFont('helvetica', 'normal');
|
||||
@@ -608,19 +656,13 @@ window.HousePdf = (() => {
|
||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Kostenraming (CostModel): samenvatting + regels per element.
|
||||
// ------------------------------------------------------------
|
||||
const COST_ROW_H = 3.4;
|
||||
// Kolommen van een detailblad: x, onderkant (rechts het titelblok).
|
||||
const COST_COLS = [{ x: 15, yMax: 278 }, { x: 150, yMax: 278 }, { x: 285, yMax: 246 }];
|
||||
const COST_Y0 = 30;
|
||||
const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '\u2026' : t);
|
||||
// Platte regels voor de detailbladen: kop per element, dan de regels.
|
||||
function costDetailRows(cost) {
|
||||
return cost.elements.flatMap(el => elementCostRows(el));
|
||||
}
|
||||
// Kop + regels van één element.
|
||||
function elementCostRows(el) {
|
||||
const rows = [];
|
||||
if (el.lines.length) {
|
||||
@@ -632,14 +674,12 @@ window.HousePdf = (() => {
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
// Kostenblok op het blad van het onderdeel zelf (onder de hoofdtekening,
|
||||
// links van het titelblok). Te veel regels: rest op het detailblad.
|
||||
function drawElementCost(doc, el, extraLabel) {
|
||||
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 (incl. verlies, bezetting, indirect, QA, afschrijving)', mat: CostModel.eur(el.kostprijs), time: '' });
|
||||
rows.push({ head: true, label: 'Kostprijs', mat: CostModel.eur(el.kostprijs), time: '' });
|
||||
const fit = Math.floor((yMax - y - 5) / COST_ROW_H);
|
||||
let shown = rows;
|
||||
if (rows.length > fit) {
|
||||
@@ -647,7 +687,6 @@ window.HousePdf = (() => {
|
||||
}
|
||||
drawCostTable(doc, x, y, shown);
|
||||
}
|
||||
// Verdeelt de regels over bladen (kolommen vullen van boven naar beneden).
|
||||
function paginateCostRows(rows) {
|
||||
const pages = [];
|
||||
let i = 0;
|
||||
@@ -730,7 +769,8 @@ window.HousePdf = (() => {
|
||||
const cost = CostModel.compute(parts, totals);
|
||||
const costOf = (element) => cost.elements.find(el => el.element === element);
|
||||
const drawWallWithCost = (sheet, opts) => {
|
||||
WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts));
|
||||
WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [],
|
||||
overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts));
|
||||
drawElementCost(doc, costOf(sheet.element));
|
||||
};
|
||||
const detailPages = paginateCostRows(costDetailRows(cost));
|
||||
@@ -745,11 +785,11 @@ window.HousePdf = (() => {
|
||||
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);
|
||||
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') {
|
||||
FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true }, opts));
|
||||
// Dakkosten per unit (alle dakvlakken samen): op het eerste dakvlak.
|
||||
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);
|
||||
@@ -763,5 +803,5 @@ window.HousePdf = (() => {
|
||||
return doc;
|
||||
}
|
||||
|
||||
return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS };
|
||||
return { generate, sheetList, materialThumbUrls, viewFaces, viewProjections, VIEWS };
|
||||
})();
|
||||
|
||||
+1432
-506
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 };
|
||||
})();
|
||||
+136
-169
@@ -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; }
|
||||
if (sg.override === 'openingen') {
|
||||
// Harde override: lampen naast elke opening, dit stuk doet verder niet mee.
|
||||
blocked.push([sg.s0, sg.s1]);
|
||||
cfg.openings.forEach(op => [op.x - s.openingOffset, op.x + op.w + s.openingOffset]
|
||||
.filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
|
||||
return;
|
||||
}
|
||||
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);
|
||||
blocked.push([Math.min(a, b), Math.max(a, b)]);
|
||||
own.push([Math.min(a, b), Math.max(a, b)]);
|
||||
});
|
||||
openings.push(...own);
|
||||
if (sg.override === 'openingen') {
|
||||
blocked.push([sg.s0, sg.s1]);
|
||||
besideOpenings(cfg, s).forEach(u => pos.push(uToS(sg, u)));
|
||||
return;
|
||||
}
|
||||
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,24 +199,18 @@ 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));
|
||||
}
|
||||
|
||||
// Binnenruimte van een unit (woning-mm): de rect min de buitenwanden, en
|
||||
// waar een naadwanddeel van deze unit tegen de naad staat, tot diens
|
||||
// binnenzijde (net als bij een buitenwand). Ook gebruikt door ElektraModel.
|
||||
function unitInnerRect(u, interior) {
|
||||
const T = {};
|
||||
u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; });
|
||||
@@ -268,9 +225,6 @@ window.LightingModel = (() => {
|
||||
return { x0: r.x0 + (T.links || 0), x1: r.x1 - (T.rechts || 0), z0: r.z0 + (T.voor || 0), z1: r.z1 - (T.achter || 0) };
|
||||
}
|
||||
|
||||
// Binnenspots per unit. units = HouseModel-parts.units (rect, walls, roof.ceilingAt),
|
||||
// interior = parts.interior (binnenwanden met place). Geeft
|
||||
// [{ unitId, x, z, y, floorY }] (mm, y = onderkant plafond, floorY = loopvlak).
|
||||
function computeIndoor(settings, units, interior) {
|
||||
const s = normalizeSettings(settings);
|
||||
if (!s.indoor) return [];
|
||||
@@ -292,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 }),
|
||||
@@ -301,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();
|
||||
@@ -316,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);
|
||||
};
|
||||
@@ -329,70 +312,59 @@ 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 (wall-wash) voor de render. Echte spots mogen niet op
|
||||
// de gevel schijnen (geen schaduw: het licht zou door de muur naar binnen
|
||||
// gaan, zie buildLights3D). In plaats daarvan komt op het gevelvlak een
|
||||
// additief, zacht kegelvormig verloop: smal en fel bij de lamp, breder en
|
||||
// zwakker naar beneden (of naar boven bij een up-light). Geen licht-
|
||||
// berekening, dus geen grens aan het aantal.
|
||||
// walls = buitenwandstukken (place, buitenMateriaal, stone); groundY = mm.
|
||||
// ------------------------------------------------------------
|
||||
// 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() {
|
||||
@@ -403,9 +375,9 @@ window.LightingModel = (() => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = ctx.createImageData(W, H);
|
||||
for (let j = 0; j < H; j++) {
|
||||
const t = j / (H - 1); // 0 = bij de lamp, 1 = einde bundel
|
||||
const half = 0.12 + 0.88 * Math.pow(t, 0.6); // bundelbreedte (fractie van de halve breedte)
|
||||
const fade = Math.min(1, t * 12) * Math.pow(1 - t, 1.6); // zacht begin, uitdoven
|
||||
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);
|
||||
@@ -424,9 +396,8 @@ window.LightingModel = (() => {
|
||||
blending: THREE.AdditiveBlending, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2
|
||||
});
|
||||
const geo = new THREE.PlaneGeometry(widthM, heightM);
|
||||
// Lamp aan de bovenrand (down) resp. onderrand (up).
|
||||
geo.translate(0, up ? heightM / 2 : -heightM / 2, 0);
|
||||
if (up) { // textuur gespiegeld: lamp onderaan
|
||||
if (up) {
|
||||
const uv = geo.attributes.uv;
|
||||
for (let i = 0; i < uv.count; i++) uv.setY(i, 1 - uv.getY(i));
|
||||
}
|
||||
@@ -437,8 +408,6 @@ window.LightingModel = (() => {
|
||||
function buildWash3D(lamps, spots, spotY, walls, groundY, fo) {
|
||||
const toM = 0.001;
|
||||
const group = new THREE.Group();
|
||||
// Gevelvlak achter een punt (mm) met buitenrichting n: dichtstbijzijnde
|
||||
// wandstuk met dezelfde normaal waar het punt vóór ligt.
|
||||
const facadeBehind = (q, n) => {
|
||||
let best = null;
|
||||
walls.forEach(p => {
|
||||
@@ -447,21 +416,21 @@ window.LightingModel = (() => {
|
||||
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);
|
||||
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) => {
|
||||
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);
|
||||
};
|
||||
// Boeideel-spots: van de spot omlaag tot het maaiveld (op een hoek,
|
||||
// diagonale normaal, geen vlek).
|
||||
(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 };
|
||||
@@ -470,9 +439,8 @@ window.LightingModel = (() => {
|
||||
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);
|
||||
place(washMesh(2 * (0.1 + H * WASH.spread), H, WASH.spot * fo), hit.piece.place, hit.lx, hit.face + 6, top, sp);
|
||||
});
|
||||
// Gevellampen (op de gevel): bundel omlaag en/of omhoog.
|
||||
(lamps || []).forEach(l => {
|
||||
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);
|
||||
@@ -480,17 +448,16 @@ window.LightingModel = (() => {
|
||||
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);
|
||||
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') {
|
||||
// Niet boven de gevel uit (tot de bovenkant van het dak).
|
||||
const wallTop = pl.floorTop + WallModel.topLine(hit.piece.config).maxY + (hit.piece.skinAbove || 0);
|
||||
const H = Math.min(WASH.lampReach, wallTop - l.y - 90) * toM;
|
||||
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo, true), pl, hit.lx, hit.face + 6, l.y + 90);
|
||||
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo, true), pl, hit.lx, hit.face + 6, l.y + 90, l);
|
||||
}
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D };
|
||||
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, unitInnerRect, freeIntervals, createMaterials, build3D, lightSources, buildLights3D };
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*!
|
||||
* 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.
|
||||
|
||||
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 };
|
||||
|
||||
function normalizeSettings(settings) {
|
||||
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
||||
s.enabled = s.enabled !== false;
|
||||
if (!WOODS[s.wood]) s.wood = DEFAULT_SETTINGS.wood;
|
||||
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).
|
||||
function compute(settings, units, groundY) {
|
||||
const s = normalizeSettings(settings);
|
||||
const out = { posts: [], count: 0, size: s.size, wood: s.wood, lengthMM: 0, m3: 0, kg: 0 };
|
||||
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 = [];
|
||||
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); };
|
||||
const fa = endFree(r.a), fb = endFree(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;
|
||||
const roof = units.map(u => u.roof).find(rf => p.x > rf.rect.x0 - EPS && p.x < rf.rect.x1 + EPS && p.z > rf.rect.z0 - EPS && p.z < rf.rect.z1 + EPS);
|
||||
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;
|
||||
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) 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);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
return { WOODS, SHOE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
|
||||
})();
|
||||
+559
-332
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;
|
||||
|
||||
+100
-182
@@ -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 = {
|
||||
const spans = (tE) => state.layout.corners === 'zijkanten' ? {
|
||||
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 + 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 + tEff('voor', -W / 2), D / 2 - tEff('achter', -W / 2)],
|
||||
rechts: [-D / 2 + tEff('voor', W / 2), D / 2 - tEff('achter', W / 2)]
|
||||
links: [-D / 2 + tE('voor', -W / 2), D / 2 - tE('achter', -W / 2)],
|
||||
rechts: [-D / 2 + 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,51 +107,82 @@ 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;
|
||||
// Tussenpunten van de daklijn (nok, knikken; WallModel.wallTopPoints);
|
||||
// een stuk krijgt de punten die erin vallen, verder de daklijn ter
|
||||
// plaatse als gewone helling.
|
||||
const tops = WallModel.wallTopPoints(wallState.wall, W, hL, hR);
|
||||
const { hL, hR, tops } = topLineOn(wallLayout, wallState.wall);
|
||||
const tp = [{ x: 0, h: hL }, ...tops, { x: W, h: hR }];
|
||||
const heightAt = (u) => {
|
||||
let i = 0;
|
||||
@@ -213,17 +200,14 @@ window.UnitModel = (() => {
|
||||
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, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; });
|
||||
.map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, 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))
|
||||
}, 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
|
||||
@@ -239,16 +223,14 @@ window.UnitModel = (() => {
|
||||
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, fill: op.fill, w: op.w, h: op.h, hR: op.hR, 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)
|
||||
}, 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
|
||||
@@ -260,17 +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. nok en knikken;
|
||||
// - de twee haakse wanden worden vlak op de hoogte van het dak boven
|
||||
// hun buitenkant (goothoogte bij een lessenaar- of zadeldak). Staat
|
||||
// de kopgevel tussen de haakse wanden in, dan loopt zijn daklijn
|
||||
// daarvoor door tot de buitenkant van de vloer (gableSpan).
|
||||
// Voorbeeld: west zuid hoger dan noord -> oost ook, noordwand vlak op de
|
||||
// lage, zuidwand vlak op de hoge hoogte. Muteert state.
|
||||
// Welke haakse wand zit aan de linker/rechterkant (van buitenaf gezien).
|
||||
const WALL_ENDS = {
|
||||
voor: { left: 'rechts', right: 'links' },
|
||||
achter: { left: 'links', right: 'rechts' },
|
||||
@@ -278,9 +249,6 @@ window.UnitModel = (() => {
|
||||
rechts: { left: 'achter', right: 'voor' }
|
||||
};
|
||||
const OPPOSITE = { voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' };
|
||||
// Plaats van een wand t.o.v. de volle vloermaat langs zijn as (van
|
||||
// buitenaf gezien): e0/e1 = afstand van de vloerrand tot het linker-/
|
||||
// rechteruiteinde, w = wandlengte, span = vloermaat (= e0 + w + e1).
|
||||
function gableSpan(state, side) {
|
||||
const layout = computeLayout(state, {});
|
||||
const wl = layout.walls.find(x => x.side === side);
|
||||
@@ -291,8 +259,6 @@ window.UnitModel = (() => {
|
||||
const e0 = Math.max(0, Math.abs(left + s.dir * span / 2)), e1 = Math.max(0, Math.abs(s.dir * span / 2 - right));
|
||||
return { e0, e1, w: wl.width, span, layout };
|
||||
}
|
||||
// Daklijn van een wand over de volle vloermaat: [{ u, h }] met u vanaf de
|
||||
// linker vloerrand; de eerste en laatste helling lopen door tot de randen.
|
||||
function fullTopLine(wall, g) {
|
||||
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 })))
|
||||
@@ -302,9 +268,6 @@ window.UnitModel = (() => {
|
||||
pts[n - 1] = { u: g.span, h: pts[n - 1].h + g.e1 * s1 };
|
||||
return pts;
|
||||
}
|
||||
// Omgekeerd: een daklijn over de volle vloermaat ([{ u, h }]) op de wand
|
||||
// zetten (hoogtes op de uiteinden, knikken als topPts; één knik in het
|
||||
// midden als heightMid).
|
||||
function setWallTopLine(wall, g, line) {
|
||||
const at = (u) => {
|
||||
let i = 0;
|
||||
@@ -322,10 +285,7 @@ window.UnitModel = (() => {
|
||||
const w = state.walls[side].wall;
|
||||
const mid = w.heightMid > 0 ? w.heightMid : null;
|
||||
const pts = Array.isArray(w.topPts) && w.topPts.length ? w.topPts : null;
|
||||
// Haakse wanden: op de onderkant van het dak boven hun buiten- of
|
||||
// binnenkant, de laagste van de twee (zo steekt de wand nooit door het
|
||||
// dak; de wig tussen bovenregel en dak vult een schuine regel op, zie
|
||||
// HouseModel / WallModel topWedge).
|
||||
// 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);
|
||||
@@ -337,47 +297,17 @@ window.UnitModel = (() => {
|
||||
if (tp) t.topPts = tp; else delete t.topPts;
|
||||
t.heightAll = hM === null && !tp && hL === hR ? hL : w.heightAll;
|
||||
};
|
||||
// Gespiegeld (van buitenaf gezien): knikken van rechts naar links.
|
||||
set(OPPOSITE[side], w.heightRight, w.heightLeft, mid, pts && pts.map(q => ({ f: 1 - q.f, h: q.h })).reverse());
|
||||
set(WALL_ENDS[side].left, edgeL, edgeL, null);
|
||||
set(WALL_ENDS[side].right, edgeR, edgeR, null);
|
||||
}
|
||||
// Hoogste punt van een wand (incl. nok en knikken).
|
||||
function wallTopOf(wallState) {
|
||||
const w = wallState.wall;
|
||||
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Dakvorm als invoer. Het dak volgt de wanden (RoofModel.computeShape);
|
||||
// hier de omgekeerde weg: één dakvorm per unit, de wanden volgen
|
||||
// (via syncWallHeights op de kopgevel).
|
||||
// { type: 'plat', low } hoogte
|
||||
// { type: 'lessenaar', dir, low, high } dir = hoge zijde (voor/achter/links/rechts)
|
||||
// { type: 'zadel', ridge, low, high } ridge = richting nok: 'z' (noord–zuid) of 'x' (west–oost)
|
||||
// { type: 'mansarde', ridge, low, high, kneeH, kneeIn, kneeRatio }
|
||||
// steil ondervlak van de goot tot de knik (kneeH, kneeIn = afstand
|
||||
// van de knik tot de gevel), daarboven flauw tot de nok
|
||||
// { type: 'wolf', ridge, low, high, cut, cutRatio }
|
||||
// zadeldak waarvan de kopgevels boven `cut` zijn afgeschuind
|
||||
// (wolfseinden, dakhelling tot de nok; rechte nok ertussen)
|
||||
// kneeRatio / cutRatio: plaats van de knik resp. het wolfseind tussen goot
|
||||
// (0) en nok (1). Gaat voor kneeH / cut, zodat ze meeschalen als goot of
|
||||
// nok verandert (roofForm geeft ze altijd terug).
|
||||
// low = goothoogte, high = hoge goot resp. nok (mm vanaf het loopvlak),
|
||||
// over de volle vloermaat: de goot ligt boven de buitenkant van de
|
||||
// gevel, de nok in het midden (ook als de kopgevel tussen de haakse
|
||||
// wanden staat; fullTopLine). kneeIn = afstand van de vloerrand.
|
||||
// Mansarde en wolfsdak: de kopgevel krijgt een daklijn met knikken
|
||||
// (wall.topPts, zie WallModel.wallTopPoints).
|
||||
// ------------------------------------------------------------
|
||||
// Grenzen van onze woningen (mm, vanaf het loopvlak; maat van de vloer):
|
||||
// nok (hoogste punt onderkant dak) max 5 m, goot / plat dak max 3 m, een
|
||||
// unit max 4,3 x 14 m (de korte zijde max 4,3 m).
|
||||
const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 };
|
||||
// Vloermaat binnen de grenzen (muteert floor = state.floor.floor). keep:
|
||||
// 'width' of 'depth' = de zojuist gewijzigde maat; bij twee lange zijden
|
||||
// wordt die geklemd (anders de kortste).
|
||||
// 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);
|
||||
@@ -387,16 +317,14 @@ window.UnitModel = (() => {
|
||||
}
|
||||
return floor;
|
||||
}
|
||||
const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' };
|
||||
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 === 'mansarde' || type === 'wolf';
|
||||
// Kopgevel (wand met de schuine daklijn) bij een dakvorm.
|
||||
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';
|
||||
}
|
||||
// Huidige dakvorm, afgelezen uit de wanden (zelfde volgorde als computeShape).
|
||||
function roofForm(unitState) {
|
||||
const W = unitState.walls;
|
||||
for (const side of ['achter', 'voor', 'links', 'rechts']) {
|
||||
@@ -405,10 +333,8 @@ window.UnitModel = (() => {
|
||||
const full = fullTopLine(w, gableSpan(unitState, side));
|
||||
const F = full[full.length - 1].u, inner = full.slice(1, -1);
|
||||
const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h));
|
||||
if (inner.length === 3) {
|
||||
const high = Math.round(inner[1].h);
|
||||
return { type: 'mansarde', ridge, low, high, kneeH: Math.round(inner[0].h), kneeIn: Math.round(inner[0].u), kneeRatio: (inner[0].h - low) / Math.max(1, high - low) };
|
||||
}
|
||||
// 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);
|
||||
@@ -423,32 +349,26 @@ window.UnitModel = (() => {
|
||||
const h = Math.max(...Object.values(W).map(wallTopOf));
|
||||
return { type: 'plat', low: h, high: h };
|
||||
}
|
||||
// Dakvorm toepassen: de kopgevel krijgt de daklijn (over de volle
|
||||
// vloermaat bepaald, zie fullTopLine), syncWallHeights doet de rest.
|
||||
// Zonder hoogteverschil wordt het een plat dak. Muteert state.
|
||||
function 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 side = sloped ? gableOf(form) : 'achter';
|
||||
const g = gableSpan(unitState, side), F = g.span, half = F / 2;
|
||||
const half = F / 2;
|
||||
let line = [{ u: 0, h: low }, { u: F, h: low }];
|
||||
if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
||||
else if (sloped && form.type === 'mansarde') {
|
||||
// Knik binnen de halve gevel; altijd boven de rechte lijn goot–nok
|
||||
// (anders geen mansarde maar een hol dak).
|
||||
// (en op de kopgevel zelf, ook als die tussen de haakse wanden staat)
|
||||
const kIn = Math.max(Math.max(g.e0, g.e1) + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18)));
|
||||
const minK = low + (high - low) * kIn / half + 20;
|
||||
const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH;
|
||||
const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK)));
|
||||
line = kH > minK - 1 && high - kH >= 20
|
||||
? [{ u: 0, h: low }, { u: kIn, h: kH }, { u: half, h: high }, { u: F - kIn, h: kH }, { u: F, h: low }]
|
||||
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
||||
} else if (sloped && form.type === 'wolf') {
|
||||
// Wolfseind: de kopgevel stopt op `cut`, waar de dakhelling die hoogte haalt.
|
||||
// Op de kopgevel zelf (ook als die tussen de haakse wanden staat).
|
||||
const minR = (Math.max(g.e0, g.e1) + 50) / half;
|
||||
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;
|
||||
@@ -456,11 +376,9 @@ window.UnitModel = (() => {
|
||||
? [{ u: 0, h: low }, { u: uc, h: cut }, { u: F - uc, h: cut }, { u: F, h: low }]
|
||||
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
||||
} else if (sloped) {
|
||||
const highLeft = WALL_ENDS[side].left === form.dir;
|
||||
line = [{ u: 0, h: highLeft ? high : low }, { u: F, h: highLeft ? low : high }];
|
||||
line = [{ u: 0, h: highAtStart ? high : low }, { u: F, h: highAtStart ? low : high }];
|
||||
}
|
||||
setWallTopLine(unitState.walls[side].wall, g, line);
|
||||
syncWallHeights(unitState, side);
|
||||
return line;
|
||||
}
|
||||
|
||||
function validateState(design) {
|
||||
@@ -476,5 +394,5 @@ window.UnitModel = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, validateState };
|
||||
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, dropMansarde, applyRoofForm, formLine, validateState };
|
||||
})();
|
||||
|
||||
+242
-94
@@ -4,24 +4,9 @@
|
||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||
* Zie LICENTIE.md.
|
||||
*/
|
||||
// ============================================================
|
||||
// wall/kozijn-model.js — Vulling van een opening (window.KozijnModel):
|
||||
// kunststof kozijnen naar het voorbeeld van de Aluplast Ideal 7000
|
||||
// (examples/Buiten kozijnen.pdf): kader 70 mm in het aanzicht, 120 mm
|
||||
// diep, buitenzijde in een andere kleur dan de binnenzijde (folie).
|
||||
//
|
||||
// Een opening krijgt een (optionele) vulling, opgeslagen bij de opening:
|
||||
// op.fill = { type: 'raam'|'deur', rooster, glas, out, in }
|
||||
// raam vast glas in het kader; rooster = ventilatierooster bovenin
|
||||
// deur deurblad (draait links) met dorpel; is de opening breder of
|
||||
// hoger dan één deur, dan vult vast glas de rest (zijlicht,
|
||||
// bovenlicht). glas = glasstrook in het deurblad
|
||||
// out/in kleur buiten/binnen (COLORS)
|
||||
// Zonder fill blijft de opening open (zoals voorheen). Het formaat volgt
|
||||
// altijd de opening (ook een schuine bovenzijde).
|
||||
//
|
||||
// Plain script (geen ES-module), geen DOM. Gebruikt door WallModel.build3D.
|
||||
// ============================================================
|
||||
// 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 = {
|
||||
@@ -30,47 +15,54 @@ window.KozijnModel = (() => {
|
||||
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' };
|
||||
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';
|
||||
|
||||
// Maten (mm).
|
||||
const P = {
|
||||
gap: 5, // stelruimte rondom
|
||||
kader: 70, // kader in het aanzicht
|
||||
depth: 120, // kaderdiepte
|
||||
dorpel: 30, // deurdorpel (hoogte)
|
||||
gap: 5,
|
||||
kader: 70,
|
||||
depth: 120,
|
||||
dorpel: 30,
|
||||
tussen: 70, // tussenstijl/-regel (deur naast zijlicht, onder bovenlicht)
|
||||
rooster: 80, // ventilatierooster (hoogte)
|
||||
glass: 24, // glasdikte
|
||||
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, // deurbladdikte
|
||||
minLight: 250 // kleinste zijlicht/bovenlicht
|
||||
leafT: 68,
|
||||
minLight: 200
|
||||
};
|
||||
|
||||
const colorKey = (k, def) => COLORS[k] ? k : def;
|
||||
// Volledige vulling (defaults ingevuld) of null (geen vulling).
|
||||
const pick = (v, opts, def) => opts[v] ? v : def;
|
||||
function normalize(fill) {
|
||||
if (!fill || !FILL_TYPES[fill.type] || fill.type === 'geen') return null;
|
||||
return {
|
||||
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;
|
||||
}
|
||||
// Standaardvulling bij een openingtype (WallModel.OPENING_TYPES); kleuren
|
||||
// van `like` (bijv. de vorige opening) overnemen.
|
||||
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 };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 2D-veelhoeken (mm, tegen de klok in, x vanaf de linkerkant van de
|
||||
// opening, y vanaf de onderkant).
|
||||
// ------------------------------------------------------------
|
||||
// Convexe veelhoek naar binnen verschoven, per zijde (zijde i = punt i ->
|
||||
// punt i+1) een eigen afstand d[i].
|
||||
function insetPoly(pts, d) {
|
||||
const n = pts.length;
|
||||
const lines = pts.map((p, i) => {
|
||||
@@ -87,8 +79,6 @@ window.KozijnModel = (() => {
|
||||
return { x: a.x + a.dx * t, y: a.y + a.dy * t };
|
||||
});
|
||||
}
|
||||
// Veelhoek afgekapt op een halfvlak (Sutherland-Hodgman): f(p) >= 0
|
||||
// blijft, f is lineair.
|
||||
function clipPoly(pts, f) {
|
||||
const out = [];
|
||||
pts.forEach((p, i) => {
|
||||
@@ -102,12 +92,14 @@ window.KozijnModel = (() => {
|
||||
});
|
||||
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;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 3D
|
||||
// ------------------------------------------------------------
|
||||
const mats = {};
|
||||
function colorMat(key) {
|
||||
const k = 'c:' + colorKey(key, DEFAULT_OUT);
|
||||
@@ -117,7 +109,7 @@ window.KozijnModel = (() => {
|
||||
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 }); // beslag
|
||||
else mats[key] = new THREE.MeshStandardMaterial({ color: 0x9a9da0, roughness: 0.3, metalness: 0.7 });
|
||||
return mats[key];
|
||||
}
|
||||
|
||||
@@ -127,8 +119,6 @@ window.KozijnModel = (() => {
|
||||
(holes || []).forEach(h => s.holes.push(new THREE.Path(h.map(p => new THREE.Vector2(p.x * toM, p.y * toM)))));
|
||||
return s;
|
||||
};
|
||||
// Plaat van z0 tot z1 (mm); buitenvlak (z1) in matOut als dat afwijkt
|
||||
// (folie aan de buitenzijde, de rest in de binnenkleur).
|
||||
function slab(g, pts, holes, z0, z1, matIn, matOut, shadow) {
|
||||
if (pts.length < 3 || areaOf(pts) < 1) return;
|
||||
const FOIL = matOut && matOut !== matIn ? 1 : 0;
|
||||
@@ -149,10 +139,12 @@ window.KozijnModel = (() => {
|
||||
g.add(mesh);
|
||||
};
|
||||
|
||||
// Kozijn in een opening (opgelost: w, h links, hR rechts). Geeft een
|
||||
// groep in mm-lokale coördinaten van de opening (x vanaf links, y vanaf
|
||||
// de onderkant, +z = buiten) in meters, of null.
|
||||
// place: { zOut (mm, buitenvlak kozijn), depth (mm), exterior (dorpel) }
|
||||
// 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;
|
||||
@@ -161,24 +153,18 @@ window.KozijnModel = (() => {
|
||||
const depth = Math.min(P.depth, place.depth || P.depth);
|
||||
const z1 = place.zOut, z0 = z1 - depth;
|
||||
const hR = op.hR !== undefined ? op.hR : op.h;
|
||||
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, P.gap, P.gap, P.gap]);
|
||||
// Bovenkant (schuin) van een veelhoek binnen het kader: >= 0 onder de
|
||||
// lijn door TL-TR van `q` (lineair, voor clipPoly).
|
||||
const below = (q) => (p) => {
|
||||
const a = q[3], b = q[2];
|
||||
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
|
||||
};
|
||||
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, P.kader, P.kader, P.kader]);
|
||||
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);
|
||||
// Lamellen: donkere sleuven op beide vlakken.
|
||||
[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]];
|
||||
@@ -190,64 +176,226 @@ window.KozijnModel = (() => {
|
||||
return g;
|
||||
}
|
||||
|
||||
// Deur: kader als U (zonder onderregel) op een dorpel (buitenwand) of
|
||||
// tot de vloer (binnenwand).
|
||||
const sill = place.exterior !== false ? P.dorpel : 0;
|
||||
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
|
||||
const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]);
|
||||
const u = [frameOuter[0], inner[0], inner[3], inner[2], inner[1], frameOuter[1], frameOuter[2], frameOuter[3]];
|
||||
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);
|
||||
// Zijlicht rechts als er naast een deurblad nog genoeg ruimte is.
|
||||
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
|
||||
const lx1 = side ? ix0 + P.leafW : ix1;
|
||||
// Bovenlicht als er boven een deurblad nog genoeg ruimte is (overal).
|
||||
const lowTop = Math.min(topAt(ix0), topAt(ix1));
|
||||
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
|
||||
const ly1 = transom ? iy0 + P.leafH : Infinity;
|
||||
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) {
|
||||
bar(region(lx1, lx1 + P.tussen, iy0, ly1));
|
||||
// Zijlicht: onderregel + glas.
|
||||
bar(region(lx1 + P.tussen, ix1, iy0, iy0 + P.kader));
|
||||
glass(region(lx1 + P.tussen, ix1, iy0 + P.kader, ly1));
|
||||
const [t0, g0, g1] = leafR ? [la - P.tussen, ix0, la - P.tussen] : [lb, lb + P.tussen, ix1];
|
||||
bar(region(t0, t0 + P.tussen, iy0, ly1));
|
||||
bar(region(g0, g1, iy0, iy0 + P.kader));
|
||||
glass(region(g0, g1, iy0 + P.kader, ly1));
|
||||
}
|
||||
if (transom) {
|
||||
bar(region(ix0, ix1, ly1, ly1 + P.tussen));
|
||||
glass(region(ix0, ix1, ly1 + P.tussen, Infinity));
|
||||
}
|
||||
|
||||
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer).
|
||||
const leaf = clipPoly(rect(ix0 + 3, lx1 - 3, iy0 + (sill ? 3 : 10), Math.min(ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 }));
|
||||
if (leaf.length < 3) return g;
|
||||
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
|
||||
const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), ly1) - 3;
|
||||
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;
|
||||
let holes = null;
|
||||
const lw = lxR - lx0;
|
||||
if (f.glas && lw >= 500 && lt - lb >= 1400) {
|
||||
const gx = lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
|
||||
const strip = rect(gx - gw / 2, gx + gw / 2, lb + 350, lt - 300);
|
||||
holes = [strip];
|
||||
slab(g, strip, null, (lzIn + lzOut) / 2 - P.glass / 2, (lzIn + lzOut) / 2 + P.glass / 2, mat('glass'), null, false);
|
||||
}
|
||||
slab(g, leaf, holes, lzIn, lzOut, mIn, mOut);
|
||||
// Kruk aan beide zijden (scharnieren links, slot rechts).
|
||||
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 hx = lxR - 75, hy = Math.min(lb + 1050, (lb + lt) / 2);
|
||||
const kx = hingeR ? lx0 + 75 : lxR - 75, gd = hingeR ? 60 : -60;
|
||||
[[lzOut, 1], [lzIn, -1]].forEach(([z, s]) => {
|
||||
box(g, mat('beslag'), hx, hy - 30, z + s * 5, 34, 210, 10); // schild
|
||||
box(g, mat('beslag'), hx, hy, z + s * 30, 20, 20, 50); // nek
|
||||
box(g, mat('beslag'), hx - 60, hy, z + s * 55, 140, 20, 20); // greep
|
||||
box(lg, mat('beslag'), kx, 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;
|
||||
}
|
||||
|
||||
return { COLORS, FILL_TYPES, PROFILE: P, normalize, defaultFill, build3D };
|
||||
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 };
|
||||
})();
|
||||
|
||||
+339
-540
File diff suppressed because it is too large
Load Diff
+5
-38
@@ -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,13 @@ window.WallPdf = (() => {
|
||||
doc.setLineWidth(0.2);
|
||||
CONFIG.openings.forEach(op => {
|
||||
const opXWorld = -wallWidth / 2 + op.x;
|
||||
// Schuine bovenzijde: y0 = bovenkant links, y0r = rechts.
|
||||
const x0 = px(opXWorld), y0 = py(op.y + op.h), y0r = py(op.y + op.hR), x1 = px(opXWorld + op.w), y1 = py(op.y);
|
||||
[[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, 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.
|
||||
// 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' });
|
||||
});
|
||||
|
||||
@@ -180,8 +154,6 @@ window.WallPdf = (() => {
|
||||
doc.text(String(Math.round(hLeft)), px(-wallWidth / 2) - 9, (py(0) + py(hLeft)) / 2, { align: 'center', angle: 90 });
|
||||
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 });
|
||||
// Tussenpunten van de daklijn: stippellijn van vloer tot nok/knik
|
||||
// (nok = het enige hoogste punt; mansarde-/wolfsgevel: knikken).
|
||||
const inner = tl.pts.slice(1, -1);
|
||||
inner.forEach(q => {
|
||||
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
|
||||
@@ -219,7 +191,6 @@ window.WallPdf = (() => {
|
||||
doc.text(String(Math.round(openingPoints[i + 1] - openingPoints[i])), px(mid - wallWidth / 2), openY, { align: 'center' });
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
@@ -231,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;
|
||||
@@ -242,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;
|
||||
@@ -255,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))];
|
||||
@@ -284,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;
|
||||
|
||||
+89
-197
@@ -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">
|
||||
@@ -38,9 +26,17 @@ const WALL_TEMPLATE = `
|
||||
<canvas id="canvas-2d-side"></canvas>
|
||||
</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">Annuleren</button>
|
||||
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,24 +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;
|
||||
// Alles op het tabblad Materiaal ligt dan vast: tabblad weg, uitleg op Opbouw.
|
||||
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);
|
||||
@@ -269,75 +250,44 @@ 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;
|
||||
// Daklijn met knikken (mansarde/wolfsdak, uit de unit-dakvorm): blijft
|
||||
// staan tot een hoogte hier met de hand wordt gewijzigd.
|
||||
let wallTopPts = null;
|
||||
|
||||
// Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset, fill? }
|
||||
// fill = vulling (kozijn, KozijnModel); zonder fill blijft hij open.
|
||||
// hR = hoogte rechts (schuine bovenzijde); zonder hR is de bovenkant
|
||||
// recht en is h de hoogte. De onderkant is altijd waterpas.
|
||||
// 'offset' betekent afhankelijk van anchor:
|
||||
// left -> afstand vanaf linker wandrand tot linkerkant opening
|
||||
// right -> afstand vanaf rechter wandrand tot rechterkant opening
|
||||
// middle -> verschuiving t.o.v. het midden (0 = gecentreerd, + = naar rechts)
|
||||
// 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, 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. 9 sleeppunten: 4 hoeken, 4 zijden en het midden
|
||||
// (verplaatsen). N/Z wijzigen de hoogte, O/W de breedte. De bovenste
|
||||
// hoeken (NW/NO) verslepen ook alleen hun eigen kant in de hoogte: zo
|
||||
// ontstaat een schuine bovenzijde. Onderin blijft het altijd waterpas
|
||||
// (ZW/ZO: breedte + onderkant).
|
||||
// ------------------------------------------------------------
|
||||
let selectedOpeningId = null;
|
||||
let dragInfo = null; // { type:'move'|'resize', handle, openingId, startMouseX, startMouseY, startLocalX, startY, startW, startH, startHR }
|
||||
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;
|
||||
// Ruwe opening zoals opgeslagen (hR alleen bij een schuine bovenzijde).
|
||||
const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset },
|
||||
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;
|
||||
// Schuin aan/uit veranderd (hoek slepen): kaart opnieuw opbouwen.
|
||||
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}"]`);
|
||||
@@ -362,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';
|
||||
@@ -388,13 +335,8 @@ function mountWallEditor(container, callbacks) {
|
||||
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'));
|
||||
function setView(mode) {
|
||||
UI.setView(document.getElementById('app-container'), mode);
|
||||
requestAnimationFrame(onWindowResize);
|
||||
}
|
||||
|
||||
@@ -433,16 +375,7 @@ 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');
|
||||
@@ -452,7 +385,6 @@ function mountWallEditor(container, callbacks) {
|
||||
if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse();
|
||||
|
||||
openings = openings.map(o => {
|
||||
// Schuine bovenzijde klapt mee om.
|
||||
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' };
|
||||
@@ -465,7 +397,6 @@ function mountWallEditor(container, callbacks) {
|
||||
setIoStatus('Horizontaal gespiegeld.', 'ok');
|
||||
}
|
||||
|
||||
// Vulling (kozijn, KozijnModel): type, rooster/glasstrook en kleuren.
|
||||
function fillControls(op) {
|
||||
const f = KozijnModel.normalize(op.fill);
|
||||
const type = f ? f.type : 'geen';
|
||||
@@ -475,6 +406,10 @@ function mountWallEditor(container, callbacks) {
|
||||
<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">
|
||||
@@ -483,6 +418,15 @@ function mountWallEditor(container, callbacks) {
|
||||
</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">
|
||||
@@ -496,7 +440,6 @@ function mountWallEditor(container, callbacks) {
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
// Kleuren van de laatste opening met een vulling (voor nieuwe openingen).
|
||||
const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; };
|
||||
|
||||
function renderOpeningsList() {
|
||||
@@ -553,6 +496,10 @@ function mountWallEditor(container, callbacks) {
|
||||
<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>
|
||||
@@ -563,8 +510,6 @@ function mountWallEditor(container, callbacks) {
|
||||
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')) {
|
||||
@@ -575,8 +520,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;
|
||||
@@ -584,7 +527,6 @@ function mountWallEditor(container, callbacks) {
|
||||
const op = openings.find(o => o.id === id);
|
||||
if (!op) return;
|
||||
if (field === 'type') {
|
||||
// Type kiezen = standaardmaat en -vulling van dat type overnemen (daarna vrij aan te passen).
|
||||
const preset = WallModel.OPENING_TYPES[e.target.value];
|
||||
op.type = e.target.value;
|
||||
op.w = preset.w; op.h = preset.h; op.y = preset.y;
|
||||
@@ -594,18 +536,22 @@ function mountWallEditor(container, callbacks) {
|
||||
} else if (field === 'fill') {
|
||||
const v = e.target.value;
|
||||
if (v === 'geen') delete op.fill;
|
||||
else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v });
|
||||
else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' || v === 'schuifdeur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v });
|
||||
renderOpeningsList();
|
||||
} else if (/^fill(Rooster|Glas|Out|In)$/.test(field)) {
|
||||
} else if (/^fill(Rooster|Glas|Volglas|Out|In|Hinge|Leaf|Swing|Leaves|Mount|Side|Dir)$/.test(field)) {
|
||||
if (!op.fill) return;
|
||||
const key = { fillRooster: 'rooster', fillGlas: 'glas', fillOut: 'out', fillIn: 'in' }[field];
|
||||
op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
||||
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;
|
||||
}
|
||||
@@ -620,9 +566,6 @@ function mountWallEditor(container, callbacks) {
|
||||
});
|
||||
document.getElementById('btn-add-opening').addEventListener('click', addOpening);
|
||||
|
||||
// Bovenkant evenwijdig aan de daklijn: hoogte rechts = hoogte links +
|
||||
// het verschil in daklijn tussen de rechter- en linkerrand (op de
|
||||
// opgeloste positie; met een nok ertussen het rechte stuk ertussen).
|
||||
function followRoof(id) {
|
||||
const op = openings.find(o => o.id === id);
|
||||
const res = (CONFIG.openings || []).find(o => o.id === id);
|
||||
@@ -633,10 +576,6 @@ function mountWallEditor(container, callbacks) {
|
||||
updateAll();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// JSON EXPORT / IMPORT — sla het volledige wand-ontwerp (parameters +
|
||||
// ruwe, niet-opgeloste openingen) op als JSON, en laad het weer terug.
|
||||
// ============================================================
|
||||
function setIoStatus(msg, cls) {
|
||||
const el = document.getElementById('io-status');
|
||||
el.textContent = msg;
|
||||
@@ -651,7 +590,6 @@ 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),
|
||||
@@ -667,7 +605,7 @@ function mountWallEditor(container, callbacks) {
|
||||
function getPureState() {
|
||||
return {
|
||||
wall: collectWallParams(),
|
||||
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, rabatColor },
|
||||
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor },
|
||||
openings: openings.map(rawOpening)
|
||||
};
|
||||
}
|
||||
@@ -692,9 +630,10 @@ function mountWallEditor(container, callbacks) {
|
||||
|
||||
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)
|
||||
rabatColor = (state.materials && state.materials.rabatColor) || null; // woning-instelling (rabat)
|
||||
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;
|
||||
syncMaterialButtons();
|
||||
|
||||
openings = state.openings.map(rawOpening);
|
||||
@@ -705,10 +644,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();
|
||||
@@ -784,11 +719,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');
|
||||
@@ -803,12 +733,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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -842,7 +769,6 @@ function mountWallEditor(container, callbacks) {
|
||||
document.getElementById('lbl-wall-h-left').textContent = peakOn ? 'Goothoogte links' : 'Hoogte links';
|
||||
document.getElementById('lbl-wall-h-right').textContent = peakOn ? 'Goothoogte rechts' : 'Hoogte rechts';
|
||||
|
||||
// Afronding/fallbacks + ankerresolutie van de openingen: WallModel.
|
||||
CONFIG = WallModel.buildConfig({
|
||||
width: wallWidth,
|
||||
heightLeft: hLeft,
|
||||
@@ -868,7 +794,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');
|
||||
|
||||
@@ -916,8 +841,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)));
|
||||
|
||||
@@ -941,7 +864,6 @@ function mountWallEditor(container, callbacks) {
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2D CANVAS RENDERER: VOORAANZICHT (ZUID) ---
|
||||
const canvas2DFront = document.getElementById('canvas-2d-front');
|
||||
const ctxFront = canvas2DFront.getContext('2d');
|
||||
|
||||
@@ -975,9 +897,6 @@ function mountWallEditor(container, callbacks) {
|
||||
for (let x = 0; x < w; x += gridSize) { ctxFront.beginPath(); ctxFront.moveTo(x, 0); ctxFront.lineTo(x, h); ctxFront.stroke(); }
|
||||
for (let 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) {
|
||||
@@ -990,7 +909,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;
|
||||
@@ -1005,7 +923,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();
|
||||
@@ -1015,9 +932,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;
|
||||
@@ -1034,8 +948,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));
|
||||
@@ -1047,7 +959,6 @@ 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);
|
||||
// Tussenpunten van de daklijn (nok = het enige hoogste punt, anders knik).
|
||||
const inner = tl.pts.slice(1, -1);
|
||||
inner.forEach(q => {
|
||||
const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1;
|
||||
@@ -1058,7 +969,6 @@ function mountWallEditor(container, callbacks) {
|
||||
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;
|
||||
@@ -1084,8 +994,8 @@ function mountWallEditor(container, callbacks) {
|
||||
const x0 = toCanvasX(opLeftMM);
|
||||
const x1 = toCanvasX(opLeftMM + op.w);
|
||||
const y0 = toCanvasY(op.y);
|
||||
const y1 = toCanvasY(op.y + op.h); // bovenkant links
|
||||
const y1r = toCanvasY(op.y + op.hR); // bovenkant rechts (schuine bovenzijde)
|
||||
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, y1r, localX: op.x, y: op.y, w: op.w, h: op.h, hR: op.hR });
|
||||
@@ -1128,7 +1038,6 @@ function mountWallEditor(container, callbacks) {
|
||||
ctxFront.fillStyle = hd.handle === 'c' ? '#ffffff' : '#4da6ff';
|
||||
ctxFront.strokeStyle = '#0a1a2a';
|
||||
ctxFront.beginPath();
|
||||
// Midden (verplaatsen) rond, de rest vierkant.
|
||||
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();
|
||||
@@ -1139,7 +1048,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;
|
||||
@@ -1148,8 +1056,6 @@ function mountWallEditor(container, callbacks) {
|
||||
});
|
||||
}
|
||||
|
||||
// Naad-zones (wand vervalt daar, zie woning) als gearceerde overlay.
|
||||
// Met upTo (mm): opbouw boven het lagere dak, alleen gearceerd tot daar.
|
||||
function drawSeamOverlay(toCanvasX, toCanvasY, halfWMM, maxRoofMM) {
|
||||
seamCuts.forEach(([u0, u1, upTo]) => {
|
||||
const x0 = toCanvasX(-halfWMM + u0), x1 = toCanvasX(-halfWMM + u1);
|
||||
@@ -1180,7 +1086,6 @@ function mountWallEditor(container, callbacks) {
|
||||
return frontHandleRects.find(hd => Math.abs(hd.cx - mx) <= 8 && Math.abs(hd.cy - my) <= 8);
|
||||
}
|
||||
function frontOpeningAt(mx, my) {
|
||||
// Binnen de (evt. schuine) omtrek: canvas-y loopt omlaag.
|
||||
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);
|
||||
@@ -1242,22 +1147,18 @@ function mountWallEditor(container, callbacks) {
|
||||
applyOpeningPosition(op, newLocalX, dragInfo.startW, wallWidth);
|
||||
op.y = Math.max(0, snap50(dragInfo.startY + dyMM));
|
||||
} else if (dragInfo.type === 'resize') {
|
||||
// Samengestelde handles: 'nw' = bovenkant links + linkerrand, enz.
|
||||
const hd = dragInfo.handle;
|
||||
const sloped = dragInfo.startHR !== dragInfo.startH || isSloped(op);
|
||||
if (hd === 'n') {
|
||||
// Hele bovenkant omhoog/omlaag (helling blijft).
|
||||
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') {
|
||||
// Eén bovenhoek: alleen die kant in de hoogte -> schuine bovenzijde.
|
||||
op.h = hd === 'nw' ? Math.max(400, snap50(dragInfo.startH + dyMM)) : dragInfo.startH;
|
||||
op.hR = hd === 'ne' ? Math.max(400, snap50(dragInfo.startHR + dyMM)) : dragInfo.startHR;
|
||||
if (op.h === op.hR) delete op.hR; // weer recht
|
||||
if (op.h === op.hR) delete op.hR;
|
||||
}
|
||||
if (hd === 's' || hd === 'sw' || hd === 'se') {
|
||||
// Onderkant; de bovenkant blijft staan (onderin altijd waterpas).
|
||||
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);
|
||||
@@ -1343,7 +1244,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);
|
||||
@@ -1376,16 +1276,13 @@ 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 rabatColor = null; // kleur rabat (WallModel.RABAT_COLORS), komt uit de woning-instellingen
|
||||
let stage = BuildStages.FINAL; // bouwfase van de 3D (schuif linksonder)
|
||||
let cladding = 'horizontaal';
|
||||
let trasraam = null;
|
||||
let stone = null;
|
||||
let stuc = null;
|
||||
let rabatColor = 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));
|
||||
@@ -1402,45 +1299,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();
|
||||
const toolbar = document.getElementById('wall-toolbar');
|
||||
toolbar.innerHTML = `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>
|
||||
<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${UI.tbSep()}${UI.viewSeg('split')}`;
|
||||
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
|
||||
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||
const viewGroup = toolbar.querySelector('.tb-view');
|
||||
ViewLayers.createMenu(viewGroup, {
|
||||
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(); }
|
||||
});
|
||||
// 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(); });
|
||||
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
|
||||
Skybox.createToggle(viewGroup, (m) => { skyMode = m; applySky(); });
|
||||
|
||||
// 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, rabatColor, spaanplaatVisible, buitenVisible, stage
|
||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, 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 });
|
||||
}
|
||||
@@ -1474,7 +1367,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);
|
||||
@@ -1490,7 +1382,7 @@ function mountWallEditor(container, callbacks) {
|
||||
|
||||
const inputs = document.querySelectorAll('#tab-wall input');
|
||||
inputs.forEach(input => {
|
||||
// Hoogte met de hand gewijzigd: de knikken van een mansarde-/wolfsdak vervallen.
|
||||
// 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);
|
||||
});
|
||||
@@ -1526,8 +1418,8 @@ function mountWallEditor(container, callbacks) {
|
||||
exportDesign,
|
||||
triggerImport,
|
||||
mirrorHorizontal,
|
||||
toggleFullscreen3D,
|
||||
toggleFullscreen2D,
|
||||
setView,
|
||||
viewState: () => ({ view: UI.viewOf(document.getElementById('app-container')) }),
|
||||
openProjectProperties,
|
||||
getStateJson: getPureState,
|
||||
loadStateJson: applyPureState
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"mode": "spots",
|
||||
"direction": "both",
|
||||
"lampHeight": 1950,
|
||||
"lampMin": 1500,
|
||||
"lampMin": 500,
|
||||
"lampMax": 3000,
|
||||
"minWallWidth": 2000,
|
||||
"endOffset": 300,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
code;omschrijving;eenheid;waarde
|
||||
arbeid.uurloon;Uurloon productie (all-in);€/u;55
|
||||
arbeid.bezetting;Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel;man;2,2
|
||||
arbeid.toeslagUren;Toeslag indirecte uren (klaarleggen, opruimen, intern transport);%;10
|
||||
arbeid.qa;QA (laatste aanpassingen, klantcontact) over de totale werktijd;%;4
|
||||
zagen.methode;Zaagmethode;;robot
|
||||
zagen.robotSnede;Robot: tijd per haakse snede;s;5
|
||||
zagen.robotSchuin;Robot: extra tijd per schuine snede;s;2,5
|
||||
zagen.robotStuk;Robot: handling per stuk (invoer, uitvoer, etiket);s;15
|
||||
zagen.handFactor;Handmatig: factor op de robottijd;×;7
|
||||
verbinden.schroevenPerVerbinding;Schroeven per verbinding (stijl/dorpel op regel);st.;3
|
||||
verbinden.eersteSchroef;Tijd eerste schroef (gat + schroef + uitlijnen);s;10
|
||||
verbinden.volgendeSchroef;Tijd volgende schroef (gat + schroef);s;5
|
||||
verbinden.verbindingenPerVloerbalk;Verbindingen per vloer-/dakbalk;st.;2
|
||||
verbinden.dubbelAfstand;Dubbele stijl (deur, hoek): schroef verticaal elke;mm;100
|
||||
verbinden.monteerFactor;Monteren: factor op de schroeftijd (incl. lijmen, positioneren);×;6
|
||||
verbinden.personeel;"Personeel bij monteren constructie (vloer, wand, dak; i.p.v. de bezetting)";man;2
|
||||
verbinden.dakFactor;Dak: factor op de tijd per schroef (moeilijker werken);×;4
|
||||
montage.muurVastzetten;Muursectie plaatsen en vastzetten;min;10
|
||||
montage.muurPersoneel;Personeel bij plaatsen muursectie (i.p.v. de bezetting);man;3
|
||||
hout.latStukMontage;Latwerk: per gezaagd stuk (meten, zagen op locatie, passen);s;240
|
||||
hout.latMeterMontage;Latwerk: bevestigen per strekkende meter;s/m;30
|
||||
plaat.spaanplaatMontage;Montage spaanplaat (passen, zagen, plaatsen);s/m²;120
|
||||
plaat.osbMontage;Montage OSB;s/m²;90
|
||||
plaat.cementplaatMontage;Montage cementplaat;s/m²;240
|
||||
plaat.schroevenPerM2;Plaatschroeven per m²;st./m²;15
|
||||
plaat.tijdPerPlaatschroef;Tijd per plaatschroef;s;4
|
||||
isolatie.glaswolMontage;Montage glaswol;s/m³;900
|
||||
gevel.vochtdoekMontageM;Montage vochtdoek per strekkende meter wand;s/m;600
|
||||
gevel.rabatMontage;Montage rabat;s/m²;600
|
||||
gevel.steenMontage;Montage steenstrips;s/m²;1800
|
||||
gevel.stucMontage;Stucwerk aanbrengen (wapenen, pleisteren);s/m²;1500
|
||||
dak.dakleerMontage;Montage dakleer;s/m²;600
|
||||
dak.rietMontage;Montage riet;s/m²;3600
|
||||
dak.beschotMontage;Montage dakbeschot;s/m²;150
|
||||
dak.felsMontage;Montage felsplaten;s/m²;1800
|
||||
dak.boeideelMontage;Montage boeideel;s/m;600
|
||||
dak.onderplaatMontage;Montage onderplaat;s/m²;900
|
||||
dak.daktrimMontage;Montage daktrim;s/m;300
|
||||
dak.nokPlaatsen;Stalen nokbalk plaatsen, stellen en vastzetten;s;2700
|
||||
dak.nokVulhoutMontage;Vulhout in de holling van de nokbalk aanbrengen (beide zijden);s/m;240
|
||||
dak.nokPersoneel;Personeel bij plaatsen nokbalk (i.p.v. de bezetting);man;3
|
||||
dak.paalMontage;Paal plaatsen: stellen, aftoppen, vastzetten aan het dak;s/st.;2700
|
||||
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,75 @@
|
||||
code;omschrijving;eenheid;prijs;leverancier;artikelnummer;bijgewerkt
|
||||
verbinden.prijsPer1000;Inkoop constructieschroeven (6×120);€/1000;45;;;
|
||||
hout.constructieM3;Constructiehout vuren C24 (regelwerk, balken);€/m³;550;;;
|
||||
hout.latwerkM;Latwerk (kruislat);€/m;0,9;;;
|
||||
plaat.spaanplaatM2;Spaanplaat;€/m²;9;;;
|
||||
plaat.osbM2;OSB (vloeren, plafonds dak);€/m²;12;;;
|
||||
plaat.cementplaatM2;Cementgebonden plaat (gevel, trasraam);€/m²;28;;;
|
||||
plaat.plaatschroefPer1000;Inkoop plaatschroeven (4×40);€/1000;15;;;
|
||||
isolatie.glaswolM3;Glaswol;€/m³;40;;;
|
||||
isolatie.steenIsolatieM3;Isolatieblok steengevel;€/m³;120;;;
|
||||
gevel.vochtdoekM2;Vochtdoek;€/m²;1,5;;;
|
||||
gevel.rabatM2;Rabat (gevelbekleding);€/m²;35;;;
|
||||
gevel.steenM2;Steenstrips (gevel + trasraam);€/m²;60;;;
|
||||
gevel.stucM2;Stucwerk gevel (wapening + sierpleister op cementgebonden plaat);€/m²;45;;;
|
||||
dak.dakleerM2;Dakleer (EPDM);€/m²;18;;;
|
||||
dak.rietM2;Riet;€/m²;90;;;
|
||||
dak.beschotM2;Dakbeschot OSB 18 mm (bovenop de sporen);€/m²;14;;;
|
||||
dak.felsM2;Felsplaten staande naad (zink/staal, klangen, scheidingslaag);€/m²;65;;;
|
||||
dak.boeideelM;Boeideel;€/m;25;;;
|
||||
dak.onderplaatM2;Onderplaat boeideel;€/m²;30;;;
|
||||
dak.daktrimM;Daktrim;€/m;12;;;
|
||||
dak.staalKg;Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond);€/kg;2,9;;;
|
||||
dak.nokOplegging;Oplegging nokbalk (stalen schoen + bouten), 2 per balk;€/st.;45;;;
|
||||
dak.paalHoutM3;Paalhout overkapping (eiken, geschaafd);€/m³;2400;;;
|
||||
dak.paalvoet;Paalvoet verzinkt, verstelbaar (incl. ankers);€/st.;40;;;
|
||||
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;;;
|
||||
|
Reference in New Issue
Block a user