15 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 a7215b9e62 v0.7.9: woningen van leads en klanten uit de CRM (alleen in de Docker-stack, api/crm/ via Caddy): bij Nieuw te zoeken en te openen, ?woning=<id> opent er direct één; Opslaan in CRM (ook Ctrl+S in zo'n tab) bewaart een nieuwe versie met opmerking, met waarschuwing als er intussen een nieuwere versie is; Downloaden (JSON + PDF) blijft; sluiten met niet-bewaarde wijzigingen vraagt bevestiging
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:40:51 +02:00
MachielandClaude Opus 5.5 0da1769f51 GLB voor AR zonder alles onder het maaiveld (GlbWriter clipBelow, zonder sandwichpanelen), AR op ware grootte; model Van den Berg (dubbel chalet met portiek en overdekte vlonder)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:30:01 +02:00
MachielandClaude Opus 5.5 4bb1548125 Light: optie price: false (of data-price="false"): geen prijs in het paneel en op het voorblad van de PDF; design().info.price blijft
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:30:01 +02:00
MachielandClaude Opus 5.5 73248a69c9 v0.7.8: light-versie in te bedden op elke pagina en eigen opslaan: texturen, kleurstalen en kosten/*.csv laden via tectumUrl() (js/core/geometry.js) vanaf de map van de scripts, zodat de engine niet naast de pagina hoeft te staan (modelpagina's van de site met de engine op /configurator/); paden in de data blijven relatief; TectumLight.mount(el, { save: { label, icon, onSave } }) vervangt de PDF-knop, onSave krijgt het ontwerp als house-design-JSON met kerncijfers, prijs en keuzes in woorden (ook el.tectumLight.design())
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:28:02 +02:00
MachielandClaude Opus 5.5 b8cc39275b v0.7.7: daktrim als echt profiel: voorkant 8 mm vóór de gevelbekleding, 30 mm boven het dak en over de bovenste trimHeight van de gevel (standaard 30 mm), opstand 20 mm diep met een flens van 3 mm op het dak over de bovenkant van de bekleding (RoofModel.TRIM); de bekleding loopt bij een daktrim door tot het dak; hoeken met daktrim/felsrand lopen op de buitenkant door, naast een boeideel stopt de trim ertegen; vrije uiteinden van binnenwanden afgewerkt in de binnenwandkleur (endCaps) in plaats van een zichtbare stijl; light-PDF: prijs op het voorblad rustig onder een dunne lijn, bedrag groot en vet, geen oranje vak
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:56 +02:00
MachielandClaude Opus 5.5 118ccf9098 v0.7.6: geen wolfsdak bij een overkapping (veranda, inpandig of uitpandig: het dak van een unit steekt buiten zijn vloer, HouseModel.hasCover): de keuze verdwijnt in configurator en light, een bestaand wolfsdak wordt een zadeldak met dezelfde goot en nok; afdekker op een stenen trasraam onder rabat, felsplaten of stuc (WallModel.TRAS_CAP): zetwerk achter de bekleding, 5° aflopend, neus 25 mm over de steen, onderbroken bij openingen, bij een buitenhoek door tot voorbij de buursteen, zwart/antraciet/wit (WallModel.TRIM_COLORS), in 3D, PDF-aanzichten, materialen, rapport en kosten; ventilatierooster van 40 mm op de gevel direct onder de onderplaat van alle boeidelen (RoofModel.VENT, niet boven een veranda of tegen een hogere gevel), sleuven in de textuur, aan/uit en kleur bij Dak › Boeideel, in 3D, PDF, rapport en kosten; light-versie: boeidelen of daktrim met kleur boeidelen (Dak) en één zetwerk-kleur voor afdekker en rooster (Gevel); 3D zijwaarts schuiven ook met de middelste muisknop, Shift/Ctrl/⌘ + slepen en de pijltjestoetsen met de muis boven de weergave (configurator en light, hints en sneltoetsenoverzicht bijgewerkt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:45:26 +02:00
MachielandClaude Opus 5.5 432e0f494a v0.7.5: AR en schermvullend in de light-versie: knop rechtsboven in de weergave; op een telefoon/tablet het ontwerp als GLB in model-viewer over de hele pagina met "Bekijk in je tuin" (ware grootte, WebXR/Scene Viewer/Quick Look, op iOS maakt model-viewer de USDZ; model-viewer 4.3.1, Apache-2.0, UMD met eigen three.js, pas geladen bij de eerste keer AR), op de desktop de weergave schermvullend (scrollwiel zoomt dan direct); GlbWriter (js/core/glb-writer.js): glTF 2.0 binair uit three.js-objecten, één primitive per materiaal, PBR met lineaire kleuren, texturen als JPEG/PNG op een macht van 2 tot 1024 px met de textuurmatrix in de UV's, lichtbundels en -vegen weg, nul-driehoeken eruit en lege normalen naar de vlaknormaal (valideert zonder fouten); GLB één keer per ontwerp, el.tectumLight.glb() en een testknop GLB op light.html; HouseModel.rootGroups en groundOrigin gedeeld met de render-export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:06:09 +02:00
MachielandClaude Opus 5.5 decbbb4b72 v0.7.4: light-versie voor de modelpagina's (light.html, js/light/, css/light.css; later in de HUGO-site in een Bootstrap col-12): één model in 3D, doorsnede (snijhoogte tot de goot) en 2D-plattegrond, met beperkte keuzes: per unit breedte en diepte (binnen UnitModel.LIMITS, per model/pagina te beperken), dakvorm met richting, goot- en nokhoogte (helling erbij) en wolfseind, dakbedekking, gevelbekleding met kleur en richting, accent van het model, trasraam (geen/steen, hoogte), binnenspots en buitenverlichting; zelfde berekening als de configurator maar alleen prijs (indicatief, incl. btw), maten, m², inhoud en gewicht zichtbaar; LightModel past keuzes toe op een schone kopie van het model (binnenwanden en inrichting schuiven mee, binnenwanden volgen de goot) en weigert keuzes die de indeling breken (ramen/deuren, binnenwanden, inrichting, ruimtes, overlap) met de dichtstbijzijnde maat die wel past; accordeon-paneel, responsief op de breedte van de tool (container queries), scrollwiel scrolt de pagina tenzij je in de 3D klikt of Ctrl houdt; opslaan = PDF (LightPdf): voorblad "Uw woningontwerp" met render, kerncijfers, keuzes, prijs, contact en QR-code naar de modelpagina (qrcode-generator, MIT), aanzichten en plattegrond (HousePdf klantvariant zonder productie-informatie) en het stappenplan met maatwerk en uitnodiging; doorsnede-afdekking naar HouseModel.sectionCaps (ook in de configurator): 1 mm boven de snede en over de wandtop van wanden die lager blijven, geen flikkerend regelwerk meer; OrbitControls met touch (één vinger draaien, twee vingers knijpen/schuiven); model Vecht (U-vorm 13 × 9,5 m, drie units rond een holte)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:48:51 +02:00
MachielandClaude Opus 5.5 51921650e3 v0.7.3: elektraleidingen nooit meer door een raam of deur (makeOpeningTest): een stijgleiding boven/onder een opening (wcd onder een raam) gaat eerst op dezelfde hoogte opzij tot naast de opening, langs dezelfde wand met een deur of raam ertussen via het plafond, gevellampen om verspringende gevelhoeken heen en het uit elkaar schuiven van buizen eindigt nooit in een opening (alle modellen 0 kruisingen); felsplaten als gevelafwerking (zink/antraciet/zwart, per woning, wand of wandstuk, opbouw als verticale rabat, kleuren gedeeld met het felsdak, in 3D, PDF en kosten); felsplaat op dak én gevel = geen boeideel, de dakplaat zet om over de rand zonder opstand, een boeideel ernaast stopt gelijk met zo'n rand (ook naast daktrim); zijwand bij een overkapping (Palen overkapping › Zijwand) in plaats van een hoekpaal, gestuukt, met de bekleding van de gevel in hetzelfde vlak aan de buitenkant (in 3D, plattegrond, PDF, DXF en kosten); model Reest (12,9 × 7,85 m, lessenaarsdak hoog aan de noordgevel over twee units in één vlak, overkapping bij de voordeur met witte zijwand, aanbouw met plat dak en daktrim, zuidgevel en dak in antraciet felsplaten, verder verticale rabat)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:27:20 +02:00
MachielandClaude Opus 5.5 89cadbebf7 v0.7.2: fixes uit de code-review van v0.7.1: elektraleidingen nemen niet langer het valse plafond van de buurruimte door de wand heen over (gebied zonder functie = geen vals plafond) en meten begin/eind 50 mm de ruimte in, zodat de zigzag bij een overgang vals plafond/dak verdwijnt (Amstel 249,9 → 246,8 m, 16 van 68 leidingen strakker); elektra en schoorsteen snijden mee met de doorsnede (geen zwevende buizen); PDF en de plaatfase van de IFC bouwen geen buizen meer; schoorsteen in 2D en 3D op de laag Gevelbekleding; Esc sluit elke dialoog via Annuleren en sneltoetsen gaan niet meer naar de editor erachter; toets 3 sluit het rapport; menu en popovers sluiten elkaar (één uitklapper tegelijk); paneelkop ge-escaped; na slepen/pannen buiten de plattegrond wordt de volgende klik niet meer ingeslikt en blijft de cursor van het gereedschap; "Alles" in Lagen zet ook beplating binnen/buiten terug; laag uit met een selectie erin werkt het paneel bij; opgeruimd: gedeelde werkbalk (UI.editorBar) voor woning, wand en vloer, één knopbouwer (UI.btnOf), dichte paneelsecties pas opgebouwd bij openen, dode knopacties weg, binnenwand verwijderen op één plek, doorgestuurde acties uit één lijst, render-export "alleen afgewerkt" volledig in HouseModel (finishedOnly), structuurboom springt alleen bij een andere selectie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:01:13 +02:00
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
MachielandClaude Opus 5.5 86fcd57b96 v0.6.3: modellen Waal (12,85 × 7 m, flauw zadeldak dat aan de oostkop 1,5 m doorloopt als veranda), Maas (9,2 × 9,2 m, woningdak met inpandige veranda in de noordwesthoek) en Rijn (L-vorm 13 × 10 m, zadeldak als twee lessenaars op de naad, vleugel met plat dak, steenstrips met schoorsteenstrook, kookeiland, logeerkamer, spots in de boeidelen); overkapping per unit (dak verlengen voorbij de gevel) met palen (PostModel: hout, maat, h.o.h., op de plattegrond/PDF en in de kosten); kleur schakelaars/wcd's (wit, antraciet, zwart); gevellampen naast openingen en per vrij wandstuk gelijk verdeeld, nooit in een opening; logobord niet meer onder een unit bij een voordeur in een binnenhoek
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:26:06 +02:00
MachielandClaude Opus 5.5 b137a458d4 v0.6.2: dakaansluiting L/T (vleugel met nokdak loopt het hoofddak in met kilkepers, of de kop wordt een schild met hoekkeper; eigen dakdeel en PDF-blad per unit, ook in de kosten), model IJssel (L-vorm met zadeldaken in riet), unit max 4,7 m breed; mansarde vervallen (oude ontwerpen worden een zadeldak met dezelfde nok); gevelbekleding volgt voorbij het wandeinde de daklijn (geen blok meer boven een steil dakvlak) en bij een bolle daklijn per punt de bovenkant van het dak; gevellampen standaard min. 500 mm h.o.h. (geen samengevoegde lamp meer boven een smal raam)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:19:43 +02:00
MachielandClaude Opus 5.5 bd084e4a96 v0.6.1: standaardmodellen uit één lijst (HouseModel.MODELS) voor Snel nieuw en het venster Nieuw, gedeelde bouwstenen (modelKit) voor Amstel en nieuw model Wetering (10,5 × 4,8 m, twee units zonder naadwand, hoekkeuken, badkamer, slaapkamer met kast); keukenblok met kookplaat (plek instelbaar) en spoelbak aan/uit, kookplaat ook in de kosten van een keukenblok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:53:24 +02:00
53 changed files with 12249 additions and 1630 deletions
+296 -41
View File
@@ -18,23 +18,38 @@ 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, qrcode-generator 1.4.4 (MIT, Kazuhiko Arase; QR in de light-PDF),
js/core/ gedeelde helpers: geometrie, bouwfasen, ribbon, UI-kit, PDF-bladen, model-viewer 4.3.1 (Apache-2.0, Google; UMD-bundel met eigen three.js, AR in de light-versie)
labels, camerageheugen, weergavefilter, skybox, licentie, logo js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel
(inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen,
DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver
(obj-writer.js), GLB-schrijver (glb-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), of een dichte
zijwand op een vrije hoek
js/house/chimney-model.js ChimneyModel: schoorsteen tegen een buitenwand (hol, sparing in de wand zoals een raam,
ook over een naad, zonder hout erin; sparing in het dak met raveelwerk (stijlen, balken, kreupele
balken), de beplating eindigt op het kader; onderbreekt de dakrand; een nokbalk stopt ervoor;
buiten steenstrip/stuc, binnen als de wanden of eigen; in de plattegrond
te verslepen langs de buitengevel)
js/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots js/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
js/crm.js koppeling met de Van Emous CRM (alleen in de Docker-stack, zie "CRM")
light.html light-versie (modelpagina's), los te openen: light.html?model=amstel
js/light/ LightModel (keuzes op een model toepassen, indelingscontrole, kerncijfers), LightPdf (klant-PDF) + light-UI
css/light.css stijl light-versie, alles onder .tectum-light
json/defaults/ referentie-JSON's (niet runtime ingeladen) json/defaults/ referentie-JSON's (niet runtime ingeladen)
kosten/ intern.csv (eigen waarden) + leveranciers.csv (live prijzen), bij het starten ingeladen kosten/ intern.csv (eigen waarden) + leveranciers.csv (live prijzen), bij het starten ingeladen
assets/ logo, texturen, skybox assets/ logo, texturen, skybox
@@ -49,9 +64,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.
@@ -74,7 +91,11 @@ assets/ logo, texturen, skybox
en plafond naar de meterkast (strekkende meters per type, draad, buis, en plafond naar de meterkast (strekkende meters per type, draad, buis,
steekverbinders op module-naden), materiaallijst en eigen PDF-blad. steekverbinders op module-naden), materiaallijst en eigen PDF-blad.
Buizen minstens 50 mm uit elkaar (gebundeld alleen het laatste stuk naar Buizen minstens 50 mm uit elkaar (gebundeld alleen het laatste stuk naar
de meterkast). Vloer-wcd's met leiding door de vloer. Elektra ook op de de meterkast). Nooit door een raam of deur (`makeOpeningTest`): een
stijgleiding boven/onder een opening (wcd onder een raam) gaat eerst op
dezelfde hoogte opzij tot naast de opening, langs dezelfde wand met een
deur of raam ertussen via het plafond, gevellampen om de gevelhoeken heen,
en uit elkaar schuiven van buizen eindigt nooit in een opening. Vloer-wcd's met leiding door de vloer. Elektra ook op de
PDF-bladen per wand, vloer en dakvlak (plafond). Alles verplaatsbaar door PDF-bladen per wand, vloer en dakvlak (plafond). Alles verplaatsbaar door
te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per
meter, boorgaten (geteld op stijlen, regels en balken), dozen, meter, boorgaten (geteld op stijlen, regels en balken), dozen,
@@ -85,7 +106,11 @@ assets/ logo, texturen, skybox
opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de
maat van de opening, ook een schuine bovenzijde. Deur: kant van het blad, maat van de opening, ook een schuine bovenzijde. Deur: kant van het blad,
scharnierzijde en draairichting (naar binnen/buiten); openslaande deuren scharnierzijde en draairichting (naar binnen/buiten); openslaande deuren
met zijlichten = twee deuren naast elkaar. Schuifdeur: 1 of 2 bladen met zijlichten = twee deuren naast elkaar. Koppelen: een opening die tegen
zijn buur aan staat (zelfde onderkant en hoogte, samen max. 2850 mm) vormt
daarmee één sparing zonder stijlen, dagkanten, latten of kozijnstijlen
ertussen; twee draaideuren worden stolp (bladen tegen elkaar), anders één
gedeelde tussenstijl. Schuifdeur: 1 of 2 bladen
(uit elkaar), in de wand of op de wand aan een rail (binnen- of (uit elkaar), in de wand of op de wand aan een rail (binnen- of
buitenzijde), glazen of dicht blad. In de plattegrond (app en PDF) een buitenzijde), glazen of dicht blad. In de plattegrond (app en PDF) een
draaicirkel per deur en de schuifrichting per schuifdeur; in de loopmodus draaicirkel per deur en de schuifrichting per schuifdeur; in de loopmodus
@@ -97,31 +122,61 @@ assets/ logo, texturen, skybox
geschilderd in de plafondkleur (Binnen › Wanden › Plafond), zolang geschilderd in de plafondkleur (Binnen › Wanden › Plafond), zolang
"Geschilderd" aan staat; anders kale OSB. "Geschilderd" aan staat; anders kale OSB.
- **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat, - **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat,
lessenaar, zadel, mansarde of wolfsdak, met schuiven en hellingshoek). lessenaar, zadel of wolfsdak, met schuiven en hellingshoek).
Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij een
met twee knikken, bij een wolfsdak afgeknot op het wolfseind, met wolfsdak afgeknot op het wolfseind, met
daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een
rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het
bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers, bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers,
nokbalk en kopbalken (ook in zaaglijst, specificaties en kosten). nokbalk en kopbalken (ook in zaaglijst, specificaties en kosten).
Een dubbel lessenaardak (twee units, elk hoog aan hun gezamenlijke naad,
op de mm gelijke goot, nok en hoogte op de naad: samen een zadeldak)
krijgt net als een zadeldak een stalen nokbalk onder de naad; één
lessenaar of twee die zo niet aansluiten niet. Staat er een schoorsteen
op de nok, dan stopt elke nokbalk vóór de schoorsteen.
Goot en nok gelden over de volle vloermaat (goot boven de buitenkant van Goot en nok gelden over de volle vloermaat (goot boven de buitenkant van
de gevel); staat de kopgevel tussen de haakse wanden, dan loopt zijn de gevel); staat de kopgevel tussen de haakse wanden, dan loopt zijn
daklijn daar precies op aan. Knik en wolfseind schalen mee met goot en daklijn daar precies op aan. Het wolfseind schaalt mee met goot en
nok (verhouding). Bij een knik (mansarde) sluiten de dakvlakken in nok (verhouding). De PDF heeft voor een wolfsdak een eigen blad met het
verstek aan. De PDF heeft voor een wolfsdak een eigen blad met het
bovenste deel (bovenaanzicht houtwerk + stuklijst). bovenste deel (bovenaanzicht houtwerk + stuklijst).
Grenzen (`UnitModel.LIMITS`): nok max 5 m, goot / plat dak max 3 m (vanaf Grenzen (`UnitModel.LIMITS`): nok max 5 m, goot / plat dak max 3 m (vanaf
bovenkant vloer tot onderkant dak), unit max 4,3 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen bovenkant vloer tot onderkant dak), unit max 4,7 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen
boeideel (versprongen, het lage loopt door tot onder het hoge). Wijzig je een boeideel (versprongen, het lage loopt door tot onder het hoge). Wijzig je een
wandhoogte in de wand-editor, dan geldt dezelfde regel vanuit die wand. wandhoogte in de wand-editor, dan geldt dezelfde regel vanuit die wand.
De daklijn van een wand hoort bij de wand zonder naden: loopt een wand De daklijn van een wand hoort bij de wand zonder naden: loopt een wand
door een naad langer door (bijv. de kopgevel naast een gekoppelde unit), door een naad langer door (bijv. de kopgevel naast een gekoppelde unit),
dan blijven nok en knikken op hun plaats (`UnitModel.topLineOn`). dan blijven nok en knikken op hun plaats (`UnitModel.topLineOn`).
- **Dakaansluiting (L/T):** staat een unit met nokdak (zadel of
wolfsdak) met zijn nok haaks op de langszijde van een buur met nokdak
(vleugel tegen hoofdvolume), dan loopt de vleugel het hoofddak in tot
zijn nok, met kilkepers in de binnenhoeken (bij gelijke goot, nok en
overspanning onder 45°); het
wolfseind van de vleugel aan de naad vervalt. Ligt de vleugel gelijk met
een kop van het hoofdvolume, dan wordt die kop een schild in de vlakken
van de vleugel (hoekkeper, de goot loopt door; de kopgevel staat dan op
goothoogte, een wolfseind daar vervalt). Een vleugel die in een wolfseind
van het hoofdvolume zou steken sluit niet aan. Elke unit houdt zijn eigen
dakdeel (sporen, kreupele sporen, kepers, knik- en nokbalken; stalen
nokbalk alleen over de eigen nok) met een eigen PDF-blad
(`HouseModel.roofJoins`, `RoofModel.computeJoinedRoof`). Niet bij dak
over de hele woning; dakramen in zo'n dakdeel doen nog niet mee.
- **Overkapping:** bij een dak over de hele woning met `cover` loopt het - **Overkapping:** bij een dak over de hele woning met `cover` loopt het
dak door tot de omhullende rechthoek waar geen unit staat (bijv. een dak door tot de omhullende rechthoek waar geen unit staat (bijv. een
terras naast een kortere module), met een afgewerkte onderplaat in de terras naast een kortere module), met een afgewerkte onderplaat in de
kleur van het boeideel (`HouseModel.coverExtensions`). kleur van het boeideel (`HouseModel.coverExtensions`). Palen
- **Dak over de hele woning:** tabblad Dak › "Over hele woning": één (Woning › Dak › Palen overkapping) komen onder de vrije dakrand; met
"Zijwand" krijgt een vrije hoek geen paal maar een gestuukte wand van
200 mm van de dakhoek terug tot de gevel, langs de zijkant van de
overkapping (bij een hoek tussen twee vrije randen de kant met de kortste
weg naar de gevel); in 3D, plattegrond, PDF, DXF en kosten
(`PostModel`, `dak.zijwandM2`/`dak.zijwandMontage`). Ligt de buitenkant
in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat,
steen) erover door, gelijk met de gevel en tot onder de dakrand.
Bij een overkapping (veranda, inpandig of uitpandig: het dak van een unit
steekt buiten zijn vloer, `HouseModel.hasCover`) is er geen wolfsdak: de
keuze verdwijnt en een bestaand wolfsdak wordt een zadeldak met dezelfde
goot en nok.
- **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één
dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle 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
@@ -155,18 +210,32 @@ assets/ logo, texturen, skybox
aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak
zonder spots). De rand van het lage dak loopt dan door tot de gevel van zonder spots). De rand van het lage dak loopt dan door tot de gevel van
het hoge, of langs een zijgevel tot de volle overstek (onder de hoge het hoge, of langs een zijgevel tot de volle overstek (onder de hoge
rand). Boeideel en daktrim steken 80 mm boven het dakleer uit. rand). Boeideel en daktrim steken 80 mm boven het dakleer uit. Hebben
dak én gevel onder een rand felsplaten, dan komt er geen boeideel: de
dakplaat zet om over de rand, net vóór de gevelbekleding en zonder
opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de
felsplaten); een boeideel ernaast stopt gelijk met de buitenkant van zo'n
rand (ook naast daktrim). Onder de boeidelen zit op de gevel een
ventilatierooster van 40 mm (direct onder de onderplaat, op de
gevelbekleding; niet boven een veranda), zwart/antraciet/wit
(`RoofModel.VENT`, Woning › Dak › Boeideel; kosten `dak.ventRoosterM`).
- **Gevel:** gevelbekleding per woning, met override per wand en per - **Gevel:** gevelbekleding per woning, met override per wand en per
wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning, wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning,
getekend als plankentextuur die over naden doorloopt), steen (181 getekend als plankentextuur die over naden doorloopt), steen (181
texturen) of stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur texturen), stucwerk (pleisterlaag 15 mm op cement-gebonden plaat, kleur
per woning, `WallModel.STUC`). Een zijde kan gesplitst worden (wand › per woning, `WallModel.STUC`) of felsplaten (staande naad, banen van
500 mm, zink/antraciet/zwart; opbouw als verticale rabat, kleuren gedeeld
met de felsplaten op het dak, `WallModel.FELS`; kosten `gevel.felsM2` en
`gevel.felsMontage`). Een zijde kan gesplitst worden (wand ›
Wandstukken › Splitsen op, gemeten vanaf de linker vloerhoek van buitenaf): Wandstukken › Splitsen op, gemeten vanaf de linker vloerhoek van buitenaf):
elk stuk wordt een eigen wand-element en kan een eigen gevel krijgen elk stuk wordt een eigen wand-element en kan een eigen gevel krijgen
(bijv. stuc links, hout rechts). Steen is overal een (bijv. stuc links, hout rechts). Steen is overal een
steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`). steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`).
Het trasraam (cementplaat of een eigen steen) komt onder beide, dus Het trasraam (cementplaat of een eigen steen) komt onder beide, dus
twee stenen kan. Latwerk, doek en metselwerk lopen per gevel door over twee stenen kan. Op een stenen trasraam onder rabat, felsplaten of stuc
(minder ver uitstekend) komt een afdekker: zetwerk 5° aflopend met een
neus van 25 mm over de steen, onderbroken bij openingen, zwart/antraciet/wit
(`WallModel.TRAS_CAP`, `TRIM_COLORS`; kosten `gevel.afdekkerM`). Latwerk, doek en metselwerk lopen per gevel door over
unit-naden. Sandwichpanelen rondom, 600 mm de grond in. unit-naden. Sandwichpanelen rondom, 600 mm de grond in.
- **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is - **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is
een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door
@@ -179,7 +248,7 @@ assets/ logo, texturen, skybox
(o.a. tegels), FIBO-wandpanelen (decor, tot een hoogte of tot het plafond), (o.a. tegels), FIBO-wandpanelen (decor, tot een hoogte of tot het plafond),
eigen verfkleur en een vals plafond op een vaste hoogte (alleen waar het eigen verfkleur en een vals plafond op een vaste hoogte (alleen waar het
dak hoger ligt; wandafwerking en binnenspots volgen). Laag in de dak hoger ligt; wandafwerking en binnenspots volgen). Laag in de
plattegrond (Beeld › Tonen: Ruimtes), label met netto m², in de structuur, plattegrond (Lagen › Ruimtes), label met netto m², in de structuur,
het rapport, de kosten (vals plafond, FIBO) en op de PDF-plattegrond met het rapport, de kosten (vals plafond, FIBO) en op de PDF-plattegrond met
een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee. een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee.
- **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset - **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset
@@ -187,8 +256,8 @@ assets/ logo, texturen, skybox
(douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met (douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met
koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat, koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat,
kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil, kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil,
salontafel, tv-meubel, boiler en wasmachine. Binnen › Inrichting: kies salontafel, tv-meubel, boiler en wasmachine. Gereedschap Inrichting (I): kies
een stuk en klik in de plattegrond; slepen, bij een wand snapt de rug op een stuk in de werkbalk en klik in de plattegrond; slepen, bij een wand snapt de rug op
het wandvlak (Alt = vrij), D = draaien, Del = verwijderen. Per stuk maten het wandvlak (Alt = vrij), D = draaien, Del = verwijderen. Per stuk maten
en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D
(afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep (afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep
@@ -196,24 +265,166 @@ 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`).
## Light-versie (modelpagina's)
Beperkte configurator voor de websitepagina van één model (later in de HUGO-build; nu los via
`light.html?model=<sleutel uit HouseModel.MODELS>`). Inbedden: de stylesheets en scripts uit `light.html`
plus `<div class="tectum-light" data-model="amstel"></div>` (mount vanzelf), of
`TectumLight.mount(el, { model, frames, url, modelViewer, save })`. De engine hoeft niet naast de pagina te staan:
texturen, kleurstalen en `kosten/*.csv` laden via `tectumUrl()` (`js/core/geometry.js`) vanaf de map van de scripts
(bijv. `/configurator/` op een modelpagina van de site); paden in de data blijven relatief.
- **Rekent als de configurator** (zelfde `HouseModel.computeParts`, totalen en `CostModel`), maar toont
alleen: indicatieve prijs incl. btw (afgerond op € 100), buitenmaten, bouwhoogte, bruto/leefbaar m²,
inhoud en gewicht. Geen uren, zaaglijst, inkoop of kostenopbouw.
- **Weergave:** 3D (afgewerkt, dag-omgeving), Doorsnede (wanden op een instelbare hoogte, standaard 1,5 m en hoogstens de laagste goot of de hoogte van een plat dak, afgesneden en wit afgedekt
met `HouseModel.sectionCaps`, dezelfde als de configurator; dak, dakrand, palen en lampen weg) en een
2D-plattegrond (ruimtes met netto m², wanden, kozijnen met draairichting, inrichting, dakrand, buitenmaten).
- **Keuzes:** per unit breedte, diepte, goothoogte en dakvorm met richting (lessenaar: hoge kant noord/zuid/
west/oost; zadel- en wolfsdak: nok noord–zuid of west–oost); bij een hellend dak de nokhoogte (lessenaar:
hoge kant, met de helling eronder; de goot omhoog neemt de nok mee), bij een wolfsdak ook de hoogte van het
wolfseind (bij een dak over de hele woning één keer), dakbedekking, gevelbekleding (hout met richting, felsplaten, stuc, steenstrips; kleur),
het accent van het model (de afwijkende gevel; of geen), trasraam (geen of steen, met hoogte; een cementplaat-trasraam van het model wordt geen), boeidelen of daktrim
(met kleur boeidelen; een model met beide houdt zijn indeling tot je kiest), zetwerk-kleur (afdekker trasraam en
ventilatierooster), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele
woning alleen als de units samen een rechthoek vormen en nooit bij een overkapping (zoals de configurator). Geen openingen,
binnenwanden, inrichting of elektra; die volgen het model.
- **Keuzes op een schone kopie:** `LightModel.build` past de keuzes elke keer toe op het model zelf; wat niet
afwijkt blijft precies zoals in het model. Bij een andere maat schuiven binnenwanden mee met hun unit en
schuift wat tegen de oost- of zuidgevel staat (binnenwand-eindpunt, inrichting) mee met die gevel (`BAND`).
Bij een andere hoogte gaan de binnenwanden van die unit evenveel mee. Een andere dakvorm dan die van het
model krijgt de helling uit `LightModel.PITCH` (lessenaar 10°, zadel 30°, wolfsdak 35°).
- **Kaders:** standaard `UnitModel.LIMITS` (maat 2,4–14 m, max 4,7 m in één richting; hoogte 2,4–3 m), per
model te beperken in `LightModel.FRAMES` of per pagina met `frames` (zelfde vorm; `null` = maat staat vast).
- **Indelingscontrole** (`LightModel.check`): een keuze waarbij ramen of deuren niet meer passen
(verschoven, ingeklemd, te hoog of over een naad), een binnenwand buiten de units valt, inrichting door
een gevel steekt, ruimtes verdwijnen of units overlappen, wordt geweigerd; vergeleken met het model zelf.
Bij een maat wordt de dichtstbijzijnde maat gezocht die nog past.
- **Indeling:** paneel als accordeon (Afmetingen, Dak, Gevel, Verlichting; één open, de kop vat de keuze samen),
kerncijfers en prijs met PDF-knop onderaan. Responsief op de breedte van de tool zelf (container queries; in de
site in een Bootstrap col-12): vanaf 720 px naast elkaar, smaller 3D boven en de keuzes eronder met prijs en
PDF vast onderin beeld. Stijl alles onder `.tectum-light` (Bootstrap-reboot geneutraliseerd); Bootstrap Icons
nodig (`css/vendor/bootstrap-icons.min.css`). Het scrollwiel scrolt de pagina, tenzij je in de 3D hebt
geklikt of Ctrl/⌘ ingedrukt houdt. Zijwaarts schuiven: rechts of middelste knop slepen, Shift/Ctrl/⌘ +
slepen, of de pijltjestoetsen met de muis boven de 3D. Touch: één vinger draaien, twee vingers knijpen/schuiven (eigen
OrbitControls, geldt ook voor de configurator).
- **Opslaan = PDF** (`LightPdf`): voorblad "Uw woningontwerp" (beeld uit de 3D-weergave, kerncijfers, keuzes
in woorden via `LightModel.describe`, indicatieve prijs, contactgegevens en een QR-code naar de modelpagina
op de site, of `data-url`/`url` van de pagina), dan aanzichten en plattegrond
(`HousePdf.generate(state, { customer: true, doc })`: zonder onderdelenlijst, elementcodes, elektra en
kosten) en als laatste het stappenplan van de site ("Hoe verloopt het traject?") met een blok over maatwerk
met de eigen software (keuken op maat, indeling, ramen en deuren). Contact en stappen staan
in `LightPdf.COMPANY` en `STEPS`.
- **Eigen opslaan** (`save: { label, icon, onSave }`): vervangt de PDF-knop, bijv. "Ontwerp aanvragen" op de site.
`onSave` krijgt `{ design, label, key, info, choices, url }`: `design` is de house-design-JSON zoals de
configurator die opslaat (de CRM maakt daar het gekozen model van), `info` de kerncijfers met prijs, `choices` de
keuzes in woorden. Zonder `save` (en via `el.tectumLight.design()`) hetzelfde ontwerp.
- **AR / schermvullend:** knop rechtsboven in de weergave. Op een telefoon of tablet: het ontwerp als GLB
(`GlbWriter`, afgewerkte woning zoals in 3D, één primitive per materiaal, texturen als JPEG/PNG tot 1024 px,
lichtbundels weg; maaiveld onder het midden = oorsprong, `HouseModel.groundOrigin`) in model-viewer, over de
hele pagina, met de knop "Bekijk in je tuin" (ware grootte; `ar-modes="webxr scene-viewer quick-look"`, op iOS
maakt model-viewer zelf de USDZ). model-viewer wordt pas bij de eerste keer geladen
(`js/vendor/model-viewer-umd.min.js` naast `js/light/`, of `modelViewer`/`data-model-viewer`). AR vraagt
https; Scene Viewer (Android zonder WebXR) heeft bovendien een publieke URL van de GLB nodig, een blob-URL
werkt daar niet. Op de desktop: de weergave schermvullend (Fullscreen API; het scrollwiel zoomt dan direct).
Per ontwerp wordt de GLB één keer gemaakt; `el.tectumLight.glb()` geeft `{ blob, url, stats }` (op
`light.html` de testknop GLB).
## CRM (woningen van leads en klanten)
Alleen in de Docker-stack (`../docker`), waar Tectum op `tectum.<domein>` achter het wachtwoord staat. Caddy zet
`api/crm/*` daar door naar de CRM (`/api/tectum/*`) met de sleutel `TECTUM_CRM_KEY`; de browser ziet die nooit.
- **Nieuw** toont onder de standaardmodellen de woningen uit de CRM (zoeken op woning, naam, relatie of plaats);
kiezen opent de nieuwste versie in een eigen tab (wolkje in de tab, naam "woning · vN").
- **`?woning=<id>`** opent die woning direct (de knop "Bewerken in Tectum" bij een woning in de CRM).
- **Opslaan in CRM** (bovenbalk, Bestand, Ctrl+S in een CRM-tab) vraagt wat er veranderd is en zet het ontwerp als
nieuwe versie bij de woning in de CRM. Staat er intussen een nieuwere versie (iemand anders), dan vraagt Tectum
eerst of het toch moet. **Downloaden (JSON + PDF)** doet in een CRM-tab wat Opslaan anders doet.
- Sluiten van een CRM-tab met wijzigingen die nog niet in de CRM staan vraagt om bevestiging.
Zonder stack (`start.sh`) bestaat `api/crm/` niet en is van de CRM niets te zien.
## Kosten en prijzen (CSV) ## Kosten en prijzen (CSV)
@@ -260,6 +471,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"
@@ -271,8 +492,42 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026
stucwerk wit met verticaal hout aan de slaapkamerkant, binnenwanden, stucwerk wit met verticaal hout aan de slaapkamerkant, binnenwanden,
dubbele schuifdeur tussen woonkamer en hal, en complete inrichting. dubbele schuifdeur tussen woonkamer en hal, en complete inrichting.
Langer maken: beide units breder (alles is vanaf de westgevel gemaat). Langer maken: beide units breder (alles is vanaf de westgevel gemaat).
Ook via Bestand › Woning Amstel. De L-woning (8 × 3,6 m + 4,5 × 3,6 m) Model **Wetering** (`HouseModel.createWetering`, naar
blijft beschikbaar als "Woning L-vorm". `examples/ontwerp_wetering.pdf`): 10,5 × 4,8 m, twee units van 2,6 en
2,2 m zonder naadwand (open woonkamer over de volle diepte), verticaal
rabat grijs, plat dak met zwart boeideel, hoekkeuken met kookplaat,
badkamer, slaapkamer met kledingkast. Model **IJssel**
(`HouseModel.createIJssel`, naar `examples/ontwerp_ijssel.pdf`): L-vorm
14 × 4,6 m met een vleugel van 4,6 × 3 m, twee units met zadeldak en
riet die met een kil en een hoekkeper op elkaar aansluiten, stucwerk wit,
openslaande deuren in de westgevel. Model **Waal**
(`HouseModel.createWaal`, naar `examples/ontwerp_waal.pdf`): 12,85 × 7 m,
flauw zadeldak dat aan de oostkop 1,5 m doorloopt als veranda op eiken
palen (Overkapping per unit). Model **Maas** (`HouseModel.createMaas`,
naar `examples/ontwerp_maas.pdf`): vierkant 9,2 × 9,2 m, flauw zadeldak
over de hele woning; de overkapping maakt van de noordwesthoek een
inpandige veranda (2,5 × 4,6 m) op eiken palen. Model **Rijn**
(`HouseModel.createRijn`, naar `examples/ontwerp_rijn.pdf`): L-vorm
13 × 10 m, hoofdvolume met flauw zadeldak (twee lessenaars die op de naad
de nok vormen) en een vleugel met plat dak, steenstrips rondom met een
schoorsteen (800 mm, hart op de naad) in de oostgevel, kookeiland, logeerkamer en
apart toilet in de vleugel. Model **Reest** (`HouseModel.createReest`,
naar `examples/ontwerp_reest.pdf`): 12,9 × 7,85 m, hoofdvolume
11,5 × 4,8 m onder één lessenaarsdak (hoog aan de noordgevel, twee units
van 3,4 en 1,4 m met lessenaars in hetzelfde vlak) dat aan de westkop
1,4 m doorloopt als overkapping bij de voordeur, dicht met een witte
zijwand; aanbouw met plat dak (toilet, berging, entree met open doorgang
naar de woonkamer, daktrim i.p.v. boeidelen); zuidgevel en dak in
antraciet felsplaten (zuidrand zonder boeideel, de felsplaten lopen over
de zijwand door), verder verticale rabat. Model **Vecht** (`HouseModel.createVecht`): U-vorm
13 × 9,5 m, drie units (slaapvleugel west, tussenlid met entree, leefvleugel oost) met de holte open naar
het noorden; de vleugels lessenaars die naar de holte aflopen, het tussenlid plat op dezelfde goot, de
overkapping van het tussenlid dekt de holte (palen niet nodig); zwarte verticale rabat, wit stucwerk aan de
holte. De standaardmodellen staan in
`HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden
daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die
`modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft
beschikbaar als "Woning L-vorm".
- Netto m² per ruimte telt de hele vloer, ook onder een laag schuin dak - Netto m² per ruimte telt de hele vloer, ook onder een laag schuin dak
(geen NEN 2580-correctie voor hoogte < 1,5 m). (geen NEN 2580-correctie voor hoogte < 1,5 m).
- Units kunnen niet gedraaid worden. - Units kunnen niet gedraaid worden.
+336 -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;
@@ -428,33 +341,28 @@ body { display: flex; flex-direction: column; }
color: var(--nav-link-color); cursor: pointer; width: 110px; color: var(--nav-link-color); cursor: pointer; width: 110px;
} }
.new-option svg { width: 40px; height: 40px; } .new-option svg { width: 40px; height: 40px; }
.new-option .bi { font-size: 36px; line-height: 40px; }
.new-option span { font-size: 13px; } .new-option span { font-size: 13px; }
.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; }
.new-crm { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-soft); max-width: 640px; }
.new-crm h3 { margin: 0 0 10px; font-size: 13px; color: var(--primary); }
.new-crm-q { width: 100%; box-sizing: border-box; font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; }
.new-crm-list { list-style: none; margin: 8px 0 0; padding: 0; max-height: 260px; overflow-y: auto; }
.new-crm-item { display: flex; gap: 10px; align-items: center; width: 100%; text-align: left; background: none; border: 0; border-radius: 6px; padding: 6px 8px; cursor: pointer; color: var(--text); font: inherit; }
.new-crm-item:hover, .new-crm-item:focus-visible { background: var(--primary-soft); color: var(--primary); }
.new-crm-item .bi { font-size: 18px; }
.new-crm-item strong { display: block; font-size: 13px; }
.new-crm-item small { display: block; font-size: 12px; color: var(--text-muted); }
.new-crm-empty { font-size: 12px; color: var(--text-muted); padding: 6px 8px; }
.doc-tab .bi-cloud { margin-right: 4px; color: var(--primary); }
#crm-toast { position: fixed; left: 50%; bottom: 24px; transform: translate(-50%, 20px); opacity: 0; pointer-events: none; z-index: 900;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; font-size: 13px;
box-shadow: 0 12px 30px rgba(33, 33, 33, .2); transition: opacity .2s, transform .2s; max-width: 90vw; }
#crm-toast.open { opacity: 1; transform: translate(-50%, 0); }
#crm-toast.ok { border-color: var(--ok); background: var(--ok-soft); }
#crm-toast.err { border-color: var(--danger); background: var(--danger-soft); }
#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; }
@@ -474,50 +382,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 {
@@ -573,7 +451,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; }
@@ -582,12 +459,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); }
@@ -605,9 +480,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; }
@@ -634,21 +506,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; }
@@ -670,72 +534,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); }
+231
View File
@@ -0,0 +1,231 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
/* Light-versie (modelpagina's). Alles onder .tectum-light, zodat de stijl van de site eromheen (Bootstrap) er niet
mee botst; koppen, labels en knoppen krijgen daarom expliciet marge, gewicht en regelhoogte. De indeling volgt de
breedte van de tool zelf (container queries), niet die van het scherm: in de site staat hij in een col-12. */
.tectum-light {
--tl-primary: #b95913;
--tl-primary-hover: #9c4a0f;
--tl-primary-soft: rgba(185, 89, 19, 0.10);
--tl-text: #212121;
--tl-muted: #5b6673;
--tl-faint: #8a94a0;
--tl-surface: #ffffff;
--tl-bg: #f9faf5;
--tl-border: #c5ced9;
--tl-border-soft: #e2e7ee;
--tl-radius: 10px;
container: tl / inline-size;
display: block;
width: 100%;
color: var(--tl-text);
font-size: 14px;
line-height: 1.4;
text-align: left;
}
.tectum-light *, .tectum-light *::before, .tectum-light *::after { box-sizing: border-box; }
.tectum-light button { font: inherit; color: inherit; line-height: 1.2; margin: 0; text-transform: none; }
.tectum-light h2, .tectum-light h3 { margin: 0; font-weight: 700; line-height: 1.2; }
.tectum-light label { display: block; margin: 0; }
.tectum-light small { font-size: 11px; color: var(--tl-faint); font-weight: 400; }
.tectum-light .tl-wrap {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
height: clamp(560px, 80vh, 800px);
border: 1px solid var(--tl-border-soft);
border-radius: var(--tl-radius);
overflow: hidden;
background: var(--tl-surface);
}
/* 3D / plattegrond */
.tectum-light .tl-stage { position: relative; min-width: 0; min-height: 0; background: #dfe6ec; }
.tectum-light .tl-3d { position: absolute; inset: 0; }
.tectum-light .tl-3d canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.tectum-light .tl-plan { position: absolute; inset: 0; width: 100%; height: 100%; display: none; background: #fff; }
.tectum-light.view-plan .tl-plan { display: block; }
.tectum-light.view-plan .tl-3d { visibility: hidden; }
.tectum-light .tl-viewbar, .tectum-light .tl-cutbar, .tectum-light .tl-busy, .tectum-light .tl-hint {
position: absolute; background: rgba(255, 255, 255, 0.94); border-radius: 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.tectum-light .tl-viewbar { top: 10px; left: 10px; display: flex; gap: 2px; padding: 3px; }
.tectum-light .tl-viewbar button {
display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 6px 10px;
border-radius: 6px; cursor: pointer; color: var(--tl-muted); white-space: nowrap;
}
.tectum-light .tl-viewbar button:hover { color: var(--tl-text); }
.tectum-light .tl-viewbar button.on { background: var(--tl-primary-soft); color: var(--tl-primary); font-weight: 600; }
.tectum-light .tl-cutbar { top: 52px; left: 10px; display: none; align-items: center; gap: 10px; padding: 6px 12px; font-size: 13px; }
.tectum-light.view-cut .tl-cutbar { display: flex; }
.tectum-light .tl-cutbar input { width: 150px; accent-color: var(--tl-primary); }
.tectum-light .tl-cutbar output { min-width: 4.2em; font-weight: 600; font-variant-numeric: tabular-nums; }
/* Rechtsboven: AR (telefoon/tablet) of schermvullend (desktop). */
.tectum-light .tl-xbtn {
position: absolute; top: 10px; right: 10px; width: 35px; height: 35px; padding: 0; border: 0; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer;
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); color: var(--tl-text);
}
.tectum-light .tl-xbtn:hover { color: var(--tl-primary); }
.tectum-light .tl-xbtn[hidden] { display: none; }
.tectum-light.has-xbtn .tl-busy { right: 53px; }
.tectum-light .tl-hint { left: 10px; bottom: 10px; font-size: 12px; color: var(--tl-muted); padding: 3px 8px; box-shadow: none; pointer-events: none; }
.tectum-light .tl-busy { top: 10px; right: 10px; display: none; align-items: center; gap: 8px; padding: 6px 12px; }
.tectum-light.is-busy .tl-busy { display: inline-flex; }
.tectum-light.is-busy .tl-panel { pointer-events: none; }
.tectum-light .tl-spinner {
width: 14px; height: 14px; border: 2px solid var(--tl-border); border-top-color: var(--tl-primary);
border-radius: 50%; animation: tl-spin 0.8s linear infinite;
}
@keyframes tl-spin { to { transform: rotate(360deg); } }
.tectum-light .tl-toast {
position: absolute; left: 50%; bottom: 42px; transform: translate(-50%, 8px); width: max-content; max-width: calc(100% - 24px);
background: #2b2f33; color: #fff; padding: 9px 14px; border-radius: 8px; font-size: 13px;
opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none;
}
.tectum-light .tl-toast.on { opacity: 1; transform: translate(-50%, 0); }
/* Paneel */
.tectum-light .tl-panel { display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--tl-border-soft); background: var(--tl-surface); }
.tectum-light .tl-head { padding: 14px 16px 10px; }
.tectum-light .tl-title { font-size: 18px; }
.tectum-light .tl-sub { margin-top: 3px; color: var(--tl-muted); font-size: 12px; }
.tectum-light .tl-sections { flex: 1; min-height: 0; overflow-y: auto; border-top: 1px solid var(--tl-border-soft); }
/* Accordeon */
.tectum-light .tl-sec { border-bottom: 1px solid var(--tl-border-soft); }
.tectum-light .tl-sec-head {
display: flex; align-items: center; gap: 9px; width: 100%; padding: 11px 16px; border: 0; background: none; cursor: pointer; text-align: left;
}
.tectum-light .tl-sec-head:hover { background: var(--tl-bg); }
.tectum-light .tl-sec-head > .bi:first-child { color: var(--tl-primary); }
.tectum-light .tl-sec-title { font-weight: 600; }
.tectum-light .tl-sec-sum { flex: 1; min-width: 0; text-align: right; font-size: 12px; color: var(--tl-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tectum-light .tl-chev { font-size: 12px; color: var(--tl-faint); transition: transform 0.15s; }
.tectum-light .tl-sec.open .tl-chev { transform: rotate(180deg); }
.tectum-light .tl-sec.open .tl-sec-sum { visibility: hidden; }
.tectum-light .tl-sec-body { padding: 0 16px 12px; }
/* Velden */
.tectum-light .tl-field { margin: 8px 0; }
.tectum-light .tl-label { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--tl-muted); margin-bottom: 4px; }
.tectum-light .tl-val { color: var(--tl-text); font-weight: 600; text-align: right; }
.tectum-light .tl-note { font-size: 11px; color: var(--tl-faint); }
.tectum-light .tl-seg { display: flex; flex-wrap: wrap; gap: 4px; }
.tectum-light .tl-seg button {
flex: 1 1 auto; border: 1px solid var(--tl-border); background: var(--tl-surface); border-radius: 7px;
padding: 6px 8px; cursor: pointer; white-space: nowrap; font-size: 13px;
}
.tectum-light .tl-seg button:hover:not(:disabled) { border-color: var(--tl-primary); }
.tectum-light .tl-seg button.on { border-color: var(--tl-primary); background: var(--tl-primary-soft); color: var(--tl-primary); font-weight: 600; }
.tectum-light .tl-units .tl-seg button { flex: 0 0 auto; min-width: 40px; }
.tectum-light button:disabled { opacity: 0.4; cursor: not-allowed; }
.tectum-light button:focus-visible, .tectum-light input:focus-visible, .tectum-light .tl-map-u:focus-visible { outline: 2px solid var(--tl-primary); outline-offset: 2px; }
.tectum-light .tl-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
.tectum-light .tl-sw {
width: 30px; height: 30px; padding: 0; border-radius: 50%; border: 1px solid rgba(0, 0, 0, 0.18);
background: var(--sw); cursor: pointer; overflow: hidden; box-shadow: 0 0 0 2px var(--tl-surface);
}
.tectum-light .tl-sw img { width: 100%; height: 100%; object-fit: cover; display: block; }
.tectum-light .tl-sw.on { box-shadow: 0 0 0 2px var(--tl-surface), 0 0 0 4px var(--tl-primary); }
.tectum-light .tl-slider { margin: 8px 0; }
.tectum-light .tl-sl-head { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; }
.tectum-light .tl-sl-head output { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
.tectum-light .tl-slider input { display: block; width: 100%; margin: 4px 0 0; accent-color: var(--tl-primary); }
.tectum-light .tl-mapbox { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
.tectum-light .tl-map { display: block; flex: 1; min-width: 0; max-height: 96px; }
.tectum-light .tl-map-u { cursor: pointer; outline: none; }
.tectum-light .tl-map-u rect { fill: var(--tl-bg); stroke: var(--tl-border); stroke-width: 40; }
.tectum-light .tl-map-u text { fill: var(--tl-muted); font-weight: 600; pointer-events: none; }
.tectum-light .tl-map-u:hover rect { stroke: var(--tl-primary); }
.tectum-light .tl-map-u.on rect { fill: var(--tl-primary-soft); stroke: var(--tl-primary); stroke-width: 60; }
.tectum-light .tl-map-u.on text { fill: var(--tl-primary); }
/* Kerncijfers en prijs */
.tectum-light .tl-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 14px; padding: 10px 16px; font-size: 12px; border-top: 1px solid var(--tl-border-soft); background: var(--tl-bg); }
.tectum-light .tl-kv { display: flex; justify-content: space-between; gap: 6px; }
.tectum-light .tl-kv span { color: var(--tl-muted); }
.tectum-light .tl-kv b { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
.tectum-light .tl-buy { display: flex; align-items: center; gap: 8px; padding: 10px 16px 12px; background: var(--tl-bg); }
.tectum-light .tl-price { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tectum-light .tl-price-val { font-size: 21px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
.tectum-light .tl-price-note { font-size: 11px; color: var(--tl-muted); }
.tectum-light .tl-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 40px; padding: 0 14px;
border: 1px solid var(--tl-border); border-radius: 8px; background: var(--tl-surface); cursor: pointer;
}
.tectum-light .tl-btn:hover { border-color: var(--tl-primary); }
.tectum-light .tl-btn.tl-icon { width: 40px; padding: 0; }
.tectum-light .tl-btn.tl-primary { background: var(--tl-primary); border-color: var(--tl-primary); color: #fff; font-weight: 600; }
.tectum-light .tl-btn.tl-primary:hover { background: var(--tl-primary-hover); }
/* Middel (bijv. tablet, of een smallere kolom): paneel iets smaller. */
@container tl (max-width: 980px) {
.tectum-light .tl-wrap { grid-template-columns: minmax(0, 1fr) 300px; }
.tectum-light .tl-viewbar button { padding: 6px 8px; }
}
/* Smal (telefoon): 3D boven, keuzes eronder; prijs en PDF blijven onderin beeld zolang de keuzes in beeld zijn. */
@container tl (max-width: 720px) {
.tectum-light .tl-wrap { grid-template-columns: 1fr; height: auto; overflow: visible; }
.tectum-light .tl-stage { height: min(62vh, 115cqw); border-radius: var(--tl-radius) var(--tl-radius) 0 0; overflow: hidden; }
.tectum-light .tl-panel { border-left: 0; border-top: 1px solid var(--tl-border-soft); border-radius: 0 0 var(--tl-radius) var(--tl-radius); }
.tectum-light .tl-sections { overflow: visible; }
.tectum-light .tl-buy { position: sticky; bottom: 0; z-index: 2; border-top: 1px solid var(--tl-border-soft); box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06); border-radius: 0 0 var(--tl-radius) var(--tl-radius); }
.tectum-light .tl-hint { display: none; }
.tectum-light .tl-viewbar { left: 8px; right: 8px; top: 8px; }
.tectum-light .tl-viewbar button { flex: 1; justify-content: center; }
.tectum-light.has-xbtn .tl-viewbar { right: 51px; }
.tectum-light .tl-xbtn { top: 8px; right: 8px; width: 37px; height: 37px; }
.tectum-light .tl-cutbar { left: 8px; right: 8px; top: 50px; }
.tectum-light .tl-cutbar input { flex: 1; width: auto; }
.tectum-light .tl-busy { top: auto; bottom: 10px; right: 10px; }
/* Grotere tikvlakken */
.tectum-light .tl-seg button { padding: 9px 8px; }
.tectum-light .tl-sw { width: 36px; height: 36px; }
.tectum-light .tl-sec-head { padding: 13px 16px; }
.tectum-light .tl-slider input { height: 28px; }
}
@container tl (max-width: 380px) {
.tectum-light .tl-viewbar button .bi { display: none; }
.tectum-light .tl-facts { grid-template-columns: 1fr; }
}
/* AR-weergave (model-viewer) over de hele pagina. Staat buiten .tectum-light: die is een container en zou het kader van
position: fixed worden. Kleur van de knop = --tl-primary. */
html.tl-noscroll, html.tl-noscroll body { overflow: hidden; }
.tl-arview {
position: fixed; inset: 0; z-index: 2147483000; display: none; background: #dfe6ec; color: #212121;
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.tl-arview.on { display: block; }
.tl-arview *, .tl-arview *::before, .tl-arview *::after { box-sizing: border-box; }
.tl-arview model-viewer { display: block; width: 100%; height: 100%; background: #dfe6ec; --poster-color: transparent; }
.tl-arview .tl-ar-top {
position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: max(10px, env(safe-area-inset-top)) 12px 10px 16px; pointer-events: none;
}
.tl-arview .tl-ar-title { font-size: 17px; font-weight: 700; }
.tl-arview .tl-ar-close {
pointer-events: auto; width: 44px; height: 44px; margin: 0; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; font-size: 18px; color: inherit;
background: rgba(255, 255, 255, 0.94); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.tl-arview .tl-ar-go {
position: absolute; left: 50%; bottom: calc(44px + env(safe-area-inset-bottom)); transform: translateX(-50%);
display: inline-flex; align-items: center; gap: 9px; height: 50px; margin: 0; padding: 0 24px; border: 0; border-radius: 25px;
background: #b95913; color: #fff; font-family: inherit; font-size: 16px; font-weight: 600; line-height: 1; white-space: nowrap; cursor: pointer; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
}
.tl-arview .tl-ar-go .bi { font-size: 22px; }
.tl-arview .tl-ar-note {
position: absolute; left: 16px; right: 16px; bottom: calc(14px + env(safe-area-inset-bottom));
text-align: center; font-size: 12px; color: #5b6673; pointer-events: none;
}
+29 -20
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">
@@ -42,19 +50,15 @@
<div class="license-box" id="about-license"></div> <div class="license-box" id="about-license"></div>
<div class="about-footer"> <div class="about-footer">
<span>&copy; 2026 Machiel Heinen &middot; ontwikkeld in opdracht van Van Emous Mantelzorgwoningen</span> <span>&copy; 2026 Machiel Heinen &middot; ontwikkeld in opdracht van Van Emous Mantelzorgwoningen</span>
<button id="about-close" class="about-close">Sluiten</button> <button id="about-close" class="about-close" data-esc>Sluiten</button>
</div> </div>
</div> </div>
</div> </div>
<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="amstel">
<svg viewBox="0 0 48 48"><rect x="6" y="10" width="36" height="14" fill="none" stroke="currentColor" stroke-width="2"/><path d="M6 24 L6 38 L38 38 L38 24" fill="none" stroke="currentColor" stroke-width="2"/><rect x="38" y="24" width="4" height="14" fill="none" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/><line x1="22" y1="24" x2="22" y2="38" stroke="currentColor" stroke-width="1"/></svg>
<span>Woning Amstel</span>
</button>
<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>
<span>Woning</span> <span>Woning</span>
@@ -71,36 +75,37 @@
<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 class="new-crm" hidden></div>
</div> </div>
</div> </div>
<div id="tab-bar"></div>
<div id="workspace"></div> <div id="workspace"></div>
<script src="js/core/license.js"></script> <script src="js/core/license.js"></script>
<script src="js/core/logo-data.js"></script> <script src="js/core/logo-data.js"></script>
<script src="js/core/geometry.js"></script> <script src="js/core/geometry.js"></script>
<script src="js/core/build-stages.js"></script> <script src="js/core/view-layers.js"></script>
<script src="js/core/canvas-labels.js"></script> <script src="js/core/canvas-labels.js"></script>
<script src="js/core/camera-memory.js"></script> <script src="js/core/camera-memory.js"></script>
<script src="js/core/walk-controls.js"></script> <script src="js/core/walk-controls.js"></script>
<script src="js/core/view-filter.js"></script>
<script src="js/core/ui-kit.js"></script> <script src="js/core/ui-kit.js"></script>
<script src="js/core/ribbon.js"></script> <script src="js/core/menubar.js"></script>
<script src="js/core/inspector.js"></script>
<script src="js/core/skybox.js"></script> <script src="js/core/skybox.js"></script>
<script src="js/vendor/jspdf.umd.min.js"></script> <script src="js/vendor/jspdf.umd.min.js"></script>
<script src="js/core/pdf-sheet.js"></script> <script src="js/core/pdf-sheet.js"></script>
<script src="js/core/dxf-writer.js"></script>
<script src="js/core/ifc-writer.js"></script>
<script src="js/core/zip-writer.js"></script>
<script src="js/core/obj-writer.js"></script>
<script src="js/wall/kozijn-model.js"></script> <script src="js/wall/kozijn-model.js"></script>
<script src="js/wall/wall-model.js"></script> <script src="js/wall/wall-model.js"></script>
<script src="js/wall/wall-pdf.js"></script> <script src="js/wall/wall-pdf.js"></script>
<script src="js/floor/floor-model.js"></script> <script src="js/floor/floor-model.js"></script>
<script src="js/floor/floor-pdf.js"></script> <script src="js/floor/floor-pdf.js"></script>
<script src="js/roof/roof-model.js"></script> <script src="js/roof/roof-model.js"></script>
<script src="js/roof/post-model.js"></script>
<script src="js/lighting/lighting-model.js"></script> <script src="js/lighting/lighting-model.js"></script>
<script src="js/elektra/elektra-model.js"></script> <script src="js/elektra/elektra-model.js"></script>
<script src="js/sandwich/sandwich-model.js"></script> <script src="js/sandwich/sandwich-model.js"></script>
@@ -109,6 +114,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>
@@ -116,7 +122,10 @@
<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/crm.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
</body> </body>
</html> </html>
+175 -87
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.0'; const APP_VERSION = '0.7.9';
// 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,132 @@ 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') && !activeCrm() },
{ label: 'Opslaan in CRM (nieuwe versie)', icon: 'cloud-upload', keys: 'Ctrl+S', run: saveToCrm, enabled: () => !!activeCrm() },
{ label: 'Downloaden (JSON + PDF)', icon: 'download', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') && !!activeCrm() },
{ sep: true }, { head: 'Exporteren' },
{ label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') },
{ label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') },
{ label: 'Render (OBJ)', icon: 'badge-3d', run: () => editorCall('exportObj'), enabled: () => editorHas('exportObj') },
{ sep: true },
{ label: 'Projectgegevens…', icon: 'card-text', run: () => editorCall('openProjectProperties'), enabled: () => editorHas('openProjectProperties') },
{ label: 'Kosten & tijden…', icon: 'calculator', run: () => CostSettings.open() },
{ sep: true },
{ label: 'Ontwerp sluiten', icon: 'x-lg', run: () => activeTabId !== null && closeTab(activeTabId), enabled: () => activeTabId !== null }
] },
{ label: 'Beeld', items: [
...UI.VIEWS.map(v => ({ label: v.label, icon: v.icon, keys: v.key1, run: () => editorCall('setView', v.key),
enabled: () => editorHas('setView'), checked: () => viewState().view === v.key })),
{ label: 'Rapport', icon: 'clipboard-data', keys: '4', run: () => editorCall('toggleReport'), enabled: () => editorHas('toggleReport'), checked: () => !!viewState().report },
{ sep: true },
{ label: 'Doorsnede', icon: 'scissors', keys: 'X', run: () => editorCall('toggleSection'), enabled: () => editorHas('toggleSection'), checked: () => !!viewState().section },
{ label: 'Plattegrond passend', icon: 'aspect-ratio', keys: 'F', run: () => editorCall('fitView'), enabled: () => editorHas('fitView') },
{ label: 'Lopen', icon: 'person-walking', keys: 'L', run: () => editorCall('toggleWalk'), enabled: () => editorHas('toggleWalk'), checked: () => !!viewState().walk },
{ sep: true },
{ label: 'Panelen verbergen', icon: 'layout-sidebar-inset', keys: 'Ctrl+\\', run: togglePanels, checked: () => document.body.classList.contains('panels-hidden') },
{ label: 'Volledig scherm', icon: 'fullscreen', keys: 'F11', run: toggleBrowserFullscreen, checked: () => !!document.fullscreenElement }
] },
{ label: 'Help', items: [
{ label: 'Sneltoetsen en bediening', icon: 'keyboard', keys: '?', run: openKeys },
{ label: 'Over Tectum', icon: 'info-circle', run: () => document.getElementById('about-overlay').classList.add('open') }
] }
];
const KEY_HELP = [
['Bestand', [['Ctrl+N', 'Nieuw ontwerp (ook uit de CRM)'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF; woning uit de CRM: nieuwe versie in de CRM)']]],
['Gereedschap (woning)', [['V', 'Selecteren'], ['W', 'Binnenwand tekenen'], ['R', 'Ruimte vullen'], ['I', 'Inrichting plaatsen'],
['Esc', 'Gereedschap stoppen, daarna selectie opheffen'], ['Del', 'Selectie verwijderen'], ['D / Shift+D', 'Inrichting draaien'], ['Dubbelklik', 'Onderdeel bewerken (wand, vloer)']]],
['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'],
['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]],
['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]],
['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts of middelste knop slepen', 'Zijwaarts verschuiven'], ['Shift/Ctrl + slepen', 'Zijwaarts verschuiven'],
['Pijltjestoetsen (muis boven de 3D)', 'Verschuiven (Shift = grotere stap)'], ['Scrollwiel', 'Zoomen']], true],
['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true],
['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]],
['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true]
];
function openKeys() {
// Muisacties als tekst, toetsen als <kbd>.
document.getElementById('keys-body').innerHTML = KEY_HELP.map(([title, rows, mouse]) =>
`<section><h3>${title}</h3>${rows.map(([k, d]) => `<div class="keys-row"><span>${mouse ? `<span class="keys-mouse">${k}</span>`
: k.split(' / ').map(x => `<kbd>${x}</kbd>`).join(' / ')}</span><span>${d}</span></div>`).join('')}</section>`).join('');
document.getElementById('keys-overlay').classList.add('open');
} }
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:
small('Woning Amstel', 'house-heart', 'data-app="new-kind" data-kind="woning" data-template="amstel"') +
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> ${tab.crm ? '<i class="bi bi-cloud" title="Woning uit de CRM"></i>' : ''}<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('');
const save = document.getElementById('top-save');
save.disabled = !editorHas('exportDesign');
save.querySelector('span').textContent = activeCrm() ? 'Opslaan in CRM' : 'Opslaan';
save.title = activeCrm() ? 'Opslaan als nieuwe versie in de CRM (Ctrl+S)' : 'Opslaan als JSON + PDF (Ctrl+S)';
}
// ---- CRM (js/crm.js): woning uit de CRM in een tab (tab.crm = { id, versie, titel }), terug als nieuwe versie
const activeCrm = () => { const t = tabs.find(x => x.id === activeTabId); return t && t.crm ? t : null; };
const crmName = (crm) => `${crm.titel} · v${crm.versie}`;
function saveActive() {
if (activeCrm()) saveToCrm(); else editorCall('exportDesign');
}
async function openCrmWoning(id) {
try {
const { woning, state } = await TectumCrm.load(id);
const open = tabs.find(t => t.crm && t.crm.id === woning.id);
if (open && !window.confirm(`${woning.titel} is al geopend. Nog een keer openen (versie ${woning.versie} uit de CRM)?`)) {
switchToTab(open.id);
return;
}
const crm = { id: woning.id, versie: woning.versie, titel: woning.titel };
openNew('woning', null, { stateJson: state, name: crmName(crm), crm });
TectumCrm.toast(`${woning.titel} geladen uit de CRM (versie ${woning.versie}${woning.contact ? ', ' + woning.contact.naam : ''}).`, 'ok');
} catch (err) {
TectumCrm.toast('Laden uit de CRM mislukt: ' + err.message, 'err');
}
}
async function saveToCrm() {
const tab = activeCrm();
if (!tab || !currentEditor) return;
const note = window.prompt(`Opslaan als nieuwe versie van ${tab.crm.titel} in de CRM.\nWat is er veranderd?`, '');
if (note === null) return;
const state = currentEditor.getStateJson();
try {
const versie = await TectumCrm.saveVersion(tab.crm.id, state, tab.crm.versie, note.trim());
if (versie === null) return;
tab.crm.versie = versie;
tab.baseJson = JSON.stringify(state);
tab.name = crmName(tab.crm);
renderTabBar();
TectumCrm.toast(`Opgeslagen in de CRM als versie ${versie}.`, 'ok');
} catch (err) {
TectumCrm.toast('Opslaan in de CRM mislukt: ' + err.message, 'err');
}
} }
function onTabSaved(tab, filenameWithExt) { function onTabSaved(tab, filenameWithExt) {
@@ -102,7 +166,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,11 +183,10 @@ function switchToTab(tabId) {
activeTabId = tabId; activeTabId = tabId;
mountEditorForTab(tabs.find(t => t.id === tabId)); mountEditorForTab(tabs.find(t => t.id === tabId));
updateEditMenuState();
renderTabBar(); renderTabBar();
} }
function openNew(kind, template) { function openNew(kind, template, opts) {
if (!editorModuleFor(kind)) return; if (!editorModuleFor(kind)) return;
const oldTab = tabs.find(t => t.id === activeTabId); const oldTab = tabs.find(t => t.id === activeTabId);
@@ -134,11 +196,13 @@ function openNew(kind, template) {
if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount(); if (currentEditor && typeof currentEditor.unmount === 'function') currentEditor.unmount();
currentEditor = null; currentEditor = null;
const tab = { id: tabIdCounter++, kind, template: template || null, name: defaultNameFor(kind), savedName: null, stateJson: null }; const tab = { id: tabIdCounter++, kind, template: template || null, name: (opts && opts.name) || defaultNameFor(kind), savedName: null,
stateJson: (opts && opts.stateJson) || null, crm: (opts && opts.crm) || null };
tabs.push(tab); tabs.push(tab);
activeTabId = tab.id; activeTabId = tab.id;
mountEditorForTab(tab); mountEditorForTab(tab);
updateEditMenuState(); // geladen ontwerp (CRM): wijzigingen tellen vanaf hier, niet vanaf het standaardmodel
if (tab.stateJson && currentEditor && typeof currentEditor.getStateJson === 'function') tab.baseJson = JSON.stringify(currentEditor.getStateJson());
renderTabBar(); renderTabBar();
} }
@@ -149,6 +213,7 @@ function isTabDirty(tab) {
? currentEditor.getStateJson() ? currentEditor.getStateJson()
: tab.stateJson; : tab.stateJson;
if (!state) return false; if (!state) return false;
if (tab.baseJson) return JSON.stringify(state) !== tab.baseJson;
return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template)); return JSON.stringify(state) !== JSON.stringify(module.getDefaultState(tab.template));
} }
@@ -165,7 +230,9 @@ function closeTab(tabId) {
switchToTab(tabId); switchToTab(tabId);
} }
if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') { if (tab.crm && isTabDirty(tab)) {
if (!window.confirm(`De wijzigingen aan ${tab.crm.titel} staan nog niet in de CRM (Opslaan in CRM). Toch sluiten?`)) return;
} else if (isTabDirty(tab) && currentEditor && typeof currentEditor.exportDesign === 'function') {
const saved = currentEditor.exportDesign(); const saved = currentEditor.exportDesign();
if (!saved) return; if (!saved) return;
} }
@@ -179,17 +246,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));
}); });
} }
@@ -213,6 +278,8 @@ function wireAbout() {
function wireNewModal() { function wireNewModal() {
const overlay = document.getElementById('new-overlay'); const overlay = document.getElementById('new-overlay');
overlay.querySelector('.new-options').insertAdjacentHTML('afterbegin', Object.entries(HouseModel.MODELS).map(([key, m]) =>
`<button class="new-option" data-kind="woning" data-template="${key}"><i class="bi bi-${m.icon}"></i><span>${m.label}</span></button>`).join(''));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => { document.querySelectorAll('.new-option:not(.disabled)').forEach(opt => {
opt.addEventListener('click', () => { opt.addEventListener('click', () => {
@@ -220,10 +287,16 @@ function wireNewModal() {
overlay.classList.remove('open'); overlay.classList.remove('open');
}); });
}); });
newCrmPicker = TectumCrm.mountPicker(overlay.querySelector('.new-crm'), (id) => {
overlay.classList.remove('open');
openCrmWoning(id);
});
} }
let newCrmPicker = null;
function openNewModal() { function openNewModal() {
document.getElementById('new-overlay').classList.add('open'); document.getElementById('new-overlay').classList.add('open');
if (newCrmPicker) newCrmPicker.refresh();
} }
function toggleBrowserFullscreen() { function toggleBrowserFullscreen() {
@@ -231,41 +304,54 @@ function toggleBrowserFullscreen() {
else document.documentElement.requestFullscreen(); else document.documentElement.requestFullscreen();
} }
// Alle dialogen; Esc gebruikt hun Annuleren-knop (data-esc), zodat bijv. projectgegevens worden teruggezet.
const OVERLAYS = '.dlg-overlay.open, #about-overlay.open, #new-overlay.open, #house-project-overlay.open, #wall-project-overlay.open, #cost-settings-overlay.open';
function closeOverlays() {
const open = document.querySelector(OVERLAYS);
if (open) {
const cancel = open.querySelector('[data-esc]');
if (cancel) cancel.click(); else open.classList.remove('open');
return true;
}
return Menubar.close() || UI.closePopovers();
}
function wireHotkeys() { 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') { // Dialoog open: alleen Esc (sluiten); geen sneltoetsen naar de editor erachter. Typen in de dialoog blijft werken.
e.preventDefault(); if (document.querySelector(OVERLAYS)) {
if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign(); if (e.key === 'Escape') closeOverlays();
e.stopImmediatePropagation();
return; return;
} }
if (e.ctrlKey && e.key.toLowerCase() === 'n') { if (e.ctrlKey || e.metaKey) {
e.preventDefault(); const act = { s: saveActive, n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k];
openNewModal(); if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); }
return; return;
} }
if (inField || e.ctrlKey || e.metaKey || e.altKey) return; if (e.key === 'Escape' && closeOverlays()) { e.stopImmediatePropagation(); return; }
if (inField || e.altKey || viewState().walk && !['l', 'escape'].includes(k)) return;
if (e.key.toLowerCase() === 'f') { const view = UI.VIEWS.find(v => v.key1 === e.key);
toggleBrowserFullscreen(); if (view) editorCall('setView', view.key);
} else if (e.key.toLowerCase() === 'h') { else if (e.key === '4') editorCall('toggleReport');
if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal(); else if (k === 'x') editorCall('toggleSection');
} else if (e.key.toLowerCase() === 't' && e.shiftKey) { else if (k === 'f') editorCall('fitView');
if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk(); else if (k === 'l') editorCall('toggleWalk');
} else if (e.key.toLowerCase() === 't') { else if (k === 'h' && e.shiftKey) editorCall('mirrorHorizontal');
if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D(); else if (e.key === '?') openKeys();
} else if (e.key.toLowerCase() === 'r') { }, true);
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', saveActive);
document.getElementById('keys-overlay').addEventListener('click', (e) => {
if (e.target.id === 'keys-overlay' || e.target.closest('[data-close]')) document.getElementById('keys-overlay').classList.remove('open');
});
wireAbout(); wireAbout();
wireNewModal(); wireNewModal();
wireHotkeys(); wireHotkeys();
@@ -276,7 +362,9 @@ document.addEventListener('DOMContentLoaded', () => {
note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>'; note.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>Tectum is geopend als bestand. Start het via een webserver: <code>./start.sh</code> of <code>python3 -m http.server</code>, en open <code>http://localhost:8000</code>. De omgeving (Dag/Nacht) werkt anders niet.</span>';
document.body.appendChild(note); document.body.appendChild(note);
} }
openNew('woning', 'amstel'); // ?woning=<id>: die woning uit de CRM openen (knop "Bewerken in Tectum" in de CRM), anders het standaardmodel
const crmId = Number(new URLSearchParams(location.search).get('woning'));
if (crmId) openCrmWoning(crmId); else openNew('woning', 'amstel');
setTimeout(() => { setTimeout(() => {
const splash = document.getElementById('splash-screen'); const splash = document.getElementById('splash-screen');
-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 };
})();
+11
View File
@@ -6,6 +6,17 @@
*/ */
// Plain script, geen ES-module: imports breken bij file:// (CORS). // Plain script, geen ES-module: imports breken bij file:// (CORS).
// Map van de engine (met assets/ en kosten/), afgeleid van de plek van dit script. Texturen, kleurstalen en de
// kosten-CSV's laden via tectumUrl(), zodat ze ook werken als de engine in een andere pagina staat (light-versie op
// een modelpagina van de site). Paden in de data blijven relatief.
const TECTUM_BASE = (() => {
const s = typeof document !== 'undefined' && document.currentScript && document.currentScript.src;
return s ? new URL('../../', s).href : '';
})();
function tectumUrl(path) {
return path && !/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(path) ? TECTUM_BASE + path : path;
}
function calculateSegmentPositions(spanWidth, minSp, maxSp) { function calculateSegmentPositions(spanWidth, minSp, maxSp) {
if (spanWidth <= 0) return []; if (spanWidth <= 0) return [];
let numBays = Math.ceil(spanWidth / maxSp); let numBays = Math.ceil(spanWidth / maxSp);
+253
View File
@@ -0,0 +1,253 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// glTF 2.0 binair (GLB) uit three.js-objecten, in meters, Y omhoog; bedoeld voor AR (model-viewer: WebXR, Scene Viewer,
// Quick Look) en andere viewers. Alles in wereldcoördinaten samengevoegd tot één mesh met één primitive per materiaal
// (weinig draw calls op een telefoon). Texturen: herhaling/verschuiving (texture.matrix) zit in de UV's, beelden als
// JPEG (of PNG bij doorzichtig), maximaal MAX_TEX px. Lichteffecten (bundels, vegen: onbelicht en doorschijnend zonder
// dieptebuffer, of additief) blijven weg; die horen bij de eigen belichting, niet in een model.
window.GlbWriter = (() => {
const MAX_TEX = 1024;
const lin = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
const WRAP = { 1000: 10497, 1001: 33071, 1002: 33648 }; // three Repeat/Clamp/Mirrored -> glTF
const isEffect = (m) => m.blending !== THREE.NormalBlending || (m.isMeshBasicMaterial && m.transparent && m.depthWrite === false);
// Beeld als JPEG/PNG-bytes, afmetingen een macht van 2 (mipmaps en herhalen ook op oudere toestellen), hoogstens
// MAX_TEX; null als het (nog) niet te tekenen is. UV's zijn 0..1, dus het rekken verandert niets aan het beeld.
const pot = (n) => Math.min(MAX_TEX, Math.pow(2, Math.max(0, Math.round(Math.log2(n)))));
function imageBytes(image, png) {
try {
const w0 = image.naturalWidth || image.videoWidth || image.width, h0 = image.naturalHeight || image.videoHeight || image.height;
if (!w0 || !h0) return null;
const c = document.createElement('canvas');
c.width = pot(w0); c.height = pot(h0);
c.getContext('2d').drawImage(image, 0, 0, c.width, c.height);
const bin = atob(c.toDataURL(png ? 'image/png' : 'image/jpeg', 0.85).split(',')[1]);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return { data: out, mime: png ? 'image/png' : 'image/jpeg' };
} catch (e) { return null; }
}
// objects: three.js-objecten (zichtbaar deel); origin: verschuiving in meters; clipBelow: alles onder deze hoogte (m, na
// de verschuiving) valt weg, driehoeken die de snede kruisen worden afgesneden (AR zet de onderkant van het model op de
// grond: met clipBelow 0 op het maaiveld valt die samen met de oorsprong). Geeft { blob, stats }.
function build(objects, opts) {
const o = opts || {};
const origin = o.origin || { x: 0, y: 0, z: 0 };
const clip = typeof o.clipBelow === 'number', clipY = o.clipBelow, EPS = 1e-6;
const originV = new THREE.Vector3(origin.x, origin.y, origin.z);
const buckets = new Map(); // materiaal -> { mat, tex, pos, nrm, uv, idx, n }
const images = new Map(), samplers = new Map(), textures = new Map();
const gltfImages = [], gltfSamplers = [], gltfTextures = [];
const bin = [];
let binLength = 0, triCount = 0;
function addView(data, target) {
const pad = (4 - binLength % 4) % 4;
if (pad) { bin.push(new Uint8Array(pad)); binLength += pad; }
const view = { buffer: 0, byteOffset: binLength, byteLength: data.byteLength };
if (target) view.target = target;
bin.push(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
binLength += data.byteLength;
return view;
}
const views = [];
function textureIndex(tex, png) {
if (!tex || !tex.image) return null;
if (!images.has(tex.image)) {
const img = imageBytes(tex.image, png);
if (img) { views.push(addView(img.data)); gltfImages.push({ bufferView: views.length - 1, mimeType: img.mime }); }
images.set(tex.image, img ? gltfImages.length - 1 : null);
}
const source = images.get(tex.image);
if (source === null) return null;
const sKey = `${tex.wrapS}/${tex.wrapT}`;
if (!samplers.has(sKey)) {
gltfSamplers.push({ magFilter: 9729, minFilter: 9987, wrapS: WRAP[tex.wrapS] || 10497, wrapT: WRAP[tex.wrapT] || 10497 });
samplers.set(sKey, gltfSamplers.length - 1);
}
const tKey = `${source}/${samplers.get(sKey)}`;
if (!textures.has(tKey)) { gltfTextures.push({ source, sampler: samplers.get(sKey) }); textures.set(tKey, gltfTextures.length - 1); }
return textures.get(tKey);
}
function bucketOf(m) {
if (!buckets.has(m)) {
const map = m.map && m.map.image ? m.map : null;
const tex = map ? textureIndex(map, !!m.transparent) : null;
buckets.set(m, { mat: m, map: tex !== null ? map : null, tex, pos: [], nrm: [], uv: [], idx: [], n: 0 });
}
return buckets.get(m);
}
const v = new THREE.Vector3(), nm = new THREE.Matrix3(), uv = new THREE.Vector2();
const pa = new THREE.Vector3(), pb = new THREE.Vector3(), pc = new THREE.Vector3(), face = new THREE.Vector3();
const visit = (root) => root && (root.updateMatrixWorld(true), root.traverseVisible(mesh => {
if (!mesh.isMesh || !mesh.geometry || !mesh.geometry.attributes.position) return;
let g = mesh.geometry;
if (!g.attributes.normal) { g = g.clone(); g.computeVertexNormals(); }
const pos = g.attributes.position, nrm = g.attributes.normal, uvA = g.attributes.uv, idx = g.index;
nm.getNormalMatrix(mesh.matrixWorld);
const flip = mesh.matrixWorld.determinant() < 0;
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const groups = g.groups && g.groups.length ? g.groups : [{ start: 0, count: idx ? idx.count : pos.count, materialIndex: 0 }];
const remaps = new Map(); // bucket -> bronhoekpunt -> index in de bucket
groups.forEach(gr => {
const m = list[gr.materialIndex] || list[0];
if (!m || m.visible === false || isEffect(m)) return;
const b = bucketOf(m);
if (!remaps.has(b)) remaps.set(b, new Int32Array(pos.count).fill(-1));
const remap = remaps.get(b);
if (b.map) b.map.updateMatrix();
const add = (ci) => {
if (remap[ci] >= 0) return remap[ci];
v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld);
b.pos.push(v.x - origin.x, v.y - origin.y, v.z - origin.z);
// Een lege normaal (bron met nul-driehoeken) wordt de vlaknormaal: glTF eist lengte 1.
v.fromBufferAttribute(nrm, ci).applyMatrix3(nm);
if (v.lengthSq() < 1e-12) v.copy(face); else v.normalize();
b.nrm.push(v.x, v.y, v.z);
if (b.map) {
if (uvA) uv.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix); else uv.set(0, 0);
// three (flipY) heeft v = 0 onderaan het beeld, glTF bovenaan.
b.uv.push(uv.x, b.map.flipY ? 1 - uv.y : uv.y);
}
return (remap[ci] = b.n++);
};
// Afgesneden driehoeken: losse hoekpunten (geïnterpoleerd), niet gedeeld via de remap.
const raw = (ci) => {
const p = new THREE.Vector3().fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).sub(originV);
const n = new THREE.Vector3().fromBufferAttribute(nrm, ci).applyMatrix3(nm);
if (n.lengthSq() < 1e-12) n.copy(face); else n.normalize();
const t = new THREE.Vector2();
if (b.map && uvA) t.fromBufferAttribute(uvA, ci).applyMatrix3(b.map.matrix);
return { p, n, t };
};
const lerp = (A, B, f) => ({
p: new THREE.Vector3().lerpVectors(A.p, B.p, f), n: new THREE.Vector3().lerpVectors(A.n, B.n, f).normalize(),
t: new THREE.Vector2().lerpVectors(A.t, B.t, f)
});
const push = (q) => {
b.pos.push(q.p.x, q.p.y, q.p.z);
b.nrm.push(q.n.x, q.n.y, q.n.z);
if (b.map) b.uv.push(q.t.x, b.map.flipY ? 1 - q.t.y : q.t.y);
return b.n++;
};
const end = Math.min(gr.start + gr.count, idx ? idx.count : pos.count);
for (let k = gr.start; k + 2 < end; k += 3) {
const s = [0, 1, 2].map(j => idx ? idx.getX(k + j) : k + j);
// Driehoeken zonder oppervlakte (< 0,1 mm²) overslaan.
pa.fromBufferAttribute(pos, s[0]).applyMatrix4(mesh.matrixWorld);
pb.fromBufferAttribute(pos, s[1]).applyMatrix4(mesh.matrixWorld).sub(pa);
pc.fromBufferAttribute(pos, s[2]).applyMatrix4(mesh.matrixWorld).sub(pa);
face.crossVectors(pb, pc);
const len = face.length();
if (len < 2e-7) continue;
face.multiplyScalar(flip ? -1 / len : 1 / len);
if (clip) {
const ys = s.map(ci => v.fromBufferAttribute(pos, ci).applyMatrix4(mesh.matrixWorld).y - origin.y);
const inside = ys.map(y => y >= clipY - EPS), nIn = inside.filter(Boolean).length;
if (!nIn) continue;
if (nIn < 3) {
const q = s.map(raw), poly = [];
for (let j = 0; j < 3; j++) {
const k2 = (j + 1) % 3;
if (inside[j]) poly.push(q[j]);
if (inside[j] !== inside[k2]) poly.push(lerp(q[j], q[k2], (clipY - ys[j]) / (ys[k2] - ys[j])));
}
const c = poly.map(push);
for (let j = 1; j + 1 < c.length; j++) {
if (flip) b.idx.push(c[0], c[j + 1], c[j]); else b.idx.push(c[0], c[j], c[j + 1]);
triCount++;
}
continue;
}
}
const c = s.map(add);
if (flip) b.idx.push(c[0], c[2], c[1]); else b.idx.push(c[0], c[1], c[2]);
triCount++;
}
});
}));
objects.forEach(visit);
// Accessors en materialen per bucket.
const accessors = [], materials = [], primitives = [];
let unlit = false;
const accessor = (arr, type, comps, ctype, target, minMax) => {
views.push(addView(arr, target));
const a = { bufferView: views.length - 1, componentType: ctype, count: arr.length / comps, type };
if (minMax) Object.assign(a, minMax);
accessors.push(a);
return accessors.length - 1;
};
buckets.forEach(b => {
if (!b.idx.length) return;
const m = b.mat, pos = new Float32Array(b.pos);
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < pos.length; i++) { const a = i % 3; if (pos[i] < min[a]) min[a] = pos[i]; if (pos[i] > max[a]) max[a] = pos[i]; }
const attributes = {
POSITION: accessor(pos, 'VEC3', 3, 5126, 34962, { min, max }),
NORMAL: accessor(new Float32Array(b.nrm), 'VEC3', 3, 5126, 34962)
};
if (b.map) attributes.TEXCOORD_0 = accessor(new Float32Array(b.uv), 'VEC2', 2, 5126, 34962);
const big = b.n >= 65535; // 65535 zelf mag niet in Uint16 (glTF)
const indices = accessor(big ? new Uint32Array(b.idx) : new Uint16Array(b.idx), 'SCALAR', 1, big ? 5125 : 5123, 34963);
// De app toont kleurwaarden zoals ze zijn (geen kleurbeheer); glTF-factoren zijn lineair, beelden sRGB.
const c = m.color || { r: 0.6, g: 0.6, b: 0.6 };
const alpha = m.transparent ? (m.opacity !== undefined ? m.opacity : 1) : 1;
const pbr = {
baseColorFactor: [lin(c.r), lin(c.g), lin(c.b), alpha],
metallicFactor: m.metalness !== undefined ? m.metalness : 0,
roughnessFactor: m.roughness !== undefined ? m.roughness : 0.85
};
if (b.tex !== null) pbr.baseColorTexture = { index: b.tex };
const mat = { name: m.name || m.type, pbrMetallicRoughness: pbr };
if (m.emissive && (m.emissive.r || m.emissive.g || m.emissive.b)) {
const k = m.emissiveIntensity !== undefined ? m.emissiveIntensity : 1;
mat.emissiveFactor = [m.emissive.r, m.emissive.g, m.emissive.b].map(x => Math.min(1, lin(x) * k));
}
if (m.transparent && (alpha < 1 || b.tex !== null)) mat.alphaMode = 'BLEND';
if (m.side !== THREE.FrontSide) mat.doubleSided = true;
if (m.isMeshBasicMaterial) { mat.extensions = { KHR_materials_unlit: {} }; unlit = true; }
materials.push(mat);
primitives.push({ attributes, indices, material: materials.length - 1, mode: 4 });
});
const json = {
asset: { version: '2.0', generator: `Tectum${o.app ? ' ' + o.app : ''}` },
scene: 0,
scenes: [{ name: o.name || 'woning', nodes: [0] }],
nodes: [{ name: o.name || 'woning', mesh: 0 }],
meshes: [{ name: o.name || 'woning', primitives }],
materials, accessors, bufferViews: views,
buffers: [{ byteLength: 0 }]
};
if (gltfImages.length) Object.assign(json, { images: gltfImages, samplers: gltfSamplers, textures: gltfTextures });
if (unlit) json.extensionsUsed = ['KHR_materials_unlit'];
const binPad = (4 - binLength % 4) % 4;
if (binPad) { bin.push(new Uint8Array(binPad)); binLength += binPad; }
json.buffers[0].byteLength = binLength;
// GLB: kop (12 bytes), JSON-blok (opgevuld met spaties), BIN-blok (opgevuld met nullen).
let jsonBytes = new TextEncoder().encode(JSON.stringify(json));
const jsonPad = (4 - jsonBytes.length % 4) % 4;
if (jsonPad) { const p = new Uint8Array(jsonBytes.length + jsonPad).fill(0x20); p.set(jsonBytes); jsonBytes = p; }
const total = 12 + 8 + jsonBytes.length + 8 + binLength;
const head = new DataView(new ArrayBuffer(20));
head.setUint32(0, 0x46546C67, true); head.setUint32(4, 2, true); head.setUint32(8, total, true);
head.setUint32(12, jsonBytes.length, true); head.setUint32(16, 0x4E4F534A, true);
const binHead = new DataView(new ArrayBuffer(8));
binHead.setUint32(0, binLength, true); binHead.setUint32(4, 0x004E4942, true);
const blob = new Blob([head, jsonBytes, binHead].concat(bin), { type: 'model/gltf-binary' });
return { blob, stats: { triangles: triCount, materials: materials.length, textures: gltfImages.length, bytes: total } };
}
return { build };
})();
+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 };
})();
+70
View File
@@ -0,0 +1,70 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Eigenschappenpaneel rechts: eigenschappen van de selectie, of van de woning als er niets geselecteerd is.
// Een sectie = { key, label, icon, groups() → [{ label, html }], wire(el) }; elke sectie krijgt een eigen element,
// zodat wire() alleen de eigen knoppen koppelt.
window.Inspector = (() => {
const { icon, esc } = UI;
const big = UI.btnOf('ins-btn ins-big');
const small = UI.btnOf('ins-btn');
const kv = (label, value, unit) =>
`<div class="ins-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
const PREF_KEY = 'tectum.inspector.open';
let open;
try { open = new Set(JSON.parse(localStorage.getItem(PREF_KEY) || 'null') || ['gevel']); } catch (e) { open = new Set(['gevel']); }
const saveOpen = () => { try { localStorage.setItem(PREF_KEY, JSON.stringify([...open])); } catch (e) { } };
// Een groepskop gelijk aan de sectienaam (bijv. Elektra › Elektra) vervalt.
const groupsHtml = (groups, secLabel) => groups.filter(Boolean).map(g =>
`<div class="ins-group${g.cls ? ' ' + g.cls : ''}">${g.label && g.label.toLowerCase() !== String(secLabel || '').toLowerCase() ? `<h3>${g.label}</h3>` : ''}<div class="ins-group-body">${g.html}</div></div>`).join('');
// view = { crumbs: [{ label, attrs }], title, icon, sub, top: html, sections: [...], collapsible, onBack }
function render(el, view) {
const scrollKey = view.title + '|' + (view.sub || '');
const keepScroll = el._insKey === scrollKey ? el.scrollTop : 0;
el._insKey = scrollKey;
el.innerHTML = `<div class="ins-head">
${view.crumbs && view.crumbs.length ? `<div class="ins-crumbs">${view.crumbs.map(c => `<button type="button" class="ins-crumb" ${c.attrs || ''}>${esc(c.label)}</button>`).join(icon('chevron-right'))}${icon('chevron-right')}</div>` : ''}
<h2>${view.icon ? icon(view.icon) : ''}<span>${esc(view.title)}</span></h2>
${view.sub ? `<div class="ins-sub">${esc(view.sub)}</div>` : ''}
</div>
${view.top ? `<div class="ins-top">${view.top}</div>` : ''}
<div class="ins-sections"></div>`;
const host = el.querySelector('.ins-sections');
view.sections.filter(Boolean).forEach(sec => {
const node = document.createElement('section');
const collapsible = view.collapsible;
const isOpen = !collapsible || open.has(sec.key);
node.className = 'ins-section' + (collapsible ? ' ins-collapsible' : '') + (isOpen ? ' open' : '');
node.innerHTML = (collapsible ? `<button type="button" class="ins-section-head" aria-expanded="${isOpen}">${sec.icon ? icon(sec.icon) : ''}<span>${sec.label}</span>${icon('chevron-down')}</button>` : '') +
'<div class="ins-section-body"></div>';
host.appendChild(node);
// Een dichte sectie wordt pas opgebouwd (en gekoppeld) als hij opengaat.
let built = false;
const build = () => {
if (built) return;
built = true;
node.querySelector('.ins-section-body').innerHTML = groupsHtml(sec.groups ? sec.groups() : [], sec.label);
if (sec.wire) sec.wire(node);
};
if (isOpen) build();
if (collapsible) node.querySelector('.ins-section-head').addEventListener('click', () => {
const now = !node.classList.contains('open');
if (now) build();
node.classList.toggle('open', now);
node.querySelector('.ins-section-head').setAttribute('aria-expanded', now);
if (now) open.add(sec.key); else open.delete(sec.key);
saveOpen();
});
});
el.scrollTop = keepScroll;
}
return { big, small, kv, render };
})();
+62
View File
@@ -0,0 +1,62 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Menubalk: menu = { label, items: [{ label, icon, keys, run, enabled(), checked() } | { sep: true } | { head }] }.
// Beschikbaarheid wordt bij het openen bepaald; is een menu open, dan opent hover het buurmenu.
window.Menubar = (() => {
let root = null, menus = [], openIdx = -1;
function itemHtml(it, mi, ii) {
if (it.sep) return '<div class="mb-sep"></div>';
if (it.head) return `<div class="mb-head">${it.head}</div>`;
const off = it.enabled && !it.enabled();
const on = it.checked && it.checked();
return `<button type="button" class="mb-item${on ? ' checked' : ''}" data-mi="${mi}" data-ii="${ii}"${off ? ' disabled' : ''}>
<span class="mb-ico">${on ? '<i class="bi bi-check2"></i>' : it.icon ? `<i class="bi bi-${it.icon}"></i>` : ''}</span>
<span class="mb-label">${it.label}</span><span class="mb-keys">${it.keys || ''}</span></button>`;
}
function close() {
if (openIdx < 0) return false;
openIdx = -1;
UI.release(close);
root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open'));
root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; });
return true;
}
function openMenu(i) {
if (openIdx < 0) UI.claim(close);
else { root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open')); root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; }); }
openIdx = i;
const wrap = root.querySelectorAll('.mb-menu')[i];
wrap.classList.add('open');
wrap.querySelector('.mb-drop').innerHTML = menus[i].items.map((it, ii) => itemHtml(it, i, ii)).join('');
}
function init(el, list) {
root = el;
menus = list;
el.innerHTML = menus.map((m, i) => `<div class="mb-menu"><button type="button" class="mb-top" data-menu="${i}">${m.label}</button><div class="mb-drop" role="menu"></div></div>`).join('');
el.addEventListener('click', (e) => {
e.stopPropagation();
const top = e.target.closest('[data-menu]');
if (top) { const i = Number(top.dataset.menu); if (openIdx === i) close(); else openMenu(i); return; }
const item = e.target.closest('.mb-item');
if (!item || item.disabled) return;
const it = menus[Number(item.dataset.mi)].items[Number(item.dataset.ii)];
close();
it.run();
});
el.addEventListener('mouseover', (e) => {
const top = e.target.closest('[data-menu]');
if (top && openIdx >= 0 && Number(top.dataset.menu) !== openIdx) openMenu(Number(top.dataset.menu));
});
}
return { init, close };
})();
+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 };
})();
+10 -16
View File
@@ -23,7 +23,7 @@ window.Skybox = (() => {
const cache = {}; const cache = {};
function loadTexture(url) { function loadTexture(url) {
if (!cache[url]) { if (!cache[url]) {
cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(url, resolve, undefined, reject)); cache[url] = new Promise((resolve, reject) => new THREE.TextureLoader().load(tectumUrl(url), resolve, undefined, reject));
cache[url].catch(() => { delete cache[url]; }); cache[url].catch(() => { delete cache[url]; });
} }
return cache[url]; return cache[url];
@@ -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;
} }
+77 -12
View File
@@ -80,8 +80,10 @@ window.UI = (() => {
const info = (text) => `<div class="ui-info">${icon('info-circle')}<span>${text}</span></div>`; const info = (text) => `<div class="ui-info">${icon('info-circle')}<span>${text}</span></div>`;
const warn = (text) => `<div class="ui-warn">${icon('exclamation-triangle')}<span>${text}</span></div>`; const warn = (text) => `<div class="ui-warn">${icon('exclamation-triangle')}<span>${text}</span></div>`;
const grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`; const grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`;
const button = (label, iconName, attrs, cls) => // Eén knopbouwer; base = de knopsoort (ui-btn, tb-btn, ins-btn …), cls = extra klassen.
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`; const btnOf = (base) => (label, iconName, attrs, cls) =>
`<button type="button" class="${base}${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
const button = btnOf('ui-btn');
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`; const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
const activeTabs = {}; const activeTabs = {};
@@ -128,16 +130,79 @@ 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 = btnOf('tb-btn');
const tbSep = () => '<span class="tb-sep"></span>';
const viewSeg = (mode) => `<div class="tb-seg" role="group" aria-label="Weergave">${VIEWS.map(v =>
tb('', v.icon, `data-view="${v.key}" title="${v.label} (${v.key1})"`, v.key === mode ? 'active' : '')).join('')}</div>`;
// Werkbalk van een editor: [left] … [Lagen, Omgeving] [after] | [2D + 3D, 2D, 3D]. Gedeeld door woning, wand en vloer.
// o: left/after (html), onResize, layers (opties voor ViewLayers.createMenu), onSky, beforeView(mode).
function editorBar(toolbar, root, o) {
toolbar.innerHTML = `${o.left || ''}<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${o.after || ''}${tbSep()}${viewSeg(viewOf(root))}`;
const group = toolbar.querySelector('.tb-view');
if (o.layers) ViewLayers.createMenu(group, o.layers);
if (o.onSky) Skybox.createToggle(group, o.onSky);
const bar = {
setView(mode) {
if (o.beforeView) o.beforeView(mode);
setView(root, mode);
requestAnimationFrame(o.onResize);
},
viewState: () => ({ view: viewOf(root) })
};
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => bar.setView(b.dataset.view)));
return bar;
}
// Eén uitklapper tegelijk (popover of menu): wie opent, sluit de vorige. claim bij openen, release bij sluiten.
let openPop = null;
function claim(close) { if (openPop && openPop !== close) openPop(); openPop = close; }
function release(close) { if (openPop === close) openPop = null; }
const closePopovers = () => { if (openPop) { openPop(); return true; } return false; };
document.addEventListener('click', () => closePopovers());
// Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere uitklapper.
function popover(btn, panel) {
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); release(close); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (panel.classList.contains('open')) { close(); return; }
claim(close);
panel.classList.add('open');
btn.classList.add('open');
});
panel.addEventListener('click', (e) => e.stopPropagation());
return close;
}
// Kaarten verdelen over kolommen van min. minW breed; telkens in de kortste kolom. Opnieuw aanroepen bij een andere breedte.
function masonry(el, minW, gap) {
if (!el) return;
gap = gap === undefined ? 12 : gap;
const cards = el._cards || (el._cards = [...el.children]);
const n = Math.max(1, Math.floor((el.clientWidth + gap) / (minW + gap)));
if (el._cols === n) return;
el._cols = n;
el.classList.add('ui-masonry');
const cols = Array.from({ length: n }, () => { const c = document.createElement('div'); c.className = 'ui-masonry-col'; return c; });
el.replaceChildren(...cols);
cards.forEach(card => cols.reduce((a, b) => b.offsetHeight < a.offsetHeight ? b : a).appendChild(card));
}
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, btnOf,
VIEWS, viewOf, setView, tb, tbSep, viewSeg, editorBar, popover, claim, release, closePopovers, masonry };
})(); })();
-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) };
}
+115
View File
@@ -0,0 +1,115 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.ViewLayers = (() => {
// phase: bouwvolgorde voor de snelkeuze "Fase" (en IFC); lagen zonder phase blijven daarbij aan.
const LIST = [
{ key: 'regelwerk', label: 'Regelwerk', icon: 'grid-3x3', phase: 0, hint: 'Vloer-, wand- en dakbalken, stalen nokbalk' },
{ key: 'elektra', label: 'Elektra', icon: 'lightning-charge', phase: 1, hint: 'Meterkast, buizen, dozen, schakelaars en wcd\'s' },
{ key: 'beplating', label: 'Beplating', icon: 'square', phase: 2, hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
{ key: 'doek', label: 'Vochtdoek', icon: 'droplet', phase: 3, hint: 'Vochtdoek op de buitenzijde' },
{ key: 'latwerk', label: 'Latwerk', icon: 'distribute-vertical', phase: 4, hint: 'Kruislat en trasraamplaat' },
{ key: 'gevel', label: 'Gevelbekleding', icon: 'bricks', phase: 5, hint: 'Rabat, felsplaten, steenstrips, stuc, schoorsteen, sandwichpanelen' },
{ key: 'kozijnen', label: 'Kozijnen', icon: 'window', phase: 5, hint: 'Kozijnen, deuren en glas' },
{ key: 'dakbedekking', label: 'Dakbedekking', icon: 'house-up', phase: 5, hint: 'Dakleer of riet' },
{ key: 'afwerking', label: 'Afwerking', icon: 'brush', phase: 6, hint: 'Vloerafwerking, verf, plafond, dakrand, dagkanten, logobord' },
{ key: 'lampen', label: 'Verlichting', icon: 'lightbulb', phase: 6, hint: 'Gevellampen, spots en binnenverlichting' },
{ key: 'inrichting', label: 'Inrichting', icon: 'lamp', phase: 6, hint: 'Sanitair, keuken, kasten, bed, zithoek' },
{ key: 'ruimtes', label: 'Ruimtes', icon: 'bounding-box', phase: 6, hint: 'Ruimtes met functie, vloer en wandpanelen' },
{ key: 'dak', label: 'Dak', icon: 'house', hint: 'Het hele dak met dakrand, overkapping en palen; uit om van boven in de woning te kijken' },
{ key: 'maten', label: 'Maten', icon: 'rulers', hint: 'Maatvoering in de plattegrond' }
];
const KEYS = LIST.map(l => l.key);
const PHASES = ['Regelwerk', 'Elektra', 'Beplating', 'Vochtdoek', 'Latwerk', 'Gevel & dak', 'Afgewerkt'];
const all = () => Object.fromEntries(KEYS.map(k => [k, true]));
const normalize = (layers) => Object.fromEntries(KEYS.map(k => [k, !layers || layers[k] !== false]));
const shows = (layers, key) => !layers || layers[key] !== false;
// Alles tot en met een bouwfase (0..6), zoals de vroegere bouwfase-schuif.
const upTo = (phase) => Object.fromEntries(LIST.map(l => [l.key, l.phase === undefined || l.phase <= phase]));
const phaseOf = (layers) => PHASES.findIndex((_, p) => LIST.every(l => l.phase === undefined || (l.phase <= p) === shows(layers, l.key)));
// Verbergt meshes met een van de materialen (ook afgeleide met userData.base), bijv. het regelwerk.
function hideByMaterial(group, materials) {
if (!group) return;
const set = new Set(materials.filter(Boolean));
group.traverse(o => {
if (!o.isMesh) return;
const m0 = Array.isArray(o.material) ? o.material[0] : o.material;
if (m0 && (set.has(m0) || set.has(m0.userData && m0.userData.base))) o.visible = false;
});
}
const GROUPS = [
['Casco', ['regelwerk', 'elektra', 'beplating', 'doek', 'latwerk']],
['Buiten', ['gevel', 'kozijnen', 'dakbedekking', 'dak']],
['Binnen', ['afwerking', 'lampen', 'inrichting', 'ruimtes']],
['Plattegrond', ['maten']]
];
// De enige plek voor zichtbaarheid: knop "Lagen" in de werkbalk met een uitklappaneel.
// keys beperkt de lagen (wand-editor); extras zijn editor-eigen schakelaars, bijv. beplating binnen/buiten.
function createMenu(toolbar, o) {
const items = o.keys ? LIST.filter(l => o.keys.includes(l.key)) : LIST;
const extras = o.extras || [];
const extra = Object.fromEntries(extras.map(x => [x.key, x.checked !== false]));
const check = (attr, key, label, hint, ic) =>
`<label class="pop-check" title="${hint || ''}"><input type="checkbox" ${attr}="${key}">${ic ? `<i class="bi bi-${ic}"></i>` : ''}<span>${label}</span></label>`;
const wrap = document.createElement('div');
wrap.className = 'tb-pop-wrap';
wrap.innerHTML = `<button type="button" class="tb-btn" title="Zichtbaarheid van lagen in 2D en 3D"><i class="bi bi-layers"></i><span>Lagen</span><i class="bi bi-chevron-down tb-caret"></i></button>
<div class="tb-pop pop-layers">
${items.length ? `<div class="pop-row">
<select class="pop-phase" title="Alles tot en met een bouwfase; Dak en Maten blijven zoals ze staan">
<option value="">Bouwfase…</option>${PHASES.map((p, i) => `<option value="${i}">${i + 1}. ${p}</option>`).join('')}
</select>
<button type="button" class="ui-btn" data-all>Alles</button>
</div>
<div class="pop-groups">${GROUPS.map(([label, keys]) => {
const g = items.filter(l => keys.includes(l.key));
return g.length ? `<div class="pop-group"><div class="pop-head">${label}</div>${g.map(l => check('data-layer', l.key, l.label, l.hint, l.icon)).join('')}</div>` : '';
}).join('')}</div>` : ''}
${extras.length ? `<div class="pop-group"><div class="pop-head">${o.extrasLabel || 'Weergave'}</div>${extras.map(x => check('data-extra', x.key, x.label, x.hint)).join('')}</div>` : ''}
</div>`;
toolbar.appendChild(wrap);
const btn = wrap.querySelector('.tb-btn'), panel = wrap.querySelector('.tb-pop');
UI.popover(btn, panel);
let cur = normalize(o.layers);
const sel = panel.querySelector('.pop-phase');
const sync = () => {
panel.querySelectorAll('[data-layer]').forEach(c => { c.checked = cur[c.dataset.layer]; });
panel.querySelectorAll('[data-extra]').forEach(c => { c.checked = extra[c.dataset.extra]; });
if (sel) { const p = phaseOf(cur); sel.value = p >= 0 ? String(p) : ''; }
const hidden = items.some(l => !cur[l.key]) || extras.some(x => !extra[x.key]);
btn.classList.toggle('flag', hidden);
};
const emit = () => { sync(); o.onChange && o.onChange(Object.assign({}, cur)); };
panel.addEventListener('change', (e) => {
const t = e.target;
if (t.dataset.layer) { cur = Object.assign({}, cur, { [t.dataset.layer]: t.checked }); emit(); }
else if (t.dataset.extra) { extra[t.dataset.extra] = t.checked; sync(); o.onExtra && o.onExtra(Object.assign({}, extra)); }
else if (t === sel && sel.value !== '') {
const keep = Object.fromEntries(LIST.filter(l => l.phase === undefined).map(l => [l.key, cur[l.key]]));
cur = Object.assign(upTo(Number(sel.value)), keep);
emit();
}
});
// Alles = ook de editor-eigen schakelaars weer aan.
const all = panel.querySelector('[data-all]');
if (all) all.addEventListener('click', () => {
const extraOff = extras.some(x => !extra[x.key]);
extras.forEach(x => { extra[x.key] = true; });
cur = ViewLayers.all();
emit();
if (extraOff && o.onExtra) o.onExtra(Object.assign({}, extra));
});
sync();
return { el: wrap, set: (l) => { cur = normalize(l); sync(); } };
}
return { LIST, KEYS, PHASES, GROUPS, all, normalize, shows, upTo, phaseOf, hideByMaterial, createMenu };
})();
+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
@@ -114,7 +114,7 @@ window.CostCsv = (() => {
} }
function loadList(list) { function loadList(list) {
const url = LISTS[list].url; const url = LISTS[list].url;
return fetch(url, { cache: 'no-store' }) return fetch(tectumUrl(url), { cache: 'no-store' })
.then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null) .then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
.catch(() => null); .catch(() => null);
} }
+13 -2
View File
@@ -95,7 +95,11 @@ window.COST_DEFAULTS = {
steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 }, steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 },
steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 }, steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 },
stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 }, stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 },
stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 } stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 },
felsM2: { label: 'Felsplaten gevel, staande naad (staal, klangen, ondergrond)', unit: '€/m²', value: 70 },
felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 },
afdekkerM: { label: 'Afdekker trasraam (aluminium zetwerk, gepoedercoat)', unit: '€/m', value: 14 },
afdekkerMontage: { label: 'Montage afdekker trasraam', unit: 's/m', value: 300 }
} }
}, },
@@ -116,11 +120,18 @@ window.COST_DEFAULTS = {
onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 }, onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 },
daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 }, daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 },
daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 }, daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 },
ventRoosterM: { label: 'Ventilatierooster onder boeideel (40 mm, aluminium)', unit: '€/m', value: 6 },
ventRoosterMontage: { label: 'Montage ventilatierooster', unit: 's/m', value: 120 },
staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 }, staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 },
nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 }, nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 },
nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 }, nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 },
nokVulhoutMontage: { label: 'Vulhout in de holling van de nokbalk aanbrengen (beide zijden)', unit: 's/m', value: 240 }, nokVulhoutMontage: { label: 'Vulhout in de holling van de nokbalk aanbrengen (beide zijden)', unit: 's/m', value: 240 },
nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 } nokPersoneel: { label: 'Personeel bij plaatsen nokbalk (i.p.v. de bezetting)', unit: 'man', value: 3 },
paalHoutM3: { label: 'Paalhout overkapping (eiken, geschaafd)', unit: '€/m³', value: 2400 },
paalvoet: { label: 'Paalvoet verzinkt, verstelbaar (incl. ankers)', unit: '€/st.', value: 40 },
paalMontage: { label: 'Paal plaatsen: stellen, aftoppen, vastzetten aan het dak', unit: 's/st.', value: 2700 },
zijwandM2: { label: 'Zijwand overkapping (regelwerk, cementgebonden plaat en stuc aan beide kanten, fundering)', unit: '€/m²', value: 140 },
zijwandMontage: { label: 'Zijwand overkapping maken en plaatsen', unit: 's/m²', value: 5400 }
} }
}, },
+25 -11
View File
@@ -155,8 +155,12 @@ window.CostModel = (() => {
add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM); add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM);
const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0; const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0;
add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage); add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage);
const fels = p.buitenMateriaal === 'vochtdoek' ? (st.felsAreaM2 || 0) : 0;
add(el, 'Gevel', 'Gevel', 'Felsplaten (staande naad)', fels, 'm²', fels * (G.felsM2 || 0), fels * (G.felsMontage || 0));
const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0); const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0);
add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage); add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage);
const cap = (st.trasraamCapM || 0);
add(el, 'Gevel', 'Gevel', 'Afdekker trasraam', cap, 'm', cap * (G.afdekkerM || 0), cap * (G.afdekkerMontage || 0));
const stuc = st.stucAreaM2 || 0; const stuc = st.stucAreaM2 || 0;
add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0)); add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0));
const latM = st.battenLengthM || 0; const latM = st.battenLengthM || 0;
@@ -167,7 +171,7 @@ window.CostModel = (() => {
const opKey = p.seam ? `iw${p.id}` : null; const opKey = p.seam ? `iw${p.id}` : null;
if (!opKey || !seenOpenings.has(opKey)) { if (!opKey || !seenOpenings.has(opKey)) {
if (opKey) seenOpenings.add(opKey); if (opKey) seenOpenings.add(opKey);
(p.config.openings || []).forEach(op => { (p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
const fill = KozijnModel.normalize(op.fill); const fill = KozijnModel.normalize(op.fill);
if (fill && fill.type === 'schuifdeur') { if (fill && fill.type === 'schuifdeur') {
add(el, 'Kozijnen', 'Kozijnen', `Schuifdeur ${op.w}×${op.h} (${fill.leaves} blad${fill.leaves > 1 ? 'en' : ''}, ${fill.mount === 'opbouw' ? 'op de wand' : 'in de wand'})`, add(el, 'Kozijnen', 'Kozijnen', `Schuifdeur ${op.w}×${op.h} (${fill.leaves} blad${fill.leaves > 1 ? 'en' : ''}, ${fill.mount === 'opbouw' ? 'op de wand' : 'in de wand'})`,
@@ -206,13 +210,13 @@ window.CostModel = (() => {
const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id); const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id);
const rs = u.roof.specs, D = S.dak; const rs = u.roof.specs, D = S.dak;
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] && u.roof.planes[0].data.calcData), 0);
u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true));
const cap = u.roof.unitRoof && u.roof.unitRoof.cap; const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] ? u.roof.planes[0].data.calcData : cap), 0);
u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true));
if (cap) { if (cap) {
const n = cap.beams.length; const n = cap.beams.length, join = cap.kind === 'join';
add(rf, 'Hout', 'Zagen', `${sawLabel}: wolfseinden ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0)); add(rf, 'Hout', 'Zagen', `${sawLabel}: ${join ? 'dak (sporen, kepers)' : 'wolfseinden'} ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0));
addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true); addJoints(rf, n * V.verbindingenPerVloerbalk, join ? 'dakbalken' : 'wolfseindbalken', true);
} }
addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage); addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage);
addSheet(rf, 'Plaatwerk', `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm`, rs.beschotM2 || 0, D.beschotM2, D.beschotMontage); addSheet(rf, 'Plaatwerk', `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm`, rs.beschotM2 || 0, D.beschotM2, D.beschotMontage);
@@ -230,13 +234,24 @@ window.CostModel = (() => {
} }
}); });
const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null); const wo = newEl('WON', 'Woning (dakrand, overkapping, fundering, verlichting)', null);
const D = S.dak; const D = S.dak;
if (totals.roofEdge) { if (totals.roofEdge) {
const e = totals.roofEdge; const e = totals.roofEdge;
add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage); add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage);
add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage); add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage);
add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage); add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage);
add(wo, 'Dak', 'Dak', 'Ventilatierooster onder boeideel', e.ventLengthM || 0, 'm', (e.ventLengthM || 0) * (D.ventRoosterM || 0), (e.ventLengthM || 0) * (D.ventRoosterMontage || 0));
}
if (totals.posts) {
const pp = totals.posts;
add(wo, 'Hout', 'Dak', `${pp.wood} palen ${pp.size}×${pp.size} mm (${pp.count} st.)`, pp.lengthM, 'm', pp.m3 * D.paalHoutM3, pp.count * D.paalMontage);
add(wo, 'Staal', 'Dak', 'Paalvoeten', pp.count, 'st.', pp.count * D.paalvoet, 0);
add(wo, 'Gevel', 'Dak', `Zijwand overkapping (${pp.walls || 0} st.)`, pp.wallM2 || 0, 'm²', (pp.wallM2 || 0) * (D.zijwandM2 || 0), (pp.wallM2 || 0) * (D.zijwandMontage || 0));
// Buitenkant zoals de gevel ernaast (stuc zit al in de zijwand).
const G = S.gevel, oo = pp.outsideM2 || {};
[['fels', 'felsplaten', G.felsM2, G.felsMontage], ['rabat', 'rabat', G.rabatM2, G.rabatMontage], ['steen', 'steenstrips', G.steenM2, G.steenMontage]].forEach(([k, label, m2, sec]) =>
add(wo, 'Gevel', 'Gevel', `Zijwand overkapping buitenzijde ${label}`, oo[k] || 0, 'm²', (oo[k] || 0) * (m2 || 0), (oo[k] || 0) * (sec || 0)));
} }
if (parts.sandwich) { if (parts.sandwich) {
const sw = parts.sandwich; const sw = parts.sandwich;
@@ -324,11 +339,10 @@ window.CostModel = (() => {
const m = f.w / 1000, crew = IN.personeel; const m = f.w / 1000, crew = IN.personeel;
const perM = (what, price, sec) => add(el, 'Inrichting', 'Inrichting', `${what} ${f.w} mm`, m, 'm', m * (price || 0), m * (sec || 0), crew); const perM = (what, price, sec) => add(el, 'Inrichting', 'Inrichting', `${what} ${f.w} mm`, m, 'm', m * (price || 0), m * (sec || 0), crew);
if (f.cost === 'keuken') perM('Keukenblok', IN.keukenM, IN.keukenMontage); if (f.cost === 'keuken') perM('Keukenblok', IN.keukenM, IN.keukenMontage);
else if (f.cost === 'kookeiland') { else if (f.cost === 'kookeiland') perM('Kookeiland', IN.kookeilandM, IN.kookeilandMontage);
perM('Kookeiland', IN.kookeilandM, IN.kookeilandMontage); else if (f.cost === 'kast') perM(f.label, IN.kastM, IN.kastMontage);
if (f.opts && f.opts.kookplaat) add(el, 'Inrichting', 'Inrichting', 'Inductiekookplaat', 1, 'st.', IN.kookplaat || 0, 0);
} else if (f.cost === 'kast') perM(f.label, IN.kastM, IN.kastMontage);
else add(el, 'Inrichting', 'Inrichting', f.label, 1, 'st.', IN[f.cost] || 0, IN[f.cost + 'Montage'] || 0, crew); else add(el, 'Inrichting', 'Inrichting', f.label, 1, 'st.', IN[f.cost] || 0, IN[f.cost + 'Montage'] || 0, crew);
if ((f.cost === 'keuken' || f.cost === 'kookeiland') && f.opts && f.opts.kookplaat) add(el, 'Inrichting', 'Inrichting', 'Inductiekookplaat', 1, 'st.', IN.kookplaat || 0, 0);
}); });
const sumLines = (lines) => lines.reduce((a, l) => ({ mat: a.mat + l.mat, sec: a.sec + l.sec, clock: a.clock + l.clock }), { mat: 0, sec: 0, clock: 0 }); const sumLines = (lines) => lines.reduce((a, l) => ({ mat: a.mat + l.mat, sec: a.sec + l.sec, clock: a.clock + l.clock }), { mat: 0, sec: 0, clock: 0 });
+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" data-esc>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">
+102
View File
@@ -0,0 +1,102 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Koppeling met de Van Emous CRM (alleen in de Docker-stack): woningen van leads en klanten laden (Nieuw › Uit de
// CRM, of ?woning=<id> in de URL) en een gewijzigde woning als nieuwe versie terugzetten (Opslaan in CRM).
// De API staat op api/crm/ naast deze pagina: Caddy stuurt dat na het Tectum-wachtwoord door naar de CRM, met de
// sleutel erbij. Zonder stack (start.sh) geeft api/crm/ een 404 en blijft alles van de CRM verborgen.
window.TectumCrm = (function () {
const API = 'api/crm/woningen';
let available = null;
async function call(url, opts) {
const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json', ...(opts && opts.body ? { 'Content-Type': 'application/json' } : {}) }, ...opts });
let data = null;
try { data = await res.json(); } catch (e) { /* geen JSON: geen CRM */ }
if (!data) throw new Error(`CRM niet bereikbaar (HTTP ${res.status})`);
return { status: res.status, data };
}
// Eén keer kijken of de CRM bereikbaar is (de lijst ophalen); onthoudt de uitkomst
function isAvailable() {
if (!available) available = list('').then(() => true, () => false);
return available;
}
async function list(q) {
const { data } = await call(`${API}?q=${encodeURIComponent(q || '')}`);
if (!data.ok) throw new Error(data.error || 'CRM-fout');
return data.woningen;
}
// Ontwerp van een woning (nieuwste versie, of `versie`), klaar voor de editor: gecontroleerd en zonder de
// bestandsvelden (type, version, exportedAt), zoals bij Openen.
async function load(id, versie) {
const { data } = await call(`${API}/${encodeURIComponent(id)}${versie ? `?versie=${versie}` : ''}`);
if (!data.ok) throw new Error(data.error || 'Woning niet gevonden');
HouseModel.validateState(data.ontwerp);
const { type, version, exportedAt, ...state } = data.ontwerp;
return { woning: data.woning, state };
}
// Nieuwe versie bewaren. basis = de versie die geladen was; is er intussen een nieuwere, dan vraagt dit eerst.
async function saveVersion(id, state, basis, opmerking) {
const ontwerp = { type: 'house-design', version: 1, exportedAt: new Date().toISOString(), configurator: APP_VERSION, ...state };
const send = (forceer) => call(`${API}/${encodeURIComponent(id)}/versies`, { method: 'POST', body: JSON.stringify({ ontwerp, opmerking, basis, forceer }) });
let { status, data } = await send(false);
if (status === 409) {
if (!window.confirm(`In de CRM staat intussen versie ${data.versie} van deze woning (u bewerkt versie ${basis}). Toch opslaan als nieuwe versie?`)) return null;
({ data } = await send(true));
}
if (!data.ok) throw new Error(data.error || 'Opslaan mislukt');
return data.versie;
}
function toast(msg, cls) {
let el = document.getElementById('crm-toast');
if (!el) {
el = document.createElement('div');
el.id = 'crm-toast';
el.setAttribute('role', 'status');
document.body.appendChild(el);
}
el.textContent = msg;
el.className = 'open ' + (cls || '');
clearTimeout(toast._t);
toast._t = setTimeout(() => { el.className = ''; }, 5000);
}
const date = (s) => (s || '').slice(0, 10).split('-').reverse().join('-');
// Sectie "Woningen uit de CRM" in het venster Nieuw: zoeken en kiezen; verborgen zolang de CRM er niet is
function mountPicker(host, onPick) {
host.innerHTML = `<h3>Woningen van leads en klanten (CRM)</h3>
<input type="search" class="new-crm-q" placeholder="Zoek op woning, naam of plaats" aria-label="Zoek in de CRM">
<ul class="new-crm-list" role="list"></ul>`;
const q = host.querySelector('.new-crm-q'), ul = host.querySelector('.new-crm-list');
let req = 0, timer = null;
const render = (rows) => {
ul.innerHTML = rows.length ? rows.map(w => `<li><button type="button" class="new-crm-item" data-id="${w.id}">
<i class="bi bi-house"></i><span><strong>${UI.esc(w.titel)}</strong>
<small>${UI.esc([w.contact && w.contact.naam, w.relatie, w.contact && w.contact.plaats].filter(Boolean).join(' · '))}
· versie ${w.versie} · ${date(w.datum)}</small></span></button></li>`).join('')
: '<li class="new-crm-empty">Geen woningen gevonden.</li>';
};
const search = () => {
const id = ++req;
list(q.value.trim()).then(rows => { if (id === req) render(rows); }, err => { if (id === req) ul.innerHTML = `<li class="new-crm-empty">${UI.esc(err.message)}</li>`; });
};
q.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(search, 250); });
ul.addEventListener('click', (e) => {
const b = e.target.closest('.new-crm-item');
if (b) onPick(Number(b.dataset.id));
});
isAvailable().then(ok => { host.hidden = !ok; if (ok) search(); });
return { refresh: () => { if (!host.hidden) search(); } };
}
return { isAvailable, list, load, saveVersion, mountPicker, toast };
})();
+238 -31
View File
@@ -12,6 +12,7 @@ window.ElektraModel = (() => {
switchHeight: 1500, switchHeight: 1500,
wcdHeight: 400, wcdHeight: 400,
wcdPerUnit: 2, wcdPerUnit: 2,
plateColor: 'wit', // schakelaars, wcd's, apparaat-wcd's en vloerdozen
slack: 10, // % slack: 10, // %
reserve: 300, reserve: 300,
meterkastReserve: 500, meterkastReserve: 500,
@@ -32,6 +33,11 @@ window.ElektraModel = (() => {
droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' }, droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' },
warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' } warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' }
}; };
const PLATE_COLORS = {
wit: { label: 'Wit', hex: 0xf6f6f2 },
antraciet: { label: 'Antraciet', hex: 0x3a3d41 },
zwart: { label: 'Zwart', hex: 0x161718 }
};
const BOX_PITCH = 71; const BOX_PITCH = 71;
const PLATE = 80; const PLATE = 80;
const WCD_W = 150; const WCD_W = 150;
@@ -62,6 +68,7 @@ window.ElektraModel = (() => {
s.removed = Array.isArray(i.removed) ? i.removed.slice() : []; s.removed = Array.isArray(i.removed) ? i.removed.slice() : [];
s.vloerWcd = Array.isArray(i.vloerWcd) ? i.vloerWcd.filter(o => o && o.unitId).map(o => Object.assign({}, o)) : []; s.vloerWcd = Array.isArray(i.vloerWcd) ? i.vloerWcd.filter(o => o && o.unitId).map(o => Object.assign({}, o)) : [];
s.enabled = s.enabled !== false; s.enabled = s.enabled !== false;
if (!PLATE_COLORS[s.plateColor]) s.plateColor = DEFAULT_SETTINGS.plateColor;
// v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard). // v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard).
if (!i.v) { if (!i.v) {
if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight; if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight;
@@ -136,9 +143,68 @@ window.ElektraModel = (() => {
return null; return null;
} }
function makeRouter(units) { // Gaat het stuk p→q (wereld, mm) door een raam of deur? Een wand telt over zijn dikte plus 5 mm, de opening 1 mm
// kleiner: een buis naast de opening of erboven/eronder mag.
function makeOpeningTest(units, interior) {
const pieces = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => { if (p.config.openings.length) pieces.push(p); })));
interior.forEach(p => { if (p.config.openings.length) pieces.push(p); });
const clip = (r, a, b, lo, hi) => {
const d = b - a;
if (Math.abs(d) < 1e-9) return a > lo && a < hi ? r : null;
let t0 = (lo - a) / d, t1 = (hi - a) / d;
if (t0 > t1) [t0, t1] = [t1, t0];
return Math.max(r[0], t0) < Math.min(r[1], t1) ? [Math.max(r[0], t0), Math.min(r[1], t1)] : null;
};
return (p, q) => pieces.some(pc => {
const pl = pc.place, c = Math.cos(pl.rotY), s = Math.sin(pl.rotY), half = pl.thickness / 2 + 5;
const loc = (v) => { const dx = v.x - pl.cx, dz = v.z - pl.cz; return { u: dx * c - dz * s + pl.width / 2, n: dx * s + dz * c, y: v.y - pl.floorTop }; };
const A = loc(p), B = loc(q);
const inWall = clip([0, 1], A.n, B.n, -half, half);
return !!inWall && pc.config.openings.some(op => {
let r = clip(inWall, A.u, B.u, op.x + 1, op.x + op.w - 1);
r = r && clip(r, A.y, B.y, op.y + 1, WallModel.openingTopMax(op) - 1);
if (!r) return false;
// Schuine bovenkant: alleen onder de daklijn van de opening.
return [0, 0.25, 0.5, 0.75, 1].some(k => {
const t = r[0] + (r[1] - r[0]) * k, u = A.u + (B.u - A.u) * t, y = A.y + (B.y - A.y) * t;
return y < WallModel.openingTopAt(op, u) - 1;
});
});
});
}
// rooms: leidingen lopen boven een vals plafond, niet tegen de onderkant van een hoog dak.
// blocked(p, q): het stuk gaat door een wandopening (makeOpeningTest).
function makeRouter(units, rooms, blocked) {
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 = in een wand of naad (geen gebied), null = geen
// vals plafond (ook een gebied zonder functie: niet het plafond van de buurruimte door de wand heen overnemen).
const roomPlenum = (x, z) => {
if (!rooms || !rooms.roomAt) return undefined;
const r = rooms.roomAt(x, z);
if (r) return r.ceilingActive ? r.ceiling : null;
return rooms.regionAt && rooms.regionAt(x, z) ? null : undefined;
};
// Absoluut; in een binnenwand het laagste valse plafond ernaast: de leiding gaat daar door de wand, niet omhoog.
const plenumAt = (x, z) => {
const u = unitAt(x, z);
if (!u) return null;
let v = roomPlenum(x, z);
if (v === undefined) {
v = null;
[[1, 0], [-1, 0], [0, 1], [0, -1]].forEach(([dx, dz]) => {
for (const d of [60, 120, 200, 300]) {
const o = roomPlenum(x + dx * d, z + dz * d);
if (o === undefined) continue;
if (o !== null) v = v === null ? o : Math.min(v, o);
break;
}
});
}
return v === null ? null : u.layout.floorTop + v;
};
const roofCeilAt = (x, z) => {
let u = unitAt(x, z); let u = unitAt(x, z);
if (!u) u = units.reduce((best, c) => { if (!u) u = units.reduce((best, c) => {
const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1); const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1);
@@ -148,24 +214,159 @@ 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));
// Begin/eind op een wandvlak 50 mm de ruimte in meten, net als top(): anders telt de buurruimte achter de wand mee.
const probe = (pt, e) => e.wall && e.wall.n ? { x: pt.x + e.wall.n.x * 50, z: pt.z + e.wall.n.z * 50 } : pt;
const S = Array.from({ length: n + 1 }, (_, k) => {
const pt = at(k / n), pr = k === 0 ? probe(pt, p) : k === n ? probe(pt, q) : pt, st = plenumOn(pr.x, pr.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);
const clear = (pts) => !blocked || pts.slice(1).every((q, i) => !blocked(pts[i], q));
// Wandpunt dat recht omhoog/omlaag door een raam of deur zou gaan (wcd onder een raam, meterkast onder een
// bovenlicht): eerst op dezelfde hoogte opzij tot naast de opening (stijl + 60 mm), de dichtstbijzijnde kant
// die vrij is. yTo(punt) = hoogte waar de leiding heen gaat. null = recht kan, of nergens plek.
function sidestep(p, yTo) {
const f = p.face;
if (!f || !blocked || !blocked(p, { x: p.x, y: yTo(p), z: p.z })) return null;
const y = p.y - f.piece.place.floorTop, u0 = uAt(f, p), lo = Math.min(y, yTo(p) - f.piece.place.floorTop);
const cands = [];
f.piece.config.openings.forEach(op => {
if (WallModel.openingTopMax(op) > lo) cands.push(op.x - f.sw - 60, op.x + op.w + f.sw + 60);
});
const pick = cands.filter(u => u > f.u0 + 50 && u < f.u1 - 50).sort((m, n) => Math.abs(m - u0) - Math.abs(n - u0)).find(u => {
const q = Object.assign({}, p, facePoint(f, u, y, 0));
return !blocked(p, q) && !blocked(q, { x: q.x, y: yTo(q), z: q.z });
});
return pick === undefined ? null : Object.assign({}, p, facePoint(f, pick, y, 0));
}
// Gevellamp naar gevellamp: langs de gevel (om de hoeken heen), nooit dwars door de woning.
function outside(a, b) {
const gap = (p) => Math.min(...units.map(u => Math.max(u.rect.x0 - p.x, 0, p.x - u.rect.x1) + Math.max(u.rect.z0 - p.z, 0, p.z - u.rect.z1)));
const d = Math.max(60, gap(a), gap(b));
const cands = [[{ x: b.x, z: a.z }], [{ x: a.x, z: b.z }]];
units.forEach(u => {
[u.rect.x0 - d, u.rect.x1 + d].forEach(x => cands.push([{ x, z: a.z }, { x, z: b.z }]));
[u.rect.z0 - d, u.rect.z1 + d].forEach(z => cands.push([{ x: a.x, z }, { x: b.x, z }]));
});
const free = (p, q) => {
const n = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 50));
for (let k = 1; k < n; k++) if (strictlyInside(p.x + (q.x - p.x) * k / n, p.z + (q.z - p.z) * k / n)) return false;
return true;
};
let best = null;
cands.forEach(c => {
const pts = [a].concat(c, [b]);
if (!pts.slice(1).every((q, i) => free(pts[i], q))) return;
const len = pts.slice(1).reduce((t, q, i) => t + Math.abs(q.x - pts[i].x) + Math.abs(q.z - pts[i].z), 0);
if (!best || len < best.len - 1) best = { len, c };
});
return best ? best.c : [strictlyInside(b.x, a.z) ? { x: a.x, z: b.z } : { x: b.x, z: a.z }];
}
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), const A2 = a.mount === 'vloer' ? null : sidestep(a, () => fa), B2 = b.mount === 'vloer' ? null : sidestep(b, () => fb);
P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')]; const a2 = A2 || a, b2 = B2 || b;
return [P(a, a.face, false, a.mount === 'vloer')].concat(A2 ? [P(A2, a.face)] : [], [P({ x: a2.x, y: fa, z: a2.z }, a.face, false, true)])
.concat(corners(a2, b2).map(c => P({ x: c.x, y: fa, z: c.z }, null, false, true)),
[P({ x: b2.x, y: fb, z: b2.z }, b.face, false, true)], B2 ? [P(B2, b.face)] : [], [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)]; // Langs dezelfde wand: eerst verticaal of eerst horizontaal; zit er een deur of raam tussen, dan via het plafond.
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a), P({ x: b.x, y: a.y, z: a.z }), P(b)]; if (a.face && a.face === b.face) {
const top = (p) => p.mount === 'plafond' ? p.y : ceilAt(p.x, p.z); const viaA = [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)];
const ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1; const viaB = [P(a, a.face), P({ x: b.x, y: a.y, z: b.z }, a.face), P(b, b.face)];
if (clear(viaA)) return viaA;
if (clear(viaB)) return viaB;
}
if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a)].concat(outside(a, b).map(c => 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 A2 = a.mount === 'plafond' ? null : sidestep(a, top), B2 = b.mount === 'plafond' ? null : sidestep(b, top);
const a2 = A2 || a, b2 = B2 || b;
const ya = top(a2), yb = top(b2), 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 (A2) pts.push(P(A2, a.face));
pts.push(P({ x: b.x, y: ceilAt(b.x, a.z), z: a.z }, null, true)); if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a2.x, y: ya, z: a2.z }, a.face, true));
pts.push(P({ x: b.x, y: yb, z: b.z }, dropB ? b.face : null, true)); corners(a2, b2, true).forEach(c => pts.push(P({ x: c.x, y: ceilAt(c.x, c.z), z: c.z }, null, true)));
if (dropB) pts.push(P(b, b.face, false)); pts.push(P({ x: b2.x, y: yb, z: b2.z }, dropB ? b.face : null, true));
return pts; if (dropB) {
if (B2) pts.push(P(B2, b.face));
pts.push(P(b, b.face, false));
}
return followCeiling(pts);
} }
const length = (pts) => pts.slice(1).reduce((s, q, i) => s + Math.hypot(q.x - pts[i].x, q.y - pts[i].y, q.z - pts[i].z), 0); const length = (pts) => pts.slice(1).reduce((s, q, i) => s + Math.hypot(q.x - pts[i].x, q.y - pts[i].y, q.z - pts[i].z), 0);
const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y); const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y);
@@ -194,7 +395,8 @@ 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 blocked = makeOpeningTest(units, interior);
const R = makeRouter(units, rooms, blocked);
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);
@@ -515,7 +717,7 @@ window.ElektraModel = (() => {
}); });
if (mkLow) out.apps.forEach(a => addRun(APPLIANCES[a.app].cable, mkLow, Object.assign({}, a.pos, { mount: 'wand', face: a.face, name: a.code }), a.group)); if (mkLow) out.apps.forEach(a => addRun(APPLIANCES[a.app].cable, mkLow, Object.assign({}, a.pos, { mount: 'wand', face: a.face, name: a.code }), a.group));
separateRuns(out.runs); separateRuns(out.runs, blocked);
const holesOf = makeHoleCounter(units, R); const holesOf = makeHoleCounter(units, R);
out.runs.forEach(r => { out.runs.forEach(r => {
r.rawMM = R.length(r.path); r.rawMM = R.length(r.path);
@@ -561,6 +763,7 @@ window.ElektraModel = (() => {
out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; }); out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; });
const m = out.materials; const m = out.materials;
const add = (naam, aantal, eenheid) => { if (aantal > 0) m.push({ naam, aantal, eenheid: eenheid || 'st.' }); }; const add = (naam, aantal, eenheid) => { if (aantal > 0) m.push({ naam, aantal, eenheid: eenheid || 'st.' }); };
const kleur = ', ' + PLATE_COLORS[s.plateColor].label.toLowerCase();
if (out.meterkast) { if (out.meterkast) {
add('Groepenkast 3-fase (aansluiting 3×25 A)', 1); add('Groepenkast 3-fase (aansluiting 3×25 A)', 1);
add('Hoofdschakelaar 4-polig 40 A', 1); add('Hoofdschakelaar 4-polig 40 A', 1);
@@ -571,13 +774,13 @@ window.ElektraModel = (() => {
add('Installatieautomaat B16 1P+N (wcd)', nWcdG); add('Installatieautomaat B16 1P+N (wcd)', nWcdG);
add('Installatieautomaat B16 1P+N (apparaten)', out.apps.filter(a => (APPLIANCES[a.app].poles || 1) === 1).length); add('Installatieautomaat B16 1P+N (apparaten)', out.apps.filter(a => (APPLIANCES[a.app].poles || 1) === 1).length);
add('Installatieautomaat 2× B16, 2-fase (kookgroep)', out.apps.filter(a => APPLIANCES[a.app].poles === 2).length); add('Installatieautomaat 2× B16, 2-fase (kookgroep)', out.apps.filter(a => APPLIANCES[a.app].poles === 2).length);
add('Schakelaar enkelpolig (inbouw)', out.switches.length); add('Schakelaar enkelpolig (inbouw)' + kleur, out.switches.length);
Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig`, frames[n])); Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig` + kleur, frames[n]));
add('Wandcontactdoos dubbel met randaarde', out.wcds.length); add('Wandcontactdoos dubbel met randaarde' + kleur, out.wcds.length);
add('Vloerdoos met dubbele wcd (randaarde)', out.floorWcds.length); add('Vloerdoos met dubbele wcd (randaarde)' + kleur, out.floorWcds.length);
const appMat = {}; const appMat = {};
out.apps.forEach(a => { const n = APPLIANCES[a.app].material; appMat[n] = (appMat[n] || 0) + 1; }); out.apps.forEach(a => { const n = APPLIANCES[a.app].material; appMat[n] = (appMat[n] || 0) + 1; });
Object.keys(appMat).forEach(n => add(n, appMat[n])); Object.keys(appMat).forEach(n => add(n + kleur, appMat[n]));
add('Luchtdichte hollewanddoos (schakelaar/wcd/apparaat)', out.switches.length + 2 * out.wcds.length + out.apps.length); add('Luchtdichte hollewanddoos (schakelaar/wcd/apparaat)', out.switches.length + 2 * out.wcds.length + out.apps.length);
add('Luchtdichte verdeeldoos (plafond)', out.boxes.length); add('Luchtdichte verdeeldoos (plafond)', out.boxes.length);
add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length); add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length);
@@ -603,7 +806,7 @@ window.ElektraModel = (() => {
} }
// Uitzondering: het laatste stuk naar de meterkast; daar komen de buizen tegen elkaar aan de kast binnen. // Uitzondering: het laatste stuk naar de meterkast; daar komen de buizen tegen elkaar aan de kast binnen.
function separateRuns(runs) { function separateRuns(runs, blocked) {
const add = (p, d, k) => Object.assign({}, p, { x: p.x + d.x * k, y: p.y + d.y * k, z: p.z + d.z * k }); const add = (p, d, k) => Object.assign({}, p, { x: p.x + d.x * k, y: p.y + d.y * k, z: p.z + d.z * k });
const axisOf = (p, q) => { const axisOf = (p, q) => {
const dx = Math.abs(q.x - p.x), dy = Math.abs(q.y - p.y), dz = Math.abs(q.z - p.z); const dx = Math.abs(q.x - p.x), dy = Math.abs(q.y - p.y), dz = Math.abs(q.z - p.z);
@@ -674,6 +877,7 @@ window.ElektraModel = (() => {
const kk = sign * k * step, p2 = add(p, dir, kk), q2 = add(q, dir, kk), s2 = seg(r, p2, q2, rad); const kk = sign * k * step, p2 = add(p, dir, kk), q2 = add(q, dir, kk), s2 = seg(r, p2, q2, rad);
const j1 = seg(r, p, p2, rad), j2 = seg(r, q2, q, rad); const j1 = seg(r, p, p2, rad), j2 = seg(r, q2, q, rad);
if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue; if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue;
if (blocked && (blocked(p2, q2) || blocked(p, p2) || blocked(q2, q))) continue;
pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1)); pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1));
placed.push(s2, j1, j2); placed.push(s2, j1, j2);
i += 2; i += 2;
@@ -727,12 +931,13 @@ window.ElektraModel = (() => {
conduit conduit
}; };
} }
function build3D(el, materials, stage) { // rough: buizen en inbouwdozen (in de wand en boven het plafond); uit bij de render-export.
function build3D(el, materials, layers, rough) {
const toM = 0.001, group = new THREE.Group(); const toM = 0.001, group = new THREE.Group();
if (!el || !el.enabled) return group; if (!el || !el.enabled) return group;
const mats = materials || createMaterials(); const mats = materials || createMaterials();
const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key); mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex);
const rough = show('elektra') && !show('beplating'); if (!ViewLayers.shows(layers, 'elektra')) return group;
const box = (w, h, d, c, rotY, mat) => { const box = (w, h, d, c, rotY, mat) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat); const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat);
mesh.position.set(c.x * toM, c.y * toM, c.z * toM); mesh.position.set(c.x * toM, c.y * toM, c.z * toM);
@@ -741,24 +946,24 @@ window.ElektraModel = (() => {
return mesh; return mesh;
}; };
const k = el.meterkast; const k = el.meterkast;
if (k && show('elektra')) { if (k) {
box(k.w, k.h, k.d, k.center, k.rotY, mats.kastMaterial); box(k.w, k.h, k.d, k.center, k.rotY, mats.kastMaterial);
const front = facePoint(k.face, k.u, k.y + k.h / 2, k.d + 2); const front = facePoint(k.face, k.u, k.y + k.h / 2, k.d + 2);
box(k.w - 30, k.h - 30, 4, front, k.rotY, mats.deurMaterial); box(k.w - 30, k.h - 30, 4, front, k.rotY, mats.deurMaterial);
} }
if (show('afwerking')) { if (ViewLayers.shows(layers, 'afwerking')) {
el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial)); el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial));
el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial)); el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial));
el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial)); el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial));
el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial)); el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial));
} }
if (!rough) return group; if (rough === false) return group;
const up = new THREE.Vector3(0, 1, 0); const up = new THREE.Vector3(0, 1, 0);
const shift = (p) => ({ const shift = (p) => ({
x: p.x - (p.wall ? p.wall.n.x * WALL_IN : 0), y: p.y + (p.ceil ? CEIL_UP : 0), z: p.z - (p.wall ? p.wall.n.z * WALL_IN : 0) x: p.x - (p.wall ? p.wall.n.x * WALL_IN : 0), y: p.y + (p.ceil ? CEIL_UP : 0), z: p.z - (p.wall ? p.wall.n.z * WALL_IN : 0)
}); });
el.runs.forEach(r => { el.runs.forEach((r, ri) => {
const c = cableInfo(r.type); const c = cableInfo(r.type);
if (!c.conduit) return; if (!c.conduit) return;
const rad = c.conduit / 2 * toM; const rad = c.conduit / 2 * toM;
@@ -770,11 +975,13 @@ window.ElektraModel = (() => {
const m = new THREE.Mesh(new THREE.CylinderGeometry(rad, rad, len * toM, 6), mat); const m = new THREE.Mesh(new THREE.CylinderGeometry(rad, rad, len * toM, 6), mat);
m.position.set((p.x + q.x) / 2 * toM, (p.y + q.y) / 2 * toM, (p.z + q.z) / 2 * toM); m.position.set((p.x + q.x) / 2 * toM, (p.y + q.y) / 2 * toM, (p.z + q.z) / 2 * toM);
m.quaternion.setFromUnitVectors(up, new THREE.Vector3(dx / len, dy / len, dz / len)); m.quaternion.setFromUnitVectors(up, new THREE.Vector3(dx / len, dy / len, dz / len));
m.userData.run = ri;
group.add(m); group.add(m);
}); });
pts.slice(1, -1).forEach(p => { pts.slice(1, -1).forEach(p => {
const j = new THREE.Mesh(new THREE.IcosahedronGeometry(rad * 1.15, 0), mat); const j = new THREE.Mesh(new THREE.IcosahedronGeometry(rad * 1.15, 0), mat);
j.position.set(p.x * toM, p.y * toM, p.z * toM); j.position.set(p.x * toM, p.y * toM, p.z * toM);
j.userData.run = ri;
group.add(j); group.add(j);
}); });
}); });
@@ -848,7 +1055,7 @@ window.ElektraModel = (() => {
} else { } else {
n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length; n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length;
} }
} else if (!vertical && p.ceil && q.ceil) { } else if (!vertical && p.ceil && q.ceil && !(p.plenum && q.plenum)) {
const mid = { x: (p.x + q.x) / 2, z: (p.z + q.z) / 2 }, u = R.unitAt(mid.x, mid.z); const mid = { x: (p.x + q.x) / 2, z: (p.z + q.z) / 2 }, u = R.unitAt(mid.x, mid.z);
if (u) u.roof.planes.forEach(pl => { if (u) u.roof.planes.forEach(pl => {
const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds); const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds);
@@ -864,5 +1071,5 @@ window.ElektraModel = (() => {
}; };
} }
return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D }; return { DEFAULT_SETTINGS, PLATE_COLORS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D };
})(); })();
+132 -36
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 };
@@ -191,7 +293,7 @@ window.FloorModel = (() => {
} }
function finishThumbUrl(key, px) { function finishThumbUrl(key, px) {
const f = FINISHES[key]; const f = FINISHES[key];
return f && f.file ? f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png')) : null; return f && f.file ? tectumUrl(f.file.replace(/[^/]+$/, (n) => `thumb/${px || 64}/` + n.replace(/\.jpg$/, '.png'))) : null;
} }
const finishMaterials = {}; const finishMaterials = {};
function finishMaterial(key) { function finishMaterial(key) {
@@ -205,7 +307,7 @@ window.FloorModel = (() => {
} }
const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 }); const mat = new THREE.MeshStandardMaterial({ color: 0xb89a7c, roughness: 0.7 });
if (typeof document !== 'undefined' && THREE.TextureLoader) { if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(f.file, (tex) => { new THREE.TextureLoader().load(tectumUrl(f.file), (tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM); tex.repeat.set(1000 / f.tileMM, 1000 / f.tileMM);
tex.anisotropy = 8; tex.anisotropy = 8;
@@ -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
}; };
})(); })();
+36 -54
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" data-esc>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,19 +620,21 @@ function mountFloorEditor(container, callbacks) {
let lastResolvedOpenings = []; let lastResolvedOpenings = [];
let lastChassisData = { wheels: [], outOfBounds: false }; let lastChassisData = { wheels: [], outOfBounds: false };
createViewFilter(document.getElementById('floor-toolbar-3d'), [
{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }
], (v) => {
osbVisible = v.beplating;
update3DScene();
});
let skyMode = Skybox.getMode(); let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene); const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => { const applySky = () => {
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(); }); const bar = UI.editorBar(document.getElementById('floor-toolbar'), document.getElementById('floor-app'), {
onResize: () => onWindowResize(),
layers: {
layers: {}, keys: [],
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
onExtra: (v) => { osbVisible = v.beplating; update3DScene(); }
},
onSky: (m) => { skyMode = m; applySky(); }
});
function update3DScene() { function update3DScene() {
if (floorGroup3D) scene.remove(floorGroup3D); if (floorGroup3D) scene.remove(floorGroup3D);
@@ -691,14 +681,6 @@ function mountFloorEditor(container, callbacks) {
} }
window.addEventListener('resize', onWindowResize); window.addEventListener('resize', onWindowResize);
function toggleFullscreen3D() {
UI.cycle3D(document.getElementById('floor-app'));
requestAnimationFrame(onWindowResize);
}
function toggleFullscreen2D() {
UI.toggle2D(document.getElementById('floor-app'));
requestAnimationFrame(onWindowResize);
}
function getPureState() { function getPureState() {
return { return {
@@ -748,8 +730,8 @@ function mountFloorEditor(container, callbacks) {
}, },
exportDesign, exportDesign,
triggerImport, triggerImport,
toggleFullscreen3D, setView: bar.setView,
toggleFullscreen2D, viewState: bar.viewState,
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 };
})();
+393
View File
@@ -0,0 +1,393 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// CAD-export van de woning (DXF): plattegrond en vier gevels in modelruimte, 1:1 in mm, met lagen per onderdeel.
// Plattegrond: noord boven, zuidwesthoek van de units = 0,0. Gevels in een 2×2-raster eronder, maaiveld als basis.
// Tekst, maatvoering en lijntypes zijn afgestemd op plotten op 1:50.
window.HouseDxf = (() => {
const SCALE = 50;
const CUT = 1500; // snijvlak plattegrond boven de vloer: openingen die lager beginnen zijn doorgesneden
const LAYERS = {
'PL-WAND-BUITEN': [7], 'PL-WAND-OPBOUW': [8], 'PL-GEVELBEKLEDING': [32], 'PL-WAND-BINNEN': [7],
'PL-KOZIJNEN': [4], 'PL-DRAAIRICHTING': [151], 'PL-SPARING-BOVEN': [4, 'STREEP'],
'PL-RUIMTE-GRENS': [3], 'PL-RUIMTE-TEKST': [7], 'PL-INRICHTING': [8], 'PL-DAKRAND': [8, 'STREEP'],
'PL-PALEN': [32], 'PL-SCHOORSTEEN': [8], 'PL-VERLICHTING': [2], 'PL-ELEKTRA': [6], 'PL-NADEN': [30, 'STREEPSTIP'],
'PL-ELEMENTCODES': [8],
'AZ-GEVEL': [7], 'AZ-TRASRAAM': [7], 'AZ-KOZIJNEN': [4], 'AZ-DAK': [7], 'AZ-DAKRAND': [8], 'AZ-SCHOORSTEEN': [8], 'AZ-MAAIVELD': [7],
'MAATVOERING': [5], 'TEKST': [7]
};
const FACE_LAYERS = { wand: 'AZ-GEVEL', opening: 'AZ-GEVEL', trasraam: 'AZ-TRASRAAM', dak: 'AZ-DAK', dakrand: 'AZ-DAKRAND', schoorsteen: 'AZ-SCHOORSTEEN' };
const mm = (paper) => paper * SCALE;
// Intervallen [a, b]: samenvoegen en aftrekken.
function mergeIv(list) {
const s = list.filter(([a, b]) => b > a).sort((p, q) => p[0] - q[0]);
const out = [];
s.forEach(([a, b]) => { const l = out[out.length - 1]; if (l && a <= l[1] + 1e-9) l[1] = Math.max(l[1], b); else out.push([a, b]); });
return out;
}
function subtractIv(from, cuts) {
let out = from.map(iv => iv.slice());
mergeIv(cuts).forEach(([c0, c1]) => {
out = out.flatMap(([a, b]) => {
if (c1 <= a || c0 >= b) return [[a, b]];
const r = [];
if (c0 > a) r.push([a, c0]);
if (c1 < b) r.push([c1, b]);
return r;
});
});
return out;
}
// Randen van een ruimte (RoomModel: as x/z, waarde v, s0..s1) tot gesloten lussen.
function roomLoops(edges) {
const key = (p) => Math.round(p.x) + ',' + Math.round(p.z);
const segs = edges.map(e => e.axis === 'x' ? [{ x: e.v, z: e.s0 }, { x: e.v, z: e.s1 }] : [{ x: e.s0, z: e.v }, { x: e.s1, z: e.v }]);
const at = new Map();
segs.forEach((s, i) => s.forEach(p => { const k = key(p); if (!at.has(k)) at.set(k, []); at.get(k).push(i); }));
const used = new Set(), out = [];
segs.forEach((s, i) => {
if (used.has(i)) return;
used.add(i);
const loop = [s[0], s[1]];
let cur = s[1];
while (key(cur) !== key(loop[0])) {
const j = (at.get(key(cur)) || []).find(n => !used.has(n));
if (j === undefined) break;
used.add(j);
cur = key(segs[j][0]) === key(cur) ? segs[j][1] : segs[j][0];
loop.push(cur);
}
const closed = key(loop[loop.length - 1]) === key(loop[0]);
if (closed) loop.pop();
const n = loop.length;
const pts = loop.filter((p, k) => {
if (!closed && (k === 0 || k === n - 1)) return true;
const a = loop[(k - 1 + n) % n], b = loop[(k + 1) % n];
return !((Math.abs(a.x - p.x) < 0.5 && Math.abs(b.x - p.x) < 0.5) || (Math.abs(a.z - p.z) < 0.5 && Math.abs(b.z - p.z) < 0.5));
});
out.push({ pts, closed });
});
return out;
}
// Cirkelboog door het eerste, middelste en laatste punt van een polylijn (draaicirkel van een deur).
function arcThrough(pts) {
const a = pts[0], b = pts[Math.floor(pts.length / 2)], c = pts[pts.length - 1];
const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
if (Math.abs(d) < 1e-9) return null;
const s = (p) => p.x * p.x + p.y * p.y;
const ctr = { x: (s(a) * (b.y - c.y) + s(b) * (c.y - a.y) + s(c) * (a.y - b.y)) / d, y: (s(a) * (c.x - b.x) + s(b) * (a.x - c.x) + s(c) * (b.x - a.x)) / d };
const ang = (p) => Math.atan2(p.y - ctr.y, p.x - ctr.x);
const ccw = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) > 0;
return { c: ctr, r: Math.hypot(a.x - ctr.x, a.y - ctr.y), a0: ccw ? ang(a) : ang(c), a1: ccw ? ang(c) : ang(a) };
}
// Tekstrichting langs een wand, altijd leesbaar (van onder of van rechts).
const readable = (deg) => { let a = ((deg % 360) + 360) % 360; if (a > 90.01 && a <= 270.01) a -= 180; return a; };
function drawPlan(dxf, parts, T) {
const W2 = (place, lx, lz) => T(UnitModel.localToWorld(place, lx, lz));
const ext = new Set();
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.add(p))));
const P = KozijnModel.PROFILE;
HouseModel.allWallPieces(parts).forEach(p => {
const pl = p.place;
if (pl.baseY) return;
const isExt = ext.has(p);
const W = pl.width, zi = -pl.thickness / 2, zo = pl.thickness / 2;
const ops = (p.config.openings || []).filter(op => op.type !== 'schoorsteen');
const cutOps = ops.filter(op => op.y < CUT);
const holes = mergeIv(cutOps.map(op => [Math.max(-W / 2, -W / 2 + op.x), Math.min(W / 2, -W / 2 + op.x + op.w)]));
const solid = subtractIv([[-W / 2, W / 2]], holes);
const lay = isExt ? 'PL-WAND-BUITEN' : 'PL-WAND-BINNEN';
const box = (a, b, z0, z1, layer, eo) => dxf.polyline([W2(pl, a, z0), W2(pl, b, z0), W2(pl, b, z1), W2(pl, a, z1)], true, layer, eo);
solid.forEach(([a, b]) => box(a, b, zi, zo, lay));
const c = isExt ? WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc) : 0;
if (isExt) {
const sh = p.config.sheathingThick || 0;
if (sh > 0) solid.forEach(([a, b]) => [zi + sh, zo - sh].forEach(z => dxf.line(W2(pl, a, z), W2(pl, b, z), 'PL-WAND-OPBOUW')));
if (c > 0) {
// Hoek: buitenhoek loopt de bekleding door over de buurwand, binnenhoek stopt ze ervoor (skinExt < 0).
const e = p.skinExt || {};
const xl = -W / 2 - (e.left > 0 ? e.left + c : (e.left || 0)), xr = W / 2 + (e.right > 0 ? e.right + c : (e.right || 0));
subtractIv([[xl, xr]], holes).forEach(([a, b]) => box(a, b, zo, zo + c, 'PL-GEVELBEKLEDING'));
}
}
cutOps.forEach(op => {
const f = KozijnModel.normalize(op.fill);
if (!f) return;
const x0 = -W / 2 + op.x, x1 = x0 + op.w;
const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat';
const kz1 = zo, kz0 = exterior ? Math.max(zi, zo - P.depth) : zi;
const a = x0 + P.gap, b = x1 - P.gap;
if (f.type === 'raam') {
box(a, b, kz0, kz1, 'PL-KOZIJNEN');
[a + P.kader, b - P.kader].forEach(x => dxf.line(W2(pl, x, kz0), W2(pl, x, kz1), 'PL-KOZIJNEN'));
dxf.line(W2(pl, a + P.kader, kz1 - 50), W2(pl, b - P.kader, kz1 - 50), 'PL-KOZIJNEN');
return;
}
box(a, a + P.kader, kz0, kz1, 'PL-KOZIJNEN');
box(b - P.kader, b, kz0, kz1, 'PL-KOZIJNEN');
KozijnModel.planSymbol(op, op.fill, { zIn: zi, zOut: zo }).forEach(sh => {
const pts = sh.pts.map(q => W2(pl, x0 + q.x, q.z));
if (sh.kind === 'arc') {
const arc = arcThrough(pts);
if (arc) dxf.arc(arc.c, arc.r, arc.a0, arc.a1, 'PL-DRAAIRICHTING');
} else if (sh.kind === 'rail') dxf.polyline(pts, !!sh.closed, 'PL-DRAAIRICHTING', { ltype: 'STIP' });
else dxf.polyline(pts, !!sh.closed, sh.kind === 'arrow' ? 'PL-DRAAIRICHTING' : 'PL-KOZIJNEN');
});
});
// Sparingen boven het snijvlak (bovenlichten, hoge ramen) gestreept, tenzij ze boven een doorgesneden opening liggen.
ops.filter(op => op.y >= CUT).forEach(op => {
const x0 = -W / 2 + op.x, x1 = x0 + op.w;
if (holes.some(([a, b]) => x0 >= a - 1 && x1 <= b + 1)) return;
box(x0, x1, zi, zo, 'PL-SPARING-BOVEN');
});
// Elementcode binnen langs de wand, verticaal gecentreerd op 2,5 mm (papier) van het binnenvlak.
const rot = readable(Math.atan2(W2(pl, 1, 0).y - W2(pl, 0, 0).y, W2(pl, 1, 0).x - W2(pl, 0, 0).x) * 180 / Math.PI) * Math.PI / 180;
const mid = W2(pl, 0, zi - mm(2.5)), th = mm(2);
dxf.text({ x: mid.x + Math.sin(rot) * th / 2, y: mid.y - Math.cos(rot) * th / 2 }, th, p.element, 'PL-ELEMENTCODES', { align: 'center', rot: rot * 180 / Math.PI });
if (isExt) {
const xs = [-W / 2, W / 2];
holes.forEach(([a, b]) => xs.push(a, b));
const ticks = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((m, n) => m - n);
const zf = zo + c, row1 = zf + 700, row2 = row1 + 450;
for (let i = 0; i < ticks.length - 1; i++) {
dxf.dim(W2(pl, ticks[i], zf), W2(pl, ticks[i + 1], zf), W2(pl, (ticks[i] + ticks[i + 1]) / 2, row1), 'MAATVOERING');
}
if (ticks.length > 2) dxf.dim(W2(pl, -W / 2, zf), W2(pl, W / 2, zf), W2(pl, 0, row2), 'MAATVOERING');
}
});
const R = parts.rooms;
R.rooms.concat(R.free).forEach(r => {
roomLoops(r.edges || []).forEach(lp => dxf.polyline(lp.pts.map(T), lp.closed, 'PL-RUIMTE-GRENS'));
const named = !!r.name;
const lines = [[named ? r.name : 'Ruimte (geen functie)', 3.5], [`${r.areaM2.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} m²`, 2.5]];
if (r.ceilingActive) lines.push([`plafond ${r.ceiling}`, 2]);
const c = T(r.label), step = mm(4.5);
lines.forEach(([t, size], i) => dxf.text({ x: c.x, y: c.y + ((lines.length - 1) / 2 - i) * step - mm(size) / 2 }, mm(size), t, 'PL-RUIMTE-TEKST', { align: 'center' }));
});
(parts.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
const eo = sh.kind === 'dash' ? { ltype: 'STREEP' } : null;
if (sh.circle) dxf.circle(T(sh.circle), sh.circle.r, 'PL-INRICHTING', eo);
else if (sh.text) { const q = T(sh.at); dxf.text({ x: q.x, y: q.y - mm(0.7) }, mm(1.4), sh.text, 'PL-INRICHTING', { align: 'center' }); }
else dxf.polyline(sh.pts.map(T), !!sh.closed, 'PL-INRICHTING', eo);
}));
if (parts.roofEdge) {
parts.roofEdge.segments.forEach(sg => dxf.line(T(sg.outerA), T(sg.outerB), 'PL-DAKRAND'));
parts.roofEdge.spots.forEach(sp => dxf.circle(T(sp), mm(0.8), 'PL-VERLICHTING'));
} else {
parts.units.forEach(u => {
const r = u.roof.rect;
dxf.polyline([{ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 }].map(T), true, 'PL-DAKRAND');
});
}
parts.posts.posts.forEach(pp => {
const s = pp.size / 2;
dxf.polyline([{ x: pp.x - s, z: pp.z - s }, { x: pp.x + s, z: pp.z - s }, { x: pp.x + s, z: pp.z + s }, { x: pp.x - s, z: pp.z + s }].map(T), true, 'PL-PALEN');
});
parts.posts.walls.forEach(w => dxf.polyline([{ x: w.x0, z: w.z0 }, { x: w.x1, z: w.z0 }, { x: w.x1, z: w.z1 }, { x: w.x0, z: w.z1 }].map(T), true, 'PL-PALEN'));
(parts.chimneys || []).forEach(ch => {
dxf.polyline(ch.footprint.map(T), true, 'PL-SCHOORSTEEN');
dxf.polyline(ch.flueFootprint.map(T), true, 'PL-SCHOORSTEEN');
});
parts.lighting.lamps.forEach(l => {
const q = T({ x: l.x + l.normal.x * 80, z: l.z + l.normal.z * 80 }), s = mm(0.9);
dxf.polyline([{ x: q.x - s, y: q.y - s }, { x: q.x + s, y: q.y - s }, { x: q.x + s, y: q.y + s }, { x: q.x - s, y: q.y + s }], true, 'PL-VERLICHTING');
});
const o = T({ x: 0, z: 0 });
ElektraPdf.drawPlan(DxfWriter.pdfAdapter(dxf, 'PL-ELEKTRA'), parts.elektra, (x) => (x + o.x) / SCALE, (z) => (z - o.y) / SCALE);
parts.seams.forEach(s => dxf.line(T({ x: s.x0, z: s.z0 }), T({ x: s.x1, z: s.z1 }), 'PL-NADEN'));
parts.rects.forEach(r => {
const q = T({ x: r.x0 + 450, z: r.z0 + 450 });
dxf.text({ x: q.x, y: q.y - mm(8) }, mm(8), r.id, 'PL-ELEMENTCODES');
});
// Totaalmaten en noordpijl buiten alles.
const rects = parts.rects;
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1));
const bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
const e = dxf.extents();
dxf.dim(T({ x: bx0, z: bz0 }), T({ x: bx1, z: bz0 }), { x: 0, y: e.y1 + 700 }, 'MAATVOERING');
dxf.dim(T({ x: bx0, z: bz1 }), T({ x: bx0, z: bz0 }), { x: e.x0 - 700, y: 0 }, 'MAATVOERING');
const na = { x: e.x1 + mm(10), y: e.y1 - mm(4) }, ns = mm(4);
dxf.polyline([{ x: na.x, y: na.y + ns }, { x: na.x - ns * 0.4, y: na.y - ns * 0.6 }, { x: na.x, y: na.y - ns * 0.3 }, { x: na.x + ns * 0.4, y: na.y - ns * 0.6 }], true, 'TEKST');
dxf.text({ x: na.x, y: na.y - ns * 0.6 - mm(5) }, mm(3.5), 'N', 'TEKST', { align: 'center' });
}
function simplifyPoly(pts) {
let p = pts.filter((q, i) => { const r = pts[(i + 1) % pts.length]; return Math.hypot(r.x - q.x, r.y - q.y) > 0.5; });
let changed = true;
while (changed && p.length > 3) {
changed = false;
for (let i = 0; i < p.length; i++) {
const a = p[(i - 1 + p.length) % p.length], b = p[i], c = p[(i + 1) % p.length];
const len = Math.hypot(c.x - a.x, c.y - a.y);
if (len > 0 && Math.abs((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)) / len < 0.5) { p.splice(i, 1); changed = true; break; }
}
}
return p;
}
const insidePoly = (q, poly) => {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j];
if ((a.y > q.y) !== (b.y > q.y) && q.x < (b.x - a.x) * (q.y - a.y) / (b.y - a.y) + a.x) inside = !inside;
}
return inside;
};
const distSeg = (q, a, b) => {
const dx = b.x - a.x, dy = b.y - a.y, l2 = dx * dx + dy * dy;
const t = l2 ? Math.max(0, Math.min(1, ((q.x - a.x) * dx + (q.y - a.y) * dy) / l2)) : 0;
return Math.hypot(q.x - a.x - t * dx, q.y - a.y - t * dy);
};
// Echt binnen (niet op de rand): lijnen die samenvallen met de rand van een vlak ervoor blijven zichtbaar.
const strictlyInside = (q, poly) => insidePoly(q, poly) && poly.every((a, i) => distSeg(q, a, poly[(i + 1) % poly.length]) > 0.5);
// Delen [t0, t1] van segment a-b die door poly worden afgedekt.
function coveredBy(a, b, poly) {
const ts = [0, 1], dx = b.x - a.x, dy = b.y - a.y;
poly.forEach((c, i) => {
const d = poly[(i + 1) % poly.length], ex = d.x - c.x, ey = d.y - c.y;
const den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-9) return;
const t = ((c.x - a.x) * ey - (c.y - a.y) * ex) / den, s = ((c.x - a.x) * dy - (c.y - a.y) * dx) / den;
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t);
});
ts.sort((m, n) => m - n);
const out = [];
for (let i = 0; i < ts.length - 1; i++) {
const t0 = ts[i], t1 = ts[i + 1];
if (t1 - t0 < 1e-7) continue;
const tm = (t0 + t1) / 2;
if (!strictlyInside({ x: a.x + dx * tm, y: a.y + dy * tm }, poly)) continue;
const l = out[out.length - 1];
if (l && Math.abs(l[1] - t0) < 1e-7) l[1] = t1; else out.push([t0, t1]);
}
return out;
}
// Verborgen lijnen weg: vlakken van achter naar voor, elke rand wordt afgedekt door de vlakken die later komen.
function hiddenLines(faces) {
const boxOf = (pts) => ({ x0: Math.min(...pts.map(p => p.x)), x1: Math.max(...pts.map(p => p.x)), y0: Math.min(...pts.map(p => p.y)), y1: Math.max(...pts.map(p => p.y)) });
const hit = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
faces.forEach(f => { f.box = boxOf(f.poly); });
const out = [];
faces.forEach((f, i) => {
const segs = [];
const ring = (pts, layer) => pts.forEach((a, k) => segs.push({ a, b: pts[(k + 1) % pts.length], layer }));
ring(f.poly, f.layer);
f.detail.forEach(pl => ring(pl, 'AZ-KOZIJNEN'));
segs.forEach(sg => {
const len = Math.hypot(sg.b.x - sg.a.x, sg.b.y - sg.a.y);
if (len < 1) return;
const sb = boxOf([sg.a, sg.b]);
sb.x0 -= 1; sb.y0 -= 1; sb.x1 += 1; sb.y1 += 1;
let vis = [[0, 1]];
for (let j = i + 1; j < faces.length && vis.length; j++) {
if (!hit(sb, faces[j].box)) continue;
const cov = coveredBy(sg.a, sg.b, faces[j].poly);
if (cov.length) vis = subtractIv(vis, cov);
}
const at = (t) => ({ x: sg.a.x + (sg.b.x - sg.a.x) * t, y: sg.a.y + (sg.b.y - sg.a.y) * t });
vis.forEach(([t0, t1]) => { if ((t1 - t0) * len > 1) out.push({ a: at(t0), b: at(t1), layer: sg.layer }); });
});
});
return out;
}
// Samenvallende en in elkaars verlengde liggende stukken per laag samenvoegen (gedeelde randen maar één keer).
function mergeCollinear(segs) {
const groups = new Map();
segs.forEach(s => {
let dx = s.b.x - s.a.x, dy = s.b.y - s.a.y;
const len = Math.hypot(dx, dy);
dx /= len; dy /= len;
if (dx < -1e-9 || (Math.abs(dx) < 1e-9 && dy < 0)) { dx = -dx; dy = -dy; }
const off = -dy * s.a.x + dx * s.a.y;
const key = `${s.layer}|${Math.round(Math.atan2(dy, dx) * 2000)}|${Math.round(off)}`;
if (!groups.has(key)) groups.set(key, { dx, dy, off, layer: s.layer, iv: [] });
const g = groups.get(key);
const ta = s.a.x * dx + s.a.y * dy, tb = s.b.x * dx + s.b.y * dy;
g.iv.push([Math.min(ta, tb), Math.max(ta, tb)]);
});
const out = [];
groups.forEach(g => mergeIv(g.iv).forEach(([t0, t1]) => {
const p = (t) => ({ x: g.dx * t - g.dy * g.off, y: g.dy * t + g.dx * g.off });
out.push({ a: p(t0), b: p(t1), layer: g.layer });
}));
return out;
}
function drawViews(dxf, parts, state, planExt) {
const { groundY, views } = HousePdf.viewProjections(parts, state);
if (!views.length) return;
const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop));
const maxW = Math.max(...views.map(w => w.u1 - w.u0)), maxH = Math.max(...views.map(w => w.v1 - groundY));
const gapX = 5000, cellH = maxH + 4500;
views.forEach((w, i) => {
const col = i % 2, row = Math.floor(i / 2);
const ox = planExt.x0 + col * (maxW + gapX) - w.u0;
const base = planExt.y0 - 3500 - maxH - row * cellH;
const P = (u, v) => ({ x: ox + u, y: base + (v - groundY) });
const faces = w.list.map(l => ({
poly: simplifyPoly(l.pts.map(p => ({ x: p.u, y: p.v }))),
layer: FACE_LAYERS[l.f.kind] || 'AZ-GEVEL',
detail: (l.f.detail || []).map(pl => simplifyPoly(pl.map(p => ({ x: w.v.u(p), y: p.y }))))
})).filter(f => f.poly.length >= 3);
mergeCollinear(hiddenLines(faces)).forEach(s => dxf.line(P(s.a.x, s.a.y), P(s.b.x, s.b.y), s.layer));
dxf.line(P(w.u0 - 1000, groundY), P(w.u1 + 1000, groundY), 'AZ-MAAIVELD');
const left = P(w.u0, groundY), xd = left.x - 900;
if (floorTop - groundY > 1) dxf.dim(P(w.u0, groundY), P(w.u0, floorTop), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } });
dxf.dim(P(w.u0, floorTop), P(w.u0, w.v1), { x: xd, y: 0 }, 'MAATVOERING', { dir: { x: 0, y: 1 } });
const peil = P(w.u0, floorTop);
dxf.line({ x: xd - mm(10), y: peil.y }, { x: xd - mm(2), y: peil.y }, 'MAATVOERING');
dxf.text({ x: xd - mm(10), y: peil.y + mm(1) }, mm(2), 'peil', 'MAATVOERING');
dxf.dim(P(w.u0, groundY), P(w.u1, groundY), { x: 0, y: base - 700 }, 'MAATVOERING');
dxf.text(P((w.u0 + w.u1) / 2, groundY - 1300 - mm(5)), mm(5), w.v.title.toUpperCase(), 'TEKST', { align: 'center' });
});
}
function generate(state) {
const parts = HouseModel.computeParts(state, { skip3D: true });
if (!parts.units.length) throw new Error('geen units in het ontwerp');
const dxf = DxfWriter.create({ scale: SCALE });
Object.entries(LAYERS).forEach(([name, [color, ltype]]) => dxf.layer(name, color, ltype));
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
const T = (p) => ({ x: p.x - bx0, y: bz1 - p.z });
drawPlan(dxf, parts, T);
const planExt = dxf.extents();
dxf.text({ x: planExt.x0, y: planExt.y0 - mm(12) }, mm(5), 'PLATTEGROND', 'TEKST');
drawViews(dxf, parts, state, planExt);
const pr = state.project || {};
const version = typeof APP_VERSION !== 'undefined' ? ` ${APP_VERSION}` : '';
const info = [
pr.naam, [pr.straat, pr.nummer].filter(Boolean).join(' '), pr.plaats,
`Tectum${version}, ${new Date().toLocaleDateString('nl-NL')}`,
'Maten in mm, peil = bovenkant vloer; tekst en maatvoering voor 1:50.'
].filter(Boolean);
info.forEach((t, i) => dxf.text({ x: planExt.x1 + mm(30), y: planExt.y1 - mm(5) - i * mm(4.5) }, mm(i === 0 && pr.naam ? 3.5 : 2.5), t, 'TEKST'));
return dxf;
}
return { generate, LAYERS };
})();
+187
View File
@@ -0,0 +1,187 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// BIM-export van de bouwconstructie (IFC4): balken, platen en elektra, geen afwerking of inrichting.
// De geometrie komt uit de 3D-opbouw (dezelfde als in de app), in twee fasen: 'elektra' (alle balken en
// leidingen, nog zonder platen, dus niets weggelaten) en 'beplating' (alleen de platen).
// Assen: X = oost, Y = noord, Z = omhoog; 0,0 = zuidwesthoek van de units, Z = 0 op peil (bovenkant vloer).
// Opbouw: unit (samenstelling) > element A-W1 / A-V / A-D (samenstelling) > onderdelen; binnenwanden los.
window.HouseIfc = (() => {
const WOOD = 'Hout', ELEKTRA_KIND = { kast: 1, doos: 1, buis: 1 };
// Productiekleuren per soort (elektra houdt de kleur uit de app).
const COLORS = {
Stijl: [0.93, 0.74, 0.42], Regel: [0.85, 0.48, 0.18], Vloerbalk: [0.62, 0.40, 0.20], Dakbalk: [0.78, 0.30, 0.22],
Paal: [0.45, 0.28, 0.15], 'Stalen nokbalk': [0.35, 0.42, 0.55],
Spaanplaat: [0.40, 0.68, 0.42], OSB: [0.95, 0.86, 0.50], Cementplaat: [0.62, 0.64, 0.68]
};
const rgbOf = (mesh) => { const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; return m && m.color ? [m.color.r, m.color.g, m.color.b] : null; };
function materialsSet() {
return {
wall: WallModel.createMaterials(), floor: FloorModel.createMaterials(), roof: RoofModel.createMaterials(),
elektra: ElektraModel.createMaterials(), post: PostModel.createMaterials()
};
}
// Materiaal -> soort onderdeel (alles wat hier niet in staat, gaat niet mee).
function classifier(M) {
const map = new Map();
map.set(M.wall.woodMaterial, { kind: 'hout', where: 'wand' });
map.set(M.floor.woodMaterial, { kind: 'hout', where: 'vloer' });
map.set(M.roof.woodMaterial, { kind: 'hout', where: 'dak' });
map.set(M.roof.steelMaterial, { kind: 'staal' });
map.set(M.wall.spaanplaatMaterial, { kind: 'plaat', label: 'Spaanplaat' });
map.set(M.wall.cementplaatMaterial, { kind: 'plaat', label: 'Cementplaat' });
map.set(M.floor.osbMaterial, { kind: 'plaat', label: 'OSB' });
map.set(M.roof.osbMaterial, { kind: 'plaat', label: 'OSB' });
Object.entries(M.elektra.conduit).forEach(([type, m]) => map.set(m, { kind: 'buis', type }));
map.set(M.elektra.boxMaterial, { kind: 'doos' });
map.set(M.elektra.kastMaterial, { kind: 'kast' });
map.set(M.elektra.deurMaterial, { kind: 'kast' });
Object.values(M.post.wood).forEach(m => map.set(m, { kind: 'paal' }));
return (mesh) => {
const m0 = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
return m0 ? map.get((m0.userData && m0.userData.base) || m0) || null : null;
};
}
// rough: buizen en dozen bouwen; de plaatfase verzamelt alleen platen en heeft ze niet nodig.
function build(state, layers, rough) {
const M = materialsSet();
const parts = HouseModel.computeParts(state, {
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
layers, elektraRough: rough
});
HouseModel.placeGroups(parts);
return { parts, classify: classifier(M) };
}
function generate(state) {
const frame = build(state, ViewLayers.upTo(1), true);
const sheet = build(state, ViewLayers.upTo(2), false);
const parts = frame.parts;
if (!parts.units.length) throw new Error('geen units in het ontwerp');
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
const peil = Math.max(...parts.units.map(u => u.layout.floorTop));
const v = new THREE.Vector3(), ax = new THREE.Vector3(), box = new THREE.Box3(), size = new THREE.Vector3();
function triangles(mesh, out) {
const g = mesh.geometry, pos = g.attributes.position, idx = g.index;
const n = idx ? idx.count : pos.count;
for (let i = 0; i < n; i++) {
v.fromBufferAttribute(pos, idx ? idx.getX(i) : i).applyMatrix4(mesh.matrixWorld);
out.push(v.x * 1000 - bx0, bz1 - v.z * 1000, v.y * 1000 - peil);
}
return out;
}
// Maat in de eigen assen (afgeschuinde sporen houden zo lengte en dikte), oplopend in mm.
function dims(mesh) {
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
box.copy(mesh.geometry.boundingBox).getSize(size);
const s = [size.x * Math.abs(mesh.scale.x), size.y * Math.abs(mesh.scale.y), size.z * Math.abs(mesh.scale.z)].map(d => Math.round(d * 1000));
const longest = s.indexOf(Math.max(...s));
ax.set(longest === 0 ? 1 : 0, longest === 1 ? 1 : 0, longest === 2 ? 1 : 0).transformDirection(mesh.matrixWorld);
return { sorted: s.slice().sort((a, b) => a - b), vertical: Math.abs(ax.y) > 0.7 };
}
const label = (d) => d.sorted.join(' × ');
const groups = new Map(); // sleutel -> { name, tag, objectType, unit, members: [] }
const loose = [];
const merged = new Map(); // leidingen per run, meterkast
const groupOf = (key, info) => { if (!groups.has(key)) groups.set(key, Object.assign({ members: [] }, info)); return groups.get(key); };
function member(mesh, c, tag) {
const d = dims(mesh);
if (d.sorted[0] < 1) return null; // vlakken zonder dikte (kopse afdekking)
const tri = triangles(mesh, []);
const props = (name, material) => ({ Element: tag || null, Onderdeel: name, Afmeting: label(d), Lengte: d.sorted[2], Materiaal: material });
if (c.kind === 'plaat') {
const name = `${c.label} ${d.sorted[0]} mm`;
return { type: 'IFCPLATE', predefined: 'SHEET', name, material: c.label, tri, color: COLORS[c.label], props: props(name, c.label) };
}
const el = (type, predefined, name, material) => ({ type, predefined, name, material, tri, color: COLORS[name], props: props(name, material) });
if (c.kind === 'staal') return el('IFCBEAM', 'BEAM', 'Stalen nokbalk', 'Staal');
if (c.kind === 'paal') return el('IFCCOLUMN', 'COLUMN', 'Paal', WOOD);
if (c.where === 'vloer') return el('IFCBEAM', 'JOIST', 'Vloerbalk', WOOD);
if (c.where === 'dak') return el('IFCMEMBER', 'RAFTER', 'Dakbalk', WOOD);
return d.vertical ? el('IFCMEMBER', 'STUD', 'Stijl', WOOD) : el('IFCMEMBER', 'PLATE', 'Regel', WOOD);
}
function collect(src, group, plates, target) {
if (!group) return;
group.updateMatrixWorld(true);
group.traverseVisible(o => {
if (!o.isMesh) return;
const c = src.classify(o);
if (!c || (c.kind === 'plaat') !== plates) return;
if (ELEKTRA_KIND[c.kind]) {
if (c.kind === 'doos') {
loose.push({ type: 'IFCJUNCTIONBOX', predefined: 'POWER', name: 'Inbouwdoos', material: 'Kunststof', tri: triangles(o, []), color: rgbOf(o), props: { Onderdeel: 'Inbouwdoos', Materiaal: 'Kunststof' } });
return;
}
const key = c.kind === 'kast' ? 'kast' : `buis:${o.userData.run}`;
if (!merged.has(key)) {
merged.set(key, c.kind === 'kast'
? { type: 'IFCELECTRICDISTRIBUTIONBOARD', predefined: 'DISTRIBUTIONBOARD', name: 'Meterkast', material: 'Kunststof', tri: [], color: rgbOf(o), props: { Onderdeel: 'Meterkast' } }
: { type: 'IFCCABLECARRIERSEGMENT', predefined: 'CONDUITSEGMENT', name: `Installatiebuis ${c.type}`, material: `PVC ${c.type}`, tri: [], len: 0, color: rgbOf(o), props: { Onderdeel: `Installatiebuis ${c.type}`, Materiaal: 'PVC' } });
}
const e = merged.get(key);
triangles(o, e.tri);
if (e.len !== undefined && o.geometry.type === 'CylinderGeometry') e.len += o.geometry.parameters.height * 1000;
return;
}
const m = member(o, c, target && target.tag);
if (m) (target ? target.members : loose).push(m);
});
}
[[frame, false], [sheet, true]].forEach(([src, plates]) => {
const P = src.parts;
P.units.forEach(u => {
collect(src, u.floorGroup, plates, groupOf(u.floorElement, { name: `Unit ${u.id} vloer`, tag: u.floorElement, objectType: 'Vloerelement', unit: u.id }));
u.walls.forEach(w => w.pieces.forEach(p => {
collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Wandelement', unit: u.id }));
}));
collect(src, u.roof.group, plates, groupOf(`${u.id}-D`, { name: `Unit ${u.id} dak`, tag: `${u.id}-D`, objectType: 'Dakelement', unit: u.id }));
});
P.interior.forEach(p => collect(src, p.group, plates, groupOf(p.element, { name: p.label, tag: p.element, objectType: 'Binnenwand', unit: null })));
collect(src, P.postsGroup, plates, null);
collect(src, P.elektraGroup, plates, null);
});
merged.forEach(e => {
if (e.len) e.props.Lengte = Math.round(e.len);
delete e.len;
loose.push(e);
});
const pr = state.project || {};
const ifc = IfcWriter.create({
project: pr.naam || 'Woning', building: pr.naam || 'Woning', author: pr.tekenaar || '',
app: `Tectum${typeof APP_VERSION !== 'undefined' ? ' ' + APP_VERSION : ''}`,
address: { lines: [pr.straat, pr.nummer].filter(Boolean).join(' '), town: pr.plaats || '' }
});
const unitParts = new Map(parts.units.map(u => [u.id, []]));
groups.forEach(g => {
const ids = g.members.map(m => ifc.element(m)).filter(Boolean);
if (!ids.length) return;
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: g.name, tag: g.tag, objectType: g.objectType, props: { Element: g.tag } });
ifc.aggregate(asm, ids);
if (g.unit && unitParts.has(g.unit)) unitParts.get(g.unit).push(asm);
else ifc.contain(asm);
});
unitParts.forEach((ids, id) => {
if (!ids.length) return;
const asm = ifc.element({ type: 'IFCELEMENTASSEMBLY', name: `Unit ${id}`, tag: id, objectType: 'Unit' });
ifc.aggregate(asm, ids);
ifc.contain(asm);
});
loose.forEach(e => ifc.contain(ifc.element(e)));
return ifc;
}
return { generate };
})();
+1242 -77
View File
File diff suppressed because it is too large Load Diff
+117 -39
View File
@@ -32,7 +32,8 @@ window.HousePdf = (() => {
list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u }); list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u });
u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }))); u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })));
u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl })); u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl }));
if (u.roof.unitRoof && u.roof.unitRoof.cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u }); const cap = u.roof.unitRoof && u.roof.unitRoof.cap;
if (cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: cap.kind === 'join' ? `Unit ${u.id} dak (aansluiting)` : `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u });
}); });
parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })); parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }));
list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' }); list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' });
@@ -41,11 +42,13 @@ window.HousePdf = (() => {
return list; return list;
} }
// customer (light-versie): zonder onderdelenlijst, elementcodes en elektra; de tekening schuift dan naar links.
function drawPlanSheet(doc, parts, sheets, sheetOpts) { function drawPlanSheet(doc, parts, sheets, sheetOpts) {
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
const customer = !!sheetOpts.customer;
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'], if (!customer) PdfSheet.table(doc, 8, 14, [0, 16, 60], ['Element', 'Onderdeel', 'Blad'],
sheets.map((s, i) => [s.element, s.label, String(i + 1)]).filter(r => r[0] !== PLAN_ELEMENT), sheets.map((s, i) => [s.element, s.label, String(i + 1)]).filter(r => r[0] !== PLAN_ELEMENT),
{ title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 }); { title: 'Onderdelen', lastColW: 8, maxY: 285, rowH: 3.9, fontSize: 6 });
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
@@ -53,7 +56,7 @@ window.HousePdf = (() => {
const rects = parts.rects; const rects = parts.rects;
const bx0 = Math.min(...rects.map(r => r.x0)), bx1 = Math.max(...rects.map(r => r.x1)); 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 bz0 = Math.min(...rects.map(r => r.z0)), bz1 = Math.max(...rects.map(r => r.z1));
const drawX0 = 105, drawX1 = 405, drawY0 = 20, drawY1 = 228; const drawX0 = customer ? 15 : 105, drawX1 = 405, drawY0 = 20, drawY1 = 228;
const margin = 32; const margin = 32;
const scale = Math.min((drawX1 - drawX0 - margin * 2) / (bx1 - bx0), (drawY1 - drawY0 - margin * 2) / (bz1 - bz0)); const scale = Math.min((drawX1 - drawX0 - margin * 2) / (bx1 - bx0), (drawY1 - drawY0 - margin * 2) / (bz1 - bz0));
const ox = (drawX0 + drawX1) / 2 - ((bx0 + bx1) / 2) * scale; const ox = (drawX0 + drawX1) / 2 - ((bx0 + bx1) / 2) * scale;
@@ -86,7 +89,7 @@ window.HousePdf = (() => {
doc.setDrawColor(...BLACK); doc.setDrawColor(...BLACK);
doc.setFillColor(255, 255, 255); doc.setFillColor(255, 255, 255);
doc.setLineWidth(0.2); doc.setLineWidth(0.2);
p.config.openings.forEach(op => { p.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
const c = pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w); const c = pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w);
poly(c, 'FD'); poly(c, 'FD');
doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z)); doc.line(px(c[0].x), py(c[0].z), px(c[2].x), py(c[2].z));
@@ -134,6 +137,18 @@ window.HousePdf = (() => {
parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD')); parts.roofEdge.spots.forEach(sp => doc.circle(px(sp.x), py(sp.z), 0.8, 'FD'));
} }
doc.setLineDashPattern([], 0); doc.setLineDashPattern([], 0);
doc.setDrawColor(...BLACK);
doc.setLineWidth(0.2);
doc.setFillColor(160, 124, 84);
parts.posts.posts.forEach(pp => doc.rect(px(pp.x - pp.size / 2), py(pp.z - pp.size / 2), pp.size * scale, pp.size * scale, 'FD'));
doc.setFillColor(235, 235, 232);
parts.posts.walls.forEach(w => doc.rect(px(w.x0), py(w.z0), (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale, 'FD'));
(parts.chimneys || []).forEach(ch => {
doc.setFillColor(150, 150, 150);
poly(ch.footprint, 'FD');
doc.setFillColor(30, 30, 30);
poly(ch.flueFootprint, 'F');
});
doc.setDrawColor(...BLACK); doc.setDrawColor(...BLACK);
doc.setFillColor(255, 211, 107); doc.setFillColor(255, 211, 107);
@@ -142,7 +157,7 @@ window.HousePdf = (() => {
doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD'); doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD');
}); });
ElektraPdf.drawPlan(doc, parts.elektra, px, py); if (!customer) ElektraPdf.drawPlan(doc, parts.elektra, px, py);
doc.setDrawColor(230, 130, 30); doc.setDrawColor(230, 130, 30);
doc.setLineWidth(0.4); doc.setLineWidth(0.4);
@@ -160,13 +175,13 @@ window.HousePdf = (() => {
const b = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2); const b = UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2);
const x0 = px(a.x) + n.x * off, y0 = py(a.z) + n.y * off, x1 = px(b.x) + n.x * off, y1 = py(b.z) + n.y * off; const x0 = px(a.x) + n.x * off, y0 = py(a.z) + n.y * off, x1 = px(b.x) + n.x * off, y1 = py(b.z) + n.y * off;
PdfSheet.dimLine(doc, x0, y0, x1, y1); PdfSheet.dimLine(doc, x0, y0, x1, y1);
const text = `${p.element}: ${p.place.width}`; const text = customer ? String(p.place.width) : `${p.element}: ${p.place.width}`;
const tx = (x0 + x1) / 2 + n.x * 2.5, ty = (y0 + y1) / 2 + n.y * 2.5 + (n.y > 0 ? 1.5 : 0); const tx = (x0 + x1) / 2 + n.x * 2.5, ty = (y0 + y1) / 2 + n.y * 2.5 + (n.y > 0 ? 1.5 : 0);
if (n.y === 0) doc.text(text, tx, ty, { align: 'center', angle: 90 }); if (n.y === 0) doc.text(text, tx, ty, { align: 'center', angle: 90 });
else doc.text(text, tx, ty, { align: 'center' }); else doc.text(text, tx, ty, { align: 'center' });
}))); })));
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
parts.interior.forEach(p => { if (!customer) parts.interior.forEach(p => {
doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: Math.abs(Math.sin(p.place.rotY)) < 0.5 ? 0 : 90 }); doc.text(`${p.element}: ${p.place.width}`, px(p.place.cx), py(p.place.cz) - 1.5, { align: 'center', angle: Math.abs(Math.sin(p.place.rotY)) < 0.5 ? 0 : 90 });
}); });
@@ -213,7 +228,7 @@ window.HousePdf = (() => {
doc.setFontSize(6); doc.setFontSize(6);
doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' + doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' +
'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp, kleurvlak = ruimte (netto m²)', drawX0, drawY1 + 4); 'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp, kleurvlak = ruimte (netto m²)', drawX0, drawY1 + 4);
if (parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5); if (!customer && parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5);
} }
const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }; const hexRgb = (h) => { const n = parseInt(String(h).replace('#', ''), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; };
@@ -235,10 +250,10 @@ window.HousePdf = (() => {
const W = p.place.width, T = p.place.thickness; const W = p.place.width, T = p.place.thickness;
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), e = p.skinExt || {}; const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), e = p.skinExt || {};
const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0); const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0);
const tl = WallModel.topLine(p.config), up = p.skinAbove || 0; const tl = WallModel.topLine(WallModel.skinConfig(p.config, p.skinAbove || 0, p.skinTop));
const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up; const top = (x) => Math.min(tl.at(x), tl.at(Math.max(-W / 2, Math.min(W / 2, x))));
const loc = [{ x: xl, y: y0 }, { x: xr, y: y0 }, { x: xr, y: top(xr) }] const loc = [{ x: xl, y: y0 }, { x: xr, y: y0 }, { x: xr, y: top(xr) }]
.concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y + up }))) .concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y })))
.concat([{ x: xl, y: top(xl) }]); .concat([{ x: xl, y: top(xl) }]);
return loc; return loc;
}; };
@@ -246,22 +261,31 @@ window.HousePdf = (() => {
const addWall = (p, y0) => { const addWall = (p, y0) => {
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) : p.fels ? WallModel.felsHex(p.fels)
faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 }); : WallModel.rabatHex(state.settings.facade.rabatColor);
faces.push({ kind: 'wand', pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 });
const tr = p.trasraam; 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 });
// Afdekker: neus over de bovenkant van de steen (alleen in 3D onderbroken bij openingen).
if (p.stats && p.stats.trasraamCapM > 0) {
const C = WallModel.TRAS_CAP, yLo = tr.top - C.DROP_MM, yHi = tr.top + C.THICK_MM;
faces.push({ kind: 'trasraam', pts: [[-W / 2, yLo], [W / 2, yLo], [W / 2, yHi], [-W / 2, yHi]].map(([lx, ly]) => toWorld(p, lx, ly, zT + C.GAP_MM + C.THICK_MM)),
n, color: WallModel.trimHex(tr.capColor), bias: 1.5 });
} }
(p.config.openings || []).forEach(op => { }
(p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => {
if (WallModel.openingTopMax(op) <= y0) return; if (WallModel.openingTopMax(op) <= y0) return;
const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y); const x0 = -p.place.width / 2 + op.x, x1 = x0 + op.w, ya = Math.max(y0, op.y);
const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR); const ybL = Math.max(ya, op.y + op.h), ybR = Math.max(ya, op.y + op.hR);
const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur; const color = op.type === 'raam' ? VIEW_COLORS.glas : VIEW_COLORS.deur;
faces.push({ pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true }); const exterior = p.buitenMateriaal === 'vochtdoek' || p.buitenMateriaal === 'cementplaat';
const detail = op.fill ? KozijnModel.viewLines(op, op.fill, exterior).map(pl => pl.map(q => toWorld(p, x0 + q.x, op.y + q.y, zOut))) : [];
faces.push({ kind: 'opening', pts: [[x0, ya], [x1, ya], [x1, ybR], [x0, ybL]].map(([lx, ly]) => toWorld(p, lx, ly, zOut)), n, color, bias: 2, window: true, detail });
}); });
}; };
parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0)))); parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => addWall(p, 0))));
@@ -278,11 +302,11 @@ window.HousePdf = (() => {
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx; let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; } if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
const len = Math.hypot(nx, ny, nz) || 1; const len = Math.hypot(nx, ny, nz) || 1;
faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 }); faces.push({ kind: 'dak', pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 });
}; };
RoofModel.capRegions(r.unitRoof).forEach(rg => { RoofModel.capRegions(r.unitRoof).forEach(rg => {
pushRoof(rg.poly.map(pt => { pushRoof(rg.poly.map(pt => {
const x = u.rect.cx + (sh.axis === 'x' ? pt.x : pt.z), z = u.rect.cz + (sh.axis === 'x' ? pt.z : pt.x); const ax = r.unitRoof.alongX, x = u.rect.cx + (ax ? pt.x : pt.z), z = u.rect.cz + (ax ? pt.z : pt.x);
return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z }; return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z };
})); }));
}); });
@@ -299,10 +323,9 @@ window.HousePdf = (() => {
const edge = parts.roofEdge; const edge = parts.roofEdge;
if (edge) { if (edge) {
const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex;
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, z: a.z + (b.z - a.z) * t }); const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, z: a.z + (b.z - a.z) * t });
edge.segments.forEach(sg => { edge.segments.forEach(sg => {
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove; const h = RoofModel.edgeHeight(edge, sg);
const n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100)); const n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100));
const top = [], bottom = []; const top = [], bottom = [];
for (let k = 0; k <= n; k++) { for (let k = 0; k <= n; k++) {
@@ -311,9 +334,31 @@ 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 }); // Rooster op de gevel, onder de onderplaat.
if (sg.fascia && sg.facadeUnder && edge.vent) {
const V = RoofModel.VENT, d = RoofModel.ventFace(sg) + V.THICK_MM - (sg.a.x * sg.normal.x + sg.a.z * sg.normal.z);
const vTop = [];
for (let k = 0; k <= n; k++) {
const I = lerp(sg.a, sg.b, k / n), q = { x: I.x + sg.normal.x * d, z: I.z + sg.normal.z * d };
vTop.push({ x: q.x, y: sg.topAt(q) - h, z: q.z });
}
faces.push({ kind: 'dakrand', pts: vTop.concat(vTop.map(q => ({ x: q.x, y: q.y - V.HEIGHT_MM, z: q.z })).reverse()),
n: { x: sg.normal.x, z: sg.normal.z }, color: WallModel.trimHex(edge.ventColor), bias: 2.5 });
}
faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: RoofModel.edgeColor(edge, sg), bias: 3 });
}); });
} }
(parts.chimneys || []).forEach(ch => {
const S = ChimneyModel.SIDES[ch.side];
const W = (t, s, y) => S.axis === 'z' ? { x: ch.plane + S.n.x * s, y, z: t } : { x: t, y, z: ch.plane + S.n.z * s };
const color = ch.outside.type === 'steen' ? (WallModel.STONE_COLORS[ch.outside.stone] || '#8a6a5c') : WallModel.stucHex(ch.outside.stuc);
const tN = S.axis === 'z' ? { x: 0, z: 1 } : { x: 1, z: 0 };
const side = (a, b, ya, n) => faces.push({ kind: 'schoorsteen', pts: [W(a.t, a.s, ya), W(b.t, b.s, ya), W(b.t, b.s, ch.yTop), W(a.t, a.s, ch.yTop)], n, color, bias: 4 });
side({ t: ch.t0, s: ch.sFront }, { t: ch.t1, s: ch.sFront }, ch.yGround, S.n);
side({ t: ch.t0, s: ch.sIn }, { t: ch.t0, s: ch.sFront }, ch.yGround, { x: -tN.x, z: -tN.z });
side({ t: ch.t1, s: ch.sIn }, { t: ch.t1, s: ch.sFront }, ch.yGround, tN);
side({ t: ch.t0, s: ch.sIn }, { t: ch.t1, s: ch.sIn }, ch.yUnder, { x: -S.n.x, z: -S.n.z });
});
return faces; return faces;
} }
@@ -327,13 +372,15 @@ window.HousePdf = (() => {
UnitModel.normalizeSplits(u.unit.walls[side].splits).filter(sp => sp.facade).forEach(sp => facades.push(HouseModel.facadeFor(state, u, side, sp.facade))); UnitModel.normalizeSplits(u.unit.walls[side].splits).filter(sp => sp.facade).forEach(sp => facades.push(HouseModel.facadeFor(state, u, side, sp.facade)));
})); }));
facades.forEach(fc => { facades.forEach(fc => {
const key = fc.type === 'steen' ? 'steen:' + fc.stone : fc.type === 'stuc' ? 'stuc:' + fc.stuc : 'rabat'; const key = fc.type === 'steen' ? 'steen:' + fc.stone : fc.type === 'stuc' ? 'stuc:' + fc.stuc : fc.type === 'fels' ? 'fels:' + fc.fels : 'rabat';
if (seen.has(key)) return; if (seen.has(key)) return;
seen.add(key); seen.add(key);
items.push(fc.type === 'steen' items.push(fc.type === 'steen'
? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] } ? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] }
: fc.type === 'stuc' : fc.type === 'stuc'
? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) } ? { label: 'Gevel', value: `Stucwerk ${((WallModel.STUC_COLORS[fc.stuc] || {}).label || '').toLowerCase()}`, color: WallModel.stucHex(fc.stuc) }
: fc.type === 'fels'
? { label: 'Gevel', value: `Felsplaten ${((WallModel.FELS.COLORS[fc.fels] || {}).label || '').toLowerCase()} (staande naad)`, color: WallModel.felsHex(fc.fels) }
: { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) }); : { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) });
}); });
if (st.trasraam.enabled) { if (st.trasraam.enabled) {
@@ -341,7 +388,11 @@ window.HousePdf = (() => {
items.push(WallModel.isStoneFinish(tf) items.push(WallModel.isStoneFinish(tf)
? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] } ? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] }
: { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat }); : { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat });
const tc = WallModel.TRIM_COLORS[st.trasraam.capColor];
if (parts && tc && HouseModel.allWallPieces(parts).some(p => p.stats && p.stats.trasraamCapM > 0)) items.push({ label: 'Afdekker trasraam', value: tc.label, color: tc.hex });
} }
const vc = parts && parts.roofEdge && parts.roofEdge.vent && parts.roofEdge.ventLengthMM > 0 && WallModel.TRIM_COLORS[parts.roofEdge.ventColor];
if (vc) items.push({ label: 'Ventilatierooster', value: vc.label, color: vc.hex });
(parts ? parts.units : state.units).forEach(u => { (parts ? parts.units : state.units).forEach(u => {
const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer'); const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer');
const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer; const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer;
@@ -412,8 +463,8 @@ window.HousePdf = (() => {
}); });
} }
function drawViewsSheet(doc, parts, state, sheetOpts) { // Per gevel de zichtbare vlakken in (u, hoogte), van achter naar voor.
const { BLACK } = PdfSheet.COLORS; function viewProjections(parts, state) {
const faces = viewFaces(parts, state); const faces = viewFaces(parts, state);
const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0); const groundY = Math.max(...parts.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0);
const views = VIEWS.map(v => { const views = VIEWS.map(v => {
@@ -424,6 +475,12 @@ window.HousePdf = (() => {
const us = list.flatMap(l => l.pts.map(p => p.u)), vs = list.flatMap(l => l.pts.map(p => p.v)).concat([groundY]); const us = list.flatMap(l => l.pts.map(p => p.u)), vs = list.flatMap(l => l.pts.map(p => p.v)).concat([groundY]);
return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) }; return { v, list, u0: Math.min(...us), u1: Math.max(...us), v0: Math.min(...vs), v1: Math.max(...vs) };
}).filter(x => x.list.length); }).filter(x => x.list.length);
return { groundY, views };
}
function drawViewsSheet(doc, parts, state, sheetOpts) {
const { BLACK } = PdfSheet.COLORS;
const { groundY, views } = viewProjections(parts, state);
const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7; const X0 = 12, X1 = 408, Y0 = 14, Y1 = 248, gap = 10, titleH = 7;
const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2; const cellW = (X1 - X0 - gap) / 2, cellH = (Y1 - Y0 - gap) / 2;
@@ -457,7 +514,7 @@ window.HousePdf = (() => {
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
} }
const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', nokbalk: 'Nokbalk', kopbalk: 'Kopbalk' }; const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', kilkeper: 'Kilkeper', nokbalk: 'Nokbalk', knikbalk: 'Knikbalk', kopbalk: 'Kopbalk' };
function drawCapSheet(doc, u, sheetOpts) { function drawCapSheet(doc, u, sheetOpts) {
const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS;
const ur = u.roof.unitRoof, c = ur.cap; const ur = u.roof.unitRoof, c = ur.cap;
@@ -474,7 +531,8 @@ window.HousePdf = (() => {
}; };
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
doc.setFontSize(11); doc.setFontSize(11);
doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20); const join = c.kind === 'join';
doc.text(join ? 'Bovenaanzicht dak (aansluiting: kil- en hoekkepers, kreupele sporen)' : 'Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20);
doc.setDrawColor(...GREY); doc.setDrawColor(...GREY);
doc.setLineWidth(0.15); doc.setLineWidth(0.15);
c.regions.forEach(rg => poly(rg.poly.map(xz), 'S')); c.regions.forEach(rg => poly(rg.poly.map(xz), 'S'));
@@ -505,17 +563,22 @@ window.HousePdf = (() => {
const H = (p) => RoofModel.profileHeight(ur.shape.profile, p); const H = (p) => RoofModel.profileHeight(ur.shape.profile, p);
const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ','); const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ',');
const hip = c.regions.find(rg => rg.key === 'h1'); const hip = c.regions.find(rg => rg.key === 'h1');
const facts = [ const facts = (join ? [
['Goot (onderkant dak)', `${Math.round(c.eave)} mm`],
['Nok (onderkant dak)', `${Math.round(c.ridgeH)} mm`],
['Helling dakvlakken', `${deg(c.slope)}°`]
] : [
['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`], ['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`],
['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`], ['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`],
['Helling dakvlakken', `${deg(c.slope)}°`], ['Helling dakvlakken', `${deg(c.slope)}°`],
['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'], ['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-']
]).concat([
['Balken', `${c.joistW} x ${c.joistH} mm`], ['Balken', `${c.joistW} x ${c.joistH} mm`],
['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`], ['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`],
['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`] ['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`]
]; ]);
doc.setTextColor(...BLACK); doc.setTextColor(...BLACK);
let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 }); let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: join ? 'Dak met aansluiting' : 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 });
PdfSheet.table(doc, 290, y + 6, [0, 32, 56, 78], ['Soort', 'Lengte (mm)', 'Hoek (°)', 'Aantal'], rows, { title: 'Stuklijst balken', fontSize: 7, rowH: 4.6, lastColW: 14, maxY: 240 }); PdfSheet.table(doc, 290, y + 6, [0, 32, 56, 78], ['Soort', 'Lengte (mm)', 'Hoek (°)', 'Aantal'], rows, { title: 'Stuklijst balken', fontSize: 7, rowH: 4.6, lastColW: 14, maxY: 240 });
doc.setFontSize(7); doc.setFontSize(7);
doc.setTextColor(...GREY); doc.setTextColor(...GREY);
@@ -576,9 +639,15 @@ window.HousePdf = (() => {
rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']); rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']);
} }
if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]); if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]);
if (e.ventLengthM > 0) rows.push(['Ventilatierooster', `${fmt(e.ventLengthM, 2)} m op de gevel onder de boeidelen, ${RoofModel.VENT.HEIGHT_MM} mm hoog, ${e.ventColor.toLowerCase()}`]);
if (totals.trasraamCap && totals.trasraamCap.lengthM > 0) rows.push(['Afdekker trasraam', `${fmt(totals.trasraamCap.lengthM, 2)} m, ${totals.trasraamCap.color.toLowerCase()}`]);
rows.push(['Boeideel-spots', e.spots + ' stuks']); rows.push(['Boeideel-spots', e.spots + ' stuks']);
rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]); rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]);
rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]); rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]);
if (totals.posts) {
const pp = totals.posts;
rows.push(['Palen overkapping', `${pp.count} stuks ${pp.wood.toLowerCase()} ${pp.size}x${pp.size} mm, ${fmt(pp.lengthM, 2)} m, ${fmt(pp.m3, 3)} m3, op paalvoeten`]);
}
PdfSheet.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting')); PdfSheet.table(doc, 120, y, [0, 40], ['Onderdeel', 'Hoeveelheid'], rows, tableOpts('Dakrand & verlichting'));
} }
@@ -720,28 +789,37 @@ window.HousePdf = (() => {
PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts);
} }
function generate(state) { // opts.customer (light-versie): alleen aanzichten en plattegrond, zonder productie-informatie en kosten.
// opts.doc: verder in een bestaand document (nieuwe pagina's erachter); opts.pageOffset/pageTotal: bladnummering
// als er pagina's omheen komen (voorblad e.d.).
function generate(state, opts) {
const o = opts || {};
const customer = !!o.customer;
const parts = HouseModel.computeParts(state, { const parts = HouseModel.computeParts(state, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials() wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
elektraRough: false
}); });
const totals = HouseModel.computeTotals(parts); const sheets = sheetList(parts).filter(s => !customer || s.kind === 'views' || s.kind === 'plan');
const sheets = sheetList(parts); const totals = customer ? null : HouseModel.computeTotals(parts);
const cost = CostModel.compute(parts, totals); const cost = customer ? null : CostModel.compute(parts, totals);
const costOf = (element) => cost.elements.find(el => el.element === element); const costOf = (element) => cost.elements.find(el => el.element === element);
const drawWallWithCost = (sheet, opts) => { const drawWallWithCost = (sheet, opts) => {
WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [], WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [],
overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts)); overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts));
drawElementCost(doc, costOf(sheet.element)); drawElementCost(doc, costOf(sheet.element));
}; };
if (!customer) {
const detailPages = paginateCostRows(costDetailRows(cost)); const detailPages = paginateCostRows(costDetailRows(cost));
sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' }); sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' });
detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols })); detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols }));
}
const project = state.project; const project = state.project;
const doc = PdfSheet.createDoc(); const doc = o.doc || PdfSheet.createDoc();
const offset = o.pageOffset || 0, total = o.pageTotal || sheets.length;
sheets.forEach((sheet, i) => { sheets.forEach((sheet, i) => {
if (i > 0) doc.addPage('a3', 'landscape'); if (i > 0 || o.doc) doc.addPage('a3', 'landscape');
const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` }; const opts = { project, titel: sheet.label, element: sheet.element, blad: `${offset + i + 1} / ${total}`, customer };
if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts); if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts);
else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts); else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts);
else if (sheet.kind === 'floor') { else if (sheet.kind === 'floor') {
@@ -763,5 +841,5 @@ window.HousePdf = (() => {
return doc; return doc;
} }
return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS }; return { generate, sheetList, materialThumbUrls, viewFaces, viewProjections, VIEWS };
})(); })();
+655 -383
View File
File diff suppressed because it is too large Load Diff
+22 -14
View File
@@ -162,8 +162,8 @@ window.FixtureModel = (() => {
}, },
keuken: { keuken: {
label: 'Keukenblok', cat: 'keuken', w: 2660, d: 600, h: 910, resize: ['w', 'd', 'h'], minW: 600, wall: true, cost: 'keuken', label: 'Keukenblok', cat: 'keuken', w: 2660, d: 600, h: 910, resize: ['w', 'd', 'h'], minW: 600, wall: true, cost: 'keuken',
opts: { front: 'antraciet', spoelbak: 0.6, hogeKast: 'links', vaatwasser: true, bovenkasten: false }, opts: { front: 'antraciet', spoel: true, spoelbak: 0.6, kookplaat: false, kookplaatPlek: 0.2, hogeKast: 'links', vaatwasser: true, bovenkasten: false },
hint: 'Onderkasten met werkblad en spoelbak (plek: 0 = links, 1 = rechts, van voren gezien); hoge kast 600 mm met inbouwkoelkast en oven', hint: 'Onderkasten met werkblad, spoelbak en eventueel kookplaat (plek: 0 = links, 1 = rechts, van voren gezien); hoge kast 600 mm met inbouwkoelkast en oven',
build(f, g) { kitchenRun(g, f, false); }, build(f, g) { kitchenRun(g, f, false); },
plan(f) { return kitchenPlan(f, false); } plan(f) { return kitchenPlan(f, false); }
}, },
@@ -386,25 +386,32 @@ window.FixtureModel = (() => {
boxAt(g, front, a + 2, b - 2, 1790, 2195, zf - 22, zf - 2); boxAt(g, front, a + 2, b - 2, 1790, 2195, zf - 22, zf - 2);
} }
if (!island) { if (!island) {
// Spoelbak + kraan if (f.opts.spoel !== false) {
const sx = spoelX(f, K); const sx = spoelX(f, K);
boxAt(g, MAT.chroom(), sx - 380, sx + 380, top - 2, top + 0.5, zb + 90, zf - 70); boxAt(g, MAT.chroom(), sx - 380, sx + 380, top - 2, top + 0.5, zb + 90, zf - 70);
boxAt(g, MAT.rubber(), sx - 360, sx + 360, top - 1, top + 1, zb + 105, zf - 85); boxAt(g, MAT.rubber(), sx - 360, sx + 360, top - 1, top + 1, zb + 105, zf - 85);
ecyl(g, MAT.zwartMetaal(), sx + 200, zb + 55, 22, 22, top, top + 300); ecyl(g, MAT.zwartMetaal(), sx + 200, zb + 55, 22, 22, top, top + 300);
boxAt(g, MAT.zwartMetaal(), sx + 190, sx + 210, top + 280, top + 300, zb + 55, zb + 280); boxAt(g, MAT.zwartMetaal(), sx + 190, sx + 210, top + 280, top + 300, zb + 55, zb + 280);
}
if (f.opts.bovenkasten) boxAt(g, front, K.x0, K.x1, 1450, 2150, zb, zb + 350); if (f.opts.bovenkasten) boxAt(g, front, K.x0, K.x1, 1450, 2150, zb, zb + 350);
} else if (f.opts.kookplaat) { }
const hw = Math.min(780, K.x1 - K.x0 - 100), hd = Math.min(520, f.d - 80); if (f.opts.kookplaat) {
boxAt(g, MAT.kookplaat(), -hw / 2, hw / 2, top, top + 4, -hd / 2, hd / 2); const { hx, hw, hd } = hobOf(f, K, island);
hobZones(hw, hd).forEach(z => ecyl(g, MAT.ring(), z.x, z.z, z.r, z.r, top + 4, top + 5)); boxAt(g, MAT.kookplaat(), hx - hw / 2, hx + hw / 2, top, top + 4, -hd / 2, hd / 2);
hobZones(hw, hd).forEach(z => ecyl(g, MAT.ring(), hx + z.x, z.z, z.r, z.r, top + 4, top + 5));
} }
} }
const hobZones = (hw, hd) => [[-0.25, -0.25, 0.19], [0.25, -0.25, 0.14], [-0.25, 0.25, 0.14], [0.25, 0.25, 0.19]] const hobZones = (hw, hd) => [[-0.25, -0.25, 0.19], [0.25, -0.25, 0.14], [-0.25, 0.25, 0.14], [0.25, 0.25, 0.19]]
.map(([a, b, r]) => ({ x: a * hw, z: b * hd, r: r * Math.min(hw, hd * 1.4) })); .map(([a, b, r]) => ({ x: a * hw, z: b * hd, r: r * Math.min(hw, hd * 1.4) }));
function spoelX(f, K) { function plekX(K, v, def) {
const t = Math.max(0, Math.min(1, f.opts.spoelbak === undefined ? 0.6 : f.opts.spoelbak)); const t = Math.max(0, Math.min(1, v === undefined ? def : v));
return Math.max(K.x0 + 400, Math.min(K.x1 - 400, K.x0 + t * (K.x1 - K.x0))); return Math.max(K.x0 + 400, Math.min(K.x1 - 400, K.x0 + t * (K.x1 - K.x0)));
} }
const spoelX = (f, K) => plekX(K, f.opts.spoelbak, 0.6);
// Kookeiland: kookplaat in het midden; keukenblok: op kookplaatPlek.
const hobOf = (f, K, island) => ({
hx: island ? 0 : plekX(K, f.opts.kookplaatPlek, 0.2), hw: Math.min(780, K.x1 - K.x0 - 100), hd: Math.min(520, f.d - 80)
});
function kitchenPlan(f, island) { function kitchenPlan(f, island) {
const zb = -f.d / 2, zf = f.d / 2, K = kitchenModules(f, island), out = []; const zb = -f.d / 2, zf = f.d / 2, K = kitchenModules(f, island), out = [];
out.push(rectP(K.x0, K.x1, zb, zf)); out.push(rectP(K.x0, K.x1, zb, zf));
@@ -413,13 +420,14 @@ window.FixtureModel = (() => {
const a = K.tall === 'links' ? -f.w / 2 : f.w / 2 - 600; const a = K.tall === 'links' ? -f.w / 2 : f.w / 2 - 600;
out.push(rectP(a, a + 600, zb, zf), lineP(a, zb, a + 600, zf, 'thin'), lineP(a, zf, a + 600, zb, 'thin')); out.push(rectP(a, a + 600, zb, zf), lineP(a, zb, a + 600, zf, 'thin'), lineP(a, zf, a + 600, zb, 'thin'));
} }
if (!island) { if (!island && f.opts.spoel !== false) {
const sx = spoelX(f, K); const sx = spoelX(f, K);
out.push(rrectP(sx - 360, sx + 360, zb + 105, zf - 85, 40, 'thin'), circP(sx + 200, zb + 55, 22, 'thin')); out.push(rrectP(sx - 360, sx + 360, zb + 105, zf - 85, 40, 'thin'), circP(sx + 200, zb + 55, 22, 'thin'));
} else if (f.opts.kookplaat) { }
const hw = Math.min(780, K.x1 - K.x0 - 100), hd = Math.min(520, f.d - 80); if (f.opts.kookplaat) {
out.push(rectP(-hw / 2, hw / 2, -hd / 2, hd / 2, 'thin')); const { hx, hw, hd } = hobOf(f, K, island);
hobZones(hw, hd).forEach(z => out.push(circP(z.x, z.z, z.r, 'thin'))); out.push(rectP(hx - hw / 2, hx + hw / 2, -hd / 2, hd / 2, 'thin'));
hobZones(hw, hd).forEach(z => out.push(circP(hx + z.x, z.z, z.r, 'thin')));
} }
return out; return out;
} }
+349
View File
@@ -0,0 +1,349 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Light-versie (modelpagina's): hetzelfde model en dezelfde berekeningen als de configurator, met een beperkte set
// keuzes. Keuzes worden altijd opnieuw toegepast op een schone kopie van het model, zodat terug naar de
// oorspronkelijke keuze precies het model geeft. Een keuze die de indeling breekt (ramen en deuren die niet meer
// passen, binnenwanden of inrichting buiten de unit, ruimtes die verdwijnen) wordt geweigerd (check).
window.LightModel = (() => {
const LIM = UnitModel.LIMITS;
// Kaders (mm) als het model of de pagina niets opgeeft; altijd binnen UnitModel.LIMITS. Per model te
// beperken met FRAMES, per pagina met mount-optie frames (zelfde vorm).
const DEFAULT_FRAME = { width: [2400, LIM.long], depth: [2400, LIM.long], height: [2400, LIM.eave] };
const FRAMES = {
// amstel: { units: { A: { width: [9000, 13000], depth: null } }, height: [2600, 3000] }
};
const STEP = 50;
// Helling (graden) bij een andere dakvorm dan die van het model.
const PITCH = { lessenaar: 10, zadel: 30, wolf: 35 };
const FACADES = { rabat: 'Hout', fels: 'Felsplaten', stuc: 'Stucwerk', steen: 'Steenstrips' };
const STONES = ['K490', 'K915', 'K911', 'K764', 'K518', 'K706'];
const LIGHTING = { geen: 'Geen', spots: 'Spots in de dakrand', gevel: 'Gevellampen' };
// Punten tot zo ver van de oost- of zuidrand van een unit horen bij die gevel en schuiven mee.
const BAND = 450;
const clone = HouseModel.clone;
const rectMap = (st) => Object.fromEntries(HouseModel.unitRects(st).map(r => [r.id, r]));
const houseRoof = (st) => !!(st.settings.roof.house.enabled && st.settings.roof.house.form);
// Alle gevel-overrides van het model (per wand en per wandstuk): het "accent".
function accents(st) {
const out = [];
st.units.forEach(u => Object.entries(u.unit.walls).forEach(([side, ws]) => {
if (u.facade.walls[side]) out.push({ get: () => u.facade.walls[side], set: (v) => { if (v) u.facade.walls[side] = v; else delete u.facade.walls[side]; } });
(ws.splits || []).forEach(sp => { if (sp && sp.facade) out.push({ get: () => sp.facade, set: (v) => { sp.facade = v || null; } }); });
}));
return out;
}
const colorOf = (o) => o.type === 'steen' ? o.stone : o.type === 'stuc' ? o.stuc : o.type === 'fels' ? o.fels : null;
function frameOf(key, override) {
const f = Object.assign({}, FRAMES[key] || {}, override || {});
const clampR = (r, lim) => r && [Math.max(1000, Math.min(lim, r[0])), Math.min(lim, r[1])];
return {
unit: (id) => {
const u = (f.units || {})[id] || {};
const pick = (k, lim) => u[k] === null ? null : clampR(u[k] || DEFAULT_FRAME[k], lim);
return { width: pick('width', LIM.long), depth: pick('depth', LIM.long) };
},
height: clampR(f.height || DEFAULT_FRAME.height, LIM.eave)
};
}
// Model inladen: schone basis, oorspronkelijke dakvormen en de keuzes zoals het model ze heeft.
function create(key, opts) {
const def = HouseModel.MODELS[key];
if (!def) throw new Error(`onbekend model "${key}"`);
const base = def.create();
const forms = Object.fromEntries(base.units.map(u => [u.id, UnitModel.roofForm(u.unit)]));
if (houseRoof(base)) forms.house = Object.assign({}, base.settings.roof.house.form);
const f = base.settings.facade, acc = accents(base).map(a => a.get());
const covering = (base.units.find(u => RoofModel.COVERINGS[u.roof.covering] && RoofModel.COVERINGS[u.roof.covering].sloped) || base.units[0]).roof.covering;
const choices = {
units: Object.fromEntries(base.units.map(u => {
const fl = u.unit.floor.floor;
return [u.id, { width: fl.width, depth: fl.depth, low: forms[u.id].low, high: highOf(forms[u.id]), type: forms[u.id].type, cut: cutOf(forms[u.id]), dir: forms[u.id].dir || null, ridge: forms[u.id].ridge || null }];
})),
house: forms.house ? { low: forms.house.low, high: highOf(forms.house), type: forms.house.type, cut: cutOf(forms.house), dir: forms.house.dir || null, ridge: forms.house.ridge || null } : null,
covering: RoofModel.COVERINGS[covering] ? covering : 'dakleer',
facade: { type: f.type, rabat: f.rabatColor, cladding: base.settings.cladding, stuc: f.stucColor, fels: f.felsColor, stone: f.stone },
accent: acc.length ? { type: acc[0].type, color: colorOf(acc[0]) } : null,
// Trasraam: alleen geen of steen; een cementplaat-trasraam van het model wordt geen.
trasraam: (() => {
const t = base.settings.trasraam, stone = WallModel.isStoneFinish(t.finish) && t.finish !== 'cementplaat';
return { on: !!t.enabled && stone, finish: stone ? t.finish : null, height: t.height };
})(),
lighting: { mode: base.settings.lighting.mode, direction: base.settings.lighting.direction, indoor: base.settings.lighting.indoor !== false },
// Boeidelen (aan = boeidelen, uit = daktrim) met kleur; een model met beide houdt zijn eigen indeling tot je kiest.
fascia: { on: base.units.some(u => u.roof.fascia !== false), color: RoofModel.FASCIA_COLORS[base.settings.roof.fasciaColor] ? base.settings.roof.fasciaColor : 'antraciet' },
// Zetwerk: één kleur voor de afdekker op het trasraam en het ventilatierooster onder de boeidelen.
trim: WallModel.isTrimColor(base.settings.roof.ventColor) ? base.settings.roof.ventColor : 'antraciet'
};
const model = { key, label: def.label, base, forms, hasAccent: acc.length > 0, frame: frameOf(key, opts && opts.frames), initial: clone(choices) };
const st = build(model, choices);
model.reference = issuesOf(st, HouseModel.computeParts(st, { skip3D: true }));
return model;
}
// Wolfseind als verhouding tussen goot (0) en nok (1), zoals de configurator: schaalt mee met goot en nok.
const WOLF_CUT = 0.6;
const cutOf = (form) => form.type !== 'wolf' ? null
: form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - form.low) / Math.max(1, form.high - form.low) : WOLF_CUT;
// Nok (of hoge kant) als keuze; null = afgeleid (plat dak, of een andere dakvorm dan die van het model).
const RISE_MIN = 300;
const highOf = (form) => form.type === 'plat' ? null : form.high;
// Dakvorm bij een keuze (c: type, low, high, cut, dir, ridge): dezelfde vorm als het model houdt richting en
// dakhoogte boven de goot, een andere krijgt de helling uit PITCH over de korte kant (lessenaar hoog aan de noord-
// of westgevel, nok in de lengte). Een gekozen richting (c.dir, c.ridge) en nok (c.high) gaan voor.
function formFor(orig, c, W, D) {
const { type, low } = c;
const dir = (f) => {
if (type === 'lessenaar') f.dir = c.dir || orig.dir || (W >= D ? 'voor' : 'links');
if (UnitModel.hasRidge(type)) f.ridge = c.ridge || orig.ridge || (W >= D ? 'x' : 'z');
return f;
};
let f;
if (type === orig.type) {
f = dir(Object.assign({}, orig, { low }));
f.high = type === 'plat' ? low : Math.min(LIM.ridge, orig.high + (low - orig.low));
} else {
f = { type, low, high: low };
if (type === 'plat') return f;
dir(f);
const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D;
const run = UnitModel.hasRidge(type) ? span / 2 : span;
f.high = Math.round(Math.min(LIM.ridge, low + Math.tan(PITCH[type] * Math.PI / 180) * run));
}
if (type !== 'plat' && c.high > 0) f.high = Math.round(Math.min(LIM.ridge, Math.max(low + RISE_MIN, c.high)));
if (type === 'wolf') { f.cutRatio = c.cut > 0 ? c.cut : cutOf(orig) || WOLF_CUT; delete f.cut; }
return f;
}
// Helling (graden) van een dakvorm over zijn overspanning (W/D: maten van de unit of de woning).
function pitchOf(f, W, D) {
if (f.type === 'plat') return 0;
const gable = UnitModel.gableOf(f), span = gable === 'voor' || gable === 'achter' ? W : D;
return Math.atan((f.high - f.low) / ((UnitModel.hasRidge(f.type) ? span / 2 : span) || 1)) * 180 / Math.PI;
}
function houseSize(st) {
const xs = HouseModel.unitRects(st);
return [Math.max(...xs.map(r => r.x1)) - Math.min(...xs.map(r => r.x0)), Math.max(...xs.map(r => r.z1)) - Math.min(...xs.map(r => r.z0))];
}
// Dakvorm zoals build hem toepast: per unit (id), of over de hele woning (id null; maten uit de gebouwde st).
// Met pitch: de helling in graden.
function formOf(model, ch, id, st) {
const [W, D] = id ? [ch.units[id].width, ch.units[id].depth] : houseSize(st);
const f = formFor(id ? model.forms[id] : model.forms.house, id ? ch.units[id] : ch.house, W, D);
return Object.assign(f, { pitch: pitchOf(f, W, D) });
}
// Unit van een punt (woningcoördinaten): de rechthoek waar het punt het diepst in ligt.
function ownerOf(rects, x, z) {
let best = null, depth = -1;
Object.values(rects).forEach(r => {
const d = Math.min(x - r.x0, r.x1 - x, z - r.z0, r.z1 - z);
if (d > -1 && d > depth) { best = r.id; depth = d; }
});
return best;
}
// Na een maatwijziging: binnenwanden (woningcoördinaten) schuiven mee met hun unit; een eindpunt tegen de oost- of
// zuidgevel van een gewijzigde unit schuift mee met die gevel (de wand rekt mee). Inrichting staat al t.o.v. de
// noordwesthoek (of de oostgevel) van zijn unit; staat een stuk tegen de oost- of zuidgevel, dan schuift het mee.
function follow(st, before, after) {
const shift = (id, x, z) => {
const b = before[id], a = after[id];
let dx = a.x0 - b.x0, dz = a.z0 - b.z0;
if (a.W !== b.W && x >= b.x1 - BAND) dx = a.x1 - b.x1;
if (a.D !== b.D && z >= b.z1 - BAND) dz = a.z1 - b.z1;
return { x: Math.round(x + dx), z: Math.round(z + dz) };
};
st.interiorWalls.forEach(iw => {
if (iw.seam) return;
[['x1', 'z1'], ['x2', 'z2']].forEach(([kx, kz]) => {
const id = ownerOf(before, iw[kx], iw[kz]);
if (!id) return;
const p = shift(id, iw[kx], iw[kz]);
iw[kx] = p.x; iw[kz] = p.z;
});
});
st.fixtures.forEach(f => {
const b = before[f.unitId], a = after[f.unitId];
if (!b || !a) return;
const r = f.rot * Math.PI / 180, c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
const ex = c * f.w / 2 + s * f.d / 2, ez = s * f.w / 2 + c * f.d / 2;
if (f.anchor !== 'oost' && a.W !== b.W && f.x + ex >= b.W - BAND) f.x += a.W - b.W;
if (a.D !== b.D && f.z + ez >= b.D - BAND) f.z += a.D - b.D;
});
}
// Keuzes toepassen op een schone kopie van het model.
function build(model, ch) {
const st = clone(model.base);
const before = rectMap(st);
st.units.forEach(u => {
const c = ch.units[u.id], fl = u.unit.floor.floor;
fl.width = c.width; fl.depth = c.depth;
UnitModel.clampSize(fl);
});
const after = rectMap(st);
follow(st, before, after);
// Dakvorm en hoogte; binnenwanden gaan evenveel mee omhoog of omlaag als de goot.
const lift = {};
if (model.forms.house) {
st.settings.roof.house.form = formOf(model, ch, null, st);
st.units.forEach(u => { lift[u.id] = ch.house.low - model.forms.house.low; });
} else {
// Alleen opnieuw opbouwen wat afwijkt van het model; de rest blijft precies zoals het model het heeft.
st.units.forEach(u => {
const c = ch.units[u.id], c0 = model.initial.units[u.id], fl = u.unit.floor.floor;
lift[u.id] = c.low - model.forms[u.id].low;
if (['type', 'low', 'high', 'cut', 'dir', 'ridge', 'width', 'depth'].every(k => c[k] === c0[k])) return;
UnitModel.applyRoofForm(u.unit, formFor(model.forms[u.id], c, fl.width, fl.depth));
});
}
st.interiorWalls.forEach(iw => {
if (iw.seam) return;
const id = ownerOf(after, (iw.x1 + iw.x2) / 2, (iw.z1 + iw.z2) / 2), dh = id ? lift[id] : 0;
if (!dh) return;
const w = iw.wall.wall;
['heightAll', 'heightLeft', 'heightRight'].forEach(k => { if (w[k] > 0) w[k] += dh; });
});
if (ch.covering !== model.initial.covering) st.units.forEach(u => { u.roof.covering = ch.covering; });
const fc = ch.facade, F = st.settings.facade;
Object.assign(F, { type: fc.type, rabatColor: fc.rabat, stucColor: fc.stuc, felsColor: fc.fels, stone: fc.stone });
st.settings.cladding = fc.cladding;
Object.assign(st.settings.trasraam, { enabled: ch.trasraam.on }, ch.trasraam.on ? { finish: ch.trasraam.finish, height: ch.trasraam.height } : {});
if (model.hasAccent && JSON.stringify(ch.accent) !== JSON.stringify(model.initial.accent)) {
const a = ch.accent;
accents(st).forEach(o => o.set(a ? Object.assign({ type: a.type }, a.type === 'steen' ? { stone: a.color } : a.type === 'stuc' ? { stuc: a.color } : a.type === 'fels' ? { fels: a.color } : {}) : null));
}
Object.assign(st.settings.lighting, { mode: ch.lighting.mode, direction: ch.lighting.direction, indoor: ch.lighting.indoor });
st.settings.trasraam.capColor = st.settings.roof.ventColor = ch.trim;
if (ch.fascia.on !== model.initial.fascia.on) st.units.forEach(u => { u.roof.fascia = ch.fascia.on; });
st.settings.roof.fasciaColor = ch.fascia.color;
return st;
}
// Wat er aan de indeling schort: per opening (verschoven, ingeklemd, te hoog voor de wand of in geen enkel
// wandstuk meer), binnenwand buiten de units, inrichting door een gevel, verdwenen ruimtes en overlappende units.
// check vergelijkt met het model zelf, zodat wat daar al zo is geen keuze blokkeert.
function issuesOf(st, parts) {
const out = new Set();
parts.overlaps.forEach(p => out.add('units ' + p.join('+')));
parts.rects.forEach(r => { if (r.error) out.add('units ' + r.id); });
const ops = (key, config, placed) => config.openings.forEach(o => {
if (o.type === 'schoorsteen') return;
const W = config.wallWidth;
const want = o.anchor === 'right' ? W - o.offset - o.w : o.anchor === 'middle' ? (W - o.w) / 2 + o.offset : o.offset;
if (o.overflowing || Math.abs(o.x - want) > 2) out.add(`opening ${key}.${o.id} plek`);
if (o.h < o.rawH - 2 || o.hR < o.rawHR - 2 || o.y < o.rawY - 2) out.add(`opening ${key}.${o.id} hoogte`);
if (placed && !placed.has(o.id)) out.add(`opening ${key}.${o.id} kwijt`);
});
// Gevelopeningen staan t.o.v. de hele wand en worden daarna over de wandstukken verdeeld (UnitModel.wallPieces).
st.units.forEach(su => {
const pu = parts.units.find(u => u.id === su.id);
Object.entries(su.unit.walls).forEach(([side, ws]) => {
const w = pu && pu.walls.find(x => x.side === side);
ops(`${su.id}-${side}`, WallModel.configFromState(ws), new Set(w ? w.pieces.flatMap(p => p.config.openings.map(o => o.id)) : []));
});
});
parts.interior.forEach(p => ops(`bw${p.id}${p.seamUnit || ''}`, p.config));
const inside = (r, x, z, m) => r && x >= r.x0 + m && x <= r.x1 - m && z >= r.z0 + m && z <= r.z1 - m;
st.interiorWalls.filter(iw => !iw.seam).forEach(iw => {
if (![[iw.x1, iw.z1], [iw.x2, iw.z2]].every(([x, z]) => parts.rects.some(r => inside(r, x, z, -1)))) out.add(`indeling bw${iw.id}`);
});
parts.fixtures.forEach(it => {
const r = parts.rects.find(q => q.id === it.unitId);
if (it.corners.some(c => !inside(r, c.x, c.z, 100))) out.add(`indeling fx${it.id}`);
});
return { keys: out, rooms: parts.rooms.rooms.length };
}
// null = in orde, anders een melding voor de klant.
function check(model, st, parts) {
const now = issuesOf(st, parts), ref = model.reference;
const added = [...now.keys].filter(k => !ref.keys.has(k));
if (now.rooms < ref.rooms) added.push('indeling ruimtes');
if (!added.length) return null;
if (added.some(k => k.startsWith('units'))) return 'De units komen dan over elkaar heen.';
if (added.some(k => k.startsWith('opening'))) return 'De ramen en deuren van dit model passen dan niet meer.';
return 'De indeling van dit model past dan niet meer.';
}
// Grootste of kleinste maat tussen een geldige (ok) en een gevraagde waarde (want) die nog past; rekent zonder 3D.
function fit(model, ch, setter, ok, want) {
let good = ok, bad = want;
while (Math.abs(bad - good) > STEP) {
const mid = Math.round((good + bad) / 2 / STEP) * STEP;
if (mid === good || mid === bad) break;
const c = clone(ch);
setter(c, mid);
const st = build(model, c);
if (check(model, st, HouseModel.computeParts(st, { skip3D: true }))) bad = mid; else good = mid;
}
return good;
}
// Kerncijfers voor de klant: geen uren, zaagstukken of inkoop.
function summary(st, parts) {
const totals = HouseModel.computeTotals(parts);
const cost = CostModel.compute(parts, totals);
const d = HouseModel.computeDimensions(parts, st.settings.groundLevel);
return {
price: cost.summary.inclBtw, kg: totals.totalKg, units: st.units.length,
lengthX: d.lengthX, lengthZ: d.lengthZ, height: d.height, brutoM2: d.brutoM2, leefbaarM2: d.leefbaarM2, inhoudM3: d.inhoudM3
};
}
// De keuzes in woorden (voorblad van de PDF): [onderwerp, tekst].
// Lessenaar: hoge kant; zadel/wolf: richting van de nok (UnitModel.RIDGES).
const SIDES = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' };
const SIDE_NAMES = { voor: 'noord', achter: 'zuid', links: 'west', rechts: 'oost' };
const ROOF_NAMES = { plat: 'plat dak', lessenaar: 'lessenaardak', zadel: 'zadeldak', wolf: 'wolfsdak' };
const cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);
function colorName(type, key) {
if (type === 'steen') return `steen ${key}`;
const list = type === 'rabat' ? WallModel.RABAT_COLORS : type === 'stuc' ? WallModel.STUC_COLORS : WallModel.FELS.COLORS;
return list[key] ? list[key].label.toLowerCase() : '';
}
function describe(model, ch, st) {
const fc = ch.facade, ids = Object.keys(ch.units), multi = ids.length > 1;
const facade = (type, color) => [FACADES[type].toLowerCase(), type === 'rabat' ? fc.cladding : null, colorName(type, color)].filter(Boolean).join(', ');
const perUnit = (fn) => {
const vals = ids.map(id => fn(ch.units[id], id));
return !multi || vals.every(v => v === vals[0]) ? vals[0] : ids.map((id, i) => `unit ${id}: ${vals[i]}`).join(', ');
};
const sloped = ch.house ? ch.house.type !== 'plat' : ids.some(id => ch.units[id].type !== 'plat');
const nok = (f) => f.type === 'plat' ? '' : `, ${f.type === 'lessenaar' ? 'hoge kant' : 'nok'} ${nl(f.high / 1000)} m`;
const way = (f) => f.type === 'lessenaar' ? ` (hoog aan de ${SIDE_NAMES[f.dir]}kant)` : UnitModel.hasRidge(f.type) ? ` (nok ${UnitModel.RIDGES[f.ridge].toLowerCase()})` : '';
const rows = [
['Dakvorm', cap(ch.house ? `${ROOF_NAMES[ch.house.type]}${way(formOf(model, ch, null, st))} over de hele woning`
: perUnit((u, id) => ROOF_NAMES[u.type] + way(formOf(model, ch, id))))],
['Hoogte', cap(ch.house ? `goot ${nl(ch.house.low / 1000)} m${nok(formOf(model, ch, null, st))}` : perUnit((u, id) => `goot ${nl(u.low / 1000)} m${nok(formOf(model, ch, id))}`))],
['Dakbedekking', (RoofModel.COVERINGS[sloped ? ch.covering : 'dakleer'] || RoofModel.COVERINGS.dakleer).label],
['Gevel', cap(facade(fc.type, ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type]))]
];
const tr = ch.trasraam;
rows.push(['Trasraam', tr.on ? `Steen ${tr.finish}, ${nl(tr.height / 1000)} m hoog` : 'Geen']);
rows.push(['Dakrand', ch.fascia.on ? `Boeidelen ${RoofModel.FASCIA_COLORS[ch.fascia.color].label.toLowerCase()}` : 'Daktrim']);
const trimUse = trimUses(ch);
if (trimUse) rows.push(['Zetwerk', `${WallModel.TRIM_COLORS[ch.trim].label} (${trimUse})`]);
if (model.hasAccent) rows.push(['Accentgevel', ch.accent ? cap(facade(ch.accent.type, ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : 'Geen']);
rows.push(['Buitenverlichting', LIGHTING[ch.lighting.mode] + (ch.lighting.mode === 'gevel' ? ` (${LightingModel.DIRECTIONS[ch.lighting.direction].toLowerCase()})` : '')]);
rows.push(['Binnenverlichting', ch.lighting.indoor ? 'Inbouwspots in het plafond' : 'Geen']);
return rows;
}
const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Waar de zetwerk-kleur voor geldt (leeg: nergens).
const trimUses = (ch) => [ch.trasraam.on ? 'afdekker trasraam' : null, ch.fascia.on ? 'ventilatierooster' : null].filter(Boolean).join(' en ');
return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, trimUses, create, build, check, fit, summary, describe, colorName };
})();
+232
View File
@@ -0,0 +1,232 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// PDF van de light-versie: voorblad "Uw woningontwerp" (beeld, kerncijfers, keuzes, indicatieve prijs tenzij
// d.price === false, contact en QR-code naar de modelpagina), de aanzichten en plattegrond (HousePdf, klantvariant) en een pagina over de werkwijze.
window.LightPdf = (() => {
const COMPANY = {
naam: 'Van Emous Mantelzorgwoningen',
adres: ['Keizerswoert 19', '3881 LE Putten'],
telefoon: '0341 - 275 147',
email: 'info@vanemousmantelzorgwoningen.nl',
web: 'https://www.vanemousmantelzorgwoningen.nl',
open: 'Ma t/m vr 08:00 - 17:00 uur, za en zo op afspraak'
};
// Modellen met een eigen pagina op de site; andere verwijzen naar het overzicht.
const SITE_MODELS = ['amstel', 'ijssel', 'maas', 'reest', 'rijn', 'waal', 'wetering', 'eem'];
const modelUrl = (key) => SITE_MODELS.includes(key) ? `${COMPANY.web}/modellen/model-${key}/` : `${COMPANY.web}/modellen/`;
// Werkwijze, naar de site (werkwijze/stappenplan).
const STEPS = [
['Mag u plaatsen?', 'Wij toetsen vrijblijvend uw perceel aan de regels van uw gemeente en geven een eerste indicatie van de mogelijkheden. Voor zekerheid helpen wij u graag bij het overleg met de gemeente.'],
['Uw ontwerp uitwerken', 'U start vanuit een van onze standaardmodellen of vanuit uw eigen ontwerp. Onze specialisten werken uw wensen uit tot een productietekening. Omdat wij in onze eigen fabriek bouwen, is vrijwel elk ontwerp technisch haalbaar.'],
['Wij gaan bouwen', 'Zodra het ontwerp definitief is en de omgevingsvergunning er is, start de productie in onze fabriek. Tijdens openingstijden bent u welkom om de voortgang te komen bekijken.'],
['Transport en plaatsing', 'De afgebouwde modules worden naar uw perceel gebracht, op de fundering geplaatst en aangesloten op de nutsvoorzieningen. Na de laatste afwerking ontvangt u de sleutel.'],
['Oplevering en service', 'Samen lopen we de woning na, zodat alles klopt met het ontwerp en uw wensen. Ook na de oplevering blijven wij beschikbaar voor service en onderhoud.']
];
const MAATWERK = ['Dit is pas het begin: alles kan op maat',
'Dit ontwerp is gemaakt met een vereenvoudigde versie van onze configurator. Met onze eigen ontwerpsoftware werken wij uw woning ' +
'samen met u tot in detail uit: een keuken op maat, een andere indeling met extra binnenwanden, of ramen en deuren op een andere ' +
'plek en in een ander formaat. U bent van harte welkom voor een vrijblijvend adviesgesprek, waarin we samen uw wensen en ' +
'mogelijkheden doornemen. Ook staat onze fabriek in Putten open voor bezoek: kom kijken hoe onze woningen gebouwd worden en ervaar ' +
'de kwaliteit met eigen ogen. Maak gerust een afspraak; wij denken graag met u mee.'];
const ORANGE = [185, 89, 19], INK = [33, 33, 33], MUTED = [91, 102, 115], SOFT = [249, 246, 240], LINE = [226, 231, 238];
const PW = 420, PH = 297, M = 20;
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
// QR-code als vlakken (scherp bij elke vergroting), met witte rand.
function drawQr(doc, text, x, y, size) {
const qr = qrcode(0, 'M');
qr.addData(text);
qr.make();
const n = qr.getModuleCount(), quiet = 2, s = size / (n + 2 * quiet);
doc.setFillColor(255, 255, 255);
doc.rect(x, y, size, size, 'F');
doc.setFillColor(0, 0, 0);
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
if (!qr.isDark(r, c)) continue;
let e = c;
while (e + 1 < n && qr.isDark(r, e + 1)) e++;
doc.rect(x + (quiet + c) * s, y + (quiet + r) * s, (e - c + 1) * s + 0.02, s + 0.02, 'F');
c = e;
}
}
}
function logo(doc, x, y, h) {
const L = window.TECTUM_LOGO;
if (L) doc.addImage(L.png, 'PNG', x, y, h * L.aspect, h, 'tectum-logo', 'FAST');
else { doc.setFont('helvetica', 'bold'); doc.setFontSize(14); doc.setTextColor(...INK); doc.text(COMPANY.naam, x, y + h * 0.7); }
}
// Contactblok met QR-code; y = bovenkant.
function contactBand(doc, y, url, label) {
doc.setDrawColor(...LINE);
doc.setLineWidth(0.3);
doc.line(M, y, PW - M, y);
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.text(COMPANY.naam, M, y + 9);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(...MUTED);
const col = (x, lines) => lines.forEach((t, i) => doc.text(t, x, y + 16 + i * 5.2));
col(M, COMPANY.adres.concat([COMPANY.open]));
col(M + 95, [`Telefoon ${COMPANY.telefoon}`, COMPANY.email, COMPANY.web.replace(/^https:\/\//, '')]);
const q = 34;
drawQr(doc, url, PW - M - q, y + 4, q);
doc.setFontSize(9);
doc.setTextColor(...INK);
doc.text(doc.splitTextToSize(label, 70), PW - M - q - 4, y + 12, { align: 'right' });
doc.setTextColor(...MUTED);
doc.setFontSize(8);
doc.text(url.replace(/^https:\/\//, ''), PW - M - q - 4, y + 30, { align: 'right' });
}
function keyValues(doc, x, y, w, rows, title) {
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(...INK);
doc.text(title, x, y);
let yy = y + 8;
rows.forEach(([k, v]) => {
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(...MUTED);
doc.text(k, x, yy);
doc.setTextColor(...INK);
const lines = doc.splitTextToSize(String(v), w - 42);
doc.text(lines, x + 42, yy);
yy += 5.4 * lines.length + 0.8;
});
return yy;
}
function cover(doc, d) {
doc.setFillColor(...SOFT);
doc.rect(0, 0, PW, PH, 'F');
logo(doc, M, 18, 14);
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold');
doc.setFontSize(34);
doc.text('Uw woningontwerp', M, 62);
doc.setTextColor(...ORANGE);
doc.setFontSize(20);
doc.text(d.label, M, 74);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(...MUTED);
doc.text(`Samengesteld op ${new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })}`, M, 82);
const i = d.info;
let y = keyValues(doc, M, 98, 150, [
['Buitenmaten', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`], ['Bouwhoogte', meters(i.height)],
['Vloeroppervlak', `${nl(i.brutoM2, 1)} m² bruto, ${nl(i.leefbaarM2, 1)} m² leefbaar`], ['Inhoud', `${nl(i.inhoudM3, 0)} m³`],
['Modules', String(i.units)]
], 'Kerncijfers');
y = keyValues(doc, M, y + 6, 150, d.choices, 'Uw keuzes');
// Prijs: rustig, als de rest van de pagina; alleen het bedrag groter en vet.
const py = Math.max(y + 4, 196), price = d.price !== false;
if (price) {
doc.setDrawColor(...LINE);
doc.setLineWidth(0.3);
doc.line(M, py, M + 150, py);
doc.setTextColor(...MUTED);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text('Indicatieve prijs', M, py + 9);
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold');
doc.setFontSize(20);
doc.text(CostModel.eur(Math.round(i.price / 100) * 100), M, py + 19);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(...MUTED);
doc.text('incl. btw, onder voorbehoud', M, py + 25);
}
// Beeld van de woning
const bx = 190, by = 18, bw = PW - M - bx, bh = 206;
if (d.image) {
const ar = d.image.width / d.image.height, w = Math.min(bw, bh * ar), h = w / ar;
doc.addImage(d.image.url, 'JPEG', bx + (bw - w) / 2, by + (bh - h) / 2, w, h);
}
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
doc.setFontSize(7);
doc.setTextColor(...MUTED);
doc.text(`Dit ontwerp${price ? ' en de prijs zijn' : ' is'} indicatief; hieraan kunnen geen rechten worden ontleend.`, M, PH - 6);
}
function process(doc, d) {
doc.setFillColor(...SOFT);
doc.rect(0, 0, PW, PH, 'F');
logo(doc, M, 18, 14);
doc.setTextColor(...INK);
doc.setFont('helvetica', 'bold');
doc.setFontSize(28);
doc.text('Hoe verloopt het traject?', M, 62);
doc.setFont('helvetica', 'normal');
doc.setFontSize(12);
doc.setTextColor(...MUTED);
doc.text(doc.splitTextToSize('Van het eerste idee tot de sleuteloverdracht: wij begeleiden u in duidelijke stappen naar een mantelzorgwoning die bij u past. Dit ontwerp is de start van stap 2.', 260), M, 74);
const gap = 8, w = (PW - 2 * M - gap * (STEPS.length - 1)) / STEPS.length, y = 92, h = 98;
STEPS.forEach(([title, text], k) => {
const x = M + k * (w + gap);
doc.setFillColor(255, 255, 255);
doc.setDrawColor(...LINE);
doc.roundedRect(x, y, w, h, 3, 3, 'FD');
doc.setFillColor(...ORANGE);
doc.circle(x + 12, y + 14, 6.5, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(13);
doc.text(String(k + 1), x + 12, y + 14, { align: 'center', baseline: 'middle' });
doc.setTextColor(...INK);
doc.setFontSize(13);
doc.text(doc.splitTextToSize(title, w - 12), x + 6, y + 32);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(...MUTED);
doc.text(doc.splitTextToSize(text, w - 12), x + 6, y + 44, { lineHeightFactor: 1.4 });
});
// Maatwerk: dit ontwerp is een begin, de eigen software kan veel verder.
const by = 195, bh = 40;
doc.setFillColor(255, 255, 255);
doc.setDrawColor(...LINE);
doc.roundedRect(M, by, PW - 2 * M, bh, 3, 3, 'FD');
doc.setFillColor(...ORANGE);
doc.rect(M, by + 3, 2.5, bh - 6, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(13);
doc.setTextColor(...INK);
doc.text(MAATWERK[0], M + 9, by + 10);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10.5);
doc.setTextColor(...MUTED);
doc.text(doc.splitTextToSize(MAATWERK[1], PW - 2 * M - 18), M + 9, by + 17, { lineHeightFactor: 1.35 });
contactBand(doc, 240, d.url, `Bekijk ${d.label} online`);
}
// d: { st, label, info, choices: [[label, tekst]], url, image: { url, width, height } | null }
function generate(d) {
const doc = PdfSheet.createDoc();
cover(doc, d);
HousePdf.generate(d.st, { customer: true, doc, pageOffset: 1, pageTotal: 4 });
doc.addPage('a3', 'landscape');
process(doc, d);
return doc;
}
return { COMPANY, modelUrl, generate };
})();
+809
View File
@@ -0,0 +1,809 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Light-versie voor de modelpagina's: één model in 3D, een paar keuzes en de kerncijfers; opslaan = PDF met
// aanzichten en plattegrond. Alle logica in LightModel (en daarachter de gewone modellen).
// Inbedden: <div class="tectum-light" data-model="amstel" data-url="…"></div> (vanzelf gemount), of
// TectumLight.mount(el, { model, frames, url, modelViewer, save, price }); url = adres voor de QR-code in de PDF (standaard
// de modelpagina), modelViewer = adres van model-viewer (standaard js/vendor naast dit script), price: false (of
// data-price="false") = geen prijs in beeld en in de PDF; design().info.price blijft er wel in.
// save = { label, icon, onSave(ontwerp) } vervangt de PDF-knop (bijv. ontwerp aanvragen op de site); onSave krijgt
// { design, label, key, info, choices, url }: design = house-design-JSON zoals de configurator die opslaat, choices = de
// keuzes in woorden (zoals op het voorblad van de PDF). Zonder save blijft het de PDF.
// Knop rechtsboven in de weergave: op een telefoon/tablet het ontwerp als GLB in model-viewer, met AR (WebXR, Scene
// Viewer, Quick Look); op de desktop schermvullend.
window.TectumLight = (() => {
const esc = UI.esc;
// model-viewer (Apache-2.0, eigen three.js ingebakken) pas laden bij de eerste keer AR.
const SCRIPT_SRC = (document.currentScript && document.currentScript.src) || location.href;
let mvLoading = null;
function loadModelViewer(src) {
if (window.customElements.get('model-viewer')) return Promise.resolve();
if (!mvLoading) mvLoading = new Promise((ok, fail) => {
const s = document.createElement('script');
s.src = src;
s.onload = ok;
s.onerror = () => { mvLoading = null; fail(new Error(`model-viewer niet geladen (${src})`)); };
document.head.appendChild(s);
});
return mvLoading;
}
const AR_DEVICE = /android|iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d });
const meters = (mm) => `${nl(mm / 1000, 2)} m`;
const clone = (o) => JSON.parse(JSON.stringify(o));
// Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator.
const SECTION = { start: 1500, min: 300, max: 3000 };
const HINTS = { '3d': 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' };
// tl-wrap is het raster; .tectum-light zelf is de container voor de container queries (breedte van de tool, niet
// van het scherm: in de HUGO-site staat hij in een Bootstrap col-12).
const TEMPLATE = `<div class="tl-wrap">
<div class="tl-stage">
<div class="tl-3d"></div>
<canvas class="tl-plan" aria-label="Plattegrond, noord boven"></canvas>
<div class="tl-viewbar" role="group" aria-label="Weergave">
<button type="button" data-view="3d" class="on"><i class="bi bi-badge-3d"></i>3D</button>
<button type="button" data-view="cut" title="Wanden afgesneden op een hoogte boven de vloer"><i class="bi bi-scissors"></i>Doorsnede</button>
<button type="button" data-view="plan"><i class="bi bi-grid-1x2"></i>Plattegrond</button>
</div>
<label class="tl-cutbar"><span>Snijhoogte</span>
<input type="range" min="${SECTION.min}" max="${SECTION.max}" step="50" value="${SECTION.start}" aria-label="Snijhoogte boven de vloer">
<output>${meters(SECTION.start)}</output></label>
<button type="button" class="tl-xbtn" hidden></button>
<div class="tl-hint"></div>
<div class="tl-busy" aria-live="polite"><span class="tl-spinner"></span>Berekenen…</div>
<div class="tl-toast" role="status" aria-live="polite"></div>
</div>
<aside class="tl-panel">
<header class="tl-head"><h2 class="tl-title"></h2><div class="tl-sub"></div></header>
<div class="tl-sections"></div>
<div class="tl-facts"></div>
<div class="tl-buy"></div>
</aside></div>`;
function mount(root, opts) {
const o = opts || {};
const model = LightModel.create(o.model || root.dataset.model, { frames: o.frames });
const showPrice = o.price !== false && root.dataset.price !== 'false';
let ch = clone(model.initial);
let unitSel = model.base.units[0].id;
let cur = null; // { st, parts, info }
root.classList.add('tectum-light');
root.innerHTML = TEMPLATE;
const $ = (sel) => root.querySelector(sel);
$('.tl-title').textContent = model.label;
// 3D: afgewerkte woning (zoals de render-export), dag-omgeving.
const stage = $('.tl-3d');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdfe6ec);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 400);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
renderer.shadowMap.enabled = true;
renderer.localClippingEnabled = true;
stage.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(5, 8, 5);
scene.add(ambient, sun);
const sky = Skybox.createEnvironment(scene);
const mats = {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(),
chimneyMaterials: ChimneyModel.createMaterials(), elektraMaterials: ElektraModel.createMaterials()
};
const S0 = model.base.settings;
if (S0.interiorPaint) mats.wallMaterials.spaanplaatMaterial.color.set(S0.interiorColor);
mats.roofMaterials.ceilingMaterial.color.set(S0.ceilingColor || '#f4f3ef');
const compute = (st) => HouseModel.computeParts(st, Object.assign({ layers: ViewLayers.all(), finishedOnly: true }, mats));
const disposeGeo = (g) => g && g.traverse(x => { if (x.geometry) x.geometry.dispose(); });
const rootsOf = HouseModel.rootGroups;
// Weergave: 3D, doorsnede (dak en lampen weg, wanden en wat erin zit afgesneden, snede wit afgedekt) of plattegrond.
let group = null, caps = null, view = '3d', fitted = false, center = { x: 0, z: 0, size: 10 }, sectionMM = SECTION.start;
const poses = {};
const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
function clip(g, planes) {
if (g) g.traverse(x => (Array.isArray(x.material) ? x.material : x.material ? [x.material] : []).forEach(m => {
if ((m.clippingPlanes || []).length === planes.length) return;
m.clippingPlanes = planes;
m.needsUpdate = true;
}));
}
function applyView() {
if (!cur) return;
const p = cur.parts, cut = view === 'cut';
p.units.forEach(u => { if (u.roof.group) u.roof.group.visible = !cut; });
[p.roofEdgeGroup, p.postsGroup].forEach(g => { if (g) g.visible = !cut; });
if (p.roomsGroup && p.roomsGroup.userData.ceilings) p.roomsGroup.userData.ceilings.visible = !cut;
const planes = cut ? [cutPlane] : [];
HouseModel.allWallPieces(p).forEach(x => clip(x.group, planes));
// Lampen snijden mee: binnenspots onder de snijhoogte blijven zichtbaar, niets zweeft.
[p.elektraGroup, p.chimneysGroup, p.roomsGroup, p.fixturesGroup, p.lightingGroup].forEach(g => clip(g, planes));
placeCut();
if (view === 'plan') drawPlan();
}
// fn in de gewone 3D-weergave (dak erop, niets afgesneden), daarna terug naar de gekozen weergave.
function as3d(fn) {
const prev = view;
if (prev !== '3d') { view = '3d'; applyView(); }
try { return fn(); } finally { if (prev !== '3d') { view = prev; applyView(); } }
}
// Snijvlak en witte afdekking op de snijhoogte; alleen dit bij het schuiven.
function placeCut() {
const p = cur.parts, floorTop = Math.max(...p.units.map(u => u.layout.floorTop));
cutPlane.constant = (floorTop + sectionMM) * 0.001;
if (caps) { group.remove(caps); disposeGeo(caps); caps = null; }
if (view === 'cut') { caps = HouseModel.sectionCaps(p, floorTop + sectionMM, capMaterial, ViewLayers.all()); group.add(caps); }
}
const cutInput = $('.tl-cutbar input');
// Niet hoger dan de goot (plat dak: de dakhoogte); bij units met een andere goot de laagste, zodat de snede altijd
// door alle gevels gaat. Loopt mee met de gekozen hoogte.
function fitCutRange() {
const lows = ch.house ? [ch.house.low] : Object.values(ch.units).map(u => u.low);
const max = Math.max(SECTION.min, Math.floor(Math.min(...lows) / 50) * 50);
cutInput.max = max;
if (sectionMM > max) sectionMM = max;
cutInput.value = sectionMM;
$('.tl-cutbar output').textContent = meters(sectionMM);
}
cutInput.addEventListener('input', () => {
sectionMM = Number(cutInput.value);
$('.tl-cutbar output').textContent = meters(sectionMM);
if (cur) placeCut();
});
// Elke weergave onthoudt zijn eigen camerastand; de doorsnede begint schuin van boven.
function setView(v) {
if (v === view) return;
if (view !== 'plan') poses[view] = { pos: camera.position.clone(), target: controls.target.clone() };
view = v;
if (v !== 'plan') {
const pose = poses[v];
if (pose) { camera.position.copy(pose.pos); controls.target.copy(pose.target); }
else {
controls.target.set(center.x, 0.6, center.z);
camera.position.set(center.x - center.size * 0.45, center.size * 1.05 + 2, center.z + center.size * 0.75);
}
controls.update();
}
root.classList.toggle('view-plan', v === 'plan');
root.classList.toggle('view-cut', v === 'cut');
root.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('on', b.dataset.view === v));
$('.tl-hint').textContent = HINTS[v];
applyView();
}
$('.tl-hint').textContent = HINTS['3d'];
root.querySelector('.tl-viewbar').addEventListener('click', (e) => { const b = e.target.closest('[data-view]'); if (b) setView(b.dataset.view); });
function show(st, parts) {
if (group) { scene.remove(group); disposeGeo(group); caps = null; }
fitCutRange();
HouseModel.placeGroups(parts);
group = new THREE.Group();
rootsOf(parts).forEach(g => group.add(g));
scene.add(group);
cur = { st, parts, info: LightModel.summary(st, parts) };
const rs = parts.rects, toM = 0.001;
const bx0 = Math.min(...rs.map(r => r.x0)), bx1 = Math.max(...rs.map(r => r.x1));
const bz0 = Math.min(...rs.map(r => r.z0)), bz1 = Math.max(...rs.map(r => r.z1));
center = { x: (bx0 + bx1) / 2 * toM, z: (bz0 + bz1) / 2 * toM, size: Math.max(bx1 - bx0, bz1 - bz0) * toM };
const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - st.settings.groundLevel - 2) * toM;
sky.fitTo(group, groundY);
Skybox.apply('day', { env: sky, controls, sun, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: group });
if (!fitted) {
controls.target.set(center.x, 1.2, center.z);
camera.position.set(center.x - center.size * 0.9, center.size * 0.7, center.z - center.size * 1.3);
controls.update();
fitted = true;
}
applyView();
HousePdf.materialThumbUrls(st).forEach(PdfSheet.preloadImage);
renderPanel();
renderSummary();
}
// Plattegrond (noord boven): ruimtes met naam en netto m², wanden met kozijnen en draairichting, inrichting,
// dakrand, palen en de buitenmaten. Zelfde gegevens als de PDF-plattegrond.
function drawPlan() {
const cv = $('.tl-plan'), W = cv.clientWidth, H = cv.clientHeight;
if (!cur || !W || !H) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
const ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
const p = cur.parts, rs = p.rects;
const ext = rs.concat(p.units.map(u => u.roof.rect));
const bx0 = Math.min(...ext.map(r => r.x0)), bx1 = Math.max(...ext.map(r => r.x1));
const bz0 = Math.min(...ext.map(r => r.z0)), bz1 = Math.max(...ext.map(r => r.z1));
const top = 64, side = 56;
const scale = Math.min((W - 2 * side) / (bx1 - bx0), (H - top - side) / (bz1 - bz0));
const ox = W / 2 - (bx0 + bx1) / 2 * scale, oy = top + (H - top - side) / 2 - (bz0 + bz1) / 2 * scale;
const P = (x, z) => [ox + x * scale, oy + z * scale];
const path = (pts, closed) => { ctx.beginPath(); pts.forEach((q, i) => ctx[i ? 'lineTo' : 'moveTo'](...P(q.x, q.z))); if (closed) ctx.closePath(); };
const corners = (pl, lx0, lx1) => { const t = pl.thickness / 2; return [[lx0, -t], [lx1, -t], [lx1, t], [lx0, t]].map(([a, b]) => UnitModel.localToWorld(pl, a, b)); };
const rgba = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`;
ctx.lineJoin = 'round';
// Dakrand
ctx.setLineDash([5, 4]); ctx.strokeStyle = '#9aa3ad'; ctx.lineWidth = 1;
if (p.roofEdge) p.roofEdge.segments.forEach(sg => { path([sg.outerA, sg.outerB]); ctx.stroke(); });
else p.units.forEach(u => { const [x, y] = P(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(x, y, u.roof.rect.W * scale, u.roof.rect.D * scale); });
ctx.setLineDash([]);
// Ruimtes
const R = p.rooms;
R.rooms.forEach(r => { ctx.fillStyle = rgba(r.color, 0.22); r.rects.forEach(c => { const [x, y] = P(c.x0, c.z0); ctx.fillRect(x, y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); });
// Inrichting
(p.fixtures || []).forEach(it => FixtureModel.planShapes(it).forEach(sh => {
ctx.strokeStyle = '#8b939b';
ctx.lineWidth = sh.kind === 'thin' ? 0.6 : 1;
ctx.setLineDash(sh.kind === 'dash' ? [3, 3] : []);
if (sh.circle) { ctx.beginPath(); ctx.arc(...P(sh.circle.x, sh.circle.z), Math.max(1, sh.circle.r * scale), 0, Math.PI * 2); ctx.stroke(); return; }
if (sh.text) return;
path(sh.pts, sh.closed);
if (sh.fill) { ctx.fillStyle = 'rgba(139, 147, 155, 0.18)'; ctx.fill(); }
ctx.stroke();
}));
ctx.setLineDash([]);
// Wanden met openingen en kozijnsymbolen
const piece = (x, fill) => {
const pl = x.place, Wp = pl.width;
path(corners(pl, -Wp / 2, Wp / 2), true);
ctx.fillStyle = fill; ctx.fill();
x.config.openings.filter(op => op.type !== 'schoorsteen').forEach(op => {
path(corners(pl, -Wp / 2 + op.x, -Wp / 2 + op.x + op.w), true);
ctx.fillStyle = '#ffffff'; ctx.fill();
ctx.strokeStyle = '#33383d'; ctx.lineWidth = 0.8; ctx.stroke();
if (!op.fill) return;
KozijnModel.planSymbol(op, op.fill, { zIn: -pl.thickness / 2, zOut: pl.thickness / 2 }).forEach(sh => {
ctx.lineWidth = sh.kind === 'leaf' ? 1.3 : 0.8;
ctx.setLineDash(sh.kind === 'arc' || sh.kind === 'rail' ? [3, 3] : []);
path(sh.pts.map(q => UnitModel.localToWorld(pl, -Wp / 2 + op.x + q.x, q.z)), !!sh.closed);
ctx.stroke();
});
ctx.setLineDash([]);
});
};
p.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(x => piece(x, '#3d4247'))));
p.interior.forEach(x => piece(x, '#7a828a'));
ctx.fillStyle = '#a07c54';
p.posts.posts.forEach(pp => { const [x, y] = P(pp.x - pp.size / 2, pp.z - pp.size / 2); ctx.fillRect(x, y, pp.size * scale, pp.size * scale); });
p.posts.walls.forEach(w => { const [x, y] = P(w.x0, w.z0); ctx.fillStyle = '#3d4247'; ctx.fillRect(x, y, (w.x1 - w.x0) * scale, (w.z1 - w.z0) * scale); });
(p.chimneys || []).forEach(c => { path(c.footprint, true); ctx.fillStyle = '#8b8f93'; ctx.fill(); });
// Binnenspots
ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#a07a1e'; ctx.lineWidth = 0.8;
(p.lighting.indoor || []).forEach(sp => { ctx.beginPath(); ctx.arc(...P(sp.x, sp.z), Math.max(2.5, 90 * scale), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); });
// Ruimtenamen
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const fs = Math.max(9, Math.min(13, scale * 260));
R.rooms.forEach(r => {
const [x, y] = P(r.label.x, r.label.z);
ctx.fillStyle = '#212121'; ctx.font = `600 ${fs}px sans-serif`;
ctx.fillText(r.name, x, y - fs * 0.6);
ctx.fillStyle = '#5b6673'; ctx.font = `${fs * 0.9}px sans-serif`;
ctx.fillText(`${nl(r.areaM2, 1)} m²`, x, y + fs * 0.6);
});
// Buitenmaten (units) en noordpijl
const ux0 = Math.min(...rs.map(r => r.x0)), ux1 = Math.max(...rs.map(r => r.x1));
const uz0 = Math.min(...rs.map(r => r.z0)), uz1 = Math.max(...rs.map(r => r.z1));
ctx.strokeStyle = '#1d5f99'; ctx.fillStyle = '#1d5f99'; ctx.lineWidth = 1; ctx.font = '12px sans-serif';
const dim = (a, b, horiz, at) => {
const [x0, y0] = horiz ? P(a, 0) : P(0, a), [x1, y1] = horiz ? P(b, 0) : P(0, b);
const A = horiz ? [x0, at] : [at, y0], B = horiz ? [x1, at] : [at, y1];
ctx.beginPath(); ctx.moveTo(...A); ctx.lineTo(...B);
[A, B].forEach(([x, y]) => { ctx.moveTo(x - 4, y + 4); ctx.lineTo(x + 4, y - 4); ctx.moveTo(...(horiz ? [x, y - 6] : [x - 6, y])); ctx.lineTo(...(horiz ? [x, y + 6] : [x + 6, y])); });
ctx.stroke();
ctx.save();
ctx.translate((A[0] + B[0]) / 2, (A[1] + B[1]) / 2);
if (!horiz) ctx.rotate(-Math.PI / 2);
ctx.fillText(nl(Math.round(b - a)), 0, -9);
ctx.restore();
};
dim(ux0, ux1, true, P(0, bz0)[1] - 22);
dim(uz0, uz1, false, P(bx0, 0)[0] - 22);
const nx = W - 28, ny = top + 6;
ctx.beginPath(); ctx.moveTo(nx, ny); ctx.lineTo(nx - 7, ny + 18); ctx.lineTo(nx + 7, ny + 18); ctx.closePath(); ctx.fill();
ctx.fillText('N', nx, ny + 30);
}
// Keuze doorrekenen; past de indeling niet, dan blijft de vorige keuze staan. Bij een maat (fit) eerst de
// dichtstbijzijnde maat zoeken die nog wel past.
let toastTimer = null;
function toast(msg) {
const t = $('.tl-toast');
t.textContent = msg;
t.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('on'), 5000);
}
let busy = false;
function tryApply(next, fit) {
if (busy) return;
busy = true;
root.classList.add('is-busy');
setTimeout(() => {
try {
let st = LightModel.build(model, next), parts = compute(st);
let msg = LightModel.check(model, st, parts), note = null;
if (msg && fit) {
rootsOf(parts).forEach(disposeGeo);
const v = LightModel.fit(model, next, fit.set, fit.ok, fit.want);
if (v !== fit.ok) {
fit.set(next, v);
st = LightModel.build(model, next);
parts = compute(st);
note = `${msg} Dichtstbijzijnde maat die past: ${meters(v)}.`;
msg = LightModel.check(model, st, parts);
} else parts = null;
}
if (msg) {
if (parts) rootsOf(parts).forEach(disposeGeo);
toast(msg);
renderPanel();
} else {
ch = next;
show(st, parts);
if (note) toast(note);
}
} catch (err) {
console.error(err);
toast('Dit kon niet worden doorgerekend.');
renderPanel();
}
busy = false;
root.classList.remove('is-busy');
}, 30);
}
// Paneel: alleen keuzes, geen vrije invoer.
const field = (label, html, extra, cls) => `<div class="tl-field${cls ? ' ' + cls : ''}"><div class="tl-label"><span>${label}</span>${extra ? `<span class="tl-val">${extra}</span>` : ''}</div>${html}</div>`;
// disabled(k) geeft false of de reden (tooltip).
const seg = (key, options, value, disabled) => `<div class="tl-seg" role="radiogroup">${Object.entries(options).map(([k, label]) => {
const why = disabled && disabled(k);
return `<button type="button" role="radio" aria-checked="${k === value}" class="${k === value ? 'on' : ''}" data-set="${key}" data-val="${esc(k)}"${why ? ` disabled title="${esc(why)}"` : ''}>${esc(label)}</button>`;
}).join('')}</div>`;
// Kleurstalen; de naam van de gekozen kleur komt in de labelregel (swatchField).
const swatches = (key, items, value, disabled) => {
const sel = items.find(it => it.key === value);
return `<div class="tl-swatches" role="radiogroup" data-name="${sel ? esc(sel.label) : ''}">${items.map(it => {
const why = disabled && disabled(it.key);
return `<button type="button" role="radio" aria-checked="${it.key === value}" class="tl-sw${it.key === value ? ' on' : ''}" data-set="${key}" data-val="${esc(it.key)}" title="${esc(why ? `${it.label}: ${why}` : it.label)}" aria-label="${esc(it.label)}" style="--sw:${it.hex}"${why ? ' disabled' : ''}>${it.img ? `<img src="${esc(it.img)}" alt="" loading="lazy">` : ''}</button>`;
}).join('')}</div>`;
};
const swatchField = (label, html) => field(label, html, (html.match(/data-name="([^"]*)"/) || [])[1]);
// Het bereik omvat altijd de maat van het model zelf; extra staat achter de waarde (bijv. de helling).
const slider = (key, label, value, range, hint, extra) => {
const lo = Math.min(range[0], value), hi = Math.max(range[1], value);
return `<label class="tl-slider" title="${esc(hint || '')}">
<span class="tl-sl-head"><span>${label}</span><output>${meters(value)}${extra || ''}</output></span>
<input type="range" data-num="${key}" min="${lo}" max="${hi}" step="${LightModel.STEP}" value="${value}" aria-label="${esc(label.replace(/<[^>]+>/g, ''))}">
</label>`;
};
// Wolfseind: hoogte waarop de kopgevels stoppen (tussen goot en nok); daarboven de schuine wolfseinden.
const wolfSlider = (key, f) => {
const at = (r) => Math.round((f.low + r * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP;
return slider(key, 'Wolfseind', at(f.cutRatio), [at(0.05), at(0.95)], 'Hoogte waarop de kopgevels stoppen; daarboven de schuine wolfseinden tot de nok');
};
const colorItems = (obj) => Object.entries(obj).map(([key, c]) => ({ key, label: c.label, hex: c.hex }));
const facadeColors = (type, current) => {
if (type === 'rabat') return colorItems(WallModel.RABAT_COLORS);
if (type === 'stuc') return colorItems(WallModel.STUC_COLORS);
if (type === 'fels') return colorItems(WallModel.FELS.COLORS);
const codes = LightModel.STONES.includes(current) || !current ? LightModel.STONES : [current].concat(LightModel.STONES);
return codes.map(code => ({ key: code, label: `Steenstrip ${code}`, hex: WallModel.STONE_COLORS[code] || '#a08070', img: WallModel.stoneThumbUrl(code, 64) }));
};
const facadeColor = (fc) => ({ rabat: fc.rabat, stuc: fc.stuc, fels: fc.fels, steen: fc.stone })[fc.type];
const roofTypes = UnitModel.ROOF_TYPES;
// Plattegrondje om een unit te kiezen (noord boven).
function unitMap() {
const rs = cur.parts.rects, pad = 300;
const x0 = Math.min(...rs.map(r => r.x0)) - pad, x1 = Math.max(...rs.map(r => r.x1)) + pad;
const z0 = Math.min(...rs.map(r => r.z0)) - pad, z1 = Math.max(...rs.map(r => r.z1)) + pad;
const fs = Math.min(x1 - x0, z1 - z0) * 0.16;
return `<svg class="tl-map" viewBox="${x0} ${z0} ${x1 - x0} ${z1 - z0}" role="group" aria-label="Units, noord boven">${rs.map(r =>
`<g class="tl-map-u${r.id === unitSel ? ' on' : ''}" data-set="unit" data-val="${esc(r.id)}" role="button" tabindex="0" aria-label="Unit ${esc(r.id)}">
<rect x="${r.x0 + 40}" y="${r.z0 + 40}" width="${r.W - 80}" height="${r.D - 80}" rx="${fs * 0.2}"/>
<text x="${r.cx}" y="${r.cz}" font-size="${fs}" dy="0.35em" text-anchor="middle">${esc(r.id)}</text></g>`).join('')}</svg>`;
}
// Accordeon: één sectie open, in de kop een samenvatting van de keuze.
let openSec = 'maten';
const roofName = (t) => ({ plat: 'Plat dak', lessenaar: 'Lessenaardak', zadel: 'Zadeldak', wolf: 'Wolfsdak' })[t];
function renderPanel() {
if (!cur) return;
const multi = model.base.units.length > 1, houseRoof = !!ch.house;
const u = ch.units[unitSel], fr = model.frame.unit(unitSel);
const sloped = cur.parts.units.some(x => x.roof.shape.type !== 'plat');
const short = UnitModel.LIMITS.short;
// Een unit mag maar in één richting langer zijn dan de transportmaat.
const range = (r, other) => r && [r[0], other > short ? Math.min(r[1], short) : r[1]];
const wR = range(fr.width, u.depth), dR = range(fr.depth, u.width);
const sizeHtml = (multi ? `<div class="tl-mapbox">${unitMap()}<span class="tl-note">Klik op een unit</span></div>` : '') +
(wR ? slider('width', 'Breedte <small>west–oost</small>', u.width, wR) : '') +
(dR ? slider('depth', 'Diepte <small>noord–zuid</small>', u.depth, dR) : '');
// Dak: per unit (of één keer bij een dak over de hele woning) vorm, richting en hoogtes; de bedekking geldt overal.
const wolfOk = !houseRoof || HouseModel.houseRoofFits(cur.st);
const roofTypes = HouseModel.hasCover(cur.st, houseRoof ? null : unitSel)
? Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')) : UnitModel.ROOF_TYPES;
const K = houseRoof ? 'house' : 'unit', c = houseRoof ? ch.house : u, f = NUM[K + 'High'].form(ch);
const covItems = Object.entries(RoofModel.COVERINGS).map(([key, cv]) => ({ key, label: cv.label, hex: cv.color, img: RoofModel.coveringThumbUrl(key, 64) }));
const roofHtml = (multi && !houseRoof ? field('Unit', seg('unit', Object.fromEntries(model.base.units.map(x => [x.id, x.id])), unitSel), null, 'tl-units') : '') +
field('Dakvorm', seg(K + 'Type', roofTypes, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) +
(c.type === 'lessenaar' ? field('Hoge kant', seg(K + 'Dir', LightModel.SIDES, f.dir)) : '') +
(UnitModel.hasRidge(c.type) ? field('Nok', seg(K + 'Ridge', UnitModel.RIDGES, f.ridge)) : '') +
slider(K + 'Low', c.type === 'plat' ? 'Hoogte' : 'Goothoogte', c.low, model.frame.height, 'Vanaf de vloer tot onderkant dak, aan de lage kant') +
(c.type !== 'plat' ? slider(K + 'High', c.type === 'lessenaar' ? 'Hoge kant' : 'Nokhoogte', f.high,
[Math.ceil((f.low + 300) / LightModel.STEP) * LightModel.STEP, UnitModel.LIMITS.ridge], 'Vanaf de vloer tot onderkant dak', ` · ${nl(f.pitch, 0)}°`) : '') +
(c.type === 'wolf' ? wolfSlider(K + 'Cut', f) : '') +
swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer')) +
field('Dakrand', seg('fascia', { aan: 'Boeidelen', uit: 'Daktrim' }, ch.fascia.on ? 'aan' : 'uit')) +
(ch.fascia.on ? swatchField('Kleur boeidelen', swatches('fasciaColor',
Object.entries(RoofModel.FASCIA_COLORS).map(([key, c]) => ({ key, label: c.label, hex: '#' + c.hex.toString(16).padStart(6, '0') })), ch.fascia.color)) : '');
const fc = ch.facade;
const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) +
(fc.type === 'rabat' ? field('Richting', seg('cladding', WallModel.CLADDING_DIRECTIONS, fc.cladding)) : '') +
swatchField('Kleur', swatches('facadeColor', facadeColors(fc.type, fc.stone), facadeColor(fc))) +
(model.hasAccent ? field('Accent <small>afwijkende gevel van het model</small>', seg('accentType', Object.assign({ geen: 'Geen' }, LightModel.FACADES), ch.accent ? ch.accent.type : 'geen')) +
(ch.accent ? swatchField('Kleur accent', swatches('accentColor', facadeColors(ch.accent.type, ch.accent.color), ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : '') : '');
// Trasraam: strook onderaan de gevel (onder rabat én steen).
const tr = ch.trasraam;
const trasraamHtml = field('Trasraam <small>stenen strook onderaan de gevel</small>', seg('trasraamType', { geen: 'Geen', steen: 'Steen' }, tr.on ? 'steen' : 'geen')) +
(tr.on ? swatchField('Steen trasraam', swatches('trasraamStone', facadeColors('steen', tr.finish), tr.finish)) : '') +
(tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : '') +
(LightModel.trimUses(ch) ? swatchField(`Zetwerk <small>${LightModel.trimUses(ch)}</small>`,
swatches('trim', Object.entries(WallModel.TRIM_COLORS).map(([key, c]) => ({ key, label: c.label, hex: c.hex })), ch.trim)) : '');
const L = ch.lighting;
const lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) +
field('Buiten', seg('lightMode', LightModel.LIGHTING, L.mode)) +
(L.mode === 'gevel' ? field('Lichtrichting', seg('lightDir', LightingModel.DIRECTIONS, L.direction)) : '');
const i = cur.info;
const types = houseRoof ? [ch.house.type] : [...new Set(Object.values(ch.units).map(x => x.type))];
const covLabel = sloped ? RoofModel.COVERINGS[ch.covering].label : 'Dakleer';
const facadeSum = `${LightModel.FACADES[fc.type]} · ${LightModel.colorName(fc.type, facadeColor(fc))}${tr.on ? ' · trasraam' : ''}`;
const lightSum = [L.indoor ? 'Spots binnen' : null, L.mode === 'geen' ? 'geen buitenlampen' : LightModel.LIGHTING[L.mode].toLowerCase()].filter(Boolean).join(' · ');
const sec = (key, title, icon, sum, html) => {
const open = openSec === key;
return `<section class="tl-sec${open ? ' open' : ''}">
<button type="button" class="tl-sec-head" data-sec="${key}" aria-expanded="${open}"><i class="bi bi-${icon}"></i><span class="tl-sec-title">${title}</span>
<span class="tl-sec-sum">${esc(sum)}</span><i class="bi bi-chevron-down tl-chev"></i></button>
${open ? `<div class="tl-sec-body">${html}</div>` : ''}</section>`;
};
const el = $('.tl-sections'), scroll = el.scrollTop;
el.innerHTML = sec('maten', 'Afmetingen', 'rulers', `${meters(i.lengthX)} × ${meters(i.lengthZ)}`, sizeHtml) +
sec('dak', 'Dak', 'house', `${types.length > 1 ? 'Gemengd' : roofName(types[0])} · ${covLabel}`, roofHtml) +
sec('gevel', 'Gevel', 'bricks', facadeSum, facadeHtml + trasraamHtml) +
sec('licht', 'Verlichting', 'lightbulb', lightSum, lightHtml);
el.scrollTop = scroll;
}
function renderSummary() {
const i = cur.info;
$('.tl-sub').textContent = `${meters(i.lengthX)} × ${meters(i.lengthZ)} · ${nl(i.brutoM2, 1)} m² · ${meters(i.height)} hoog`;
const kv = (k, v) => `<div class="tl-kv"><span>${k}</span><b>${v}</b></div>`;
$('.tl-facts').innerHTML = kv('Vloer bruto', `${nl(i.brutoM2, 1)} m²`) + kv('Leefbaar', `${nl(i.leefbaarM2, 1)} m²`) +
kv('Inhoud', `${nl(i.inhoudM3, 0)} m³`) + kv('Gewicht', `${nl(Math.round(i.kg / 10) * 10, 0)} kg`);
$('.tl-buy').innerHTML = `<div class="tl-price">${showPrice ? `<span class="tl-price-val">${CostModel.eur(Math.round(i.price / 100) * 100)}</span><span class="tl-price-note">indicatief, incl. btw</span>` : ''}</div>
<button type="button" class="tl-btn tl-icon" data-act="reset" title="Alle keuzes terug naar het model" aria-label="Terug naar het model"><i class="bi bi-arrow-counterclockwise"></i></button>
${o.save ? `<button type="button" class="tl-btn tl-primary" data-act="save"><i class="bi bi-${esc(o.save.icon || 'send')}"></i>${esc(o.save.label || 'Opslaan')}</button>`
: '<button type="button" class="tl-btn tl-primary" data-act="pdf"><i class="bi bi-file-earmark-pdf"></i>PDF</button>'}`;
}
// Keuzes (knoppen en kleuren).
const panel = $('.tl-panel');
function choose(key, val) {
if (key === 'unit') { unitSel = val; renderPanel(); return; }
const R = (k) => k.startsWith('house') ? next.house : next.units[unitSel];
const next = clone(ch), fc = next.facade;
// Andere dakvorm: de nok volgt weer uit de helling (of uit het model, bij zijn eigen dakvorm).
if (key === 'unitType' || key === 'houseType') Object.assign(R(key), { type: val, high: null });
else if (key === 'unitDir' || key === 'houseDir') R(key).dir = val;
else if (key === 'unitRidge' || key === 'houseRidge') R(key).ridge = val;
else if (key === 'indoor') next.lighting.indoor = val === 'aan';
else if (key === 'covering') next.covering = val;
else if (key === 'facadeType') fc.type = val;
else if (key === 'cladding') fc.cladding = val;
else if (key === 'facadeColor') fc[{ rabat: 'rabat', stuc: 'stuc', fels: 'fels', steen: 'stone' }[fc.type]] = val;
else if (key === 'accentType') next.accent = val === 'geen' ? null : { type: val, color: val === 'rabat' ? null : facadeColor(Object.assign({}, fc, { type: val })) };
else if (key === 'accentColor') { if (next.accent.type === 'rabat') fc.rabat = val; else next.accent.color = val; }
else if (key === 'trasraamType') {
const t = next.trasraam;
t.on = val === 'steen';
if (t.on && !t.finish) t.finish = fc.type === 'steen' ? fc.stone : LightModel.STONES[0];
} else if (key === 'trasraamStone') next.trasraam.finish = val;
else if (key === 'trim') next.trim = val;
else if (key === 'fascia') next.fascia.on = val === 'aan';
else if (key === 'fasciaColor') next.fascia.color = val;
else if (key === 'lightMode') next.lighting.mode = val;
else if (key === 'lightDir') next.lighting.direction = val;
else return;
tryApply(next);
}
panel.addEventListener('click', (e) => {
const h = e.target.closest('[data-sec]');
if (h) { openSec = openSec === h.dataset.sec ? null : h.dataset.sec; renderPanel(); return; }
const b = e.target.closest('[data-set]');
if (b && !b.disabled) { choose(b.dataset.set, b.dataset.val); return; }
const a = e.target.closest('[data-act]');
if (!a || !cur) return;
if (a.dataset.act === 'pdf') savePdf();
else if (a.dataset.act === 'save') o.save.onSave(design());
else if (a.dataset.act === 'reset') tryApply(clone(model.initial));
});
panel.addEventListener('keydown', (e) => {
const g = e.target.closest('g[data-set]');
if (g && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); choose(g.dataset.set, g.dataset.val); }
});
// Maten: label volgt het schuiven, doorrekenen bij loslaten.
const NUM = {
width: { get: (c) => c.units[unitSel].width, set: (c, v) => { c.units[unitSel].width = v; } },
depth: { get: (c) => c.units[unitSel].depth, set: (c, v) => { c.units[unitSel].depth = v; } },
// De goot omhoog of omlaag neemt een gekozen nok mee (zelfde dak, hoger of lager).
unitLow: { get: (c) => c.units[unitSel].low, set: (c, v) => lift(c.units[unitSel], v) },
houseLow: { get: (c) => c.house.low, set: (c, v) => lift(c.house, v) },
trasraamHigh: { get: (c) => c.trasraam.height, set: (c, v) => { c.trasraam.height = v; } }
};
function lift(x, v) { if (x.high > 0) x.high += v - x.low; x.low = v; }
// Wolfseind: de keuze is een verhouding (goot 0, nok 1), de schuif een hoogte.
const unitForm = (c) => LightModel.formOf(model, c, unitSel), houseForm = (c) => LightModel.formOf(model, c, null, cur.st);
NUM.unitHigh = { form: unitForm, get: (c) => unitForm(c).high, set: (c, v) => { c.units[unitSel].high = v; } };
NUM.houseHigh = { form: houseForm, get: (c) => houseForm(c).high, set: (c, v) => { c.house.high = v; } };
[['unitCut', (c) => c.units[unitSel], unitForm], ['houseCut', (c) => c.house, houseForm]].forEach(([k, of, form]) => {
NUM[k] = {
form,
get: (c) => { const f = form(c); return Math.round((f.low + f.cutRatio * (f.high - f.low)) / LightModel.STEP) * LightModel.STEP; },
set: (c, v) => { const f = form(c); of(c).cut = Math.max(0.05, Math.min(0.95, (v - f.low) / Math.max(1, f.high - f.low))); }
};
});
panel.addEventListener('input', (e) => {
const r = e.target.closest('[data-num]');
if (r) r.closest('.tl-slider').querySelector('output').textContent = meters(Number(r.value));
});
panel.addEventListener('change', (e) => {
const r = e.target.closest('[data-num]');
if (!r) return;
const n = NUM[r.dataset.num], v = Number(r.value), old = n.get(ch);
if (v === old) return;
const next = clone(ch);
n.set(next, v);
tryApply(next, { set: n.set, ok: old, want: v });
});
// Beeld voor het voorblad: de 3D-weergave (ook vanuit doorsnede of plattegrond) op vaste resolutie.
function snapshot() {
const W = 1600, H = 1500, other = view !== '3d', pr = renderer.getPixelRatio();
const keep = { pos: camera.position.clone(), target: controls.target.clone() };
const url = as3d(() => {
if (other && poses['3d']) { camera.position.copy(poses['3d'].pos); controls.target.copy(poses['3d'].target); }
renderer.setPixelRatio(1);
renderer.setSize(W, H, false);
camera.aspect = W / H;
camera.updateProjectionMatrix();
controls.update();
renderer.render(scene, camera);
return renderer.domElement.toDataURL('image/jpeg', 0.88);
});
renderer.setPixelRatio(pr);
if (other) {
camera.position.copy(keep.pos);
controls.target.copy(keep.target);
controls.update();
}
resize();
return { url, width: W, height: H };
}
const pageUrl = o.url || root.dataset.url || LightPdf.modelUrl(model.key);
// Het huidige ontwerp voor buiten de tool (save.onSave, el.tectumLight.design()).
function design() {
const st = clone(cur.st);
st.project.naam = model.label;
st.project.opmerking = 'Ontwerp uit de light-versie';
return {
design: Object.assign({ type: 'house-design', version: 1, exportedAt: new Date().toISOString() }, st),
label: model.label, key: model.key, info: Object.assign({}, cur.info),
choices: LightModel.describe(model, ch, cur.st), url: pageUrl
};
}
function savePdf() {
const st = clone(cur.st);
st.project.naam = model.label;
st.project.opmerking = 'Indicatief ontwerp';
try {
let image = null;
try { image = snapshot(); } catch (err) { console.warn(err); }
LightPdf.generate({ st, label: model.label, info: cur.info, choices: LightModel.describe(model, ch, cur.st), url: pageUrl, image, price: showPrice })
.save(`${model.label.replace(/[^A-Za-z0-9_-]+/g, '-')}-${new Date().toISOString().slice(0, 10)}.pdf`);
} catch (err) {
console.error(err);
toast('De PDF kon niet worden gemaakt.');
}
}
// Prijzen komen uit de CSV's (async geladen): bijwerken zodra ze er zijn.
const offCosts = CostModel.onChange(() => { if (cur && cur.parts) { cur.info = LightModel.summary(cur.st, cur.parts); renderSummary(); } });
let visible = true, raf = 0;
function resize() {
const w = stage.clientWidth, h = stage.clientHeight;
if (!w || !h) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
const ro = new ResizeObserver(() => { resize(); if (view === 'plan') drawPlan(); });
ro.observe(stage);
// Alleen tekenen als de configurator in beeld is (modelpagina scrolt).
const io = new IntersectionObserver((es) => { visible = es.some(x => x.isIntersecting); });
// In een pagina: het scrollwiel scrolt de pagina, tenzij je in de 3D hebt geklikt (tot je de muis eruit haalt) of
// Ctrl/⌘ ingedrukt houdt (of schermvullend); dan zoomt het.
let engaged = false;
const stageEl = $('.tl-stage');
stageEl.addEventListener('pointerdown', () => { engaged = true; });
stageEl.addEventListener('pointerleave', () => { engaged = false; });
stageEl.addEventListener('wheel', (e) => {
if (engaged || e.ctrlKey || e.metaKey || view === 'plan' || fsEl() === stageEl) return;
e.stopPropagation();
toast('Klik eerst in de weergave (of houd Ctrl ingedrukt) om te zoomen.');
}, { capture: true });
io.observe(stage);
// GLB van het ontwerp (afgewerkte woning zoals in 3D, maaiveld onder het midden = oorsprong); één keer per ontwerp.
// Alles onder het maaiveld (sandwichpanelen, vloerbalken) valt weg: AR zet het laagste punt op de grond.
let glb = null;
function makeGlb() {
if (glb && glb.parts === cur.parts) return glb;
if (glb) URL.revokeObjectURL(glb.url);
const out = as3d(() => GlbWriter.build(rootsOf(cur.parts).filter(g => g !== cur.parts.sandwichGroup),
{ name: model.label, origin: HouseModel.groundOrigin(cur.st, cur.parts), clipBelow: 0 }));
glb = { parts: cur.parts, blob: out.blob, url: URL.createObjectURL(out.blob), stats: out.stats };
return glb;
}
// Knop rechtsboven: AR op een telefoon/tablet, schermvullend op de desktop.
const xbtn = $('.tl-xbtn');
const fsEl = () => document.fullscreenElement || document.webkitFullscreenElement;
const canFull = !!(stageEl.requestFullscreen || stageEl.webkitRequestFullscreen);
function setXbtn() {
const full = fsEl() === stageEl;
xbtn.innerHTML = `<i class="bi bi-${AR_DEVICE ? 'badge-ar' : full ? 'fullscreen-exit' : 'fullscreen'}"></i>`;
xbtn.title = AR_DEVICE ? 'Bekijk het ontwerp in je eigen tuin (AR)' : full ? 'Schermvullend uit (Esc)' : 'Schermvullend';
xbtn.setAttribute('aria-label', xbtn.title);
}
if (AR_DEVICE || canFull) { xbtn.hidden = false; root.classList.add('has-xbtn'); setXbtn(); }
function toggleFull() {
const r = fsEl() === stageEl ? (document.exitFullscreen || document.webkitExitFullscreen).call(document)
: (stageEl.requestFullscreen || stageEl.webkitRequestFullscreen).call(stageEl);
if (r && r.catch) r.catch(() => toast('Schermvullend lukt hier niet.'));
}
const onFull = () => { root.classList.toggle('is-full', fsEl() === stageEl); setXbtn(); };
document.addEventListener('fullscreenchange', onFull);
document.addEventListener('webkitfullscreenchange', onFull);
// AR: model-viewer in een eigen laag over de hele pagina (buiten de tool: die is een container en dus het
// kader van position: fixed). De AR-knop van model-viewer verschijnt alleen als het toestel AR kan.
const mvSrc = o.modelViewer || root.dataset.modelViewer || new URL('../vendor/model-viewer-umd.min.js', SCRIPT_SRC).href;
let arView = null, arOpen = false;
const AR_NOTE = 'Ware grootte · met twee vingers draaien, met één vinger verplaatsen';
function closeAr() {
if (!arOpen) return;
arOpen = false;
arView.classList.remove('on');
document.documentElement.classList.remove('tl-noscroll');
}
const arKey = (e) => { if (arOpen && e.key === 'Escape') closeAr(); };
document.addEventListener('keydown', arKey);
function showAr(url) {
if (!arView) {
arView = document.createElement('div');
arView.className = 'tl-arview';
arView.setAttribute('role', 'dialog');
arView.setAttribute('aria-modal', 'true');
arView.innerHTML = `<model-viewer camera-controls touch-action="none" interaction-prompt="none" shadow-intensity="1" environment-image="neutral"
ar ar-modes="webxr scene-viewer quick-look" ar-scale="fixed" ar-placement="floor" xr-environment>
<button type="button" slot="ar-button" class="tl-ar-go"><i class="bi bi-badge-ar"></i>Bekijk in je tuin</button>
</model-viewer>
<div class="tl-ar-top"><b class="tl-ar-title"></b><button type="button" class="tl-ar-close" aria-label="Sluiten"><i class="bi bi-x-lg"></i></button></div>
<div class="tl-ar-note"></div>`;
document.body.appendChild(arView);
const mv = arView.querySelector('model-viewer'), note = arView.querySelector('.tl-ar-note');
arView.querySelector('.tl-ar-close').addEventListener('click', closeAr);
mv.addEventListener('load', () => {
note.textContent = mv.canActivateAR ? AR_NOTE : 'AR werkt niet op dit toestel of in deze browser; draaien en zoomen kan wel.';
});
mv.addEventListener('ar-status', (e) => { if (e.detail.status === 'failed') note.textContent = 'AR kon niet starten op dit toestel.'; });
}
const mv = arView.querySelector('model-viewer');
arView.setAttribute('aria-label', `${model.label} in 3D en AR`);
arView.querySelector('.tl-ar-title').textContent = model.label;
mv.alt = `3D-model van ${model.label}`;
if (mv.getAttribute('src') !== url) { arView.querySelector('.tl-ar-note').textContent = ''; mv.setAttribute('src', url); }
arView.classList.add('on');
document.documentElement.classList.add('tl-noscroll');
arOpen = true;
}
async function openAr() {
if (!cur || busy) return;
busy = true;
root.classList.add('is-busy');
try {
await new Promise(r => setTimeout(r, 30)); // eerst de spinner tekenen
const g = makeGlb();
await loadModelViewer(mvSrc);
showAr(g.url);
} catch (err) {
console.error(err);
toast('De AR-weergave kon niet worden geopend.');
}
busy = false;
root.classList.remove('is-busy');
}
xbtn.addEventListener('click', () => { if (AR_DEVICE) openAr(); else toggleFull(); });
(function loop() {
raf = requestAnimationFrame(loop);
if (visible && !arOpen && view !== 'plan') renderer.render(scene, camera);
})();
root.classList.add('is-busy');
setTimeout(() => {
const st = LightModel.build(model, ch);
show(st, compute(st));
root.classList.remove('is-busy');
}, 30);
return {
unmount() {
cancelAnimationFrame(raf);
ro.disconnect(); io.disconnect(); offCosts(); controls.dispose();
document.removeEventListener('keydown', arKey);
document.removeEventListener('fullscreenchange', onFull);
document.removeEventListener('webkitfullscreenchange', onFull);
closeAr();
if (arView) arView.remove();
if (glb) URL.revokeObjectURL(glb.url);
if (group) disposeGeo(group);
renderer.dispose();
root.innerHTML = '';
},
getChoices: () => clone(ch),
design: () => cur && design(),
// GLB van het huidige ontwerp: { blob, url, stats } (zelfde als voor AR).
glb: () => cur && makeGlb()
};
}
function autoMount() {
document.querySelectorAll('.tectum-light[data-model]').forEach(el => {
if (el.dataset.mounted) return;
el.dataset.mounted = '1';
try { el.tectumLight = mount(el); } catch (err) { console.error(err); el.textContent = `Configurator niet beschikbaar: ${err.message}`; }
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoMount);
else autoMount();
return { mount, autoMount };
})();
+67 -32
View File
@@ -10,7 +10,7 @@ window.LightingModel = (() => {
mode: 'spots', mode: 'spots',
direction: 'both', direction: 'both',
lampHeight: 1950, lampHeight: 1950,
lampMin: 1500, lampMin: 500,
lampMax: 3000, lampMax: 3000,
minWallWidth: 2000, minWallWidth: 2000,
endOffset: 300, endOffset: 300,
@@ -39,14 +39,16 @@ window.LightingModel = (() => {
calculateSegmentPositions(len, min, max).forEach(p => out.push({ x: A.x + (B.x - A.x) * p / len, z: A.z + (B.z - A.z) * p / len })); calculateSegmentPositions(len, min, max).forEach(p => out.push({ x: A.x + (B.x - A.x) * p / len, z: A.z + (B.z - A.z) * p / len }));
} }
function enforceMinSpacing(positions, min) { // Te dicht bij elkaar: samen naar het midden, tenzij daar een opening zit (dan vervalt de tweede).
function enforceMinSpacing(positions, min, blockedAt) {
let pts = positions.slice().sort((a, b) => a - b); let pts = positions.slice().sort((a, b) => a - b);
let changed = true; let changed = true;
while (changed && pts.length > 1) { while (changed && pts.length > 1) {
changed = false; changed = false;
for (let i = 0; i < pts.length - 1; i++) { for (let i = 0; i < pts.length - 1; i++) {
if (pts[i + 1] - pts[i] < min - 0.5) { if (pts[i + 1] - pts[i] < min - 0.5) {
pts.splice(i, 2, (pts[i] + pts[i + 1]) / 2); const mid = (pts[i] + pts[i + 1]) / 2;
pts.splice(i, 2, blockedAt && blockedAt(mid) ? pts[i] : mid);
changed = true; changed = true;
break; break;
} }
@@ -55,6 +57,27 @@ window.LightingModel = (() => {
return pts; return pts;
} }
// Naast openingen: op openingOffset van elke opening; is de tussenruimte daarvoor te klein, dan één lamp in
// het midden ervan (of geen, als die tegen de kozijnen zou hangen). Posities in u (mm vanaf de linkerkant).
function besideOpenings(cfg, s) {
const W = cfg.wallWidth, off = s.openingOffset, clear = cfg.studWidth + 150;
const ops = cfg.openings.map(op => [op.x, op.x + op.w]).sort((p, q) => p[0] - q[0]);
const out = [];
ops.forEach(([x0, x1], i) => {
const next = ops[i + 1];
if (i === 0) { const u = Math.max(x0 - off, s.endOffset); if (x0 - u >= clear) out.push(u); }
if (next) {
const gap = next[0] - x1;
if (gap >= 2 * off + 1000) out.push(x1 + off, next[0] - off);
else if (gap >= 2 * clear) out.push((x1 + next[0]) / 2);
} else {
const u = Math.min(x1 + off, W - s.endOffset);
if (u - x1 >= clear) out.push(u);
}
});
return out;
}
function freeIntervals(lo, hi, blocked) { function freeIntervals(lo, hi, blocked) {
const free = []; const free = [];
let cursor = lo; let cursor = lo;
@@ -113,7 +136,7 @@ window.LightingModel = (() => {
}; };
if (s.mode === 'gevel') { if (s.mode === 'gevel') {
runs.forEach(run => { runs.forEach(run => {
const blocked = []; const blocked = [], openings = [];
const pos = []; const pos = [];
// Delen achter een andere wand (bijv. een stuk dat de naad in loopt) zijn geen zichtbare gevel. // Delen achter een andere wand (bijv. een stuk dat de naad in loopt) zijn geen zichtbare gevel.
segs.filter(o => !run.segs.includes(o)).forEach(o => { segs.filter(o => !run.segs.includes(o)).forEach(o => {
@@ -126,26 +149,29 @@ window.LightingModel = (() => {
const cfg = sg.piece.config, sW = cfg.studWidth; const cfg = sg.piece.config, sW = cfg.studWidth;
const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100; const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100;
if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; } if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; }
if (sg.override === 'openingen') { const own = [];
blocked.push([sg.s0, sg.s1]);
cfg.openings.forEach(op => [op.x - s.openingOffset, op.x + op.w + s.openingOffset]
.filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
return;
}
cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + sW >= H).forEach(op => { cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + sW >= H).forEach(op => {
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW); const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
blocked.push([Math.min(a, b), Math.max(a, b)]); own.push([Math.min(a, b), Math.max(a, b)]);
}); });
openings.push(...own);
if (sg.override === 'openingen') {
blocked.push([sg.s0, sg.s1]);
besideOpenings(cfg, s).forEach(u => pos.push(uToS(sg, u)));
return;
}
blocked.push(...own);
}); });
// Automatisch: per vrij wandstuk gelijk verdeeld, midden in gelijke delen van ca. lampMax (één lamp
// midden tussen twee ramen), niet tegen de kozijnen of de hoek aan.
freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => { freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => {
if (b - a < s.minWallWidth) return; if (b - a < s.minWallWidth) return;
const first = a + s.endOffset, last = b - s.endOffset; const k = Math.max(1, Math.round((b - a) / s.lampMax));
pos.push(first); for (let i = 0; i < k; i++) pos.push(a + (b - a) * (i + 0.5) / k);
calculateSegmentPositions(last - first, s.lampMin, s.lampMax).forEach(p => pos.push(first + p));
pos.push(last);
}); });
// Min. h.o.h. geldt over alle lampen van de gevel samen (ook overrides en de grens tussen wanden). // Min. h.o.h. geldt over alle lampen van de gevel samen (ook overrides en de grens tussen wanden).
enforceMinSpacing(pos, s.lampMin).forEach(v => addLamp(run, v)); const inOpening = (v) => openings.some(([a, b]) => v > a && v < b);
enforceMinSpacing(pos, s.lampMin, inOpening).filter(v => !inOpening(v)).forEach(v => addLamp(run, v));
}); });
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => p.lamps.sort((a, b) => a.u - b.u)))); units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => p.lamps.sort((a, b) => a.u - b.u))));
} }
@@ -298,32 +324,41 @@ window.LightingModel = (() => {
const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 }; const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 }; const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 };
const DAY_OUTDOOR_FACTOR = 0.35; const DAY_OUTDOOR_FACTOR = 0.35;
function buildLights3D(lamps, spots, spotY, indoor, o) { // Lichtbronnen in meters: positie, richting (genormaliseerd), halve kegelhoek; gedeeld door 3D en render-export.
const toM = 0.001; function lightSources(lamps, spots, spotY, indoor) {
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1; const toM = 0.001, out = [];
const group = new THREE.Group(); const add = (kind, x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => { const len = Math.hypot(tx, ty, tz) || 1;
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay); out.push({ kind, x, y, z, dir: { x: tx / len, y: ty / len, z: tz / len }, intensity, angle, distance, decay, penumbra, ref });
light.userData.ref = ref;
light.position.set(x, y, z);
light.target.position.set(x + tx, y + ty, z + tz);
group.add(light);
group.add(light.target);
}; };
const tilt = Math.tan(OUTDOOR.angle); const tilt = Math.tan(OUTDOOR.angle);
lamps.forEach(l => { lamps.forEach(l => {
const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM; const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM;
const nx = l.normal.x * tilt, nz = l.normal.z * tilt; const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l); if (l.direction !== 'up') add('wandlamp', x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
if (l.direction !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l); if (l.direction !== 'down') add('wandlamp', x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
}); });
(spots || []).forEach(p => { (spots || []).forEach(p => {
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1; const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra, p); add('dakrandspot', p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra, p);
}); });
(indoor || []).forEach(p => { (indoor || []).forEach(p => {
const drop = (p.y - p.floorY) * toM; const drop = (p.y - p.floorY) * toM;
add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra, p); add('binnenspot', p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra, p);
});
return out;
}
function buildLights3D(lamps, spots, spotY, indoor, o) {
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
const group = new THREE.Group();
lightSources(lamps, spots, spotY, indoor).forEach(s => {
const light = new THREE.SpotLight(0xffd9a0, s.kind === 'binnenspot' ? s.intensity : s.intensity * fo, s.distance, s.angle, s.penumbra, s.decay);
light.userData.ref = s.ref;
light.position.set(s.x, s.y, s.z);
light.target.position.set(s.x + s.dir.x, s.y + s.dir.y, s.z + s.dir.z);
group.add(light);
group.add(light.target);
}); });
if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo)); if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo));
return group; return group;
@@ -424,5 +459,5 @@ window.LightingModel = (() => {
return group; return group;
} }
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D }; return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, unitInnerRect, freeIntervals, createMaterials, build3D, lightSources, buildLights3D };
})(); })();
+217
View File
@@ -0,0 +1,217 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Palen onder een overkapping: steekt een dak minstens minExt buiten de vloeren uit (veranda), dan komen er
// palen onder de dakrand. Hoekpalen staan met hun buitenkant gelijk met de dakhoek; een eind dat op een gevel
// uitkomt rust op die wand. Daartussen zo weinig palen dat de h.o.h.-afstand onder maxSpan blijft.
// sideWall: een vrije hoek krijgt geen paal maar een dichte zijwand (gestuukt) van de dakhoek terug tot de gevel,
// met de buitenkant gelijk met de zijkant van het dak. Ligt die buitenkant in het vlak van een gevel, dan loopt de
// bekleding van die gevel erover door (outsideOf, bijv. felsplaten) en staat de wand gelijk met de bekleding.
window.PostModel = (() => {
const EPS = 0.5;
const SHOE_MM = 100; // verzinkte paalvoet tussen maaiveld en paal
const WOODS = {
eiken: { label: 'Eiken', hex: 0xa07c54, kgM3: 750 },
douglas: { label: 'Douglas', hex: 0xb98a5e, kgM3: 530 }
};
const DEFAULT_SETTINGS = { enabled: true, size: 200, wood: 'eiken', maxSpan: 3600, minExt: 1000, sideWall: false, sideColor: 'wit' };
const SIDE_MM = 200; // dikte zijwand (regelwerk, plaat en stuc aan beide kanten)
const SIDE_TOP_BELOW_MM = 40; // doorlopende gevelbekleding: tot zo ver onder de bovenkant van het dak
function normalizeSettings(settings) {
const s = Object.assign({}, DEFAULT_SETTINGS, settings || {});
s.enabled = s.enabled !== false;
s.sideWall = !!s.sideWall;
if (!WOODS[s.wood]) s.wood = DEFAULT_SETTINGS.wood;
if (!WallModel.isStucColor(s.sideColor)) s.sideColor = DEFAULT_SETTINGS.sideColor;
const num = (v, def, min, max) => (isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : def);
s.size = num(s.size, DEFAULT_SETTINGS.size, 100, 400);
s.maxSpan = num(s.maxSpan, DEFAULT_SETTINGS.maxSpan, 1000, 8000);
s.minExt = num(s.minExt, DEFAULT_SETTINGS.minExt, 400, 5000);
return s;
}
// units: [{ rect (vloer), roof: { rect, ceilingAt } }] in wereld-mm; groundY = maaiveld (absoluut).
// outsideOf(wall, out): { finish, kind, depth } van de gevel in het vlak van de buitenkant, of null.
function compute(settings, units, groundY, outsideOf) {
const s = normalizeSettings(settings);
const out = { posts: [], count: 0, size: s.size, wood: s.wood, lengthMM: 0, m3: 0, kg: 0, walls: [], wallM2: 0, sideColor: s.sideColor };
if (!s.enabled || !units.length) return out;
const floors = units.map(u => u.rect);
const onFloor = (x, z, m) => floors.some(f => x > f.x0 - m && x < f.x1 + m && z > f.z0 - m && z < f.z1 + m);
const underRoof = (x, z) => units.some(u => { const r = u.roof.rect; return x > r.x0 && x < r.x1 && z > r.z0 && z < r.z1; });
// Vrije stukken dakrand: buitenrand van de daken samen, recht tegenover een gevel op minstens minExt (loodrecht
// op de rand gemeten); de zijkanten van een veranda krijgen alleen de hoekpalen. In een binnenhoek (veranda
// tussen twee units) loopt het stuk door tot de andere gevel en rust daar op.
const runs = [];
units.forEach(u => {
const R = u.roof.rect;
[
{ axis: 'z', at: R.x1, n: 1, s0: R.z0, s1: R.z1 }, { axis: 'z', at: R.x0, n: -1, s0: R.z0, s1: R.z1 },
{ axis: 'x', at: R.z1, n: 1, s0: R.x0, s1: R.x1 }, { axis: 'x', at: R.z0, n: -1, s0: R.x0, s1: R.x1 }
].forEach(e => {
const pt = (t, d) => (e.axis === 'z' ? { x: e.at - e.n * d, z: t } : { x: t, z: e.at - e.n * d });
const facing = (p) => Math.min(...floors.map(f => {
const [lo, hi, c0, c1, v, c] = e.axis === 'z' ? [f.z0, f.z1, f.x0, f.x1, p.z, p.x] : [f.x0, f.x1, f.z0, f.z1, p.x, p.z];
if (!(v > lo + EPS && v < hi - EPS)) return Infinity;
const d = e.n > 0 ? c - c1 : c0 - c;
return d > -EPS ? d : Infinity;
}));
const free = (t) => {
const p = pt(t, 0), o = pt(t, -1), d = facing(p);
return !underRoof(o.x, o.z) && d >= s.minExt - EPS && d < Infinity;
};
const cuts = [e.s0, e.s1].concat(floors.flatMap(f => (e.axis === 'z' ? [f.z0, f.z1] : [f.x0, f.x1]))
.filter(t => t > e.s0 + EPS && t < e.s1 - EPS)).sort((a, b) => a - b);
for (let i = 0; i < cuts.length - 1; i++) {
const a = cuts[i], b = cuts[i + 1];
if (b - a < EPS || !free((a + b) / 2)) continue;
const last = runs[runs.length - 1];
if (last && last.axis === e.axis && last.n === e.n && Math.abs(last.at - e.at) < EPS && Math.abs(last.b - a) < EPS) last.b = b;
else runs.push({ axis: e.axis, at: e.at, n: e.n, a, b });
}
});
});
// Aansluitende stukken van verschillende units (bijv. twee units met dezelfde veranda) samenvoegen.
runs.sort((p, q) => p.axis.localeCompare(q.axis) || p.n - q.n || Math.round(p.at) - Math.round(q.at) || p.a - q.a);
const merged = [];
runs.forEach(r => {
const m = merged.find(o => o.axis === r.axis && o.n === r.n && Math.abs(o.at - r.at) < EPS && Math.abs(o.b - r.a) < EPS);
if (m) m.b = r.b; else merged.push(Object.assign({}, r));
});
const half = s.size / 2;
const pts = [];
const roofAt = (x, z) => units.map(u => u.roof).find(rf => x > rf.rect.x0 - EPS && x < rf.rect.x1 + EPS && z > rf.rect.z0 - EPS && z < rf.rect.z1 + EPS);
const endFreeOf = (r, t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); };
// Zijwand op het vrije eind t van run r (dir +1: wand van t de run in, -1: andersom), terug tot de gevel waar de
// run naar kijkt. Delen twee runs een vrije hoek (veranda in een binnenhoek), dan is de run met de kortste weg
// naar de gevel de voorkant en komt de wand langs de zijkant; het andere eind rust op die wand.
const sideCands = [];
if (s.sideWall) merged.forEach(r => [[r.a, 1], [r.b, -1]].forEach(([t, dir]) => {
if (!endFreeOf(r, t)) return;
const lo = Math.min(t, t + dir * SIDE_MM), hi = Math.max(t, t + dir * SIDE_MM);
let reach = Infinity;
floors.forEach(f => {
const [s0, s1, c0, c1] = r.axis === 'z' ? [f.z0, f.z1, f.x0, f.x1] : [f.x0, f.x1, f.z0, f.z1];
if (s0 > hi - EPS || s1 < lo + EPS) return;
const d = r.n > 0 ? r.at - c1 : c0 - r.at;
if (d > -EPS && d < reach) reach = d;
});
if (!isFinite(reach) || reach < s.size) return;
const a = Math.min(r.at, r.at - r.n * reach), b = Math.max(r.at, r.at - r.n * reach);
const corner = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at };
const outward = r.axis === 'z' ? { x: 0, z: -dir } : { x: -dir, z: 0 };
sideCands.push({ r, t, corner, reach, out: outward, w: r.axis === 'z' ? { x0: a, x1: b, z0: lo, z1: hi } : { x0: lo, x1: hi, z0: a, z1: b } });
}));
const sameCorner = (p, q) => Math.abs(p.x - q.x) < EPS && Math.abs(p.z - q.z) < EPS;
const sideAt = (r, t) => { const c = sideCands.find(d => d.r === r && d.t === t); return !!c && sideCands.some(d => d.built && sameCorner(d.corner, c.corner)); };
sideCands.forEach(c => {
if (sideCands.some(d => d !== c && sameCorner(d.corner, c.corner) && (d.reach < c.reach - EPS || (Math.abs(d.reach - c.reach) < EPS && sideCands.indexOf(d) < sideCands.indexOf(c))))) return;
const w = c.w;
const tops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]]
.map(([x, z]) => { const rf = roofAt(x, z); return rf ? rf.ceilingAt(x, z) : null; }).filter(v => v !== null);
if (!tops.length || !(Math.max(...tops) > groundY)) return;
const wall = Object.assign({}, w, { y0: groundY, y1: Math.round(Math.max(...tops)), color: s.sideColor, out: c.out, outside: null });
const o = outsideOf ? outsideOf(wall, c.out) : null;
if (o) {
if (c.out.x > 0) wall.x1 += o.depth; else if (c.out.x < 0) wall.x0 -= o.depth;
if (c.out.z > 0) wall.z1 += o.depth; else if (c.out.z < 0) wall.z0 -= o.depth;
Object.assign(wall, { outside: o.finish, outsideKind: o.kind });
// Zoals de gevel: de bekleding loopt tot onder de dakrand door (over de kant van het dakpakket).
const roofTops = [[w.x0 + 1, w.z0 + 1], [w.x1 - 1, w.z0 + 1], [w.x0 + 1, w.z1 - 1], [w.x1 - 1, w.z1 - 1]]
.map(([x, z]) => { const rf = roofAt(x, z); return rf && rf.topAt ? rf.topAt(x, z) : null; }).filter(v => v !== null);
if (roofTops.length) wall.y1 = Math.max(wall.y1, Math.round(Math.min(...roofTops) - SIDE_TOP_BELOW_MM));
}
out.walls.push(wall);
c.built = true;
});
merged.forEach(r => {
const pos = (t) => (r.axis === 'z' ? { x: r.at - r.n * half, z: t } : { x: t, z: r.at - r.n * half });
// Een eind dat op of tegen een vloer uitkomt rust op die gevel.
const endFree = (t) => { const p = r.axis === 'z' ? { x: r.at, z: t } : { x: t, z: r.at }; return !onFloor(p.x, p.z, s.size); };
// Een dichte zijwand neemt de plaats van de hoekpaal in; het eind rust dan op die wand.
const fa = endFree(r.a) && !sideAt(r, r.a), fb = endFree(r.b) && !sideAt(r, r.b);
const pa = fa ? r.a + half : r.a, pb = fb ? r.b - half : r.b;
if (pb - pa < EPS) return;
const n = Math.max(1, Math.ceil((pb - pa) / s.maxSpan - 1e-9));
for (let k = 0; k <= n; k++) {
if ((k === 0 && !fa) || (k === n && !fb)) continue;
pts.push(pos(pa + (pb - pa) * k / n));
}
});
pts.forEach(p => {
if (out.posts.some(q => Math.abs(q.x - p.x) < s.size && Math.abs(q.z - p.z) < s.size)) return;
if (out.walls.some(w => p.x > w.x0 - half && p.x < w.x1 + half && p.z > w.z0 - half && p.z < w.z1 + half)) return;
const roof = roofAt(p.x, p.z);
if (!roof) return;
// Hoogste hoek: bij een hellend dak verdwijnt de paal aan de lage kant in het dakpakket, zonder kier.
const top = Math.max(...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([a, b]) => roof.ceilingAt(p.x + a * (half - 1), p.z + b * (half - 1))));
if (!(top > groundY + SHOE_MM)) return;
out.posts.push({ x: Math.round(p.x), z: Math.round(p.z), y0: groundY, y1: Math.round(top), size: s.size });
});
out.count = out.posts.length;
out.lengthMM = out.posts.reduce((t, p) => t + p.y1 - p.y0 - SHOE_MM, 0);
out.m3 = out.lengthMM * s.size * s.size / 1e9;
out.kg = out.m3 * WOODS[s.wood].kgM3;
const wallArea = (w) => Math.max(w.x1 - w.x0, w.z1 - w.z0) * (w.y1 - w.y0) / 1e6;
out.wallM2 = out.walls.reduce((t, w) => t + wallArea(w), 0);
out.outsideM2 = {};
out.walls.filter(w => w.outsideKind).forEach(w => { out.outsideM2[w.outsideKind] = (out.outsideM2[w.outsideKind] || 0) + wallArea(w); });
return out;
}
function createMaterials() {
return {
wood: Object.fromEntries(Object.entries(WOODS).map(([k, w]) => [k, new THREE.MeshStandardMaterial({ color: w.hex, roughness: 0.78 })])),
shoe: new THREE.MeshStandardMaterial({ color: 0x9ba0a4, roughness: 0.45, metalness: 0.6 })
};
}
function build3D(data, materials) {
const toM = 0.001, group = new THREE.Group();
if (!data || !(data.posts.length || (data.walls || []).length)) return group;
const m = materials || createMaterials();
const woodMat = m.wood[data.wood] || m.wood.eiken;
data.posts.forEach(p => {
const h = p.y1 - p.y0 - SHOE_MM;
const post = new THREE.Mesh(new THREE.BoxGeometry(p.size * toM, h * toM, p.size * toM), woodMat);
post.position.set(p.x * toM, (p.y0 + SHOE_MM + h / 2) * toM, p.z * toM);
group.add(post);
const plate = new THREE.Mesh(new THREE.BoxGeometry((p.size + 40) * toM, 10 * toM, (p.size + 40) * toM), m.shoe);
plate.position.set(p.x * toM, (p.y0 + 5) * toM, p.z * toM);
group.add(plate);
const stem = new THREE.Mesh(new THREE.BoxGeometry(p.size * 0.35 * toM, (SHOE_MM - 10) * toM, p.size * 0.35 * toM), m.shoe);
stem.position.set(p.x * toM, (p.y0 + 10 + (SHOE_MM - 10) / 2) * toM, p.z * toM);
group.add(stem);
});
(data.walls || []).forEach(w => {
const c = [(w.x0 + w.x1) / 2 * toM, (w.y0 + w.y1) / 2 * toM, (w.z0 + w.z1) / 2 * toM];
const geo = new THREE.BoxGeometry((w.x1 - w.x0) * toM, (w.y1 - w.y0) * toM, (w.z1 - w.z0) * toM);
// UV in wereldmeters, zoals de gevels: texturen op dezelfde schaal.
const pos = geo.attributes.position, nrm = geo.attributes.normal, uv = geo.attributes.uv;
for (let k = 0; k < pos.count; k++) {
const x = pos.getX(k) + c[0], y = pos.getY(k) + c[1], z = pos.getZ(k) + c[2];
if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y);
}
uv.needsUpdate = true;
// Volgorde van de vlakken in BoxGeometry: +x, -x, +y, -y, +z, -z.
const stuc = WallModel.stucMaterial(w.color), mats = [stuc, stuc, stuc, stuc, stuc, stuc];
const outMat = w.outside && w.out ? WallModel.claddingMaterial(w.outside) : null;
if (outMat) mats[w.out.x > 0 ? 0 : w.out.x < 0 ? 1 : w.out.z > 0 ? 4 : 5] = outMat;
const box = new THREE.Mesh(geo, mats);
box.position.set(...c);
box.castShadow = true;
box.receiveShadow = true;
group.add(box);
});
return group;
}
return { WOODS, SHOE_MM, SIDE_MM, DEFAULT_SETTINGS, normalizeSettings, compute, createMaterials, build3D };
})();
+432 -108
View File
@@ -12,6 +12,12 @@ window.RoofModel = (() => {
const DENSITY_DAKLEER_KGM2 = 1.4; const DENSITY_DAKLEER_KGM2 = 1.4;
const TRIM_THICK_MM = 25; const TRIM_THICK_MM = 25;
const EDGE_ABOVE_MM = 80; const EDGE_ABOVE_MM = 80;
// Daktrim: voorkant 8 mm vóór de gevel, over de bovenste trimHeight van de gevel en 30 mm boven het dak; de opstand
// is 20 mm diep, daarachter ligt een flens van 3 mm op het dak over de bovenkant van de bekleding.
const TRIM = { OUT_MM: 8, ABOVE_MM: 30, DEPTH_MM: 20, FLANGE_MM: 3 };
// Felsplaat op dak én gevel: geen boeideel; de dakplaat zet om over de rand (net vóór de gevelbekleding), zonder
// opstand (de waterkant van een lessenaar), tot over de bovenkant van de gevelplaten.
const FELS_EDGE = { lipMM: 2, aboveMM: 2, heightMM: 120 };
const SPOT_RATIO = 0.4; const SPOT_RATIO = 0.4;
const OVERHANG_MIN = 200, OVERHANG_MAX = 400; const OVERHANG_MIN = 200, OVERHANG_MAX = 400;
@@ -20,9 +26,36 @@ window.RoofModel = (() => {
fasciaHeight: 300, fasciaHeight: 300,
spotMin: 800, spotMin: 800,
spotMax: 1200, spotMax: 1200,
trimHeight: 80, trimHeight: 30,
fasciaColor: 'antraciet' fasciaColor: 'antraciet',
vent: true,
ventColor: 'antraciet'
}; };
// Ventilatierooster onder de boeidelen: strook op de gevel, direct onder de onderplaat; sleuven in de textuur.
// Kleuren als de afdekker van het trasraam (WallModel.TRIM_COLORS).
const VENT = { HEIGHT_MM: 40, THICK_MM: 12, PITCH_MM: 12 };
const ventMaterials = {};
function ventMaterial(key) {
const k = WallModel.isTrimColor(key) ? key : 'antraciet';
if (ventMaterials[k]) return ventMaterials[k];
const base = new THREE.Color(WallModel.trimHex(k));
const canvas = typeof document !== 'undefined' && document.createElement('canvas');
const ctx = canvas && canvas.getContext && canvas.getContext('2d');
let mat;
if (!ctx) mat = new THREE.MeshStandardMaterial({ color: base, roughness: 0.5, metalness: 0.3 });
else {
canvas.width = 32; canvas.height = 32;
ctx.fillStyle = '#' + base.getHexString();
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#' + base.clone().multiplyScalar(0.18).getHexString();
ctx.fillRect(11, 6, 11, 20); // sleuf, 4 mm breed en 25 mm hoog
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.anisotropy = 8;
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5, metalness: 0.3 });
}
return (ventMaterials[k] = mat);
}
const FASCIA_COLORS = { const FASCIA_COLORS = {
wit: { label: 'Wit', hex: 0xf2f2f0 }, wit: { label: 'Wit', hex: 0xf2f2f0 },
@@ -35,7 +68,9 @@ window.RoofModel = (() => {
// Riet e.d. alleen bij een hellend dak; plat valt terug op dakleer. // Riet e.d. alleen bij een hellend dak; plat valt terug op dakleer.
// Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer hier toevoegen). // Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer hier toevoegen).
const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } }; const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } };
const FELS = { thickMM: 25, kgM2: 6.5, seamMM: 500, colors: { zink: ['Felsplaat zink', '#8e9497'], antraciet: ['Felsplaat antraciet', '#3d4146'], zwart: ['Felsplaat zwart', '#1f2022'] } }; // Kleuren en naadafstand gedeeld met felsplaten op de gevel (WallModel.FELS).
const FELS = { thickMM: 25, kgM2: 6.5, seamMM: WallModel.FELS.SEAM_MM,
colors: Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, ['Felsplaat ' + c.label.toLowerCase(), c.hex]])) };
const COVERINGS = Object.assign({ const COVERINGS = Object.assign({
dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 } dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 }
}, ...RIET.nrs.map(nr => ({ }, ...RIET.nrs.map(nr => ({
@@ -55,7 +90,7 @@ window.RoofModel = (() => {
} }
function coveringThumbUrl(key, px) { function coveringThumbUrl(key, px) {
const c = COVERINGS[key]; const c = COVERINGS[key];
return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null; return c && c.file ? tectumUrl(c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png'))) : null;
} }
function coveringFor(key, shapeType) { function coveringFor(key, shapeType) {
const c = COVERINGS[key]; const c = COVERINGS[key];
@@ -73,7 +108,7 @@ window.RoofModel = (() => {
if (!c.file) return null; if (!c.file) return null;
const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 }); const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 });
if (typeof document !== 'undefined' && THREE.TextureLoader) { if (typeof document !== 'undefined' && THREE.TextureLoader) {
new THREE.TextureLoader().load(c.file, (tex) => { new THREE.TextureLoader().load(tectumUrl(c.file), (tex) => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM); tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM);
tex.anisotropy = 8; tex.anisotropy = 8;
@@ -92,7 +127,7 @@ window.RoofModel = (() => {
const deck = clone(floorState); const deck = clone(floorState);
deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 }; deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
deck.openings = []; deck.openings = [];
return { followFloor: true, offsetX: 0, offsetZ: 0, fascia: true, covering: 'dakleer', deck }; return { followFloor: true, offsetX: 0, offsetZ: 0, extend: { f: 0, r: 0, b: 0, l: 0 }, fascia: true, covering: 'dakleer', deck };
} }
function thickness(calcData, covering) { function thickness(calcData, covering) {
@@ -125,18 +160,8 @@ window.RoofModel = (() => {
deck.position.y = deckH; deck.position.y = deckH;
group.add(deck); group.add(deck);
const wM = calcData.width * toM, dM = calcData.depth * toM; const dM = calcData.depth * toM;
const shape = new THREE.Shape(); const shape = FloorModel.surfaceShapes(calcData, resolvedOpenings, toM);
shape.moveTo(-wM / 2, -dM / 2);
shape.lineTo(wM / 2, -dM / 2);
shape.lineTo(wM / 2, dM / 2);
shape.lineTo(-wM / 2, dM / 2);
shape.closePath();
resolvedOpenings.filter(o => !o.removed && o.r > 0).forEach(o => {
const hole = new THREE.Path();
hole.absarc(o.x * toM, o.y * toM - dM / 2, o.r * toM, 0, Math.PI * 2, false);
shape.holes.push(hole);
});
const cov = COVERINGS[opts.covering] || COVERINGS.dakleer; const cov = COVERINGS[opts.covering] || COVERINGS.dakleer;
const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial; const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial;
const bT = BESCHOT_MM * toM; const bT = BESCHOT_MM * toM;
@@ -212,7 +237,7 @@ window.RoofModel = (() => {
return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } }; return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } };
} }
} }
const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel'; const type = profile.length === 2 ? 'lessenaar' : 'zadel';
return { type, axis, profile }; return { type, axis, profile };
} }
@@ -267,8 +292,11 @@ window.RoofModel = (() => {
finishKNm2: 0.3, // afwerking/installaties onder het dak finishKNm2: 0.3, // afwerking/installaties onder het dak
gammaG: 1.2, gammaQ: 1.5, deflection: 250 gammaG: 1.2, gammaQ: 1.5, deflection: 250
}; };
const ridgeTypes = ['zadel', 'mansarde', 'wolf']; const ridgeTypes = ['zadel', 'wolf'];
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2) { // span: [q0, q1] waar de nok echt loopt (dakaansluiting: tot de hoekkeper).
// cuts: sparingen (schoorsteen) in plattegrondmaten; ligt er een op de nok, dan stopt de balk bear mm erin
// (negatief: ervoor).
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2, span, cuts) {
if (roofState.steelRidge === false || !ridgeTypes.includes(shape.type)) return null; if (roofState.steelRidge === false || !ridgeTypes.includes(shape.type)) return null;
const pr = shape.profile, inner = pr.slice(1, -1); const pr = shape.profile, inner = pr.slice(1, -1);
if (!inner.length) return null; if (!inner.length) return null;
@@ -283,6 +311,13 @@ window.RoofModel = (() => {
q0 = Math.max(uq0, Math.min(g.qM, g.q0 + g.inset)); q0 = Math.max(uq0, Math.min(g.qM, g.q0 + g.inset));
q1 = Math.min(uq1, Math.max(g.qM, g.q1 - g.inset)); q1 = Math.min(uq1, Math.max(g.qM, g.q1 - g.inset));
} }
if (span) { q0 = Math.max(q0, span[0]); q1 = Math.min(q1, span[1]); }
(cuts || []).forEach(c => {
const [pa, pb] = alongX ? [c.x0, c.x1] : [c.z0, c.z1], [qa, qb] = alongX ? [c.z0, c.z1] : [c.x0, c.x1];
if (R.p < pa - 100 || R.p > pb + 100 || qb < q0 || qa > q1) return;
if ((qa + qb) / 2 > (q0 + q1) / 2) q1 = Math.min(q1, qa + (c.bear || 0));
else q0 = Math.max(q0, qb - (c.bear || 0));
});
const L = q1 - q0; const L = q1 - q0;
if (L < 100) return null; if (L < 100) return null;
// q in N/mm = kN/m. // q in N/mm = kN/m.
@@ -348,7 +383,9 @@ window.RoofModel = (() => {
return out; return out;
} }
function computeUnitRoof(roofState, shape, rect, rims) { // cuts: rechthoekige sparingen (schoorsteen) in plattegrondmaten van de unit: { x0, x1, z0, z1, bear }.
function computeUnitRoof(roofState, shape, rect, rims, cutRects) {
const roofCuts = cutRects || [];
const alongX = shape.axis === 'x'; const alongX = shape.axis === 'x';
const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0; const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0;
const xc = (rect.x0 + rect.x1) / 2, zc = (rect.z0 + rect.z1) / 2; const xc = (rect.x0 + rect.x1) / 2, zc = (rect.z0 + rect.z1) / 2;
@@ -370,10 +407,18 @@ window.RoofModel = (() => {
const slopeLen = Math.hypot(run, h1 - h0), cos = run / slopeLen; const slopeLen = Math.hypot(run, h1 - h0), cos = run / slopeLen;
const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 }; const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 };
let data; let data;
if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5) { const deckCuts = roofCuts.map(c => {
const [qa, qb] = alongX ? [c.x0, c.x1] : [c.z0, c.z1];
const a = Math.max(qa, p0), b = Math.min(qb, p1);
if (b - a < 1) return null;
const [x0, x1] = alongX ? [zc - c.z1, zc - c.z0] : [c.x0 - xc, c.x1 - xc];
return { x0, x1, y0: (a - p0) / cos, y1: (b - p0) / cos };
}).filter(Boolean);
if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5 && !deckCuts.length) {
data = flat; data = flat;
} else { } else {
const deck = clone(roofState.deck); const deck = clone(roofState.deck);
deck.cuts = deckCuts;
deck.floor.width = Math.round(alongX ? D : W); deck.floor.width = Math.round(alongX ? D : W);
deck.floor.depth = Math.round(slopeLen); deck.floor.depth = Math.round(slopeLen);
deck.openings = holes.filter(hl => hl.q >= p0 && (hl.q < p1 || last)).map(hl => ({ deck.openings = holes.filter(hl => hl.q >= p0 && (hl.q < p1 || last)).map(hl => ({
@@ -393,7 +438,7 @@ window.RoofModel = (() => {
}); });
}); });
// Mansardeknik: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik, // Knik in de daklijn: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik,
// bovenkant op het snijpunt van de bovenvlakken. // bovenkant op het snijpunt van de bovenvlakken.
planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; }); planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; });
planes.slice(0, -1).forEach((pi, i) => { planes.slice(0, -1).forEach((pi, i) => {
@@ -417,12 +462,8 @@ window.RoofModel = (() => {
topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick)); topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick));
} }
const specs = sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : [])); const specs = sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : []));
const ridgeBeam = computeRidgeBeam(roofState, shape, rect, alongX, flat.calcData, specs.totalKg / Math.max(0.1, W * D / 1e6)); const ridgeBeam = computeRidgeBeam(roofState, shape, rect, alongX, flat.calcData, specs.totalKg / Math.max(0.1, W * D / 1e6), null, roofCuts);
Object.assign(specs, { addRidgeSpecs(specs, ridgeBeam);
nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0,
nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0
});
specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg;
return { return {
shape, covering, planes: outPlanes, specs, ridgeBeam, shape, covering, planes: outPlanes, specs, ridgeBeam,
undersideAt, topAt, cap, rect, alongX, undersideAt, topAt, cap, rect, alongX,
@@ -431,6 +472,200 @@ window.RoofModel = (() => {
}; };
} }
// Stalen nok onder de naad van een dubbel lessenaardak (HouseModel): shape is het samengestelde zadel en rect
// de twee units samen, beide in de lokale maten van deze unit; de balk hoort bij deze unit (3D, kosten).
function sharedRidgeBeam(roofState, unitRoof, shape, rect, span, cuts) {
const pl = unitRoof.planes[0];
if (!pl || !pl.data) return null;
const r = unitRoof.rect, kgM2 = unitRoof.specs.totalKg / Math.max(0.1, (r.x1 - r.x0) * (r.z1 - r.z0) / 1e6);
const rb = computeRidgeBeam(roofState, shape, rect, shape.axis === 'x', pl.data.calcData, kgM2, span, cuts);
if (!rb) return null;
rb.alongX = shape.axis === 'x';
rb.shared = true;
unitRoof.ridgeBeam = rb;
addRidgeSpecs(unitRoof.specs, rb);
return rb;
}
function addRidgeSpecs(specs, ridgeBeam) {
Object.assign(specs, {
nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0,
nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0
});
specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg;
}
// Dakaansluiting (HouseModel.roofJoins): vlakken h = a + bx·x + bz·z, samengevoegd volgens expr
// (vlaknummer of ['min' | 'max', ...]). extra(k): ophoging per vlak (bovenkant dak).
const planeAt = (pl, p) => pl.a + pl.bx * p.x + pl.bz * p.z;
function joinValue(join, p, extra) {
const at = (e) => Array.isArray(e) ? Math[e[0]](...e.slice(1).map(at)) : planeAt(join.planes[e], p) + (extra ? extra(e) : 0);
return at(join.expr);
}
function convexHull(pts) {
const sorted = pts.slice().sort((a, b) => a.x - b.x || a.z - b.z);
const cross = (o, a, b) => (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
const half = (list) => list.reduce((h, q) => {
while (h.length >= 2 && cross(h[h.length - 2], h[h.length - 1], q) <= 0.01 * Math.hypot(q.x - h[h.length - 2].x, q.z - h[h.length - 2].z)) h.pop();
h.push(q);
return h;
}, []);
const lo = half(sorted), up = half(sorted.slice().reverse());
const out = lo.slice(0, -1).concat(up.slice(0, -1));
return out.filter((p, k) => { const q = out[(k + 1) % out.length]; return Math.hypot(q.x - p.x, q.z - p.z) > 0.5; });
}
// Dak van één unit in een dakaansluiting (join in unit-lokale maten): per vlak de convexe stukken binnen het dek,
// sporen haaks op de goot (kreupel tegen kil- en hoekkepers), kepers en nokken op de grenzen tussen de vlakken.
// Alles als houtwerk zoals het bovenste deel van een wolfsdak (cap); de eigen nok wordt de stalen nokbalk.
function computeJoinedRoof(roofState, join, rect, shape) {
const fp = roofState.deck.floor;
const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22;
const covering = coveringFor(roofState.covering, shape.type);
const cov = COVERINGS[covering] || COVERINGS.dakleer;
const pls = join.planes.map((pl, k) => {
const slope = Math.hypot(pl.bx, pl.bz), cos = 1 / Math.sqrt(1 + slope * slope);
const tV = underlay / cos, jV = joistH / cos, bV = BESCHOT_MM / cos, covV = cov.thickMM / cos;
const under = (p) => planeAt(pl, p);
return { key: k, pl, slope, cos, tV, jV, bV, covV, vThick: tV + jV + bV + covV, under, f: under, along: Math.abs(pl.bx) >= Math.abs(pl.bz) ? 'p' : 'q' };
});
const activeAt = (p) => {
const h = joinValue(join, p);
return pls.reduce((a, b) => Math.abs(b.under(p) - h) < Math.abs(a.under(p) - h) ? b : a);
};
const box = [{ x: rect.x0, z: rect.z0 }, { x: rect.x1, z: rect.z0 }, { x: rect.x1, z: rect.z1 }, { x: rect.x0, z: rect.z1 }];
const clipBox = (poly) => [(p) => p.x - rect.x0, (p) => rect.x1 - p.x, (p) => p.z - rect.z0, (p) => rect.z1 - p.z]
.reduce((pl, f) => pl.length >= 3 ? clipPoly(pl, f) : pl, poly);
const pairs = [];
pls.forEach((A, i) => pls.slice(i + 1).forEach(B => {
if (Math.hypot(A.pl.bx - B.pl.bx, A.pl.bz - B.pl.bz) > 1e-9) pairs.push({ A, B, f: (p) => A.under(p) - B.under(p) });
}));
// Afgeronde wandhoogtes maken bijna samenvallende lijnen: splinters (gemiddeld < 0,5 mm breed) vallen weg.
const perim = (poly) => poly.reduce((t, p, k) => { const q = poly[(k + 1) % poly.length]; return t + Math.hypot(q.x - p.x, q.z - p.z); }, 0);
const sliver = (poly) => quadArea(poly) < Math.max(100, 0.25 * perim(poly));
let cells = [box];
pairs.forEach(({ f }) => {
cells = cells.flatMap(c => [clipPoly(c, f), clipPoly(c, p => -f(p))]).filter(c => c.length >= 3 && !sliver(c));
});
const centroid = (poly) => ({ x: poly.reduce((t, p) => t + p.x, 0) / poly.length, z: poly.reduce((t, p) => t + p.z, 0) / poly.length });
const regions = [];
pls.forEach(pl => {
const list = cells.filter(c => activeAt(centroid(c)) === pl).map(convexHull);
// Aangrenzende cellen samenvoegen zolang het geheel convex blijft.
let merged = true;
while (merged) {
merged = false;
for (let i = 0; i < list.length && !merged; i++) {
for (let j = i + 1; j < list.length && !merged; j++) {
const h = convexHull(list[i].concat(list[j]));
if (quadArea(h) - quadArea(list[i]) - quadArea(list[j]) < 0.5 * perim(h)) { list[i] = h; list.splice(j, 1); merged = true; }
}
}
}
list.filter(poly => poly.length >= 3).forEach(poly => regions.push(Object.assign({}, pl, { poly })));
});
// Grenzen tussen twee vlakken: bol = nok (waterpas) of hoekkeper, hol = kilkeper.
const members = [];
pairs.forEach(({ A, B, f }) => {
const gx = A.pl.bx - B.pl.bx, gz = A.pl.bz - B.pl.bz, g = Math.hypot(gx, gz), c0 = f({ x: 0, z: 0 });
const o = { x: -c0 * gx / (g * g), z: -c0 * gz / (g * g) }, d = { x: -gz / g, z: gx / g }, n = { x: gx / g, z: gz / g };
let t0 = -Infinity, t1 = Infinity;
[['x', rect.x0, rect.x1], ['z', rect.z0, rect.z1]].forEach(([k, lo, hi]) => {
if (Math.abs(d[k]) < 1e-9) { if (o[k] < lo || o[k] > hi) t1 = -Infinity; return; }
const a = (lo - o[k]) / d[k], b = (hi - o[k]) / d[k];
t0 = Math.max(t0, Math.min(a, b));
t1 = Math.min(t1, Math.max(a, b));
});
if (!(t1 - t0 >= 100)) return;
const pt = (t) => ({ x: o.x + d.x * t, z: o.z + d.z * t });
const ts = [t0, t1];
pairs.forEach(q => {
const dn = q.f(pt(1)) - q.f(pt(0));
if (Math.abs(dn) < 1e-9) return;
const t = -q.f(pt(0)) / dn;
if (t > t0 && t < t1) ts.push(t);
});
ts.sort((a, b) => a - b);
const segs = [];
let cur = null;
ts.slice(0, -1).forEach((ta, k) => {
const tb = ts[k + 1];
if (tb - ta < 1) return;
const m = pt((ta + tb) / 2), off = (s) => ({ x: m.x + n.x * s, z: m.z + n.z * s });
const s1 = activeAt(off(2)), s2 = activeAt(off(-2));
if (Math.abs(joinValue(join, m) - A.under(m)) > 0.5 || !((s1 === A && s2 === B) || (s1 === B && s2 === A))) { cur = null; return; }
const onA = off(s1 === A ? 2 : -2), convex = B.under(onA) > A.under(onA);
if (cur && cur.convex === convex) { cur.tb = tb; return; }
cur = { ta, tb, convex };
segs.push(cur);
});
// Waterpas en bol: nok als de vlakken van elkaar af aflopen, anders een knik.
const ridge = A.pl.bx * B.pl.bx + A.pl.bz * B.pl.bz < 0;
segs.forEach(sg => {
const P = pt(sg.ta), Q = pt(sg.tb), level = Math.abs(A.under(P) - A.under(Q)) < 1;
members.push({ kind: !sg.convex ? 'kilkeper' : !level ? 'hoekkeper' : ridge ? 'nokbalk' : 'knikbalk', A: P, B: Q, rg: A });
});
});
const alongX = shape.axis === 'x';
const own = members.find(m => m.kind === 'nokbalk' && (alongX ? Math.abs(m.A.x - m.B.x) < 1 : Math.abs(m.A.z - m.B.z) < 1));
const steel = own && roofState.steelRidge !== false;
const { hw, beams, awayFrom, addBeam } = beamTools(joistW);
members.forEach(m => { if (!(steel && m === own)) addBeam(m.kind, m.rg, m.A, m.B, [], true); });
pls.forEach(pl => {
const polys = regions.filter(rg => rg.key === pl.key).map(rg => rg.poly);
if (!polys.length) return;
const fixed = pl.along === 'p' ? 'z' : 'x';
const [lo, hi] = fixed === 'z' ? [rect.z0, rect.z1] : [rect.x0, rect.x1];
joistSpread(fp, hi - lo).map(v => lo + v).forEach(v => {
const spans = polys.map(poly => cutLine(poly, fixed, v)).filter(Boolean).sort((a, b) => a[0] - b[0]);
const runs = [];
spans.forEach(sp => {
const last = runs[runs.length - 1];
if (last && sp[0] <= last[1] + 1) last[1] = Math.max(last[1], sp[1]);
else runs.push(sp.slice());
});
runs.forEach(span => {
const pt = (t) => fixed === 'z' ? { x: t, z: v } : { x: v, z: t };
const A = pt(span[0]), B = pt(span[1]), mid = pt((span[0] + span[1]) / 2);
const clips = members.filter(m => distToSeg(A, m) < hw + 1 || distToSeg(B, m) < hw + 1).map(m => awayFrom(m, mid));
addBeam('spoor', pl, A, B, clips);
});
});
});
const ownBeams = clipBeams(beams, clipBox);
const capSpecs = regionSpecs(ownBeams, regions, joistW, joistH, underlay, covering);
const kgM2 = capSpecs.totalKg / Math.max(0.1, (rect.x1 - rect.x0) * (rect.z1 - rect.z0) / 1e6);
const q = own ? (alongX ? [own.A.z, own.B.z] : [own.A.x, own.B.x]) : null;
// Wolfsdak: de nok loopt hier tot de aansluiting, de inkorting van de wolfseinden volgt uit span.
const ridgeShape = shape.type === 'wolf' ? Object.assign({}, shape, { type: 'zadel' }) : shape;
const ridgeBeam = steel ? computeRidgeBeam(roofState, ridgeShape, rect, alongX, { underlayThick: underlay, joistH }, kgM2, [Math.min(...q), Math.max(...q)]) : null;
if (ridgeBeam) ridgeBeam.alongX = alongX;
const specs = Object.assign({}, capSpecs);
addRidgeSpecs(specs, ridgeBeam);
const heights = regions.flatMap(rg => rg.poly.map(p => rg.under(p)));
const maxTop = Math.max(...regions.map(rg => Math.max(...rg.poly.map(p => rg.under(p) + rg.vThick))));
const cap = {
kind: 'join', pA: rect.x0, pB: rect.x1, q0: rect.z0, q1: rect.z1, eave: Math.min(...heights), ridgeH: Math.max(...heights),
slope: Math.max(...regions.map(rg => rg.slope)), joistW, joistH, regions, beams: ownBeams, specs: capSpecs, maxTop
};
// Knikpunten van de daklijn op de randen (nok, knik, wolfseind, kepers): daar knikt ook het boeideel.
const ridges = [];
members.forEach(m => [m.A, m.B].forEach(p => {
const onX = Math.abs(p.x - rect.x0) < 1 || Math.abs(p.x - rect.x1) < 1, onZ = Math.abs(p.z - rect.z0) < 1 || Math.abs(p.z - rect.z1) < 1;
const r = onX && !onZ ? { axis: 'z', value: p.z } : onZ && !onX ? { axis: 'x', value: p.x } : null;
if (r && !ridges.some(q => q.axis === r.axis && Math.abs(q.value - r.value) < 1)) ridges.push(r);
}));
return {
shape, covering, planes: [], specs, ridgeBeam, cap, rect, alongX: true, ridges, maxTop,
undersideAt: (x, z) => joinValue(join, { x, z }),
topAt: (x, z) => joinValue(join, { x, z }, k => pls[k].vThick)
};
}
const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel
function computeCap(roofState, shape, rect, alongX, covering) { function computeCap(roofState, shape, rect, alongX, covering) {
const H = (p) => profileHeight(shape.profile, p); const H = (p) => profileHeight(shape.profile, p);
@@ -465,53 +700,7 @@ window.RoofModel = (() => {
.filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100); .filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100);
if (!clipped.some(rg => rg.along === 'q')) return null; if (!clipped.some(rg => rg.along === 'q')) return null;
const cutLine = (poly, fixed, v) => { const { hw, beams, awayFrom, addBeam } = beamTools(joistW);
const ts = [];
poly.forEach((a, k) => {
const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed];
if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return;
const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x';
ts.push(a[o] + (b[o] - a[o]) * t);
});
return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null;
};
const hw = joistW / 2;
const rectAround = (A, B) => {
const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw;
return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }];
};
const awayFrom = (m, from) => {
const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L;
const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz;
const sgn = d(from) >= 0 ? 1 : -1;
return (p) => sgn * d(p) - hw;
};
const distToSeg = (p, m) => {
const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz;
const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2));
return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t));
};
const beams = [];
const addBeam = (kind, rg, A, B, clips, lineHeight) => {
if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null;
let poly = rectAround(A, B);
(clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); });
if (poly.length < 3) return null;
const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz);
const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV;
const yAt = lineHeight
? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L)))
: (p) => rg.under(p) + rg.tV;
const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L);
const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L;
if (plan < 50) return null;
const bm = {
kind, A, B, poly, yAt, jV: rg.jV, slope,
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
};
beams.push(bm);
return bm;
};
const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0]; const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0];
const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0]; const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0];
const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }]; const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }];
@@ -534,8 +723,7 @@ window.RoofModel = (() => {
if (ridgeBeam && roofState.steelRidge !== false) beams.splice(beams.indexOf(ridgeBeam), 1); if (ridgeBeam && roofState.steelRidge !== false) beams.splice(beams.indexOf(ridgeBeam), 1);
} }
const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []); const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []);
const spread = (len) => FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: joistW, joistHeight: joistH, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: underlay }).joists.map(x => x + len / 2); const qs = joistSpread(fp, q1 - q0).map(v => q0 + v), ps = joistSpread(fp, pB - pA).map(v => pA + v);
const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(pB - pA).map(v => pA + v);
regions.forEach(rg => { regions.forEach(rg => {
const fixed = rg.along === 'p' ? 'z' : 'x'; const fixed = rg.along === 'p' ? 'z' : 'x';
(rg.along === 'p' ? qs : ps).forEach(v => { (rg.along === 'p' ? qs : ps).forEach(v => {
@@ -548,8 +736,69 @@ window.RoofModel = (() => {
}); });
}); });
const ownBeams = beams.map(bm => { const ownBeams = clipBeams(beams, clipBox);
const poly = clipBox(bm.poly); const specs = regionSpecs(ownBeams, clipped, joistW, joistH, underlay, covering);
const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick))));
return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop };
}
// Houtwerk op dakvlakken met een eigen omtrek (wolfseinden, dakaansluiting); balken als rechthoek in bovenaanzicht.
function cutLine(poly, fixed, v) {
const ts = [];
poly.forEach((a, k) => {
const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed];
if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return;
const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x';
ts.push(a[o] + (b[o] - a[o]) * t);
});
return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null;
}
function distToSeg(p, m) {
const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz;
const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2));
return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t));
}
function joistSpread(fp, len) {
return FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: fp.joistWidth || 70, joistHeight: fp.joistHeight || 220, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: fp.underlayThick || 22 })
.joists.map(x => x + len / 2);
}
function beamTools(joistW) {
const hw = joistW / 2, beams = [];
const rectAround = (A, B) => {
const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw;
return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }];
};
const awayFrom = (m, from) => {
const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L;
const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz;
const sgn = d(from) >= 0 ? 1 : -1;
return (p) => sgn * d(p) - hw;
};
const addBeam = (kind, rg, A, B, clips, lineHeight) => {
if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null;
let poly = rectAround(A, B);
(clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); });
if (poly.length < 3) return null;
const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz);
const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV;
const yAt = lineHeight
? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L)))
: (p) => rg.under(p) + rg.tV;
const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L);
const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L;
if (plan < 50) return null;
const bm = {
kind, A, B, poly, yAt, jV: rg.jV, slope,
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
};
beams.push(bm);
return bm;
};
return { hw, beams, awayFrom, addBeam };
}
function clipBeams(beams, clip) {
return beams.map(bm => {
const poly = clip(bm.poly);
if (poly.length < 3 || quadArea(poly) < 100) return null; if (poly.length < 3 || quadArea(poly) < 100) return null;
const dx = bm.B.x - bm.A.x, dz = bm.B.z - bm.A.z, L = Math.hypot(dx, dz); const dx = bm.B.x - bm.A.x, dz = bm.B.z - bm.A.z, L = Math.hypot(dx, dz);
const ts = poly.map(p => ((p.x - bm.A.x) * dx + (p.z - bm.A.z) * dz) / L); const ts = poly.map(p => ((p.x - bm.A.x) * dx + (p.z - bm.A.z) * dz) / L);
@@ -557,20 +806,19 @@ window.RoofModel = (() => {
if (plan < 50) return null; if (plan < 50) return null;
return Object.assign({}, bm, { poly, length: Math.round(plan * Math.sqrt(1 + slope * slope)) }); return Object.assign({}, bm, { poly, length: Math.round(plan * Math.sqrt(1 + slope * slope)) });
}).filter(Boolean); }).filter(Boolean);
}
function regionSpecs(beams, regions, joistW, joistH, underlay, covering) {
const W = joistW * 1e-3, Hh = joistH * 1e-3; const W = joistW * 1e-3, Hh = joistH * 1e-3;
const woodM3 = ownBeams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0); const woodM3 = beams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0);
const areaM2 = clipped.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6; const areaM2 = regions.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6;
const cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3; const cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3;
const specs = Object.assign({ return Object.assign({
joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3, joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3,
osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3, osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3,
beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bVol * DENSITY_OSB_KGM3, beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bVol * DENSITY_OSB_KGM3,
voidM3: Math.max(0, areaM2 * Hh - woodM3), voidM3: Math.max(0, areaM2 * Hh - woodM3),
totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg
}, cs); }, cs);
const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick))));
return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop };
} }
// Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ. // Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ.
@@ -581,7 +829,8 @@ window.RoofModel = (() => {
if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts)); if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts));
if (unitRoof.ridgeBeam) { if (unitRoof.ridgeBeam) {
const r = unitRoof.rect; const r = unitRoof.rect;
group.add(buildRidgeBeam3D(unitRoof.ridgeBeam, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, unitRoof.alongX, opts.materials)); const rb = unitRoof.ridgeBeam;
group.add(buildRidgeBeam3D(rb, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, rb.alongX !== undefined ? rb.alongX : unitRoof.alongX, opts.materials));
} }
unitRoof.planes.forEach(pl => { unitRoof.planes.forEach(pl => {
const g = new THREE.Group(); const g = new THREE.Group();
@@ -647,7 +896,7 @@ window.RoofModel = (() => {
const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt))); const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt)));
const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0]; const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0];
const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); }; const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); };
const up = (T[1][0] - T[0][0]) * (T[2][2] - T[0][2]) - (T[1][2] - T[0][2]) * (T[2][0] - T[0][0]) < 0; const up = T.reduce((t, a, k) => { const b = T[(k + 1) % n]; return t + a[0] * b[2] - b[0] * a[2]; }, 0) < 0;
for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]); for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]);
const topCount = pos.length / 3; const topCount = pos.length / 3;
for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2); for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2);
@@ -709,7 +958,8 @@ window.RoofModel = (() => {
} }
// facadeDepth negatief = gevel van een hoger deel die naar dit dak kijkt: de rand stopt tegen die gevel. // facadeDepth negatief = gevel van een hoger deel die naar dit dak kijkt: de rand stopt tegen die gevel.
function computeEdge(settings, roofs, facadeDepth) { // facadeFels(a, b, normal): de gevel onder deze rand heeft felsplaten (met een felsdak: geen boeideel).
function computeEdge(settings, roofs, facadeDepth, facadeFels) {
const s = normalizeSettings(settings); const s = normalizeSettings(settings);
const rects = roofs.map(r => r.rect); const rects = roofs.map(r => r.rect);
if (!roofs.length) return null; if (!roofs.length) return null;
@@ -753,19 +1003,23 @@ window.RoofModel = (() => {
const j = (i + 1) % n, b = base[j]; const j = (i + 1) % n, b = base[j];
const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 }; const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 };
const owner = members.find(r => onRectEdge(r.rect, mid)) || members[0]; const owner = members.find(r => onRectEdge(r.rect, mid)) || members[0];
const fascia = owner.fascia !== false; const normal = rectilinearNormal(a, b);
const fels = coverKind(owner.covering) === 'fels' && !!facadeFels && !!facadeFels(a, b, normal);
const fascia = owner.fascia !== false && !fels;
const off = fascia ? offBoei : offTrim; const off = fascia ? offBoei : offTrim;
// Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots. // Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots.
const nb = others.find(r => onRectEdge(r.rect, mid)); const nb = others.find(r => onRectEdge(r.rect, mid));
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z); const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
const above = fels ? FELS_EDGE.aboveMM : fascia ? EDGE_ABOVE_MM : TRIM.ABOVE_MM;
return { return {
a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b), a, b, owner: owner.unitId, fascia, fels, covering: owner.covering, normal, above,
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j], outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j],
topAt: (p) => owner.topAt(p.x, p.z) + EDGE_ABOVE_MM, ridges: owner.ridges || [], topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [],
overRoof: !!nb && !dropped, dropped overRoof: !!nb && !dropped, dropped
}; };
}); });
const depRaw = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) || 0 : 0)); const depIn = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) : 0));
const depRaw = depIn.map(d => d || 0);
const dep = depRaw.map(d => Math.min(0, d)); const dep = depRaw.map(d => Math.min(0, d));
const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO); const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO);
const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5; const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5;
@@ -779,7 +1033,25 @@ window.RoofModel = (() => {
spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni]) spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni])
}; };
}); });
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] })); // facadeUnder: een eigen gevel onder de rand (niet bij een veranda of een hogere gevel ertegenaan).
segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i], facadeUnder: depIn[i] !== null && depIn[i] >= 0 }));
// Rand zonder overstek (daktrim, felsrand): ligt net vóór de gevelbekleding en loopt bij een hoek door tot de
// buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand.
const edgeOff = (k) => segs[k].fels ? Math.max(TRIM_THICK_MM, depRaw[k] + FELS_EDGE.lipMM) : Math.max(0, depRaw[k]) + TRIM.OUT_MM;
const mv = (p, nrm, d) => ({ x: p.x + nrm.x * d, z: p.z + nrm.z * d });
segs.forEach((sg, i) => {
const ends = [['a', 'outerA', (i - 1 + n) % n], ['b', 'outerB', (i + 1) % n]];
if (!sg.fascia) ends.forEach(([k, ok, j]) => {
const nb = segs[j], q = mv(sg[k], sg.normal, edgeOff(i));
sg[ok] = perp(sg.normal, nb.normal) && !nb.fascia && !nb.dropped ? mv(q, nb.normal, edgeOff(j)) : q;
});
else if (sg.fascia) ends.forEach(([k, ok, j]) => {
const nb = segs[j];
if (nb.fascia || nb.dropped || !perp(sg.normal, nb.normal)) return;
sg[ok] = mv(mv(sg[k], sg.normal, s.overhang), nb.normal, edgeOff(j));
});
});
const insideOther = (p) => others.some(r => p.x > r.rect.x0 + 0.5 && p.x < r.rect.x1 - 0.5 && p.z > r.rect.z0 + 0.5 && p.z < r.rect.z1 - 0.5); const insideOther = (p) => others.some(r => p.x > r.rect.x0 + 0.5 && p.x < r.rect.x1 - 0.5 && p.z > r.rect.z0 + 0.5 && p.z < r.rect.z1 - 0.5);
segs.forEach((sg, i) => { segs.forEach((sg, i) => {
@@ -808,9 +1080,13 @@ window.RoofModel = (() => {
return { return {
segments, overhang: s.overhang, spotRatio: SPOT_RATIO, segments, overhang: s.overhang, spotRatio: SPOT_RATIO,
fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor, fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor,
top, edgeAbove: EDGE_ABOVE_MM, vent: s.vent, ventColor: s.ventColor,
top,
fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0), fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0),
trimLengthMM: segments.filter(sg => !sg.fascia).reduce((t, sg) => t + segLen(sg), 0), ventLengthMM: s.vent ? segments.filter(sg => sg.fascia && sg.facadeUnder).reduce((t, sg) => t + Math.hypot(sg.b.x - sg.a.x, sg.b.z - sg.a.z), 0) : 0,
// Felsrand: zetwerk van de dakplaten, zit in de prijs van de felsplaten.
trimLengthMM: segments.filter(sg => !sg.fascia && !sg.fels).reduce((t, sg) => t + segLen(sg), 0),
felsEdgeLengthMM: segments.filter(sg => sg.fels).reduce((t, sg) => t + segLen(sg), 0),
soffitM2: segments.filter(sg => sg.fascia).reduce((t, sg) => t + quadArea([sg.a, sg.b, sg.outerB, sg.outerA]), 0) / 1e6, soffitM2: segments.filter(sg => sg.fascia).reduce((t, sg) => t + quadArea([sg.a, sg.b, sg.outerB, sg.outerA]), 0) / 1e6,
spots: [] spots: []
}; };
@@ -829,19 +1105,38 @@ window.RoofModel = (() => {
return out; return out;
} }
function prismGeometry(poly, topFn, h) { // uAt(p): met textuur, u langs de rand; v = 0 onder, 1 boven.
const toM = 0.001, n = poly.length, pos = []; function prismGeometry(poly, topFn, h, uAt) {
const toM = 0.001, n = poly.length, pos = [], uvs = [];
const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]); const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]);
const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]); const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]);
const U = uAt ? poly.map(uAt) : null;
const tri = (a, b, c) => pos.push(...a, ...b, ...c); const tri = (a, b, c) => pos.push(...a, ...b, ...c);
for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); tri(B[0], B[k + 1], B[k]); } const uv = (...q) => { if (U) q.forEach(([k, v]) => uvs.push(U[k], v)); };
for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); tri(T[k], B[j], T[j]); } for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); uv([0, 1], [k, 1], [k + 1, 1]); tri(B[0], B[k + 1], B[k]); uv([0, 0], [k + 1, 0], [k, 0]); }
for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); uv([k, 1], [k, 0], [j, 0]); tri(T[k], B[j], T[j]); uv([k, 1], [j, 0], [j, 1]); }
const geo = new THREE.BufferGeometry(); const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
if (U) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.computeVertexNormals(); geo.computeVertexNormals();
return geo; return geo;
} }
// Gevelvlak onder een rand (afstand langs de normaal): de dakrand plus de bekleding die erbuiten steekt.
function ventFace(sg) {
return sg.a.x * sg.normal.x + sg.a.z * sg.normal.z + Math.max(0, sg.facadeDepth || 0);
}
// Hoogte van de rand vanaf sg.topAt naar beneden: boeideel, felsrand of daktrim (met opstand boven het dak).
function edgeHeight(edge, sg) {
return sg.fascia ? edge.fasciaHeight : sg.fels ? FELS_EDGE.heightMM : edge.trimHeight + TRIM.ABOVE_MM;
}
// Kleur van de rand in tekeningen (hex-getal).
function edgeColor(edge, sg) {
if (sg.fascia) return (FASCIA_COLORS[edge.fasciaColor] || FASCIA_COLORS.antraciet).hex;
const c = sg.fels && COVERINGS[sg.covering];
return c ? parseInt(c.color.slice(1), 16) : 0xb8bcc0;
}
function buildEdge3D(edge, materials, opts) { function buildEdge3D(edge, materials, opts) {
const o = opts || {}; const o = opts || {};
const toM = 0.001; const toM = 0.001;
@@ -855,12 +1150,35 @@ 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);
}); });
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove; // Schoorsteen: de rand stopt ertegen (gaps langs de rand, in wereld-mm).
polys.forEach(pl => { const alongZ = Math.abs(sg.a.x - sg.b.x) < 0.5;
const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial); (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 = edgeHeight(edge, sg);
const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial;
const nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z;
const add = (pl, topFn, hh, m, u) => {
const mesh = new THREE.Mesh(prismGeometry(pl, topFn, hh, u), m);
mesh.visible = o.edgeVisible !== false; mesh.visible = o.edgeVisible !== false;
group.add(mesh); group.add(mesh);
};
if (!sg.fascia && !sg.fels) {
// Daktrim: opstand aan de voorkant, flens erachter op het dak.
const inner = nd(sg.outerA) - TRIM.DEPTH_MM;
polys.forEach(pl => {
const up = clipPoly(pl, p => nd(p) - inner), fl = clipPoly(pl, p => inner - nd(p));
if (up.length >= 3) add(up, sg.topAt, h, mat);
if (fl.length >= 3) add(fl, p => sg.topAt(p) - TRIM.ABOVE_MM + TRIM.FLANGE_MM, TRIM.FLANGE_MM, mat);
}); });
} else polys.forEach(pl => add(pl, sg.topAt, h, mat));
// Rooster: op de gevel (voorkant bekleding), onder de onderplaat van het boeideel.
if (!sg.fascia || !sg.facadeUnder || !edge.vent) return;
const face = ventFace(sg);
const u = (p) => (alongZ ? p.z : p.x) / VENT.PITCH_MM;
polys.map(pl => clipPoly(clipPoly(pl, p => nd(p) - face), p => face + VENT.THICK_MM - nd(p))).filter(pl => pl.length >= 3)
.forEach(pl => add(pl, (p) => sg.topAt(p) - h, VENT.HEIGHT_MM, ventMaterial(edge.ventColor), u));
}); });
const spotR = 0.035, spotH = 0.012; const spotR = 0.035, spotH = 0.012;
@@ -879,15 +1197,21 @@ window.RoofModel = (() => {
s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang)); s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang));
delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit
const h = s.house || {}; const h = s.house || {};
s.house = { enabled: !!h.enabled, form: h.form && UnitModel.ROOF_TYPES[h.form.type] ? Object.assign({}, h.form) : null, cover: !!h.cover }; const form = h.form ? Object.assign({}, h.form) : null;
// v0.6.2: mansarde vervallen, wordt een zadeldak met dezelfde nok.
if (form && form.type === 'mansarde') { form.type = 'zadel'; delete form.kneeH; delete form.kneeIn; delete form.kneeRatio; }
s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover };
s.posts = PostModel.normalizeSettings(s.posts);
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor; if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
s.vent = s.vent !== false;
if (!WallModel.isTrimColor(s.ventColor)) s.ventColor = DEFAULT_SETTINGS.ventColor;
return s; return s;
} }
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, TRIM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs, createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions, computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions,
computeEdge, buildEdge3D, normalizeSettings FELS_EDGE, VENT, ventFace, computeEdge, buildEdge3D, edgeHeight, edgeColor, 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++) {
+14 -21
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));
@@ -306,8 +306,8 @@ window.UnitModel = (() => {
return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h)); return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h));
} }
// kneeRatio / cutRatio gaan voor kneeH / cut, zodat ze meeschalen als goot of nok verandert. // cutRatio gaat voor cut, zodat ze meeschalen als goot of nok verandert.
const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 }; const LIMITS = { ridge: 5000, eave: 3000, short: 4700, long: 14000 };
function clampSize(floor, keep) { function clampSize(floor, keep) {
floor.width = Math.min(LIMITS.long, floor.width); floor.width = Math.min(LIMITS.long, floor.width);
floor.depth = Math.min(LIMITS.long, floor.depth); floor.depth = Math.min(LIMITS.long, floor.depth);
@@ -317,9 +317,9 @@ window.UnitModel = (() => {
} }
return floor; return floor;
} }
const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' }; const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', wolf: 'Wolfsdak' };
const RIDGES = { z: 'Noord–zuid', x: 'West–oost' }; const RIDGES = { z: 'Noord–zuid', x: 'West–oost' };
const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf'; const hasRidge = (type) => type === 'zadel' || type === 'wolf';
function gableOf(form) { function gableOf(form) {
if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter'; if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter';
if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links'; if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links';
@@ -333,10 +333,8 @@ window.UnitModel = (() => {
const full = fullTopLine(w, gableSpan(unitState, side)); const full = fullTopLine(w, gableSpan(unitState, side));
const F = full[full.length - 1].u, inner = full.slice(1, -1); const F = full[full.length - 1].u, inner = full.slice(1, -1);
const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h)); const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h));
if (inner.length === 3) { // v0.6.2: mansarde vervallen; een opgeslagen mansarde wordt een zadeldak met dezelfde nok.
const high = Math.round(inner[1].h); if (inner.length === 3) return { type: 'zadel', ridge, low, high: Math.round(inner[1].h) };
return { type: 'mansarde', ridge, low, high, kneeH: Math.round(inner[0].h), kneeIn: Math.round(inner[0].u), kneeRatio: (inner[0].h - low) / Math.max(1, high - low) };
}
if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) { if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) {
const slope = (inner[0].h - L.h) / Math.max(1, inner[0].u); const slope = (inner[0].h - L.h) / Math.max(1, inner[0].u);
const high = Math.round(L.h + slope * F / 2); const high = Math.round(L.h + slope * F / 2);
@@ -351,6 +349,10 @@ window.UnitModel = (() => {
const h = Math.max(...Object.values(W).map(wallTopOf)); const h = Math.max(...Object.values(W).map(wallTopOf));
return { type: 'plat', low: h, high: h }; return { type: 'plat', low: h, high: h };
} }
function dropMansarde(unitState) {
const f = roofForm(unitState), side = f.type === 'zadel' ? gableOf(f) : null;
if (side && fullTopLine(unitState.walls[side].wall, gableSpan(unitState, side)).length === 5) applyRoofForm(unitState, f);
}
function applyRoofForm(unitState, form) { function applyRoofForm(unitState, form) {
const side = form.type !== 'plat' ? gableOf(form) : 'achter'; const side = form.type !== 'plat' ? gableOf(form) : 'achter';
const g = gableSpan(unitState, side); const g = gableSpan(unitState, side);
@@ -365,16 +367,7 @@ window.UnitModel = (() => {
const half = F / 2; const half = F / 2;
let line = [{ u: 0, h: low }, { u: F, h: low }]; let line = [{ u: 0, h: low }, { u: F, h: low }];
if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }]; if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
else if (sloped && form.type === 'mansarde') { else if (sloped && form.type === 'wolf') {
// Knik altijd boven de rechte lijn goot–nok, anders wordt het een hol dak.
const kIn = Math.max(eMin + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18)));
const minK = low + (high - low) * kIn / half + 20;
const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH;
const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK)));
line = kH > minK - 1 && high - kH >= 20
? [{ u: 0, h: low }, { u: kIn, h: kH }, { u: half, h: high }, { u: F - kIn, h: kH }, { u: F, h: low }]
: [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }];
} else if (sloped && form.type === 'wolf') {
const minR = (eMin + 50) / half; const minR = (eMin + 50) / half;
const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6; const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6;
const cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low)))); const cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low))));
@@ -401,5 +394,5 @@ window.UnitModel = (() => {
} }
} }
return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, formLine, validateState }; return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, dropMansarde, applyRoofForm, formLine, validateState };
})(); })();
+78 -20
View File
@@ -4,7 +4,9 @@
// hebben om het origineel te vendoren; dekt precies de subset die wall.js // hebben om het origineel te vendoren; dekt precies de subset die wall.js
// gebruikt (`new THREE.OrbitControls(camera, domElement)`, // gebruikt (`new THREE.OrbitControls(camera, domElement)`,
// `controls.target.set(...)`, `controls.update()`), plus interactie: // `controls.target.set(...)`, `controls.update()`), plus interactie:
// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen. // links-slepen = roteren, scrollwiel = zoomen; zijwaarts schuiven (pannen) met rechts- of middelste-knop-slepen,
// Shift/Ctrl/⌘ + links-slepen, of de pijltjestoetsen terwijl de muis boven de weergave staat;
// touch: één vinger = roteren, twee vingers = knijpen (zoomen) en samen schuiven (pannen).
// //
// Moet NA three.min.js geladen worden (leunt op window.THREE). // Moet NA three.min.js geladen worden (leunt op window.THREE).
// ============================================================ // ============================================================
@@ -49,7 +51,9 @@
}; };
function onPointerDown(event) { function onPointerDown(event) {
if (event.button === 2) { const panButton = event.button === 2 || event.button === 1 || (event.button === 0 && (event.shiftKey || event.ctrlKey || event.metaKey));
if (event.button === 1) event.preventDefault(); // geen autoscroll
if (panButton) {
if (!scope.enablePan) return; if (!scope.enablePan) return;
isPanning = true; isPanning = true;
} else { } else {
@@ -60,13 +64,7 @@
lastY = event.clientY; lastY = event.clientY;
} }
function onPointerMove(event) { function rotate(dx, dy) {
if (isRotating) {
const dx = event.clientX - lastX;
const dy = event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
const w = scope.domElement.clientWidth || 1; const w = scope.domElement.clientWidth || 1;
const h = scope.domElement.clientHeight || 1; const h = scope.domElement.clientHeight || 1;
@@ -79,12 +77,8 @@
offset.setFromSpherical(spherical); offset.setFromSpherical(spherical);
scope.object.position.copy(scope.target).add(offset); scope.object.position.copy(scope.target).add(offset);
scope.object.lookAt(scope.target); scope.object.lookAt(scope.target);
} else if (isPanning) { }
const dx = event.clientX - lastX; function pan(dx, dy) {
const dy = event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
const distance = scope.object.position.distanceTo(scope.target); const distance = scope.object.position.distanceTo(scope.target);
const fov = (scope.object.fov || 50) * Math.PI / 180; const fov = (scope.object.fov || 50) * Math.PI / 180;
const h = scope.domElement.clientHeight || 1; const h = scope.domElement.clientHeight || 1;
@@ -96,6 +90,21 @@
scope.target.add(xAxis).add(yAxis); scope.target.add(xAxis).add(yAxis);
scope.object.position.add(xAxis).add(yAxis); scope.object.position.add(xAxis).add(yAxis);
} }
function zoom(scale) {
offset.copy(scope.object.position).sub(scope.target);
const distance = Math.max(scope.minDistance, Math.min(scope.maxDistance, offset.length() * scale));
offset.setLength(distance);
scope.object.position.copy(scope.target).add(offset);
}
function onPointerMove(event) {
if (!isRotating && !isPanning) return;
const dx = event.clientX - lastX;
const dy = event.clientY - lastY;
lastX = event.clientX;
lastY = event.clientY;
if (isRotating) rotate(dx, dy);
else pan(dx, dy);
} }
function onPointerUp() { isRotating = false; isPanning = false; } function onPointerUp() { isRotating = false; isPanning = false; }
@@ -103,20 +112,62 @@
function onWheel(event) { function onWheel(event) {
if (!scope.enableZoom) return; if (!scope.enableZoom) return;
event.preventDefault(); event.preventDefault();
const scale = event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale(); zoom(event.deltaY > 0 ? 1 / getZoomScale() : getZoomScale());
offset.copy(scope.object.position).sub(scope.target);
const distance = Math.max(scope.minDistance, Math.min(scope.maxDistance, offset.length() * scale));
offset.setLength(distance);
scope.object.position.copy(scope.target).add(offset);
} }
// Touch: midden en afstand van de vingers; bij een wissel van het aantal vingers opnieuw beginnen.
let touch = null;
function touchState(list) {
const a = list[0], b = list[1] || list[0];
return { n: list.length, x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2, d: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) };
}
function onTouchStart(event) {
event.preventDefault();
touch = touchState(event.touches);
}
function onTouchMove(event) {
if (!touch) return;
event.preventDefault();
const t = touchState(event.touches);
if (t.n !== touch.n) { touch = t; return; }
if (t.n === 1) { if (scope.enableRotate) rotate(t.x - touch.x, t.y - touch.y); }
else {
if (scope.enablePan) pan(t.x - touch.x, t.y - touch.y);
if (scope.enableZoom && t.d > 0 && touch.d > 0) zoom(touch.d / t.d);
}
touch = t;
}
function onTouchEnd(event) { touch = event.touches.length ? touchState(event.touches) : null; }
function onContextMenu(event) { event.preventDefault(); } function onContextMenu(event) { event.preventDefault(); }
// Pijltjestoetsen: schuiven terwijl de muis boven de weergave staat (niet in een invoerveld).
let hover = false;
const onEnter = () => { hover = true; };
const onLeave = () => { hover = false; };
const KEY_PAN = { ArrowLeft: [1, 0], ArrowRight: [-1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
function onKeyDown(event) {
const d = KEY_PAN[event.key];
if (!d || !hover || !scope.enablePan || event.altKey || event.ctrlKey || event.metaKey) return;
const t = event.target, tag = t && t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (t && t.isContentEditable)) return;
event.preventDefault();
const step = (scope.domElement.clientHeight || 400) * (event.shiftKey ? 0.25 : 0.08);
pan(d[0] * step, d[1] * step);
}
this.domElement.addEventListener('mousedown', onPointerDown); this.domElement.addEventListener('mousedown', onPointerDown);
window.addEventListener('mousemove', onPointerMove); window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp); window.addEventListener('mouseup', onPointerUp);
this.domElement.addEventListener('wheel', onWheel, { passive: false }); this.domElement.addEventListener('wheel', onWheel, { passive: false });
this.domElement.addEventListener('contextmenu', onContextMenu); this.domElement.addEventListener('contextmenu', onContextMenu);
this.domElement.addEventListener('mouseenter', onEnter);
this.domElement.addEventListener('mouseleave', onLeave);
window.addEventListener('keydown', onKeyDown);
this.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
this.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
this.domElement.addEventListener('touchend', onTouchEnd);
this.domElement.addEventListener('touchcancel', onTouchEnd);
this.dispose = function () { this.dispose = function () {
scope.domElement.removeEventListener('mousedown', onPointerDown); scope.domElement.removeEventListener('mousedown', onPointerDown);
@@ -124,6 +175,13 @@
window.removeEventListener('mouseup', onPointerUp); window.removeEventListener('mouseup', onPointerUp);
scope.domElement.removeEventListener('wheel', onWheel); scope.domElement.removeEventListener('wheel', onWheel);
scope.domElement.removeEventListener('contextmenu', onContextMenu); scope.domElement.removeEventListener('contextmenu', onContextMenu);
scope.domElement.removeEventListener('mouseenter', onEnter);
scope.domElement.removeEventListener('mouseleave', onLeave);
window.removeEventListener('keydown', onKeyDown);
scope.domElement.removeEventListener('touchstart', onTouchStart);
scope.domElement.removeEventListener('touchmove', onTouchMove);
scope.domElement.removeEventListener('touchend', onTouchEnd);
scope.domElement.removeEventListener('touchcancel', onTouchEnd);
}; };
} }
+1117
View File
File diff suppressed because one or more lines are too long
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+2297
View File
File diff suppressed because it is too large Load Diff
+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 };
})(); })();
+250 -56
View File
@@ -72,6 +72,67 @@ window.WallModel = (() => {
return mat; return mat;
} }
// Felsplaten (staande naad) op de gevel: zelfde opbouw als verticale rabat (doek, latten, bekleding), alleen het
// materiaal verschilt. Kleuren ook voor het dak (RoofModel.FELS). In 3D de naden als textuur over het blok.
const FELS = {
SEAM_MM: 500, TILE_SEAMS: 6,
COLORS: {
zink: { label: 'Zink', hex: '#8e9497' },
antraciet: { label: 'Antraciet', hex: '#3d4146' },
zwart: { label: 'Zwart', hex: '#1f2022' }
}
};
const isFelsColor = (key) => !!FELS.COLORS[key];
const felsHex = (key) => (FELS.COLORS[key] || FELS.COLORS.antraciet).hex;
const felsMaterials = {};
function felsMaterial(key) {
const k = isFelsColor(key) ? key : 'antraciet';
if (felsMaterials[k]) return felsMaterials[k];
let mat;
const canvas = typeof document !== 'undefined' && document.createElement('canvas');
const ctx = canvas && canvas.getContext && canvas.getContext('2d');
if (!ctx) mat = new THREE.MeshStandardMaterial({ color: felsHex(k), roughness: 0.4, metalness: 0.55 });
else {
const B = 100, N = FELS.TILE_SEAMS; // 5 mm/px
canvas.width = B * N;
canvas.height = 16;
const base = new THREE.Color(felsHex(k));
const shade = (f) => '#' + base.clone().multiplyScalar(f).getHexString();
const col = (a, b, color) => { ctx.fillStyle = color; ctx.fillRect(a, 0, b - a, canvas.height); };
for (let i = 0; i < N; i++) {
const a = i * B, f = 0.97 + ((i * 29) % 7) / 100; // per baan net iets anders
col(a, a + B, shade(f));
col(a + 6, a + 14, shade(f * 1.04)); // lichte zetting naast de naad
col(a, a + 2, shade(f * 0.6)); // schaduw van de naad
col(a + 2, a + 5, shade(f * 1.35)); // staande naad (licht)
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1000 / (FELS.SEAM_MM * N), 1);
tex.anisotropy = 8;
mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.4, metalness: 0.55 });
}
felsMaterials[k] = mat;
return mat;
}
// Afwerking in zetwerk (aluminium, gepoedercoat): afdekker op een stenen trasraam en het ventilatierooster onder
// de boeidelen.
const TRIM_COLORS = {
zwart: { label: 'Zwart', hex: '#1f2022' },
antraciet: { label: 'Antraciet', hex: '#3d4146' },
wit: { label: 'Wit', hex: '#f3f2ee' }
};
const isTrimColor = (key) => !!TRIM_COLORS[key];
const trimHex = (key) => (TRIM_COLORS[key] || TRIM_COLORS.antraciet).hex;
const trimMaterials = {};
function trimMaterial(key) {
const k = isTrimColor(key) ? key : 'antraciet';
return trimMaterials[k] || (trimMaterials[k] = new THREE.MeshStandardMaterial({ color: trimHex(k), roughness: 0.45, metalness: 0.3 }));
}
// Afdekker trasraam: bovenkant 5° aflopend (85° met de neus), neus 25 mm over de steen, 3 mm ervoor.
const TRAS_CAP = { SLOPE_DEG: 5, DROP_MM: 25, GAP_MM: 3, THICK_MM: 2 };
function claddingOffset(buitenMateriaal, stone, stuc) { function claddingOffset(buitenMateriaal, stone, stuc) {
if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM; if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM;
if (buitenMateriaal !== 'cementplaat') return 0; if (buitenMateriaal !== 'cementplaat') return 0;
@@ -121,11 +182,19 @@ window.WallModel = (() => {
return mat; return mat;
} }
// Materiaal van een gevelbekleding (UV in meters): felsplaten, steen, stuc of rabat; null = kale plaat.
function claddingMaterial(f) {
if (isFelsColor(f.fels)) return felsMaterial(f.fels);
if (isStoneFinish(f.stone)) return stoneMaterial(f.stone);
if (isStucColor(f.stuc)) return stucMaterial(f.stuc);
return f.rabatColor ? rabatMaterial(f.rabatColor, f.cladding) : null;
}
// Hoogte vanaf het loopvlak (= onderkant van de wand). // Hoogte vanaf het loopvlak (= onderkant van de wand).
const TRASRAAM_DEFAULT_MM = 350; const TRASRAAM_DEFAULT_MM = 350;
function trasraamFor(heightMM, finish) { function trasraamFor(heightMM, finish, capColor) {
if (!(heightMM > 0)) return null; if (!(heightMM > 0)) return null;
return { bottom: 0, top: heightMM, finish: finish || 'cementplaat' }; return { bottom: 0, top: heightMM, finish: finish || 'cementplaat', capColor: isTrimColor(capColor) ? capColor : 'antraciet' };
} }
// Textuur 2062 × 1125 px op 2 mm/px. JOINT_MM: hart onderste voeg boven de onderrand (gemeten 1-2 px); // Textuur 2062 × 1125 px op 2 mm/px. JOINT_MM: hart onderste voeg boven de onderrand (gemeten 1-2 px);
@@ -165,8 +234,8 @@ window.WallModel = (() => {
'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274' 'R961': '#c7b8a2', 'R970': '#ecdeb1', 'R980': '#ae9c88', 'R985': '#cd8b6d', 'R991': '#aa8274'
}; };
const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish); const isStoneFinish = (finish) => TRASRAAM_STONES.includes(finish);
const stoneTextureUrl = (code) => STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'); const stoneTextureUrl = (code) => tectumUrl(STONE.DIR + encodeURIComponent(code + ' - Witte voeg.jpg'));
const stoneThumbUrl = (code, px) => STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'); const stoneThumbUrl = (code, px) => tectumUrl(STONE.DIR + 'thumb/' + (px || 64) + '/' + encodeURIComponent(code + ' - Witte voeg.png'));
// UV's in meters, dus repeat = 1/tegel. // UV's in meters, dus repeat = 1/tegel.
const stoneMaterials = {}; const stoneMaterials = {};
function stoneMaterial(code) { function stoneMaterial(code) {
@@ -269,6 +338,17 @@ window.WallModel = (() => {
}; };
} }
function raiseTop(CONFIG, d) {
return d ? Object.assign({}, CONFIG, {
wallHeightLeft: CONFIG.wallHeightLeft + d, wallHeightRight: CONFIG.wallHeightRight + d,
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + d }))
}) : CONFIG;
}
// Bovenrand van de buitenlagen: per punt op het dak (skinTop) of de wandrand + skinAbove.
function skinConfig(CONFIG, skinAbove, skinTop) {
return skinTop ? Object.assign({}, CONFIG, { wallHeightLeft: skinTop.hL, wallHeightRight: skinTop.hR, wallTop: skinTop.pts }) : raiseTop(CONFIG, skinAbove);
}
function topPlateOutlines(tl) { function topPlateOutlines(tl) {
const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n]; const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n];
return tl.segs.map((sg, i) => [ return tl.segs.map((sg, i) => [
@@ -309,20 +389,26 @@ window.WallModel = (() => {
const edgeMargin = studWidth * CORNER_STUDS; const edgeMargin = studWidth * CORNER_STUDS;
let resolved = rawOpenings.map(raw => { let resolved = rawOpenings.map(raw => {
const w = Math.min(Math.max(400, raw.w), maxOpW); // Schoorsteen: de sparing kan smaller zijn (half over een naad tussen units).
const w = Math.min(Math.max(raw.type === 'schoorsteen' ? 100 : 400, raw.w), maxOpW);
let x; let x;
if (raw.anchor === 'right') x = wallWidth - raw.offset - w; if (raw.anchor === 'right') x = wallWidth - raw.offset - w;
else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset; else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset;
else x = raw.offset; else x = raw.offset;
return { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y }; const o = { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y };
if (raw.chimney) o.chimney = raw.chimney;
if (raw.koppel && o.type !== 'schoorsteen') o.koppel = true;
return o;
}); });
resolved.sort((a, b) => a.x - b.x); resolved.sort((a, b) => a.x - b.x);
// Een schoorsteen over een naad loopt door tot het wandeinde: daar geen hoekstijlen in de schoorsteen.
const mL = (o) => o.chimney && o.chimney.atStart ? 0 : edgeMargin, mR = (o) => o.chimney && o.chimney.atEnd ? 0 : edgeMargin;
resolved.forEach(o => { resolved.forEach(o => {
o.x = Math.max(edgeMargin, Math.min(o.x, wallWidth - o.w - edgeMargin)); o.x = Math.max(mL(o), Math.min(o.x, wallWidth - o.w - mR(o)));
}); });
// Boven elkaar (bijv. raam boven deur) mogen openingen in x overlappen. Schuin: de hoogste kant telt. // Boven elkaar (bijv. raam boven deur) mogen openingen in x overlappen. Schuin: de hoogste kant telt.
@@ -336,22 +422,42 @@ window.WallModel = (() => {
// uitsparingen. Naast een deur 1× extra voor de dubbele stijl. // uitsparingen. Naast een deur 1× extra voor de dubbele stijl.
const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0)); const gapOf = (a, b) => studWidth * (2 + (isDoor(a) ? 1 : 0) + (isDoor(b) ? 1 : 0));
// Koppelen: staat een gekoppelde opening tegen zijn buur aan (niet verder dan de normale tussenruimte),
// met dezelfde onderkant en bovenkant op de naad, dan vormen ze één sparing zonder stijlen, dagkant of
// latten ertussen; de rechter sluit direct aan. Samen niet breder dan de max. sparingbreedte.
const rightOf = new Map(), leftOf = new Map(), koppelWhy = new Map();
const groupW = (o) => { let w = 0; for (let p = o; p; p = leftOf.get(p)) w += p.w; return w; };
const FAR = 'staat niet tegen de opening ernaast aan';
const blame = (a, b, why) => [a, b].forEach(o => { if (o.koppel && (!koppelWhy.has(o) || koppelWhy.get(o) === FAR)) koppelWhy.set(o, why); });
resolved.forEach((a, i) => {
const b = resolved.slice(i + 1).find(o => !stacked(a, o));
if (!b || !(a.koppel || b.koppel) || a.type === 'schoorsteen' || b.type === 'schoorsteen' || leftOf.has(b)) return;
let why = null;
if (b.x - a.x - a.w > gapOf(a, b) + 0.5) why = FAR;
else if (Math.abs(a.rawY - b.rawY) > 0.5 || Math.abs(a.rawY + a.rawHR - b.rawY - b.rawH) > 0.5) why = 'onderkant en hoogte moeten gelijk zijn aan de opening ernaast';
else if (groupW(a) + b.w > maxOpW + 0.5) why = `samen breder dan ${maxOpW} mm`;
if (why) { blame(a, b, why); return; }
rightOf.set(a, b);
leftOf.set(b, a);
});
const gap = (a, b) => rightOf.get(a) === b ? 0 : gapOf(a, b);
for (let i = 1; i < resolved.length; i++) { for (let i = 1; i < resolved.length; i++) {
const cur = resolved[i]; const cur = resolved[i], partner = leftOf.get(cur);
if (partner) cur.x = partner.x + partner.w;
for (let j = 0; j < i; j++) { for (let j = 0; j < i; j++) {
const prev = resolved[j]; const prev = resolved[j];
if (stacked(prev, cur)) continue; if (stacked(prev, cur)) continue;
cur.x = Math.max(cur.x, prev.x + prev.w + gapOf(prev, cur)); cur.x = Math.max(cur.x, prev.x + prev.w + gap(prev, cur));
} }
} }
const rightLimit = wallWidth - edgeMargin;
for (let i = resolved.length - 1; i >= 0; i--) { for (let i = resolved.length - 1; i >= 0; i--) {
const cur = resolved[i]; const cur = resolved[i];
let maxX = rightLimit - cur.w; let maxX = wallWidth - mR(cur) - cur.w;
for (let j = i + 1; j < resolved.length; j++) { for (let j = i + 1; j < resolved.length; j++) {
const next = resolved[j]; const next = resolved[j];
if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gapOf(cur, next) - cur.w); if (!stacked(cur, next)) maxX = Math.min(maxX, next.x - gap(cur, next) - cur.w);
} }
if (cur.x > maxX) cur.x = maxX; if (cur.x > maxX) cur.x = maxX;
} }
@@ -359,22 +465,34 @@ window.WallModel = (() => {
// De daklijn is bol, dus de rechte lijn tussen twee punten eronder blijft eronder. // De daklijn is bol, dus de rechte lijn tussen twee punten eronder blijft eronder.
resolved.forEach(o => { resolved.forEach(o => {
const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w); const ceilL = roofAt(o.x), ceilR = roofAt(o.x + o.w);
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - plateThick * 2)); // Schoorsteen: tot de bovenkant, zonder latei en bovenregel erin (geen hout in het rookkanaal).
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - plateThick * 2)); const keep = o.type === 'schoorsteen' ? 0 : plateThick * 2;
const hL = Math.min(Math.max(400, o.rawH), Math.max(400, ceilL - keep));
const hR = Math.min(Math.max(400, o.rawHR), Math.max(400, ceilR - keep));
const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick); const maxOpY = Math.max(0, Math.min(ceilL - hL, ceilR - hR) - plateThick);
o.y = Math.max(0, Math.min(o.rawY, maxOpY)); o.y = Math.max(0, Math.min(o.rawY, maxOpY));
o.h = hL; o.h = hL;
o.hR = hR; o.hR = hR;
}); });
// Twee draaideuren: stolp (bladen tegen elkaar), anders één gedeelde tussenstijl.
const swing = (o) => !!o.fill && o.fill.type === 'deur';
rightOf.forEach((b, a) => {
const fits = Math.abs(b.x - a.x - a.w) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.y + a.hR - b.y - b.h) < 0.5;
if (!fits) { rightOf.delete(a); blame(a, b, 'past hier niet tegen de opening ernaast'); return; }
a.joinR = b.joinL = swing(a) && swing(b) ? 'stolp' : 'stijl';
});
koppelWhy.forEach((why, o) => { if (!o.joinL && !o.joinR) o.koppelWarn = why; });
resolved.forEach(o => { if (o.koppel && !o.joinL && !o.joinR && !o.koppelWarn) o.koppelWarn = 'geen opening direct ernaast'; });
resolved.forEach(o => { resolved.forEach(o => {
o.overflowing = (o.x < edgeMargin - 0.5) || (o.x + o.w > wallWidth - edgeMargin + 0.5); o.overflowing = (o.x < mL(o) - 0.5) || (o.x + o.w > wallWidth - mR(o) + 0.5);
}); });
for (let i = 0; i < resolved.length; i++) { for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) { for (let j = i + 1; j < resolved.length; j++) {
const a = resolved[i], b = resolved[j]; const a = resolved[i], b = resolved[j];
const [l, r] = a.x <= b.x ? [a, b] : [b, a]; const [l, r] = a.x <= b.x ? [a, b] : [b, a];
if (!stacked(a, b, true) && r.x < l.x + l.w + gapOf(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; } if (!stacked(a, b, true) && r.x < l.x + l.w + gap(l, r) - 0.5) { a.overflowing = true; b.overflowing = true; }
} }
} }
@@ -453,10 +571,14 @@ window.WallModel = (() => {
}); });
const cands = []; const cands = [];
cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' })); cornerStuds.forEach(x => cands.push({ x, kind: 'hoek' }));
// Gekoppelde zijde: geen stijlen ernaast; boven (en onder) de naad één kreupele stijl.
ops.forEach(op => { ops.forEach(op => {
const x0 = startX + op.x, x1 = x0 + op.w; const x0 = startX + op.x, x1 = x0 + op.w;
cands.push({ x: x0 - sw / 2, kind: 'koning' }, { x: x1 + sw / 2, kind: 'koning' }); if (!op.joinL) cands.push({ x: x0 - sw / 2, kind: 'koning' });
if (isDoor(op)) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' }, { x: x1 + 1.5 * sw, kind: 'dubbel' }); if (!op.joinR) cands.push({ x: x1 + sw / 2, kind: 'koning' });
else cands.push({ x: x1, kind: 'kreupel', seam: true });
if (isDoor(op) && !op.joinL) cands.push({ x: x0 - 1.5 * sw, kind: 'dubbel' });
if (isDoor(op) && !op.joinR) cands.push({ x: x1 + 1.5 * sw, kind: 'dubbel' });
}); });
const cols = []; const cols = [];
@@ -478,16 +600,17 @@ window.WallModel = (() => {
ops.forEach(op => calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing) ops.forEach(op => calculateSegmentPositions(op.w, CONFIG.minSpacing, CONFIG.maxSpacing)
.forEach(p => cands.push({ x: startX + op.x + p, kind: 'kreupel', owner: op }))); .forEach(p => cands.push({ x: startX + op.x + p, kind: 'kreupel', owner: op })));
const inWall = (c) => c.x >= startX + sw / 2 - 0.5 && c.x <= startX + CONFIG.wallWidth - sw / 2 + 0.5;
const PRIO = { hoek: 0, koning: 1, dubbel: 2, regel: 3, kreupel: 4 }; const PRIO = { hoek: 0, koning: 1, dubbel: 2, regel: 3, kreupel: 4 };
const roofMin = (x) => Math.min(...top.underPts(x - sw / 2, x + sw / 2).map(p => p.y)); const roofMin = (x) => Math.min(...top.underPts(x - sw / 2, x + sw / 2).map(p => p.y));
const segs = []; const segs = [];
cands.forEach(c => { cands.filter(inWall).forEach(c => {
const x = Math.round(c.x); const x = Math.round(c.x);
const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0); const hit = bands.filter(bd => Math.min(bd.x1, x + sw / 2) - Math.max(bd.x0, x - sw / 2) > 0.5).sort((p, q) => p.y0 - q.y0);
let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null; let cur = [CONFIG.plateThick, CONFIG.plateThick], below = null;
const curMax = () => Math.max(cur[0], cur[1]); const curMax = () => Math.max(cur[0], cur[1]);
const push = (y1, above) => { const push = (y1, above) => {
if (c.kind === 'kreupel') { if (c.kind === 'kreupel' && !c.seam) {
// Onder de opening niet als hij op een andere opening uitkomt: dat stuk is al de kreupele stijl daarvan. // Onder de opening niet als hij op een andere opening uitkomt: dat stuk is al de kreupele stijl daarvan.
const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner; const onTop = below && below.op === c.owner, underneath = above && above.op === c.owner;
if (!onTop && !(underneath && !below)) return; if (!onTop && !(underneath && !below)) return;
@@ -524,16 +647,20 @@ window.WallModel = (() => {
const roofSafetyMargin = 10; const roofSafetyMargin = 10;
const sw = CONFIG.studWidth; const sw = CONFIG.studWidth;
return CONFIG.openings.map(op => { return CONFIG.openings.map(op => {
const hx0 = -halfW + op.x - sw; // Schoorsteen: geen kozijn of dagkant, de plaat loopt door tot op de stijl naast de sparing.
const hx1 = -halfW + op.x + op.w + sw; const m = op.type === 'schoorsteen' ? 0 : sw;
const s = (op.hR - op.h) / op.w, vo = sw * Math.sqrt(1 + s * s); // Gekoppelde zijde: de uitsparing sluit precies aan op die van de buur.
const mL = op.joinL ? 0 : m, mR = op.joinR ? 0 : m;
const hx0 = -halfW + op.x - mL;
const hx1 = -halfW + op.x + op.w + mR;
const s = (op.hR - op.h) / op.w, vo = m * Math.sqrt(1 + s * s);
const clampTop = (y, x) => { const clampTop = (y, x) => {
const ceil = topYAt(x) - roofSafetyMargin; const ceil = topYAt(x) - roofSafetyMargin;
return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y; return y > ceil ? Math.max(op.y + roofSafetyMargin, ceil) : y;
}; };
const hy1 = clampTop(op.y + op.h + vo - s * sw, hx0); const hy1 = clampTop(op.y + op.h + vo - s * mL, hx0);
const hy1r = clampTop(op.y + op.hR + vo + s * sw, hx1); const hy1r = clampTop(op.y + op.hR + vo + s * mR, hx1);
return { hx0, hx1, hy0: op.y, hy1, hy1r }; return { hx0, hx1, hy0: op.y, hy1, hy1r, joinL: !!op.joinL, joinR: !!op.joinR };
}); });
} }
const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1; const holeTopR = (h) => h.hy1r !== undefined ? h.hy1r : h.hy1;
@@ -593,7 +720,7 @@ window.WallModel = (() => {
const fixed = [lo, hi]; const fixed = [lo, hi];
holes.forEach(h => { holes.forEach(h => {
if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return; if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return;
[h.hx0 - w / 2, h.hx1 + w / 2].forEach(x => { if (x > lo && x < hi) fixed.push(x); }); [!h.joinL && h.hx0 - w / 2, !h.joinR && h.hx1 + w / 2].forEach(x => { if (x !== false && x > lo && x < hi) fixed.push(x); });
}); });
fixed.sort((a, b) => a - b); fixed.sort((a, b) => a - b);
const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w); const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w);
@@ -693,8 +820,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';
@@ -705,6 +832,7 @@ window.WallModel = (() => {
const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0; const skinFrom = opts.skinFrom > 0 ? opts.skinFrom : 0;
const painted = !!opts.painted; const painted = !!opts.painted;
const stone = buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone) ? opts.stone : null; const stone = buitenMateriaal === 'cementplaat' && isStoneFinish(opts.stone) ? opts.stone : null;
const felsKey = buitenMateriaal === 'vochtdoek' && isFelsColor(opts.fels) ? opts.fels : null;
// spaanplaatBelow: naadwand geheel in één unit; onder het dak van de andere unit is het een binnenwand. // spaanplaatBelow: naadwand geheel in één unit; onder het dak van de andere unit is het een binnenwand.
const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek'; const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek';
const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel'); const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel');
@@ -734,11 +862,11 @@ window.WallModel = (() => {
let spaanplaatBuitenAreaM2 = 0; let spaanplaatBuitenAreaM2 = 0;
let spaanplaatBuitenVolumeM3 = 0; let spaanplaatBuitenVolumeM3 = 0;
let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0; let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0;
let rabatAreaM2 = 0; let rabatAreaM2 = 0, felsAreaM2 = 0;
let trasraamAreaM2 = 0, trasraamVolumeM3 = 0; let trasraamAreaM2 = 0, trasraamVolumeM3 = 0;
let stoneAreaM2 = 0, stoneIsolatieM3 = 0; let stoneAreaM2 = 0, stoneIsolatieM3 = 0;
let stucAreaM2 = 0; let stucAreaM2 = 0;
let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0; let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0, trasraamCapM = 0;
let horizBeamCount = 0; let horizBeamCount = 0;
function createExtrudedBeam(shape, depth, positionZ) { function createExtrudedBeam(shape, depth, positionZ) {
@@ -769,16 +897,10 @@ window.WallModel = (() => {
const startXM = -halfW; const startXM = -halfW;
const topYAt = tl.at; const topYAt = tl.at;
const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0; const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0;
const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, { const CONFIG_OUT = skinConfig(CONFIG, skinAbove, opts.skinTop);
wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove, const tlOut = CONFIG_OUT === CONFIG ? tl : topLine(CONFIG_OUT, toM);
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + skinAbove }))
}) : CONFIG;
const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl;
const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove; const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove;
const CONFIG_BAT = battenAbove ? Object.assign({}, CONFIG, { const CONFIG_BAT = raiseTop(CONFIG_OUT, battenAbove - skinAbove);
wallHeightLeft: CONFIG.wallHeightLeft + battenAbove, wallHeightRight: CONFIG.wallHeightRight + battenAbove,
wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + battenAbove }))
}) : CONFIG;
const underPts = tl.underPts; const underPts = tl.underPts;
// LET OP: xMin/xMax zijn wereld-coördinaten (-halfW..+halfW), niet lokaal (0..wallWidth). // LET OP: xMin/xMax zijn wereld-coördinaten (-halfW..+halfW), niet lokaal (0..wallWidth).
@@ -789,7 +911,8 @@ window.WallModel = (() => {
yMin: op.y * toM, yMin: op.y * toM,
yMax: (op.y + op.h) * toM, yMax: (op.y + op.h) * toM,
yMaxR: (op.y + op.hR) * toM, yMaxR: (op.y + op.hR) * toM,
wM: op.w * toM wM: op.w * toM,
chimney: op.type === 'schoorsteen'
})); }));
const floorCuts = opsM const floorCuts = opsM
@@ -820,7 +943,21 @@ window.WallModel = (() => {
horizBeamCount++; horizBeamCount++;
} }
topPlateOutlines(tl).forEach(pts => { // Bovenregel onderbroken waar een schoorsteen doorloopt.
const clipX = (pts, x, keepBelow) => {
const out = [], f = (p) => keepBelow ? x - p.x : p.x - x;
pts.forEach((p, k) => {
const q = pts[(k + 1) % pts.length], fp = f(p), fq = f(q);
if (fp >= 0) out.push(p);
if ((fp >= 0) !== (fq >= 0)) { const t = fp / (fp - fq); out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }); }
});
return out;
};
let plates = topPlateOutlines(tl);
opsM.filter(op => op.chimney).forEach(op => {
plates = plates.flatMap(pts => [clipX(pts, op.xMin, true), clipX(pts, op.xMax, false)]).filter(pts => pts.length >= 3 && polygonArea(pts) > 1e-6);
});
plates.forEach(pts => {
wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2)); wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2));
horizBeamCount++; horizBeamCount++;
}); });
@@ -858,6 +995,7 @@ window.WallModel = (() => {
}); });
opsM.forEach(op => { opsM.forEach(op => {
if (op.chimney) return; // geen latei: de schoorsteen draagt de bovenkant
const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s); const s = (op.yMaxR - op.yMax) / op.wM, vo = sWidthM * Math.sqrt(1 + s * s);
const opTopShape = new THREE.Shape(); const opTopShape = new THREE.Shape();
opTopShape.moveTo(op.xMin, op.yMax); opTopShape.moveTo(op.xMin, op.yMax);
@@ -940,7 +1078,9 @@ window.WallModel = (() => {
uv.needsUpdate = true; uv.needsUpdate = true;
return geo; return geo;
}; };
const topClampedOut = (x) => tlOut.at(Math.max(-halfW, Math.min(halfW, x))); // Voorbij het wandeinde (hoekdoorloop) de daklijn volgen: vlak doorgetrokken steekt hij boven een steil dakvlak uit.
const beyondEnd = (T) => (x) => Math.min(T.at(x), T.at(Math.max(-halfW, Math.min(halfW, x))));
const topClampedOut = beyondEnd(tlOut);
const layerShapes = (a, b, y0, yCap) => { const layerShapes = (a, b, y0, yCap) => {
if (b - a <= edgeEps) return []; if (b - a <= edgeEps) return [];
const capped = yCap === Infinity; const capped = yCap === Infinity;
@@ -985,6 +1125,41 @@ window.WallModel = (() => {
return { areaM2: area(mid) + area(caps), isoM3: area(mid) * tIso }; return { areaM2: area(mid) + area(caps), isoM3: area(mid) * tIso };
} }
// Afdekker op een stenen trasraam waar de gevel erboven minder ver uitsteekt (rabat, felsplaat, stuc): zetwerk
// over de bovenkant van de steen, achter de bekleding beginnend, aan de voorkant een neus over de steen. Onderbroken
// waar een opening door de bovenkant van het trasraam gaat; bij een buitenhoek tot voorbij de steen van de buurwand.
function stoneCap(tr) {
const C = TRAS_CAP, T = tr.top * toM;
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM, zF = zB + STONE.THICK_MM * toM;
const zIn = zF + C.GAP_MM * toM, zOut = zIn + C.THICK_MM * toM, lip = zOut - zF;
const yFront = T + C.THICK_MM * toM, yBack = yFront + Math.tan(C.SLOPE_DEG * Math.PI / 180) * (zOut - zB);
const ext = extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM + STONE.THICK_MM);
const a = -halfW - ext.left - (ext.left > 0 ? lip : 0), b = halfW + ext.right + (ext.right > 0 ? lip : 0);
const yLo = T - C.DROP_MM * toM;
const cuts = outerHoles.filter(h => h.hy0 < yBack && h.hy1 > yLo).map(h => [h.hx0, h.hx1]).sort((p, q) => p[0] - q[0]);
const runs = [];
let x = a;
cuts.forEach(([c0, c1]) => { if (c0 > x) runs.push([x, Math.min(b, c0)]); x = Math.max(x, c1); });
if (x < b) runs.push([x, b]);
const shape = new THREE.Shape([
[zB, T + 0.0005], [zIn, T + 0.0005], [zIn, yLo], [zOut, yLo], [zOut, yFront], [zB, yBack]
].map(([pz, py]) => new THREE.Vector2(pz, py)));
let lengthM = 0;
runs.forEach(([r0, r1]) => {
if (r1 - r0 <= edgeEps) return;
lengthM += r1 - r0;
if (!buitenVisible || !show('gevel')) return;
// Profiel in (z, y), langs de wand uitgetrokken: -90° om Y legt de extrusie langs -X.
const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: r1 - r0, bevelEnabled: false }), trimMaterial(tr.capColor));
mesh.rotation.y = -Math.PI / 2;
mesh.position.x = r1;
mesh.castShadow = true;
mesh.receiveShadow = true;
wallGroup3D.add(mesh);
});
return lengthM;
}
function stucBlock(colorKey, y0) { function stucBlock(colorKey, y0) {
const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM; const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM;
const t = STUC.THICK_MM * toM; const t = STUC.THICK_MM * toM;
@@ -1082,6 +1257,17 @@ window.WallModel = (() => {
cap.position.set(x + dir * 0.0003, h / 2, 0); cap.position.set(x + dir * 0.0003, h / 2, 0);
if (spaanplaatVisible) wallGroup3D.add(cap); if (spaanplaatVisible) wallGroup3D.add(cap);
}); });
// Vrij uiteinde van een binnenwand (endCaps): afgewerkt zoals de wand, geen stijl in zicht.
const endCaps = opts.endCaps || {};
if (buitenMateriaal === 'spaanplaat' && spaanplaatVisible) [[endCaps.left, -halfW, -1], [endCaps.right, halfW, 1]].forEach(([on, x, dir]) => {
if (!on) return;
const h = topYAt(x), depth = maxDepth + 2 * (skinGapM + sThickM);
if (!(h > 0)) return;
const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial);
cap.rotation.y = dir * Math.PI / 2;
cap.position.set(x + dir * 0.0003, h / 2, 0);
wallGroup3D.add(cap);
});
spaanplaatAreaM2 = spaanplaatResult.areaM2; spaanplaatAreaM2 = spaanplaatResult.areaM2;
spaanplaatVolumeM3 = spaanplaatResult.volumeM3; spaanplaatVolumeM3 = spaanplaatResult.volumeM3;
@@ -1106,6 +1292,7 @@ window.WallModel = (() => {
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true }); const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
trasraamStoneAreaM2 = r.areaM2; trasraamStoneAreaM2 = r.areaM2;
trasraamIsolatieM3 = r.isoM3; trasraamIsolatieM3 = r.isoM3;
if (stucKey) trasraamCapM = stoneCap(trasraam);
y0 = trasraam.top * toM; y0 = trasraam.top * toM;
} else if (trasraam) { } else if (trasraam) {
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
@@ -1183,6 +1370,7 @@ window.WallModel = (() => {
const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true }); const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true });
trasraamStoneAreaM2 = r.areaM2; trasraamStoneAreaM2 = r.areaM2;
trasraamIsolatieM3 = r.isoM3; trasraamIsolatieM3 = r.isoM3;
trasraamCapM = stoneCap(trasraam);
} else if (trasraam) { } else if (trasraam) {
const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), {
y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM
@@ -1216,7 +1404,7 @@ window.WallModel = (() => {
const rExt = extM(rabatDepthMM); const rExt = extM(rabatDepthMM);
const rxL = -halfW - rExt.left, rxR = halfW + rExt.right; const rxL = -halfW - rExt.left, rxR = halfW + rExt.right;
const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM; const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM;
const rTop = (x) => tlBat.at(Math.max(-halfW, Math.min(halfW, x))); const rTop = beyondEnd(tlBat);
const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps) const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps)
? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => { ? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => {
const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y))); const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y)));
@@ -1224,6 +1412,7 @@ window.WallModel = (() => {
return shape; return shape;
}) : []; }) : [];
rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0); rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0);
if (felsKey) { felsAreaM2 = rabatAreaM2; rabatAreaM2 = 0; }
if (rabatOn && rabatShapes.length) { if (rabatOn && rabatShapes.length) {
const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false }); const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false });
const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM; const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM;
@@ -1233,7 +1422,7 @@ window.WallModel = (() => {
if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y); if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y);
} }
uv.needsUpdate = true; uv.needsUpdate = true;
const mesh = new THREE.Mesh(geo, rabatMaterial(opts.rabatColor, cladding)); const mesh = new THREE.Mesh(geo, felsKey ? felsMaterial(felsKey) : rabatMaterial(opts.rabatColor, cladding));
mesh.position.set(0, 0, zDoek); mesh.position.set(0, 0, zDoek);
mesh.castShadow = true; mesh.castShadow = true;
mesh.receiveShadow = true; mesh.receiveShadow = true;
@@ -1241,7 +1430,7 @@ window.WallModel = (() => {
} }
} }
if (show('gevel') && window.KozijnModel) { if (show('kozijnen') && window.KozijnModel) {
const sh = CONFIG.sheathingThick, half = CONFIG.studDepth / 2; const sh = CONFIG.sheathingThick, half = CONFIG.studDepth / 2;
const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat'; const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat';
const place = exterior ? { zOut: half + sh, depth: KozijnModel.PROFILE.depth, exterior: true } const place = exterior ? { zOut: half + sh, depth: KozijnModel.PROFILE.depth, exterior: true }
@@ -1277,7 +1466,7 @@ window.WallModel = (() => {
const holes = sheathingHoles(CONFIG); const holes = sheathingHoles(CONFIG);
CONFIG.openings.forEach((op, i) => { CONFIG.openings.forEach((op, i) => {
const h = holes[i]; const h = holes[i];
if (!h) return; if (!h || op.type === 'schoorsteen') return; // de schoorsteen sluit de sparing zelf af
const x0 = -CONFIG.wallWidth / 2 + op.x, x1 = x0 + op.w, y0 = op.y; const x0 = -CONFIG.wallWidth / 2 + op.x, x1 = x0 + op.w, y0 = op.y;
const topAt = (x) => op.y + op.h + (x - x0) / op.w * (op.hR - op.h); const topAt = (x) => op.y + op.h + (x - x0) / op.w * (op.hR - op.h);
const hTop = (x) => holeTopAt(h, x); const hTop = (x) => holeTopAt(h, x);
@@ -1288,35 +1477,40 @@ 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,
stats: { stats: {
woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2, woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2,
spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount, spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount,
battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2 battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, trasraamCapM, stoneAreaM2, stoneIsolatieM3, stucAreaM2
} }
}; };
} }
@@ -1324,7 +1518,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);
} }
@@ -1545,8 +1739,8 @@ 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, FELS, isFelsColor, felsHex, TRIM_COLORS, isTrimColor, trimHex, trimMaterial, TRAS_CAP, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText,
sheathingHoles, skinSegmentsAtY, topLine, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY, stoneMaterial, stucMaterial, felsMaterial, claddingMaterial, 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
}; };
+50 -51
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" data-esc>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,16 +335,6 @@ function mountWallEditor(container, callbacks) {
updateAll(); updateAll();
} }
function toggleFullscreen3D() {
UI.cycle3D(document.getElementById('app-container'));
requestAnimationFrame(onWindowResize);
}
function toggleFullscreen2D() {
UI.toggle2D(document.getElementById('app-container'));
requestAnimationFrame(onWindowResize);
}
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer', const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking']; 'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
let projectPropsSnapshot = null; let projectPropsSnapshot = null;
@@ -424,7 +416,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 +491,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 +539,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();
@@ -602,7 +600,7 @@ function mountWallEditor(container, callbacks) {
function getPureState() { function getPureState() {
return { return {
wall: collectWallParams(), wall: collectWallParams(),
materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor }, materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, fels },
openings: openings.map(rawOpening) openings: openings.map(rawOpening)
}; };
} }
@@ -631,6 +629,7 @@ function mountWallEditor(container, callbacks) {
stone = (state.materials && state.materials.stone) || null; stone = (state.materials && state.materials.stone) || null;
stuc = (state.materials && state.materials.stuc) || null; stuc = (state.materials && state.materials.stuc) || null;
rabatColor = (state.materials && state.materials.rabatColor) || null; rabatColor = (state.materials && state.materials.rabatColor) || null;
fels = (state.materials && state.materials.fels) || null;
syncMaterialButtons(); syncMaterialButtons();
openings = state.openings.map(rawOpening); openings = state.openings.map(rawOpening);
@@ -730,12 +729,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 +1272,14 @@ 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 fels = null;
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,29 +1296,32 @@ function mountWallEditor(container, callbacks) {
let spaanplaatVisible = true; let spaanplaatVisible = true;
let buitenVisible = true; let buitenVisible = true;
createViewFilter(document.getElementById('wall-toolbar-3d'), [
{ key: 'binnen', label: 'Beplating binnenzijde' },
{ key: 'buiten', label: 'Beplating buitenzijde' }
], (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);
const applySky = () => { const applySky = () => {
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(); }); const toolbar = document.getElementById('wall-toolbar');
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); }); const bar = UI.editorBar(toolbar, document.getElementById('app-container'), {
left: `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>`,
onResize: onWindowResize,
layers: {
layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'],
onChange: (l) => { layers = l; update3DScene(); },
extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }],
onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); }
},
onSky: (m) => { skyMode = m; applySky(); }
});
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
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, fels, spaanplaatVisible, buitenVisible, layers
}); });
wallGroup3D = result.group; wallGroup3D = result.group;
lastWallStats = result.stats; lastWallStats = result.stats;
@@ -1380,7 +1379,7 @@ function mountWallEditor(container, callbacks) {
const inputs = document.querySelectorAll('#tab-wall input'); const inputs = document.querySelectorAll('#tab-wall input');
inputs.forEach(input => { inputs.forEach(input => {
// Hoogte met de hand gewijzigd: de knikken van een mansarde-/wolfsdak vervallen. // Hoogte met de hand gewijzigd: de knikken van een wolfsdak vervallen.
if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; }); if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; });
input.addEventListener('input', updateAll); input.addEventListener('input', updateAll);
}); });
@@ -1416,8 +1415,8 @@ function mountWallEditor(container, callbacks) {
exportDesign, exportDesign,
triggerImport, triggerImport,
mirrorHorizontal, mirrorHorizontal,
toggleFullscreen3D, setView: bar.setView,
toggleFullscreen2D, viewState: bar.viewState,
openProjectProperties, openProjectProperties,
getStateJson: getPureState, getStateJson: getPureState,
loadStateJson: applyPureState loadStateJson: applyPureState
+2 -2
View File
@@ -41,14 +41,14 @@
"fasciaHeight": 300, "fasciaHeight": 300,
"spotMin": 800, "spotMin": 800,
"spotMax": 1200, "spotMax": 1200,
"trimHeight": 80, "trimHeight": 30,
"fasciaColor": "antraciet" "fasciaColor": "antraciet"
}, },
"lighting": { "lighting": {
"mode": "spots", "mode": "spots",
"direction": "both", "direction": "both",
"lampHeight": 1950, "lampHeight": 1950,
"lampMin": 1500, "lampMin": 500,
"lampMax": 3000, "lampMax": 3000,
"minWallWidth": 2000, "minWallWidth": 2000,
"endOffset": 300, "endOffset": 300,
+3
View File
@@ -30,6 +30,7 @@ gevel.vochtdoekMontageM;Montage vochtdoek per strekkende meter wand;s/m;600
gevel.rabatMontage;Montage rabat;s/m²;600 gevel.rabatMontage;Montage rabat;s/m²;600
gevel.steenMontage;Montage steenstrips;s/m²;1800 gevel.steenMontage;Montage steenstrips;s/m²;1800
gevel.stucMontage;Stucwerk aanbrengen (wapenen, pleisteren);s/m²;1500 gevel.stucMontage;Stucwerk aanbrengen (wapenen, pleisteren);s/m²;1500
gevel.felsMontage;Montage felsplaten gevel (ook dagkanten en hoeken);s/m²;2100
dak.dakleerMontage;Montage dakleer;s/m²;600 dak.dakleerMontage;Montage dakleer;s/m²;600
dak.rietMontage;Montage riet;s/m²;3600 dak.rietMontage;Montage riet;s/m²;3600
dak.beschotMontage;Montage dakbeschot;s/m²;150 dak.beschotMontage;Montage dakbeschot;s/m²;150
@@ -40,6 +41,8 @@ dak.daktrimMontage;Montage daktrim;s/m;300
dak.nokPlaatsen;Stalen nokbalk plaatsen, stellen en vastzetten;s;2700 dak.nokPlaatsen;Stalen nokbalk plaatsen, stellen en vastzetten;s;2700
dak.nokVulhoutMontage;Vulhout in de holling van de nokbalk aanbrengen (beide zijden);s/m;240 dak.nokVulhoutMontage;Vulhout in de holling van de nokbalk aanbrengen (beide zijden);s/m;240
dak.nokPersoneel;Personeel bij plaatsen nokbalk (i.p.v. de bezetting);man;3 dak.nokPersoneel;Personeel bij plaatsen nokbalk (i.p.v. de bezetting);man;3
dak.paalMontage;Paal plaatsen: stellen, aftoppen, vastzetten aan het dak;s/st.;2700
dak.zijwandMontage;Zijwand overkapping maken en plaatsen;s/m²;5400
vloer.afwerkingMontage;Montage vloerafwerking;s/m²;1200 vloer.afwerkingMontage;Montage vloerafwerking;s/m²;1200
vloer.sandwichMontage;Montage per sandwichpaneel;s;600 vloer.sandwichMontage;Montage per sandwichpaneel;s;600
afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800 afwerking.valsPlafondMontage;Montage vals plafond;s/m²;1800
1 code omschrijving eenheid waarde
30 gevel.rabatMontage Montage rabat s/m² 600
31 gevel.steenMontage Montage steenstrips s/m² 1800
32 gevel.stucMontage Stucwerk aanbrengen (wapenen, pleisteren) s/m² 1500
33 gevel.felsMontage Montage felsplaten gevel (ook dagkanten en hoeken) s/m² 2100
34 dak.dakleerMontage Montage dakleer s/m² 600
35 dak.rietMontage Montage riet s/m² 3600
36 dak.beschotMontage Montage dakbeschot s/m² 150
41 dak.nokPlaatsen Stalen nokbalk plaatsen, stellen en vastzetten s 2700
42 dak.nokVulhoutMontage Vulhout in de holling van de nokbalk aanbrengen (beide zijden) s/m 240
43 dak.nokPersoneel Personeel bij plaatsen nokbalk (i.p.v. de bezetting) man 3
44 dak.paalMontage Paal plaatsen: stellen, aftoppen, vastzetten aan het dak s/st. 2700
45 dak.zijwandMontage Zijwand overkapping maken en plaatsen s/m² 5400
46 vloer.afwerkingMontage Montage vloerafwerking s/m² 1200
47 vloer.sandwichMontage Montage per sandwichpaneel s 600
48 afwerking.valsPlafondMontage Montage vals plafond s/m² 1800
+4
View File
@@ -12,6 +12,7 @@ gevel.vochtdoekM2;Vochtdoek;€/m²;1,5;;;
gevel.rabatM2;Rabat (gevelbekleding);€/m²;35;;; gevel.rabatM2;Rabat (gevelbekleding);€/m²;35;;;
gevel.steenM2;Steenstrips (gevel + trasraam);€/m²;60;;; gevel.steenM2;Steenstrips (gevel + trasraam);€/m²;60;;;
gevel.stucM2;Stucwerk gevel (wapening + sierpleister op cementgebonden plaat);€/m²;45;;; gevel.stucM2;Stucwerk gevel (wapening + sierpleister op cementgebonden plaat);€/m²;45;;;
gevel.felsM2;Felsplaten gevel, staande naad (staal, klangen, ondergrond);€/m²;70;;;
dak.dakleerM2;Dakleer (EPDM);€/m²;18;;; dak.dakleerM2;Dakleer (EPDM);€/m²;18;;;
dak.rietM2;Riet;€/m²;90;;; dak.rietM2;Riet;€/m²;90;;;
dak.beschotM2;Dakbeschot OSB 18 mm (bovenop de sporen);€/m²;14;;; dak.beschotM2;Dakbeschot OSB 18 mm (bovenop de sporen);€/m²;14;;;
@@ -21,6 +22,9 @@ dak.onderplaatM2;Onderplaat boeideel;€/m²;30;;;
dak.daktrimM;Daktrim;€/m;12;;; dak.daktrimM;Daktrim;€/m;12;;;
dak.staalKg;Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond);€/kg;2,9;;; dak.staalKg;Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond);€/kg;2,9;;;
dak.nokOplegging;Oplegging nokbalk (stalen schoen + bouten), 2 per balk;€/st.;45;;; dak.nokOplegging;Oplegging nokbalk (stalen schoen + bouten), 2 per balk;€/st.;45;;;
dak.paalHoutM3;Paalhout overkapping (eiken, geschaafd);€/m³;2400;;;
dak.paalvoet;Paalvoet verzinkt, verstelbaar (incl. ankers);€/st.;40;;;
dak.zijwandM2;Zijwand overkapping (regelwerk, cementgebonden plaat en stuc aan beide kanten, fundering);€/m²;140;;;
vloer.afwerkingM2;Vloerafwerking;€/m²;35;;; vloer.afwerkingM2;Vloerafwerking;€/m²;35;;;
vloer.sandwichM2;Sandwichpaneel;€/m²;35;;; vloer.sandwichM2;Sandwichpaneel;€/m²;35;;;
afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;; afwerking.valsPlafondM2;Vals plafond (regelwerk + gipsplaat, afgewerkt);€/m²;38;;;
1 code omschrijving eenheid prijs leverancier artikelnummer bijgewerkt
12 gevel.rabatM2 Rabat (gevelbekleding) €/m² 35
13 gevel.steenM2 Steenstrips (gevel + trasraam) €/m² 60
14 gevel.stucM2 Stucwerk gevel (wapening + sierpleister op cementgebonden plaat) €/m² 45
15 gevel.felsM2 Felsplaten gevel, staande naad (staal, klangen, ondergrond) €/m² 70
16 dak.dakleerM2 Dakleer (EPDM) €/m² 18
17 dak.rietM2 Riet €/m² 90
18 dak.beschotM2 Dakbeschot OSB 18 mm (bovenop de sporen) €/m² 14
22 dak.daktrimM Daktrim €/m 12
23 dak.staalKg Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond) €/kg 2,9
24 dak.nokOplegging Oplegging nokbalk (stalen schoen + bouten), 2 per balk €/st. 45
25 dak.paalHoutM3 Paalhout overkapping (eiken, geschaafd) €/m³ 2400
26 dak.paalvoet Paalvoet verzinkt, verstelbaar (incl. ankers) €/st. 40
27 dak.zijwandM2 Zijwand overkapping (regelwerk, cementgebonden plaat en stuc aan beide kanten, fundering) €/m² 140
28 vloer.afwerkingM2 Vloerafwerking €/m² 35
29 vloer.sandwichM2 Sandwichpaneel €/m² 35
30 afwerking.valsPlafondM2 Vals plafond (regelwerk + gipsplaat, afgewerkt) €/m² 38
+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<!--
Tectum
Copyright © 2026 Machiel Heinen.
Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
Zie LICENTIE.md.
-->
<!-- Light-versie (modelpagina's), voorlopig los: light.html?model=amstel. Op een modelpagina is alleen het
<div class="tectum-light" data-model="…"> plus de stylesheets en scripts hieronder nodig. -->
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Woning samenstellen — Van Emous Mantelzorgwoningen</title>
<link rel="stylesheet" href="css/vendor/bootstrap-icons.min.css">
<link rel="stylesheet" href="css/light.css">
<style>
body { margin: 0; padding: 24px; background: #f9faf5; color: #212121; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.page { max-width: 1400px; margin: 0 auto; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.page-head img { height: 34px; }
.page-head select, .page-head button { font: inherit; padding: 6px 10px; border: 1px solid #c5ced9; border-radius: 8px; background: #fff; }
@media (max-width: 820px) { body { padding: 12px; } }
</style>
<script src="js/vendor/three.min.js"></script>
<script src="js/vendor/OrbitControls.js"></script>
</head>
<body>
<div class="page">
<div class="page-head">
<img src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
<!-- Alleen voor het losse testen: op een modelpagina staat het model vast. -->
<div>
<select id="model-pick" aria-label="Model"></select>
<!-- Alleen voor het testen: de GLB die op een telefoon in AR wordt getoond (bijv. in Blender of een glTF-viewer). -->
<button type="button" id="glb-dl" title="De GLB die AR gebruikt downloaden">GLB</button>
</div>
</div>
<div id="configurator" class="tectum-light"></div>
</div>
<script src="js/core/logo-data.js"></script>
<script src="js/core/geometry.js"></script>
<script src="js/core/view-layers.js"></script>
<script src="js/core/ui-kit.js"></script>
<script src="js/core/skybox.js"></script>
<script src="js/core/glb-writer.js"></script>
<script src="js/vendor/jspdf.umd.min.js"></script>
<script src="js/vendor/qrcode.js"></script>
<script src="js/core/pdf-sheet.js"></script>
<script src="js/wall/kozijn-model.js"></script>
<script src="js/wall/wall-model.js"></script>
<script src="js/wall/wall-pdf.js"></script>
<script src="js/floor/floor-model.js"></script>
<script src="js/floor/floor-pdf.js"></script>
<script src="js/roof/roof-model.js"></script>
<script src="js/roof/post-model.js"></script>
<script src="js/lighting/lighting-model.js"></script>
<script src="js/elektra/elektra-model.js"></script>
<script src="js/sandwich/sandwich-model.js"></script>
<script src="js/wall/wall.js"></script>
<script src="js/floor/floor.js"></script>
<script src="js/unit/unit-model.js"></script>
<script src="js/room/room-model.js"></script>
<script src="js/interior/fixture-model.js"></script>
<script src="js/house/chimney-model.js"></script>
<script src="js/house/house-model.js"></script>
<script src="js/cost/cost-defaults.js"></script>
<script src="js/cost/cost-csv.js"></script>
<script src="js/cost/cost-model.js"></script>
<script src="js/elektra/elektra-pdf.js"></script>
<script src="js/house/house-pdf.js"></script>
<script src="js/light/light-model.js"></script>
<script src="js/light/light-pdf.js"></script>
<script>
// Model uit de URL (?model=amstel); de keuzelijst herlaadt de pagina met een ander model.
(function () {
const key = new URLSearchParams(location.search).get('model');
const model = HouseModel.MODELS[key] ? key : Object.keys(HouseModel.MODELS)[0];
const pick = document.getElementById('model-pick');
pick.innerHTML = Object.entries(HouseModel.MODELS).map(([k, m]) => `<option value="${k}"${k === model ? ' selected' : ''}>${m.label}</option>`).join('');
pick.addEventListener('change', () => { location.search = '?model=' + pick.value; });
const el = document.getElementById('configurator'), btn = document.getElementById('glb-dl');
el.dataset.model = model;
btn.addEventListener('click', () => {
const g = el.tectumLight && el.tectumLight.glb();
if (!g) return;
const a = document.createElement('a');
a.href = g.url;
a.download = `${model}.glb`;
a.click();
btn.textContent = `GLB · ${(g.stats.bytes / 1e6).toLocaleString('nl-NL', { maximumFractionDigits: 1 })} MB · ${g.stats.triangles.toLocaleString('nl-NL')} driehoeken`;
});
})();
</script>
<script src="js/light/light.js"></script>
</body>
</html>