diff --git a/README.md b/README.md index 6bc0a45..bff39db 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht). ## Structuur ``` -index.html app-shell: ribbon, splashscreen +index.html app-shell: bovenbalk (menu, documenten, opslaan), sneltoetsen, splashscreen css/app.css alle stijl js/vendor/ three.js r128, eigen OrbitControls, jsPDF -js/core/ gedeelde helpers: geometrie, bouwfasen, ribbon, UI-kit, PDF-bladen, +js/core/ gedeelde helpers: geometrie, weergavelagen (lagenmenu), menubalk, eigenschappenpaneel + (inspector.js), UI-kit (werkbalk, weergave, popovers), PDF-bladen, DXF-schrijver (dxf-writer.js), IFC-schrijver (ifc-writer.js), OBJ-schrijver - (obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen, weergavefilter, + (obj-writer.js), ZIP (zip-writer.js), labels, camerageheugen, skybox, licentie, logo js/wall/ WallModel (logica) + wand-PDF + wand-editor js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor @@ -57,9 +58,11 @@ assets/ logo, texturen, skybox - **Assen/zijden.** Bovenaanzicht boven = noord: voor = noord, achter = zuid, links = west, rechts = oost (interne sleutels blijven voor/achter/…). - **Hoogtes** gelden vanaf bovenkant vloer (loopvlak). -- **Bouwfasen** (`BuildStages`): regelwerk → elektra (buizen, dozen, meterkast) → beplating → vochtdoek → - latwerk → gevel & dak → afgewerkt, de volgorde in de hal. De 3D toont de - gekozen fase; lagen die daarin aan beide zijden zijn ingesloten worden niet +- **Weergavelagen** (`ViewLayers`): regelwerk, elektra, beplating, vochtdoek, + latwerk, gevelbekleding, kozijnen, dakbedekking, afwerking, verlichting, + inrichting, ruimtes, dak en maten, elk los aan/uit; de snelkeuze Fase zet + alles tot en met een bouwfase aan (de volgorde in de hal). Lagen die aan + beide zijden zijn ingesloten worden niet gebouwd (stijlen tussen spaanplaat en doek, latten en doek achter de rabat, sporen tussen plafond en dakbedekking). Specificaties en totalen rekenen altijd met alle lagen. @@ -151,7 +154,7 @@ assets/ logo, texturen, skybox dak door tot de omhullende rechthoek waar geen unit staat (bijv. een terras naast een kortere module), met een afgewerkte onderplaat in de kleur van het boeideel (`HouseModel.coverExtensions`). -- **Dak over de hele woning:** tabblad Dak › "Over hele woning": één +- **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden de wolfseinden kilkepers vragen, dan wordt het een zadeldak), bijv. een @@ -209,7 +212,7 @@ assets/ logo, texturen, skybox (o.a. tegels), FIBO-wandpanelen (decor, tot een hoogte of tot het plafond), eigen verfkleur en een vals plafond op een vaste hoogte (alleen waar het dak hoger ligt; wandafwerking en binnenspots volgen). Laag in de - plattegrond (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 een ruimtestaat. Het zaadpunt hoort bij een unit en schuift mee. - **Inrichting** (`interior/fixture-model.js`): zwevend toilet (wandcloset @@ -217,8 +220,8 @@ assets/ logo, texturen, skybox (douchegoot, glaswand, regendouche), keukenblok (spoelbak, hoge kast met koelkast en oven, vaatwasser, bovenkasten), kookeiland met inductieplaat, kledingkast, bed, eettafel met stoelen, bank met chaise longue, fauteuil, - salontafel, tv-meubel, boiler en wasmachine. Binnen › Inrichting: kies - een stuk en klik in de plattegrond; slepen, bij een wand snapt de rug op + salontafel, tv-meubel, boiler en wasmachine. Gereedschap Inrichting (I): kies + een stuk in de werkbalk en klik in de plattegrond; slepen, bij een wand snapt de rug op het wandvlak (Alt = vrij), D = draaien, Del = verwijderen. Per stuk maten en opties (front, plek spoelbak, glaswand links/rechts, ...). In 2D, 3D (afgewerkte fase), de PDF-plattegrond, de structuur en de kosten (groep @@ -226,26 +229,38 @@ assets/ logo, texturen, skybox schuift mee. - **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld, override per wand) en binnenspots in een raster per unit. -- **Ribbon** (`core/ribbon.js`, vervangt de menubalk): Bestand · Start · - Gevel · Dak · Binnen · Constructie · Verlichting · Rapport · Beeld · Extra, - plus een gekleurd tabblad voor de selectie (unit, wandstuk, binnenwand of - naadwand) dat vanzelf opent. Velden staan in groepen naast elkaar, met - schuiven voor maten met een logisch bereik. Dubbelklik op een tabblad - klapt de ribbon in. Plattegrond en 3D gebruiken de volle breedte; tabblad - Rapport toont het rapport (transport per unit, gewicht, afmetingen, - kosten, hout, plaatwerk, met staafdiagrammen) in plaats van de plattegrond. -- **3D:** schuif Bouwfase linksonder; Beeld › Tonen: Dak/Lampen; Beeld › - Doorsnede (wanden afgesneden op een instelbare hoogte, schuif in de ribbon - en onder de 3D, snede wit afgedekt), binnenzijde in een - verfkleur (afgewerkte fase), omgeving Ontwerp/Dag/Nacht, Render met lampen - als echte lichtbronnen. +- **Indeling** (zelfde patroon als CAD/BIM-pakketten; elke functie op één plek): + bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren, + projectgegevens, kosten & tijden) · Beeld · Help, de geopende ontwerpen als + tabs en de knop Opslaan; links de structuur (groepen in-/uitklapbaar en + onthouden; de groep met de selectie klapt vanzelf open); in het midden een werkbalk met + gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, met de + opties van het actieve gereedschap ernaast; Unit toevoegen) en weergave + (Lagen, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D / + 3D met 1/2/3); rechts het eigenschappenpaneel: de selectie (unit, + wandstuk, binnenwand, ruimte, inrichting, elektra) met kruimelpad terug, + of zonder selectie de woning (kerncijfers en inklapbare secties Gevel, + Dak, Binnen, Verlichting, Elektra, Constructie); onderin een statusbalk + met de hint van het gereedschap en meldingen. Wand- en vloer/dak-editor + hebben dezelfde indeling (werkbalk boven, paneel met tabbladen en + Resultaat rechts; binnen de woning met "← Woning" of Esc terug). Help › + Sneltoetsen (?) toont alle toetsen. Rapport toont transport per unit, + gewicht, afmetingen, kosten, hout en plaatwerk in plaats van de + plattegrond, als masonry (kaart in de kortste kolom, leesvolgorde blijft). +- **3D en plattegrond:** één lagenmenu (Lagen in de werkbalk, met bouwfase + als snelkeuze) voor 3D en plattegrond; plattegrond zoomt met het + scrollwiel en de knoppen rechtsonder (F = passend), verschuiven met + slepen; Doorsnede (wanden afgesneden op een instelbare hoogte, schuif in + de werkbalk, snede wit afgedekt), binnenzijde in een verfkleur (afgewerkte + fase), omgeving Ontwerp/Dag/Nacht, Render met lampen als echte + lichtbronnen. - **Transport:** per unit buitenmaat (incl. gevelafwerking), hoogte en gewicht (vloer/wanden/dak) met een indicatieve breedteklasse. - **Uitvoer:** JSON opslaan/openen en een samenhangende PDF (aanzichten noord/oost/zuid/west als vlakke render met legenda afwerking, plattegrond, vloeren, wandstukken, binnenwanden, totalen). -- **CAD-export (DXF):** Bestand › Exporteren › CAD (DXF), ook in de ribbon - Woning › Uitvoer; los van Opslaan/Ctrl+S. DXF R12 (AC1009), opent in elk +- **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 @@ -281,10 +296,14 @@ assets/ logo, texturen, skybox (`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. De volledige afgewerkte woning (laatste bouwfase, dak, dakrand, - lampen, ruimtes, inrichting; los van de gekozen fase, doorsnede en - weergave-opties; zonder logobord, grond en lucht) als ZIP met `.obj`, `.mtl` - en `textures/*.png` (rabat, steen, riet, tegels). Meters, Y omhoog (de + 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 + `_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, @@ -340,7 +359,7 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026 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: geen lichtbronnen (lampen alleen als gloeiend materiaal), geen +- 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 diff --git a/css/app.css b/css/app.css index a4d437f..a90ee42 100644 --- a/css/app.css +++ b/css/app.css @@ -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 { flex: 1; 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 + .pane-2d-half { border-left: 1px solid var(--border); } - #control-panel { - height: 380px; - background: var(--van-emous-ultra-light); - border-top: 1px solid var(--border); - display: flex; - flex-direction: column; - } - .tab-bar { display: flex; background: var(--van-emous-blue); @@ -148,39 +116,6 @@ } .tab-content.active { display: block; } - #pane-3d { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); } - - #specs-panel-3d { - height: 380px; - background: var(--van-emous-ultra-light); - border-top: 1px solid var(--border); - overflow-y: auto; - padding: 15px 20px; - } - #specs-panel-3d .controls-grid { grid-template-columns: 1fr 1fr; } - - .toolbar-3d { - position: absolute; - top: 15px; - right: 15px; - z-index: 10; - display: flex; - gap: 8px; - } - .btn-3d-toggle { - background: rgba(0, 0, 0, 0.75); - border: 1px solid var(--primary-on-dark); - color: var(--primary-on-dark); - padding: 6px 12px; - border-radius: 4px; - font-size: 12px; - font-weight: bold; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - } - .btn-3d-toggle.active { background: var(--primary); border-color: var(--primary); color: #fff; } - .pane-label { position: absolute; top: 15px; @@ -292,28 +227,6 @@ .material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); } html, body { height: 100%; } -#tab-bar { - height: 34px; - background: var(--van-emous-ultra-light); - border-bottom: 1px solid var(--border); - display: flex; - align-items: stretch; - overflow-x: auto; -} -.tab-item { - display: flex; align-items: center; gap: 8px; - padding: 0 12px; font-size: 12px; color: var(--text-muted); - border-right: 1px solid var(--border-soft); border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap; -} -.tab-item:hover { color: var(--text); } -.tab-item.active { background: var(--surface); color: var(--text); border-bottom: 2px solid var(--primary); } -.tab-item .tab-close { - color: var(--text-faint); font-size: 14px; line-height: 1; padding: 2px; -} -.tab-item .tab-close:hover { color: var(--danger); } - -body { display: flex; flex-direction: column; } -#tab-bar { flex: none; } #workspace { width: 100vw; flex: 1; min-height: 0; position: relative; } #workspace-empty { height: 100%; display: flex; align-items: center; justify-content: center; @@ -433,29 +346,6 @@ body { display: flex; flex-direction: column; } .new-option:hover:not(.disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); } .new-option.disabled { opacity: .4; cursor: not-allowed; } -#floor-app { display: flex; width: 100%; height: 100%; } -#floor-left-column { - flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; - border-right: 1px solid var(--border); -} -#floor-right-column { flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0; } -#floor-app.fullscreen-3d #floor-left-column { display: none; } -#floor-app.fullscreen-2d #floor-right-column { display: none; } -#floor-pane-2d-container { flex: 1; display: flex; flex-direction: column; min-height: 0; width: 100%; } -#floor-control-panel { height: 380px; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); display: flex; flex-direction: column; } -#floor-control-panel .tab-content { padding: 15px 20px; overflow-y: auto; flex: 1; display: none; } -#floor-control-panel .tab-content.active { display: block; } -#floor-pane-3d { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); } -#floor-specs-panel-3d { - height: 380px; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-y: auto; padding: 15px 20px; -} -#floor-specs-panel-3d .controls-grid { grid-template-columns: 1fr 1fr; } - -#house-app { display: flex; width: 100%; height: 100%; } -#house-nav { - width: 240px; flex: none; display: flex; flex-direction: column; gap: 6px; - background: var(--van-emous-blue); border-right: 1px solid var(--border); padding: 10px 8px; overflow-y: auto; -} .hn-title { font-size: 12px; font-weight: bold; color: var(--primary); text-transform: uppercase; letter-spacing: .8px; padding: 2px 6px 6px; } #house-nav-tree { display: flex; flex-direction: column; gap: 2px; } .hn-group { margin-top: 6px; } @@ -475,50 +365,20 @@ body { display: flex; flex-direction: column; } .hn-item.disabled:hover { background: none; color: #9aa4b0; } .hn-actions { display: flex; gap: 6px; margin-top: 10px; } .hn-actions .io-btn { flex: 1; } -#house-nav .io-status { margin: auto 0 0; padding: 6px; } -#house-part-host { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; } -#house-part-host > #app-container, -#house-part-host > #floor-app, -#house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; } - -#house-overview { display: flex; width: 100%; } -.ho-views { flex: 1; display: flex; min-width: 0; min-height: 0; } -.ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); } -#ho-pane-plan { border-right: 1px solid var(--border); } -@media (max-width: 1100px) { - .ho-views { flex-direction: column; } - #ho-pane-plan, .ho-report { border-right: none; border-bottom: 1px solid var(--border); } -} -#house-overview.fullscreen-3d #ho-pane-plan, #house-overview.fullscreen-3d .ho-report { display: none !important; } -#house-overview.fullscreen-2d #ho-pane-3d { display: none; } .ho-report { display: none; flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 14px 18px 20px; background: var(--van-emous-ultra-light); border-right: 1px solid var(--border); } #house-overview.show-report .ho-report { display: block; } #house-overview.show-report #ho-pane-plan { display: none; } -.ho-report .ui-spec-grid { grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); align-items: start; } .ho-report-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; } .ho-report-head h1 { margin: 0; font-size: 14px; } .ho-report-head span { font-size: 12px; color: var(--text-muted); } .ho-warn { position: absolute; top: 52px; left: 15px; right: 15px; z-index: 9; pointer-events: none; } .ho-warn .ui-warn { margin: 0 0 6px; max-width: 560px; } -.ho-hint { - position: absolute; left: 15px; bottom: 10px; font-size: 11px; color: #bbb; - background: rgba(0, 0, 0, .6); padding: 3px 8px; border-radius: 4px; pointer-events: none; -} .ho-prop-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .io-btn-danger { border-color: #e3b4b0; color: var(--danger); } .io-btn-danger:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); } .control-group input:disabled { opacity: .5; cursor: not-allowed; background: var(--border-soft); } -.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; } .ui-section { margin-bottom: 14px; } .ui-section h2, .ui-spec-card h2 { @@ -574,7 +434,6 @@ body { display: flex; flex-direction: column; } .ui-tab-page { display: none; } .ui-tab-page.active { display: block; } -.ui-spec-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; } .ui-spec-card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 6px; padding: 10px 12px; } .ui-spec-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); } .ui-spec-row:last-child { border-bottom: none; } @@ -583,12 +442,10 @@ body { display: flex; flex-direction: column; } .ui-spec-total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 2px; } .ui-spec-total .ui-spec-val { color: var(--primary); } -.btn-3d-toggle { display: inline-flex; align-items: center; gap: 6px; } .hn-item .bi { margin-right: 6px; color: var(--text-muted); } .hn-item.active .bi, .hn-item:hover .bi { color: var(--primary); } .hn-label { display: inline-flex; align-items: center; } -.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 label { font-size: 12.5px; color: var(--text); } @@ -606,9 +463,6 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: .hn-title { display: flex; align-items: center; gap: 6px; } .file-protocol-warning { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 9000; max-width: 640px; box-shadow: 0 8px 20px rgba(33,33,33,.2); } .file-protocol-warning code { background: rgba(0,0,0,.06); padding: 0 4px; border-radius: 3px; } -.vf-option { gap: 9px; } -.vf-option.active { color: var(--primary-on-dark); font-weight: 600; } -.vf-menu .vf-option:hover { color: var(--primary-on-dark); } .hs-stone-preview { display: block; width: 128px; height: auto; margin: 4px 0 8px; border: 1px solid var(--border, #ccc); border-radius: 3px; } @@ -635,21 +489,13 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: .ui-range input[type=number] { width: 84px; } .ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; } -.stage-bar { - position: absolute; left: 15px; bottom: 14px; z-index: 10; min-width: 300px; max-width: calc(100% - 30px); - background: rgba(0, 0, 0, 0.75); border: 1px solid #444; border-radius: 6px; padding: 7px 12px 6px; color: #eee; font-size: 12px; +.plan-zoom { position: absolute; right: 12px; bottom: 12px; z-index: 10; display: flex; flex-direction: column; gap: 4px; } +.plan-zoom button { + width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; cursor: pointer; + background: rgba(0, 0, 0, 0.75); border: 1px solid #555; border-radius: 4px; color: #eee; font-size: 14px; } -.stage-row, .stage-extra { display: flex; align-items: center; gap: 10px; } -.stage-title { display: inline-flex; align-items: center; gap: 6px; color: var(--primary-on-dark); font-weight: 600; white-space: nowrap; } -.stage-range { flex: 1; min-width: 90px; accent-color: var(--primary-on-dark); } -.stage-name { min-width: 84px; font-weight: 600; white-space: nowrap; } -.stage-track { flex: 1; display: flex; flex-direction: column; min-width: 120px; } -.stage-track .stage-range { width: 100%; margin: 0; } -.stage-ticks { display: flex; justify-content: space-between; padding: 0 3px; font-size: 10px; color: #888; } -.stage-ticks span { cursor: pointer; padding: 0 3px; } -.stage-ticks span.on { color: var(--primary-on-dark); } -.stage-extra { display: none; margin-top: 6px; padding-top: 6px; border-top: 1px solid #444; } -.stage-extra.show { display: flex; } +.plan-zoom button:hover { border-color: var(--primary-on-dark); color: var(--primary-on-dark); } +.plan-zoom-val { text-align: center; font-size: 10px; color: #aaa; } .viz { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; } .viz-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; } @@ -671,72 +517,316 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: .viz-val { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text); text-align: right; white-space: nowrap; } .viz-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; } -.rb-top { display: flex; align-items: flex-end; height: 34px; padding: 0 8px; } -.rb-brand { display: flex; align-items: center; height: 100%; padding: 0 14px 0 4px; font-weight: 700; color: var(--primary); letter-spacing: .5px; } -.rb-tabs { display: flex; gap: 2px; height: 100%; align-items: flex-end; overflow-x: auto; scrollbar-width: none; } -.rb-tab { - display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 14px; border: 1px solid transparent; border-bottom: none; - border-radius: 5px 5px 0 0; background: none; font: inherit; font-size: 12.5px; color: var(--nav-link-color); cursor: pointer; white-space: nowrap; -} -.rb-tab:hover { background: rgba(255, 255, 255, .55); color: var(--primary); } -.rb-tab.active { background: var(--van-emous-ultra-light); border-color: var(--border); color: var(--primary); font-weight: 600; margin-bottom: -1px; height: 29px; } -.rb-tab.rb-ctx { color: #fff; background: var(--primary); margin-left: 10px; } -.rb-tab.rb-ctx:hover { background: var(--primary-hover); color: #fff; } -.rb-tab.rb-ctx.active { background: var(--van-emous-ultra-light); color: var(--primary); box-shadow: inset 0 3px 0 var(--primary); } -.rb-right { margin-left: auto; display: flex; align-items: center; height: 100%; gap: 10px; font-size: 11px; color: var(--text-muted); padding-left: 12px; } -.rb-body { - position: relative; display: flex; align-items: stretch; height: 104px; padding: 6px 40px 4px 6px; - background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-x: auto; overflow-y: hidden; -} -#ribbon.rb-collapsed .rb-body { display: none; } -.rb-collapse { position: absolute; right: 8px; bottom: 6px; border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 2px 4px; } -.rb-collapse:hover { color: var(--primary); } -.rb-group { display: flex; flex-direction: column; flex: none; padding: 0 12px; border-right: 1px solid var(--border-soft); } -.rb-group:last-of-type { border-right: none; } -.rb-group-body { flex: 1; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 16px; align-items: center; justify-content: start; } -.rb-group-label { text-align: center; font-size: 10.5px; color: var(--text-faint); padding-top: 2px; white-space: nowrap; } -.rb-btn { - display: inline-flex; align-items: center; gap: 6px; border: 1px solid transparent; background: none; border-radius: 4px; - font: inherit; font-size: 12px; color: var(--text); cursor: pointer; white-space: nowrap; -} -.rb-btn:hover:not(:disabled) { background: var(--primary-soft); border-color: rgba(185, 89, 19, .25); color: var(--primary); } -.rb-btn:disabled { color: #a9b2bd; cursor: default; } -.rb-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); } -.rb-btn.rb-danger:hover:not(:disabled) { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); } -.rb-small { height: 24px; padding: 0 7px; } -.rb-small .bi { font-size: 14px; color: var(--primary); } -.rb-btn:disabled .bi { color: inherit; } -.rb-big { grid-row: span 3; flex-direction: column; justify-content: center; gap: 4px; min-width: 64px; height: 76px; padding: 4px 8px; text-align: center; } -.rb-big .bi { font-size: 24px; color: var(--primary); } -.rb-big span { white-space: normal; max-width: 86px; line-height: 1.2; } -.rb-kv { display: flex; justify-content: space-between; gap: 12px; font-size: 11.5px; color: var(--text-muted); min-width: 150px; } -.rb-kv b { color: var(--text); font-variant-numeric: tabular-nums; } -.rb-kv small { font-weight: normal; color: var(--text-faint); margin-left: 2px; } -.rb-note { grid-row: span 3; max-width: 260px; font-size: 11px; line-height: 1.4; color: var(--text-muted); white-space: normal; align-self: center; } -.rb-note.rb-warn { color: var(--danger); } -.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); } +/* ===== Shell: bovenbalk (menu + documenten), editorindeling, werkbalk, eigenschappen, statusbalk ===== */ +:root { --panel-w: 340px; --nav-w: 230px; } +body { display: flex; flex-direction: column; } -.rb-pair { display: flex; align-items: center; gap: 6px; } -.rb-cols { grid-row: span 3; display: grid; grid-auto-flow: column; grid-template-rows: repeat(3, 26px); column-gap: 18px; align-items: center; } -.rb-cols:empty { display: none; } +#topbar { + flex: none; height: 40px; display: flex; align-items: stretch; gap: 4px; padding: 0 10px 0 4px; + background: var(--van-emous-blue); border-bottom: 1px solid var(--border); position: relative; z-index: 200; user-select: none; +} +.tb-brand { display: flex; align-items: center; padding: 0 12px 0 8px; font-weight: 700; color: var(--primary); letter-spacing: .5px; } +#menubar { display: flex; align-items: center; } +.mb-menu { position: relative; height: 100%; display: flex; align-items: center; } +.mb-top { height: 28px; padding: 0 10px; border: none; background: none; border-radius: 4px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer; } +.mb-top:hover, .mb-menu.open .mb-top { background: rgba(255, 255, 255, .7); color: var(--primary); } +.mb-drop { + display: none; position: absolute; top: calc(100% - 3px); left: 0; min-width: 260px; padding: 4px; + background: var(--surface); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 10px 28px rgba(33, 33, 33, .18); z-index: 300; +} +.mb-menu.open .mb-drop { display: block; } +.mb-item { + display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 6px; width: 100%; padding: 6px 8px; + border: none; background: none; border-radius: 4px; font: inherit; font-size: 13px; color: var(--text); text-align: left; cursor: pointer; +} +.mb-item:hover:not(:disabled) { background: var(--primary-soft); color: var(--primary); } +.mb-item:disabled { color: var(--text-faint); cursor: default; } +.mb-ico .bi { color: var(--text-muted); } +.mb-item:hover:not(:disabled) .mb-ico .bi, .mb-item.checked .mb-ico .bi { color: var(--primary); } +.mb-item:disabled .mb-ico .bi { color: inherit; } +.mb-keys { font-size: 11.5px; color: var(--text-faint); padding-left: 18px; white-space: nowrap; } +.mb-sep { height: 1px; background: var(--border-soft); margin: 4px 6px; } +.mb-head { font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); padding: 6px 8px 2px 36px; } + +#doc-tabs { display: flex; align-items: flex-end; gap: 2px; flex: 1; min-width: 0; margin-left: 14px; overflow: hidden; } +.doc-tab { + display: flex; align-items: center; gap: 4px; flex: 0 1 auto; min-width: 70px; max-width: 220px; height: 32px; padding: 0 4px 0 12px; + border: 1px solid transparent; border-bottom: none; border-radius: 6px 6px 0 0; font-size: 12.5px; color: var(--text-muted); cursor: pointer; +} +.doc-tab:hover { background: rgba(255, 255, 255, .5); color: var(--text); } +.doc-tab.active { background: var(--surface); border-color: var(--border); color: var(--text); font-weight: 600; height: 33px; margin-bottom: -1px; } +.doc-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.doc-close { width: 20px; height: 20px; flex: none; border: none; background: none; border-radius: 4px; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; } +.doc-close:hover { background: var(--danger-soft); color: var(--danger); } +.top-right { display: flex; align-items: center; gap: 12px; margin-left: auto; padding-left: 12px; font-size: 11px; color: var(--text-muted); white-space: nowrap; } +.top-save { + display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border: 1px solid var(--primary); border-radius: 4px; + background: var(--primary); color: #fff; font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; +} +.top-save:hover:not(:disabled) { background: var(--primary-hover); } +.top-save:disabled { opacity: .45; cursor: default; } + +/* Editoren: [werkbalk + viewports] | paneel rechts. Dezelfde indeling voor woning, wand en vloer/dak. */ +.ed-app, #house-overview { display: flex; width: 100%; height: 100%; min-height: 0; } +.ed-main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; } +.ed-views, .ho-views { flex: 1; min-height: 0; min-width: 0; display: flex; } +#left-column, #right-column, #floor-left-column, #floor-right-column { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; } +#left-column, #floor-left-column { border-right: 1px solid var(--border); } +#floor-pane-2d-container { flex: 1; display: flex; flex-direction: column; min-height: 0; width: 100%; } +#pane-3d, #floor-pane-3d { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); } +.ed-app.fullscreen-3d #left-column, .ed-app.fullscreen-3d #floor-left-column { display: none; } +.ed-app.fullscreen-2d #right-column, .ed-app.fullscreen-2d #floor-right-column { display: none; } +.ed-panel { width: var(--panel-w); flex: none; min-height: 0; display: flex; flex-direction: column; background: var(--van-emous-ultra-light); border-left: 1px solid var(--border); } +.ed-panel .tab-content { padding: 12px 14px; } +.ed-panel .controls-grid { grid-template-columns: 1fr; gap: 4px; } +.ed-panel .tab-btn { padding: 9px 4px; font-size: 12px; gap: 5px; } +.ed-panel .io-toolbar:has(.io-status:empty) { display: none; } +#specs-panel-3d, #floor-specs-panel-3d { padding: 0; } +body.panels-hidden .ed-panel, body.panels-hidden #house-nav, body.panels-hidden .statusbar { display: none; } + +#house-app { display: flex; flex-direction: column; width: 100%; height: 100%; } +.house-body { flex: 1; min-height: 0; display: flex; } +#house-nav { + width: var(--nav-w); flex: none; display: flex; flex-direction: column; gap: 6px; padding: 10px 8px; overflow-y: auto; + background: var(--van-emous-blue); border-right: 1px solid var(--border); +} +#house-part-host { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; } +#house-part-host > .ed-app, #house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; } +.ho-pane { flex: 1; position: relative; min-height: 0; min-width: 0; background: var(--viewport-bg); } +#ho-pane-plan { border-right: 1px solid var(--border); } +#house-overview.fullscreen-3d #ho-pane-plan, #house-overview.fullscreen-3d .ho-report { display: none !important; } +#house-overview.fullscreen-2d #ho-pane-3d { display: none; } +@media (max-width: 1250px) { + :root { --panel-w: 300px; --nav-w: 190px; } + .ho-views, .ed-views { flex-direction: column; } + #ho-pane-plan, .ho-report, #left-column, #floor-left-column { border-right: none; border-bottom: 1px solid var(--border); } +} + +.statusbar { + flex: none; height: 24px; display: flex; align-items: center; gap: 16px; padding: 0 12px; + background: var(--van-emous-blue); border-top: 1px solid var(--border); font-size: 11.5px; color: var(--text-muted); +} +.sb-hint { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.statusbar .io-status { white-space: nowrap; } + +/* Werkbalk boven de viewports */ +.ed-toolbar { + flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; min-height: 40px; padding: 4px 8px; + background: var(--surface); border-bottom: 1px solid var(--border); position: relative; z-index: 20; container-type: inline-size; +} +.tb-group { display: flex; align-items: center; gap: 2px; } +.tb-spacer { flex: 1; } +.tb-sep { width: 1px; height: 22px; background: var(--border-soft); margin: 0 6px; } +.tb-btn { + display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 9px; border: 1px solid transparent; border-radius: 5px; + background: none; font: inherit; font-size: 12.5px; color: var(--text); cursor: pointer; white-space: nowrap; position: relative; +} +.tb-btn .bi { font-size: 15px; color: var(--text-muted); } +.tb-btn .tb-caret { font-size: 10px; } +.tb-btn:hover { background: var(--primary-soft); color: var(--primary); } +.tb-btn:hover .bi, .tb-btn.active .bi, .tb-btn.open .bi { color: var(--primary); } +.tb-btn.active, .tb-btn.open { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); } +.tb-btn.flag::after { content: ''; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--primary); } +.tb-title { font-size: 12.5px; font-weight: 600; padding: 0 6px; white-space: nowrap; } +.tb-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; } +.tb-seg .tb-btn { height: 28px; padding: 0 8px; border: none; border-radius: 0; } +.tb-seg .tb-btn + .tb-btn { border-left: 1px solid var(--border-soft); } +.tb-seg .tb-btn.active { background: var(--primary); color: #fff; } +.tb-seg .tb-btn.active .bi { color: #fff; } +.tb-options:empty { display: none; } +.tb-opt { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; padding-left: 10px; border-left: 1px solid var(--border-soft); font-size: 12px; color: var(--text-muted); } +.tb-opt select { height: 28px; max-width: 220px; padding: 0 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; color: var(--text); } +.tb-range { display: none; align-items: center; gap: 6px; margin-left: 2px; font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; } +.tb-section.on .tb-range { display: inline-flex; } +.tb-range input { width: 110px; accent-color: var(--primary); } +#ho-section-val { min-width: 54px; } +/* Smalle werkbalk: eerst de weergaveknoppen zonder tekst, daarna ook het gereedschap (titel blijft als tooltip). */ +@container (max-width: 1240px) { .tb-view .tb-btn > span, .tb-section .tb-btn > span, [data-tb="report"] > span { display: none; } } +@container (max-width: 860px) { .tb-btn > span, .tb-opt > span { display: none; } } + +.tb-pop-wrap { position: relative; } +.tb-pop { + display: none; position: absolute; top: calc(100% + 6px); right: 0; min-width: 170px; padding: 6px; + background: var(--surface); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 10px 28px rgba(33, 33, 33, .18); z-index: 50; +} +.tb-pop.open { display: block; } +.pop-option { + display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border: none; border-radius: 4px; background: none; + font: inherit; font-size: 12.5px; color: var(--text); text-align: left; cursor: pointer; +} +.pop-option:hover { background: var(--primary-soft); color: var(--primary); } +.pop-option.active { color: var(--primary); font-weight: 600; } +.pop-layers { width: max-content; max-width: min(440px, 90vw); padding: 10px 12px; } +.pop-row { display: flex; gap: 8px; margin-bottom: 6px; } +.pop-phase { flex: 1; height: 28px; padding: 0 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; } +.pop-groups { display: grid; grid-template-columns: repeat(2, minmax(170px, 1fr)); gap: 0 18px; } +.pop-head { margin: 6px 0 2px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); } +.pop-check { display: flex; align-items: center; gap: 7px; padding: 3px 2px; font-size: 12.5px; cursor: pointer; white-space: nowrap; } +.pop-check:hover { color: var(--primary); } +.pop-check input { margin: 0; accent-color: var(--primary); } +.pop-check .bi { width: 14px; color: var(--text-muted); } + +/* Eigenschappenpaneel */ +.inspector { overflow-y: auto; background: var(--surface); } +.ins-head { position: sticky; top: 0; z-index: 2; padding: 12px 14px 10px; background: var(--surface); border-bottom: 1px solid var(--border-soft); } +.ins-crumbs { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; font-size: 11.5px; color: var(--text-faint); } +.ins-crumbs .bi { font-size: 9px; } +.ins-crumb { padding: 0; border: none; background: none; font: inherit; color: var(--primary); cursor: pointer; } +.ins-crumb:hover { text-decoration: underline; } +.ins-head h2 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; font-weight: 600; color: var(--text); } +.ins-head h2 .bi { color: var(--primary); } +.ins-sub { margin-top: 2px; font-size: 11.5px; color: var(--text-muted); } +.ins-top { padding: 8px 14px 10px; border-bottom: 1px solid var(--border-soft); } +.ins-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; } +.ins-summary .ins-wide { grid-column: 1 / -1; } +.ins-kv { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); font-size: 12px; color: var(--text-muted); } +.ins-kv b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; } +.ins-kv small { margin-left: 2px; font-weight: normal; color: var(--text-faint); } +.ins-section { border-bottom: 1px solid var(--border-soft); } +.ins-section-head { + display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 14px; border: none; background: none; + font: inherit; font-size: 13px; font-weight: 600; color: var(--text); text-align: left; cursor: pointer; +} +.ins-section-head:hover { background: var(--van-emous-ultra-light); color: var(--primary); } +.ins-section-head > .bi:first-child { width: 16px; color: var(--primary); } +.ins-section-head .bi-chevron-down { margin-left: auto; font-size: 11px; color: var(--text-faint); transition: transform .15s; } +.ins-section.open .ins-section-head .bi-chevron-down { transform: rotate(180deg); } +.ins-collapsible:not(.open) .ins-section-body { display: none; } +.ins-section-body { padding: 2px 14px 12px; } +.ins-group + .ins-group { margin-top: 8px; } +.ins-group h3 { margin: 8px 0 4px; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); } +.ins-group-body { display: flex; flex-direction: column; gap: 2px; } +.ins-btn { + display: inline-flex; align-items: center; gap: 7px; align-self: flex-start; margin: 3px 0; padding: 5px 11px; + border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; color: var(--text); cursor: pointer; +} +.ins-btn .bi { color: var(--primary); } +.ins-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); } +.ins-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); } +.ins-btn:disabled { opacity: .5; cursor: default; } +.ins-btn.ins-danger { color: var(--danger); border-color: #e3b4b0; } +.ins-btn.ins-danger .bi { color: var(--danger); } +.ins-btn.ins-danger:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); } +.ins-group-body > .ins-btn + .ins-btn { margin-top: 0; } +.ins-note { margin: 4px 0; font-size: 11.5px; line-height: 1.45; color: var(--text-muted); } +.ins-note.ins-warn { padding: 6px 8px; border-radius: 4px; background: var(--danger-soft); color: var(--danger); } +/* Staal of foto in de eenheid-kolom, zodat het veld uitlijnt met de rest. */ +.ins-pair { position: relative; } +.ins-pair > .ins-thumb, .ins-pair > .ins-swatch { position: absolute; right: 0; top: 50%; transform: translateY(-50%); } +.ins-thumb { width: 26px; height: 26px; display: block; border: 1px solid var(--border); border-radius: 3px; object-fit: cover; } +.ins-swatch { width: 26px; height: 22px; border: 1px solid var(--border); border-radius: 3px; } +.ins-check { display: flex; align-items: center; gap: 7px; padding: 3px 0; font-size: 12.5px; cursor: pointer; } +.ins-check input { margin: 0; accent-color: var(--primary); } +.ins-cols { display: flex; flex-direction: column; } +.ins-cols:empty { display: none; } +.inspector .ui-field { min-height: 30px; } +.inspector .ui-control input[type=number] { width: 96px; } +.inspector .ui-control select, .inspector .ui-control input[type=text] { width: 150px; } +.inspector .ui-control input[type=color] { width: 46px; height: 24px; padding: 0; border: 1px solid var(--border); border-radius: 3px; background: none; } +.inspector .ui-switch { margin-right: 0; } +.inspector .ui-range input[type=number] { width: 80px; } + +/* Dialogen */ +.dlg-overlay { position: fixed; inset: 0; z-index: 500; display: none; align-items: center; justify-content: center; background: rgba(33, 33, 33, .45); } +.dlg-overlay.open { display: flex; } +.dlg { + display: flex; flex-direction: column; width: 780px; max-width: 94%; max-height: 88vh; + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 12px 30px rgba(33, 33, 33, .2); +} +.dlg-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px 8px; } +.dlg-head h2 { margin: 0; font-size: 15px; color: var(--primary); } +.dlg-x { border: none; background: none; font-size: 20px; line-height: 1; color: var(--text-faint); cursor: pointer; } +.dlg-x:hover { color: var(--danger); } +.keys-body { overflow-y: auto; padding: 4px 20px 18px; columns: 2 300px; column-gap: 28px; } +.keys-body section { break-inside: avoid; margin-bottom: 14px; } +.keys-body h3 { margin: 0 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); } +.keys-row { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border-soft); font-size: 12.5px; } +.keys-row span:last-child { color: var(--text-muted); text-align: right; } +kbd { + display: inline-block; min-width: 20px; padding: 1px 6px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; + background: var(--van-emous-ultra-light); font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--text); text-align: center; +} +#new-modal { max-width: 92vw; } +.new-options { flex-wrap: wrap; max-width: 640px; } +.keys-mouse { font-weight: 600; color: var(--text); } +#house-nav { overflow-x: hidden; } +.hn-item { min-width: 0; } +.hn-label { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hn-meta { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Structuur: inklapbare groepen */ +.hn-item { align-items: center; } +.hn-item .hn-label { flex: 1; } +.hn-group:not(.open) .hn-children { display: none; } +.hn-head { cursor: pointer; } +.hn-head:hover { color: var(--primary); background: rgba(255, 255, 255, .6); } +.hn-toggle { + flex: none; width: 18px; height: 18px; margin: 0 2px 0 -4px; padding: 0; display: inline-flex; align-items: center; justify-content: center; + border: none; border-radius: 3px; background: none; color: var(--text-faint); cursor: pointer; +} +.hn-toggle .bi { margin: 0 !important; font-size: 10px; color: inherit !important; transition: transform .15s; } +.hn-group.open > .hn-item .hn-toggle .bi { transform: rotate(90deg); } +.hn-toggle:hover { background: rgba(255, 255, 255, .8); color: var(--primary); } +.hn-sub { padding-left: 30px; } + +/* Masonry: kaarten in de kortste kolom (leesvolgorde blijft links → rechts) */ +.ui-masonry { display: flex; align-items: flex-start; gap: 12px; } +.ui-masonry-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12px; } + +/* ===== Gelijktrekken oude onderdelen met de nieuwe stijl ===== */ +.io-btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; font-family: inherit; font-size: 12px; } +.ui-btn.ui-btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; } + +/* Viewportlabels: herkenbaar maar niet dominant */ +.pane-label { + top: 10px; left: 10px; padding: 3px 8px; border: none; border-radius: 4px; background: rgba(0, 0, 0, .55); + font-size: 11px; font-weight: 600; letter-spacing: .8px; color: #c9ced6; +} +.ho-warn { top: 40px; left: 10px; right: 10px; } + +/* Eigenschappenpaneel: getal naast schuif lijnt uit met gewone getalvelden; bredere keuzelijsten */ +.inspector .ui-range { gap: 0; } +.inspector .ui-range input[type=range] { margin-right: 10px; } +.inspector .ui-range input[type=number], .inspector .ui-control input[type=number] { width: 96px; } +.inspector .ui-control select, .inspector .ui-control input[type=text] { width: 170px; } +.inspector .ui-field label { flex: 1; min-width: 0; } + +/* Rapport- en resultaatkaarten: lange waarde schuift onder het label i.p.v. het label te pletten */ +.ui-spec-row { flex-wrap: wrap; } +.ui-spec-val { margin-left: auto; } + +/* Wand-/vloerpaneel: koppen, velden en knoppen zoals het eigenschappenpaneel */ +.ed-panel h1 { margin: 12px 0 4px; font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: var(--text-faint); } +.ed-panel h1 .bi { display: none; } +.ed-panel .tab-content > :first-child h1:first-child, .ed-panel .controls-grid > div:first-child > h1:first-child { margin-top: 2px; } +.ed-panel .spec-section h1 { margin: 0 0 6px; font-size: 12px; color: var(--primary); letter-spacing: .6px; } +.ed-panel .spec-section h1 .bi { display: inline; } +.ed-panel .divider { border: none; margin: 4px 0; } +.ed-panel .control-group label { flex: 1; min-width: 0; line-height: 1.3; } +.ed-panel .control-group > div { display: flex; align-items: center; flex: none; white-space: nowrap; } +.ed-panel .control-group .unit { display: inline-block; width: 22px; margin-left: 3px; } +.ed-panel .control-group > div > select:last-child, .ed-panel .control-group > div > input:last-child { margin-right: 25px; } +.ed-panel .control-group .ui-switch { margin-right: 25px; } +.ed-panel .control-group input[type=number], .ed-panel .control-group input[type=text], .ed-panel .control-group select { width: 110px; } +.ed-panel .material-info { font-size: 11.5px; } + +.material-toggle-group { flex-wrap: wrap; } +.material-toggle-btn { + flex: 1 1 auto; min-width: 0; padding: 6px 10px; border-radius: 4px; text-transform: none; letter-spacing: 0; + font-family: inherit; font-size: 12.5px; font-weight: 500; +} +.material-toggle-btn:disabled { opacity: .55; cursor: not-allowed; } + +.add-opening-btn { + width: 100%; justify-content: center; padding: 7px 10px; background: var(--surface); border: 1px dashed var(--border); + color: var(--text); font-weight: 500; font-family: inherit; +} +.add-opening-btn .bi { color: var(--primary); } +.add-opening-btn:hover { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); } +.opening-card-title { text-transform: none; letter-spacing: 0; font-size: 13px; color: var(--text); } +.opening-remove-btn { border-color: transparent; color: var(--text-faint); } +.opening-remove-btn:hover { border-color: var(--danger); color: var(--danger); } +#cost-settings-modal .cs-row input, #cost-settings-modal .cs-row select { + padding: 4px 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); font: inherit; font-size: 12.5px; color: var(--text); +} +#cost-settings-modal .cs-row input:focus, #cost-settings-modal .cs-row select:focus { border-color: var(--primary); outline: none; box-shadow: 0 0 0 2px var(--primary-soft); } +#cost-settings-modal .cs-row input:disabled, #cost-settings-modal .cs-row input[readonly] { background: var(--border-soft); } diff --git a/index.html b/index.html index ad58dad..180d42e 100644 --- a/index.html +++ b/index.html @@ -22,15 +22,23 @@
Tectum — Versie 0.0.0
-
-
-
Tectum
-
-
+
+
Tectum
+ +
+
+ +
-
+
+ +
+
@@ -49,7 +57,7 @@
-

