2 Commits
Author SHA1 Message Date
MachielandClaude Opus 5.5 89cadbebf7 v0.7.2: fixes uit de code-review van v0.7.1: elektraleidingen nemen niet langer het valse plafond van de buurruimte door de wand heen over (gebied zonder functie = geen vals plafond) en meten begin/eind 50 mm de ruimte in, zodat de zigzag bij een overgang vals plafond/dak verdwijnt (Amstel 249,9 → 246,8 m, 16 van 68 leidingen strakker); elektra en schoorsteen snijden mee met de doorsnede (geen zwevende buizen); PDF en de plaatfase van de IFC bouwen geen buizen meer; schoorsteen in 2D en 3D op de laag Gevelbekleding; Esc sluit elke dialoog via Annuleren en sneltoetsen gaan niet meer naar de editor erachter; toets 3 sluit het rapport; menu en popovers sluiten elkaar (één uitklapper tegelijk); paneelkop ge-escaped; na slepen/pannen buiten de plattegrond wordt de volgende klik niet meer ingeslikt en blijft de cursor van het gereedschap; "Alles" in Lagen zet ook beplating binnen/buiten terug; laag uit met een selectie erin werkt het paneel bij; opgeruimd: gedeelde werkbalk (UI.editorBar) voor woning, wand en vloer, één knopbouwer (UI.btnOf), dichte paneelsecties pas opgebouwd bij openen, dode knopacties weg, binnenwand verwijderen op één plek, doorgestuurde acties uit één lijst, render-export "alleen afgewerkt" volledig in HouseModel (finishedOnly), structuurboom springt alleen bij een andere selectie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:01:13 +02:00
MachielandClaude Opus 5.5 ab0dca9bb5 v0.7.1: UI-overhaul, ribbon vervangen door de indeling van CAD/BIM-pakketten met elke functie op één plek: bovenbalk met menu Bestand (nieuw, openen, opslaan, exporteren, projectgegevens, kosten & tijden) · Beeld · Help, de ontwerpen als tabs en de knop Opslaan; links de structuur met inklapbare groepen (onthouden, groep met de selectie klapt vanzelf open); werkbalk boven de viewports met gereedschap (Selecteren V, Binnenwand W, Ruimte R, Inrichting I, opties van het gereedschap ernaast, Unit toevoegen) en weergave (één Lagen-menu met bouwfase voor 2D en 3D, Omgeving, Doorsnede X, Render, Lopen L, Rapport 4, 2D + 3D / 2D / 3D met 1/2/3); rechts het eigenschappenpaneel (Inspector: selectie met kruimelpad, of de woning met kerncijfers en inklapbare secties Gevel, Dak, Binnen, Verlichting, Elektra, Constructie); statusbalk met hint en meldingen; wand- en vloer/dak-editor in dezelfde indeling (paneel rechts met tab Resultaat, terug met ← Woning of Esc); sneltoetsenoverzicht (?), Ctrl+O, Ctrl+\ panelen verbergen; rapport als masonry; dubbele functies weg (lagenbalk, Beeld-tab en Weergave-filter, export en kosten op twee plekken, Snel nieuw); oude onderdelen gelijkgetrokken (dialoogknoppen, materiaalknoppen, chassis als schakelaar, eenheden); weergavelagen (ViewLayers) i.p.v. bouwfasen, elk los aan/uit; render-export met Blender-script voor de lampen; elektraleidingen boven een vals plafond en haaks binnen de units bij een L/T-vorm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:16:43 +02:00
23 changed files with 1580 additions and 1081 deletions
+49 -30
View File
@@ -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
`<naam>_lampen.py`: in Blender uitvoeren importeert de OBJ en zet elke lamp
(gevellamp, dakrandspot, binnenspot) als echte spotlight met dezelfde plek,
richting en kegel als in de app (`LightingModel.lightSources`). Meters, Y omhoog (de
standaard OBJ-import van Blender zet dat goed: noord = +Y), oorsprong op
maaiveld onder het midden. Kleuren omgerekend van sRGB naar lineair (de app
heeft geen kleurbeheer, Blender wel), PBR via `Pr`/`Pm`/`d`/`Ke` (ruwheid,
@@ -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
+318 -228
View File
@@ -39,30 +39,6 @@
}
#app-container { display: flex; width: 100%; height: 100%; }
#left-column {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
border-right: 1px solid var(--border);
}
#right-column {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
}
#app-container.fullscreen-3d #left-column { display: none; }
#app-container.fullscreen-2d #right-column { display: none; }
#pane-2d-container {
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); }
+19 -16
View File
@@ -22,15 +22,23 @@
<div class="splash-version"><span class="splash-name">Tectum</span> &mdash; Versie <span class="app-version-number">0.0.0</span></div>
</div>
<header id="ribbon">
<div class="rb-top">
<div class="rb-brand"><span class="splash-name">Tectum</span></div>
<div class="rb-tabs" role="tablist"></div>
<div class="rb-right"><span class="app-version-slot"></span></div>
<header id="topbar">
<div class="tb-brand"><span class="splash-name">Tectum</span></div>
<nav id="menubar" aria-label="Menu"></nav>
<div id="doc-tabs" role="tablist" aria-label="Geopende ontwerpen"></div>
<div class="top-right">
<button type="button" id="top-save" class="top-save" title="Opslaan als JSON + PDF (Ctrl+S)"><i class="bi bi-save"></i><span>Opslaan</span></button>
<span class="app-version-slot"></span>
</div>
<div class="rb-body"></div>
</header>
<div id="keys-overlay" class="dlg-overlay">
<div class="dlg" role="dialog" aria-labelledby="keys-title">
<div class="dlg-head"><h2 id="keys-title">Sneltoetsen en bediening</h2><button type="button" class="dlg-x" data-close>&times;</button></div>
<div class="keys-body" id="keys-body"></div>
</div>
</div>
<div id="about-overlay">
<div id="about-modal">
<img class="about-logo" src="assets/logo.svg" alt="Van Emous Mantelzorgwoningen">
@@ -42,14 +50,14 @@
<div class="license-box" id="about-license"></div>
<div class="about-footer">
<span>&copy; 2026 Machiel Heinen &middot; ontwikkeld in opdracht van Van Emous Mantelzorgwoningen</span>
<button id="about-close" class="about-close">Sluiten</button>
<button id="about-close" class="about-close" data-esc>Sluiten</button>
</div>
</div>
</div>
<div id="new-overlay">
<div id="new-modal">
<h2>Nieuw</h2>
<h2>Nieuw ontwerp</h2>
<div class="new-options">
<button class="new-option" data-kind="woning" data-template="single">
<svg viewBox="0 0 48 48"><rect x="8" y="14" width="32" height="20" fill="none" stroke="currentColor" stroke-width="2"/><line x1="8" y1="24" x2="40" y2="24" stroke="currentColor" stroke-width="1" stroke-dasharray="2 2"/></svg>
@@ -67,27 +75,22 @@
<svg viewBox="0 0 48 48"><rect x="6" y="6" width="36" height="36" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="18" x2="42" y2="18" stroke="currentColor" stroke-width="2"/><line x1="6" y1="30" x2="42" y2="30" stroke="currentColor" stroke-width="2"/><line x1="18" y1="6" x2="18" y2="42" stroke="currentColor" stroke-width="2"/><line x1="30" y1="6" x2="30" y2="42" stroke="currentColor" stroke-width="2"/></svg>
<span>Los: vloer</span>
</button>
<button class="new-option disabled" data-kind="dak" disabled title="Nog niet beschikbaar">
<svg viewBox="0 0 48 48"><path d="M4 30 L24 8 L44 30" fill="none" stroke="currentColor" stroke-width="2"/><line x1="10" y1="30" x2="38" y2="30" stroke="currentColor" stroke-width="2"/></svg>
<span>Dak</span>
</button>
</div>
</div>
</div>
<div id="tab-bar"></div>
<div id="workspace"></div>
<script src="js/core/license.js"></script>
<script src="js/core/logo-data.js"></script>
<script src="js/core/geometry.js"></script>
<script src="js/core/build-stages.js"></script>
<script src="js/core/view-layers.js"></script>
<script src="js/core/canvas-labels.js"></script>
<script src="js/core/camera-memory.js"></script>
<script src="js/core/walk-controls.js"></script>
<script src="js/core/view-filter.js"></script>
<script src="js/core/ui-kit.js"></script>
<script src="js/core/ribbon.js"></script>
<script src="js/core/menubar.js"></script>
<script src="js/core/inspector.js"></script>
<script src="js/core/skybox.js"></script>
<script src="js/vendor/jspdf.umd.min.js"></script>
<script src="js/core/pdf-sheet.js"></script>
+103 -84
View File
@@ -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.2';
// 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 = `<div id="workspace-empty">
<div>Geen ontwerp geopend.</div>
<div style="font-size:11px;">Bestand &rarr; Nieuw &rarr; kies een objecttype</div>
<div style="font-size:12px;">Bestand &rsaquo; Nieuw (Ctrl+N)</div>
</div>`;
}
function updateEditMenuState() {
Ribbon.refresh();
}
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function' && (!currentEditor.can || currentEditor.can(fn)));
const editorCall = (fn, ...args) => { if (editorHas(fn)) return currentEditor[fn](...args); };
const viewState = () => editorCall('viewState') || {};
const editorHas = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function');
const editorCall = (fn) => { if (editorHas(fn)) currentEditor[fn](); };
const disabledUnless = (fn) => editorHas(fn) ? '' : 'disabled';
function appRibbonTabs() {
const { big, small } = Ribbon;
const wireClicks = (body) => body.querySelectorAll('[data-app]').forEach(b => b.addEventListener('click', () => {
const a = b.dataset.app;
if (a === 'new') openNewModal();
else if (a === 'new-kind') openNew(b.dataset.kind, b.dataset.template);
else if (a === 'fullscreen') toggleBrowserFullscreen();
else if (a === 'costs') CostSettings.open();
else if (a === 'about') document.getElementById('about-overlay').classList.add('open');
else editorCall(a);
}));
return [
{ key: 'bestand', label: 'Bestand', order: 0, wire: wireClicks, groups: () => [
{ label: 'Document', html:
big('Nieuw', 'file-earmark-plus', 'data-app="new" title="Nieuw ontwerp (Ctrl+N)"') +
big('Openen', 'folder2-open', `data-app="triggerImport" title="JSON-ontwerp openen" ${disabledUnless('triggerImport')}`) +
big('Opslaan', 'save', `data-app="exportDesign" title="Opslaan als JSON + PDF (Ctrl+S)" ${disabledUnless('exportDesign')}`) },
{ label: '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')}`) }
// Menu met de sneltoets per item; KEY_HELP hieronder is het overzicht (Help › Sneltoetsen). Editor-acties werken alleen als de editor ze kent.
const MENUS = [
{ label: 'Bestand', items: [
{ label: 'Nieuw…', icon: 'file-earmark-plus', keys: 'Ctrl+N', run: openNewModal },
{ label: 'Openen…', icon: 'folder2-open', keys: 'Ctrl+O', run: () => editorCall('triggerImport'), enabled: () => editorHas('triggerImport') },
{ label: 'Opslaan (JSON + PDF)', icon: 'save', keys: 'Ctrl+S', run: () => editorCall('exportDesign'), enabled: () => editorHas('exportDesign') },
{ sep: true }, { head: 'Exporteren' },
{ label: 'CAD (DXF)', icon: 'vector-pen', run: () => editorCall('exportDxf'), enabled: () => editorHas('exportDxf') },
{ label: 'BIM (IFC)', icon: 'boxes', run: () => editorCall('exportIfc'), enabled: () => editorHas('exportIfc') },
{ label: 'Render (OBJ)', icon: 'badge-3d', run: () => editorCall('exportObj'), enabled: () => editorHas('exportObj') },
{ sep: true },
{ label: 'Projectgegevens…', icon: 'card-text', run: () => editorCall('openProjectProperties'), enabled: () => editorHas('openProjectProperties') },
{ label: 'Kosten & tijden…', icon: 'calculator', run: () => CostSettings.open() },
{ sep: true },
{ label: 'Ontwerp sluiten', icon: 'x-lg', run: () => activeTabId !== null && closeTab(activeTabId), enabled: () => activeTabId !== null }
] },
{ key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [
{ label: 'Venster', html:
small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') +
small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) +
small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) },
{ label: 'Camera', html:
big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) +
small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') }
{ label: 'Beeld', items: [
...UI.VIEWS.map(v => ({ label: v.label, icon: v.icon, keys: v.key1, run: () => editorCall('setView', v.key),
enabled: () => editorHas('setView'), checked: () => viewState().view === v.key })),
{ label: 'Rapport', icon: 'clipboard-data', keys: '4', run: () => editorCall('toggleReport'), enabled: () => editorHas('toggleReport'), checked: () => !!viewState().report },
{ sep: true },
{ label: 'Doorsnede', icon: 'scissors', keys: 'X', run: () => editorCall('toggleSection'), enabled: () => editorHas('toggleSection'), checked: () => !!viewState().section },
{ label: 'Plattegrond passend', icon: 'aspect-ratio', keys: 'F', run: () => editorCall('fitView'), enabled: () => editorHas('fitView') },
{ label: 'Lopen', icon: 'person-walking', keys: 'L', run: () => editorCall('toggleWalk'), enabled: () => editorHas('toggleWalk'), checked: () => !!viewState().walk },
{ sep: true },
{ label: 'Panelen verbergen', icon: 'layout-sidebar-inset', keys: 'Ctrl+\\', run: togglePanels, checked: () => document.body.classList.contains('panels-hidden') },
{ label: 'Volledig scherm', icon: 'fullscreen', keys: 'F11', run: toggleBrowserFullscreen, checked: () => !!document.fullscreenElement }
] },
{ key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [
{ label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) },
{ label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') },
{ label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') }
{ label: 'Help', items: [
{ label: 'Sneltoetsen en bediening', icon: 'keyboard', keys: '?', run: openKeys },
{ label: 'Over Tectum', icon: 'info-circle', run: () => document.getElementById('about-overlay').classList.add('open') }
] }
];
const KEY_HELP = [
['Bestand', [['Ctrl+N', 'Nieuw ontwerp'], ['Ctrl+O', 'Openen'], ['Ctrl+S', 'Opslaan (JSON + PDF)']]],
['Gereedschap (woning)', [['V', 'Selecteren'], ['W', 'Binnenwand tekenen'], ['R', 'Ruimte vullen'], ['I', 'Inrichting plaatsen'],
['Esc', 'Gereedschap stoppen, daarna selectie opheffen'], ['Del', 'Selectie verwijderen'], ['D / Shift+D', 'Inrichting draaien'], ['Dubbelklik', 'Onderdeel bewerken (wand, vloer)']]],
['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'],
['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]],
['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]],
['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts slepen', 'Verschuiven'], ['Scrollwiel', 'Zoomen']], true],
['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true],
['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]],
['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true]
];
function openKeys() {
// Muisacties als tekst, toetsen als <kbd>.
document.getElementById('keys-body').innerHTML = KEY_HELP.map(([title, rows, mouse]) =>
`<section><h3>${title}</h3>${rows.map(([k, d]) => `<div class="keys-row"><span>${mouse ? `<span class="keys-mouse">${k}</span>`
: k.split(' / ').map(x => `<kbd>${x}</kbd>`).join(' / ')}</span><span>${d}</span></div>`).join('')}</section>`).join('');
document.getElementById('keys-overlay').classList.add('open');
}
function togglePanels() {
document.body.classList.toggle('panels-hidden');
window.dispatchEvent(new Event('resize'));
}
function renderTabBar() {
const bar = document.getElementById('tab-bar');
const bar = document.getElementById('doc-tabs');
bar.innerHTML = tabs.map(tab => `
<div class="tab-item ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}">
<span class="tab-name">${tab.name}</span>
<span class="tab-close" data-close-id="${tab.id}">&times;</span>
<div class="doc-tab ${tab.id === activeTabId ? 'active' : ''}" data-tab-id="${tab.id}" role="tab" aria-selected="${tab.id === activeTabId}" title="${UI.esc(tab.name)}">
<span class="doc-name">${UI.esc(tab.name)}</span>
<button type="button" class="doc-close" data-close-id="${tab.id}" title="Sluiten" aria-label="Sluiten">&times;</button>
</div>
`).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,54 @@ function toggleBrowserFullscreen() {
else document.documentElement.requestFullscreen();
}
// Alle dialogen; Esc gebruikt hun Annuleren-knop (data-esc), zodat bijv. projectgegevens worden teruggezet.
const OVERLAYS = '.dlg-overlay.open, #about-overlay.open, #new-overlay.open, #house-project-overlay.open, #wall-project-overlay.open, #cost-settings-overlay.open';
function closeOverlays() {
const open = document.querySelector(OVERLAYS);
if (open) {
const cancel = open.querySelector('[data-esc]');
if (cancel) cancel.click(); else open.classList.remove('open');
return true;
}
return Menubar.close() || UI.closePopovers();
}
function wireHotkeys() {
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();
// Dialoog open: alleen Esc (sluiten); geen sneltoetsen naar de editor erachter. Typen in de dialoog blijft werken.
if (document.querySelector(OVERLAYS)) {
if (e.key === 'Escape') closeOverlays();
e.stopImmediatePropagation();
return;
}
if (e.ctrlKey && e.key.toLowerCase() === 'n') {
e.preventDefault();
openNewModal();
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 (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();
-62
View File
@@ -1,62 +0,0 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.BuildStages = (() => {
const LIST = [
{ key: 'regelwerk', label: 'Regelwerk', hint: 'Vloer-, wand- en dakbalken' },
{ key: 'elektra', label: 'Elektra', hint: 'Installatiebuizen door wanden en plafond, dozen en meterkast' },
{ key: 'beplating', label: 'Beplating', hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
{ key: 'doek', label: 'Vochtdoek', hint: 'Vochtdoek op de buitenzijde' },
{ key: 'latwerk', label: 'Latwerk', hint: 'Kruislat en trasraamplaat' },
{ key: 'gevel', label: 'Gevel & dak', hint: 'Rabat, steen, kozijnen, dakbedekking, sandwichpanelen' },
{ key: 'afwerking', label: 'Afgewerkt', hint: 'Dakrand, verlichting, vloerafwerking, verf binnen' }
];
const FINAL = LIST.length - 1;
const INDEX = Object.fromEntries(LIST.map((s, i) => [s.key, i]));
const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL;
const shows = (stage, key) => clamp(stage) >= INDEX[key];
function createBar(pane, stage, onChange) {
const el = document.createElement('div');
el.className = 'stage-bar';
el.innerHTML = `
<div class="stage-row">
<span class="stage-title"><i class="bi bi-layers"></i>Bouwfase</span>
<div class="stage-track">
<input type="range" class="stage-range" min="0" max="${FINAL}" step="1" value="${clamp(stage)}" aria-label="Bouwfase">
<div class="stage-ticks">${LIST.map((s, i) => `<span data-i="${i}" title="${s.label}: ${s.hint}">${i + 1}</span>`).join('')}</div>
</div>
<span class="stage-name"></span>
</div>
<div class="stage-extra"></div>`;
pane.appendChild(el);
const range = el.querySelector('.stage-range'), name = el.querySelector('.stage-name');
const sync = () => {
const i = clamp(Number(range.value));
name.textContent = LIST[i].label;
name.title = LIST[i].hint;
el.querySelectorAll('.stage-ticks span').forEach(t => t.classList.toggle('on', Number(t.dataset.i) <= i));
};
range.addEventListener('input', () => { sync(); onChange(clamp(Number(range.value))); });
el.querySelector('.stage-ticks').addEventListener('click', (e) => {
const t = e.target.closest('[data-i]');
if (!t) return;
range.value = t.dataset.i;
sync();
onChange(clamp(Number(range.value)));
});
// T, R, ... zijn sneltoetsen van de weergave, niet van de schuif.
range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); });
// Na slepen focus loslaten, zodat de pijltoetsen weer naar de weergave (lopen) gaan.
el.addEventListener('pointerup', (e) => { if (e.target.matches('input[type=range]')) setTimeout(() => e.target.blur(), 0); });
sync();
return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } };
}
return { LIST, FINAL, INDEX, clamp, shows, createBar };
})();
+70
View File
@@ -0,0 +1,70 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Eigenschappenpaneel rechts: eigenschappen van de selectie, of van de woning als er niets geselecteerd is.
// Een sectie = { key, label, icon, groups() → [{ label, html }], wire(el) }; elke sectie krijgt een eigen element,
// zodat wire() alleen de eigen knoppen koppelt.
window.Inspector = (() => {
const { icon, esc } = UI;
const big = UI.btnOf('ins-btn ins-big');
const small = UI.btnOf('ins-btn');
const kv = (label, value, unit) =>
`<div class="ins-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
const PREF_KEY = 'tectum.inspector.open';
let open;
try { open = new Set(JSON.parse(localStorage.getItem(PREF_KEY) || 'null') || ['gevel']); } catch (e) { open = new Set(['gevel']); }
const saveOpen = () => { try { localStorage.setItem(PREF_KEY, JSON.stringify([...open])); } catch (e) { } };
// Een groepskop gelijk aan de sectienaam (bijv. Elektra › Elektra) vervalt.
const groupsHtml = (groups, secLabel) => groups.filter(Boolean).map(g =>
`<div class="ins-group${g.cls ? ' ' + g.cls : ''}">${g.label && g.label.toLowerCase() !== String(secLabel || '').toLowerCase() ? `<h3>${g.label}</h3>` : ''}<div class="ins-group-body">${g.html}</div></div>`).join('');
// view = { crumbs: [{ label, attrs }], title, icon, sub, top: html, sections: [...], collapsible, onBack }
function render(el, view) {
const scrollKey = view.title + '|' + (view.sub || '');
const keepScroll = el._insKey === scrollKey ? el.scrollTop : 0;
el._insKey = scrollKey;
el.innerHTML = `<div class="ins-head">
${view.crumbs && view.crumbs.length ? `<div class="ins-crumbs">${view.crumbs.map(c => `<button type="button" class="ins-crumb" ${c.attrs || ''}>${esc(c.label)}</button>`).join(icon('chevron-right'))}${icon('chevron-right')}</div>` : ''}
<h2>${view.icon ? icon(view.icon) : ''}<span>${esc(view.title)}</span></h2>
${view.sub ? `<div class="ins-sub">${esc(view.sub)}</div>` : ''}
</div>
${view.top ? `<div class="ins-top">${view.top}</div>` : ''}
<div class="ins-sections"></div>`;
const host = el.querySelector('.ins-sections');
view.sections.filter(Boolean).forEach(sec => {
const node = document.createElement('section');
const collapsible = view.collapsible;
const isOpen = !collapsible || open.has(sec.key);
node.className = 'ins-section' + (collapsible ? ' ins-collapsible' : '') + (isOpen ? ' open' : '');
node.innerHTML = (collapsible ? `<button type="button" class="ins-section-head" aria-expanded="${isOpen}">${sec.icon ? icon(sec.icon) : ''}<span>${sec.label}</span>${icon('chevron-down')}</button>` : '') +
'<div class="ins-section-body"></div>';
host.appendChild(node);
// Een dichte sectie wordt pas opgebouwd (en gekoppeld) als hij opengaat.
let built = false;
const build = () => {
if (built) return;
built = true;
node.querySelector('.ins-section-body').innerHTML = groupsHtml(sec.groups ? sec.groups() : [], sec.label);
if (sec.wire) sec.wire(node);
};
if (isOpen) build();
if (collapsible) node.querySelector('.ins-section-head').addEventListener('click', () => {
const now = !node.classList.contains('open');
if (now) build();
node.classList.toggle('open', now);
node.querySelector('.ins-section-head').setAttribute('aria-expanded', now);
if (now) open.add(sec.key); else open.delete(sec.key);
saveOpen();
});
});
el.scrollTop = keepScroll;
}
return { big, small, kv, render };
})();
+62
View File
@@ -0,0 +1,62 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Menubalk: menu = { label, items: [{ label, icon, keys, run, enabled(), checked() } | { sep: true } | { head }] }.
// Beschikbaarheid wordt bij het openen bepaald; is een menu open, dan opent hover het buurmenu.
window.Menubar = (() => {
let root = null, menus = [], openIdx = -1;
function itemHtml(it, mi, ii) {
if (it.sep) return '<div class="mb-sep"></div>';
if (it.head) return `<div class="mb-head">${it.head}</div>`;
const off = it.enabled && !it.enabled();
const on = it.checked && it.checked();
return `<button type="button" class="mb-item${on ? ' checked' : ''}" data-mi="${mi}" data-ii="${ii}"${off ? ' disabled' : ''}>
<span class="mb-ico">${on ? '<i class="bi bi-check2"></i>' : it.icon ? `<i class="bi bi-${it.icon}"></i>` : ''}</span>
<span class="mb-label">${it.label}</span><span class="mb-keys">${it.keys || ''}</span></button>`;
}
function close() {
if (openIdx < 0) return false;
openIdx = -1;
UI.release(close);
root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open'));
root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; });
return true;
}
function openMenu(i) {
if (openIdx < 0) UI.claim(close);
else { root.querySelectorAll('.mb-menu').forEach(m => m.classList.remove('open')); root.querySelectorAll('.mb-drop').forEach(d => { d.innerHTML = ''; }); }
openIdx = i;
const wrap = root.querySelectorAll('.mb-menu')[i];
wrap.classList.add('open');
wrap.querySelector('.mb-drop').innerHTML = menus[i].items.map((it, ii) => itemHtml(it, i, ii)).join('');
}
function init(el, list) {
root = el;
menus = list;
el.innerHTML = menus.map((m, i) => `<div class="mb-menu"><button type="button" class="mb-top" data-menu="${i}">${m.label}</button><div class="mb-drop" role="menu"></div></div>`).join('');
el.addEventListener('click', (e) => {
e.stopPropagation();
const top = e.target.closest('[data-menu]');
if (top) { const i = Number(top.dataset.menu); if (openIdx === i) close(); else openMenu(i); return; }
const item = e.target.closest('.mb-item');
if (!item || item.disabled) return;
const it = menus[Number(item.dataset.mi)].items[Number(item.dataset.ii)];
close();
it.run();
});
el.addEventListener('mouseover', (e) => {
const top = e.target.closest('[data-menu]');
if (top && openIdx >= 0 && Number(top.dataset.menu) !== openIdx) openMenu(Number(top.dataset.menu));
});
}
return { init, close };
})();
+66 -1
View File
@@ -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 };
})();
-116
View File
@@ -1,116 +0,0 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.Ribbon = (() => {
const sources = {};
const SOURCE_ORDER = ['app', 'editor'];
let root = null, tabsEl = null, bodyEl = null;
let activeKey = null, lastPlainKey = null;
let auto = true;
let collapsed = false, notified = null;
const listeners = [];
const PREF_KEY = 'tectum.ribbon.collapsed';
const icon = (n) => `<i class="bi bi-${n}" aria-hidden="true"></i>`;
const big = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-big${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
const small = (label, iconName, attrs, cls) =>
`<button type="button" class="rb-btn rb-small${cls ? ' ' + cls : ''}" ${attrs || ''}>${icon(iconName)}<span>${label}</span></button>`;
const kv = (label, value, unit) =>
`<div class="rb-kv"><span>${label}</span><b>${value}${unit ? `<small>${unit}</small>` : ''}</b></div>`;
function allTabs() {
const byKey = new Map();
SOURCE_ORDER.concat(Object.keys(sources).filter(n => !SOURCE_ORDER.includes(n))).forEach(name => {
(sources[name] || []).forEach(t => {
if (!byKey.has(t.key)) byKey.set(t.key, { key: t.key, label: t.label, order: t.order || 0, contextual: !!t.contextual, icon: t.icon, parts: [] });
const m = byKey.get(t.key);
if (t.order !== undefined && name !== 'app') m.order = t.order;
if (t.label && name !== 'app') m.label = t.label;
if (t.contextual) m.contextual = true;
m.parts.push(t);
});
});
return Array.from(byKey.values()).sort((a, b) => (a.contextual - b.contextual) || (a.order - b.order));
}
function renderTabs(list) {
tabsEl.innerHTML = list.map(t =>
`<button type="button" class="rb-tab${t.key === activeKey ? ' active' : ''}${t.contextual ? ' rb-ctx' : ''}" data-rb-tab="${t.key}">${t.contextual && t.icon ? icon(t.icon) : ''}<span>${t.label}</span></button>`).join('');
}
function renderBody(list) {
const tab = list.find(t => t.key === activeKey);
if (!tab) { bodyEl.innerHTML = ''; return; }
const groups = [];
tab.parts.forEach(p => (p.groups ? p.groups() : []).forEach(g => g && groups.push(g)));
bodyEl.innerHTML = groups.map(g =>
`<section class="rb-group${g.cls ? ' ' + g.cls : ''}"><div class="rb-group-body">${g.html}</div><div class="rb-group-label">${g.label}</div></section>`).join('') +
`<button type="button" class="rb-collapse" title="Ribbon inklappen (dubbelklik op een tabblad)">${icon('chevron-up')}</button>`;
tab.parts.forEach(p => p.wire && p.wire(bodyEl));
}
function render() {
if (!root) return;
const list = allTabs();
if (!list.some(t => t.key === activeKey) || (auto && !list.some(t => t.key === activeKey && t.contextual))) {
const pick = [lastPlainKey, 'start'].find(k => list.some(t => t.key === k));
activeKey = pick || (list.find(t => !t.contextual) || {}).key || null;
}
renderTabs(list);
renderBody(list);
root.classList.toggle('rb-collapsed', collapsed);
if (notified !== activeKey) { notified = activeKey; listeners.forEach(fn => fn(activeKey)); }
}
function activate(key) {
const list = allTabs();
const tab = list.find(t => t.key === key);
if (!tab) return;
activeKey = key;
auto = false;
if (!tab.contextual) lastPlainKey = key;
if (collapsed) { collapsed = false; savePref(); }
render();
tab.parts.forEach(p => p.onShow && p.onShow());
}
function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { } }
function init(el) {
root = el;
try { collapsed = localStorage.getItem(PREF_KEY) === '1'; } catch (e) { collapsed = false; }
tabsEl = el.querySelector('.rb-tabs');
bodyEl = el.querySelector('.rb-body');
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest('[data-rb-tab]');
if (!b) return;
if (b.dataset.rbTab === activeKey && collapsed) { collapsed = false; savePref(); render(); return; }
activate(b.dataset.rbTab);
});
tabsEl.addEventListener('dblclick', (e) => {
if (!e.target.closest('[data-rb-tab]')) return;
collapsed = !collapsed; savePref(); render();
});
bodyEl.addEventListener('click', (e) => {
if (e.target.closest('.rb-collapse')) { collapsed = true; savePref(); render(); }
});
render();
}
function setSource(name, tabs, opts) {
sources[name] = tabs || [];
const ctx = (tabs || []).find(t => t.contextual);
if (opts && opts.focus && ctx) { activeKey = ctx.key; if (collapsed) { collapsed = false; savePref(); } }
render();
if (opts && opts.focus && ctx && ctx.onShow) ctx.onShow();
}
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv };
})();
+9 -15
View File
@@ -161,32 +161,26 @@ window.Skybox = (() => {
function createToggle(toolbar, onChange) {
let m = getMode();
const wrap = document.createElement('div');
wrap.className = 'view-filter';
wrap.innerHTML = `<button type="button" class="btn-3d-toggle vf-btn" title="Omgeving"></button>
<div class="vf-menu">${MODES.map(k => `<div class="vf-item vf-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</div>`).join('')}</div>`;
wrap.className = 'tb-pop-wrap';
wrap.innerHTML = `<button type="button" class="tb-btn" title="Omgeving in 3D"></button>
<div class="tb-pop">${MODES.map(k => `<button type="button" class="pop-option" data-mode="${k}"><i class="bi bi-${LABELS[k][0]}"></i>${LABELS[k][1]}</button>`).join('')}</div>`;
toolbar.appendChild(wrap);
const btn = wrap.querySelector('.vf-btn'), menu = wrap.querySelector('.vf-menu');
const btn = wrap.querySelector('.tb-btn'), menu = wrap.querySelector('.tb-pop');
const close = UI.popover(btn, menu);
const sync = () => {
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i>${LABELS[m][1]}<i class="bi bi-chevron-down"></i>`;
menu.querySelectorAll('.vf-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
btn.innerHTML = `<i class="bi bi-${LABELS[m][0]}"></i><span>${LABELS[m][1]}</span><i class="bi bi-chevron-down tb-caret"></i>`;
menu.querySelectorAll('.pop-option').forEach(o => o.classList.toggle('active', o.dataset.mode === m));
};
btn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('open'); });
menu.addEventListener('click', (e) => {
e.stopPropagation();
const opt = e.target.closest('.vf-option');
const opt = e.target.closest('.pop-option');
if (!opt) return;
menu.classList.remove('open');
close();
if (opt.dataset.mode === m) return;
m = opt.dataset.mode;
remember(m);
sync();
onChange(m);
});
const closeOnOutside = () => {
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
menu.classList.remove('open');
};
document.addEventListener('click', closeOnOutside);
sync();
return wrap;
}
+77 -12
View File
@@ -80,8 +80,10 @@ window.UI = (() => {
const info = (text) => `<div class="ui-info">${icon('info-circle')}<span>${text}</span></div>`;
const warn = (text) => `<div class="ui-warn">${icon('exclamation-triangle')}<span>${text}</span></div>`;
const grid = (...cols) => `<div class="ui-grid">${cols.map(c => `<div>${c}</div>`).join('')}</div>`;
const button = (label, iconName, attrs, cls) =>
`<button type="button" class="ui-btn${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}<span>${label}</span></button>`;
// Eén knopbouwer; base = de knopsoort (ui-btn, tb-btn, ins-btn …), cls = extra klassen.
const btnOf = (base) => (label, iconName, attrs, cls) =>
`<button type="button" class="${base}${cls ? ' ' + cls : ''}" ${attrs || ''}>${iconName ? icon(iconName) : ''}${label ? `<span>${label}</span>` : ''}</button>`;
const button = btnOf('ui-btn');
const actions = (...btns) => `<div class="ui-actions">${btns.join('')}</div>`;
const activeTabs = {};
@@ -128,16 +130,79 @@ 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 = btnOf('tb-btn');
const tbSep = () => '<span class="tb-sep"></span>';
const viewSeg = (mode) => `<div class="tb-seg" role="group" aria-label="Weergave">${VIEWS.map(v =>
tb('', v.icon, `data-view="${v.key}" title="${v.label} (${v.key1})"`, v.key === mode ? 'active' : '')).join('')}</div>`;
// Werkbalk van een editor: [left] … [Lagen, Omgeving] [after] | [2D + 3D, 2D, 3D]. Gedeeld door woning, wand en vloer.
// o: left/after (html), onResize, layers (opties voor ViewLayers.createMenu), onSky, beforeView(mode).
function editorBar(toolbar, root, o) {
toolbar.innerHTML = `${o.left || ''}<div class="tb-spacer"></div><div class="tb-group tb-view"></div>${o.after || ''}${tbSep()}${viewSeg(viewOf(root))}`;
const group = toolbar.querySelector('.tb-view');
if (o.layers) ViewLayers.createMenu(group, o.layers);
if (o.onSky) Skybox.createToggle(group, o.onSky);
const bar = {
setView(mode) {
if (o.beforeView) o.beforeView(mode);
setView(root, mode);
requestAnimationFrame(o.onResize);
},
viewState: () => ({ view: viewOf(root) })
};
toolbar.querySelectorAll('[data-view]').forEach(b => b.addEventListener('click', () => bar.setView(b.dataset.view)));
return bar;
}
// Eén uitklapper tegelijk (popover of menu): wie opent, sluit de vorige. claim bij openen, release bij sluiten.
let openPop = null;
function claim(close) { if (openPop && openPop !== close) openPop(); openPop = close; }
function release(close) { if (openPop === close) openPop = null; }
const closePopovers = () => { if (openPop) { openPop(); return true; } return false; };
document.addEventListener('click', () => closePopovers());
// Uitklappaneel onder een knop; sluit bij klik buiten, Esc of een andere uitklapper.
function popover(btn, panel) {
const close = () => { panel.classList.remove('open'); btn.classList.remove('open'); release(close); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (panel.classList.contains('open')) { close(); return; }
claim(close);
panel.classList.add('open');
btn.classList.add('open');
});
panel.addEventListener('click', (e) => e.stopPropagation());
return close;
}
// Kaarten verdelen over kolommen van min. minW breed; telkens in de kortste kolom. Opnieuw aanroepen bij een andere breedte.
function masonry(el, minW, gap) {
if (!el) return;
gap = gap === undefined ? 12 : gap;
const cards = el._cards || (el._cards = [...el.children]);
const n = Math.max(1, Math.floor((el.clientWidth + gap) / (minW + gap)));
if (el._cols === n) return;
el._cols = n;
el.classList.add('ui-masonry');
const cols = Array.from({ length: n }, () => { const c = document.createElement('div'); c.className = 'ui-masonry-col'; return c; });
el.replaceChildren(...cols);
cards.forEach(card => cols.reduce((a, b) => b.offsetHeight < a.offsetHeight ? b : a).appendChild(card));
}
return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, text, onText, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, btnOf,
VIEWS, viewOf, setView, tb, tbSep, viewSeg, editorBar, popover, claim, release, closePopovers, masonry };
})();
-43
View File
@@ -1,43 +0,0 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
function createViewFilter(toolbar, items, onChange) {
const state = {};
items.forEach(it => { state[it.key] = it.checked !== false; });
const wrap = document.createElement('div');
wrap.className = 'view-filter';
wrap.innerHTML = `
<button type="button" class="btn-3d-toggle vf-btn"><i class="bi bi-eye"></i>Weergave<i class="bi bi-chevron-down"></i></button>
<div class="vf-menu">
${items.map(it => `<label class="vf-item"><input type="checkbox" data-key="${it.key}" ${state[it.key] ? 'checked' : ''}> ${it.label}</label>`).join('')}
</div>`;
toolbar.insertBefore(wrap, toolbar.firstChild);
const menu = wrap.querySelector('.vf-menu');
const btn = wrap.querySelector('.vf-btn');
btn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('open');
btn.classList.toggle('active', menu.classList.contains('open'));
});
menu.addEventListener('click', (e) => e.stopPropagation());
menu.addEventListener('change', (e) => {
const key = e.target.dataset.key;
if (!key) return;
state[key] = e.target.checked;
onChange(Object.assign({}, state));
});
const closeOnOutside = () => {
if (!document.body.contains(wrap)) { document.removeEventListener('click', closeOnOutside); return; }
menu.classList.remove('open');
btn.classList.remove('active');
};
document.addEventListener('click', closeOnOutside);
return { state: () => Object.assign({}, state) };
}
+115
View File
@@ -0,0 +1,115 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.ViewLayers = (() => {
// phase: bouwvolgorde voor de snelkeuze "Fase" (en IFC); lagen zonder phase blijven daarbij aan.
const LIST = [
{ key: 'regelwerk', label: 'Regelwerk', icon: 'grid-3x3', phase: 0, hint: 'Vloer-, wand- en dakbalken, stalen nokbalk' },
{ key: 'elektra', label: 'Elektra', icon: 'lightning-charge', phase: 1, hint: 'Meterkast, buizen, dozen, schakelaars en wcd\'s' },
{ key: 'beplating', label: 'Beplating', icon: 'square', phase: 2, hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' },
{ key: 'doek', label: 'Vochtdoek', icon: 'droplet', phase: 3, hint: 'Vochtdoek op de buitenzijde' },
{ key: 'latwerk', label: 'Latwerk', icon: 'distribute-vertical', phase: 4, hint: 'Kruislat en trasraamplaat' },
{ key: 'gevel', label: 'Gevelbekleding', icon: 'bricks', phase: 5, hint: 'Rabat, steenstrips, stuc, schoorsteen, sandwichpanelen' },
{ key: 'kozijnen', label: 'Kozijnen', icon: 'window', phase: 5, hint: 'Kozijnen, deuren en glas' },
{ key: 'dakbedekking', label: 'Dakbedekking', icon: 'house-up', phase: 5, hint: 'Dakleer of riet' },
{ key: 'afwerking', label: 'Afwerking', icon: 'brush', phase: 6, hint: 'Vloerafwerking, verf, plafond, dakrand, dagkanten, logobord' },
{ key: 'lampen', label: 'Verlichting', icon: 'lightbulb', phase: 6, hint: 'Gevellampen, spots en binnenverlichting' },
{ key: 'inrichting', label: 'Inrichting', icon: 'lamp', phase: 6, hint: 'Sanitair, keuken, kasten, bed, zithoek' },
{ key: 'ruimtes', label: 'Ruimtes', icon: 'bounding-box', phase: 6, hint: 'Ruimtes met functie, vloer en wandpanelen' },
{ key: 'dak', label: 'Dak', icon: 'house', hint: 'Het hele dak met dakrand, overkapping en palen; uit om van boven in de woning te kijken' },
{ key: 'maten', label: 'Maten', icon: 'rulers', hint: 'Maatvoering in de plattegrond' }
];
const KEYS = LIST.map(l => l.key);
const PHASES = ['Regelwerk', 'Elektra', 'Beplating', 'Vochtdoek', 'Latwerk', 'Gevel & dak', 'Afgewerkt'];
const all = () => Object.fromEntries(KEYS.map(k => [k, true]));
const normalize = (layers) => Object.fromEntries(KEYS.map(k => [k, !layers || layers[k] !== false]));
const shows = (layers, key) => !layers || layers[key] !== false;
// Alles tot en met een bouwfase (0..6), zoals de vroegere bouwfase-schuif.
const upTo = (phase) => Object.fromEntries(LIST.map(l => [l.key, l.phase === undefined || l.phase <= phase]));
const phaseOf = (layers) => PHASES.findIndex((_, p) => LIST.every(l => l.phase === undefined || (l.phase <= p) === shows(layers, l.key)));
// Verbergt meshes met een van de materialen (ook afgeleide met userData.base), bijv. het regelwerk.
function hideByMaterial(group, materials) {
if (!group) return;
const set = new Set(materials.filter(Boolean));
group.traverse(o => {
if (!o.isMesh) return;
const m0 = Array.isArray(o.material) ? o.material[0] : o.material;
if (m0 && (set.has(m0) || set.has(m0.userData && m0.userData.base))) o.visible = false;
});
}
const GROUPS = [
['Casco', ['regelwerk', 'elektra', 'beplating', 'doek', 'latwerk']],
['Buiten', ['gevel', 'kozijnen', 'dakbedekking', 'dak']],
['Binnen', ['afwerking', 'lampen', 'inrichting', 'ruimtes']],
['Plattegrond', ['maten']]
];
// De enige plek voor zichtbaarheid: knop "Lagen" in de werkbalk met een uitklappaneel.
// keys beperkt de lagen (wand-editor); extras zijn editor-eigen schakelaars, bijv. beplating binnen/buiten.
function createMenu(toolbar, o) {
const items = o.keys ? LIST.filter(l => o.keys.includes(l.key)) : LIST;
const extras = o.extras || [];
const extra = Object.fromEntries(extras.map(x => [x.key, x.checked !== false]));
const check = (attr, key, label, hint, ic) =>
`<label class="pop-check" title="${hint || ''}"><input type="checkbox" ${attr}="${key}">${ic ? `<i class="bi bi-${ic}"></i>` : ''}<span>${label}</span></label>`;
const wrap = document.createElement('div');
wrap.className = 'tb-pop-wrap';
wrap.innerHTML = `<button type="button" class="tb-btn" title="Zichtbaarheid van lagen in 2D en 3D"><i class="bi bi-layers"></i><span>Lagen</span><i class="bi bi-chevron-down tb-caret"></i></button>
<div class="tb-pop pop-layers">
${items.length ? `<div class="pop-row">
<select class="pop-phase" title="Alles tot en met een bouwfase; Dak en Maten blijven zoals ze staan">
<option value="">Bouwfase…</option>${PHASES.map((p, i) => `<option value="${i}">${i + 1}. ${p}</option>`).join('')}
</select>
<button type="button" class="ui-btn" data-all>Alles</button>
</div>
<div class="pop-groups">${GROUPS.map(([label, keys]) => {
const g = items.filter(l => keys.includes(l.key));
return g.length ? `<div class="pop-group"><div class="pop-head">${label}</div>${g.map(l => check('data-layer', l.key, l.label, l.hint, l.icon)).join('')}</div>` : '';
}).join('')}</div>` : ''}
${extras.length ? `<div class="pop-group"><div class="pop-head">${o.extrasLabel || 'Weergave'}</div>${extras.map(x => check('data-extra', x.key, x.label, x.hint)).join('')}</div>` : ''}
</div>`;
toolbar.appendChild(wrap);
const btn = wrap.querySelector('.tb-btn'), panel = wrap.querySelector('.tb-pop');
UI.popover(btn, panel);
let cur = normalize(o.layers);
const sel = panel.querySelector('.pop-phase');
const sync = () => {
panel.querySelectorAll('[data-layer]').forEach(c => { c.checked = cur[c.dataset.layer]; });
panel.querySelectorAll('[data-extra]').forEach(c => { c.checked = extra[c.dataset.extra]; });
if (sel) { const p = phaseOf(cur); sel.value = p >= 0 ? String(p) : ''; }
const hidden = items.some(l => !cur[l.key]) || extras.some(x => !extra[x.key]);
btn.classList.toggle('flag', hidden);
};
const emit = () => { sync(); o.onChange && o.onChange(Object.assign({}, cur)); };
panel.addEventListener('change', (e) => {
const t = e.target;
if (t.dataset.layer) { cur = Object.assign({}, cur, { [t.dataset.layer]: t.checked }); emit(); }
else if (t.dataset.extra) { extra[t.dataset.extra] = t.checked; sync(); o.onExtra && o.onExtra(Object.assign({}, extra)); }
else if (t === sel && sel.value !== '') {
const keep = Object.fromEntries(LIST.filter(l => l.phase === undefined).map(l => [l.key, cur[l.key]]));
cur = Object.assign(upTo(Number(sel.value)), keep);
emit();
}
});
// Alles = ook de editor-eigen schakelaars weer aan.
const all = panel.querySelector('[data-all]');
if (all) all.addEventListener('click', () => {
const extraOff = extras.some(x => !extra[x.key]);
extras.forEach(x => { extra[x.key] = true; });
cur = ViewLayers.all();
emit();
if (extraOff && o.onExtra) o.onExtra(Object.assign({}, extra));
});
sync();
return { el: wrap, set: (l) => { cur = normalize(l); sync(); } };
}
return { LIST, KEYS, PHASES, GROUPS, all, normalize, shows, upTo, phaseOf, hideByMaterial, createMenu };
})();
+6 -6
View File
@@ -24,13 +24,13 @@ window.CostSettings = (() => {
<div class="cs-body"></div>
<div class="material-info">Bedragen excl. btw. De CSV's in kosten/ zijn leidend: Toepassen geldt tot herladen; om een wijziging te houden "intern.csv downloaden" en het bestand in kosten/ vervangen, of met "Wijzigingen downloaden" alleen de aangepaste velden (met de standaardwaarde ernaast) opslaan. Prijzen pas je aan in leveranciers.csv (CSV = daaruit, anders noodwaarde). Een veld dat afwijkt van intern.csv is gemarkeerd.</div>
<div class="modal-footer">
<button type="button" class="io-btn" data-cs="reset">Standaard</button>
<button type="button" class="io-btn" data-cs="export">intern.csv downloaden</button>
<button type="button" class="io-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
<button type="button" class="io-btn" data-cs="import">Importeren&hellip;</button>
<button type="button" class="ui-btn" data-cs="reset">Standaard</button>
<button type="button" class="ui-btn" data-cs="export">intern.csv downloaden</button>
<button type="button" class="ui-btn" data-cs="export-changes" title="Alleen de velden die afwijken van intern.csv">Wijzigingen downloaden</button>
<button type="button" class="ui-btn" data-cs="import">Importeren&hellip;</button>
<span style="flex:1"></span>
<button type="button" class="io-btn" data-cs="cancel">Annuleren</button>
<button type="button" class="material-toggle-btn active" data-cs="save" style="flex:none; padding:6px 18px;">Toepassen</button>
<button type="button" class="ui-btn" data-cs="cancel" data-esc>Annuleren</button>
<button type="button" class="ui-btn ui-btn-primary" data-cs="save">Toepassen</button>
</div>
<input type="file" data-file="import" accept=".csv,.json,text/csv,application/json,text/plain" style="display:none">
<input type="file" data-file="leveranciers" accept=".csv,text/csv,text/plain" style="display:none">
+128 -17
View File
@@ -143,9 +143,36 @@ 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 = in een wand of naad (geen gebied), null = geen
// vals plafond (ook een gebied zonder functie: niet het plafond van de buurruimte door de wand heen overnemen).
const roomPlenum = (x, z) => {
if (!rooms || !rooms.roomAt) return undefined;
const r = rooms.roomAt(x, z);
if (r) return r.ceilingActive ? r.ceiling : null;
return rooms.regionAt && rooms.regionAt(x, z) ? null : undefined;
};
// Absoluut; in een binnenwand het laagste valse plafond ernaast: de leiding gaat daar door de wand, niet omhoog.
const plenumAt = (x, z) => {
const u = unitAt(x, z);
if (!u) return null;
let v = roomPlenum(x, z);
if (v === undefined) {
v = null;
[[1, 0], [-1, 0], [0, 1], [0, -1]].forEach(([dx, dz]) => {
for (const d of [60, 120, 200, 300]) {
const o = roomPlenum(x + dx * d, z + dz * d);
if (o === undefined) continue;
if (o !== null) v = v === null ? o : Math.min(v, o);
break;
}
});
}
return v === null ? null : u.layout.floorTop + v;
};
const roofCeilAt = (x, z) => {
let u = unitAt(x, z);
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 +182,108 @@ 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));
// Begin/eind op een wandvlak 50 mm de ruimte in meten, net als top(): anders telt de buurruimte achter de wand mee.
const probe = (pt, e) => e.wall && e.wall.n ? { x: pt.x + e.wall.n.x * 50, z: pt.z + e.wall.n.z * 50 } : pt;
const S = Array.from({ length: n + 1 }, (_, k) => {
const pt = at(k / n), pr = k === 0 ? probe(pt, p) : k === n ? probe(pt, q) : pt, st = plenumOn(pr.x, pr.z);
return { t: k / n, pt, st, y: hOf(pt, st) };
});
for (let k = 1; k <= n; k++) {
const A = S[k - 1], B = S[k];
if (!same(A.st, B.st)) {
let lo = A.t, hi = B.t;
for (let it = 0; it < 10; it++) { const m = (lo + hi) / 2, pm = at(m); if (same(plenumOn(pm.x, pm.z), A.st)) lo = m; else hi = m; }
const pb = at((lo + hi) / 2);
out.push(mk(pb, hOf(pb, A.st)), mk(pb, hOf(pb, B.st)));
} else if (k < n && same(B.st, S[k + 1].st) && Math.abs(A.y - 2 * B.y + S[k + 1].y) > 2) out.push(mk(B.pt, B.y));
}
}
out.push(q);
});
return out;
}
const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; };
const 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 +312,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 +846,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 +861,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 +970,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);
+36 -54
View File
@@ -6,7 +6,10 @@
*/
const FLOOR_TEMPLATE = `
<div id="floor-app">
<div id="floor-app" class="ed-app">
<div class="ed-main">
<div class="ed-toolbar" id="floor-toolbar"></div>
<div class="ed-views">
<div id="floor-left-column">
<div id="floor-pane-2d-container">
<div class="pane-2d-sub" id="floor-pane-top">
@@ -18,7 +21,15 @@ const FLOOR_TEMPLATE = `
<canvas id="floor-canvas-section"></canvas>
</div>
</div>
<div id="floor-control-panel">
</div>
<div id="floor-right-column">
<div id="floor-pane-3d">
<div class="pane-label">3D</div>
</div>
</div>
</div>
</div>
<div id="floor-control-panel" class="ed-panel">
<div class="io-toolbar">
<input type="file" id="f-file-import-json" accept="application/json" style="display:none;">
<span id="f-io-status" class="io-status"></span>
@@ -27,6 +38,7 @@ const FLOOR_TEMPLATE = `
<button class="tab-btn active" data-tab="floor-tab-main"><i class="bi bi-rulers"></i> Opbouw</button>
<button class="tab-btn" data-tab="floor-tab-openings"><i class="bi bi-bullseye"></i> Doorvoeren</button>
<button class="tab-btn" data-tab="floor-tab-chassis"><i class="bi bi-truck"></i> Chassis</button>
<button class="tab-btn" data-tab="floor-tab-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
</div>
<div id="floor-tab-main" class="tab-content active">
@@ -57,20 +69,6 @@ const FLOOR_TEMPLATE = `
<div class="control-group"><label>Afstand max.</label>
<div><input type="number" id="f-inp-max-sp" value="600" step="10"><span class="unit">mm</span></div></div>
<div class="divider"></div>
<div class="result-row"><span>Vloerbalken</span><span data-spec="joist-count" class="highlight">-</span></div>
<div class="result-row"><span>Vloeroppervlak</span><span data-spec="floor-area" class="highlight">-</span></div>
<div class="result-row"><span>Volume balkhout</span><span data-spec="joist-m3" class="highlight">-</span></div>
<div class="result-row"><span>Gewicht balkhout</span><span data-spec="joist-kg" class="highlight">-</span></div>
<div class="divider"></div>
<div class="result-row"><span>OSB oppervlak</span><span data-spec="osb-m2" class="highlight">-</span></div>
<div class="result-row"><span>OSB volume</span><span data-spec="osb-m3" class="highlight">-</span></div>
<div class="result-row"><span>OSB gewicht</span><span data-spec="osb-kg" class="highlight">-</span></div>
<div class="divider"></div>
<div class="result-row"><span>Isolatie</span><span data-spec="void-m3" class="highlight">-</span></div>
<div class="result-row"><span>Totaal gewicht</span><span data-spec="total-kg" class="highlight">-</span></div>
</div>
</div>
</div>
@@ -82,9 +80,8 @@ const FLOOR_TEMPLATE = `
</div>
<div id="floor-tab-chassis" class="tab-content">
<div class="material-toggle-group">
<button type="button" id="f-btn-chassis-toggle" class="material-toggle-btn">Chassis-laag: Uit</button>
</div>
<div class="control-group"><label for="f-chassis-on">Chassis-laag</label>
<div><label class="ui-switch"><input type="checkbox" id="f-chassis-on"><span></span></label></div></div>
<div class="controls-grid">
<div>
<div class="control-group"><label>Wieldiameter</label>
@@ -99,17 +96,9 @@ const FLOOR_TEMPLATE = `
</div>
<div id="f-chassis-warning" class="global-warning" style="display:none;"></div>
</div>
</div>
</div>
<div id="floor-right-column">
<div id="floor-pane-3d">
<div class="pane-label">3D</div>
<div class="toolbar-3d" id="floor-toolbar-3d"></div>
</div>
<div id="floor-tab-specs" class="tab-content">
<div id="floor-specs-panel-3d">
<div class="controls-grid">
<div>
<div>
<div class="spec-section">
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
@@ -139,6 +128,7 @@ const FLOOR_TEMPLATE = `
</div>
</div>
</div>
</div>
<div id="wall-project-overlay">
<div id="wall-project-modal">
@@ -169,8 +159,8 @@ const FLOOR_TEMPLATE = `
</div>
</div>
<div class="modal-footer">
<button type="button" id="wall-project-cancel" class="io-btn">Annuleren</button>
<button type="button" id="wall-project-save" class="material-toggle-btn active" style="flex:none; padding:6px 18px;">Opslaan</button>
<button type="button" id="wall-project-cancel" class="ui-btn" data-esc>Annuleren</button>
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
</div>
</div>
</div>
@@ -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,19 +620,21 @@ 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();
});
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => {
skyEnv.fitTo(floorGroup3D, 0);
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient: floorAmbient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: floorGroup3D });
};
Skybox.createToggle(document.getElementById('floor-toolbar-3d'), (m) => { skyMode = m; applySky(); });
const bar = UI.editorBar(document.getElementById('floor-toolbar'), document.getElementById('floor-app'), {
onResize: () => onWindowResize(),
layers: {
layers: {}, keys: [],
extras: [{ key: 'beplating', label: roofMode ? 'Beplating (plafond + dakleer)' : 'Beplating (OSB-plaat)' }],
onExtra: (v) => { osbVisible = v.beplating; update3DScene(); }
},
onSky: (m) => { skyMode = m; applySky(); }
});
function update3DScene() {
if (floorGroup3D) scene.remove(floorGroup3D);
@@ -691,14 +681,6 @@ 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'));
requestAnimationFrame(onWindowResize);
}
function getPureState() {
return {
@@ -748,8 +730,8 @@ function mountFloorEditor(container, callbacks) {
},
exportDesign,
triggerImport,
toggleFullscreen3D,
toggleFullscreen2D,
setView: bar.setView,
viewState: bar.viewState,
openProjectProperties,
getStateJson: getPureState,
loadStateJson: applyPureState
+5 -4
View File
@@ -49,19 +49,20 @@ window.HouseIfc = (() => {
};
}
function build(state, stage) {
// rough: buizen en dozen bouwen; de plaatfase verzamelt alleen platen en heeft ze niet nodig.
function build(state, layers, rough) {
const M = materialsSet();
const parts = HouseModel.computeParts(state, {
wallMaterials: M.wall, floorMaterials: M.floor, roofMaterials: M.roof, elektraMaterials: M.elektra, postMaterials: M.post,
stage, lampenVisible: false
layers, elektraRough: rough
});
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), true);
const sheet = build(state, ViewLayers.upTo(2), false);
const parts = frame.parts;
if (!parts.units.length) throw new Error('geen units in het ontwerp');
const bx0 = Math.min(...parts.rects.map(r => r.x0)), bz1 = Math.max(...parts.rects.map(r => r.z1));
+32 -12
View File
@@ -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.finishedOnly ? false : 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,12 +1717,23 @@ 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 };
const parts = { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup, posts, postsGroup, chimneys, chimneysGroup, rooms, roomsGroup, fixtures, fixturesGroup };
if (build && o.finishedOnly) keepFinished(parts, o.wallMaterials);
return parts;
}
// finishedOnly (render-export): alleen wat in de afgewerkte woning te zien is. Naast de buitenste vloerbalken en geen
// elektraleidingen (hierboven): geen isolatie, latten en vochtdoek achter de gevel, geen lichtkegels, valse plafonds altijd.
function keepFinished(parts, wallMaterials) {
if (wallMaterials) allWallPieces(parts).forEach(p => ViewLayers.hideByMaterial(p.group,
[wallMaterials.isolatieMaterial, wallMaterials.battenMaterial, wallMaterials.doekMaterial]));
if (parts.lightingGroup) parts.lightingGroup.traverse(x => { if (x.userData.beam) x.visible = false; });
if (parts.roomsGroup && parts.roomsGroup.userData.ceilings) parts.roomsGroup.userData.ceilings.visible = true;
}
// 3D-groepen op hun plek in de woning (m); elektra, palen en dakrand staan al in woningcoördinaten.
+2 -1
View File
@@ -762,7 +762,8 @@ window.HousePdf = (() => {
function generate(state) {
const parts = HouseModel.computeParts(state, {
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials()
wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(),
elektraRough: false
});
const totals = HouseModel.computeTotals(parts);
const sheets = sheetList(parts);
+413 -314
View File
File diff suppressed because it is too large Load Diff
+25 -16
View File
@@ -324,32 +324,41 @@ window.LightingModel = (() => {
const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
const 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 };
})();
+4 -3
View File
@@ -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,
+36 -42
View File
@@ -6,7 +6,10 @@
*/
const WALL_TEMPLATE = `
<div id="app-container">
<div id="app-container" class="ed-app">
<div class="ed-main">
<div class="ed-toolbar" id="wall-toolbar"></div>
<div class="ed-views">
<div id="left-column">
<div id="pane-2d-container">
<div class="pane-2d-sub pane-2d-primary" id="pane-2d-front">
@@ -23,9 +26,17 @@ const WALL_TEMPLATE = `
<canvas id="canvas-2d-side"></canvas>
</div>
</div>
</div>
</div>
<div id="right-column">
<div id="pane-3d">
<div class="pane-label">3D</div>
</div>
</div>
</div>
</div>
<div id="control-panel">
<div id="control-panel" class="ed-panel">
<div class="io-toolbar">
<input type="file" id="file-import-json" accept="application/json" style="display:none;">
<span id="io-status" class="io-status"></span>
@@ -34,6 +45,7 @@ const WALL_TEMPLATE = `
<button class="tab-btn active" data-tab="tab-wall"><i class="bi bi-rulers"></i> Opbouw</button>
<button class="tab-btn" data-tab="tab-opening"><i class="bi bi-door-open"></i> Openingen</button>
<button class="tab-btn" data-tab="tab-materials"><i class="bi bi-layers"></i> Materiaal</button>
<button class="tab-btn" data-tab="tab-specs"><i class="bi bi-clipboard-data"></i> Resultaat</button>
</div>
<!-- TAB 1: WAND -->
@@ -104,10 +116,6 @@ const WALL_TEMPLATE = `
<div><input type="number" id="inp-max-sp" value="480" step="10" min="150" max="1500"><span class="unit">mm</span></div>
</div>
<div class="divider"></div>
<div class="result-row"><span>Verticale balken</span><span data-spec="vert-beams" class="highlight">-</span></div>
<div class="result-row"><span>Horizontale balken</span><span data-spec="horiz-beams" class="highlight">-</span></div>
<div class="result-row"><span>Dakhelling</span><span data-spec="roof-angle" class="highlight">-</span></div>
</div>
</div>
</div>
@@ -138,17 +146,10 @@ const WALL_TEMPLATE = `
</div>
</div>
</div>
</div>
<div id="right-column">
<div id="pane-3d">
<div class="pane-label">3D</div>
<div class="toolbar-3d" id="wall-toolbar-3d"></div>
</div>
<div id="tab-specs" class="tab-content">
<div id="specs-panel-3d">
<div class="controls-grid">
<div>
<div>
<div class="spec-section">
<h1><i class="bi bi-columns-gap"></i> Balken</h1>
@@ -188,6 +189,7 @@ const WALL_TEMPLATE = `
</div>
</div>
</div>
</div>
<div id="wall-project-overlay">
<div id="wall-project-modal">
@@ -218,8 +220,8 @@ const WALL_TEMPLATE = `
</div>
</div>
<div class="modal-footer">
<button type="button" id="wall-project-cancel" class="io-btn">Annuleren</button>
<button type="button" id="wall-project-save" class="material-toggle-btn active" style="flex:none; padding:6px 18px;">Opslaan</button>
<button type="button" id="wall-project-cancel" class="ui-btn" data-esc>Annuleren</button>
<button type="button" id="wall-project-save" class="ui-btn ui-btn-primary">Opslaan</button>
</div>
</div>
</div>
@@ -333,16 +335,6 @@ function mountWallEditor(container, callbacks) {
updateAll();
}
function toggleFullscreen3D() {
UI.cycle3D(document.getElementById('app-container'));
requestAnimationFrame(onWindowResize);
}
function toggleFullscreen2D() {
UI.toggle2D(document.getElementById('app-container'));
requestAnimationFrame(onWindowResize);
}
const PROJECT_PROP_IDS = ['inp-proj-naam', 'inp-proj-plaats', 'inp-proj-straat', 'inp-proj-nummer',
'inp-proj-tekenaar', 'inp-proj-titel', 'inp-proj-element', 'inp-proj-opmerking'];
let projectPropsSnapshot = null;
@@ -1279,14 +1271,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,29 +1294,32 @@ 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();
});
let skyMode = Skybox.getMode();
const skyEnv = Skybox.createEnvironment(scene);
const applySky = () => {
skyEnv.fitTo(wallGroup3D, 0);
Skybox.apply(skyMode, { env: skyEnv, grid, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: wallGroup3D });
};
Skybox.createToggle(document.getElementById('wall-toolbar-3d'), (m) => { skyMode = m; applySky(); });
BuildStages.createBar(pane3D, stage, (st) => { stage = st; update3DScene(); });
const toolbar = document.getElementById('wall-toolbar');
const bar = UI.editorBar(toolbar, document.getElementById('app-container'), {
left: `<div class="tb-group tb-tools">${UI.tb('Spiegelen', 'symmetry-vertical', 'data-wt="mirror" title="Horizontaal spiegelen (Shift+H)"')}</div>`,
onResize: onWindowResize,
layers: {
layers, keys: ['regelwerk', 'beplating', 'doek', 'latwerk', 'gevel', 'kozijnen', 'afwerking'],
onChange: (l) => { layers = l; update3DScene(); },
extras: [{ key: 'binnen', label: 'Beplating binnenzijde' }, { key: 'buiten', label: 'Beplating buitenzijde' }],
onExtra: (v) => { spaanplaatVisible = v.binnen; buitenVisible = v.buiten; update3DScene(); }
},
onSky: (m) => { skyMode = m; applySky(); }
});
toolbar.querySelector('[data-wt="mirror"]').addEventListener('click', () => mirrorHorizontal());
let lastWallStats = null;
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 +1413,8 @@ function mountWallEditor(container, callbacks) {
exportDesign,
triggerImport,
mirrorHorizontal,
toggleFullscreen3D,
toggleFullscreen2D,
setView: bar.setView,
viewState: bar.viewState,
openProjectProperties,
getStateJson: getPureState,
loadStateJson: applyPureState