2 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 ab0dca9bb5 v0.7.1: UI-overhaul, ribbon vervangen door de indeling van CAD/BIM-pakketten met elke functie op één plek: bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren, projectgegevens, kosten & tijden) · Beeld · Help, de ontwerpen als tabs en de knop Opslaan; links de structuur met inklapbare groepen (onthouden, groep met de selectie klapt vanzelf open); werkbalk boven de viewports met gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, opties van het gereedschap ernaast, Unit toevoegen) en weergave (één Lagen-menu met bouwfase voor 2D en 3D, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D / 3D met 1/2/3); rechts het eigenschappenpaneel (Inspector: selectie met kruimelpad, of de woning met kerncijfers en inklapbare secties Gevel, Dak, Binnen, Verlichting, Elektra, Constructie); statusbalk met hint en meldingen; wand- en vloer/dak-editor in dezelfde indeling (paneel rechts met tab Resultaat, terug met ← Woning of Esc); sneltoetsenoverzicht (?), Ctrl+O, Ctrl+\ panelen verbergen; rapport als masonry; dubbele functies weg (lagenbalk, Beeld-tab en Weergave-filter, export en kosten op twee plekken, Snel nieuw); oude onderdelen gelijkgetrokken (dialoogknoppen, materiaalknoppen, chassis als schakelaar, eenheden); weergavelagen (ViewLayers) i.p.v. bouwfasen, elk los aan/uit; render-export met Blender-script voor de lampen; elektraleidingen boven een vals plafond en haaks binnen de units bij een L/T-vorm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:16:43 +02:00
MachielandClaude Opus 5.5 adb3c74f85 v0.7.0: exports voor CAD, BIM en renders (Bestand › Exporteren, los van Opslaan/Ctrl+S): CAD (DXF R12, plattegrond met wanden/sparingen/gevelbekleding/kozijnen met draaicirkel, ruimtes, inrichting, dakrand, elektra en echte maatvoering, plus vier gevels als lijntekening met verborgen lijnen weg; lagen PL-/AZ-, 1:1 mm, afgestemd op 1:50; in CAD op te slaan als DWG), BIM (IFC4 met alleen de bouwconstructie: stijlen, regels, vloer- en dakbalken, stalen nokbalk, palen, spaanplaat/OSB/cementplaat en elektra per leiding, gegroepeerd per unit en element met Pset_Tectum en kleur per soort) en Render (OBJ + MTL + texturen in een ZIP voor Blender, volledige afgewerkte woning, kleuren lineair, PBR); schoorsteen tegen een buitenwand (ChimneyModel: sparing in wand en dak met raveelwerk, steenstrip/stuc, verslepen langs de gevel); kozijnen koppelen met een eigen schakelaar; kozijnindeling (deurblad, zij-/bovenlicht, schuifbladen) gedeeld tussen 3D, plattegrond en aanzicht
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:00:32 +02:00
34 changed files with 3819 additions and 1175 deletions
+117 -26
View File
@@ -18,22 +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) 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)
@@ -50,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.
@@ -86,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
@@ -105,6 +119,11 @@ assets/ logo, texturen, skybox
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. Het wolfseind schaalt mee met goot en daklijn daar precies op aan. Het wolfseind schaalt mee met goot en
@@ -135,7 +154,7 @@ assets/ logo, texturen, skybox
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
@@ -193,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
@@ -201,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
@@ -210,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)
@@ -274,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"
@@ -303,7 +394,7 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
(`HouseModel.createRijn`, naar `examples/ontwerp_rijn.pdf`): L-vorm (`HouseModel.createRijn`, naar `examples/ontwerp_rijn.pdf`): L-vorm
13 × 10 m, hoofdvolume met flauw zadeldak (twee lessenaars die op de naad 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 de nok vormen) en een vleugel met plat dak, steenstrips rondom met een
donkere schoorsteenstrook in de oostgevel, kookeiland, logeerkamer en schoorsteen (800 mm, hart op de naad) in de oostgevel, kookeiland, logeerkamer en
apart toilet in de vleugel. De standaardmodellen staan in 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
+318 -228
View File
@@ -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); }
+25 -15
View File
@@ -22,15 +22,23 @@
<div class="splash-version"><span class="splash-name">Tectum</span> &mdash; Versie <span class="app-version-number">0.0.0</span></div> <div class="splash-version"><span class="splash-name">Tectum</span> &mdash; 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>&times;</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,30 +75,29 @@
<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>
@@ -106,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>
@@ -113,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>
+96 -85
View File
@@ -4,7 +4,7 @@
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md. * Zie LICENTIE.md.
*/ */
const APP_VERSION = '0.6.3'; 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 &rarr; Nieuw &rarr; kies een objecttype</div> <div style="font-size:12px;">Bestand &rsaquo; 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}">&times;</span> <button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">&times;</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();
-62
View File
@@ -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 };
})();
+284
View File
@@ -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 };
})();
+187
View File
@@ -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 };
})();
+63
View File
@@ -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 };
})();
+61
View File
@@ -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 };
})();
+198
View File
@@ -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 };
})();
-116
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
})(); })();
-43
View File
@@ -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) };
}
+108
View File
@@ -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 };
})();
+70
View File
@@ -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 };
})();
+1 -1
View File
@@ -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'})`,
+6 -6
View File
@@ -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&hellip;</button> <button type="button" class="ui-btn" data-cs="import">Importeren&hellip;</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">
+120 -18
View File
@@ -143,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);
@@ -155,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);
@@ -201,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);
@@ -735,13 +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();
mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex); mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex);
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key); if (!ViewLayers.shows(layers, 'elektra')) return group;
const rough = show('elektra') && !show('beplating');
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);
@@ -750,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;
@@ -779,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);
}); });
}); });
@@ -857,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);
+130 -34
View File
@@ -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
View File
@@ -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
+322
View File
@@ -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 };
})();
+392
View File
@@ -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 };
})();
+186
View File
@@ -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 };
})();
+114 -24
View File
@@ -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;
} }
@@ -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: [] });
@@ -309,6 +311,7 @@ window.HouseModel = (() => {
S.lighting.mode = 'spots'; S.lighting.mode = 'spots';
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: 14000, depth: D, height: LO, windowOn: [] }); 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: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 4600, depth: 3000, height: LO, windowOn: [] });
@@ -383,6 +386,7 @@ window.HouseModel = (() => {
S.lighting.mode = 'spots'; S.lighting.mode = 'spots';
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: D, height: LO, windowOn: [] }); 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: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: D, height: LO, windowOn: [] });
@@ -473,6 +477,7 @@ window.HouseModel = (() => {
S.lighting.mode = 'spots'; S.lighting.mode = 'spots';
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: 9200 - V, depth: D, height: LO, windowOn: [] }); 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: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 9200, depth: D, height: LO, windowOn: [] });
@@ -556,7 +561,7 @@ window.HouseModel = (() => {
// een vleugel van 4,2 × 3,46 m aan de zuidwestkant. Flauw zadeldak over het hoofdvolume als twee lessenaars die // 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 // 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. // goothoogte. Alleen een naadwand naar de vleugel; de 160 mm wand langs de nok is een gewone binnenwand.
// Steenstrips rondom, in de oostgevel een donkere strook waar de schoorsteen staat. Maten buitenwerks vanaf de // 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). // noordwesthoek, zoals de tekening (de steenstrips komen daar in de configurator buiten de units nog bij).
function createRijn() { function createRijn() {
const st = newModelState('Rijn'); const st = newModelState('Rijn');
@@ -567,6 +572,7 @@ window.HouseModel = (() => {
S.lighting.mode = 'spots'; S.lighting.mode = 'spots';
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: 13000, depth: D, height: LO, windowOn: [] }); 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 B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 13000, depth: D, height: LO, windowOn: [] });
@@ -582,7 +588,9 @@ window.HouseModel = (() => {
['raam', 500, 1400, 700, 'right', 3630, K('raam')], ['raam', 500, 1400, 700, 'right', 3630, K('raam')],
['raam', 1200, 500, 1600, 'right', 4760, K('raam')], ['raam', 1200, 500, 1600, 'right', 4760, K('raam')],
['raam', 1400, 1400, 700, 'right', 7560, K('raam')]]); ['raam', 1400, 1400, 700, 'right', 7560, K('raam')]]);
ops(A.unit.walls.rechts, [['raam', 1800, 2100, 0, 'right', 785, 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, [ 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', 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 })]]); ['deur', 900, 2100, 0, 'right', 1630, K('deur', { leaf: 'links', hinge: 'links', swing: 'buiten', volglas: true })]]);
@@ -593,10 +601,10 @@ window.HouseModel = (() => {
ops(C.unit.walls.links, [['raam', 500, 700, 1400, 'left', 365, 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.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')]]); ops(C.unit.walls.achter, [['raam', 1400, 1100, 1000, 'left', 1505, K('raam')]]);
// Schoorsteen midden in de oostgevel (z 2870–3670). u loopt van buiten gezien van links (zuid) naar rechts. // Schoorsteen 800 mm breed midden in de oostgevel, hart op de naad (z 3270), 180 mm voor de gevel.
A.facade.walls.rechts = { type: 'steen', stone: SCHOORSTEEN }; st.chimneys.push(Object.assign(ChimneyModel.create(st.chimneys, 'B', 'rechts', 3270), {
A.unit.walls.rechts.splits = [{ u: 400, facade: { type: 'steen', stone: BRICK } }]; w: 800, out: 180, above: 600, outside: { type: 'steen', stone: SCHOORSTEEN, stuc: 'wit' }
B.unit.walls.rechts.splits = [{ u: 2870, facade: { type: 'steen', stone: SCHOORSTEEN } }]; }));
const DH = 2115; const DH = 2115;
// Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant. // Noord–zuid: 'buiten' = west, 'left' = vanaf de noordkant; west–oost: 'buiten' = zuid, 'left' = vanaf de westkant.
@@ -1096,6 +1104,40 @@ window.HouseModel = (() => {
return out; 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. // De kop die een schild wordt volgt het dak; vervalt de aansluiting, dan krijgt de unit zijn eigen dakvorm terug.
function applyRoofJoins(state) { function applyRoofJoins(state) {
const rects = unitRects(state); const rects = unitRects(state);
@@ -1195,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 = {};
@@ -1206,18 +1249,29 @@ window.HouseModel = (() => {
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 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, skinTop) { 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, skinTop, 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);
@@ -1254,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));
@@ -1269,7 +1324,16 @@ window.HouseModel = (() => {
planes: joins[u.id].planes.map(pl => ({ a: pl.a + pl.bx * rect.cx + pl.bz * rect.cz, bx: pl.bx, bz: pl.bz })), 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 expr: joins[u.id].expr
}; };
const ur = join ? RoofModel.computeJoinedRoof(u.roof, join, roofRect, shape) : RoofModel.computeUnitRoof(u.roof, shape, roofRect, rims); 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', 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;
@@ -1281,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;
@@ -1477,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);
} }
@@ -1518,7 +1583,10 @@ window.HouseModel = (() => {
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)) };
@@ -1619,22 +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 groundY = units.length ? Math.max(...units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0) : 0;
const posts = PostModel.compute(state.settings.roof.posts, units, groundY); const posts = PostModel.compute(state.settings.roof.posts, units, groundY);
const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null; 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;
@@ -1643,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, posts, postsGroup, 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) {
@@ -1911,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.
@@ -1924,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 || {});
@@ -1977,6 +2067,6 @@ window.HouseModel = (() => {
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, EXTEND_MAX, 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
}; };
})(); })();
+35 -10
View File
@@ -87,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));
@@ -139,6 +139,12 @@ window.HousePdf = (() => {
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
doc.setFillColor(160, 124, 84); 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.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);
@@ -252,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))));
@@ -283,7 +291,7 @@ 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 => {
@@ -316,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;
} }
@@ -417,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 => {
@@ -429,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;
@@ -778,5 +803,5 @@ window.HousePdf = (() => {
return doc; return doc;
} }
return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS }; return { generate, sheetList, materialThumbUrls, viewFaces, viewProjections, VIEWS };
})(); })();
+543 -265
View File
File diff suppressed because it is too large Load Diff
+25 -16
View File
@@ -324,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;
@@ -450,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 };
})(); })();
+46 -17
View File
@@ -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;
@@ -269,7 +259,9 @@ window.RoofModel = (() => {
}; };
const ridgeTypes = ['zadel', 'wolf']; const ridgeTypes = ['zadel', 'wolf'];
// span: [q0, q1] waar de nok echt loopt (dakaansluiting: tot de hoekkeper). // span: [q0, q1] waar de nok echt loopt (dakaansluiting: tot de hoekkeper).
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2, span) { // 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;
@@ -285,6 +277,12 @@ window.RoofModel = (() => {
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]); } 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.
@@ -350,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;
@@ -372,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 => ({
@@ -419,7 +427,7 @@ 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);
addRidgeSpecs(specs, ridgeBeam); addRidgeSpecs(specs, ridgeBeam);
return { return {
shape, covering, planes: outPlanes, specs, ridgeBeam, shape, covering, planes: outPlanes, specs, ridgeBeam,
@@ -429,6 +437,21 @@ 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) { function addRidgeSpecs(specs, ridgeBeam) {
Object.assign(specs, { Object.assign(specs, {
nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0, nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0,
@@ -1046,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);
@@ -1082,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, computeJoinedRoof, joinValue, buildUnit3D, capRegions, computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
computeEdge, buildEdge3D, normalizeSettings computeEdge, buildEdge3D, normalizeSettings
}; };
})(); })();
+2 -1
View File
@@ -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++) {
+2 -2
View File
@@ -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));
+114 -43
View File
@@ -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 };
})(); })();
+104 -36
View File
@@ -320,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.
@@ -347,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;
} }
@@ -370,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; }
} }
} }
@@ -464,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 = [];
@@ -489,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;
@@ -535,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;
@@ -604,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);
@@ -704,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';
@@ -794,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
@@ -825,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++;
}); });
@@ -863,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);
@@ -1248,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 }
@@ -1284,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);
@@ -1295,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,
@@ -1331,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);
} }
@@ -1553,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, skinConfig, 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
}; };
+47 -45
View File
@@ -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">
@@ -23,9 +26,17 @@ const WALL_TEMPLATE = `
<canvas id="canvas-2d-side"></canvas> <canvas id="canvas-2d-side"></canvas>
</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>
<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;
@@ -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