Nieuw

+

Nieuw ontwerp

-
-
- + - - + + diff --git a/js/app.js b/js/app.js index 15f043e..4fb0c58 100644 --- a/js/app.js +++ b/js/app.js @@ -4,7 +4,7 @@ * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ -const APP_VERSION = '0.7.0'; +const APP_VERSION = '0.7.1'; // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. let tabs = []; @@ -28,72 +28,83 @@ function defaultNameFor(kind) { function setWorkspaceEmpty() { document.getElementById('workspace').innerHTML = `
Geen ontwerp geopend.
-
Bestand → Nieuw → kies een objecttype
+
Bestand › Nieuw (Ctrl+N)
`; } -function updateEditMenuState() { - Ribbon.refresh(); +const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function' && (!currentEditor.can || currentEditor.can(fn))); +const editorCall = (fn, ...args) => { if (editorHas(fn)) return currentEditor[fn](...args); }; +const viewState = () => editorCall('viewState') || {}; + +// Menu met de sneltoets per item; KEY_HELP hieronder is het overzicht (Help › Sneltoetsen). Editor-acties werken alleen als de editor ze kent. +const MENUS = [ + { label: 'Bestand', items: [ + { label: 'Nieuw…', icon: 'file-earmark-plus', keys: 'Ctrl+N', run: openNewModal }, + { label: 'Openen…', icon: 'folder2-open', keys: 'Ctrl+O', run: () => editorCall('triggerImport'), enabled: () => editorHas('triggerImport') }, + { label: 'Opslaan (JSON + PDF)', icon: 'save', keys: 'Ctrl+S', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') }, + { sep: true }, { head: 'Exporteren' }, + { label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') }, + { label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') }, + { label: 'Render (OBJ)', icon: 'badge-3d', run: () => editorCall('exportObj'), enabled: () => editorHas('exportObj') }, + { sep: true }, + { label: 'Projectgegevens…', icon: 'card-text', run: () => editorCall('openProjectProperties'), enabled: () => editorHas('openProjectProperties') }, + { label: 'Kosten & tijden…', icon: 'calculator', run: () => CostSettings.open() }, + { sep: true }, + { label: 'Ontwerp sluiten', icon: 'x-lg', run: () => activeTabId !== null && closeTab(activeTabId), enabled: () => activeTabId !== null } + ] }, + { label: 'Beeld', items: [ + ...UI.VIEWS.map(v => ({ label: v.label, icon: v.icon, keys: v.key1, run: () => editorCall('setView', v.key), + enabled: () => editorHas('setView'), checked: () => viewState().view === v.key })), + { label: 'Rapport', icon: 'clipboard-data', keys: '4', run: () => editorCall('toggleReport'), enabled: () => editorHas('toggleReport'), checked: () => !!viewState().report }, + { sep: true }, + { label: 'Doorsnede', icon: 'scissors', keys: 'X', run: () => editorCall('toggleSection'), enabled: () => editorHas('toggleSection'), checked: () => !!viewState().section }, + { label: 'Plattegrond passend', icon: 'aspect-ratio', keys: 'F', run: () => editorCall('fitView'), enabled: () => editorHas('fitView') }, + { label: 'Lopen', icon: 'person-walking', keys: 'L', run: () => editorCall('toggleWalk'), enabled: () => editorHas('toggleWalk'), checked: () => !!viewState().walk }, + { sep: true }, + { label: 'Panelen verbergen', icon: 'layout-sidebar-inset', keys: 'Ctrl+\\', run: togglePanels, checked: () => document.body.classList.contains('panels-hidden') }, + { label: 'Volledig scherm', icon: 'fullscreen', keys: 'F11', run: toggleBrowserFullscreen, checked: () => !!document.fullscreenElement } + ] }, + { label: 'Help', items: [ + { label: 'Sneltoetsen en bediening', icon: 'keyboard', keys: '?', run: openKeys }, + { label: 'Over Tectum', icon: 'info-circle', run: () => document.getElementById('about-overlay').classList.add('open') } + ] } +]; + +const KEY_HELP = [ + ['Bestand', [['Ctrl+N', 'Nieuw ontwerp'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF)']]], + ['Gereedschap (woning)', [['V', 'Selecteren'], ['W', 'Binnenwand tekenen'], ['R', 'Ruimte vullen'], ['I', 'Inrichting plaatsen'], + ['Esc', 'Gereedschap stoppen, daarna selectie opheffen'], ['Del', 'Selectie verwijderen'], ['D / Shift+D', 'Inrichting draaien'], ['Dubbelklik', 'Onderdeel bewerken (wand, vloer)']]], + ['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'], + ['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]], + ['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]], + ['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts slepen', 'Verschuiven'], ['Scrollwiel', 'Zoomen']], true], + ['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true], + ['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]], + ['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true] +]; + +function openKeys() { + // Muisacties als tekst, toetsen als . + document.getElementById('keys-body').innerHTML = KEY_HELP.map(([title, rows, mouse]) => + `

