Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab0dca9bb5 | ||
|
|
adb3c74f85 | ||
|
|
86fcd57b96 | ||
|
|
b137a458d4 |
@@ -18,21 +18,30 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht).
|
|||||||
## Structuur
|
## Structuur
|
||||||
|
|
||||||
```
|
```
|
||||||
index.html app-shell: ribbon, splashscreen
|
index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen
|
||||||
css/app.css alle stijl
|
css/app.css alle stijl
|
||||||
js/vendor/ three.js r128, eigen OrbitControls, jsPDF
|
js/vendor/ three.js r128, eigen OrbitControls, jsPDF
|
||||||
js/core/ gedeelde helpers: geometrie, bouwfasen, ribbon, UI-kit, PDF-bladen,
|
js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel
|
||||||
labels, camerageheugen, weergavefilter, skybox, licentie, logo
|
(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/wall/ WallModel (logica) + wand-PDF + wand-editor
|
||||||
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor
|
||||||
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
|
js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim)
|
||||||
|
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/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots
|
||||||
js/elektra/ ElektraModel + ElektraPdf: meterkast, schakelaars (letters), wcd, groepen, leidingen
|
js/elektra/ ElektraModel + ElektraPdf: meterkast, schakelaars (letters), wcd, groepen, leidingen
|
||||||
js/sandwich/ SandwichModel: sandwichpanelen rondom
|
js/sandwich/ SandwichModel: sandwichpanelen rondom
|
||||||
js/room/ RoomModel: ruimtes (verfemmer), functie, netto m², afwerking, vals plafond
|
js/room/ RoomModel: ruimtes (verfemmer), functie, netto m², afwerking, vals plafond
|
||||||
js/interior/ FixtureModel: inrichting (sanitair, keuken, kasten, meubels, boiler)
|
js/interior/ FixtureModel: inrichting (sanitair, keuken, kasten, meubels, boiler)
|
||||||
js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
|
js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken
|
||||||
js/house/ HouseModel (woning) + woning-PDF + woning-editor
|
js/house/ HouseModel (woning) + woning-PDF + 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/cost/ CostModel (kostenberekening), posten + noodwaarden, CostCsv (CSV's), venster Kosten & tijden
|
||||||
js/app.js menubalk, versienummer, actieve editor
|
js/app.js menubalk, versienummer, actieve editor
|
||||||
json/defaults/ referentie-JSON's (niet runtime ingeladen)
|
json/defaults/ referentie-JSON's (niet runtime ingeladen)
|
||||||
@@ -49,9 +58,11 @@ assets/ logo, texturen, skybox
|
|||||||
- **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
|
- **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid,
|
||||||
links = west, rechts = oost (interne sleutels blijven voor/achter/…).
|
links = west, rechts = oost (interne sleutels blijven voor/achter/…).
|
||||||
- **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
|
- **Hoogtes** gelden vanaf bovenkant vloer (loopvlak).
|
||||||
- **Bouwfasen** (`BuildStages`): regelwerk → elektra (buizen, dozen, meterkast) → beplating → vochtdoek →
|
- **Weergavelagen** (`ViewLayers`): regelwerk, elektra, beplating, vochtdoek,
|
||||||
latwerk → gevel & dak → afgewerkt, de volgorde in de hal. De 3D toont de
|
latwerk, gevelbekleding, kozijnen, dakbedekking, afwerking, verlichting,
|
||||||
gekozen fase; lagen die daarin aan beide zijden zijn ingesloten worden niet
|
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,
|
gebouwd (stijlen tussen spaanplaat en doek, latten en doek achter de rabat,
|
||||||
sporen tussen plafond en dakbedekking). Specificaties en totalen rekenen
|
sporen tussen plafond en dakbedekking). Specificaties en totalen rekenen
|
||||||
altijd met alle lagen.
|
altijd met alle lagen.
|
||||||
@@ -85,7 +96,11 @@ assets/ logo, texturen, skybox
|
|||||||
opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de
|
opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de
|
||||||
maat van de opening, ook een schuine bovenzijde. Deur: kant van het blad,
|
maat van de opening, ook een schuine bovenzijde. Deur: kant van het blad,
|
||||||
scharnierzijde en draairichting (naar binnen/buiten); openslaande deuren
|
scharnierzijde en draairichting (naar binnen/buiten); openslaande deuren
|
||||||
met zijlichten = twee deuren naast elkaar. Schuifdeur: 1 of 2 bladen
|
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
|
(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
|
buitenzijde), glazen of dicht blad. In de plattegrond (app en PDF) een
|
||||||
draaicirkel per deur en de schuifrichting per schuifdeur; in de loopmodus
|
draaicirkel per deur en de schuifrichting per schuifdeur; in de loopmodus
|
||||||
@@ -97,31 +112,49 @@ assets/ logo, texturen, skybox
|
|||||||
geschilderd in de plafondkleur (Binnen › Wanden › Plafond), zolang
|
geschilderd in de plafondkleur (Binnen › Wanden › Plafond), zolang
|
||||||
"Geschilderd" aan staat; anders kale OSB.
|
"Geschilderd" aan staat; anders kale OSB.
|
||||||
- **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat,
|
- **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat,
|
||||||
lessenaar, zadel, mansarde of wolfsdak, met schuiven en hellingshoek).
|
lessenaar, zadel of wolfsdak, met schuiven en hellingshoek).
|
||||||
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde
|
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij een
|
||||||
met twee knikken, bij een wolfsdak afgeknot op het wolfseind, met
|
wolfsdak afgeknot op het wolfseind, met
|
||||||
daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een
|
daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een
|
||||||
rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het
|
rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het
|
||||||
bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers,
|
bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers,
|
||||||
nokbalk en kopbalken (ook in zaaglijst, specificaties en kosten).
|
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
|
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
|
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
|
daklijn daar precies op aan. Het wolfseind schaalt mee met goot en
|
||||||
nok (verhouding). Bij een knik (mansarde) sluiten de dakvlakken in
|
nok (verhouding). De PDF heeft voor een wolfsdak een eigen blad met het
|
||||||
verstek aan. De PDF heeft voor een wolfsdak een eigen blad met het
|
|
||||||
bovenste deel (bovenaanzicht houtwerk + stuklijst).
|
bovenste deel (bovenaanzicht houtwerk + stuklijst).
|
||||||
Grenzen (`UnitModel.LIMITS`): nok max 5 m, goot / plat dak max 3 m (vanaf
|
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
|
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.
|
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
|
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),
|
door een naad langer door (bijv. de kopgevel naast een gekoppelde unit),
|
||||||
dan blijven nok en knikken op hun plaats (`UnitModel.topLineOn`).
|
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
|
- **Overkapping:** bij een dak over de hele woning met `cover` loopt het
|
||||||
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
|
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
|
||||||
terras naast een kortere module), met een afgewerkte onderplaat in de
|
terras naast een kortere module), met een afgewerkte onderplaat in de
|
||||||
kleur van het boeideel (`HouseModel.coverExtensions`).
|
kleur van het boeideel (`HouseModel.coverExtensions`).
|
||||||
- **Dak over de hele woning:** tabblad Dak › "Over hele woning": één
|
- **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één
|
||||||
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle
|
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
|
units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden
|
||||||
de wolfseinden kilkepers vragen, dan wordt het een zadeldak), bijv. een
|
de wolfseinden kilkepers vragen, dan wordt het een zadeldak), bijv. een
|
||||||
@@ -179,7 +212,7 @@ assets/ logo, texturen, skybox
|
|||||||
(o.a. tegels), FIBO-wandpanelen (decor, tot een hoogte of tot het plafond),
|
(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
|
eigen verfkleur en een vals plafond op een vaste hoogte (alleen waar het
|
||||||
dak hoger ligt; wandafwerking en binnenspots volgen). Laag in de
|
dak hoger ligt; wandafwerking en binnenspots volgen). Laag in de
|
||||||
plattegrond (Beeld › Tonen: Ruimtes), label met netto m², in de structuur,
|
plattegrond (Lagen › Ruimtes), label met netto m², in de structuur,
|
||||||
het rapport, de kosten (vals plafond, FIBO) en op de PDF-plattegrond met
|
het rapport, de kosten (vals plafond, FIBO) en op de PDF-plattegrond met
|
||||||
een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee.
|
een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee.
|
||||||
- **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset
|
- **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset
|
||||||
@@ -187,8 +220,8 @@ assets/ logo, texturen, skybox
|
|||||||
(douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met
|
(douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met
|
||||||
koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat,
|
koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat,
|
||||||
kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil,
|
kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil,
|
||||||
salontafel, tv-meubel, boiler en wasmachine. Binnen › Inrichting: kies
|
salontafel, tv-meubel, boiler en wasmachine. Gereedschap Inrichting (I): kies
|
||||||
een stuk en klik in de plattegrond; slepen, bij een wand snapt de rug op
|
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
|
het wandvlak (Alt = vrij), D = draaien, Del = verwijderen. Per stuk maten
|
||||||
en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D
|
en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D
|
||||||
(afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep
|
(afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep
|
||||||
@@ -196,24 +229,86 @@ assets/ logo, texturen, skybox
|
|||||||
schuift mee.
|
schuift mee.
|
||||||
- **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
|
- **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld,
|
||||||
override per wand) en binnenspots in een raster per unit.
|
override per wand) en binnenspots in een raster per unit.
|
||||||
- **Ribbon** (`core/ribbon.js`, vervangt de menubalk): Bestand · Start ·
|
- **Indeling** (zelfde patroon als CAD/BIM-pakketten; elke functie op één plek):
|
||||||
Gevel · Dak · Binnen · Constructie · Verlichting · Rapport · Beeld · Extra,
|
bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren,
|
||||||
plus een gekleurd tabblad voor de selectie (unit, wandstuk, binnenwand of
|
projectgegevens, kosten & tijden) · Beeld · Help, de geopende ontwerpen als
|
||||||
naadwand) dat vanzelf opent. Velden staan in groepen naast elkaar, met
|
tabs en de knop Opslaan; links de structuur (groepen in-/uitklapbaar en
|
||||||
schuiven voor maten met een logisch bereik. Dubbelklik op een tabblad
|
onthouden; de groep met de selectie klapt vanzelf open); in het midden een werkbalk met
|
||||||
klapt de ribbon in. Plattegrond en 3D gebruiken de volle breedte; tabblad
|
gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, met de
|
||||||
Rapport toont het rapport (transport per unit, gewicht, afmetingen,
|
opties van het actieve gereedschap ernaast; Unit toevoegen) en weergave
|
||||||
kosten, hout, plaatwerk, met staafdiagrammen) in plaats van de plattegrond.
|
(Lagen, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D /
|
||||||
- **3D:** schuif Bouwfase linksonder; Beeld › Tonen: Dak/Lampen; Beeld ›
|
3D met 1/2/3); rechts het eigenschappenpaneel: de selectie (unit,
|
||||||
Doorsnede (wanden afgesneden op een instelbare hoogte, schuif in de ribbon
|
wandstuk, binnenwand, ruimte, inrichting, elektra) met kruimelpad terug,
|
||||||
en onder de 3D, snede wit afgedekt), binnenzijde in een
|
of zonder selectie de woning (kerncijfers en inklapbare secties Gevel,
|
||||||
verfkleur (afgewerkte fase), omgeving Ontwerp/Dag/Nacht, Render met lampen
|
Dak, Binnen, Verlichting, Elektra, Constructie); onderin een statusbalk
|
||||||
als echte lichtbronnen.
|
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
|
- **Transport:** per unit buitenmaat (incl. gevelafwerking), hoogte en
|
||||||
gewicht (vloer/wanden/dak) met een indicatieve breedteklasse.
|
gewicht (vloer/wanden/dak) met een indicatieve breedteklasse.
|
||||||
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
|
- **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten
|
||||||
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
|
noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond,
|
||||||
vloeren, wandstukken, binnenwanden, totalen).
|
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)
|
## Kosten en prijzen (CSV)
|
||||||
|
|
||||||
@@ -260,6 +355,16 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
|
|||||||
|
|
||||||
## Bekende beperkingen
|
## 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
|
- Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij
|
||||||
2400 m/u, zaagsneden, hoeken) in de app en op het totalenblad (`SAW_M_PER_H`).
|
2400 m/u, zaagsneden, hoeken) in de app en op het totalenblad (`SAW_M_PER_H`).
|
||||||
- Tijdelijk: een nieuwe woning is het voorbeeldontwerp "Vlinderwoning"
|
- Tijdelijk: een nieuwe woning is het voorbeeldontwerp "Vlinderwoning"
|
||||||
@@ -275,7 +380,22 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
|
|||||||
`examples/ontwerp_wetering.pdf`): 10,5 × 4,8 m, twee units van 2,6 en
|
`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
|
2,2 m zonder naadwand (open woonkamer over de volle diepte), verticaal
|
||||||
rabat grijs, plat dak met zwart boeideel, hoekkeuken met kookplaat,
|
rabat grijs, plat dak met zwart boeideel, hoekkeuken met kookplaat,
|
||||||
badkamer, slaapkamer met kledingkast. De standaardmodellen staan in
|
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
|
`HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden
|
||||||
daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die
|
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
|
`modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft
|
||||||
|
|||||||
+318
-228
@@ -39,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 {
|
#pane-2d-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -85,14 +61,6 @@
|
|||||||
.pane-2d-half { flex: 1; position: relative; min-width: 0; min-height: 0; background: var(--viewport-bg); }
|
.pane-2d-half { flex: 1; position: relative; min-width: 0; min-height: 0; background: var(--viewport-bg); }
|
||||||
.pane-2d-half + .pane-2d-half { border-left: 1px solid var(--border); }
|
.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 {
|
.tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--van-emous-blue);
|
background: var(--van-emous-blue);
|
||||||
@@ -148,39 +116,6 @@
|
|||||||
}
|
}
|
||||||
.tab-content.active { display: block; }
|
.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 {
|
.pane-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 15px;
|
top: 15px;
|
||||||
@@ -292,28 +227,6 @@
|
|||||||
.material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); }
|
.material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); }
|
||||||
html, body { height: 100%; }
|
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 { width: 100vw; flex: 1; min-height: 0; position: relative; }
|
||||||
#workspace-empty {
|
#workspace-empty {
|
||||||
height: 100%; display: flex; align-items: center; justify-content: center;
|
height: 100%; display: flex; align-items: center; justify-content: center;
|
||||||
@@ -433,29 +346,6 @@ body { display: flex; flex-direction: column; }
|
|||||||
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
|
.new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
|
||||||
.new-option.disabled { opacity: .4; cursor: not-allowed; }
|
.new-option.disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
#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; }
|
|
||||||
|
|
||||||
#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; }
|
.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; }
|
#house-nav-tree { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.hn-group { margin-top: 6px; }
|
.hn-group { margin-top: 6px; }
|
||||||
@@ -475,50 +365,20 @@ body { display: flex; flex-direction: column; }
|
|||||||
.hn-item.disabled:hover { background: none; color: #9aa4b0; }
|
.hn-item.disabled:hover { background: none; color: #9aa4b0; }
|
||||||
.hn-actions { display: flex; gap: 6px; margin-top: 10px; }
|
.hn-actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||||
.hn-actions .io-btn { flex: 1; }
|
.hn-actions .io-btn { flex: 1; }
|
||||||
#house-nav .io-status { margin: auto 0 0; padding: 6px; }
|
|
||||||
|
|
||||||
#house-part-host { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
|
||||||
#house-part-host > #app-container,
|
|
||||||
#house-part-host > #floor-app,
|
|
||||||
#house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; }
|
|
||||||
|
|
||||||
#house-overview { display: flex; width: 100%; }
|
|
||||||
.ho-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); }
|
.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-report { display: block; }
|
||||||
#house-overview.show-report #ho-pane-plan { display: none; }
|
#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 { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
|
||||||
.ho-report-head h1 { margin: 0; font-size: 14px; }
|
.ho-report-head h1 { margin: 0; font-size: 14px; }
|
||||||
.ho-report-head span { font-size: 12px; color: var(--text-muted); }
|
.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 { 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-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; }
|
.ho-prop-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||||
.io-btn-danger { border-color: #e3b4b0; color: var(--danger); }
|
.io-btn-danger { border-color: #e3b4b0; color: var(--danger); }
|
||||||
.io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
|
.io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
|
||||||
.control-group input:disabled { opacity: .5; cursor: not-allowed; background: var(--border-soft); }
|
.control-group input:disabled { opacity: .5; cursor: not-allowed; background: var(--border-soft); }
|
||||||
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
.bi { line-height: 1; vertical-align: -0.125em; }
|
.bi { line-height: 1; vertical-align: -0.125em; }
|
||||||
.ui-section { margin-bottom: 14px; }
|
.ui-section { margin-bottom: 14px; }
|
||||||
.ui-section h2, .ui-spec-card h2 {
|
.ui-section h2, .ui-spec-card h2 {
|
||||||
@@ -574,7 +434,6 @@ body { display: flex; flex-direction: column; }
|
|||||||
.ui-tab-page { display: none; }
|
.ui-tab-page { display: none; }
|
||||||
.ui-tab-page.active { display: block; }
|
.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-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 { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); }
|
||||||
.ui-spec-row:last-child { border-bottom: none; }
|
.ui-spec-row:last-child { border-bottom: none; }
|
||||||
@@ -583,12 +442,10 @@ body { display: flex; flex-direction: column; }
|
|||||||
.ui-spec-total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 2px; }
|
.ui-spec-total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 2px; }
|
||||||
.ui-spec-total .ui-spec-val { color: var(--primary); }
|
.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 .bi { margin-right: 6px; color: var(--text-muted); }
|
||||||
.hn-item.active .bi, .hn-item:hover .bi { color: var(--primary); }
|
.hn-item.active .bi, .hn-item:hover .bi { color: var(--primary); }
|
||||||
.hn-label { display: inline-flex; align-items: center; }
|
.hn-label { display: inline-flex; align-items: center; }
|
||||||
|
|
||||||
.render-only #specs-panel-3d, .render-only #floor-specs-panel-3d { display: none; }
|
|
||||||
|
|
||||||
.control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; }
|
.control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; }
|
||||||
.control-group label { font-size: 12.5px; color: var(--text); }
|
.control-group label { font-size: 12.5px; color: var(--text); }
|
||||||
@@ -606,9 +463,6 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
|||||||
.hn-title { display: flex; align-items: center; gap: 6px; }
|
.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 { 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; }
|
.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); }
|
|
||||||
|
|
||||||
.hs-stone-preview { display: block; width: 128px; height: auto; margin: 4px 0 8px; border: 1px solid var(--border, #ccc); border-radius: 3px; }
|
.hs-stone-preview { display: block; width: 128px; height: auto; margin: 4px 0 8px; border: 1px solid var(--border, #ccc); border-radius: 3px; }
|
||||||
|
|
||||||
@@ -635,21 +489,13 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight:
|
|||||||
.ui-range input[type=number] { width: 84px; }
|
.ui-range input[type=number] { width: 84px; }
|
||||||
.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
|
.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; }
|
||||||
|
|
||||||
.stage-bar {
|
.plan-zoom { position: absolute; right: 12px; bottom: 12px; z-index: 10; display: flex; flex-direction: column; gap: 4px; }
|
||||||
position: absolute; left: 15px; bottom: 14px; z-index: 10; min-width: 300px; max-width: calc(100% - 30px);
|
.plan-zoom button {
|
||||||
background: rgba(0, 0, 0, 0.75); border: 1px solid #444; border-radius: 6px; padding: 7px 12px 6px; color: #eee; font-size: 12px;
|
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; }
|
.plan-zoom button:hover { border-color: var(--primary-on-dark); color: var(--primary-on-dark); }
|
||||||
.stage-title { display: inline-flex; align-items: center; gap: 6px; color: var(--primary-on-dark); font-weight: 600; white-space: nowrap; }
|
.plan-zoom-val { text-align: center; font-size: 10px; color: #aaa; }
|
||||||
.stage-range { flex: 1; min-width: 90px; accent-color: var(--primary-on-dark); }
|
|
||||||
.stage-name { min-width: 84px; font-weight: 600; white-space: nowrap; }
|
|
||||||
.stage-track { flex: 1; display: flex; flex-direction: column; min-width: 120px; }
|
|
||||||
.stage-track .stage-range { width: 100%; margin: 0; }
|
|
||||||
.stage-ticks { display: flex; justify-content: space-between; padding: 0 3px; font-size: 10px; color: #888; }
|
|
||||||
.stage-ticks span { cursor: pointer; padding: 0 3px; }
|
|
||||||
.stage-ticks span.on { color: var(--primary-on-dark); }
|
|
||||||
.stage-extra { display: none; margin-top: 6px; padding-top: 6px; border-top: 1px solid #444; }
|
|
||||||
.stage-extra.show { display: flex; }
|
|
||||||
|
|
||||||
.viz { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; }
|
.viz { --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 { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; }
|
||||||
@@ -671,72 +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-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; }
|
.viz-note { font-size: 11px; color: var(--text-faint); line-height: 1.45; margin-top: 6px; }
|
||||||
|
|
||||||
#ribbon { flex: none; background: var(--van-emous-blue); border-bottom: 1px solid var(--border); position: relative; z-index: 100; user-select: none; }
|
/* ===== Shell: bovenbalk (menu + documenten), editorindeling, werkbalk, eigenschappen, statusbalk ===== */
|
||||||
.rb-top { display: flex; align-items: flex-end; height: 34px; padding: 0 8px; }
|
:root { --panel-w: 340px; --nav-w: 230px; }
|
||||||
.rb-brand { display: flex; align-items: center; height: 100%; padding: 0 14px 0 4px; font-weight: 700; color: var(--primary); letter-spacing: .5px; }
|
body { display: flex; flex-direction: column; }
|
||||||
.rb-tabs { display: flex; gap: 2px; height: 100%; align-items: flex-end; overflow-x: auto; scrollbar-width: none; }
|
|
||||||
.rb-tab {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 14px; border: 1px solid transparent; border-bottom: none;
|
|
||||||
border-radius: 5px 5px 0 0; background: none; font: inherit; font-size: 12.5px; color: var(--nav-link-color); cursor: pointer; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.rb-tab:hover { background: rgba(255, 255, 255, .55); color: var(--primary); }
|
|
||||||
.rb-tab.active { background: var(--van-emous-ultra-light); border-color: var(--border); color: var(--primary); font-weight: 600; margin-bottom: -1px; height: 29px; }
|
|
||||||
.rb-tab.rb-ctx { color: #fff; background: var(--primary); margin-left: 10px; }
|
|
||||||
.rb-tab.rb-ctx:hover { background: var(--primary-hover); color: #fff; }
|
|
||||||
.rb-tab.rb-ctx.active { background: var(--van-emous-ultra-light); color: var(--primary); box-shadow: inset 0 3px 0 var(--primary); }
|
|
||||||
.rb-right { margin-left: auto; display: flex; align-items: center; height: 100%; gap: 10px; font-size: 11px; color: var(--text-muted); padding-left: 12px; }
|
|
||||||
.rb-body {
|
|
||||||
position: relative; display: flex; align-items: stretch; height: 104px; padding: 6px 40px 4px 6px;
|
|
||||||
background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-x: auto; overflow-y: hidden;
|
|
||||||
}
|
|
||||||
#ribbon.rb-collapsed .rb-body { display: none; }
|
|
||||||
.rb-collapse { position: absolute; right: 8px; bottom: 6px; border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 2px 4px; }
|
|
||||||
.rb-collapse:hover { color: var(--primary); }
|
|
||||||
.rb-group { display: flex; flex-direction: column; flex: none; padding: 0 12px; border-right: 1px solid var(--border-soft); }
|
|
||||||
.rb-group:last-of-type { border-right: none; }
|
|
||||||
.rb-group-body { flex: 1; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 16px; align-items: center; justify-content: start; }
|
|
||||||
.rb-group-label { text-align: center; font-size: 10.5px; color: var(--text-faint); padding-top: 2px; white-space: nowrap; }
|
|
||||||
.rb-btn {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px; border: 1px solid transparent; background: none; border-radius: 4px;
|
|
||||||
font: inherit; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.rb-btn:hover:not(:disabled) { background: var(--primary-soft); border-color: rgba(185, 89, 19, .25); color: var(--primary); }
|
|
||||||
.rb-btn:disabled { color: #a9b2bd; cursor: default; }
|
|
||||||
.rb-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
|
|
||||||
.rb-btn.rb-danger:hover:not(:disabled) { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
|
|
||||||
.rb-small { height: 24px; padding: 0 7px; }
|
|
||||||
.rb-small .bi { font-size: 14px; color: var(--primary); }
|
|
||||||
.rb-btn:disabled .bi { color: inherit; }
|
|
||||||
.rb-big { grid-row: span 3; flex-direction: column; justify-content: center; gap: 4px; min-width: 64px; height: 76px; padding: 4px 8px; text-align: center; }
|
|
||||||
.rb-big .bi { font-size: 24px; color: var(--primary); }
|
|
||||||
.rb-big span { white-space: normal; max-width: 86px; line-height: 1.2; }
|
|
||||||
.rb-kv { display: flex; justify-content: space-between; gap: 12px; font-size: 11.5px; color: var(--text-muted); min-width: 150px; }
|
|
||||||
.rb-kv b { color: var(--text); font-variant-numeric: tabular-nums; }
|
|
||||||
.rb-kv small { font-weight: normal; color: var(--text-faint); margin-left: 2px; }
|
|
||||||
.rb-note { grid-row: span 3; max-width: 260px; font-size: 11px; line-height: 1.4; color: var(--text-muted); white-space: normal; align-self: center; }
|
|
||||||
.rb-note.rb-warn { color: var(--danger); }
|
|
||||||
.rb-group .ui-field { min-height: 0; height: 26px; gap: 8px; }
|
|
||||||
.rb-group .ui-field label { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
|
|
||||||
.rb-group .ui-control input[type=number] { width: 70px; padding: 2px 5px; font-size: 12px; }
|
|
||||||
.rb-group .ui-control select, .rb-group .ui-control input[type=text] { width: 132px; padding: 2px 4px; font-size: 12px; }
|
|
||||||
.rb-group .ui-control input[type=color] { width: 46px; height: 22px; padding: 0; border: 1px solid var(--border); border-radius: 3px; background: none; }
|
|
||||||
.rb-group .ui-unit { width: 20px; margin-left: 3px; font-size: 10.5px; }
|
|
||||||
.rb-group .ui-switch { margin-right: 0; }
|
|
||||||
.rb-group .ui-field-range { flex-wrap: nowrap; padding: 0; }
|
|
||||||
.rb-group .ui-field-range label { flex: none; }
|
|
||||||
.rb-group .ui-range { flex: none; gap: 6px; }
|
|
||||||
.rb-group .ui-range input[type=range] { width: 96px; flex: none; }
|
|
||||||
.rb-group .ui-range input[type=number] { width: 62px; }
|
|
||||||
.rb-thumb { width: 26px; height: 26px; border-radius: 3px; border: 1px solid var(--border); object-fit: cover; display: block; }
|
|
||||||
.rb-swatch { width: 26px; height: 22px; border-radius: 3px; border: 1px solid var(--border); }
|
|
||||||
.rb-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; height: 24px; }
|
|
||||||
.rb-seg .rb-btn { border-radius: 0; border: none; border-right: 1px solid var(--border-soft); height: 22px; padding: 0 8px; }
|
|
||||||
.rb-seg .rb-btn:last-child { border-right: none; }
|
|
||||||
.rb-seg .rb-btn.active { background: var(--primary); color: #fff; }
|
|
||||||
.rb-seg .rb-btn.active .bi { color: #fff; }
|
|
||||||
.rb-check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap; }
|
|
||||||
.rb-check input { accent-color: var(--primary); }
|
|
||||||
|
|
||||||
.rb-pair { display: flex; align-items: center; gap: 6px; }
|
#topbar {
|
||||||
.rb-cols { grid-row: span 3; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 18px; align-items: center; }
|
flex: none; height: 40px; display: flex; align-items: stretch; gap: 4px; padding: 0 10px 0 4px;
|
||||||
.rb-cols:empty { display: none; }
|
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); }
|
||||||
|
|||||||
+26
-15
@@ -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 class="splash-version"><span class="splash-name">Tectum</span> — Versie <span class="app-version-number">0.0.0</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header id="ribbon">
|
<header id="topbar">
|
||||||
<div class="rb-top">
|
<div class="tb-brand"><span class="splash-name">Tectum</span></div>
|
||||||
<div class="rb-brand"><span class="splash-name">Tectum</span></div>
|
<nav id="menubar" aria-label="Menu"></nav>
|
||||||
<div class="rb-tabs" role="tablist"></div>
|
<div id="doc-tabs" role="tablist" aria-label="Geopende ontwerpen"></div>
|
||||||
<div class="rb-right"><span class="app-version-slot"></span></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>
|
||||||
<div class="rb-body"></div>
|
|
||||||
</header>
|
</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-overlay">
|
||||||
<div id="about-modal">
|
<div id="about-modal">
|
||||||
<img class="about-logo" src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
<img class="about-logo" src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
|
||||||
@@ -49,7 +57,7 @@
|
|||||||
|
|
||||||
<div id="new-overlay">
|
<div id="new-overlay">
|
||||||
<div id="new-modal">
|
<div id="new-modal">
|
||||||
<h2>Nieuw</h2>
|
<h2>Nieuw ontwerp</h2>
|
||||||
<div class="new-options">
|
<div class="new-options">
|
||||||
<button class="new-option" data-kind="woning" data-template="single">
|
<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>
|
<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,36 +75,36 @@
|
|||||||
<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>
|
<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>
|
<span>Los: vloer</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-bar"></div>
|
|
||||||
<div id="workspace"></div>
|
<div id="workspace"></div>
|
||||||
|
|
||||||
<script src="js/core/license.js"></script>
|
<script src="js/core/license.js"></script>
|
||||||
<script src="js/core/logo-data.js"></script>
|
<script src="js/core/logo-data.js"></script>
|
||||||
<script src="js/core/geometry.js"></script>
|
<script src="js/core/geometry.js"></script>
|
||||||
<script src="js/core/build-stages.js"></script>
|
<script src="js/core/view-layers.js"></script>
|
||||||
<script src="js/core/canvas-labels.js"></script>
|
<script src="js/core/canvas-labels.js"></script>
|
||||||
<script src="js/core/camera-memory.js"></script>
|
<script src="js/core/camera-memory.js"></script>
|
||||||
<script src="js/core/walk-controls.js"></script>
|
<script src="js/core/walk-controls.js"></script>
|
||||||
<script src="js/core/view-filter.js"></script>
|
|
||||||
<script src="js/core/ui-kit.js"></script>
|
<script src="js/core/ui-kit.js"></script>
|
||||||
<script src="js/core/ribbon.js"></script>
|
<script src="js/core/menubar.js"></script>
|
||||||
|
<script src="js/core/inspector.js"></script>
|
||||||
<script src="js/core/skybox.js"></script>
|
<script src="js/core/skybox.js"></script>
|
||||||
<script src="js/vendor/jspdf.umd.min.js"></script>
|
<script src="js/vendor/jspdf.umd.min.js"></script>
|
||||||
<script src="js/core/pdf-sheet.js"></script>
|
<script src="js/core/pdf-sheet.js"></script>
|
||||||
|
<script src="js/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/kozijn-model.js"></script>
|
||||||
<script src="js/wall/wall-model.js"></script>
|
<script src="js/wall/wall-model.js"></script>
|
||||||
<script src="js/wall/wall-pdf.js"></script>
|
<script src="js/wall/wall-pdf.js"></script>
|
||||||
<script src="js/floor/floor-model.js"></script>
|
<script src="js/floor/floor-model.js"></script>
|
||||||
<script src="js/floor/floor-pdf.js"></script>
|
<script src="js/floor/floor-pdf.js"></script>
|
||||||
<script src="js/roof/roof-model.js"></script>
|
<script src="js/roof/roof-model.js"></script>
|
||||||
|
<script src="js/roof/post-model.js"></script>
|
||||||
<script src="js/lighting/lighting-model.js"></script>
|
<script src="js/lighting/lighting-model.js"></script>
|
||||||
<script src="js/elektra/elektra-model.js"></script>
|
<script src="js/elektra/elektra-model.js"></script>
|
||||||
<script src="js/sandwich/sandwich-model.js"></script>
|
<script src="js/sandwich/sandwich-model.js"></script>
|
||||||
@@ -105,6 +113,7 @@
|
|||||||
<script src="js/unit/unit-model.js"></script>
|
<script src="js/unit/unit-model.js"></script>
|
||||||
<script src="js/room/room-model.js"></script>
|
<script src="js/room/room-model.js"></script>
|
||||||
<script src="js/interior/fixture-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/house/house-model.js"></script>
|
||||||
<script src="js/cost/cost-defaults.js"></script>
|
<script src="js/cost/cost-defaults.js"></script>
|
||||||
<script src="js/cost/cost-csv.js"></script>
|
<script src="js/cost/cost-csv.js"></script>
|
||||||
@@ -112,6 +121,8 @@
|
|||||||
<script src="js/cost/cost-settings.js"></script>
|
<script src="js/cost/cost-settings.js"></script>
|
||||||
<script src="js/elektra/elektra-pdf.js"></script>
|
<script src="js/elektra/elektra-pdf.js"></script>
|
||||||
<script src="js/house/house-pdf.js"></script>
|
<script src="js/house/house-pdf.js"></script>
|
||||||
|
<script src="js/house/house-dxf.js"></script>
|
||||||
|
<script src="js/house/house-ifc.js"></script>
|
||||||
<script src="js/house/house.js"></script>
|
<script src="js/house/house.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
||||||
* Zie LICENTIE.md.
|
* Zie LICENTIE.md.
|
||||||
*/
|
*/
|
||||||
const APP_VERSION = '0.6.1';
|
const APP_VERSION = '0.7.1';
|
||||||
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
// 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang.
|
||||||
|
|
||||||
let tabs = [];
|
let tabs = [];
|
||||||
@@ -28,68 +28,83 @@ function defaultNameFor(kind) {
|
|||||||
function setWorkspaceEmpty() {
|
function setWorkspaceEmpty() {
|
||||||
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
|
document.getElementById('workspace').innerHTML = `<div id="workspace-empty">
|
||||||
<div>Geen ontwerp geopend.</div>
|
<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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEditMenuState() {
|
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function' && (!currentEditor.can || currentEditor.can(fn)));
|
||||||
Ribbon.refresh();
|
const editorCall = (fn, ...args) => { if (editorHas(fn)) return currentEditor[fn](...args); };
|
||||||
|
const viewState = () => editorCall('viewState') || {};
|
||||||
|
|
||||||
|
// Menu met de sneltoets per item; KEY_HELP hieronder is het overzicht (Help › Sneltoetsen). Editor-acties werken alleen als de editor ze kent.
|
||||||
|
const MENUS = [
|
||||||
|
{ label: 'Bestand', items: [
|
||||||
|
{ label: 'Nieuw…', icon: 'file-earmark-plus', keys: 'Ctrl+N', run: openNewModal },
|
||||||
|
{ label: 'Openen…', icon: 'folder2-open', keys: 'Ctrl+O', run: () => editorCall('triggerImport'), enabled: () => editorHas('triggerImport') },
|
||||||
|
{ label: 'Opslaan (JSON + PDF)', icon: 'save', keys: 'Ctrl+S', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') },
|
||||||
|
{ sep: true }, { head: 'Exporteren' },
|
||||||
|
{ label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') },
|
||||||
|
{ label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') },
|
||||||
|
{ label: 'Render (OBJ)', icon: 'badge-3d', run: () => editorCall('exportObj'), enabled: () => editorHas('exportObj') },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Projectgegevens…', icon: 'card-text', run: () => editorCall('openProjectProperties'), enabled: () => editorHas('openProjectProperties') },
|
||||||
|
{ label: 'Kosten & tijden…', icon: 'calculator', run: () => CostSettings.open() },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Ontwerp sluiten', icon: 'x-lg', run: () => activeTabId !== null && closeTab(activeTabId), enabled: () => activeTabId !== null }
|
||||||
|
] },
|
||||||
|
{ label: 'Beeld', items: [
|
||||||
|
...UI.VIEWS.map(v => ({ label: v.label, icon: v.icon, keys: v.key1, run: () => editorCall('setView', v.key),
|
||||||
|
enabled: () => editorHas('setView'), checked: () => viewState().view === v.key })),
|
||||||
|
{ label: 'Rapport', icon: 'clipboard-data', keys: '4', run: () => editorCall('toggleReport'), enabled: () => editorHas('toggleReport'), checked: () => !!viewState().report },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Doorsnede', icon: 'scissors', keys: 'X', run: () => editorCall('toggleSection'), enabled: () => editorHas('toggleSection'), checked: () => !!viewState().section },
|
||||||
|
{ label: 'Plattegrond passend', icon: 'aspect-ratio', keys: 'F', run: () => editorCall('fitView'), enabled: () => editorHas('fitView') },
|
||||||
|
{ label: 'Lopen', icon: 'person-walking', keys: 'L', run: () => editorCall('toggleWalk'), enabled: () => editorHas('toggleWalk'), checked: () => !!viewState().walk },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: 'Panelen verbergen', icon: 'layout-sidebar-inset', keys: 'Ctrl+\\', run: togglePanels, checked: () => document.body.classList.contains('panels-hidden') },
|
||||||
|
{ label: 'Volledig scherm', icon: 'fullscreen', keys: 'F11', run: toggleBrowserFullscreen, checked: () => !!document.fullscreenElement }
|
||||||
|
] },
|
||||||
|
{ label: 'Help', items: [
|
||||||
|
{ label: 'Sneltoetsen en bediening', icon: 'keyboard', keys: '?', run: openKeys },
|
||||||
|
{ label: 'Over Tectum', icon: 'info-circle', run: () => document.getElementById('about-overlay').classList.add('open') }
|
||||||
|
] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const KEY_HELP = [
|
||||||
|
['Bestand', [['Ctrl+N', 'Nieuw ontwerp'], ['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');
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
|
function togglePanels() {
|
||||||
const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); };
|
document.body.classList.toggle('panels-hidden');
|
||||||
const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled';
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
|
||||||
function appRibbonTabs() {
|
|
||||||
const { big, small } = Ribbon;
|
|
||||||
const wireClicks = (body) => body.querySelectorAll('[data-app]').forEach(b => b.addEventListener('click', () => {
|
|
||||||
const a = b.dataset.app;
|
|
||||||
if (a === 'new') openNewModal();
|
|
||||||
else if (a === 'new-kind') openNew(b.dataset.kind, b.dataset.template);
|
|
||||||
else if (a === 'fullscreen') toggleBrowserFullscreen();
|
|
||||||
else if (a === 'costs') CostSettings.open();
|
|
||||||
else if (a === 'about') document.getElementById('about-overlay').classList.add('open');
|
|
||||||
else editorCall(a);
|
|
||||||
}));
|
|
||||||
return [
|
|
||||||
{ key: 'bestand', label: 'Bestand', order: 0, wire: wireClicks, groups: () => [
|
|
||||||
{ label: 'Document', html:
|
|
||||||
big('Nieuw', 'file-earmark-plus', 'data-app="new" title="Nieuw ontwerp (Ctrl+N)"') +
|
|
||||||
big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) +
|
|
||||||
big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) },
|
|
||||||
{ label: 'Snel nieuw', html:
|
|
||||||
Object.entries(HouseModel.MODELS).map(([key, m]) => small(m.label, m.icon, `data-app="new-kind" data-kind="woning" data-template="${key}"`)).join('') +
|
|
||||||
small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') +
|
|
||||||
small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') +
|
|
||||||
small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') +
|
|
||||||
small('Los: vloer', 'grid-3x3', 'data-app="new-kind" data-kind="vloer"') },
|
|
||||||
{ label: 'Project', html: big('Eigenschappen', 'card-text', `data-app="openProjectProperties" title="Projectgegevens voor de PDF" ${disabledUnless('openProjectProperties')}`) }
|
|
||||||
] },
|
|
||||||
{ key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [
|
|
||||||
{ label: 'Venster', html:
|
|
||||||
small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') +
|
|
||||||
small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) +
|
|
||||||
small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) },
|
|
||||||
{ label: 'Camera', html:
|
|
||||||
big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) +
|
|
||||||
small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') }
|
|
||||||
] },
|
|
||||||
{ key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [
|
|
||||||
{ label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) },
|
|
||||||
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') },
|
|
||||||
{ label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') }
|
|
||||||
] }
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTabBar() {
|
function renderTabBar() {
|
||||||
const bar = document.getElementById('tab-bar');
|
const bar = document.getElementById('doc-tabs');
|
||||||
bar.innerHTML = tabs.map(tab => `
|
bar.innerHTML = tabs.map(tab => `
|
||||||
<div class="tab-item ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}">
|
<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="tab-name">${tab.name}</span>
|
<span class="doc-name">${UI.esc(tab.name)}</span>
|
||||||
<span class="tab-close" data-close-id="${tab.id}">×</span>
|
<button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">×</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
document.getElementById('top-save').disabled = !editorHas('exportDesign');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTabSaved(tab, filenameWithExt) {
|
function onTabSaved(tab, filenameWithExt) {
|
||||||
@@ -102,7 +117,6 @@ function onTabSaved(tab, filenameWithExt) {
|
|||||||
function mountEditorForTab(tab) {
|
function mountEditorForTab(tab) {
|
||||||
const ws = document.getElementById('workspace');
|
const ws = document.getElementById('workspace');
|
||||||
ws.innerHTML = '';
|
ws.innerHTML = '';
|
||||||
Ribbon.setSource('editor', []);
|
|
||||||
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
|
currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id });
|
||||||
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
|
if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') {
|
||||||
currentEditor.loadStateJson(tab.stateJson);
|
currentEditor.loadStateJson(tab.stateJson);
|
||||||
@@ -120,7 +134,6 @@ function switchToTab(tabId) {
|
|||||||
|
|
||||||
activeTabId = tabId;
|
activeTabId = tabId;
|
||||||
mountEditorForTab(tabs.find(t => t.id === tabId));
|
mountEditorForTab(tabs.find(t => t.id === tabId));
|
||||||
updateEditMenuState();
|
|
||||||
renderTabBar();
|
renderTabBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +151,6 @@ function openNew(kind, template) {
|
|||||||
tabs.push(tab);
|
tabs.push(tab);
|
||||||
activeTabId = tab.id;
|
activeTabId = tab.id;
|
||||||
mountEditorForTab(tab);
|
mountEditorForTab(tab);
|
||||||
updateEditMenuState();
|
|
||||||
renderTabBar();
|
renderTabBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,17 +191,15 @@ function closeTab(tabId) {
|
|||||||
switchToTab(tabs[tabs.length - 1].id);
|
switchToTab(tabs[tabs.length - 1].id);
|
||||||
} else {
|
} else {
|
||||||
setWorkspaceEmpty();
|
setWorkspaceEmpty();
|
||||||
Ribbon.setSource('editor', []);
|
|
||||||
updateEditMenuState();
|
|
||||||
renderTabBar();
|
renderTabBar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireTabBar() {
|
function wireTabBar() {
|
||||||
document.getElementById('tab-bar').addEventListener('click', (e) => {
|
document.getElementById('doc-tabs').addEventListener('click', (e) => {
|
||||||
const closeBtn = e.target.closest('.tab-close');
|
const closeBtn = e.target.closest('.doc-close');
|
||||||
if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; }
|
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));
|
if (item) switchToTab(parseInt(item.dataset.tabId, 10));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -233,41 +243,42 @@ function toggleBrowserFullscreen() {
|
|||||||
else document.documentElement.requestFullscreen();
|
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() {
|
function wireHotkeys() {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
|
const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
const act = { s: () => editorCall('exportDesign'), n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
|
||||||
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign();
|
if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
|
if (e.key === 'Escape' && closeOverlays()) { e.stopImmediatePropagation(); return; }
|
||||||
e.preventDefault();
|
if (inField || e.altKey || viewState().walk && !['l', 'escape'].includes(k)) return;
|
||||||
openNewModal();
|
const view = UI.VIEWS.find(v => v.key1 === e.key);
|
||||||
return;
|
if (view) editorCall('setView', view.key);
|
||||||
}
|
else if (e.key === '4') editorCall('toggleReport');
|
||||||
if (inField || e.ctrlKey || e.metaKey || e.altKey) return;
|
else if (k === 'x') editorCall('toggleSection');
|
||||||
|
else if (k === 'f') editorCall('fitView');
|
||||||
if (e.key.toLowerCase() === 'f') {
|
else if (k === 'l') editorCall('toggleWalk');
|
||||||
toggleBrowserFullscreen();
|
else if (k === 'h' && e.shiftKey) editorCall('mirrorHorizontal');
|
||||||
} else if (e.key.toLowerCase() === 'h') {
|
else if (e.key === '?') openKeys();
|
||||||
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal();
|
}, true);
|
||||||
} 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; });
|
||||||
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; });
|
||||||
Ribbon.init(document.getElementById('ribbon'));
|
Menubar.init(document.getElementById('menubar'), MENUS);
|
||||||
Ribbon.setSource('app', appRibbonTabs());
|
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();
|
wireAbout();
|
||||||
wireNewModal();
|
wireNewModal();
|
||||||
wireHotkeys();
|
wireHotkeys();
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Tectum
|
|
||||||
* Copyright © 2026 Machiel Heinen.
|
|
||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
||||||
* Zie LICENTIE.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
window.BuildStages = (() => {
|
|
||||||
const LIST = [
|
|
||||||
{ key: 'regelwerk', label: 'Regelwerk', hint: 'Vloer-, wand- en dakbalken' },
|
|
||||||
{ key: 'elektra', label: 'Elektra', hint: 'Installatiebuizen door wanden en plafond, dozen en meterkast' },
|
|
||||||
{ key: 'beplating', label: 'Beplating', hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
|
|
||||||
{ key: 'doek', label: 'Vochtdoek', hint: 'Vochtdoek op de buitenzijde' },
|
|
||||||
{ key: 'latwerk', label: 'Latwerk', hint: 'Kruislat en trasraamplaat' },
|
|
||||||
{ key: 'gevel', label: 'Gevel & dak', hint: 'Rabat, steen, kozijnen, dakbedekking, sandwichpanelen' },
|
|
||||||
{ key: 'afwerking', label: 'Afgewerkt', hint: 'Dakrand, verlichting, vloerafwerking, verf binnen' }
|
|
||||||
];
|
|
||||||
const FINAL = LIST.length - 1;
|
|
||||||
const INDEX = Object.fromEntries(LIST.map((s, i) => [s.key, i]));
|
|
||||||
|
|
||||||
const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL;
|
|
||||||
const shows = (stage, key) => clamp(stage) >= INDEX[key];
|
|
||||||
|
|
||||||
function createBar(pane, stage, onChange) {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'stage-bar';
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="stage-row">
|
|
||||||
<span class="stage-title"><i class="bi bi-layers"></i>Bouwfase</span>
|
|
||||||
<div class="stage-track">
|
|
||||||
<input type="range" class="stage-range" min="0" max="${FINAL}" step="1" value="${clamp(stage)}" aria-label="Bouwfase">
|
|
||||||
<div class="stage-ticks">${LIST.map((s, i) => `<span data-i="${i}" title="${s.label}: ${s.hint}">${i + 1}</span>`).join('')}</div>
|
|
||||||
</div>
|
|
||||||
<span class="stage-name"></span>
|
|
||||||
</div>
|
|
||||||
<div class="stage-extra"></div>`;
|
|
||||||
pane.appendChild(el);
|
|
||||||
const range = el.querySelector('.stage-range'), name = el.querySelector('.stage-name');
|
|
||||||
const sync = () => {
|
|
||||||
const i = clamp(Number(range.value));
|
|
||||||
name.textContent = LIST[i].label;
|
|
||||||
name.title = LIST[i].hint;
|
|
||||||
el.querySelectorAll('.stage-ticks span').forEach(t => t.classList.toggle('on', Number(t.dataset.i) <= i));
|
|
||||||
};
|
|
||||||
range.addEventListener('input', () => { sync(); onChange(clamp(Number(range.value))); });
|
|
||||||
el.querySelector('.stage-ticks').addEventListener('click', (e) => {
|
|
||||||
const t = e.target.closest('[data-i]');
|
|
||||||
if (!t) return;
|
|
||||||
range.value = t.dataset.i;
|
|
||||||
sync();
|
|
||||||
onChange(clamp(Number(range.value)));
|
|
||||||
});
|
|
||||||
// T, R, ... zijn sneltoetsen van de weergave, niet van de schuif.
|
|
||||||
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
|
|
||||||
// Na slepen focus loslaten, zodat de pijltoetsen weer naar de weergave (lopen) gaan.
|
|
||||||
el.addEventListener('pointerup', (e) => { if (e.target.matches('input[type=range]')) setTimeout(() => e.target.blur(), 0); });
|
|
||||||
sync();
|
|
||||||
return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { LIST, FINAL, INDEX, clamp, shows, createBar };
|
|
||||||
})();
|
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Tectum
|
|
||||||
* Copyright © 2026 Machiel Heinen.
|
|
||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
||||||
* Zie LICENTIE.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
window.Ribbon = (() => {
|
|
||||||
const sources = {};
|
|
||||||
const SOURCE_ORDER = ['app', 'editor'];
|
|
||||||
let root = null, tabsEl = null, bodyEl = null;
|
|
||||||
let activeKey = null, lastPlainKey = null;
|
|
||||||
let auto = true;
|
|
||||||
let collapsed = false, notified = null;
|
|
||||||
const listeners = [];
|
|
||||||
const PREF_KEY = 'tectum.ribbon.collapsed';
|
|
||||||
|
|
||||||
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
|
|
||||||
const big = (label, iconName, attrs, cls) =>
|
|
||||||
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
|
||||||
const small = (label, iconName, attrs, cls) =>
|
|
||||||
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
|
|
||||||
const kv = (label, value, unit) =>
|
|
||||||
`<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
|
|
||||||
|
|
||||||
function allTabs() {
|
|
||||||
const byKey = new Map();
|
|
||||||
SOURCE_ORDER.concat(Object.keys(sources).filter(n => !SOURCE_ORDER.includes(n))).forEach(name => {
|
|
||||||
(sources[name] || []).forEach(t => {
|
|
||||||
if (!byKey.has(t.key)) byKey.set(t.key, { key: t.key, label: t.label, order: t.order || 0, contextual: !!t.contextual, icon: t.icon, parts: [] });
|
|
||||||
const m = byKey.get(t.key);
|
|
||||||
if (t.order !== undefined && name !== 'app') m.order = t.order;
|
|
||||||
if (t.label && name !== 'app') m.label = t.label;
|
|
||||||
if (t.contextual) m.contextual = true;
|
|
||||||
m.parts.push(t);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return Array.from(byKey.values()).sort((a, b) => (a.contextual - b.contextual) || (a.order - b.order));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTabs(list) {
|
|
||||||
tabsEl.innerHTML = list.map(t =>
|
|
||||||
`<button type="button" class="rb-tab${t.key === activeKey ? ' active' : ''}${t.contextual ? ' rb-ctx' : ''}" data-rb-tab="${t.key}">${t.contextual && t.icon ? icon(t.icon) : ''}<span>${t.label}</span></button>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBody(list) {
|
|
||||||
const tab = list.find(t => t.key === activeKey);
|
|
||||||
if (!tab) { bodyEl.innerHTML = ''; return; }
|
|
||||||
const groups = [];
|
|
||||||
tab.parts.forEach(p => (p.groups ? p.groups() : []).forEach(g => g && groups.push(g)));
|
|
||||||
bodyEl.innerHTML = groups.map(g =>
|
|
||||||
`<section class="rb-group${g.cls ? ' ' + g.cls : ''}"><div class="rb-group-body">${g.html}</div><div class="rb-group-label">${g.label}</div></section>`).join('') +
|
|
||||||
`<button type="button" class="rb-collapse" title="Ribbon inklappen (dubbelklik op een tabblad)">${icon('chevron-up')}</button>`;
|
|
||||||
tab.parts.forEach(p => p.wire && p.wire(bodyEl));
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
if (!root) return;
|
|
||||||
const list = allTabs();
|
|
||||||
if (!list.some(t => t.key === activeKey) || (auto && !list.some(t => t.key === activeKey && t.contextual))) {
|
|
||||||
const pick = [lastPlainKey, 'start'].find(k => list.some(t => t.key === k));
|
|
||||||
activeKey = pick || (list.find(t => !t.contextual) || {}).key || null;
|
|
||||||
}
|
|
||||||
renderTabs(list);
|
|
||||||
renderBody(list);
|
|
||||||
root.classList.toggle('rb-collapsed', collapsed);
|
|
||||||
if (notified !== activeKey) { notified = activeKey; listeners.forEach(fn => fn(activeKey)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function activate(key) {
|
|
||||||
const list = allTabs();
|
|
||||||
const tab = list.find(t => t.key === key);
|
|
||||||
if (!tab) return;
|
|
||||||
activeKey = key;
|
|
||||||
auto = false;
|
|
||||||
if (!tab.contextual) lastPlainKey = key;
|
|
||||||
if (collapsed) { collapsed = false; savePref(); }
|
|
||||||
render();
|
|
||||||
tab.parts.forEach(p => p.onShow && p.onShow());
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { } }
|
|
||||||
|
|
||||||
function init(el) {
|
|
||||||
root = el;
|
|
||||||
try { collapsed = localStorage.getItem(PREF_KEY) === '1'; } catch (e) { collapsed = false; }
|
|
||||||
tabsEl = el.querySelector('.rb-tabs');
|
|
||||||
bodyEl = el.querySelector('.rb-body');
|
|
||||||
tabsEl.addEventListener('click', (e) => {
|
|
||||||
const b = e.target.closest('[data-rb-tab]');
|
|
||||||
if (!b) return;
|
|
||||||
if (b.dataset.rbTab === activeKey && collapsed) { collapsed = false; savePref(); render(); return; }
|
|
||||||
activate(b.dataset.rbTab);
|
|
||||||
});
|
|
||||||
tabsEl.addEventListener('dblclick', (e) => {
|
|
||||||
if (!e.target.closest('[data-rb-tab]')) return;
|
|
||||||
collapsed = !collapsed; savePref(); render();
|
|
||||||
});
|
|
||||||
bodyEl.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.rb-collapse')) { collapsed = true; savePref(); render(); }
|
|
||||||
});
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSource(name, tabs, opts) {
|
|
||||||
sources[name] = tabs || [];
|
|
||||||
const ctx = (tabs || []).find(t => t.contextual);
|
|
||||||
if (opts && opts.focus && ctx) { activeKey = ctx.key; if (collapsed) { collapsed = false; savePref(); } }
|
|
||||||
render();
|
|
||||||
if (opts && opts.focus && ctx && ctx.onShow) ctx.onShow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
|
|
||||||
|
|
||||||
return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv };
|
|
||||||
})();
|
|
||||||
+9
-15
@@ -161,32 +161,26 @@ window.Skybox = (() => {
|
|||||||
function createToggle(toolbar, onChange) {
|
function createToggle(toolbar, onChange) {
|
||||||
let m = getMode();
|
let m = getMode();
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'view-filter';
|
wrap.className = 'tb-pop-wrap';
|
||||||
wrap.innerHTML = `<button type="button" class="btn-3d-toggle vf-btn" title="Omgeving"></button>
|
wrap.innerHTML = `<button type="button" class="tb-btn" title="Omgeving in 3D"></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>`;
|
<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);
|
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 = () => {
|
const sync = () => {
|
||||||
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i>${LABELS[m][1]}<i class="bi bi-chevron-down"></i>`;
|
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('.vf-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
|
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) => {
|
menu.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
const opt = e.target.closest('.pop-option');
|
||||||
const opt = e.target.closest('.vf-option');
|
|
||||||
if (!opt) return;
|
if (!opt) return;
|
||||||
menu.classList.remove('open');
|
close();
|
||||||
if (opt.dataset.mode === m) return;
|
if (opt.dataset.mode === m) return;
|
||||||
m = opt.dataset.mode;
|
m = opt.dataset.mode;
|
||||||
remember(m);
|
remember(m);
|
||||||
sync();
|
sync();
|
||||||
onChange(m);
|
onChange(m);
|
||||||
});
|
});
|
||||||
const closeOnOutside = () => {
|
|
||||||
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
|
|
||||||
menu.classList.remove('open');
|
|
||||||
};
|
|
||||||
document.addEventListener('click', closeOnOutside);
|
|
||||||
sync();
|
sync();
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-10
@@ -128,16 +128,56 @@ window.UI = (() => {
|
|||||||
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
|
if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycle3D(el) {
|
// Weergave van een editor: klassen fullscreen-2d/-3d op het editor-element (de looptocht bewaart en herstelt className).
|
||||||
el.classList.remove('fullscreen-2d');
|
const VIEWS = [
|
||||||
if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d');
|
{ key: 'split', icon: 'layout-split', label: '2D + 3D', key1: '1' },
|
||||||
else if (!el.classList.contains('render-only')) el.classList.add('render-only');
|
{ key: '2d', icon: 'square', label: 'Alleen 2D', key1: '2' },
|
||||||
else el.classList.remove('fullscreen-3d', 'render-only');
|
{ key: '3d', icon: 'box', label: 'Alleen 3D', key1: '3' }
|
||||||
}
|
];
|
||||||
function toggle2D(el) {
|
const viewOf = (el) => el.classList.contains('fullscreen-3d') ? '3d' : el.classList.contains('fullscreen-2d') ? '2d' : 'split';
|
||||||
el.classList.remove('fullscreen-3d', 'render-only');
|
function setView(el, mode) {
|
||||||
el.classList.toggle('fullscreen-2d');
|
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, text, onText, 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,43 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Tectum
|
|
||||||
* Copyright © 2026 Machiel Heinen.
|
|
||||||
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
||||||
* Zie LICENTIE.md.
|
|
||||||
*/
|
|
||||||
|
|
||||||
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));
|
|
||||||
});
|
|
||||||
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 };
|
||||||
|
})();
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -120,7 +120,10 @@ window.COST_DEFAULTS = {
|
|||||||
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
|
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 },
|
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 },
|
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 }
|
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 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+12
-7
@@ -167,7 +167,7 @@ window.CostModel = (() => {
|
|||||||
const opKey = p.seam ? `iw${p.id}` : null;
|
const opKey = p.seam ? `iw${p.id}` : null;
|
||||||
if (!opKey || !seenOpenings.has(opKey)) {
|
if (!opKey || !seenOpenings.has(opKey)) {
|
||||||
if (opKey) seenOpenings.add(opKey);
|
if (opKey) seenOpenings.add(opKey);
|
||||||
(p.config.openings || []).forEach(op => {
|
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||||
const fill = KozijnModel.normalize(op.fill);
|
const fill = KozijnModel.normalize(op.fill);
|
||||||
if (fill && fill.type === 'schuifdeur') {
|
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'})`,
|
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'})`,
|
||||||
@@ -206,13 +206,13 @@ window.CostModel = (() => {
|
|||||||
|
|
||||||
const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
|
const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
|
||||||
const rs = u.roof.specs, D = S.dak;
|
const rs = u.roof.specs, D = S.dak;
|
||||||
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] && u.roof.planes[0].data.calcData), 0);
|
|
||||||
u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true));
|
|
||||||
const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
|
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) {
|
if (cap) {
|
||||||
const n = cap.beams.length;
|
const n = cap.beams.length, join = cap.kind === 'join';
|
||||||
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));
|
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, 'wolfseindbalken', true);
|
addJoints(rf, n * V.verbindingenPerVloerbalk, join ? 'dakbalken' : 'wolfseindbalken', true);
|
||||||
}
|
}
|
||||||
addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage);
|
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);
|
addSheet(rf, 'Plaatwerk', `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm`, rs.beschotM2 || 0, D.beschotM2, D.beschotMontage);
|
||||||
@@ -230,7 +230,7 @@ window.CostModel = (() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null);
|
const wo = newEl('WON', 'Woning (dakrand, overkapping, fundering, verlichting)', null);
|
||||||
const D = S.dak;
|
const D = S.dak;
|
||||||
if (totals.roofEdge) {
|
if (totals.roofEdge) {
|
||||||
const e = totals.roofEdge;
|
const e = totals.roofEdge;
|
||||||
@@ -238,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', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
|
||||||
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
|
add(wo, 'Dak', 'Dak', `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) {
|
if (parts.sandwich) {
|
||||||
const sw = 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);
|
add(wo, 'Vloer', 'Vloer', `Sandwichpanelen (${sw.panelCount} platen)`, sw.areaM2, 'm²', sw.areaM2 * S.vloer.sandwichM2, sw.panelCount * S.vloer.sandwichMontage);
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ window.CostSettings = (() => {
|
|||||||
<div class="cs-body"></div>
|
<div class="cs-body"></div>
|
||||||
<div class="material-info">Bedragen excl. btw. De CSV's in kosten/ zijn leidend: Toepassen geldt tot herladen; om een wijziging te houden "intern.csv downloaden" en het bestand in kosten/ vervangen, of met "Wijzigingen downloaden" alleen de aangepaste velden (met de standaardwaarde ernaast) opslaan. Prijzen pas je aan in leveranciers.csv (CSV = daaruit, anders noodwaarde). Een veld dat afwijkt van intern.csv is gemarkeerd.</div>
|
<div class="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">
|
<div class="modal-footer">
|
||||||
<button type="button" class="io-btn" data-cs="reset">Standaard</button>
|
<button type="button" class="ui-btn" data-cs="reset">Standaard</button>
|
||||||
<button type="button" class="io-btn" data-cs="export">intern.csv downloaden</button>
|
<button type="button" class="ui-btn" data-cs="export">intern.csv downloaden</button>
|
||||||
<button type="button" class="io-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
|
<button type="button" class="ui-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
|
||||||
<button type="button" class="io-btn" data-cs="import">Importeren…</button>
|
<button type="button" class="ui-btn" data-cs="import">Importeren…</button>
|
||||||
<span style="flex:1"></span>
|
<span style="flex:1"></span>
|
||||||
<button type="button" class="io-btn" data-cs="cancel">Annuleren</button>
|
<button type="button" class="ui-btn" data-cs="cancel">Annuleren</button>
|
||||||
<button type="button" class="material-toggle-btn active" data-cs="save" style="flex:none; padding:6px 18px;">Toepassen</button>
|
<button type="button" class="ui-btn ui-btn-primary" data-cs="save">Toepassen</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" 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">
|
<input type="file" data-file="leveranciers" accept=".csv,text/csv,text/plain" style="display:none">
|
||||||
|
|||||||
+135
-24
@@ -12,6 +12,7 @@ window.ElektraModel = (() => {
|
|||||||
switchHeight: 1500,
|
switchHeight: 1500,
|
||||||
wcdHeight: 400,
|
wcdHeight: 400,
|
||||||
wcdPerUnit: 2,
|
wcdPerUnit: 2,
|
||||||
|
plateColor: 'wit', // schakelaars, wcd's, apparaat-wcd's en vloerdozen
|
||||||
slack: 10, // %
|
slack: 10, // %
|
||||||
reserve: 300,
|
reserve: 300,
|
||||||
meterkastReserve: 500,
|
meterkastReserve: 500,
|
||||||
@@ -32,6 +33,11 @@ window.ElektraModel = (() => {
|
|||||||
droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
|
droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
|
||||||
warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' }
|
warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' }
|
||||||
};
|
};
|
||||||
|
const PLATE_COLORS = {
|
||||||
|
wit: { label: 'Wit', hex: 0xf6f6f2 },
|
||||||
|
antraciet: { label: 'Antraciet', hex: 0x3a3d41 },
|
||||||
|
zwart: { label: 'Zwart', hex: 0x161718 }
|
||||||
|
};
|
||||||
const BOX_PITCH = 71;
|
const BOX_PITCH = 71;
|
||||||
const PLATE = 80;
|
const PLATE = 80;
|
||||||
const WCD_W = 150;
|
const WCD_W = 150;
|
||||||
@@ -62,6 +68,7 @@ window.ElektraModel = (() => {
|
|||||||
s.removed = Array.isArray(i.removed) ? i.removed.slice() : [];
|
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.vloerWcd = Array.isArray(i.vloerWcd) ? i.vloerWcd.filter(o => o && o.unitId).map(o => Object.assign({}, o)) : [];
|
||||||
s.enabled = s.enabled !== false;
|
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).
|
// v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard).
|
||||||
if (!i.v) {
|
if (!i.v) {
|
||||||
if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight;
|
if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight;
|
||||||
@@ -136,9 +143,30 @@ window.ElektraModel = (() => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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);
|
let u = unitAt(x, z);
|
||||||
if (!u) u = units.reduce((best, c) => {
|
if (!u) u = units.reduce((best, c) => {
|
||||||
const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1);
|
const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1);
|
||||||
@@ -148,24 +176,103 @@ window.ElektraModel = (() => {
|
|||||||
const r = u.rect;
|
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)));
|
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)));
|
||||||
};
|
};
|
||||||
|
// 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 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) {
|
function path(a, b) {
|
||||||
const P = (p, wall, ceil, floor) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil, floor: !!floor });
|
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') {
|
if (a.mount === 'vloer' || b.mount === 'vloer') {
|
||||||
const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH;
|
const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH;
|
||||||
return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true), P({ x: b.x, y: fa, z: a.z }, null, false, true),
|
return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true)]
|
||||||
P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')];
|
.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)];
|
if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
|
||||||
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a), P({ x: b.x, y: a.y, z: a.z }), P(b)];
|
// Gevellamp naar gevellamp: langs de gevel, bij een binnenhoek niet dwars door de woning.
|
||||||
const top = (p) => p.mount === 'plafond' ? p.y : ceilAt(p.x, p.z);
|
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 ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1;
|
||||||
const pts = [P(a, a.face, a.mount === 'plafond')];
|
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));
|
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));
|
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));
|
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);
|
const length = (pts) => pts.slice(1).reduce((s, q, i) => s + Math.hypot(q.x - pts[i].x, q.y - pts[i].y, q.z - pts[i].z), 0);
|
||||||
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
|
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
|
||||||
@@ -194,7 +301,7 @@ window.ElektraModel = (() => {
|
|||||||
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
|
groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: []
|
||||||
};
|
};
|
||||||
if (!s.enabled || !units.length) return out;
|
if (!s.enabled || !units.length) return out;
|
||||||
const R = makeRouter(units);
|
const R = makeRouter(units, rooms);
|
||||||
const faces = {};
|
const faces = {};
|
||||||
units.forEach(u => { faces[u.id] = facesOf(u, interior); });
|
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 faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1);
|
||||||
@@ -561,6 +668,7 @@ window.ElektraModel = (() => {
|
|||||||
out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; });
|
out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; });
|
||||||
const m = out.materials;
|
const m = out.materials;
|
||||||
const add = (naam, aantal, eenheid) => { if (aantal > 0) m.push({ naam, aantal, eenheid: eenheid || 'st.' }); };
|
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) {
|
if (out.meterkast) {
|
||||||
add('Groepenkast 3-fase (aansluiting 3×25 A)', 1);
|
add('Groepenkast 3-fase (aansluiting 3×25 A)', 1);
|
||||||
add('Hoofdschakelaar 4-polig 40 A', 1);
|
add('Hoofdschakelaar 4-polig 40 A', 1);
|
||||||
@@ -571,13 +679,13 @@ window.ElektraModel = (() => {
|
|||||||
add('Installatieautomaat B16 1P+N (wcd)', nWcdG);
|
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 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('Installatieautomaat 2× B16, 2-fase (kookgroep)', out.apps.filter(a => APPLIANCES[a.app].poles === 2).length);
|
||||||
add('Schakelaar enkelpolig (inbouw)', out.switches.length);
|
add('Schakelaar enkelpolig (inbouw)' + kleur, out.switches.length);
|
||||||
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig`, frames[n]));
|
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig` + kleur, frames[n]));
|
||||||
add('Wandcontactdoos dubbel met randaarde', out.wcds.length);
|
add('Wandcontactdoos dubbel met randaarde' + kleur, out.wcds.length);
|
||||||
add('Vloerdoos met dubbele wcd (randaarde)', out.floorWcds.length);
|
add('Vloerdoos met dubbele wcd (randaarde)' + kleur, out.floorWcds.length);
|
||||||
const appMat = {};
|
const appMat = {};
|
||||||
out.apps.forEach(a => { const n = APPLIANCES[a.app].material; appMat[n] = (appMat[n] || 0) + 1; });
|
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 hollewanddoos (schakelaar/wcd/apparaat)', out.switches.length + 2 * out.wcds.length + out.apps.length);
|
||||||
add('Luchtdichte verdeeldoos (plafond)', out.boxes.length);
|
add('Luchtdichte verdeeldoos (plafond)', out.boxes.length);
|
||||||
add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length);
|
add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length);
|
||||||
@@ -727,12 +835,13 @@ window.ElektraModel = (() => {
|
|||||||
conduit
|
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();
|
const toM = 0.001, group = new THREE.Group();
|
||||||
if (!el || !el.enabled) return group;
|
if (!el || !el.enabled) return group;
|
||||||
const mats = materials || createMaterials();
|
const mats = materials || createMaterials();
|
||||||
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key);
|
mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex);
|
||||||
const rough = show('elektra') && !show('beplating');
|
if (!ViewLayers.shows(layers, 'elektra')) return group;
|
||||||
const box = (w, h, d, c, rotY, mat) => {
|
const box = (w, h, d, c, rotY, mat) => {
|
||||||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
|
||||||
mesh.position.set(c.x * toM, c.y * toM, c.z * toM);
|
mesh.position.set(c.x * toM, c.y * toM, c.z * toM);
|
||||||
@@ -741,24 +850,24 @@ window.ElektraModel = (() => {
|
|||||||
return mesh;
|
return mesh;
|
||||||
};
|
};
|
||||||
const k = el.meterkast;
|
const k = el.meterkast;
|
||||||
if (k && show('elektra')) {
|
if (k) {
|
||||||
box(k.w, k.h, k.d, k.center, k.rotY, mats.kastMaterial);
|
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);
|
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);
|
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.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.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.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));
|
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;
|
||||||
|
|
||||||
const up = new THREE.Vector3(0, 1, 0);
|
const up = new THREE.Vector3(0, 1, 0);
|
||||||
const shift = (p) => ({
|
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)
|
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);
|
const c = cableInfo(r.type);
|
||||||
if (!c.conduit) return;
|
if (!c.conduit) return;
|
||||||
const rad = c.conduit / 2 * toM;
|
const rad = c.conduit / 2 * toM;
|
||||||
@@ -770,11 +879,13 @@ window.ElektraModel = (() => {
|
|||||||
const m = new THREE.Mesh(new THREE.CylinderGeometry(rad, rad, len * toM, 6), mat);
|
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.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.quaternion.setFromUnitVectors(up, new THREE.Vector3(dx / len, dy / len, dz / len));
|
||||||
|
m.userData.run = ri;
|
||||||
group.add(m);
|
group.add(m);
|
||||||
});
|
});
|
||||||
pts.slice(1, -1).forEach(p => {
|
pts.slice(1, -1).forEach(p => {
|
||||||
const j = new THREE.Mesh(new THREE.IcosahedronGeometry(rad * 1.15, 0), mat);
|
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.position.set(p.x * toM, p.y * toM, p.z * toM);
|
||||||
|
j.userData.run = ri;
|
||||||
group.add(j);
|
group.add(j);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -848,7 +959,7 @@ window.ElektraModel = (() => {
|
|||||||
} else {
|
} 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;
|
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) {
|
} 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);
|
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 => {
|
if (u) u.roof.planes.forEach(pl => {
|
||||||
const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds);
|
const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds);
|
||||||
@@ -864,5 +975,5 @@ window.ElektraModel = (() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D };
|
return { DEFAULT_SETTINGS, PLATE_COLORS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+130
-34
@@ -102,6 +102,101 @@ window.FloorModel = (() => {
|
|||||||
return { joists, width, depth, joistW, joistH, underlayThick, rims: r, joistLen };
|
return { joists, width, depth, joistW, joistH, underlayThick, rims: r, joistLen };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function topHeight(calcData) {
|
||||||
return calcData.joistH + calcData.underlayThick;
|
return calcData.joistH + calcData.underlayThick;
|
||||||
}
|
}
|
||||||
@@ -115,7 +210,12 @@ window.FloorModel = (() => {
|
|||||||
const rimCount = calcData.rims ? (calcData.rims.front ? 1 : 0) + (calcData.rims.back ? 1 : 0) : 0;
|
const rimCount = calcData.rims ? (calcData.rims.front ? 1 : 0) + (calcData.rims.back ? 1 : 0) : 0;
|
||||||
const joistLenM = (calcData.joistLen || depth) * toM;
|
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 joistWoodKg = joistWoodM3 * DENSITY_WOOD_KGM3;
|
||||||
|
|
||||||
const grossAreaM2 = widthM * depthM;
|
const grossAreaM2 = widthM * depthM;
|
||||||
@@ -125,6 +225,7 @@ window.FloorModel = (() => {
|
|||||||
holesAreaM2 += Math.PI * rM * rM;
|
holesAreaM2 += Math.PI * rM * rM;
|
||||||
holesVoidM3 += Math.PI * rM * rM * joistHM;
|
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 osbAreaM2 = Math.max(0, grossAreaM2 - holesAreaM2);
|
||||||
const osbVolM3 = osbAreaM2 * underlayM;
|
const osbVolM3 = osbAreaM2 * underlayM;
|
||||||
const osbKg = osbVolM3 * DENSITY_OSB_KGM3;
|
const osbKg = osbVolM3 * DENSITY_OSB_KGM3;
|
||||||
@@ -154,6 +255,7 @@ window.FloorModel = (() => {
|
|||||||
|
|
||||||
function computeFromState(state, rims) {
|
function computeFromState(state, rims) {
|
||||||
const calcData = computeFrame(state.floor, 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 resolvedOpenings = resolveFloorOpenings(state.openings || [], calcData.width, calcData.depth, calcData.joists, calcData.joistW);
|
||||||
const chassisData = computeChassisWheels(state.chassis, calcData.width, calcData.depth);
|
const chassisData = computeChassisWheels(state.chassis, calcData.width, calcData.depth);
|
||||||
return { calcData, resolvedOpenings, chassisData };
|
return { calcData, resolvedOpenings, chassisData };
|
||||||
@@ -240,7 +342,22 @@ window.FloorModel = (() => {
|
|||||||
const rims = calcData.rims || {};
|
const rims = calcData.rims || {};
|
||||||
const joistLenM = (calcData.joistLen || depth) * toM;
|
const joistLenM = (calcData.joistLen || depth) * toM;
|
||||||
const joistZ = ((rims.front ? joistW : 0) - (rims.back ? joistW : 0)) / 2 * toM;
|
const joistZ = ((rims.front ? joistW : 0) - (rims.back ? joistW : 0)) / 2 * toM;
|
||||||
joists.forEach((xMM, 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;
|
if (opts.outerJoistsOnly && i > 0 && i < joists.length - 1) return;
|
||||||
const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM);
|
const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM);
|
||||||
const mesh = new THREE.Mesh(geo, woodMaterial);
|
const mesh = new THREE.Mesh(geo, woodMaterial);
|
||||||
@@ -248,7 +365,14 @@ window.FloorModel = (() => {
|
|||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
floorGroup3D.add(mesh);
|
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);
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(widthM, joistHM, joistWM), woodMaterial);
|
||||||
mesh.position.set(0, joistHM / 2, sign * (depthM - joistWM) / 2);
|
mesh.position.set(0, joistHM / 2, sign * (depthM - joistWM) / 2);
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
@@ -257,24 +381,7 @@ window.FloorModel = (() => {
|
|||||||
|
|
||||||
// Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.
|
// Isolatie wordt niet getekend; de leegte-inhoud zit in computeSpecs.
|
||||||
|
|
||||||
const osbShape = new THREE.Shape();
|
const osbGeo = new THREE.ExtrudeGeometry(surfaceShapes(calcData, resolvedOpenings, toM), { steps: 1, depth: underlayM, bevelEnabled: false });
|
||||||
osbShape.moveTo(-widthM / 2, -depthM / 2);
|
|
||||||
osbShape.lineTo(widthM / 2, -depthM / 2);
|
|
||||||
osbShape.lineTo(widthM / 2, depthM / 2);
|
|
||||||
osbShape.lineTo(-widthM / 2, depthM / 2);
|
|
||||||
osbShape.closePath();
|
|
||||||
|
|
||||||
resolvedOpenings.filter(o => !o.removed).forEach(o => {
|
|
||||||
const cx = o.x * toM;
|
|
||||||
const cz = o.y * toM - depthM / 2;
|
|
||||||
const rM = o.r * toM;
|
|
||||||
if (rM <= 0) return;
|
|
||||||
const hole = new THREE.Path();
|
|
||||||
hole.absarc(cx, cz, rM, 0, Math.PI * 2, false);
|
|
||||||
osbShape.holes.push(hole);
|
|
||||||
});
|
|
||||||
|
|
||||||
const osbGeo = new THREE.ExtrudeGeometry(osbShape, { steps: 1, depth: underlayM, bevelEnabled: false });
|
|
||||||
const osbMesh = new THREE.Mesh(osbGeo, osbMaterial);
|
const osbMesh = new THREE.Mesh(osbGeo, osbMaterial);
|
||||||
osbMesh.rotation.x = Math.PI / 2;
|
osbMesh.rotation.x = Math.PI / 2;
|
||||||
osbMesh.position.set(0, joistHM + underlayM, 0);
|
osbMesh.position.set(0, joistHM + underlayM, 0);
|
||||||
@@ -304,18 +411,7 @@ window.FloorModel = (() => {
|
|||||||
|
|
||||||
const fin = opts.finish && finishMaterial(opts.finish.key);
|
const fin = opts.finish && finishMaterial(opts.finish.key);
|
||||||
if (fin) {
|
if (fin) {
|
||||||
const shape = new THREE.Shape();
|
const geo = new THREE.ShapeGeometry(surfaceShapes(calcData, resolvedOpenings, toM));
|
||||||
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 ox = opts.finish.originX || 0, oz = opts.finish.originZ || 0;
|
const ox = opts.finish.originX || 0, oz = opts.finish.originZ || 0;
|
||||||
const pos = geo.attributes.position, uv = geo.attributes.uv;
|
const pos = geo.attributes.position, uv = geo.attributes.uv;
|
||||||
// Na het kantelen (-90° om X) wordt vorm-y wereld -z.
|
// Na het kantelen (-90° om X) wordt vorm-y wereld -z.
|
||||||
@@ -353,6 +449,6 @@ window.FloorModel = (() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
FINISHES, finishMaterial, finishThumbUrl, tileTexture, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
|
FINISHES, finishMaterial, finishThumbUrl, tileTexture, validateState, computeFrame, topHeight, resolveOpenings: resolveFloorOpenings, computeChassisWheels,
|
||||||
computeSpecs, computeFromState, createMaterials, build3D
|
computeSpecs, computeFromState, createMaterials, build3D, surfaceShapes, joistLayout
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+38
-51
@@ -6,7 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const FLOOR_TEMPLATE = `
|
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-left-column">
|
||||||
<div id="floor-pane-2d-container">
|
<div id="floor-pane-2d-container">
|
||||||
<div class="pane-2d-sub" id="floor-pane-top">
|
<div class="pane-2d-sub" id="floor-pane-top">
|
||||||
@@ -18,7 +21,15 @@ const FLOOR_TEMPLATE = `
|
|||||||
<canvas id="floor-canvas-section"></canvas>
|
<canvas id="floor-canvas-section"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="io-toolbar">
|
||||||
<input type="file" id="f-file-import-json" accept="application/json" style="display:none;">
|
<input type="file" id="f-file-import-json" accept="application/json" style="display:none;">
|
||||||
<span id="f-io-status" class="io-status"></span>
|
<span id="f-io-status" class="io-status"></span>
|
||||||
@@ -27,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 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-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-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>
|
||||||
|
|
||||||
<div id="floor-tab-main" class="tab-content active">
|
<div id="floor-tab-main" class="tab-content active">
|
||||||
@@ -57,20 +69,6 @@ const FLOOR_TEMPLATE = `
|
|||||||
<div class="control-group"><label>Afstand max.</label>
|
<div class="control-group"><label>Afstand max.</label>
|
||||||
<div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div>
|
<div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
<div class="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,9 +80,8 @@ const FLOOR_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="floor-tab-chassis" class="tab-content">
|
<div id="floor-tab-chassis" class="tab-content">
|
||||||
<div class="material-toggle-group">
|
<div class="control-group"><label for="f-chassis-on">Chassis-laag</label>
|
||||||
<button type="button" id="f-btn-chassis-toggle" class="material-toggle-btn">Chassis-laag: Uit</button>
|
<div><label class="ui-switch"><input type="checkbox" id="f-chassis-on"><span></span></label></div></div>
|
||||||
</div>
|
|
||||||
<div class="controls-grid">
|
<div class="controls-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="control-group"><label>Wieldiameter</label>
|
<div class="control-group"><label>Wieldiameter</label>
|
||||||
@@ -99,17 +96,9 @@ const FLOOR_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
<div id="f-chassis-warning" class="global-warning" style="display:none;"></div>
|
<div id="f-chassis-warning" class="global-warning" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div id="floor-tab-specs" class="tab-content">
|
||||||
</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-specs-panel-3d">
|
<div id="floor-specs-panel-3d">
|
||||||
<div class="controls-grid">
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div class="spec-section">
|
<div class="spec-section">
|
||||||
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
||||||
@@ -139,6 +128,7 @@ const FLOOR_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="wall-project-overlay">
|
<div id="wall-project-overlay">
|
||||||
<div id="wall-project-modal">
|
<div id="wall-project-modal">
|
||||||
@@ -169,8 +159,8 @@ const FLOOR_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" id="wall-project-cancel" class="io-btn">Annuleren</button>
|
<button type="button" id="wall-project-cancel" class="ui-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-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,16 +275,14 @@ function mountFloorEditor(container, callbacks) {
|
|||||||
document.querySelectorAll('#floor-tab-main input').forEach(inp => inp.addEventListener('input', updateAll));
|
document.querySelectorAll('#floor-tab-main input').forEach(inp => inp.addEventListener('input', updateAll));
|
||||||
|
|
||||||
function syncChassisToggleLabel() {
|
function syncChassisToggleLabel() {
|
||||||
const btn = document.getElementById('f-btn-chassis-toggle');
|
document.getElementById('f-chassis-on').checked = chassis.enabled;
|
||||||
btn.classList.toggle('active', chassis.enabled);
|
|
||||||
btn.textContent = 'Chassis-laag: ' + (chassis.enabled ? 'Aan' : 'Uit');
|
|
||||||
}
|
}
|
||||||
document.getElementById('f-btn-chassis-toggle').addEventListener('click', () => {
|
document.getElementById('f-chassis-on').addEventListener('change', (e) => {
|
||||||
chassis.enabled = !chassis.enabled;
|
chassis.enabled = e.target.checked;
|
||||||
syncChassisToggleLabel();
|
syncChassisToggleLabel();
|
||||||
updateAll();
|
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.wheelDiameter = parseFloat(document.getElementById('f-inp-wheel-d').value) || 400;
|
||||||
chassis.trackWidth = parseFloat(document.getElementById('f-inp-track').value) || 1200;
|
chassis.trackWidth = parseFloat(document.getElementById('f-inp-track').value) || 1200;
|
||||||
chassis.wheelbase = parseFloat(document.getElementById('f-inp-wheelbase').value) || 1200;
|
chassis.wheelbase = parseFloat(document.getElementById('f-inp-wheelbase').value) || 1200;
|
||||||
@@ -632,11 +620,14 @@ function mountFloorEditor(container, callbacks) {
|
|||||||
let lastResolvedOpenings = [];
|
let lastResolvedOpenings = [];
|
||||||
let lastChassisData = { wheels: [], outOfBounds: false };
|
let lastChassisData = { wheels: [], outOfBounds: false };
|
||||||
|
|
||||||
createViewFilter(document.getElementById('floor-toolbar-3d'), [
|
const toolbar = document.getElementById('floor-toolbar');
|
||||||
{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }
|
toolbar.innerHTML = `<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${UI.tbSep()}${UI.viewSeg('split')}`;
|
||||||
], (v) => {
|
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||||
osbVisible = v.beplating;
|
const viewGroup = toolbar.querySelector('.tb-view');
|
||||||
update3DScene();
|
ViewLayers.createMenu(viewGroup, {
|
||||||
|
layers: {}, keys: [],
|
||||||
|
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
|
||||||
|
onExtra: (v) => { osbVisible = v.beplating; update3DScene(); }
|
||||||
});
|
});
|
||||||
let skyMode = Skybox.getMode();
|
let skyMode = Skybox.getMode();
|
||||||
const skyEnv = Skybox.createEnvironment(scene);
|
const skyEnv = Skybox.createEnvironment(scene);
|
||||||
@@ -644,7 +635,7 @@ function mountFloorEditor(container, callbacks) {
|
|||||||
skyEnv.fitTo(floorGroup3D, 0);
|
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.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() {
|
function update3DScene() {
|
||||||
if (floorGroup3D) scene.remove(floorGroup3D);
|
if (floorGroup3D) scene.remove(floorGroup3D);
|
||||||
@@ -691,12 +682,8 @@ function mountFloorEditor(container, callbacks) {
|
|||||||
}
|
}
|
||||||
window.addEventListener('resize', onWindowResize);
|
window.addEventListener('resize', onWindowResize);
|
||||||
|
|
||||||
function toggleFullscreen3D() {
|
function setView(mode) {
|
||||||
UI.cycle3D(document.getElementById('floor-app'));
|
UI.setView(document.getElementById('floor-app'), mode);
|
||||||
requestAnimationFrame(onWindowResize);
|
|
||||||
}
|
|
||||||
function toggleFullscreen2D() {
|
|
||||||
UI.toggle2D(document.getElementById('floor-app'));
|
|
||||||
requestAnimationFrame(onWindowResize);
|
requestAnimationFrame(onWindowResize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,8 +735,8 @@ function mountFloorEditor(container, callbacks) {
|
|||||||
},
|
},
|
||||||
exportDesign,
|
exportDesign,
|
||||||
triggerImport,
|
triggerImport,
|
||||||
toggleFullscreen3D,
|
setView,
|
||||||
toggleFullscreen2D,
|
viewState: () => ({ view: UI.viewOf(document.getElementById('floor-app')) }),
|
||||||
openProjectProperties,
|
openProjectProperties,
|
||||||
getStateJson: getPureState,
|
getStateJson: getPureState,
|
||||||
loadStateJson: applyPureState
|
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 };
|
||||||
|
})();
|
||||||
+648
-35
@@ -74,7 +74,7 @@ window.HouseModel = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function newModelState(naam) {
|
function newModelState(naam) {
|
||||||
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [] };
|
const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [], rooms: [], fixtures: [], chimneys: [] };
|
||||||
st.project.naam = naam || '';
|
st.project.naam = naam || '';
|
||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
@@ -123,14 +123,14 @@ window.HouseModel = (() => {
|
|||||||
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), { out: 'zwart', in: 'zwart' }, extra || {});
|
const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), { out: 'zwart', in: 'zwart' }, extra || {});
|
||||||
const binnen = (extra) => Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit', glas: false }, extra || {});
|
const binnen = (extra) => Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit', glas: false }, extra || {});
|
||||||
const ops = (ws, list) => { ws.openings = list.map(([type, w, h, y, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y, anchor, offset, fill })); };
|
const ops = (ws, list) => { ws.openings = list.map(([type, w, h, y, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y, anchor, offset, fill })); };
|
||||||
const iw = (x1, z1, x2, z2, openings) => {
|
const iw = (x1, z1, x2, z2, openings, t) => {
|
||||||
const wall = interiorWallDefaults(T, H);
|
const wall = interiorWallDefaults(t || T, H);
|
||||||
wall.openings = (openings || []).map(([type, w, h, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y: 0, anchor, offset, fill }));
|
wall.openings = (openings || []).map(([type, w, h, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y: 0, anchor, offset, fill }));
|
||||||
st.interiorWalls.push({ id: st.interiorWalls.length + 1, x1, z1, x2, z2, wall });
|
st.interiorWalls.push({ id: st.interiorWalls.length + 1, x1, z1, x2, z2, wall });
|
||||||
};
|
};
|
||||||
// zaadpunt t.o.v. de noordwesthoek van de unit
|
// zaadpunt t.o.v. de noordwesthoek van de unit
|
||||||
const room = (unitId, x, z, type, name) =>
|
const room = (unitId, x, z, type, name, extra) =>
|
||||||
st.rooms.push(Object.assign(RoomModel.applyType({ id: st.rooms.length + 1, unitId, x, z, name: '' }, type), { name: name || '' }));
|
st.rooms.push(Object.assign(RoomModel.applyType({ id: st.rooms.length + 1, unitId, x, z, name: '' }, type), { name: name || '' }, extra || {}));
|
||||||
// Midden voetafdruk t.o.v. de noordwesthoek van de unit; rot = richting van de rug (0 = noord, 90 = oost).
|
// Midden voetafdruk t.o.v. de noordwesthoek van de unit; rot = richting van de rug (0 = noord, 90 = oost).
|
||||||
// anchor 'oost': x opgeven vanaf de westgevel, het meubel schuift mee met de oostgevel.
|
// anchor 'oost': x opgeven vanaf de westgevel, het meubel schuift mee met de oostgevel.
|
||||||
const fx = (type, unitId, x, z, rot, size, opts) => {
|
const fx = (type, unitId, x, z, rot, size, opts) => {
|
||||||
@@ -154,6 +154,7 @@ window.HouseModel = (() => {
|
|||||||
S.lighting.mode = 'gevel';
|
S.lighting.mode = 'gevel';
|
||||||
S.lighting.direction = 'both';
|
S.lighting.direction = 'both';
|
||||||
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
||||||
|
if (S.elektra) S.elektra.plateColor = 'antraciet';
|
||||||
|
|
||||||
const A = createUnitEntry('A', null, { width: 11240, depth: 3600, height: H, windowOn: [] });
|
const A = createUnitEntry('A', null, { width: 11240, depth: 3600, height: H, windowOn: [] });
|
||||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10600, depth: 3600, height: H, windowOn: [] });
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10600, depth: 3600, height: H, windowOn: [] });
|
||||||
@@ -243,6 +244,7 @@ window.HouseModel = (() => {
|
|||||||
S.lighting.mode = 'gevel';
|
S.lighting.mode = 'gevel';
|
||||||
S.lighting.direction = 'both';
|
S.lighting.direction = 'both';
|
||||||
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
||||||
|
if (S.elektra) S.elektra.plateColor = 'antraciet';
|
||||||
|
|
||||||
const A = createUnitEntry('A', null, { width: W, depth: DA, height: H, windowOn: [] });
|
const A = createUnitEntry('A', null, { width: W, depth: DA, height: H, windowOn: [] });
|
||||||
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: DB, height: H, windowOn: [] });
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: DB, height: H, windowOn: [] });
|
||||||
@@ -294,10 +296,378 @@ window.HouseModel = (() => {
|
|||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model IJssel (examples/ontwerp_ijssel.pdf): L-vorm 14 × 4,6 m met een vleugel van 4,6 × 3 m aan de zuidoostkant,
|
||||||
|
// samen 14 × 7,6 m. Twee units: hoofdvolume met zadeldak west–oost, vleugel met zadeldak noord–zuid. Gelijke
|
||||||
|
// goot, nok en overspanning, dus de daken sluiten onder 45° aan (dakaansluiting, zie roofJoins): kil in de
|
||||||
|
// binnenhoek en, omdat de vleugel gelijk ligt met de oostgevel, een hoekkeper (de oostkop wordt een schild).
|
||||||
|
// Geen naadwanden; de wand tussen hal/badkamer en slaapkamer is een gewone binnenwand. Binnenwanden volgen het
|
||||||
|
// dak nog niet, dus alle ruimtes krijgen een vlak plafond op wandhoogte. Alles buitenwerks vanaf de noordwesthoek.
|
||||||
|
function createIJssel() {
|
||||||
|
const st = newModelState('IJssel');
|
||||||
|
const S = st.settings, LO = 3000, HI = 4600, H = 2900, D = 4600, ROOF = 'riet_001';
|
||||||
|
Object.assign(S.facade, { type: 'stuc', stucColor: 'wit' });
|
||||||
|
Object.assign(S.trasraam, { enabled: true, height: 600, finish: 'R510' });
|
||||||
|
S.roof.fasciaColor = 'zwart';
|
||||||
|
S.lighting.mode = 'spots';
|
||||||
|
S.lighting.direction = 'both';
|
||||||
|
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
||||||
|
if (S.elektra) S.elektra.plateColor = 'antraciet';
|
||||||
|
|
||||||
|
const A = createUnitEntry('A', null, { width: 14000, depth: D, height: LO, windowOn: [] });
|
||||||
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 4600, depth: 3000, height: LO, windowOn: [] });
|
||||||
|
st.units.push(A, B);
|
||||||
|
UnitModel.applyRoofForm(A.unit, { type: 'zadel', ridge: 'x', low: LO, high: HI });
|
||||||
|
UnitModel.applyRoofForm(B.unit, { type: 'zadel', ridge: 'z', low: LO, high: HI });
|
||||||
|
[A, B].forEach(u => { u.roof.covering = ROOF; });
|
||||||
|
|
||||||
|
const { K, binnen, ops, iw, room, fx } = modelKit(st, H, 90);
|
||||||
|
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
|
||||||
|
ops(A.unit.walls.voor, [['raam', 1500, 500, 1600, 'right', 5445, K('raam')], ['raam', 500, 700, 1400, 'right', 9950, K('raam')]]);
|
||||||
|
ops(A.unit.walls.links, [
|
||||||
|
['deur', 1424, 2100, 0, 'left', 610, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
|
||||||
|
['deur', 1424, 2100, 0, 'left', 2376, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })]]);
|
||||||
|
ops(A.unit.walls.rechts, [['raam', 1400, 1100, 1000, 'right', 608, K('raam')], ['raam', 1200, 500, 1600, 'right', 3101, K('raam')]]);
|
||||||
|
ops(A.unit.walls.achter, [
|
||||||
|
['raam', 1800, 2800, 0, 'left', 3215, K('raam')],
|
||||||
|
['voordeur', 1100, 2100, 0, 'left', 8240, K('deur', { hinge: 'rechts', swing: 'buiten', glas: true })]]);
|
||||||
|
ops(B.unit.walls.achter, [['raam', 500, 1400, 700, 'left', 410, K('raam')], ['raam', 500, 1400, 700, 'right', 1100, K('raam')]]);
|
||||||
|
A.lighting.walls.achter = 'openingen';
|
||||||
|
|
||||||
|
const DH = 2115;
|
||||||
|
iw(9580, 190, 9580, 3220, [], 160); // keuken | toilet, hal
|
||||||
|
iw(8060, 3265, 9660, 3265); // woonkamer | hal
|
||||||
|
iw(8105, 3310, 8105, 4410, [['deur', 930, DH, 'left', 86, binnen({ hinge: 'rechts', swing: 'buiten' })]]); // hal → woonkamer
|
||||||
|
iw(9660, 1633, 10760, 1633, [['deur', 930, DH, 'left', 85, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet | hal
|
||||||
|
iw(10805, 190, 10805, 4410, [['deur', 930, DH, 'left', 1725, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // hal | berging, badkamer
|
||||||
|
iw(10850, 2965, 13810, 2965); // berging | badkamer
|
||||||
|
iw(9590, 4455, 13810, 4455, [ // hal, badkamer | slaapkamer
|
||||||
|
['deur', 930, DH, 'left', 115, binnen({ hinge: 'links', swing: 'buiten' })],
|
||||||
|
['deur', 980, DH, 'left', 2350, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
|
||||||
|
|
||||||
|
const plafond = { ceiling: H };
|
||||||
|
room('A', 3000, 1500, 'woonkeuken', 'Woonkamer/keuken', plafond);
|
||||||
|
room('A', 10210, 900, 'toilet');
|
||||||
|
room('A', 12300, 1500, 'berging', 'Berging', plafond);
|
||||||
|
room('A', 9000, 3800, 'hal', 'Hal', plafond);
|
||||||
|
room('A', 12300, 3700, 'badkamer');
|
||||||
|
room('B', 2000, 1400, 'slaapkamer', 'Slaapkamer', plafond);
|
||||||
|
|
||||||
|
const xB = (x) => x - 9400, zB = (z) => z - D;
|
||||||
|
fx('bank', 'A', 2030, 690, 0, { w: 3600, d: 1000 }, { chaise: 'links', chaiseLengte: 1700 });
|
||||||
|
fx('salontafel', 'A', 2410, 1980, 0, { w: 800, d: 800 }, { vorm: 'rond' });
|
||||||
|
fx('fauteuil', 'A', 3120, 2950, 150);
|
||||||
|
fx('tvmeubel', 'A', 1060, 4210, 180, { w: 1200 });
|
||||||
|
fx('eettafel', 'A', 6100, 1115, 0, { w: 2000, d: 1000 }, { vorm: 'ovaal', stoelen: 6 });
|
||||||
|
fx('keuken', 'A', 9200, 1705, 90, { w: 3030 }, { front: 'antraciet', hogeKast: 'rechts', spoelbak: 0.16, kookplaat: true, kookplaatPlek: 0.8 });
|
||||||
|
fx('toilet', 'A', 10210, 535, 0, { w: 1100 });
|
||||||
|
fx('boiler', 'A', 11040, 415, 0);
|
||||||
|
fx('douche', 'A', 11300, 3710, 0, { w: 900, d: 1400 }, { glas: 'rechts', glasLengte: 1400 });
|
||||||
|
fx('wastafel', 'A', 12300, 3240, 0, { w: 800 });
|
||||||
|
fx('toilet', 'A', 13355, 3355, 0, { w: 900 });
|
||||||
|
fx('bed', 'B', xB(11400), zB(6360), 180, { w: 1600, d: 2100 }, { nachtkastjes: false });
|
||||||
|
fx('kledingkast', 'B', xB(13510), zB(5955), 90, { w: 2910, anchor: 'oost' });
|
||||||
|
|
||||||
|
computeParts(st, { skip3D: true });
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model Waal (examples/ontwerp_waal.pdf): 12,85 × 7 m, twee units van 3,5 m met één flauw zadeldak over de
|
||||||
|
// woning (nok west–oost op de naad). Het dak loopt aan de oostkop 1,5 m door als veranda (10,5 m²) op drie
|
||||||
|
// eiken palen (PostModel). Geen naadwand: slaapkamer en woonkamer/keuken lopen over beide units door; bij de
|
||||||
|
// naad staat aan elke kant een binnenwand (badkamer/keuken | hal). Alles buitenwerks vanaf de noordwesthoek.
|
||||||
|
function createWaal() {
|
||||||
|
const st = newModelState('Waal');
|
||||||
|
const S = st.settings, LO = 2800, HI = 3400, W = 12850, D = 3500;
|
||||||
|
Object.assign(S.facade, { type: 'rabat', rabatColor: 'grijs' });
|
||||||
|
S.cladding = 'verticaal';
|
||||||
|
Object.assign(S.trasraam, { enabled: true, height: 600, finish: 'R510' });
|
||||||
|
Object.assign(S.roof, { fasciaColor: 'wit', overhang: RoofModel.OVERHANG_MAX, fasciaHeight: 350 });
|
||||||
|
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 200, wood: 'eiken' });
|
||||||
|
S.lighting.mode = 'spots';
|
||||||
|
S.lighting.direction = 'both';
|
||||||
|
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
||||||
|
if (S.elektra) S.elektra.plateColor = 'antraciet';
|
||||||
|
|
||||||
|
const A = createUnitEntry('A', null, { width: W, depth: D, height: LO, windowOn: [] });
|
||||||
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: D, height: LO, windowOn: [] });
|
||||||
|
st.units.push(A, B);
|
||||||
|
setHouseRoof(st, true);
|
||||||
|
Object.assign(S.roof.house, { form: { type: 'zadel', ridge: 'x', low: LO, high: HI } });
|
||||||
|
[A, B].forEach(u => { u.roof.extend = { f: 0, r: 1500, b: 0, l: 0 }; });
|
||||||
|
|
||||||
|
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
|
||||||
|
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
|
||||||
|
ops(A.unit.walls.voor, [
|
||||||
|
['raam', 800, 500, 1600, 'right', 3490, K('raam')],
|
||||||
|
['raam', 800, 1100, 1000, 'right', 6530, K('raam')],
|
||||||
|
['raam', 1400, 1100, 1000, 'right', 8510, K('raam')]]);
|
||||||
|
ops(A.unit.walls.links, [['raam', 500, 1400, 700, 'left', 1170, K('raam')]]);
|
||||||
|
ops(B.unit.walls.links, [['raam', 500, 1400, 700, 'left', 390, K('raam')], ['raam', 1400, 1100, 1000, 'left', 1570, K('raam')]]);
|
||||||
|
// Oostkop onder de veranda: schuifdeur bij de keuken, openslaande deuren bij de woonkamer.
|
||||||
|
ops(A.unit.walls.rechts, [['deur', 1800, 2100, 0, 'right', 880,
|
||||||
|
K('deur', { type: 'schuifdeur', leaves: 2, mount: 'opbouw', side: 'binnen', glas: true })]]);
|
||||||
|
ops(B.unit.walls.rechts, [
|
||||||
|
['deur', 900, 2100, 0, 'right', 860, K('deur', { leaf: 'rechts', hinge: 'rechts', swing: 'buiten', volglas: true })],
|
||||||
|
['deur', 900, 2100, 0, 'right', 1760, K('deur', { leaf: 'links', hinge: 'links', swing: 'buiten', volglas: true })]]);
|
||||||
|
ops(B.unit.walls.achter, [
|
||||||
|
['raam', 500, 1400, 700, 'left', 3530, K('raam')],
|
||||||
|
['voordeur', 1100, 2100, 0, 'left', 4310, K('deur', { hinge: 'links', swing: 'buiten', glas: true })],
|
||||||
|
...[7130, 8230, 10470, 11570].map(x => ['raam', 500, 2100, 0, 'left', x, K('raam')])]);
|
||||||
|
B.lighting.walls.achter = 'openingen';
|
||||||
|
|
||||||
|
const DH = 2115;
|
||||||
|
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
|
||||||
|
iw(3235, 190, 3235, 3430, [['deur', 980, 2130, 'left', 1530, // slaapkamer | badkamer
|
||||||
|
Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
|
||||||
|
iw(5325, 190, 5325, 3430); // badkamer | keuken
|
||||||
|
iw(3280, 3510, 6535, 3510, [], 160); // badkamer, keuken | hal (over de naad)
|
||||||
|
iw(3235, 3590, 3235, 4655, [['deur', 930, DH, 'left', 75, binnen({ hinge: 'links', swing: 'buiten' })]]); // hal → slaapkamer
|
||||||
|
iw(6490, 3590, 6490, 6810, [['deur', 930, DH, 'left', 72, binnen({ hinge: 'links', swing: 'binnen' })]]); // hal, entree | woonkamer
|
||||||
|
iw(190, 4700, 6445, 4700, [ // slaapkamer, hal | berging, toilet, entree
|
||||||
|
['deur', 930, DH, 'left', 120, binnen({ hinge: 'links', swing: 'binnen' })],
|
||||||
|
['deur', 930, DH, 'left', 4200, binnen({ hinge: 'rechts', swing: 'buiten' })]]);
|
||||||
|
iw(2248, 4745, 2248, 5819); // berging | toilet
|
||||||
|
iw(2203, 5864, 3607, 5864); // toilet | berging, entree
|
||||||
|
iw(2938, 5909, 2938, 6810); // berging | entree
|
||||||
|
iw(3562, 4745, 3562, 5819, [['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet → entree
|
||||||
|
|
||||||
|
const plafond = { ceiling: LO };
|
||||||
|
room('A', 9500, 1800, 'woonkeuken', 'Woonkamer/keuken', plafond);
|
||||||
|
room('A', 1500, 1800, 'slaapkamer', 'Slaapkamer', plafond);
|
||||||
|
room('A', 4300, 2200, 'badkamer', '', plafond);
|
||||||
|
room('B', 4800, 600, 'hal', 'Hal', plafond);
|
||||||
|
room('B', 2900, 1800, 'toilet', '', plafond);
|
||||||
|
room('B', 4700, 2700, 'hal', 'Entree', plafond);
|
||||||
|
room('B', 1200, 2400, 'berging', 'Berging', plafond);
|
||||||
|
|
||||||
|
const zB = (z) => z - D;
|
||||||
|
fx('kledingkast', 'A', 1690, 490, 0, { w: 3000 });
|
||||||
|
fx('bed', 'A', 1240, 2770, 270, { w: 1600, d: 2100 }, { nachtkastjes: false });
|
||||||
|
fx('douche', 'A', 4585, 640, 90, { w: 900, d: 1390 }, { glas: 'rechts', glasLengte: 700 });
|
||||||
|
fx('wastafel', 'A', 5050, 1920, 90, { w: 800 });
|
||||||
|
fx('toilet', 'A', 3790, 3085, 180, { w: 1000 });
|
||||||
|
fx('keuken', 'A', 5670, 1810, 270, { w: 3240 }, { front: 'antraciet', hogeKast: 'links', spoel: false, vaatwasser: false, kookplaat: true, kookplaatPlek: 0.45 });
|
||||||
|
fx('keuken', 'A', 7025, 490, 0, { w: 2110 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.5, kookplaat: false });
|
||||||
|
fx('eettafel', 'A', 10230, 1730, 0, { w: 2000, d: 1100 }, { vorm: 'ovaal', stoelen: 6 });
|
||||||
|
fx('bank', 'B', 9820, zB(5365), 90, { w: 2500, d: 900 }, { chaise: 'rechts', chaiseLengte: 1300 });
|
||||||
|
fx('fauteuil', 'B', 8420, zB(3910), 330);
|
||||||
|
fx('salontafel', 'B', 8180, zB(5180), 0, { w: 800, d: 800 }, { vorm: 'rond' });
|
||||||
|
fx('tvmeubel', 'B', 6735, zB(5815), 270, { w: 1500, d: 400 });
|
||||||
|
fx('kledingkast', 'B', 6145, zB(5777), 90, { w: 2060 });
|
||||||
|
fx('toilet', 'B', 2638, zB(5312), 270, { w: 1000 });
|
||||||
|
fx('boiler', 'B', 1810, zB(4970), 0);
|
||||||
|
fx('wasmachine', 'B', 2500, zB(6510), 180);
|
||||||
|
|
||||||
|
computeParts(st, { skip3D: true });
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model Maas (examples/ontwerp_maas.pdf): vierkant 9,2 × 9,2 m onder één flauw zadeldak (nok west–oost op de
|
||||||
|
// naad). Twee units van 4,6 m diep: noord 6,7 m breed (oost uitgelijnd), zuid 9,2 m. De overkapping van het
|
||||||
|
// woningdak maakt van de noordwesthoek een inpandige veranda (2,5 × 4,6 m) op eiken palen. Maten buitenwerks vanaf
|
||||||
|
// de noordwesthoek van de woning; unit A begint 2,5 m oostelijker (X = interieur-coördinaten).
|
||||||
|
function createMaas() {
|
||||||
|
const st = newModelState('Maas');
|
||||||
|
const S = st.settings, LO = 2800, HI = 3500, D = 4600, V = 2500;
|
||||||
|
Object.assign(S.facade, { type: 'rabat', rabatColor: 'naturel' });
|
||||||
|
S.cladding = 'verticaal';
|
||||||
|
S.trasraam.enabled = false;
|
||||||
|
Object.assign(S.roof, { fasciaColor: 'antraciet', overhang: 300, fasciaHeight: 300 });
|
||||||
|
S.roof.posts = PostModel.normalizeSettings({ enabled: true, size: 200, wood: 'eiken' });
|
||||||
|
S.lighting.mode = 'spots';
|
||||||
|
S.lighting.direction = 'both';
|
||||||
|
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
||||||
|
if (S.elektra) S.elektra.plateColor = 'antraciet';
|
||||||
|
|
||||||
|
const A = createUnitEntry('A', null, { width: 9200 - V, depth: D, height: LO, windowOn: [] });
|
||||||
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 9200, depth: D, height: LO, windowOn: [] });
|
||||||
|
st.units.push(A, B);
|
||||||
|
setHouseRoof(st, true);
|
||||||
|
Object.assign(S.roof.house, { form: { type: 'zadel', ridge: 'x', low: LO, high: HI }, cover: true });
|
||||||
|
|
||||||
|
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
|
||||||
|
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
|
||||||
|
ops(A.unit.walls.voor, [
|
||||||
|
['raam', 800, 1500, 600, 'right', 970, K('raam')],
|
||||||
|
['raam', 800, 1500, 600, 'right', 2715, K('raam')],
|
||||||
|
['raam', 800, 1100, 1000, 'right', 4815, K('raam')]]);
|
||||||
|
// Westgevel onder de veranda: openslaande deuren met een vast zijlicht aan beide kanten (samen 3000 × 2100).
|
||||||
|
ops(A.unit.walls.links, [
|
||||||
|
['raam', 600, 2100, 0, 'left', 770, K('raam')],
|
||||||
|
['deur', 900, 2100, 0, 'left', 1370, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })],
|
||||||
|
['deur', 900, 2100, 0, 'left', 2270, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })],
|
||||||
|
['raam', 600, 2100, 0, 'left', 3170, K('raam')]]);
|
||||||
|
ops(B.unit.walls.voor, [['raam', 1100, 1500, 600, 'right', 315, K('raam')]]);
|
||||||
|
ops(B.unit.walls.links, [['raam', 800, 1500, 600, 'left', 200, K('raam')]]);
|
||||||
|
ops(B.unit.walls.rechts, [['raam', 800, 1100, 1000, 'right', 210, K('raam')]]);
|
||||||
|
ops(B.unit.walls.achter, [
|
||||||
|
['raam', 500, 700, 1400, 'left', 3525, K('raam')],
|
||||||
|
['voordeur', 1100, 2100, 0, 'left', 5675, K('deur', { hinge: 'links', swing: 'buiten' })],
|
||||||
|
['raam', 500, 1500, 600, 'left', 8375, K('raam')]]);
|
||||||
|
|
||||||
|
ensureSeamWalls(st);
|
||||||
|
const seam = st.interiorWalls.find(w => w.seam);
|
||||||
|
if (seam) {
|
||||||
|
const tA = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.links));
|
||||||
|
seam.wall.openings = [{ id: 1, type: 'deur', w: 930, h: 2115, y: 0, anchor: 'left', offset: Math.round(5790 - V - tA),
|
||||||
|
fill: binnen({ hinge: 'rechts', swing: 'binnen' }) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DH = 2115, X = (x) => x - V;
|
||||||
|
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
|
||||||
|
iw(X(3250), 4700, X(3250), 9010, [ // slaapkamer | hal, toilet, badkamer
|
||||||
|
['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'binnen' })],
|
||||||
|
['deur', 980, 2130, 'left', 2780, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
|
||||||
|
iw(X(3295), 5740, X(5620), 5740, [['deur', 930, DH, 'left', 665, binnen({ hinge: 'links', swing: 'binnen' })]]); // hal | toilet, schacht
|
||||||
|
iw(X(5075), 5785, X(5075), 6865); // toilet | schacht
|
||||||
|
iw(X(3295), 6910, X(5530), 6910); // toilet, schacht | badkamer
|
||||||
|
iw(X(5575), 5785, X(5575), 9010); // schacht, badkamer | entree
|
||||||
|
iw(X(6865), 4700, X(6865), 9010, [ // entree | logeerkamer, berging
|
||||||
|
['deur', 930, DH, 'left', 70, binnen({ hinge: 'links', swing: 'buiten' })],
|
||||||
|
['deur', 930, DH, 'left', 3185, binnen({ hinge: 'rechts', swing: 'buiten' })]]);
|
||||||
|
iw(X(6910), 6915, X(9010), 6915); // logeerkamer | berging
|
||||||
|
|
||||||
|
const plafond = { ceiling: LO };
|
||||||
|
room('A', 2500, 2000, 'woonkeuken', 'Woonkamer/keuken');
|
||||||
|
room('B', 1500, 2000, 'slaapkamer', 'Slaapkamer', plafond);
|
||||||
|
room('B', 4100, 1700, 'toilet');
|
||||||
|
room('B', 4000, 3300, 'badkamer');
|
||||||
|
room('B', 6200, 2500, 'hal', 'Entree', plafond);
|
||||||
|
room('B', 7900, 1100, 'slaapkamer', 'Logeerkamer', plafond);
|
||||||
|
room('B', 7900, 3300, 'berging', 'Berging', plafond);
|
||||||
|
|
||||||
|
const zB = (z) => z - D;
|
||||||
|
fx('keuken', 'A', X(7620), 490, 0, { w: 1580 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.58, kookplaat: false });
|
||||||
|
fx('keuken', 'A', X(8710), 2290, 90, { w: 4200 }, { front: 'antraciet', hogeKast: 'rechts', spoel: false, vaatwasser: false, kookplaat: true, kookplaatPlek: 0.43 });
|
||||||
|
fx('eettafel', 'A', X(5860), 1090, 0, { w: 1000, d: 1000 }, { vorm: 'ovaal', stoelen: 4 });
|
||||||
|
fx('bank', 'A', X(4020), 3770, 180, { w: 2550, d: 900 }, { chaise: 'rechts', chaiseLengte: 1300 });
|
||||||
|
fx('salontafel', 'A', X(4200), 2710, 0, { w: 800, d: 800 }, { vorm: 'rond' });
|
||||||
|
fx('fauteuil', 'A', X(3480), 1460, 330);
|
||||||
|
fx('bed', 'B', 1240, zB(6520), 270, { w: 1600, d: 2100 }, { nachtkastjes: false });
|
||||||
|
fx('kledingkast', 'B', 1700, zB(8710), 180, { w: 3000 });
|
||||||
|
fx('toilet', 'B', 3600, zB(6325), 270, { w: 1000 });
|
||||||
|
fx('wastafel', 'B', 3760, zB(7205), 0, { w: 800 });
|
||||||
|
fx('toilet', 'B', 3760, zB(8700), 180, { w: 1000 });
|
||||||
|
fx('douche', 'B', 5090, zB(8265), 180, { w: 900, d: 1500 }, { glas: 'rechts', glasLengte: 700 });
|
||||||
|
fx('bed', 'B', 7960, zB(6430), 90, { w: 900, d: 2100 }, { nachtkastjes: false });
|
||||||
|
fx('wasmachine', 'B', 7280, zB(7260), 0);
|
||||||
|
fx('boiler', 'B', 8700, zB(7260), 0);
|
||||||
|
|
||||||
|
computeParts(st, { skip3D: true });
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model Rijn (examples/ontwerp_rijn.pdf): L-vorm 13 × 10 m, hoofdvolume 13 × 6,54 m (twee units van 3,27 m) met
|
||||||
|
// een vleugel van 4,2 × 3,46 m aan de zuidwestkant. Flauw zadeldak over het hoofdvolume als twee lessenaars die
|
||||||
|
// op de naad de nok vormen (een woningdak zou ook de vleugel overspannen); de vleugel heeft een plat dak op
|
||||||
|
// goothoogte. Alleen een naadwand naar de vleugel; de 160 mm wand langs de nok is een gewone binnenwand.
|
||||||
|
// Steenstrips rondom, in de oostgevel een schoorsteen (ChimneyModel) over de naad. Maten buitenwerks vanaf de
|
||||||
|
// noordwesthoek, zoals de tekening (de steenstrips komen daar in de configurator buiten de units nog bij).
|
||||||
|
function createRijn() {
|
||||||
|
const st = newModelState('Rijn');
|
||||||
|
const S = st.settings, LO = 2800, HI = 3500, D = 3270, BRICK = 'R752', SCHOORSTEEN = 'R777';
|
||||||
|
Object.assign(S.facade, { type: 'steen', stone: BRICK });
|
||||||
|
Object.assign(S.trasraam, { enabled: true, height: 500, finish: 'R669' });
|
||||||
|
Object.assign(S.roof, { fasciaColor: 'wit', overhang: 300, fasciaHeight: 350 });
|
||||||
|
S.lighting.mode = 'spots';
|
||||||
|
S.lighting.direction = 'both';
|
||||||
|
if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true });
|
||||||
|
if (S.elektra) S.elektra.plateColor = 'antraciet';
|
||||||
|
|
||||||
|
const A = createUnitEntry('A', null, { width: 13000, depth: D, height: LO, windowOn: [] });
|
||||||
|
const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 13000, depth: D, height: LO, windowOn: [] });
|
||||||
|
const C = createUnitEntry('C', { to: 'B', side: 'achter', align: 'start', offset: 0 }, { width: 4200, depth: 3460, height: LO, windowOn: [] });
|
||||||
|
st.units.push(A, B, C);
|
||||||
|
UnitModel.applyRoofForm(A.unit, { type: 'lessenaar', dir: 'achter', low: LO, high: HI });
|
||||||
|
UnitModel.applyRoofForm(B.unit, { type: 'lessenaar', dir: 'voor', low: LO, high: HI });
|
||||||
|
|
||||||
|
const { K, binnen, ops, iw, room, fx } = modelKit(st, LO, 90);
|
||||||
|
// Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand.
|
||||||
|
ops(A.unit.walls.voor, [
|
||||||
|
['raam', 500, 1400, 700, 'right', 1045, K('raam')],
|
||||||
|
['raam', 500, 1400, 700, 'right', 3630, K('raam')],
|
||||||
|
['raam', 1200, 500, 1600, 'right', 4760, K('raam')],
|
||||||
|
['raam', 1400, 1400, 700, 'right', 7560, K('raam')]]);
|
||||||
|
// De oostwand van A begint na de noordwand: offset vanaf het wandeinde.
|
||||||
|
const tN = WallModel.thicknessOf(WallModel.configFromState(A.unit.walls.voor));
|
||||||
|
ops(A.unit.walls.rechts, [['raam', 1800, 2100, 0, 'right', Math.round(785 - tN), K('raam')]]);
|
||||||
|
ops(B.unit.walls.rechts, [
|
||||||
|
['deur', 900, 2100, 0, 'right', 730, K('deur', { leaf: 'rechts', hinge: 'rechts', swing: 'buiten', volglas: true })],
|
||||||
|
['deur', 900, 2100, 0, 'right', 1630, K('deur', { leaf: 'links', hinge: 'links', swing: 'buiten', volglas: true })]]);
|
||||||
|
ops(B.unit.walls.links, [['raam', 1400, 1100, 1000, 'left', 465, K('raam')]]);
|
||||||
|
ops(B.unit.walls.achter, [
|
||||||
|
['raam', 1100, 2100, 0, 'left', 4680, K('raam')],
|
||||||
|
['raam', 1800, 2100, 0, 'left', 10490, K('raam')]]);
|
||||||
|
ops(C.unit.walls.links, [['raam', 500, 700, 1400, 'left', 365, K('raam')]]);
|
||||||
|
ops(C.unit.walls.rechts, [['voordeur', 1100, 2100, 0, 'right', 70, K('deur', { hinge: 'links', swing: 'buiten', glas: true })]]);
|
||||||
|
ops(C.unit.walls.achter, [['raam', 1400, 1100, 1000, 'left', 1505, K('raam')]]);
|
||||||
|
// Schoorsteen 800 mm breed midden in de oostgevel, hart op de naad (z 3270), 180 mm voor de gevel.
|
||||||
|
st.chimneys.push(Object.assign(ChimneyModel.create(st.chimneys, 'B', 'rechts', 3270), {
|
||||||
|
w: 800, out: 180, above: 600, outside: { type: 'steen', stone: SCHOORSTEEN, stuc: 'wit' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DH = 2115;
|
||||||
|
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
|
||||||
|
iw(190, 3266, 6513, 3266, [['deur', 930, DH, 'left', 2800, binnen({ hinge: 'links', swing: 'buiten' })]], 160); // slaapkamer, badkamer | keuken (langs de nok)
|
||||||
|
iw(4380, 190, 4380, 3186, [['deur', 980, 2130, 'left', 1136, // slaapkamer | badkamer
|
||||||
|
Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]);
|
||||||
|
iw(6466, 190, 6466, 3186); // badkamer | woonkamer
|
||||||
|
iw(5800, 1135, 6421, 1135); // muurtje naast de douche
|
||||||
|
iw(2762, 3346, 2762, 6440, [['deur', 930, DH, 'left', 104, binnen({ hinge: 'links', swing: 'buiten' })]]); // berging | keuken
|
||||||
|
iw(1880, 6640, 1880, 7655, [['deur', 930, DH, 'left', 40, binnen({ hinge: 'links', swing: 'binnen' })]]); // toilet | entree
|
||||||
|
iw(190, 7700, 3107, 7700, [['deur', 930, DH, 'left', 1820, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet, entree | logeerkamer
|
||||||
|
iw(3062, 7745, 3062, 8335); // kapstoknis entree
|
||||||
|
iw(3107, 8290, 4010, 8290); // kapstoknis | kast logeerkamer
|
||||||
|
|
||||||
|
ensureSeamWalls(st, 'C');
|
||||||
|
const seam = st.interiorWalls.find(w => w.seam);
|
||||||
|
if (seam) {
|
||||||
|
const tC = WallModel.thicknessOf(WallModel.configFromState(C.unit.walls.links));
|
||||||
|
seam.wall.openings = [{ id: 1, type: 'deur', w: 930, h: DH, y: 0, anchor: 'left', offset: Math.round(2915 - tC),
|
||||||
|
fill: binnen({ hinge: 'links', swing: 'buiten' }) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const plafond = { ceiling: LO };
|
||||||
|
room('A', 9500, 1500, 'woonkeuken', 'Woonkamer/keuken');
|
||||||
|
room('A', 2600, 1700, 'slaapkamer', 'Slaapkamer', plafond);
|
||||||
|
room('A', 5400, 2000, 'badkamer', '', plafond);
|
||||||
|
room('B', 1400, 1500, 'berging', 'Berging', plafond);
|
||||||
|
room('C', 1000, 500, 'toilet');
|
||||||
|
room('C', 2400, 500, 'hal', 'Entree');
|
||||||
|
room('C', 1500, 2300, 'slaapkamer', 'Logeerkamer');
|
||||||
|
|
||||||
|
const zB = (z) => z - D, zC = (z) => z - 2 * D;
|
||||||
|
fx('kledingkast', 'A', 493, 1688, 270, { w: 2990 });
|
||||||
|
fx('bed', 'A', 2615, 1240, 0, { w: 1600, d: 2100 }, { nachtkastjes: false });
|
||||||
|
fx('douche', 'A', 5725, 640, 0, { w: 1400, d: 900 }, { glas: 'links', glasLengte: 900 });
|
||||||
|
fx('wastafel', 'A', 6191, 2000, 90, { w: 800 });
|
||||||
|
fx('toilet', 'A', 4998, 2680, 180);
|
||||||
|
fx('bank', 'A', 11195, 800, 0, { w: 3000, d: 1000 }, { chaise: 'rechts', chaiseLengte: 2100 });
|
||||||
|
fx('fauteuil', 'A', 8880, 1720, 315);
|
||||||
|
fx('salontafel', 'A', 10410, 2280, 0, { w: 800, d: 800 }, { vorm: 'rond' });
|
||||||
|
fx('keuken', 'B', 4945, zB(4350), 90, { w: 2000 }, { front: 'antraciet', hogeKast: 'geen', spoelbak: 0.15, kookplaat: true, kookplaatPlek: 0.85 });
|
||||||
|
fx('kookeiland', 'B', 5545, zB(4350), 270, { w: 2000, d: 600 }, { front: 'antraciet', kookplaat: false });
|
||||||
|
fx('keuken', 'B', 3110, zB(5080), 270, { w: 1200 }, { front: 'antraciet', hogeKast: 'rechts', spoel: false, vaatwasser: false });
|
||||||
|
fx('eettafel', 'B', 10010, zB(4990), 0, { w: 2000, d: 1100 }, { vorm: 'ovaal', stoelen: 6 });
|
||||||
|
fx('wasmachine', 'B', 620, zB(6140), 180);
|
||||||
|
fx('wasmachine', 'B', 1450, zB(6140), 180); // droger
|
||||||
|
fx('boiler', 'B', 2350, zB(6215), 180);
|
||||||
|
fx('toilet', 'C', 535, zC(7150), 270);
|
||||||
|
fx('bed', 'C', 650, zC(8745), 0, { w: 900, d: 2000 }, { nachtkastjes: false });
|
||||||
|
fx('kledingkast', 'C', 3703, zC(9072), 90, { w: 1450 });
|
||||||
|
|
||||||
|
computeParts(st, { skip3D: true });
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
|
// Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template.
|
||||||
const MODELS = {
|
const MODELS = {
|
||||||
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
|
amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel },
|
||||||
wetering: { label: 'Woning Wetering', icon: 'house-door', create: createWetering }
|
wetering: { label: 'Woning Wetering', icon: 'house-door', create: createWetering },
|
||||||
|
ijssel: { label: 'Woning IJssel', icon: 'houses', create: createIJssel },
|
||||||
|
waal: { label: 'Woning Waal', icon: 'house-add', create: createWaal },
|
||||||
|
maas: { label: 'Woning Maas', icon: 'house-check', create: createMaas },
|
||||||
|
rijn: { label: 'Woning Rijn', icon: 'house-gear', create: createRijn }
|
||||||
};
|
};
|
||||||
|
|
||||||
function createDefaultState(template) {
|
function createDefaultState(template) {
|
||||||
@@ -523,7 +893,9 @@ window.HouseModel = (() => {
|
|||||||
ws.materials = Object.assign({}, ws.materials, facadeMaterials(state, facadeFor(state, u, side)));
|
ws.materials = Object.assign({}, ws.materials, facadeMaterials(state, facadeFor(state, u, side)));
|
||||||
});
|
});
|
||||||
if (u.roof.followFloor) {
|
if (u.roof.followFloor) {
|
||||||
const e = cover[u.id] || { l: 0, r: 0, f: 0, b: 0 };
|
// Overkapping van het woningdak plus de eigen verlenging (veranda) van deze unit.
|
||||||
|
const c = cover[u.id] || {}, x = u.roof.extend || {};
|
||||||
|
const e = Object.fromEntries(['l', 'r', 'f', 'b'].map(k => [k, (c[k] || 0) + (x[k] || 0)]));
|
||||||
u.roof.deck.floor.width = u.unit.floor.floor.width + e.l + e.r;
|
u.roof.deck.floor.width = u.unit.floor.floor.width + e.l + e.r;
|
||||||
u.roof.deck.floor.depth = u.unit.floor.floor.depth + e.f + e.b;
|
u.roof.deck.floor.depth = u.unit.floor.floor.depth + e.f + e.b;
|
||||||
u.roof.offsetX = (e.r - e.l) / 2;
|
u.roof.offsetX = (e.r - e.l) / 2;
|
||||||
@@ -532,6 +904,12 @@ window.HouseModel = (() => {
|
|||||||
});
|
});
|
||||||
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
|
state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; });
|
||||||
applyHouseRoof(state);
|
applyHouseRoof(state);
|
||||||
|
applyRoofJoins(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXTEND_MAX = 4000;
|
||||||
|
function normalizeExtend(e) {
|
||||||
|
return Object.fromEntries(['f', 'r', 'b', 'l'].map(k => [k, Math.max(0, Math.min(EXTEND_MAX, Math.round(Number((e || {})[k]) || 0)))]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const houseRoofOn = (state) => !!(state.settings.roof.house && state.settings.roof.house.enabled && state.settings.roof.house.form);
|
const houseRoofOn = (state) => !!(state.settings.roof.house && state.settings.roof.house.enabled && state.settings.roof.house.form);
|
||||||
@@ -607,7 +985,6 @@ window.HouseModel = (() => {
|
|||||||
const shape = houseRoofShape(state, rects);
|
const shape = houseRoofShape(state, rects);
|
||||||
if (!shape) return;
|
if (!shape) return;
|
||||||
const form = state.settings.roof.house.form, ln = shape.line;
|
const form = state.settings.roof.house.form, ln = shape.line;
|
||||||
if (form.type === 'mansarde' && ln.length === 5) Object.assign(form, { kneeIn: Math.round(ln[1].u), kneeH: Math.round(ln[1].h) });
|
|
||||||
if (form.type === 'wolf' && ln.length === 4) form.cut = Math.round(ln[1].h);
|
if (form.type === 'wolf' && ln.length === 4) form.cut = Math.round(ln[1].h);
|
||||||
const planes = RoofModel.shapeLines(shape);
|
const planes = RoofModel.shapeLines(shape);
|
||||||
state.units.forEach((u, i) => {
|
state.units.forEach((u, i) => {
|
||||||
@@ -636,16 +1013,168 @@ window.HouseModel = (() => {
|
|||||||
const a = pts[k], b = pts[k + 2];
|
const a = pts[k], b = pts[k + 2];
|
||||||
return q.h > a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h) + 0.5;
|
return q.h > a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h) + 0.5;
|
||||||
});
|
});
|
||||||
const wall = u.unit.walls[wl.side].wall;
|
setWallTop(u.unit.walls[wl.side].wall, W, pts[0].h, pts[pts.length - 1].h, inner);
|
||||||
const hL = Math.round(pts[0].h), hR = Math.round(pts[pts.length - 1].h);
|
|
||||||
const mid = inner.length === 1 && Math.abs(inner[0].x - W / 2) < 0.5;
|
|
||||||
Object.assign(wall, { heightLeft: hL, heightRight: hR, heightAll: Math.min(hL, hR), heightMid: mid ? Math.round(inner[0].h) : null });
|
|
||||||
if (inner.length && !mid) wall.topPts = inner.map(q => ({ f: q.x / W, h: Math.round(q.h) }));
|
|
||||||
else delete wall.topPts;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWallTop(wall, W, h0, h1, inner) {
|
||||||
|
const hL = Math.round(h0), hR = Math.round(h1);
|
||||||
|
const mid = inner.length === 1 && Math.abs(inner[0].x - W / 2) < 0.5;
|
||||||
|
Object.assign(wall, { heightLeft: hL, heightRight: hR, heightAll: Math.min(hL, hR), heightMid: mid ? Math.round(inner[0].h) : null });
|
||||||
|
if (inner.length && !mid) wall.topPts = inner.map(q => ({ f: q.x / W, h: Math.round(q.h) }));
|
||||||
|
else delete wall.topPts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dakaansluiting (L/T): een vleugel met nokdak (zadel, wolfsdak), nok haaks op de naad, tegen de
|
||||||
|
// langszijde van een hoofdvolume met nokdak loopt het hoofddak in tot zijn nok (kilkepers). 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, goot loopt door; een wolfseind daar vervalt); hooguit één kop,
|
||||||
|
// anders heeft het hoofdvolume geen kopgevel meer om zijn eigen dakvorm uit af te leiden. Een vleugel die in een
|
||||||
|
// wolfseind van het hoofdvolume zou steken (niet gelijk met de kop) sluit niet aan.
|
||||||
|
// Vlakken in wereldmaten; h = min(verre vlakken, wolfseinden, schildvlakken, max(min(nabije vlakken), vleugels)),
|
||||||
|
// zie RoofModel.computeJoinedRoof.
|
||||||
|
const JOIN_TYPES = ['zadel', 'wolf'];
|
||||||
|
function roofJoins(state, rects) {
|
||||||
|
const out = {};
|
||||||
|
if (houseRoofOn(state)) return out;
|
||||||
|
const rs = rects || unitRects(state);
|
||||||
|
if (rs.some(r => r.error) || overlappingPairs(rs).length) return out;
|
||||||
|
const at = (pl, [x, z]) => pl.a + pl.bx * x + pl.bz * z;
|
||||||
|
const info = state.units.map((u, i) => {
|
||||||
|
const r = rs[i], shape = RoofModel.computeShape(u.unit, UnitModel.computeLayout(u.unit, {}));
|
||||||
|
const planes = [];
|
||||||
|
if (!JOIN_TYPES.includes(shape.type)) return { u, r, shape, planes };
|
||||||
|
const sx = shape.axis === 'x';
|
||||||
|
const s = shape.type === 'wolf' && shape.hip
|
||||||
|
? Object.assign({}, shape, { hip: Object.assign({}, shape.hip, sx ? { q0: -r.D / 2, q1: r.D / 2 } : { q0: -r.W / 2, q1: r.W / 2 }) })
|
||||||
|
: shape;
|
||||||
|
const corners = [[r.x0, r.z0], [r.x1, r.z0], [r.x0, r.z1], [r.x1, r.z1]];
|
||||||
|
RoofModel.shapeLines(s).forEach(l => {
|
||||||
|
const [bx, bz] = sx ? [l.bp, l.bq] : [l.bq, l.bp];
|
||||||
|
const pl = { a: l.a - bx * r.cx - bz * r.cz, bx, bz };
|
||||||
|
// Wolfsdak: onder- en bovenstuk liggen (op afronding na) in één vlak.
|
||||||
|
if (!planes.some(q => corners.every(c => Math.abs(at(q, c) - at(pl, c)) < 2))) planes.push(pl);
|
||||||
|
});
|
||||||
|
return { u, r, shape, planes };
|
||||||
|
});
|
||||||
|
const groups = new Map(), role = {};
|
||||||
|
info.forEach(M => info.forEach(W => {
|
||||||
|
if (M === W || !M.planes.length || !W.planes.length || M.shape.axis === W.shape.axis) return;
|
||||||
|
if (role[M.u.id] === 'vleugel' || role[W.u.id]) return;
|
||||||
|
const seamX = M.shape.axis === 'z'; // naad evenwijdig aan de nok van M
|
||||||
|
const [m0, m1, w0, w1] = seamX ? [M.r.x0, M.r.x1, W.r.x0, W.r.x1] : [M.r.z0, M.r.z1, W.r.z0, W.r.z1];
|
||||||
|
const [a0, a1, b0, b1] = seamX ? [M.r.z0, M.r.z1, W.r.z0, W.r.z1] : [M.r.x0, M.r.x1, W.r.x0, W.r.x1];
|
||||||
|
const seam = Math.abs(a1 - b0) < EPS ? a1 : Math.abs(b1 - a0) < EPS ? a0 : null;
|
||||||
|
if (seam === null || w0 < m0 - EPS || w1 > m1 + EPS || (w0 < m0 + EPS && w1 > m1 - EPS)) return;
|
||||||
|
const xz = (t) => seamX ? [t, seam] : [seam, t];
|
||||||
|
const dir = seam === a1 ? 1 : -1; // vleugel aan de +kant (dwars op de naad) van M
|
||||||
|
const across = (pl) => seamX ? pl.bz : pl.bx, along = (pl) => seamX ? pl.bx : pl.bz;
|
||||||
|
const g = groups.get(M) || { M, seamX, wings: [], flush: null, drop: [] };
|
||||||
|
const end = Math.abs(w1 - m1) < EPS ? 1 : Math.abs(w0 - m0) < EPS ? 0 : -1;
|
||||||
|
const toEnd = (pl) => end ? along(pl) < 0 : along(pl) > 0; // daalt naar die kop
|
||||||
|
const flush = end >= 0 && !g.flush ? {
|
||||||
|
planes: W.planes.filter(pl => Math.abs(along(pl)) > 1e-9 && toEnd(pl)),
|
||||||
|
drop: M.planes.filter(pl => Math.abs(across(pl)) <= 1e-9 && toEnd(pl)),
|
||||||
|
side: seamX ? (end ? 'rechts' : 'links') : (end ? 'achter' : 'voor')
|
||||||
|
} : null;
|
||||||
|
const wingTop = Math.max(...W.shape.profile.map(q => q.h));
|
||||||
|
const hips = M.planes.filter(pl => Math.abs(across(pl)) <= 1e-9 && !g.drop.includes(pl) && !(flush && flush.drop.includes(pl)));
|
||||||
|
if (hips.some(pl => Math.min(at(pl, xz(w0)), at(pl, xz(w1))) < wingTop - 1)) return;
|
||||||
|
if (flush) { g.flush = flush; g.drop.push(...flush.drop); }
|
||||||
|
g.wings.push({ W, sign: -dir, roof: W.planes.filter(pl => Math.abs(along(pl)) > 1e-9 || across(pl) * dir < 0) });
|
||||||
|
groups.set(M, g);
|
||||||
|
role[M.u.id] = 'hoofd';
|
||||||
|
role[W.u.id] = 'vleugel';
|
||||||
|
}));
|
||||||
|
groups.forEach(g => {
|
||||||
|
const planes = [], idx = (pl) => planes.includes(pl) ? planes.indexOf(pl) : planes.push(pl) - 1;
|
||||||
|
const cross = (pl) => g.seamX ? pl.bz : pl.bx, terms = [];
|
||||||
|
[-1, 1].forEach(sg => {
|
||||||
|
const side = g.M.planes.filter(pl => Math.abs(cross(pl)) > 1e-9 && Math.sign(cross(pl)) === sg);
|
||||||
|
const ws = g.wings.filter(w => w.sign === sg);
|
||||||
|
if (!ws.length) side.forEach(pl => terms.push(idx(pl)));
|
||||||
|
else terms.push(['max', ['min', ...side.map(idx)], ...ws.map(w => ['min', ...w.roof.map(idx)])]);
|
||||||
|
});
|
||||||
|
g.M.planes.filter(pl => Math.abs(cross(pl)) <= 1e-9 && !g.drop.includes(pl)).forEach(pl => terms.push(idx(pl)));
|
||||||
|
if (g.flush) g.flush.planes.forEach(pl => terms.push(idx(pl)));
|
||||||
|
const expr = ['min', ...terms];
|
||||||
|
[g.M, ...g.wings.map(w => w.W)].forEach(X => { out[X.u.id] = { planes, expr, flush: X === g.M && g.flush ? g.flush.side : null }; });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dubbel lessenaardak = zadeldak: twee units met elk een lessenaar dwars op hun naad, allebei hoog aan de naad, met
|
||||||
|
// op de mm gelijke goot, nok en hoogte op de naad. Dan een stalen nokbalk onder de naad, net als bij een zadeldak.
|
||||||
|
// Eén lessenaar, of twee die niet zo op elkaar aansluiten, krijgen er geen. Een schoorsteen op de nok maakt een
|
||||||
|
// sparing in het dak; de balk stopt dan vóór de schoorsteen (RoofModel.computeRidgeBeam).
|
||||||
|
function doubleLessenaarPairs(state, rects, cuts) {
|
||||||
|
const out = {};
|
||||||
|
const info = state.units.map((u, i) => {
|
||||||
|
const r = rects[i];
|
||||||
|
if (r.error || u.roof.steelRidge === false) return null;
|
||||||
|
const layout = UnitModel.computeLayout(u.unit, cuts[u.id]);
|
||||||
|
const sh = RoofModel.computeShape(u.unit, layout);
|
||||||
|
if (sh.type !== 'lessenaar' || sh.profile.length !== 2) return null;
|
||||||
|
const c = sh.axis === 'x' ? r.cx : r.cz;
|
||||||
|
const [A, B] = sh.profile.map(q => ({ p: q.p + c, h: q.h })).sort((a, b) => a.p - b.p);
|
||||||
|
return { u, r, axis: sh.axis, A, B, floorTop: layout.floorTop, slope: (B.h - A.h) / (B.p - A.p) };
|
||||||
|
});
|
||||||
|
const at = (L, p) => L.A.h + (p - L.A.p) * L.slope;
|
||||||
|
info.forEach(M => info.forEach(N => {
|
||||||
|
if (!M || !N || M === N || M.axis !== N.axis || out[M.u.id] || out[N.u.id]) return;
|
||||||
|
const ax = M.axis, lo = (r) => ax === 'x' ? r.x0 : r.z0, hi = (r) => ax === 'x' ? r.x1 : r.z1;
|
||||||
|
const seam = hi(M.r);
|
||||||
|
if (Math.abs(seam - lo(N.r)) > EPS || !(M.slope > 0 && N.slope < 0) || Math.abs(M.floorTop - N.floorTop) > EPS) return;
|
||||||
|
if (Math.abs(M.A.h - N.B.h) > EPS || Math.abs(M.B.h - N.A.h) > EPS || Math.abs(at(M, seam) - at(N, seam)) > EPS) return;
|
||||||
|
const qlo = (r) => ax === 'x' ? r.z0 : r.x0, qhi = (r) => ax === 'x' ? r.z1 : r.x1;
|
||||||
|
const q0 = Math.max(qlo(M.r), qlo(N.r)), q1 = Math.min(qhi(M.r), qhi(N.r));
|
||||||
|
if (q1 - q0 < 100) return;
|
||||||
|
out[M.u.id] = {
|
||||||
|
axis: ax, a0: lo(M.r), a1: hi(N.r), q0, q1, span: [q0, q1],
|
||||||
|
profile: [{ p: M.A.p, h: M.A.h }, { p: seam, h: at(M, seam) }, { p: N.B.p, h: N.B.h }]
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// De kop die een schild wordt volgt het dak; vervalt de aansluiting, dan krijgt de unit zijn eigen dakvorm terug.
|
||||||
|
function applyRoofJoins(state) {
|
||||||
|
const rects = unitRects(state);
|
||||||
|
const joins = roofJoins(state, rects);
|
||||||
|
state.units.forEach((u, i) => {
|
||||||
|
const join = joins[u.id], side = join && join.flush;
|
||||||
|
if (Object.entries(u.unit.walls).some(([k, ws]) => ws.wall.joinTop && k !== side)) {
|
||||||
|
Object.values(u.unit.walls).forEach(ws => { delete ws.wall.joinTop; });
|
||||||
|
UnitModel.applyRoofForm(u.unit, UnitModel.roofForm(u.unit));
|
||||||
|
}
|
||||||
|
if (!side) return;
|
||||||
|
const r = rects[i];
|
||||||
|
const wl = UnitModel.computeLayout(u.unit, {}).walls.find(w => w.side === side);
|
||||||
|
const s = UnitModel.WALL_SIDES.find(x => x.key === side), alongX = s.axis === 'x';
|
||||||
|
const start = (alongX ? wl.cx + r.cx : wl.cz + r.cz) - s.dir * wl.width / 2;
|
||||||
|
const perp = alongX ? wl.cz + r.cz : wl.cx + r.cx, out = alongX ? wl.normal.y : wl.normal.x;
|
||||||
|
const faces = [perp + out * wl.thickness / 2, perp - out * wl.thickness / 2];
|
||||||
|
const pos = (x, f) => alongX ? { x: start + s.dir * x, z: f } : { x: f, z: start + s.dir * x };
|
||||||
|
const W = wl.width, xs = [0, W];
|
||||||
|
// Knikken alleen waar twee vlakken langs een wandvlak even hoog zijn.
|
||||||
|
faces.forEach(f => join.planes.forEach((p1, j) => join.planes.slice(j + 1).forEach(p2 => {
|
||||||
|
const h = (pl, x) => { const q = pos(x, f); return pl.a + pl.bx * q.x + pl.bz * q.z; };
|
||||||
|
const d0 = h(p1, 0) - h(p2, 0), d1 = h(p1, W) - h(p2, W);
|
||||||
|
if (Math.abs(d1 - d0) > 1e-9) { const x = -d0 / (d1 - d0) * W; if (x > 1 && x < W - 1) xs.push(x); }
|
||||||
|
})));
|
||||||
|
const hAt = (x) => Math.min(...faces.map(f => { const q = pos(x, f); return RoofModel.joinValue(join, q); }));
|
||||||
|
const pts = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((a, b) => a - b).map(x => ({ x, h: hAt(x) }));
|
||||||
|
const inner = pts.slice(1, -1).filter((q, k) => {
|
||||||
|
const a = pts[k], b = pts[k + 2];
|
||||||
|
return Math.abs(q.h - (a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h))) > 0.5;
|
||||||
|
});
|
||||||
|
const wall = u.unit.walls[side].wall;
|
||||||
|
setWallTop(wall, W, pts[0].h, pts[pts.length - 1].h, inner);
|
||||||
|
wall.joinTop = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setHouseRoof(state, on) {
|
function setHouseRoof(state, on) {
|
||||||
const hr = state.settings.roof.house;
|
const hr = state.settings.roof.house;
|
||||||
if (!!on === houseRoofOn(state)) return;
|
if (!!on === houseRoofOn(state)) return;
|
||||||
@@ -708,8 +1237,9 @@ window.HouseModel = (() => {
|
|||||||
function computeParts(state, opts) {
|
function computeParts(state, opts) {
|
||||||
const o = opts || {};
|
const o = opts || {};
|
||||||
const build = !o.skip3D;
|
const build = !o.skip3D;
|
||||||
const stage = BuildStages.clamp(o.stage);
|
const layers = ViewLayers.normalize(o.layers);
|
||||||
const shows = (key) => BuildStages.shows(stage, key);
|
const shows = (key) => layers[key];
|
||||||
|
const hideFrame = (group, mats) => { if (group && mats && !shows('regelwerk')) ViewLayers.hideByMaterial(group, [mats.woodMaterial, mats.steelMaterial]); };
|
||||||
applyGlobalSettings(state);
|
applyGlobalSettings(state);
|
||||||
const rects = unitRects(state);
|
const rects = unitRects(state);
|
||||||
const rectById = {};
|
const rectById = {};
|
||||||
@@ -718,18 +1248,30 @@ window.HouseModel = (() => {
|
|||||||
const { cuts, seams } = computeSeams(rects, wallThickness);
|
const { cuts, seams } = computeSeams(rects, wallThickness);
|
||||||
const houseShape = houseRoofShape(state, rects);
|
const houseShape = houseRoofShape(state, rects);
|
||||||
const coverExt = coverExtensions(state, rects);
|
const coverExt = coverExtensions(state, rects);
|
||||||
|
const joins = roofJoins(state, rects);
|
||||||
|
const chimneysPlaced = ChimneyModel.place(ChimneyModel.normalize(state.chimneys, state.units.map(u => u.id)), rects, {
|
||||||
|
wallThickness,
|
||||||
|
faceDepth: (id, side) => {
|
||||||
|
const fc = facadeFor(state, state.units.find(u => u.id === id), side), m = facadeMaterials(state, fc);
|
||||||
|
return WallModel.claddingOffset(m.exterior, m.stone, m.stuc);
|
||||||
|
},
|
||||||
|
overhang: RoofModel.normalizeSettings(state.settings.roof).overhang
|
||||||
|
});
|
||||||
|
const ridgePairs = houseShape ? {} : doubleLessenaarPairs(state, rects, cuts);
|
||||||
|
|
||||||
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc) {
|
function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop) {
|
||||||
if (!build) return { group: null, stats: null, specs: null };
|
if (!build) return { group: null, stats: null, specs: null };
|
||||||
const built = WallModel.build3D(config, frame, {
|
const built = WallModel.build3D(config, frame, {
|
||||||
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc,
|
materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop,
|
||||||
painted: state.settings.interiorPaint && shows('afwerking'), stage, rabatColor: state.settings.facade.rabatColor
|
painted: state.settings.interiorPaint && shows('afwerking'), layers, rabatColor: state.settings.facade.rabatColor
|
||||||
});
|
});
|
||||||
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function wallOf(u, rect, layout, wl, raised) {
|
function wallOf(u, rect, layout, wl, raised) {
|
||||||
const ws = u.unit.walls[wl.side];
|
const ws0 = u.unit.walls[wl.side];
|
||||||
|
const chimneyCuts = ChimneyModel.wallCuts(chimneysPlaced, rect, wl, Math.round(ws0.wall.studWidth || WallModel.STANDARD_BEAM.width), rects);
|
||||||
|
const ws = chimneyCuts.length ? Object.assign({}, ws0, { openings: (ws0.openings || []).concat(chimneyCuts) }) : ws0;
|
||||||
const wp = UnitModel.wallPieces(wl, ws, raised);
|
const wp = UnitModel.wallPieces(wl, ws, raised);
|
||||||
const pieces = wp.pieces.map(p => {
|
const pieces = wp.pieces.map(p => {
|
||||||
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
|
const c = UnitModel.localToWorld(wl, p.lxCenter, 0);
|
||||||
@@ -766,9 +1308,10 @@ window.HouseModel = (() => {
|
|||||||
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
|
const floorData = FloorModel.computeFromState(u.unit.floor, rims);
|
||||||
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
|
const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings);
|
||||||
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
|
const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, {
|
||||||
materials: o.floorMaterials, osbVisible: shows('beplating'),
|
materials: o.floorMaterials, osbVisible: shows('beplating'), outerJoistsOnly: !!o.finishedOnly && shows('beplating'),
|
||||||
finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null
|
finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null
|
||||||
}) : null;
|
}) : null;
|
||||||
|
hideFrame(floorGroup, o.floorMaterials);
|
||||||
|
|
||||||
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
|
const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl));
|
||||||
|
|
||||||
@@ -776,9 +1319,23 @@ window.HouseModel = (() => {
|
|||||||
const deckFrame = FloorModel.computeFrame(u.roof.deck.floor);
|
const deckFrame = FloorModel.computeFrame(u.roof.deck.floor);
|
||||||
const roofW = deckFrame.width, roofD = deckFrame.depth;
|
const roofW = deckFrame.width, roofD = deckFrame.depth;
|
||||||
const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0;
|
const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0;
|
||||||
const ur = RoofModel.computeUnitRoof(u.roof, shape, { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }, rims);
|
const roofRect = { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 };
|
||||||
|
const join = joins[u.id] && {
|
||||||
|
planes: joins[u.id].planes.map(pl => ({ a: pl.a + pl.bx * rect.cx + pl.bz * rect.cz, bx: pl.bx, bz: pl.bz })),
|
||||||
|
expr: joins[u.id].expr
|
||||||
|
};
|
||||||
|
const roofCuts = ChimneyModel.roofCuts(chimneysPlaced, rect,
|
||||||
|
{ x0: rect.cx + roofRect.x0, x1: rect.cx + roofRect.x1, z0: rect.cz + roofRect.z0, z1: rect.cz + roofRect.z1 });
|
||||||
|
const ur = join ? RoofModel.computeJoinedRoof(u.roof, join, roofRect, shape) : RoofModel.computeUnitRoof(u.roof, shape, roofRect, rims, roofCuts);
|
||||||
|
const dl = !join && ridgePairs[u.id];
|
||||||
|
if (dl) {
|
||||||
|
const c = dl.axis === 'x' ? rect.cx : rect.cz, cq = dl.axis === 'x' ? rect.cz : rect.cx;
|
||||||
|
const zadel = { type: 'zadel', axis: dl.axis, profile: dl.profile.map(q => ({ p: q.p - c, h: q.h })) };
|
||||||
|
const both = dl.axis === 'x' ? { x0: dl.a0 - c, x1: dl.a1 - c, z0: dl.q0 - cq, z1: dl.q1 - cq } : { x0: dl.q0 - cq, x1: dl.q1 - cq, z0: dl.a0 - c, z1: dl.a1 - c };
|
||||||
|
RoofModel.sharedRidgeBeam(u.roof, ur, zadel, both, dl.span.map(q => q - cq), roofCuts);
|
||||||
|
}
|
||||||
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
|
const roofCx = rect.cx + ox, roofCz = rect.cz + oz;
|
||||||
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', mansarde: 'mansarde', wolf: 'wolfsdak' }[shape.type];
|
const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', wolf: 'wolfsdak' }[shape.type];
|
||||||
const multi = ur.planes.length > 1;
|
const multi = ur.planes.length > 1;
|
||||||
ur.planes.forEach((pl, i) => {
|
ur.planes.forEach((pl, i) => {
|
||||||
pl.element = `${u.id}-D${i + 1}`;
|
pl.element = `${u.id}-D${i + 1}`;
|
||||||
@@ -788,9 +1345,10 @@ window.HouseModel = (() => {
|
|||||||
const roofGroup = build ? RoofModel.buildUnit3D(ur, {
|
const roofGroup = build ? RoofModel.buildUnit3D(ur, {
|
||||||
materials: o.roofMaterials,
|
materials: o.roofMaterials,
|
||||||
osbVisible: shows('beplating'),
|
osbVisible: shows('beplating'),
|
||||||
dakleerVisible: shows('gevel'),
|
dakleerVisible: shows('dakbedekking'),
|
||||||
ceilingMaterial: state.settings.interiorPaint && shows('afwerking') && o.roofMaterials ? o.roofMaterials.ceilingMaterial : null
|
ceilingMaterial: state.settings.interiorPaint && shows('afwerking') && o.roofMaterials ? o.roofMaterials.ceilingMaterial : null
|
||||||
}) : null;
|
}) : null;
|
||||||
|
hideFrame(roofGroup, o.roofMaterials);
|
||||||
const ext = coverExt[u.id];
|
const ext = coverExt[u.id];
|
||||||
if (roofGroup && ext && shows('afwerking')) {
|
if (roofGroup && ext && shows('afwerking')) {
|
||||||
const fz = rect.z0 - ext.f, bz = rect.z1 + ext.b;
|
const fz = rect.z0 - ext.f, bz = rect.z1 + ext.b;
|
||||||
@@ -984,7 +1542,7 @@ window.HouseModel = (() => {
|
|||||||
const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb);
|
const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb);
|
||||||
// Gespiegeld: de schuine bovenzijde klapt om. Het kozijn (fill) staat alleen in het eerste deel.
|
// Gespiegeld: de schuine bovenzijde klapt om. Het kozijn (fill) staat alleen in het eerste deel.
|
||||||
const flip = ua > ub;
|
const flip = ua > ub;
|
||||||
return { id: op.id, type: op.type, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
|
return { id: op.id, type: op.type, koppel: op.koppel, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) };
|
||||||
});
|
});
|
||||||
config = WallModel.buildConfig(paramsOf(lf), ops);
|
config = WallModel.buildConfig(paramsOf(lf), ops);
|
||||||
}
|
}
|
||||||
@@ -1003,11 +1561,32 @@ window.HouseModel = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buitenlagen tot de bovenkant van het dak, per punt: een steil vlak (wolfseind) is verticaal dikker.
|
||||||
|
function skinTopLine(p, roof) {
|
||||||
|
const cfg = p.config, W = cfg.wallWidth, tl = WallModel.topLine(cfg), zF = p.place.thickness / 2;
|
||||||
|
const xs = new Set([0, W].concat((cfg.wallTop || []).map(q => q.x)));
|
||||||
|
for (let x = 100; x < W; x += 100) xs.add(x);
|
||||||
|
const pts = [...xs].sort((a, b) => a - b).map(x => {
|
||||||
|
const top = tl.at(x - W / 2), w = UnitModel.localToWorld(p.place, x - W / 2, zF), wallTop = p.place.floorTop + top;
|
||||||
|
return { x, h: top + Math.max(0, roof.topAt(w.x, w.z) - Math.min(wallTop, roof.ceilingAt(w.x, w.z))) };
|
||||||
|
});
|
||||||
|
const L = pts[0], R = pts[pts.length - 1];
|
||||||
|
const inner = WallModel.wallTopPoints({ topPts: pts.slice(1, -1).map(q => ({ f: q.x / W, h: q.h })) }, W, Math.round(L.h), Math.round(R.h));
|
||||||
|
const line = [L, ...inner, R];
|
||||||
|
const at = (x) => { let i = 0; while (i < line.length - 2 && x > line[i + 1].x) i++; const a = line[i], b = line[i + 1]; return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h); };
|
||||||
|
// Alleen een bolle daklijn: een kil of sprong (binnenhoek L-vorm) zou de lijn erboven trekken; dan vast skinAbove.
|
||||||
|
if (pts.some(q => at(q.x) > q.h + 5)) return null;
|
||||||
|
return { hL: Math.round(L.h), hR: Math.round(R.h), pts: inner };
|
||||||
|
}
|
||||||
|
|
||||||
function topWedge(p, roof) {
|
function topWedge(p, roof) {
|
||||||
const cfg = p.config, half = cfg.wallWidth / 2, zF = cfg.studDepth / 2, tl = WallModel.topLine(cfg);
|
const cfg = p.config, half = cfg.wallWidth / 2, zF = cfg.studDepth / 2, tl = WallModel.topLine(cfg);
|
||||||
const xs = new Set([-half, half].concat(tl.kinks));
|
const xs = new Set([-half, half].concat(tl.kinks));
|
||||||
for (let x = -half + 250; x < half; x += 250) xs.add(x);
|
for (let x = -half + 250; x < half; x += 250) xs.add(x);
|
||||||
|
const flues = cfg.openings.filter(op => op.type === 'schoorsteen').map(op => [op.x - half, op.x + op.w - half]);
|
||||||
|
flues.forEach(([a, b]) => { xs.add(a); xs.add(b); });
|
||||||
const pts = [...xs].sort((a, b) => a - b).map(x => {
|
const pts = [...xs].sort((a, b) => a - b).map(x => {
|
||||||
|
if (flues.some(([a, b]) => x > a + 0.5 && x < b - 0.5)) return { x, o: 0, i: 0 }; // geen hout in de schoorsteen
|
||||||
const top = p.place.floorTop + tl.at(x);
|
const top = p.place.floorTop + tl.at(x);
|
||||||
const at = (z) => { const w = UnitModel.localToWorld(p.place, x, z); return Math.max(0, Math.min(800, roof.ceilingAt(w.x, w.z) - top)); };
|
const at = (z) => { const w = UnitModel.localToWorld(p.place, x, z); return Math.max(0, Math.min(800, roof.ceilingAt(w.x, w.z) - top)); };
|
||||||
return { x, o: Math.round(at(zF)), i: Math.round(at(-zF)) };
|
return { x, o: Math.round(at(zF)), i: Math.round(at(-zF)) };
|
||||||
@@ -1041,6 +1620,7 @@ window.HouseModel = (() => {
|
|||||||
p.skinAbove = covered !== null
|
p.skinAbove = covered !== null
|
||||||
? Math.max(0, Math.round(covered - 3 - wallTop))
|
? Math.max(0, Math.round(covered - 3 - wallTop))
|
||||||
: Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - Math.min(wallTop, u.roof.ceilingAt(m.x, m.z))));
|
: Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - Math.min(wallTop, u.roof.ceilingAt(m.x, m.z))));
|
||||||
|
p.skinTop = covered !== null ? null : skinTopLine(p, u.roof);
|
||||||
p.underRoof = covered !== null;
|
p.underRoof = covered !== null;
|
||||||
p.config.topWedge = topWedge(p, u.roof);
|
p.config.topWedge = topWedge(p, u.roof);
|
||||||
// Binnenhoek-doorloop: tot de bovenkant van het dak van de buur geen buitenlagen (trimTop).
|
// Binnenhoek-doorloop: tot de bovenkant van het dak van de buur geen buitenlagen (trimTop).
|
||||||
@@ -1097,7 +1677,7 @@ window.HouseModel = (() => {
|
|||||||
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined)));
|
||||||
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior));
|
||||||
extPieces.concat(interior).forEach(p => {
|
extPieces.concat(interior).forEach(p => {
|
||||||
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc);
|
const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop);
|
||||||
if (built.group && p.shiftZ) {
|
if (built.group && p.shiftZ) {
|
||||||
const outer = new THREE.Group();
|
const outer = new THREE.Group();
|
||||||
built.group.position.z = p.shiftZ * 0.001;
|
built.group.position.z = p.shiftZ * 0.001;
|
||||||
@@ -1107,18 +1687,28 @@ window.HouseModel = (() => {
|
|||||||
Object.assign(p, built);
|
Object.assign(p, built);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const groundY = units.length ? Math.max(...units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0) : 0;
|
||||||
|
const chimneys = ChimneyModel.resolve(chimneysPlaced, units, roofEdge, groundY, roofEdge ? roofEdge.overhang : 0);
|
||||||
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
|
const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge);
|
||||||
|
if (roofEdge && chimneys.length) roofEdge.spots = roofEdge.spots.filter(sp => !ChimneyModel.covers(chimneys, sp.x, sp.z, 60));
|
||||||
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
|
const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, {
|
||||||
edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking')
|
edgeVisible: shows('afwerking'), spotsVisible: shows('lampen')
|
||||||
}) : null;
|
}) : null;
|
||||||
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
|
lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior);
|
||||||
LightingModel.roomSpots(state.settings.lighting, lighting.indoor, rooms, units);
|
LightingModel.roomSpots(state.settings.lighting, lighting.indoor, rooms, units);
|
||||||
RoomModel.lowerSpots(rooms, lighting.indoor);
|
RoomModel.lowerSpots(rooms, lighting.indoor);
|
||||||
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
|
const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null;
|
||||||
if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false && shows('afwerking');
|
if (lightingGroup) lightingGroup.visible = shows('lampen');
|
||||||
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge, rooms);
|
const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge, rooms);
|
||||||
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null;
|
const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, layers, o.elektraRough) : null;
|
||||||
if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra');
|
if (elektraGroup) elektraGroup.visible = shows('elektra');
|
||||||
|
|
||||||
|
const posts = PostModel.compute(state.settings.roof.posts, units, groundY);
|
||||||
|
const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null;
|
||||||
|
const chimneysGroup = build && chimneys.length ? ChimneyModel.build3D(chimneys, {
|
||||||
|
materials: o.chimneyMaterials, wallMaterial: o.wallMaterials && o.wallMaterials.spaanplaatMaterial
|
||||||
|
}) : null;
|
||||||
|
if (chimneysGroup) chimneysGroup.visible = shows('gevel');
|
||||||
|
|
||||||
const sandwich = state.settings.sandwich && units.length
|
const sandwich = state.settings.sandwich && units.length
|
||||||
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
|
? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null;
|
||||||
@@ -1127,12 +1717,26 @@ window.HouseModel = (() => {
|
|||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
const roomsGroup = build ? RoomModel.build3D(rooms) : null;
|
const roomsGroup = build ? RoomModel.build3D(rooms) : null;
|
||||||
if (roomsGroup) roomsGroup.visible = shows('afwerking');
|
if (roomsGroup) roomsGroup.visible = shows('ruimtes') && shows('afwerking');
|
||||||
const fixtures = FixtureModel.compute(state.fixtures || [], units);
|
const fixtures = FixtureModel.compute(state.fixtures || [], units);
|
||||||
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
|
const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null;
|
||||||
if (fixturesGroup) fixturesGroup.visible = shows('afwerking');
|
if (fixturesGroup) fixturesGroup.visible = shows('inrichting');
|
||||||
|
|
||||||
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, rooms, roomsGroup, fixtures, fixturesGroup };
|
return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten.
|
||||||
|
function placeGroups(parts) {
|
||||||
|
const toM = 0.001;
|
||||||
|
parts.units.forEach(u => {
|
||||||
|
if (u.floorGroup) u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM);
|
||||||
|
if (u.roof.group) u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM);
|
||||||
|
});
|
||||||
|
allWallPieces(parts).forEach(p => {
|
||||||
|
if (!p.group) return;
|
||||||
|
p.group.rotation.y = p.place.rotY;
|
||||||
|
p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function allWallPieces(parts) {
|
function allWallPieces(parts) {
|
||||||
@@ -1271,6 +1875,7 @@ window.HouseModel = (() => {
|
|||||||
kg: u.floorSpecs.totalKg + u.roof.specs.totalKg + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.totalKg, 0), 0)
|
kg: u.floorSpecs.totalKg + u.roof.specs.totalKg + u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + p.specs.totalKg, 0), 0)
|
||||||
}));
|
}));
|
||||||
if (parts.interior.length) weights.push({ label: 'Binnenwanden', element: 'BW', kg: parts.interior.reduce((a, p) => a + p.specs.totalKg, 0) });
|
if (parts.interior.length) weights.push({ label: 'Binnenwanden', element: 'BW', kg: parts.interior.reduce((a, p) => a + p.specs.totalKg, 0) });
|
||||||
|
if (parts.posts.count) weights.push({ label: 'Palen overkapping', element: 'PAAL', kg: parts.posts.kg });
|
||||||
|
|
||||||
const isolatie = parts.units.map(u => ({
|
const isolatie = parts.units.map(u => ({
|
||||||
label: `Unit ${u.id}`, element: u.id,
|
label: `Unit ${u.id}`, element: u.id,
|
||||||
@@ -1350,6 +1955,10 @@ window.HouseModel = (() => {
|
|||||||
return { unit: u.id, name: rb.profile.name, kgM: rb.profile.kgM, lengthM: rb.length / 1000, kg: rb.steelKg, fillM3: rb.fillM3, fillKg: rb.fillKg,
|
return { unit: u.id, name: rb.profile.name, kgM: rb.profile.kgM, lengthM: rb.length / 1000, kg: rb.steelKg, fillM3: rb.fillM3, fillKg: rb.fillKg,
|
||||||
util: rb.util, defl: rb.defl, deflMax: rb.deflMax, ok: rb.ok, below: rb.below, loadKNm: rb.loadKNm };
|
util: rb.util, defl: rb.defl, deflMax: rb.deflMax, ok: rb.ok, below: rb.below, loadKNm: rb.loadKNm };
|
||||||
}),
|
}),
|
||||||
|
posts: parts.posts.count ? {
|
||||||
|
count: parts.posts.count, size: parts.posts.size, wood: (PostModel.WOODS[parts.posts.wood] || {}).label || '',
|
||||||
|
lengthM: parts.posts.lengthMM / 1000, m3: parts.posts.m3, kg: parts.posts.kg
|
||||||
|
} : null,
|
||||||
roofEdge: parts.roofEdge ? {
|
roofEdge: parts.roofEdge ? {
|
||||||
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
|
fasciaLengthM: parts.roofEdge.fasciaLengthMM / 1000,
|
||||||
fasciaHeight: parts.roofEdge.fasciaHeight,
|
fasciaHeight: parts.roofEdge.fasciaHeight,
|
||||||
@@ -1390,6 +1999,7 @@ window.HouseModel = (() => {
|
|||||||
});
|
});
|
||||||
if (design.rooms !== undefined && !Array.isArray(design.rooms)) throw new Error('veld "rooms" moet een lijst zijn');
|
if (design.rooms !== undefined && !Array.isArray(design.rooms)) throw new Error('veld "rooms" moet een lijst zijn');
|
||||||
if (design.fixtures !== undefined && !Array.isArray(design.fixtures)) throw new Error('veld "fixtures" moet een lijst zijn');
|
if (design.fixtures !== undefined && !Array.isArray(design.fixtures)) throw new Error('veld "fixtures" moet een lijst zijn');
|
||||||
|
if (design.chimneys !== undefined && !Array.isArray(design.chimneys)) throw new Error('veld "chimneys" moet een lijst zijn');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Oud unit-ontwerp (type 'unit-design') -> woning met één unit.
|
// Oud unit-ontwerp (type 'unit-design') -> woning met één unit.
|
||||||
@@ -1403,6 +2013,7 @@ window.HouseModel = (() => {
|
|||||||
state.interiorWalls = state.interiorWalls || [];
|
state.interiorWalls = state.interiorWalls || [];
|
||||||
state.rooms = RoomModel.normalize(state.rooms, state.units.map(u => u.id));
|
state.rooms = RoomModel.normalize(state.rooms, state.units.map(u => u.id));
|
||||||
state.fixtures = FixtureModel.normalize(state.fixtures, state.units.map(u => u.id));
|
state.fixtures = FixtureModel.normalize(state.fixtures, state.units.map(u => u.id));
|
||||||
|
state.chimneys = ChimneyModel.normalize(state.chimneys, state.units.map(u => u.id));
|
||||||
const def = createDefaultSettings();
|
const def = createDefaultSettings();
|
||||||
state.settings = state.settings || {};
|
state.settings = state.settings || {};
|
||||||
state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {});
|
state.settings.spacing = Object.assign(def.spacing, state.settings.spacing || {});
|
||||||
@@ -1437,9 +2048,11 @@ window.HouseModel = (() => {
|
|||||||
state.settings.elektra = ElektraModel.normalizeSettings(state.settings.elektra);
|
state.settings.elektra = ElektraModel.normalizeSettings(state.settings.elektra);
|
||||||
state.units.forEach(u => {
|
state.units.forEach(u => {
|
||||||
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
|
if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' };
|
||||||
|
UnitModel.dropMansarde(u.unit);
|
||||||
if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; }
|
if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; }
|
||||||
if (u.roof.fascia === undefined) u.roof.fascia = oldRoof.edge !== 'daktrim';
|
if (u.roof.fascia === undefined) u.roof.fascia = oldRoof.edge !== 'daktrim';
|
||||||
if (!RoofModel.COVERINGS[u.roof.covering]) u.roof.covering = 'dakleer';
|
if (!RoofModel.COVERINGS[u.roof.covering]) u.roof.covering = 'dakleer';
|
||||||
|
u.roof.extend = normalizeExtend(u.roof.extend);
|
||||||
u.lighting = { walls: (u.lighting && u.lighting.walls) || {} };
|
u.lighting = { walls: (u.lighting && u.lighting.walls) || {} };
|
||||||
u.facade = { walls: (u.facade && u.facade.walls) || {} };
|
u.facade = { walls: (u.facade && u.facade.walls) || {} };
|
||||||
if (!hadFacade) Object.entries(u.unit.walls).forEach(([side, ws]) => {
|
if (!hadFacade) Object.entries(u.unit.walls).forEach(([side, ws]) => {
|
||||||
@@ -1453,7 +2066,7 @@ window.HouseModel = (() => {
|
|||||||
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
|
ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials,
|
||||||
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs,
|
||||||
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls,
|
||||||
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions,
|
houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX,
|
||||||
computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
computeParts, placeGroups, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+61
-21
@@ -32,7 +32,8 @@ window.HousePdf = (() => {
|
|||||||
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
|
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
|
||||||
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
|
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
|
||||||
u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
|
u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
|
||||||
if (u.roof.unitRoof && u.roof.unitRoof.cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u });
|
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 }));
|
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
|
||||||
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
|
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
|
||||||
@@ -86,7 +87,7 @@ window.HousePdf = (() => {
|
|||||||
doc.setDrawColor(...BLACK);
|
doc.setDrawColor(...BLACK);
|
||||||
doc.setFillColor(255, 255, 255);
|
doc.setFillColor(255, 255, 255);
|
||||||
doc.setLineWidth(0.2);
|
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);
|
const c = pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w);
|
||||||
poly(c, 'FD');
|
poly(c, 'FD');
|
||||||
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
|
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
|
||||||
@@ -134,6 +135,16 @@ window.HousePdf = (() => {
|
|||||||
parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD'));
|
parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD'));
|
||||||
}
|
}
|
||||||
doc.setLineDashPattern([], 0);
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
doc.setDrawColor(...BLACK);
|
doc.setDrawColor(...BLACK);
|
||||||
doc.setFillColor(255, 211, 107);
|
doc.setFillColor(255, 211, 107);
|
||||||
@@ -235,10 +246,10 @@ window.HousePdf = (() => {
|
|||||||
const W = p.place.width, T = p.place.thickness;
|
const W = p.place.width, T = p.place.thickness;
|
||||||
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), 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 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 tl = WallModel.topLine(WallModel.skinConfig(p.config, p.skinAbove || 0, p.skinTop));
|
||||||
const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up;
|
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) }]
|
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) }]);
|
.concat([{ x: xl, y: top(xl) }]);
|
||||||
return loc;
|
return loc;
|
||||||
};
|
};
|
||||||
@@ -247,21 +258,23 @@ window.HousePdf = (() => {
|
|||||||
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
const n = { x: p.place.normal.x, z: p.place.normal.y };
|
||||||
const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
|
const 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);
|
const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : p.stuc ? WallModel.stucHex(p.stuc) : WallModel.rabatHex(state.settings.facade.rabatColor);
|
||||||
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
|
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;
|
const tr = p.trasraam;
|
||||||
if (tr && tr.top > y0) {
|
if (tr && tr.top > y0) {
|
||||||
const W = p.place.width;
|
const W = p.place.width;
|
||||||
const band = wallFace(p, y0).map(q => ({ x: q.x, y: Math.min(q.y, tr.top) }));
|
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 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);
|
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 });
|
||||||
}
|
}
|
||||||
(p.config.openings || []).forEach(op => {
|
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
|
||||||
if (WallModel.openingTopMax(op) <= y0) return;
|
if (WallModel.openingTopMax(op) <= y0) return;
|
||||||
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y);
|
const 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 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;
|
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.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0))));
|
||||||
@@ -278,11 +291,11 @@ window.HousePdf = (() => {
|
|||||||
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
|
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; }
|
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
|
||||||
const len = Math.hypot(nx, ny, nz) || 1;
|
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 });
|
||||||
};
|
};
|
||||||
RoofModel.capRegions(r.unitRoof).forEach(rg => {
|
RoofModel.capRegions(r.unitRoof).forEach(rg => {
|
||||||
pushRoof(rg.poly.map(pt => {
|
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 };
|
return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z };
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -311,9 +324,20 @@ window.HousePdf = (() => {
|
|||||||
top.push({ x: O.x, y: Math.max(tI, tO), z: O.z });
|
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 });
|
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;
|
return faces;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,8 +436,8 @@ window.HousePdf = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawViewsSheet(doc, parts, state, sheetOpts) {
|
// Per gevel de zichtbare vlakken in (u, hoogte), van achter naar voor.
|
||||||
const { BLACK } = PdfSheet.COLORS;
|
function viewProjections(parts, state) {
|
||||||
const faces = viewFaces(parts, state);
|
const faces = viewFaces(parts, state);
|
||||||
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
|
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
|
||||||
const views = VIEWS.map(v => {
|
const views = VIEWS.map(v => {
|
||||||
@@ -424,6 +448,12 @@ 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]);
|
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) };
|
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
|
||||||
}).filter(x => x.list.length);
|
}).filter(x => x.list.length);
|
||||||
|
return { groundY, views };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawViewsSheet(doc, parts, state, sheetOpts) {
|
||||||
|
const { BLACK } = PdfSheet.COLORS;
|
||||||
|
const { groundY, views } = viewProjections(parts, state);
|
||||||
|
|
||||||
const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7;
|
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 cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2;
|
||||||
@@ -457,7 +487,7 @@ window.HousePdf = (() => {
|
|||||||
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function drawCapSheet(doc, u, sheetOpts) {
|
||||||
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
|
||||||
const ur = u.roof.unitRoof, c = ur.cap;
|
const ur = u.roof.unitRoof, c = ur.cap;
|
||||||
@@ -474,7 +504,8 @@ window.HousePdf = (() => {
|
|||||||
};
|
};
|
||||||
doc.setTextColor(...BLACK);
|
doc.setTextColor(...BLACK);
|
||||||
doc.setFontSize(11);
|
doc.setFontSize(11);
|
||||||
doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20);
|
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.setDrawColor(...GREY);
|
||||||
doc.setLineWidth(0.15);
|
doc.setLineWidth(0.15);
|
||||||
c.regions.forEach(rg => poly(rg.poly.map(xz), 'S'));
|
c.regions.forEach(rg => poly(rg.poly.map(xz), 'S'));
|
||||||
@@ -505,17 +536,22 @@ window.HousePdf = (() => {
|
|||||||
const H = (p) => RoofModel.profileHeight(ur.shape.profile, p);
|
const H = (p) => RoofModel.profileHeight(ur.shape.profile, p);
|
||||||
const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ',');
|
const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ',');
|
||||||
const hip = c.regions.find(rg => rg.key === 'h1');
|
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`],
|
['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`],
|
||||||
['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`],
|
['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`],
|
||||||
['Helling dakvlakken', `${deg(c.slope)}°`],
|
['Helling dakvlakken', `${deg(c.slope)}°`],
|
||||||
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'],
|
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-']
|
||||||
|
]).concat([
|
||||||
['Balken', `${c.joistW} x ${c.joistH} mm`],
|
['Balken', `${c.joistW} x ${c.joistH} mm`],
|
||||||
['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`],
|
['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`],
|
||||||
['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`]
|
['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`]
|
||||||
];
|
]);
|
||||||
doc.setTextColor(...BLACK);
|
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 });
|
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.setFontSize(7);
|
||||||
doc.setTextColor(...GREY);
|
doc.setTextColor(...GREY);
|
||||||
@@ -579,6 +615,10 @@ window.HousePdf = (() => {
|
|||||||
rows.push(['Boeideel-spots', e.spots + ' stuks']);
|
rows.push(['Boeideel-spots', e.spots + ' stuks']);
|
||||||
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
|
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
|
||||||
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
|
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
|
||||||
|
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.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,5 +803,5 @@ window.HousePdf = (() => {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS };
|
return { generate, sheetList, materialThumbUrls, viewFaces, viewProjections, VIEWS };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+594
-296
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ window.LightingModel = (() => {
|
|||||||
mode: 'spots',
|
mode: 'spots',
|
||||||
direction: 'both',
|
direction: 'both',
|
||||||
lampHeight: 1950,
|
lampHeight: 1950,
|
||||||
lampMin: 1500,
|
lampMin: 500,
|
||||||
lampMax: 3000,
|
lampMax: 3000,
|
||||||
minWallWidth: 2000,
|
minWallWidth: 2000,
|
||||||
endOffset: 300,
|
endOffset: 300,
|
||||||
@@ -39,14 +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 }));
|
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 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
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 pts = positions.slice().sort((a, b) => a - b);
|
||||||
let changed = true;
|
let changed = true;
|
||||||
while (changed && pts.length > 1) {
|
while (changed && pts.length > 1) {
|
||||||
changed = false;
|
changed = false;
|
||||||
for (let i = 0; i < pts.length - 1; i++) {
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
if (pts[i + 1] - pts[i] < min - 0.5) {
|
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;
|
changed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -55,6 +57,27 @@ window.LightingModel = (() => {
|
|||||||
return pts;
|
return pts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function freeIntervals(lo, hi, blocked) {
|
||||||
const free = [];
|
const free = [];
|
||||||
let cursor = lo;
|
let cursor = lo;
|
||||||
@@ -113,7 +136,7 @@ window.LightingModel = (() => {
|
|||||||
};
|
};
|
||||||
if (s.mode === 'gevel') {
|
if (s.mode === 'gevel') {
|
||||||
runs.forEach(run => {
|
runs.forEach(run => {
|
||||||
const blocked = [];
|
const blocked = [], openings = [];
|
||||||
const pos = [];
|
const pos = [];
|
||||||
// Delen achter een andere wand (bijv. een stuk dat de naad in loopt) zijn geen zichtbare gevel.
|
// 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 => {
|
segs.filter(o => !run.segs.includes(o)).forEach(o => {
|
||||||
@@ -126,26 +149,29 @@ window.LightingModel = (() => {
|
|||||||
const cfg = sg.piece.config, sW = cfg.studWidth;
|
const cfg = sg.piece.config, sW = cfg.studWidth;
|
||||||
const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100;
|
const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100;
|
||||||
if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; }
|
if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; }
|
||||||
if (sg.override === 'openingen') {
|
const own = [];
|
||||||
blocked.push([sg.s0, sg.s1]);
|
|
||||||
cfg.openings.forEach(op => [op.x - s.openingOffset, op.x + op.w + s.openingOffset]
|
|
||||||
.filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + sW >= H).forEach(op => {
|
cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + sW >= H).forEach(op => {
|
||||||
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
|
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
|
||||||
blocked.push([Math.min(a, b), Math.max(a, b)]);
|
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]) => {
|
freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => {
|
||||||
if (b - a < s.minWallWidth) return;
|
if (b - a < s.minWallWidth) return;
|
||||||
const first = a + s.endOffset, last = b - s.endOffset;
|
const k = Math.max(1, Math.round((b - a) / s.lampMax));
|
||||||
pos.push(first);
|
for (let i = 0; i < k; i++) pos.push(a + (b - a) * (i + 0.5) / k);
|
||||||
calculateSegmentPositions(last - first, s.lampMin, s.lampMax).forEach(p => pos.push(first + p));
|
|
||||||
pos.push(last);
|
|
||||||
});
|
});
|
||||||
// Min. h.o.h. geldt over alle lampen van de gevel samen (ook overrides en de grens tussen wanden).
|
// Min. h.o.h. geldt over alle lampen van de gevel samen (ook overrides en de grens tussen wanden).
|
||||||
enforceMinSpacing(pos, s.lampMin).forEach(v => addLamp(run, v));
|
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))));
|
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => p.lamps.sort((a, b) => a.u - b.u))));
|
||||||
}
|
}
|
||||||
@@ -298,32 +324,41 @@ window.LightingModel = (() => {
|
|||||||
const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
|
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 INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 };
|
||||||
const DAY_OUTDOOR_FACTOR = 0.35;
|
const DAY_OUTDOOR_FACTOR = 0.35;
|
||||||
function buildLights3D(lamps, spots, spotY, indoor, o) {
|
// Lichtbronnen in meters: positie, richting (genormaliseerd), halve kegelhoek; gedeeld door 3D en render-export.
|
||||||
const toM = 0.001;
|
function lightSources(lamps, spots, spotY, indoor) {
|
||||||
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
|
const toM = 0.001, out = [];
|
||||||
const group = new THREE.Group();
|
const add = (kind, x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
|
||||||
const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
|
const len = Math.hypot(tx, ty, tz) || 1;
|
||||||
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay);
|
out.push({ kind, x, y, z, dir: { x: tx / len, y: ty / len, z: tz / len }, intensity, angle, distance, decay, penumbra, ref });
|
||||||
light.userData.ref = ref;
|
|
||||||
light.position.set(x, y, z);
|
|
||||||
light.target.position.set(x + tx, y + ty, z + tz);
|
|
||||||
group.add(light);
|
|
||||||
group.add(light.target);
|
|
||||||
};
|
};
|
||||||
const tilt = Math.tan(OUTDOOR.angle);
|
const tilt = Math.tan(OUTDOOR.angle);
|
||||||
lamps.forEach(l => {
|
lamps.forEach(l => {
|
||||||
const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM;
|
const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM;
|
||||||
const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
|
const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
|
||||||
if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
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(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
if (l.direction !== 'down') add('wandlamp', x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
|
||||||
});
|
});
|
||||||
(spots || []).forEach(p => {
|
(spots || []).forEach(p => {
|
||||||
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
|
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
|
||||||
add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra, p);
|
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 => {
|
(indoor || []).forEach(p => {
|
||||||
const drop = (p.y - p.floorY) * toM;
|
const drop = (p.y - p.floorY) * toM;
|
||||||
add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra, p);
|
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));
|
if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo));
|
||||||
return group;
|
return group;
|
||||||
@@ -424,5 +459,5 @@ window.LightingModel = (() => {
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, 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 };
|
||||||
|
})();
|
||||||
+310
-86
@@ -92,7 +92,7 @@ window.RoofModel = (() => {
|
|||||||
const deck = clone(floorState);
|
const deck = clone(floorState);
|
||||||
deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
|
deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
|
||||||
deck.openings = [];
|
deck.openings = [];
|
||||||
return { followFloor: true, offsetX: 0, offsetZ: 0, fascia: true, covering: 'dakleer', deck };
|
return { followFloor: true, offsetX: 0, offsetZ: 0, extend: { f: 0, r: 0, b: 0, l: 0 }, fascia: true, covering: 'dakleer', deck };
|
||||||
}
|
}
|
||||||
|
|
||||||
function thickness(calcData, covering) {
|
function thickness(calcData, covering) {
|
||||||
@@ -125,18 +125,8 @@ window.RoofModel = (() => {
|
|||||||
deck.position.y = deckH;
|
deck.position.y = deckH;
|
||||||
group.add(deck);
|
group.add(deck);
|
||||||
|
|
||||||
const wM = calcData.width * toM, dM = calcData.depth * toM;
|
const dM = calcData.depth * toM;
|
||||||
const shape = new THREE.Shape();
|
const shape = FloorModel.surfaceShapes(calcData, resolvedOpenings, toM);
|
||||||
shape.moveTo(-wM / 2, -dM / 2);
|
|
||||||
shape.lineTo(wM / 2, -dM / 2);
|
|
||||||
shape.lineTo(wM / 2, dM / 2);
|
|
||||||
shape.lineTo(-wM / 2, dM / 2);
|
|
||||||
shape.closePath();
|
|
||||||
resolvedOpenings.filter(o => !o.removed && o.r > 0).forEach(o => {
|
|
||||||
const hole = new THREE.Path();
|
|
||||||
hole.absarc(o.x * toM, o.y * toM - dM / 2, o.r * toM, 0, Math.PI * 2, false);
|
|
||||||
shape.holes.push(hole);
|
|
||||||
});
|
|
||||||
const cov = COVERINGS[opts.covering] || COVERINGS.dakleer;
|
const cov = COVERINGS[opts.covering] || COVERINGS.dakleer;
|
||||||
const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial;
|
const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial;
|
||||||
const bT = BESCHOT_MM * toM;
|
const bT = BESCHOT_MM * toM;
|
||||||
@@ -212,7 +202,7 @@ window.RoofModel = (() => {
|
|||||||
return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } };
|
return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel';
|
const type = profile.length === 2 ? 'lessenaar' : 'zadel';
|
||||||
return { type, axis, profile };
|
return { type, axis, profile };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,8 +257,11 @@ window.RoofModel = (() => {
|
|||||||
finishKNm2: 0.3, // afwerking/installaties onder het dak
|
finishKNm2: 0.3, // afwerking/installaties onder het dak
|
||||||
gammaG: 1.2, gammaQ: 1.5, deflection: 250
|
gammaG: 1.2, gammaQ: 1.5, deflection: 250
|
||||||
};
|
};
|
||||||
const ridgeTypes = ['zadel', 'mansarde', 'wolf'];
|
const ridgeTypes = ['zadel', 'wolf'];
|
||||||
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2) {
|
// span: [q0, q1] waar de nok echt loopt (dakaansluiting: tot de hoekkeper).
|
||||||
|
// cuts: sparingen (schoorsteen) in plattegrondmaten; ligt er een op de nok, dan stopt de balk bear mm erin
|
||||||
|
// (negatief: ervoor).
|
||||||
|
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2, span, cuts) {
|
||||||
if (roofState.steelRidge === false || !ridgeTypes.includes(shape.type)) return null;
|
if (roofState.steelRidge === false || !ridgeTypes.includes(shape.type)) return null;
|
||||||
const pr = shape.profile, inner = pr.slice(1, -1);
|
const pr = shape.profile, inner = pr.slice(1, -1);
|
||||||
if (!inner.length) return null;
|
if (!inner.length) return null;
|
||||||
@@ -283,6 +276,13 @@ window.RoofModel = (() => {
|
|||||||
q0 = Math.max(uq0, Math.min(g.qM, g.q0 + g.inset));
|
q0 = Math.max(uq0, Math.min(g.qM, g.q0 + g.inset));
|
||||||
q1 = Math.min(uq1, Math.max(g.qM, g.q1 - g.inset));
|
q1 = Math.min(uq1, Math.max(g.qM, g.q1 - g.inset));
|
||||||
}
|
}
|
||||||
|
if (span) { q0 = Math.max(q0, span[0]); q1 = Math.min(q1, span[1]); }
|
||||||
|
(cuts || []).forEach(c => {
|
||||||
|
const [pa, pb] = alongX ? [c.x0, c.x1] : [c.z0, c.z1], [qa, qb] = alongX ? [c.z0, c.z1] : [c.x0, c.x1];
|
||||||
|
if (R.p < pa - 100 || R.p > pb + 100 || qb < q0 || qa > q1) return;
|
||||||
|
if ((qa + qb) / 2 > (q0 + q1) / 2) q1 = Math.min(q1, qa + (c.bear || 0));
|
||||||
|
else q0 = Math.max(q0, qb - (c.bear || 0));
|
||||||
|
});
|
||||||
const L = q1 - q0;
|
const L = q1 - q0;
|
||||||
if (L < 100) return null;
|
if (L < 100) return null;
|
||||||
// q in N/mm = kN/m.
|
// q in N/mm = kN/m.
|
||||||
@@ -348,7 +348,9 @@ window.RoofModel = (() => {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeUnitRoof(roofState, shape, rect, rims) {
|
// cuts: rechthoekige sparingen (schoorsteen) in plattegrondmaten van de unit: { x0, x1, z0, z1, bear }.
|
||||||
|
function computeUnitRoof(roofState, shape, rect, rims, cutRects) {
|
||||||
|
const roofCuts = cutRects || [];
|
||||||
const alongX = shape.axis === 'x';
|
const alongX = shape.axis === 'x';
|
||||||
const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0;
|
const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0;
|
||||||
const xc = (rect.x0 + rect.x1) / 2, zc = (rect.z0 + rect.z1) / 2;
|
const xc = (rect.x0 + rect.x1) / 2, zc = (rect.z0 + rect.z1) / 2;
|
||||||
@@ -370,10 +372,18 @@ window.RoofModel = (() => {
|
|||||||
const slopeLen = Math.hypot(run, h1 - h0), cos = run / slopeLen;
|
const slopeLen = Math.hypot(run, h1 - h0), cos = run / slopeLen;
|
||||||
const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 };
|
const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 };
|
||||||
let data;
|
let data;
|
||||||
if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5) {
|
const deckCuts = roofCuts.map(c => {
|
||||||
|
const [qa, qb] = alongX ? [c.x0, c.x1] : [c.z0, c.z1];
|
||||||
|
const a = Math.max(qa, p0), b = Math.min(qb, p1);
|
||||||
|
if (b - a < 1) return null;
|
||||||
|
const [x0, x1] = alongX ? [zc - c.z1, zc - c.z0] : [c.x0 - xc, c.x1 - xc];
|
||||||
|
return { x0, x1, y0: (a - p0) / cos, y1: (b - p0) / cos };
|
||||||
|
}).filter(Boolean);
|
||||||
|
if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5 && !deckCuts.length) {
|
||||||
data = flat;
|
data = flat;
|
||||||
} else {
|
} else {
|
||||||
const deck = clone(roofState.deck);
|
const deck = clone(roofState.deck);
|
||||||
|
deck.cuts = deckCuts;
|
||||||
deck.floor.width = Math.round(alongX ? D : W);
|
deck.floor.width = Math.round(alongX ? D : W);
|
||||||
deck.floor.depth = Math.round(slopeLen);
|
deck.floor.depth = Math.round(slopeLen);
|
||||||
deck.openings = holes.filter(hl => hl.q >= p0 && (hl.q < p1 || last)).map(hl => ({
|
deck.openings = holes.filter(hl => hl.q >= p0 && (hl.q < p1 || last)).map(hl => ({
|
||||||
@@ -393,7 +403,7 @@ window.RoofModel = (() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mansardeknik: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik,
|
// Knik in de daklijn: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik,
|
||||||
// bovenkant op het snijpunt van de bovenvlakken.
|
// bovenkant op het snijpunt van de bovenvlakken.
|
||||||
planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; });
|
planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; });
|
||||||
planes.slice(0, -1).forEach((pi, i) => {
|
planes.slice(0, -1).forEach((pi, i) => {
|
||||||
@@ -417,12 +427,8 @@ window.RoofModel = (() => {
|
|||||||
topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick));
|
topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick));
|
||||||
}
|
}
|
||||||
const specs = sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : []));
|
const specs = sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : []));
|
||||||
const ridgeBeam = computeRidgeBeam(roofState, shape, rect, alongX, flat.calcData, specs.totalKg / Math.max(0.1, W * D / 1e6));
|
const ridgeBeam = computeRidgeBeam(roofState, shape, rect, alongX, flat.calcData, specs.totalKg / Math.max(0.1, W * D / 1e6), null, roofCuts);
|
||||||
Object.assign(specs, {
|
addRidgeSpecs(specs, ridgeBeam);
|
||||||
nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0,
|
|
||||||
nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0
|
|
||||||
});
|
|
||||||
specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg;
|
|
||||||
return {
|
return {
|
||||||
shape, covering, planes: outPlanes, specs, ridgeBeam,
|
shape, covering, planes: outPlanes, specs, ridgeBeam,
|
||||||
undersideAt, topAt, cap, rect, alongX,
|
undersideAt, topAt, cap, rect, alongX,
|
||||||
@@ -431,6 +437,200 @@ window.RoofModel = (() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stalen nok onder de naad van een dubbel lessenaardak (HouseModel): shape is het samengestelde zadel en rect
|
||||||
|
// de twee units samen, beide in de lokale maten van deze unit; de balk hoort bij deze unit (3D, kosten).
|
||||||
|
function sharedRidgeBeam(roofState, unitRoof, shape, rect, span, cuts) {
|
||||||
|
const pl = unitRoof.planes[0];
|
||||||
|
if (!pl || !pl.data) return null;
|
||||||
|
const r = unitRoof.rect, kgM2 = unitRoof.specs.totalKg / Math.max(0.1, (r.x1 - r.x0) * (r.z1 - r.z0) / 1e6);
|
||||||
|
const rb = computeRidgeBeam(roofState, shape, rect, shape.axis === 'x', pl.data.calcData, kgM2, span, cuts);
|
||||||
|
if (!rb) return null;
|
||||||
|
rb.alongX = shape.axis === 'x';
|
||||||
|
rb.shared = true;
|
||||||
|
unitRoof.ridgeBeam = rb;
|
||||||
|
addRidgeSpecs(unitRoof.specs, rb);
|
||||||
|
return rb;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRidgeSpecs(specs, ridgeBeam) {
|
||||||
|
Object.assign(specs, {
|
||||||
|
nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0,
|
||||||
|
nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0
|
||||||
|
});
|
||||||
|
specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dakaansluiting (HouseModel.roofJoins): vlakken h = a + bx·x + bz·z, samengevoegd volgens expr
|
||||||
|
// (vlaknummer of ['min' | 'max', ...]). extra(k): ophoging per vlak (bovenkant dak).
|
||||||
|
const planeAt = (pl, p) => pl.a + pl.bx * p.x + pl.bz * p.z;
|
||||||
|
function joinValue(join, p, extra) {
|
||||||
|
const at = (e) => Array.isArray(e) ? Math[e[0]](...e.slice(1).map(at)) : planeAt(join.planes[e], p) + (extra ? extra(e) : 0);
|
||||||
|
return at(join.expr);
|
||||||
|
}
|
||||||
|
function convexHull(pts) {
|
||||||
|
const sorted = pts.slice().sort((a, b) => a.x - b.x || a.z - b.z);
|
||||||
|
const cross = (o, a, b) => (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
|
||||||
|
const half = (list) => list.reduce((h, q) => {
|
||||||
|
while (h.length >= 2 && cross(h[h.length - 2], h[h.length - 1], q) <= 0.01 * Math.hypot(q.x - h[h.length - 2].x, q.z - h[h.length - 2].z)) h.pop();
|
||||||
|
h.push(q);
|
||||||
|
return h;
|
||||||
|
}, []);
|
||||||
|
const lo = half(sorted), up = half(sorted.slice().reverse());
|
||||||
|
const out = lo.slice(0, -1).concat(up.slice(0, -1));
|
||||||
|
return out.filter((p, k) => { const q = out[(k + 1) % out.length]; return Math.hypot(q.x - p.x, q.z - p.z) > 0.5; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dak van één unit in een dakaansluiting (join in unit-lokale maten): per vlak de convexe stukken binnen het dek,
|
||||||
|
// sporen haaks op de goot (kreupel tegen kil- en hoekkepers), kepers en nokken op de grenzen tussen de vlakken.
|
||||||
|
// Alles als houtwerk zoals het bovenste deel van een wolfsdak (cap); de eigen nok wordt de stalen nokbalk.
|
||||||
|
function computeJoinedRoof(roofState, join, rect, shape) {
|
||||||
|
const fp = roofState.deck.floor;
|
||||||
|
const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22;
|
||||||
|
const covering = coveringFor(roofState.covering, shape.type);
|
||||||
|
const cov = COVERINGS[covering] || COVERINGS.dakleer;
|
||||||
|
const pls = join.planes.map((pl, k) => {
|
||||||
|
const slope = Math.hypot(pl.bx, pl.bz), cos = 1 / Math.sqrt(1 + slope * slope);
|
||||||
|
const tV = underlay / cos, jV = joistH / cos, bV = BESCHOT_MM / cos, covV = cov.thickMM / cos;
|
||||||
|
const under = (p) => planeAt(pl, p);
|
||||||
|
return { key: k, pl, slope, cos, tV, jV, bV, covV, vThick: tV + jV + bV + covV, under, f: under, along: Math.abs(pl.bx) >= Math.abs(pl.bz) ? 'p' : 'q' };
|
||||||
|
});
|
||||||
|
const activeAt = (p) => {
|
||||||
|
const h = joinValue(join, p);
|
||||||
|
return pls.reduce((a, b) => Math.abs(b.under(p) - h) < Math.abs(a.under(p) - h) ? b : a);
|
||||||
|
};
|
||||||
|
|
||||||
|
const box = [{ x: rect.x0, z: rect.z0 }, { x: rect.x1, z: rect.z0 }, { x: rect.x1, z: rect.z1 }, { x: rect.x0, z: rect.z1 }];
|
||||||
|
const clipBox = (poly) => [(p) => p.x - rect.x0, (p) => rect.x1 - p.x, (p) => p.z - rect.z0, (p) => rect.z1 - p.z]
|
||||||
|
.reduce((pl, f) => pl.length >= 3 ? clipPoly(pl, f) : pl, poly);
|
||||||
|
const pairs = [];
|
||||||
|
pls.forEach((A, i) => pls.slice(i + 1).forEach(B => {
|
||||||
|
if (Math.hypot(A.pl.bx - B.pl.bx, A.pl.bz - B.pl.bz) > 1e-9) pairs.push({ A, B, f: (p) => A.under(p) - B.under(p) });
|
||||||
|
}));
|
||||||
|
// Afgeronde wandhoogtes maken bijna samenvallende lijnen: splinters (gemiddeld < 0,5 mm breed) vallen weg.
|
||||||
|
const perim = (poly) => poly.reduce((t, p, k) => { const q = poly[(k + 1) % poly.length]; return t + Math.hypot(q.x - p.x, q.z - p.z); }, 0);
|
||||||
|
const sliver = (poly) => quadArea(poly) < Math.max(100, 0.25 * perim(poly));
|
||||||
|
let cells = [box];
|
||||||
|
pairs.forEach(({ f }) => {
|
||||||
|
cells = cells.flatMap(c => [clipPoly(c, f), clipPoly(c, p => -f(p))]).filter(c => c.length >= 3 && !sliver(c));
|
||||||
|
});
|
||||||
|
const centroid = (poly) => ({ x: poly.reduce((t, p) => t + p.x, 0) / poly.length, z: poly.reduce((t, p) => t + p.z, 0) / poly.length });
|
||||||
|
const regions = [];
|
||||||
|
pls.forEach(pl => {
|
||||||
|
const list = cells.filter(c => activeAt(centroid(c)) === pl).map(convexHull);
|
||||||
|
// Aangrenzende cellen samenvoegen zolang het geheel convex blijft.
|
||||||
|
let merged = true;
|
||||||
|
while (merged) {
|
||||||
|
merged = false;
|
||||||
|
for (let i = 0; i < list.length && !merged; i++) {
|
||||||
|
for (let j = i + 1; j < list.length && !merged; j++) {
|
||||||
|
const h = convexHull(list[i].concat(list[j]));
|
||||||
|
if (quadArea(h) - quadArea(list[i]) - quadArea(list[j]) < 0.5 * perim(h)) { list[i] = h; list.splice(j, 1); merged = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.filter(poly => poly.length >= 3).forEach(poly => regions.push(Object.assign({}, pl, { poly })));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grenzen tussen twee vlakken: bol = nok (waterpas) of hoekkeper, hol = kilkeper.
|
||||||
|
const members = [];
|
||||||
|
pairs.forEach(({ A, B, f }) => {
|
||||||
|
const gx = A.pl.bx - B.pl.bx, gz = A.pl.bz - B.pl.bz, g = Math.hypot(gx, gz), c0 = f({ x: 0, z: 0 });
|
||||||
|
const o = { x: -c0 * gx / (g * g), z: -c0 * gz / (g * g) }, d = { x: -gz / g, z: gx / g }, n = { x: gx / g, z: gz / g };
|
||||||
|
let t0 = -Infinity, t1 = Infinity;
|
||||||
|
[['x', rect.x0, rect.x1], ['z', rect.z0, rect.z1]].forEach(([k, lo, hi]) => {
|
||||||
|
if (Math.abs(d[k]) < 1e-9) { if (o[k] < lo || o[k] > hi) t1 = -Infinity; return; }
|
||||||
|
const a = (lo - o[k]) / d[k], b = (hi - o[k]) / d[k];
|
||||||
|
t0 = Math.max(t0, Math.min(a, b));
|
||||||
|
t1 = Math.min(t1, Math.max(a, b));
|
||||||
|
});
|
||||||
|
if (!(t1 - t0 >= 100)) return;
|
||||||
|
const pt = (t) => ({ x: o.x + d.x * t, z: o.z + d.z * t });
|
||||||
|
const ts = [t0, t1];
|
||||||
|
pairs.forEach(q => {
|
||||||
|
const dn = q.f(pt(1)) - q.f(pt(0));
|
||||||
|
if (Math.abs(dn) < 1e-9) return;
|
||||||
|
const t = -q.f(pt(0)) / dn;
|
||||||
|
if (t > t0 && t < t1) ts.push(t);
|
||||||
|
});
|
||||||
|
ts.sort((a, b) => a - b);
|
||||||
|
const segs = [];
|
||||||
|
let cur = null;
|
||||||
|
ts.slice(0, -1).forEach((ta, k) => {
|
||||||
|
const tb = ts[k + 1];
|
||||||
|
if (tb - ta < 1) return;
|
||||||
|
const m = pt((ta + tb) / 2), off = (s) => ({ x: m.x + n.x * s, z: m.z + n.z * s });
|
||||||
|
const s1 = activeAt(off(2)), s2 = activeAt(off(-2));
|
||||||
|
if (Math.abs(joinValue(join, m) - A.under(m)) > 0.5 || !((s1 === A && s2 === B) || (s1 === B && s2 === A))) { cur = null; return; }
|
||||||
|
const onA = off(s1 === A ? 2 : -2), convex = B.under(onA) > A.under(onA);
|
||||||
|
if (cur && cur.convex === convex) { cur.tb = tb; return; }
|
||||||
|
cur = { ta, tb, convex };
|
||||||
|
segs.push(cur);
|
||||||
|
});
|
||||||
|
// Waterpas en bol: nok als de vlakken van elkaar af aflopen, anders een knik.
|
||||||
|
const ridge = A.pl.bx * B.pl.bx + A.pl.bz * B.pl.bz < 0;
|
||||||
|
segs.forEach(sg => {
|
||||||
|
const P = pt(sg.ta), Q = pt(sg.tb), level = Math.abs(A.under(P) - A.under(Q)) < 1;
|
||||||
|
members.push({ kind: !sg.convex ? 'kilkeper' : !level ? 'hoekkeper' : ridge ? 'nokbalk' : 'knikbalk', A: P, B: Q, rg: A });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const alongX = shape.axis === 'x';
|
||||||
|
const own = members.find(m => m.kind === 'nokbalk' && (alongX ? Math.abs(m.A.x - m.B.x) < 1 : Math.abs(m.A.z - m.B.z) < 1));
|
||||||
|
const steel = own && roofState.steelRidge !== false;
|
||||||
|
const { hw, beams, awayFrom, addBeam } = beamTools(joistW);
|
||||||
|
members.forEach(m => { if (!(steel && m === own)) addBeam(m.kind, m.rg, m.A, m.B, [], true); });
|
||||||
|
pls.forEach(pl => {
|
||||||
|
const polys = regions.filter(rg => rg.key === pl.key).map(rg => rg.poly);
|
||||||
|
if (!polys.length) return;
|
||||||
|
const fixed = pl.along === 'p' ? 'z' : 'x';
|
||||||
|
const [lo, hi] = fixed === 'z' ? [rect.z0, rect.z1] : [rect.x0, rect.x1];
|
||||||
|
joistSpread(fp, hi - lo).map(v => lo + v).forEach(v => {
|
||||||
|
const spans = polys.map(poly => cutLine(poly, fixed, v)).filter(Boolean).sort((a, b) => a[0] - b[0]);
|
||||||
|
const runs = [];
|
||||||
|
spans.forEach(sp => {
|
||||||
|
const last = runs[runs.length - 1];
|
||||||
|
if (last && sp[0] <= last[1] + 1) last[1] = Math.max(last[1], sp[1]);
|
||||||
|
else runs.push(sp.slice());
|
||||||
|
});
|
||||||
|
runs.forEach(span => {
|
||||||
|
const pt = (t) => fixed === 'z' ? { x: t, z: v } : { x: v, z: t };
|
||||||
|
const A = pt(span[0]), B = pt(span[1]), mid = pt((span[0] + span[1]) / 2);
|
||||||
|
const clips = members.filter(m => distToSeg(A, m) < hw + 1 || distToSeg(B, m) < hw + 1).map(m => awayFrom(m, mid));
|
||||||
|
addBeam('spoor', pl, A, B, clips);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownBeams = clipBeams(beams, clipBox);
|
||||||
|
const capSpecs = regionSpecs(ownBeams, regions, joistW, joistH, underlay, covering);
|
||||||
|
const kgM2 = capSpecs.totalKg / Math.max(0.1, (rect.x1 - rect.x0) * (rect.z1 - rect.z0) / 1e6);
|
||||||
|
const q = own ? (alongX ? [own.A.z, own.B.z] : [own.A.x, own.B.x]) : null;
|
||||||
|
// Wolfsdak: de nok loopt hier tot de aansluiting, de inkorting van de wolfseinden volgt uit span.
|
||||||
|
const ridgeShape = shape.type === 'wolf' ? Object.assign({}, shape, { type: 'zadel' }) : shape;
|
||||||
|
const ridgeBeam = steel ? computeRidgeBeam(roofState, ridgeShape, rect, alongX, { underlayThick: underlay, joistH }, kgM2, [Math.min(...q), Math.max(...q)]) : null;
|
||||||
|
if (ridgeBeam) ridgeBeam.alongX = alongX;
|
||||||
|
const specs = Object.assign({}, capSpecs);
|
||||||
|
addRidgeSpecs(specs, ridgeBeam);
|
||||||
|
const heights = regions.flatMap(rg => rg.poly.map(p => rg.under(p)));
|
||||||
|
const maxTop = Math.max(...regions.map(rg => Math.max(...rg.poly.map(p => rg.under(p) + rg.vThick))));
|
||||||
|
const cap = {
|
||||||
|
kind: 'join', pA: rect.x0, pB: rect.x1, q0: rect.z0, q1: rect.z1, eave: Math.min(...heights), ridgeH: Math.max(...heights),
|
||||||
|
slope: Math.max(...regions.map(rg => rg.slope)), joistW, joistH, regions, beams: ownBeams, specs: capSpecs, maxTop
|
||||||
|
};
|
||||||
|
// Knikpunten van de daklijn op de randen (nok, knik, wolfseind, kepers): daar knikt ook het boeideel.
|
||||||
|
const ridges = [];
|
||||||
|
members.forEach(m => [m.A, m.B].forEach(p => {
|
||||||
|
const onX = Math.abs(p.x - rect.x0) < 1 || Math.abs(p.x - rect.x1) < 1, onZ = Math.abs(p.z - rect.z0) < 1 || Math.abs(p.z - rect.z1) < 1;
|
||||||
|
const r = onX && !onZ ? { axis: 'z', value: p.z } : onZ && !onX ? { axis: 'x', value: p.x } : null;
|
||||||
|
if (r && !ridges.some(q => q.axis === r.axis && Math.abs(q.value - r.value) < 1)) ridges.push(r);
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
shape, covering, planes: [], specs, ridgeBeam, cap, rect, alongX: true, ridges, maxTop,
|
||||||
|
undersideAt: (x, z) => joinValue(join, { x, z }),
|
||||||
|
topAt: (x, z) => joinValue(join, { x, z }, k => pls[k].vThick)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel
|
const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel
|
||||||
function computeCap(roofState, shape, rect, alongX, covering) {
|
function computeCap(roofState, shape, rect, alongX, covering) {
|
||||||
const H = (p) => profileHeight(shape.profile, p);
|
const H = (p) => profileHeight(shape.profile, p);
|
||||||
@@ -465,53 +665,7 @@ window.RoofModel = (() => {
|
|||||||
.filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100);
|
.filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100);
|
||||||
if (!clipped.some(rg => rg.along === 'q')) return null;
|
if (!clipped.some(rg => rg.along === 'q')) return null;
|
||||||
|
|
||||||
const cutLine = (poly, fixed, v) => {
|
const { hw, beams, awayFrom, addBeam } = beamTools(joistW);
|
||||||
const ts = [];
|
|
||||||
poly.forEach((a, k) => {
|
|
||||||
const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed];
|
|
||||||
if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return;
|
|
||||||
const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x';
|
|
||||||
ts.push(a[o] + (b[o] - a[o]) * t);
|
|
||||||
});
|
|
||||||
return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null;
|
|
||||||
};
|
|
||||||
const hw = joistW / 2;
|
|
||||||
const rectAround = (A, B) => {
|
|
||||||
const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw;
|
|
||||||
return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }];
|
|
||||||
};
|
|
||||||
const awayFrom = (m, from) => {
|
|
||||||
const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L;
|
|
||||||
const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz;
|
|
||||||
const sgn = d(from) >= 0 ? 1 : -1;
|
|
||||||
return (p) => sgn * d(p) - hw;
|
|
||||||
};
|
|
||||||
const distToSeg = (p, m) => {
|
|
||||||
const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz;
|
|
||||||
const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2));
|
|
||||||
return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t));
|
|
||||||
};
|
|
||||||
const beams = [];
|
|
||||||
const addBeam = (kind, rg, A, B, clips, lineHeight) => {
|
|
||||||
if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null;
|
|
||||||
let poly = rectAround(A, B);
|
|
||||||
(clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); });
|
|
||||||
if (poly.length < 3) return null;
|
|
||||||
const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz);
|
|
||||||
const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV;
|
|
||||||
const yAt = lineHeight
|
|
||||||
? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L)))
|
|
||||||
: (p) => rg.under(p) + rg.tV;
|
|
||||||
const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L);
|
|
||||||
const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L;
|
|
||||||
if (plan < 50) return null;
|
|
||||||
const bm = {
|
|
||||||
kind, A, B, poly, yAt, jV: rg.jV, slope,
|
|
||||||
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
|
|
||||||
};
|
|
||||||
beams.push(bm);
|
|
||||||
return bm;
|
|
||||||
};
|
|
||||||
const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0];
|
const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0];
|
||||||
const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0];
|
const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0];
|
||||||
const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }];
|
const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }];
|
||||||
@@ -534,8 +688,7 @@ window.RoofModel = (() => {
|
|||||||
if (ridgeBeam && roofState.steelRidge !== false) beams.splice(beams.indexOf(ridgeBeam), 1);
|
if (ridgeBeam && roofState.steelRidge !== false) beams.splice(beams.indexOf(ridgeBeam), 1);
|
||||||
}
|
}
|
||||||
const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []);
|
const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []);
|
||||||
const spread = (len) => FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: joistW, joistHeight: joistH, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: underlay }).joists.map(x => x + len / 2);
|
const qs = joistSpread(fp, q1 - q0).map(v => q0 + v), ps = joistSpread(fp, pB - pA).map(v => pA + v);
|
||||||
const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(pB - pA).map(v => pA + v);
|
|
||||||
regions.forEach(rg => {
|
regions.forEach(rg => {
|
||||||
const fixed = rg.along === 'p' ? 'z' : 'x';
|
const fixed = rg.along === 'p' ? 'z' : 'x';
|
||||||
(rg.along === 'p' ? qs : ps).forEach(v => {
|
(rg.along === 'p' ? qs : ps).forEach(v => {
|
||||||
@@ -548,8 +701,69 @@ window.RoofModel = (() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const ownBeams = beams.map(bm => {
|
const ownBeams = clipBeams(beams, clipBox);
|
||||||
const poly = clipBox(bm.poly);
|
const specs = regionSpecs(ownBeams, clipped, joistW, joistH, underlay, covering);
|
||||||
|
const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick))));
|
||||||
|
return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Houtwerk op dakvlakken met een eigen omtrek (wolfseinden, dakaansluiting); balken als rechthoek in bovenaanzicht.
|
||||||
|
function cutLine(poly, fixed, v) {
|
||||||
|
const ts = [];
|
||||||
|
poly.forEach((a, k) => {
|
||||||
|
const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed];
|
||||||
|
if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return;
|
||||||
|
const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x';
|
||||||
|
ts.push(a[o] + (b[o] - a[o]) * t);
|
||||||
|
});
|
||||||
|
return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null;
|
||||||
|
}
|
||||||
|
function distToSeg(p, m) {
|
||||||
|
const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz;
|
||||||
|
const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2));
|
||||||
|
return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t));
|
||||||
|
}
|
||||||
|
function joistSpread(fp, len) {
|
||||||
|
return FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: fp.joistWidth || 70, joistHeight: fp.joistHeight || 220, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: fp.underlayThick || 22 })
|
||||||
|
.joists.map(x => x + len / 2);
|
||||||
|
}
|
||||||
|
function beamTools(joistW) {
|
||||||
|
const hw = joistW / 2, beams = [];
|
||||||
|
const rectAround = (A, B) => {
|
||||||
|
const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw;
|
||||||
|
return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }];
|
||||||
|
};
|
||||||
|
const awayFrom = (m, from) => {
|
||||||
|
const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L;
|
||||||
|
const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz;
|
||||||
|
const sgn = d(from) >= 0 ? 1 : -1;
|
||||||
|
return (p) => sgn * d(p) - hw;
|
||||||
|
};
|
||||||
|
const addBeam = (kind, rg, A, B, clips, lineHeight) => {
|
||||||
|
if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null;
|
||||||
|
let poly = rectAround(A, B);
|
||||||
|
(clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); });
|
||||||
|
if (poly.length < 3) return null;
|
||||||
|
const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz);
|
||||||
|
const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV;
|
||||||
|
const yAt = lineHeight
|
||||||
|
? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L)))
|
||||||
|
: (p) => rg.under(p) + rg.tV;
|
||||||
|
const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L);
|
||||||
|
const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L;
|
||||||
|
if (plan < 50) return null;
|
||||||
|
const bm = {
|
||||||
|
kind, A, B, poly, yAt, jV: rg.jV, slope,
|
||||||
|
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
|
||||||
|
};
|
||||||
|
beams.push(bm);
|
||||||
|
return bm;
|
||||||
|
};
|
||||||
|
return { hw, beams, awayFrom, addBeam };
|
||||||
|
}
|
||||||
|
function clipBeams(beams, clip) {
|
||||||
|
return beams.map(bm => {
|
||||||
|
const poly = clip(bm.poly);
|
||||||
if (poly.length < 3 || quadArea(poly) < 100) return null;
|
if (poly.length < 3 || quadArea(poly) < 100) return null;
|
||||||
const dx = bm.B.x - bm.A.x, dz = bm.B.z - bm.A.z, L = Math.hypot(dx, dz);
|
const dx = bm.B.x - bm.A.x, dz = bm.B.z - bm.A.z, L = Math.hypot(dx, dz);
|
||||||
const ts = poly.map(p => ((p.x - bm.A.x) * dx + (p.z - bm.A.z) * dz) / L);
|
const ts = poly.map(p => ((p.x - bm.A.x) * dx + (p.z - bm.A.z) * dz) / L);
|
||||||
@@ -557,20 +771,19 @@ window.RoofModel = (() => {
|
|||||||
if (plan < 50) return null;
|
if (plan < 50) return null;
|
||||||
return Object.assign({}, bm, { poly, length: Math.round(plan * Math.sqrt(1 + slope * slope)) });
|
return Object.assign({}, bm, { poly, length: Math.round(plan * Math.sqrt(1 + slope * slope)) });
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
function regionSpecs(beams, regions, joistW, joistH, underlay, covering) {
|
||||||
const W = joistW * 1e-3, Hh = joistH * 1e-3;
|
const W = joistW * 1e-3, Hh = joistH * 1e-3;
|
||||||
const woodM3 = ownBeams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0);
|
const woodM3 = beams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0);
|
||||||
const areaM2 = clipped.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6;
|
const areaM2 = regions.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6;
|
||||||
const cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3;
|
const cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3;
|
||||||
const specs = Object.assign({
|
return Object.assign({
|
||||||
joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3,
|
joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3,
|
||||||
osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3,
|
osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3,
|
||||||
beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bVol * DENSITY_OSB_KGM3,
|
beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bVol * DENSITY_OSB_KGM3,
|
||||||
voidM3: Math.max(0, areaM2 * Hh - woodM3),
|
voidM3: Math.max(0, areaM2 * Hh - woodM3),
|
||||||
totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg
|
totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg
|
||||||
}, cs);
|
}, cs);
|
||||||
const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick))));
|
|
||||||
return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ.
|
// Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ.
|
||||||
@@ -581,7 +794,8 @@ window.RoofModel = (() => {
|
|||||||
if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts));
|
if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts));
|
||||||
if (unitRoof.ridgeBeam) {
|
if (unitRoof.ridgeBeam) {
|
||||||
const r = unitRoof.rect;
|
const r = unitRoof.rect;
|
||||||
group.add(buildRidgeBeam3D(unitRoof.ridgeBeam, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, unitRoof.alongX, opts.materials));
|
const rb = unitRoof.ridgeBeam;
|
||||||
|
group.add(buildRidgeBeam3D(rb, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, rb.alongX !== undefined ? rb.alongX : unitRoof.alongX, opts.materials));
|
||||||
}
|
}
|
||||||
unitRoof.planes.forEach(pl => {
|
unitRoof.planes.forEach(pl => {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
@@ -647,7 +861,7 @@ window.RoofModel = (() => {
|
|||||||
const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt)));
|
const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt)));
|
||||||
const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0];
|
const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0];
|
||||||
const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); };
|
const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); };
|
||||||
const up = (T[1][0] - T[0][0]) * (T[2][2] - T[0][2]) - (T[1][2] - T[0][2]) * (T[2][0] - T[0][0]) < 0;
|
const up = T.reduce((t, a, k) => { const b = T[(k + 1) % n]; return t + a[0] * b[2] - b[0] * a[2]; }, 0) < 0;
|
||||||
for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]);
|
for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]);
|
||||||
const topCount = pos.length / 3;
|
const topCount = pos.length / 3;
|
||||||
for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2);
|
for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2);
|
||||||
@@ -855,6 +1069,12 @@ window.RoofModel = (() => {
|
|||||||
const f = (p) => r.axis === 'x' ? p.x - r.value : p.z - r.value;
|
const f = (p) => r.axis === 'x' ? p.x - r.value : p.z - r.value;
|
||||||
polys = polys.flatMap(pl => [clipPoly(pl, f), clipPoly(pl, p => -f(p))]).filter(pl => pl.length >= 3);
|
polys = polys.flatMap(pl => [clipPoly(pl, f), clipPoly(pl, p => -f(p))]).filter(pl => pl.length >= 3);
|
||||||
});
|
});
|
||||||
|
// Schoorsteen: de rand stopt ertegen (gaps langs de rand, in wereld-mm).
|
||||||
|
const alongZ = Math.abs(sg.a.x - sg.b.x) < 0.5;
|
||||||
|
(sg.gaps || []).forEach(([g0, g1]) => {
|
||||||
|
const t = (p) => alongZ ? p.z : p.x;
|
||||||
|
polys = polys.flatMap(pl => [clipPoly(pl, p => g0 - t(p)), clipPoly(pl, p => t(p) - g1)]).filter(pl => pl.length >= 3);
|
||||||
|
});
|
||||||
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove;
|
||||||
polys.forEach(pl => {
|
polys.forEach(pl => {
|
||||||
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial);
|
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial);
|
||||||
@@ -879,7 +1099,11 @@ window.RoofModel = (() => {
|
|||||||
s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang));
|
s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang));
|
||||||
delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit
|
delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit
|
||||||
const h = s.house || {};
|
const h = s.house || {};
|
||||||
s.house = { enabled: !!h.enabled, form: h.form && UnitModel.ROOF_TYPES[h.form.type] ? Object.assign({}, h.form) : null, cover: !!h.cover };
|
const form = h.form ? Object.assign({}, h.form) : null;
|
||||||
|
// v0.6.2: mansarde vervallen, wordt een zadeldak met dezelfde nok.
|
||||||
|
if (form && form.type === 'mansarde') { form.type = 'zadel'; delete form.kneeH; delete form.kneeIn; delete form.kneeRatio; }
|
||||||
|
s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover };
|
||||||
|
s.posts = PostModel.normalizeSettings(s.posts);
|
||||||
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
|
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -887,7 +1111,7 @@ window.RoofModel = (() => {
|
|||||||
return {
|
return {
|
||||||
DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
|
DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
|
||||||
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
||||||
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions,
|
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
|
||||||
computeEdge, buildEdge3D, normalizeSettings
|
computeEdge, buildEdge3D, normalizeSettings
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ window.RoomModel = (() => {
|
|||||||
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)));
|
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 };
|
if (lb - la < 1) return { quads: [], area: 0 };
|
||||||
const hiAt = (lx) => Math.min(tl.at(lx), H);
|
const hiAt = (lx) => Math.min(tl.at(lx), H);
|
||||||
const ops = (cfg.openings || []).map(op => ({ x0: -W / 2 + op.x, x1: -W / 2 + op.x + op.w, y0: op.y, top: (lx) => WallModel.openingTopAt(op, lx + W / 2) }));
|
// 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]);
|
const bs = new Set([la, lb]);
|
||||||
tl.pts.forEach(pt => bs.add(pt.x));
|
tl.pts.forEach(pt => bs.add(pt.x));
|
||||||
for (let i = 0; i < tl.pts.length - 1; i++) {
|
for (let i = 0; i < tl.pts.length - 1; i++) {
|
||||||
|
|||||||
+14
-21
@@ -200,7 +200,7 @@ window.UnitModel = (() => {
|
|||||||
const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => {
|
const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => {
|
||||||
const openings = fullConfig.openings
|
const openings = fullConfig.openings
|
||||||
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS)
|
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS)
|
||||||
.map(op => { used.add(op.id); return { id: op.id, type: op.type, 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, {
|
const params = Object.assign({}, wallState.wall, {
|
||||||
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b))
|
width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b))
|
||||||
}, topsIn(a, b, 0));
|
}, topsIn(a, b, 0));
|
||||||
@@ -223,7 +223,7 @@ window.UnitModel = (() => {
|
|||||||
if (Math.min(hA, hB) < MIN_RAISE_MM) return;
|
if (Math.min(hA, hB) < MIN_RAISE_MM) return;
|
||||||
const openings = fullConfig.openings
|
const openings = fullConfig.openings
|
||||||
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS)
|
.filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS)
|
||||||
.map(op => { used.add(op.id); return { id: op.id, type: op.type, 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, {
|
const params = Object.assign({}, wallState.wall, {
|
||||||
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB)
|
width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB)
|
||||||
}, topsIn(a, b, base));
|
}, topsIn(a, b, base));
|
||||||
@@ -306,8 +306,8 @@ window.UnitModel = (() => {
|
|||||||
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h));
|
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h));
|
||||||
}
|
}
|
||||||
|
|
||||||
// kneeRatio / cutRatio gaan voor kneeH / cut, zodat ze meeschalen als goot of nok verandert.
|
// cutRatio gaat voor cut, zodat ze meeschalen als goot of nok verandert.
|
||||||
const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 };
|
const LIMITS = { ridge: 5000, eave: 3000, short: 4700, long: 14000 };
|
||||||
function clampSize(floor, keep) {
|
function clampSize(floor, keep) {
|
||||||
floor.width = Math.min(LIMITS.long, floor.width);
|
floor.width = Math.min(LIMITS.long, floor.width);
|
||||||
floor.depth = Math.min(LIMITS.long, floor.depth);
|
floor.depth = Math.min(LIMITS.long, floor.depth);
|
||||||
@@ -317,9 +317,9 @@ window.UnitModel = (() => {
|
|||||||
}
|
}
|
||||||
return floor;
|
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 RIDGES = { z: 'Noord–zuid', x: 'West–oost' };
|
||||||
const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf';
|
const hasRidge = (type) => type === 'zadel' || type === 'wolf';
|
||||||
function gableOf(form) {
|
function gableOf(form) {
|
||||||
if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter';
|
if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter';
|
||||||
if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links';
|
if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links';
|
||||||
@@ -333,10 +333,8 @@ window.UnitModel = (() => {
|
|||||||
const full = fullTopLine(w, gableSpan(unitState, side));
|
const full = fullTopLine(w, gableSpan(unitState, side));
|
||||||
const F = full[full.length - 1].u, inner = full.slice(1, -1);
|
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));
|
const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h));
|
||||||
if (inner.length === 3) {
|
// v0.6.2: mansarde vervallen; een opgeslagen mansarde wordt een zadeldak met dezelfde nok.
|
||||||
const high = Math.round(inner[1].h);
|
if (inner.length === 3) return { type: 'zadel', ridge, low, high: Math.round(inner[1].h) };
|
||||||
return { type: 'mansarde', ridge, low, high, kneeH: Math.round(inner[0].h), kneeIn: Math.round(inner[0].u), kneeRatio: (inner[0].h - low) / Math.max(1, high - low) };
|
|
||||||
}
|
|
||||||
if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) {
|
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 slope = (inner[0].h - L.h) / Math.max(1, inner[0].u);
|
||||||
const high = Math.round(L.h + slope * F / 2);
|
const high = Math.round(L.h + slope * F / 2);
|
||||||
@@ -351,6 +349,10 @@ window.UnitModel = (() => {
|
|||||||
const h = Math.max(...Object.values(W).map(wallTopOf));
|
const h = Math.max(...Object.values(W).map(wallTopOf));
|
||||||
return { type: 'plat', low: h, high: h };
|
return { type: 'plat', low: h, high: h };
|
||||||
}
|
}
|
||||||
|
function dropMansarde(unitState) {
|
||||||
|
const f = roofForm(unitState), side = f.type === 'zadel' ? gableOf(f) : null;
|
||||||
|
if (side && fullTopLine(unitState.walls[side].wall, gableSpan(unitState, side)).length === 5) applyRoofForm(unitState, f);
|
||||||
|
}
|
||||||
function applyRoofForm(unitState, form) {
|
function applyRoofForm(unitState, form) {
|
||||||
const side = form.type !== 'plat' ? gableOf(form) : 'achter';
|
const side = form.type !== 'plat' ? gableOf(form) : 'achter';
|
||||||
const g = gableSpan(unitState, side);
|
const g = gableSpan(unitState, side);
|
||||||
@@ -365,16 +367,7 @@ window.UnitModel = (() => {
|
|||||||
const half = F / 2;
|
const half = F / 2;
|
||||||
let line = [{ u: 0, h: low }, { u: F, h: low }];
|
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 }];
|
if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
||||||
else if (sloped && form.type === 'mansarde') {
|
else if (sloped && form.type === 'wolf') {
|
||||||
// Knik altijd boven de rechte lijn goot–nok, anders wordt het een hol dak.
|
|
||||||
const kIn = Math.max(eMin + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18)));
|
|
||||||
const minK = low + (high - low) * kIn / half + 20;
|
|
||||||
const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH;
|
|
||||||
const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK)));
|
|
||||||
line = kH > minK - 1 && high - kH >= 20
|
|
||||||
? [{ u: 0, h: low }, { u: kIn, h: kH }, { u: half, h: high }, { u: F - kIn, h: kH }, { u: F, h: low }]
|
|
||||||
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
|
|
||||||
} else if (sloped && form.type === 'wolf') {
|
|
||||||
const minR = (eMin + 50) / half;
|
const minR = (eMin + 50) / half;
|
||||||
const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6;
|
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 cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low))));
|
||||||
@@ -401,5 +394,5 @@ window.UnitModel = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, formLine, validateState };
|
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 };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+114
-43
@@ -92,6 +92,11 @@ window.KozijnModel = (() => {
|
|||||||
});
|
});
|
||||||
return out;
|
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 rect = (x0, x1, y0, y1) => [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }];
|
||||||
const areaOf = (pts) => Math.abs(pts.reduce((a, p, i) => { const q = pts[(i + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2;
|
const 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;
|
||||||
|
|
||||||
@@ -134,6 +139,11 @@ window.KozijnModel = (() => {
|
|||||||
g.add(mesh);
|
g.add(mesh);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gekoppelde zijde (op.joinL/joinR, zie WallModel.resolveOpenings): geen speling en geen kaderstijl;
|
||||||
|
// 'stijl' = de helft van een tussenstijl die het kozijn ernaast afmaakt, 'stolp' = niets (bladen tegen elkaar).
|
||||||
|
const sideGap = (join) => join ? 0 : P.gap;
|
||||||
|
const sideKader = (join) => join === 'stolp' ? 0 : join ? P.tussen / 2 : P.kader;
|
||||||
|
|
||||||
// Lokaal: x vanaf links, y vanaf de onderkant, +z = buiten (mm in, meters uit).
|
// Lokaal: x vanaf links, y vanaf de onderkant, +z = buiten (mm in, meters uit).
|
||||||
function build3D(op, fill, place) {
|
function build3D(op, fill, place) {
|
||||||
const f = normalize(fill);
|
const f = normalize(fill);
|
||||||
@@ -143,15 +153,12 @@ window.KozijnModel = (() => {
|
|||||||
const depth = Math.min(P.depth, place.depth || P.depth);
|
const depth = Math.min(P.depth, place.depth || P.depth);
|
||||||
const z1 = place.zOut, z0 = z1 - depth;
|
const z1 = place.zOut, z0 = z1 - depth;
|
||||||
const hR = op.hR !== undefined ? op.hR : op.h;
|
const hR = op.hR !== undefined ? op.hR : op.h;
|
||||||
const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, P.gap, P.gap, P.gap]);
|
const kL = sideKader(op.joinL), kR = sideKader(op.joinR);
|
||||||
const below = (q) => (p) => {
|
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 a = q[3], b = q[2];
|
|
||||||
return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
|
|
||||||
};
|
|
||||||
const glass = (pts) => slab(g, pts, null, z1 - 50 - P.glass / 2, z1 - 50 + P.glass / 2, mat('glass'), null, false);
|
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') {
|
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);
|
slab(g, outer, [inner], z0, z1, mIn, mOut);
|
||||||
let pane = inner;
|
let pane = inner;
|
||||||
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) {
|
if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) {
|
||||||
@@ -171,8 +178,8 @@ window.KozijnModel = (() => {
|
|||||||
|
|
||||||
const sill = place.exterior !== false ? P.dorpel : 0;
|
const sill = place.exterior !== false ? P.dorpel : 0;
|
||||||
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
|
const frameOuter = insetPoly(outer, [sill, 0, 0, 0]);
|
||||||
const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]);
|
const inner = insetPoly(frameOuter, [0, kR, P.kader, kL]);
|
||||||
const u = [frameOuter[0], inner[0], inner[3], inner[2], inner[1], frameOuter[1], frameOuter[2], frameOuter[3]];
|
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);
|
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);
|
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);
|
||||||
|
|
||||||
@@ -187,12 +194,9 @@ window.KozijnModel = (() => {
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
|
const hingeR = f.hinge === 'rechts';
|
||||||
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
|
const d = doorSplit(f, ix0, ix1, iy0, Math.min(topAt(ix0), topAt(ix1)));
|
||||||
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
|
const { side, leafR, la, lb, transom, ly1 } = d;
|
||||||
const lowTop = Math.min(topAt(ix0), topAt(ix1));
|
|
||||||
const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight;
|
|
||||||
const ly1 = transom ? iy0 + P.leafH : Infinity;
|
|
||||||
if (side) {
|
if (side) {
|
||||||
const [t0, g0, g1] = leafR ? [la - P.tussen, ix0, la - P.tussen] : [lb, lb + P.tussen, ix1];
|
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(t0, t0 + P.tussen, iy0, ly1));
|
||||||
@@ -204,20 +208,10 @@ window.KozijnModel = (() => {
|
|||||||
glass(region(ix0, ix1, ly1 + P.tussen, Infinity));
|
glass(region(ix0, ix1, ly1 + P.tussen, Infinity));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer).
|
const L = doorLeaf(f, d, iy0, sill, top, topAt);
|
||||||
const leaf = clipPoly(rect(la + 3, lb - 3, iy0 + (sill ? 3 : 10), Math.min(ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 }));
|
if (!L) return g;
|
||||||
if (leaf.length < 3) return g;
|
const { leaf, lx0, lxR, lb0, lt, lw, holes } = L;
|
||||||
const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y);
|
|
||||||
const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb0 = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), ly1) - 3;
|
|
||||||
const lzOut = z1 - 15, lzIn = lzOut - P.leafT;
|
const lzOut = z1 - 15, lzIn = lzOut - P.leafT;
|
||||||
let holes = null;
|
|
||||||
const lw = lxR - lx0;
|
|
||||||
if (f.volglas && lw >= 400 && lt - lb0 >= 800) {
|
|
||||||
holes = [rect(lx0 + 110, lxR - 110, lb0 + 140, lt - 110)]; // glasdeur: smal kader rondom
|
|
||||||
} else if (f.glas && lw >= 500 && lt - lb0 >= 1400) {
|
|
||||||
const gx = hingeR ? lxR - lw * 0.3 : lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18);
|
|
||||||
holes = [rect(gx - gw / 2, gx + gw / 2, lb0 + 350, lt - 300)]; // glas: in het deurblad (hieronder)
|
|
||||||
}
|
|
||||||
const outward = f.swing === 'buiten';
|
const outward = f.swing === 'buiten';
|
||||||
const hx = hingeR ? lxR : lx0, hz = outward ? lzOut : lzIn;
|
const hx = hingeR ? lxR : lx0, hz = outward ? lzOut : lzIn;
|
||||||
const pivot = new THREE.Group(), lg = new THREE.Group();
|
const pivot = new THREE.Group(), lg = new THREE.Group();
|
||||||
@@ -245,28 +239,64 @@ window.KozijnModel = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SLIDE = { overlap: 50, gap: 12, railH: 40, railD: 45, leafT: 40, frame: 70 };
|
const SLIDE = { overlap: 50, gap: 12, railH: 40, railD: 45, leafT: 40, frame: 70 };
|
||||||
function buildSlideLeaves(g, f, o) {
|
// 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 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 out = f.side === 'buiten';
|
||||||
const faceIn = o.place.faceIn !== undefined ? o.place.faceIn : o.z0;
|
const faceIn = o.place.faceIn !== undefined ? o.place.faceIn : o.z0;
|
||||||
const faceOut = o.place.faceOut !== undefined ? o.place.faceOut : o.z1;
|
const faceOut = o.place.faceOut !== undefined ? o.place.faceOut : o.z1;
|
||||||
const ov = opbouw ? SLIDE.overlap : -3;
|
|
||||||
const xa = o.ix0 - ov, xb = o.ix1 + ov;
|
|
||||||
const yb = o.iy0 + (o.sill ? 3 : 10);
|
|
||||||
const yt = Math.min(o.topAt(o.ix0), o.topAt(o.ix1)) + (opbouw ? SLIDE.overlap : -3);
|
|
||||||
const lt = opbouw ? SLIDE.leafT : P.leafT;
|
const lt = opbouw ? SLIDE.leafT : P.leafT;
|
||||||
const [lz0, lz1] = opbouw
|
const [lz0, lz1] = opbouw
|
||||||
? (out ? [faceOut + SLIDE.gap, faceOut + SLIDE.gap + lt] : [faceIn - SLIDE.gap - lt, faceIn - SLIDE.gap])
|
? (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];
|
: [(faceIn + faceOut) / 2 - lt / 2, (faceIn + faceOut) / 2 + lt / 2];
|
||||||
const lw = (xb - xa) / n;
|
S.leaves.forEach(l => {
|
||||||
const dirs = n === 2 ? [-1, 1] : [f.dir === 'rechts' ? 1 : -1];
|
const { dir, x0, x1 } = l;
|
||||||
dirs.forEach((dir, i) => {
|
|
||||||
const x0 = xa + i * lw + (n === 2 ? (i ? 1 : 0) : 0), x1 = xa + (i + 1) * lw - (n === 2 && !i ? 1 : 0);
|
|
||||||
const lg = new THREE.Group();
|
const lg = new THREE.Group();
|
||||||
g.add(lg);
|
g.add(lg);
|
||||||
const pts = rect(x0, x1, yb, yt);
|
const pts = rect(x0, x1, yb, yt);
|
||||||
if (f.glas && x1 - x0 > 2 * SLIDE.frame + 100) {
|
const pane = S.pane(l);
|
||||||
const pane = rect(x0 + SLIDE.frame, x1 - SLIDE.frame, yb + SLIDE.frame + 30, yt - SLIDE.frame);
|
if (pane) {
|
||||||
slab(lg, pts, [pane], lz0, lz1, colorMat(f.in), opbouw ? null : colorMat(f.out));
|
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);
|
slab(lg, pane, null, (lz0 + lz1) / 2 - 4, (lz0 + lz1) / 2 + 4, mat('glass'), null, false);
|
||||||
} else {
|
} else {
|
||||||
@@ -291,12 +321,11 @@ window.KozijnModel = (() => {
|
|||||||
const f = normalize(fill);
|
const f = normalize(fill);
|
||||||
if (!f || f.type === 'raam') return [];
|
if (!f || f.type === 'raam') return [];
|
||||||
const zIn = faces.zIn, zOut = faces.zOut;
|
const zIn = faces.zIn, zOut = faces.zOut;
|
||||||
const ix0 = P.gap + P.kader, ix1 = op.w - P.gap - P.kader;
|
const ix0 = sideGap(op.joinL) + sideKader(op.joinL), ix1 = op.w - sideGap(op.joinR) - sideKader(op.joinR);
|
||||||
const out = [];
|
const out = [];
|
||||||
if (f.type === 'deur') {
|
if (f.type === 'deur') {
|
||||||
const hingeR = f.hinge === 'rechts', leafR = f.leaf === 'rechts';
|
const hingeR = f.hinge === 'rechts';
|
||||||
const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight;
|
const { la, lb } = doorSplit(f, ix0, ix1, 0, 0);
|
||||||
const la = side && leafR ? ix1 - P.leafW : ix0, lb = side && !leafR ? ix0 + P.leafW : ix1;
|
|
||||||
const L = lb - la, hx = hingeR ? lb : la, s = hingeR ? -1 : 1;
|
const L = lb - la, hx = hingeR ? lb : la, s = hingeR ? -1 : 1;
|
||||||
const outward = f.swing === 'buiten', hz = outward ? zOut : zIn, dz = outward ? 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 }] });
|
out.push({ kind: 'leaf', pts: [{ x: hx, z: hz }, { x: hx, z: hz + dz * L }] });
|
||||||
@@ -326,5 +355,47 @@ window.KozijnModel = (() => {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { COLORS, FILL_TYPES, HINGES, SWINGS, SLIDE_MOUNTS, SLIDE_SIDES, SLIDE_DIRS, PROFILE: P, normalize, defaultFill, build3D, planSymbol, colorMaterial: colorMat };
|
// 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 };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+122
-47
@@ -269,6 +269,17 @@ window.WallModel = (() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function raiseTop(CONFIG, d) {
|
||||||
|
return d ? Object.assign({}, CONFIG, {
|
||||||
|
wallHeightLeft: CONFIG.wallHeightLeft + d, wallHeightRight: CONFIG.wallHeightRight + d,
|
||||||
|
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + d }))
|
||||||
|
}) : CONFIG;
|
||||||
|
}
|
||||||
|
// Bovenrand van de buitenlagen: per punt op het dak (skinTop) of de wandrand + skinAbove.
|
||||||
|
function skinConfig(CONFIG, skinAbove, skinTop) {
|
||||||
|
return skinTop ? Object.assign({}, CONFIG, { wallHeightLeft: skinTop.hL, wallHeightRight: skinTop.hR, wallTop: skinTop.pts }) : raiseTop(CONFIG, skinAbove);
|
||||||
|
}
|
||||||
|
|
||||||
function topPlateOutlines(tl) {
|
function topPlateOutlines(tl) {
|
||||||
const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n];
|
const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n];
|
||||||
return tl.segs.map((sg, i) => [
|
return tl.segs.map((sg, i) => [
|
||||||
@@ -309,20 +320,26 @@ window.WallModel = (() => {
|
|||||||
const edgeMargin = studWidth * CORNER_STUDS;
|
const edgeMargin = studWidth * CORNER_STUDS;
|
||||||
|
|
||||||
let resolved = rawOpenings.map(raw => {
|
let resolved = rawOpenings.map(raw => {
|
||||||
const w = Math.min(Math.max(400, raw.w), maxOpW);
|
// Schoorsteen: de sparing kan smaller zijn (half over een naad tussen units).
|
||||||
|
const w = Math.min(Math.max(raw.type === 'schoorsteen' ? 100 : 400, raw.w), maxOpW);
|
||||||
|
|
||||||
let x;
|
let x;
|
||||||
if (raw.anchor === 'right') x = wallWidth - raw.offset - w;
|
if (raw.anchor === 'right') x = wallWidth - raw.offset - w;
|
||||||
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
|
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
|
||||||
else x = raw.offset;
|
else x = raw.offset;
|
||||||
|
|
||||||
return { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
|
const o = { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
|
||||||
|
if (raw.chimney) o.chimney = raw.chimney;
|
||||||
|
if (raw.koppel && o.type !== 'schoorsteen') o.koppel = true;
|
||||||
|
return o;
|
||||||
});
|
});
|
||||||
|
|
||||||
resolved.sort((a, b) => a.x - b.x);
|
resolved.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
// Een schoorsteen over een naad loopt door tot het wandeinde: daar geen hoekstijlen in de schoorsteen.
|
||||||
|
const mL = (o) => o.chimney && o.chimney.atStart ? 0 : edgeMargin, mR = (o) => o.chimney && o.chimney.atEnd ? 0 : edgeMargin;
|
||||||
resolved.forEach(o => {
|
resolved.forEach(o => {
|
||||||
o.x = Math.max(edgeMargin, Math.min(o.x, wallWidth - o.w - edgeMargin));
|
o.x = Math.max(mL(o), Math.min(o.x, wallWidth - o.w - mR(o)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Boven elkaar (bijv. raam boven deur) mogen openingen in x overlappen. Schuin: de hoogste kant telt.
|
// Boven elkaar (bijv. raam boven deur) mogen openingen in x overlappen. Schuin: de hoogste kant telt.
|
||||||
@@ -336,22 +353,42 @@ window.WallModel = (() => {
|
|||||||
// uitsparingen. Naast een deur 1× extra voor de dubbele stijl.
|
// uitsparingen. Naast een deur 1× extra voor de dubbele stijl.
|
||||||
const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0));
|
const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0));
|
||||||
|
|
||||||
|
// Koppelen: staat een gekoppelde opening tegen zijn buur aan (niet verder dan de normale tussenruimte),
|
||||||
|
// met dezelfde onderkant en bovenkant op de naad, dan vormen ze één sparing zonder stijlen, dagkant of
|
||||||
|
// latten ertussen; de rechter sluit direct aan. Samen niet breder dan de max. sparingbreedte.
|
||||||
|
const rightOf = new Map(), leftOf = new Map(), koppelWhy = new Map();
|
||||||
|
const groupW = (o) => { let w = 0; for (let p = o; p; p = leftOf.get(p)) w += p.w; return w; };
|
||||||
|
const FAR = 'staat niet tegen de opening ernaast aan';
|
||||||
|
const blame = (a, b, why) => [a, b].forEach(o => { if (o.koppel && (!koppelWhy.has(o) || koppelWhy.get(o) === FAR)) koppelWhy.set(o, why); });
|
||||||
|
resolved.forEach((a, i) => {
|
||||||
|
const b = resolved.slice(i + 1).find(o => !stacked(a, o));
|
||||||
|
if (!b || !(a.koppel || b.koppel) || a.type === 'schoorsteen' || b.type === 'schoorsteen' || leftOf.has(b)) return;
|
||||||
|
let why = null;
|
||||||
|
if (b.x - a.x - a.w > gapOf(a, b) + 0.5) why = FAR;
|
||||||
|
else if (Math.abs(a.rawY - b.rawY) > 0.5 || Math.abs(a.rawY + a.rawHR - b.rawY - b.rawH) > 0.5) why = 'onderkant en hoogte moeten gelijk zijn aan de opening ernaast';
|
||||||
|
else if (groupW(a) + b.w > maxOpW + 0.5) why = `samen breder dan ${maxOpW} mm`;
|
||||||
|
if (why) { blame(a, b, why); return; }
|
||||||
|
rightOf.set(a, b);
|
||||||
|
leftOf.set(b, a);
|
||||||
|
});
|
||||||
|
const gap = (a, b) => rightOf.get(a) === b ? 0 : gapOf(a, b);
|
||||||
|
|
||||||
for (let i = 1; i < resolved.length; i++) {
|
for (let i = 1; i < resolved.length; i++) {
|
||||||
const cur = resolved[i];
|
const cur = resolved[i], partner = leftOf.get(cur);
|
||||||
|
if (partner) cur.x = partner.x + partner.w;
|
||||||
for (let j = 0; j < i; j++) {
|
for (let j = 0; j < i; j++) {
|
||||||
const prev = resolved[j];
|
const prev = resolved[j];
|
||||||
if (stacked(prev, cur)) continue;
|
if (stacked(prev, cur)) continue;
|
||||||
cur.x = Math.max(cur.x, prev.x + prev.w + gapOf(prev, cur));
|
cur.x = Math.max(cur.x, prev.x + prev.w + gap(prev, cur));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rightLimit = wallWidth - edgeMargin;
|
|
||||||
for (let i = resolved.length - 1; i >= 0; i--) {
|
for (let i = resolved.length - 1; i >= 0; i--) {
|
||||||
const cur = resolved[i];
|
const cur = resolved[i];
|
||||||
let maxX = rightLimit - cur.w;
|
let maxX = wallWidth - mR(cur) - cur.w;
|
||||||
for (let j = i + 1; j < resolved.length; j++) {
|
for (let j = i + 1; j < resolved.length; j++) {
|
||||||
const next = resolved[j];
|
const next = resolved[j];
|
||||||
if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gapOf(cur, next) - cur.w);
|
if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gap(cur, next) - cur.w);
|
||||||
}
|
}
|
||||||
if (cur.x > maxX) cur.x = maxX;
|
if (cur.x > maxX) cur.x = maxX;
|
||||||
}
|
}
|
||||||
@@ -359,22 +396,34 @@ window.WallModel = (() => {
|
|||||||
// De daklijn is bol, dus de rechte lijn tussen twee punten eronder blijft eronder.
|
// De daklijn is bol, dus de rechte lijn tussen twee punten eronder blijft eronder.
|
||||||
resolved.forEach(o => {
|
resolved.forEach(o => {
|
||||||
const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
|
const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
|
||||||
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - plateThick * 2));
|
// Schoorsteen: tot de bovenkant, zonder latei en bovenregel erin (geen hout in het rookkanaal).
|
||||||
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - plateThick * 2));
|
const keep = o.type === 'schoorsteen' ? 0 : plateThick * 2;
|
||||||
|
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - keep));
|
||||||
|
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - keep));
|
||||||
const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
|
const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
|
||||||
o.y = Math.max(0, Math.min(o.rawY, maxOpY));
|
o.y = Math.max(0, Math.min(o.rawY, maxOpY));
|
||||||
o.h = hL;
|
o.h = hL;
|
||||||
o.hR = hR;
|
o.hR = hR;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Twee draaideuren: stolp (bladen tegen elkaar), anders één gedeelde tussenstijl.
|
||||||
|
const swing = (o) => !!o.fill && o.fill.type === 'deur';
|
||||||
|
rightOf.forEach((b, a) => {
|
||||||
|
const fits = Math.abs(b.x - a.x - a.w) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.y + a.hR - b.y - b.h) < 0.5;
|
||||||
|
if (!fits) { rightOf.delete(a); blame(a, b, 'past hier niet tegen de opening ernaast'); return; }
|
||||||
|
a.joinR = b.joinL = swing(a) && swing(b) ? 'stolp' : 'stijl';
|
||||||
|
});
|
||||||
|
koppelWhy.forEach((why, o) => { if (!o.joinL && !o.joinR) o.koppelWarn = why; });
|
||||||
|
resolved.forEach(o => { if (o.koppel && !o.joinL && !o.joinR && !o.koppelWarn) o.koppelWarn = 'geen opening direct ernaast'; });
|
||||||
|
|
||||||
resolved.forEach(o => {
|
resolved.forEach(o => {
|
||||||
o.overflowing = (o.x < edgeMargin - 0.5) || (o.x + o.w > wallWidth - edgeMargin + 0.5);
|
o.overflowing = (o.x < mL(o) - 0.5) || (o.x + o.w > wallWidth - mR(o) + 0.5);
|
||||||
});
|
});
|
||||||
for (let i = 0; i < resolved.length; i++) {
|
for (let i = 0; i < resolved.length; i++) {
|
||||||
for (let j = i + 1; j < resolved.length; j++) {
|
for (let j = i + 1; j < resolved.length; j++) {
|
||||||
const a = resolved[i], b = resolved[j];
|
const a = resolved[i], b = resolved[j];
|
||||||
const [l, r] = a.x <= b.x ? [a, b] : [b, a];
|
const [l, r] = a.x <= b.x ? [a, b] : [b, a];
|
||||||
if (!stacked(a, b, true) && r.x < l.x + l.w + gapOf(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
|
if (!stacked(a, b, true) && r.x < l.x + l.w + gap(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,10 +502,14 @@ window.WallModel = (() => {
|
|||||||
});
|
});
|
||||||
const cands = [];
|
const cands = [];
|
||||||
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
|
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
|
||||||
|
// Gekoppelde zijde: geen stijlen ernaast; boven (en onder) de naad één kreupele stijl.
|
||||||
ops.forEach(op => {
|
ops.forEach(op => {
|
||||||
const x0 = startX + op.x, x1 = x0 + op.w;
|
const x0 = startX + op.x, x1 = x0 + op.w;
|
||||||
cands.push({ x: x0 - sw / 2, kind: 'koning' }, { x: x1 + sw / 2, kind: 'koning' });
|
if (!op.joinL) cands.push({ x: x0 - sw / 2, kind: 'koning' });
|
||||||
if (isDoor(op)) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' }, { x: x1 + 1.5 * sw, kind: 'dubbel' });
|
if (!op.joinR) cands.push({ x: x1 + sw / 2, kind: 'koning' });
|
||||||
|
else cands.push({ x: x1, kind: 'kreupel', seam: true });
|
||||||
|
if (isDoor(op) && !op.joinL) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' });
|
||||||
|
if (isDoor(op) && !op.joinR) cands.push({ x: x1 + 1.5 * sw, kind: 'dubbel' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const cols = [];
|
const cols = [];
|
||||||
@@ -478,16 +531,17 @@ window.WallModel = (() => {
|
|||||||
ops.forEach(op => calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing)
|
ops.forEach(op => calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing)
|
||||||
.forEach(p => cands.push({ x: startX + op.x + p, kind: 'kreupel', owner: op })));
|
.forEach(p => cands.push({ x: startX + op.x + p, kind: 'kreupel', owner: op })));
|
||||||
|
|
||||||
|
const inWall = (c) => c.x >= startX + sw / 2 - 0.5 && c.x <= startX + CONFIG.wallWidth - sw / 2 + 0.5;
|
||||||
const PRIO = { hoek: 0, koning: 1, dubbel: 2, regel: 3, kreupel: 4 };
|
const PRIO = { hoek: 0, koning: 1, dubbel: 2, regel: 3, kreupel: 4 };
|
||||||
const roofMin = (x) => Math.min(...top.underPts(x - sw / 2, x + sw / 2).map(p => p.y));
|
const roofMin = (x) => Math.min(...top.underPts(x - sw / 2, x + sw / 2).map(p => p.y));
|
||||||
const segs = [];
|
const segs = [];
|
||||||
cands.forEach(c => {
|
cands.filter(inWall).forEach(c => {
|
||||||
const x = Math.round(c.x);
|
const x = Math.round(c.x);
|
||||||
const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0);
|
const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0);
|
||||||
let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null;
|
let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null;
|
||||||
const curMax = () => Math.max(cur[0], cur[1]);
|
const curMax = () => Math.max(cur[0], cur[1]);
|
||||||
const push = (y1, above) => {
|
const push = (y1, above) => {
|
||||||
if (c.kind === 'kreupel') {
|
if (c.kind === 'kreupel' && !c.seam) {
|
||||||
// Onder de opening niet als hij op een andere opening uitkomt: dat stuk is al de kreupele stijl daarvan.
|
// Onder de opening niet als hij op een andere opening uitkomt: dat stuk is al de kreupele stijl daarvan.
|
||||||
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
|
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
|
||||||
if (!onTop && !(underneath && !below)) return;
|
if (!onTop && !(underneath && !below)) return;
|
||||||
@@ -524,16 +578,20 @@ window.WallModel = (() => {
|
|||||||
const roofSafetyMargin = 10;
|
const roofSafetyMargin = 10;
|
||||||
const sw = CONFIG.studWidth;
|
const sw = CONFIG.studWidth;
|
||||||
return CONFIG.openings.map(op => {
|
return CONFIG.openings.map(op => {
|
||||||
const hx0 = -halfW + op.x - sw;
|
// Schoorsteen: geen kozijn of dagkant, de plaat loopt door tot op de stijl naast de sparing.
|
||||||
const hx1 = -halfW + op.x + op.w + sw;
|
const m = op.type === 'schoorsteen' ? 0 : sw;
|
||||||
const s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s);
|
// Gekoppelde zijde: de uitsparing sluit precies aan op die van de buur.
|
||||||
|
const mL = op.joinL ? 0 : m, mR = op.joinR ? 0 : m;
|
||||||
|
const hx0 = -halfW + op.x - mL;
|
||||||
|
const hx1 = -halfW + op.x + op.w + mR;
|
||||||
|
const s = (op.hR - op.h) / op.w, vo = m * Math.sqrt(1 + s * s);
|
||||||
const clampTop = (y, x) => {
|
const clampTop = (y, x) => {
|
||||||
const ceil = topYAt(x) - roofSafetyMargin;
|
const ceil = topYAt(x) - roofSafetyMargin;
|
||||||
return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y;
|
return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y;
|
||||||
};
|
};
|
||||||
const hy1 = clampTop(op.y + op.h + vo - s * sw, hx0);
|
const hy1 = clampTop(op.y + op.h + vo - s * mL, hx0);
|
||||||
const hy1r = clampTop(op.y + op.hR + vo + s * sw, hx1);
|
const hy1r = clampTop(op.y + op.hR + vo + s * mR, hx1);
|
||||||
return { hx0, hx1, hy0: op.y, hy1, hy1r };
|
return { hx0, hx1, hy0: op.y, hy1, hy1r, joinL: !!op.joinL, joinR: !!op.joinR };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1;
|
const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1;
|
||||||
@@ -593,7 +651,7 @@ window.WallModel = (() => {
|
|||||||
const fixed = [lo, hi];
|
const fixed = [lo, hi];
|
||||||
holes.forEach(h => {
|
holes.forEach(h => {
|
||||||
if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return;
|
if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return;
|
||||||
[h.hx0 - w / 2, h.hx1 + w / 2].forEach(x => { if (x > lo && x < hi) fixed.push(x); });
|
[!h.joinL && h.hx0 - w / 2, !h.joinR && h.hx1 + w / 2].forEach(x => { if (x !== false && x > lo && x < hi) fixed.push(x); });
|
||||||
});
|
});
|
||||||
fixed.sort((a, b) => a - b);
|
fixed.sort((a, b) => a - b);
|
||||||
const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w);
|
const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w);
|
||||||
@@ -693,8 +751,8 @@ window.WallModel = (() => {
|
|||||||
function build3D(CONFIG, calcData, opts) {
|
function build3D(CONFIG, calcData, opts) {
|
||||||
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
|
const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials;
|
||||||
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
|
const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek';
|
||||||
const stage = BuildStages.clamp(opts.stage);
|
const layers = ViewLayers.normalize(opts.layers);
|
||||||
const show = (key) => BuildStages.shows(stage, key);
|
const show = (key) => layers[key];
|
||||||
const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating');
|
const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating');
|
||||||
const buitenVisible = opts.buitenVisible !== false;
|
const buitenVisible = opts.buitenVisible !== false;
|
||||||
const cladding = opts.cladding || 'horizontaal';
|
const cladding = opts.cladding || 'horizontaal';
|
||||||
@@ -769,16 +827,10 @@ window.WallModel = (() => {
|
|||||||
const startXM = -halfW;
|
const startXM = -halfW;
|
||||||
const topYAt = tl.at;
|
const topYAt = tl.at;
|
||||||
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
|
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
|
||||||
const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, {
|
const CONFIG_OUT = skinConfig(CONFIG, skinAbove, opts.skinTop);
|
||||||
wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove,
|
const tlOut = CONFIG_OUT === CONFIG ? tl : topLine(CONFIG_OUT, toM);
|
||||||
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + skinAbove }))
|
|
||||||
}) : CONFIG;
|
|
||||||
const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl;
|
|
||||||
const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove;
|
const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove;
|
||||||
const CONFIG_BAT = battenAbove ? Object.assign({}, CONFIG, {
|
const CONFIG_BAT = raiseTop(CONFIG_OUT, battenAbove - skinAbove);
|
||||||
wallHeightLeft: CONFIG.wallHeightLeft + battenAbove, wallHeightRight: CONFIG.wallHeightRight + battenAbove,
|
|
||||||
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + battenAbove }))
|
|
||||||
}) : CONFIG;
|
|
||||||
const underPts = tl.underPts;
|
const underPts = tl.underPts;
|
||||||
|
|
||||||
// LET OP: xMin/xMax zijn wereld-coördinaten (-halfW..+halfW), niet lokaal (0..wallWidth).
|
// LET OP: xMin/xMax zijn wereld-coördinaten (-halfW..+halfW), niet lokaal (0..wallWidth).
|
||||||
@@ -789,7 +841,8 @@ window.WallModel = (() => {
|
|||||||
yMin: op.y * toM,
|
yMin: op.y * toM,
|
||||||
yMax: (op.y + op.h) * toM,
|
yMax: (op.y + op.h) * toM,
|
||||||
yMaxR: (op.y + op.hR) * toM,
|
yMaxR: (op.y + op.hR) * toM,
|
||||||
wM: op.w * toM
|
wM: op.w * toM,
|
||||||
|
chimney: op.type === 'schoorsteen'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const floorCuts = opsM
|
const floorCuts = opsM
|
||||||
@@ -820,7 +873,21 @@ window.WallModel = (() => {
|
|||||||
horizBeamCount++;
|
horizBeamCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
topPlateOutlines(tl).forEach(pts => {
|
// Bovenregel onderbroken waar een schoorsteen doorloopt.
|
||||||
|
const clipX = (pts, x, keepBelow) => {
|
||||||
|
const out = [], f = (p) => keepBelow ? x - p.x : p.x - x;
|
||||||
|
pts.forEach((p, k) => {
|
||||||
|
const q = pts[(k + 1) % pts.length], fp = f(p), fq = f(q);
|
||||||
|
if (fp >= 0) out.push(p);
|
||||||
|
if ((fp >= 0) !== (fq >= 0)) { const t = fp / (fp - fq); out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }); }
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let plates = topPlateOutlines(tl);
|
||||||
|
opsM.filter(op => op.chimney).forEach(op => {
|
||||||
|
plates = plates.flatMap(pts => [clipX(pts, op.xMin, true), clipX(pts, op.xMax, false)]).filter(pts => pts.length >= 3 && polygonArea(pts) > 1e-6);
|
||||||
|
});
|
||||||
|
plates.forEach(pts => {
|
||||||
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
|
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
|
||||||
horizBeamCount++;
|
horizBeamCount++;
|
||||||
});
|
});
|
||||||
@@ -858,6 +925,7 @@ window.WallModel = (() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
opsM.forEach(op => {
|
opsM.forEach(op => {
|
||||||
|
if (op.chimney) return; // geen latei: de schoorsteen draagt de bovenkant
|
||||||
const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s);
|
const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s);
|
||||||
const opTopShape = new THREE.Shape();
|
const opTopShape = new THREE.Shape();
|
||||||
opTopShape.moveTo(op.xMin, op.yMax);
|
opTopShape.moveTo(op.xMin, op.yMax);
|
||||||
@@ -940,7 +1008,9 @@ window.WallModel = (() => {
|
|||||||
uv.needsUpdate = true;
|
uv.needsUpdate = true;
|
||||||
return geo;
|
return geo;
|
||||||
};
|
};
|
||||||
const topClampedOut = (x) => tlOut.at(Math.max(-halfW, Math.min(halfW, x)));
|
// Voorbij het wandeinde (hoekdoorloop) de daklijn volgen: vlak doorgetrokken steekt hij boven een steil dakvlak uit.
|
||||||
|
const beyondEnd = (T) => (x) => Math.min(T.at(x), T.at(Math.max(-halfW, Math.min(halfW, x))));
|
||||||
|
const topClampedOut = beyondEnd(tlOut);
|
||||||
const layerShapes = (a, b, y0, yCap) => {
|
const layerShapes = (a, b, y0, yCap) => {
|
||||||
if (b - a <= edgeEps) return [];
|
if (b - a <= edgeEps) return [];
|
||||||
const capped = yCap === Infinity;
|
const capped = yCap === Infinity;
|
||||||
@@ -1216,7 +1286,7 @@ window.WallModel = (() => {
|
|||||||
const rExt = extM(rabatDepthMM);
|
const rExt = extM(rabatDepthMM);
|
||||||
const rxL = -halfW - rExt.left, rxR = halfW + rExt.right;
|
const rxL = -halfW - rExt.left, rxR = halfW + rExt.right;
|
||||||
const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM;
|
const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM;
|
||||||
const rTop = (x) => tlBat.at(Math.max(-halfW, Math.min(halfW, x)));
|
const rTop = beyondEnd(tlBat);
|
||||||
const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps)
|
const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps)
|
||||||
? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => {
|
? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => {
|
||||||
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
|
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
|
||||||
@@ -1241,7 +1311,7 @@ window.WallModel = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (show('gevel') && window.KozijnModel) {
|
if (show('kozijnen') && window.KozijnModel) {
|
||||||
const sh = CONFIG.sheathingThick, half = CONFIG.studDepth / 2;
|
const sh = CONFIG.sheathingThick, half = CONFIG.studDepth / 2;
|
||||||
const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat';
|
const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat';
|
||||||
const place = exterior ? { zOut: half + sh, depth: KozijnModel.PROFILE.depth, exterior: true }
|
const place = exterior ? { zOut: half + sh, depth: KozijnModel.PROFILE.depth, exterior: true }
|
||||||
@@ -1277,7 +1347,7 @@ window.WallModel = (() => {
|
|||||||
const holes = sheathingHoles(CONFIG);
|
const holes = sheathingHoles(CONFIG);
|
||||||
CONFIG.openings.forEach((op, i) => {
|
CONFIG.openings.forEach((op, i) => {
|
||||||
const h = holes[i];
|
const h = holes[i];
|
||||||
if (!h) return;
|
if (!h || op.type === 'schoorsteen') return; // de schoorsteen sluit de sparing zelf af
|
||||||
const x0 = -CONFIG.wallWidth / 2 + op.x, x1 = x0 + op.w, y0 = op.y;
|
const x0 = -CONFIG.wallWidth / 2 + op.x, x1 = x0 + op.w, y0 = op.y;
|
||||||
const topAt = (x) => op.y + op.h + (x - x0) / op.w * (op.hR - op.h);
|
const topAt = (x) => op.y + op.h + (x - x0) / op.w * (op.hR - op.h);
|
||||||
const hTop = (x) => holeTopAt(h, x);
|
const hTop = (x) => holeTopAt(h, x);
|
||||||
@@ -1288,28 +1358,33 @@ window.WallModel = (() => {
|
|||||||
].filter(pts => area(pts) > 1);
|
].filter(pts => area(pts) > 1);
|
||||||
const fill = KozijnModel.normalize(op.fill);
|
const fill = KozijnModel.normalize(op.fill);
|
||||||
const isWindow = op.y >= CONFIG.plateThick;
|
const isWindow = op.y >= CONFIG.plateThick;
|
||||||
|
// Gekoppelde zijde: geen dagkant; vensterbank en waterslag lopen door tot de naad.
|
||||||
|
const over = (d) => [op.joinL ? x0 : x0 - d, op.joinR ? x1 : x1 + d];
|
||||||
if (finishIn) {
|
if (finishIn) {
|
||||||
ring.forEach(pts => prism(pts, zIn, zIn + sh, spaanplaatMaterial));
|
ring.forEach(pts => prism(pts, zIn, zIn + sh, spaanplaatMaterial));
|
||||||
if (buitenMateriaal === 'spaanplaat') ring.forEach(pts => prism(pts, zSheetOut - sh, zSheetOut, spaanplaatMaterial));
|
if (buitenMateriaal === 'spaanplaat') ring.forEach(pts => prism(pts, zSheetOut - sh, zSheetOut, spaanplaatMaterial));
|
||||||
const zB = fill ? (exterior ? kozBack : null) : (buitenMateriaal === 'geen' ? halfD : zSheetOut);
|
const zB = fill ? (exterior ? kozBack : null) : (buitenMateriaal === 'geen' ? halfD : zSheetOut);
|
||||||
if (zB !== null) {
|
if (zB !== null) {
|
||||||
const boards = [
|
const boards = [
|
||||||
[{ x: x0, y: y0 }, { x: x0 + T, y: y0 }, { x: x0 + T, y: topAt(x0 + T) }, { x: x0, y: topAt(x0) }],
|
!op.joinL && [{ x: x0, y: y0 }, { x: x0 + T, y: y0 }, { x: x0 + T, y: topAt(x0 + T) }, { x: x0, y: topAt(x0) }],
|
||||||
[{ x: x1 - T, y: y0 }, { x: x1, y: y0 }, { x: x1, y: topAt(x1) }, { x: x1 - T, y: topAt(x1 - T) }],
|
!op.joinR && [{ x: x1 - T, y: y0 }, { x: x1, y: y0 }, { x: x1, y: topAt(x1) }, { x: x1 - T, y: topAt(x1 - T) }],
|
||||||
[{ x: x0, y: topAt(x0) - T }, { x: x1, y: topAt(x1) - T }, { x: x1, y: topAt(x1) }, { x: x0, y: topAt(x0) }]
|
[{ x: x0, y: topAt(x0) - T }, { x: x1, y: topAt(x1) - T }, { x: x1, y: topAt(x1) }, { x: x0, y: topAt(x0) }]
|
||||||
];
|
].filter(Boolean);
|
||||||
boards.forEach(pts => prism(pts, zIn, zB, spaanplaatMaterial));
|
boards.forEach(pts => prism(pts, zIn, zB, spaanplaatMaterial));
|
||||||
if (isWindow) prism([{ x: x0 - 30, y: y0 - SILL + 5 }, { x: x1 + 30, y: y0 - SILL + 5 }, { x: x1 + 30, y: y0 + 5 }, { x: x0 - 30, y: y0 + 5 }], zIn - SILL_OUT, zB, spaanplaatMaterial);
|
const [sa, sb] = over(30);
|
||||||
|
if (isWindow) prism([{ x: sa, y: y0 - SILL + 5 }, { x: sb, y: y0 - SILL + 5 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }], zIn - SILL_OUT, zB, spaanplaatMaterial);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (finishOut) {
|
if (finishOut) {
|
||||||
const outMat = KozijnModel.colorMaterial(fill ? fill.out : 'antraciet');
|
const outMat = KozijnModel.colorMaterial(fill ? fill.out : 'antraciet');
|
||||||
ring.forEach(pts => prism(pts, halfD + G, zFacade, outMat));
|
ring.forEach(pts => prism(pts, halfD + G, zFacade, outMat));
|
||||||
if (isWindow && fill) prism([{ x: x0 - 20, y: y0 - 25 }, { x: x1 + 20, y: y0 - 25 }, { x: x1 + 20, y: y0 + 5 }, { x: x0 - 20, y: y0 + 5 }],
|
const [sa, sb] = over(20);
|
||||||
|
if (isWindow && fill) prism([{ x: sa, y: y0 - 25 }, { x: sb, y: y0 - 25 }, { x: sb, y: y0 + 5 }, { x: sa, y: y0 + 5 }],
|
||||||
CONFIG.studDepth / 2 + sh - 20, zFacade + SLAG_OUT, outMat);
|
CONFIG.studDepth / 2 + sh - 20, zFacade + SLAG_OUT, outMat);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!show('regelwerk')) ViewLayers.hideByMaterial(wallGroup3D, [woodMaterial]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
group: wallGroup3D,
|
group: wallGroup3D,
|
||||||
@@ -1324,7 +1399,7 @@ window.WallModel = (() => {
|
|||||||
// Dubbelzijdig: de schuine regel heeft geen vaste oriëntatie.
|
// Dubbelzijdig: de schuine regel heeft geen vaste oriëntatie.
|
||||||
const wedgeMats = new WeakMap();
|
const wedgeMats = new WeakMap();
|
||||||
function wedgeMaterial(wood) {
|
function wedgeMaterial(wood) {
|
||||||
if (!wedgeMats.has(wood)) { const m = wood.clone(); m.side = THREE.DoubleSide; wedgeMats.set(wood, m); }
|
if (!wedgeMats.has(wood)) { const m = wood.clone(); m.side = THREE.DoubleSide; m.userData.base = wood; wedgeMats.set(wood, m); }
|
||||||
return wedgeMats.get(wood);
|
return wedgeMats.get(wood);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1546,7 +1621,7 @@ window.WallModel = (() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
|
||||||
sheathingHoles, skinSegmentsAtY, topLine, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
stoneMaterial, stucMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY,
|
||||||
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame,
|
||||||
createMaterials, build3D, computeSpecs
|
createMaterials, build3D, computeSpecs
|
||||||
};
|
};
|
||||||
|
|||||||
+48
-46
@@ -6,7 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const WALL_TEMPLATE = `
|
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="left-column">
|
||||||
<div id="pane-2d-container">
|
<div id="pane-2d-container">
|
||||||
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
|
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
|
||||||
@@ -24,8 +27,16 @@ const WALL_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="io-toolbar">
|
||||||
<input type="file" id="file-import-json" accept="application/json" style="display:none;">
|
<input type="file" id="file-import-json" accept="application/json" style="display:none;">
|
||||||
<span id="io-status" class="io-status"></span>
|
<span id="io-status" class="io-status"></span>
|
||||||
@@ -34,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 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-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-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>
|
</div>
|
||||||
|
|
||||||
<!-- TAB 1: WAND -->
|
<!-- TAB 1: WAND -->
|
||||||
@@ -104,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><input type="number" id="inp-max-sp" value="480" step="10" min="150" max="1500"><span class="unit">mm</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
<div class="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,17 +146,10 @@ const WALL_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</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 id="specs-panel-3d">
|
||||||
<div class="controls-grid">
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div class="spec-section">
|
<div class="spec-section">
|
||||||
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
|
||||||
@@ -188,6 +189,7 @@ const WALL_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="wall-project-overlay">
|
<div id="wall-project-overlay">
|
||||||
<div id="wall-project-modal">
|
<div id="wall-project-modal">
|
||||||
@@ -218,8 +220,8 @@ const WALL_TEMPLATE = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" id="wall-project-cancel" class="io-btn">Annuleren</button>
|
<button type="button" id="wall-project-cancel" class="ui-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-save" class="ui-btn ui-btn-primary">Opslaan</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,13 +335,8 @@ function mountWallEditor(container, callbacks) {
|
|||||||
updateAll();
|
updateAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFullscreen3D() {
|
function setView(mode) {
|
||||||
UI.cycle3D(document.getElementById('app-container'));
|
UI.setView(document.getElementById('app-container'), mode);
|
||||||
requestAnimationFrame(onWindowResize);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFullscreen2D() {
|
|
||||||
UI.toggle2D(document.getElementById('app-container'));
|
|
||||||
requestAnimationFrame(onWindowResize);
|
requestAnimationFrame(onWindowResize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +421,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
${type === 'schuifdeur' ? sw('fillGlas', 'Glazen blad', f.glas, 'Glas in een smal kader; uit = dicht blad') : ''}
|
${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)') +
|
${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('fillLeaf', 'Blad', KozijnModel.HINGES, f.leaf, 'Kant van het deurblad (van buitenaf gezien) als er een zijlicht naast past') +
|
||||||
pick('fillHinge', 'Scharnier', KozijnModel.HINGES, f.hinge, 'Van buitenaf gezien. Openslaande deuren: twee deuren naast elkaar, bladen naar het midden, scharnieren buiten') +
|
pick('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) : ''}
|
pick('fillSwing', 'Draait', KozijnModel.SWINGS, f.swing) : ''}
|
||||||
${type === 'schuifdeur' ? pick('fillLeaves', 'Bladen', { 1: '1 blad', 2: '2 bladen (uit elkaar)' }, String(f.leaves)) +
|
${type === 'schuifdeur' ? pick('fillLeaves', 'Bladen', { 1: '1 blad', 2: '2 bladen (uit elkaar)' }, String(f.leaves)) +
|
||||||
pick('fillMount', 'Montage', KozijnModel.SLIDE_MOUNTS, f.mount) +
|
pick('fillMount', 'Montage', KozijnModel.SLIDE_MOUNTS, f.mount) +
|
||||||
@@ -499,6 +496,10 @@ function mountWallEditor(container, callbacks) {
|
|||||||
<label>Schuine bovenzijde</label>
|
<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><label class="ui-switch"><input type="checkbox" data-field="sloped" data-id="${op.id}" ${isSloped(op) ? 'checked' : ''}><span></span></label></div>
|
||||||
</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>` : ''}
|
${isSloped(op) ? `<button class="opening-roof-btn" data-action="follow-roof" data-id="${op.id}" title="Hoogte rechts zo zetten dat de bovenkant evenwijdig aan de daklijn loopt"><i class="bi bi-slash-lg"></i> Volg dakhelling</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -543,6 +544,8 @@ function mountWallEditor(container, callbacks) {
|
|||||||
fillLeaves: 'leaves', fillMount: 'mount', fillSide: 'side', fillDir: 'dir' }[field];
|
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;
|
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();
|
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') {
|
} else if (field === 'sloped') {
|
||||||
if (e.target.checked) op.hR = op.h; else delete op.hR;
|
if (e.target.checked) op.hR = op.h; else delete op.hR;
|
||||||
renderOpeningsList();
|
renderOpeningsList();
|
||||||
@@ -730,12 +733,9 @@ function mountWallEditor(container, callbacks) {
|
|||||||
const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`);
|
const warnEl = document.querySelector(`[data-warning-for="${o.id}"]`);
|
||||||
if (!card || !warnEl) return;
|
if (!card || !warnEl) return;
|
||||||
card.classList.toggle('opening-warn', !!o.overflowing);
|
card.classList.toggle('opening-warn', !!o.overflowing);
|
||||||
if (o.overflowing) {
|
const msg = o.overflowing ? 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.' : o.koppelWarn ? `Koppelen: ${o.koppelWarn}.` : '';
|
||||||
warnEl.style.display = 'block';
|
warnEl.style.display = msg ? 'block' : 'none';
|
||||||
warnEl.textContent = 'Past niet volledig op het gekozen ankerpunt bij deze wandbreedte.';
|
warnEl.textContent = msg;
|
||||||
} else {
|
|
||||||
warnEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1276,14 +1276,13 @@ function mountWallEditor(container, callbacks) {
|
|||||||
|
|
||||||
const wallMaterials = WallModel.createMaterials();
|
const wallMaterials = WallModel.createMaterials();
|
||||||
|
|
||||||
// Alleen knoppen met data-material/-cladding: de "Opslaan"-knop van Project eigenschappen heeft ook de klasse material-toggle-btn.
|
|
||||||
let buitenMateriaal = 'vochtdoek';
|
let buitenMateriaal = 'vochtdoek';
|
||||||
let cladding = 'horizontaal';
|
let cladding = 'horizontaal';
|
||||||
let trasraam = null;
|
let trasraam = null;
|
||||||
let stone = null;
|
let stone = null;
|
||||||
let stuc = null;
|
let stuc = null;
|
||||||
let rabatColor = null;
|
let rabatColor = null;
|
||||||
let stage = BuildStages.FINAL;
|
let layers = ViewLayers.all();
|
||||||
function syncMaterialButtons() {
|
function syncMaterialButtons() {
|
||||||
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal));
|
||||||
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
|
document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding));
|
||||||
@@ -1300,13 +1299,17 @@ function mountWallEditor(container, callbacks) {
|
|||||||
|
|
||||||
let spaanplaatVisible = true;
|
let spaanplaatVisible = true;
|
||||||
let buitenVisible = true;
|
let buitenVisible = true;
|
||||||
createViewFilter(document.getElementById('wall-toolbar-3d'), [
|
const toolbar = document.getElementById('wall-toolbar');
|
||||||
{ key: 'binnen', label: 'Beplating binnenzijde' },
|
toolbar.innerHTML = `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>
|
||||||
{ key: 'buiten', label: 'Beplating buitenzijde' }
|
<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${UI.tbSep()}${UI.viewSeg('split')}`;
|
||||||
], (v) => {
|
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
|
||||||
spaanplaatVisible = v.binnen;
|
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||||
buitenVisible = v.buiten;
|
const viewGroup = toolbar.querySelector('.tb-view');
|
||||||
update3DScene();
|
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(); }
|
||||||
});
|
});
|
||||||
let skyMode = Skybox.getMode();
|
let skyMode = Skybox.getMode();
|
||||||
const skyEnv = Skybox.createEnvironment(scene);
|
const skyEnv = Skybox.createEnvironment(scene);
|
||||||
@@ -1314,15 +1317,14 @@ function mountWallEditor(container, callbacks) {
|
|||||||
skyEnv.fitTo(wallGroup3D, 0);
|
skyEnv.fitTo(wallGroup3D, 0);
|
||||||
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
|
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
|
||||||
};
|
};
|
||||||
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
|
Skybox.createToggle(viewGroup, (m) => { skyMode = m; applySky(); });
|
||||||
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
|
|
||||||
|
|
||||||
let lastWallStats = null;
|
let lastWallStats = null;
|
||||||
|
|
||||||
function update3DScene() {
|
function update3DScene() {
|
||||||
if (wallGroup3D) scene.remove(wallGroup3D);
|
if (wallGroup3D) scene.remove(wallGroup3D);
|
||||||
const result = WallModel.build3D(CONFIG, calcData, {
|
const result = WallModel.build3D(CONFIG, calcData, {
|
||||||
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, stage
|
materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, layers
|
||||||
});
|
});
|
||||||
wallGroup3D = result.group;
|
wallGroup3D = result.group;
|
||||||
lastWallStats = result.stats;
|
lastWallStats = result.stats;
|
||||||
@@ -1380,7 +1382,7 @@ function mountWallEditor(container, callbacks) {
|
|||||||
|
|
||||||
const inputs = document.querySelectorAll('#tab-wall input');
|
const inputs = document.querySelectorAll('#tab-wall input');
|
||||||
inputs.forEach(input => {
|
inputs.forEach(input => {
|
||||||
// Hoogte met de hand gewijzigd: de knikken van een mansarde-/wolfsdak vervallen.
|
// Hoogte met de hand gewijzigd: de knikken van een wolfsdak vervallen.
|
||||||
if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; });
|
if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; });
|
||||||
input.addEventListener('input', updateAll);
|
input.addEventListener('input', updateAll);
|
||||||
});
|
});
|
||||||
@@ -1416,8 +1418,8 @@ function mountWallEditor(container, callbacks) {
|
|||||||
exportDesign,
|
exportDesign,
|
||||||
triggerImport,
|
triggerImport,
|
||||||
mirrorHorizontal,
|
mirrorHorizontal,
|
||||||
toggleFullscreen3D,
|
setView,
|
||||||
toggleFullscreen2D,
|
viewState: () => ({ view: UI.viewOf(document.getElementById('app-container')) }),
|
||||||
openProjectProperties,
|
openProjectProperties,
|
||||||
getStateJson: getPureState,
|
getStateJson: getPureState,
|
||||||
loadStateJson: applyPureState
|
loadStateJson: applyPureState
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"mode": "spots",
|
"mode": "spots",
|
||||||
"direction": "both",
|
"direction": "both",
|
||||||
"lampHeight": 1950,
|
"lampHeight": 1950,
|
||||||
"lampMin": 1500,
|
"lampMin": 500,
|
||||||
"lampMax": 3000,
|
"lampMax": 3000,
|
||||||
"minWallWidth": 2000,
|
"minWallWidth": 2000,
|
||||||
"endOffset": 300,
|
"endOffset": 300,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ dak.daktrimMontage;Montage daktrim;s/m;300
|
|||||||
dak.nokPlaatsen;Stalen nokbalk plaatsen, stellen en vastzetten;s;2700
|
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.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.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.afwerkingMontage;Montage vloerafwerking;s/m²;1200
|
||||||
vloer.sandwichMontage;Montage per sandwichpaneel;s;600
|
vloer.sandwichMontage;Montage per sandwichpaneel;s;600
|
||||||
afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800
|
afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800
|
||||||
|
|||||||
|
@@ -21,6 +21,8 @@ dak.onderplaatM2;Onderplaat boeideel;€/m²;30;;;
|
|||||||
dak.daktrimM;Daktrim;€/m;12;;;
|
dak.daktrimM;Daktrim;€/m;12;;;
|
||||||
dak.staalKg;Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond);€/kg;2,9;;;
|
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.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.afwerkingM2;Vloerafwerking;€/m²;35;;;
|
||||||
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
|
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
|
||||||
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
|
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
|
||||||
|
|||||||
|
Reference in New Issue
Block a user