diff --git a/README.md b/README.md index e9e4374..a8ff6d9 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,16 @@ build-stap. Direct openen via `file://` werkt niet (texturen/lucht). ## Structuur ``` -index.html app-shell: menubalk, splashscreen +index.html app-shell: ribbon, splashscreen css/app.css alle stijl js/vendor/ three.js r128, eigen OrbitControls, jsPDF -js/core/ gedeelde helpers: geometrie, UI-kit, PDF-bladen, labels, - camerageheugen, weergavefilter, skybox, licentie, logo +js/core/ gedeelde helpers: geometrie, bouwfasen, ribbon, UI-kit, PDF-bladen, + labels, camerageheugen, weergavefilter, skybox, licentie, logo js/wall/ WallModel (logica) + wand-PDF + wand-editor js/floor/ FloorModel (logica) + vloer-PDF + vloer-editor js/roof/ RoofModel: dak (plat/lessenaar/zadel) + dakrand (boeidelen/daktrim) js/lighting/ LightingModel: gevellampen, boeideel-spots, binnenspots +js/elektra/ ElektraModel + ElektraPdf: meterkast, schakelaars (letters), wcd, groepen, leidingen js/sandwich/ SandwichModel: sandwichpanelen rondom js/unit/ UnitModel: 1 vloer + 4 wanden, naden, wandstukken js/house/ HouseModel (woning) + woning-PDF + woning-editor @@ -44,6 +45,15 @@ 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 + 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. +- **Invoer volgt de logica:** de dakvorm is een eigenschap van de unit + (plat/lessenaar/zadel, richting, goot- en nokhoogte of helling); de + wanden volgen. ## Functionaliteit @@ -52,9 +62,37 @@ assets/ logo, texturen, skybox 38x235 met dubbele hoekstijlen en een dubbele stijl naast deuren, zaagplan op handelslengtes, spaanplaat binnen, vochtdoek (met kruislat en trasraam) of cementplaat buiten. -- **Wandhoogte:** schuine bovenrand of middenpunt (nok) met goothoogtes. In - een unit bepaalt de laatst gewijzigde wand de dakvorm: de tegenoverliggende - wand spiegelt mee, de haakse wanden worden vlak op de hoekhoogtes. +- **Elektra** (`elektra/`): meterkast (3×25 A) bij de voordeur, schakelaars + met letter (A binnen, B buiten, per unit een eigen; dezelfde letter bij de + lampen erop), dubbele wcd's per unit, apparaten met eigen groep (kookgroep + perilex, wasmachine, droger, warmtepomp, optioneel oven/vaatwasser). + Groepenverklaring met fasen en aardlekschakelaars, leidingen via stijlen + en plafond naar de meterkast (strekkende meters per type, draad, buis, + steekverbinders op module-naden), materiaallijst en eigen PDF-blad. + Alles verplaatsbaar door te slepen in de plattegrond. +- **Kozijnen** (`wall/kozijn-model.js`): per opening een vulling (geen, raam + met vast glas en optioneel ventilatierooster, of deur met dorpel en + optionele glasstrook; zij- en bovenlicht vullen de rest van een grote + opening), kleur buiten en binnen (wit, grijs, antraciet, zwart). Volgt de + maat van de opening, ook een schuine bovenzijde. +- **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat, + lessenaar, zadel, mansarde of wolfsdak, met schuiven en hellingshoek). + Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde + met twee knikken, bij een wolfsdak afgeknot op het wolfseind, met + daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een + rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het + bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers, + nokbalk en kopbalken (ook in zaaglijst, specificaties en kosten). + Goot en nok gelden over de volle vloermaat (goot boven de buitenkant van + de gevel); staat de kopgevel tussen de haakse wanden, dan loopt zijn + daklijn daar precies op aan. Knik en wolfseind schalen mee met goot en + nok (verhouding). Bij een knik (mansarde) sluiten de dakvlakken in + verstek aan. De PDF heeft voor een wolfsdak een eigen blad met het + bovenste deel (bovenaanzicht houtwerk + stuklijst). + Grenzen (`UnitModel.LIMITS`): nok max 5 m, goot / plat dak max 3 m (vanaf + bovenkant vloer tot onderkant dak), unit max 4,3 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen + boeideel (versprongen, het lage loopt door tot onder het hoge). Wijzig je een + wandhoogte in de wand-editor, dan geldt dezelfde regel vanuit die wand. - **Vloer:** balken, cirkelvormige doorvoeren, OSB, optioneel chassis; in de woning een vloerafwerking (textuur, bijv. visgraat). - **Woning:** units koppelen aan een zijde (uitlijning + verschuiving); op @@ -80,16 +118,29 @@ assets/ logo, texturen, skybox het hoge, of langs een zijgevel tot de volle overstek (onder de hoge rand). Boeideel en daktrim steken 80 mm boven het dakleer uit. - **Gevel:** gevelbekleding per woning, met override per wand: rabat - (vochtdoek + kruislat) of steen (181 texturen). Steen is overal een + (vochtdoek + kruislat + rabat 12 mm, kleur per woning, getekend als + plankentextuur die over naden doorloopt) of steen (181 texturen). Steen is overal een steenstrip op een wit isolatieblok, samen 110 mm (`WallModel.STONE`). Het trasraam (cementplaat of een eigen steen) komt onder beide, dus twee stenen kan. Latwerk, doek en metselwerk lopen per gevel door over unit-naden. Sandwichpanelen rondom, 600 mm de grond in. - **Verlichting:** boeideel-spots of gevellampen (per gevel verdeeld, override per wand) en binnenspots in een raster per unit. -- **3D:** weergavefilters (o.a. Vloerplan: wanden afgesneden op 500 mm, - snede wit afgedekt), binnenzijde van de wanden in een verfkleur (globaal), - omgeving Ontwerp/Dag/Nacht, Render met lampen als echte lichtbronnen. +- **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. +- **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). @@ -98,8 +149,10 @@ assets/ logo, texturen, skybox - Tijdelijk (verwijderen voor productie): zaagmachine-overzicht (zaagmeters bij 2400 m/u, zaagsneden, hoeken) in de app en op het totalenblad (`SAW_M_PER_H`). -- Tijdelijk: een nieuwe woning is het testontwerp (L-vorm, lessenaar met - riet); uit te zetten met `TEST_DEFAULT` in `js/house/house-model.js`. +- Tijdelijk: een nieuwe woning is het voorbeeldontwerp "Vlinderwoning" + (92 m², drie units, vlinderdak met riet + platte tuinkamer, ook als + `json/premade/vlinderwoning.json`); uit te zetten met `TEST_DEFAULT` in + `js/house/house-model.js`. - Units kunnen niet gedraaid worden. - Getekende binnenwanden volgen de dakvorm (nog) niet. - De dak-editor toont het dek in de plattegrond (niet per schuin dakvlak). diff --git a/assets/README.txt b/assets/README.txt index 0adc399..ca5abe9 100644 --- a/assets/README.txt +++ b/assets/README.txt @@ -7,3 +7,6 @@ Assets voor de 3D-weergave en de PDF: naadloos), voorvertoning in thumb/32|64|128/*.png (WallModel.TRASRAAM_STONES) - textures/vloer/ vloerafwerking (FloorModel.FINISHES), bijv. visgraat_001.jpg (1500x1500, tegel 3 m); thumb/32|64|128/*.png - textures/dak/ dakbedekking: riet_.jpg (1024x1024, tegel 1 m); nummers in RoofModel RIET.nrs; thumb/32|64|128/*.png +- audio/five-armies.mp3 soundtrack easter egg (Ctrl+Shift+Alt+L shooter; core/granny-kit.js): + "Five Armies" Kevin MacLeod (incompetech.com), licensed under + Creative Commons: By Attribution 4.0 — https://creativecommons.org/licenses/by/4.0/ diff --git a/assets/audio/five-armies.mp3 b/assets/audio/five-armies.mp3 new file mode 100644 index 0000000..a732acb Binary files /dev/null and b/assets/audio/five-armies.mp3 differ diff --git a/css/app.css b/css/app.css index 07e0bd3..3f89b97 100644 --- a/css/app.css +++ b/css/app.css @@ -236,6 +236,7 @@ position: relative; } .opening-card.opening-selected { background: var(--primary-soft); border-color: var(--primary); } + .opening-fill { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border); } .opening-card.opening-warn { border-color: var(--danger); box-shadow: 0 0 0 1px var(--danger) inset; @@ -298,56 +299,10 @@ .material-toggle-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; } .material-toggle-btn:hover:not(.active) { border-color: var(--primary); color: var(--primary); } /* ============================================================ - APP SHELL: menubalk, workspace-wrapper, splashscreen, about-modal + APP SHELL: ribbon, workspace-wrapper, splashscreen, about-modal ============================================================ */ html, body { height: 100%; } -#app-bar { - height: 40px; - background: var(--van-emous-blue); - border-bottom: 1px solid var(--border); - display: flex; - align-items: center; - padding: 0 4px; - position: relative; - z-index: 100; - user-select: none; -} -#app-bar .menu-group { position: relative; } -#app-bar .menu-btn { - background: none; border: none; color: var(--nav-link-color); font-size: 13px; - padding: 8px 14px; cursor: pointer; border-radius: 4px; -} -#app-bar .menu-btn:hover, #app-bar .menu-btn.open { background: var(--surface); color: var(--primary); } -#app-bar .menu-dropdown { - position: absolute; top: 100%; left: 0; background: var(--surface); border: 1px solid var(--border); - border-radius: 0 0 6px 6px; min-width: 220px; display: none; box-shadow: 0 8px 20px rgba(33, 33, 33, .15); -} -#app-bar .menu-dropdown.open { display: block; } -#app-bar .menu-item { - padding: 9px 16px; font-size: 13px; color: var(--nav-link-color); cursor: pointer; position: relative; - display: flex; justify-content: space-between; align-items: center; -} -#app-bar .menu-item:hover { background: var(--primary-soft); color: var(--primary); } -#app-bar .menu-hotkey { color: var(--text-faint); font-size: 11px; margin-left: 16px; } -#app-bar .menu-item.disabled { color: #b0b8c2; cursor: not-allowed; pointer-events: none; } -#app-bar .menu-item.disabled:hover { background: none; color: #b0b8c2; } -#app-bar .submenu { - position: absolute; left: 100%; top: -1px; background: var(--surface); border: 1px solid var(--border); - border-radius: 6px; min-width: 200px; display: none; box-shadow: 0 8px 20px rgba(33, 33, 33, .15); -} -#app-bar .menu-item:hover .submenu, #app-bar .submenu.open { display: block; } -#app-bar .submenu .menu-item { color: var(--nav-link-color); } -#app-bar .submenu .menu-item:hover { background: var(--primary-soft); color: var(--primary); } - -#app-bar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; padding-right: 6px; } -#app-version-tag { font-size: 11px; color: var(--text-muted); } -#btn-about { - background: var(--surface); border: 1px solid var(--border); color: var(--nav-link-color); font-size: 12px; - padding: 5px 10px; border-radius: 4px; cursor: pointer; -} -#btn-about:hover { border-color: var(--primary); color: var(--primary); } - #tab-bar { height: 34px; background: var(--van-emous-ultra-light); @@ -368,7 +323,9 @@ html, body { height: 100%; } } .tab-item .tab-close:hover { color: var(--danger); } -#workspace { width: 100vw; height: calc(100vh - 40px - 34px); position: relative; } +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; color: var(--text-faint); font-size: 14px; flex-direction: column; gap: 10px; @@ -435,6 +392,31 @@ html, body { height: 100%; } #wall-project-modal .modal-footer, #house-project-modal .modal-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; } #wall-project-modal .control-group input, #house-project-modal .control-group input { width: 150px; text-align: left; } +/* --- Kosten & tijden (CostSettings) --- */ +#cost-settings-overlay { + position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500; + display: none; align-items: center; justify-content: center; +} +#cost-settings-overlay.open { display: flex; } +#cost-settings-modal { + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px; + width: 760px; max-width: 94%; max-height: 90vh; display: flex; flex-direction: column; + box-shadow: 0 12px 30px rgba(33, 33, 33, .2); +} +#cost-settings-modal .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } +#cost-settings-modal .modal-header h2 { margin: 0; font-size: 15px; color: var(--primary); } +#cost-settings-modal .modal-close-x { background: none; border: none; color: var(--text-faint); font-size: 19px; line-height: 1; cursor: pointer; padding: 0 4px; } +#cost-settings-modal .modal-close-x:hover { color: var(--danger); } +#cost-settings-modal .modal-footer { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; } +#cost-settings-modal .cs-body { overflow-y: auto; min-height: 0; flex: 1; columns: 2 340px; column-gap: 24px; } +#cost-settings-modal .cs-group { break-inside: avoid; margin-bottom: 14px; } +#cost-settings-modal .cs-group h3 { font-size: 12px; color: var(--primary); text-transform: uppercase; letter-spacing: .5px; margin: 0 0 6px; } +#cost-settings-modal .cs-row { display: grid; grid-template-columns: 1fr 84px 48px; gap: 6px; align-items: center; font-size: 12px; padding: 2px 0; } +#cost-settings-modal .cs-row input, #cost-settings-modal .cs-row select { width: 100%; text-align: right; } +#cost-settings-modal .cs-row select { text-align: left; } +#cost-settings-modal .cs-unit { color: var(--text-faint); font-size: 11px; } +#cost-settings-modal .cs-row.changed .cs-label { color: var(--primary); font-weight: bold; } + #new-overlay { position: fixed; inset: 0; background: rgba(33, 33, 33, .45); z-index: 500; display: none; align-items: center; justify-content: center; @@ -508,7 +490,6 @@ html, body { height: 100%; } .hn-item.disabled:hover { background: none; color: #9aa4b0; } .hn-actions { display: flex; gap: 6px; margin-top: 10px; } .hn-actions .io-btn { flex: 1; } -.hn-soon { margin-top: 10px; border-top: 1px solid var(--border); padding-top: 6px; } #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; } @@ -516,20 +497,31 @@ html, body { height: 100%; } #house-part-host > #floor-app, #house-part-host > #house-overview { flex: 1; min-height: 0; height: auto; } +/* Overzicht: plattegrond en 3D over de volle breedte (smal: onder elkaar); + bediening in de ribbon. Tabblad Rapport: rapport i.p.v. plattegrond. */ #house-overview { display: flex; width: 100%; } -.ho-col { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; } -.ho-left { border-right: 1px solid var(--border); } -#house-overview.fullscreen-3d .ho-left { display: none; } -#house-overview.fullscreen-2d .ho-right { display: none; } -.ho-pane { flex: 1; position: relative; min-height: 0; background: var(--viewport-bg); } +.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-props, #ho-specs { - height: 340px; flex: none; background: var(--van-emous-ultra-light); border-top: 1px solid var(--border); overflow-y: auto; padding: 15px 20px; -} -#ho-specs .controls-grid { grid-template-columns: 1fr 1fr; } .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); } @@ -619,7 +611,7 @@ html, body { height: 100%; } .hn-label { display: inline-flex; align-items: center; } /* Weergave-cyclus (T): render-only = ook het info-deel onder de 3D weg. */ -.render-only #ho-specs, .render-only #specs-panel-3d, .render-only #floor-specs-panel-3d { display: none; } +.render-only #specs-panel-3d, .render-only #floor-specs-panel-3d { display: none; } /* Bestaande editor-panelen (wand/vloer) in dezelfde stijl als de UI-kit. */ .control-group { min-height: 28px; margin-bottom: 3px; gap: 10px; } @@ -650,10 +642,27 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; pointer-events: none; white-space: nowrap; z-index: 5; } .walk-hint.show { display: block; } -/* Beeld > Camera: uitleg (geen actie) */ -#app-bar .submenu .menu-info { padding: 6px 12px 2px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted, #8a8f96); } -#app-bar .submenu .menu-help { cursor: default; opacity: .85; } -#app-bar .submenu .menu-help:hover { background: transparent; color: var(--nav-link-color); } +/* Easter egg: schieten (Ctrl+L) */ +.shooter-hud { display: none; position: absolute; inset: 0; pointer-events: none; z-index: 6; font-family: monospace; color: #ffd27a; } +.shooter-hud.show { display: block; } +.shooter-cross { position: absolute; left: 50%; top: 50%; width: 18px; height: 18px; margin: -9px 0 0 -9px; + background: linear-gradient(#ffd27a, #ffd27a) center/2px 100% no-repeat, linear-gradient(#ffd27a, #ffd27a) center/100% 2px no-repeat; opacity: .85; } +.shooter-weapon { position: absolute; right: 18px; bottom: 16px; font-size: 18px; font-weight: 700; text-shadow: 0 0 4px #000; } +.shooter-credit { position: absolute; left: 14px; top: 12px; font-size: 11px; color: #fff; opacity: .7; text-shadow: 0 0 3px #000; } +.shooter-kills { position: absolute; right: 18px; top: 12px; font-size: 18px; font-weight: 700; text-shadow: 0 0 4px #000; } +.shooter-shout { position: absolute; left: 50%; top: 22%; transform: translateX(-50%); font-size: 20px; font-weight: 700; color: #fff; + text-shadow: 0 0 4px #000, 2px 2px 0 #8040a0; opacity: 0; transition: opacity .2s; white-space: nowrap; } +.shooter-shout.show { opacity: 1; } +.shooter-ouch { position: absolute; inset: 0; background: rgba(255, 0, 0, 0.45); opacity: 0; } +.shooter-ouch.show { animation: shooter-ouch .4s ease-out; } +@keyframes shooter-ouch { from { opacity: 1; } to { opacity: 0; } } +.shooter-announce { position: absolute; left: 50%; top: 34%; transform: translateX(-50%); font: italic 900 42px Impact, 'Arial Black', sans-serif; + color: #ff2a1a; text-shadow: 0 0 8px #000, 3px 3px 0 #400; letter-spacing: 2px; white-space: nowrap; opacity: 0; } +.shooter-announce.show { opacity: 1; animation: shooter-announce .35s ease-out; } +@keyframes shooter-announce { from { transform: translateX(-50%) scale(2.2); opacity: 0; } to { transform: translateX(-50%) scale(1); opacity: 1; } } +.shooter-help { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); padding: 6px 12px; border-radius: 4px; + background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; white-space: nowrap; } + /* Openingen: "Volg dakhelling" (schuine bovenzijde) */ .opening-roof-btn { @@ -661,3 +670,129 @@ h1 { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: background: var(--surface); border: 1px solid var(--border); color: var(--primary); border-radius: 4px; cursor: pointer; } .opening-roof-btn:hover { background: var(--primary-soft); border-color: var(--primary); } + +/* Inspector: kruimelpad, schuiven, kleurstaal. */ +.ui-crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 11px; color: var(--text-faint); margin-bottom: 4px; } +.ui-crumbs .bi { font-size: 9px; } +.ui-crumbs span:last-child { color: var(--text-muted); } +.ui-field-range { flex-wrap: wrap; row-gap: 2px; padding: 2px 0 4px; } +.ui-field-range label { flex: 1 1 100%; } +.ui-range { flex: 1 1 100%; gap: 8px; } +.ui-range input[type=range] { flex: 1; min-width: 0; accent-color: var(--primary); } +.ui-range input[type=number] { width: 84px; } +.ui-swatch { height: 14px; border-radius: 3px; border: 1px solid var(--border); margin: 2px 0 6px; } + +/* Bouwfase (core/build-stages.js): schuif linksonder in de 3D. */ +.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; +} +.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; } +.walking .stage-bar { display: none; } + +/* Grafieken in het rapport (staafdiagrammen, HTML). Categorische kleuren + in vaste volgorde: vloer, wanden, dak. */ +.viz { --series-1: #2a78d6; --series-2: #eb6834; --series-3: #1baf7a; --track: #eef1f5; } +.viz-legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--text-muted); margin: -2px 0 8px; } +.viz-legend span { display: inline-flex; align-items: center; gap: 5px; } +.viz-key { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } +.viz-key.s1, .viz-seg.s1 { background: var(--series-1); } +.viz-key.s2, .viz-seg.s2 { background: var(--series-2); } +.viz-key.s3, .viz-seg.s3 { background: var(--series-3); } +.viz-row { display: grid; grid-template-columns: 96px 1fr auto; align-items: center; gap: 8px; padding: 4px 0; } +.viz-label { font-size: 12px; color: var(--text); line-height: 1.25; } +.viz-label small { display: block; font-size: 10px; color: var(--text-faint); } +.viz-row:has(.viz-label small) { grid-template-columns: 1fr auto; } +.viz-row:has(.viz-label small) .viz-label { grid-column: 1 / -1; } +.viz-track { height: 12px; } +.viz-bar { display: flex; gap: 2px; height: 100%; min-width: 2px; } +.viz-seg { height: 100%; min-width: 2px; } +.viz-seg:last-child { border-radius: 0 4px 4px 0; } +.viz-seg:hover { filter: brightness(1.12); } +.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 (core/ribbon.js): tabbladen + groepen van 3 rijen hoog. + ============================================================ */ +#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); } +/* Velden (UI-kit) compact in de ribbon: label links, invoer rechts, één rij. */ +.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 { 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); } + +/* Ribbon: hulpklassen voor de woning-tabbladen. */ +.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; } diff --git a/index.html b/index.html index f5fe54f..157726a 100644 --- a/index.html +++ b/index.html @@ -22,64 +22,14 @@
Tectum — Versie 0.0.0
-
- +
+
@@ -131,25 +81,35 @@ + + + + + + + + + + diff --git a/js/app.js b/js/app.js index 08ff517..8e21bb8 100644 --- a/js/app.js +++ b/js/app.js @@ -4,7 +4,7 @@ * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ -const APP_VERSION = '0.4.17'; +const APP_VERSION = '0.5.2'; // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. let tabs = []; @@ -32,15 +32,56 @@ function setWorkspaceEmpty() {
`; } +// Ribbon (core/ribbon.js): app-tabbladen Bestand, Beeld en Extra; de +// editor voegt er zijn eigen tabbladen aan toe. Knoppen die de huidige +// editor niet ondersteunt staan uit. function updateEditMenuState() { - const has = (fn) => !!(currentEditor && typeof currentEditor[fn] === 'function'); - document.getElementById('menu-opslaan-als').classList.toggle('disabled', !has('exportDesign')); - document.getElementById('menu-importeren').classList.toggle('disabled', !has('triggerImport')); - document.getElementById('menu-mirror-h').classList.toggle('disabled', !has('mirrorHorizontal')); - document.getElementById('menu-3d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen3D')); - document.getElementById('menu-2d-fullscreen').classList.toggle('disabled', !has('toggleFullscreen2D')); - document.getElementById('menu-walk').classList.toggle('disabled', !has('toggleWalk')); - document.getElementById('menu-project-props').classList.toggle('disabled', !has('openProjectProperties')); + Ribbon.refresh(); +} + +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: 'Snel nieuw', html: + small('Woning', 'house', 'data-app="new-kind" data-kind="woning" data-template="single"') + + small('Woning L-vorm', 'bounding-box-circles', 'data-app="new-kind" data-kind="woning" data-template="L"') + + small('Los: muur', 'bricks', 'data-app="new-kind" data-kind="muur"') + + small('Los: vloer', 'grid-3x3', 'data-app="new-kind" data-kind="vloer"') }, + { label: 'Project', html: big('Eigenschappen', 'card-text', `data-app="openProjectProperties" title="Projectgegevens voor de PDF" ${disabledUnless('openProjectProperties')}`) } + ] }, + { key: 'beeld', label: 'Beeld', order: 80, wire: wireClicks, groups: () => [ + { label: 'Venster', html: + small('Volledig scherm', 'fullscreen', 'data-app="fullscreen" title="F"') + + small('3D groot', 'badge-3d', `data-app="toggleFullscreen3D" title="T: standaard → 3D groot → alleen render" ${disabledUnless('toggleFullscreen3D')}`) + + small('2D groot', 'aspect-ratio', `data-app="toggleFullscreen2D" title="R" ${disabledUnless('toggleFullscreen2D')}`) }, + { label: 'Camera', html: + big('Lopen', 'person-walking', `data-app="toggleWalk" title="Shift+T. W/S of pijltjes: vooruit/achteruit · A/D: opzij · pijl links/rechts: draaien · Shift: rennen · slepen: rondkijken" ${disabledUnless('toggleWalk')}`) + + small('Bediening', 'mouse', 'title="Rondom: slepen = draaien · rechts slepen = verschuiven · scrollwiel = zoomen. Lopen: Shift+T."') } + ] }, + { key: 'extra', label: 'Extra', order: 90, wire: wireClicks, groups: () => [ + { label: 'Bewerken', html: small('Horizontaal spiegelen', 'symmetry-vertical', `data-app="mirrorHorizontal" title="H" ${disabledUnless('mirrorHorizontal')}`) }, + { label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-app="costs"') }, + { label: 'Info', html: big('Over Tectum', 'info-circle', 'data-app="about"') } + ] } + ]; } function renderTabBar() { @@ -63,6 +104,7 @@ 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); @@ -85,7 +127,7 @@ function switchToTab(tabId) { } function openNew(kind, template) { - if (!editorModuleFor(kind)) { closeAllMenus(); return; } // 'dak' nog niet beschikbaar + if (!editorModuleFor(kind)) return; // 'dak' nog niet beschikbaar const oldTab = tabs.find(t => t.id === activeTabId); if (oldTab && currentEditor && typeof currentEditor.getStateJson === 'function') { @@ -100,7 +142,6 @@ function openNew(kind, template) { mountEditorForTab(tab); updateEditMenuState(); renderTabBar(); - closeAllMenus(); } function isTabDirty(tab) { @@ -140,6 +181,7 @@ function closeTab(tabId) { switchToTab(tabs[tabs.length - 1].id); } else { setWorkspaceEmpty(); + Ribbon.setSource('editor', []); updateEditMenuState(); renderTabBar(); } @@ -154,69 +196,6 @@ function wireTabBar() { }); } -function closeAllMenus() { - document.querySelectorAll('.menu-dropdown, .submenu').forEach(el => el.classList.remove('open')); - document.querySelectorAll('.menu-btn').forEach(el => el.classList.remove('open')); -} - -function wireTopLevelMenu(btnId, dropdownId) { - const btn = document.getElementById(btnId); - const dropdown = document.getElementById(dropdownId); - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const willOpen = !dropdown.classList.contains('open'); - closeAllMenus(); - if (willOpen) { dropdown.classList.add('open'); btn.classList.add('open'); } - }); -} - -function wireMenu() { - wireTopLevelMenu('menu-bestand-btn', 'menu-bestand-dropdown'); - wireTopLevelMenu('menu-bewerken-btn', 'menu-bewerken-dropdown'); - wireTopLevelMenu('menu-beeld-btn', 'menu-beeld-dropdown'); - - document.getElementById('menu-nieuw-item').addEventListener('click', (e) => e.stopPropagation()); - document.querySelectorAll('.menu-nieuw-option').forEach(opt => { - opt.addEventListener('click', () => openNew(opt.dataset.kind, opt.dataset.template)); - }); - - document.getElementById('menu-opslaan-als').addEventListener('click', () => { - if (currentEditor && typeof currentEditor.exportDesign === 'function') currentEditor.exportDesign(); - closeAllMenus(); - }); - document.getElementById('menu-importeren').addEventListener('click', () => { - if (currentEditor && typeof currentEditor.triggerImport === 'function') currentEditor.triggerImport(); - closeAllMenus(); - }); - document.getElementById('menu-mirror-h').addEventListener('click', () => { - if (currentEditor && typeof currentEditor.mirrorHorizontal === 'function') currentEditor.mirrorHorizontal(); - closeAllMenus(); - }); - document.getElementById('menu-fullscreen').addEventListener('click', () => { - toggleBrowserFullscreen(); - closeAllMenus(); - }); - document.getElementById('menu-3d-fullscreen').addEventListener('click', () => { - if (currentEditor && typeof currentEditor.toggleFullscreen3D === 'function') currentEditor.toggleFullscreen3D(); - closeAllMenus(); - }); - document.getElementById('menu-camera-item').addEventListener('click', (e) => e.stopPropagation()); - document.getElementById('menu-walk').addEventListener('click', () => { - if (currentEditor && typeof currentEditor.toggleWalk === 'function') currentEditor.toggleWalk(); - closeAllMenus(); - }); - document.getElementById('menu-2d-fullscreen').addEventListener('click', () => { - if (currentEditor && typeof currentEditor.toggleFullscreen2D === 'function') currentEditor.toggleFullscreen2D(); - closeAllMenus(); - }); - document.getElementById('menu-project-props').addEventListener('click', () => { - if (currentEditor && typeof currentEditor.openProjectProperties === 'function') currentEditor.openProjectProperties(); - closeAllMenus(); - }); - - document.addEventListener('click', closeAllMenus); -} - // Minimale markdown-weergave voor de licentietekst (koppen + alinea's). function renderLicenseHtml(md) { const esc = (t) => t.replace(/&/g, '&').replace(//g, '>'); @@ -231,10 +210,6 @@ function renderLicenseHtml(md) { function wireAbout() { const overlay = document.getElementById('about-overlay'); document.getElementById('about-license').innerHTML = renderLicenseHtml(window.TECTUM_LICENSE_MD || ''); - document.getElementById('btn-about').addEventListener('click', (e) => { - e.stopPropagation(); - overlay.classList.add('open'); - }); document.getElementById('about-close').addEventListener('click', () => overlay.classList.remove('open')); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); }); } @@ -251,7 +226,6 @@ function wireNewModal() { } function openNewModal() { - closeAllMenus(); document.getElementById('new-overlay').classList.add('open'); } @@ -274,6 +248,12 @@ function wireHotkeys() { openNewModal(); return; } + // Easter egg (shooter); e.code omdat Alt/Shift e.key per toetsenbord verandert. + if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyL') { + e.preventDefault(); + if (currentEditor && typeof currentEditor.toggleShooter === 'function') currentEditor.toggleShooter(); + return; + } if (inField || e.ctrlKey || e.metaKey || e.altKey) return; if (e.key.toLowerCase() === 'f') { @@ -293,7 +273,8 @@ function wireHotkeys() { 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; }); - wireMenu(); + Ribbon.init(document.getElementById('ribbon')); + Ribbon.setSource('app', appRibbonTabs()); wireAbout(); wireNewModal(); wireHotkeys(); diff --git a/js/core/build-stages.js b/js/core/build-stages.js new file mode 100644 index 0000000..827bcf6 --- /dev/null +++ b/js/core/build-stages.js @@ -0,0 +1,85 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// core/build-stages.js — Bouwfasen (window.BuildStages): de volgorde waarin +// een unit in de hal wordt opgebouwd. Eén begrip voor zowel de weergave +// (schuif "Bouwfase" in de 3D) als het weglaten van verborgen delen: +// +// 0 regelwerk vloer-, wand- en dakbalken +// 1 elektra installatiebuizen, dozen en meterkast (ElektraModel) +// 2 beplating spaanplaat binnen, OSB vloer/plafond, cementplaat buiten +// 3 doek vochtdoek +// 4 latwerk kruislat, trasraamplaat +// 5 gevel rabat, steen, kozijnen, dakbedekking, sandwichpanelen +// 6 afwerking dakrand, verlichting, vloerafwerking, verf binnen +// +// Fasen zijn cumulatief: fase n toont alles t/m n. Een laag die in de +// gekozen fase aan beide zijden is ingesloten door latere lagen wordt niet +// gebouwd (bijv. stijlen tussen spaanplaat en doek, latten achter de +// rabat). De specificaties rekenen altijd met alle lagen. +// +// Plain script (geen ES-module); het model-deel heeft geen DOM nodig. +// ============================================================ + +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 en plafond, 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])); + + // Fase (getal of undefined = afgewerkt) normaliseren. + const clamp = (stage) => (typeof stage === 'number' && isFinite(stage)) ? Math.max(0, Math.min(FINAL, Math.round(stage))) : FINAL; + // Is laag `key` zichtbaar in fase `stage`? + const shows = (stage, key) => clamp(stage) >= INDEX[key]; + + // Schuif onderin een 3D-weergave (DOM). pane = de weergave (position: + // relative); onChange(stage) bij elke stap. Geeft { set(stage), el, + // extra } terug; extra = vrije rij onder de schuif (bijv. doorsnede). + function createBar(pane, stage, onChange) { + const el = document.createElement('div'); + el.className = 'stage-bar'; + el.innerHTML = ` +
+ Bouwfase +
+ +
${LIST.map((s, i) => `${i + 1}`).join('')}
+
+ +
+
`; + pane.appendChild(el); + const range = el.querySelector('.stage-range'), name = el.querySelector('.stage-name'); + const sync = () => { + const i = clamp(Number(range.value)); + name.textContent = LIST[i].label; + name.title = LIST[i].hint; + el.querySelectorAll('.stage-ticks span').forEach(t => t.classList.toggle('on', Number(t.dataset.i) <= i)); + }; + range.addEventListener('input', () => { sync(); onChange(clamp(Number(range.value))); }); + el.querySelector('.stage-ticks').addEventListener('click', (e) => { + const t = e.target.closest('[data-i]'); + if (!t) return; + range.value = t.dataset.i; + sync(); + onChange(clamp(Number(range.value))); + }); + // Toetsen in de weergave mogen de schuif niet bedienen (T, R, ...). + range.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) range.blur(); }); + sync(); + return { el, extra: el.querySelector('.stage-extra'), set: (s) => { range.value = clamp(s); sync(); } }; + } + + return { LIST, FINAL, INDEX, clamp, shows, createBar }; +})(); diff --git a/js/core/granny-kit.js b/js/core/granny-kit.js new file mode 100644 index 0000000..be10473 --- /dev/null +++ b/js/core/granny-kit.js @@ -0,0 +1,174 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// core/granny-kit.js — onderdelen van de easter egg (core/shooter.js): +// oma-sprite, gibs, kreten, +// geluidseffecten, soundtrack en de omroeper à la Unreal Tournament. +// Plain script (geen ES-module). +// ============================================================ + +const GrannyKit = (() => { + // Oud vrouwtje met rollator, 16x25 pixels (twee loopbeelden). + const LADY = [ + '......hHHh......', + '.....HHHHHH.....', + '....HHHHHHHH....', + '....HSSSSSSH....', + '....SGEsSGES....', + '....SSSssSSS....', + '.....SsrrsS.....', + '......SSSS......', + '.....WWWWWW.....', + '...DDDDWWDDDD...', + '..DDDDDDDDDDDD..', + '..DdDDDDDDDDdD..', + '..DdDDDDDDDDdD..', + 'MSSMMMMMMMMMMSSM', + '.M.DDDDDDDDDD.M.', + '.MRRRRRRRRRRRRM.', + '.MRrRrRrRrRrRRM.', + '.MRRRRRRRRRRRRM.', + '.M..dDDDDDDd..M.', + '.M...DDDDDD...M.', + '.M....LL.LL...M.', + '.M....LL..LL..M.', + '.M....BB..BB..M.', + 'KKK..........KKK', + 'KkK..........KkK' + ]; + const LADY_B = LADY.slice(0, 20).concat([ + '.M...LL..LL...M.', + '.M...LL...LL..M.', + '.M...BB...BB..M.', + 'KkK..........KkK', + 'KKK..........KKK' + ]); + const PAL = { h: '#9a9aa8', H: '#d0d0da', S: '#f0c8a0', s: '#d09878', E: '#101010', G: '#707080', r: '#a04040', + W: '#f4f4f4', D: '#8040a0', d: '#5a2a78', M: '#b8c0c8', R: '#c03030', L: '#c8a888', B: '#402018', K: '#202020', k: '#606060' }; + + // Gibs: 8-bit stukjes oma (vlees, botjes, gebit, bril, handtas, breibol, rollatorwiel). + const GIBS = [ + ['..rr....', '.rRRr...', 'rRRRRr..', 'rRrRRRr.', '.rRRRRr.', '..rRRr..', '...rr...'], + ['.rr.rr..', 'rRRrRRr.', 'rRRRRRr.', '.rRRRr..', '..rrr...'], + ['ww....ww', 'wWw..wWw', '.wWwwWw.', '..wWWw..', '.wWwwWw.', 'wWw..wWw', 'ww....ww'], + ['.pppppp.', 'pWWWWWWp', 'pWkWkWkp', 'pWWWWWWp', '.pppppp.'], + ['GG..GG..', 'GbG.GbG.', 'GGGGGGG.', '........'], + ['..nn....', '.n..n...', 'nNNNNNn.', 'NNNNNNN.', 'NNyNNNN.', 'NNNNNNN.'], + ['.YYYY...', 'YyYYyY..', 'YYyYYy..', 'YyYYyY..', '.YYYY...', '.....Y..', '......Y.'], + ['.KKKK...', 'KkkkkK..', 'KkMMkK..', 'KkMMkK..', 'KkkkkK..', '.KKKK...'] + ]; + const GIB_PAL = { r: '#6a0808', R: '#c01818', w: '#a09880', W: '#f0ecdc', p: '#e07888', k: '#303030', G: '#606070', b: '#a0c8f0', + n: '#402018', N: '#7a4a22', y: '#d0b040', Y: '#c050a0', K: '#202020', M: '#b8c0c8' }; + + const SHOUTS = ['"Jongeman! Voeten vegen!"', '"In mijn tijd hadden we geen raketten!"', '"Ik bel je moeder!"', + '"Waar is de bingo?"', '"Dat dak lekt, hoor!"', '"Is dit wel vergund?"', '"Ik heb een abonnement op de bus!"']; + const DEATH_SHOUTS = ['"Oeeehh, mijn heup!"', '"Dat zeg ik tegen de wijkagent!"', '"Aaargh, de bingo..."']; + const HIT_SHOUTS = ['*BAM* met de handtas!', '*klets* met de rollator!', '"Neem een koekje, en dan wegwezen!"']; + const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; + + // Pixelraster -> canvas (1 px per teken, optioneel met rand en één tintkleur; + // bottom = figuur op de onderrand, voor sprites die op de grond staan). + function pixelCanvas(rows, pal, opts) { + const o = Object.assign({ pad: 0, size: 0, tint: null, bottom: false }, opts || {}); + const w = Math.max(...rows.map(r => r.length)), h = rows.length; + const c = document.createElement('canvas'); + c.width = o.size || w + o.pad * 2; + c.height = o.size || h + o.pad * 2; + const g = c.getContext('2d'); + const ox = Math.floor((c.width - w) / 2), oy = o.bottom ? c.height - h : Math.floor((c.height - h) / 2); + rows.forEach((row, y) => [...row].forEach((ch, x) => { + if (!pal[ch]) return; + g.fillStyle = o.tint || pal[ch]; + g.fillRect(ox + x, oy + y, 1, 1); + })); + return c; + } + + // Geluidseffecten: gesynthetiseerde ruis via WebAudio. + function createSfx() { + let audio = null; + return { + bang(len, freq, vol) { + try { + audio = audio || new (window.AudioContext || window.webkitAudioContext)(); + const n = Math.floor(audio.sampleRate * len), buf = audio.createBuffer(1, n, audio.sampleRate), d = buf.getChannelData(0); + for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 3); + const src = audio.createBufferSource(), f = audio.createBiquadFilter(), g = audio.createGain(); + src.buffer = buf; f.type = 'lowpass'; f.frequency.value = freq; g.gain.value = vol; + src.connect(f).connect(g).connect(audio.destination); + src.start(); + } catch (e) { /* geen geluid, jammer */ } + }, + close() { if (audio) audio.close(); audio = null; } + }; + } + + // Soundtrack: "Five Armies" Kevin MacLeod (incompetech.com), CC BY 4.0. + const MUSIC_CREDIT = '♪ "Five Armies" Kevin MacLeod (incompetech.com) · CC BY 4.0'; + function createMusic() { + const music = new Audio('assets/audio/five-armies.mp3'); + music.loop = true; + music.volume = 0.5; + return { + play() { music.currentTime = 0; music.play().catch(() => {}); }, // toetsdruk = gebruikersactie + stop() { music.pause(); } + }; + } + + // Omroeper (Web Speech API + grote tekst in labelEl). + // Multi-kill: kills binnen MULTI_WINDOW s na elkaar. Spree: kills zonder geraakt te worden. + const MULTI_WINDOW = 6; + const MULTI = { 2: 'Double Granny!', 3: 'Multi Granny!', 4: 'Mega Granny!', 5: 'Ultra Granny!', 6: 'M M M MONSTER GRANNY!' }; + const SPREE = { 5: 'Bingo Spree!', 10: 'Rollator Rampage!', 15: 'Dominating the retirement home!', 20: 'Unstoppable!', 25: 'Godlike!', 30: 'Wicked Sick... of grannies!' }; + function createAnnouncer(labelEl) { + let kills = 0, multi = 0, lastKillAt = -99, spree = 0, clock = 0, timer = 0; + function say(text) { + labelEl.textContent = text.replace(/^M M M /, 'M-M-M-'); + labelEl.classList.remove('show'); void labelEl.offsetWidth; labelEl.classList.add('show'); + timer = 2.2; + try { + const u = new SpeechSynthesisUtterance(text); + const v = speechSynthesis.getVoices().find(x => /^en(-|_)(US|GB)/i.test(x.lang) && /male|david|daniel|george/i.test(x.name)) + || speechSynthesis.getVoices().find(x => /^en/i.test(x.lang)); + if (v) u.voice = v; + u.lang = 'en-US'; u.pitch = 0.1; u.rate = 0.8; u.volume = 1; + speechSynthesis.cancel(); + speechSynthesis.speak(u); + } catch (e) { /* geen stem, dan alleen tekst */ } + } + return { + say, + get kills() { return kills; }, + kill() { + kills++; + multi = clock - lastKillAt <= MULTI_WINDOW ? multi + 1 : 1; + lastKillAt = clock; + spree++; + if (kills === 1) say('First Blood!'); + else if (multi >= 2) say(MULTI[Math.min(multi, 6)]); + else if (SPREE[spree]) say(SPREE[spree]); + return kills; + }, + hurt() { + if (spree >= 5) say('Spree ended by a handbag!'); + spree = 0; + }, + reset() { kills = multi = spree = 0; lastKillAt = -99; }, + update(dt) { + clock += dt; + if (timer > 0 && (timer -= dt) <= 0) labelEl.classList.remove('show'); + }, + stop() { + try { speechSynthesis.cancel(); } catch (e) { /* */ } + labelEl.classList.remove('show'); + } + }; + } + + return { LADY, LADY_B, PAL, GIBS, GIB_PAL, SHOUTS, DEATH_SHOUTS, HIT_SHOUTS, MUSIC_CREDIT, + pick, pixelCanvas, createSfx, createMusic, createAnnouncer }; +})(); diff --git a/js/core/ribbon.js b/js/core/ribbon.js new file mode 100644 index 0000000..f1d4782 --- /dev/null +++ b/js/core/ribbon.js @@ -0,0 +1,145 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// core/ribbon.js — Ribbon (window.Ribbon): de bedieningsbalk bovenin. Eén +// plek voor alle commando's en instellingen; de weergaven eronder blijven +// vrij. +// +// Tabbladen komen uit bronnen: 'app' (Bestand, Beeld, Extra) en 'editor' +// (de actieve editor, bijv. Start, Gevel, Dak, ... en een contextueel +// tabblad voor de selectie). Tabbladen met dezelfde sleutel uit meerdere +// bronnen worden samengevoegd (bijv. Beeld: weergave-opties van de woning +// + vensterknoppen van de app). +// +// Ribbon.setSource('editor', [{ +// key, label, order (sortering), contextual (gekleurd, achteraan), +// groups: () => [{ label, html }], // inhoud, opnieuw bij elke weergave +// wire: (body) => {}, // events koppelen na het tekenen +// onShow: () => {} // tabblad wordt actief +// }]); +// Ribbon.activate(key) · Ribbon.refresh() · Ribbon.active() · Ribbon.onChange(fn) +// +// Groepen: een raster van 3 rijen hoog; velden (UI-kit) staan per kolom +// onder elkaar. Grote knop: Ribbon.big(...), kleine: Ribbon.small(...). +// Plain script (geen ES-module). +// ============================================================ + +window.Ribbon = (() => { + const sources = {}; // naam -> [tab] + const SOURCE_ORDER = ['app', 'editor']; + let root = null, tabsEl = null, bodyEl = null; + let activeKey = null, lastPlainKey = null; + let auto = true; // tabblad nog niet door de gebruiker gekozen: voorkeur 'start' + let collapsed = false, notified = null; + const listeners = []; + const PREF_KEY = 'tectum.ribbon.collapsed'; + + const icon = (n) => ``; + // Grote knop (3 rijen hoog): icoon boven het label. + const big = (label, iconName, attrs, cls) => + ``; + // Kleine knop (1 rij). + const small = (label, iconName, attrs, cls) => + ``; + // Waarde-regel (alleen lezen) binnen een groep. + const kv = (label, value, unit) => + `
${label}${value}${unit ? `${unit}` : ''}
`; + + function allTabs() { + const byKey = new Map(); + SOURCE_ORDER.concat(Object.keys(sources).filter(n => !SOURCE_ORDER.includes(n))).forEach(name => { + (sources[name] || []).forEach(t => { + if (!byKey.has(t.key)) byKey.set(t.key, { key: t.key, label: t.label, order: t.order || 0, contextual: !!t.contextual, icon: t.icon, parts: [] }); + const m = byKey.get(t.key); + if (t.order !== undefined && name !== 'app') m.order = t.order; + if (t.label && name !== 'app') m.label = t.label; + if (t.contextual) m.contextual = true; + m.parts.push(t); + }); + }); + return Array.from(byKey.values()).sort((a, b) => (a.contextual - b.contextual) || (a.order - b.order)); + } + + function renderTabs(list) { + tabsEl.innerHTML = list.map(t => + ``).join(''); + } + + function renderBody(list) { + const tab = list.find(t => t.key === activeKey); + if (!tab) { bodyEl.innerHTML = ''; return; } + const groups = []; + tab.parts.forEach(p => (p.groups ? p.groups() : []).forEach(g => g && groups.push(g))); + bodyEl.innerHTML = groups.map(g => + `
${g.html}
${g.label}
`).join('') + + ``; + tab.parts.forEach(p => p.wire && p.wire(bodyEl)); + } + + function render() { + if (!root) return; + const list = allTabs(); + if (!list.some(t => t.key === activeKey) || (auto && !list.some(t => t.key === activeKey && t.contextual))) { + const pick = [lastPlainKey, 'start'].find(k => list.some(t => t.key === k)); + activeKey = pick || (list.find(t => !t.contextual) || {}).key || null; + } + renderTabs(list); + renderBody(list); + root.classList.toggle('rb-collapsed', collapsed); + if (notified !== activeKey) { notified = activeKey; listeners.forEach(fn => fn(activeKey)); } + } + + function activate(key) { + const list = allTabs(); + const tab = list.find(t => t.key === key); + if (!tab) return; + activeKey = key; + auto = false; + if (!tab.contextual) lastPlainKey = key; + if (collapsed) { collapsed = false; savePref(); } + render(); + tab.parts.forEach(p => p.onShow && p.onShow()); + } + + function savePref() { try { localStorage.setItem(PREF_KEY, collapsed ? '1' : '0'); } catch (e) { /* alleen deze sessie */ } } + + 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(); + } + + // Bron vervangen. Een nieuw contextueel tabblad (andere sleutel) wordt + // actief als opts.focus; verdwijnt het actieve, dan terug naar het vorige. + 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(); + } + + // fn(key) bij elke wissel van het actieve tabblad. + function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; } + + return { init, setSource, activate, refresh: render, active: () => activeKey, onChange, big, small, kv }; +})(); diff --git a/js/core/shooter.js b/js/core/shooter.js new file mode 100644 index 0000000..faf1aa6 --- /dev/null +++ b/js/core/shooter.js @@ -0,0 +1,469 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// core/shooter.js — easter egg: schieten in de loopmodus (Ctrl+Shift+Alt+L). +// klik in beeld muis vangen (Esc = loslaten) +// linkermuisknop vuren +// 1 / 2 / scrollwiel pistool / raketwerper +// Kogelgaten en roetplekken blijven op wanden/dak/vloer achter, raketten +// laten ook wat rondvliegend metselwerk achter. Bewust simpel en buggy. +// Plain script (geen ES-module); gebruikt THREE. +// +// const gun = createShooter({ scene, camera, domElement, pane, walk, targets: () => [..] }); +// gun.start(); gun.update(dt); gun.stop(); gun.dispose(); +// ============================================================ + +function createShooter(o) { + const { scene, camera, domElement, pane, walk } = o; + const group = new THREE.Group(); + group.name = 'shooter'; + let active = false, weapon = 0, cooldown = 0, recoil = 0; + const decals = [], rockets = [], blasts = [], debris = []; + const MAX_DECALS = 250; + + // --- Texturen (canvas) --- + function canvasTex(size, draw) { + const c = document.createElement('canvas'); + c.width = c.height = size; + draw(c.getContext('2d'), size); + const t = new THREE.CanvasTexture(c); + return t; + } + const holeTex = canvasTex(64, (g, s) => { + const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2); + r.addColorStop(0, 'rgba(0,0,0,1)'); r.addColorStop(0.18, 'rgba(10,8,6,1)'); + r.addColorStop(0.3, 'rgba(60,50,40,0.8)'); r.addColorStop(1, 'rgba(0,0,0,0)'); + g.fillStyle = r; g.fillRect(0, 0, s, s); + g.strokeStyle = 'rgba(20,15,10,0.7)'; g.lineWidth = 1.5; + for (let i = 0; i < 6; i++) { + const a = Math.random() * Math.PI * 2, l = s * (0.25 + Math.random() * 0.2); + g.beginPath(); g.moveTo(s / 2, s / 2); g.lineTo(s / 2 + Math.cos(a) * l, s / 2 + Math.sin(a) * l); g.stroke(); + } + }); + const scorchTex = canvasTex(128, (g, s) => { + for (let i = 0; i < 40; i++) { + const x = s / 2 + (Math.random() - 0.5) * s * 0.5, y = s / 2 + (Math.random() - 0.5) * s * 0.5; + const rr = s * (0.1 + Math.random() * 0.25); + const r = g.createRadialGradient(x, y, 0, x, y, rr); + r.addColorStop(0, 'rgba(15,10,5,0.5)'); r.addColorStop(1, 'rgba(15,10,5,0)'); + g.fillStyle = r; g.fillRect(0, 0, s, s); + } + g.fillStyle = 'rgba(255,120,20,0.6)'; + for (let i = 0; i < 25; i++) g.fillRect(s / 2 + (Math.random() - 0.5) * s * 0.4, s / 2 + (Math.random() - 0.5) * s * 0.4, 2, 2); + }); + const fireTex = canvasTex(64, (g, s) => { + const r = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2); + r.addColorStop(0, 'rgba(255,255,220,1)'); r.addColorStop(0.3, 'rgba(255,180,40,0.9)'); + r.addColorStop(0.7, 'rgba(200,50,0,0.4)'); r.addColorStop(1, 'rgba(0,0,0,0)'); + g.fillStyle = r; g.fillRect(0, 0, s, s); + }); + // Puin krijgt een willekeurige steentextuur uit de bibliotheek (als die laadt). + const brickTex = new THREE.TextureLoader().load(['assets/textures/buitenmuur/R200 - Witte voeg.jpg', 'assets/textures/buitenmuur/K739 - Witte voeg.jpg', 'assets/textures/buitenmuur/R480 - Witte voeg.jpg'][Math.floor(Math.random() * 3)]); + const decalMat = (map) => new THREE.MeshBasicMaterial({ map, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4 }); + const holeMat = decalMat(holeTex), scorchMat = decalMat(scorchTex); + const debrisMat = new THREE.MeshStandardMaterial({ map: brickTex, roughness: 0.9 }); + const fireMat = new THREE.SpriteMaterial({ map: fireTex, blending: THREE.AdditiveBlending, depthWrite: false }); + + // --- Wapens (vastgeplakt aan de camera) --- + const metal = new THREE.MeshStandardMaterial({ color: 0x2a2c30, metalness: 0.7, roughness: 0.35 }); + const olive = new THREE.MeshStandardMaterial({ color: 0x4b5a32, roughness: 0.6 }); + function box(w, h, d, m, x, y, z) { const b = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); b.position.set(x, y, z); return b; } + const pistol = new THREE.Group(); + pistol.add(box(0.035, 0.045, 0.2, metal, 0, 0, -0.1), box(0.03, 0.09, 0.04, metal, 0, -0.055, -0.02)); + const launcher = new THREE.Group(); + const tube = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.7, 12, 1, true), olive); + tube.material.side = THREE.DoubleSide; + tube.rotation.x = Math.PI / 2; tube.position.z = -0.25; + launcher.add(tube, box(0.03, 0.09, 0.04, metal, 0, -0.08, -0.05), box(0.02, 0.05, 0.03, metal, 0.05, 0.05, -0.2)); + const flash = new THREE.Sprite(fireMat.clone()); + flash.scale.set(0.15, 0.15, 1); + flash.visible = false; + const viewModel = new THREE.Group(); + viewModel.add(pistol, launcher, flash); + const gunLight = new THREE.PointLight(0xffaa44, 0, 6); + group.add(viewModel, gunLight); + + // --- HUD --- + const hud = document.createElement('div'); + hud.className = 'shooter-hud'; + hud.innerHTML = '
Klik = muis vangen · linkerknop = vuren · 1/2 of scroll = wapen · Esc = muis los · Ctrl+Shift+Alt+L = stoppen
'; + pane.appendChild(hud); + hud.querySelector('.shooter-credit').textContent = GrannyKit.MUSIC_CREDIT; + const weaponLabel = hud.querySelector('.shooter-weapon'); + const killsLabel = hud.querySelector('.shooter-kills'); + const shoutLabel = hud.querySelector('.shooter-shout'); + const ouch = hud.querySelector('.shooter-ouch'); + let shoutTimer = 0; + function shout(text) { shoutLabel.textContent = text; shoutLabel.classList.add('show'); shoutTimer = 2.5; } + function setKills(n) { killsLabel.textContent = "Oma's: " + n; } + const announcer = GrannyKit.createAnnouncer(hud.querySelector('.shooter-announce')); + + function selectWeapon(i) { + weapon = (i + 2) % 2; + pistol.visible = weapon === 0; + launcher.visible = weapon === 1; + weaponLabel.textContent = weapon === 0 ? '[1] Pistool ∞' : '[2] Raketwerper ∞'; + flash.position.set(0, 0, weapon === 0 ? -0.22 : -0.62); + } + selectWeapon(0); + + // --- Vijand: oud vrouwtje met rollator (8-bit sprite, Doom-stijl) --- + // Eén tegelijk: spawnt op 12-18 m, schuifelt op je af, na de kill komt de volgende. + function spriteTex(rows, tint) { + const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.PAL, { size: 32, tint, bottom: true })); + t.magFilter = t.minFilter = THREE.NearestFilter; + t.generateMipmaps = false; + return t; + } + const ladyTex = [spriteTex(GrannyKit.LADY), spriteTex(GrannyKit.LADY_B)]; + const ladyHurtTex = spriteTex(GrannyKit.LADY, '#ff3030'); + const LADY_H = 1.6 * 32 / 25; // sprite-hoogte (m) zodat de figuur ~1,6 m is + let mob = null, spawnTimer = 1.5, attackTimer = 0, animT = 0; + function spawnMob() { + const me = camera.position; + let x = me.x, z = me.z; + for (let i = 0; i < 15; i++) { + const a = Math.random() * Math.PI * 2, d = 12 + Math.random() * 6; + x = me.x + Math.sin(a) * d; z = me.z + Math.cos(a) * d; + if (!o.blocked || !o.blocked(x, z)) break; + } + const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: ladyTex[0], transparent: true, alphaTest: 0.5 })); + sp.scale.set(LADY_H, LADY_H, 1); + group.add(sp); + mob = { sp, x, z, hp: 3, alive: true, hurt: 0, dead: 0 }; + placeMob(); + shout(GrannyKit.pick(GrannyKit.SHOUTS)); + } + function mobFloor(x, z) { return o.floorAt ? o.floorAt(x, z) : 0; } + function placeMob() { mob.sp.position.set(mob.x, mobFloor(mob.x, mob.z) + LADY_H / 2 - (mob.dead ? LADY_H * 0.3 : 0), mob.z); } + function mobCenter() { return new THREE.Vector3(mob.x, mobFloor(mob.x, mob.z) + 0.8, mob.z); } + // Straal tegen een staande cilinder (r 0,4 m, 1,6 m hoog) om de oma. + function rayHitsMob(origin, dir, maxDist) { + if (!mob || !mob.alive) return false; + const c = mobCenter(); + const t = c.clone().sub(origin).dot(dir); + if (t < 0 || t > maxDist) return false; + const p = origin.clone().addScaledVector(dir, t); + return Math.hypot(p.x - c.x, p.z - c.z) < 0.4 && Math.abs(p.y - c.y) < 0.8; + } + const gibTex = GrannyKit.GIBS.map(rows => { + const t = new THREE.CanvasTexture(GrannyKit.pixelCanvas(rows, GrannyKit.GIB_PAL, { size: 8 })); + t.magFilter = t.minFilter = THREE.NearestFilter; + t.generateMipmaps = false; + return t; + }); + const bloodTex = canvasTex(32, (g) => { + g.fillStyle = '#7a0a0a'; + for (let i = 0; i < 14; i++) { + const a = Math.random() * 6.28, d = Math.random() * 10, r = 2 + Math.random() * 5; + g.beginPath(); g.arc(16 + Math.cos(a) * d, 16 + Math.sin(a) * d, r, 0, 6.28); g.fill(); + } + }); + bloodTex.magFilter = THREE.NearestFilter; + const bloodMat = decalMat(bloodTex); + function spawnGibs(center, count) { + for (let i = 0; i < count; i++) { + // Vooral vlees, plus een paar herkenbare stukken. + const k = i < count * 0.5 ? Math.floor(Math.random() * 2) : Math.floor(Math.random() * GrannyKit.GIBS.length); + const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: gibTex[k], alphaTest: 0.5 })); + const sz = 0.18 + Math.random() * 0.14; + sp.scale.set(sz, sz, 1); + sp.position.copy(center).add(new THREE.Vector3((Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.8, (Math.random() - 0.5) * 0.4)); + const v = new THREE.Vector3((Math.random() - 0.5) * 9, 2 + Math.random() * 6, (Math.random() - 0.5) * 9); + group.add(sp); + debris.push({ mesh: sp, v, spin: new THREE.Vector3((Math.random() - 0.5) * 20, 0, 0), rest: false, gib: true }); + } + // Plas onder de plek des onheils. + bloodSplat(center.x, center.z, 1.4); + trimDebris(); + } + function bloodSplat(x, z, size) { + const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), bloodMat); + m.rotation.set(-Math.PI / 2, 0, Math.random() * 6.28); + m.position.set(x, mobFloor(x, z) + 0.006, z); + group.add(m); + decals.push(m); + } + function trimDebris() { + while (debris.length > 200) { + const old = debris.shift(); + group.remove(old.mesh); + if (old.mesh.isSprite) old.mesh.material.dispose(); else old.mesh.geometry.dispose(); + } + } + + function damageMob(n, gib) { + if (!mob || !mob.alive) return; + mob.hp -= n; + mob.hurt = 0.15; + spawnBlast(mobCenter(), 0.4, 0.2); + if (mob.hp > 0) { bang(0.2, 900, 0.3); return; } + mob.alive = false; + mob.dead = 0.001; + mob.sp.material.map = ladyHurtTex; + mob.sp.material.rotation = Math.PI / 2; // omgevallen + if (gib) { mob.sp.visible = false; mob.dead = 2.4; spawnGibs(mobCenter(), 22); } + else spawnGibs(mobCenter(), 5); + setKills(announcer.kill()); + shout(GrannyKit.pick(GrannyKit.DEATH_SHOUTS)); + spawnTimer = 1; + } + function updateMob(t) { + if (!mob) { spawnTimer -= t; if (spawnTimer <= 0) spawnMob(); return; } + if (!mob.alive) { + mob.dead += t; + mob.sp.material.opacity = Math.max(0, 1 - (mob.dead - 1.5)); + mob.sp.material.transparent = true; + placeMob(); + if (mob.dead > 2.5) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; } + return; + } + const me = camera.position; + const dx = me.x - mob.x, dz = me.z - mob.z, dist = Math.hypot(dx, dz); + if (dist > 0.9) { + const v = 1.3 * t / dist; + const nx = mob.x + dx * v, nz = mob.z + dz * v; + if (!o.blocked || !o.blocked(nx, nz)) { mob.x = nx; mob.z = nz; } + else if (!o.blocked(nx, mob.z)) mob.x = nx; + else if (!o.blocked(mob.x, nz)) mob.z = nz; + animT += t; + } else { + attackTimer -= t; + if (attackTimer <= 0) { + attackTimer = 1.2; + bang(0.1, 400, 0.6); + ouch.classList.remove('show'); void ouch.offsetWidth; ouch.classList.add('show'); + announcer.hurt(); + shout(GrannyKit.pick(GrannyKit.HIT_SHOUTS)); + } + } + mob.hurt -= t; + mob.sp.material.map = mob.hurt > 0 ? ladyHurtTex : ladyTex[Math.floor(animT * 4) % 2]; + placeMob(); + } + + const music = GrannyKit.createMusic(); + const sfx = GrannyKit.createSfx(); + const bang = sfx.bang; + + // --- Raken --- + const ray = new THREE.Raycaster(); + function hitScan(origin, dir, far) { + ray.set(origin, dir); + ray.far = far; + const hits = ray.intersectObjects(o.targets().filter(t => t && t.visible), true); + return hits.find(h => h.face && h.object.isMesh && isVisible(h.object)) || null; + } + function isVisible(obj) { + for (let p = obj; p; p = p.parent) if (!p.visible) return false; + return true; + } + function addDecal(hit, mat, size) { + const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld); + if (n.dot(ray.ray.direction) > 0) n.negate(); // altijd de kant waar je vandaan schiet + const m = new THREE.Mesh(new THREE.PlaneGeometry(size, size), mat); + m.position.copy(hit.point).addScaledVector(n, 0.004); + m.lookAt(hit.point.clone().add(n)); + m.rotateZ(Math.random() * Math.PI * 2); + group.add(m); + decals.push(m); + if (decals.length > MAX_DECALS) { const old = decals.shift(); group.remove(old); old.geometry.dispose(); } + } + + const tmpDir = new THREE.Vector3(); + function fire() { + if (cooldown > 0) return; + camera.getWorldDirection(tmpDir); + // Lichte spreiding, het blijft een configurator. + tmpDir.x += (Math.random() - 0.5) * 0.01; tmpDir.y += (Math.random() - 0.5) * 0.01; + tmpDir.normalize(); + flash.visible = true; + flash.material.rotation = Math.random() * 6; + gunLight.intensity = 2; + if (weapon === 0) { + cooldown = 0.18; recoil = 1; + bang(0.15, 3000, 0.5); + const hit = hitScan(camera.position, tmpDir, 200); + if (rayHitsMob(camera.position, tmpDir, hit ? hit.distance : 200)) damageMob(1); + else if (hit) { + addDecal(hit, holeMat, 0.05 + Math.random() * 0.03); + spawnBlast(hit.point, 0.15, 0.12); + } + } else { + cooldown = 0.8; recoil = 2.5; + bang(0.5, 600, 0.6); + const r = new THREE.Group(); + r.add(box(0.06, 0.06, 0.3, olive, 0, 0, 0)); + const tail = new THREE.Sprite(fireMat); + tail.scale.set(0.3, 0.3, 1); tail.position.z = 0.2; + r.add(tail); + r.position.copy(camera.position).addScaledVector(tmpDir, 0.6); + r.lookAt(r.position.clone().add(tmpDir)); + group.add(r); + rockets.push({ mesh: r, dir: tmpDir.clone(), speed: 14, life: 6 }); + } + } + + function spawnBlast(pos, size, life) { + const s = new THREE.Sprite(fireMat.clone()); + s.position.copy(pos); + s.scale.set(size, size, 1); + group.add(s); + blasts.push({ s, size, life, t: 0 }); + } + + function explode(hit, point, normal) { + bang(1.2, 300, 1); + if (hit) { + addDecal(hit, scorchMat, 1.2 + Math.random() * 0.6); + point = hit.point; + normal = hit.face.normal.clone().transformDirection(hit.object.matrixWorld); + if (normal.dot(ray.ray.direction) > 0) normal.negate(); + } + spawnBlast(point, 2.5, 0.5); + const light = new THREE.PointLight(0xff8833, 6, 12); + light.position.copy(point); + group.add(light); + blasts.push({ light, life: 0.5, t: 0 }); + if (mob && mob.alive && mobCenter().distanceTo(point) < 3) damageMob(99, true); + if (!hit) return; // direct op de oma: alleen gibs, geen stenen + const n = normal; + for (let i = 0; i < 12; i++) { + const sz = 0.05 + Math.random() * 0.12; + const d = new THREE.Mesh(new THREE.BoxGeometry(sz * 2, sz, sz), debrisMat); + d.position.copy(hit.point).addScaledVector(n, 0.1); + d.rotation.set(Math.random() * 6, Math.random() * 6, 0); + const v = n.clone().multiplyScalar(2 + Math.random() * 4).add(new THREE.Vector3((Math.random() - 0.5) * 6, Math.random() * 5, (Math.random() - 0.5) * 6)); + group.add(d); + debris.push({ mesh: d, v, spin: new THREE.Vector3(Math.random() * 10, Math.random() * 10, 0), rest: false }); + } + trimDebris(); + } + + // --- Invoer --- + function onClick(e) { + if (!active) return; + if (document.pointerLockElement !== domElement) { domElement.requestPointerLock && domElement.requestPointerLock(); return; } + if (e.button === 0) fire(); + } + function onMouseMove(e) { + if (!active || document.pointerLockElement !== domElement) return; + walk.look(-e.movementX * 0.0025, -e.movementY * 0.0025); + } + function onWheel(e) { + if (!active) return; + e.preventDefault(); + selectWeapon(weapon + (e.deltaY > 0 ? 1 : -1)); + } + function onKey(e) { + if (!active || e.ctrlKey) return; + if (e.key === '1') selectWeapon(0); + if (e.key === '2') selectWeapon(1); + } + domElement.addEventListener('mousedown', onClick); + document.addEventListener('mousemove', onMouseMove); + domElement.addEventListener('wheel', onWheel, { passive: false }); + window.addEventListener('keydown', onKey); + + function clear() { + if (mob) { group.remove(mob.sp); mob.sp.material.dispose(); mob = null; } + shoutLabel.classList.remove('show'); + [...decals, ...debris.map(d => d.mesh), ...rockets.map(r => r.mesh), ...blasts.map(b => b.s || b.light)].forEach(m => group.remove(m)); + decals.length = debris.length = rockets.length = blasts.length = 0; + } + + return { + get active() { return active; }, + start() { + active = true; + scene.add(group); + hud.classList.add('show'); + announcer.reset(); + setKills(0); + spawnTimer = 1.5; + music.play(); + }, + stop() { + active = false; + if (document.pointerLockElement === domElement) document.exitPointerLock(); + clear(); + scene.remove(group); + hud.classList.remove('show'); + music.stop(); + announcer.stop(); + }, + update(dt) { + if (!active) return; + const t = Math.min(dt, 0.05); + cooldown -= t; + // Wapen volgt de camera, met wat terugslag. + recoil = Math.max(0, recoil - t * 8); + viewModel.position.copy(camera.position); + viewModel.quaternion.copy(camera.quaternion); + viewModel.translateX(0.18); viewModel.translateY(-0.16 + recoil * 0.01); viewModel.translateZ(-0.25 + recoil * 0.03); + viewModel.rotateX(recoil * 0.08); + gunLight.position.copy(camera.position); + if (flash.visible && cooldown < (weapon === 0 ? 0.13 : 0.72)) flash.visible = false; + gunLight.intensity = Math.max(0, gunLight.intensity - t * 30); + announcer.update(t); + updateMob(t); + if (shoutTimer > 0 && (shoutTimer -= t) <= 0) shoutLabel.classList.remove('show'); + + for (let i = rockets.length - 1; i >= 0; i--) { + const r = rockets[i]; + r.life -= t; + const stepLen = r.speed * t; + r.speed += 10 * t; + const hit = hitScan(r.mesh.position, r.dir, stepLen); + if (rayHitsMob(r.mesh.position, r.dir, stepLen + 0.3)) { + explode(null, mobCenter(), r.dir.clone().negate()); + group.remove(r.mesh); + rockets.splice(i, 1); + } else if (hit || r.life <= 0) { + if (hit) explode(hit); + group.remove(r.mesh); + rockets.splice(i, 1); + } else { + r.mesh.position.addScaledVector(r.dir, stepLen); + if (Math.random() < 0.5) spawnBlast(r.mesh.position.clone().addScaledVector(r.dir, -0.2), 0.2, 0.3); + } + } + for (let i = blasts.length - 1; i >= 0; i--) { + const b = blasts[i]; + b.t += t; + const k = b.t / b.life; + if (b.s) { b.s.scale.setScalar(b.size * (0.6 + k)); b.s.material.opacity = 1 - k; } + if (b.light) b.light.intensity = 6 * (1 - k); + if (k >= 1) { group.remove(b.s || b.light); if (b.s) b.s.material.dispose(); blasts.splice(i, 1); } + } + for (const d of debris) { + if (d.rest) continue; + d.v.y -= 9.81 * t; + const next = d.mesh.position.clone().addScaledVector(d.v, t); + const floor = (o.floorAt ? o.floorAt(next.x, next.z) : 0) + (d.gib ? d.mesh.scale.y * 0.35 : 0.03); + if (next.y <= floor && d.v.y < 0) { + next.y = floor; + d.v.multiplyScalar(0.4); d.v.y = -d.v.y * 0.5; d.spin.multiplyScalar(0.5); + if (d.gib && !d.splat) { d.splat = true; bloodSplat(next.x, next.z, 0.3 + Math.random() * 0.3); } + if (d.v.length() < 0.3) d.rest = true; + } + d.mesh.position.copy(next); + if (d.mesh.isSprite) d.mesh.material.rotation += d.spin.x * t; + else { d.mesh.rotation.x += d.spin.x * t; d.mesh.rotation.y += d.spin.y * t; } + } + }, + dispose() { + this.stop(); + domElement.removeEventListener('mousedown', onClick); + document.removeEventListener('mousemove', onMouseMove); + domElement.removeEventListener('wheel', onWheel); + window.removeEventListener('keydown', onKey); + hud.remove(); + sfx.close(); + } + }; +} diff --git a/js/core/skybox.js b/js/core/skybox.js index 6998b1a..1370939 100644 --- a/js/core/skybox.js +++ b/js/core/skybox.js @@ -212,5 +212,5 @@ window.Skybox = (() => { return wrap; } - return { CAMERA_HEIGHT_M, NIGHT_FACTOR, createEnvironment, apply, getMode, lightFactor, createToggle }; + return { CAMERA_HEIGHT_M, NIGHT_FACTOR, MODES, LABELS, createEnvironment, apply, getMode, setMode: remember, lightFactor, createToggle }; })(); diff --git a/js/core/ui-kit.js b/js/core/ui-kit.js index 4da2ebf..4102ada 100644 --- a/js/core/ui-kit.js +++ b/js/core/ui-kit.js @@ -7,7 +7,8 @@ // ============================================================ // core/ui-kit.js — gedeelde UI-bouwstenen (window.UI): iconen (Bootstrap // Icons, css/vendor), invoervelden, schakelaars, secties, tabbladen, -// specificatie-kaarten en de 3-staps weergave-cyclus (sneltoets T). +// inklapbare secties, schuiven, specificatie-kaarten en de 3-staps +// weergave-cyclus (sneltoets T). // Eén plek voor opmaak en woordgebruik, zodat alle panelen gelijk ogen. // // Woordgebruik labels: zelfstandig naamwoord met hoofdletter, geen dubbele @@ -34,6 +35,35 @@ window.UI = (() => { `${unit}`, o); } + // Schuif met gekoppeld getal (voor maten met een logisch bereik, zoals + // hoogtes en hellingen). o: { min, max, step, unit ('mm' standaard), hint }. + // Het getal mag buiten het schuifbereik; het model klemt zelf. + function range(id, label, value, o) { + o = o || {}; + const unit = o.unit === undefined ? 'mm' : o.unit; + const lim = `min="${o.min}" max="${o.max}" step="${o.step || 10}"`; + return `
` + + `
` + + `${unit}
`; + } + // fn(v) bij schuiven en typen; set(id, v) zet beide zonder event. + function onRange(id, fn, after) { + const n = document.getElementById(id), r = document.getElementById(id + '-r'); + if (!n || !r) return; + r.addEventListener('input', () => { n.value = r.value; fn(parseFloat(r.value)); after && after(); }); + n.addEventListener('input', () => { + const v = parseFloat(n.value); + if (!isFinite(v)) return; + r.value = v; + fn(v); after && after(); + }); + } + function setRange(id, v) { + const n = document.getElementById(id), r = document.getElementById(id + '-r'); + if (n && document.activeElement !== n) n.value = v; + if (r) r.value = v; + } + // Keuzelijst. options: { waarde: 'Label' }. function sel(id, label, options, value, o) { const opts = Object.entries(options).map(([k, v]) => ``).join(''); @@ -126,5 +156,5 @@ window.UI = (() => { el.classList.toggle('fullscreen-2d'); } - return { esc, icon, hint, num, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D }; + return { esc, icon, hint, num, range, onRange, setRange, sel, toggle, color, onColor, section, info, warn, grid, button, actions, tabs, wireTabs, specCard, onNum, onSel, onToggle, cycle3D, toggle2D }; })(); diff --git a/js/core/walk-controls.js b/js/core/walk-controls.js index e6ca121..d4901e2 100644 --- a/js/core/walk-controls.js +++ b/js/core/walk-controls.js @@ -27,6 +27,7 @@ function createWalkControls(camera, domElement, opts) { let active = false; let x = 0, z = 0, yaw = 0, pitch = 0; let drag = null; + let speedFactor = 1; // bijv. 1,3 in de shooter-modus; reset bij start() const inField = () => ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement && document.activeElement.tagName); const MOVE_KEYS = ['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift']; @@ -71,10 +72,17 @@ function createWalkControls(camera, domElement, opts) { get active() { return active; }, start(pose) { x = pose.x; z = pose.z; yaw = pose.yaw || 0; pitch = 0; + speedFactor = 1; keys.clear(); active = true; apply(); }, + // Rondkijken van buitenaf (bijv. muis gevangen in core/shooter.js). + set speedFactor(f) { speedFactor = f; }, + look(dYaw, dPitch) { + yaw += dYaw; + pitch = Math.max(-1.4, Math.min(1.4, pitch + dPitch)); + }, stop() { active = false; keys.clear(); drag = null; }, update(dt) { if (!active) return; @@ -84,7 +92,7 @@ function createWalkControls(camera, domElement, opts) { const f = (keys.has('w') || keys.has('arrowup') ? 1 : 0) - (keys.has('s') || keys.has('arrowdown') ? 1 : 0); const r = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0); if (f || r) { - const v = (keys.has('shift') ? o.run : o.speed) * t / Math.hypot(f, r); + const v = (keys.has('shift') ? o.run : o.speed) * speedFactor * t / Math.hypot(f, r); const fx = Math.sin(yaw), fz = Math.cos(yaw); // vooruit const rx = -fz, rz = fx; // rechts (kijkend langs +Z is rechts -X) step((fx * f + rx * r) * v, (fz * f + rz * r) * v); diff --git a/js/cost/cost-defaults.js b/js/cost/cost-defaults.js new file mode 100644 index 0000000..e33c9fc --- /dev/null +++ b/js/cost/cost-defaults.js @@ -0,0 +1,185 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// cost/cost-defaults.js — STANDAARD kosten en tijden (datafile). +// +// Dit zijn de fabrieksinstellingen. Aanpassen kan op twee manieren: +// 1. in de app: menu Instellingen > Kosten & tijden (opgeslagen in deze +// browser, exporteren/importeren als JSON); +// 2. hier in dit bestand (geldt dan voor iedereen als nieuwe standaard). +// Tip: exporteer in de app en neem de waarden hier over. +// +// Een JS-bestand i.p.v. JSON omdat de app ook via file:// draait (fetch van +// lokale bestanden werkt daar niet). +// +// Opbouw: groep -> items. Elk item: { label, unit, value } of met +// options: { sleutel: 'tekst' } voor een keuze. Nieuwe items verschijnen +// vanzelf in het instellingenvenster; de berekening (CostModel) moet ze +// natuurlijk wel gebruiken. Bedragen excl. btw. Tijden in seconden tenzij +// anders vermeld. LET OP: de getallen zijn voorlopige schattingen. +// ============================================================ + +window.COST_DEFAULTS = { + arbeid: { + label: 'Arbeid', + items: { + uurloon: { label: 'Uurloon productie (all-in)', unit: '€/u', value: 45 }, + bezetting: { label: 'Bezetting per productiestap (heftruck, vegen, aangeven): gemiddeld personeel', unit: 'man', value: 2.2 }, + toeslagUren: { label: 'Toeslag indirecte uren (klaarleggen, opruimen, intern transport)', unit: '%', value: 10 }, + qa: { label: 'QA (laatste aanpassingen, klantcontact) over de totale werktijd', unit: '%', value: 4 } + } + }, + + zagen: { + label: 'Zagen', + items: { + methode: { label: 'Zaagmethode', options: { robot: 'Zaagrobot', hand: 'Handmatig' }, value: 'robot' }, + // Machinetijd testontwerp (409 stuks, 818 sneden): ~2 u 55 min. + robotSnede: { label: 'Robot: tijd per haakse snede', unit: 's', value: 5 }, + robotSchuin: { label: 'Robot: extra tijd per schuine snede', unit: 's', value: 2.5 }, + robotStuk: { label: 'Robot: handling per stuk (invoer, uitvoer, etiket)', unit: 's', value: 15 }, + handFactor: { label: 'Handmatig: factor op de robottijd', unit: '×', value: 7 } + } + }, + + verbinden: { + label: 'Verbindingen (regelwerk)', + items: { + schroevenPerVerbinding: { label: 'Schroeven per verbinding (stijl/dorpel op regel)', unit: 'st.', value: 3 }, + eersteSchroef: { label: 'Tijd eerste schroef (gat + schroef + uitlijnen)', unit: 's', value: 10 }, + volgendeSchroef: { label: 'Tijd volgende schroef (gat + schroef)', unit: 's', value: 5 }, + prijsPer1000: { label: 'Inkoop constructieschroeven (6×120)', unit: '€/1000', value: 45 }, + verbindingenPerVloerbalk: { label: 'Verbindingen per vloer-/dakbalk', unit: 'st.', value: 2 }, + dubbelAfstand: { label: 'Dubbele stijl (deur, hoek): schroef verticaal elke', unit: 'mm', value: 100 }, + monteerFactor: { label: 'Monteren: factor op de schroeftijd (incl. lijmen, positioneren)', unit: '×', value: 6 }, + personeel: { label: 'Personeel bij monteren constructie (vloer, wand, dak; i.p.v. de bezetting)', unit: 'man', value: 2 }, + dakFactor: { label: 'Dak: factor op de tijd per schroef (moeilijker werken)', unit: '×', value: 4 } + } + }, + + montage: { + label: 'Montage', + items: { + muurVastzetten: { label: 'Muursectie plaatsen en vastzetten', unit: 'min', value: 10 }, + muurPersoneel: { label: 'Personeel bij plaatsen muursectie (i.p.v. de bezetting)', unit: 'man', value: 3 } + } + }, + + hout: { + label: 'Hout', + items: { + constructieM3: { label: 'Constructiehout vuren C24 (regelwerk, balken)', unit: '€/m³', value: 550 }, + latwerkM: { label: 'Latwerk (kruislat)', unit: '€/m', value: 0.9 }, + latStukMontage: { label: 'Latwerk: per gezaagd stuk (meten, zagen op locatie, passen)', unit: 's', value: 240 }, + latMeterMontage: { label: 'Latwerk: bevestigen per strekkende meter', unit: 's/m', value: 30 } + } + }, + + plaat: { + label: 'Plaatwerk', + items: { + spaanplaatM2: { label: 'Spaanplaat', unit: '€/m²', value: 9 }, + spaanplaatMontage: { label: 'Montage spaanplaat (passen, zagen, plaatsen)', unit: 's/m²', value: 120 }, + osbM2: { label: 'OSB (vloeren, plafonds dak)', unit: '€/m²', value: 12 }, + osbMontage: { label: 'Montage OSB', unit: 's/m²', value: 90 }, + cementplaatM2: { label: 'Cementgebonden plaat (gevel, trasraam)', unit: '€/m²', value: 28 }, + cementplaatMontage: { label: 'Montage cementplaat', unit: 's/m²', value: 240 }, + schroevenPerM2: { label: 'Plaatschroeven per m²', unit: 'st./m²', value: 15 }, + tijdPerPlaatschroef: { label: 'Tijd per plaatschroef', unit: 's', value: 4 }, + plaatschroefPer1000: { label: 'Inkoop plaatschroeven (4×40)', unit: '€/1000', value: 15 } + } + }, + + isolatie: { + label: 'Isolatie', + items: { + glaswolM3: { label: 'Glaswol', unit: '€/m³', value: 40 }, + glaswolMontage: { label: 'Montage glaswol', unit: 's/m³', value: 900 }, + steenIsolatieM3: { label: 'Isolatieblok steengevel', unit: '€/m³', value: 120 } + } + }, + + gevel: { + label: 'Gevel', + items: { + vochtdoekM2: { label: 'Vochtdoek', unit: '€/m²', value: 1.5 }, + vochtdoekMontageM: { label: 'Montage vochtdoek per strekkende meter wand', unit: 's/m', value: 600 }, + rabatM2: { label: 'Rabat (gevelbekleding)', unit: '€/m²', value: 35 }, + rabatMontage: { label: 'Montage rabat', unit: 's/m²', value: 600 }, + steenM2: { label: 'Steenstrips (gevel + trasraam)', unit: '€/m²', value: 60 }, + steenMontage: { label: 'Montage steenstrips', unit: 's/m²', value: 1800 } + } + }, + + dak: { + label: 'Dak & dakrand', + items: { + dakleerM2: { label: 'Dakleer (EPDM)', unit: '€/m²', value: 18 }, + dakleerMontage: { label: 'Montage dakleer', unit: 's/m²', value: 600 }, + rietM2: { label: 'Riet', unit: '€/m²', value: 90 }, + rietMontage: { label: 'Montage riet', unit: 's/m²', value: 3600 }, + boeideelM: { label: 'Boeideel', unit: '€/m', value: 25 }, + boeideelMontage: { label: 'Montage boeideel', unit: 's/m', value: 600 }, + onderplaatM2: { label: 'Onderplaat boeideel', unit: '€/m²', value: 30 }, + onderplaatMontage: { label: 'Montage onderplaat', unit: 's/m²', value: 900 }, + daktrimM: { label: 'Daktrim', unit: '€/m', value: 12 }, + daktrimMontage: { label: 'Montage daktrim', unit: 's/m', value: 300 } + } + }, + + vloer: { + label: 'Vloer & fundering', + items: { + afwerkingM2: { label: 'Vloerafwerking', unit: '€/m²', value: 35 }, + afwerkingMontage: { label: 'Montage vloerafwerking', unit: 's/m²', value: 1200 }, + sandwichM2: { label: 'Sandwichpaneel', unit: '€/m²', value: 35 }, + sandwichMontage: { label: 'Montage per sandwichpaneel', unit: 's', value: 600 } + } + }, + + kozijnen: { + label: 'Kozijnen', + items: { + raamM2: { label: 'Raamkozijn incl. glas', unit: '€/m²', value: 450 }, + deur: { label: 'Binnen-/buitendeur incl. kozijn', unit: '€/st.', value: 900 }, + voordeur: { label: 'Voordeur incl. kozijn', unit: '€/st.', value: 1800 }, + montage: { label: 'Montage per kozijn', unit: 's', value: 5400 } + } + }, + + verlichting: { + label: 'Verlichting', + items: { + gevellamp: { label: 'Gevellamp', unit: '€/st.', value: 45 }, + boeideelSpot: { label: 'Boeideel-spot', unit: '€/st.', value: 25 }, + binnenspot: { label: 'Binnenspot', unit: '€/st.', value: 20 }, + montage: { label: 'Montage per lamp/spot (incl. bekabeling)', unit: 's', value: 900 } + } + }, + + bedrijf: { + label: 'Afschrijving & bedrijfskosten', + items: { + machines: { label: 'Afschrijving machines (zaagrobot, gereedschap, inrichting) per productie-uur', unit: '€/u', value: 8 }, + slijtage: { label: 'Slijtage en verbruik gereedschap (zaagbladen, bits, accu’s) per productie-uur', unit: '€/u', value: 2 }, + huisvesting: { label: 'Huisvesting productiehal (huur, energie) per productie-uur', unit: '€/u', value: 6 }, + transportUnit: { label: 'Transport per unit naar de bouwplaats', unit: '€/unit', value: 750 }, + kraan: { label: 'Kraan voor plaatsen woning', unit: '€', value: 1200 } + } + }, + + opslag: { + label: 'Opslagen', + items: { + verlies: { label: 'Materiaalverlies (beschadiging, misgezaagd, restmateriaal; naast het berekende snijverlies balken)', unit: '%', value: 5 }, + klein: { label: 'Klein materiaal (lijm, kit, tape, nieten)', unit: '%', value: 3 }, + algemeen: { label: 'Algemene kosten (kantoor, verzekering, garantie) over de kostprijs', unit: '%', value: 5 }, + marge: { label: 'Winstmarge', unit: '%', value: 15 }, + btw: { label: 'Btw', unit: '%', value: 21 } + } + } +}; diff --git a/js/cost/cost-model.js b/js/cost/cost-model.js new file mode 100644 index 0000000..d044d28 --- /dev/null +++ b/js/cost/cost-model.js @@ -0,0 +1,346 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// cost/cost-model.js — CostModel: ENIGE bron van de kostenberekening +// (materiaal + arbeid). Geen DOM. Rekent op de doorgerekende woning +// (HouseModel.computeParts + computeTotals) met de kosten/tijden uit +// COST_DEFAULTS (cost-defaults.js) plus de eigen instellingen van de +// gebruiker (localStorage, instellingenvenster cost-settings.js). +// +// Opbouw: per element (wandstuk, vloer, dak, woning-breed) regels +// { cat, act, label, qty, unit, mat (€), sec (s arbeid) } +// cat = materiaalgroep, act = werkzaamheid. Opgeteld per element, per unit +// en voor de woning; daarna opslagen (indirecte uren, materiaal, marge, btw). +// +// Plain script, geladen na house-model.js en cost-defaults.js. +// ============================================================ + +window.CostModel = (() => { + const STORAGE_KEY = 'tectum.costs'; + const listeners = []; + + // ------------------------------------------------------------ + // Instellingen: standaard (COST_DEFAULTS) + eigen waarden (localStorage). + // values = { groep: { sleutel: waarde } }. + // ------------------------------------------------------------ + function defaultValues() { + const v = {}; + Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => { + v[g] = {}; + Object.entries(grp.items).forEach(([k, it]) => { v[g][k] = it.value; }); + }); + return v; + } + // Alleen bekende sleutels met een geldig type overnemen. + function mergeValues(base, over) { + const v = JSON.parse(JSON.stringify(base)); + Object.entries(over || {}).forEach(([g, items]) => { + const grp = window.COST_DEFAULTS[g]; + if (!grp || !items || typeof items !== 'object') return; + Object.entries(items).forEach(([k, val]) => { + const it = grp.items[k]; + if (!it) return; + if (it.options ? Object.prototype.hasOwnProperty.call(it.options, val) : typeof val === 'number' && isFinite(val)) v[g][k] = val; + }); + }); + return v; + } + function readOverrides() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; } catch (e) { return {}; } + } + function getSettings() { return mergeValues(defaultValues(), readOverrides()); } + // Slaat alleen de afwijkingen van de standaard op. + function saveSettings(values) { + const def = defaultValues(), diff = {}; + Object.entries(mergeValues(def, values)).forEach(([g, items]) => Object.entries(items).forEach(([k, val]) => { + if (val !== def[g][k]) (diff[g] = diff[g] || {})[k] = val; + })); + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(diff)); } catch (e) { /* geen opslag: alleen deze sessie */ } + listeners.forEach(fn => fn()); + } + function resetSettings() { saveSettings(defaultValues()); } + function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; } + + // Export/import (JSON-bestand): altijd de volledige set waarden. + function exportJson(values) { + return { type: 'tectum-costs', version: 1, exportedAt: new Date().toISOString(), values: mergeValues(defaultValues(), values) }; + } + function importJson(obj) { + if (!obj || obj.type !== 'tectum-costs' || typeof obj.values !== 'object') throw new Error('geen Tectum-kostenbestand'); + return mergeValues(defaultValues(), obj.values); + } + + // ------------------------------------------------------------ + // Berekening + // ------------------------------------------------------------ + const CAT_ORDER = ['Hout', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting']; + const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting']; + const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl']; + const DORPEL_KINDS = ['bovendorpel', 'onderdorpel']; + + function compute(parts, totals, settings) { + const S = settings || getSettings(); + const elements = []; + const newEl = (element, label, unit) => { const el = { element, label, unit, lines: [] }; elements.push(el); return el; }; + // sec = tijd voor één persoon; crew = personeel bij die stap (standaard + // de gemiddelde bezetting per productiestap). Opgeslagen sec = manuren. + const add = (el, cat, act, label, qty, unit, mat, sec, crew) => { + if (!(qty > 0)) return; + const c = sec ? (crew !== undefined ? crew : S.arbeid.bezetting) : 0; + // clock = doorlooptijd van de stap (s), voor afschrijving/huisvesting. + el.lines.push({ cat, act, label, qty, unit, mat: mat || 0, sec: (sec || 0) * c, clock: c ? sec : 0, crew: c }); + }; + + // --- Gedeelde rekenregels --- + const Z = S.zagen, V = S.verbinden, P = S.plaat; + const sawFactor = Z.methode === 'hand' ? Z.handFactor : 1; + // Zaagtijd van één stuk (s): handling + sneden (+ extra per schuine snede). + // saw: totalen voor het zaagoverzicht (robot- en handtijd, los van de keuze). + const saw = { pieces: 0, cuts: 0, angled: 0, robotSec: 0 }; + const sawSec = (cutAngles, count = 1) => { + const ca = cutAngles || [0, 0], angled = ca.filter(a => a !== 0).length; + const robot = Z.robotStuk + ca.length * Z.robotSnede + angled * Z.robotSchuin; + saw.pieces += count; saw.cuts += count * ca.length; saw.angled += count * angled; saw.robotSec += count * robot; + return count * robot * sawFactor; + }; + const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)'; + const jointSec = V.eersteSchroef + Math.max(0, V.schroevenPerVerbinding - 1) * V.volgendeSchroef; + // Constructie monteren (lijmen + schroeven): schroeftijd x V.monteerFactor, + // met V.personeel man (manuren), op het dak factor V.dakFactor (roof = true). + const addJoints = (el, joints, label, roof) => { + const n = joints * V.schroevenPerVerbinding; + const f = V.monteerFactor * (roof ? V.dakFactor : 1); + add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven (${joints} verb. × ${V.schroevenPerVerbinding}, ×${V.monteerFactor}${roof ? `, dak ×${V.dakFactor}` : ''})`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel); + }; + // Plaat: materiaal + montage + plaatschroeven. + const addSheet = (el, cat, label, m2, priceM2, montageSecM2) => { + if (!(m2 > 0)) return; + add(el, cat, 'Plaatwerk', label, m2, 'm²', m2 * priceM2, m2 * montageSecM2); + const n = Math.ceil(m2 * P.schroevenPerM2); + add(el, 'Schroeven', 'Plaatwerk', `Plaatschroeven ${label.toLowerCase()}`, n, 'st.', n * P.plaatschroefPer1000 / 1000, n * P.tijdPerPlaatschroef); + }; + // Snijverlies wandbalken (inkoop in handelslengtes, hele woning). + const waste = totals.purchaseStockMM > 0 ? totals.purchaseWasteMM / Math.max(1, totals.purchaseStockMM - totals.purchaseWasteMM) : 0; + + const fmtM = (v) => v.toLocaleString('nl-NL', { maximumFractionDigits: 1 }); + // --- Wandstukken (buiten + binnen/naad) --- + const seenOpenings = new Set(); // naadwand: openingen één keer (beide delen hebben dezelfde deur) + function wallElement(p, unitId) { + const el = newEl(p.element, p.label, unitId); + const bom = WallModel.computeBOM(p.config, p.frame); + const woodM3 = bom.reduce((a, b) => a + b.width * b.depth * b.length, 0) / 1e9; + add(el, 'Hout', 'Zagen', `Regelwerk ${bom.length} st. (incl. ${Math.round(waste * 100)}% snijverlies)`, woodM3 * (1 + waste), 'm³', woodM3 * (1 + waste) * S.hout.constructieM3, 0); + const cuts = bom.reduce((a, b) => a + (b.cutAngles || [0, 0]).length, 0); + add(el, 'Hout', 'Zagen', `${sawLabel}: ${bom.length} stuks`, cuts, 'sneden', 0, bom.reduce((a, b) => a + sawSec(b.cutAngles), 0)); + const verticals = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).length; + const dorpels = bom.filter(b => DORPEL_KINDS.includes(b.kind)).length; + const topPlates = bom.filter(b => b.kind === 'bovenregel').length; + addJoints(el, 2 * verticals + 2 * dorpels + Math.max(0, topPlates - 1), 'Stijlen/dorpels'); + // Dubbele stijlen (deur, hoek: twee stijlen tegen elkaar): verticaal + // elke V.dubbelAfstand mm een schroef over de gezamenlijke lengte. + const studs = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).sort((a, b) => a.x - b.x); + let dblScrews = 0, dblPairs = 0; + for (let i = 1; i < studs.length; i++) { + const a = studs[i - 1], b = studs[i]; + if (b.x - a.x > (a.width + b.width) / 2 + 1) continue; + const ov = Math.min(a.y + a.length / 2, b.y + b.length / 2) - Math.max(a.y - a.length / 2, b.y - b.length / 2); + if (ov <= 0) continue; + dblPairs++; + dblScrews += Math.max(2, Math.floor(ov / V.dubbelAfstand)); + } + add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}× (schroef elke ${V.dubbelAfstand} mm, ×${V.monteerFactor})`, dblScrews, 'st.', + dblScrews * V.prijsPer1000 / 1000, dblScrews * V.volgendeSchroef * V.monteerFactor, V.personeel); + const M = S.montage; + add(el, 'Hout', 'Wanden plaatsen', `Plaatsen en vastzetten (${M.muurVastzetten} min)`, 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel); + + const st = p.stats || {}; + addSheet(el, 'Plaatwerk', 'Spaanplaat', (st.spaanplaatAreaM2 || 0) + (st.spaanplaatBuitenAreaM2 || 0), P.spaanplaatM2, P.spaanplaatMontage); + addSheet(el, 'Plaatwerk', 'Cementplaat', (st.cementplaatAreaM2 || 0) + (st.trasraamAreaM2 || 0), P.cementplaatM2, P.cementplaatMontage); + const I = S.isolatie; + const wool = (p.specs && p.specs.voidVolumeM3) || 0; + add(el, 'Isolatie', 'Isoleren', 'Glaswol', wool, 'm³', wool * I.glaswolM3, wool * I.glaswolMontage); + const stoneIso = (st.stoneIsolatieM3 || 0) + (st.trasraamIsolatieM3 || 0); + add(el, 'Isolatie', 'Gevel', 'Isolatieblok steen', stoneIso, 'm³', stoneIso * I.steenIsolatieM3, 0); + + const G = S.gevel; + const doek = st.doekAreaM2 || 0; + // Montage per strekkende meter wand (breedte van het stuk). + const doekM = doek > 0 ? p.place.width / 1000 : 0; + add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM); + // Rabat over het doek, boven het trasraam (gevelvlak uit WallModel). + const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0; + add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage); + const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0); + add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage); + const latM = st.battenLengthM || 0; + add(el, 'Hout', 'Latwerk', `Latwerk (${st.battenCount || 0} latten inkoop, ${st.battenPieces || 0} stukken)`, latM, 'm', latM * S.hout.latwerkM, + (st.battenPieces || 0) * S.hout.latStukMontage + latM * S.hout.latMeterMontage); + + const K = S.kozijnen; + const opKey = p.seam ? `iw${p.id}` : null; + if (!opKey || !seenOpenings.has(opKey)) { + if (opKey) seenOpenings.add(opKey); + (p.config.openings || []).forEach(op => { + if (op.type === 'raam') { + const m2 = WallModel.openingArea(op) / 1e6; + add(el, 'Kozijnen', 'Kozijnen', `Raam ${op.w}×${op.h}`, m2, 'm²', m2 * K.raamM2, K.montage); + } else { + add(el, 'Kozijnen', 'Kozijnen', `${op.type === 'voordeur' ? 'Voordeur' : 'Deur'} ${op.w}×${op.h}`, 1, 'st.', op.type === 'voordeur' ? K.voordeur : K.deur, K.montage); + } + }); + } + } + parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => wallElement(p, u.id)))); + parts.interior.forEach(p => wallElement(p, p.seamUnit || null)); + + // --- Vloer- en dakbalken (balklaag: zagen + verbindingen) --- + function joistLayer(el, cd, angleDeg, what, roof) { + const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length; + const n = cd.joists.length; + const ang = angleDeg || 0; + add(el, 'Hout', 'Zagen', `${sawLabel}: ${what}balken ${n} + kopbalken ${rims}`, 2 * (n + rims), 'sneden', 0, sawSec([ang, ang], n) + sawSec([0, 0], rims)); + addJoints(el, n * V.verbindingenPerVloerbalk, `${what}balken`, roof); + } + + parts.units.forEach(u => { + // Vloer + const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id); + const fs = u.floorSpecs; + add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', fs.joistWoodM3 * S.hout.constructieM3, 0); + joistLayer(fl, u.floorData.calcData, 0, 'Vloer'); + addSheet(fl, 'Plaatwerk', 'OSB vloer', fs.osbAreaM2, P.osbM2, P.osbMontage); + add(fl, 'Isolatie', 'Isoleren', 'Glaswol vloer', fs.voidM3, 'm³', fs.voidM3 * S.isolatie.glaswolM3, fs.voidM3 * S.isolatie.glaswolMontage); + // Afwerking: vloer min de voetafdruk van de wanden op deze unit. + const foot = u.walls.reduce((a, w) => a + w.pieces.filter(p => !p.place.baseY).reduce((b, p) => b + p.place.width * p.place.thickness, 0), 0) + + parts.interior.filter(p => p.seamUnit === u.id).reduce((a, p) => a + p.place.width * p.place.thickness, 0); + const finM2 = Math.max(0, u.rect.W * u.rect.D - foot) / 1e6; + add(fl, 'Vloer', 'Vloer', 'Vloerafwerking', finM2, 'm²', finM2 * S.vloer.afwerkingM2, finM2 * S.vloer.afwerkingMontage); + + // Dak + const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id); + const rs = u.roof.specs, D = S.dak; + add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', rs.joistWoodM3 * S.hout.constructieM3, 0); + u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true)); + // Wolfsdak: bovenste deel (sporen, hoekkepers, nokbalk, kopbalken). + const cap = u.roof.unitRoof && u.roof.unitRoof.cap; + if (cap) { + const n = cap.beams.length; + add(rf, 'Hout', 'Zagen', `${sawLabel}: wolfseinden ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0)); + addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true); + } + addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage); + add(rf, 'Isolatie', 'Isoleren', 'Glaswol dak', rs.voidM3, 'm³', rs.voidM3 * S.isolatie.glaswolM3, rs.voidM3 * S.isolatie.glaswolMontage); + add(rf, 'Dak', 'Dak', 'Dakleer (EPDM)', rs.dakleerM2 || 0, 'm²', (rs.dakleerM2 || 0) * D.dakleerM2, (rs.dakleerM2 || 0) * D.dakleerMontage); + add(rf, 'Dak', 'Dak', 'Riet', rs.rietM2 || 0, 'm²', (rs.rietM2 || 0) * D.rietM2, (rs.rietM2 || 0) * D.rietMontage); + }); + + // --- Woning-breed: dakrand, sandwichpanelen, verlichting --- + const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null); + const D = S.dak; + if (totals.roofEdge) { + const e = totals.roofEdge; + add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage); + add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage); + add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage); + } + if (parts.sandwich) { + const sw = parts.sandwich; + add(wo, 'Vloer', 'Vloer', `Sandwichpanelen (${sw.panelCount} platen)`, sw.areaM2, 'm²', sw.areaM2 * S.vloer.sandwichM2, sw.panelCount * S.vloer.sandwichMontage); + } + const L = S.verlichting, lm = totals.lamps; + add(wo, 'Verlichting', 'Verlichting', 'Gevellampen', lm.total, 'st.', lm.total * L.gevellamp, lm.total * L.montage); + const spots = totals.roofEdge ? totals.roofEdge.spots : 0; + add(wo, 'Verlichting', 'Verlichting', 'Boeideel-spots', spots, 'st.', spots * L.boeideelSpot, spots * L.montage); + add(wo, 'Verlichting', 'Verlichting', 'Binnenspots', lm.indoor, 'st.', lm.indoor * L.binnenspot, lm.indoor * L.montage); + + // --- Optellen --- + const sumLines = (lines) => lines.reduce((a, l) => ({ mat: a.mat + l.mat, sec: a.sec + l.sec, clock: a.clock + l.clock }), { mat: 0, sec: 0, clock: 0 }); + elements.forEach(el => Object.assign(el, sumLines(el.lines))); + const byKey = (key, order) => { + const m = new Map(); + elements.forEach(el => el.lines.forEach(l => { + const r = m.get(l[key]) || { name: l[key], mat: 0, sec: 0 }; + r.mat += l.mat; r.sec += l.sec; + m.set(l[key], r); + })); + return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)); + }; + // Kostprijs van materiaal + directe arbeidstijd (s), overal dezelfde + // regels: materiaal + verlies + klein materiaal; uren + indirect + QA + // tegen het uurloon (manuren); afschrijving/slijtage/huisvesting per + // productie-uur = doorlooptijd (klokuren, niet x personeel) + indirect, zonder QA. + const O = S.opslag, B = S.bedrijf, A = S.arbeid; + const perHour = B.machines + B.slijtage + B.huisvesting; + const split = (sec) => { + const direct = sec / 3600, indirect = direct * A.toeslagUren / 100; + const qa = (direct + indirect) * A.qa / 100; // QA: % van de totale werktijd + return { direct, indirect, qa, total: direct + indirect + qa }; + }; + const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100); + const costOf = (r) => mat0(r.mat) + split(r.sec).total * A.uurloon + prodHours(r.clock) * perHour; + const mat0 = (mat) => mat * (1 + (O.verlies + O.klein) / 100); + elements.forEach(el => { el.kostprijs = costOf(el); }); + + const units = parts.units.map(u => { + const r = Object.assign({ id: u.id }, sumLines(elements.filter(el => el.unit === u.id).flatMap(el => el.lines))); + r.transport = B.transportUnit; + r.kostprijs = costOf(r) + r.transport; + return r; + }); + const rest = sumLines(elements.filter(el => el.unit === null).flatMap(el => el.lines)); + units.push(Object.assign({ id: null, transport: 0 }, rest, { kostprijs: costOf(rest) + B.kraan })); + + const all = sumLines(elements.flatMap(el => el.lines)); + const h = split(all.sec); + h.prod = prodHours(all.clock); + const n = parts.units.length; + const pct = (v) => String(v).replace('.', ',') + '%'; + const eur0 = (v) => eur(v); + const posts = [ + ['Materiaal', all.mat], + [`Materiaalverlies ${pct(O.verlies)}`, all.mat * O.verlies / 100], + [`Klein materiaal ${pct(O.klein)}`, all.mat * O.klein / 100], + [`Arbeid ${uur(h.total)} manuur × ${eur0(A.uurloon)} (incl. bezetting, indirect en QA)`, h.total * A.uurloon], + [`Afschrijving machines ${uur(h.prod)} productie-uur × ${eur(B.machines, 2)}`, h.prod * B.machines], + [`Slijtage gereedschap ${uur(h.prod)} productie-uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage], + [`Huisvesting productiehal ${uur(h.prod)} productie-uur × ${eur(B.huisvesting, 2)}`, h.prod * B.huisvesting], + [`Transport ${n} unit${n === 1 ? '' : 's'} × ${eur0(B.transportUnit)}`, n * B.transportUnit], + ['Kraan plaatsen', B.kraan] + ]; + const kostprijs = posts.reduce((a, p) => a + p[1], 0); + const algemeen = kostprijs * O.algemeen / 100; + const marge = (kostprijs + algemeen) * O.marge / 100; + const exBtw = kostprijs + algemeen + marge; + const btw = exBtw * O.btw / 100; + // Overzicht als rijen (kaart en PDF): sub = subtotaal. + const rows = posts.filter(p => p[1] > 0).map(([label, amount]) => ({ label, amount })) + .concat([ + { label: 'Kostprijs', amount: kostprijs, sub: true }, + { label: `Algemene kosten ${pct(O.algemeen)}`, amount: algemeen }, + { label: `Marge ${pct(O.marge)}`, amount: marge }, + { label: 'Totaal excl. btw', amount: exBtw, sub: true }, + { label: `Btw ${pct(O.btw)}`, amount: btw } + ]); + return { + settings: S, elements, units, + saw: Object.assign(saw, { handSec: saw.robotSec * Z.handFactor, method: Z.methode }), + categories: byKey('cat', CAT_ORDER), activities: byKey('act', ACT_ORDER), + summary: { mat: all.mat, hoursDirect: h.direct, hoursIndirect: h.indirect, hoursQa: h.qa, hours: h.total, kostprijs, exBtw, btw, inclBtw: exBtw + btw, rows } + }; + } + + // Bedrag als "€ 1.234" (of met decimalen). + function eur(n, decimals = 0) { + return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); + } + // Uren als decimaal getal, bijv. "12,5". + function uur(hours) { + return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); + } + + return { STORAGE_KEY, defaultValues, getSettings, saveSettings, resetSettings, onChange, exportJson, importJson, compute, eur, uur }; +})(); diff --git a/js/cost/cost-settings.js b/js/cost/cost-settings.js new file mode 100644 index 0000000..5c50c45 --- /dev/null +++ b/js/cost/cost-settings.js @@ -0,0 +1,124 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// cost/cost-settings.js — CostSettings: venster "Kosten & tijden". Bouwt +// het formulier automatisch uit COST_DEFAULTS (elk item een invoerveld), +// opslaan via CostModel.saveSettings (localStorage). Exporteren/importeren +// als JSON-bestand; "Standaard" zet alle velden terug op cost-defaults.js. +// Alleen UI; de rekenregels staan in CostModel. +// ============================================================ + +window.CostSettings = (() => { + let overlay = null; + + const esc = UI.esc; + + function build() { + overlay = document.createElement('div'); + overlay.id = 'cost-settings-overlay'; + overlay.innerHTML = ` +
+ +
+
Bedragen excl. btw. Opslaan bewaart in deze browser én downloadt een JSON-bestand; een gewijzigd veld is gemarkeerd. Standaardwaarden staan in js/cost/cost-defaults.js.
+ + +
`; + document.body.appendChild(overlay); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) return close(); + const btn = e.target.closest('[data-cs]'); + if (!btn) return; + const a = btn.dataset.cs; + if (a === 'cancel') close(); + else if (a === 'save') { // ook als JSON-bestand (om te delen/bewaren) + const v = readForm(); + CostModel.saveSettings(v); + download(CostModel.exportJson(v)); + close(); + } + else if (a === 'reset') fill(CostModel.defaultValues()); + else if (a === 'export') download(CostModel.exportJson(readForm())); + else if (a === 'import') overlay.querySelector('input[type=file]').click(); + }); + overlay.addEventListener('input', markChanged); + overlay.querySelector('input[type=file]').addEventListener('change', (e) => { + const file = e.target.files[0]; + e.target.value = ''; + if (!file) return; + file.text().then(txt => fill(CostModel.importJson(JSON.parse(txt)))) + .catch(err => alert('Importeren mislukt: ' + err.message)); + }); + } + + function renderForm() { + const body = overlay.querySelector('.cs-body'); + body.innerHTML = Object.entries(window.COST_DEFAULTS).map(([g, grp]) => ` +
+

${esc(grp.label)}

+ ${Object.entries(grp.items).map(([k, it]) => ` + `).join('')} +
`).join(''); + } + + function fill(values) { + overlay.querySelectorAll('[data-g]').forEach(el => { el.value = values[el.dataset.g][el.dataset.k]; }); + markChanged(); + } + function readForm() { + const v = CostModel.getSettings(); + overlay.querySelectorAll('[data-g]').forEach(el => { + const it = window.COST_DEFAULTS[el.dataset.g].items[el.dataset.k]; + const val = it.options ? el.value : parseFloat(el.value); + if (it.options || isFinite(val)) v[el.dataset.g][el.dataset.k] = val; + }); + return v; + } + // Afwijkend van de standaard: gemarkeerd. + function markChanged() { + const def = CostModel.defaultValues(); + overlay.querySelectorAll('[data-g]').forEach(el => { + const d = def[el.dataset.g][el.dataset.k]; + el.closest('.cs-row').classList.toggle('changed', typeof d === 'number' ? parseFloat(el.value) !== d : el.value !== d); + }); + } + + function download(obj) { + const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `tectum-kosten-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + } + + function open() { + if (!overlay) build(); + renderForm(); // opnieuw: nieuwe items in cost-defaults.js verschijnen vanzelf + fill(CostModel.getSettings()); + overlay.classList.add('open'); + } + function close() { if (overlay) overlay.classList.remove('open'); } + + return { open, close }; +})(); diff --git a/js/elektra/elektra-model.js b/js/elektra/elektra-model.js new file mode 100644 index 0000000..747040a --- /dev/null +++ b/js/elektra/elektra-model.js @@ -0,0 +1,716 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// elektra/elektra-model.js — ElektraModel: ENIGE bron van de elektra- +// installatie van een woning (eerste versie, automatisch). Geen DOM. +// +// Woning-breed (settings.elektra). Alles wordt afgeleid uit de woning: +// - Meterkast (3×25 A, 3-fase) als kast aan de binnenzijde van een wand +// in de unit met de voordeur, zo dicht mogelijk bij de voordeur, vrij van +// openingen (incl. raamwerk) en van de schakelaars. +// - Schakelaarcombinatie naast de voordeur (binnenzijde, 1500 mm), aan de +// slotzijde (deur draait links, KozijnModel; anders de scharnierzijde): +// A = binnenlicht van die unit, B = buitenlicht (boeideel-spots en +// gevellampen). Elke andere unit krijgt een schakelaar naast de deur +// waardoor je hem binnenkomt (naadwand, anders een buitendeur), C, D, ... +// Elke lamp/spot krijgt de letter van zijn schakelaar (p.letter). +// - Wandcontactdozen (dubbel, 400 mm): wcdPerUnit per unit, zo ver mogelijk +// uit elkaar langs de binnenzijde van de wanden. +// - Apparaten (settings.apparaten, APPLIANCES) met elk een eigen groep: +// kookgroep (perilex, 2-fase, 5×2,5), wasmachine, droger, warmtepomp, +// optioneel oven en vaatwasser. Keukenapparaten bij elkaar in de grootste +// andere unit, de rest naast de meterkast (nog geen keuken in het model). +// - Handmatig verplaatsen (settings.place[key] = { element, side, u, y }): +// key 'mk' (meterkast), 'sw:' (schakelaars), 'wcd::', +// 'app:'. element = wandstuk, side = -1 (kamerzijde; bij een +// binnenwand ±1), u = hart langs de wand (wand-x), y = hoogte (optioneel). +// Handmatige plekken gaan voor: de automatische plaatsing wijkt uit. +// Wandvlakken: binnenzijde van buitenwanden en naadwanddelen, beide +// zijden van binnenwanden (in de unit waar hun midden ligt). +// - Groepen: per unit een lichtgroep (B10, 1,5 mm²) en een wcd-groep +// (B16, 2,5 mm²); het buitenlicht op een eigen groep (B10, XMvK 3×1,5), +// op dezelfde aardlek als het binnenlicht van de voordeur-unit (zelfde +// schakeldoos). Fasen L1/L2/L3 om en om; aardlekschakelaars (30 mA, +// max. 4 groepen elk, minstens 2) om en om, zodat licht en wcd van één +// unit niet op dezelfde aardlek zitten. +// +// Leidingen (HSB): binnen VD-draad in flexibele installatiebuis, via de +// stijlen (zelfde wand) of via het plafond (andere wand/plafondpunt): eerst +// verticaal in de wand naar het plafond, dan haaks (x, dan z) naar het +// volgende punt. Per schakeldoos en groep een luchtdichte verdeeldoos in het +// plafond erboven (groepen niet gemengd in één verdeeldoos); vandaar de schakeldraden omlaag en de lampen doorgelust volgens +// een minimale opspannende boom (kortste totale lengte). Buitenverlichting in +// XMvK. Een leiding over een naad tussen units krijgt een steekverbinder +// (modules worden los gebouwd en op de bouwplaats gekoppeld). +// Elke leiding: + reserve (lus in de doos, meterkast extra) en + slack %. +// +// Plain script, geladen na lighting-model.js en unit-model.js. +// ============================================================ + +window.ElektraModel = (() => { + const DEFAULT_SETTINGS = { + enabled: true, + meterkast: { w: 600, h: 900, d: 250, y: 1000 }, // mm; y = onderkant boven vloer + switchHeight: 1500, // hart schakelaar boven vloer + wcdHeight: 400, // hart wcd boven vloer + wcdPerUnit: 2, // dubbele wcd per unit + slack: 10, // % extra op elke leiding + reserve: 300, // mm per leiding (lussen in de dozen) + meterkastReserve: 500, // mm extra per leiding vanuit de meterkast + apparaten: { koken: true, wasmachine: true, droger: true, warmtepomp: true, oven: false, vaatwasser: false }, + place: {} // handmatige plekken (zie boven) + }; + // Apparaten met een eigen groep. code = label op de plattegrond (twee + // letters: geen verwarring met de schakelaarletters). + const APPLIANCES = { + koken: { label: 'Kookgroep (perilex)', code: 'KK', breaker: '2× B16', poles: 2, cable: 'koken', height: 600, zone: 'keuken', material: 'Perilex-wandcontactdoos (2-fase)' }, + oven: { label: 'Oven', code: 'OV', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' }, + vaatwasser: { label: 'Vaatwasser', code: 'VW', breaker: 'B16', cable: 'wcd', height: 600, zone: 'keuken', material: 'Wandcontactdoos enkel met randaarde' }, + wasmachine: { label: 'Wasmachine', code: 'WM', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' }, + droger: { label: 'Droger', code: 'DR', breaker: 'B16', cable: 'wcd', height: 1100, zone: 'techniek', material: 'Wandcontactdoos enkel met randaarde' }, + warmtepomp: { label: 'Warmtepomp', code: 'WP', breaker: 'B16', cable: 'wcd', height: 1200, zone: 'techniek', material: 'Werkschakelaar 2-polig (warmtepomp)' } + }; + const BOX_PITCH = 71; // h.o.h. inbouwdozen in een combinatie + const PLATE = 80; // afdekraam (vierkant) + const WCD_W = 150; // afdekraam dubbele wcd + const DOOR_GAP = 100; // kozijnstijl -> hart eerste doos (bovenop de stijl) + const VD_OFFSET = 150; // verdeeldoos: zoveel de kamer in vanaf het wandvlak + + // Leidingtypen. cores: aders (kleur via CORE_COLORS); conduit: buis-Ø (mm), + // 0 = kabel zonder buis. + const CORE_COLORS = { L: 'bruin', L2: 'zwart', L3: 'grijs', N: 'blauw', PE: 'geel/groen', S: 'zwart' }; + const CABLES = { + licht: { label: 'VD 3×1,5 mm² in buis Ø16', use: 'Verlichting: voeding en doorlus', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 16 }, + wcd: { label: 'VD 3×2,5 mm² in buis Ø19', use: 'Wcd en apparaten', mm2: 2.5, cores: ['L', 'N', 'PE'], conduit: 19 }, + koken: { label: 'VD 5×2,5 mm² in buis Ø25', use: 'Kookgroep (perilex, 2-fase)', mm2: 2.5, cores: ['L', 'L2', 'L3', 'N', 'PE'], conduit: 25 }, + buiten: { label: 'XMvK 3×1,5 mm²', use: 'Buitenverlichting (boeideel/gevel)', mm2: 1.5, cores: ['L', 'N', 'PE'], conduit: 0 } + }; + // Schakeldraden verdeeldoos -> schakeldoos: fase + één per schakelaar. + function cableInfo(key) { + if (CABLES[key]) return CABLES[key]; + const m = /^schakel(\d)$/.exec(key); + const n = m ? Number(m[1]) : 2; + return { label: `VD ${n}×1,5 mm² in buis Ø16`, use: `Schakeldraden (${n - 1} schakelaar${n > 2 ? 's' : ''})`, mm2: 1.5, cores: ['L'].concat(Array(n - 1).fill('S')), conduit: 16 }; + } + + function normalizeSettings(settings) { + const i = settings || {}; + const s = Object.assign({}, DEFAULT_SETTINGS, i); + s.meterkast = Object.assign({}, DEFAULT_SETTINGS.meterkast, i.meterkast || {}); + s.apparaten = Object.assign({}, DEFAULT_SETTINGS.apparaten, i.apparaten || {}); + s.place = Object.assign({}, i.place || {}); + s.enabled = s.enabled !== false; + // v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard). + if (!i.v) { + if (i.switchHeight === 1050) s.switchHeight = DEFAULT_SETTINGS.switchHeight; + if (i.wcdHeight === 300) s.wcdHeight = DEFAULT_SETTINGS.wcdHeight; + } + s.v = 2; + return s; + } + + // ------------------------------------------------------------ + // Wandvlakken (binnenzijde) waarop iets gemonteerd kan worden. + // ------------------------------------------------------------ + // Zijde van een wandstuk (side -1 = lokaal -Z: bij buitenwand en + // naadwanddeel de kamerkant; binnenwand: beide zijden), begrensd tot de + // binnenruimte van de unit. + function faceOf(p, inner, u, side) { + const pl = p.place, W = pl.width, lz = side * pl.thickness / 2; + const a = UnitModel.localToWorld(pl, -W / 2, lz), b = UnitModel.localToWorld(pl, W / 2, lz); + const axis = Math.abs(b.x - a.x) > Math.abs(b.z - a.z) ? 'x' : 'z'; + const uOf = (v) => (v - a[axis]) / (b[axis] - a[axis]) * W; + const ua = uOf(axis === 'x' ? inner.x0 : inner.z0), ub = uOf(axis === 'x' ? inner.x1 : inner.z1); + return { + piece: p, unit: u, a, b, axis, W, lz, + u0: Math.max(0, Math.min(ua, ub)), u1: Math.min(W, Math.max(ua, ub)), + n: { x: side * Math.sin(pl.rotY), z: side * Math.cos(pl.rotY) }, // naar de kamer + side, + sw: p.config.studWidth || 45, + reserved: [] // [{ a, b, y0, y1 }] al bezet (meterkast, schakelaars, wcd) + }; + } + function facesOf(u, interior) { + const inner = LightingModel.unitInnerRect(u, interior); + const pieces = []; + u.walls.forEach(w => w.pieces.forEach(p => { if (!p.baseY) pieces.push(p); })); + interior.filter(p => p.seamUnit === u.id).forEach(p => pieces.push(p)); + const list = pieces.map(p => faceOf(p, inner, u, -1)); + const r = u.rect; + interior.filter(p => !p.seam && p.place.cx >= r.x0 && p.place.cx <= r.x1 && p.place.cz >= r.z0 && p.place.cz <= r.z1) + .forEach(p => list.push(faceOf(p, inner, u, -1), faceOf(p, inner, u, 1))); + return list.filter(f => f.u1 - f.u0 > 200); + } + // Punt op een wandvlak: u langs de wand (0..W), y boven de vloer, off de + // kamer in (negatief = de wand in). + function facePoint(f, u, y, off) { + const q = UnitModel.localToWorld(f.piece.place, u - f.W / 2, f.lz + f.side * (off || 0)); + return { x: q.x, y: f.piece.place.floorTop + y, z: q.z }; + } + const uAt = (f, q) => (q[f.axis] - f.a[f.axis]) / (f.b[f.axis] - f.a[f.axis]) * f.W; + // Vrije stukken van een vlak tussen hoogte y0..y1: openingen (incl. + // raamwerk + margin) en wat al bezet is eraf, edge vrij van de uiteinden. + function freeOn(f, y0, y1, margin, edge) { + const blocked = []; + f.piece.config.openings.forEach(op => { + if (op.y < y1 && WallModel.openingTopMax(op) > y0) blocked.push([op.x - f.sw - margin, op.x + op.w + f.sw + margin]); + }); + f.reserved.forEach(r => { if (r.y0 < y1 && r.y1 > y0) blocked.push([r.a, r.b]); }); + return LightingModel.freeIntervals(f.u0 + edge, f.u1 - edge, blocked); + } + // Dichtstbijzijnde vrije plek (breedte w) op een van de vlakken bij target + // (wereld {x, z}). Geeft { f, u, d } of null. + function nearestFree(faces, target, w, y0, y1, margin, edge) { + let best = null; + faces.forEach(f => freeOn(f, y0, y1, margin, edge).forEach(([a, b]) => { + if (b - a < w) return; + const u = Math.max(a + w / 2, Math.min(b - w / 2, uAt(f, target))); + const q = facePoint(f, u, 0, 0); + const d = Math.hypot(q.x - target.x, q.z - target.z); + if (!best || d < best.d) best = { f, u, d }; + })); + return best; + } + + // Schakelaarcombinatie van n dozen naast opening op op vlak f: eerst de + // slotzijde (rechts van de opening in wand-x), dan de scharnierzijde. + // Geeft de doos-centra (u, dichtst bij de deur eerst) of null. + function besideDoor(f, op, n, height) { + const width = PLATE + (n - 1) * BOX_PITCH; + const free = freeOn(f, height - PLATE / 2, height + PLATE / 2, 0, f.sw); + const fits = (lo, hi) => free.some(([a, b]) => lo >= a - 0.5 && hi <= b + 0.5); + const r0 = op.x + op.w + f.sw + DOOR_GAP; + if (fits(r0 - PLATE / 2, r0 - PLATE / 2 + width)) return Array.from({ length: n }, (_, i) => r0 + i * BOX_PITCH); + const l0 = op.x - f.sw - DOOR_GAP; + if (fits(l0 + PLATE / 2 - width, l0 + PLATE / 2)) return Array.from({ length: n }, (_, i) => l0 - i * BOX_PITCH); + return null; + } + + // ------------------------------------------------------------ + // Routes. Knooppunt: { x, y, z, mount: 'wand'|'plafond'|'buiten', face?, mk? }. + // ------------------------------------------------------------ + function makeRouter(units) { + 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) => { + 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); + const d = Math.hypot(dx, dz); + return !best || d < best.d ? { u: c, d } : best; + }, null).u; + 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))); + }; + // Pad als 3D-punten (mm). Per punt: wall = wandvlak waar het in de wand + // ligt, ceil = boven het plafond (voor de buizen in de 3D, build3D). + function path(a, b) { + const P = (p, wall, ceil) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil }); + 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)]; // door de stijlen + 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); + 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)); + 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; + } + 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); + // Snelle schatting (voor de boom): haaks + hoogteverschil. + const cost = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.z - b.z) + Math.abs(a.y - b.y); + // Aantal keer dat het pad (in plattegrond) van de ene unit naar een andere gaat. + function crossings(pts) { + let last = null, n = 0; + pts.slice(1).forEach((q, i) => { + const p = pts[i], len = Math.hypot(q.x - p.x, q.z - p.z), steps = Math.max(1, Math.ceil(len / 100)); + for (let k = 0; k <= steps; k++) { + const u = unitAt(p.x + (q.x - p.x) * k / steps, p.z + (q.z - p.z) * k / steps); + if (!u) continue; + if (last && u.id !== last) n++; + last = u.id; + } + }); + return n; + } + return { unitAt, ceilAt, path, length, cost, crossings }; + } + + // ------------------------------------------------------------ + // Alles voor de hele woning. units/interior = HouseModel-parts, + // lighting = parts.lighting (lamps, indoor), roofEdge = parts.roofEdge. + // Zet p.letter op elke lamp/spot. Geeft het elektra-resultaat (zie onder). + // ------------------------------------------------------------ + function compute(settings, units, interior, lighting, roofEdge) { + const s = normalizeSettings(settings); + const indoor = lighting.indoor || [], gevel = lighting.lamps || [], spots = (roofEdge && roofEdge.spots) || []; + indoor.concat(gevel, spots).forEach(p => { delete p.letter; }); + const out = { + enabled: s.enabled, settings: s, meterkast: null, combos: [], switches: [], wcds: [], apps: [], boxes: [], + groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: [] + }; + if (!s.enabled || !units.length) return out; + const R = makeRouter(units); + 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); + const allFaces = out.faces = units.reduce((a, u) => a.concat(faces[u.id]), []); + + // 1. Voordeur (anders de eerste buitendeur, anders de eerste unit). + let door = null; + ['voordeur', 'deur'].some(type => units.some(u => u.walls.some(w => w.pieces.some(p => { + if (p.baseY) return false; + const op = p.config.openings.find(o => o.type === type); + const f = op && faceOfPiece(u, p); + if (f) door = { unit: u, face: f, op }; + return !!door; + })))); + if (!door) out.warnings.push('Geen voordeur of buitendeur gevonden: schakelaars en meterkast vrij geplaatst.'); + const mainUnit = door ? door.unit : units[0]; + const hasOutdoor = gevel.length + spots.length > 0; + let letterCode = 65; + const nextLetter = () => String.fromCharCode(letterCode++); + const mainFuncs = hasOutdoor ? ['binnen', 'buiten'] : ['binnen']; + const mk = s.meterkast; + const apps = Object.keys(APPLIANCES).filter(k => s.apparaten[k]); + const num = (v, def) => typeof v === 'number' && isFinite(v) ? v : def; + + // Handmatige plekken eerst vastleggen (de automatische wijken uit). + const comboWidth = (n) => PLATE + (n - 1) * BOX_PITCH; + const widthOf = (key) => { + if (key === 'mk') return mk.w; + let m = /^sw:(.+)$/.exec(key); + if (m) return units.some(u => u.id === m[1]) ? comboWidth(m[1] === mainUnit.id ? mainFuncs.length : 1) : 0; + m = /^wcd:(.+):(\d+)$/.exec(key); + if (m) return units.some(u => u.id === m[1]) && Number(m[2]) <= s.wcdPerUnit ? WCD_W : 0; + m = /^app:(.+)$/.exec(key); + return m && apps.includes(m[1]) ? PLATE : 0; + }; + const fixed = {}; + Object.keys(s.place).forEach(key => { + const o = s.place[key], w = widthOf(key); + if (!o || !w) return; + const f = allFaces.find(x => x.piece.element === o.element && x.side === (o.side || -1)); + if (!f) return; + const u = Math.max(f.u0 + w / 2, Math.min(f.u1 - w / 2, num(o.u, (f.u0 + f.u1) / 2))); + fixed[key] = { f, u, y: num(o.y, null) }; + f.reserved.push({ a: u - w / 2 - 50, b: u + w / 2 + 50, y0: 0, y1: 1e6 }); + }); + const centersAt = (u, n) => Array.from({ length: n }, (_, i) => u - comboWidth(n) / 2 + PLATE / 2 + i * BOX_PITCH); + + // 2. Schakelaars bij de voordeur (A binnen, B buiten). + const addCombo = (unit, f, us, funcs, y, manual) => { + const yy = y === null || y === undefined ? s.switchHeight : y; + const combo = { id: out.combos.length + 1, key: 'sw:' + unit.id, manual: !!manual, unitId: unit.id, face: f, us, y: yy, rotY: f.piece.place.rotY, switches: [] }; + funcs.forEach((func, i) => { + const sw = { letter: nextLetter(), func, unitId: unit.id, combo, u: us[i], lamps: [], label: func === 'buiten' ? 'Buitenlicht' : `Binnenlicht unit ${unit.id}` }; + sw.pos = facePoint(f, us[i], yy, 0); + combo.switches.push(sw); + out.switches.push(sw); + }); + const lo = Math.min(...us) - PLATE / 2, hi = Math.max(...us) + PLATE / 2; + if (!manual) f.reserved.push({ a: lo - 60, b: hi + 60, y0: 0, y1: 1e6 }); + combo.u = (lo + hi) / 2; + combo.pos = facePoint(f, combo.u, yy, 0); + combo.width = hi - lo; + combo.center = facePoint(f, combo.u, yy, 5); + out.combos.push(combo); + return combo; + }; + const fxMain = fixed['sw:' + mainUnit.id]; + if (fxMain) addCombo(mainUnit, fxMain.f, centersAt(fxMain.u, mainFuncs.length), mainFuncs, fxMain.y, true); + else if (door) { + const us = besideDoor(door.face, door.op, mainFuncs.length, s.switchHeight); + if (us) addCombo(mainUnit, door.face, us, mainFuncs); + else { + const doorPt = facePoint(door.face, door.op.x + door.op.w / 2, 0, 0); + const w = PLATE + (mainFuncs.length - 1) * BOX_PITCH; + const nf = nearestFree(faces[mainUnit.id], doorPt, w, s.switchHeight - PLATE / 2, s.switchHeight + PLATE / 2, 0, 100); + if (nf) addCombo(mainUnit, nf.f, mainFuncs.map((_, i) => nf.u - w / 2 + PLATE / 2 + i * BOX_PITCH), mainFuncs); + } + } + if (!out.combos.length) { + const f = faces[mainUnit.id][0]; + if (f) addCombo(mainUnit, f, mainFuncs.map((_, i) => (f.u0 + f.u1) / 2 + i * BOX_PITCH), mainFuncs); + } + const entry = out.combos[0] ? out.combos[0].pos : { x: mainUnit.rect.cx, z: mainUnit.rect.cz }; + + // 3. Meterkast: zo dicht mogelijk bij de voordeur, eerst in die unit. + const target = door ? facePoint(door.face, door.op.x + door.op.w / 2, 0, 0) : entry; + const fxMk = fixed.mk; + const mkY = fxMk && fxMk.y !== null ? fxMk.y : mk.y; + let spot = fxMk ? { f: fxMk.f, u: fxMk.u } : nearestFree(faces[mainUnit.id], target, mk.w, mkY, mkY + mk.h, 50, 50); + if (!spot) units.some(u => u !== mainUnit && (spot = nearestFree(faces[u.id], target, mk.w, mkY, mkY + mk.h, 50, 50))); + if (spot) { + const f = spot.f; + if (!fxMk) f.reserved.push({ a: spot.u - mk.w / 2 - 50, b: spot.u + mk.w / 2 + 50, y0: 0, y1: 1e6 }); + out.meterkast = { + key: 'mk', manual: !!fxMk, unitId: f.unit.id, element: f.piece.element, face: f, u: spot.u, w: mk.w, h: mk.h, d: mk.d, y: mkY, rotY: f.piece.place.rotY, + center: facePoint(f, spot.u, mkY + mk.h / 2, mk.d / 2), + corners: [[-1, 0], [1, 0], [1, mk.d], [-1, mk.d]].map(([i, d]) => facePoint(f, spot.u + i * mk.w / 2, 0, d)) + }; + } else out.warnings.push('Geen vrije wand voor de meterkast gevonden.'); + + // 4. Schakelaar per andere unit, naast de deur waardoor je hem binnenkomt. + units.forEach(u => { + if (u === mainUnit) return; + const fx = fixed['sw:' + u.id]; + if (fx) { addCombo(u, fx.f, centersAt(fx.u, 1), ['binnen'], fx.y, true); return; } + const cands = []; + faces[u.id].forEach(f => f.piece.config.openings.forEach(op => { + if (op.type !== 'deur' && op.type !== 'voordeur') return; + const q = facePoint(f, op.x + op.w / 2, 0, 0); + cands.push({ f, op, d: Math.hypot(q.x - entry.x, q.z - entry.z) + (f.piece.seamUnit ? 0 : 5000) }); // naadwand-deur eerst + })); + cands.sort((a, b) => a.d - b.d); + for (const c of cands) { + const us = besideDoor(c.f, c.op, 1, s.switchHeight); + if (us) { addCombo(u, c.f, us, ['binnen']); return; } + } + out.warnings.push(`Unit ${u.id}: geen deur met ruimte voor een schakelaar; binnenlicht op schakelaar ${out.switches[0] ? out.switches[0].letter : '-'}.`); + }); + + // 5. Lampen aan schakelaars koppelen (letter). + const swOf = (unitId, func) => out.switches.find(x => x.unitId === unitId && x.func === func) || out.switches.find(x => x.func === func) || out.switches[0]; + indoor.forEach(p => { const sw = swOf(p.unitId, 'binnen'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x, y: p.y, z: p.z, mount: 'plafond', kind: 'binnenspot', ref: p }); } }); + gevel.forEach(p => { const sw = swOf(mainUnit.id, 'buiten'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x + p.normal.x * 60, y: p.y, z: p.z + p.normal.z * 60, mount: 'buiten', kind: 'gevellamp', ref: p }); } }); + spots.forEach(p => { const sw = swOf(mainUnit.id, 'buiten'); if (sw) { p.letter = sw.letter; sw.lamps.push({ x: p.x, y: p.y, z: p.z, mount: 'buiten', kind: 'boeideelspot', ref: p }); } }); + + // 6. Wandcontactdozen per unit (zo ver mogelijk uit elkaar). + units.forEach(u => { + const y0 = s.wcdHeight - 60, y1 = s.wcdHeight + 60, cands = []; + faces[u.id].forEach(f => freeOn(f, y0, y1, 100, 150).forEach(([a, b]) => { + if (b - a < WCD_W + 20) return; + (b - a > 1200 ? [a + 300, (a + b) / 2, b - 300] : [(a + b) / 2]).forEach(uu => cands.push({ f, u: uu, len: b - a, p: facePoint(f, uu, s.wcdHeight, 0) })); + })); + const chosen = [], freeKeys = []; + for (let i = 1; i <= s.wcdPerUnit; i++) { + const key = `wcd:${u.id}:${i}`, fx = fixed[key]; + if (!fx) { freeKeys.push(key); continue; } + const y = fx.y !== null ? fx.y : s.wcdHeight; + chosen.push({ f: fx.f, u: fx.u, y, p: facePoint(fx.f, fx.u, y, 0), key, manual: true }); + } + while (freeKeys.length) { + let best = null, bestScore = -1; + cands.forEach(c => { + if (chosen.some(x => x.f === c.f && Math.abs(x.u - c.u) < WCD_W + 150)) return; + const score = chosen.length ? Math.min(...chosen.map(x => Math.hypot(x.p.x - c.p.x, x.p.z - c.p.z))) : c.len; + if (score > bestScore) { bestScore = score; best = c; } + }); + if (!best) break; + chosen.push(Object.assign({}, best, { key: freeKeys.shift(), y: s.wcdHeight })); + best.f.reserved.push({ a: best.u - WCD_W / 2 - 75, b: best.u + WCD_W / 2 + 75, y0: 0, y1: s.wcdHeight + 200 }); + } + if (chosen.length < s.wcdPerUnit) out.warnings.push(`Unit ${u.id}: ruimte voor ${chosen.length} van ${s.wcdPerUnit} wcd's.`); + chosen.sort((a, b) => a.key < b.key ? -1 : 1).forEach(c => out.wcds.push({ + key: c.key, manual: !!c.manual, unitId: u.id, face: c.f, u: c.u, y: c.y, rotY: c.f.piece.place.rotY, pos: c.p, center: facePoint(c.f, c.u, c.y, 6) + })); + }); + + // 6b. Apparaten: keuken bij elkaar in de grootste andere unit, de rest + // naast de meterkast. + const others = units.filter(u => u !== mainUnit); + const kitchen = (others.length ? others : units).reduce((a, b) => a.rect.W * a.rect.D >= b.rect.W * b.rect.D ? a : b); + const mkUnit = out.meterkast ? units.find(u => u.id === out.meterkast.unitId) : mainUnit; + const zones = { + keuken: { target: { x: kitchen.rect.cx, z: kitchen.rect.cz }, order: [kitchen].concat(units.filter(u => u !== kitchen)) }, + techniek: { target: out.meterkast ? out.meterkast.center : entry, order: [mkUnit].concat(units.filter(u => u !== mkUnit)) } + }; + apps.forEach(k => { + const A = APPLIANCES[k], key = 'app:' + k, fx = fixed[key]; + let f, uu, y = A.height; + if (fx) { f = fx.f; uu = fx.u; if (fx.y !== null) y = fx.y; } + else { + const z = zones[A.zone]; + let nf = null; + z.order.some(u => (nf = nearestFree(faces[u.id], z.target, PLATE + 40, y - 60, y + 60, 100, 150))); + if (!nf) { out.warnings.push(`${A.label}: geen vrije wand gevonden.`); return; } + f = nf.f; uu = nf.u; + f.reserved.push({ a: uu - PLATE / 2 - 80, b: uu + PLATE / 2 + 80, y0: 0, y1: 1e6 }); + } + out.apps.push({ key, manual: !!fx, app: k, label: A.label, code: A.code, unitId: f.unit.id, face: f, u: uu, y, rotY: f.piece.place.rotY, pos: facePoint(f, uu, y, 0), center: facePoint(f, uu, y, 6) }); + }); + + // 7. Groepen: per unit licht (B10) + wcd (B16); fasen en aardlek om en om. + units.forEach(u => { + const sws = out.switches.filter(x => x.unitId === u.id && x.func === 'binnen'); + if (sws.length) out.groups.push({ kind: 'licht', unitId: u.id, label: `Verlichting unit ${u.id}`, breaker: 'B10', cable: 'licht', switches: sws.map(x => x.letter) }); + if (out.wcds.some(w => w.unitId === u.id)) out.groups.push({ kind: 'wcd', unitId: u.id, label: `Wcd unit ${u.id}`, breaker: 'B16', cable: 'wcd', switches: [] }); + const outSw = out.switches.filter(x => x.unitId === u.id && x.func === 'buiten'); + if (outSw.length) out.groups.push({ kind: 'buiten', unitId: u.id, label: 'Buitenverlichting', breaker: 'B10', cable: 'buiten', switches: outSw.map(x => x.letter) }); + }); + out.apps.forEach(a => { + const A = APPLIANCES[a.app]; + out.groups.push({ kind: 'app', app: a.app, unitId: a.unitId, label: A.label, breaker: A.breaker, cable: A.cable, poles: A.poles || 1, switches: [] }); + }); + const nRcd = Math.max(2, Math.ceil(out.groups.length / 4)); + let ph = 0; + out.groups.forEach((g, i) => { + const n = g.poles || 1; + g.nr = i + 1; g.rcd = i % nRcd + 1; + g.phase = Array.from({ length: n }, (_, j) => 'L' + ((ph + j) % 3 + 1)).join('+'); + ph += n; + }); + out.rcdCount = out.groups.length ? nRcd : 0; + const groupOf = (kind, unitId) => out.groups.find(g => g.kind === kind && g.unitId === unitId); + // Buitenlicht zit in de schakeldoos van het binnenlicht: zelfde aardlek. + const gOut = out.groups.find(g => g.kind === 'buiten'), gIn = gOut && groupOf('licht', gOut.unitId); + if (gOut && gIn) gOut.rcd = gIn.rcd; + // Max. 4 groepen per aardlek: anders een andere groep (niet die twee) + // naar de aardlek met de minste groepen. + for (let guard = 0; guard < 20; guard++) { + const cnt = Array.from({ length: nRcd }, (_, i) => out.groups.filter(g => g.rcd === i + 1).length); + const over = cnt.findIndex(c => c > 4); + if (over < 0) break; + const mv = out.groups.slice().reverse().find(g => g.rcd === over + 1 && g !== gOut && g !== gIn); + if (!mv) break; + mv.rcd = cnt.indexOf(Math.min(...cnt)) + 1; + } + out.switches.forEach(sw => { const g = groupOf(sw.func === 'buiten' ? 'buiten' : 'licht', sw.unitId); sw.group = g ? g.nr : null; }); + out.wcds.forEach(w => { const g = groupOf('wcd', w.unitId); w.group = g ? g.nr : null; }); + out.apps.forEach(a => { const g = out.groups.find(x => x.kind === 'app' && x.app === a.app); a.group = g ? g.nr : null; }); + + // 8. Leidingen. + const mkNode = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY + mk.h, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null; + const mkLow = out.meterkast ? Object.assign(facePoint(out.meterkast.face, out.meterkast.u, mkY, 0), { mount: 'wand', face: out.meterkast.face, mk: true, name: 'MK' }) : null; + const addRun = (type, a, b, group, letter) => { + const pts = R.path(a, b); + const raw = R.length(pts); + const lengthMM = (raw + s.reserve + (a.mk || b.mk ? s.meterkastReserve : 0)) * (1 + s.slack / 100); + out.runs.push({ type, group, letter: letter || null, from: a.name || '', to: b.name || '', path: pts, rawMM: raw, lengthMM, crossings: R.crossings(pts), outdoor: a.mount === 'buiten' || b.mount === 'buiten' }); + }; + // Minimale opspannende boom vanaf root (Prim, op de snelle schatting). + const tree = (root, nodes, typeOf, group, letter) => { + const done = [root], rest = nodes.slice(); + while (rest.length) { + let best = null; + rest.forEach((n, i) => done.forEach(t => { const c = R.cost(t, n); if (!best || c < best.c) best = { c, i, t }; })); + const n = rest.splice(best.i, 1)[0]; + addRun(typeOf(best.t, n), best.t, n, group, letter); + done.push(n); + } + }; + // Per schakeldoos één verdeeldoos per groep (naast elkaar langs de wand). + for (const combo of out.combos) { + const byGroup = []; + combo.switches.forEach(sw => { + let e = byGroup.find(x => x.group === sw.group); + if (!e) byGroup.push(e = { group: sw.group, switches: [] }); + e.switches.push(sw); + }); + combo.vds = []; + const along = { x: combo.face.b.x - combo.face.a.x, z: combo.face.b.z - combo.face.a.z }, al = Math.hypot(along.x, along.z) || 1; + byGroup.forEach((e, i) => { + const shift = (i - (byGroup.length - 1) / 2) * VD_OFFSET; + const vd = { + x: combo.pos.x + combo.face.n.x * VD_OFFSET + along.x / al * shift, z: combo.pos.z + combo.face.n.z * VD_OFFSET + along.z / al * shift, + mount: 'plafond', name: `VD${combo.id}${byGroup.length > 1 ? String.fromCharCode(97 + i) : ''}` + }; + vd.y = R.ceilAt(vd.x, vd.z); + combo.vds.push(vd); + out.boxes.push({ kind: 'verdeeldoos', combo: combo.id, group: e.group, x: vd.x, y: vd.y, z: vd.z, unitId: combo.unitId }); + if (mkNode) addRun('licht', mkNode, vd, e.group); // voeding binnen: VD in buis + const swNode = Object.assign({}, combo.pos, { mount: 'wand', face: combo.face, name: e.switches.map(x => x.letter).join('') }); + addRun('schakel' + (e.switches.length + 1), vd, swNode, e.group); + e.switches.forEach(sw => tree(vd, sw.lamps.map((l, k) => Object.assign(l, { name: `${sw.letter}${k + 1}` })), + (a, b) => a.mount === 'buiten' || b.mount === 'buiten' ? 'buiten' : 'licht', e.group, sw.letter)); + }); + } + out.groups.filter(g => g.kind === 'wcd').forEach(g => { + const nodes = out.wcds.filter(w => w.group === g.nr).map((w, i) => Object.assign({}, w.pos, { mount: 'wand', face: w.face, name: `WCD${g.nr}.${i + 1}` })); + if (mkLow && nodes.length) tree(mkLow, nodes, () => 'wcd', g.nr); + }); + // Apparaten: elk een eigen leiding vanuit de meterkast. + if (mkLow) out.apps.forEach(a => addRun(APPLIANCES[a.app].cable, mkLow, Object.assign({}, a.pos, { mount: 'wand', face: a.face, name: a.code }), a.group)); + + // 9. Totalen: per leidingtype, draad per doorsnede/kleur, buis, groep. + const byType = {}; + out.runs.forEach(r => { byType[r.type] = (byType[r.type] || 0) + r.lengthMM; }); + out.cables = Object.keys(byType).sort().map(k => ({ key: k, label: cableInfo(k).label, use: cableInfo(k).use, meters: byType[k] / 1000 })); + const wires = {}, conduits = {}; + out.runs.forEach(r => { + const c = cableInfo(r.type); + if (!c.conduit) return; + conduits[c.conduit] = (conduits[c.conduit] || 0) + r.lengthMM; + c.cores.forEach(core => { + const k = `${String(c.mm2).replace('.', ',')} mm² ${CORE_COLORS[core]}`; + wires[k] = (wires[k] || 0) + r.lengthMM; + }); + }); + out.wires = Object.keys(wires).sort().map(k => ({ label: `VD-draad ${k}`, meters: wires[k] / 1000 })); + out.conduits = Object.keys(conduits).sort((a, b) => a - b).map(k => ({ label: `Flexibele installatiebuis Ø${k} mm`, meters: conduits[k] / 1000 })); + out.groups.forEach(g => { + const rs = out.runs.filter(r => r.group === g.nr); + g.meters = rs.reduce((a, r) => a + r.lengthMM, 0) / 1000; + g.points = g.kind === 'wcd' ? out.wcds.filter(w => w.group === g.nr).length + : g.kind === 'app' ? 1 + : out.switches.filter(x => x.group === g.nr).reduce((a, x) => a + x.lamps.length, 0); + }); + out.groups.forEach(g => { + if ((g.kind === 'licht' || g.kind === 'buiten') && g.points > 50) out.warnings.push(`Groep ${g.nr} (${g.label}): ${g.points} lichtpunten; controleer het vermogen (LED).`); + }); + + // 10. Materiaal (aantallen). + const crossBy = {}; + out.runs.forEach(r => { if (r.crossings) { const n = cableInfo(r.type).cores.length; crossBy[n] = (crossBy[n] || 0) + r.crossings; } }); + const nIndoor = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'binnenspot').length, 0); + const nSpots = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'boeideelspot').length, 0); + const nGevel = out.switches.reduce((a, x) => a + x.lamps.filter(l => l.kind === 'gevellamp').length, 0); + const nLicht = out.groups.filter(g => g.kind === 'licht').length, nWcdG = out.groups.filter(g => g.kind === 'wcd').length; + const frames = {}; + out.combos.forEach(c => { frames[c.switches.length] = (frames[c.switches.length] || 0) + 1; }); + const m = out.materials; + const add = (naam, aantal, eenheid) => { if (aantal > 0) m.push({ naam, aantal, eenheid: eenheid || 'st.' }); }; + if (out.meterkast) { + add('Groepenkast 3-fase (aansluiting 3×25 A)', 1); + add('Hoofdschakelaar 4-polig 40 A', 1); + } + add('Aardlekschakelaar 2-polig 40 A / 30 mA', out.rcdCount); + add('Installatieautomaat B10 1P+N (verlichting)', nLicht); + add('Installatieautomaat B10 1P+N (buitenverlichting)', out.groups.filter(g => g.kind === 'buiten').length); + add('Installatieautomaat B16 1P+N (wcd)', nWcdG); + add('Installatieautomaat B16 1P+N (apparaten)', out.apps.filter(a => (APPLIANCES[a.app].poles || 1) === 1).length); + add('Installatieautomaat 2× B16, 2-fase (kookgroep)', out.apps.filter(a => APPLIANCES[a.app].poles === 2).length); + add('Schakelaar enkelpolig (inbouw)', out.switches.length); + Object.keys(frames).forEach(n => add(`Afdekraam ${n}-voudig`, frames[n])); + add('Wandcontactdoos dubbel met randaarde', out.wcds.length); + const appMat = {}; + out.apps.forEach(a => { const n = APPLIANCES[a.app].material; appMat[n] = (appMat[n] || 0) + 1; }); + Object.keys(appMat).forEach(n => add(n, appMat[n])); + add('Luchtdichte hollewanddoos (schakelaar/wcd/apparaat)', out.switches.length + 2 * out.wcds.length + out.apps.length); + add('Luchtdichte verdeeldoos (plafond)', out.boxes.length); + add('Lasklem 3-voudig (schatting)', out.boxes.length * 3 + out.switches.length + out.wcds.length); + add('LED-inbouwspot binnen + luchtdichte spotdoos', nIndoor); + add('Buitenspot boeideel (IP44)', nSpots); + add('Gevellamp (IP44)', nGevel); + add('Luchtdichte kabeldoorvoer naar buiten', out.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && r.to !== '' && !r.to.startsWith('VD')).length); + Object.keys(crossBy).forEach(n => add(`Steekverbinder module-naad ${n}-polig (set m/v)`, crossBy[n])); + + // 11. Verplaatsbare onderdelen (plattegrond: slepen, selecteren). + // marker = plek van het symbool in de plattegrond (iets de kamer in). + const item = (key, kind, label, f, u, y, manual, off, unitId) => out.items.push({ + key, kind, label, unitId: unitId || f.unit.id, element: f.piece.element, side: f.side, u, y, manual: !!manual, marker: facePoint(f, u, 0, off) + }); + const k = out.meterkast; + if (k) item('mk', 'mk', 'Meterkast', k.face, k.u, k.y, k.manual, k.d / 2); + out.combos.forEach(c => item(c.key, 'sw', 'Schakelaar ' + c.switches.map(x => x.letter).join(' '), c.face, c.u, c.y, c.manual, 260, c.unitId)); + out.wcds.forEach(w => item(w.key, 'wcd', `Wcd ${w.key.split(':')[2]} unit ${w.unitId} (groep ${w.group})`, w.face, w.u, w.y, w.manual, 120, w.unitId)); + out.apps.forEach(a => item(a.key, 'app', `${a.label} (groep ${a.group})`, a.face, a.u, a.y, a.manual, 150)); + return out; + } + + // Dichtstbijzijnde wandvlak (kamerzijde) bij plattegrondpunt (x, z), voor + // het slepen: { element, side, u, x, z, n } of null. + function snapToWall(el, x, z) { + let best = null; + ((el && el.faces) || []).forEach(f => { + const u = Math.max(f.u0, Math.min(f.u1, uAt(f, { x, z }))); + const q = facePoint(f, u, 0, 0); + if ((x - q.x) * f.n.x + (z - q.z) * f.n.z < -(f.piece.place.thickness + 50)) return; // aan de andere kant van de wand + const d = Math.hypot(q.x - x, q.z - z); + if (!best || d < best.d) best = { d, element: f.piece.element, side: f.side, u: Math.round(u / 10) * 10, x: q.x, z: q.z, n: f.n }; + }); + return best; + } + + // ------------------------------------------------------------ + // 3D per bouwfase (BuildStages): + // elektra meterkast (blijft), installatiebuizen (zeskant, kleur per + // leidingtype) in de wanden en boven het plafond, luchtdichte + // inbouwdozen, verdeeldozen en spotdozen. Vanaf "beplating" + // zitten buizen en dozen achter de platen: niet meer gebouwd. + // afwerking afdekramen van schakelaars, wcd's en apparaten. + // Kabel zonder buis (XMvK buiten) en losse draad worden niet getekend. + // ------------------------------------------------------------ + const WALL_IN = 35; // buis: zoveel achter het wandvlak (in de stijlruimte) + const CEIL_UP = 35; // buis: zoveel boven de onderkant plafond (op de plafondplaat) + const CONDUIT_COLORS = { licht: 0xe0b020, wcd: 0xcc4a30, koken: 0x8a3a9a, schakel: 0x9a6a20 }; + function createMaterials() { + const conduit = {}; + Object.keys(CONDUIT_COLORS).forEach(k => { conduit[k] = new THREE.MeshStandardMaterial({ color: CONDUIT_COLORS[k], roughness: 0.55 }); }); + return { + kastMaterial: new THREE.MeshStandardMaterial({ color: 0xd8d8d2, roughness: 0.6 }), + deurMaterial: new THREE.MeshStandardMaterial({ color: 0xc4c4bd, roughness: 0.5 }), + plateMaterial: new THREE.MeshStandardMaterial({ color: 0xf6f6f2, roughness: 0.4 }), + boxMaterial: new THREE.MeshStandardMaterial({ color: 0xe8742a, roughness: 0.6 }), + conduit + }; + } + function build3D(el, materials, stage) { + const toM = 0.001, group = new THREE.Group(); + if (!el || !el.enabled) return group; + const mats = materials || createMaterials(); + const st = BuildStages.clamp(stage), show = (key) => BuildStages.shows(st, key); + const rough = show('elektra') && !show('beplating'); + 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); + mesh.rotation.y = rotY; + group.add(mesh); + return mesh; + }; + const k = el.meterkast; + if (k && show('elektra')) { + 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')) { + 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)); + } + if (!rough) return group; + + // Installatiebuizen: zeskantige cilinders per padstuk + bolletje op elke knik. + const up = new THREE.Vector3(0, 1, 0); + const shift = (p) => ({ + x: p.x - (p.wall ? p.wall.n.x * WALL_IN : 0), y: p.y + (p.ceil ? CEIL_UP : 0), z: p.z - (p.wall ? p.wall.n.z * WALL_IN : 0) + }); + el.runs.forEach(r => { + const c = cableInfo(r.type); + if (!c.conduit) return; + const rad = c.conduit / 2 * toM; + const mat = mats.conduit[r.type] || mats.conduit.schakel; + const pts = r.path.map(shift); + pts.slice(1).forEach((q, i) => { + const p = pts[i], dx = q.x - p.x, dy = q.y - p.y, dz = q.z - p.z, len = Math.hypot(dx, dy, dz); + if (len < 1) return; + const m = new THREE.Mesh(new THREE.CylinderGeometry(rad, rad, len * toM, 6), mat); + m.position.set((p.x + q.x) / 2 * toM, (p.y + q.y) / 2 * toM, (p.z + q.z) / 2 * toM); + m.quaternion.setFromUnitVectors(up, new THREE.Vector3(dx / len, dy / len, dz / len)); + group.add(m); + }); + pts.slice(1, -1).forEach(p => { + const j = new THREE.Mesh(new THREE.IcosahedronGeometry(rad * 1.15, 0), mat); + j.position.set(p.x * toM, p.y * toM, p.z * toM); + group.add(j); + }); + }); + // Luchtdichte inbouwdozen (schakelaars, wcd, apparaten): in de wand. + const wallBox = (f, u, y) => { + const c = facePoint(f, u, y, -22); + const m = new THREE.Mesh(new THREE.CylinderGeometry(0.034, 0.034, 0.045, 12), mats.boxMaterial); + m.position.set(c.x * toM, c.y * toM, c.z * toM); + m.quaternion.setFromUnitVectors(up, new THREE.Vector3(f.n.x, 0, f.n.z)); + group.add(m); + }; + el.switches.forEach(sw => wallBox(sw.combo.face, sw.u, sw.combo.y)); + el.wcds.forEach(w => { wallBox(w.face, w.u - 36, w.y); wallBox(w.face, w.u + 36, w.y); }); + el.apps.forEach(a => wallBox(a.face, a.u, a.y)); + // Verdeeldozen en spotdozen boven het plafond. + el.boxes.forEach(b => box(90, 50, 90, { x: b.x, y: b.y + CEIL_UP + 10, z: b.z }, 0, mats.boxMaterial)); + el.switches.forEach(sw => sw.lamps.filter(l => l.kind === 'binnenspot').forEach(l => { + const m = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.07, 12), mats.boxMaterial); + m.position.set(l.x * toM, (l.y + 18 + 35) * toM, l.z * toM); + group.add(m); + })); + return group; + } + + return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, cableInfo, normalizeSettings, compute, snapToWall, createMaterials, build3D }; +})(); diff --git a/js/elektra/elektra-pdf.js b/js/elektra/elektra-pdf.js new file mode 100644 index 0000000..ed8f04d --- /dev/null +++ b/js/elektra/elektra-pdf.js @@ -0,0 +1,154 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// elektra/elektra-pdf.js — ElektraPdf: elektra in de woning-PDF. +// drawPlan : meterkast, schakelaars (letter), wcd's (groep), lamp- +// letters en leidingen op de plattegrond (blad Plattegrond) +// drawSheet : eigen blad met groepenverklaring, schakelaars, strekkende +// meters per leidingtype, draad, buis en materiaal. +// Alle gegevens komen uit ElektraModel.compute (parts.elektra). +// Plain script, geladen na pdf-sheet.js en elektra-model.js. +// ============================================================ + +window.ElektraPdf = (() => { + const fmt = (n, d = 1) => Number(n).toFixed(d).replace('.', ','); + // Kleur per leidingtype (ook de legenda). + const RUN_COLORS = { licht: [215, 160, 0], wcd: [205, 55, 35], buiten: [40, 140, 60], schakel: [150, 95, 20] }; + const runColor = (type) => RUN_COLORS[type] || RUN_COLORS.schakel; + + // px/py: woning-mm -> blad-mm. + function drawPlan(doc, el, px, py) { + if (!el || !el.enabled) return; + const BLACK = PdfSheet.COLORS.BLACK; + // Leidingen (gestippeld, plattegrond-projectie van het pad). + doc.setLineWidth(0.15); + doc.setLineDashPattern([0.8, 0.6], 0); + el.runs.forEach(r => { + doc.setDrawColor(...runColor(r.type)); + r.path.slice(1).forEach((b, i) => { + const a = r.path[i]; + if (Math.hypot(px(b.x) - px(a.x), py(b.z) - py(a.z)) > 0.05) doc.line(px(a.x), py(a.z), px(b.x), py(b.z)); + }); + }); + doc.setLineDashPattern([], 0); + + // Meterkast + const k = el.meterkast; + if (k) { + doc.setDrawColor(...BLACK); + doc.setFillColor(90, 90, 90); + doc.setLineWidth(0.2); + const c = k.corners; + doc.lines(c.slice(1).map((p, i) => [px(p.x) - px(c[i].x), py(p.z) - py(c[i].z)]), px(c[0].x), py(c[0].z), [1, 1], 'FD', true); + doc.setFontSize(5); + doc.setTextColor(...BLACK); + const m = { x: (c[0].x + c[2].x) / 2 + k.face.n.x * 350, z: (c[0].z + c[2].z) / 2 + k.face.n.z * 350 }; + doc.text('MK', px(m.x), py(m.z) + 0.8, { align: 'center' }); + } + + // Verdeeldozen (plafond): klein vierkantje. + doc.setDrawColor(...runColor('schakel')); + doc.setLineWidth(0.15); + el.boxes.forEach(b => doc.rect(px(b.x) - 0.6, py(b.z) - 0.6, 1.2, 1.2, 'S')); + + // Schakelaars: kadertje met de letters, iets de kamer in. + doc.setFontSize(5); + el.combos.forEach(c => { + const q = { x: c.pos.x + c.face.n.x * 260, z: c.pos.z + c.face.n.z * 260 }; + const text = c.switches.map(s => s.letter).join(' '); + const w = 1.6 + text.length * 1.0; + doc.setDrawColor(...BLACK); + doc.setFillColor(255, 255, 255); + doc.setLineWidth(0.2); + doc.rect(px(q.x) - w / 2, py(q.z) - 1.2, w, 2.4, 'FD'); + doc.setTextColor(...BLACK); + doc.text(text, px(q.x), py(q.z) + 0.7, { align: 'center' }); + }); + + // Wcd: halve cirkel tegen de wand + groepnummer. + el.wcds.forEach(w => { + const q = { x: w.pos.x + w.face.n.x * 120, z: w.pos.z + w.face.n.z * 120 }; + doc.setDrawColor(...RUN_COLORS.wcd); + doc.setFillColor(255, 255, 255); + doc.setLineWidth(0.2); + doc.circle(px(q.x), py(q.z), 0.9, 'FD'); + doc.setTextColor(...RUN_COLORS.wcd); + doc.setFontSize(4); + doc.text(String(w.group), px(q.x + w.face.n.x * 250), py(q.z + w.face.n.z * 250) + 0.6, { align: 'center' }); + }); + + // Apparaten: kadertje met de code (KK, WM, ...) + groepnummer. + el.apps.forEach(a => { + const it = el.items.find(x => x.key === a.key), q = it.marker; + doc.setDrawColor(...RUN_COLORS.wcd); + doc.setFillColor(255, 255, 255); + doc.setLineWidth(0.2); + doc.rect(px(q.x) - 1.6, py(q.z) - 1.1, 3.2, 2.2, 'FD'); + doc.setTextColor(...RUN_COLORS.wcd); + doc.setFontSize(3.6); + doc.text(a.code, px(q.x), py(q.z) + 0.5, { align: 'center' }); + doc.text(String(a.group), px(q.x + a.face.n.x * 330), py(q.z + a.face.n.z * 330) + 0.5, { align: 'center' }); + }); + + // Lampletters (zelfde letter als de schakelaar). + doc.setFontSize(3.6); + doc.setTextColor(...BLACK); + el.switches.forEach(s => s.lamps.forEach(l => doc.text(s.letter, px(l.x) + 0.9, py(l.z) - 0.7))); + } + // Twee regels (doc.text met een array). + const PLAN_LEGEND = [ + 'Elektra: MK = meterkast, kadertje = schakelaar(s) met letter (dezelfde letter staat bij de lampen erop), rood rondje = dubbele wcd (groepnummer), vierkantje = verdeeldoos in plafond;', + 'rood kadertje = apparaat (KK kookgroep, WM wasmachine, DR droger, WP warmtepomp, OV oven, VW vaatwasser) met groepnummer; gestippeld = leidingen (geel licht, bruin schakeldraad, rood wcd/apparaat, groen buiten).' + ]; + + function drawSheet(doc, el, sheetOpts) { + const BLACK = PdfSheet.COLORS.BLACK; + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...BLACK); + doc.setFontSize(14); + doc.text('Elektra', 15, 18); + const t = (title) => ({ title, fontSize: 7, rowH: 4.4, lastColW: 16 }); + let y = 30; + y = PdfSheet.table(doc, 15, y, [0, 12, 62, 74, 88, 104, 140, 156], ['Groep', 'Omschrijving', 'Fase', 'Aardlek', 'Automaat', 'Leiding', 'Punten', 'Lengte'], + el.groups.map(g => [String(g.nr), g.label, g.phase, `AL${g.rcd}`, g.breaker, ElektraModel.cableInfo(g.cable).label.replace(/ in buis.*/, ''), + g.kind === 'app' ? 'apparaat' : `${g.points} ${g.kind === 'wcd' ? 'wcd' : 'lamp'}`, fmt(g.meters) + ' m']), + t('Groepenverklaring (aansluiting 3×25 A)')); + y += 4; + y = PdfSheet.table(doc, 15, y, [0, 14, 60, 76, 92], ['Letter', 'Functie', 'Unit', 'Groep', 'Lampen'], + el.switches.map(s => [s.letter, s.label, s.unitId, String(s.group || '-'), + `${s.lamps.length} (${[['binnenspot', 'binnenspot'], ['boeideelspot', 'boeideel-spot'], ['gevellamp', 'gevellamp']] + .map(([k, l]) => [s.lamps.filter(x => x.kind === k).length, l]).filter(([n]) => n).map(([n, l]) => `${n} ${l}`).join(', ') || '-'})`]), + t('Schakelaars (letter staat ook bij de lampen op de plattegrond)')); + y += 4; + const k = el.meterkast; + const notes = [ + k ? `Meterkast ${k.w}×${k.h}×${k.d} mm (b×h×d) in unit ${k.unitId}, wand ${k.element}, onderkant ${k.y} mm boven vloer.` : 'Geen meterkast geplaatst.', + `Schakelaars op ${el.settings.switchHeight} mm, wcd op ${el.settings.wcdHeight} mm (hart, boven bovenkant vloer).`, + `Leidinglengte = route via stijlen/plafond + ${el.settings.reserve} mm reserve (meterkast +${el.settings.meterkastReserve} mm), + ${el.settings.slack}% marge.`, + 'Groepen per unit: licht B10 (1,5 mm²), wcd B16 (2,5 mm²); buitenlicht eigen groep B10 (XMvK), zelfde aardlek als het binnenlicht in dezelfde schakeldoos.', + 'Per schakeldoos één verdeeldoos per groep; fasen en aardlekschakelaars om en om.', + 'Apparaten (kookgroep, wasmachine, ...) automatisch geplaatst zolang er geen keuken/bijkeuken is; verplaatsbaar in de plattegrond.', + 'Indicatief eerste ontwerp; uitvoering en controle volgens NEN 1010 door de installateur.' + ].concat(el.warnings.map(w => 'Let op: ' + w)); + doc.setFontSize(7); + notes.forEach(n => { doc.text(n, 15, y); y += 4.2; }); + + let y2 = 30; + y2 = PdfSheet.table(doc, 205, y2, [0, 46, 102], ['Leidingtype', 'Gebruik', 'Lengte'], + el.cables.map(c => [c.label, c.use, fmt(c.meters) + ' m']), t('Leidingen (strekkende meter)')); + y2 += 4; + y2 = PdfSheet.table(doc, 205, y2, [0, 102], ['Draad', 'Lengte'], el.wires.map(w => [w.label, fmt(w.meters) + ' m']), t('Draad (per ader)')); + y2 += 4; + PdfSheet.table(doc, 205, y2, [0, 102], ['Buis', 'Lengte'], el.conduits.map(c => [c.label, fmt(c.meters) + ' m']), t('Installatiebuis')); + + PdfSheet.table(doc, 330, 30, [0, 66], ['Onderdeel', 'Aantal'], el.materials.map(m => [m.naam, `${m.aantal} ${m.eenheid}`]), Object.assign(t('Materiaal'), { maxY: 240 })); + + PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); + } + + return { RUN_COLORS, drawPlan, drawSheet, PLAN_LEGEND }; +})(); diff --git a/js/floor/floor-model.js b/js/floor/floor-model.js index 5a5bc38..bdca69b 100644 --- a/js/floor/floor-model.js +++ b/js/floor/floor-model.js @@ -257,7 +257,9 @@ window.FloorModel = (() => { // Z = -diepte/2), Y = dikte (balkhoogte + OSB). Balken en isolatie zijn // simpele dozen; de OSB-onderlaag is een geëxtrudeerd Shape met ronde // uitsparingen voor de doorvoeren. - // opts: { materials, osbVisible, finish } + // opts: { materials, osbVisible, finish, outerJoistsOnly } + // outerJoistsOnly: alleen de buitenste balken en kopbalken (de rest is + // ingesloten, bijv. dakbalken tussen plafondplaat en dakbedekking). // finish (optioneel): { key (FINISHES), originX, originZ } — afwerking als // dun vlak op de OSB; UV's in meters vanaf origin (m, woning-coördinaten // van het vloermidden), zodat het patroon over units doorloopt. @@ -277,7 +279,8 @@ window.FloorModel = (() => { const rims = calcData.rims || {}; const joistLenM = (calcData.joistLen || depth) * toM; const joistZ = ((rims.front ? joistW : 0) - (rims.back ? joistW : 0)) / 2 * toM; - joists.forEach(xMM => { + joists.forEach((xMM, i) => { + if (opts.outerJoistsOnly && i > 0 && i < joists.length - 1) return; const geo = new THREE.BoxGeometry(joistWM, joistHM, joistLenM); const mesh = new THREE.Mesh(geo, woodMaterial); mesh.position.set(xMM * toM, joistHM / 2, joistZ); diff --git a/js/house/house-model.js b/js/house/house-model.js index d9cb867..f39c2f5 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -64,7 +64,7 @@ window.HouseModel = (() => { // rabat = vochtdoek + kruislat + rabat (richting: cladding); // steen = cement-gebonden plaat + steenblok (stone = steencode, WallModel.STONE). // Het trasraam (eigen afwerking/steen) komt onder beide. - facade: { type: 'rabat', stone: 'K490' }, + facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel' }, // rabatColor: WallModel.RABAT_COLORS cladding: 'horizontaal', // richting rabat, voorlopig voor alle buitenwanden // Trasraam: cement-gebonden plaat onderaan alle vochtdoek-gevels, hoogte // vanaf het beloopbare vloervlak (WallModel.trasraamFor). @@ -78,7 +78,8 @@ window.HouseModel = (() => { groundLevel: 0, // maaiveld: zoveel mm onder het loopvlak (bovenkant vloer); voor de 3D-omgeving floorFinish: 'visgraat_001', // vloerafwerking (FloorModel.FINISHES), voorlopig globaal roof: Object.assign({}, RoofModel.DEFAULT_SETTINGS), - lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS) + lighting: Object.assign({}, LightingModel.DEFAULT_SETTINGS), + elektra: ElektraModel.normalizeSettings() // meterkast, schakelaars, wcd, leidingen (ElektraModel) }; } @@ -98,21 +99,52 @@ window.HouseModel = (() => { return { type, stone, override: !!o }; } - // TIJDELIJK (testen): een nieuwe woning is standaard het testontwerp: - // L-vorm, unit A lessenaar (noord 2600, zuid 3200) met riet. Op false - // zetten om terug te gaan naar de gewone standaard (één unit). + // TIJDELIJK (testen): een nieuwe woning is standaard het voorbeeldontwerp + // (ook als json/premade/vlinderwoning.json): 92 m², A (straat) en B (tuin) + // met een vlinderdak (lessenaars laag op de naad), C een lage tuinkamer + // met plat dak, veel glas. Op false zetten voor de gewone standaard (één unit). const TEST_DEFAULT = true; + function createVlinderwoning() { + const st = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] }; + st.project.naam = 'Vlinderwoning'; + st.settings.trasraam.finish = 'K490'; + const A = createUnitEntry('A', null, { width: 10000, depth: 4000, windowOn: [] }); + const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'start', offset: 0 }, { width: 10000, depth: 3600, windowOn: [] }); + const C = createUnitEntry('C', { to: 'A', side: 'rechts', align: 'start', offset: 0 }, { width: 4000, depth: 4000, windowOn: [] }); + st.units.push(A, B, C); + const h = (u, k, l, r) => Object.assign(u.unit.walls[k].wall, { heightAll: Math.min(l, r), heightLeft: l, heightRight: r, heightMid: null }); + const HI = 3400, LO = 2700; + h(A, 'voor', HI, HI); h(A, 'achter', LO, LO); h(A, 'links', HI, LO); h(A, 'rechts', LO, HI); + h(B, 'voor', LO, LO); h(B, 'achter', HI, HI); h(B, 'links', LO, HI); h(B, 'rechts', HI, LO); + ['voor', 'achter', 'links', 'rechts'].forEach(k => h(C, k, LO, LO)); + A.roof.covering = B.roof.covering = 'riet_002'; + C.roof.covering = 'dakleer'; + // Vulling (KozijnModel): deuren met glasstrook, ramen met een + // borstwering (vanaf 900 mm) met ventilatierooster. + const ops = (u, side, list) => { + u.unit.walls[side].openings = list.map(([type, w, hh, y, anchor, offset], i) => ({ + id: i + 1, type, w, h: hh, y, anchor, offset, + fill: Object.assign(KozijnModel.defaultFill(type), type === 'raam' ? { rooster: y >= 900 } : { glas: true }) + })); + }; + ops(A, 'voor', [['raam', 1200, 1100, 1000, 'left', 700], ['voordeur', 1150, 2300, 0, 'left', 2700], ['raam', 500, 2300, 0, 'left', 3950], + ['raam', 2200, 600, 2000, 'left', 5200], ['raam', 900, 2100, 300, 'right', 1800], ['raam', 900, 2100, 300, 'right', 600]]); + ops(A, 'links', [['raam', 1400, 1200, 900, 'middle', 0]]); + ops(B, 'achter', [['raam', 600, 2600, 250, 'left', 800], ['raam', 600, 2600, 250, 'left', 1700], ['deur', 900, 2300, 0, 'middle', -1600], + ['raam', 2000, 2300, 0, 'middle', 0], ['deur', 900, 2300, 0, 'middle', 1600], ['raam', 1200, 1200, 1000, 'right', 700]]); + ops(B, 'links', [['raam', 1600, 500, 1700, 'middle', 0]]); + ops(B, 'rechts', [['raam', 1000, 1300, 900, 'left', 500], ['raam', 1000, 1300, 900, 'right', 500]]); + ops(C, 'voor', [['raam', 2600, 1500, 700, 'middle', 0]]); + ops(C, 'rechts', [['raam', 1100, 2100, 250, 'left', 500], ['raam', 1100, 2100, 250, 'right', 500]]); + ops(C, 'achter', [['deur', 900, 2200, 0, 'left', 500], ['raam', 2000, 2200, 0, 'right', 400]]); + ensureSeamWalls(st); + computeParts(st, { skip3D: true }); + return st; + } + function createDefaultState(template) { - if (TEST_DEFAULT && (!template || template === 'single')) { - const st = createDefaultState('L'); - const A = st.units[0], W = A.unit.walls; - const h = (k, l, r) => Object.assign(W[k].wall, { heightAll: Math.min(l, r), heightLeft: l, heightRight: r, heightMid: null }); - h('achter', 3200, 3200); h('links', 2600, 3200); h('rechts', 3200, 2600); - A.roof.covering = 'riet_001'; - computeParts(st, { skip3D: true }); - return st; - } + if (TEST_DEFAULT && (!template || template === 'single')) return createVlinderwoning(); const state = { project: createEmptyProject(), settings: createDefaultSettings(), units: [], interiorWalls: [] }; state.units.push(createUnitEntry('A', null, { width: 6000, depth: 3000 })); if (template === 'L') { @@ -120,12 +152,12 @@ window.HouseModel = (() => { // Standaard L-woning: voordeur midden in de noordgevel van A, drie // ramen in de zuidgevel van B (bovenkanten gelijk met de voordeur). state.units[0].unit.walls.voor.openings = [ - { id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0 } + { id: 1, type: 'voordeur', w: 1150, h: 2150, y: 0, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('voordeur') } ]; state.units[1].unit.walls.achter.openings = [ - { id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450 }, - { id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0 }, - { id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450 } + { id: 1, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'left', offset: 450, fill: KozijnModel.defaultFill('raam') }, + { id: 2, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }, + { id: 3, type: 'raam', w: 500, h: 1900, y: 250, anchor: 'right', offset: 450, fill: KozijnModel.defaultFill('raam') } ]; } ensureSeamWalls(state); @@ -276,7 +308,8 @@ window.HouseModel = (() => { wall.wall.heightAll = wall.wall.heightLeft = wall.wall.heightRight = height || wall.wall.heightAll; wall.materials = { interior: 'spaanplaat', exterior: 'geen' }; const d = WallModel.OPENING_TYPES.deur; - wall.openings = [{ id: 1, type: 'deur', w: d.w, h: d.h, y: d.y, anchor: 'middle', offset: 0 }]; + // Binnendeur: wit, zonder glas. + wall.openings = [{ id: 1, type: 'deur', w: d.w, h: d.h, y: d.y, anchor: 'middle', offset: 0, fill: Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit' }) }]; setThickness(wall, DEFAULT_SEAM_LEAF_MM); return wall; } @@ -325,13 +358,27 @@ window.HouseModel = (() => { // ------------------------------------------------------------ // Alle onderdelen doorgerekend (+ 3D, tenzij opts.skip3D). - // opts: { wallMaterials, floorMaterials, beplatingVisible, skip3D } + // opts: { wallMaterials, floorMaterials, ..., stage, lampenVisible, skip3D } + // stage = bouwfase (BuildStages, standaard afgewerkt): bepaalt welke lagen + // gebouwd worden; de berekening (stats, totalen) is altijd volledig. // ------------------------------------------------------------ // Past de woning-brede instellingen toe op de onderdeel-states (muteert // state): h.o.h. van alle wanden, en de dakmaat als die de vloer volgt. + // Grenzen (UnitModel.LIMITS) afdwingen: vloermaat, goot/plat dak en nok. + // Muteert state. + function enforceLimits(state) { + const L = UnitModel.LIMITS; + state.units.forEach(u => { + UnitModel.clampSize(u.unit.floor.floor); + const f = UnitModel.roofForm(u.unit); + if (f.low > L.eave + 0.5 || f.high > L.ridge + 0.5) UnitModel.applyRoofForm(u.unit, f); + }); + } + function applyGlobalSettings(state) { const sp = state.settings.spacing; const tr = state.settings.trasraam; + enforceLimits(state); state.units.forEach(u => { const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null; Object.entries(u.unit.walls).forEach(([side, ws]) => { @@ -339,8 +386,8 @@ window.HouseModel = (() => { ws.wall.maxSpacing = sp.exterior.max; const fc = facadeFor(state, u, side); ws.materials = Object.assign({}, ws.materials, fc.type === 'steen' - ? { exterior: 'cementplaat', stone: fc.stone, cladding: state.settings.cladding, trasraam } - : { exterior: 'vochtdoek', stone: null, cladding: state.settings.cladding, trasraam }); + ? { exterior: 'cementplaat', stone: fc.stone, cladding: state.settings.cladding, trasraam, rabatColor: null } + : { exterior: 'vochtdoek', stone: null, cladding: state.settings.cladding, trasraam, rabatColor: state.settings.facade.rabatColor }); }); if (u.roof.followFloor) { u.roof.deck.floor.width = u.unit.floor.floor.width; @@ -399,6 +446,8 @@ 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); applyGlobalSettings(state); const rects = unitRects(state); const rectById = {}; @@ -406,12 +455,11 @@ window.HouseModel = (() => { const wallThickness = (id, side) => WallModel.thicknessOf(WallModel.configFromState(state.units.find(u => u.id === id).unit.walls[side])); const { cuts, seams } = computeSeams(rects, wallThickness); - function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop) { + function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove) { 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, - painted: state.settings.interiorPaint, - spaanplaatVisible: o.beplatingVisible !== false, buitenVisible: o.beplatingVisible !== false + materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, + painted: state.settings.interiorPaint && shows('afwerking'), stage, rabatColor: state.settings.facade.rabatColor }); return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) }; } @@ -458,21 +506,22 @@ 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: o.beplatingVisible !== false, - finish: { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } + materials: o.floorMaterials, osbVisible: shows('beplating'), + finish: shows('afwerking') ? { key: state.settings.floorFinish, originX: rect.cx * 0.001, originZ: rect.cz * 0.001 } : null }) : null; const walls = layout.walls.map(wl => wallOf(u, rect, layout, wl)); // Dak: dek = vloer-state (180° gespiegeld, RoofModel), in de vorm die - // de wanden aangeven (plat, lessenaar of zadel; RoofModel.computeShape). + // de wanden aangeven (plat, lessenaar, zadel, mansarde of wolfsdak; + // RoofModel.computeShape). const shape = RoofModel.computeShape(u.unit, layout); const deckFrame = FloorModel.computeFrame(u.roof.deck.floor); const roofW = deckFrame.width, roofD = deckFrame.depth; const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0; const ur = RoofModel.computeUnitRoof(u.roof, shape, { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }, rims); const roofCx = rect.cx + ox, roofCz = rect.cz + oz; - const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel' }[shape.type]; + const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', mansarde: 'mansarde', wolf: 'wolfsdak' }[shape.type]; const multi = ur.planes.length > 1; ur.planes.forEach((pl, i) => { pl.element = `${u.id}-D${i + 1}`; @@ -481,6 +530,7 @@ window.HouseModel = (() => { }); const roof = { element: `${u.id}-D1`, shape, covering: ur.covering, planes: ur.planes, specs: ur.specs, + unitRoof: ur, // o.a. het bovenste deel van een wolfsdak (cap, RoofModel.capRegions) rect: { x0: roofCx - roofW / 2, x1: roofCx + roofW / 2, z0: roofCz - roofD / 2, z1: roofCz + roofD / 2, cx: roofCx, cz: roofCz, W: roofW, D: roofD }, base: layout.floorTop, // 3D: group-Y = 0 op het loopvlak // Onderkant plafond resp. bovenkant dakleer op (x, z) (woning-mm, absoluut). @@ -488,12 +538,11 @@ window.HouseModel = (() => { topAt: (x, z) => layout.floorTop + ur.topAt(x - rect.cx, z - rect.cz), ridges: ur.ridges.map(r => ({ axis: r.axis, value: r.value + (r.axis === 'x' ? rect.cx : rect.cz) })), top: layout.floorTop + ur.maxTop, - // Zichtbaarheid zoals wanden/vloeren: plafondplaat + dakleer (EPDM) - // horen bij "Beplating". + // Bouwfase: plafondplaat bij "beplating", bedekking bij "gevel". group: build ? RoofModel.buildUnit3D(ur, { materials: o.roofMaterials, - osbVisible: o.beplatingVisible !== false, - dakleerVisible: o.beplatingVisible !== false + osbVisible: shows('beplating'), + dakleerVisible: shows('gevel') }) : null }; @@ -640,15 +689,17 @@ window.HouseModel = (() => { const axisOf = (q) => horiz ? q.x - u.rect.cx : q.z - u.rect.cz; const W = lf.place.width; const pL = axisOf(UnitModel.localToWorld(lf.place, -W / 2, 0)), pR = axisOf(UnitModel.localToWorld(lf.place, W / 2, 0)); - const peak = prof.length > 2 ? prof[1] : null; // Opbouw erboven (raiseSeamWalls): het deel stopt op de voet daarvan. const cap = raisedCap[`${lf.uid}|${lf.uid === sm.a ? sm.b : sm.a}`]; const lim = (h) => cap !== undefined ? Math.min(h, cap) : h; const hL = lim(RoofModel.profileHeight(prof, pL)), hR = lim(RoofModel.profileHeight(prof, pR)); - const hasPeak = !!peak && peak.p > Math.min(pL, pR) + 1 && peak.p < Math.max(pL, pR) - 1 && lim(peak.h) > Math.max(hL, hR) + 0.5; + // Tussenpunten (nok, knikken) die in het deel vallen, als fractie vanaf links. + const lo = Math.min(pL, pR), hi = Math.max(pL, pR); + const inner = prof.slice(1, -1).filter(q => q.p > lo + 1 && q.p < hi - 1) + .map(q => ({ f: Math.abs(q.p - pL) / (hi - lo), h: Math.round(lim(q.h)) })).sort((a, b) => a.f - b.f); return { heightLeft: Math.round(hL), heightRight: Math.round(hR), - heightMid: hasPeak ? Math.round(lim(peak.h)) : null, peakX: hasPeak ? Math.abs(peak.p - pL) : undefined + heightMid: null, peakX: undefined, topPts: inner.length ? inner : undefined }; }; const paramsOf = (lf) => Object.assign({}, iw.wall.wall, { width: lf.place.width }, heightsOf(lf)); @@ -669,7 +720,7 @@ window.HouseModel = (() => { const first = leaves[0]; const firstParams = paramsOf(first); // Voor de wand-editor: breedte en daklijn van het eerste deel. - Object.assign(iw.wall.wall, { width: first.place.width, heightLeft: firstParams.heightLeft, heightRight: firstParams.heightRight, heightMid: firstParams.heightMid }); + Object.assign(iw.wall.wall, { width: first.place.width, heightLeft: firstParams.heightLeft, heightRight: firstParams.heightRight, heightMid: null, topPts: firstParams.topPts }); const firstConfig = WallModel.buildConfig(firstParams, iw.wall.openings || []); const toLocalU = (pl, q) => { const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY); @@ -683,6 +734,7 @@ window.HouseModel = (() => { const wb = UnitModel.localToWorld(first.place, -first.place.width / 2 + op.x + op.w, 0); const ua = toLocalU(lf.place, wa), ub = toLocalU(lf.place, wb); // Gespiegeld (andere kijkrichting): schuine bovenzijde klapt om. + // Het kozijn (fill) staat alleen in het eerste deel. const flip = ua > ub; return { id: op.id, type: op.type, w: op.w, h: flip ? op.hR : op.h, hR: flip ? op.h : op.hR, y: op.y, anchor: 'left', offset: Math.round(Math.min(ua, ub)) }; }); @@ -706,6 +758,23 @@ window.HouseModel = (() => { }); } + // Schuine regel op de bovenregel (WallModel, CONFIG.topWedge): helt het + // dak dwars over de wand (goot van een zadel-/lessenaardak, onder een + // wolfseind), dan zit er tussen de vlakke bovenregel en de onderkant van + // het dak een wig. Per positie langs de wand de hoogte van die wig aan + // de buiten- (o) en binnenkant (i) van het regelwerk (mm), of null. + function topWedge(p, roof) { + const cfg = p.config, half = cfg.wallWidth / 2, zF = cfg.studDepth / 2, tl = WallModel.topLine(cfg); + const xs = new Set([-half, half].concat(tl.kinks)); + for (let x = -half + 250; x < half; x += 250) xs.add(x); + const pts = [...xs].sort((a, b) => a - b).map(x => { + const top = p.place.floorTop + tl.at(x); + const at = (z) => { const w = UnitModel.localToWorld(p.place, x, z); return Math.max(0, Math.min(800, roof.ceilingAt(w.x, w.z) - top)); }; + return { x, o: Math.round(at(zF)), i: Math.round(at(-zF)) }; + }); + return pts.some(q => Math.max(q.o, q.i) >= 5) ? { pts } : null; + } + // Doek en latwerk volgen de hele gevel (niet per unit/wandstuk), ook // over het blootliggende deel van een naadwand. Daarna pas bouwen. const extPieces = []; @@ -718,7 +787,10 @@ window.HouseModel = (() => { units.forEach(u => { u.walls.forEach(w => w.pieces.forEach(p => { const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2); - p.skinAbove = Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - u.roof.ceilingAt(m.x, m.z))); + // (vanaf de bovenkant van de wand: daar kan nog een schuine regel op) + const wallTop = p.place.floorTop + WallModel.topLine(p.config).at(0); + p.skinAbove = Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - Math.min(wallTop, u.roof.ceilingAt(m.x, m.z)))); + p.config.topWedge = topWedge(p, u.roof); // Binnenhoek-doorloop (negatieve skinExt): tot de bovenkant van het // dak van de buur-unit die daar staat geen buitenlagen (trimTop). const e = p.skinExt || {}, W = p.place.width; @@ -731,23 +803,11 @@ window.HouseModel = (() => { }); })); }); - assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined))); - extPieces.concat(interior).forEach(p => { - const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop); - if (built.group && p.shiftZ) { - const outer = new THREE.Group(); - built.group.position.z = p.shiftZ * 0.001; - outer.add(built.group); - built.group = outer; - } - Object.assign(p, built); - }); - // Dakrand (boeidelen/daktrim) als één doorlopende rand om alle daken; - // boeidelen aan/uit per unit. De rand volgt de dakvorm van de unit waar - // de ribbe bij hoort; platte daken delen één randhoogte (de hoogste). - const flatTops = units.filter(u => u.roof.shape.type === 'plat').map(u => u.roof.top); - const flatTop = flatTops.length ? Math.max(...flatTops) : 0; + // boeidelen aan/uit per unit. De rand volgt de dakvorm (en hoogte) van + // de unit waar de ribbe bij hoort, ook bij platte daken: een hoogte- + // verschil verspringt, en vanaf de boeideelhoogte krijgt het hoge dak een + // eigen rand en loopt die van het lage door (RoofModel.computeEdge). // Dikte van de gevelafwerking langs een dakrand-ribbe (boeideel komt // erbovenop): gevelstuk op die lijn met dezelfde buitenrichting (+), of // een gevel van een ander deel die naar dit dak toe kijkt (-). @@ -771,27 +831,66 @@ window.HouseModel = (() => { const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof, units.map(u => ({ unitId: u.id, rect: u.roof.rect, fascia: state.units.find(su => su.id === u.id).roof.fascia !== false, - topAt: u.roof.shape.type === 'plat' ? () => flatTop : u.roof.topAt, ridges: u.roof.ridges + topAt: u.roof.topAt, ridges: u.roof.ridges })), facadeDepth) : null; + // Latwerk stopt onder het boeideel/de daktrim die boven het wandstuk + // zit (battenAbove, mm t.o.v. de wandbovenkant, kan negatief zijn): rand + // bovenkant = dak + EDGE_ABOVE, hoogte fasciaHeight resp. trimHeight + EDGE_ABOVE. + const pointInPoly = (q, poly) => { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i], b = poly[j]; + if ((a.z > q.z) !== (b.z > q.z) && q.x < (b.x - a.x) * (q.z - a.z) / (b.z - a.z) + a.x) inside = !inside; + } + return inside; + }; + if (roofEdge) extPieces.forEach(p => { + const nrm = { x: p.place.normal.x, z: p.place.normal.y }; + const m = UnitModel.localToWorld(p.place, 0, p.place.thickness / 2); + // Een paar afstanden vóór de gevel: de rand begint op de dakrand, die + // op of iets vóór het wandvlak kan liggen. + const sg = [10, 60, 150].map(d => ({ x: m.x + nrm.x * d, z: m.z + nrm.z * d })) + .map(q => roofEdge.segments.find(e => pointInPoly(q, [e.a, e.b, e.outerB, e.outerA]))).find(Boolean); + if (!sg) return; + const h = sg.fascia ? roofEdge.fasciaHeight : roofEdge.trimHeight + roofEdge.edgeAbove; + p.battenAbove = (p.skinAbove || 0) + roofEdge.edgeAbove - h; + }); + assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined))); + extPieces.concat(interior).forEach(p => { + const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove); + if (built.group && p.shiftZ) { + const outer = new THREE.Group(); + built.group.position.z = p.shiftZ * 0.001; + outer.add(built.group); + built.group = outer; + } + Object.assign(p, built); + }); + // Buitenverlichting: gevellampen per wandstuk + boeideel-spots (vult roofEdge.spots). const lighting = LightingModel.compute(state.settings.lighting, state.settings.roof, units, state.units, roofEdge); - // Zichtbaarheid: boeideel/daktrim volgt "Beplating", spots en lampen volgen "Lampen". + // Zichtbaarheid: dakrand en lampen in de laatste bouwfase; lampen ook via "Lampen". const roofEdgeGroup = build && roofEdge ? RoofModel.buildEdge3D(roofEdge, o.roofMaterials, { - edgeVisible: o.beplatingVisible !== false, spotsVisible: o.lampenVisible !== false + edgeVisible: shows('afwerking'), spotsVisible: o.lampenVisible !== false && shows('afwerking') }) : null; // Binnenspots in het plafond, per unit (LightingModel.computeIndoor). lighting.indoor = LightingModel.computeIndoor(state.settings.lighting, units, interior); const lightingGroup = build ? LightingModel.build3D(lighting.lamps, o.lightingMaterials || LightingModel.createMaterials(), lighting.indoor) : null; - if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false; + if (lightingGroup) lightingGroup.visible = o.lampenVisible !== false && shows('afwerking'); + // Elektra: meterkast, schakelaars (letter = ook op hun lampen), wcd's, + // groepen en leidingen naar de meterkast (ElektraModel). + const elektra = ElektraModel.compute(state.settings.elektra, units, interior, lighting, roofEdge); + const elektraGroup = build ? ElektraModel.build3D(elektra, o.elektraMaterials, stage) : null; + if (elektraGroup) elektraGroup.visible = o.elektraVisible !== false && shows('elektra'); // Sandwichpanelen rondom de vloeren, vanaf het loopvlak naar beneden. const sandwich = state.settings.sandwich && units.length ? SandwichModel.compute(rects, Math.max(...units.map(u => u.layout.floorTop))) : null; const sandwichGroup = build && sandwich ? SandwichModel.build3D(sandwich, o.sandwichMaterials || SandwichModel.createMaterials(), { - visible: o.beplatingVisible !== false + visible: shows('gevel') }) : null; - return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, sandwich, sandwichGroup }; + return { rects, seams, overlaps: overlappingPairs(rects), units, interior, roofEdge, roofEdgeGroup, lighting, lightingGroup, elektra, elektraGroup, sandwich, sandwichGroup }; } // Alle wandstukken (buiten + binnen) plat op een rij. @@ -872,8 +971,38 @@ window.HouseModel = (() => { ]; } - // TIJDELIJK (verwijderen voor productie): capaciteit zaagmachine. - const SAW_M_PER_H = 2400; + // ------------------------------------------------------------ + // Transport: elke unit gaat als losse module op de wagen (vloer, wanden + // incl. zijn naadwanddelen, dak; zonder dakrand). Buitenmaat incl. de + // gevelafwerking (latwerk + rabat, steen). Breedteklassen indicatief + // (wegvervoer NL): tot 2550 mm regulier, tot 3500 mm met ontheffing, + // daarboven met begeleiding. Controleer altijd bij de vervoerder. + // ------------------------------------------------------------ + const TRANSPORT = { widthRegular: 2550, widthPermit: 3500 }; + const TRANSPORT_CLASSES = { regulier: 'Regulier', ontheffing: 'Ontheffing', begeleiding: 'Ontheffing + begeleiding' }; + function computeTransport(parts) { + const kgOf = (p) => (p.specs && p.specs.totalKg) || 0; + return parts.units.map(u => { + const leaves = parts.interior.filter(p => p.seamUnit === u.id); + const kg = { + vloer: u.floorSpecs.totalKg, + wanden: u.walls.reduce((a, w) => a + w.pieces.reduce((b, p) => b + kgOf(p), 0), 0) + leaves.reduce((a, p) => a + kgOf(p), 0), + dak: u.roof.specs.totalKg + }; + // Afwerking buiten het wandvlak per zijde (0 als de wand vervalt). + const out = (side) => Math.max(0, ...((u.walls.find(w => w.side === side) || { pieces: [] }).pieces + .filter(p => !p.baseY) + .map(p => Math.max(WallModel.claddingOffset(p.buitenMateriaal, p.stone), + p.trasraam && WallModel.isStoneFinish(p.trasraam.finish) ? WallModel.STONE.THICK_MM : 0)))); + const ew = u.rect.W + out('links') + out('rechts'), ed = u.rect.D + out('voor') + out('achter'); + const width = Math.min(ew, ed); + return { + id: u.id, kg, totalKg: kg.vloer + kg.wanden + kg.dak, + length: Math.max(ew, ed), width, height: u.roof.top, + cls: width <= TRANSPORT.widthRegular ? 'regulier' : width <= TRANSPORT.widthPermit ? 'ontheffing' : 'begeleiding' + }; + }); + } function computeTotals(parts) { const pieces = allWallPieces(parts); @@ -906,6 +1035,9 @@ window.HouseModel = (() => { rc.joists.forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.joistLen, [ang, ang])); ['front', 'back'].filter(k => rc.rims[k]).forEach(() => addPiece('dakbalk', rc.joistW, rc.joistH, rc.width)); }); + // Wolfsdak: bovenste deel (sporen, hoekkepers, nokbalk, kopbalken). + const cap = u.roof.unitRoof && u.roof.unitRoof.cap; + if (cap) cap.beams.forEach(bm => addPiece('dakbalk', cap.joistW, cap.joistH, bm.length, [bm.angle, bm.angle])); }); const wallBom = []; pieces.forEach(p => WallModel.computeBOM(p.config, p.frame).forEach(b => { wallBom.push(b); addPiece(b.kind, b.width, b.depth, b.length, b.cutAngles); })); @@ -934,9 +1066,9 @@ window.HouseModel = (() => { return { kinds: kindList, sawList: Array.from(sawList.values()).sort((a, b) => b.length - a.length || a.width - b.width), - // TIJDELIJK (verwijderen voor productie): zaagmachine, SAW_M_PER_H meter per uur. + // Zaagmachine: totale zaaglengte, sneden en hoeken (tijd: CostModel). saw: { - meters: saw.lengthMM / 1000, hours: saw.lengthMM / 1000 / SAW_M_PER_H, cuts: saw.cuts, + meters: saw.lengthMM / 1000, cuts: saw.cuts, angles: Array.from(saw.angles.entries()).map(([deg, count]) => ({ deg, count })).sort((a, b) => a.deg - b.deg) }, beamCount: kindList.reduce((a, k) => a + k.count, 0), @@ -986,6 +1118,10 @@ window.HouseModel = (() => { { naam: 'Vochtdoek (buitenzijde)', m2: byMat('vochtdoek').reduce((a, p) => a + p.stats.doekAreaM2, 0), m3: null, kg: byMat('vochtdoek').reduce((a, p) => a + p.specs.buitenKg, 0) + }, + { + naam: `Rabat ${WallModel.CLADDING_THICK_MM} mm (gevelbekleding)`, m2: byMat('vochtdoek').reduce((a, p) => a + (p.stats.rabatAreaM2 || 0), 0), + m3: null, kg: 0 // gewicht volgt } ], weights, @@ -1053,6 +1189,7 @@ window.HouseModel = (() => { state.settings.facade = Object.assign(def.facade, state.settings.facade || {}); if (!FACADE_TYPES[state.settings.facade.type]) state.settings.facade.type = 'rabat'; if (!WallModel.isStoneFinish(state.settings.facade.stone)) state.settings.facade.stone = def.facade.stone; + if (!WallModel.RABAT_COLORS[state.settings.facade.rabatColor]) state.settings.facade.rabatColor = def.facade.rabatColor; state.settings.trasraam = Object.assign(def.trasraam, state.settings.trasraam || {}); state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false; if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM; @@ -1071,6 +1208,7 @@ window.HouseModel = (() => { if (!lightIn.mode) lightIn.mode = legacyUnitLight ? legacyUnitLight.mode : (oldRoof.spots === false ? 'geen' : 'spots'); if (!lightIn.direction && legacyUnitLight && legacyUnitLight.direction) lightIn.direction = legacyUnitLight.direction; state.settings.lighting = LightingModel.normalizeSettings(lightIn); + state.settings.elektra = ElektraModel.normalizeSettings(state.settings.elektra); state.units.forEach(u => { if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' }; if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; } @@ -1086,9 +1224,9 @@ window.HouseModel = (() => { } return { - ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, SAW_M_PER_H, // SAW_M_PER_H: tijdelijk + ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, createDefaultState, unitRects, computeSeams, overlappingPairs, interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls, - computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, validateState, fromUnitDesign, normalize + computeParts, allWallPieces, computeTotals, computeDimensions, dimensionRows, TRANSPORT, TRANSPORT_CLASSES, computeTransport, validateState, fromUnitDesign, normalize }; })(); diff --git a/js/house/house-pdf.js b/js/house/house-pdf.js index 86ee07b..51badb2 100644 --- a/js/house/house-pdf.js +++ b/js/house/house-pdf.js @@ -20,7 +20,7 @@ window.HousePdf = (() => { const KIND_LABELS = { 'vloerbalk': 'Vloerbalk', 'dakbalk': 'Dakbalk', 'onderregel': 'Onderregel', 'bovenregel': 'Bovenregel', 'verticale balk': 'Verticale balk', 'kreupele stijl': 'Kreupele stijl', - 'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel' + 'bovendorpel': 'Bovendorpel', 'onderdorpel': 'Onderdorpel', 'schuine regel': 'Schuine regel' }; const PLAN_ELEMENT = 'PL-1'; const VIEWS_ELEMENT = 'AZ-1'; @@ -46,10 +46,13 @@ window.HousePdf = (() => { list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u }); u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }))); u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl })); + // Wolfsdak: het bovenste deel (wolfseinden, nok) op een eigen blad. + if (u.roof.unitRoof && u.roof.unitRoof.cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u }); }); parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })); list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' }); list.push({ kind: 'saw', element: 'ZAAG', label: 'Zaaglijst & zaagmachine' }); + if (parts.elektra && parts.elektra.enabled) list.push({ kind: 'elektra', element: 'EL', label: 'Elektra' }); return list; } @@ -128,6 +131,9 @@ window.HousePdf = (() => { doc.rect(px(l.x + l.normal.x * 80) - 0.9, py(l.z + l.normal.z * 80) - 0.9, 1.8, 1.8, 'FD'); }); + // Elektra: meterkast, schakelaars (letters), wcd, leidingen (ElektraPdf). + ElektraPdf.drawPlan(doc, parts.elektra, px, py); + // Naden: gestippeld oranje doc.setDrawColor(230, 130, 30); doc.setLineWidth(0.4); @@ -177,6 +183,7 @@ window.HousePdf = (() => { doc.setFontSize(6); doc.text('Plattegrond woning — oranje gestippeld = naad tussen units (wand vervalt), openingen gekruist, grijs gestippeld = dakrand, zwart gestippeld = ' + 'boeideel (o = spot), grijs = daktrim, geel blokje = gevellamp', drawX0, drawY1 + 4); + if (parts.elektra && parts.elektra.enabled) doc.text(ElektraPdf.PLAN_LEGEND, drawX0, drawY1 + 7.5); } // ------------------------------------------------------------ @@ -216,7 +223,7 @@ window.HousePdf = (() => { const addWall = (p, y0) => { const n = { x: p.place.normal.x, z: p.place.normal.y }; const zOut = p.place.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone); - const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : VIEW_COLORS.rabat; + const color = p.stone ? (WallModel.STONE_COLORS[p.stone] || '#a0724f') : WallModel.rabatHex(state.settings.facade.rabatColor); faces.push({ pts: wallFace(p, y0).map(q => toWorld(p, q.x, q.y, zOut)), n, color, bias: 0 }); // Trasraam (strook onderaan, iets naar voren) const tr = p.trasraam; @@ -246,14 +253,7 @@ window.HousePdf = (() => { if (sh.type === 'plat') return; const cov = RoofModel.COVERINGS[r.covering] || RoofModel.COVERINGS.dakleer; const ctr = sh.axis === 'x' ? u.rect.cx : u.rect.cz; - r.planes.forEach(pl => { - const a0 = ctr + pl.p0, a1 = ctr + pl.p1, mid = (a0 + a1) / 2; - // Hoogte net binnen het vlak (op de nok geeft topAt het hoogste vlak). - const inward = (t) => t + Math.sign(mid - t) * 0.5; - const corners = sh.axis === 'x' - ? [[a0, r.rect.z0], [a1, r.rect.z0], [a1, r.rect.z1], [a0, r.rect.z1]] - : [[r.rect.x0, a0], [r.rect.x1, a0], [r.rect.x1, a1], [r.rect.x0, a1]]; - const pts = corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z })); + const pushRoof = (pts) => { // Normaal (omhoog) uit het vlak. const [A, B, C] = pts; const ux = B.x - A.x, uy = B.y - A.y, uz = B.z - A.z, vx = C.x - A.x, vy = C.y - A.y, vz = C.z - A.z; @@ -261,18 +261,45 @@ window.HousePdf = (() => { if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; } const len = Math.hypot(nx, ny, nz) || 1; faces.push({ pts, n: { x: nx / len, z: nz / len }, roof: true, color: cov.color || '#2a2a2a', bias: 0 }); + }; + // Wolfsdak: bovenste deel per vlak (RoofModel.capRegions, unit-lokaal (p, q)). + RoofModel.capRegions(r.unitRoof).forEach(rg => { + pushRoof(rg.poly.map(pt => { + const x = u.rect.cx + (sh.axis === 'x' ? pt.x : pt.z), z = u.rect.cz + (sh.axis === 'x' ? pt.z : pt.x); + return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z }; + })); + }); + r.planes.forEach(pl => { + const a0 = ctr + pl.p0, a1 = ctr + pl.p1, mid = (a0 + a1) / 2; + // Hoogte net binnen het vlak (op de nok geeft topAt het hoogste vlak). + const inward = (t) => t + Math.sign(mid - t) * 0.5; + const corners = sh.axis === 'x' + ? [[a0, r.rect.z0], [a1, r.rect.z0], [a1, r.rect.z1], [a0, r.rect.z1]] + : [[r.rect.x0, a0], [r.rect.x1, a0], [r.rect.x1, a1], [r.rect.x0, a1]]; + pushRoof(corners.map(([x, z]) => ({ x, y: sh.axis === 'x' ? r.topAt(inward(x), z) : r.topAt(x, inward(z)), z }))); }); }); - // Dakrand: buitenvlak van elk boeideel / elke daktrim. + // Dakrand: elk boeideel / elke daktrim zoals je hem van buiten ziet: de + // bovenkant volgt het dak (sg.topAt, langs de ribbe bemonsterd, zodat + // knikken van een kopgevel - zadel, mansarde, wolfsdak - meekomen), en + // telt zowel de dakkant als de buitenkant mee (bij een hellend dak ligt + // de buitenkant lager, de bovenkant van het boeideel dekt de gevel af). const edge = parts.roofEdge; if (edge) { const fc = (RoofModel.FASCIA_COLORS[edge.fasciaColor] || RoofModel.FASCIA_COLORS.antraciet).hex; + const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, z: a.z + (b.z - a.z) * t }); edge.segments.forEach(sg => { const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove; - const A = sg.outerA, B = sg.outerB, tA = sg.topAt(A), tB = sg.topAt(B); - const pts = [{ x: A.x, y: tA, z: A.z }, { x: B.x, y: tB, z: B.z }, { x: B.x, y: tB - h, z: B.z }, { x: A.x, y: tA - h, z: A.z }]; - faces.push({ pts, n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 }); + const n = Math.max(1, Math.ceil(Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z) / 100)); + const top = [], bottom = []; + for (let k = 0; k <= n; k++) { + const t = k / n, I = lerp(sg.a, sg.b, t), O = lerp(sg.outerA, sg.outerB, t); + const tI = sg.topAt(I), tO = sg.topAt(O); + top.push({ x: O.x, y: Math.max(tI, tO), z: O.z }); + bottom.push({ x: O.x, y: Math.min(tI, tO) - h, z: O.z }); + } + faces.push({ pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: sg.fascia ? fc : 0xb8bcc0, bias: 3 }); }); } return faces; @@ -292,7 +319,7 @@ window.HousePdf = (() => { seen.add(key); items.push(fc.type === 'steen' ? { label: 'Gevel', value: `Steen ${fc.stone}`, thumb: WallModel.stoneThumbUrl(fc.stone, 64), color: WallModel.STONE_COLORS[fc.stone] } - : { label: 'Gevel', value: `Rabat (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: VIEW_COLORS.rabat }); + : { label: 'Gevel', value: `Rabat ${((WallModel.RABAT_COLORS[st.facade.rabatColor] || {}).label || '').toLowerCase()} (${(WallModel.CLADDING_DIRECTIONS[st.cladding] || st.cladding).toLowerCase()})`, color: WallModel.rabatHex(st.facade.rabatColor) }); })); if (st.trasraam.enabled) { const tf = st.trasraam.finish; @@ -420,6 +447,80 @@ window.HousePdf = (() => { PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); } + // Blad voor het bovenste deel van een wolfsdak (RoofModel.computeCap): + // bovenaanzicht van het houtwerk (sporen, hoekkepers, nokbalk, kopbalken) + // op de dakvlakken, en de stuklijst. + const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', nokbalk: 'Nokbalk', kopbalk: 'Kopbalk' }; + function drawCapSheet(doc, u, sheetOpts) { + const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; + const ur = u.roof.unitRoof, c = ur.cap; + // (p, q) -> plattegrond (x, z), unit-lokaal mm. + const xz = (pt) => ur.alongX ? { x: pt.x, z: pt.z } : { x: pt.z, z: pt.x }; + const band = [{ x: c.pA, z: c.q0 }, { x: c.pB, z: c.q0 }, { x: c.pB, z: c.q1 }, { x: c.pA, z: c.q1 }].map(xz); + const xs = band.map(p => p.x), zs = band.map(p => p.z); + const X0 = 20, X1 = 275, Y0 = 32, Y1 = 230; + const sc = Math.min((X1 - X0) / (Math.max(...xs) - Math.min(...xs)), (Y1 - Y0) / (Math.max(...zs) - Math.min(...zs))); + const ox = (X0 + X1) / 2 - (Math.min(...xs) + Math.max(...xs)) / 2 * sc, oz = (Y0 + Y1) / 2 - (Math.min(...zs) + Math.max(...zs)) / 2 * sc; + const P = (p) => [ox + p.x * sc, oz + p.z * sc]; + const poly = (pts, style) => { + const q = pts.map(P); + doc.lines(q.slice(1).map((b, k) => [b[0] - q[k][0], b[1] - q[k][1]]), q[0][0], q[0][1], [1, 1], style, true); + }; + doc.setTextColor(...BLACK); + doc.setFontSize(11); + doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20); + // Dakvlakken (grijs) en de band. + doc.setDrawColor(...GREY); + doc.setLineWidth(0.15); + c.regions.forEach(rg => poly(rg.poly.map(xz), 'S')); + // Balken (rood, plattegrond: rechthoek van balkbreedte rond de hartlijn). + doc.setDrawColor(...RED); + doc.setLineWidth(0.2); + c.beams.forEach(bm => poly(bm.poly.map(xz), 'S')); + // Maten: lengte en diepte van de band, diepte van een wolfseind. + doc.setDrawColor(...BLUE); + doc.setTextColor(...BLUE); + doc.setFontSize(7); + const bx0 = Math.min(...xs), bx1 = Math.max(...xs), bz0 = Math.min(...zs), bz1 = Math.max(...zs); + const [ax, ay] = P({ x: bx0, z: bz1 }), [bx, by] = P({ x: bx1, z: bz1 }); + PdfSheet.dimLine(doc, ax, ay + 8, bx, by + 8); + doc.text(`${Math.round(bx1 - bx0)}`, (ax + bx) / 2, ay + 7, { align: 'center' }); + const [cx0, cy0] = P({ x: bx1, z: bz0 }), [cx1, cy1] = P({ x: bx1, z: bz1 }); + PdfSheet.dimLine(doc, cx0 + 8, cy0, cx1 + 8, cy1); + doc.text(`${Math.round(bz1 - bz0)}`, cx0 + 9.5, (cy0 + cy1) / 2, { angle: 90 }); + + // Stuklijst en kerngegevens. + const groups = new Map(); + c.beams.forEach(bm => { + const key = `${bm.kind}|${Math.round(bm.length / 10) * 10}|${bm.angle}`; + const g = groups.get(key) || { kind: bm.kind, length: Math.round(bm.length / 10) * 10, angle: bm.angle, n: 0 }; + g.n++; + groups.set(key, g); + }); + const order = Object.keys(CAP_KINDS); + const rows = [...groups.values()].sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || b.length - a.length) + .map(g => [CAP_KINDS[g.kind] || g.kind, String(g.length), String(g.angle).replace('.', ','), String(g.n)]); + const H = (p) => RoofModel.profileHeight(ur.shape.profile, p); + const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ','); + const hip = c.regions.find(rg => rg.key === 'h1'); + const facts = [ + ['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`], + ['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`], + ['Helling dakvlakken', `${deg((H(c.pR) - H(c.pA)) / (c.pR - c.pA))}°`], + ['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'], + ['Balken', `${c.joistW} x ${c.joistH} mm`], + ['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`], + ['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`] + ]; + doc.setTextColor(...BLACK); + let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 }); + PdfSheet.table(doc, 290, y + 6, [0, 32, 56, 78], ['Soort', 'Lengte (mm)', 'Hoek (°)', 'Aantal'], rows, { title: 'Stuklijst balken', fontSize: 7, rowH: 4.6, lastColW: 14, maxY: 240 }); + doc.setFontSize(7); + doc.setTextColor(...GREY); + doc.text('Hoogtes vanaf bovenkant vloer. Lengte = langs de balk; hoek = helling van de balk (zaagsnede aan beide koppen). Grijs = dakvlakken, rood = houtwerk.', X0, 240); + PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); + } + function drawTotalsSheet(doc, totals, sheetOpts) { const { BLACK } = PdfSheet.COLORS; doc.setFont('helvetica', 'normal'); @@ -476,7 +577,7 @@ window.HousePdf = (() => { } // Eigen blad na de totalen: zaaglijst vuren (in kolommen) + zaagmachine. - function drawSawSheet(doc, totals, sheetOpts) { + function drawSawSheet(doc, totals, cost, sheetOpts) { const { BLACK } = PdfSheet.COLORS; doc.setFont('helvetica', 'normal'); doc.setTextColor(...BLACK); @@ -496,10 +597,10 @@ window.HousePdf = (() => { // TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht. const sw = totals.saw; - const hm = (h) => `${Math.floor(h)}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`; PdfSheet.table(doc, 290, 30, [0, 55], ['Onderdeel', 'Waarde'], [ ['Zaagmeters', fmt(sw.meters, 1) + ' m'], - [`Zaagtijd (${HouseModel.SAW_M_PER_H} m/u)`, hm(sw.hours) + ' u:mm'], + ['Zaagtijd robot', CostModel.uur(cost.saw.robotSec / 3600) + ' uur'], + [`Zaagtijd handmatig (\u00d7${cost.settings.zagen.handFactor})`, CostModel.uur(cost.saw.handSec / 3600) + ' uur'], ['Zaagsneden', sw.cuts + ' st.'], ['Verschillende hoeken', String(sw.angles.length)] ].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0\u00b0)' : `Schuin ${fmt(a.deg, 1)}\u00b0`, a.count + ' sneden'])), tableOpts('Zaagmachine (tijdelijk)')); @@ -507,12 +608,134 @@ window.HousePdf = (() => { PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); } + // ------------------------------------------------------------ + // Kostenraming (CostModel): samenvatting + regels per element. + // ------------------------------------------------------------ + const COST_ROW_H = 3.4; + // Kolommen van een detailblad: x, onderkant (rechts het titelblok). + const COST_COLS = [{ x: 15, yMax: 278 }, { x: 150, yMax: 278 }, { x: 285, yMax: 246 }]; + const COST_Y0 = 30; + const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '\u2026' : t); + // Platte regels voor de detailbladen: kop per element, dan de regels. + function costDetailRows(cost) { + return cost.elements.flatMap(el => elementCostRows(el)); + } + // Kop + regels van één element. + function elementCostRows(el) { + const rows = []; + if (el.lines.length) { + rows.push({ head: true, label: `${el.element} ${el.label}`, mat: CostModel.eur(el.mat), time: CostModel.uur(el.sec / 3600) }); + el.lines.forEach(l => rows.push({ + label: l.label, qty: `${fmt(l.qty, ['st.', 'sneden', 'sectie'].includes(l.unit) ? 0 : 2)} ${l.unit}`, + mat: l.mat ? CostModel.eur(l.mat, 2) : '', time: l.sec ? CostModel.uur(l.sec / 3600) : '' + })); + } + return rows; + } + // Kostenblok op het blad van het onderdeel zelf (onder de hoofdtekening, + // links van het titelblok). Te veel regels: rest op het detailblad. + function drawElementCost(doc, el, extraLabel) { + if (!el || !el.lines.length) return; + const x = 132, y = 236, yMax = 287; + const rows = elementCostRows(el); + rows[0] = Object.assign({}, rows[0], { label: `Kosten ${el.element}${extraLabel ? ' (' + extraLabel + ')' : ''}` }); + rows.push({ head: true, label: 'Kostprijs (incl. verlies, bezetting, indirect, QA, afschrijving)', mat: CostModel.eur(el.kostprijs), time: '' }); + const fit = Math.floor((yMax - y - 5) / COST_ROW_H); + let shown = rows; + if (rows.length > fit) { + shown = rows.slice(0, fit - 2).concat([{ label: `+ ${rows.length - fit + 1} regels: zie kostenblad detail`, mat: '', time: '' }, rows[rows.length - 1]]); + } + drawCostTable(doc, x, y, shown); + } + // Verdeelt de regels over bladen (kolommen vullen van boven naar beneden). + function paginateCostRows(rows) { + const pages = []; + let i = 0; + while (i < rows.length) { + const cols = COST_COLS.map(c => { + const n = Math.floor((c.yMax - COST_Y0 - 6) / COST_ROW_H); + const chunk = rows.slice(i, i + n); + i += chunk.length; + return chunk; + }); + pages.push(cols); + } + return pages; + } + function drawCostTable(doc, x, y, rows) { + const { BLACK, GREY } = PdfSheet.COLORS; + doc.setFontSize(6.5); + doc.setTextColor(...GREY); + doc.text('Omschrijving', x, y); doc.text('Hoeveelheid', x + 84, y, { align: 'right' }); + doc.text('Materiaal', x + 104, y, { align: 'right' }); doc.text('Manuur', x + 124, y, { align: 'right' }); + doc.setDrawColor(...GREY); doc.setLineWidth(0.15); + doc.line(x, y + 1.5, x + 126, y + 1.5); + y += 5; + rows.forEach(r => { + doc.setTextColor(...BLACK); + doc.setFont('helvetica', r.head ? 'bold' : 'normal'); + doc.text(clip(r.label, r.head ? 60 : 52), r.head ? x : x + 2, y); + if (r.qty) doc.text(r.qty, x + 84, y, { align: 'right' }); + doc.text(r.mat, x + 104, y, { align: 'right' }); + doc.text(r.time, x + 124, y, { align: 'right' }); + y += COST_ROW_H; + }); + doc.setFont('helvetica', 'normal'); + } + + function drawCostSheet(doc, cost, sheetOpts) { + const { BLACK } = PdfSheet.COLORS; + const s = cost.summary, S = cost.settings, eur = (n) => CostModel.eur(n); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...BLACK); + doc.setFontSize(14); + doc.text('Kostenraming (indicatief)', 15, 18); + const tableOpts = (title) => ({ title, fontSize: 7, rowH: 4.6, lastColW: 24 }); + let y = PdfSheet.table(doc, 15, 30, [0, 82], ['Post', 'Bedrag'], + s.rows.map(r => [r.label, eur(r.amount)]).concat([['Totaal incl. btw', eur(s.inclBtw)]]), tableOpts('Samenvatting')); + y = PdfSheet.table(doc, 15, y + 6, [0, 82], ['Groep', 'Materiaal'], + cost.categories.filter(r => r.mat > 0).map(r => [r.name, eur(r.mat)]), tableOpts('Materiaal per groep')); + PdfSheet.table(doc, 15, y + 6, [0, 82], ['Werkzaamheid', 'Manuur'], + cost.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600)]) + .concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect)], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa)], ['Totaal', CostModel.uur(s.hours)]]), + tableOpts('Arbeid per werkzaamheid')); + + PdfSheet.table(doc, 130, 30, [0, 72, 97, 122], ['Element / onderdeel', 'Materiaal', 'Manuur', 'Kostprijs*'], + cost.elements.filter(el => el.lines.length).map(el => [clip(`${el.element} ${el.label}`, 48), eur(el.mat), CostModel.uur(el.sec / 3600), eur(el.kostprijs)]), + Object.assign(tableOpts('Per element'), { maxY: 278 })); + PdfSheet.table(doc, 290, 30, [0, 40, 65, 88], ['Unit', 'Materiaal', 'Manuur', 'Kostprijs*'], + cost.units.map(u => [u.id ? `Unit ${u.id}` : 'Woning-breed', eur(u.mat), CostModel.uur(u.sec / 3600), eur(u.kostprijs)]), tableOpts('Per unit')); + doc.setFontSize(6.5); + doc.text('* Kostprijs: materiaal incl. verlies en klein materiaal + arbeid (manuren incl. bezetting, indirect en QA) + afschrijving/huisvesting', 290, 236); + doc.text(' (+ transport per unit, kraan bij woning-breed); zonder algemene kosten, marge en btw.', 290, 240); + doc.text('Schattingen op basis van Instellingen > Kosten & tijden; bedragen excl. btw tenzij vermeld.', 290, 244); + PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); + } + + function drawCostDetailSheet(doc, cols, sheetOpts) { + doc.setFont('helvetica', 'normal'); + doc.setTextColor(...PdfSheet.COLORS.BLACK); + doc.setFontSize(14); + doc.text('Kostenraming per element (detail)', 15, 18); + cols.forEach((rows, i) => { if (rows.length) drawCostTable(doc, COST_COLS[i].x, COST_Y0, rows); }); + PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); + } + function generate(state) { const parts = HouseModel.computeParts(state, { wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials() }); const totals = HouseModel.computeTotals(parts); const sheets = sheetList(parts); + const cost = CostModel.compute(parts, totals); + const costOf = (element) => cost.elements.find(el => el.element === element); + const drawWallWithCost = (sheet, opts) => { + WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts)); + drawElementCost(doc, costOf(sheet.element)); + }; + const detailPages = paginateCostRows(costDetailRows(cost)); + sheets.push({ kind: 'cost', element: 'KOST', label: 'Kostenraming' }); + detailPages.forEach((cols, i) => sheets.push({ kind: 'costdetail', element: `KOST-${i + 1}`, label: 'Kostenraming detail' + (detailPages.length > 1 ? ` (${i + 1})` : ''), cols })); const project = state.project; const doc = PdfSheet.createDoc(); @@ -521,14 +744,24 @@ window.HousePdf = (() => { const opts = { project, titel: sheet.label, element: sheet.element, blad: `${i + 1} / ${sheets.length}` }; if (sheet.kind === 'views') drawViewsSheet(doc, parts, state, opts); else if (sheet.kind === 'plan') drawPlanSheet(doc, parts, sheets, opts); - else if (sheet.kind === 'floor') FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts); - else if (sheet.kind === 'roof') FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true }, opts)); - else if (sheet.kind === 'wall') WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [] }, opts)); - else if (sheet.kind === 'saw') drawSawSheet(doc, totals, opts); + else if (sheet.kind === 'floor') { + FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, opts); + drawElementCost(doc, costOf(sheet.element)); + } else if (sheet.kind === 'roof') { + FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true }, opts)); + // Dakkosten per unit (alle dakvlakken samen): op het eerste dakvlak. + if (/-D1$/.test(sheet.element)) drawElementCost(doc, costOf(sheet.element.replace(/\d+$/, '')), 'alle dakvlakken'); + } + else if (sheet.kind === 'cap') drawCapSheet(doc, sheet.unit, opts); + else if (sheet.kind === 'wall') drawWallWithCost(sheet, opts); + else if (sheet.kind === 'saw') drawSawSheet(doc, totals, cost, opts); + else if (sheet.kind === 'elektra') ElektraPdf.drawSheet(doc, parts.elektra, opts); + else if (sheet.kind === 'cost') drawCostSheet(doc, cost, opts); + else if (sheet.kind === 'costdetail') drawCostDetailSheet(doc, sheet.cols, opts); else drawTotalsSheet(doc, totals, opts); }); return doc; } - return { generate, sheetList, materialThumbUrls }; + return { generate, sheetList, materialThumbUrls, viewFaces, VIEWS }; })(); diff --git a/js/house/house.js b/js/house/house.js index d674426..63816f6 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -11,9 +11,9 @@ // Opbouw van het scherm: // - Links een navigatieboom: Overzicht, per unit de vloer en 4 wanden, // binnenwanden, en acties (+ Unit, + Binnenwand). -// - Rechts het actieve onderdeel: het woning-overzicht (plattegrond + -// eigenschappen + 3D + totalen), of de ECHTE WallEditor/FloorEditor voor -// één onderdeel. +// - Rechts het actieve onderdeel: het woning-overzicht (plattegrond + 3D +// met bouwfase, inspector met Eigenschappen | Rapport), of de ECHTE +// WallEditor/FloorEditor voor één onderdeel. // // Geen eigen wand-/vloer-/unitlogica: alles via HouseModel/UnitModel/ // WallModel/FloorModel en HousePdf. @@ -23,16 +23,8 @@ const HOUSE_TEMPLATE = `
@@ -66,28 +58,22 @@ const HOUSE_TEMPLATE = `
`; +// Overzicht: plattegrond en 3D over de volle breedte; alle bediening zit in +// de ribbon (core/ribbon.js). Tabblad Rapport toont het rapport in plaats +// van de plattegrond. const HOUSE_OVERVIEW_TEMPLATE = `
-
+
+
PLATTEGROND
-
- - -
+
-
-
-
3D
-
- -
-
`; @@ -112,6 +98,9 @@ function mountHouseEditor(container, callbacks) { // null | { type: 'unit', id } | { type: 'wall', unit, side, element } | { type: 'iw', id } let selection = null; let pendingTool = null; // 'iw' = direct in teken-modus bij openen overzicht + // Weergave van het overzicht (blijft bewaard bij wisselen van onderdeel): + // bouwfase (BuildStages) en de hoogte van de doorsnede (mm boven het loopvlak). + const view3D = { stage: BuildStages.FINAL, section: false, sectionMM: 1000, dak: true, lampen: true, elektra: true }; // Camerastand per onderdeel onthouden (CameraMemory), per tabblad. const camKey = (part) => `${(callbacks && callbacks.cameraKey) || 'woning'}:${part}`; @@ -139,7 +128,7 @@ function mountHouseEditor(container, callbacks) { const unit = unitById(a).unit, before = unit.walls[b].wall; const next = activeEditor.getStateJson(); unit.walls[b] = next; - const h = (w) => [w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : null].join('|'); + const h = (w) => [w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : null, JSON.stringify(w.topPts || null)].join('|'); if (h(before) !== h(next.wall)) UnitModel.syncWallHeights(unit, b); } else if (kind === 'roof' && unitById(a)) unitById(a).roof.deck = activeEditor.getStateJson(); @@ -191,20 +180,48 @@ function mountHouseEditor(container, callbacks) { activeEditor.loadStateJson(HouseModel.clone(iwById(Number(a)).wall)); } else { activePart = 'overzicht'; - activeEditor = mountOverview(); + activeEditor = mountOverview(); // rendert de boom en de ribbon zelf + return; } + setPartRibbon(kind, a, b); renderNav(); } + // Ribbon terwijl een onderdeel-editor open is: terug naar de woning en de + // woning-acties; de editor zelf heeft zijn eigen panelen. + function setPartRibbon(kind, a, b) { + const { big } = Ribbon; + const what = kind === 'floor' ? `Vloer unit ${a}` : kind === 'roof' ? `Dak unit ${a}` : kind === 'wall' + ? `${(UnitModel.WALL_SIDES.find(sd => sd.key === b) || {}).label || 'Wand'} unit ${a}` + : (iwById(Number(a)) && iwById(Number(a)).seam ? 'Naadwand' : 'Binnenwand'); + Ribbon.setSource('editor', [{ + key: 'start', label: 'Start', order: 10, + groups: () => [ + { label: 'Navigatie', html: big('Terug naar woning', 'arrow-left-circle', 'data-hp="back" title="Overzicht van de woning"') }, + { label: 'Onderdeel', html: `
${what}
Wijzigingen gelden direct voor de woning. Hoogtes van een wand bepalen de dakvorm van de unit (de overstaande wand spiegelt mee).
` }, + { label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-hp="add-unit"') + big('Binnenwand tekenen', 'pencil', 'data-hp="draw-iw"') } + ], + wire: (body) => body.querySelectorAll('[data-hp]').forEach(btn => btn.addEventListener('click', () => { + if (btn.dataset.hp === 'back') showPart('overzicht'); + else if (btn.dataset.hp === 'add-unit') addUnit(); + else drawInteriorWall(); + })) + }]); + Ribbon.activate('start'); + } + // ------------------------------------------------------------ // Navigatieboom // ------------------------------------------------------------ - function renderNav() { - const parts = HouseModel.computeParts(state, { skip3D: true }); - const item = (part, label, meta, cls, iconName) => - `
+ // parts (optioneel): al doorgerekende onderdelen (overzicht), anders zelf rekenen. + function renderNav(parts) { + parts = parts || HouseModel.computeParts(state, { skip3D: true }); + const item = (part, label, meta, cls, iconName, active) => + `
${iconName ? UI.icon(iconName) : ''}${label}${meta ? `${meta}` : ''}
`; - let html = item('overzicht', 'Overzicht', '', 'hn-top', 'grid-1x2'); + // "Woning" = overzicht zonder selectie: de instellingen van de hele woning. + let html = item('overzicht', 'Woning', `${parts.units.length} unit${parts.units.length === 1 ? '' : 's'}`, 'hn-top', 'house-door', + activePart === 'overzicht' && !selection); parts.units.forEach(u => { const sel = selection && selection.type === 'unit' && selection.id === u.id && activePart === 'overzicht'; html += `
`; @@ -238,11 +255,16 @@ function mountHouseEditor(container, callbacks) { if (unitRow) { selection = { type: 'unit', id: unitRow.dataset.selectUnit }; if (activePart !== 'overzicht') showPart('overzicht'); - else { activeEditor.refresh(); renderNav(); } + else activeEditor.select(selection); return; } const row = e.target.closest('[data-part]'); - if (row && row.dataset.part !== activePart) showPart(row.dataset.part); + if (!row) return; + if (row.dataset.part === 'overzicht') { + selection = null; + if (activePart !== 'overzicht') showPart('overzicht'); + else activeEditor.select(null); + } else if (row.dataset.part !== activePart) showPart(row.dataset.part); }); // ------------------------------------------------------------ @@ -272,12 +294,12 @@ function mountHouseEditor(container, callbacks) { showPart('overzicht'); } - document.getElementById('hn-add-unit').addEventListener('click', addUnit); - document.getElementById('hn-add-iw').addEventListener('click', () => { + // Binnenwand tekenen (ribbon): vanuit een onderdeel eerst terug naar het overzicht. + function drawInteriorWall() { pendingTool = 'iw'; if (activePart !== 'overzicht') showPart('overzicht'); else activeEditor.setTool('iw'); - }); + } // ------------------------------------------------------------ // OVERZICHT @@ -286,7 +308,6 @@ function mountHouseEditor(container, callbacks) { host.innerHTML = HOUSE_OVERVIEW_TEMPLATE; const canvas = document.getElementById('ho-canvas'); const ctx = canvas.getContext('2d'); - const propsEl = document.getElementById('ho-props'); // --- 3D --- const pane3D = document.getElementById('ho-pane-3d'); @@ -319,15 +340,13 @@ function mountHouseEditor(container, callbacks) { const roofMaterials = RoofModel.createMaterials(); const lightingMaterials = LightingModel.createMaterials(); const sandwichMaterials = SandwichModel.createMaterials(); + const elektraMaterials = ElektraModel.createMaterials(); let houseGroup = null; - let dakVisible = true; // Opgeslagen camerastand gaat voor; anders eenmalig passend maken. let cameraFitted = CameraMemory.restore(overviewCamKey, camera, controls); - let beplatingVisible = true, lampenVisible = true; - // Vloerplan: alle wanden afgesneden op VLOERPLAN_CUT_MM boven het loopvlak - // (clipping-vlak, doorsnede blijft open); dak en lampen uit. - const VLOERPLAN_CUT_MM = 500; - let vloerplan = false; + // Doorsnede: alle wanden afgesneden op view3D.sectionMM boven het + // loopvlak (clipping-vlak, snede wit afgedekt); dak en lampen uit. + let planCaps = null; const cutPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0); renderer.localClippingEnabled = true; const capMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff }); @@ -338,13 +357,15 @@ function mountHouseEditor(container, callbacks) { function buildPlanCaps(cutAbs) { const toM = 0.001, g = new THREE.Group(); const S = WallModel.STONE; + // Bouwfase: buitenlagen (latwerk is open, dus pas vanaf de gevel) en beplating alleen als ze er staan. + const gevel = BuildStages.shows(view3D.stage, 'gevel'), beplating = BuildStages.shows(view3D.stage, 'beplating'); HouseModel.allWallPieces(parts).forEach(p => { const y = cutAbs - p.place.floorTop; // snijhoogte in het stuk if (y <= 0 || y >= WallModel.topLine(p.config).maxY) return; const W = p.place.width, T = p.place.thickness; // Buitenlagen op de snijhoogte (alleen buitenwanden: skinExt/normal). let out = 0; - if (p.place.normal && p.buitenMateriaal !== 'geen') { + if (gevel && p.place.normal && p.buitenMateriaal !== 'geen') { const tr = p.trasraam; out = tr && y < tr.top ? (WallModel.isStoneFinish(tr.finish) ? S.THICK_MM : 0) : WallModel.claddingOffset(p.buitenMateriaal, p.stone); } @@ -356,7 +377,8 @@ function mountHouseEditor(container, callbacks) { let cur = xL; gaps.forEach(([a, b]) => { if (a > cur) spans.push([cur, Math.min(a, xR)]); cur = Math.max(cur, b); }); if (xR > cur) spans.push([cur, xR]); - const z0 = -T / 2, z1 = T / 2 + out; + const sh = beplating ? 0 : p.config.sheathingThick; + const z0 = -T / 2 + sh, z1 = T / 2 + out - (p.buitenMateriaal !== 'geen' ? sh : 0); spans.filter(([a, b]) => b - a > 1).forEach(([a, b]) => { const m = new THREE.Mesh(new THREE.PlaneGeometry((b - a) * toM, (z1 - z0) * toM), capMaterial); m.rotation.x = -Math.PI / 2; @@ -374,20 +396,40 @@ function mountHouseEditor(container, callbacks) { let skyMode = Skybox.getMode(); const skyEnv = Skybox.createEnvironment(scene); - // Weergave-filter (core/view-filter.js). Beplating = wandplaten, vloer- - // en plafondplaten, dakleer en boeideel/daktrim. - createViewFilter(document.getElementById('ho-toolbar-3d'), [ - { key: 'beplating', label: 'Beplating' }, - { key: 'dak', label: 'Dak' }, - { key: 'lampen', label: 'Lampen' }, - { key: 'vloerplan', label: 'Vloerplan', checked: false } - ], (v) => { - beplatingVisible = v.beplating; - dakVisible = v.dak; - lampenVisible = v.lampen; - vloerplan = v.vloerplan; - refresh({ keepProps: true }); + // Bouwfase (core/build-stages.js) + hoogte van de doorsnede. + const stageBar = BuildStages.createBar(pane3D, view3D.stage, (st) => { + view3D.stage = st; + scheduleRefresh({ keepProps: true }); }); + stageBar.extra.innerHTML = `Doorsnede + + `; + const sectionInput = document.getElementById('ho-section'); + function syncSectionRow() { + stageBar.extra.classList.toggle('show', view3D.section); + document.getElementById('ho-section-val').textContent = `${view3D.sectionMM} mm`; + } + // Snijhoogte (schuif hier en in Beeld › Doorsnede). Alleen het snijvlak + // en de afdekking verplaatsen, niet alles herbouwen. + function setSectionMM(v) { + if (!isFinite(v)) return; + view3D.sectionMM = Math.max(50, Math.min(8000, Math.round(v))); + sectionInput.value = view3D.sectionMM; + UI.setRange('hv-section-h', view3D.sectionMM); + syncSectionRow(); + if (!parts || !view3D.section) return; + placeSection(); + } + sectionInput.addEventListener('input', () => setSectionMM(Number(sectionInput.value))); + sectionInput.addEventListener('keydown', (e) => { if (!/^Arrow|Home|End/.test(e.key)) sectionInput.blur(); }); + syncSectionRow(); + function placeSection() { + const floorTop = Math.max(...parts.units.map(u => u.layout.floorTop)); + cutPlane.constant = (floorTop + view3D.sectionMM) * 0.001; + if (planCaps) { houseGroup.remove(planCaps); disposeGroup(planCaps); } + planCaps = view3D.section ? buildPlanCaps(floorTop + view3D.sectionMM) : null; + if (planCaps) houseGroup.add(planCaps); + } // Omgeving (core/skybox.js: geen/dag/nacht) en Render. Render = lampen en // spots als echte lichtbronnen, in het licht van de gekozen omgeving; @@ -395,29 +437,34 @@ function mountHouseEditor(container, callbacks) { // tegelvloer ligt op het maaiveld, dus het ingegraven deel van vloer en // sandwichpanelen verdwijnt eronder. let lightsGroup = null; + // buildLights3D maakt per lamp eigen wash-materialen; de textuur is gedeeld (material.dispose laat die staan). + function disposeLights() { + if (!lightsGroup) return; + scene.remove(lightsGroup); + lightsGroup.traverse(o => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); if (o.isLight && o.dispose) o.dispose(); }); + lightsGroup = null; + } function applyRenderMode() { const lampsOn = fullRender || skyMode === 'night'; scene.background = new THREE.Color(0x1a1a1a); Skybox.apply(skyMode, { env: skyEnv, controls, sun: dirLight, ambient, base: { ambient: 0.6, sun: 0.8 }, shadowRoot: houseGroup }); ground.visible = fullRender && skyMode === 'none'; // zonder omgeving: vlak waarop het lamplicht valt grid.visible = skyMode === 'none' && !fullRender; - if (lightsGroup) { scene.remove(lightsGroup); lightsGroup = null; } + disposeLights(); // De nep-lichtbundels (kegels) alleen zonder echte lampen. if (parts && parts.lightingGroup) parts.lightingGroup.traverse(o => { if (o.userData.beam) o.visible = !lampsOn; }); - if (lampsOn && parts && lampenVisible && !vloerplan) { - const edge = dakVisible ? parts.roofEdge : null; + if (lampsOn && parts && view3D.lampen && !view3D.section && BuildStages.shows(view3D.stage, 'afwerking')) { + const edge = view3D.dak ? parts.roofEdge : null; lightsGroup = LightingModel.buildLights3D(parts.lighting.lamps, edge ? edge.spots : [], edge ? edge.top - edge.fasciaHeight : 0, parts.lighting.indoor, { - outdoorFactor: skyMode === 'night' ? 1 : LightingModel.DAY_OUTDOOR_FACTOR + outdoorFactor: skyMode === 'night' ? 1 : LightingModel.DAY_OUTDOOR_FACTOR, + // Nep-lichtvlekken op de gevel (wall-wash), tot het maaiveld. + walls: parts.units.flatMap(u => u.walls.flatMap(w => w.pieces)), + groundY: Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel }); scene.add(lightsGroup); } } - Skybox.createToggle(document.getElementById('ho-toolbar-3d'), (m) => { skyMode = m; applyRenderMode(); }); - document.getElementById('ho-btn-render').addEventListener('click', (e) => { - fullRender = !fullRender; - e.currentTarget.classList.toggle('active', fullRender); - applyRenderMode(); - }); + // (Omgeving, Render en Tonen: ribbon Beeld, zie ribbonTabs.) // --- Plattegrond: view-transform + hit-tests --- let parts = null; @@ -433,7 +480,23 @@ function mountHouseEditor(container, callbacks) { return Math.abs(lx) <= place.width / 2 + tol && Math.abs(lz) <= place.thickness / 2 + tol; } + // Verplaatsbaar elektra-onderdeel (ElektraModel items) onder de cursor. + function elektraHit(x, z) { + const el = parts && parts.elektra; + if (!el || !el.enabled || !view3D.elektra) return null; + const tol = 10 / view.scale; + let best = null; + el.items.forEach(it => { + const d = Math.hypot(it.marker.x - x, it.marker.z - z); + if (d < tol && (!best || d < best.d)) best = { d, it }; + }); + return best ? best.it : null; + } + const elektraItem = (key) => parts && parts.elektra && parts.elektra.items.find(it => it.key === key); + function hitTest(x, z) { + const eh = elektraHit(x, z); + if (eh) return { type: 'elektra', id: eh.key }; const iw = parts.interior.find(p => pieceHit(p.place, x, z, 4)); if (iw) return { type: 'iw', id: iw.id }; for (const u of parts.units) { @@ -447,6 +510,13 @@ function mountHouseEditor(container, callbacks) { } const sameSel = (a, b) => a && b && a.type === b.type && (a.id === b.id) && (a.element === b.element); + // Selectie wijzigen (plattegrond of boom): inspector naar Eigenschappen. + function select(sel) { + selection = sel; + syncRibbon(true); + drawPlan(); + renderNav(parts); + } // --- Tekenen van binnenwanden --- let tool = 'select'; @@ -456,14 +526,11 @@ function mountHouseEditor(container, callbacks) { function setTool(t) { tool = t; drawStart = null; - document.getElementById('ho-tool-select').classList.toggle('active', t === 'select'); - document.getElementById('ho-tool-iw').classList.toggle('active', t === 'iw'); + document.querySelectorAll('[data-ho-tool]').forEach(b => b.classList.toggle('active', b.dataset.hoTool === t)); canvas.style.cursor = t === 'iw' ? 'crosshair' : 'default'; updateHint(); drawPlan(); } - document.getElementById('ho-tool-select').addEventListener('click', () => setTool('select')); - document.getElementById('ho-tool-iw').addEventListener('click', () => setTool('iw')); function updateHint() { document.getElementById('ho-hint').textContent = tool === 'iw' @@ -498,6 +565,39 @@ function mountHouseEditor(container, callbacks) { refresh(); } + // Elektra slepen: langs de wanden (ElektraModel.snapToWall); bij loslaten + // vastleggen als handmatige plek (settings.elektra.place). + let elDrag = null, suppressClick = false; + canvas.addEventListener('mousedown', (e) => { + if (tool !== 'select' || !parts || e.button !== 0) return; + const r = canvas.getBoundingClientRect(); + const w = toWorld(e.clientX - r.left, e.clientY - r.top); + const it = elektraHit(w.x, w.z); + if (it) elDrag = { key: it.key, sx: e.clientX, sy: e.clientY, moved: false, snap: null }; + }); + canvas.addEventListener('mousemove', (e) => { + if (!elDrag) return; + if (!elDrag.moved && Math.hypot(e.clientX - elDrag.sx, e.clientY - elDrag.sy) < 4) return; + elDrag.moved = true; + canvas.style.cursor = 'grabbing'; + const r = canvas.getBoundingClientRect(); + const w = toWorld(e.clientX - r.left, e.clientY - r.top); + elDrag.snap = ElektraModel.snapToWall(parts.elektra, w.x, w.z); + drawPlan(); + }); + function onElektraDrop() { + if (!elDrag) return; + const d = elDrag; + elDrag = null; + canvas.style.cursor = tool === 'iw' ? 'crosshair' : 'default'; + if (!d.moved || !d.snap) return; + suppressClick = true; + const pl = state.settings.elektra.place, prev = pl[d.key] || {}; + pl[d.key] = { element: d.snap.element, side: d.snap.side, u: d.snap.u, y: prev.y }; + selection = { type: 'elektra', id: d.key }; + refresh(); + } + window.addEventListener('mouseup', onElektraDrop); canvas.addEventListener('mousemove', (e) => { if (tool !== 'iw' || !parts) return; const r = canvas.getBoundingClientRect(); @@ -517,8 +617,9 @@ function mountHouseEditor(container, callbacks) { drawPlan(); return; } + if (suppressClick) { suppressClick = false; return; } const hit = hitTest(w.x, w.z); - if (!sameSel(hit, selection)) { selection = hit; renderProps(); drawPlan(); renderNav(); } + if (!sameSel(hit, selection)) select(hit); }); canvas.addEventListener('dblclick', (e) => { if (tool !== 'select' || !parts) return; @@ -671,6 +772,80 @@ function mountHouseEditor(container, callbacks) { ctx.strokeRect(q.x - 3.5, q.y - 3.5, 7, 7); }); + // Elektra (ElektraModel): leidingen, meterkast, schakelaars met letter, + // wcd met groepnummer en bij elke lamp de letter van zijn schakelaar. + const el = parts.elektra; + if (el && el.enabled && view3D.elektra) { + const RC = ElektraPdf.RUN_COLORS, rgb = (c, a) => `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a})`; + const inward = (q, n, d) => toPx(q.x + n.x * d, q.z + n.z * d); + ctx.save(); + ctx.lineWidth = 1.2; ctx.setLineDash([4, 3]); + el.runs.forEach(r => { + ctx.strokeStyle = rgb(RC[r.type] || RC.schakel, 0.8); + ctx.beginPath(); + r.path.forEach((q, i) => { const p = toPx(q.x, q.z); if (i) ctx.lineTo(p.x, p.y); else ctx.moveTo(p.x, p.y); }); + ctx.stroke(); + }); + ctx.restore(); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + if (el.meterkast) { + const k = el.meterkast; + polyPath(k.corners); + ctx.fillStyle = '#5a5a5a'; ctx.fill(); + ctx.strokeStyle = '#eee'; ctx.lineWidth = 1; ctx.stroke(); + const c = inward({ x: (k.corners[0].x + k.corners[2].x) / 2, z: (k.corners[0].z + k.corners[2].z) / 2 }, k.face.n, 380); + ctx.fillStyle = '#eee'; ctx.font = 'bold 10px sans-serif'; + ctx.fillText('MK', c.x, c.y); + } + ctx.strokeStyle = rgb(RC.schakel, 1); ctx.lineWidth = 1; + el.boxes.forEach(b => { const q = toPx(b.x, b.z); ctx.strokeRect(q.x - 3, q.y - 3, 6, 6); }); + ctx.font = 'bold 10px sans-serif'; + el.combos.forEach(c => { + const q = inward(c.pos, c.face.n, 260), text = c.switches.map(x => x.letter).join(' '), w = 8 + text.length * 6; + ctx.fillStyle = '#fff'; ctx.fillRect(q.x - w / 2, q.y - 7, w, 14); + ctx.strokeStyle = '#1a1a1a'; ctx.strokeRect(q.x - w / 2, q.y - 7, w, 14); + ctx.fillStyle = '#1a1a1a'; ctx.fillText(text, q.x, q.y + 0.5); + }); + ctx.font = '9px sans-serif'; + el.wcds.forEach(w => { + const q = inward(w.pos, w.face.n, 120), t = inward(w.pos, w.face.n, 420); + ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.arc(q.x, q.y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + ctx.fillStyle = rgb(RC.wcd, 1); ctx.fillText(String(w.group), t.x, t.y); + }); + ctx.font = 'bold 8px sans-serif'; + el.apps.forEach(a => { + const it = el.items.find(x => x.key === a.key), q = toPx(it.marker.x, it.marker.z), t = inward(a.pos, a.face.n, 480); + ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5; + ctx.fillRect(q.x - 9, q.y - 6, 18, 12); ctx.strokeRect(q.x - 9, q.y - 6, 18, 12); + ctx.fillStyle = rgb(RC.wcd, 1); ctx.fillText(a.code, q.x, q.y + 0.5); + ctx.font = '9px sans-serif'; ctx.fillText(String(a.group), t.x, t.y); ctx.font = 'bold 8px sans-serif'; + }); + ctx.fillStyle = '#ffe9a8'; ctx.font = '9px sans-serif'; + el.switches.forEach(sw => sw.lamps.forEach(l => { const q = toPx(l.x, l.z); ctx.fillText(sw.letter, q.x + 7, q.y - 6); })); + // Geselecteerd onderdeel (ring; gestippeld = handmatig geplaatst) en sleep-voorbeeld. + const selIt = selection && selection.type === 'elektra' && el.items.find(x => x.key === selection.id); + if (selIt) { + const q = toPx(selIt.marker.x, selIt.marker.z); + ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(q.x, q.y, 13, 0, Math.PI * 2); ctx.stroke(); + } + el.items.filter(x => x.manual).forEach(x => { + const q = toPx(x.marker.x, x.marker.z); + ctx.fillStyle = '#4da6ff'; ctx.beginPath(); ctx.arc(q.x + 10, q.y - 9, 2.5, 0, Math.PI * 2); ctx.fill(); + }); + if (elDrag && elDrag.snap) { + const sn = elDrag.snap, a = toPx(sn.x, sn.z), b = toPx(sn.x + sn.n.x * 200, sn.z + sn.n.z * 200); + ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + ctx.beginPath(); ctx.arc(b.x, b.y, 7, 0, Math.PI * 2); ctx.stroke(); + ctx.fillStyle = '#4da6ff'; ctx.textAlign = 'left'; + ctx.fillText(`${sn.element} · ${sn.u} mm`, b.x + 10, b.y - 8); + ctx.textAlign = 'center'; + } + ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; + } + // Noordpijl (boven = noord; wanden/zijden heten naar windrichting) ctx.save(); ctx.translate(w - 34, 78); @@ -702,308 +877,705 @@ function mountHouseEditor(container, callbacks) { } } - // --- Eigenschappenpaneel (contextafhankelijk, opgebouwd met UI-kit) --- - const { num, sel, toggle, section, info, button, actions } = UI; - const cols = UI.grid; // (grid = de 3D-rasterhulp) - UI.wireTabs(propsEl); - const head = (iconName, title, sub) => - `

${UI.icon(iconName)}${title}

${sub ? `${sub}` : ''}
`; - const after = () => refresh({ keepProps: true }); // getal: panelen blijven staan - const rerender = () => refresh(); // keuze: velden kunnen wijzigen - - function renderProps() { - const warn = (parts.overlaps.length ? UI.warn(`Units overlappen elkaar: ${parts.overlaps.map(p => p.join(' + ')).join(', ')}.`) : '') + - parts.rects.filter(r => r.error).map(r => UI.warn(`Unit ${r.id}: ${r.error}`)).join(''); - - if (selection && selection.type === 'unit' && unitById(selection.id)) renderUnitProps(unitById(selection.id), warn); - else if (selection && selection.type === 'wall') renderWallProps(warn); - else if (selection && selection.type === 'iw' && iwById(selection.id)) renderIwProps(iwById(selection.id), warn); - else renderHouseProps(warn); + // ------------------------------------------------------------ + // RIBBON (core/ribbon.js): alle bediening van het overzicht. Vaste + // tabbladen voor de woning, plus een contextueel tabblad voor de + // selectie (unit, wandstuk, binnenwand/naadwand). + // ------------------------------------------------------------ + const { num, sel, toggle } = UI; + const { big, small, kv } = Ribbon; + const after = () => scheduleRefresh({ keepProps: true }); // getal/schuif: ribbon blijft staan + const rerender = () => refresh(); // keuze: velden kunnen wijzigen + // Afgeleide waarden in de ribbon die na een verversing meelopen + // (bijv. de dakhelling), zonder het tabblad opnieuw op te bouwen. + let live = []; + function updateLive() { live.forEach(fn => fn()); } + const note = (text, isWarn) => `
${text}
`; + const pair = (...items) => `
${items.join('')}
`; + const thumb = (url, alt) => url ? `${alt}` : ''; + const check = (id, label, checked) => ``; + const SIDE_SHORT = { voor: 'Noord', achter: 'Zuid', links: 'West', rechts: 'Oost' }; + const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ','); + // Keuzes voor de vormgeving van het trasraam: plaat of een steen. + const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` }))); + const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions); + // Kliks op knoppen met data-ho-act / data-go / data-ho-tool (per tekening opnieuw). + function wireButtons(body) { + body.querySelectorAll('[data-ho-tool]').forEach(b => b.addEventListener('click', () => setTool(b.dataset.hoTool))); + body.querySelectorAll('[data-go]').forEach(b => b.addEventListener('click', () => showPart(b.dataset.go))); + body.querySelectorAll('[data-ho-act]').forEach(b => b.addEventListener('click', () => { + const a = b.dataset.hoAct; + if (a === 'add-unit') addUnit(); + else if (a === 'project') openProjectProperties(); + else if (a === 'save') exportDesign(); + else if (a === 'costs') CostSettings.open(); + else if (a === 'add-seams') { HouseModel.ensureSeamWalls(state, b.dataset.unit); refresh(); } + else if (a === 'remove-unit') removeUnit(b.dataset.unit); + else if (a === 'remove-iw') { + state.interiorWalls = state.interiorWalls.filter(w => w.id !== Number(b.dataset.iw)); + selection = null; + refresh(); + } + })); } - function renderUnitProps(u, warn) { + // --- Vaste tabbladen --- + const startTab = { + key: 'start', label: 'Start', order: 10, + groups: () => [ + { label: 'Gereedschap', html: + big('Selecteren', 'cursor', `data-ho-tool="select" title="Klik = selecteren, dubbelklik = bewerken (Esc)"`, tool === 'select' ? 'active' : '') + + big('Binnenwand tekenen', 'pencil', `data-ho-tool="iw" title="Twee klikken in de plattegrond; snapt op 50 mm, haaks en op wandvlakken"`, tool === 'iw' ? 'active' : '') }, + { label: 'Woning', html: big('Unit toevoegen', 'plus-square', 'data-ho-act="add-unit" title="Aan de geselecteerde (of laatste) unit"') + big('Project', 'card-text', 'data-ho-act="project"') }, + { label: 'Overzicht', html: `
` }, + { label: 'Werkwijze', html: note('Selecteer een unit, wand of binnenwand in de plattegrond of de structuur: het tabblad van die selectie verschijnt rechts. Dubbelklik om te bewerken. Oranje gestippeld = naad tussen units.') } + ], + wire: (body) => { + live = []; + wireButtons(body); + const dimsLive = () => { + const el = body.querySelector('[data-live="start-dims"]'); + if (!el || !parts) return; + const d = HouseModel.computeDimensions(parts, state.settings.groundLevel), f = houseFmt; + el.innerHTML = kv('Buitenmaten', `${f(d.lengthX / 1000, 2)} × ${f(d.lengthZ / 1000, 2)}`, 'm') + kv('Bruto vloer', f(d.brutoM2, 1), 'm²') + + kv('Bouwhoogte', f(d.height / 1000, 2), 'm') + kv('Leefbaar', f(d.leefbaarM2, 1), 'm²') + + kv('Gevel netto', f(d.gevelNettoM2, 1), 'm²') + kv('Inhoud', f(d.inhoudM3, 0), 'm³'); + }; + live.push(dimsLive); + dimsLive(); + } + }; + + const gevelTab = { + key: 'gevel', label: 'Gevel', order: 20, + groups: () => { + const st = state.settings; + const rabatColors = Object.fromEntries(Object.entries(WallModel.RABAT_COLORS).map(([k, c]) => [k, c.label])); + return [ + { label: 'Bekleding', html: sel('hs-facade', 'Type', HouseModel.FACADE_TYPES, st.facade.type, { hint: 'Voor alle buitenwanden; per wand aan te passen (selecteer de wand)' }) + + (st.facade.type === 'steen' + ? pair(sel('hs-facade-stone', 'Steen', stoneOptions, st.facade.stone, { hint: `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik` }), thumb(WallModel.stoneThumbUrl(st.facade.stone, 64), st.facade.stone)) + : sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'Rabat op vochtdoek en kruislat' }) + + pair(sel('hs-rabat-color', 'Kleur', rabatColors, st.facade.rabatColor, { hint: 'Ook voor rabat-overrides per wand' }), + `
`)) }, + { label: 'Trasraam', html: toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Strook onderaan de gevel, onder rabat én steen' }) + + (st.trasraam.enabled + ? UI.range('hs-trasraam-h', 'Hoogte', st.trasraam.height, { min: 100, max: 1000, step: 10, hint: 'Vanaf bovenkant vloer' }) + + pair(sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish), + WallModel.isStoneFinish(st.trasraam.finish) ? thumb(WallModel.stoneThumbUrl(st.trasraam.finish, 64), st.trasraam.finish) : '') + : '') }, + { label: 'Onderzijde', html: toggle('hs-sandwich', 'Sandwichpanelen', st.sandwich, + { hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm rondom, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` }) + + UI.range('hs-ground', 'Maaiveld', st.groundLevel, { min: 0, max: 800, step: 10, hint: 'Onder de bovenkant vloer; tegelvloer in de 3D bij Dag/Nacht' }) }, + { label: 'Per wand', html: note('Een eigen gevel voor één zijde van een unit: selecteer die wand in de plattegrond.') } + ]; + }, + wire: (body) => { + live = []; + const st = state.settings; + UI.onSel('hs-facade', v => { st.facade.type = v; }, rerender); + UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender); + UI.onSel('hs-rabat-color', v => { st.facade.rabatColor = v; }, rerender); + UI.onSel('hs-facade-stone', v => { st.facade.stone = v; }, rerender); + UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender); + UI.onRange('hs-trasraam-h', v => { if (v > 0) st.trasraam.height = v; }, after); + UI.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender); + UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender); + UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after); + } + }; + + const dakTab = { + key: 'dak', label: 'Dak', order: 30, + groups: () => { + const r = state.settings.roof; + return [ + { label: 'Boeideel', html: + UI.range('hs-overhang', 'Overstek', r.overhang, { min: RoofModel.OVERHANG_MIN, max: RoofModel.OVERHANG_MAX, step: 10, hint: 'Vanaf de gevel' }) + + UI.range('hs-fascia-h', 'Hoogte', r.fasciaHeight, { min: 150, max: 500, step: 10 }) + + sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) }, + { label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) }, + { label: 'Daken per unit', html: `
` }, + { label: 'Dakvorm', html: note('Vorm, hoogtes, helling en bedekking zijn per unit: selecteer een unit.') } + ]; + }, + wire: (body) => { + live = []; + const r = state.settings.roof, pos = { positive: true }; + UI.onRange('hs-overhang', v => { if (v > 0) r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after); + UI.onRange('hs-fascia-h', v => { if (v > 0) r.fasciaHeight = v; }, after); + UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender); + UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos); + const list = () => { + const el = body.querySelector('[data-live="roof-list"]'); + if (!el || !parts) return; + el.innerHTML = parts.units.map(u => { + const pl = u.roof.planes[0], deg = Math.abs(pl ? pl.theta : 0) * 180 / Math.PI; + const cov = (RoofModel.COVERINGS[u.roof.covering] || {}).label || ''; + return kv(`Unit ${u.id}`, `${(UnitModel.ROOF_TYPES[u.roof.shape.type] || u.roof.shape.type).toLowerCase()}${deg > 0.05 ? ' ' + fmtDeg(deg) + '°' : ''} · ${cov.toLowerCase()}`); + }).join(''); + }; + live.push(list); + list(); + } + }; + + const binnenTab = { + key: 'binnen', label: 'Binnen', order: 40, + groups: () => { + const st = state.settings; + return [ + { label: 'Wanden', html: toggle('hs-int-paint', 'Geschilderd', st.interiorPaint, { hint: 'Alle wanden aan de kamerzijde; egaal, zonder plaatnaden. Uit: kale spaanplaat.' }) + + (st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning' }) : '') }, + { label: 'Vloer', html: pair(sel('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, fi]) => [k, fi.label])), st.floorFinish), + thumb(FloorModel.finishThumbUrl(st.floorFinish, 64), st.floorFinish)) }, + { label: 'Binnenwanden', html: big('Binnenwand tekenen', 'pencil', 'data-ho-tool="iw"') + note(`${state.interiorWalls.length} binnen-/naadwand(en). Selecteer er een voor positie, dikte en openingen.`) } + ]; + }, + wire: (body) => { + live = []; + wireButtons(body); + const st = state.settings; + UI.onToggle('hs-int-paint', v => { st.interiorPaint = v; }, rerender); + UI.onColor('hs-int-color', v => { st.interiorColor = v; }, after); + UI.onSel('hs-floor-finish', v => { st.floorFinish = v; }, rerender); + } + }; + + const constructieTab = { + key: 'constructie', label: 'Constructie', order: 45, + groups: () => { + const sp = state.settings.spacing; + const bad = sp.exterior.max < 2 * sp.exterior.min || sp.interior.max < 2 * sp.interior.min; + return [ + { label: 'Stijlen buitenwanden', html: num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart' }) }, + { label: 'Stijlen binnenwanden', html: num('hs-int-min', 'Afstand min.', sp.interior.min, { step: 10, hint: 'Hart-op-hart' }) + num('hs-int-max', 'Afstand max.', sp.interior.max, { step: 10, hint: 'Hart-op-hart' }) }, + { label: 'Verdeling', html: note(bad + ? 'Max. is kleiner dan 2 × min.: niet elk vak is dan te verdelen; daar wijkt de verdeling af van min. of max.' + : 'Elk vak wordt gelijk verdeeld met een h.o.h. tussen min. en max. (max. ≥ 2 × min.: altijd mogelijk).', bad) }, + { label: 'Balken', html: kv('Wandbalk', `${WallModel.STANDARD_BEAM.width} × ${WallModel.STANDARD_BEAM.depth}`, 'mm') + + kv('Handelslengtes', WallModel.STOCK_LENGTHS_MM.map(l => l / 1000).join(' · ').replace(/\./g, ','), 'm') + + kv('Latwerk', `${WallModel.BATTEN.thick} × ${WallModel.BATTEN.width}, ${WallModel.BATTEN.spacing} h.o.h.`, 'mm') } + ]; + }, + wire: () => { + live = []; + const sp = state.settings.spacing, pos = { positive: true }; + UI.onNum('hs-ext-min', v => { sp.exterior.min = v; }, after, pos); + UI.onNum('hs-ext-max', v => { sp.exterior.max = v; }, after, pos); + UI.onNum('hs-int-min', v => { sp.interior.min = v; }, after, pos); + UI.onNum('hs-int-max', v => { sp.interior.max = v; }, after, pos); + } + }; + + const lichtTab = { + key: 'licht', label: 'Verlichting', order: 50, + groups: () => { + const r = state.settings.roof, lt = state.settings.lighting; + const groups = [{ label: 'Buiten', html: sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode) + + (lt.mode === 'gevel' ? sel('hs-light-dir', 'Richting', LightingModel.DIRECTIONS, lt.direction) : '') }]; + if (lt.mode === 'gevel') { + groups.push({ label: 'Gevellampen', html: UI.range('hs-lamp-h', 'Hoogte', lt.lampHeight, { min: 1200, max: 3000, step: 50, hint: 'Vanaf bovenkant vloer' }) + + num('hs-lamp-min', 'Afstand min.', lt.lampMin, { hint: 'Hart-op-hart' }) + num('hs-lamp-max', 'Afstand max.', lt.lampMax, { hint: 'Hart-op-hart' }) }); + groups.push({ label: 'Plaatsing', html: num('hs-lamp-w', 'Min. gevel', lt.minWallWidth, { hint: 'Smallere vrije stukken gevel krijgen geen lamp' }) + + num('hs-lamp-end', 'Tot hoek', lt.endOffset) + num('hs-lamp-open', 'Tot opening', lt.openingOffset, { hint: 'Voor wanden met de instelling "Naast openingen" (selecteer de wand)' }) }); + } else if (lt.mode === 'spots') { + groups.push({ label: 'Boeideel-spots', html: num('hs-spot-min', 'Afstand min.', r.spotMin, { hint: 'Hart-op-hart' }) + num('hs-spot-max', 'Afstand max.', r.spotMax, { hint: 'Hart-op-hart' }) + + note('Alleen onder units met boeidelen.') }); + } + groups.push({ label: 'Binnenspots', html: toggle('hs-indoor', 'In plafond', lt.indoor, { hint: 'Per unit verdeeld in het plafond' }) + + (lt.indoor ? num('hs-indoor-min', 'Afstand min.', lt.indoorMin, { hint: 'Hart-op-hart, in beide richtingen' }) + + num('hs-indoor-max', 'Afstand max.', lt.indoorMax, { hint: 'Hart-op-hart, in beide richtingen' }) + + num('hs-indoor-off', 'Tot wand', lt.indoorOffset, { hint: 'Tot de binnenzijde van een wand, of tot de unitrand bij een naad' }) : '') }); + groups.push({ label: 'Aantallen', html: `
` }); + return groups; + }, + wire: (body) => { + live = []; + const r = state.settings.roof, lt = state.settings.lighting, pos = { positive: true }; + UI.onSel('hs-light-mode', v => { lt.mode = v; }, rerender); + UI.onSel('hs-light-dir', v => { lt.direction = v; }, rerender); + UI.onRange('hs-lamp-h', v => { if (v > 0) lt.lampHeight = v; }, after); + UI.onNum('hs-lamp-min', v => { lt.lampMin = v; }, after, pos); + UI.onNum('hs-lamp-max', v => { lt.lampMax = v; }, after, pos); + UI.onNum('hs-lamp-w', v => { lt.minWallWidth = v; }, after, pos); + UI.onNum('hs-lamp-end', v => { lt.endOffset = v; }, after, pos); + UI.onNum('hs-lamp-open', v => { lt.openingOffset = v; }, after, pos); + UI.onNum('hs-spot-min', v => { r.spotMin = v; }, after, pos); + UI.onNum('hs-spot-max', v => { r.spotMax = v; }, after, pos); + UI.onToggle('hs-indoor', v => { lt.indoor = v; }, rerender); + UI.onNum('hs-indoor-min', v => { lt.indoorMin = v; }, after, pos); + UI.onNum('hs-indoor-max', v => { lt.indoorMax = v; }, after, pos); + UI.onNum('hs-indoor-off', v => { lt.indoorOffset = v; }, after); + const count = () => { + const el = body.querySelector('[data-live="light-count"]'); + if (!el || !parts) return; + el.innerHTML = kv('Gevellampen', parts.lighting.lamps.length, 'st.') + + kv('Boeideel-spots', parts.roofEdge ? parts.roofEdge.spots.length : 0, 'st.') + kv('Binnenspots', (parts.lighting.indoor || []).length, 'st.'); + }; + live.push(count); + count(); + } + }; + + const elektraTab = { + key: 'elektra', label: 'Elektra', order: 55, + groups: () => { + const e = state.settings.elektra; + const groups = [{ label: 'Elektra', html: toggle('he-on', 'Installatie', e.enabled, { hint: 'Meterkast, schakelaars, wcd en leidingen (automatisch, eerste versie)' }) }]; + if (e.enabled) { + groups.push({ label: 'Meterkast', html: num('he-mk-w', 'Breedte', e.meterkast.w, { step: 10 }) + num('he-mk-h', 'Hoogte', e.meterkast.h, { step: 10 }) + + num('he-mk-d', 'Diepte', e.meterkast.d, { step: 10 }) + num('he-mk-y', 'Onderkant', e.meterkast.y, { step: 10, hint: 'Vanaf bovenkant vloer; zo dicht mogelijk bij de voordeur' }) }); + groups.push({ label: 'Punten', html: num('he-sw-h', 'Schakelaar', e.switchHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + + num('he-wcd-h', 'Wcd', e.wcdHeight, { step: 10, hint: 'Hart, vanaf bovenkant vloer' }) + num('he-wcd-n', 'Wcd per unit', e.wcdPerUnit, { step: 1, hint: 'Dubbele wcd' }) }); + groups.push({ label: 'Apparaten', html: Object.entries(ElektraModel.APPLIANCES).map(([k, A]) => + check(`he-app-${k}`, `${A.label} (${A.code})`, !!e.apparaten[k])).join('') }); + const nManual = Object.keys(e.place).length; + groups.push({ label: 'Plaatsing', html: note('Sleep meterkast, schakelaars, wcd en apparaten in de plattegrond; selecteer er een voor positie en hoogte.') + + (nManual ? big('Alles automatisch', 'arrow-counterclockwise', `data-he-reset title="${nManual} handmatige plek(ken) wissen"`) : '') }); + groups.push({ label: 'Leidingen', html: num('he-slack', 'Marge %', e.slack, { step: 1 }) + num('he-res', 'Reserve', e.reserve, { step: 50, hint: 'Per leiding (lussen in de dozen), mm' }) + + num('he-mk-res', 'Extra MK', e.meterkastReserve, { step: 50, hint: 'Per leiding vanuit de meterkast, mm' }) }); + groups.push({ label: 'Strekkende meters', html: `
` }); + groups.push({ label: 'Groepen', html: `
` }); + } + return groups; + }, + wire: (body) => { + live = []; + const e = state.settings.elektra, pos = { positive: true }; + UI.onToggle('he-on', v => { e.enabled = v; }, rerender); + UI.onNum('he-mk-w', v => { e.meterkast.w = v; }, after, pos); + UI.onNum('he-mk-h', v => { e.meterkast.h = v; }, after, pos); + UI.onNum('he-mk-d', v => { e.meterkast.d = v; }, after, pos); + UI.onNum('he-mk-y', v => { e.meterkast.y = v; }, after); + UI.onNum('he-sw-h', v => { e.switchHeight = v; }, after, pos); + UI.onNum('he-wcd-h', v => { e.wcdHeight = v; }, after, pos); + UI.onNum('he-wcd-n', v => { e.wcdPerUnit = Math.max(0, Math.round(v)); }, after); + UI.onNum('he-slack', v => { e.slack = v; }, after); + UI.onNum('he-res', v => { e.reserve = v; }, after); + UI.onNum('he-mk-res', v => { e.meterkastReserve = v; }, after); + Object.keys(ElektraModel.APPLIANCES).forEach(k => { + const cb = document.getElementById(`he-app-${k}`); + if (cb) cb.addEventListener('change', () => { e.apparaten[k] = cb.checked; refresh(); }); + }); + const rs = body.querySelector('[data-he-reset]'); + if (rs) rs.addEventListener('click', () => { e.place = {}; refresh(); }); + const show = () => { + const el = parts && parts.elektra; + const c = body.querySelector('[data-live="el-cables"]'), g = body.querySelector('[data-live="el-groups"]'); + if (!el) return; + if (c) c.innerHTML = el.cables.map(x => kv(x.use, houseFmt(x.meters, 1), 'm')).join('') || note('Geen leidingen.'); + if (g) g.innerHTML = el.groups.map(x => kv(`${x.nr} ${x.phase} AL${x.rcd}`, `${x.label} (${x.breaker})`)).join('') + el.warnings.map(w => note(w, true)).join(''); + }; + live.push(show); + show(); + } + }; + + const rapportTab = { + key: 'rapport', label: 'Rapport', order: 60, + groups: () => [ + { label: 'Rapport', html: note('Transport per unit, gewicht, afmetingen, kosten, hout en plaatwerk. Staat in plaats van de plattegrond zolang dit tabblad open is en loopt mee met elke wijziging.') }, + { label: 'Uitvoer', html: big('Opslaan + PDF', 'file-earmark-pdf', 'data-ho-act="save" title="JSON en de volledige PDF (Ctrl+S)"') }, + { label: 'Instellingen', html: big('Kosten & tijden', 'calculator', 'data-ho-act="costs"') } + ], + wire: (body) => { live = []; wireButtons(body); } + }; + + const beeldTab = { + key: 'beeld', label: 'Beeld', order: 70, + groups: () => [ + { label: 'Tonen', html: check('hv-dak', 'Dak', view3D.dak) + check('hv-lampen', 'Lampen', view3D.lampen) + check('hv-elektra', 'Elektra', view3D.elektra) }, + { label: 'Doorsnede', html: check('hv-section', 'Doorsnede', view3D.section) + + UI.range('hv-section-h', 'Hoogte', view3D.sectionMM, { min: 100, max: 4000, step: 50, hint: 'Snijhoogte boven de bovenkant vloer' }) }, + { label: 'Omgeving', html: `
${Skybox.MODES.map(m => small(Skybox.LABELS[m][1], Skybox.LABELS[m][0], `data-sky="${m}"`, m === skyMode ? 'active' : '')).join('')}
` + + small('Render', 'camera', 'data-render title="Elke lamp en spot als echte lichtbron"', fullRender ? 'active' : '') } + ], + wire: (body) => { + live = []; + const onCheck = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('change', () => { fn(el.checked); scheduleRefresh({ keepProps: true }); }); }; + onCheck('hv-dak', v => { view3D.dak = v; }); + onCheck('hv-lampen', v => { view3D.lampen = v; }); + onCheck('hv-elektra', v => { view3D.elektra = v; }); + onCheck('hv-section', v => { view3D.section = v; syncSectionRow(); }); + UI.onRange('hv-section-h', setSectionMM); + body.querySelectorAll('[data-sky]').forEach(b => b.addEventListener('click', () => { + skyMode = b.dataset.sky; + Skybox.setMode(skyMode); + body.querySelectorAll('[data-sky]').forEach(x => x.classList.toggle('active', x === b)); + applyRenderMode(); + })); + const rb = body.querySelector('[data-render]'); + if (rb) rb.addEventListener('click', () => { fullRender = !fullRender; rb.classList.toggle('active', fullRender); applyRenderMode(); }); + } + }; + + // --- Contextueel tabblad: de selectie --- + function contextTab() { + if (!selection) return null; + if (selection.type === 'unit' && unitById(selection.id)) return unitTab(unitById(selection.id)); + if (selection.type === 'wall') return wallTab(); + if (selection.type === 'iw' && iwById(selection.id)) return iwById(selection.id).seam ? seamTab(iwById(selection.id)) : iwTab(iwById(selection.id)); + if (selection.type === 'elektra' && elektraItem(selection.id)) return elektraItemTab(elektraItem(selection.id)); + return null; + } + + // Geselecteerd elektra-onderdeel: plek langs de wand en hoogte; slepen in + // de plattegrond of hier invullen maakt de plek handmatig. + function elektraItemTab(it) { + const e = state.settings.elektra; + const setPlace = (patch) => { + const cur = e.place[it.key] || { element: it.element, side: it.side, u: Math.round(it.u) }; + e.place[it.key] = Object.assign({}, cur, patch); + }; + return { + key: 'sel', label: it.label, icon: 'plug', contextual: true, order: 100, + groups: () => [ + { label: 'Onderdeel', html: kv('Wand', it.element + (it.side > 0 ? ' (andere zijde)' : '')) + kv('Unit', it.unitId) + kv('Plaatsing', it.manual ? 'handmatig' : 'automatisch') }, + { label: 'Plaats', html: num('he-it-u', 'Langs wand', Math.round(it.u), { step: 10, hint: 'Hart, vanaf de linkerkant van de wand (buitenaanzicht)' }) + + num('he-it-y', it.kind === 'mk' ? 'Onderkant' : 'Hoogte', Math.round(it.y), { step: 10, hint: it.kind === 'mk' ? 'Vanaf bovenkant vloer' : 'Hart, vanaf bovenkant vloer' }) }, + { label: 'Verplaatsen', html: note('Sleep het onderdeel in de plattegrond naar een andere wand of plek.') + + (it.manual ? big('Automatisch', 'arrow-counterclockwise', 'data-he-auto title="Weer automatisch plaatsen"') : '') } + ], + wire: (body) => { + live = []; + UI.onNum('he-it-u', v => setPlace({ u: v }), after); + UI.onNum('he-it-y', v => setPlace({ y: v }), after); + const b = body.querySelector('[data-he-auto]'); + if (b) b.addEventListener('click', () => { delete e.place[it.key]; refresh(); }); + } + }; + } + + function unitTab(u) { const isRoot = state.units[0].id === u.id; - const a = u.attach; - const axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z'; const f = u.unit.floor.floor; - const koppeling = isRoot - ? info('Basis-unit: de noordwesthoek ligt op (0, 0). Andere units koppelen hieraan.') - : sel('hp-att-to', 'Gekoppeld aan', Object.fromEntries(state.units.filter(o => o.id !== u.id).map(o => [o.id, 'Unit ' + o.id])), a.to) + - sel('hp-att-side', 'Zijde', HouseModel.SIDE_LABELS, a.side) + - sel('hp-att-align', 'Uitlijning', HouseModel.ALIGN_LABELS[axis], a.align) + - num('hp-att-offset', 'Verschuiving', a.offset || 0); - // Dakbedekking per unit; riet alleen bij een hellend dak. - const pu = parts && parts.units.find(x => x.id === u.id); - const shapeType = pu ? pu.roof.shape.type : 'plat'; - const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS) - .filter(([, c]) => !c.sloped || shapeType !== 'plat').map(([k, c]) => [k, c.label])); - const covInfo = (k) => { const c = RoofModel.COVERINGS[k]; return `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²`; }; - const covNow = RoofModel.coveringFor(u.roof.covering, shapeType); - const bedekking = thumbPicker('hp-roof-cover', 'Bedekking', covOpts, covNow, RoofModel.coveringThumbUrl(covNow, 128), - shapeType === 'plat' ? 'Riet kan alleen bij een hellend dak (lessenaar/zadel)' : covInfo(covNow) + '; boeideel schuift mee omhoog'); - const dak = toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) + - (u.roof.followFloor ? '' : - num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) + - num('hp-roof-d', 'Diepte', u.roof.deck.floor.depth, { hint: 'Noord–zuid' }) + - num('hp-roof-ox', 'Verschuiving oost', u.roof.offsetX || 0) + - num('hp-roof-oz', 'Verschuiving zuid', u.roof.offsetZ || 0)) + - toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim van 80 mm' }) + - bedekking; - - const missing = HouseModel.missingSeamWalls(state, u.id); - propsEl.innerHTML = warn + head('box', `Unit ${u.id}`, `${f.width} × ${f.depth} mm`) + - UI.tabs('unit', [ - { key: 'maat', label: 'Afmetingen', icon: 'rulers', html: cols( - num('hp-width', 'Breedte', f.width, { hint: 'West–oost' }) + num('hp-depth', 'Diepte', f.depth, { hint: 'Noord–zuid' }), - sel('hp-corners', 'Hoekaansluiting', UnitModel.CORNER_MODES, u.unit.layout.corners)) }, - { key: 'koppeling', label: 'Koppeling', icon: 'link-45deg', html: cols(koppeling, missing.length - ? UI.warn(`Zonder naadwand: ${missing.map(m => `unit ${m.a} | ${m.b}`).join(', ')}. De naad staat daar open.`) + - actions(button('Naadwand toevoegen', 'plus-lg', `data-action="add-seams" data-unit="${u.id}"`)) - : '') }, - { key: 'dak', label: 'Dak', icon: 'house', html: cols(dak, u.roof.followFloor ? info('Het dak heeft de maat van de vloer. Zet "Maat volgt vloer" uit voor een eigen maat of verschuiving.') : '') } - ]) + - actions( - button('Vloer bewerken', 'grid-3x3', `data-go="floor:${u.id}"`), - button('Dak bewerken', 'house', `data-go="roof:${u.id}"`), - isRoot ? '' : button('Unit verwijderen', 'trash', `data-remove-unit="${u.id}"`, 'ui-btn-danger')); - - UI.onNum('hp-width', v => { if (v >= 1000) f.width = v; }, after); - UI.onNum('hp-depth', v => { if (v >= 1000) f.depth = v; }, after); - UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after); - UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender); - // Andere koppeling: naadwanden van deze unit gaan mee (oude vervallen, - // nieuwe naden krijgen er een). - UI.onSel('hp-att-to', v => { u.attach.to = v; HouseModel.syncSeamWalls(state, u.id); }, rerender); - UI.onSel('hp-att-side', v => { u.attach.side = v; HouseModel.syncSeamWalls(state, u.id); }, rerender); - UI.onSel('hp-att-align', v => { u.attach.align = v; HouseModel.syncSeamWalls(state, u.id); }, rerender); - UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender); - UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender); - UI.onSel('hp-roof-cover', v => { u.roof.covering = v; }, rerender); - UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after); - UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after); - UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after); - UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after); + // Dakvorm (UnitModel.roofForm): de invoer; de wanden volgen. + const form = UnitModel.roofForm(u.unit); + const sloped = () => form.type !== 'plat'; + const LIM = UnitModel.LIMITS; + // Actuele parts opzoeken: na Breedte/Diepte (keepProps) wordt de tab niet opnieuw opgebouwd. + const partsOf = () => parts && parts.units.find(x => x.id === u.id); + // Horizontale lengte van de helling: over de volle vloermaat (goot boven de buitenkant gevel). + const runOf = () => { + const pu = partsOf(); + const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form)); + const L = pu ? (s.axis === 'x' ? pu.layout.floorWidth : pu.layout.floorDepth) : 1000; + return UnitModel.hasRidge(form.type) ? L / 2 : L; + }; + const degOf = () => sloped() ? Math.atan(Math.max(0, form.high - form.low) / runOf()) * 180 / Math.PI : 0; + // Mansarde: hellingen van het onder- en bovenvlak. + const mansardeDeg = () => { + const kIn = Math.max(1, form.kneeIn || 1), rest = Math.max(1, runOf() - kIn); + return [Math.atan(Math.max(0, form.kneeH - form.low) / kIn), Math.atan(Math.max(0, form.high - form.kneeH) / rest)].map(r => r * 180 / Math.PI); + }; + return { + key: 'sel', label: `Unit ${u.id}`, icon: 'box', contextual: true, order: 100, + groups: () => { + const a = u.attach, axis = a && (a.side === 'voor' || a.side === 'achter') ? 'x' : 'z'; + const pu = partsOf(), shapeNow = pu ? pu.roof.shape.type : form.type; + const covOpts = Object.fromEntries(Object.entries(RoofModel.COVERINGS).filter(([, c]) => !c.sloped || shapeNow !== 'plat').map(([k, c]) => [k, c.label])); + const covNow = RoofModel.coveringFor(u.roof.covering, shapeNow); + const c = RoofModel.COVERINGS[covNow]; + const missing = HouseModel.missingSeamWalls(state, u.id); + const groups = [ + { label: 'Afmetingen', html: + UI.range('hp-width', 'Breedte', f.width, { min: 2000, max: LIM.long, step: 50, hint: `West–oost (unit max ${LIM.short / 1000} x ${LIM.long / 1000} m)`.replace('.', ',') }) + + UI.range('hp-depth', 'Diepte', f.depth, { min: 2000, max: LIM.long, step: 50, hint: `Noord–zuid (unit max ${LIM.short / 1000} x ${LIM.long / 1000} m)`.replace('.', ',') }) + + sel('hp-corners', 'Hoeken', UnitModel.CORNER_MODES, u.unit.layout.corners, { hint: 'Welke wanden doorlopen over de volle vloermaat' }) }, + { label: 'Dakvorm', html: sel('hp-roof-type', 'Vorm', UnitModel.ROOF_TYPES, form.type) + + (form.type === 'lessenaar' ? sel('hp-roof-dir', 'Hoge zijde', SIDE_SHORT, form.dir) : '') + + (UnitModel.hasRidge(form.type) ? sel('hp-roof-ridge', 'Nok', UnitModel.RIDGES, form.ridge) : '') + + UI.range('hp-roof-low', sloped() ? 'Goot' : 'Hoogte', form.low, { min: 2100, max: LIM.eave, step: 10, hint: `Vanaf bovenkant vloer tot onderkant dak (max ${LIM.eave} mm)` }) } + ]; + if (sloped()) groups.push({ label: 'Helling', html: + UI.range('hp-roof-high', UnitModel.hasRidge(form.type) ? 'Nok' : 'Hoog', form.high, { min: 2100, max: LIM.ridge, step: 10, hint: `Max ${LIM.ridge} mm` }) + + (form.type === 'mansarde' ? '' : UI.range('hp-roof-deg', 'Helling', Math.round(degOf() * 10) / 10, { min: 1, max: 60, step: 0.5, unit: '°', hint: 'Zet de hoge zijde/nok bij de huidige goothoogte' })) + + `
${kv('Kopgevel', `wand ${SIDE_SHORT[UnitModel.gableOf(form)].toLowerCase()} + overstaande`)}
` }); + if (form.type === 'mansarde') groups.push({ label: 'Mansarde', html: + UI.range('hp-roof-knee-h', 'Knik', form.kneeH, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte van de knik tussen het steile en het flauwe dakvlak' }) + + UI.range('hp-roof-knee-in', 'Knik in', form.kneeIn, { min: 100, max: 3000, step: 10, hint: 'Horizontale afstand van de gevel tot de knik' }) + + `
${kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '))}
` }); + if (form.type === 'wolf') groups.push({ label: 'Wolfsdak', html: + UI.range('hp-roof-cut', 'Wolfseind', form.cut, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte waarop de kopgevels stoppen; daarboven driehoekige wolfseinden met de dakhelling tot de nok' }) }); + groups.push({ label: 'Dak', html: + pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet kan alleen bij een hellend dak' : `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²` }), + thumb(RoofModel.coveringThumbUrl(covNow, 64), c.label)) + + toggle('hp-fascia', 'Boeidelen', u.roof.fascia !== false, { hint: 'Uit = daktrim' }) + + toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) }); + if (!u.roof.followFloor) groups.push({ label: 'Dakmaat', html: + num('hp-roof-w', 'Breedte', u.roof.deck.floor.width, { hint: 'West–oost' }) + num('hp-roof-d', 'Diepte', u.roof.deck.floor.depth, { hint: 'Noord–zuid' }) + + num('hp-roof-ox', 'Versch. oost', u.roof.offsetX || 0) + num('hp-roof-oz', 'Versch. zuid', u.roof.offsetZ || 0) }); + groups.push({ label: 'Koppeling', html: isRoot + ? note('Basis-unit: de noordwesthoek ligt op (0, 0). Andere units koppelen hieraan.') + : sel('hp-att-to', 'Aan', Object.fromEntries(state.units.filter(o => o.id !== u.id).map(o => [o.id, 'Unit ' + o.id])), a.to) + + sel('hp-att-side', 'Zijde', HouseModel.SIDE_LABELS, a.side) + + sel('hp-att-align', 'Uitlijning', HouseModel.ALIGN_LABELS[axis], a.align) + + num('hp-att-offset', 'Verschuiving', a.offset || 0) }); + groups.push({ label: 'Bewerken', html: + big('Vloer', 'grid-3x3', `data-go="floor:${u.id}" title="Vloer-editor: balken, doorvoeren"`) + + big('Dak', 'house', `data-go="roof:${u.id}" title="Dak-editor: dek, doorvoeren"`) + + (missing.length ? small('Naadwand toevoegen', 'plus-lg', `data-ho-act="add-seams" data-unit="${u.id}" title="Zonder naadwand: ${missing.map(m => `${m.a}|${m.b}`).join(', ')}"`) : '') + + (isRoot ? '' : small('Unit verwijderen', 'trash', `data-ho-act="remove-unit" data-unit="${u.id}"`, 'rb-danger')) }); + return groups; + }, + wire: (body) => { + live = []; + wireButtons(body); + // Unitmaat binnen de grenzen (de andere maat geklemd als beide te groot zijn). + const size = (k, v) => { + if (!(v >= 1000)) return; + f[k] = v; + UnitModel.clampSize(f, k === 'width' ? 'depth' : 'width'); + UI.setRange('hp-width', f.width); UI.setRange('hp-depth', f.depth); + }; + UI.onRange('hp-width', v => size('width', v), after); + UI.onRange('hp-depth', v => size('depth', v), after); + UI.onSel('hp-corners', v => { u.unit.layout.corners = v; }, rerender); + UI.onNum('hp-att-offset', v => { u.attach.offset = v; }, after); + // Andere koppeling: naadwanden van deze unit gaan mee. + UI.onSel('hp-att-to', v => { u.attach.to = v; HouseModel.syncSeamWalls(state, u.id); }, rerender); + UI.onSel('hp-att-side', v => { u.attach.side = v; HouseModel.syncSeamWalls(state, u.id); }, rerender); + UI.onSel('hp-att-align', v => { u.attach.align = v; HouseModel.syncSeamWalls(state, u.id); }, rerender); + // Dakvorm: elke wijziging past de wanden aan (applyRoofForm). + const apply = () => { + form.low = Math.min(form.low, LIM.eave); + form.high = Math.min(form.high, LIM.ridge); + UnitModel.applyRoofForm(u.unit, form); + }; + UI.onSel('hp-roof-type', v => { + form.type = v; + if (v !== 'plat' && !(form.high > form.low)) form.high = form.low + 700; + if (v === 'lessenaar' && !form.dir) form.dir = 'voor'; + if (UnitModel.hasRidge(v) && !form.ridge) form.ridge = 'z'; + if (v === 'mansarde') { + // Standaard: steil onderdeel (~65°) over ruim 1/3 van de halve gevel, flauw (~27°) bovendeel. + const run = runOf(); + form.kneeIn = Math.round(run * 0.35); + form.kneeH = Math.round(form.low + form.kneeIn * 2.1); + form.high = Math.min(LIM.ridge, Math.max(form.high, Math.round(form.kneeH + (run - form.kneeIn) * 0.5))); + form.kneeRatio = Math.min(0.9, (form.kneeH - form.low) / (form.high - form.low)); + } + if (v === 'wolf') { + if (form.high - form.low < 1000) form.high = form.low + 1500; + form.cutRatio = 0.6; + } + apply(); + }, rerender); + UI.onSel('hp-roof-dir', v => { form.dir = v; apply(); }, rerender); + UI.onSel('hp-roof-ridge', v => { form.ridge = v; apply(); }, rerender); + // Na toepassen: de werkelijke (geklemde) maten in de velden; de + // verhoudingen (kneeRatio, cutRatio) blijven zoals ingesteld. + const syncDeg = () => { + const now = UnitModel.roofForm(u.unit); + if (now.type === form.type) ['kneeH', 'kneeIn', 'cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; }); + UI.setRange('hp-roof-deg', Math.round(degOf() * 10) / 10); + UI.setRange('hp-roof-knee-h', form.kneeH); + UI.setRange('hp-roof-knee-in', form.kneeIn); + UI.setRange('hp-roof-cut', form.cut); + const md = body.querySelector('[data-live="roof-mansarde"]'); + if (md && form.type === 'mansarde') md.innerHTML = kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / ')); + }; + UI.onRange('hp-roof-low', v => { + if (!(v >= 500)) return; + form.low = v; + if (sloped() && form.high < v) { form.high = v; UI.setRange('hp-roof-high', v); } + apply(); syncDeg(); + }, after); + UI.onRange('hp-roof-high', v => { + if (!(v >= 500)) return; + form.high = Math.max(v, form.low); + apply(); syncDeg(); + }, after); + UI.onRange('hp-roof-deg', v => { + if (!(v >= 0.5) || v > 75) return; + form.high = Math.round(form.low + Math.tan(v * Math.PI / 180) * runOf()); + UI.setRange('hp-roof-high', form.high); + apply(); syncDeg(); + }, after); + const ratioOf = (v) => Math.max(0.05, Math.min(0.95, (v - form.low) / Math.max(1, form.high - form.low))); + UI.onRange('hp-roof-knee-h', v => { if (v >= 500) { form.kneeRatio = ratioOf(v); apply(); syncDeg(); } }, after); + UI.onRange('hp-roof-knee-in', v => { if (v >= 50) { form.kneeIn = v; apply(); syncDeg(); } }, after); + UI.onRange('hp-roof-cut', v => { if (v >= 500) { form.cutRatio = ratioOf(v); apply(); syncDeg(); } }, after); + UI.onSel('hp-roof-cover', v => { u.roof.covering = v; }, rerender); + UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender); + UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender); + UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, after); + UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, after); + UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, after); + UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, after); + } + }; } - function renderWallProps(warn) { + function wallTab() { const pu = parts.units.find(x => x.id === selection.unit); const wl = pu && pu.walls.find(x => x.side === selection.side); const piece = wl && wl.pieces.find(p => p.element === selection.element); - if (!piece) { selection = null; return renderProps(); } - const lt = state.settings.lighting; - const rows = [ - ['Lengte zijde', wl.width, 'mm'], - ['Lengte wandstuk', piece.place.width, 'mm'], - ['Positie', `${Math.round(piece.u0)} – ${Math.round(piece.u1)}`, 'mm'], - ['Naad', wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)} – ${Math.round(b)}`).join(', ') : 'geen', wl.cutsU.length ? 'mm' : ''], - ['Openingen', piece.config.openings.length, ''] - ]; - const lampen = lt.mode === 'gevel' - ? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, unitById(pu.id).lighting.walls[wl.side] || 'auto', - { hint: `Automatisch: ${lt.endOffset} mm uit de hoek, alleen op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm naast elke opening.` }) + - UI.specCard('Op dit wandstuk', 'lightbulb', [['Gevellampen', piece.lamps.length, 'st.']]) - : info('Gevellampen staan uit. Kies bij Woning › Verlichting het type "Gevellampen".'); + if (!piece) return null; const su = unitById(pu.id); - const fc = HouseModel.facadeFor(state, su, wl.side); - const ov = su.facade.walls[wl.side] || null; - const f0 = state.settings.facade; - const woningLabel = `Woning (${HouseModel.FACADE_TYPES[f0.type]}${f0.type === 'steen' ? ' ' + f0.stone : ''})`; - const gevel = sel('hw-facade', 'Type', Object.assign({ woning: woningLabel }, HouseModel.FACADE_TYPES), ov ? ov.type : 'woning', - { hint: 'Geldt voor de hele zijde van deze unit' }) + - (ov && ov.type === 'steen' ? stonePicker('hw-facade-stone', 'Steen', fc.stone, `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik`) : ''); - propsEl.innerHTML = warn + head('bricks', piece.element, piece.label) + - cols(UI.specCard('Wandstuk', 'rulers', rows), section('Gevelbekleding', 'grip-horizontal', gevel)) + - section('Gevellampen', 'lightbulb', lampen) + - (wl.dropped.length ? UI.warn(`${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.`) : '') + - actions(button('Wand bewerken', 'pencil-square', `data-go="wall:${pu.id}:${wl.side}"`)); - UI.onSel('hw-light', v => { unitById(pu.id).lighting.walls[wl.side] = v; }, rerender); - UI.onSel('hw-facade', v => { - if (v === 'woning') delete su.facade.walls[wl.side]; - else su.facade.walls[wl.side] = v === 'steen' ? { type: v, stone: fc.stone } : { type: v }; - }, rerender); - UI.onSel('hw-facade-stone', v => { su.facade.walls[wl.side].stone = v; }, rerender); + return { + key: 'sel', label: `Wand ${piece.element}`, icon: 'bricks', contextual: true, order: 100, + groups: () => { + const lt = state.settings.lighting; + const fc = HouseModel.facadeFor(state, su, wl.side); + const ov = su.facade.walls[wl.side] || null; + const f0 = state.settings.facade; + const woningLabel = `Woning (${HouseModel.FACADE_TYPES[f0.type]}${f0.type === 'steen' ? ' ' + f0.stone : ''})`; + return [ + { label: piece.label, html: + kv('Lengte zijde', wl.width, 'mm') + kv('Wandstuk', piece.place.width, 'mm') + kv('Positie', `${Math.round(piece.u0)} – ${Math.round(piece.u1)}`, 'mm') + + kv('Openingen', piece.config.openings.length) + kv('Naad', wl.cutsU.length ? wl.cutsU.map(([a, b]) => `${Math.round(a)}–${Math.round(b)}`).join(', ') : 'geen', wl.cutsU.length ? 'mm' : '') + + kv('Hoogte', `${piece.config.wallHeightLeft} / ${piece.config.wallHeightRight}`, 'mm') }, + { label: 'Gevel (hele zijde)', html: + sel('hw-facade', 'Type', Object.assign({ woning: woningLabel }, HouseModel.FACADE_TYPES), ov ? ov.type : 'woning') + + (ov && ov.type === 'steen' ? pair(sel('hw-facade-stone', 'Steen', stoneOptions, fc.stone), thumb(WallModel.stoneThumbUrl(fc.stone, 64), fc.stone)) : '') }, + { label: 'Gevellampen', html: lt.mode === 'gevel' + ? sel('hw-light', 'Lampen', LightingModel.WALL_OVERRIDES, su.lighting.walls[wl.side] || 'auto', + { hint: `Automatisch: ${lt.endOffset} mm uit de hoek, op vrije gevel van min. ${lt.minWallWidth} mm. Naast openingen: ${lt.openingOffset} mm ernaast.` }) + + kv('Op dit wandstuk', piece.lamps.length, 'st.') + : note('Gevellampen staan uit (tabblad Verlichting).') }, + wl.dropped.length ? { label: 'Let op', html: note(`${wl.dropped.length} opening(en) van deze zijde vallen in een naad en vervallen.`, true) } : null, + { label: 'Bewerken', html: big('Wand bewerken', 'pencil-square', `data-go="wall:${pu.id}:${wl.side}" title="Wand-editor: openingen, hoogtes, regelwerk"`) } + ]; + }, + wire: (body) => { + live = []; + wireButtons(body); + const fc = HouseModel.facadeFor(state, su, wl.side); + UI.onSel('hw-light', v => { su.lighting.walls[wl.side] = v; }, rerender); + UI.onSel('hw-facade', v => { + if (v === 'woning') delete su.facade.walls[wl.side]; + else su.facade.walls[wl.side] = v === 'steen' ? { type: v, stone: fc.stone } : { type: v }; + }, rerender); + UI.onSel('hw-facade-stone', v => { su.facade.walls[wl.side].stone = v; }, rerender); + } + }; } - function renderIwProps(iw, warn) { + function iwTab(iw) { const p = parts.interior.find(x => x.id === iw.id); - if (iw.seam) return renderSeamProps(iw, warn); - if (!p) { selection = null; return renderProps(); } - propsEl.innerHTML = warn + head('distribute-horizontal', p.element, `Binnenwand · ${p.place.width} mm`) + - cols( - section('Positie', 'crosshair', num('hi-x1', 'Begin X', iw.x1) + num('hi-z1', 'Begin Y', iw.z1) + num('hi-x2', 'Eind X', iw.x2) + num('hi-z2', 'Eind Y', iw.z2)), - section('Opbouw', 'layers', num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), { step: 5 }) + num('hi-height', 'Hoogte', iw.wall.wall.heightAll))) + - actions( - button('Openingen bewerken', 'pencil-square', `data-go="iw:${iw.id}"`), - button('Verwijderen', 'trash', `data-remove-iw="${iw.id}"`, 'ui-btn-danger')); - ['x1', 'z1', 'x2', 'z2'].forEach(k => UI.onNum('hi-' + k, v => { iw[k] = v; }, after)); - UI.onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); }, after); - UI.onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; }, after); + if (!p) return null; + return { + key: 'sel', label: p.element, icon: 'distribute-horizontal', contextual: true, order: 100, + groups: () => [ + { label: 'Positie', html: num('hi-x1', 'Begin X', iw.x1) + num('hi-z1', 'Begin Y', iw.z1) + num('hi-x2', 'Eind X', iw.x2) + num('hi-z2', 'Eind Y', iw.z2) }, + { label: 'Opbouw', html: num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall), { step: 5 }) + num('hi-height', 'Hoogte', iw.wall.wall.heightAll) + kv('Lengte', p.place.width, 'mm') }, + { label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}" title="Delete"`, 'rb-danger') } + ], + wire: (body) => { + live = []; + wireButtons(body); + ['x1', 'z1', 'x2', 'z2'].forEach(k => UI.onNum('hi-' + k, v => { iw[k] = v; }, after)); + UI.onNum('hi-thick', v => { if (v >= 40) HouseModel.setThickness(iw.wall, v); }, after); + UI.onNum('hi-height', v => { if (v >= 500) iw.wall.wall.heightAll = iw.wall.wall.heightLeft = iw.wall.wall.heightRight = v; }, after); + } + }; } // Naadwand: midden op de naad (twee delen, één per unit) of geheel in // unit A/B (één wand). Eén set openingen en maten; de hoogte volgt per // deel de daklijn van zijn unit. - function renderSeamProps(iw, warn) { + function seamTab(iw) { const leaves = parts.interior.filter(x => x.id === iw.id); - const title = `Naadwand ${iw.seam.a} | ${iw.seam.b}`; - const exposed = leaves.filter(l => l.skinFrom !== undefined); const inUnit = iw.seam.in === 'a' || iw.seam.in === 'b' ? iw.seam[iw.seam.in] : null; - const plaatsen = { midden: 'Midden op de naad (2 delen)', a: `Geheel in unit ${iw.seam.a}`, b: `Geheel in unit ${iw.seam.b}` }; const sheet = iw.wall.wall.sheathingThick; - const dikte = inUnit - ? num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall) + sheet, { step: 5, hint: 'Regelwerk + beplating aan beide zijden' }) - : num('hi-thick', 'Dikte per deel', HouseModel.thicknessOf(iw.wall), { step: 5, hint: 'Regelwerk + beplating aan de kamerzijde; naadzijde zonder plaat' }); - const uitleg = inUnit - ? `Binnenwand geheel in unit ${inUnit}, tegen de naad aan, met beplating aan beide zijden. De hoogte volgt de daklijn van unit ${inUnit}.` - : 'Tussenwand op de naad, per unit een eigen deel dat tegen het andere aan staat. Openingen gelden voor beide delen (de deur loopt door). De hoogte volgt per deel de daklijn van zijn unit.'; - propsEl.innerHTML = warn + head('layout-split', title, leaves.length ? (leaves.length === 1 ? '1 deel' : `${leaves.length} delen`) : 'vervalt') + - (leaves.length ? '' : UI.warn(`Unit ${iw.seam.a} en ${iw.seam.b} staan niet (meer) tegen elkaar; deze naadwand wordt niet gebouwd.`)) + - cols( - section('Opbouw', 'layers', sel('hi-seam-in', 'Plaats', plaatsen, iw.seam.in || 'midden') + dikte), - UI.specCard('Delen', 'layout-split', leaves.map(l => [`${l.element} (unit ${l.seamUnit})`, l.place.width, 'mm']))) + - info(uitleg + - exposed.map(l => ` ${l.element} steekt vanaf ${l.skinFrom} mm boven het dak van de andere unit uit; dat stuk krijgt buitenafwerking die met de gevel meeloopt.`).join('')) + - actions( - button('Openingen bewerken', 'pencil-square', `data-go="iw:${iw.id}"`), - button('Verwijderen', 'trash', `data-remove-iw="${iw.id}"`, 'ui-btn-danger')); - UI.onSel('hi-seam-in', v => { if (v === 'midden') delete iw.seam.in; else iw.seam.in = v; }, rerender); - UI.onNum('hi-thick', v => { const t = inUnit ? v - sheet : v; if (t >= 40) HouseModel.setThickness(iw.wall, t); }, after); + return { + key: 'sel', label: `Naadwand ${iw.seam.a}|${iw.seam.b}`, icon: 'layout-split', contextual: true, order: 100, + groups: () => { + const plaatsen = { midden: 'Midden (2 delen)', a: `Geheel in unit ${iw.seam.a}`, b: `Geheel in unit ${iw.seam.b}` }; + const exposed = leaves.filter(l => l.skinFrom !== undefined); + return [ + { label: 'Opbouw', html: sel('hi-seam-in', 'Plaats', plaatsen, iw.seam.in || 'midden') + + (inUnit + ? num('hi-thick', 'Dikte', HouseModel.thicknessOf(iw.wall) + sheet, { step: 5, hint: 'Regelwerk + beplating aan beide zijden' }) + : num('hi-thick', 'Dikte per deel', HouseModel.thicknessOf(iw.wall), { step: 5, hint: 'Regelwerk + beplating aan de kamerzijde; naadzijde zonder plaat' })) }, + { label: 'Delen', html: leaves.length + ? leaves.map(l => kv(`${l.element}`, l.place.width, 'mm')).join('') + exposed.map(l => kv(`${l.element} buiten vanaf`, l.skinFrom, 'mm')).join('') + : note(`Unit ${iw.seam.a} en ${iw.seam.b} staan niet (meer) tegen elkaar; deze naadwand wordt niet gebouwd.`, true) }, + { label: 'Uitleg', html: note(inUnit + ? `Binnenwand geheel in unit ${inUnit}, tegen de naad, beplating aan beide zijden; hoogte volgt de daklijn van unit ${inUnit}.` + : 'Per unit een eigen deel tegen het andere aan; openingen gelden voor beide delen (de deur loopt door). Hoogte volgt per deel de daklijn.') }, + { label: 'Bewerken', html: big('Openingen', 'pencil-square', `data-go="iw:${iw.id}"`) + small('Verwijderen', 'trash', `data-ho-act="remove-iw" data-iw="${iw.id}"`, 'rb-danger') } + ]; + }, + wire: (body) => { + live = []; + wireButtons(body); + UI.onSel('hi-seam-in', v => { if (v === 'midden') delete iw.seam.in; else iw.seam.in = v; }, rerender); + UI.onNum('hi-thick', v => { const t = inUnit ? v - sheet : v; if (t >= 40) HouseModel.setThickness(iw.wall, t); }, after); + } + }; } - // Woning-instellingen (niets geselecteerd): alles wat voor de hele woning - // geldt, in tabbladen. - // Keuzes voor de vormgeving van het trasraam: plaat of een steen. - const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` }))); - const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions); - // Keuzelijst met voorvertoning (textuur-thumbnail), als die er is. - const thumbPicker = (id, label, options, value, thumbUrl, hint) => sel(id, label, options, value, { hint }) + - (thumbUrl ? `${value}` : ''); - const stonePicker = (id, label, value, hint) => thumbPicker(id, label, stoneOptions, value, WallModel.stoneThumbUrl(value, 128), hint); - - function renderHouseProps(warn) { - const st = state.settings, sp = st.spacing, r = st.roof, lt = st.lighting; - const pos = { positive: true }; - const verlichting = lt.mode === 'gevel' - ? cols( - section('Gevellampen', 'lightbulb', sel('hs-light-dir', 'Lichtrichting', LightingModel.DIRECTIONS, lt.direction) + - num('hs-lamp-h', 'Hoogte', lt.lampHeight, { hint: 'Vanaf bovenkant vloer' }) + - num('hs-lamp-min', 'Afstand min.', lt.lampMin, { hint: 'Hart-op-hart' }) + num('hs-lamp-max', 'Afstand max.', lt.lampMax, { hint: 'Hart-op-hart' })), - section('Plaatsing', 'distribute-horizontal', num('hs-lamp-w', 'Min. gevelbreedte', lt.minWallWidth, { hint: 'Smallere vrije stukken gevel krijgen geen lamp' }) + - num('hs-lamp-end', 'Afstand tot hoek', lt.endOffset) + - num('hs-lamp-open', 'Afstand tot opening', lt.openingOffset, { hint: 'Voor wanden met de instelling "Naast openingen"' }))) - : lt.mode === 'spots' - ? cols(section('Boeideel-spots', 'lightbulb', num('hs-spot-min', 'Afstand min.', r.spotMin, { hint: 'Hart-op-hart' }) + num('hs-spot-max', 'Afstand max.', r.spotMax, { hint: 'Hart-op-hart' })), - info('Spots komen alleen onder units met boeidelen.')) - : ''; - - propsEl.innerHTML = warn + head('house-door', 'Woning', `${state.units.length} unit(s) · ${state.interiorWalls.length} binnenwand(en)`) + - UI.tabs('woning', [ - { key: 'algemeen', label: 'Algemeen', icon: 'house-door', html: - info('Klik in de plattegrond op een unit, wand of binnenwand om te selecteren; dubbelklik om te bewerken. Waar units tegen elkaar staan (oranje gestippeld) vervalt de wand.') + - actions(button('Unit toevoegen', 'plus-lg', 'data-action="add-unit"'), button('Binnenwand tekenen', 'pencil', 'data-action="draw-iw"'), - button('Projectgegevens', 'card-text', 'data-action="project"')) }, - { key: 'gevel', label: 'Gevel', icon: 'bricks', html: cols( - section('Gevelbekleding', 'grip-horizontal', - sel('hs-facade', 'Type', HouseModel.FACADE_TYPES, st.facade.type, { hint: 'Voor alle buitenwanden; per wand aan te passen (klik een wand in de plattegrond)' }) + - (st.facade.type === 'steen' - ? stonePicker('hs-facade-stone', 'Steen', st.facade.stone, `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik`) - : sel('hs-cladding', 'Richting', WallModel.CLADDING_DIRECTIONS, st.cladding, { hint: 'Rabat op vochtdoek en kruislat' }))) + - section('Trasraam', 'bricks', toggle('hs-trasraam', 'Trasraam', st.trasraam.enabled, { hint: 'Strook onderaan de gevel, onder rabat én steen (eigen steen mogelijk)' }) + - (st.trasraam.enabled ? num('hs-trasraam-h', 'Hoogte', st.trasraam.height, { step: 10, hint: 'Vanaf bovenkant vloer' }) + - sel('hs-trasraam-finish', 'Vormgeving', trasraamFinishes, st.trasraam.finish, { - hint: WallModel.isStoneFinish(st.trasraam.finish) - ? `Steenstrip op isolatieblok, samen ${WallModel.STONE.THICK_MM} mm dik` - : 'Cement-gebonden plaat' - }) + - (WallModel.isStoneFinish(st.trasraam.finish) - ? `${st.trasraam.finish}` : '') : '')), - section('Sandwichpanelen', 'bounding-box', toggle('hs-sandwich', 'Rondom woning', st.sandwich, - { hint: `${SandwichModel.PANEL_WIDTH_MM} × ${SandwichModel.PANEL_LENGTH_MM} × ${SandwichModel.THICK_MM} mm, vanaf bovenkant vloer ${SandwichModel.DEPTH_MM} mm naar beneden` })) + - section('Maaiveld', 'tree', num('hs-ground', 'Onder bovenkant vloer', st.groundLevel, { step: 10, hint: 'Hoogte van de tegelvloer in de 3D-weergave bij Dag/Nacht' }))) }, - { key: 'binnen', label: 'Binnen', icon: 'palette', html: cols( - section('Wanden', 'palette', toggle('hs-int-paint', 'Geschilderd', st.interiorPaint, { hint: 'Alle wanden aan de kamerzijde; egaal, zonder plaatnaden' }) + - (st.interiorPaint ? UI.color('hs-int-color', 'Verfkleur', st.interiorColor, { hint: 'Voorlopig één kleur voor de hele woning' }) : '')), - section('Vloer', 'grid-3x3', thumbPicker('hs-floor-finish', 'Afwerking', Object.fromEntries(Object.entries(FloorModel.FINISHES).map(([k, f]) => [k, f.label])), st.floorFinish, - FloorModel.finishThumbUrl(st.floorFinish, 128), 'Alle vloeren (voorlopig één keuze)') + - info('Wanden uit: kale spaanplaat met plaatnaden.'))) }, - { key: 'constructie', label: 'Regelwerk', icon: 'columns-gap', html: cols( - section('Buitenwanden', 'bricks', num('hs-ext-min', 'Afstand min.', sp.exterior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-ext-max', 'Afstand max.', sp.exterior.max, { step: 10, hint: 'Hart-op-hart stijlen' })), - section('Binnenwanden', 'distribute-horizontal', num('hs-int-min', 'Afstand min.', sp.interior.min, { step: 10, hint: 'Hart-op-hart stijlen' }) + num('hs-int-max', 'Afstand max.', sp.interior.max, { step: 10, hint: 'Hart-op-hart stijlen' }))) }, - { key: 'dak', label: 'Dakrand', icon: 'house', html: cols( - section('Boeideel', 'border-outer', num('hs-overhang', 'Overstek', r.overhang, { step: 10, hint: `${RoofModel.OVERHANG_MIN} – ${RoofModel.OVERHANG_MAX} mm` }) + num('hs-fascia-h', 'Hoogte', r.fasciaHeight, { step: 10 }) + - sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor, { hint: 'Egale kleur' })), - section('Daktrim', 'border-top', num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }))) }, - { key: 'verlichting', label: 'Verlichting', icon: 'lightbulb', html: - cols(section('Buiten', 'sun', sel('hs-light-mode', 'Type', LightingModel.MODES, lt.mode)), - section('Binnenspots', 'circle', toggle('hs-indoor', 'Spots in plafond', lt.indoor, { hint: 'Per unit verdeeld in het plafond' }) + - (lt.indoor ? num('hs-indoor-min', 'Afstand min.', lt.indoorMin, { hint: 'Hart-op-hart, in beide richtingen' }) + - num('hs-indoor-max', 'Afstand max.', lt.indoorMax, { hint: 'Hart-op-hart, in beide richtingen' }) + - num('hs-indoor-off', 'Afstand tot wand', lt.indoorOffset, { hint: 'Tot de binnenzijde van een wand, of tot de unitrand bij een naad' }) : ''))) + - verlichting } - ]); - - UI.onSel('hs-cladding', v => { st.cladding = v; }, rerender); - UI.onSel('hs-facade', v => { st.facade.type = v; }, rerender); - UI.onColor('hs-int-color', v => { st.interiorColor = v; }, after); - UI.onToggle('hs-int-paint', v => { st.interiorPaint = v; }, rerender); - UI.onSel('hs-floor-finish', v => { st.floorFinish = v; }, rerender); - UI.onSel('hs-facade-stone', v => { st.facade.stone = v; }, rerender); - UI.onToggle('hs-trasraam', v => { st.trasraam.enabled = v; }, rerender); - UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender); - UI.onNum('hs-trasraam-h', v => { st.trasraam.height = v; }, after, pos); - UI.onSel('hs-trasraam-finish', v => { st.trasraam.finish = v; }, rerender); - UI.onNum('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after); - UI.onNum('hs-ext-min', v => { sp.exterior.min = v; }, after, pos); - UI.onNum('hs-ext-max', v => { sp.exterior.max = v; }, after, pos); - UI.onNum('hs-int-min', v => { sp.interior.min = v; }, after, pos); - UI.onNum('hs-int-max', v => { sp.interior.max = v; }, after, pos); - UI.onNum('hs-overhang', v => { r.overhang = Math.min(RoofModel.OVERHANG_MAX, Math.max(RoofModel.OVERHANG_MIN, v)); }, after, pos); - UI.onNum('hs-fascia-h', v => { r.fasciaHeight = v; }, after, pos); - UI.onSel('hs-fascia-color', v => { r.fasciaColor = v; }, rerender); - UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos); - UI.onNum('hs-spot-min', v => { r.spotMin = v; }, after, pos); - UI.onNum('hs-spot-max', v => { r.spotMax = v; }, after, pos); - UI.onSel('hs-light-mode', v => { lt.mode = v; }, rerender); - UI.onSel('hs-light-dir', v => { lt.direction = v; }, rerender); - UI.onNum('hs-lamp-h', v => { lt.lampHeight = v; }, after, pos); - UI.onNum('hs-lamp-min', v => { lt.lampMin = v; }, after, pos); - UI.onNum('hs-lamp-max', v => { lt.lampMax = v; }, after, pos); - UI.onNum('hs-lamp-w', v => { lt.minWallWidth = v; }, after, pos); - UI.onNum('hs-lamp-end', v => { lt.endOffset = v; }, after, pos); - UI.onNum('hs-lamp-open', v => { lt.openingOffset = v; }, after, pos); - UI.onToggle('hs-indoor', v => { lt.indoor = v; }, rerender); - UI.onNum('hs-indoor-min', v => { lt.indoorMin = v; }, after, pos); - UI.onNum('hs-indoor-max', v => { lt.indoorMax = v; }, after, pos); - UI.onNum('hs-indoor-off', v => { lt.indoorOffset = v; }, after); + // Ribbon bijwerken (na een verversing of een andere selectie). focus = + // het selectie-tabblad openen. + function syncRibbon(focus) { + const tabs = [startTab, gevelTab, dakTab, binnenTab, constructieTab, lichtTab, elektraTab, rapportTab, beeldTab]; + const ctx = contextTab(); + if (ctx) tabs.push(ctx); + Ribbon.setSource('editor', tabs, { focus: focus && !!ctx }); + } + // Waarschuwingen (overlappende units, ongeldige koppeling) op de plattegrond. + function renderWarnings() { + const msgs = (parts.overlaps.length ? [`Units overlappen elkaar: ${parts.overlaps.map(p => p.join(' + ')).join(', ')}.`] : []) + .concat(parts.rects.filter(r => r.error).map(r => `Unit ${r.id}: ${r.error}`)); + document.getElementById('ho-warn').innerHTML = msgs.map(m => UI.warn(m)).join(''); } - propsEl.addEventListener('click', (e) => { - const go = e.target.closest('[data-go]'); - if (go) { showPart(go.dataset.go); return; } - const ru = e.target.closest('[data-remove-unit]'); - if (ru) { removeUnit(ru.dataset.removeUnit); return; } - const ri = e.target.closest('[data-remove-iw]'); - if (ri) { - state.interiorWalls = state.interiorWalls.filter(w => w.id !== Number(ri.dataset.removeIw)); - selection = null; - refresh(); - return; - } - const act = e.target.closest('[data-action]'); - if (!act) return; - if (act.dataset.action === 'add-unit') addUnit(); - else if (act.dataset.action === 'draw-iw') setTool('iw'); - else if (act.dataset.action === 'project') openProjectProperties(); - else if (act.dataset.action === 'add-seams') { HouseModel.ensureSeamWalls(state, act.dataset.unit); refresh(); } + // --- Rapport: in plaats van de plattegrond zolang tabblad Rapport open is --- + let reportOpen = false, reportDirty = true; + const overviewEl = document.getElementById('house-overview'); + const offRibbon = Ribbon.onChange((key) => { + const open = key === 'rapport'; + if (open === reportOpen) return; + reportOpen = open; + overviewEl.classList.toggle('show-report', open); + if (open && reportDirty && parts) renderReport(); + requestAnimationFrame(onResize); }); - // --- Specificaties/totalen (onder de 3D) --- - function renderSpecs(totals) { + // Grafieken (HTML, geen bibliotheek). Kleuren: vaste volgorde, zie css + // (--series-1..3); waarden staan er altijd als tekst bij, de tabel + // eronder is de toegankelijke weergave. + const esc = UI.esc; + function stackedBars(title, iconName, series, rows, fmtVal, note) { + const max = Math.max(1, ...rows.map(r => r.total)); + const legend = series.length > 1 + ? `
${series.map((sr, i) => `${sr.label}`).join('')}
` : ''; + const body = rows.map(r => `
+
${esc(r.label)}${r.sub ? `${r.sub}` : ''}
+
${series.map((sr, i) => { + const v = r.values[sr.key] || 0; + return v > 0 ? `` : ''; + }).join('')}
+
${fmtVal(r.total)}
+
`).join(''); + return `

${UI.icon(iconName)}${title}

${legend}${body}${note ? `
${note}
` : ''}
`; + } + + function renderReport() { + reportDirty = false; const f = houseFmt; + const totals = HouseModel.computeTotals(parts); const dims = HouseModel.computeDimensions(parts, state.settings.groundLevel); + const transport = HouseModel.computeTransport(parts); + const cost = CostModel.compute(parts, totals); + const kg = (v) => `${f(v, 0)} kg`; + const m = (mm) => f(mm / 1000, 2); const cards = [ - UI.specCard('Afmetingen', 'rulers', HouseModel.dimensionRows(dims, f)), + stackedBars('Transport per unit', 'truck', [ + { key: 'vloer', label: 'Vloer' }, { key: 'wanden', label: 'Wanden' }, { key: 'dak', label: 'Dak' } + ], transport.map(t => ({ + label: `Unit ${t.id}`, total: t.totalKg, values: t.kg, + sub: `${m(t.length)} × ${m(t.width)} × ${m(t.height)} m · ${HouseModel.TRANSPORT_CLASSES[t.cls].toLowerCase()}` + })), kg, `Buitenmaat l × b × h incl. gevelafwerking, zonder dakrand. Breedte tot ${HouseModel.TRANSPORT.widthRegular} mm regulier, tot ${HouseModel.TRANSPORT.widthPermit} mm met ontheffing (indicatief). Gewicht zonder kozijnen, isolatie en steen.`), UI.specCard('Gewicht', 'speedometer2', totals.weights.map(r => [r.label, f(r.kg), 'kg']), ['Totaal woning', f(totals.totalKg), 'kg']), + UI.specCard('Afmetingen', 'rulers', HouseModel.dimensionRows(dims, f)), + stackedBars('Kostprijs per unit', 'cash-coin', [{ key: 'v', label: 'Kostprijs' }], + cost.units.map(u => ({ label: u.id ? `Unit ${u.id}` : 'Woning-breed', total: u.kostprijs, values: { v: u.kostprijs } })), (v) => CostModel.eur(v), + 'Woning-breed: dakrand, fundering, verlichting, kraan.'), + ...costCards(cost), UI.specCard('Hout & isolatie', 'tree', [ ['Balken', totals.beamCount, 'st.'], ['Volume hout', f(totals.woodM3, 3), 'm³'], @@ -1012,7 +1584,7 @@ function mountHouseEditor(container, callbacks) { [`Latwerk ${WallModel.BATTEN.thick}×${WallModel.BATTEN.width}`, `${totals.battens.count} × ${WallModel.BATTEN.length}`, 'mm'], ['Glaswol', f(totals.isolatieM3, 3), 'm³'] ]), - UI.specCard('Plaatwerk', 'layers', totals.plaatwerk.filter(p => p.m2 > 0).map(p => [p.naam, f(p.m2, 2), 'm²'])) + UI.specCard('Plaatwerk', 'layers', totals.plaatwerk.filter(pw => pw.m2 > 0).map(pw => [pw.naam, f(pw.m2, 2), 'm²'])) ]; if (totals.roofEdge) { const e = totals.roofEdge, rows = []; @@ -1022,37 +1594,67 @@ function mountHouseEditor(container, callbacks) { cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows)); } // TIJDELIJK (verwijderen voor productie): zaagmachine-overzicht. - const sw = totals.saw, hm = (h) => `${Math.floor(h)}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`; + const sw = totals.saw, cs = cost.saw; cards.push(UI.specCard('Zaagmachine (tijdelijk)', 'scissors', [ ['Zaagmeters', f(sw.meters, 1), 'm'], - [`Zaagtijd (${HouseModel.SAW_M_PER_H} m/u)`, hm(sw.hours), 'u:mm'], + ['Zaagtijd robot', CostModel.uur(cs.robotSec / 3600), 'uur'], + [`Zaagtijd handmatig (×${cost.settings.zagen.handFactor})`, CostModel.uur(cs.handSec / 3600), 'uur'], ['Zaagsneden', sw.cuts, 'st.'], ['Verschillende hoeken', sw.angles.length, ''] ].concat(sw.angles.map(a => [a.deg === 0 ? 'Haaks (0°)' : `Schuin ${f(a.deg, 1)}°`, a.count, 'sneden'])))); - document.getElementById('ho-specs').innerHTML = `
${cards.join('')}
`; + document.getElementById('ho-report').innerHTML = `

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

${UI.esc(state.project.naam || 'Woning')} · ${state.units.length} unit(s)
${cards.join('')}
`; } + // Kostenraming (CostModel; kosten/tijden via Instellingen > Kosten & tijden). + function costCards(c) { + const s = c.summary, S = c.settings; + const eur = (n) => CostModel.eur(n); + return [ + UI.specCard('Kostenraming (indicatief)', 'cash-coin', + s.rows.map(r => [r.sub ? `${r.label}` : r.label, r.sub ? `${eur(r.amount)}` : eur(r.amount), '']), + ['Totaal incl. btw', eur(s.inclBtw), '']), + stackedBars('Materiaal per groep', 'box-seam', [{ key: 'v', label: 'Materiaal' }], + c.categories.filter(r => r.mat > 0).map(r => ({ label: r.name, total: r.mat, values: { v: r.mat } })), eur), + UI.specCard('Arbeid per werkzaamheid', 'tools', c.activities.filter(r => r.sec > 0).map(r => [r.name, CostModel.uur(r.sec / 3600), 'manuur']) + .concat([[`Toeslag indirect ${S.arbeid.toeslagUren}%`, CostModel.uur(s.hoursIndirect), 'manuur'], [`QA ${S.arbeid.qa}%`, CostModel.uur(s.hoursQa), 'manuur']]), ['Totaal', CostModel.uur(s.hours), 'manuur']) + ]; + } + const offCosts = CostModel.onChange(() => { if (parts && overviewEl.isConnected) { reportDirty = true; if (reportOpen) renderReport(); } }); + // --- Alles opnieuw doorrekenen --- + // Verversen gebundeld per beeldje (schuiven en typen geven veel events). + let pending = null, firstSync = true; + function scheduleRefresh(opts) { + const keep = !!(opts && opts.keepProps); + if (pending) { pending.keepProps = pending.keepProps && keep; return; } + pending = { keepProps: keep }; + requestAnimationFrame(() => { const o = pending; pending = null; if (canvas.isConnected) refresh(o); }); + } + // Oude 3D vrijgeven (geometrie wordt per verversing nieuw opgebouwd; + // materialen worden gedeeld en blijven). + const disposeGroup = (g) => g && g.traverse(o => { if (o.geometry) o.geometry.dispose(); }); + function refresh(opts) { // Voorvertoningen van de gekozen materialen alvast laden voor de PDF // (legenda op het aanzichtenblad; HousePdf.materialThumbs). HousePdf.materialThumbUrls(state).forEach(PdfSheet.preloadImage); - // Binnenzijde geschilderd: de (gedeelde) spaanplaat krijgt de verfkleur. - wallMaterials.spaanplaatMaterial.color.set(state.settings.interiorPaint ? state.settings.interiorColor : SPAANPLAAT_COLOR); - parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, beplatingVisible, lampenVisible }); - const totals = HouseModel.computeTotals(parts); + // Binnenzijde geschilderd (laatste bouwfase): de gedeelde spaanplaat krijgt de verfkleur. + const painted = state.settings.interiorPaint && BuildStages.shows(view3D.stage, 'afwerking'); + wallMaterials.spaanplaatMaterial.color.set(painted ? state.settings.interiorColor : SPAANPLAAT_COLOR); + parts = HouseModel.computeParts(state, { wallMaterials, floorMaterials, roofMaterials, lightingMaterials, sandwichMaterials, elektraMaterials, stage: view3D.stage, lampenVisible: view3D.lampen, elektraVisible: view3D.elektra }); + reportDirty = true; - if (houseGroup) scene.remove(houseGroup); + if (houseGroup) { scene.remove(houseGroup); disposeGroup(houseGroup); } houseGroup = new THREE.Group(); + planCaps = null; const toM = 0.001; parts.units.forEach(u => { u.floorGroup.position.set(u.rect.cx * toM, 0, u.rect.cz * toM); houseGroup.add(u.floorGroup); }); - // Vloerplan: snijvlak op het (hoogste) loopvlak + VLOERPLAN_CUT_MM. De - // wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit). - cutPlane.constant = (Math.max(...parts.units.map(u => u.layout.floorTop)) + VLOERPLAN_CUT_MM) * toM; - const clip = vloerplan ? [cutPlane] : []; + // Doorsnede: de wandmaterialen worden gedeeld, dus altijd zetten (ook weer uit). + const section = view3D.section; + const clip = section ? [cutPlane] : []; HouseModel.allWallPieces(parts).forEach(p => { p.group.rotation.y = p.place.rotY; p.group.position.set(p.place.cx * toM, p.place.floorTop * toM, p.place.cz * toM); @@ -1066,8 +1668,8 @@ function mountHouseEditor(container, callbacks) { }); houseGroup.add(p.group); }); - if (vloerplan) houseGroup.add(buildPlanCaps(cutPlane.constant / toM)); - if (dakVisible && !vloerplan) { + placeSection(); + if (view3D.dak && !section) { parts.units.forEach(u => { u.roof.group.position.set(u.roof.rect.cx * toM, u.roof.base * toM, u.roof.rect.cz * toM); houseGroup.add(u.roof.group); @@ -1075,7 +1677,8 @@ function mountHouseEditor(container, callbacks) { if (parts.roofEdgeGroup) houseGroup.add(parts.roofEdgeGroup); } if (parts.sandwichGroup) houseGroup.add(parts.sandwichGroup); - if (parts.lightingGroup && !vloerplan) houseGroup.add(parts.lightingGroup); + if (parts.lightingGroup && !section) houseGroup.add(parts.lightingGroup); + if (parts.elektraGroup) houseGroup.add(parts.elektraGroup); scene.add(houseGroup); const bx0 = Math.min(...parts.rects.map(r => r.x0)), bx1 = Math.max(...parts.rects.map(r => r.x1)); @@ -1085,6 +1688,8 @@ function mountHouseEditor(container, callbacks) { // Maaiveld: settings.groundLevel onder het loopvlak (bovenkant vloer). // 2 mm extra lager: bij maaiveld 0 niet in hetzelfde vlak als de OSB (flikkeren). const groundY = (Math.max(...parts.units.map(u => u.layout.floorTop)) - state.settings.groundLevel - 2) * toM; + signPos = null; + if (!section && BuildStages.shows(view3D.stage, 'afwerking')) houseGroup.add(buildLogoSign(groundY)); skyEnv.fitTo(houseGroup, groundY); ground.position.set(cx, groundY - 0.002, cz); applyRenderMode(); @@ -1096,10 +1701,68 @@ function mountHouseEditor(container, callbacks) { cameraFitted = true; } - if (!(opts && opts.keepProps)) renderProps(); - renderSpecs(totals); + // Selectie die na de herberekening niet meer bestaat (wandstuk hernoemd of vervallen): loslaten. + const stale = !!selection && !contextTab(); + if (stale) selection = null; + if (stale || !(opts && opts.keepProps)) syncRibbon(firstSync && !!selection); + else updateLive(); + firstSync = false; + renderWarnings(); + if (reportOpen) renderReport(); drawPlan(); - renderNav(); + renderNav(parts); + } + + // --- Bord met logo: paal + plaat naast het pad, enkele meters voor de + // voordeur, met de plaat naar buiten gericht (leesbaar vanaf de straat). + const SIGN = { ahead: 4000, aside: 1800, poleH: 2200, poleW: 80, plateW: 1500, plateD: 30 }; + let signPos = null; // mm, voor botsen tijdens lopen + let signMaterial = null; + // Paal en rand: één keer aanmaken (buildLogoSign draait bij elke refresh). + const signPoleMat = new THREE.MeshStandardMaterial({ color: 0x2b2b2b, roughness: 0.6, metalness: 0.3 }); + const signEdgeMat = new THREE.MeshStandardMaterial({ color: 0x212121, roughness: 0.6 }); + function getSignMaterial() { + if (signMaterial) return signMaterial; + const canvas = document.createElement('canvas'); + canvas.width = 1024; canvas.height = 340; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const tex = new THREE.CanvasTexture(canvas); + tex.anisotropy = 8; + if (window.TECTUM_LOGO) { + const img = new Image(); + img.onload = () => { + const w = canvas.width * 0.86, h = w / window.TECTUM_LOGO.aspect; + ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h); + tex.needsUpdate = true; + }; + img.src = window.TECTUM_LOGO.png; + } + signMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5 }); + return signMaterial; + } + function buildLogoSign(groundY) { + const { d, n } = frontDoor(); + // Opzij langs de gevel (rechts gezien vanaf de straat), zodat het pad vrij blijft. + const t = { x: n.z, z: -n.x }; + signPos = { x: d.x + n.x * SIGN.ahead + t.x * SIGN.aside, z: d.z + n.z * SIGN.ahead + t.z * SIGN.aside }; + const toM = 0.001, plateH = SIGN.plateW / 3; // canvas 1024 x 340 + const g = new THREE.Group(); + const pole = new THREE.Mesh(new THREE.BoxGeometry(SIGN.poleW * toM, SIGN.poleH * toM, SIGN.poleW * toM), signPoleMat); + pole.position.y = SIGN.poleH / 2 * toM; + g.add(pole); + const edge = signEdgeMat; + const face = getSignMaterial(); + // BoxGeometry-materialvolgorde: +x, -x, +y, -y, +z (voorkant), -z (achterkant). + const plate = new THREE.Mesh(new THREE.BoxGeometry(SIGN.plateW * toM, plateH * toM, SIGN.plateD * toM), + [edge, edge, edge, edge, face, face]); + plate.position.set(0, (SIGN.poleH - plateH / 2 - 100) * toM, (SIGN.poleW + SIGN.plateD) / 2 * toM); + g.add(plate); + g.traverse(o => { if (o.isMesh) o.receiveShadow = true; }); + g.position.set(signPos.x * toM, groundY, signPos.z * toM); + g.rotation.y = Math.atan2(n.x, n.z); // lokale +z = gevelnormaal (naar buiten) + return g; } // --- Lopen (first person), eigen sneltoets Shift+T --- @@ -1109,6 +1772,7 @@ function mountHouseEditor(container, callbacks) { function wallBlocked(xm, zm) { if (!parts) return false; const x = xm * 1000, z = zm * 1000; + if (signPos && Math.hypot(x - signPos.x, z - signPos.z) < SIGN.poleW / 2 + WALK_RADIUS) return true; return HouseModel.allWallPieces(parts).some(p => { if (p.place.baseY) return false; // opbouw boven een lager dak const c = Math.cos(p.place.rotY), s = Math.sin(p.place.rotY); @@ -1130,9 +1794,9 @@ function mountHouseEditor(container, callbacks) { } const walk = createWalkControls(camera, renderer.domElement, { blocked: wallBlocked, floorAt: groundAt }); let orbitSaved = null; - // Startpositie: 3 m voor de voordeur (anders een buitendeur, anders midden - // voor de noordgevel), kijkend naar de deur. - function walkStartPose() { + // Voordeur (anders een buitendeur, anders midden van de noordgevel): + // midden van de opening aan de buitenzijde (mm) + gevelnormaal naar buiten. + function frontDoor() { const ext = []; parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => ext.push(p)))); let best = null; @@ -1146,7 +1810,11 @@ function mountHouseEditor(container, callbacks) { best = { p, lx: 0 }; } const d = UnitModel.localToWorld(best.p.place, best.lx, best.p.place.thickness / 2); - const n = { x: best.p.place.normal.x, z: best.p.place.normal.y }; + return { d, n: { x: best.p.place.normal.x, z: best.p.place.normal.y } }; + } + // Startpositie: 3 m voor de voordeur, kijkend naar de deur. + function walkStartPose() { + const { d, n } = frontDoor(); const x = (d.x + n.x * 3000) / 1000, z = (d.z + n.z * 3000) / 1000; return { x, z, yaw: Math.atan2(-n.x, -n.z) }; } @@ -1180,7 +1848,21 @@ function mountHouseEditor(container, callbacks) { controls.update(); } } - walkHint.classList.toggle('show', on); + walkHint.classList.toggle('show', on && !shooter.active); + pane3D.classList.toggle('walking', on); + if (!on && shooter.active) shooter.stop(); + } + + // --- Easter egg: schieten (Ctrl+Shift+Alt+L), bovenop het lopen --- + const shooter = createShooter({ scene, camera, domElement: renderer.domElement, pane: pane3D, walk, + floorAt: groundAt, blocked: wallBlocked, targets: () => [houseGroup, ground] }); + function toggleShooter() { + if (shooter.active) { setWalk(false); return; } + if (!parts) return; + setWalk(true); + walk.speedFactor = 1.3; // 30% sneller in de shooter-modus + shooter.start(); + walkHint.classList.remove('show'); } let rafId = null, lastT = performance.now(); @@ -1188,6 +1870,7 @@ function mountHouseEditor(container, callbacks) { rafId = requestAnimationFrame(animate); const now = performance.now(); walk.update((now - lastT) / 1000); + shooter.update((now - lastT) / 1000); lastT = now; renderer.render(scene, camera); } @@ -1215,16 +1898,26 @@ function mountHouseEditor(container, callbacks) { unmount() { window.removeEventListener('resize', onResize); window.removeEventListener('keydown', onKey); + window.removeEventListener('mouseup', onElektraDrop); if (rafId !== null) cancelAnimationFrame(rafId); setWalk(false); // orbitcamera terug vóór het opslaan walk.dispose(); + shooter.dispose(); CameraMemory.save(overviewCamKey, camera, controls); + disposeGroup(houseGroup); + disposeLights(); + signPoleMat.dispose(); signEdgeMat.dispose(); + offRibbon(); + offCosts(); + Ribbon.setSource('editor', []); renderer.dispose(); }, refresh, + select, setTool, toggleFullscreen3D: () => toggleView(UI.cycle3D), toggleWalk: () => setWalk(!walk.active), + toggleShooter, toggleFullscreen2D: () => toggleView(UI.toggle2D) }; } @@ -1341,6 +2034,7 @@ function mountHouseEditor(container, callbacks) { mirrorHorizontal: forward('mirrorHorizontal'), toggleFullscreen3D: forward('toggleFullscreen3D'), toggleWalk: forward('toggleWalk'), + toggleShooter: forward('toggleShooter'), toggleFullscreen2D: forward('toggleFullscreen2D'), openProjectProperties, getStateJson, diff --git a/js/lighting/lighting-model.js b/js/lighting/lighting-model.js index 7ad1a15..4c5cc5f 100644 --- a/js/lighting/lighting-model.js +++ b/js/lighting/lighting-model.js @@ -251,6 +251,23 @@ window.LightingModel = (() => { return Math.hypot(p.x - (a.x + t * dx), p.z - (a.z + t * dz)); } + // Binnenruimte van een unit (woning-mm): de rect min de buitenwanden, en + // waar een naadwanddeel van deze unit tegen de naad staat, tot diens + // binnenzijde (net als bij een buitenwand). Ook gebruikt door ElektraModel. + function unitInnerRect(u, interior) { + const T = {}; + u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; }); + const r = u.rect; + interior.filter(iw => iw.seamUnit === u.id).forEach(iw => { + const p = iw.place, horiz = Math.abs(Math.sin(p.rotY)) < 0.5; + const [side, d] = horiz + ? (Math.abs(p.cz - r.z0) < Math.abs(p.cz - r.z1) ? ['voor', p.cz - r.z0] : ['achter', r.z1 - p.cz]) + : (Math.abs(p.cx - r.x0) < Math.abs(p.cx - r.x1) ? ['links', p.cx - r.x0] : ['rechts', r.x1 - p.cx]); + T[side] = Math.max(T[side] || 0, d + p.thickness / 2); + }); + return { x0: r.x0 + (T.links || 0), x1: r.x1 - (T.rechts || 0), z0: r.z0 + (T.voor || 0), z1: r.z1 - (T.achter || 0) }; + } + // Binnenspots per unit. units = HouseModel-parts.units (rect, walls, roof.ceilingAt), // interior = parts.interior (binnenwanden met place). Geeft // [{ unitId, x, z, y, floorY }] (mm, y = onderkant plafond, floorY = loopvlak). @@ -259,21 +276,10 @@ window.LightingModel = (() => { if (!s.indoor) return []; const out = []; units.forEach(u => { - const T = {}; - u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; }); - const r = u.rect, off = s.indoorOffset; - // Naadwanddelen van deze unit staan binnen de rect tegen de naad: het - // raster begint dan vanaf hun binnenzijde (net als bij een buitenwand). + const off = s.indoorOffset, inner = unitInnerRect(u, interior); const own = interior.filter(iw => !iw.seamUnit || iw.seamUnit === u.id); - own.filter(iw => iw.seamUnit).forEach(iw => { - const p = iw.place, horiz = Math.abs(Math.sin(p.rotY)) < 0.5; - const [side, d] = horiz - ? (Math.abs(p.cz - r.z0) < Math.abs(p.cz - r.z1) ? ['voor', p.cz - r.z0] : ['achter', r.z1 - p.cz]) - : (Math.abs(p.cx - r.x0) < Math.abs(p.cx - r.x1) ? ['links', p.cx - r.x0] : ['rechts', r.x1 - p.cx]); - T[side] = Math.max(T[side] || 0, d + p.thickness / 2); - }); - const xs = gridPositions(r.x0 + (T.links || 0) + off, r.x1 - (T.rechts || 0) - off, s.indoorMin, s.indoorMax); - const zs = gridPositions(r.z0 + (T.voor || 0) + off, r.z1 - (T.achter || 0) - off, s.indoorMin, s.indoorMax); + const xs = gridPositions(inner.x0 + off, inner.x1 - off, s.indoorMin, s.indoorMax); + const zs = gridPositions(inner.z0 + off, inner.z1 - off, s.indoorMin, s.indoorMax); xs.forEach(x => zs.forEach(z => { const tooClose = own.some(iw => { const half = iw.place.width / 2, c = Math.cos(iw.place.rotY), sn = Math.sin(iw.place.rotY); @@ -374,8 +380,117 @@ window.LightingModel = (() => { const drop = (p.y - p.floorY) * toM; // plafond -> loopvlak 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); }); + if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo)); return group; } - return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, createMaterials, build3D, buildLights3D }; + // ------------------------------------------------------------ + // Nep-lichtvlekken (wall-wash) voor de render. Echte spots mogen niet op + // de gevel schijnen (geen schaduw: het licht zou door de muur naar binnen + // gaan, zie buildLights3D). In plaats daarvan komt op het gevelvlak een + // additief, zacht kegelvormig verloop: smal en fel bij de lamp, breder en + // zwakker naar beneden (of naar boven bij een up-light). Geen licht- + // berekening, dus geen grens aan het aantal. + // walls = buitenwandstukken (place, buitenMateriaal, stone); groundY = mm. + // ------------------------------------------------------------ + const WASH = { color: 0xffd9a0, spot: 0.55, lamp: 0.45, spread: 0.5, lampReach: 1800 }; + let washTexture = null; + function getWashTexture() { + if (washTexture) return washTexture; + const W = 128, H = 256; + const canvas = document.createElement('canvas'); + canvas.width = W; canvas.height = H; + const ctx = canvas.getContext('2d'); + const img = ctx.createImageData(W, H); + for (let j = 0; j < H; j++) { + const t = j / (H - 1); // 0 = bij de lamp, 1 = einde bundel + const half = 0.12 + 0.88 * Math.pow(t, 0.6); // bundelbreedte (fractie van de halve breedte) + const fade = Math.min(1, t * 12) * Math.pow(1 - t, 1.6); // zacht begin, uitdoven + for (let i = 0; i < W; i++) { + const x = Math.abs((i + 0.5) / W * 2 - 1) / half; + const v = x >= 1 ? 0 : fade * Math.pow(1 - x * x, 2); + const k = (j * W + i) * 4; + img.data[k] = img.data[k + 1] = img.data[k + 2] = Math.round(255 * v); + img.data[k + 3] = 255; + } + } + ctx.putImageData(img, 0, 0); + washTexture = new THREE.CanvasTexture(canvas); + return washTexture; + } + function washMesh(widthM, heightM, opacity, up) { + const mat = new THREE.MeshBasicMaterial({ + map: getWashTexture(), color: WASH.color, transparent: true, opacity, + blending: THREE.AdditiveBlending, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2 + }); + const geo = new THREE.PlaneGeometry(widthM, heightM); + // Lamp aan de bovenrand (down) resp. onderrand (up). + geo.translate(0, up ? heightM / 2 : -heightM / 2, 0); + if (up) { // textuur gespiegeld: lamp onderaan + const uv = geo.attributes.uv; + for (let i = 0; i < uv.count; i++) uv.setY(i, 1 - uv.getY(i)); + } + const m = new THREE.Mesh(geo, mat); + m.renderOrder = 2; + return m; + } + function buildWash3D(lamps, spots, spotY, walls, groundY, fo) { + const toM = 0.001; + const group = new THREE.Group(); + // Gevelvlak achter een punt (mm) met buitenrichting n: dichtstbijzijnde + // wandstuk met dezelfde normaal waar het punt vóór ligt. + const facadeBehind = (q, n) => { + let best = null; + walls.forEach(p => { + const pl = p.place; + if (pl.normal.x * n.x + pl.normal.y * n.z < 0.9) return; + const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY); + const dx = q.x - pl.cx, dz = q.z - pl.cz; + const lx = dx * c - dz * sn, lz = dx * sn + dz * c; + const face = pl.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone); + const d = lz - face; + if (Math.abs(lx) > pl.width / 2 + 50 || d < -5 || d > 1500) return; + if (!best || d < best.d) best = { d, piece: p, lx, face }; + }); + return best; + }; + const place = (mesh, pl, lx, lz, yMM) => { + const w = UnitModel.localToWorld(pl, lx, lz); + mesh.position.set(w.x * toM, yMM * toM, w.z * toM); + mesh.rotation.y = pl.rotY; + group.add(mesh); + }; + // Boeideel-spots: van de spot omlaag tot het maaiveld (op een hoek, + // diagonale normaal, geen vlek). + (spots || []).forEach(sp => { + const n = sp.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1; + const nn = { x: n.x / len, z: n.z / len }; + if (Math.abs(nn.x) > 0.1 && Math.abs(nn.z) > 0.1) return; + const top = (sp.y !== undefined ? sp.y : spotY) - 30; + const hit = facadeBehind(sp, nn); + if (!hit || top - groundY < 300) return; + const H = (top - groundY) * toM; + place(washMesh(2 * (0.1 + H * WASH.spread), H, WASH.spot * fo), hit.piece.place, hit.lx, hit.face + 6, top); + }); + // Gevellampen (op de gevel): bundel omlaag en/of omhoog. + (lamps || []).forEach(l => { + const n = { x: l.normal.x, z: l.normal.z }; + const hit = facadeBehind({ x: l.x + n.x * 20, z: l.z + n.z * 20 }, n); + if (!hit) return; + const pl = hit.piece.place; + if (l.direction !== 'up') { + const H = Math.min(WASH.lampReach, l.y - 90 - groundY) * toM; + if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo), pl, hit.lx, hit.face + 6, l.y - 90); + } + if (l.direction !== 'down') { + // Niet boven de gevel uit (tot de bovenkant van het dak). + const wallTop = pl.floorTop + WallModel.topLine(hit.piece.config).maxY + (hit.piece.skinAbove || 0); + const H = Math.min(WASH.lampReach, wallTop - l.y - 90) * toM; + if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo, true), pl, hit.lx, hit.face + 6, l.y + 90); + } + }); + return group; + } + + return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D }; })(); diff --git a/js/roof/roof-model.js b/js/roof/roof-model.js index 9882d02..fc84cff 100644 --- a/js/roof/roof-model.js +++ b/js/roof/roof-model.js @@ -13,8 +13,9 @@ // bovenop. Het "dek" van een dak is dus gewoon een vloer-state. // // Dakvorm volgt de wanden (computeShape): het paar tegenoverliggende wanden -// met een schuine bovenrand of middenpunt (nok) zijn de kopgevels, het dak -// volgt hun daklijn (plat, lessenaar of zadel). UnitModel.syncWallHeights +// met een schuine bovenrand of tussenpunten (nok, knikken) zijn de kopgevels, +// het dak volgt hun daklijn (plat, lessenaar, zadel, mansarde of wolfsdak; +// bij een wolfsdak komen daar de wolfseinden bij, zie computeShape). UnitModel.syncWallHeights // houdt de wanden daarvoor consistent. Het dek (in de plattegrond) wordt per // dakvlak omgerekend naar een schuin vlak met de balken als sporen (van // laag naar hoog); doorvoeren gaan mee naar het vlak waarin ze vallen. @@ -139,8 +140,10 @@ window.RoofModel = (() => { const m = opts.materials; const toM = 0.001; const group = new THREE.Group(); + // Plafondplaat én bedekking zichtbaar: de sporen ertussen zijn ingesloten. const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, { - materials: m, osbVisible: opts.osbVisible !== false + materials: m, osbVisible: opts.osbVisible !== false, + outerJoistsOnly: opts.osbVisible !== false && opts.dakleerVisible !== false }); const deckH = (calcData.joistH + calcData.underlayThick) * toM; deck.scale.y = -1; @@ -169,8 +172,7 @@ window.RoofModel = (() => { leer.rotation.x = Math.PI / 2; leer.position.y = deckH + cov.thickMM * toM; leer.castShadow = cov.thickMM > DAKLEER_MM; - leer.visible = opts.dakleerVisible !== false; - group.add(leer); + if (opts.dakleerVisible !== false) group.add(leer); return group; } @@ -202,22 +204,41 @@ window.RoofModel = (() => { const s = UnitModel.WALL_SIDES.find(x => x.key === side); const c = s.axis === 'x' ? wl.cx : wl.cz; const pts = [{ p: c - s.dir * wl.width / 2, h: w.heightLeft }, { p: c + s.dir * wl.width / 2, h: w.heightRight }]; - if (w.heightMid > 0) pts.push({ p: c, h: Math.max(w.heightMid, w.heightLeft, w.heightRight) }); + // Tussenpunten (nok, knikken van mansarde/wolfsdak), x vanaf links. + WallModel.wallTopPoints(w, wl.width, w.heightLeft, w.heightRight) + .forEach(q => pts.push({ p: c - s.dir * wl.width / 2 + s.dir * q.x, h: q.h })); return pts.sort((a, b) => a.p - b.p); } // Dakvorm van een unit. layout = UnitModel.computeLayout-resultaat. - // Geeft { type: 'plat'|'lessenaar'|'zadel', axis: 'x'|'z', profile }: - // de hoogte van de dakonderkant (vanaf het loopvlak) hangt alleen af van - // de positie langs axis (profileHeight). Kopgevels: de eerste wand met - // een schuine daklijn (noord/zuid -> dak helt langs X, west/oost -> langs Z). - // Zonder schuine wanden: plat op de hoogste wand. + // Geeft { type: 'plat'|'lessenaar'|'zadel'|'mansarde'|'wolf', axis: 'x'|'z', + // profile, hip? }: de hoogte van de dakonderkant (vanaf het loopvlak) + // hangt alleen af van de positie langs axis (profileHeight), behalve bij + // een wolfsdak (hip). Kopgevels: de eerste wand met een schuine daklijn + // (noord/zuid -> dak helt langs X, west/oost -> langs Z). Zonder schuine + // wanden: plat op de hoogste wand. + // zadel 3 punten (goot, nok, goot) + // mansarde 5 punten (goot, knik, nok, knik, goot) + // wolf 4 punten met een vlakke top: de kopgevel stopt op het + // wolfseind. Het profiel wordt dat van het zadeldak (de + // schuine stukken doorgetrokken tot de nok), met de knikken op + // het wolfseind; hip = { h }: de wolfseinden lopen vanaf de + // kopgevels op hoogte h omhoog tot de nok (computeCap). function computeShape(unitState, layout) { for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) { const profile = wallProfile(unitState, layout, side); - if (profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) { - return { type: profile.length > 2 ? 'zadel' : 'lessenaar', axis, profile }; + if (!profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) continue; + if (profile.length === 4 && Math.abs(profile[1].h - profile[2].h) < 0.5) { + const [L, A, B, R] = profile, sA = (A.h - L.h) / (A.p - L.p), sB = (R.h - B.h) / (R.p - B.p); + // Snijpunt van de doorgetrokken schuine stukken = nok. + const pr = Math.abs(sA - sB) > 1e-9 ? (B.h - sB * B.p - A.h + sA * A.p) / (sA - sB) : (A.p + B.p) / 2; + const ridge = { p: pr, h: A.h + sA * (pr - A.p) }; + if (sA > 0 && sB < 0 && pr > A.p && pr < B.p) { + return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } }; + } } + const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel'; + return { type, axis, profile }; } const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws))); return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] }; @@ -297,22 +318,200 @@ window.RoofModel = (() => { }); }); - const planeAt = (q) => planes.find((pl, i) => q < pl.p1 || i === planes.length - 1); - const undersideAt = (x, z) => H(alongX ? x : z); - const topAt = (x, z) => { + // Knik tussen twee vlakken met een verschillende helling (mansarde): + // de bovenkanten snijden elkaar vóór (het steilere vlak is verticaal + // dikker), dus de vlakken sluiten in verstek aan: onderkant op de knik, + // bovenkant op het snijpunt van de bovenvlakken (miter = verschuiving + // van de bovenkant t.o.v. de knik, mm langs de as; 0 = verticaal). + planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; }); + planes.slice(0, -1).forEach((pi, i) => { + const pj = planes[i + 1], ai = Math.tan(pi.theta), aj = Math.tan(pj.theta); + if (Math.abs(ai - aj) < 1e-4) return; + const dp = (pj.vThick - pi.vThick) / (ai - aj); + pi.miter1 = dp; pj.miter0 = dp; + }); + const mainUnder = (x, z) => H(alongX ? x : z); + // Bovenkant: het laagste van de (doorgetrokken) bovenvlakken; de daklijn + // is bol, dus dat is precies het dak, ook rond een knik. + const mainTop = (x, z) => { const q = alongX ? x : z; - const onKink = planes.filter(pl => Math.abs(q - pl.p0) < 0.5 || Math.abs(q - pl.p1) < 0.5); - const t = onKink.length > 1 ? Math.max(...onKink.map(pl => pl.vThick)) : planeAt(q).vThick; - return H(q) + t; + return Math.min(...planes.map(pl => pl.h0 + (q - pl.p0) * Math.tan(pl.theta) + pl.vThick)); }; + // Wolfsdak: de vlakken tussen de wolfseind-knikken worden geen + // rechthoekige dekken; dat bovenste deel (cap, computeCap) is het laagste + // van de twee dakvlakken en de twee wolfseinden, met één top. + let cap = null, undersideAt = mainUnder, topAt = mainTop, outPlanes = planes; + if (shape.type === 'wolf' && shape.hip && kinks.length === 3) { + cap = computeCap(roofState, shape, rect, alongX, kinks, covering); + outPlanes = planes.filter(pl => !(pl.p0 >= cap.pA - 0.5 && pl.p1 <= cap.pB + 0.5)); + const at = (x, z) => ({ x: alongX ? x : z, z: alongX ? z : x }); // (p, q) + undersideAt = (x, z) => Math.min(mainUnder(x, z), ...cap.regions.map(rg => rg.under(at(x, z)))); + topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick)); + } return { - shape, covering, planes, specs: sumSpecs(planes.map(pl => pl.specs)), - undersideAt, topAt, + shape, covering, planes: outPlanes, specs: sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : [])), + undersideAt, topAt, cap, rect, alongX, ridges: kinks.map(value => ({ axis: shape.axis, value })), - maxTop: Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick)) + maxTop: cap ? cap.maxTop : Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick)) }; } + // ------------------------------------------------------------ + // Bovenste deel van een wolfsdak (cap). Coördinaten (p, q) als { x: p, + // z: q } (unit-lokaal mm): p langs de helling van het hoofddak, q langs de + // nok. Band p = pA..pB (wolfseind-knikken), q = q0..q1 (de kopgevels). + // Vier vlakken (onderkant dak): m1/m2 de twee dakvlakken, h1/h2 de + // wolfseinden: vanaf de kopgevel op hipH met de dakhelling omhoog tot de + // nokhoogte (driehoek), daartussen een rechte nok op pR. Is de unit te kort + // voor een nokstuk, dan worden de wolfseinden steiler en lopen ze in één + // punt samen. Per vlak het gebied waar het het laagste is (regions). + // Houtwerk zoals de dekken: plafondplaat, sporen (dakvlakken: op dezelfde + // q als de balken van het onderste dek; wolfseinden: verdeeld over pA..pB), + // hoekkepers van elke hoek van de band naar het nokeinde, nokbalk, kopbalk + // langs de voet van een wolfseind. + // ------------------------------------------------------------ + const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel + function computeCap(roofState, shape, rect, alongX, kinks, covering) { + const H = (p) => profileHeight(shape.profile, p); + const [pA, pR, pB] = kinks, hipH = shape.hip.h, top = H(pR); + const q0 = alongX ? rect.z0 : rect.x0, q1 = alongX ? rect.z1 : rect.x1, qM = (q0 + q1) / 2; + const fp = roofState.deck.floor; + const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22; + const cov = COVERINGS[covering] || COVERINGS.dakleer; + const sA = (top - H(pA)) / (pR - pA), sB = (top - H(pB)) / (pB - pR); + // Helling wolfseinden: die van het dak, maar minstens zo steil dat ze de + // nokhoogte halen vóór het midden tussen de kopgevels. + const tH = Math.max((sA + sB) / 2, (top - hipH) / Math.max(1, qM - q0)); + const inset = (top - hipH) / tH; // nokeinde vanaf de kopgevel + const plane = (key, slope, f, along) => { + const cos = Math.cos(Math.atan(slope)); + return { key, f, along, cos, tV: underlay / cos, jV: joistH / cos, covV: cov.thickMM / cos }; + }; + const fns = [ + plane('m1', sA, (p) => H(pA) + (p.x - pA) * sA, 'p'), + plane('m2', sB, (p) => H(pB) + (pB - p.x) * sB, 'p'), + plane('h1', tH, (p) => hipH + (p.z - q0) * tH, 'q'), + plane('h2', tH, (p) => hipH + (q1 - p.z) * tH, 'q') + ]; + const band = [{ x: pA, z: q0 }, { x: pB, z: q0 }, { x: pB, z: q1 }, { x: pA, z: q1 }]; + const regions = fns.map((fi, i) => { + let poly = band; + fns.forEach((fj, j) => { if (j !== i && poly.length >= 3) poly = clipPoly(poly, (p) => fj.f(p) - fi.f(p)); }); + // Samenvallende punten (afsnijden door de top) weg. + poly = poly.filter((p, k) => { const q = poly[(k + 1) % poly.length]; return Math.hypot(q.x - p.x, q.z - p.z) > 0.5; }); + return Object.assign({}, fi, { poly, under: fi.f, vThick: fi.tV + fi.jV + fi.covV }); + }).filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100); + + // Snijden van een lijn (vaste p of vaste q) met een convexe veelhoek. + const cutLine = (poly, fixed, v) => { + const ts = []; + poly.forEach((a, k) => { + const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed]; + if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return; + const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x'; + ts.push(a[o] + (b[o] - a[o]) * t); + }); + return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null; + }; + // Balken als veelhoek in de plattegrond (rechthoek van balkbreedte rond + // de hartlijn A-B, afgezaagd tegen de balken waar hij op aansluit) met + // de onderkant op het dakvlak (yAt: + plafondplaat). Lengte = langs de + // balk, hoek = helling van de balk. + const hw = joistW / 2; + const rectAround = (A, B) => { + const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw; + return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }]; + }; + // Halfvlak aan de kant van `from` naast het zijvlak van balk m (>= 0 blijft). + const awayFrom = (m, from) => { + const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L; + const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz; + const sgn = d(from) >= 0 ? 1 : -1; + return (p) => sgn * d(p) - hw; + }; + const distToSeg = (p, m) => { + const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz; + const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2)); + return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t)); + }; + const beams = []; + const addBeam = (kind, rg, A, B, clips, lineHeight) => { + if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null; + let poly = rectAround(A, B); + (clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); }); + if (poly.length < 3) return null; + const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz); + const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV; + // Hartlijnbalk (hoekkeper, nokbalk): hoogte langs de hartlijn; anders op het vlak. + const yAt = lineHeight + ? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L))) + : (p) => rg.under(p) + rg.tV; + const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L); + const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L; + if (plan < 50) return null; + const bm = { + kind, A, B, poly, yAt, jV: rg.jV, + length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10 + }; + beams.push(bm); + return bm; + }; + // Raamwerk: kopbalken (binnen de kopgevel, op de bovenregel), hoekkepers + // van elke hoek van de band naar het nokeinde, nokbalk ertussen. De + // hoekkepers staan op de kopbalk en sluiten bovenin in verstek op elkaar + // aan; de nokbalk is afgezaagd tegen de hoekkepers. + const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0]; + const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0]; + const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }]; + const hasRidge = ends[1].z - ends[0].z >= 100; + const kops = [q0 + hw, q1 - hw].map(qv => addBeam('kopbalk', hOf(qv), { x: pA, z: qv }, { x: pB, z: qv }, [ + (p) => p.z - q0 - 0.01, (p) => q1 - p.z - 0.01])).filter(Boolean); + const hips = band.map(c => { + const e = c.z < qM ? ends[0] : ends[1], sp = Math.sign(pR - c.x), sq = Math.sign(qM - c.z); + const clips = [(p) => sp * (pR - p.x)]; + if (!hasRidge) clips.push((p) => sq * (qM - p.z)); + // Op de kopbalk: vanaf zijn binnenkant. + clips.push((p) => sq * (c.z + sq * joistW - p.z) * -1); + return addBeam('hoekkeper', mOf(c.x), c, e, clips, true); + }).filter(Boolean); + let ridgeBeam = null; + if (hasRidge) { + const mid = { x: pR, z: qM }; + ridgeBeam = addBeam('nokbalk', mOf(pR - 1), ends[0], ends[1], hips.map(h => awayFrom(h, mid)), true); + } + const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []); + // Sporen: afgezaagd tegen de balk(en) waar ze op uitkomen. + const spread = (len) => FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: joistW, joistHeight: joistH, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: underlay }).joists.map(x => x + len / 2); + const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(pB - pA).map(v => pA + v); + regions.forEach(rg => { + const fixed = rg.along === 'p' ? 'z' : 'x'; + (rg.along === 'p' ? qs : ps).forEach(v => { + const span = cutLine(rg.poly, fixed, v); + if (!span) return; + const pt = (t) => fixed === 'z' ? { x: t, z: v } : { x: v, z: t }; + const A = pt(span[0]), B = pt(span[1]), mid = pt((span[0] + span[1]) / 2); + const clips = members.filter(m => distToSeg(A, m) < hw + 1 || distToSeg(B, m) < hw + 1).map(m => awayFrom(m, mid)); + addBeam('spoor', rg, A, B, clips); + }); + }); + + // Specificaties (zelfde sleutels als computeSpecs). + const W = joistW * 1e-3, Hh = joistH * 1e-3; + const woodM3 = beams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0); + const areaM2 = regions.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6; + const isLeer = !COVERINGS[covering] || covering === 'dakleer'; + const covKg = areaM2 * cov.kgM2, osbVol = areaM2 * underlay * 1e-3; + const specs = { + joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3, + osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3, + voidM3: Math.max(0, areaM2 * Hh - woodM3), + dakleerM2: isLeer ? areaM2 : 0, dakleerKg: isLeer ? covKg : 0, rietM2: isLeer ? 0 : areaM2, rietKg: isLeer ? 0 : covKg, + totalKg: woodM3 * DENSITY_WOOD_KGM3 + osbVol * DENSITY_OSB_KGM3 + covKg + }; + const maxTop = top + Math.max(...regions.map(rg => rg.vThick)); + return { pA, pR, pB, q0, q1, hipH, joistW, joistH, regions, beams, specs, maxTop }; + } + // 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek, // Y = 0 op het loopvlak): per dakvlak build3D, zo vervormd dat de // onderkant van het plafond precies op de daklijn van de wanden ligt. @@ -327,6 +526,7 @@ window.RoofModel = (() => { function buildUnit3D(unitRoof, opts) { const toM = 0.001; const group = new THREE.Group(); + if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts)); unitRoof.planes.forEach(pl => { const g = new THREE.Group(); g.position.set(pl.cx * toM, pl.y * toM, pl.cz * toM); @@ -339,13 +539,104 @@ window.RoofModel = (() => { 0, 1 / c, sn, 0, 0, 0, c, 0, 0, 0, 0, 1); - shear.add(build3D(pl.data.calcData, pl.data.resolvedOpenings, Object.assign({ covering: unitRoof.covering }, opts))); + const deck = build3D(pl.data.calcData, pl.data.resolvedOpenings, Object.assign({ covering: unitRoof.covering }, opts)); + if (pl.miter0 || pl.miter1) miterDeck(deck, pl, c); + shear.add(deck); g.add(shear); group.add(g); }); return group; } + // Verstek op de uiteinden van een dek (vóór de afschuiving; lokaal z + // langs de helling, -L/2 .. L/2, y = 0 onderkant .. T bovenkant): elk + // hoekpunt op een uiteinde schuift met de hoogte mee naar de verstekhoek + // (pl.miter0 / miter1 in mm langs de as, omgerekend langs de helling). + function miterDeck(deck, pl, cos) { + const toM = 0.001, cd = pl.data.calcData; + const L = cd.depth * toM, T = pl.vThick * cos * toM; + const d0 = pl.miter0 * toM / cos, d1 = pl.miter1 * toM / cos; + const v = new THREE.Vector3(), rel = new THREE.Matrix4(), inv = new THREE.Matrix4(); + deck.updateMatrixWorld(true); + const base = new THREE.Matrix4().copy(deck.matrixWorld).invert(); + deck.traverse(o => { + const pos = o.geometry && o.geometry.attributes && o.geometry.attributes.position; + if (!pos) return; + rel.multiplyMatrices(base, o.matrixWorld); + inv.copy(rel).invert(); + for (let k = 0; k < pos.count; k++) { + v.fromBufferAttribute(pos, k).applyMatrix4(rel); + const t = Math.max(0, Math.min(1, v.y / T)); + if (d1 && v.z > L / 2 - 1e-4) v.z = L / 2 + d1 * t; + else if (d0 && v.z < -L / 2 + 1e-4) v.z = -L / 2 + d0 * t; + else continue; + v.applyMatrix4(inv); + pos.setXYZ(k, v.x, v.y, v.z); + } + pos.needsUpdate = true; + o.geometry.computeBoundingSphere(); + if (o.geometry.attributes.normal) o.geometry.computeVertexNormals(); + }); + } + + // Bovenste deel van een wolfsdak (unitRoof.cap, zie computeCap): per + // hellend vlak plafondplaat en bedekking (verticale dikte, UV langs de + // helling), sporen/hoekkepers/kopbalken als balken met verticale kopse + // kanten (zoals de afgeschoven dekken). Plaat én bedekking zichtbaar: + // alleen de kopbalken (de rest is ingesloten). + function capRegions(unitRoof) { return unitRoof.cap ? unitRoof.cap.regions : []; } + function buildCap3D(unitRoof, opts) { + const toM = 0.001, m = opts.materials, c = unitRoof.cap, r = unitRoof.rect; + const xc = (r.x0 + r.x1) / 2, zc = (r.z0 + r.z1) / 2; + const group = new THREE.Group(); + const osbOn = opts.osbVisible !== false, covOn = opts.dakleerVisible !== false; + const covMat = coveringMaterial(unitRoof.covering) || m.dakleerMaterial; + // (p, q) -> wereld (m, t.o.v. het dakmidden). + const P = (pt, y) => { + const x = unitRoof.alongX ? pt.x : pt.z, z = unitRoof.alongX ? pt.z : pt.x; + return [(x - xc) * toM, y * toM, (z - zc) * toM]; + }; + // Plaat onder een convexe veelhoek: onderkant bottom(pt), dikte h (verticaal). + function slab(poly, bottom, h, topMat, sideMat, uvOf) { + const n = poly.length, pos = [], uv = []; + const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt))); + const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0]; + const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); }; + // Oriëntatie zo dat de bovenkant naar boven wijst. + const up = (T[1][0] - T[0][0]) * (T[2][2] - T[0][2]) - (T[1][2] - T[0][2]) * (T[2][0] - T[0][0]) < 0; + for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]); + const topCount = pos.length / 3; + for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2); + for (let k = 0; k < n; k++) { + const j = (k + 1) % n; + up ? (tri(T[k], B[k], B[j], z2, z2, z2), tri(T[k], B[j], T[j], z2, z2, z2)) + : (tri(T[k], B[j], B[k], z2, z2, z2), tri(T[k], T[j], B[j], z2, z2, z2)); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); + geo.addGroup(0, topCount, 0); + geo.addGroup(topCount, pos.length / 3 - topCount, 1); + geo.computeVertexNormals(); + const mesh = new THREE.Mesh(geo, [topMat, sideMat]); + mesh.castShadow = true; + mesh.receiveShadow = true; + group.add(mesh); + } + c.regions.forEach(rg => { + if (osbOn) slab(rg.poly, rg.under, rg.tV, m.osbMaterial, m.osbMaterial); + if (covOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV, rg.covV, covMat, covMat, + (pt) => rg.along === 'p' ? [pt.z * toM, pt.x * toM / rg.cos] : [pt.x * toM, pt.z * toM / rg.cos]); + }); + // Balken: hun (afgezaagde) veelhoek in de plattegrond, onderkant op + // bm.yAt, hoogte jV verticaal. + c.beams.forEach(bm => { + if (osbOn && covOn && bm.kind !== 'kopbalk') return; + slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial); + }); + return group; + } + // ------------------------------------------------------------ // Dakrand over de hele woning // ------------------------------------------------------------ @@ -593,7 +884,7 @@ window.RoofModel = (() => { return { DAKLEER_MM, EDGE_ABOVE_MM, RIET, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl, createDefaultRoof, thickness, createMaterials, build3D, computeSpecs, - computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, + computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions, computeEdge, buildEdge3D, normalizeSettings }; })(); diff --git a/js/unit/unit-model.js b/js/unit/unit-model.js index 02e649a..0c6712b 100644 --- a/js/unit/unit-model.js +++ b/js/unit/unit-model.js @@ -192,24 +192,31 @@ window.UnitModel = (() => { const fullConfig = WallModel.configFromState(wallState); const W = wallLayout.width; const hL = wallState.wall.heightLeft, hR = wallState.wall.heightRight; - // Middenpunt (nok) midden op de wand; een stuk krijgt de nok alleen als - // die erin valt, anders de daklijn ter plaatse als gewone helling. - const hM = wallState.wall.heightMid > 0 ? Math.max(wallState.wall.heightMid, hL, hR) : null; - const pU = W / 2; - const heightAt = (u) => hM === null ? hL + (u / W) * (hR - hL) - : u <= pU ? hL + (u / pU) * (hM - hL) : hM + ((u - pU) / (W - pU)) * (hR - hM); + // Tussenpunten van de daklijn (nok, knikken; WallModel.wallTopPoints); + // een stuk krijgt de punten die erin vallen, verder de daklijn ter + // plaatse als gewone helling. + const tops = WallModel.wallTopPoints(wallState.wall, W, hL, hR); + const tp = [{ x: 0, h: hL }, ...tops, { x: W, h: hR }]; + const heightAt = (u) => { + let i = 0; + while (i < tp.length - 2 && u > tp[i + 1].x) i++; + const a = tp[i], b = tp[i + 1]; + return a.h + (u - a.x) / (b.x - a.x) * (b.h - a.h); + }; + const topsIn = (a, b, base) => { + const inner = tops.filter(q => q.x > a + 1 && q.x < b - 1); + return inner.length ? { heightMid: null, topPts: inner.map(q => ({ f: (q.x - a) / (b - a), h: q.h - base })) } : { heightMid: null, topPts: undefined }; + }; const multi = wallLayout.pieces.length > 1; const used = new Set(); const pieces = wallLayout.pieces.map(([a, b, inL, inR], i) => { const openings = fullConfig.openings .filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS) - .map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; }); - const hasPeak = hM !== null && a < pU - 1 && b > pU + 1; + .map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y, anchor: 'left', offset: op.x - a }; }); const params = Object.assign({}, wallState.wall, { - width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b)), - heightMid: hasPeak ? hM : null, peakX: hasPeak ? pU - a : undefined - }); + width: b - a, heightLeft: Math.round(heightAt(a)), heightRight: Math.round(heightAt(b)) + }, topsIn(a, b, 0)); const config = WallModel.buildConfig(params, openings); return { suffix: multi ? String.fromCharCode(97 + i) : '', // a, b, ... @@ -228,16 +235,14 @@ window.UnitModel = (() => { const ins = wallLayout.endInset || { left: 0, right: 0 }; raisedList.forEach((r, i) => { const { u0: a, u1: b, base } = r; - const hasPeak = hM !== null && a < pU - 1 && b > pU + 1; const hA = heightAt(a) - base, hB = heightAt(b) - base; if (Math.min(hA, hB) < MIN_RAISE_MM) return; const openings = fullConfig.openings .filter(op => op.x >= a - EPS && op.x + op.w <= b + EPS && op.y >= base - EPS) - .map(op => { used.add(op.id); return { id: op.id, type: op.type, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; }); + .map(op => { used.add(op.id); return { id: op.id, type: op.type, fill: op.fill, w: op.w, h: op.h, hR: op.hR, y: op.y - base, anchor: 'left', offset: op.x - a }; }); const params = Object.assign({}, wallState.wall, { - width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB), - heightMid: hasPeak ? hM - base : null, peakX: hasPeak ? pU - a : undefined - }); + width: b - a, heightLeft: Math.round(hA), heightRight: Math.round(hB) + }, topsIn(a, b, base)); const config = WallModel.buildConfig(params, openings); pieces.push({ suffix: 'o' + (raisedList.length > 1 ? i + 1 : ''), // opbouw @@ -258,9 +263,11 @@ window.UnitModel = (() => { // Wandhoogtes koppelen, zodat er altijd een logisch dak op past. De // bewerkte wand (side) bepaalt de dakvorm: // - de tegenoverliggende wand krijgt dezelfde hoogtes gespiegeld (van - // buitenaf gezien wisselen links en rechts), incl. middenpunt (nok); - // - de twee haakse wanden worden vlak op de hoogte van de hoek waar ze - // op aansluiten (goothoogte bij een lessenaar- of zadeldak). + // buitenaf gezien wisselen links en rechts), incl. nok en knikken; + // - de twee haakse wanden worden vlak op de hoogte van het dak boven + // hun buitenkant (goothoogte bij een lessenaar- of zadeldak). Staat + // de kopgevel tussen de haakse wanden in, dan loopt zijn daklijn + // daarvoor door tot de buitenkant van de vloer (gableSpan). // Voorbeeld: west zuid hoger dan noord -> oost ook, noordwand vlak op de // lage, zuidwand vlak op de hoge hoogte. Muteert state. // Welke haakse wand zit aan de linker/rechterkant (van buitenaf gezien). @@ -271,22 +278,189 @@ window.UnitModel = (() => { rechts: { left: 'achter', right: 'voor' } }; const OPPOSITE = { voor: 'achter', achter: 'voor', links: 'rechts', rechts: 'links' }; + // Plaats van een wand t.o.v. de volle vloermaat langs zijn as (van + // buitenaf gezien): e0/e1 = afstand van de vloerrand tot het linker-/ + // rechteruiteinde, w = wandlengte, span = vloermaat (= e0 + w + e1). + function gableSpan(state, side) { + const layout = computeLayout(state, {}); + const wl = layout.walls.find(x => x.side === side); + const s = WALL_SIDES.find(x => x.key === side); + const span = s.axis === 'x' ? layout.floorWidth : layout.floorDepth; + const c = s.axis === 'x' ? wl.cx : wl.cz; + const left = c - s.dir * wl.width / 2, right = c + s.dir * wl.width / 2; + const e0 = Math.max(0, Math.abs(left + s.dir * span / 2)), e1 = Math.max(0, Math.abs(s.dir * span / 2 - right)); + return { e0, e1, w: wl.width, span, layout }; + } + // Daklijn van een wand over de volle vloermaat: [{ u, h }] met u vanaf de + // linker vloerrand; de eerste en laatste helling lopen door tot de randen. + function fullTopLine(wall, g) { + const pts = [{ u: g.e0, h: wall.heightLeft }] + .concat(WallModel.wallTopPoints(wall, g.w, wall.heightLeft, wall.heightRight).map(q => ({ u: g.e0 + q.x, h: q.h }))) + .concat([{ u: g.e0 + g.w, h: wall.heightRight }]); + const n = pts.length, s0 = (pts[1].h - pts[0].h) / (pts[1].u - pts[0].u), s1 = (pts[n - 1].h - pts[n - 2].h) / (pts[n - 1].u - pts[n - 2].u); + pts[0] = { u: 0, h: pts[0].h - g.e0 * s0 }; + pts[n - 1] = { u: g.span, h: pts[n - 1].h + g.e1 * s1 }; + return pts; + } + // Omgekeerd: een daklijn over de volle vloermaat ([{ u, h }]) op de wand + // zetten (hoogtes op de uiteinden, knikken als topPts; één knik in het + // midden als heightMid). + function setWallTopLine(wall, g, line) { + const at = (u) => { + let i = 0; + while (i < line.length - 2 && u > line[i + 1].u) i++; + const a = line[i], b = line[i + 1]; + return a.h + (u - a.u) / (b.u - a.u) * (b.h - a.h); + }; + const hL = Math.round(at(g.e0)), hR = Math.round(at(g.e0 + g.w)); + const inner = line.slice(1, -1).filter(q => q.u > g.e0 + 1 && q.u < g.e0 + g.w - 1).map(q => ({ f: (q.u - g.e0) / g.w, h: Math.round(q.h) })); + const mid = inner.length === 1 && Math.abs(inner[0].f - 0.5) < 1e-3; + Object.assign(wall, { heightLeft: hL, heightRight: hR, heightMid: mid ? inner[0].h : null, heightAll: Math.min(hL, hR) }); + if (inner.length && !mid) wall.topPts = inner; else delete wall.topPts; + } function syncWallHeights(state, side) { const w = state.walls[side].wall; const mid = w.heightMid > 0 ? w.heightMid : null; - const set = (key, hL, hR, hM) => { + const pts = Array.isArray(w.topPts) && w.topPts.length ? w.topPts : null; + // Haakse wanden: op de onderkant van het dak boven hun buiten- of + // binnenkant, de laagste van de twee (zo steekt de wand nooit door het + // dak; de wig tussen bovenregel en dak vult een schuine regel op, zie + // HouseModel / WallModel topWedge). + const g = gableSpan(state, side), full = fullTopLine(w, g), n = full.length; + const thick = (key) => (g.layout.walls.find(x => x.side === key) || {}).thickness || 0; + const s0 = (full[1].h - full[0].h) / (full[1].u - full[0].u), s1 = (full[n - 1].h - full[n - 2].h) / (full[n - 1].u - full[n - 2].u); + const edgeL = Math.round(Math.min(full[0].h, full[0].h + thick(WALL_ENDS[side].left) * s0)); + const edgeR = Math.round(Math.min(full[n - 1].h, full[n - 1].h - thick(WALL_ENDS[side].right) * s1)); + const set = (key, hL, hR, hM, tp) => { const t = state.walls[key].wall; t.heightLeft = hL; t.heightRight = hR; t.heightMid = hM; - t.heightAll = hM === null && hL === hR ? hL : w.heightAll; + if (tp) t.topPts = tp; else delete t.topPts; + t.heightAll = hM === null && !tp && hL === hR ? hL : w.heightAll; }; - set(OPPOSITE[side], w.heightRight, w.heightLeft, mid); - set(WALL_ENDS[side].left, w.heightLeft, w.heightLeft, null); - set(WALL_ENDS[side].right, w.heightRight, w.heightRight, null); + // Gespiegeld (van buitenaf gezien): knikken van rechts naar links. + set(OPPOSITE[side], w.heightRight, w.heightLeft, mid, pts && pts.map(q => ({ f: 1 - q.f, h: q.h })).reverse()); + set(WALL_ENDS[side].left, edgeL, edgeL, null); + set(WALL_ENDS[side].right, edgeR, edgeR, null); } - // Hoogste punt van een wand (incl. nok). + // Hoogste punt van een wand (incl. nok en knikken). function wallTopOf(wallState) { const w = wallState.wall; - return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0); + return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h)); + } + + // ------------------------------------------------------------ + // Dakvorm als invoer. Het dak volgt de wanden (RoofModel.computeShape); + // hier de omgekeerde weg: één dakvorm per unit, de wanden volgen + // (via syncWallHeights op de kopgevel). + // { type: 'plat', low } hoogte + // { type: 'lessenaar', dir, low, high } dir = hoge zijde (voor/achter/links/rechts) + // { type: 'zadel', ridge, low, high } ridge = richting nok: 'z' (noord–zuid) of 'x' (west–oost) + // { type: 'mansarde', ridge, low, high, kneeH, kneeIn, kneeRatio } + // steil ondervlak van de goot tot de knik (kneeH, kneeIn = afstand + // van de knik tot de gevel), daarboven flauw tot de nok + // { type: 'wolf', ridge, low, high, cut, cutRatio } + // zadeldak waarvan de kopgevels boven `cut` zijn afgeschuind + // (wolfseinden, dakhelling tot de nok; rechte nok ertussen) + // kneeRatio / cutRatio: plaats van de knik resp. het wolfseind tussen goot + // (0) en nok (1). Gaat voor kneeH / cut, zodat ze meeschalen als goot of + // nok verandert (roofForm geeft ze altijd terug). + // low = goothoogte, high = hoge goot resp. nok (mm vanaf het loopvlak), + // over de volle vloermaat: de goot ligt boven de buitenkant van de + // gevel, de nok in het midden (ook als de kopgevel tussen de haakse + // wanden staat; fullTopLine). kneeIn = afstand van de vloerrand. + // Mansarde en wolfsdak: de kopgevel krijgt een daklijn met knikken + // (wall.topPts, zie WallModel.wallTopPoints). + // ------------------------------------------------------------ + // Grenzen van onze woningen (mm, vanaf het loopvlak; maat van de vloer): + // nok (hoogste punt onderkant dak) max 5 m, goot / plat dak max 3 m, een + // unit max 4,3 x 14 m (de korte zijde max 4,3 m). + const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 }; + // Vloermaat binnen de grenzen (muteert floor = state.floor.floor). keep: + // 'width' of 'depth' = de zojuist gewijzigde maat; bij twee lange zijden + // wordt die geklemd (anders de kortste). + function clampSize(floor, keep) { + floor.width = Math.min(LIMITS.long, floor.width); + floor.depth = Math.min(LIMITS.long, floor.depth); + if (Math.min(floor.width, floor.depth) > LIMITS.short) { + const k = keep || (floor.width <= floor.depth ? 'width' : 'depth'); + floor[k] = LIMITS.short; + } + return floor; + } + const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' }; + const RIDGES = { z: 'Noord–zuid', x: 'West–oost' }; + const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf'; + // Kopgevel (wand met de schuine daklijn) bij een dakvorm. + function gableOf(form) { + if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter'; + if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links'; + return 'achter'; + } + // Huidige dakvorm, afgelezen uit de wanden (zelfde volgorde als computeShape). + function roofForm(unitState) { + const W = unitState.walls; + for (const side of ['achter', 'voor', 'links', 'rechts']) { + const w = W[side].wall; + const ridge = side === 'voor' || side === 'achter' ? 'z' : 'x'; + const full = fullTopLine(w, gableSpan(unitState, side)); + const F = full[full.length - 1].u, inner = full.slice(1, -1); + const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h)); + if (inner.length === 3) { + const high = Math.round(inner[1].h); + return { type: 'mansarde', ridge, low, high, kneeH: Math.round(inner[0].h), kneeIn: Math.round(inner[0].u), kneeRatio: (inner[0].h - low) / Math.max(1, high - low) }; + } + if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) { + const slope = (inner[0].h - L.h) / Math.max(1, inner[0].u); + const high = Math.round(L.h + slope * F / 2); + return { type: 'wolf', ridge, low, high, cut: Math.round(inner[0].h), cutRatio: (inner[0].h - low) / Math.max(1, high - low) }; + } + if (inner.length === 1) return { type: 'zadel', ridge, low, high: Math.round(inner[0].h) }; + if (Math.abs(L.h - R.h) > 0.5) { + const dir = R.h > L.h ? WALL_ENDS[side].right : WALL_ENDS[side].left; + return { type: 'lessenaar', dir, low, high: Math.round(Math.max(L.h, R.h)) }; + } + } + const h = Math.max(...Object.values(W).map(wallTopOf)); + return { type: 'plat', low: h, high: h }; + } + // Dakvorm toepassen: de kopgevel krijgt de daklijn (over de volle + // vloermaat bepaald, zie fullTopLine), syncWallHeights doet de rest. + // Zonder hoogteverschil wordt het een plat dak. Muteert state. + function applyRoofForm(unitState, form) { + const low = Math.round(Math.min(LIMITS.eave, form.low)); + const high = Math.round(Math.min(LIMITS.ridge, Math.max(low, form.high || low))); + const sloped = form.type !== 'plat' && high > low; + const side = sloped ? gableOf(form) : 'achter'; + const g = gableSpan(unitState, side), F = g.span, half = F / 2; + let line = [{ u: 0, h: low }, { u: F, h: low }]; + if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }]; + else if (sloped && form.type === 'mansarde') { + // Knik binnen de halve gevel; altijd boven de rechte lijn goot–nok + // (anders geen mansarde maar een hol dak). + // (en op de kopgevel zelf, ook als die tussen de haakse wanden staat) + const kIn = Math.max(Math.max(g.e0, g.e1) + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18))); + const minK = low + (high - low) * kIn / half + 20; + const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH; + const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK))); + line = kH > minK - 1 && high - kH >= 20 + ? [{ u: 0, h: low }, { u: kIn, h: kH }, { u: half, h: high }, { u: F - kIn, h: kH }, { u: F, h: low }] + : [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }]; + } else if (sloped && form.type === 'wolf') { + // Wolfseind: de kopgevel stopt op `cut`, waar de dakhelling die hoogte haalt. + // Op de kopgevel zelf (ook als die tussen de haakse wanden staat). + const minR = (Math.max(g.e0, g.e1) + 50) / half; + const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6; + const cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low)))); + const uc = (cut - low) / (high - low) * half; + line = high - low > 100 + ? [{ u: 0, h: low }, { u: uc, h: cut }, { u: F - uc, h: cut }, { u: F, h: low }] + : [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }]; + } else if (sloped) { + const highLeft = WALL_ENDS[side].left === form.dir; + line = [{ u: 0, h: highLeft ? high : low }, { u: F, h: highLeft ? low : high }]; + } + setWallTopLine(unitState.walls[side].wall, g, line); + syncWallHeights(unitState, side); } function validateState(design) { @@ -302,5 +476,5 @@ window.UnitModel = (() => { } } - return { WALL_SIDES, CORNER_MODES, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, validateState }; + return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, validateState }; })(); diff --git a/js/wall/kozijn-model.js b/js/wall/kozijn-model.js new file mode 100644 index 0000000..0ba65c0 --- /dev/null +++ b/js/wall/kozijn-model.js @@ -0,0 +1,253 @@ +/*! + * Tectum + * Copyright © 2026 Machiel Heinen. + * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. + * Zie LICENTIE.md. + */ +// ============================================================ +// wall/kozijn-model.js — Vulling van een opening (window.KozijnModel): +// kunststof kozijnen naar het voorbeeld van de Aluplast Ideal 7000 +// (examples/Buiten kozijnen.pdf): kader 70 mm in het aanzicht, 120 mm +// diep, buitenzijde in een andere kleur dan de binnenzijde (folie). +// +// Een opening krijgt een (optionele) vulling, opgeslagen bij de opening: +// op.fill = { type: 'raam'|'deur', rooster, glas, out, in } +// raam vast glas in het kader; rooster = ventilatierooster bovenin +// deur deurblad (draait links) met dorpel; is de opening breder of +// hoger dan één deur, dan vult vast glas de rest (zijlicht, +// bovenlicht). glas = glasstrook in het deurblad +// out/in kleur buiten/binnen (COLORS) +// Zonder fill blijft de opening open (zoals voorheen). Het formaat volgt +// altijd de opening (ook een schuine bovenzijde). +// +// Plain script (geen ES-module), geen DOM. Gebruikt door WallModel.build3D. +// ============================================================ + +window.KozijnModel = (() => { + const COLORS = { + wit: { label: 'Wit', ral: 'RAL 9016', hex: 0xf1f0ea }, + grijs: { label: 'Grijs', ral: 'RAL 7038', hex: 0xaeb0aa }, + antraciet: { label: 'Antraciet', ral: 'RAL 7016', hex: 0x383e42 }, + zwart: { label: 'Zwart', ral: 'RAL 9005', hex: 0x151515 } + }; + const FILL_TYPES = { geen: 'Geen (open)', raam: 'Raam (vast glas)', deur: 'Deur' }; + const DEFAULT_OUT = 'antraciet', DEFAULT_IN = 'wit'; + + // Maten (mm). + const P = { + gap: 5, // stelruimte rondom + kader: 70, // kader in het aanzicht + depth: 120, // kaderdiepte + dorpel: 30, // deurdorpel (hoogte) + tussen: 70, // tussenstijl/-regel (deur naast zijlicht, onder bovenlicht) + rooster: 80, // ventilatierooster (hoogte) + glass: 24, // glasdikte + leafW: 930, // deurblad (binnenmaat kader) als er een zijlicht naast past + leafH: 2200, // deurblad als er een bovenlicht boven past + leafT: 68, // deurbladdikte + minLight: 250 // kleinste zijlicht/bovenlicht + }; + + const colorKey = (k, def) => COLORS[k] ? k : def; + // Volledige vulling (defaults ingevuld) of null (geen vulling). + function normalize(fill) { + if (!fill || !FILL_TYPES[fill.type] || fill.type === 'geen') return null; + return { + type: fill.type, rooster: !!fill.rooster, glas: fill.glas !== false, + out: colorKey(fill.out, DEFAULT_OUT), in: colorKey(fill.in, DEFAULT_IN) + }; + } + // Standaardvulling bij een openingtype (WallModel.OPENING_TYPES); kleuren + // van `like` (bijv. de vorige opening) overnemen. + function defaultFill(openingType, like) { + const l = normalize(like) || {}; + const type = openingType === 'deur' || openingType === 'voordeur' ? 'deur' : 'raam'; + return { type, rooster: false, glas: openingType === 'voordeur', out: l.out || DEFAULT_OUT, in: l.in || DEFAULT_IN }; + } + + // ------------------------------------------------------------ + // 2D-veelhoeken (mm, tegen de klok in, x vanaf de linkerkant van de + // opening, y vanaf de onderkant). + // ------------------------------------------------------------ + // Convexe veelhoek naar binnen verschoven, per zijde (zijde i = punt i -> + // punt i+1) een eigen afstand d[i]. + function insetPoly(pts, d) { + const n = pts.length; + const lines = pts.map((p, i) => { + const q = pts[(i + 1) % n]; + const len = Math.hypot(q.x - p.x, q.y - p.y) || 1; + const dx = (q.x - p.x) / len, dy = (q.y - p.y) / len; + return { x: p.x - dy * d[i], y: p.y + dx * d[i], dx, dy }; + }); + return pts.map((p, i) => { + const a = lines[(i - 1 + n) % n], b = lines[i]; + const den = a.dx * b.dy - a.dy * b.dx; + if (Math.abs(den) < 1e-9) return { x: b.x, y: b.y }; + const t = ((b.x - a.x) * b.dy - (b.y - a.y) * b.dx) / den; + return { x: a.x + a.dx * t, y: a.y + a.dy * t }; + }); + } + // Veelhoek afgekapt op een halfvlak (Sutherland-Hodgman): f(p) >= 0 + // blijft, f is lineair. + function clipPoly(pts, f) { + const out = []; + pts.forEach((p, i) => { + const q = pts[(i + 1) % pts.length]; + const fp = f(p), fq = f(q); + if (fp >= 0) out.push(p); + if ((fp >= 0) !== (fq >= 0)) { + const t = fp / (fp - fq); + out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }); + } + }); + return out; + } + const rect = (x0, x1, y0, y1) => [{ x: x0, y: y0 }, { x: x1, y: y0 }, { x: x1, y: y1 }, { x: x0, y: y1 }]; + const areaOf = (pts) => Math.abs(pts.reduce((a, p, i) => { const q = pts[(i + 1) % pts.length]; return a + p.x * q.y - q.x * p.y; }, 0)) / 2; + + // ------------------------------------------------------------ + // 3D + // ------------------------------------------------------------ + const mats = {}; + function colorMat(key) { + const k = 'c:' + colorKey(key, DEFAULT_OUT); + return mats[k] || (mats[k] = new THREE.MeshStandardMaterial({ color: COLORS[colorKey(key, DEFAULT_OUT)].hex, roughness: 0.45 })); + } + function mat(key) { + if (mats[key]) return mats[key]; + if (key === 'glass') mats[key] = new THREE.MeshStandardMaterial({ color: 0x9fbfcc, roughness: 0.05, metalness: 0.2, transparent: true, opacity: 0.28, depthWrite: false, side: THREE.DoubleSide }); + else if (key === 'rubber') mats[key] = new THREE.MeshStandardMaterial({ color: 0x1b1b1b, roughness: 0.8 }); + else mats[key] = new THREE.MeshStandardMaterial({ color: 0x9a9da0, roughness: 0.3, metalness: 0.7 }); // beslag + return mats[key]; + } + + const toM = 0.001; + const toShape = (pts, holes) => { + const s = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.y * toM))); + (holes || []).forEach(h => s.holes.push(new THREE.Path(h.map(p => new THREE.Vector2(p.x * toM, p.y * toM))))); + return s; + }; + // Plaat van z0 tot z1 (mm); buitenvlak (z1) in matOut als dat afwijkt + // (folie aan de buitenzijde, de rest in de binnenkleur). + function slab(g, pts, holes, z0, z1, matIn, matOut, shadow) { + if (pts.length < 3 || areaOf(pts) < 1) return; + const FOIL = matOut && matOut !== matIn ? 1 : 0; + const add = (a, b, m) => { + const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(toShape(pts, holes), { depth: (b - a) * toM, bevelEnabled: false }), m); + mesh.position.z = a * toM; + mesh.castShadow = shadow !== false; + mesh.receiveShadow = true; + g.add(mesh); + }; + add(z0, z1 - FOIL, matIn); + if (FOIL) add(z1 - FOIL, z1, matOut); + } + const box = (g, m, x, y, z, w, h, d) => { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(w * toM, h * toM, d * toM), m); + mesh.position.set(x * toM, y * toM, z * toM); + mesh.castShadow = true; + g.add(mesh); + }; + + // Kozijn in een opening (opgelost: w, h links, hR rechts). Geeft een + // groep in mm-lokale coördinaten van de opening (x vanaf links, y vanaf + // de onderkant, +z = buiten) in meters, of null. + // place: { zOut (mm, buitenvlak kozijn), depth (mm), exterior (dorpel) } + function build3D(op, fill, place) { + const f = normalize(fill); + if (!f) return null; + const g = new THREE.Group(); + const mIn = colorMat(f.in), mOut = colorMat(f.out); + const depth = Math.min(P.depth, place.depth || P.depth); + const z1 = place.zOut, z0 = z1 - depth; + const hR = op.hR !== undefined ? op.hR : op.h; + const outer = insetPoly([{ x: 0, y: 0 }, { x: op.w, y: 0 }, { x: op.w, y: hR }, { x: 0, y: op.h }], [P.gap, P.gap, P.gap, P.gap]); + // Bovenkant (schuin) van een veelhoek binnen het kader: >= 0 onder de + // lijn door TL-TR van `q` (lineair, voor clipPoly). + const below = (q) => (p) => { + const a = q[3], b = q[2]; + return (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y); + }; + const glass = (pts) => slab(g, pts, null, z1 - 50 - P.glass / 2, z1 - 50 + P.glass / 2, mat('glass'), null, false); + + if (f.type === 'raam') { + const inner = insetPoly(outer, [P.kader, P.kader, P.kader, P.kader]); + slab(g, outer, [inner], z0, z1, mIn, mOut); + let pane = inner; + if (f.rooster && Math.min(inner[3].y, inner[2].y) - inner[0].y > P.rooster + 200) { + pane = insetPoly(inner, [0, 0, P.rooster, 0]); + const band = [pane[3], pane[2], inner[2], inner[3]]; + slab(g, [band[0], band[1], band[2], band[3]], null, z1 - 105, z1 - 8, mIn, mOut); + // Lamellen: donkere sleuven op beide vlakken. + [0.3, 0.55, 0.8].forEach(t => { + const a = insetPoly(inner, [0, 20, P.rooster * t - 4, 20]), b = insetPoly(inner, [0, 20, P.rooster * t + 4, 20]); + const slot = [b[3], b[2], a[2], a[3]]; + slab(g, slot, null, z1 - 8, z1 - 6, mat('rubber')); + slab(g, slot, null, z1 - 107, z1 - 105, mat('rubber')); + }); + } + glass(pane); + return g; + } + + // Deur: kader als U (zonder onderregel) op een dorpel (buitenwand) of + // tot de vloer (binnenwand). + const sill = place.exterior !== false ? P.dorpel : 0; + const frameOuter = insetPoly(outer, [sill, 0, 0, 0]); + const inner = insetPoly(frameOuter, [0, P.kader, P.kader, P.kader]); + const u = [frameOuter[0], inner[0], inner[3], inner[2], inner[1], frameOuter[1], frameOuter[2], frameOuter[3]]; + slab(g, u, null, z0, z1, mIn, mOut); + if (sill) box(g, mat('rubber'), (outer[0].x + outer[1].x) / 2, outer[0].y + sill / 2, z1 - depth / 2 + 15, outer[1].x - outer[0].x, sill, depth + 30); + + const ix0 = inner[0].x, ix1 = inner[1].x, iy0 = inner[0].y; + const top = below(inner); + const topAt = (x) => inner[3].y + (x - inner[3].x) / (inner[2].x - inner[3].x) * (inner[2].y - inner[3].y); + // Zijlicht rechts als er naast een deurblad nog genoeg ruimte is. + const side = ix1 - ix0 - P.leafW - P.tussen >= P.minLight; + const lx1 = side ? ix0 + P.leafW : ix1; + // Bovenlicht als er boven een deurblad nog genoeg ruimte is (overal). + const lowTop = Math.min(topAt(ix0), topAt(ix1)); + const transom = lowTop - iy0 - P.leafH - P.tussen >= P.minLight; + const ly1 = transom ? iy0 + P.leafH : Infinity; + const region = (x0, x1, y0, y1) => clipPoly(rect(x0, x1, y0, Math.min(y1, 1e6)), top); + const bar = (pts) => slab(g, pts, null, z0 + 10, z1 - 10, mIn, mOut); + if (side) { + bar(region(lx1, lx1 + P.tussen, iy0, ly1)); + // Zijlicht: onderregel + glas. + bar(region(lx1 + P.tussen, ix1, iy0, iy0 + P.kader)); + glass(region(lx1 + P.tussen, ix1, iy0 + P.kader, ly1)); + } + if (transom) { + bar(region(ix0, ix1, ly1, ly1 + P.tussen)); + glass(region(ix0, ix1, ly1 + P.tussen, Infinity)); + } + + // Deurblad (3 mm speling, onderaan 10 mm boven dorpel/vloer). + const leaf = clipPoly(rect(ix0 + 3, lx1 - 3, iy0 + (sill ? 3 : 10), Math.min(ly1 - 3, 1e6)), (p) => top({ x: p.x, y: p.y + 3 })); + if (leaf.length < 3) return g; + const xs = leaf.map(p => p.x), ys = leaf.map(p => p.y); + const lx0 = Math.min(...xs), lxR = Math.max(...xs), lb = Math.min(...ys), lt = Math.min(topAt(lx0), topAt(lxR), ly1) - 3; + const lzOut = z1 - 15, lzIn = lzOut - P.leafT; + let holes = null; + const lw = lxR - lx0; + if (f.glas && lw >= 500 && lt - lb >= 1400) { + const gx = lx0 + lw * 0.3, gw = Math.min(160, lw * 0.18); + const strip = rect(gx - gw / 2, gx + gw / 2, lb + 350, lt - 300); + holes = [strip]; + slab(g, strip, null, (lzIn + lzOut) / 2 - P.glass / 2, (lzIn + lzOut) / 2 + P.glass / 2, mat('glass'), null, false); + } + slab(g, leaf, holes, lzIn, lzOut, mIn, mOut); + // Kruk aan beide zijden (scharnieren links, slot rechts). + if (lw >= 400) { + const hx = lxR - 75, hy = Math.min(lb + 1050, (lb + lt) / 2); + [[lzOut, 1], [lzIn, -1]].forEach(([z, s]) => { + box(g, mat('beslag'), hx, hy - 30, z + s * 5, 34, 210, 10); // schild + box(g, mat('beslag'), hx, hy, z + s * 30, 20, 20, 50); // nek + box(g, mat('beslag'), hx - 60, hy, z + s * 55, 140, 20, 20); // greep + }); + } + return g; + } + + return { COLORS, FILL_TYPES, PROFILE: P, normalize, defaultFill, build3D }; +})(); diff --git a/js/wall/wall-model.js b/js/wall/wall-model.js index 4d86e84..cc7f999 100644 --- a/js/wall/wall-model.js +++ b/js/wall/wall-model.js @@ -14,8 +14,9 @@ // voor zowel de losse wand als de unit gelden. // // Werkt met een "wand-state" (zelfde formaat als getStateJson/JSON-export): -// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, studWidth, ...}, -// materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset}] } +// { wall: {width, heightAll, heightLeft, heightRight, heightMid?, topPts?, studWidth, ...}, +// materials: {interior, exterior}, openings: [{id,w,h,hR?,y,anchor,offset,fill?}] } +// fill = vulling van de opening (kozijn, zie wall/kozijn-model.js). // // Plain script (geen ES-module), moet na core/geometry.js geladen worden. // ============================================================ @@ -51,7 +52,64 @@ window.WallModel = (() => { // vochtdoek; cement-gebonden platen zijn voor steenstrips. const BATTEN = { thick: 20, width: 30, length: 4000, spacing: 400 }; const CLADDING_DIRECTIONS = { horizontaal: 'Horizontaal', verticaal: 'Verticaal' }; - const CLADDING_THICK_MM = 12; // gevelbekleding (WEO/Novalit), nog niet getekend + const CLADDING_THICK_MM = 12; // gevelbekleding (WEO/Novalit) + + // Rabat (gevelbekleding op de kruislat): planken van BOARD_MM werkende + // breedte in de richting van materials.cladding, in een kleur uit + // RABAT_COLORS (woning-instelling). In 3D één blok per wandstuk, van het + // doek tot de voorkant van de planken (latwerk + plank): zo zijn hoeken, + // dagkanten en onderrand dicht en vervallen doek en latten erachter. De + // planken zijn een textuur in gevel-coördinaten (lopen door over naden). + const RABAT = { BOARD_MM: 150, TILE_BOARDS: 8 }; + const RABAT_COLORS = { + naturel: { label: 'Naturel', hex: '#a0805f' }, + antraciet: { label: 'Antraciet', hex: '#3c4045' }, + zwart: { label: 'Zwart', hex: '#1f2022' }, + grijs: { label: 'Grijs', hex: '#8b8f93' }, + wit: { label: 'Gebroken wit', hex: '#e6e3dc' }, + groen: { label: 'Mosgroen', hex: '#55604c' } + }; + const rabatHex = (key) => (RABAT_COLORS[key] || RABAT_COLORS.naturel).hex; + // Eén materiaal per kleur + richting (gedeeld). Textuur: TILE_BOARDS + // planken, elk met een lichte bovenkant en een schaduwlijn onderaan + // (overlap); kleine kleurverschillen per plank. + const rabatMaterials = {}; + function rabatMaterial(colorKey, direction) { + const key = (RABAT_COLORS[colorKey] ? colorKey : 'naturel') + '|' + (direction === 'verticaal' ? 'v' : 'h'); + if (rabatMaterials[key]) return rabatMaterials[key]; + const hex = rabatHex(colorKey); + let mat; + const canvas = typeof document !== 'undefined' && document.createElement('canvas'); + const ctx = canvas && canvas.getContext && canvas.getContext('2d'); + if (!ctx) { + mat = new THREE.MeshStandardMaterial({ color: hex, roughness: 0.8 }); + } else { + const B = 32, N = RABAT.TILE_BOARDS, vert = direction === 'verticaal'; + canvas.width = vert ? B * N : 16; + canvas.height = vert ? 16 : B * N; + const base = new THREE.Color(hex); + const shade = (f) => '#' + base.clone().multiplyScalar(f).getHexString(); + // rect(a, b) = band over de lengte van de plank, van a tot b (px dwars). + const rect = (a, b, color) => { + ctx.fillStyle = color; + if (vert) ctx.fillRect(a, 0, b - a, canvas.height); else ctx.fillRect(0, a, canvas.width, b - a); + }; + for (let i = 0; i < N; i++) { + const a = i * B, f = 0.96 + ((i * 37) % 9) / 100; // 0,96 .. 1,04 + rect(a, a + B, shade(f)); + rect(a, a + 2, shade(f * 1.12)); // bovenkant (licht) + rect(a + B - 4, a + B, shade(f * 0.55)); // schaduw van de overlap + } + const tex = new THREE.CanvasTexture(canvas); + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + const tileM = RABAT.BOARD_MM * N / 1000; + if (vert) tex.repeat.set(1 / tileM, 1); else tex.repeat.set(1, 1 / tileM); + tex.anisotropy = 8; + mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.8 }); + } + rabatMaterials[key] = mat; + return mat; + } // Hoe ver de afgewerkte gevel buiten het wandvlak ligt (mm): bij vochtdoek // de kruislat (2 lagen) + de gevelbekleding. Gebruikt o.a. voor de @@ -198,9 +256,10 @@ window.WallModel = (() => { const EXTERIOR_MATERIALS = { vochtdoek: 'Vochtdoek', cementplaat: 'Cement-gebonden platen', spaanplaat: 'Spaanplaat', geen: 'Geen (naadzijde)' }; // Bovenrand van de wand (daklijn): één recht stuk van links naar rechts, - // of met een middenpunt (nok, wallPeak) twee stukken die elkaar daar - // raken. De nok ligt nooit lager dan de goten (buildConfig), dus de - // bovenrand is overal het MINIMUM van de (doorgetrokken) stukken — dat + // of via tussenpunten (CONFIG.wallTop: nok, of de knikken van een + // mansarde-/wolfsdak) meerdere stukken. De daklijn is altijd bol + // (wallTopPoints), dus de bovenrand is overal het MINIMUM van de + // (doorgetrokken) stukken — dat // houdt at/under/rangeAbove simpel en maakt "laagste punt boven een // opening" gelijk aan het laagste van de twee uiteinden. // x t.o.v. het wandmidden, alles in mm x k (k = 0.001: meters). @@ -213,8 +272,8 @@ window.WallModel = (() => { k = k || 1; const halfW = CONFIG.wallWidth / 2 * k; const L = { x: -halfW, y: CONFIG.wallHeightLeft * k }, R = { x: halfW, y: CONFIG.wallHeightRight * k }; - const pk = CONFIG.wallPeak ? { x: (CONFIG.wallPeak.x - CONFIG.wallWidth / 2) * k, y: CONFIG.wallPeak.h * k } : null; - const pts = pk ? [L, pk, R] : [L, R]; + const inner = (CONFIG.wallTop || []).map(q => ({ x: (q.x - CONFIG.wallWidth / 2) * k, y: q.h * k })); + const pts = [L, ...inner, R]; const segs = []; for (let i = 0; i < pts.length - 1; i++) { const a = pts[i], b = pts[i + 1], slope = (b.y - a.y) / (b.x - a.x); @@ -223,15 +282,15 @@ window.WallModel = (() => { const lineY = (sg, x) => sg.a.y + (x - sg.a.x) * sg.slope; const at = (x) => Math.min(...segs.map(sg => lineY(sg, x))); const under = (x) => Math.min(...segs.map(sg => lineY(sg, x) - sg.vo)); - let knee = null; - if (segs.length === 2) { - const [s0, s1] = segs, ds = s0.slope - s1.slope; + // Knieën: snijpunt van de onderkanten van twee opeenvolgende bovenregels. + const knees = segs.slice(0, -1).map((s0, i) => { + const s1 = segs[i + 1], ds = s0.slope - s1.slope; // s0.a.y + (x - s0.a.x) s0.slope - vo0 = s1.a.y + (x - s1.a.x) s1.slope - vo1 const x = Math.abs(ds) > 1e-12 ? ((s1.a.y - s1.a.x * s1.slope - s1.vo) - (s0.a.y - s0.a.x * s0.slope - s0.vo)) / ds - : pk.x; - knee = { x, y: under(x) }; - } + : s0.b.x; + return { x, y: under(x) }; + }); const rangeAbove = (y) => { let x0 = -halfW, x1 = halfW; segs.forEach(sg => { @@ -246,30 +305,54 @@ window.WallModel = (() => { .filter(c => c.x > c.sg.a.x && c.x < c.sg.b.x).map(c => c.x); const underPts = (xa, xb) => { const out = [{ x: xa, y: under(xa) }]; - if (knee && knee.x > xa && knee.x < xb) out.push({ x: knee.x, y: knee.y }); + knees.filter(kn => kn.x > xa && kn.x < xb).forEach(kn => out.push({ x: kn.x, y: kn.y })); out.push({ x: xb, y: under(xb) }); return out; }; + // peak: hoogste tussenpunt (nok), voor maatvoering. + const peak = inner.length ? inner.reduce((m, q) => q.y > m.y ? q : m) : null; return { - pts, segs, peak: pk, knee, at, under, rangeAbove, crossings, underPts, + pts, segs, peak, knees, knee: knees[0] || null, at, under, rangeAbove, crossings, underPts, maxY: Math.max(...pts.map(p => p.y)), minY: Math.min(L.y, R.y), - kinks: pk ? [pk.x] : [] + kinks: inner.map(q => q.x) }; } // Omtrek(ken) van de bovenregel(s) (zelfde eenheid als topLine): één - // parallellogram, of met nok twee stukken in verstek (nok boven, knie onder). + // parallellogram, of per stuk van de daklijn een bovenregel; die sluiten + // in verstek op elkaar aan (knik boven, knie onder). function topPlateOutlines(tl) { - const [L, R] = [tl.pts[0], tl.pts[tl.pts.length - 1]]; - if (!tl.peak) { - const vo = tl.segs[0].vo; - return [[L, R, { x: R.x, y: R.y - vo }, { x: L.x, y: L.y - vo }]]; + const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n]; + return tl.segs.map((sg, i) => [ + sg.a, sg.b, + i < n - 1 ? tl.knees[i] : { x: R.x, y: R.y - sg.vo }, + i > 0 ? tl.knees[i - 1] : { x: L.x, y: L.y - sg.vo } + ]); + } + + // Tussenpunten van de daklijn (mm vanaf links) uit de wand-parameters: + // topPts [{ f (0..1 van de breedte), h }] (mansarde, wolfsdak), of het + // middenpunt heightMid op peakX (standaard midden). Alleen punten die + // de daklijn bol houden (boven de koorde tussen hun buren); de bovenrand + // is dan overal het minimum van de doorgetrokken stukken (topLine). + function wallTopPoints(params, wallWidth, hLeft, hRight) { + let raw = []; + if (Array.isArray(params.topPts) && params.topPts.length) { + raw = params.topPts.map(q => ({ x: q.f * wallWidth, h: Math.round(q.h) })); + } else if (params.heightMid > 0) { + const px = params.peakX !== undefined ? params.peakX : wallWidth / 2; + raw = [{ x: px, h: Math.round(Math.max(params.heightMid, hLeft, hRight)) }]; } - const P = tl.peak, K = tl.knee; - return [ - [L, P, K, { x: L.x, y: L.y - tl.segs[0].vo }], - [P, R, { x: R.x, y: R.y - tl.segs[1].vo }, K] - ]; + let pts = [{ x: 0, h: hLeft }, ...raw.filter(q => q.x > 1 && q.x < wallWidth - 1).sort((a, b) => a.x - b.x), { x: wallWidth, h: hRight }]; + for (let changed = true; changed;) { + changed = false; + for (let i = 1; i < pts.length - 1; i++) { + const a = pts[i - 1], b = pts[i + 1], q = pts[i]; + const chord = a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h); + if (q.x - a.x < 1 || b.x - q.x < 1 || q.h <= chord + 0.5) { pts.splice(i, 1); changed = true; break; } + } + } + return pts.slice(1, -1); } // Heuristiek (geen volwaardige constraint-solver): elke opening wordt eerst @@ -305,7 +388,7 @@ window.WallModel = (() => { else if (raw.anchor === 'middle') x = (wallWidth - w) / 2 + raw.offset; else x = raw.offset; - return { id: raw.id, type: raw.type || 'raam', w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y }; + return { id: raw.id, type: raw.type || 'raam', fill: raw.fill || null, w, anchor: raw.anchor, offset: raw.offset, x, rawH: raw.h, rawHR: hRightOf(raw), rawY: raw.y }; }); resolved.sort((a, b) => a.x - b.x); @@ -400,24 +483,23 @@ window.WallModel = (() => { const hRight = Math.round(params.heightRight || 2900); const studWidth = Math.round(params.studWidth || STANDARD_BEAM.width); const plateThick = Math.round(params.plateThick || STANDARD_BEAM.width); - // Middenpunt (nok): standaard midden op de wand, of op peakX (mm vanaf - // links; een wandstuk uit een unit waar de nok niet in het midden - // valt). Nooit lager dan de hoogste goot; gelijk = geen nok. - let wallPeak = null; - if (params.heightMid > 0) { - const px = params.peakX !== undefined ? params.peakX : wallWidth / 2; - const h = Math.round(Math.max(params.heightMid, hLeft, hRight)); - if (px > 1 && px < wallWidth - 1 && h > hLeft + (px / wallWidth) * (hRight - hLeft) + 0.5) wallPeak = { x: px, h }; - } - const roofAt = (x) => wallPeak - ? (x <= wallPeak.x ? hLeft + x / wallPeak.x * (wallPeak.h - hLeft) : wallPeak.h + (x - wallPeak.x) / (wallWidth - wallPeak.x) * (hRight - wallPeak.h)) - : hLeft + (x / wallWidth) * (hRight - hLeft); + // Tussenpunten van de daklijn (nok; knikken bij mansarde/wolfsdak), + // zie wallTopPoints. Een wandstuk uit een unit krijgt ze op zijn eigen + // positie (peakX / topPts). + const wallTop = wallTopPoints(params, wallWidth, hLeft, hRight); + const tp = [{ x: 0, h: hLeft }, ...wallTop, { x: wallWidth, h: hRight }]; + const roofAt = (x) => { + let i = 0; + while (i < tp.length - 2 && x > tp[i + 1].x) i++; + const a = tp[i], b = tp[i + 1]; + return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h); + }; return { wallWidth, wallHeightLeft: hLeft, wallHeightRight: hRight, - wallPeak, + wallTop, studWidth, studDepth: Math.round(params.studDepth || STANDARD_BEAM.depth), plateThick, @@ -447,7 +529,7 @@ window.WallModel = (() => { const top = topLine(CONFIG); const slope = top.segs[0].slope; // Met nok: de steilste helling (beide kanten lopen dan op naar de nok). - const angleDeg = top.peak + const angleDeg = top.segs.length > 1 ? Math.round(Math.max(...top.segs.map(sg => Math.atan(Math.abs(sg.slope)))) * (180 / Math.PI)) : Math.round(Math.atan(slope) * (180 / Math.PI)); @@ -623,9 +705,12 @@ window.WallModel = (() => { // facade (optioneel, woning): { u0, extStart, isEnd } — plaats van dit // wandstuk in de doorlopende gevel (zie HouseModel.assignFacades): u0 = // afstand (mm, wandvlak) van het gevelbegin (incl. hoekdoorloop) tot de - // linkerrand van dit stuk. Het verticale latten-raster volgt dan de - // hele gevel i.p.v. per stuk opnieuw te beginnen; een eindlat komt - // alleen aan het einde van de gevel. + // linkerrand van dit stuk (gebruikt door de horizontale latten via gridY0). + // Verticale latten: per wandstuk (de modules worden los vervoerd, de + // bekleding eindigt op de rand van het stuk) een lat op beide randen en + // langs beide zijden van elke opening (de bekleding moet daar op een lat + // eindigen); daartussen gelijk verdeeld, h.o.h. max. BATTEN.spacing + // (krimpend als het vak kleiner is, minimaal de twee randlatten). // facade.gridY0 (optioneel, mm, wand-coördinaten): hoogte waarop het // horizontale latten-raster van de hele gevel begint. Een wandstuk dat // hoger begint (opbouw, blootliggende naadwand) volgt zo hetzelfde raster. @@ -637,8 +722,6 @@ window.WallModel = (() => { const y0 = Math.max(skinStartY(trasraam), skinFrom || 0); // boven het trasraam const ext = cornerExt(skinExt, 2 * BATTEN.thick); // over de hoekstijlen van de doorlopende wand const xL = -CONFIG.wallWidth / 2 - ext.left, xR = CONFIG.wallWidth / 2 + ext.right; - const gridX0 = facade ? -CONFIG.wallWidth / 2 - facade.u0 - (facade.extStart ? 2 * BATTEN.thick : 0) : xL; - const isEnd = facade ? facade.isEnd : true; const halfW = CONFIG.wallWidth / 2; const tl = topLine(CONFIG), topYAt = tl.at; const holes = sheathingHoles(CONFIG).concat(extraHoles || []); @@ -653,17 +736,35 @@ window.WallModel = (() => { yStart = g + Math.max(0, Math.ceil((yStart - g) / sp - 1e-9)) * sp; } for (let y = yStart; y < tl.maxY - w / 2; y += sp) { + // Schuine bovenrand: afkappen op de bovenkant van de lat (y + w/2), + // anders steekt het uiteinde tot w/2 boven de daklijn (door het dak). + const cap = tl.rangeAbove(y + w / 2); + if (!cap) continue; skinSegmentsAtY(CONFIG, y, extraHoles).forEach(([x0, x1]) => { + x0 = Math.max(x0, cap[0]); x1 = Math.min(x1, cap[1]); if (x0 <= -halfW + 0.5) x0 = xL; if (x1 >= halfW - 0.5) x1 = xR; if (x1 - x0 > w) horizontaal.push({ x0, x1, y }); }); } - // Verticale latten: vanaf de linkerrand elke 400 mm, plus een lat op de rechterrand. + // Verticale latten: vaste posities (randen, zijden van openingen), + // daartussen gelijk verdeeld met h.o.h. <= sp. + const lo = xL + w / 2, hi = xR - w / 2; + const fixed = [lo, hi]; + holes.forEach(h => { + if (h.hy0 >= tl.maxY || holeTopAt(h, h.hx0) <= y0) return; // valt buiten de bekleding + [h.hx0 - w / 2, h.hx1 + w / 2].forEach(x => { if (x > lo && x < hi) fixed.push(x); }); + }); + fixed.sort((a, b) => a - b); + const anchors = fixed.filter((x, i) => i === 0 || x - fixed[i - 1] > w); // niet dubbel op elkaar const xs = []; - // Raster vanaf het gevelbegin; een lat hoort bij het stuk waar zijn hart in valt. - for (let x = gridX0 + w / 2 + Math.max(0, Math.ceil((xL - gridX0 - w / 2) / sp - 1e-9)) * sp; x <= xR - (isEnd ? w / 2 - 0.5 : 0) && x < xR; x += sp) xs.push(x); - if (isEnd && (!xs.length || xR - w / 2 - xs[xs.length - 1] > 50)) xs.push(xR - w / 2); + anchors.forEach((x, i) => { + xs.push(x); + const next = anchors[i + 1]; + if (next === undefined) return; + const n = Math.ceil((next - x) / sp - 1e-9); + for (let k = 1; k < n; k++) xs.push(x + (next - x) * k / n); + }); const verticaal = []; xs.forEach(x => { const yTop = Math.min(topYAt(x - w / 2), topYAt(x + w / 2)); @@ -757,14 +858,19 @@ window.WallModel = (() => { // Bouwt de volledige 3D-wand (in meters, lokaal assenstelsel: X = breedte // gecentreerd, Y = hoogte vanaf onderkant onderregel, +Z = buitenzijde/zuid) // en geeft daarnaast de volume-boekhouding terug voor de specificaties. - // opts: { materials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible } + // opts: { materials, buitenMateriaal, cladding, trasraam, spaanplaatVisible, buitenVisible, stage, rabatColor } // trasraam: { bottom, top } in mm (wand-coördinaten, zie trasraamFor) of null. // skinExt: { left, right } in mm (zie cornerExt) of null. // facade: { u0, extStart, isEnd } (zie computeBattens) of null. + // stage: bouwfase (BuildStages, standaard afgewerkt). Lagen van een latere + // fase en lagen die in deze fase volledig ingesloten zijn worden niet + // gebouwd; de stats rekenen altijd met alle lagen. function build3D(CONFIG, calcData, opts) { const { woodMaterial, spaanplaatMaterial, doekMaterial, cementplaatMaterial, battenMaterial } = opts.materials; const buitenMateriaal = opts.buitenMateriaal || 'vochtdoek'; - const spaanplaatVisible = opts.spaanplaatVisible !== false; + const stage = BuildStages.clamp(opts.stage); + const show = (key) => BuildStages.shows(stage, key); + const spaanplaatVisible = opts.spaanplaatVisible !== false && show('beplating'); const buitenVisible = opts.buitenVisible !== false; const cladding = opts.cladding || 'horizontaal'; // Trasraam: bij rabat (vochtdoek) en bij een steengevel (cementplaat + stone). @@ -778,6 +884,19 @@ window.WallModel = (() => { // spaanplaatBelow: onder skinFrom aan de buitenzijde spaanplaat (naadwand // geheel in één unit: onder het dak van de andere unit is het een binnenwand). const spaanplaatBelow = !!opts.spaanplaatBelow && skinFrom > 0 && buitenMateriaal === 'vochtdoek'; + // Zichtbare buitenlagen in deze fase. Bij rabat (vochtdoek-gevel) dekt + // het rabatblok doek en latten volledig af (zie RABAT). + const rabatOn = buitenMateriaal === 'vochtdoek' && buitenVisible && show('gevel'); + const doekOn = buitenVisible && show('doek') && !rabatOn; + const battensOn = buitenVisible && show('latwerk') && !rabatOn; + // Regelwerk ingesloten: binnen spaanplaat, buiten een plaat of het doek + // (naadzijde 'geen': het deel van de andere unit staat ertegen). Dan + // alleen de stukken die in de dagkant van een opening of aan de rand + // te zien zijn (hoek-, koningsstijlen, regels, dorpels). + const outerCovered = buitenMateriaal === 'geen' || + (buitenVisible && show(buitenMateriaal === 'vochtdoek' ? 'doek' : 'beplating')); + const frameEnclosed = opts.cull !== false && spaanplaatVisible && outerCovered; + const HIDDEN_STUDS = { regel: true, kreupel: true, dubbel: true }; // Watermerk-UV's (doek) in gevel-coördinaten, zodat het patroon over // naden/wandstukken doorloopt (UV = vormcoördinaat in m + verschuiving). const uvShiftM = facade ? (CONFIG.wallWidth / 2 + facade.u0) * 0.001 : 0; @@ -805,7 +924,8 @@ window.WallModel = (() => { let doekAreaM2 = 0; // enkelzijdig, alleen als buitenMateriaal='vochtdoek' let spaanplaatBuitenAreaM2 = 0; // alleen als buitenMateriaal='spaanplaat' (binnenwanden) let spaanplaatBuitenVolumeM3 = 0; - let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0; // kruislat (alleen vochtdoek) + let battenLengthM = 0, battenVolumeM3 = 0, battenCount = 0, battenPieces = 0; // kruislat (alleen vochtdoek); count = inkoop, pieces = gezaagde stukken + let rabatAreaM2 = 0; // gevelbekleding rabat (gevelvlak, alleen vochtdoek) let trasraamAreaM2 = 0, trasraamVolumeM3 = 0; // cement-gebonden plaat onderaan (alleen vochtdoek) let stoneAreaM2 = 0, stoneIsolatieM3 = 0; // steengevel (cementplaat + stone): strip + isolatieblok let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0; // stenen trasraam: steenstrip (gevelvlak) + isolatieblok @@ -845,9 +965,16 @@ window.WallModel = (() => { const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0; const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, { wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove, - wallPeak: CONFIG.wallPeak ? { x: CONFIG.wallPeak.x, h: CONFIG.wallPeak.h + skinAbove } : null + wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + skinAbove })) }) : CONFIG; const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl; + // Latwerk stopt onder het boeideel/de daktrim (opts.battenAbove, mm + // t.o.v. de wandbovenkant, kan negatief zijn); zonder rand tot skinAbove. + const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove; + const CONFIG_BAT = battenAbove ? Object.assign({}, CONFIG, { + wallHeightLeft: CONFIG.wallHeightLeft + battenAbove, wallHeightRight: CONFIG.wallHeightRight + battenAbove, + wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + battenAbove })) + }) : CONFIG; // Onderkant bovenregel van xLeft naar xRight (incl. verstek bij de nok). const underPts = tl.underPts; @@ -904,9 +1031,32 @@ window.WallModel = (() => { wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), pDepthM, -pDepthM / 2)); horizBeamCount++; }); + // Schuine regel op de bovenregel (CONFIG.topWedge): vult de wig tot + // de onderkant van het dak, over de diepte van het regelwerk. + if (CONFIG.topWedge) { + const wg = CONFIG.topWedge.pts, zO = sDepthM / 2, zI = -sDepthM / 2, pos = [], tlMM = topLine(CONFIG); + const P = (q, z, h) => [q.x * toM, (tlMM.at(q.x) + h) * toM, z]; + const quad = (a, b, c, d) => pos.push(...a, ...b, ...c, ...a, ...c, ...d); + for (let k = 0; k < wg.length - 1; k++) { + const a = wg[k], b = wg[k + 1]; + quad(P(a, zO, a.o), P(b, zO, b.o), P(b, zI, b.i), P(a, zI, a.i)); // boven (dakkant) + quad(P(a, zO, 0), P(b, zO, 0), P(b, zO, b.o), P(a, zO, a.o)); // buiten + quad(P(a, zI, a.i), P(b, zI, b.i), P(b, zI, 0), P(a, zI, 0)); // binnen + woodVolumeM3 += ((a.o + a.i) / 2 + (b.o + b.i) / 2) / 2 * (b.x - a.x) * CONFIG.studDepth * 1e-9; + } + [wg[0], wg[wg.length - 1]].forEach(q => quad(P(q, zO, 0), P(q, zO, q.o), P(q, zI, q.i), P(q, zI, 0))); // kopse kanten + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + geo.computeVertexNormals(); + const wedge = new THREE.Mesh(geo, woodMaterial.side === THREE.DoubleSide ? woodMaterial : wedgeMaterial(woodMaterial)); + wedge.castShadow = true; + wallGroup3D.add(wedge); + } // C. VERTICALE STIJLEN (vol, koning, dubbel bij deuren, kreupel): elk // stuk van y0 tot y1, of tot onder de daklijn (y1 null), zie computeFrame. + // Ingesloten stijlen (frameEnclosed) tellen mee in het volume, maar + // worden niet gebouwd. calcData.studs.forEach(st => { const xM = st.x * toM; const xLeft = xM - (sWidthM / 2); @@ -916,6 +1066,7 @@ window.WallModel = (() => { const pts = st.y1 === null ? [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, ...underPts(xLeft, xRight).reverse()] : [{ x: xLeft, y: y0l }, { x: xRight, y: y0r }, { x: xRight, y: st.y1 * toM }, { x: xLeft, y: st.y1 * toM }]; + if (frameEnclosed && HIDDEN_STUDS[st.kind]) { woodVolumeM3 += polygonArea(pts) * sDepthM; return; } wallGroup3D.add(createExtrudedBeam(new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))), sDepthM, -sDepthM / 2)); }); @@ -1063,7 +1214,7 @@ window.WallModel = (() => { if (b - a <= edgeEps) return []; const capped = yCap === Infinity; const pieces = columnMinusRects(a, b, y0, capped ? topClamped : (x) => Math.min(yCap, topClamped(x)), rects, - capped ? { extraXs: tlOut.kinks } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap)) }); + capped ? { extraXs: tlOut.kinks.concat([-halfW, halfW]) } : { yCap, extraXs: tlOut.kinks.concat(tlOut.crossings(yCap), [-halfW, halfW]) }); return pieces.map(piece => { const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y))); piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y))))); @@ -1071,10 +1222,9 @@ window.WallModel = (() => { }); }; const add = (shapes, depth, z, mat) => { - if (!shapes.length) return; + if (!shapes.length || !buitenVisible || !show('gevel')) return; const mesh = new THREE.Mesh(setUV(new THREE.ExtrudeGeometry(shapes, { steps: 1, depth, bevelEnabled: false })), mat); mesh.position.set(0, 0, z); - mesh.visible = buitenVisible; mesh.castShadow = true; wallGroup3D.add(mesh); }; @@ -1136,22 +1286,19 @@ window.WallModel = (() => { return shape; }); if (!boardShapes.length) continue; - - const boardExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false }; - const boardGeo = new THREE.ExtrudeGeometry(boardShapes, boardExtrudeSettings); const boardAreaM2 = boardShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0); - const board = new THREE.Mesh(boardGeo, material); - board.position.set(0, 0, z); - board.visible = visible; - wallGroup3D.add(board); - - if (!seamless) { - const edgesGeo = new THREE.EdgesGeometry(boardGeo); - const edgeLines = new THREE.LineSegments(edgesGeo, edgeMaterial); - edgeLines.position.set(0, 0, z); - edgeLines.visible = visible; - wallGroup3D.add(edgeLines); + // Niet zichtbaar (fase/weergave): alleen meten, niet bouwen. + if (visible) { + const boardGeo = new THREE.ExtrudeGeometry(boardShapes, { steps: 1, depth: sThickM, bevelEnabled: false }); + const board = new THREE.Mesh(boardGeo, material); + board.position.set(0, 0, z); + wallGroup3D.add(board); + if (!seamless) { + const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(boardGeo), edgeMaterial); + edgeLines.position.set(0, 0, z); + wallGroup3D.add(edgeLines); + } } areaM2 += boardAreaM2; @@ -1163,15 +1310,15 @@ window.WallModel = (() => { const shape = new THREE.Shape([ new THREE.Vector2(a, yB), new THREE.Vector2(b, yB), new THREE.Vector2(b, skinTopAt(b)), new THREE.Vector2(a, skinTopAt(a)) ]); - const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: sThickM, bevelEnabled: false }); - const mesh = new THREE.Mesh(geo, material); - mesh.position.set(0, 0, z + ext.zOut); - mesh.visible = visible; - wallGroup3D.add(mesh); - const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMaterial); - edgeLines.position.set(0, 0, z + ext.zOut); - edgeLines.visible = visible; - wallGroup3D.add(edgeLines); + if (visible) { + const geo = new THREE.ExtrudeGeometry(shape, { steps: 1, depth: sThickM, bevelEnabled: false }); + const mesh = new THREE.Mesh(geo, material); + mesh.position.set(0, 0, z + ext.zOut); + wallGroup3D.add(mesh); + const edgeLines = new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMaterial); + edgeLines.position.set(0, 0, z + ext.zOut); + wallGroup3D.add(edgeLines); + } const a2 = shapeAreaWithHoles(shape); areaM2 += a2; volumeM3 += a2 * sThickM; @@ -1191,8 +1338,7 @@ window.WallModel = (() => { const cap = new THREE.Mesh(new THREE.PlaneGeometry(depth, h), spaanplaatMaterial); cap.rotation.y = dir * Math.PI / 2; cap.position.set(x + dir * 0.0003, h / 2, 0); - cap.visible = spaanplaatVisible; - wallGroup3D.add(cap); + if (spaanplaatVisible) wallGroup3D.add(cap); }); spaanplaatAreaM2 = spaanplaatResult.areaM2; spaanplaatVolumeM3 = spaanplaatResult.volumeM3; @@ -1200,19 +1346,19 @@ window.WallModel = (() => { // Buitenzijde (zuid): cement-gebonden platen (zelfde plaatlogica als // hierboven) OF vochtdoek (horizontale banen, zie onder) — nooit beide. if (spaanplaatBelow) { - const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible, { y0: 0, y1: skinFrom * 0.001 }); + const spaanOnder = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating'), { y0: 0, y1: skinFrom * 0.001 }); spaanplaatBuitenAreaM2 = spaanOnder.areaM2; spaanplaatBuitenVolumeM3 = spaanOnder.volumeM3; } if (buitenMateriaal === 'spaanplaat') { // Binnenwand: aan beide zijden spaanplaat. - const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible); + const spaanBuiten = buildVerticalBoardSkin(spaanplaatMaterial, 'front', buitenVisible && show('beplating')); spaanplaatBuitenAreaM2 = spaanBuiten.areaM2; spaanplaatBuitenVolumeM3 = spaanBuiten.volumeM3; } else if (buitenMateriaal === 'geen') { // Naadzijde: geen beplating (het deel van de andere unit staat ertegen). } else if (buitenMateriaal === 'cementplaat') { - const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, null, + const cementResult = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('beplating'), null, Object.assign(extM(0), { zOut: sThickM + 2 * skinGapM })); cementplaatAreaM2 = cementResult.areaM2; cementplaatVolumeM3 = cementResult.volumeM3; @@ -1227,7 +1373,7 @@ window.WallModel = (() => { trasraamIsolatieM3 = r.isoM3; y0 = trasraam.top * toM; } else if (trasraam) { - const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, { + const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + skinGapM }, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM)); trasraamAreaM2 = tras.areaM2; @@ -1257,6 +1403,10 @@ window.WallModel = (() => { // transparant zijn). const doekZStepM = 0.0002; const DOEK_OUT_M = 0.001; // 0.2 mm per baan, verwaarloosbaar t.o.v. de spouwdiepte + // Doek tot de bovenrand van de gevel (achter de dakrand langs, ook + // zichtbaar in fasen zonder dakrand); latwerk en rabat stoppen onder + // het boeideel/de daktrim (tlBat). + const tlBat = CONFIG_BAT === CONFIG_OUT ? tlOut : topLine(CONFIG_BAT, toM); const maxRoofY = tlOut.maxY; let rowY0 = skinFrom * toM; @@ -1279,17 +1429,15 @@ window.WallModel = (() => { return shape; }); if (rowShapes.length) { - const rowExtrudeSettings = { steps: 1, depth: sThickM, bevelEnabled: false }; - const rowGeo = shiftUV(new THREE.ExtrudeGeometry(rowShapes, rowExtrudeSettings)); const rowAreaM2 = rowShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0); - - const doekRow = new THREE.Mesh(rowGeo, doekMaterial); - // 1 mm naar buiten: niet in hetzelfde vlak als de dakrand (dak op - // de wand), anders flikkert het dak door het doek heen waar de - // gevel tot onder de dakrand doorloopt (skinAbove). - doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M); - doekRow.visible = buitenVisible; - wallGroup3D.add(doekRow); + if (doekOn) { + const doekRow = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(rowShapes, { steps: 1, depth: sThickM, bevelEnabled: false })), doekMaterial); + // 1 mm naar buiten: niet in hetzelfde vlak als de dakrand (dak op + // de wand), anders flikkert het dak door het doek heen waar de + // gevel tot onder de dakrand doorloopt (skinAbove). + doekRow.position.set(0, 0, (maxDepth / 2) + skinGapM + rowIndex * doekZStepM + DOEK_OUT_M); + wallGroup3D.add(doekRow); + } // Elke baan telt met zijn volle oppervlak mee (incl. de overlapzone // met de vorige baan) — dat is fysiek correct voor een materiaal- @@ -1306,10 +1454,11 @@ window.WallModel = (() => { if (b - a <= edgeEps || Math.min(roofAt(a), roofAt(b)) <= rowY0 + edgeEps) return; const pts = [{ x: a, y: rowY0 }, { x: b, y: rowY0 }, { x: b, y: Math.min(rowY1, roofAt(b)) }, { x: a, y: Math.min(rowY1, roofAt(a)) }]; const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x, p.y))); - const mesh = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: stripM, bevelEnabled: false })), doekMaterial); - mesh.position.set(0, 0, zOut); - mesh.visible = buitenVisible; - wallGroup3D.add(mesh); + if (doekOn) { + const mesh = new THREE.Mesh(shiftUV(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: stripM, bevelEnabled: false })), doekMaterial); + mesh.position.set(0, 0, zOut); + wallGroup3D.add(mesh); + } doekAreaM2 += shapeAreaWithHoles(shape); }); rowY0 += doekStepM; @@ -1325,7 +1474,7 @@ window.WallModel = (() => { trasraamStoneAreaM2 = r.areaM2; trasraamIsolatieM3 = r.isoM3; } else if (trasraam) { - const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible, { + const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM }, extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM)); trasraamAreaM2 = tras.areaM2; @@ -1334,7 +1483,7 @@ window.WallModel = (() => { // G. KRUISLAT op het vochtdoek (computeBattens): onderste laag direct op // het doek, bovenste laag (haaks op de gevelbekleding) daaroverheen. - const bat = computeBattens(CONFIG_OUT, cladding, trasraam, skinExt, facade, skinFrom, trimHolesMM); + const bat = computeBattens(CONFIG_BAT, cladding, trasraam, skinExt, facade, skinFrom, trimHolesMM); const bT = BATTEN.thick * toM, bW = BATTEN.width * toM; const zDoek = (maxDepth / 2) + skinGapM + sThickM + rowIndex * doekZStepM + skinGapM + DOEK_OUT_M; const addLayer = (orientation, z) => { @@ -1343,15 +1492,69 @@ window.WallModel = (() => { const len = horiz ? (b.x1 - b.x0) * toM : (b.y1 - b.y0) * toM; const mesh = new THREE.Mesh(new THREE.BoxGeometry(horiz ? len : bW, horiz ? bW : len, bT), battenMaterial); mesh.position.set(horiz ? (b.x0 + b.x1) / 2 * toM : b.x * toM, horiz ? b.y * toM : (b.y0 + b.y1) / 2 * toM, z + bT / 2); - mesh.visible = buitenVisible; wallGroup3D.add(mesh); }); }; - addLayer(bat.under, zDoek); - addLayer(bat.top, zDoek + bT); + if (battensOn) { + addLayer(bat.under, zDoek); + addLayer(bat.top, zDoek + bT); + } battenLengthM = bat.totalLengthMM * toM; battenVolumeM3 = battenLengthM * bT * bW; battenCount = bat.stock.count; + battenPieces = bat.lengths.length; + + // I. RABAT op de kruislat: één blok van het doek tot de voorkant van + // de planken (RABAT), boven het trasraam tot onder de dakrand (zoals + // het latwerk), met dezelfde uitsparingen en hoekdoorloop als de + // andere buitenlagen. Oppervlak (gevelvlak) altijd, het blok alleen + // vanaf fase "gevel". + const rabatDepthMM = 2 * BATTEN.thick + CLADDING_THICK_MM; + const rExt = extM(rabatDepthMM); + const rxL = -halfW - rExt.left, rxR = halfW + rExt.right; + const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM; + const rTop = (x) => tlBat.at(Math.max(-halfW, Math.min(halfW, x))); + const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps) + ? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => { + const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y))); + piece.holes.forEach(hole => shape.holes.push(new THREE.Path(hole.map(q => new THREE.Vector2(q.x, q.y))))); + return shape; + }) : []; + rabatAreaM2 = rabatShapes.reduce((sum, shape) => sum + shapeAreaWithHoles(shape), 0); + if (rabatOn && rabatShapes.length) { + const geo = new THREE.ExtrudeGeometry(rabatShapes, { steps: 1, depth: rabatDepthMM * toM, bevelEnabled: false }); + // UV's in gevel-coördinaten: u langs de gevel, v vanaf het latten- + // raster (bovenkant trasraam), zodat de planknaden doorlopen. + const gridM = (facade && facade.gridY0 !== undefined ? facade.gridY0 : skinStartY(trasraam)) * toM; + const pos = geo.attributes.position, nrm = geo.attributes.normal, uv = geo.attributes.uv; + for (let k = 0; k < pos.count; k++) { + const x = pos.getX(k) + uvShiftM, y = pos.getY(k) - gridM, z = pos.getZ(k); + if (Math.abs(nrm.getX(k)) > 0.5) uv.setXY(k, z, y); else if (Math.abs(nrm.getY(k)) > 0.5) uv.setXY(k, x, z); else uv.setXY(k, x, y); + } + uv.needsUpdate = true; + const mesh = new THREE.Mesh(geo, rabatMaterial(opts.rabatColor, cladding)); + mesh.position.set(0, 0, zDoek); + mesh.castShadow = true; + mesh.receiveShadow = true; + wallGroup3D.add(mesh); + } + } + + // Kozijnen (KozijnModel): vanaf de fase gevel. Buitenwand: buitenvlak + // gelijk met de buitenbeplating, 120 mm diep, met dorpel; binnen- en + // naadwand: over de volle wanddikte, zonder dorpel. + if (show('gevel') && 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 } + : buitenMateriaal === 'geen' ? { zOut: half, depth: CONFIG.studDepth + sh, exterior: false } + : { zOut: half + sh, depth: CONFIG.studDepth + 2 * sh, exterior: false }; + CONFIG.openings.forEach(op => { + const k = op.fill ? KozijnModel.build3D(op, op.fill, place) : null; + if (!k) return; + k.position.set(startXM + op.x * toM, op.y * toM, 0); + wallGroup3D.add(k); + }); } return { @@ -1359,11 +1562,18 @@ window.WallModel = (() => { stats: { woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2, spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount, - battenLengthM, battenVolumeM3, battenCount, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3 + battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3 } }; } + // Hout, dubbelzijdig (de schuine regel heeft geen vaste oriëntatie). + const wedgeMats = new WeakMap(); + function wedgeMaterial(wood) { + if (!wedgeMats.has(wood)) { const m = wood.clone(); m.side = THREE.DoubleSide; wedgeMats.set(wood, m); } + return wedgeMats.get(wood); + } + // Specificaties (volumes -> gewichten), op basis van de stats uit build3D. function computeSpecs(CONFIG, stats, buitenMateriaal) { const toM = 0.001; @@ -1440,6 +1650,27 @@ window.WallModel = (() => { pieces.push({ length: halfW - cursor, width: CONFIG.plateThick, depth: CONFIG.plateDepth, kind: 'onderregel', x: (cursor + halfW) / 2, y: CONFIG.plateThick / 2, cutAngles: [0, 0] }); } + // Schuine regel (CONFIG.topWedge): één stuk over de wand, uit een balk + // van de grootste wighoogte gezaagd onder de dakhelling. + // Per aaneengesloten stuk waar een wig zit (bijv. alleen onder een wolfseind). + if (CONFIG.topWedge) { + const wg = CONFIG.topWedge.pts, has = (q) => Math.max(q.o, q.i) >= 5, runs = []; + for (let k = 0; k < wg.length - 1; k++) { + if (!has(wg[k]) && !has(wg[k + 1])) continue; + const last = runs[runs.length - 1]; + if (last && last[last.length - 1] === wg[k]) last.push(wg[k + 1]); else runs.push([wg[k], wg[k + 1]]); + } + runs.forEach(run => { + const hMax = Math.max(...run.map(q => Math.max(q.o, q.i))); + const bevel = Math.round(Math.max(...run.map(q => Math.atan(Math.abs(q.o - q.i) / CONFIG.studDepth))) * 1800 / Math.PI) / 10; + let len = 0; + for (let k = 0; k < run.length - 1; k++) len += Math.hypot(run[k + 1].x - run[k].x, tl.at(run[k + 1].x) - tl.at(run[k].x)); + const outline = run.map(q => ({ x: q.x, y: tl.at(q.x) })).concat(run.slice().reverse().map(q => ({ x: q.x, y: tl.at(q.x) + Math.max(q.o, q.i) }))); + const xm = (run[0].x + run[run.length - 1].x) / 2; + pieces.push({ length: Math.round(len), width: Math.max(1, Math.round(hMax)), depth: CONFIG.studDepth, kind: 'schuine regel', x: xm, y: tl.at(xm) + hMax / 2, outline, cutAngles: [bevel, bevel] }); + }); + } + // Bovenregel(s): met nok twee stukken (verstek). outline = werkelijke // omtrek (PDF-tekening). topPlateOutlines(tl).forEach((outline, i) => { @@ -1580,6 +1811,9 @@ window.WallModel = (() => { if (o.type !== undefined && !OPENING_TYPES[o.type]) { throw new Error(`opening #${i + 1}: type moet ${Object.keys(OPENING_TYPES).join('/')} zijn (was: ${o.type})`); } + if (o.fill != null && (typeof o.fill !== 'object' || !KozijnModel.FILL_TYPES[o.fill.type])) { + throw new Error(`opening #${i + 1}: fill.type moet ${Object.keys(KozijnModel.FILL_TYPES).join('/')} zijn`); + } if (!['left', 'middle', 'right'].includes(o.anchor)) { throw new Error(`opening #${i + 1}: anchor moet 'left', 'middle' of 'right' zijn (was: ${o.anchor})`); } @@ -1587,8 +1821,8 @@ window.WallModel = (() => { } return { - STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText, - sheathingHoles, skinSegmentsAtY, topLine, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY, + STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText, + sheathingHoles, skinSegmentsAtY, topLine, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame, createMaterials, build3D, computeSpecs }; diff --git a/js/wall/wall-pdf.js b/js/wall/wall-pdf.js index 942dba1..2b955a9 100644 --- a/js/wall/wall-pdf.js +++ b/js/wall/wall-pdf.js @@ -180,13 +180,16 @@ window.WallPdf = (() => { doc.text(String(Math.round(hLeft)), px(-wallWidth / 2) - 9, (py(0) + py(hLeft)) / 2, { align: 'center', angle: 90 }); drawDimLine(px(wallWidth / 2) + 7, py(0), px(wallWidth / 2) + 7, py(hRight)); doc.text(String(Math.round(hRight)), px(wallWidth / 2) + 11, (py(0) + py(hRight)) / 2, { align: 'center', angle: 90 }); - if (tl.peak) { - // Nokhoogte: stippellijn van vloer tot nok. + // Tussenpunten van de daklijn: stippellijn van vloer tot nok/knik + // (nok = het enige hoogste punt; mansarde-/wolfsgevel: knikken). + const inner = tl.pts.slice(1, -1); + inner.forEach(q => { + const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1; doc.setLineDashPattern([1, 1], 0); - doc.line(px(tl.peak.x), py(0), px(tl.peak.x), py(tl.peak.y)); + doc.line(px(q.x), py(0), px(q.x), py(q.y)); doc.setLineDashPattern([], 0); - doc.text('nok ' + Math.round(tl.peak.y), px(tl.peak.x) + 1.5, py(tl.peak.y) + 4); - } + doc.text((isNok ? 'nok ' : 'knik ') + Math.round(q.y), px(q.x) + 1.5, py(q.y) + 4); + }); const studEdges = new Set([0, wallWidth]); calcData.fullStuds.forEach(x => studEdges.add(Math.round(x - CONFIG.studWidth / 2 + wallWidth / 2))); diff --git a/js/wall/wall.js b/js/wall/wall.js index 2ebc3d4..ac9a928 100644 --- a/js/wall/wall.js +++ b/js/wall/wall.js @@ -255,6 +255,11 @@ function mountWallEditor(container, callbacks) { const lockCladding = callbacks && callbacks.lockCladding; // ... en de buitenzijde (gevelbekleding per woning, override per wand). const lockExterior = callbacks && callbacks.lockExterior; + // Alles op het tabblad Materiaal ligt dan vast: tabblad weg, uitleg op Opbouw. + if (lockExterior && lockCladding) { + const tabBtn = container.querySelector('.tab-btn[data-tab="tab-materials"]'); + if (tabBtn) tabBtn.style.display = 'none'; + } // Binnen een woning is de h.o.h. globaal (woning-instellingen), niet per wand. if (callbacks && callbacks.lockSpacing) { @@ -279,8 +284,12 @@ function mountWallEditor(container, callbacks) { let calcData = { fullStuds: [], studs: [] }; let wallGroup3D = null; let lastBaseHeight = 2400; + // Daklijn met knikken (mansarde/wolfsdak, uit de unit-dakvorm): blijft + // staan tot een hoogte hier met de hand wordt gewijzigd. + let wallTopPts = null; - // Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset } + // Elke opening: { id, w, h, hR?, y, anchor: 'left'|'middle'|'right', offset, fill? } + // fill = vulling (kozijn, KozijnModel); zonder fill blijft hij open. // hR = hoogte rechts (schuine bovenzijde); zonder hR is de bovenkant // recht en is h de hoogte. De onderkant is altijd waterpas. // 'offset' betekent afhankelijk van anchor: @@ -288,7 +297,7 @@ function mountWallEditor(container, callbacks) { // right -> afstand vanaf rechter wandrand tot rechterkant opening // middle -> verschuiving t.o.v. het midden (0 = gecentreerd, + = naar rechts) let openings = [ - { id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 } + { id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') } ]; let openingIdCounter = 2; @@ -323,7 +332,7 @@ function mountWallEditor(container, callbacks) { const isSloped = (op) => op.hR !== undefined; // Ruwe opening zoals opgeslagen (hR alleen bij een schuine bovenzijde). const rawOpening = (o) => Object.assign({ id: o.id, type: o.type || 'raam', w: o.w, h: o.h, y: o.y, anchor: o.anchor, offset: o.offset }, - o.hR !== undefined ? { hR: o.hR } : {}); + o.hR !== undefined ? { hR: o.hR } : {}, o.fill ? { fill: Object.assign({}, o.fill) } : {}); function syncOpeningCardInputs(op) { const card = document.querySelector(`.opening-card[data-opening-id="${op.id}"]`); @@ -372,7 +381,8 @@ function mountWallEditor(container, callbacks) { h: preset.h, y: preset.y, anchor: 'left', - offset: 300 + n * 1400 + offset: 300 + n * 1400, + fill: KozijnModel.defaultFill('raam', lastFill()) }); renderOpeningsList(); updateAll(); @@ -439,6 +449,7 @@ function mountWallEditor(container, callbacks) { const tmp = hLeftInput.value; hLeftInput.value = hRightInput.value; hRightInput.value = tmp; + if (wallTopPts) wallTopPts = wallTopPts.map(q => ({ f: 1 - q.f, h: q.h })).reverse(); openings = openings.map(o => { // Schuine bovenzijde klapt mee om. @@ -454,6 +465,40 @@ function mountWallEditor(container, callbacks) { setIoStatus('Horizontaal gespiegeld.', 'ok'); } + // Vulling (kozijn, KozijnModel): type, rooster/glasstrook en kleuren. + function fillControls(op) { + const f = KozijnModel.normalize(op.fill); + const type = f ? f.type : 'geen'; + const opts = (entries, cur) => entries.map(([k, label]) => ``).join(''); + const colors = Object.entries(KozijnModel.COLORS).map(([k, c]) => [k, `${c.label} (${c.ral})`]); + const sw = (field, label, on, title) => `
+ +
+
`; + return `
+
+
+ +
+
+ ${type === 'raam' ? sw('fillRooster', 'Rooster', f.rooster, 'Ventilatierooster bovenin het kozijn') : ''} + ${type === 'deur' ? sw('fillGlas', 'Glasstrook', f.glas, 'Glasstrook in het deurblad') : ''} +
+
+ ${f ? `
+ +
+
+
+ +
+
` : ''} +
+
`; + } + // Kleuren van de laatste opening met een vulling (voor nieuwe openingen). + const lastFill = () => { const o = openings.slice().reverse().find(x => x.fill); return o ? o.fill : null; }; + function renderOpeningsList() { const list = document.getElementById('openings-list'); list.innerHTML = openings.map(op => ` @@ -511,6 +556,7 @@ function mountWallEditor(container, callbacks) { ${isSloped(op) ? `` : ''}
+ ${fillControls(op)}
`).join(''); @@ -538,12 +584,22 @@ function mountWallEditor(container, callbacks) { const op = openings.find(o => o.id === id); if (!op) return; if (field === 'type') { - // Type kiezen = standaardmaat van dat type overnemen (daarna vrij aan te passen). + // Type kiezen = standaardmaat en -vulling van dat type overnemen (daarna vrij aan te passen). const preset = WallModel.OPENING_TYPES[e.target.value]; op.type = e.target.value; op.w = preset.w; op.h = preset.h; op.y = preset.y; delete op.hR; + if (op.fill) op.fill = KozijnModel.defaultFill(op.type, op.fill); renderOpeningsList(); + } else if (field === 'fill') { + const v = e.target.value; + if (v === 'geen') delete op.fill; + else op.fill = Object.assign(KozijnModel.defaultFill(v === 'deur' ? (op.type === 'raam' ? 'deur' : op.type) : 'raam', op.fill || lastFill()), { type: v }); + renderOpeningsList(); + } else if (/^fill(Rooster|Glas|Out|In)$/.test(field)) { + if (!op.fill) return; + const key = { fillRooster: 'rooster', fillGlas: 'glas', fillOut: 'out', fillIn: 'in' }[field]; + op.fill[key] = e.target.type === 'checkbox' ? e.target.checked : e.target.value; } else if (field === 'sloped') { if (e.target.checked) op.hR = op.h; else delete op.hR; renderOpeningsList(); @@ -597,6 +653,7 @@ function mountWallEditor(container, callbacks) { heightRight: parseFloat(document.getElementById('inp-wall-h-right').value), // Middenpunt (nok); null = rechte bovenrand. heightMid: document.getElementById('inp-wall-peak').checked ? parseFloat(document.getElementById('inp-wall-h-mid').value) : null, + topPts: wallTopPts ? wallTopPts.map(q => Object.assign({}, q)) : undefined, studWidth: parseFloat(document.getElementById('inp-stud-w').value), studDepth: parseFloat(document.getElementById('inp-stud-d').value), plateThick: parseFloat(document.getElementById('inp-plate-w').value), @@ -610,7 +667,7 @@ function mountWallEditor(container, callbacks) { function getPureState() { return { wall: collectWallParams(), - materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone }, + materials: { interior: 'spaanplaat', exterior: buitenMateriaal, cladding, trasraam, stone, rabatColor }, openings: openings.map(rawOpening) }; } @@ -631,11 +688,13 @@ function mountWallEditor(container, callbacks) { document.getElementById('inp-min-sp').value = state.wall.minSpacing; document.getElementById('inp-max-sp').value = state.wall.maxSpacing; lastBaseHeight = state.wall.heightAll; + wallTopPts = Array.isArray(state.wall.topPts) && state.wall.topPts.length ? state.wall.topPts.map(q => Object.assign({}, q)) : null; buitenMateriaal = (state.materials && state.materials.exterior) || 'vochtdoek'; cladding = (state.materials && state.materials.cladding) || 'horizontaal'; trasraam = (state.materials && state.materials.trasraam) || null; // woning-instelling stone = (state.materials && state.materials.stone) || null; // woning-instelling (steen) + rabatColor = (state.materials && state.materials.rabatColor) || null; // woning-instelling (rabat) syncMaterialButtons(); openings = state.openings.map(rawOpening); @@ -789,6 +848,7 @@ function mountWallEditor(container, callbacks) { heightLeft: hLeft, heightRight: hRight, heightMid: peakOn ? hMid : null, + topPts: wallTopPts || undefined, studWidth: parseFloat(document.getElementById('inp-stud-w').value), studDepth: parseFloat(document.getElementById('inp-stud-d').value), plateThick: parseFloat(document.getElementById('inp-plate-w').value), @@ -987,14 +1047,16 @@ function mountWallEditor(container, callbacks) { const yAxisRightX = Math.min(w - 10, toCanvasX(halfWMM) + 14); drawCanvasDim(ctxFront, yAxisRightX, toCanvasY(0), yAxisRightX, toCanvasY(hRightMM), 'Y: ' + Math.round(hRightMM) + 'mm', Math.min(w - 4, yAxisRightX + 12), (toCanvasY(0) + toCanvasY(hRightMM)) / 2); - if (tl.peak) { - const pxC = toCanvasX(tl.peak.x); + // Tussenpunten van de daklijn (nok = het enige hoogste punt, anders knik). + const inner = tl.pts.slice(1, -1); + inner.forEach(q => { + const isNok = inner.filter(o => o.y >= q.y - 0.5).length === 1; ctxFront.fillStyle = '#9ab'; ctxFront.font = '11px sans-serif'; ctxFront.textAlign = 'center'; - ctxFront.fillText('nok ' + Math.round(tl.peak.y) + 'mm', pxC, Math.max(12, toCanvasY(tl.peak.y) - 6)); + ctxFront.fillText((isNok ? 'nok ' : 'knik ') + Math.round(q.y) + 'mm', toCanvasX(q.x), Math.max(12, toCanvasY(q.y) - 6)); ctxFront.textAlign = 'start'; - } + }); // Stijlstuk van y0 tot y1 (of tot de daklijn), zie WallModel.computeFrame. const drawStudFront = (st) => { @@ -1322,6 +1384,8 @@ function mountWallEditor(container, callbacks) { let cladding = 'horizontaal'; // richting gevelbekleding (alleen bij vochtdoek) let trasraam = null; // { bottom, top } mm, komt uit de woning-instellingen (WallModel.trasraamFor) let stone = null; // steencode bij cementplaat, komt uit de woning-instellingen + let rabatColor = null; // kleur rabat (WallModel.RABAT_COLORS), komt uit de woning-instellingen + let stage = BuildStages.FINAL; // bouwfase van de 3D (schuif linksonder) 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)); @@ -1355,6 +1419,7 @@ function mountWallEditor(container, callbacks) { 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(); }); // 3D-opbouw + volume-boekhouding komen uit WallModel.build3D (gedeeld // met de unit-editor). @@ -1363,7 +1428,7 @@ function mountWallEditor(container, callbacks) { function update3DScene() { if (wallGroup3D) scene.remove(wallGroup3D); const result = WallModel.build3D(CONFIG, calcData, { - materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, spaanplaatVisible, buitenVisible + materials: wallMaterials, buitenMateriaal, cladding, trasraam, stone, rabatColor, spaanplaatVisible, buitenVisible, stage }); wallGroup3D = result.group; lastWallStats = result.stats; @@ -1425,6 +1490,8 @@ function mountWallEditor(container, callbacks) { const inputs = document.querySelectorAll('#tab-wall input'); inputs.forEach(input => { + // Hoogte met de hand gewijzigd: de knikken van een mansarde-/wolfsdak vervallen. + if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; }); input.addEventListener('input', updateAll); }); @@ -1476,6 +1543,6 @@ window.WallEditor = { sheathingThick: 10, minSpacing: 240, maxSpacing: 480 }, materials: { interior: 'spaanplaat', exterior: 'vochtdoek', cladding: 'horizontaal' }, - openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0 }] + openings: [{ id: 1, type: 'raam', w: 1200, h: 1200, y: 600, anchor: 'middle', offset: 0, fill: KozijnModel.defaultFill('raam') }] }) }; diff --git a/json/premade/vlinderwoning.json b/json/premade/vlinderwoning.json new file mode 100644 index 0000000..2c6376c --- /dev/null +++ b/json/premade/vlinderwoning.json @@ -0,0 +1,1026 @@ +{ + "type": "house-design", + "version": 1, + "exportedAt": "2026-10-02T16:56:40.791Z", + "project": { + "naam": "Vlinderwoning", + "plaats": "", + "straat": "", + "nummer": "", + "tekenaar": "", + "opmerking": "Voorbeeldontwerp: twee units met vlinderdak (lessenaars naar de naad), lage tuinkamer met plat dak." + }, + "settings": { + "spacing": { + "exterior": { + "min": 240, + "max": 480 + }, + "interior": { + "min": 300, + "max": 600 + } + }, + "facade": { + "type": "rabat", + "stone": "K490" + }, + "cladding": "horizontaal", + "trasraam": { + "enabled": true, + "height": 350, + "finish": "K490" + }, + "interiorPaint": true, + "interiorColor": "#f2efe9", + "sandwich": true, + "groundLevel": 0, + "floorFinish": "visgraat_001", + "roof": { + "overhang": 300, + "fasciaHeight": 300, + "spotMin": 800, + "spotMax": 1200, + "trimHeight": 80, + "fasciaColor": "antraciet" + }, + "lighting": { + "mode": "spots", + "direction": "both", + "lampHeight": 1950, + "lampMin": 1500, + "lampMax": 3000, + "minWallWidth": 2000, + "endOffset": 300, + "openingOffset": 500, + "indoor": true, + "indoorMin": 800, + "indoorMax": 1500, + "indoorOffset": 600 + } + }, + "units": [ + { + "id": "A", + "attach": null, + "unit": { + "layout": { + "corners": "voor-achter" + }, + "floor": { + "floor": { + "width": 10000, + "depth": 4000, + "joistWidth": 70, + "joistHeight": 220, + "minSpacing": 300, + "maxSpacing": 600, + "underlayThick": 22 + }, + "chassis": { + "enabled": false, + "wheelDiameter": 400, + "trackWidth": 1200, + "wheelbase": 1200 + }, + "openings": [] + }, + "walls": { + "voor": { + "wall": { + "width": 10000, + "heightAll": 3400, + "heightLeft": 3400, + "heightRight": 3400, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [ + { + "id": 1, + "type": "raam", + "w": 1200, + "h": 1100, + "y": 1000, + "anchor": "left", + "offset": 700, + "fill": { + "type": "raam", + "rooster": true, + "glas": false, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 2, + "type": "voordeur", + "w": 1150, + "h": 2300, + "y": 0, + "anchor": "left", + "offset": 2700, + "fill": { + "type": "deur", + "rooster": false, + "glas": true, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 3, + "type": "raam", + "w": 500, + "h": 2300, + "y": 0, + "anchor": "left", + "offset": 3950, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 4, + "type": "raam", + "w": 2200, + "h": 600, + "y": 2000, + "anchor": "left", + "offset": 5200, + "fill": { + "type": "raam", + "rooster": true, + "glas": false, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 5, + "type": "raam", + "w": 900, + "h": 2100, + "y": 300, + "anchor": "right", + "offset": 1800, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 6, + "type": "raam", + "w": 900, + "h": 2100, + "y": 300, + "anchor": "right", + "offset": 600, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + } + ] + }, + "achter": { + "wall": { + "width": 10000, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 2700, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [] + }, + "links": { + "wall": { + "width": 3744, + "heightAll": 2700, + "heightLeft": 3400, + "heightRight": 2700, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [ + { + "id": 1, + "type": "raam", + "w": 1400, + "h": 1200, + "y": 900, + "anchor": "middle", + "offset": 0, + "fill": { + "type": "raam", + "rooster": true, + "glas": false, + "out": "antraciet", + "in": "wit" + } + } + ] + }, + "rechts": { + "wall": { + "width": 3744, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 3400, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [] + } + } + }, + "roof": { + "followFloor": true, + "offsetX": 0, + "offsetZ": 0, + "fascia": true, + "covering": "riet_002", + "deck": { + "floor": { + "width": 10000, + "depth": 4000, + "joistWidth": 70, + "joistHeight": 220, + "minSpacing": 300, + "maxSpacing": 600, + "underlayThick": 22 + }, + "chassis": { + "enabled": false, + "wheelDiameter": 400, + "trackWidth": 1200, + "wheelbase": 1200 + }, + "openings": [] + } + }, + "lighting": { + "walls": {} + }, + "facade": { + "walls": {} + } + }, + { + "id": "B", + "attach": { + "to": "A", + "side": "achter", + "align": "start", + "offset": 0 + }, + "unit": { + "layout": { + "corners": "voor-achter" + }, + "floor": { + "floor": { + "width": 10000, + "depth": 3600, + "joistWidth": 70, + "joistHeight": 220, + "minSpacing": 300, + "maxSpacing": 600, + "underlayThick": 22 + }, + "chassis": { + "enabled": false, + "wheelDiameter": 400, + "trackWidth": 1200, + "wheelbase": 1200 + }, + "openings": [] + }, + "walls": { + "voor": { + "wall": { + "width": 10000, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 2700, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [] + }, + "achter": { + "wall": { + "width": 10000, + "heightAll": 3400, + "heightLeft": 3400, + "heightRight": 3400, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [ + { + "id": 1, + "type": "raam", + "w": 600, + "h": 2600, + "y": 250, + "anchor": "left", + "offset": 800, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 2, + "type": "raam", + "w": 600, + "h": 2600, + "y": 250, + "anchor": "left", + "offset": 1700, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 3, + "type": "deur", + "w": 900, + "h": 2300, + "y": 0, + "anchor": "middle", + "offset": -1600, + "fill": { + "type": "deur", + "rooster": false, + "glas": true, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 4, + "type": "raam", + "w": 2000, + "h": 2300, + "y": 0, + "anchor": "middle", + "offset": 0, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 5, + "type": "deur", + "w": 900, + "h": 2300, + "y": 0, + "anchor": "middle", + "offset": 1600, + "fill": { + "type": "deur", + "rooster": false, + "glas": true, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 6, + "type": "raam", + "w": 1200, + "h": 1200, + "y": 1000, + "anchor": "right", + "offset": 700, + "fill": { + "type": "raam", + "rooster": true, + "glas": false, + "out": "antraciet", + "in": "wit" + } + } + ] + }, + "links": { + "wall": { + "width": 3344, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 3400, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [ + { + "id": 1, + "type": "raam", + "w": 1600, + "h": 500, + "y": 1700, + "anchor": "middle", + "offset": 0, + "fill": { + "type": "raam", + "rooster": true, + "glas": false, + "out": "antraciet", + "in": "wit" + } + } + ] + }, + "rechts": { + "wall": { + "width": 3344, + "heightAll": 2700, + "heightLeft": 3400, + "heightRight": 2700, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [ + { + "id": 1, + "type": "raam", + "w": 1000, + "h": 1300, + "y": 900, + "anchor": "left", + "offset": 500, + "fill": { + "type": "raam", + "rooster": true, + "glas": false, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 2, + "type": "raam", + "w": 1000, + "h": 1300, + "y": 900, + "anchor": "right", + "offset": 500, + "fill": { + "type": "raam", + "rooster": true, + "glas": false, + "out": "antraciet", + "in": "wit" + } + } + ] + } + } + }, + "roof": { + "followFloor": true, + "offsetX": 0, + "offsetZ": 0, + "fascia": true, + "covering": "riet_002", + "deck": { + "floor": { + "width": 10000, + "depth": 3600, + "joistWidth": 70, + "joistHeight": 220, + "minSpacing": 300, + "maxSpacing": 600, + "underlayThick": 22 + }, + "chassis": { + "enabled": false, + "wheelDiameter": 400, + "trackWidth": 1200, + "wheelbase": 1200 + }, + "openings": [] + } + }, + "lighting": { + "walls": {} + }, + "facade": { + "walls": {} + } + }, + { + "id": "C", + "attach": { + "to": "A", + "side": "rechts", + "align": "start", + "offset": 0 + }, + "unit": { + "layout": { + "corners": "voor-achter" + }, + "floor": { + "floor": { + "width": 4000, + "depth": 4000, + "joistWidth": 70, + "joistHeight": 220, + "minSpacing": 300, + "maxSpacing": 600, + "underlayThick": 22 + }, + "chassis": { + "enabled": false, + "wheelDiameter": 400, + "trackWidth": 1200, + "wheelbase": 1200 + }, + "openings": [] + }, + "walls": { + "voor": { + "wall": { + "width": 4000, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 2700, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [ + { + "id": 1, + "type": "raam", + "w": 2600, + "h": 1500, + "y": 700, + "anchor": "middle", + "offset": 0, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + } + ] + }, + "achter": { + "wall": { + "width": 4000, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 2700, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [ + { + "id": 1, + "type": "deur", + "w": 900, + "h": 2200, + "y": 0, + "anchor": "left", + "offset": 500, + "fill": { + "type": "deur", + "rooster": false, + "glas": true, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 2, + "type": "raam", + "w": 2000, + "h": 2200, + "y": 0, + "anchor": "right", + "offset": 400, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + } + ] + }, + "links": { + "wall": { + "width": 3488, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 2700, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [] + }, + "rechts": { + "wall": { + "width": 3488, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 2700, + "studWidth": 38, + "studDepth": 235, + "plateThick": 38, + "plateDepth": 235, + "sheathingThick": 10, + "minSpacing": 240, + "maxSpacing": 480, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "vochtdoek", + "cladding": "horizontaal", + "stone": null, + "trasraam": { + "bottom": 0, + "top": 350, + "finish": "K490" + } + }, + "openings": [ + { + "id": 1, + "type": "raam", + "w": 1100, + "h": 2100, + "y": 250, + "anchor": "left", + "offset": 500, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + }, + { + "id": 2, + "type": "raam", + "w": 1100, + "h": 2100, + "y": 250, + "anchor": "right", + "offset": 500, + "fill": { + "type": "raam", + "rooster": false, + "glas": false, + "out": "antraciet", + "in": "wit" + } + } + ] + } + } + }, + "roof": { + "followFloor": true, + "offsetX": 0, + "offsetZ": 0, + "fascia": true, + "covering": "dakleer", + "deck": { + "floor": { + "width": 4000, + "depth": 4000, + "joistWidth": 70, + "joistHeight": 220, + "minSpacing": 300, + "maxSpacing": 600, + "underlayThick": 22 + }, + "chassis": { + "enabled": false, + "wheelDiameter": 400, + "trackWidth": 1200, + "wheelbase": 1200 + }, + "openings": [] + } + }, + "lighting": { + "walls": {} + }, + "facade": { + "walls": {} + } + } + ], + "interiorWalls": [ + { + "id": 1, + "seam": { + "a": "A", + "b": "B" + }, + "wall": { + "wall": { + "width": 9744, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 2700, + "studWidth": 38, + "studDepth": 90, + "plateThick": 38, + "plateDepth": 90, + "sheathingThick": 10, + "minSpacing": 300, + "maxSpacing": 600, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "geen" + }, + "openings": [ + { + "id": 1, + "type": "deur", + "w": 900, + "h": 2150, + "y": 0, + "anchor": "middle", + "offset": 0, + "fill": { + "type": "deur", + "rooster": false, + "glas": false, + "out": "wit", + "in": "wit" + } + } + ] + } + }, + { + "id": 2, + "seam": { + "a": "A", + "b": "C" + }, + "wall": { + "wall": { + "width": 3744, + "heightAll": 2700, + "heightLeft": 2700, + "heightRight": 3400, + "studWidth": 38, + "studDepth": 90, + "plateThick": 38, + "plateDepth": 90, + "sheathingThick": 10, + "minSpacing": 300, + "maxSpacing": 600, + "heightMid": null + }, + "materials": { + "interior": "spaanplaat", + "exterior": "geen" + }, + "openings": [ + { + "id": 1, + "type": "deur", + "w": 900, + "h": 2150, + "y": 0, + "anchor": "middle", + "offset": 0, + "fill": { + "type": "deur", + "rooster": false, + "glas": false, + "out": "wit", + "in": "wit" + } + } + ] + } + } + ] +} \ No newline at end of file