${title}

${rows.map(([k, d]) => `
${mouse ? `${k}` + : k.split(' / ').map(x => `${x}`).join(' / ')}${d}
`).join('')}
`).join(''); + document.getElementById('keys-overlay').classList.add('open'); } -const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function'); -const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); }; -const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled'; - -function appRibbonTabs() { - const { big, small } = Ribbon; - const wireClicks = (body) => body.querySelectorAll('[data-app]').forEach(b => b.addEventListener('click', () => { - const a = b.dataset.app; - if (a === 'new') openNewModal(); - else if (a === 'new-kind') openNew(b.dataset.kind, b.dataset.template); - else if (a === 'fullscreen') toggleBrowserFullscreen(); - else if (a === 'costs') CostSettings.open(); - else if (a === 'about') document.getElementById('about-overlay').classList.add('open'); - else editorCall(a); - })); - return [ - { key: 'bestand', label: 'Bestand', order: 0, wire: wireClicks, groups: () => [ - { label: 'Document', html: - big('Nieuw', 'file-earmark-plus', 'data-app="new" title="Nieuw ontwerp (Ctrl+N)"') + - big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) + - big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) }, - { label: 'Exporteren', html: - big('CAD (DXF)', 'vector-pen', `data-app="exportDxf" title="Plattegrond en gevels als DXF voor CAD (AutoCAD, BricsCAD, …), 1:1 in mm; daar op te slaan als DWG" ${disabledUnless('exportDxf')}`) + - big('BIM (IFC)', 'boxes', `data-app="exportIfc" title="Bouwconstructie als IFC4 (balken, platen, elektra) per element, zonder afwerking en inrichting" ${disabledUnless('exportIfc')}`) + - big('Render (OBJ)', 'badge-3d', `data-app="exportObj" title="Volledige afgewerkte woning als OBJ + MTL + texturen in een ZIP, voor renders in Blender" ${disabledUnless('exportObj')}`) }, - { label: 'Snel nieuw', html: - Object.entries(HouseModel.MODELS).map(([key, m]) => small(m.label, m.icon, `data-app="new-kind" data-kind="woning" data-template="${key}"`)).join('') + - small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') + - small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') + - small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') + - small('Los: vloer', 'grid-3x3', 'data-app="new-kind" data-kind="vloer"') }, - { label: 'Project', html: big('Eigenschappen', 'card-text', `data-app="openProjectProperties" title="Projectgegevens voor de PDF" ${disabledUnless('openProjectProperties')}`) } - ] }, - { key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [ - { label: 'Venster', html: - small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') + - small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) + - small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) }, - { label: 'Camera', html: - big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) + - small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') } - ] }, - { key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [ - { label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) }, - { label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') }, - { label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') } - ] } - ]; +function togglePanels() { + document.body.classList.toggle('panels-hidden'); + window.dispatchEvent(new Event('resize')); } function renderTabBar() { - const bar = document.getElementById('tab-bar'); + const bar = document.getElementById('doc-tabs'); bar.innerHTML = tabs.map(tab => ` -
- ${tab.name} - × + `).join(''); + document.getElementById('top-save').disabled = !editorHas('exportDesign'); } function onTabSaved(tab, filenameWithExt) { @@ -106,7 +117,6 @@ function onTabSaved(tab, filenameWithExt) { function mountEditorForTab(tab) { const ws = document.getElementById('workspace'); ws.innerHTML = ''; - Ribbon.setSource('editor', []); currentEditor = editorModuleFor(tab.kind).mount(ws, { onSaved: (filename) => onTabSaved(tab, filename), template: tab.template, cameraKey: 'tab' + tab.id }); if (tab.stateJson && typeof currentEditor.loadStateJson === 'function') { currentEditor.loadStateJson(tab.stateJson); @@ -124,7 +134,6 @@ function switchToTab(tabId) { activeTabId = tabId; mountEditorForTab(tabs.find(t => t.id === tabId)); - updateEditMenuState(); renderTabBar(); } @@ -142,7 +151,6 @@ function openNew(kind, template) { tabs.push(tab); activeTabId = tab.id; mountEditorForTab(tab); - updateEditMenuState(); renderTabBar(); } @@ -183,17 +191,15 @@ function closeTab(tabId) { switchToTab(tabs[tabs.length - 1].id); } else { setWorkspaceEmpty(); - Ribbon.setSource('editor', []); - updateEditMenuState(); renderTabBar(); } } function wireTabBar() { - document.getElementById('tab-bar').addEventListener('click', (e) => { - const closeBtn = e.target.closest('.tab-close'); + document.getElementById('doc-tabs').addEventListener('click', (e) => { + const closeBtn = e.target.closest('.doc-close'); if (closeBtn) { closeTab(parseInt(closeBtn.dataset.closeId, 10)); return; } - const item = e.target.closest('.tab-item'); + const item = e.target.closest('.doc-tab'); if (item) switchToTab(parseInt(item.dataset.tabId, 10)); }); } @@ -237,41 +243,42 @@ function toggleBrowserFullscreen() { else document.documentElement.requestFullscreen(); } +function closeOverlays() { + const open = document.querySelector('.dlg-overlay.open, #about-overlay.open, #new-overlay.open'); + if (open) { open.classList.remove('open'); return true; } + return Menubar.close() || UI.closePopovers(); +} + function wireHotkeys() { document.addEventListener('keydown', (e) => { const inField = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName); - - if (e.ctrlKey && e.key.toLowerCase() === 's') { - e.preventDefault(); - if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign(); + const k = e.key.toLowerCase(); + if (e.ctrlKey || e.metaKey) { + const act = { s: () => editorCall('exportDesign'), n: openNewModal, o: () => editorCall('triggerImport'), '\\': togglePanels }[k]; + if (act && !e.altKey && !e.shiftKey) { e.preventDefault(); act(); } return; } - if (e.ctrlKey && e.key.toLowerCase() === 'n') { - e.preventDefault(); - openNewModal(); - return; - } - if (inField || e.ctrlKey || e.metaKey || e.altKey) return; - - if (e.key.toLowerCase() === 'f') { - toggleBrowserFullscreen(); - } else if (e.key.toLowerCase() === 'h') { - if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal(); - } else if (e.key.toLowerCase() === 't' && e.shiftKey) { - if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk(); - } else if (e.key.toLowerCase() === 't') { - if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D(); - } else if (e.key.toLowerCase() === 'r') { - if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D(); - } - }); + if (e.key === 'Escape' && closeOverlays()) { e.stopImmediatePropagation(); return; } + if (inField || e.altKey || viewState().walk && !['l', 'escape'].includes(k)) return; + const view = UI.VIEWS.find(v => v.key1 === e.key); + if (view) editorCall('setView', view.key); + else if (e.key === '4') editorCall('toggleReport'); + else if (k === 'x') editorCall('toggleSection'); + else if (k === 'f') editorCall('fitView'); + else if (k === 'l') editorCall('toggleWalk'); + else if (k === 'h' && e.shiftKey) editorCall('mirrorHorizontal'); + else if (e.key === '?') openKeys(); + }, true); } document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.app-version-slot').forEach(el => { el.textContent = 'v' + APP_VERSION; }); document.querySelectorAll('.app-version-number').forEach(el => { el.textContent = APP_VERSION; }); - Ribbon.init(document.getElementById('ribbon')); - Ribbon.setSource('app', appRibbonTabs()); + Menubar.init(document.getElementById('menubar'), MENUS); + document.getElementById('top-save').addEventListener('click', () => editorCall('exportDesign')); + document.getElementById('keys-overlay').addEventListener('click', (e) => { + if (e.target.id === 'keys-overlay' || e.target.closest('[data-close]')) document.getElementById('keys-overlay').classList.remove('open'); + }); wireAbout(); wireNewModal(); wireHotkeys(); diff --git a/js/core/build-stages.js b/js/core/build-stages.js deleted file mode 100644 index c224db6..0000000 --- a/js/core/build-stages.js +++ /dev/null @@ -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 = ` -
- Bouwfase -
- -
${LIST.map((s, i) => `${i + 1}`).join('')}
-
- -
-
`; - 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 }; -})(); diff --git a/js/core/inspector.js b/js/core/inspector.js new file mode 100644 index 0000000..755fa41 --- /dev/null +++ b/js/core/inspector.js @@ -0,0 +1,63 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// Eigenschappenpaneel rechts: eigenschappen van de selectie, of van de woning als er niets geselecteerd is. +// Een sectie = { key, label, icon, groups() → [{ label, html }], wire(el) }; elke sectie krijgt een eigen element, +// zodat wire() alleen de eigen knoppen koppelt. + +window.Inspector = (() => { + const icon = (n) => ``; + const big = (label, iconName, attrs, cls) => + ``; + const small = (label, iconName, attrs, cls) => + ``; + const kv = (label, value, unit) => + `
${label}${value}${unit ? `${unit}` : ''}
`; + + 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 => + `
${g.label && g.label.toLowerCase() !== String(secLabel || '').toLowerCase() ? `

${g.label}

` : ''}
${g.html}
`).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 = `
+ ${view.crumbs && view.crumbs.length ? `
${view.crumbs.map(c => ``).join(icon('chevron-right'))}${icon('chevron-right')}
` : ''} +

${view.icon ? icon(view.icon) : ''}${view.title}

+ ${view.sub ? `
${view.sub}
` : ''} +
+ ${view.top ? `
${view.top}
` : ''} +
`; + 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 ? `` : '') + + `
${groupsHtml(sec.groups ? sec.groups() : [], sec.label)}
`; + host.appendChild(node); + if (collapsible) node.querySelector('.ins-section-head').addEventListener('click', () => { + const now = !node.classList.contains('open'); + node.classList.toggle('open', now); + node.querySelector('.ins-section-head').setAttribute('aria-expanded', now); + if (now) open.add(sec.key); else open.delete(sec.key); + saveOpen(); + }); + if (sec.wire) sec.wire(node); + }); + el.scrollTop = keepScroll; + } + + return { big, small, kv, render }; +})(); diff --git a/js/core/menubar.js b/js/core/menubar.js new file mode 100644 index 0000000..29edde9 --- /dev/null +++ b/js/core/menubar.js @@ -0,0 +1,61 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// Menubalk: menu = { label, items: [{ label, icon, keys, run, enabled(), checked() } | { sep: true } | { head }] }. +// Beschikbaarheid wordt bij het openen bepaald; is een menu open, dan opent hover het buurmenu. + +window.Menubar = (() => { + let root = null, menus = [], openIdx = -1; + + function itemHtml(it, mi, ii) { + if (it.sep) return '
'; + if (it.head) return `
${it.head}
`; + const off = it.enabled && !it.enabled(); + const on = it.checked && it.checked(); + return ``; + } + + function close() { + if (openIdx < 0) return false; + openIdx = -1; + root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open')); + root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; }); + return true; + } + + function openMenu(i) { + close(); + openIdx = i; + const wrap = root.querySelectorAll('.mb-menu')[i]; + wrap.classList.add('open'); + wrap.querySelector('.mb-drop').innerHTML = menus[i].items.map((it, ii) => itemHtml(it, i, ii)).join(''); + } + + function init(el, list) { + root = el; + menus = list; + el.innerHTML = menus.map((m, i) => `
`).join(''); + el.addEventListener('click', (e) => { + e.stopPropagation(); + const top = e.target.closest('[data-menu]'); + if (top) { const i = Number(top.dataset.menu); if (openIdx === i) close(); else openMenu(i); return; } + const item = e.target.closest('.mb-item'); + if (!item || item.disabled) return; + const it = menus[Number(item.dataset.mi)].items[Number(item.dataset.ii)]; + close(); + it.run(); + }); + el.addEventListener('mouseover', (e) => { + const top = e.target.closest('[data-menu]'); + if (top && openIdx >= 0 && Number(top.dataset.menu) !== openIdx) openMenu(Number(top.dataset.menu)); + }); + document.addEventListener('click', close); + } + + return { init, close }; +})(); diff --git a/js/core/obj-writer.js b/js/core/obj-writer.js index f4d3416..0150662 100644 --- a/js/core/obj-writer.js +++ b/js/core/obj-writer.js @@ -129,5 +129,70 @@ window.ObjWriter = (() => { }; } - return { build }; + // 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 }; })(); diff --git a/js/core/ribbon.js b/js/core/ribbon.js deleted file mode 100644 index 7df767e..0000000 --- a/js/core/ribbon.js +++ /dev/null @@ -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) => ``; - const big = (label, iconName, attrs, cls) => - ``; - const small = (label, iconName, attrs, cls) => - ``; - const kv = (label, value, unit) => - `
${label}${value}${unit ? `${unit}` : ''}
`; - - 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 => - ``).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 => - `
${g.html}
${g.label}
`).join('') + - ``; - 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 }; -})(); diff --git a/js/core/skybox.js b/js/core/skybox.js index fbfca67..b541501 100644 --- a/js/core/skybox.js +++ b/js/core/skybox.js @@ -161,32 +161,26 @@ window.Skybox = (() => { function createToggle(toolbar, onChange) { let m = getMode(); const wrap = document.createElement('div'); - wrap.className = 'view-filter'; - wrap.innerHTML = ` -
${MODES.map(k => `
${LABELS[k][1]}
`).join('')}
`; + wrap.className = 'tb-pop-wrap'; + wrap.innerHTML = ` +
${MODES.map(k => ``).join('')}
`; toolbar.appendChild(wrap); - const btn = wrap.querySelector('.vf-btn'), menu = wrap.querySelector('.vf-menu'); + const btn = wrap.querySelector('.tb-btn'), menu = wrap.querySelector('.tb-pop'); + const close = UI.popover(btn, menu); const sync = () => { - btn.innerHTML = `${LABELS[m][1]}`; - menu.querySelectorAll('.vf-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m)); + btn.innerHTML = `${LABELS[m][1]}`; + menu.querySelectorAll('.pop-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m)); }; - btn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('open'); }); menu.addEventListener('click', (e) => { - e.stopPropagation(); - const opt = e.target.closest('.vf-option'); + const opt = e.target.closest('.pop-option'); if (!opt) return; - menu.classList.remove('open'); + close(); if (opt.dataset.mode === m) return; m = opt.dataset.mode; remember(m); sync(); onChange(m); }); - const closeOnOutside = () => { - if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; } - menu.classList.remove('open'); - }; - document.addEventListener('click', closeOnOutside); sync(); return wrap; } diff --git a/js/core/ui-kit.js b/js/core/ui-kit.js index 40a3da9..407b2f3 100644 --- a/js/core/ui-kit.js +++ b/js/core/ui-kit.js @@ -128,16 +128,56 @@ window.UI = (() => { if (el) el.addEventListener('change', (e) => { fn(e.target.checked); after && after(); }); } - function cycle3D(el) { - el.classList.remove('fullscreen-2d'); - if (!el.classList.contains('fullscreen-3d')) el.classList.add('fullscreen-3d'); - else if (!el.classList.contains('render-only')) el.classList.add('render-only'); - else el.classList.remove('fullscreen-3d', 'render-only'); - } - function toggle2D(el) { - el.classList.remove('fullscreen-3d', 'render-only'); - el.classList.toggle('fullscreen-2d'); + // Weergave van een editor: klassen fullscreen-2d/-3d op het editor-element (de looptocht bewaart en herstelt className). + const VIEWS = [ + { key: 'split', icon: 'layout-split', label: '2D + 3D', key1: '1' }, + { key: '2d', icon: 'square', label: 'Alleen 2D', key1: '2' }, + { key: '3d', icon: 'box', label: 'Alleen 3D', key1: '3' } + ]; + const viewOf = (el) => el.classList.contains('fullscreen-3d') ? '3d' : el.classList.contains('fullscreen-2d') ? '2d' : 'split'; + function setView(el, mode) { + el.classList.toggle('fullscreen-3d', mode === '3d'); + el.classList.toggle('fullscreen-2d', mode === '2d'); + el.classList.remove('render-only'); + el.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode)); } - return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D }; + const tb = (label, iconName, attrs, cls) => + ``; + const tbSep = () => ''; + const viewSeg = (mode) => `
${VIEWS.map(v => + tb('', v.icon, `data-view="${v.key}" title="${v.label} (${v.key1})"`, v.key === mode ? 'active' : '')).join('')}
`; + + // Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere popover. + let openPop = null; + function popover(btn, panel) { + const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); if (openPop === close) openPop = null; }; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const was = panel.classList.contains('open'); + if (openPop) openPop(); + if (!was) { panel.classList.add('open'); btn.classList.add('open'); openPop = close; } + }); + panel.addEventListener('click', (e) => e.stopPropagation()); + return close; + } + const closePopovers = () => { if (openPop) { openPop(); return true; } return false; }; + document.addEventListener('click', () => closePopovers()); + + // Kaarten verdelen over kolommen van min. minW breed; telkens in de kortste kolom. Opnieuw aanroepen bij een andere breedte. + function masonry(el, minW, gap) { + if (!el) return; + gap = gap === undefined ? 12 : gap; + const cards = el._cards || (el._cards = [...el.children]); + const n = Math.max(1, Math.floor((el.clientWidth + gap) / (minW + gap))); + if (el._cols === n) return; + el._cols = n; + el.classList.add('ui-masonry'); + const cols = Array.from({ length: n }, () => { const c = document.createElement('div'); c.className = 'ui-masonry-col'; return c; }); + el.replaceChildren(...cols); + cards.forEach(card => cols.reduce((a, b) => b.offsetHeight < a.offsetHeight ? b : a).appendChild(card)); + } + + return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, + VIEWS, viewOf, setView, tb, tbSep, viewSeg, popover, closePopovers, masonry }; })(); diff --git a/js/core/view-filter.js b/js/core/view-filter.js deleted file mode 100644 index 3ae4d4b..0000000 --- a/js/core/view-filter.js +++ /dev/null @@ -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 = ` - -
- ${items.map(it => ``).join('')} -
`; - 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) }; -} diff --git a/js/core/view-layers.js b/js/core/view-layers.js new file mode 100644 index 0000000..a626c23 --- /dev/null +++ b/js/core/view-layers.js @@ -0,0 +1,108 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ + +window.ViewLayers = (() => { + // phase: bouwvolgorde voor de snelkeuze "Fase" (en IFC); lagen zonder phase blijven daarbij aan. + const LIST = [ + { key: 'regelwerk', label: 'Regelwerk', icon: 'grid-3x3', phase: 0, hint: 'Vloer-, wand- en dakbalken, stalen nokbalk' }, + { key: 'elektra', label: 'Elektra', icon: 'lightning-charge', phase: 1, hint: 'Meterkast, buizen, dozen, schakelaars en wcd\'s' }, + { key: 'beplating', label: 'Beplating', icon: 'square', phase: 2, hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' }, + { key: 'doek', label: 'Vochtdoek', icon: 'droplet', phase: 3, hint: 'Vochtdoek op de buitenzijde' }, + { key: 'latwerk', label: 'Latwerk', icon: 'distribute-vertical', phase: 4, hint: 'Kruislat en trasraamplaat' }, + { key: 'gevel', label: 'Gevelbekleding', icon: 'bricks', phase: 5, hint: 'Rabat, steenstrips, stuc, schoorsteen, sandwichpanelen' }, + { key: 'kozijnen', label: 'Kozijnen', icon: 'window', phase: 5, hint: 'Kozijnen, deuren en glas' }, + { key: 'dakbedekking', label: 'Dakbedekking', icon: 'house-up', phase: 5, hint: 'Dakleer of riet' }, + { key: 'afwerking', label: 'Afwerking', icon: 'brush', phase: 6, hint: 'Vloerafwerking, verf, plafond, dakrand, dagkanten, logobord' }, + { key: 'lampen', label: 'Verlichting', icon: 'lightbulb', phase: 6, hint: 'Gevellampen, spots en binnenverlichting' }, + { key: 'inrichting', label: 'Inrichting', icon: 'lamp', phase: 6, hint: 'Sanitair, keuken, kasten, bed, zithoek' }, + { key: 'ruimtes', label: 'Ruimtes', icon: 'bounding-box', phase: 6, hint: 'Ruimtes met functie, vloer en wandpanelen' }, + { key: 'dak', label: 'Dak', icon: 'house', hint: 'Het hele dak met dakrand, overkapping, palen en schoorsteen; uit om van boven in de woning te kijken' }, + { key: 'maten', label: 'Maten', icon: 'rulers', hint: 'Maatvoering in de plattegrond' } + ]; + const KEYS = LIST.map(l => l.key); + const PHASES = ['Regelwerk', 'Elektra', 'Beplating', 'Vochtdoek', 'Latwerk', 'Gevel & dak', 'Afgewerkt']; + + const all = () => Object.fromEntries(KEYS.map(k => [k, true])); + const normalize = (layers) => Object.fromEntries(KEYS.map(k => [k, !layers || layers[k] !== false])); + const shows = (layers, key) => !layers || layers[key] !== false; + // Alles tot en met een bouwfase (0..6), zoals de vroegere bouwfase-schuif. + const upTo = (phase) => Object.fromEntries(LIST.map(l => [l.key, l.phase === undefined || l.phase <= phase])); + const phaseOf = (layers) => PHASES.findIndex((_, p) => LIST.every(l => l.phase === undefined || (l.phase <= p) === shows(layers, l.key))); + + // Verbergt meshes met een van de materialen (ook afgeleide met userData.base), bijv. het regelwerk. + function hideByMaterial(group, materials) { + if (!group) return; + const set = new Set(materials.filter(Boolean)); + group.traverse(o => { + if (!o.isMesh) return; + const m0 = Array.isArray(o.material) ? o.material[0] : o.material; + if (m0 && (set.has(m0) || set.has(m0.userData && m0.userData.base))) o.visible = false; + }); + } + + const GROUPS = [ + ['Casco', ['regelwerk', 'elektra', 'beplating', 'doek', 'latwerk']], + ['Buiten', ['gevel', 'kozijnen', 'dakbedekking', 'dak']], + ['Binnen', ['afwerking', 'lampen', 'inrichting', 'ruimtes']], + ['Plattegrond', ['maten']] + ]; + + // De enige plek voor zichtbaarheid: knop "Lagen" in de werkbalk met een uitklappaneel. + // keys beperkt de lagen (wand-editor); extras zijn editor-eigen schakelaars, bijv. beplating binnen/buiten. + function createMenu(toolbar, o) { + const items = o.keys ? LIST.filter(l => o.keys.includes(l.key)) : LIST; + const extras = o.extras || []; + const extra = Object.fromEntries(extras.map(x => [x.key, x.checked !== false])); + const check = (attr, key, label, hint, ic) => + ``; + const wrap = document.createElement('div'); + wrap.className = 'tb-pop-wrap'; + wrap.innerHTML = ` +
+ ${items.length ? `
+ + +
+
${GROUPS.map(([label, keys]) => { + const g = items.filter(l => keys.includes(l.key)); + return g.length ? `
${label}
${g.map(l => check('data-layer', l.key, l.label, l.hint, l.icon)).join('')}
` : ''; + }).join('')}
` : ''} + ${extras.length ? `
${o.extrasLabel || 'Weergave'}
${extras.map(x => check('data-extra', x.key, x.label, x.hint)).join('')}
` : ''} +
`; + toolbar.appendChild(wrap); + const btn = wrap.querySelector('.tb-btn'), panel = wrap.querySelector('.tb-pop'); + UI.popover(btn, panel); + let cur = normalize(o.layers); + const sel = panel.querySelector('.pop-phase'); + const sync = () => { + panel.querySelectorAll('[data-layer]').forEach(c => { c.checked = cur[c.dataset.layer]; }); + panel.querySelectorAll('[data-extra]').forEach(c => { c.checked = extra[c.dataset.extra]; }); + if (sel) { const p = phaseOf(cur); sel.value = p >= 0 ? String(p) : ''; } + const hidden = items.some(l => !cur[l.key]) || extras.some(x => !extra[x.key]); + btn.classList.toggle('flag', hidden); + }; + const emit = () => { sync(); o.onChange && o.onChange(Object.assign({}, cur)); }; + panel.addEventListener('change', (e) => { + const t = e.target; + if (t.dataset.layer) { cur = Object.assign({}, cur, { [t.dataset.layer]: t.checked }); emit(); } + else if (t.dataset.extra) { extra[t.dataset.extra] = t.checked; sync(); o.onExtra && o.onExtra(Object.assign({}, extra)); } + else if (t === sel && sel.value !== '') { + const keep = Object.fromEntries(LIST.filter(l => l.phase === undefined).map(l => [l.key, cur[l.key]])); + cur = Object.assign(upTo(Number(sel.value)), keep); + emit(); + } + }); + const all = panel.querySelector('[data-all]'); + if (all) all.addEventListener('click', () => { cur = ViewLayers.all(); emit(); }); + sync(); + return { el: wrap, set: (l) => { cur = normalize(l); sync(); }, toggle: (key) => { cur = Object.assign({}, cur, { [key]: !cur[key] }); emit(); } }; + } + + return { LIST, KEYS, PHASES, GROUPS, all, normalize, shows, upTo, phaseOf, hideByMaterial, createMenu }; +})(); diff --git a/js/cost/cost-settings.js b/js/cost/cost-settings.js index b059d23..24e5fe8 100644 --- a/js/cost/cost-settings.js +++ b/js/cost/cost-settings.js @@ -24,13 +24,13 @@ window.CostSettings = (() => {
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.
diff --git a/js/elektra/elektra-model.js b/js/elektra/elektra-model.js index 05b33e3..63c819f 100644 --- a/js/elektra/elektra-model.js +++ b/js/elektra/elektra-model.js @@ -143,9 +143,30 @@ window.ElektraModel = (() => { return null; } - function makeRouter(units) { + // rooms: leidingen lopen boven een vals plafond, niet tegen de onderkant van een hoog dak. + function makeRouter(units, rooms) { const unitAt = (x, z) => units.find(u => x >= u.rect.x0 - 1 && x <= u.rect.x1 + 1 && z >= u.rect.z0 - 1 && z <= u.rect.z1 + 1) || null; - const ceilAt = (x, z) => { + // Vals plafond (mm boven vloer) van de ruimte op (x,z): undefined = geen ruimte (binnenwand, naad), null = geen vals plafond. + const roomPlenum = (x, z) => { const r = rooms && rooms.roomAt ? rooms.roomAt(x, z) : null; return r ? (r.ceilingActive ? r.ceiling : null) : undefined; }; + // Absoluut; in een binnenwand het laagste valse plafond ernaast: de leiding gaat daar door de wand, niet omhoog. + const plenumAt = (x, z) => { + const u = unitAt(x, z); + if (!u) return null; + let v = roomPlenum(x, z); + if (v === undefined) { + v = null; + [[1, 0], [-1, 0], [0, 1], [0, -1]].forEach(([dx, dz]) => { + for (const d of [60, 120, 200, 300]) { + const o = roomPlenum(x + dx * d, z + dz * d); + if (o === undefined) continue; + if (o !== null) v = v === null ? o : Math.min(v, o); + break; + } + }); + } + return v === null ? null : u.layout.floorTop + v; + }; + const roofCeilAt = (x, z) => { let u = unitAt(x, z); if (!u) u = units.reduce((best, c) => { const dx = Math.max(c.rect.x0 - x, 0, x - c.rect.x1), dz = Math.max(c.rect.z0 - z, 0, z - c.rect.z1); @@ -155,24 +176,103 @@ window.ElektraModel = (() => { const r = u.rect; return u.roof.ceilingAt(Math.max(r.x0 + 1, Math.min(r.x1 - 1, x)), Math.max(r.z0 + 1, Math.min(r.z1 - 1, z))); }; + // Leidingvlak: het valse plafond waar dat lager ligt dan het dak, anders de onderkant van het dak. + const plenumOn = (x, z) => { const v = plenumAt(x, z); return v !== null && v < roofCeilAt(x, z) - 1 ? v : null; }; + const ceilAt = (x, z) => { const v = plenumOn(x, z); return v !== null ? v : roofCeilAt(x, z); }; + const inPlenum = (x, z, y) => y < roofCeilAt(x, z) - 50; + // Over het plafond het vlak volgen: knikken in het dak (nok, kil) als tussenpunt, de overgang vals plafond/dak + // verticaal op de grens (in de wand), zodat de buis nooit los door de ruimte of buiten het dak loopt. + function followCeiling(pts) { + const same = (u, v) => (u === null && v === null) || (u !== null && v !== null && Math.abs(u - v) < 1); + const out = [pts[0]]; + pts.slice(1).forEach((q, i) => { + const p = pts[i], len = Math.hypot(q.x - p.x, q.z - p.z); + if (p.ceil && q.ceil && !p.floor && !q.floor && len > 1) { + const at = (t) => ({ x: p.x + (q.x - p.x) * t, z: p.z + (q.z - p.z) * t }); + const hOf = (pt, st) => st !== null ? st : roofCeilAt(pt.x, pt.z); + const mk = (pt, y) => Object.assign({}, p, pt, { y, wall: null, ceil: true, floor: false, plenum: inPlenum(pt.x, pt.z, y) }); + const n = Math.max(2, Math.ceil(len / 100)); + const S = Array.from({ length: n + 1 }, (_, k) => { const pt = at(k / n), st = plenumOn(pt.x, pt.z); return { t: k / n, pt, st, y: hOf(pt, st) }; }); + for (let k = 1; k <= n; k++) { + const A = S[k - 1], B = S[k]; + if (!same(A.st, B.st)) { + let lo = A.t, hi = B.t; + for (let it = 0; it < 10; it++) { const m = (lo + hi) / 2, pm = at(m); if (same(plenumOn(pm.x, pm.z), A.st)) lo = m; else hi = m; } + const pb = at((lo + hi) / 2); + out.push(mk(pb, hOf(pb, A.st)), mk(pb, hOf(pb, B.st))); + } else if (k < n && same(B.st, S[k + 1].st) && Math.abs(A.y - 2 * B.y + S[k + 1].y) > 2) out.push(mk(B.pt, B.y)); + } + } + out.push(q); + }); + return out; + } const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; }; + const clampTo = (v, lo, hi) => lo > hi ? (lo + hi) / 2 : Math.max(lo, Math.min(hi, v)); + const segInside = (p, q) => { + const n = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 50)); + for (let k = 0; k <= n; k++) if (!unitAt(p.x + (q.x - p.x) * k / n, p.z + (q.z - p.z) * k / n)) return false; + return true; + }; + // Punt buiten de woning (gevellamp, dakrandspot): eerst loodrecht naar de dichtstbijzijnde unit. + const clampIn = (p) => { + if (unitAt(p.x, p.z)) return null; + let best = null; + units.forEach(u => { + const r = u.rect, x = clampTo(p.x, r.x0 + 1, r.x1 - 1), z = clampTo(p.z, r.z0 + 1, r.z1 - 1), d = Math.hypot(x - p.x, z - p.z); + if (!best || d < best.d) best = { x, z, d }; + }); + return best && { x: best.x, z: best.z }; + }; + // Knikpunten in de plattegrond tussen a en b (exclusief), haaks en binnen de units: bij een L- of T-vorm mag de + // knik niet in de lege hoek buiten de woning vallen. Eerst x dan z (zoals altijd), anders z dan x, anders via een unit. + function corners(a, b, viaCeiling) { + const A = clampIn(a), B = clampIn(b), pa = A || a, pb = B || b; + const cands = [[{ x: pb.x, z: pa.z }], [{ x: pa.x, z: pb.z }]]; + units.forEach(u => { + const r = u.rect, xm = clampTo((pa.x + pb.x) / 2, r.x0 + 150, r.x1 - 150), zm = clampTo((pa.z + pb.z) / 2, r.z0 + 150, r.z1 - 150); + cands.push([{ x: xm, z: pa.z }, { x: xm, z: pb.z }], [{ x: pa.x, z: zm }, { x: pb.x, z: zm }]); + }); + let best = null; + cands.forEach(c => { + const pts = [pa].concat(c, [pb]); + if (!pts.slice(1).every((q, i) => segInside(pts[i], q))) return; + let len = pts.slice(1).reduce((t, q, i) => t + Math.abs(q.x - pts[i].x) + Math.abs(q.z - pts[i].z), 0); + // Over het plafond telt op- en neergaan mee: liever door een laag vals plafond dan over de nok. + if (viaCeiling) pts.slice(1).forEach((q, i) => { + const p = pts[i], m = Math.max(1, Math.ceil(Math.hypot(q.x - p.x, q.z - p.z) / 200)); + let prev = ceilAt(p.x, p.z); + for (let k = 1; k <= m; k++) { const y = ceilAt(p.x + (q.x - p.x) * k / m, p.z + (q.z - p.z) * k / m); len += Math.abs(y - prev); prev = y; } + }); + if (!best || len < best.len - 1) best = { len, c }; + }); + return [A].concat(best ? best.c : cands[0], [B]).filter(Boolean) + .filter(q => Math.hypot(q.x - a.x, q.z - a.z) > 0.5 && Math.hypot(q.x - b.x, q.z - b.z) > 0.5); + } + const strictlyInside = (x, z) => units.some(u => x > u.rect.x0 + 5 && x < u.rect.x1 - 5 && z > u.rect.z0 + 5 && z < u.rect.z1 - 5); function path(a, b) { - const P = (p, wall, ceil, 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') { 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), - P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')]; + return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true)] + .concat(corners(a, b).map(c => P({ x: c.x, y: fa, z: c.z }, null, false, true)), + [P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')]); } if (a.face && a.face === b.face) return [P(a, a.face), P({ x: a.x, y: b.y, z: a.z }, a.face), P(b, b.face)]; - if (a.mount === 'buiten' && b.mount === 'buiten') return [P(a), P({ x: b.x, y: a.y, z: a.z }), P(b)]; - const top = (p) => p.mount === 'plafond' ? p.y : ceilAt(p.x, p.z); + // Gevellamp naar gevellamp: langs de gevel, bij een binnenhoek niet dwars door de woning. + if (a.mount === 'buiten' && b.mount === 'buiten') { + const c = strictlyInside(b.x, a.z) ? { x: a.x, z: b.z } : { x: b.x, z: a.z }; + return [P(a), P({ x: c.x, y: a.y, z: c.z }), P(b)]; + } + // Wandpunt: de plafondhoogte iets de ruimte in meten (de wand zelf hoort bij geen ruimte). + const top = (p) => p.mount === 'plafond' ? p.y : p.face && p.face.n ? ceilAt(p.x + p.face.n.x * 50, p.z + p.face.n.z * 50) : ceilAt(p.x, p.z); const ya = top(a), yb = top(b), dropB = Math.abs(yb - b.y) > 1; const pts = [P(a, a.face, a.mount === 'plafond')]; if (Math.abs(ya - a.y) > 1) pts.push(P({ x: a.x, y: ya, z: a.z }, a.face, true)); - pts.push(P({ x: b.x, y: ceilAt(b.x, a.z), z: a.z }, null, true)); + corners(a, b, true).forEach(c => pts.push(P({ x: c.x, y: ceilAt(c.x, c.z), z: c.z }, null, true))); pts.push(P({ x: b.x, y: yb, z: b.z }, dropB ? b.face : null, true)); if (dropB) pts.push(P(b, b.face, false)); - return pts; + return followCeiling(pts); } const length = (pts) => pts.slice(1).reduce((s, q, i) => s + Math.hypot(q.x - pts[i].x, q.y - pts[i].y, q.z - pts[i].z), 0); const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y); @@ -201,7 +301,7 @@ window.ElektraModel = (() => { groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: [] }; if (!s.enabled || !units.length) return out; - const R = makeRouter(units); + const R = makeRouter(units, rooms); const faces = {}; units.forEach(u => { faces[u.id] = facesOf(u, interior); }); const faceOfPiece = (u, p) => faces[u.id].find(f => f.piece === p && f.side === -1); @@ -735,13 +835,13 @@ window.ElektraModel = (() => { conduit }; } - function build3D(el, materials, stage) { + // rough: buizen en inbouwdozen (in de wand en boven het plafond); uit bij de render-export. + function build3D(el, materials, layers, rough) { const toM = 0.001, group = new THREE.Group(); if (!el || !el.enabled) return group; const mats = materials || createMaterials(); mats.plateMaterial.color.setHex(PLATE_COLORS[el.settings.plateColor].hex); - const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key); - const rough = show('elektra') && !show('beplating'); + if (!ViewLayers.shows(layers, 'elektra')) return group; const box = (w, h, d, c, rotY, mat) => { const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), mat); mesh.position.set(c.x * toM, c.y * toM, c.z * toM); @@ -750,18 +850,18 @@ window.ElektraModel = (() => { return mesh; }; const k = el.meterkast; - if (k && show('elektra')) { + if (k) { box(k.w, k.h, k.d, k.center, k.rotY, mats.kastMaterial); const front = facePoint(k.face, k.u, k.y + k.h / 2, k.d + 2); box(k.w - 30, k.h - 30, 4, front, k.rotY, mats.deurMaterial); } - if (show('afwerking')) { + if (ViewLayers.shows(layers, 'afwerking')) { el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial)); el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial)); el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial)); el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial)); } - if (!rough) return group; + if (rough === false) return group; const up = new THREE.Vector3(0, 1, 0); const shift = (p) => ({ @@ -859,7 +959,7 @@ window.ElektraModel = (() => { } else { n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length; } - } else if (!vertical && p.ceil && q.ceil) { + } else if (!vertical && p.ceil && q.ceil && !(p.plenum && q.plenum)) { const mid = { x: (p.x + q.x) / 2, z: (p.z + q.z) / 2 }, u = R.unitAt(mid.x, mid.z); if (u) u.roof.planes.forEach(pl => { const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds); diff --git a/js/floor/floor.js b/js/floor/floor.js index 3fd6257..7eed262 100644 --- a/js/floor/floor.js +++ b/js/floor/floor.js @@ -6,7 +6,10 @@ */ const FLOOR_TEMPLATE = ` -
+
+
+
+
@@ -18,7 +21,15 @@ const FLOOR_TEMPLATE = `
-
+
+
+
+
3D
+
+
+
+
+
@@ -27,6 +38,7 @@ const FLOOR_TEMPLATE = ` +
@@ -57,20 +69,6 @@ const FLOOR_TEMPLATE = `
mm
-
-
Vloerbalken-
-
Vloeroppervlak-
-
Volume balkhout-
-
Gewicht balkhout-
- -
-
OSB oppervlak-
-
OSB volume-
-
OSB gewicht-
- -
-
Isolatie-
-
Totaal gewicht-
@@ -82,9 +80,8 @@ const FLOOR_TEMPLATE = `
-
- -
+
+
@@ -99,17 +96,9 @@ const FLOOR_TEMPLATE = `
-
-
- -
-
-
3D
-
-
- +
-
+

Balken

@@ -139,6 +128,7 @@ const FLOOR_TEMPLATE = `
+
@@ -169,8 +159,8 @@ const FLOOR_TEMPLATE = `
@@ -285,16 +275,14 @@ function mountFloorEditor(container, callbacks) { document.querySelectorAll('#floor-tab-main input').forEach(inp => inp.addEventListener('input', updateAll)); function syncChassisToggleLabel() { - const btn = document.getElementById('f-btn-chassis-toggle'); - btn.classList.toggle('active', chassis.enabled); - btn.textContent = 'Chassis-laag: ' + (chassis.enabled ? 'Aan' : 'Uit'); + document.getElementById('f-chassis-on').checked = chassis.enabled; } - document.getElementById('f-btn-chassis-toggle').addEventListener('click', () => { - chassis.enabled = !chassis.enabled; + document.getElementById('f-chassis-on').addEventListener('change', (e) => { + chassis.enabled = e.target.checked; syncChassisToggleLabel(); updateAll(); }); - document.querySelectorAll('#floor-tab-chassis input').forEach(inp => inp.addEventListener('input', () => { + document.querySelectorAll('#floor-tab-chassis input[type=number]').forEach(inp => inp.addEventListener('input', () => { chassis.wheelDiameter = parseFloat(document.getElementById('f-inp-wheel-d').value) || 400; chassis.trackWidth = parseFloat(document.getElementById('f-inp-track').value) || 1200; chassis.wheelbase = parseFloat(document.getElementById('f-inp-wheelbase').value) || 1200; @@ -632,11 +620,14 @@ function mountFloorEditor(container, callbacks) { let lastResolvedOpenings = []; 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(); + const toolbar = document.getElementById('floor-toolbar'); + toolbar.innerHTML = `
${UI.tbSep()}${UI.viewSeg('split')}`; + toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); + const viewGroup = toolbar.querySelector('.tb-view'); + ViewLayers.createMenu(viewGroup, { + layers: {}, keys: [], + extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }], + onExtra: (v) => { osbVisible = v.beplating; update3DScene(); } }); let skyMode = Skybox.getMode(); const skyEnv = Skybox.createEnvironment(scene); @@ -644,7 +635,7 @@ function mountFloorEditor(container, callbacks) { skyEnv.fitTo(floorGroup3D, 0); Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient: floorAmbient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: floorGroup3D }); }; - Skybox.createToggle(document.getElementById('floor-toolbar-3d'), (m) => { skyMode = m; applySky(); }); + Skybox.createToggle(viewGroup, (m) => { skyMode = m; applySky(); }); function update3DScene() { if (floorGroup3D) scene.remove(floorGroup3D); @@ -691,12 +682,8 @@ function mountFloorEditor(container, callbacks) { } window.addEventListener('resize', onWindowResize); - function toggleFullscreen3D() { - UI.cycle3D(document.getElementById('floor-app')); - requestAnimationFrame(onWindowResize); - } - function toggleFullscreen2D() { - UI.toggle2D(document.getElementById('floor-app')); + function setView(mode) { + UI.setView(document.getElementById('floor-app'), mode); requestAnimationFrame(onWindowResize); } @@ -748,8 +735,8 @@ function mountFloorEditor(container, callbacks) { }, exportDesign, triggerImport, - toggleFullscreen3D, - toggleFullscreen2D, + setView, + viewState: () => ({ view: UI.viewOf(document.getElementById('floor-app')) }), openProjectProperties, getStateJson: getPureState, loadStateJson: applyPureState diff --git a/js/house/house-ifc.js b/js/house/house-ifc.js index e999abb..ead4e42 100644 --- a/js/house/house-ifc.js +++ b/js/house/house-ifc.js @@ -49,19 +49,19 @@ window.HouseIfc = (() => { }; } - function build(state, stage) { + function build(state, layers) { const M = materialsSet(); const parts = HouseModel.computeParts(state, { wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post, - stage, lampenVisible: false + layers: Object.assign(layers, { lampen: false }) }); HouseModel.placeGroups(parts); return { parts, classify: classifier(M) }; } function generate(state) { - const frame = build(state, BuildStages.INDEX.elektra); - const sheet = build(state, BuildStages.INDEX.beplating); + const frame = build(state, ViewLayers.upTo(1)); + const sheet = build(state, ViewLayers.upTo(2)); const parts = frame.parts; if (!parts.units.length) throw new Error('geen units in het ontwerp'); const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1)); diff --git a/js/house/house-model.js b/js/house/house-model.js index 0510230..8720e38 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -154,6 +154,7 @@ window.HouseModel = (() => { S.lighting.mode = 'gevel'; S.lighting.direction = 'both'; if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true }); + if (S.elektra) S.elektra.plateColor = 'antraciet'; const A = createUnitEntry('A', null, { width: 11240, depth: 3600, height: H, windowOn: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10600, depth: 3600, height: H, windowOn: [] }); @@ -243,6 +244,7 @@ window.HouseModel = (() => { S.lighting.mode = 'gevel'; S.lighting.direction = 'both'; if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true }); + if (S.elektra) S.elektra.plateColor = 'antraciet'; const A = createUnitEntry('A', null, { width: W, depth: DA, height: H, windowOn: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: DB, height: H, windowOn: [] }); @@ -309,6 +311,7 @@ window.HouseModel = (() => { S.lighting.mode = 'spots'; S.lighting.direction = 'both'; if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true }); + if (S.elektra) S.elektra.plateColor = 'antraciet'; const A = createUnitEntry('A', null, { width: 14000, depth: D, height: LO, windowOn: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 4600, depth: 3000, height: LO, windowOn: [] }); @@ -383,6 +386,7 @@ window.HouseModel = (() => { S.lighting.mode = 'spots'; S.lighting.direction = 'both'; if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true }); + if (S.elektra) S.elektra.plateColor = 'antraciet'; const A = createUnitEntry('A', null, { width: W, depth: D, height: LO, windowOn: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: W, depth: D, height: LO, windowOn: [] }); @@ -473,6 +477,7 @@ window.HouseModel = (() => { S.lighting.mode = 'spots'; S.lighting.direction = 'both'; if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true }); + if (S.elektra) S.elektra.plateColor = 'antraciet'; const A = createUnitEntry('A', null, { width: 9200 - V, depth: D, height: LO, windowOn: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 9200, depth: D, height: LO, windowOn: [] }); @@ -567,6 +572,7 @@ window.HouseModel = (() => { S.lighting.mode = 'spots'; S.lighting.direction = 'both'; if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true }); + if (S.elektra) S.elektra.plateColor = 'antraciet'; const A = createUnitEntry('A', null, { width: 13000, depth: D, height: LO, windowOn: [] }); const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 13000, depth: D, height: LO, windowOn: [] }); @@ -1231,8 +1237,9 @@ window.HouseModel = (() => { function computeParts(state, opts) { const o = opts || {}; const build = !o.skip3D; - const stage = BuildStages.clamp(o.stage); - const shows = (key) => BuildStages.shows(stage, key); + const layers = ViewLayers.normalize(o.layers); + const shows = (key) => layers[key]; + const hideFrame = (group, mats) => { if (group && mats && !shows('regelwerk')) ViewLayers.hideByMaterial(group, [mats.woodMaterial, mats.steelMaterial]); }; applyGlobalSettings(state); const rects = unitRects(state); const rectById = {}; @@ -1256,7 +1263,7 @@ window.HouseModel = (() => { if (!build) return { group: null, stats: null, specs: null }; const built = WallModel.build3D(config, frame, { materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, - painted: state.settings.interiorPaint && shows('afwerking'), stage, rabatColor: state.settings.facade.rabatColor + painted: state.settings.interiorPaint && shows('afwerking'), layers, rabatColor: state.settings.facade.rabatColor }); return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) }; } @@ -1301,9 +1308,10 @@ window.HouseModel = (() => { const floorData = FloorModel.computeFromState(u.unit.floor, rims); const floorSpecs = FloorModel.computeSpecs(floorData.calcData, floorData.resolvedOpenings); const floorGroup = build ? FloorModel.build3D(floorData.calcData, floorData.resolvedOpenings, floorData.chassisData, { - materials: o.floorMaterials, osbVisible: shows('beplating'), + materials: o.floorMaterials, osbVisible: shows('beplating'), outerJoistsOnly: !!o.finishedOnly && shows('beplating'), finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null }) : null; + hideFrame(floorGroup, o.floorMaterials); const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl)); @@ -1337,9 +1345,10 @@ window.HouseModel = (() => { const roofGroup = build ? RoofModel.buildUnit3D(ur, { materials: o.roofMaterials, osbVisible: shows('beplating'), - dakleerVisible: shows('gevel'), + dakleerVisible: shows('dakbedekking'), ceilingMaterial: state.settings.interiorPaint && shows('afwerking') && o.roofMaterials ? o.roofMaterials.ceilingMaterial : null }) : null; + hideFrame(roofGroup, o.roofMaterials); const ext = coverExt[u.id]; if (roofGroup && ext && shows('afwerking')) { const fz = rect.z0 - ext.f, bz = rect.z1 + ext.b; @@ -1683,16 +1692,16 @@ window.HouseModel = (() => { const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge); if (roofEdge && chimneys.length) roofEdge.spots = roofEdge.spots.filter(sp => !ChimneyModel.covers(chimneys, sp.x, sp.z, 60)); const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, { - edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking') + edgeVisible: shows('afwerking'), spotsVisible: shows('lampen') }) : null; lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior); LightingModel.roomSpots(state.settings.lighting, lighting.indoor, rooms, units); RoomModel.lowerSpots(rooms, lighting.indoor); const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null; - if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false && shows('afwerking'); + if (lightingGroup) lightingGroup.visible = shows('lampen'); const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge, rooms); - const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null; - if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra'); + const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, layers, o.elektraRough) : null; + if (elektraGroup) elektraGroup.visible = shows('elektra'); const posts = PostModel.compute(state.settings.roof.posts, units, groundY); const postsGroup = build ? PostModel.build3D(posts, o.postMaterials) : null; @@ -1708,10 +1717,10 @@ window.HouseModel = (() => { }) : null; const roomsGroup = build ? RoomModel.build3D(rooms) : null; - if (roomsGroup) roomsGroup.visible = shows('afwerking'); + if (roomsGroup) roomsGroup.visible = shows('ruimtes') && shows('afwerking'); const fixtures = FixtureModel.compute(state.fixtures || [], units); const fixturesGroup = build ? FixtureModel.build3D(fixtures) : null; - if (fixturesGroup) fixturesGroup.visible = shows('afwerking'); + if (fixturesGroup) fixturesGroup.visible = shows('inrichting'); return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup }; } diff --git a/js/house/house.js b/js/house/house.js index 4feac08..693b537 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -7,13 +7,18 @@ const HOUSE_TEMPLATE = `
- -
+
+ +
+
+
+ + +
@@ -36,8 +41,8 @@ const HOUSE_TEMPLATE = `
Geldt voor elk blad van de woning-PDF. Titel en elementnummer worden per blad automatisch ingevuld.
@@ -45,22 +50,31 @@ const HOUSE_TEMPLATE = ` const HOUSE_OVERVIEW_TEMPLATE = `
+
+
PLATTEGROND
-
+
+ + + +
100%
+
3D
+
+
`; -const SPACING_LOCK_MSG = 'De h.o.h. is globaal ingesteld voor alle wanden van de woning (Overzicht, niets geselecteerd).'; +const SPACING_LOCK_MSG = 'De h.o.h. is globaal ingesteld voor alle wanden van de woning (eigenschappen Woning › Constructie, niets geselecteerd).'; const HOUSE_PROJECT_KEYS = ['naam', 'plaats', 'straat', 'nummer', 'tekenaar', 'opmerking']; const HOUSE_SIDES = ['voor', 'achter', 'links', 'rechts']; @@ -79,7 +93,7 @@ function mountHouseEditor(container, callbacks) { let activeWallLayout = null; let selection = null; let pendingTool = null; - const view3D = { stage: BuildStages.FINAL, section: false, sectionMM: 1000, dak: true, lampen: true, elektra: true, ruimtes: true, inrichting: true }; + const view3D = { layers: ViewLayers.all(), section: false, sectionMM: 1000, mode: 'split', report: false }; let fillType = 'woonkamer'; let fixtureType = 'toilet'; const camKey = (part) => `${(callbacks && callbacks.cameraKey) || 'woning'}:${part}`; @@ -134,8 +148,8 @@ function mountHouseEditor(container, callbacks) { cameraKey: camKey(part), lockWidth: 'Binnen een woning volgt de wandbreedte uit de vloer van de unit.', lockSpacing: SPACING_LOCK_MSG, - lockCladding: 'De richting van de gevelbekleding is (voorlopig) globaal: Overzicht, niets geselecteerd.', - lockExterior: 'De gevelbekleding volgt de woning (Overzicht › Gevel); per wand aan te passen via de wand in de plattegrond.', + lockCladding: 'De richting van de gevelbekleding is (voorlopig) globaal: eigenschappen Woning › Gevel, niets geselecteerd.', + lockExterior: 'De gevelbekleding volgt de woning (eigenschappen Woning › Gevel); per wand aan te passen via de wand in de plattegrond.', seamCuts: wl.cutsU, lamps: [].concat(...wl.pieces.map(p => p.lamps.map(l => ({ u: p.u0 + l.u, y: l.y })))) }); @@ -150,100 +164,118 @@ function mountHouseEditor(container, callbacks) { activeEditor = FloorEditor.mount(host, { mode: 'roof', cameraKey: camKey(part), - lockSize: u.roof.followFloor ? 'Dit dak volgt de vloermaat. Zet "Dak volgt vloer" uit bij de unit (overzicht) om de maat aan te passen.' : null + lockSize: u.roof.followFloor ? 'Dit dak volgt de vloermaat. Zet "Dak volgt vloer" uit bij de unit (woning, unit selecteren) om de maat aan te passen.' : null }); activeEditor.loadStateJson(HouseModel.clone(u.roof.deck)); } else if (kind === 'iw' && iwById(Number(a))) { HouseModel.applyGlobalSettings(state); if (iwById(Number(a)).seam) HouseModel.computeParts(state, { skip3D: true }); else HouseModel.interiorLayout(iwById(Number(a))); - activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'De lengte van een binnenwand volgt uit de tekening (overzicht).', lockSpacing: SPACING_LOCK_MSG }); + activeEditor = WallEditor.mount(host, { cameraKey: camKey(part), lockWidth: 'De lengte van een binnenwand volgt uit de tekening in de plattegrond van de woning.', lockSpacing: SPACING_LOCK_MSG }); activeEditor.loadStateJson(HouseModel.clone(iwById(Number(a)).wall)); } else { activePart = 'overzicht'; activeEditor = mountOverview(); return; } - setPartRibbon(kind, a, b); + setPartToolbar(kind, a, b); renderNav(); } - function setPartRibbon(kind, a, b) { - const { big } = Ribbon; + // Onderdeel-editor binnen de woning: terug-knop en naam vooraan in de werkbalk van die editor. + function setPartToolbar(kind, a, b) { const what = kind === 'floor' ? `Vloer unit ${a}` : kind === 'roof' ? `Dak unit ${a}` : kind === 'wall' ? `${(UnitModel.WALL_SIDES.find(sd => sd.key === b) || {}).label || 'Wand'} unit ${a}` : (iwById(Number(a)) && iwById(Number(a)).seam ? 'Naadwand' : 'Binnenwand'); - Ribbon.setSource('editor', [{ - key: 'start', label: 'Start', order: 10, - groups: () => [ - { label: 'Navigatie', html: big('Terug naar woning', 'arrow-left-circle', 'data-hp="back" title="Overzicht van de woning"') }, - { label: 'Onderdeel', html: `
${what}
Wijzigingen gelden direct voor de woning. Hoogtes van een wand bepalen de dakvorm van de unit (de overstaande wand spiegelt mee).
` }, - { label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-hp="add-unit"') + big('Binnenwand tekenen', 'pencil', 'data-hp="draw-iw"') } - ], - wire: (body) => body.querySelectorAll('[data-hp]').forEach(btn => btn.addEventListener('click', () => { - if (btn.dataset.hp === 'back') showPart('overzicht'); - else if (btn.dataset.hp === 'add-unit') addUnit(); - else drawInteriorWall(); - })) - }]); - Ribbon.activate('start'); + const bar = host.querySelector('.ed-toolbar'); + if (bar) { + bar.insertAdjacentHTML('afterbegin', `
${UI.tb('Woning', 'arrow-left', 'data-hp="back" title="Terug naar de woning (Esc)"')}${what}
${UI.tbSep()}`); + bar.querySelector('[data-hp="back"]').addEventListener('click', () => showPart('overzicht')); + } + setHint('Wijzigingen gelden direct voor de woning. Hoogtes van een wand bepalen de dakvorm van de unit (de overstaande wand spiegelt mee).'); } + function setHint(text) { document.getElementById('ho-hint').textContent = text; } + // Structuur: groepen in-/uitklapbaar (onthouden); een groep met het actieve onderdeel of de selectie klapt vanzelf open. + const NAV_PREF = 'tectum.nav.closed'; + let navClosed; + try { navClosed = new Set(JSON.parse(localStorage.getItem(NAV_PREF) || 'null') || ['iw', 'rooms', 'fixtures']); } catch (e) { navClosed = new Set(['iw', 'rooms', 'fixtures']); } function renderNav(parts) { parts = parts || HouseModel.computeParts(state, { skip3D: true }); + const inOverview = activePart === 'overzicht'; const item = (part, label, meta, cls, iconName, active) => `
${iconName ? UI.icon(iconName) : ''}${label}${meta ? `${meta}` : ''}
`; - let html = item('overzicht', 'Woning', `${parts.units.length} unit${parts.units.length === 1 ? '' : 's'}`, 'hn-top', 'house-door', - activePart === 'overzicht' && !selection); + const chevron = (key) => ``; + const group = (key, headHtml, body, hasActive) => + `
${headHtml}
${body}
`; + const head = (key, iconName, label, meta) => + `
${chevron(key)}${UI.icon(iconName)}${label}${meta ? `${meta}` : ''}
`; + const [pk, pa] = (activePart || '').split(':'); + let html = item('overzicht', 'Woning', `${parts.units.length} unit${parts.units.length === 1 ? '' : 's'}`, 'hn-top', 'house-door', inOverview && !selection); parts.units.forEach(u => { - const sel = selection && selection.type === 'unit' && selection.id === u.id && activePart === 'overzicht'; - html += `
`; - html += `
${UI.icon('box')}Unit ${u.id}${u.rect.W} × ${u.rect.D}
`; - html += item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub', 'grid-3x3'); - html += item(`roof:${u.id}`, 'Dak', u.roof.element, 'hn-sub', 'house'); + const sel = selection && selection.type === 'unit' && selection.id === u.id && inOverview; + let body = item(`floor:${u.id}`, 'Vloer', u.floorElement, 'hn-sub', 'grid-3x3') + item(`roof:${u.id}`, 'Dak', u.roof.element, 'hn-sub', 'house'); u.walls.forEach(w => { const meta = w.removed ? 'vervalt' : w.pieces.map(p => p.element).join(', '); - html += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : ''), 'bricks'); + body += item(`wall:${u.id}:${w.side}`, w.label, meta, 'hn-sub' + (w.removed ? ' hn-removed' : ''), 'bricks'); }); - html += `
`; + const wallSel = inOverview && selection && selection.type === 'wall' && selection.unit === u.id; + html += group('unit:' + u.id, + `
${chevron('unit:' + u.id)}${UI.icon('box')}Unit ${u.id}${u.rect.W} × ${u.rect.D}
`, + body, (['floor', 'roof', 'wall'].includes(pk) && pa === u.id) || wallSel); }); if (parts.interior.length) { - html += `
${UI.icon('distribute-horizontal')}Binnenwanden
`; + let body = ''; const seen = new Set(); parts.interior.forEach(p => { if (seen.has(p.id)) return; seen.add(p.id); if (p.seam) { const els = parts.interior.filter(x => x.id === p.id).map(x => x.element).join(', '); - html += item(`iw:${p.id}`, `Naadwand ${p.seam.a}|${p.seam.b}`, els, 'hn-sub', 'layout-split'); - } else html += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub', 'distribute-horizontal'); + body += item(`iw:${p.id}`, `Naadwand ${p.seam.a}|${p.seam.b}`, els, 'hn-sub', 'layout-split'); + } else body += item(`iw:${p.id}`, p.element, `${p.place.width} mm`, 'hn-sub', 'distribute-horizontal'); }); - html += `
`; + html += group('iw', head('iw', 'distribute-horizontal', 'Binnenwanden', seen.size), body, + pk === 'iw' || (inOverview && selection && selection.type === 'iw')); } const R = parts.rooms; if (R && (R.rooms.length || R.free.length)) { const total = R.rooms.concat(R.free).reduce((a, r) => a + r.areaM2, 0); - html += `
${UI.icon('bounding-box')}Ruimtes${houseFmt(total, 1)} m²
`; + let body = ''; R.rooms.forEach(r => { - const act = selection && selection.type === 'room' && selection.id === r.id && activePart === 'overzicht'; - html += `
${UI.icon('square-half')}${UI.esc(r.name)}${houseFmt(r.areaM2, 2)} m²
`; + const act = selection && selection.type === 'room' && selection.id === r.id && inOverview; + body += `
${UI.icon('square-half')}${UI.esc(r.name)}${houseFmt(r.areaM2, 2)} m²
`; }); - if (R.free.length) html += `
${R.free.length} zonder functie${houseFmt(R.free.reduce((a, r) => a + r.areaM2, 0), 1)} m²
`; - html += `
`; + if (R.free.length) body += `
${R.free.length} zonder functie${houseFmt(R.free.reduce((a, r) => a + r.areaM2, 0), 1)} m²
`; + html += group('rooms', head('rooms', 'bounding-box', 'Ruimtes', `${houseFmt(total, 1)} m²`), body, + inOverview && selection && selection.type === 'room'); } if (parts.fixtures && parts.fixtures.length) { - html += `
${UI.icon('lamp')}Inrichting${parts.fixtures.length}
`; + let body = ''; parts.fixtures.forEach(f => { - const act = selection && selection.type === 'fixture' && selection.id === f.id && activePart === 'overzicht'; - html += `
${UI.icon('dot')}${UI.esc(f.label)}${f.unitId}
`; + const act = selection && selection.type === 'fixture' && selection.id === f.id && inOverview; + body += `
${UI.icon('dot')}${UI.esc(f.label)}${f.unitId}
`; }); - html += `
`; + html += group('fixtures', head('fixtures', 'lamp', 'Inrichting', parts.fixtures.length), body, + inOverview && selection && selection.type === 'fixture'); } - document.getElementById('house-nav-tree').innerHTML = html; + const tree = document.getElementById('house-nav-tree'); + tree.innerHTML = html; + const act = tree.querySelector('.hn-item.active'); + if (act) act.scrollIntoView({ block: 'nearest' }); } document.getElementById('house-nav-tree').addEventListener('click', (e) => { + const tg = e.target.closest('[data-nav-toggle]'); + if (tg && (tg.classList.contains('hn-toggle') || tg.classList.contains('hn-head'))) { + const key = tg.dataset.navToggle, g = tg.closest('.hn-group'); + const open = !g.classList.contains('open'); + g.classList.toggle('open', open); + if (open) navClosed.delete(key); else navClosed.add(key); + try { localStorage.setItem(NAV_PREF, JSON.stringify([...navClosed])); } catch (err) { } + return; + } const roomRow = e.target.closest('[data-select-room]'); const unitRow = e.target.closest('[data-select-unit]'); const fxRow = e.target.closest('[data-select-fixture]'); @@ -295,6 +327,7 @@ function mountHouseEditor(container, callbacks) { function mountOverview() { host.innerHTML = HOUSE_OVERVIEW_TEMPLATE; + UI.setView(document.getElementById('house-overview'), view3D.mode); const canvas = document.getElementById('ho-canvas'); const ctx = canvas.getContext('2d'); @@ -340,7 +373,7 @@ function mountHouseEditor(container, callbacks) { const toM = 0.001, g = new THREE.Group(); const S = WallModel.STONE; // Latwerk is open: pas vanaf de gevel afdekken. - const gevel = BuildStages.shows(view3D.stage, 'gevel'), beplating = BuildStages.shows(view3D.stage, 'beplating'); + const gevel = view3D.layers.gevel, beplating = view3D.layers.beplating; HouseModel.allWallPieces(parts).forEach(p => { const y = cutAbs - p.place.floorTop; if (y <= 0 || y >= WallModel.topLine(p.config).maxY) return; @@ -377,30 +410,85 @@ function mountHouseEditor(container, callbacks) { let skyMode = Skybox.getMode(); const skyEnv = Skybox.createEnvironment(scene); - const stageBar = BuildStages.createBar(pane3D, view3D.stage, (st) => { - view3D.stage = st; - scheduleRefresh({ keepProps: true }); + // Werkbalk: gereedschap links (met opties van het actieve gereedschap), weergave rechts. + const toolbar = document.getElementById('ho-toolbar'); + const TOOLS = [ + ['select', 'Selecteren', 'cursor', 'V', 'Klik = selecteren, dubbelklik = bewerken'], + ['iw', 'Binnenwand', 'pencil', 'W', 'Twee klikken in de plattegrond; snapt op 50 mm, haaks en op wandvlakken'], + ['room', 'Ruimte', 'paint-bucket', 'R', 'Functie kiezen en in een ruimte klikken; de ruimte loopt tot de wanden'], + ['fixture', 'Inrichting', 'lamp', 'I', 'Stuk kiezen en in de plattegrond klikken (D = draaien)'] + ]; + toolbar.innerHTML = `
${TOOLS.map(([k, label, ic, key, hint]) => + UI.tb(label, ic, `data-ho-tool="${k}" title="${label} (${key}): ${hint}"`)).join('')}
+ ${UI.tbSep()}${UI.tb('Unit', 'plus-square', 'data-tb="add-unit" title="Unit toevoegen aan de geselecteerde (of laatste) unit"')} +
+
+
+
+ ${UI.tb('Doorsnede', 'scissors', 'data-tb="section" title="Horizontale doorsnede (X)"')} + +
+ ${UI.tb('', 'camera', 'data-tb="render" title="Render: elke lamp en spot als echte lichtbron"')} + ${UI.tb('', 'person-walking', 'data-tb="walk" title="Lopen (L): W/S vooruit/achteruit, A/D opzij, slepen = rondkijken, E = deur/schakelaar"')} + ${UI.tbSep()} + ${UI.tb('Rapport', 'clipboard-data', 'data-tb="report" title="Rapport in plaats van de plattegrond (4): transport, gewicht, kosten, hout en plaatwerk"')} + ${UI.viewSeg(UI.viewOf(document.getElementById('house-overview')))}`; + toolbar.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool))); + toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); + toolbar.addEventListener('click', (e) => { + const b = e.target.closest('[data-tb]'); + if (!b) return; + const a = b.dataset.tb; + if (a === 'add-unit') addUnit(); + else if (a === 'section') toggleSection(); + else if (a === 'render') { fullRender = !fullRender; syncToolbar(); applyRenderMode(); } + else if (a === 'walk') setWalk(!walk.active); + else if (a === 'report') toggleReport(); + b.blur(); }); - stageBar.extra.innerHTML = `Doorsnede - - `; + const viewGroup = document.getElementById('ho-view-group'); + const layerMenu = ViewLayers.createMenu(viewGroup, { layers: view3D.layers, onChange: setLayers }); + Skybox.createToggle(viewGroup, (m) => { skyMode = m; applyRenderMode(); }); + function syncToolbar() { + const q = (a) => toolbar.querySelector(`[data-tb="${a}"]`); + q('section').classList.toggle('active', view3D.section); + q('render').classList.toggle('active', fullRender); + q('walk').classList.toggle('active', !!(walk && walk.active)); + q('report').classList.toggle('active', reportOpen); + document.getElementById('ho-section-group').classList.toggle('on', view3D.section); + const mode = UI.viewOf(document.getElementById('house-overview')); + toolbar.querySelectorAll('[data-view]').forEach(b => b.classList.toggle('active', b.dataset.view === mode)); + } + + // Eén set lagen voor 3D en plattegrond. + function setLayers(l) { + view3D.layers = ViewLayers.normalize(l); + const L = view3D.layers; + if (selection && ((!L.ruimtes && (selection.type === 'room' || selection.type === 'region')) || + (!L.inrichting && selection.type === 'fixture') || (!L.elektra && selection.type === 'elektra'))) selection = null; + layerMenu.set(L); + scheduleRefresh({ keepProps: true }); + } const sectionInput = document.getElementById('ho-section'); function syncSectionRow() { - stageBar.extra.classList.toggle('show', view3D.section); document.getElementById('ho-section-val').textContent = `${view3D.sectionMM} mm`; + syncToolbar(); } function setSectionMM(v) { if (!isFinite(v)) return; view3D.sectionMM = Math.max(50, Math.min(8000, Math.round(v))); sectionInput.value = view3D.sectionMM; - UI.setRange('hv-section-h', view3D.sectionMM); syncSectionRow(); if (!parts || !view3D.section) return; placeSection(); } + function toggleSection() { + view3D.section = !view3D.section; + syncSectionRow(); + scheduleRefresh({ keepProps: true }); + } sectionInput.addEventListener('input', () => setSectionMM(Number(sectionInput.value))); sectionInput.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) sectionInput.blur(); }); - syncSectionRow(); function placeSection() { const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop)); cutPlane.constant = (floorTop + view3D.sectionMM) * 0.001; @@ -425,8 +513,8 @@ function mountHouseEditor(container, callbacks) { grid.visible = skyMode === 'none' && !fullRender; disposeLights(); if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !lampsOn; }); - if (lampsOn && parts && view3D.lampen && !view3D.section && BuildStages.shows(view3D.stage, 'afwerking')) { - const edge = view3D.dak ? parts.roofEdge : null; + if (lampsOn && parts && view3D.layers.lampen && !view3D.section) { + const edge = view3D.layers.dak ? parts.roofEdge : null; lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, parts.lighting.indoor, { outdoorFactor: skyMode === 'night' ? 1 : LightingModel.DAY_OUTDOOR_FACTOR, walls: parts.units.flatMap(u => u.walls.flatMap(w => w.pieces)), @@ -439,6 +527,8 @@ function mountHouseEditor(container, callbacks) { let parts = null; let view = { scale: 1, ox: 0, oy: 0 }; + // Zoom ten opzichte van passend; cx/cz = midden van het beeld (mm), null = midden van de woning. + const plan2D = { zoom: 1, cx: null, cz: null }; const toPx = (x, z) => ({ x: view.ox + x * view.scale, y: view.oy + z * view.scale }); const toWorld = (mx, my) => ({ x: (mx - view.ox) / view.scale, z: (my - view.oy) / view.scale }); @@ -452,7 +542,7 @@ function mountHouseEditor(container, callbacks) { function elektraHit(x, z) { const el = parts && parts.elektra; - if (!el || !el.enabled || !view3D.elektra) return null; + if (!el || !el.enabled || !view3D.layers.elektra) return null; const tol = 10 / view.scale; let best = null; el.items.forEach(it => { @@ -472,7 +562,7 @@ function mountHouseEditor(container, callbacks) { function hitTest(x, z) { const eh = elektraHit(x, z); if (eh) return { type: 'elektra', id: eh.key }; - const ch = ChimneyModel.hit(parts.chimneys || [], x, z, 0); + const ch = view3D.layers.gevel && ChimneyModel.hit(parts.chimneys || [], x, z, 0); if (ch && chimneyWallHit(ch)) return chimneyWallHit(ch); const iw = parts.interior.find(p => pieceHit(p.place, x, z, 4)); if (iw) return { type: 'iw', id: iw.id }; @@ -484,7 +574,7 @@ function mountHouseEditor(container, callbacks) { } const fx = fixtureHit(x, z); if (fx) return { type: 'fixture', id: fx.id }; - if (view3D.ruimtes && parts.rooms) { + if (view3D.layers.ruimtes && parts.rooms) { const rm = parts.rooms.roomAt(x, z); if (rm) return { type: 'room', id: rm.id }; if (parts.rooms.freeAt(x, z)) return { type: 'region', x: Math.round(x), z: Math.round(z) }; @@ -492,7 +582,7 @@ function mountHouseEditor(container, callbacks) { return unitHit(x, z); } function fixtureHit(x, z) { - if (!parts || !view3D.inrichting || !parts.fixtures) return null; + if (!parts || !view3D.layers.inrichting || !parts.fixtures) return null; return FixtureModel.hit(parts.fixtures, x, z, 3 / view.scale); } const fixtureState = (id) => (state.fixtures || []).find(f => f.id === id); @@ -560,7 +650,7 @@ function mountHouseEditor(container, callbacks) { const sameSel = (a, b) => a && b && a.type === b.type && (a.id === b.id) && (a.element === b.element); function select(sel) { selection = sel; - syncRibbon(true); + renderInspector(); drawPlan(); renderNav(parts); } @@ -580,6 +670,7 @@ function mountHouseEditor(container, callbacks) { canvas.style.cursor = t === 'iw' || t === 'fixture' ? 'crosshair' : t === 'room' ? 'cell' : 'default'; roomHover = null; fxHover = null; + renderToolOptions(); updateHint(); drawPlan(); } @@ -696,7 +787,7 @@ function mountHouseEditor(container, callbacks) { if (tool !== 'select' || !parts || e.button !== 0 || elDrag || fxDrag) return; const r = canvas.getBoundingClientRect(); const w = toWorld(e.clientX - r.left, e.clientY - r.top); - const ch = ChimneyModel.hit(parts.chimneys || [], w.x, w.z, 0); + const ch = view3D.layers.gevel && ChimneyModel.hit(parts.chimneys || [], w.x, w.z, 0); if (ch) chDrag = { id: ch.id, sx: e.clientX, sy: e.clientY, moved: false, snap: null }; }); canvas.addEventListener('mousemove', (e) => { @@ -790,30 +881,95 @@ function mountHouseEditor(container, callbacks) { else if (hit.type === 'iw') showPart(`iw:${hit.id}`); else if (hit.type === 'unit') showPart(`floor:${hit.id}`); }); - function onKey(e) { - if (['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; - if (e.key === 'Escape') { - if (drawStart) { drawStart = null; updateHint(); drawPlan(); } else if (tool === 'iw' || tool === 'room' || tool === 'fixture') setTool('select'); - } else if (e.key.toLowerCase() === 'd' && !walk.active && !e.ctrlKey && !e.altKey && !e.metaKey && (tool === 'fixture' || (selection && selection.type === 'fixture'))) { - const deg = e.shiftKey ? -90 : 90; - if (tool === 'fixture') { if (fxHover) { fxHover.rot = ((fxHover.rot + deg) % 360 + 360) % 360; drawPlan(); } } - else rotateFixture(selection.id, deg); - } else if (e.key === 'Delete' && selection && selection.type === 'fixture') { - removeFixture(selection.id); - } else if (e.key.toLowerCase() === 'e' && walk.active && !e.ctrlKey && !e.altKey && !e.metaKey) { - interact(); - } else if (e.key === 'Delete' && selection && selection.type === 'room') { - removeRoom(selection.id); - } else if (e.key === 'Delete' && selection && selection.type === 'elektra' && selection.id !== 'mk') { - removeSelectedElektra(); - } else if (e.key === 'Delete' && selection && selection.type === 'iw') { + const TOOL_KEYS = { v: 'select', w: 'iw', r: 'room', i: 'fixture' }; + function deleteSelection() { + if (!selection) return; + if (selection.type === 'fixture') removeFixture(selection.id); + else if (selection.type === 'room') removeRoom(selection.id); + else if (selection.type === 'elektra' && selection.id !== 'mk') removeSelectedElektra(); + else if (selection.type === 'iw') { state.interiorWalls = state.interiorWalls.filter(w => w.id !== selection.id); selection = null; refresh(); } } + function onKey(e) { + if (['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; + const k = e.key.toLowerCase(), plain = !e.ctrlKey && !e.altKey && !e.metaKey; + if (e.key === 'Escape') { + if (walk.active) setWalk(false); + else if (drawStart) { drawStart = null; updateHint(); drawPlan(); } + else if (tool !== 'select') setTool('select'); + else if (selection) select(null); + } else if (walk.active) { + if (k === 'e' && plain) interact(); + } else if (plain && !e.shiftKey && TOOL_KEYS[k]) { + setTool(TOOL_KEYS[k]); + } else if (k === 'd' && plain && (tool === 'fixture' || (selection && selection.type === 'fixture'))) { + const deg = e.shiftKey ? -90 : 90; + if (tool === 'fixture') { if (fxHover) { fxHover.rot = ((fxHover.rot + deg) % 360 + 360) % 360; drawPlan(); } } + else rotateFixture(selection.id, deg); + } else if ((e.key === 'Delete' || e.key === 'Backspace') && plain && selection) { + e.preventDefault(); + deleteSelection(); + } + } window.addEventListener('keydown', onKey); + const zoomVal = document.getElementById('ho-zoom-val'); + function zoomPlanAt(px, py, factor) { + const k = Math.max(0.5, Math.min(25, plan2D.zoom * factor)); + if (!parts || k === plan2D.zoom) return; + const p = toWorld(px, py), s1 = view.scale * k / plan2D.zoom; + plan2D.zoom = k; + plan2D.cx = (canvas.width / 2 - (px - p.x * s1)) / s1; + plan2D.cz = (canvas.height / 2 - (py - p.z * s1)) / s1; + zoomVal.textContent = `${Math.round(k * 100)}%`; + drawPlan(); + } + function fitPlan() { + Object.assign(plan2D, { zoom: 1, cx: null, cz: null }); + zoomVal.textContent = '100%'; + drawPlan(); + } + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const r = canvas.getBoundingClientRect(); + zoomPlanAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-Math.max(-300, Math.min(300, e.deltaY * (e.deltaMode === 1 ? 33 : 1))) * 0.002)); + }, { passive: false }); + host.querySelector('.plan-zoom').addEventListener('click', (e) => { + const b = e.target.closest('[data-zoom]'); + if (!b) return; + b.blur(); + if (b.dataset.zoom === 'fit') fitPlan(); + else zoomPlanAt(canvas.width / 2, canvas.height / 2, b.dataset.zoom === 'in' ? 1.4 : 1 / 1.4); + }); + // Verschuiven: middelste muisknop overal, linksslepen op een plek zonder sleepbaar onderdeel. + let pan = null; + canvas.addEventListener('mousedown', (e) => { + if (!parts) return; + const mid = e.button === 1; + if (!mid && (e.button !== 0 || tool !== 'select' || elDrag || fxDrag || chDrag)) return; + if (mid) e.preventDefault(); + pan = { sx: e.clientX, sy: e.clientY, ox: view.ox, oy: view.oy, moved: false, mid }; + }); + function onPanMove(e) { + if (!pan) return; + if (!pan.moved && Math.hypot(e.clientX - pan.sx, e.clientY - pan.sy) < 4) return; + pan.moved = true; + canvas.style.cursor = 'grabbing'; + plan2D.cx = (canvas.width / 2 - (pan.ox + e.clientX - pan.sx)) / view.scale; + plan2D.cz = (canvas.height / 2 - (pan.oy + e.clientY - pan.sy)) / view.scale; + drawPlan(); + } + function onPanEnd() { + if (!pan) return; + if (pan.moved) { canvas.style.cursor = 'default'; if (!pan.mid) suppressClick = true; } + pan = null; + } + window.addEventListener('mousemove', onPanMove); + window.addEventListener('mouseup', onPanEnd); + function polyPath(pts) { ctx.beginPath(); pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i === 0) ctx.moveTo(q.x, q.y); else ctx.lineTo(q.x, q.y); }); @@ -834,8 +990,10 @@ function mountHouseEditor(container, callbacks) { const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1)); const bz0 = Math.min(...parts.rects.map(r => r.z0)), bz1 = Math.max(...parts.rects.map(r => r.z1)); - const scale = Math.min(w / ((bx1 - bx0) * 1.45), h / ((bz1 - bz0) * 1.45)); - view = { scale, ox: w / 2 - ((bx0 + bx1) / 2) * scale, oy: h / 2 - ((bz0 + bz1) / 2) * scale }; + const scale = Math.min(w / ((bx1 - bx0) * 1.45), h / ((bz1 - bz0) * 1.45)) * plan2D.zoom; + const mx = plan2D.cx === null ? (bx0 + bx1) / 2 : plan2D.cx, mz = plan2D.cz === null ? (bz0 + bz1) / 2 : plan2D.cz; + view = { scale, ox: w / 2 - mx * scale, oy: h / 2 - mz * scale }; + const L = view3D.layers; ctx.strokeStyle = '#222'; ctx.lineWidth = 1; const g = 500 * scale, gx = view.ox % g, gy = view.oy % g; @@ -859,7 +1017,7 @@ function mountHouseEditor(container, callbacks) { ctx.textBaseline = 'alphabetic'; }); - const R = view3D.ruimtes ? parts.rooms : null; + const R = view3D.layers.ruimtes ? parts.rooms : null; const rectsPath = (rr) => { ctx.beginPath(); rr.forEach(c => { const a = toPx(c.x0, c.z0); ctx.rect(a.x, a.y, (c.x1 - c.x0) * scale, (c.z1 - c.z0) * scale); }); }; const hexA = (hex, a) => `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a})`; const selRoom = R && selection && selection.type === 'room' ? R.rooms.find(r => r.id === selection.id) : null; @@ -890,7 +1048,7 @@ function mountHouseEditor(container, callbacks) { polyPath(pieceCorners(p.place, -p.place.width / 2 + op.x, -p.place.width / 2 + op.x + op.w)); ctx.fillStyle = op.overflowing ? 'rgba(255, 60, 60, 0.85)' : (op.y < p.config.plateThick ? '#1d5f99' : '#9fd0ff'); ctx.fill(); - if (op.fill) drawOpeningSymbol(p, op); + if (op.fill && L.kozijnen) drawOpeningSymbol(p, op); }); }; function drawOpeningSymbol(p, op) { @@ -940,7 +1098,7 @@ function mountHouseEditor(container, callbacks) { }); ctx.restore(); }; - if (view3D.inrichting && parts.fixtures) { + if (view3D.layers.inrichting && parts.fixtures) { parts.fixtures.forEach(it => { const dragging = fxDrag && fxDrag.moved && fxDrag.pose && fxDrag.id === it.id; const sel = selection && selection.type === 'fixture' && selection.id === it.id; @@ -962,7 +1120,7 @@ function mountHouseEditor(container, callbacks) { }); ctx.restore(); - parts.units.forEach(u => u.walls.forEach(wl => wl.pieces.forEach(p => { + if (L.maten) parts.units.forEach(u => u.walls.forEach(wl => wl.pieces.forEach(p => { const n = p.place.normal, off = 14; const a = toPx(...Object.values(UnitModel.localToWorld(p.place, -p.place.width / 2, p.place.thickness / 2))); const b = toPx(...Object.values(UnitModel.localToWorld(p.place, p.place.width / 2, p.place.thickness / 2))); @@ -970,7 +1128,7 @@ function mountHouseEditor(container, callbacks) { drawCanvasDim(ctx, x0, y0, x1, y1, `${p.element}: ${p.place.width}mm`, (x0 + x1) / 2 + (n.x < 0 ? -4 : n.x > 0 ? 12 : 0), (y0 + y1) / 2 + (n.y < 0 ? -4 : n.y > 0 ? 11 : 0)); }))); - parts.interior.forEach(p => { + if (L.maten) parts.interior.forEach(p => { const c = toPx(p.place.cx, p.place.cz); ctx.save(); ctx.translate(c.x, c.y); @@ -1001,11 +1159,11 @@ function mountHouseEditor(container, callbacks) { ctx.save(); ctx.setLineDash([3, 4]); ctx.strokeStyle = 'rgba(200, 200, 200, 0.55)'; ctx.lineWidth = 1; - parts.units.forEach(u => { + if (L.dak) parts.units.forEach(u => { const a = toPx(u.roof.rect.x0, u.roof.rect.z0); ctx.strokeRect(a.x, a.y, u.roof.rect.W * scale, u.roof.rect.D * scale); }); - if (parts.roofEdge) { + if (parts.roofEdge && L.dak) { parts.roofEdge.segments.forEach(sg => { ctx.strokeStyle = sg.fascia ? 'rgba(232, 136, 61, 0.9)' : 'rgba(190, 195, 200, 0.9)'; const a = toPx(sg.outerA.x, sg.outerA.z), b = toPx(sg.outerB.x, sg.outerB.z); @@ -1013,18 +1171,18 @@ function mountHouseEditor(container, callbacks) { }); ctx.setLineDash([]); ctx.fillStyle = '#ffe9a8'; - parts.roofEdge.spots.forEach(sp => { + if (L.lampen) parts.roofEdge.spots.forEach(sp => { const q = toPx(sp.x, sp.z); ctx.beginPath(); ctx.arc(q.x, q.y, 3, 0, Math.PI * 2); ctx.fill(); }); } ctx.setLineDash([]); ctx.fillStyle = '#a07c54'; ctx.strokeStyle = '#e8d3b0'; ctx.lineWidth = 1; - parts.posts.posts.forEach(pp => { + if (L.dak) parts.posts.posts.forEach(pp => { const a = toPx(pp.x - pp.size / 2, pp.z - pp.size / 2), sz = pp.size * scale; ctx.fillRect(a.x, a.y, sz, sz); ctx.strokeRect(a.x, a.y, sz, sz); }); - (parts.chimneys || []).forEach(ch => { + (L.gevel ? parts.chimneys || [] : []).forEach(ch => { const path = (pts) => { ctx.beginPath(); pts.forEach((p, i) => { const q = toPx(p.x, p.z); if (i) ctx.lineTo(q.x, q.y); else ctx.moveTo(q.x, q.y); }); ctx.closePath(); }; const sn = chDrag && chDrag.moved && chDrag.snap && chDrag.id === ch.id ? chDrag.snap : null; if (sn) { @@ -1043,20 +1201,20 @@ function mountHouseEditor(container, callbacks) { ctx.restore(); ctx.strokeStyle = '#ffe9a8'; ctx.lineWidth = 1.5; - (parts.lighting.indoor || []).forEach(sp => { + (L.lampen ? parts.lighting.indoor || [] : []).forEach(sp => { const q = toPx(sp.x, sp.z); ctx.beginPath(); ctx.arc(q.x, q.y, 3.5, 0, Math.PI * 2); ctx.stroke(); }); ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 1; - parts.lighting.lamps.forEach(l => { + (L.lampen ? parts.lighting.lamps : []).forEach(l => { const q = toPx(l.x + l.normal.x * 60, l.z + l.normal.z * 60); ctx.fillRect(q.x - 3.5, q.y - 3.5, 7, 7); ctx.strokeRect(q.x - 3.5, q.y - 3.5, 7, 7); }); const el = parts.elektra; - if (el && el.enabled && view3D.elektra) { + if (el && el.enabled && view3D.layers.elektra) { const RC = ElektraPdf.RUN_COLORS, rgb = (c, a) => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`; const inward = (q, n, d) => toPx(q.x + n.x * d, q.z + n.z * d); ctx.save(); @@ -1165,21 +1323,21 @@ function mountHouseEditor(container, callbacks) { } const { num, sel, toggle } = UI; - const { big, small, kv } = Ribbon; + const { big, small, kv } = Inspector; const after = () => scheduleRefresh({ keepProps: true }); const rerender = () => refresh(); let live = []; function updateLive() { live.forEach(fn => fn()); } - const note = (text, isWarn) => `
${text}
`; - const pair = (...items) => `
${items.join('')}
`; - const thumb = (url, alt) => url ? `${alt}` : ''; - const check = (id, label, checked) => ``; + const note = (text, isWarn) => `
${text}
`; + const pair = (...items) => `
${items.join('')}
`; + const thumb = (url, alt) => url ? `${alt}` : ''; + const check = (id, label, checked) => ``; const SIDE_SHORT = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' }; const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ','); const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` }))); const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions); const stucColors = Object.fromEntries(Object.entries(WallModel.STUC_COLORS).map(([k, c]) => [k, c.label])); - const swatchOf = (hex) => `
`; + const swatchOf = (hex) => `
`; function wireButtons(body) { body.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool))); body.querySelectorAll('[data-go]').forEach(b => b.addEventListener('click', () => showPart(b.dataset.go))); @@ -1202,34 +1360,6 @@ function mountHouseEditor(container, callbacks) { })); } - const startTab = { - key: 'start', label: 'Start', order: 10, - groups: () => [ - { label: 'Gereedschap', html: - big('Selecteren', 'cursor', `data-ho-tool="select" title="Klik = selecteren, dubbelklik = bewerken (Esc)"`, tool === 'select' ? 'active' : '') + - big('Binnenwand tekenen', 'pencil', `data-ho-tool="iw" title="Twee klikken in de plattegrond; snapt op 50 mm, haaks en op wandvlakken"`, tool === 'iw' ? 'active' : '') }, - roomFillGroup(), - { label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-ho-act="add-unit" title="Aan de geselecteerde (of laatste) unit"') + big('Project', 'card-text', 'data-ho-act="project"') }, - { label: 'Overzicht', html: `
` }, - { label: 'Werkwijze', html: note('Selecteer een unit, wand of binnenwand in de plattegrond of de structuur: het tabblad van die selectie verschijnt rechts. Dubbelklik om te bewerken. Oranje gestippeld = naad tussen units.') } - ], - wire: (body) => { - live = []; - wireButtons(body); - wireRoomFill(); - const dimsLive = () => { - const el = body.querySelector('[data-live="start-dims"]'); - if (!el || !parts) return; - const d = HouseModel.computeDimensions(parts, state.settings.groundLevel), f = houseFmt; - el.innerHTML = kv('Buitenmaten', `${f(d.lengthX / 1000, 2)} × ${f(d.lengthZ / 1000, 2)}`, 'm') + kv('Bruto vloer', f(d.brutoM2, 1), 'm²') + - kv('Bouwhoogte', f(d.height / 1000, 2), 'm') + kv('Leefbaar', f(d.leefbaarM2, 1), 'm²') + - kv('Gevel netto', f(d.gevelNettoM2, 1), 'm²') + kv('Inhoud', f(d.inhoudM3, 0), 'm³'); - }; - live.push(dimsLive); - dimsLive(); - } - }; - const gevelTab = { key: 'gevel', label: 'Gevel', order: 20, groups: () => { @@ -1237,7 +1367,7 @@ function mountHouseEditor(container, callbacks) { const rabatColors = Object.fromEntries(Object.entries(WallModel.RABAT_COLORS).map(([k, c]) => [k, c.label])); const rabatUi = sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'Rabat op vochtdoek en kruislat' }) + pair(sel('hs-rabat-color', 'Kleur', rabatColors, st.facade.rabatColor, { hint: 'Ook voor rabat-overrides per wand' }), - `
`); + `
`); const usesRabat = state.units.some(u => Object.keys(u.unit.walls).some(side => HouseModel.facadeFor(state, u, side).type === 'rabat' || UnitModel.normalizeSplits(u.unit.walls[side].splits).some(sp => sp.facade && sp.facade.type === 'rabat'))); return [ @@ -1261,7 +1391,6 @@ function mountHouseEditor(container, callbacks) { ]; }, wire: (body) => { - live = []; const st = state.settings; UI.onSel('hs-facade', v => { st.facade.type = v; }, rerender); UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender); @@ -1312,11 +1441,10 @@ function mountHouseEditor(container, callbacks) { UI.range('hs-posts-size', 'Maat', r.posts.size, { min: 100, max: 300, step: 10, hint: 'Vierkant, mm' }) + num('hs-posts-span', 'Max. h.o.h.', r.posts.maxSpan, { step: 100, hint: 'Grootste afstand tussen palen (of paal en gevel)' }) + num('hs-posts-min', 'Min. uitkraging', r.posts.minExt, { step: 100, hint: 'Kleinere overstekken krijgen geen palen' }) : '') }, - { label: 'Daken per unit', html: `
` }); + { label: 'Daken per unit', html: `
` }); return groups; }, wire: (body) => { - live = []; const r = state.settings.roof, pos = { positive: true }; UI.onToggle('hs-roof-house', v => { HouseModel.setHouseRoof(state, v); }, rerender); UI.onSel('hs-roof-cover', v => { state.units.forEach(u => { u.roof.covering = v; }); }, rerender); @@ -1356,19 +1484,10 @@ function mountHouseEditor(container, callbacks) { UI.color('hs-ceil-color', 'Plafond', st.ceilingColor, { hint: 'Onderkant dak (plafondplaat), afgewerkte fase' }) : '') }, { label: 'Vloer', html: pair(sel('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, fi]) => [k, fi.label])), st.floorFinish), thumb(FloorModel.finishThumbUrl(st.floorFinish, 64), st.floorFinish)) }, - { label: 'Binnenwanden', html: big('Binnenwand tekenen', 'pencil', 'data-ho-tool="iw"') + note(`${state.interiorWalls.length} binnen-/naadwand(en). Selecteer er een voor positie, dikte en openingen.`) }, - roomFillGroup(), - { label: 'Inrichting', html: sel('hf-new-type', 'Stuk', fixtureTypeOpts(), fixtureType, { hint: 'Kies een stuk en klik daarna in de plattegrond (D = draaien). Bij een wand snapt de rug op het wandvlak.' }) + - big('Plaatsen', 'plus-square', `data-ho-tool="fixture" title="Klik in de plattegrond om het stuk neer te zetten (Esc = stoppen)"`, tool === 'fixture' ? 'active' : '') + - note(`${(state.fixtures || []).length} stuk(ken). Selecteer er een om te slepen, draaien (D) of aan te passen.`) }, - { label: 'Ruimtes', html: `
` } + { label: 'Ruimtes', html: `
` } ]; }, wire: (body) => { - live = []; - wireButtons(body); - wireRoomFill(); - UI.onSel('hf-new-type', v => { fixtureType = v; if (tool !== 'fixture') setTool('fixture'); else updateHint(); }, null); const list = () => { const el = body.querySelector('[data-live="room-list"]'); if (!el || !parts) return; @@ -1404,7 +1523,6 @@ function mountHouseEditor(container, callbacks) { ]; }, wire: () => { - live = []; const sp = state.settings.spacing, pos = { positive: true }; UI.onNum('hs-ext-min', v => { sp.exterior.min = v; }, after, pos); UI.onNum('hs-ext-max', v => { sp.exterior.max = v; }, after, pos); @@ -1432,11 +1550,10 @@ function mountHouseEditor(container, callbacks) { (lt.indoor ? num('hs-indoor-min', 'Afstand min.', lt.indoorMin, { hint: 'Hart-op-hart, in beide richtingen' }) + num('hs-indoor-max', 'Afstand max.', lt.indoorMax, { hint: 'Hart-op-hart, in beide richtingen' }) + num('hs-indoor-off', 'Tot wand', lt.indoorOffset, { hint: 'Tot de binnenzijde van een wand, of tot de unitrand bij een naad' }) : '') }); - groups.push({ label: 'Aantallen', html: `
` }); + groups.push({ label: 'Aantallen', html: `
` }); return groups; }, wire: (body) => { - live = []; const r = state.settings.roof, lt = state.settings.lighting, pos = { positive: true }; UI.onSel('hs-light-mode', v => { lt.mode = v; }, rerender); UI.onSel('hs-light-dir', v => { lt.direction = v; }, rerender); @@ -1472,7 +1589,7 @@ function mountHouseEditor(container, callbacks) { groups.push({ label: 'Meterkast', html: num('he-mk-w', 'Breedte', e.meterkast.w, { step: 10 }) + num('he-mk-h', 'Hoogte', e.meterkast.h, { step: 10 }) + num('he-mk-d', 'Diepte', e.meterkast.d, { step: 10 }) + num('he-mk-y', 'Onderkant', e.meterkast.y, { step: 10, hint: 'Vanaf bovenkant vloer; zo dicht mogelijk bij de voordeur' }) }); groups.push({ label: 'Punten', html: num('he-sw-h', 'Schakelaar', e.switchHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + - num('he-wcd-h', 'Wcd', e.wcdHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + num('he-wcd-n', 'Wcd per unit', e.wcdPerUnit, { step: 1, hint: 'Dubbele wcd' }) + + num('he-wcd-h', 'Wcd', e.wcdHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + num('he-wcd-n', 'Wcd per unit', e.wcdPerUnit, { step: 1, unit: 'st.', hint: 'Dubbele wcd' }) + sel('he-plate', 'Kleur', Object.fromEntries(Object.entries(ElektraModel.PLATE_COLORS).map(([k, c]) => [k, c.label])), e.plateColor || 'wit', { hint: 'Schakelaars, wcd\'s en vloerdozen' }) }); groups.push({ label: 'Apparaten', html: Object.entries(ElektraModel.APPLIANCES).map(([k, A]) => check(`he-app-${k}`, `${A.label} (${A.code})`, !!e.apparaten[k])).join('') }); @@ -1485,15 +1602,14 @@ function mountHouseEditor(container, callbacks) { groups.push({ label: 'Plaatsing', html: note('Sleep meterkast, schakelaars, wcd en apparaten in de plattegrond; selecteer er een voor positie, hoogte of verwijderen (Del).') + (nManual ? big('Alles automatisch', 'arrow-counterclockwise', `data-he-reset title="${nManual} handmatige plek(ken) wissen"`) : '') + (nRemoved ? big('Verwijderde terug', 'arrow-counterclockwise', `data-he-unremove title="${nRemoved} verwijderde wcd/schakelaar(s) terugzetten"`) : '') }); - groups.push({ label: 'Leidingen', html: num('he-slack', 'Marge %', e.slack, { step: 1 }) + num('he-res', 'Reserve', e.reserve, { step: 50, hint: 'Per leiding (lussen in de dozen), mm' }) + + groups.push({ label: 'Leidingen', html: num('he-slack', 'Marge', e.slack, { step: 1, unit: '%', hint: 'Extra lengte op elke leiding' }) + num('he-res', 'Reserve', e.reserve, { step: 50, hint: 'Per leiding (lussen in de dozen), mm' }) + num('he-mk-res', 'Extra MK', e.meterkastReserve, { step: 50, hint: 'Per leiding vanuit de meterkast, mm' }) }); - groups.push({ label: 'Strekkende meters', html: `
` }); - groups.push({ label: 'Groepen', html: `
` }); + groups.push({ label: 'Strekkende meters', html: `
` }); + groups.push({ label: 'Groepen', html: `
` }); } return groups; }, wire: (body) => { - live = []; const e = state.settings.elektra, pos = { positive: true }; UI.onToggle('he-on', v => { e.enabled = v; }, rerender); UI.onNum('he-mk-w', v => { e.meterkast.w = v; }, after, pos); @@ -1544,49 +1660,6 @@ function mountHouseEditor(container, callbacks) { } }; - const rapportTab = { - key: 'rapport', label: 'Rapport', order: 60, - groups: () => [ - { label: 'Rapport', html: note('Transport per unit, gewicht, afmetingen, kosten, hout en plaatwerk. Staat in plaats van de plattegrond zolang dit tabblad open is en loopt mee met elke wijziging.') }, - { label: 'Uitvoer', html: big('Opslaan + PDF', 'file-earmark-pdf', 'data-ho-act="save" title="JSON en de volledige PDF (Ctrl+S)"') + - big('CAD (DXF)', 'vector-pen', 'data-ho-act="dxf" title="Plattegrond en gevels als DXF voor CAD (AutoCAD, BricsCAD, …), 1:1 in mm; daar op te slaan als DWG"') + - big('BIM (IFC)', 'boxes', 'data-ho-act="ifc" title="Bouwconstructie als IFC4 (balken, platen, elektra) per element, zonder afwerking en inrichting"') + - big('Render (OBJ)', 'badge-3d', 'data-ho-act="obj" title="Volledige afgewerkte woning als OBJ + MTL + texturen in een ZIP, voor renders in Blender"') }, - { label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-ho-act="costs"') } - ], - wire: (body) => { live = []; wireButtons(body); } - }; - - const beeldTab = { - key: 'beeld', label: 'Beeld', order: 70, - groups: () => [ - { label: 'Tonen', html: check('hv-dak', 'Dak', view3D.dak) + check('hv-lampen', 'Lampen', view3D.lampen) + check('hv-elektra', 'Elektra', view3D.elektra) + check('hv-ruimtes', 'Ruimtes (2D)', view3D.ruimtes) + check('hv-inrichting', 'Inrichting', view3D.inrichting) }, - { label: 'Doorsnede', html: check('hv-section', 'Doorsnede', view3D.section) + - UI.range('hv-section-h', 'Hoogte', view3D.sectionMM, { min: 100, max: 4000, step: 50, hint: 'Snijhoogte boven de bovenkant vloer' }) }, - { label: 'Omgeving', html: `
${Skybox.MODES.map(m => small(Skybox.LABELS[m][1], Skybox.LABELS[m][0], `data-sky="${m}"`, m === skyMode ? 'active' : '')).join('')}
` + - small('Render', 'camera', 'data-render title="Elke lamp en spot als echte lichtbron"', fullRender ? 'active' : '') } - ], - wire: (body) => { - live = []; - const onCheck = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('change', () => { fn(el.checked); scheduleRefresh({ keepProps: true }); }); }; - onCheck('hv-dak', v => { view3D.dak = v; }); - onCheck('hv-lampen', v => { view3D.lampen = v; }); - onCheck('hv-elektra', v => { view3D.elektra = v; }); - onCheck('hv-ruimtes', v => { view3D.ruimtes = v; if (!v && selection && (selection.type === 'room' || selection.type === 'region')) selection = null; }); - onCheck('hv-inrichting', v => { view3D.inrichting = v; if (!v && selection && selection.type === 'fixture') selection = null; }); - onCheck('hv-section', v => { view3D.section = v; syncSectionRow(); }); - UI.onRange('hv-section-h', setSectionMM); - body.querySelectorAll('[data-sky]').forEach(b => b.addEventListener('click', () => { - skyMode = b.dataset.sky; - Skybox.setMode(skyMode); - body.querySelectorAll('[data-sky]').forEach(x => x.classList.toggle('active', x === b)); - applyRenderMode(); - })); - const rb = body.querySelector('[data-render]'); - if (rb) rb.addEventListener('click', () => { fullRender = !fullRender; rb.classList.toggle('active', fullRender); applyRenderMode(); }); - } - }; - function contextTab() { if (!selection) return null; if (selection.type === 'unit' && unitById(selection.id)) return unitTab(unitById(selection.id)); @@ -1655,12 +1728,11 @@ function mountHouseEditor(container, callbacks) { if (optHtml) groups.push({ label: 'Opties', html: optHtml }); groups.push({ label: 'Bewerken', html: small('Draaien 90°', 'arrow-clockwise', 'data-hf="rot" title="D (Shift+D = andersom)"') + small('Draaien 15°', 'arrow-clockwise', 'data-hf="rot15"') + small('Dupliceren', 'files', 'data-hf="dup"') + - small('Verwijderen', 'trash', 'data-hf="del" title="Delete"', 'rb-danger') + + small('Verwijderen', 'trash', 'data-hf="del" title="Delete"', 'ins-danger') + note('Sleep het stuk in de plattegrond; bij een wand snapt de rug op het wandvlak, Alt = vrij.') }); return groups; }, wire: (body) => { - live = []; const place = (x, z) => { placeFixture(fs, x, z); }; UI.onSel('hf-type', v => { const N = FixtureModel.TYPES[v]; @@ -1693,14 +1765,18 @@ function mountHouseEditor(container, callbacks) { } const roomTypeOpts = Object.fromEntries(Object.entries(RoomModel.TYPES).map(([k, t]) => [k, t.label])); - function roomFillGroup() { - return { label: 'Ruimte vullen', html: sel('hr-fill-type', 'Functie', roomTypeOpts, fillType, { hint: 'Klik daarna in de plattegrond: de ruimte loopt tot de wanden (deuren en ramen tellen als wand)' }) + - big('Ruimte vullen', 'paint-bucket', `data-ho-tool="room" title="Verfemmer: klik in een ruimte om de functie te geven (Esc = stoppen)"`, tool === 'room' ? 'active' : '') }; + // Opties van het actieve gereedschap, rechts naast het gereedschap in de werkbalk. + function renderToolOptions() { + const el = document.getElementById('ho-tool-options'); + if (!el) return; + const opt = (id, label, opts, val) => ``; + el.innerHTML = tool === 'room' ? opt('hr-fill-type', 'Functie', roomTypeOpts, fillType) + : tool === 'fixture' ? opt('hf-new-type', 'Stuk', fixtureTypeOpts(), fixtureType) : ''; + UI.onSel('hr-fill-type', v => { fillType = v; updateHint(); document.activeElement.blur(); }); + UI.onSel('hf-new-type', v => { fixtureType = v; updateHint(); document.activeElement.blur(); }); } - function wireRoomFill() { - UI.onSel('hr-fill-type', v => { fillType = v; if (tool !== 'room') setTool('room'); else updateHint(); }, null); - } - const swatch = (hex) => `
`; + const swatch = (hex) => `
`; function roomTab(rs) { const pr = () => parts.rooms.rooms.find(r => r.id === rs.id); @@ -1716,7 +1792,7 @@ function mountHouseEditor(container, callbacks) { return [ { label: 'Ruimte', html: sel('hr-type', 'Functie', roomTypeOpts, rs.type, { hint: 'Een andere functie zet de standaard afwerking van die functie' }) + UI.text('hr-name', 'Naam', rs.name, { placeholder: RoomModel.TYPES[rs.type].label, hint: 'Leeg = de functie (genummerd bij meer van dezelfde)' }) }, - { label: 'Maten', html: lost ? note(lost.reason + '. Verwijder de ruimte of herstel de wand.', true) : `
` }, + { label: 'Maten', html: lost ? note(lost.reason + '. Verwijder de ruimte of herstel de wand.', true) : `
` }, { label: 'Plafond', html: toggle('hr-ceil-on', 'Vals plafond', !!rs.ceiling, { hint: 'Verlaagd plafond op een vaste hoogte, ongeacht het dak; wanden en spots volgen' }) + (rs.ceiling ? UI.range('hr-ceil', 'Hoogte', rs.ceiling, { min: 2000, max: 3400, step: 10, hint: 'Onderkant plafond vanaf bovenkant vloer' }) : '') + `
` }, @@ -1728,11 +1804,10 @@ function mountHouseEditor(container, callbacks) { (rs.fibo.height ? UI.range('hr-fibo-h', 'Hoogte', rs.fibo.height, { min: 300, max: 3000, step: 50, hint: 'Vanaf bovenkant vloer' }) : '') : '') }, { label: 'Verf', html: toggle('hr-paint-on', 'Eigen kleur', !!rs.paint, { hint: rs.fibo ? 'Boven de FIBO; anders de verfkleur van de woning' : 'Anders de verfkleur van de woning' }) + (rs.paint ? UI.color('hr-paint', 'Kleur', rs.paint) : '') }, - { label: 'Bewerken', html: small('Verwijderen', 'trash', 'data-hr-del title="Ruimte (functie en afwerking) verwijderen (Delete)"', 'rb-danger') } + { label: 'Bewerken', html: small('Verwijderen', 'trash', 'data-hr-del title="Ruimte (functie en afwerking) verwijderen (Delete)"', 'ins-danger') } ]; }, wire: (body) => { - live = []; UI.onSel('hr-type', v => { RoomModel.applyType(rs, v); }, rerender); UI.onText('hr-name', v => { rs.name = v; }, after); UI.onToggle('hr-ceil-on', v => { rs.ceiling = v ? (RoomModel.TYPES[rs.type].ceiling || 2600) : null; }, rerender); @@ -1768,7 +1843,6 @@ function mountHouseEditor(container, callbacks) { { label: 'Uitleg', html: note('De ruimte loopt tot de wanden; deuren en ramen tellen als wand. Klik nog eens voor de unit.') } ], wire: () => { - live = []; const sx = selection.x, sz = selection.z; UI.onSel('hr-new-type', v => { if (v) assignRoom(sx, sz, v); }, null); } @@ -1799,7 +1873,6 @@ function mountHouseEditor(container, callbacks) { { label: 'Bewerken', html: note('Sleep de vloer-wcd in de plattegrond (ook naar een andere unit).') + big('Verwijderen', 'trash', 'data-he-vw-del title="Vloer-wcd verwijderen (Del)"') } ], wire: (body) => { - live = []; UI.onNum('he-vw-x', v => { if (vw) vw.x = v; }, after); UI.onNum('he-vw-z', v => { if (vw) vw.z = v; }, after); const b = body.querySelector('[data-he-vw-del]'); @@ -1823,7 +1896,6 @@ function mountHouseEditor(container, callbacks) { : { label: 'Bewerken', html: big('Verwijderen', 'trash', 'data-he-it-del title="Onderdeel verwijderen (Del)"') } ], wire: (body) => { - live = []; UI.onNum('he-it-u', v => setPlace({ u: v }), after); UI.onNum('he-it-y', v => setPlace({ y: v }), after); const b = body.querySelector('[data-he-auto]'); @@ -1935,7 +2007,7 @@ function mountHouseEditor(container, callbacks) { UI.range('hp-depth', 'Diepte', f.depth, { min: 2000, max: LIM.long, step: 50, hint: `Noord–zuid (unit max ${LIM.short / 1000} x ${LIM.long / 1000} m)`.replace('.', ',') }) + sel('hp-corners', 'Hoeken', UnitModel.CORNER_MODES, u.unit.layout.corners, { hint: 'Welke wanden doorlopen over de volle vloermaat' }) }, ]; - if (houseRoof) groups.push({ label: 'Dakvorm', html: note(`Dak over de hele woning (${(UnitModel.ROOF_TYPES[shapeNow] || shapeNow).toLowerCase()}): vorm en hoogtes op het tabblad Dak.`) }); + if (houseRoof) groups.push({ label: 'Dakvorm', html: note(`Dak over de hele woning (${(UnitModel.ROOF_TYPES[shapeNow] || shapeNow).toLowerCase()}): vorm en hoogtes bij Woning › Dak (niets geselecteerd).`) }); else groups.push(...roofUi.groups()); groups.push({ label: 'Dak', html: pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet en felsplaten alleen bij een hellend dak' : `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²` }), @@ -1960,11 +2032,10 @@ function mountHouseEditor(container, callbacks) { big('Vloer', 'grid-3x3', `data-go="floor:${u.id}" title="Vloer-editor: balken, doorvoeren"`) + big('Dak', 'house', `data-go="roof:${u.id}" title="Dak-editor: dek, doorvoeren"`) + (missing.length ? small('Naadwand toevoegen', 'plus-lg', `data-ho-act="add-seams" data-unit="${u.id}" title="Zonder naadwand: ${missing.map(m => `${m.a}|${m.b}`).join(', ')}"`) : '') + - (isRoot ? '' : small('Unit verwijderen', 'trash', `data-ho-act="remove-unit" data-unit="${u.id}"`, 'rb-danger')) }); + (isRoot ? '' : small('Unit verwijderen', 'trash', `data-ho-act="remove-unit" data-unit="${u.id}"`, 'ins-danger')) }); return groups; }, wire: (body) => { - live = []; wireButtons(body); const size = (k, v) => { if (!(v >= 1000)) return; @@ -2016,7 +2087,7 @@ function mountHouseEditor(container, callbacks) { num(id + '-above', 'Boven het dak', c.above, { step: 50, hint: r ? `Bovenkant ${r.yTop - r.yFloor} mm boven de vloer (hoogste dakpunt + dit)` : '' }) + fin(id + '-out-type', 'Buiten', c.outside, ChimneyModel.OUTSIDE_TYPES) + fin(id + '-in-type', 'Binnen', c.inside, ChimneyModel.INSIDE_TYPES) + - small('Schoorsteen weg', 'trash', `data-hw-ch="del" data-ch="${c.id}"`, 'rb-danger'); + small('Schoorsteen weg', 'trash', `data-hw-ch="del" data-ch="${c.id}"`, 'ins-danger'); }).join(''); return { label: 'Schoorsteen', html: html + small('Schoorsteen toevoegen', 'plus-lg', `data-hw-ch="add" data-u="${Math.round(e0 + (piece.u0 + piece.u1) / 2)}"`) }; } @@ -2085,13 +2156,12 @@ function mountHouseEditor(container, callbacks) { ? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, su.lighting.walls[wl.side] || 'auto', { hint: `Automatisch: ${lt.endOffset} mm uit de hoek, op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm ernaast.` }) + kv('Op dit wandstuk', piece.lamps.length, 'st.') - : note('Gevellampen staan uit (tabblad Verlichting).') }, + : note('Gevellampen staan uit (Woning › Verlichting).') }, wl.dropped.length ? { label: 'Let op', html: note(`${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.`, true) } : null, { label: 'Bewerken', html: big('Wand bewerken', 'pencil-square', `data-go="wall:${pu.id}:${wl.side}" title="Wand-editor: openingen, hoogtes, regelwerk"`) } ]; }, wire: (body) => { - live = []; wireButtons(body); const fc = HouseModel.facadeFor(state, su, wl.side); UI.onSel('hw-light', v => { su.lighting.walls[wl.side] = v; }, rerender); @@ -2132,10 +2202,9 @@ function mountHouseEditor(container, callbacks) { groups: () => [ { label: 'Positie', html: num('hi-x1', 'Begin X', iw.x1) + num('hi-z1', 'Begin Y', iw.z1) + num('hi-x2', 'Eind X', iw.x2) + num('hi-z2', 'Eind Y', iw.z2) }, { label: 'Opbouw', html: num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), { step: 5 }) + num('hi-height', 'Hoogte', iw.wall.wall.heightAll) + kv('Lengte', p.place.width, 'mm') }, - { label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}" title="Delete"`, 'rb-danger') } + { label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}" title="Delete"`, 'ins-danger') } ], wire: (body) => { - live = []; wireButtons(body); ['x1', 'z1', 'x2', 'z2'].forEach(k => UI.onNum('hi-' + k, v => { iw[k] = v; }, after)); UI.onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); }, after); @@ -2164,11 +2233,10 @@ function mountHouseEditor(container, callbacks) { { label: 'Uitleg', html: note(inUnit ? `Binnenwand geheel in unit ${inUnit}, tegen de naad, beplating aan beide zijden; hoogte volgt de daklijn van unit ${inUnit}.` : 'Per unit een eigen deel tegen het andere aan; openingen gelden voor beide delen (de deur loopt door). Hoogte volgt per deel de daklijn.') }, - { label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}"`, 'rb-danger') } + { label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}"`, 'ins-danger') } ]; }, wire: (body) => { - live = []; wireButtons(body); UI.onSel('hi-seam-in', v => { if (v === 'midden') delete iw.seam.in; else iw.seam.in = v; }, rerender); UI.onNum('hi-thick', v => { const t = inUnit ? v - sheet : v; if (t >= 40) HouseModel.setThickness(iw.wall, t); }, after); @@ -2176,11 +2244,38 @@ function mountHouseEditor(container, callbacks) { }; } - function syncRibbon(focus) { - const tabs = [startTab, gevelTab, dakTab, binnenTab, constructieTab, lichtTab, elektraTab, rapportTab, beeldTab]; + const WONING_SECTIONS = () => [ + Object.assign({ icon: 'bricks' }, gevelTab), Object.assign({ icon: 'house' }, dakTab), Object.assign({ icon: 'door-open' }, binnenTab), + Object.assign({ icon: 'lightbulb' }, lichtTab), Object.assign({ icon: 'lightning-charge' }, elektraTab), Object.assign({ icon: 'grid-3x3' }, constructieTab) + ]; + // Eigenschappenpaneel: de selectie, of zonder selectie de instellingen van de hele woning. + function renderInspector() { + const el = document.getElementById('ho-inspector'); + if (!el) return; + live = []; const ctx = contextTab(); - if (ctx) tabs.push(ctx); - Ribbon.setSource('editor', tabs, { focus: focus && !!ctx }); + if (ctx) { + Inspector.render(el, { crumbs: [{ label: 'Woning', attrs: 'data-deselect title="Selectie opheffen (Esc)"' }], title: ctx.label, icon: ctx.icon || 'cursor', + sections: [{ key: 'sel', groups: ctx.groups, wire: ctx.wire }] }); + } else { + const n = state.units.length; + Inspector.render(el, { title: 'Woning', icon: 'house-door', sub: `${n} unit${n === 1 ? '' : 's'} · niets geselecteerd`, collapsible: true, + top: '
', + sections: WONING_SECTIONS() }); + const dims = () => { + const d0 = el.querySelector('[data-live="woning-dims"]'); + if (!d0 || !parts) return; + const d = HouseModel.computeDimensions(parts, state.settings.groundLevel), f = houseFmt; + const wide = (h) => h.replace('class="ins-kv"', 'class="ins-kv ins-wide"'); + d0.innerHTML = wide(kv('Buitenmaten', `${f(d.lengthX / 1000, 2)} × ${f(d.lengthZ / 1000, 2)}`, 'm')) + + kv('Bruto vloer', f(d.brutoM2, 1), 'm²') + kv('Leefbaar', f(d.leefbaarM2, 1), 'm²') + + kv('Bouwhoogte', f(d.height / 1000, 2), 'm') + kv('Inhoud', f(d.inhoudM3, 0), 'm³') + wide(kv('Gevel netto', f(d.gevelNettoM2, 1), 'm²')); + }; + live.push(dims); + dims(); + } + const back = el.querySelector('[data-deselect]'); + if (back) back.addEventListener('click', () => select(null)); } function renderWarnings() { const msgs = (parts.overlaps.length ? [`Units overlappen elkaar: ${parts.overlaps.map(p => p.join(' + ')).join(', ')}.`] : []) @@ -2190,14 +2285,21 @@ function mountHouseEditor(container, callbacks) { let reportOpen = false, reportDirty = true; const overviewEl = document.getElementById('house-overview'); - const offRibbon = Ribbon.onChange((key) => { - const open = key === 'rapport'; + function toggleReport(force) { + const open = force === undefined ? !reportOpen : !!force; if (open === reportOpen) return; - reportOpen = open; + reportOpen = view3D.report = open; + if (open && UI.viewOf(overviewEl) === '3d') UI.setView(overviewEl, view3D.mode = 'split'); overviewEl.classList.toggle('show-report', open); if (open && reportDirty && parts) renderReport(); + syncToolbar(); requestAnimationFrame(onResize); - }); + } + function setView(mode) { + setWalk(false); + UI.setView(overviewEl, view3D.mode = mode); + requestAnimationFrame(onResize); + } const esc = UI.esc; function stackedBars(title, iconName, series, rows, fmtVal, note) { @@ -2277,8 +2379,10 @@ function mountHouseEditor(container, callbacks) { ['Zaagsneden', sw.cuts, 'st.'], ['Verschillende hoeken', sw.angles.length, ''] ].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0°)' : `Schuin ${f(a.deg, 1)}°`, a.count, 'sneden'])))); - document.getElementById('ho-report').innerHTML = `

${UI.icon('clipboard-data')}Rapport

${UI.esc(state.project.naam || 'Woning')} · ${state.units.length} unit(s)
${cards.join('')}
`; + document.getElementById('ho-report').innerHTML = `

${UI.icon('clipboard-data')}Rapport

${UI.esc(state.project.naam || 'Woning')} · ${state.units.length} unit(s)
${cards.join('')}
`; + layoutReport(); } + const layoutReport = () => { if (reportOpen) UI.masonry(document.querySelector('#ho-report .ho-report-cards'), 300); }; function costCards(c) { const s = c.summary, S = c.settings; @@ -2295,7 +2399,7 @@ function mountHouseEditor(container, callbacks) { } const offCosts = CostModel.onChange(() => { if (parts && overviewEl.isConnected) { reportDirty = true; if (reportOpen) renderReport(); } }); - let pending = null, firstSync = true; + let pending = null; function scheduleRefresh(opts) { const keep = !!(opts && opts.keepProps); if (pending) { pending.keepProps = pending.keepProps && keep; return; } @@ -2307,10 +2411,10 @@ function mountHouseEditor(container, callbacks) { function refresh(opts) { HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage); - const painted = state.settings.interiorPaint && BuildStages.shows(view3D.stage, 'afwerking'); + const painted = state.settings.interiorPaint && view3D.layers.afwerking; wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR); roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef'); - parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, postMaterials, chimneyMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra }); + parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, postMaterials, chimneyMaterials, elektraMaterials, layers: view3D.layers }); reportDirty = true; if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); } @@ -2334,7 +2438,7 @@ function mountHouseEditor(container, callbacks) { houseGroup.add(p.group); }); placeSection(); - if (view3D.dak && !section) { + if (view3D.layers.dak && !section) { parts.units.forEach(u => houseGroup.add(u.roof.group)); if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup); if (parts.postsGroup) houseGroup.add(parts.postsGroup); @@ -2353,7 +2457,6 @@ function mountHouseEditor(container, callbacks) { houseGroup.add(parts.roomsGroup); } if (parts.fixturesGroup) { - parts.fixturesGroup.visible = parts.fixturesGroup.visible && view3D.inrichting; parts.fixturesGroup.traverse(o => { if (!o.material || (o.material.clippingPlanes || []).length === clip.length) return; o.material.clippingPlanes = clip; @@ -2370,7 +2473,7 @@ function mountHouseEditor(container, callbacks) { // 2 mm extra lager: bij maaiveld 0 anders in hetzelfde vlak als de OSB (flikkeren). const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel - 2) * toM; signPos = null; - if (!section && BuildStages.shows(view3D.stage, 'afwerking')) houseGroup.add(buildLogoSign(groundY)); + if (!section && view3D.layers.afwerking) houseGroup.add(buildLogoSign(groundY)); skyEnv.fitTo(houseGroup, groundY); ground.position.set(cx, groundY - 0.002, cz); applyRenderMode(); @@ -2385,9 +2488,8 @@ function mountHouseEditor(container, callbacks) { const stale = !!selection && !contextTab(); if (stale) selection = null; - if (stale || !(opts && opts.keepProps)) syncRibbon(firstSync && !!selection); + if (stale || !(opts && opts.keepProps)) renderInspector(); else updateLive(); - firstSync = false; renderWarnings(); if (reportOpen) renderReport(); drawPlan(); @@ -2468,7 +2570,7 @@ function mountHouseEditor(container, callbacks) { // Door een deur mag je altijd lopen, ook als de opening smal of laag is. return !(p.config.openings || []).some(op => isWalkDoor(op) && lx > -W / 2 + op.x - WALK_RADIUS * 0.2 && lx < -W / 2 + op.x + op.w + WALK_RADIUS * 0.2); - }) || (view3D.inrichting && BuildStages.shows(view3D.stage, 'afwerking') && (parts.fixtures || []) + }) || (view3D.layers.inrichting && (parts.fixtures || []) .some(f => FixtureModel.blocks(f) && FixtureModel.contains(f, x, z, WALK_RADIUS * 0.6))); } function groundAt(xm, zm) { @@ -2502,7 +2604,7 @@ function mountHouseEditor(container, callbacks) { } const walkHint = document.createElement('div'); walkHint.className = 'walk-hint'; - walkHint.textContent = 'Lopen: W/S of pijltjes = vooruit/achteruit · A/D = opzij · pijl links/rechts = draaien · Shift = rennen · slepen = rondkijken · E = deur/schakelaar · Shift+T = terug'; + walkHint.textContent = 'Lopen: W/S of pijltjes = vooruit/achteruit · A/D = opzij · pijl links/rechts = draaien · Shift = rennen · slepen = rondkijken · E = deur/schakelaar · L of Esc = terug'; pane3D.appendChild(walkHint); const walkPrompt = document.createElement('div'); walkPrompt.className = 'walk-prompt'; @@ -2625,6 +2727,7 @@ function mountHouseEditor(container, callbacks) { walkHint.classList.toggle('show', on); if (!on) updatePrompt(); pane3D.classList.toggle('walking', on); + syncToolbar(); } let rafId = null, lastT = performance.now(); @@ -2642,17 +2745,13 @@ function mountHouseEditor(container, callbacks) { camera.updateProjectionMatrix(); renderer.setSize(pane3D.clientWidth, pane3D.clientHeight); drawPlan(); + layoutReport(); } window.addEventListener('resize', onResize); - function toggleView(fn) { - const el = document.getElementById('house-overview'); - setWalk(false); - fn(el); - requestAnimationFrame(onResize); - } - refresh(); + syncSectionRow(); + if (view3D.report) toggleReport(true); animate(); setTool(pendingTool || 'select'); pendingTool = null; @@ -2663,6 +2762,8 @@ function mountHouseEditor(container, callbacks) { window.removeEventListener('keydown', onKey); window.removeEventListener('mouseup', onElektraDrop); window.removeEventListener('mouseup', onFixtureDrop); + window.removeEventListener('mousemove', onPanMove); + window.removeEventListener('mouseup', onPanEnd); if (rafId !== null) cancelAnimationFrame(rafId); setWalk(false); // orbitcamera terug vóór het opslaan walk.dispose(); @@ -2670,17 +2771,18 @@ function mountHouseEditor(container, callbacks) { disposeGroup(houseGroup); disposeLights(); signPoleMat.dispose(); signEdgeMat.dispose(); - offRibbon(); offCosts(); - Ribbon.setSource('editor', []); renderer.dispose(); }, refresh, select, setTool, - toggleFullscreen3D: () => toggleView(UI.cycle3D), + setView, + toggleReport: () => toggleReport(), + toggleSection, + fitView: () => fitPlan(), toggleWalk: () => setWalk(!walk.active), - toggleFullscreen2D: () => toggleView(UI.toggle2D) + viewState: () => ({ view: UI.viewOf(overviewEl), report: reportOpen, section: view3D.section, walk: walk.active }) }; } @@ -2760,31 +2862,38 @@ function mountHouseEditor(container, callbacks) { const exportDxf = () => exportAs('dxf', 'CAD-export (DXF)', (f) => HouseDxf.generate(state).save(f), 'DXF, in CAD op te slaan als DWG'); const exportIfc = () => exportAs('ifc', 'BIM-export (IFC)', (f) => HouseIfc.generate(state).save(f), 'IFC4: balken, platen en elektra'); - // Render-export: de volledige afgewerkte woning, los van de gekozen bouwfase, doorsnede en weergave-opties. + // Render-export (sfeerimpressie): alleen wat in de afgewerkte woning te zien is, los van de gekozen lagen, doorsnede + // en weergave-opties; geen buizen, dozen, binnenste vloerbalken, latten en isolatie achter de gevel, lichtkegels. + // Lampen als echte lichtbronnen via een Blender-script in de ZIP. // Eigen materiaalsets (steen, riet, rabat en tegels komen uit gedeelde caches); maaiveld onder het midden = oorsprong. - const exportObj = () => exportAs('zip', 'render-export (OBJ + texturen, ZIP)', (filename) => { + const exportObj = () => exportAs('zip', 'render-export (OBJ + texturen + lampen, ZIP)', (filename) => { const wallMaterials = WallModel.createMaterials(), roofMaterials = RoofModel.createMaterials(); if (state.settings.interiorPaint) wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorColor); roofMaterials.ceilingMaterial.color.set(state.settings.ceilingColor || '#f4f3ef'); const full = HouseModel.computeParts(state, { wallMaterials, floorMaterials: FloorModel.createMaterials(), roofMaterials, lightingMaterials: LightingModel.createMaterials(), sandwichMaterials: SandwichModel.createMaterials(), postMaterials: PostModel.createMaterials(), chimneyMaterials: ChimneyModel.createMaterials(), - elektraMaterials: ElektraModel.createMaterials(), stage: BuildStages.FINAL, lampenVisible: true, elektraVisible: true + elektraMaterials: ElektraModel.createMaterials(), layers: ViewLayers.all(), elektraRough: false, finishedOnly: true }); HouseModel.placeGroups(full); + HouseModel.allWallPieces(full).forEach(p => ViewLayers.hideByMaterial(p.group, + [wallMaterials.isolatieMaterial, wallMaterials.battenMaterial, wallMaterials.doekMaterial])); + if (full.lightingGroup) full.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = false; }); if (full.roomsGroup && full.roomsGroup.userData.ceilings) full.roomsGroup.userData.ceilings.visible = true; const roots = full.units.map(u => u.floorGroup).concat(HouseModel.allWallPieces(full).map(p => p.group), full.units.map(u => u.roof.group), [full.roofEdgeGroup, full.postsGroup, full.chimneysGroup, full.sandwichGroup, full.lightingGroup, full.elektraGroup, full.roomsGroup, full.fixturesGroup]).filter(Boolean); const bx0 = Math.min(...full.rects.map(r => r.x0)), bx1 = Math.max(...full.rects.map(r => r.x1)); const bz0 = Math.min(...full.rects.map(r => r.z0)), bz1 = Math.max(...full.rects.map(r => r.z1)); const ground = Math.max(...full.units.map(u => u.layout.floorTop)) - (state.settings.groundLevel || 0); - const out = ObjWriter.build(roots, { - name: filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning', app: APP_VERSION, - origin: { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 } - }); + const name = filename.replace(/\.zip$/i, '').replace(/[^A-Za-z0-9_-]+/g, '_') || 'woning'; + const origin = { x: (bx0 + bx1) / 2000, y: ground / 1000, z: (bz0 + bz1) / 2000 }; + const out = ObjWriter.build(roots, { name, app: APP_VERSION, origin }); + const edge = full.roofEdge; + const lights = LightingModel.lightSources(full.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, full.lighting.indoor); + out.files.push({ name: `${name}_lampen.py`, data: new TextEncoder().encode(ObjWriter.blenderLights(lights, { name, app: APP_VERSION, origin })) }); ZipWriter.save(out.files, filename); - return out.stats; - }, (s) => `${s.triangles.toLocaleString('nl-NL')} driehoeken, ${s.materials} materialen, ${s.textures} texturen; uitpakken en in Blender importeren`); + return Object.assign(out.stats, { lights: lights.length, script: `${name}_lampen.py` }); + }, (s) => `${s.triangles.toLocaleString('nl-NL')} driehoeken, ${s.materials} materialen, ${s.textures} texturen, ${s.lights} lampen; uitpakken en in Blender ${s.script} uitvoeren (importeert de OBJ en plaatst de lampen)`); function triggerImport() { document.getElementById('h-file-import-json').click(); @@ -2820,13 +2929,22 @@ function mountHouseEditor(container, callbacks) { }); function forward(fn) { - return () => { if (activeEditor && typeof activeEditor[fn] === 'function') activeEditor[fn](); }; + return (...args) => { if (activeEditor && typeof activeEditor[fn] === 'function') return activeEditor[fn](...args); }; } + const has = (fn) => !!(activeEditor && typeof activeEditor[fn] === 'function'); + const FORWARDED = ['mirrorHorizontal', 'setView', 'toggleReport', 'toggleSection', 'fitView', 'toggleWalk']; + + function onPartKey(e) { + if (e.key !== 'Escape' || activePart === 'overzicht' || ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; + showPart('overzicht'); + } + window.addEventListener('keydown', onPartKey); showPart('overzicht'); return { unmount() { + window.removeEventListener('keydown', onPartKey); if (activeEditor && typeof activeEditor.unmount === 'function') activeEditor.unmount(); activeEditor = null; }, @@ -2836,9 +2954,14 @@ function mountHouseEditor(container, callbacks) { exportObj, triggerImport, mirrorHorizontal: forward('mirrorHorizontal'), - toggleFullscreen3D: forward('toggleFullscreen3D'), + setView: forward('setView'), + toggleReport: forward('toggleReport'), + toggleSection: forward('toggleSection'), + fitView: forward('fitView'), toggleWalk: forward('toggleWalk'), - toggleFullscreen2D: forward('toggleFullscreen2D'), + viewState: () => forward('viewState')() || {}, + // Doorgestuurde acties bestaan alleen als de actieve onderdeel-editor ze kent (bijv. Rapport alleen in de woning). + can: (fn) => !FORWARDED.includes(fn) || has(fn), openProjectProperties, getStateJson, loadStateJson diff --git a/js/lighting/lighting-model.js b/js/lighting/lighting-model.js index f3dc974..35d263f 100644 --- a/js/lighting/lighting-model.js +++ b/js/lighting/lighting-model.js @@ -324,32 +324,41 @@ window.LightingModel = (() => { const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 }; const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 }; const DAY_OUTDOOR_FACTOR = 0.35; - function buildLights3D(lamps, spots, spotY, indoor, o) { - const toM = 0.001; - const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1; - const group = new THREE.Group(); - const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => { - const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay); - 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); + // Lichtbronnen in meters: positie, richting (genormaliseerd), halve kegelhoek; gedeeld door 3D en render-export. + function lightSources(lamps, spots, spotY, indoor) { + const toM = 0.001, out = []; + const add = (kind, x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => { + const len = Math.hypot(tx, ty, tz) || 1; + out.push({ kind, x, y, z, dir: { x: tx / len, y: ty / len, z: tz / len }, intensity, angle, distance, decay, penumbra, ref }); }; const tilt = Math.tan(OUTDOOR.angle); lamps.forEach(l => { const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM; const nx = l.normal.x * tilt, nz = l.normal.z * tilt; - if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, 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 !== 'up') add('wandlamp', x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l); + if (l.direction !== 'down') add('wandlamp', x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l); }); (spots || []).forEach(p => { const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1; - add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra, 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 => { 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)); return group; @@ -450,5 +459,5 @@ window.LightingModel = (() => { 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 }; })(); diff --git a/js/wall/wall-model.js b/js/wall/wall-model.js index d3a32cb..4bf4400 100644 --- a/js/wall/wall-model.js +++ b/js/wall/wall-model.js @@ -751,8 +751,8 @@ window.WallModel = (() => { function build3D(CONFIG, calcData, opts) { const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials; const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek'; - const stage = BuildStages.clamp(opts.stage); - const show = (key) => BuildStages.shows(stage, key); + const layers = ViewLayers.normalize(opts.layers); + const show = (key) => layers[key]; const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating'); const buitenVisible = opts.buitenVisible !== false; const cladding = opts.cladding || 'horizontaal'; @@ -1311,7 +1311,7 @@ window.WallModel = (() => { } } - if (show('gevel') && window.KozijnModel) { + if (show('kozijnen') && window.KozijnModel) { const sh = CONFIG.sheathingThick, half = CONFIG.studDepth / 2; const exterior = buitenMateriaal === 'vochtdoek' || buitenMateriaal === 'cementplaat'; const place = exterior ? { zOut: half + sh, depth: KozijnModel.PROFILE.depth, exterior: true } @@ -1384,6 +1384,7 @@ window.WallModel = (() => { } }); } + if (!show('regelwerk')) ViewLayers.hideByMaterial(wallGroup3D, [woodMaterial]); return { group: wallGroup3D, diff --git a/js/wall/wall.js b/js/wall/wall.js index c984aac..80f8139 100644 --- a/js/wall/wall.js +++ b/js/wall/wall.js @@ -6,7 +6,10 @@ */ const WALL_TEMPLATE = ` -
+
+
+
+
@@ -24,8 +27,16 @@ const WALL_TEMPLATE = `
+
+
+
+
3D
+
+
+
+
-
+
@@ -34,6 +45,7 @@ const WALL_TEMPLATE = ` +
@@ -104,10 +116,6 @@ const WALL_TEMPLATE = `
mm
-
-
Verticale balken-
-
Horizontale balken-
-
Dakhelling-
@@ -138,17 +146,10 @@ const WALL_TEMPLATE = `
-
- - -
-
-
3D
-
-
+
-
+

Balken

@@ -188,6 +189,7 @@ const WALL_TEMPLATE = `
+
@@ -218,8 +220,8 @@ const WALL_TEMPLATE = `
@@ -333,13 +335,8 @@ function mountWallEditor(container, callbacks) { updateAll(); } - function toggleFullscreen3D() { - UI.cycle3D(document.getElementById('app-container')); - requestAnimationFrame(onWindowResize); - } - - function toggleFullscreen2D() { - UI.toggle2D(document.getElementById('app-container')); + function setView(mode) { + UI.setView(document.getElementById('app-container'), mode); requestAnimationFrame(onWindowResize); } @@ -1279,14 +1276,13 @@ function mountWallEditor(container, callbacks) { 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 cladding = 'horizontaal'; let trasraam = null; let stone = null; let stuc = null; let rabatColor = null; - let stage = BuildStages.FINAL; + let layers = ViewLayers.all(); function syncMaterialButtons() { document.querySelectorAll('.material-toggle-btn[data-material]').forEach(b => b.classList.toggle('active', b.dataset.material === buitenMateriaal)); document.querySelectorAll('.material-toggle-btn[data-cladding]').forEach(b => b.classList.toggle('active', b.dataset.cladding === cladding)); @@ -1303,13 +1299,17 @@ function mountWallEditor(container, callbacks) { let spaanplaatVisible = 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(); + const toolbar = document.getElementById('wall-toolbar'); + toolbar.innerHTML = `
${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}
+
${UI.tbSep()}${UI.viewSeg('split')}`; + toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal()); + toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); + const viewGroup = toolbar.querySelector('.tb-view'); + ViewLayers.createMenu(viewGroup, { + layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'], + onChange: (l) => { layers = l; update3DScene(); }, + extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }], + onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); } }); let skyMode = Skybox.getMode(); const skyEnv = Skybox.createEnvironment(scene); @@ -1317,15 +1317,14 @@ function mountWallEditor(container, callbacks) { skyEnv.fitTo(wallGroup3D, 0); Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D }); }; - Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); }); - BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); }); + Skybox.createToggle(viewGroup, (m) => { skyMode = m; applySky(); }); let lastWallStats = null; function update3DScene() { if (wallGroup3D) scene.remove(wallGroup3D); const result = WallModel.build3D(CONFIG, calcData, { - materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, stage + materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, stuc, rabatColor, spaanplaatVisible, buitenVisible, layers }); wallGroup3D = result.group; lastWallStats = result.stats; @@ -1419,8 +1418,8 @@ function mountWallEditor(container, callbacks) { exportDesign, triggerImport, mirrorHorizontal, - toggleFullscreen3D, - toggleFullscreen2D, + setView, + viewState: () => ({ view: UI.viewOf(document.getElementById('app-container')) }), openProjectProperties, getStateJson: getPureState, loadStateJson: applyPureState