diff --git a/README.md b/README.md index 108c5be..7e7162d 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,10 @@ assets/ logo, texturen, skybox (`PostModel`, `dak.zijwandM2`/`dak.zijwandMontage`). Ligt de buitenkant in het vlak van een gevel, dan loopt die bekleding (felsplaten, rabat, steen) erover door, gelijk met de gevel en tot onder de dakrand. + Bij een overkapping (veranda, inpandig of uitpandig: het dak van een unit + steekt buiten zijn vloer, `HouseModel.hasCover`) is er geen wolfsdak: de + keuze verdwijnt en een bestaand wolfsdak wordt een zadeldak met dezelfde + goot en nok. - **Dak over de hele woning:** eigenschappen Woning › Dak › "Over hele woning": één dakvorm (zelfde invoer als per unit) over de omhullende rechthoek van alle units; een wolfsdak alleen bij een rechthoek (bij een L-/T-vorm zouden @@ -210,7 +214,10 @@ assets/ logo, texturen, skybox dakplaat zet om over de rand, net vóór de gevelbekleding en zonder opstand (`RoofModel.FELS_EDGE`, zetwerk zit in de prijs van de felsplaten); een boeideel ernaast stopt gelijk met de buitenkant van zo'n - rand (ook naast daktrim). + rand (ook naast daktrim). Onder de boeidelen zit op de gevel een + ventilatierooster van 40 mm (direct onder de onderplaat, op de + gevelbekleding; niet boven een veranda), zwart/antraciet/wit + (`RoofModel.VENT`, Woning › Dak › Boeideel; kosten `dak.ventRoosterM`). - **Gevel:** gevelbekleding per woning, met override per wand en per wandstuk: rabat (vochtdoek + kruislat + rabat 12 mm, kleur per woning, getekend als plankentextuur die over naden doorloopt), steen (181 @@ -224,7 +231,10 @@ assets/ logo, texturen, skybox (bijv. stuc links, hout rechts). 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 + twee stenen kan. Op een stenen trasraam onder rabat, felsplaten of stuc + (minder ver uitstekend) komt een afdekker: zetwerk 5° aflopend met een + neus van 25 mm over de steen, onderbroken bij openingen, zwart/antraciet/wit + (`WallModel.TRAS_CAP`, `TRIM_COLORS`; kosten `gevel.afdekkerM`). Latwerk, doek en metselwerk lopen per gevel door over unit-naden. Sandwichpanelen rondom, 600 mm de grond in. - **Ruimtes** (`room/room-model.js`): elk door wanden afgesloten gebied is een ruimte, bepaald als een verfemmer: alles wat bereikbaar is zonder door @@ -352,8 +362,10 @@ plus `
` (mount vanzelf), of west/oost; zadel- en wolfsdak: nok noord–zuid of west–oost); bij een hellend dak de nokhoogte (lessenaar: hoge kant, met de helling eronder; de goot omhoog neemt de nok mee), bij een wolfsdak ook de hoogte van het wolfseind (bij een dak over de hele woning één keer), dakbedekking, gevelbekleding (hout met richting, felsplaten, stuc, steenstrips; kleur), - het accent van het model (de afwijkende gevel; of geen), trasraam (geen of steen, met hoogte; een cementplaat-trasraam van het model wordt geen), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele - woning alleen als de units samen een rechthoek vormen (zoals de configurator). Geen openingen, + het accent van het model (de afwijkende gevel; of geen), trasraam (geen of steen, met hoogte; een cementplaat-trasraam van het model wordt geen), boeidelen of daktrim + (met kleur boeidelen; een model met beide houdt zijn indeling tot je kiest), zetwerk-kleur (afdekker trasraam en + ventilatierooster), binnenspots aan/uit en het type buitenverlichting. Wolfsdak over de hele + woning alleen als de units samen een rechthoek vormen en nooit bij een overkapping (zoals de configurator). Geen openingen, binnenwanden, inrichting of elektra; die volgen het model. - **Keuzes op een schone kopie:** `LightModel.build` past de keuzes elke keer toe op het model zelf; wat niet afwijkt blijft precies zoals in het model. Bij een andere maat schuiven binnenwanden mee met hun unit en @@ -371,7 +383,8 @@ plus `
` (mount vanzelf), of site in een Bootstrap col-12): vanaf 720 px naast elkaar, smaller 3D boven en de keuzes eronder met prijs en PDF vast onderin beeld. Stijl alles onder `.tectum-light` (Bootstrap-reboot geneutraliseerd); Bootstrap Icons nodig (`css/vendor/bootstrap-icons.min.css`). Het scrollwiel scrolt de pagina, tenzij je in de 3D hebt - geklikt of Ctrl/⌘ ingedrukt houdt. Touch: één vinger draaien, twee vingers knijpen/schuiven (eigen + geklikt of Ctrl/⌘ ingedrukt houdt. Zijwaarts schuiven: rechts of middelste knop slepen, Shift/Ctrl/⌘ + + slepen, of de pijltjestoetsen met de muis boven de 3D. Touch: één vinger draaien, twee vingers knijpen/schuiven (eigen OrbitControls, geldt ook voor de configurator). - **Opslaan = PDF** (`LightPdf`): voorblad "Uw woningontwerp" (beeld uit de 3D-weergave, kerncijfers, keuzes in woorden via `LightModel.describe`, indicatieve prijs, contactgegevens en een QR-code naar de modelpagina diff --git a/js/app.js b/js/app.js index 24779cc..dcba411 100644 --- a/js/app.js +++ b/js/app.js @@ -4,7 +4,7 @@ * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ -const APP_VERSION = '0.7.5'; +const APP_VERSION = '0.7.6'; // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. let tabs = []; @@ -77,7 +77,8 @@ const KEY_HELP = [ ['Beeld', [['1 / 2 / 3', '2D + 3D, alleen 2D, alleen 3D'], ['4', 'Rapport aan/uit'], ['X', 'Doorsnede aan/uit'], ['F', 'Plattegrond passend'], ['L', 'Lopen aan/uit'], ['Ctrl+\\', 'Panelen verbergen'], ['F11', 'Volledig scherm']]], ['Wand-editor', [['Shift+H', 'Horizontaal spiegelen']]], - ['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts slepen', 'Verschuiven'], ['Scrollwiel', 'Zoomen']], true], + ['3D rondkijken', [['Slepen', 'Draaien'], ['Rechts of middelste knop slepen', 'Zijwaarts verschuiven'], ['Shift/Ctrl + slepen', 'Zijwaarts verschuiven'], + ['Pijltjestoetsen (muis boven de 3D)', 'Verschuiven (Shift = grotere stap)'], ['Scrollwiel', 'Zoomen']], true], ['Plattegrond', [['Scrollwiel', 'Zoomen'], ['Slepen op lege plek', 'Verschuiven'], ['Middelste muisknop', 'Verschuiven']], true], ['Lopen', [['W / S', 'Vooruit / achteruit (ook pijl op/neer)'], ['A / D', 'Opzij'], ['← / →', 'Draaien'], ['Shift', 'Rennen'], ['E', 'Deur of schakelaar bedienen'], ['L / Esc', 'Stoppen met lopen']]], ['Rondkijken tijdens lopen', [['Slepen', 'Rondkijken']], true] diff --git a/js/cost/cost-defaults.js b/js/cost/cost-defaults.js index 87c5c2e..4054255 100644 --- a/js/cost/cost-defaults.js +++ b/js/cost/cost-defaults.js @@ -97,7 +97,9 @@ window.COST_DEFAULTS = { stucM2: { label: 'Stucwerk gevel (wapening + sierpleister op cementgebonden plaat)', unit: '€/m²', value: 45 }, stucMontage: { label: 'Stucwerk aanbrengen (wapenen, pleisteren)', unit: 's/m²', value: 1500 }, felsM2: { label: 'Felsplaten gevel, staande naad (staal, klangen, ondergrond)', unit: '€/m²', value: 70 }, - felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 } + felsMontage: { label: 'Montage felsplaten gevel (ook dagkanten en hoeken)', unit: 's/m²', value: 2100 }, + afdekkerM: { label: 'Afdekker trasraam (aluminium zetwerk, gepoedercoat)', unit: '€/m', value: 14 }, + afdekkerMontage: { label: 'Montage afdekker trasraam', unit: 's/m', value: 300 } } }, @@ -118,6 +120,8 @@ window.COST_DEFAULTS = { 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 }, + ventRoosterM: { label: 'Ventilatierooster onder boeideel (40 mm, aluminium)', unit: '€/m', value: 6 }, + ventRoosterMontage: { label: 'Montage ventilatierooster', unit: 's/m', value: 120 }, staalKg: { label: 'Stalen nokbalk IPE S235 (op maat gezaagd, geboord, gegrond)', unit: '€/kg', value: 2.9 }, nokOplegging: { label: 'Oplegging nokbalk (stalen schoen + bouten), 2 per balk', unit: '€/st.', value: 45 }, nokPlaatsen: { label: 'Stalen nokbalk plaatsen, stellen en vastzetten', unit: 's', value: 2700 }, diff --git a/js/cost/cost-model.js b/js/cost/cost-model.js index 2d99e8f..d3bdeee 100644 --- a/js/cost/cost-model.js +++ b/js/cost/cost-model.js @@ -159,6 +159,8 @@ window.CostModel = (() => { add(el, 'Gevel', 'Gevel', 'Felsplaten (staande naad)', fels, 'm²', fels * (G.felsM2 || 0), fels * (G.felsMontage || 0)); const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0); add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage); + const cap = (st.trasraamCapM || 0); + add(el, 'Gevel', 'Gevel', 'Afdekker trasraam', cap, 'm', cap * (G.afdekkerM || 0), cap * (G.afdekkerMontage || 0)); const stuc = st.stucAreaM2 || 0; add(el, 'Gevel', 'Gevel', 'Stucwerk', stuc, 'm²', stuc * (G.stucM2 || 0), stuc * (G.stucMontage || 0)); const latM = st.battenLengthM || 0; @@ -239,6 +241,7 @@ window.CostModel = (() => { 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); + add(wo, 'Dak', 'Dak', 'Ventilatierooster onder boeideel', e.ventLengthM || 0, 'm', (e.ventLengthM || 0) * (D.ventRoosterM || 0), (e.ventLengthM || 0) * (D.ventRoosterMontage || 0)); } if (totals.posts) { const pp = totals.posts; diff --git a/js/house/house-model.js b/js/house/house-model.js index 07e3851..102512b 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -36,7 +36,7 @@ window.HouseModel = (() => { spacing: { exterior: { min: 240, max: 480 }, interior: { min: 300, max: 600 } }, facade: { type: 'rabat', stone: 'K490', rabatColor: 'naturel', stucColor: 'wit', felsColor: 'antraciet' }, cladding: 'horizontaal', - trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat' }, + trasraam: { enabled: true, height: WallModel.TRASRAAM_DEFAULT_MM, finish: 'cementplaat', capColor: 'antraciet' }, interiorPaint: true, interiorColor: '#f2efe9', ceilingColor: '#f4f3ef', @@ -67,7 +67,7 @@ window.HouseModel = (() => { } function facadeMaterials(state, fc) { const tr = state.settings.trasraam; - const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish) : null; + const trasraam = tr.enabled ? WallModel.trasraamFor(tr.height, tr.finish, tr.capColor) : null; const cladding = state.settings.cladding; if (fc.type === 'steen') return { exterior: 'cementplaat', stone: fc.stone, stuc: null, cladding, trasraam, rabatColor: null, fels: null }; if (fc.type === 'stuc') return { exterior: 'cementplaat', stone: null, stuc: fc.stuc, cladding, trasraam, rabatColor: null, fels: null }; @@ -1121,10 +1121,33 @@ window.HouseModel = (() => { } }); state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; }); + // Geen wolfsdak boven een overkapping: wordt een zadeldak met dezelfde goot en nok. + if (houseRoofOn(state)) { + if (state.settings.roof.house.form.type === 'wolf' && state.units.some(u => roofCovers(u, cover))) state.settings.roof.house.form.type = 'zadel'; + } else state.units.forEach(u => { + const f = UnitModel.roofForm(u.unit); + if (f.type === 'wolf' && roofCovers(u, cover)) UnitModel.applyRoofForm(u.unit, Object.assign(f, { type: 'zadel' })); + }); applyHouseRoof(state); applyRoofJoins(state); } + // Overkapping (veranda, inpandig of uitpandig): het dak van de unit steekt buiten zijn vloer. Een vrije dakmaat + // telt pas vanaf de kleinste paal-uitkraging; daaronder is het overstek. + function roofCovers(u, cover) { + if (!u.roof.followFloor) { + const d = u.roof.deck.floor, f = u.unit.floor.floor, m = 400; + return (d.width - f.width) / 2 + Math.abs(u.roof.offsetX || 0) > m || (d.depth - f.depth) / 2 + Math.abs(u.roof.offsetZ || 0) > m; + } + const c = cover[u.id] || {}, x = u.roof.extend || {}; + return ['l', 'r', 'f', 'b'].some(k => (c[k] || 0) + (x[k] || 0) > EPS); + } + // Overkapping onder het dak van de unit (unitId) of ergens onder de woning (geen unitId): dan geen wolfsdak. + function hasCover(state, unitId) { + const cover = coverExtensions(state); + return state.units.some(u => (!unitId || u.id === unitId) && roofCovers(u, cover)); + } + const EXTEND_MAX = 4000; function normalizeExtend(e) { return Object.fromEntries(['f', 'r', 'b', 'l'].map(k => [k, Math.max(0, Math.min(EXTEND_MAX, Math.round(Number((e || {})[k]) || 0)))])); @@ -1873,7 +1896,7 @@ window.HouseModel = (() => { }; const facadeDepth = (a, b, nrm) => { const e = edgePiece(a, b, nrm); - return e ? Math.sign(e.dot) * WallModel.claddingOffset(e.p.buitenMateriaal, e.p.stone, e.p.stuc) : 0; + return e ? Math.sign(e.dot) * WallModel.claddingOffset(e.p.buitenMateriaal, e.p.stone, e.p.stuc) : null; // null: geen gevel (veranda) }; const facadeFels = (a, b, nrm) => { const e = edgePiece(a, b, nrm); return !!(e && e.dot > 0 && e.p.fels); }; const roofEdge = units.length ? RoofModel.computeEdge(state.settings.roof, @@ -2219,6 +2242,7 @@ window.HouseModel = (() => { woodM3: sumU(u => u.floorSpecs.joistWoodM3 + u.roof.specs.joistWoodM3) + sumP(p => p.stats.woodVolumeM3), woodKg: sumU(u => u.floorSpecs.joistWoodKg + u.roof.specs.joistWoodKg) + sumP(p => p.specs.woodKg), battens: { count: sumP(p => p.stats.battenCount || 0), lengthM: sumP(p => p.stats.battenLengthM || 0), kg: sumP(p => p.specs.battenKg || 0) }, + trasraamCap: { lengthM: sumP(p => p.stats.trasraamCapM || 0), color: (WallModel.TRIM_COLORS[(pieces.find(p => p.stats.trasraamCapM > 0 && p.trasraam) || { trasraam: {} }).trasraam.capColor] || {}).label || '' }, isolatie, isolatieM3: isolatie.reduce((a, r) => a + r.m3, 0), plaatwerk: [ @@ -2292,6 +2316,8 @@ window.HouseModel = (() => { overhang: parts.roofEdge.overhang, soffitM2: parts.roofEdge.soffitM2, trimLengthM: parts.roofEdge.trimLengthMM / 1000, + ventLengthM: (parts.roofEdge.ventLengthMM || 0) / 1000, + ventColor: parts.roofEdge.vent ? (WallModel.TRIM_COLORS[parts.roofEdge.ventColor] || {}).label || '' : '', trimHeight: parts.roofEdge.trimHeight, spots: parts.roofEdge.spots.length } : null, @@ -2357,6 +2383,7 @@ window.HouseModel = (() => { state.settings.trasraam.enabled = state.settings.trasraam.enabled !== false; if (!(state.settings.trasraam.height > 0)) state.settings.trasraam.height = WallModel.TRASRAAM_DEFAULT_MM; if (!WallModel.isStoneFinish(state.settings.trasraam.finish)) state.settings.trasraam.finish = 'cementplaat'; + if (!WallModel.isTrimColor(state.settings.trasraam.capColor)) state.settings.trasraam.capColor = 'antraciet'; state.settings.sandwich = state.settings.sandwich !== false; if (!/^#[0-9a-f]{6}$/i.test(state.settings.interiorColor || '')) state.settings.interiorColor = def.interiorColor; if (!/^#[0-9a-f]{6}$/i.test(state.settings.ceilingColor || '')) state.settings.ceilingColor = def.ceilingColor; @@ -2393,7 +2420,7 @@ window.HouseModel = (() => { ALIGN_LABELS, SIDE_LABELS, DEFAULT_INTERIOR_THICKNESS, FACADE_TYPES, facadeFor, facadeMaterials, clone, createEmptyProject, createDefaultSettings, createUnitEntry, applyGlobalSettings, nextUnitId, MODELS, createDefaultState, unitRects, computeSeams, overlappingPairs, interiorWallDefaults, setThickness, thicknessOf, interiorLayout, ensureSeamWalls, syncSeamWalls, missingSeamWalls, - houseRoofShape, houseRoofFits, setHouseRoof, coverExtensions, EXTEND_MAX, + houseRoofShape, houseRoofFits, hasCover, setHouseRoof, coverExtensions, EXTEND_MAX, computeParts, placeGroups, rootGroups, groundOrigin, sectionCaps, 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 ab0a7d7..c520e84 100644 --- a/js/house/house-pdf.js +++ b/js/house/house-pdf.js @@ -271,6 +271,12 @@ window.HousePdf = (() => { const trColor = WallModel.isStoneFinish(tr.finish) ? (WallModel.STONE_COLORS[tr.finish] || '#a0724f') : VIEW_COLORS.cementplaat; const zT = p.place.thickness / 2 + (WallModel.isStoneFinish(tr.finish) ? WallModel.STONE.THICK_MM : 12); faces.push({ kind: 'trasraam', pts: band.map(q => toWorld(p, q.x, q.y, zT)), n, color: trColor, bias: 1 }); + // Afdekker: neus over de bovenkant van de steen (alleen in 3D onderbroken bij openingen). + if (p.stats && p.stats.trasraamCapM > 0) { + const C = WallModel.TRAS_CAP, yLo = tr.top - C.DROP_MM, yHi = tr.top + C.THICK_MM; + faces.push({ kind: 'trasraam', pts: [[-W / 2, yLo], [W / 2, yLo], [W / 2, yHi], [-W / 2, yHi]].map(([lx, ly]) => toWorld(p, lx, ly, zT + C.GAP_MM + C.THICK_MM)), + n, color: WallModel.trimHex(tr.capColor), bias: 1.5 }); + } } (p.config.openings || []).filter(op => op.type !== 'schoorsteen').forEach(op => { if (WallModel.openingTopMax(op) <= y0) return; @@ -328,6 +334,17 @@ window.HousePdf = (() => { 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 }); } + // Rooster op de gevel, onder de onderplaat. + if (sg.fascia && sg.facadeUnder && edge.vent) { + const V = RoofModel.VENT, d = RoofModel.ventFace(sg) + V.THICK_MM - (sg.a.x * sg.normal.x + sg.a.z * sg.normal.z); + const vTop = []; + for (let k = 0; k <= n; k++) { + const I = lerp(sg.a, sg.b, k / n), q = { x: I.x + sg.normal.x * d, z: I.z + sg.normal.z * d }; + vTop.push({ x: q.x, y: sg.topAt(q) - h, z: q.z }); + } + faces.push({ kind: 'dakrand', pts: vTop.concat(vTop.map(q => ({ x: q.x, y: q.y - V.HEIGHT_MM, z: q.z })).reverse()), + n: { x: sg.normal.x, z: sg.normal.z }, color: WallModel.trimHex(edge.ventColor), bias: 2.5 }); + } faces.push({ kind: 'dakrand', pts: top.concat(bottom.reverse()), n: { x: sg.normal.x, z: sg.normal.z }, color: RoofModel.edgeColor(edge, sg), bias: 3 }); }); } @@ -371,7 +388,11 @@ window.HousePdf = (() => { items.push(WallModel.isStoneFinish(tf) ? { label: `Trasraam (${st.trasraam.height} mm)`, value: `Steen ${tf}`, thumb: WallModel.stoneThumbUrl(tf, 64), color: WallModel.STONE_COLORS[tf] } : { label: `Trasraam (${st.trasraam.height} mm)`, value: 'Cementplaat', color: VIEW_COLORS.cementplaat }); + const tc = WallModel.TRIM_COLORS[st.trasraam.capColor]; + if (parts && tc && HouseModel.allWallPieces(parts).some(p => p.stats && p.stats.trasraamCapM > 0)) items.push({ label: 'Afdekker trasraam', value: tc.label, color: tc.hex }); } + const vc = parts && parts.roofEdge && parts.roofEdge.vent && parts.roofEdge.ventLengthMM > 0 && WallModel.TRIM_COLORS[parts.roofEdge.ventColor]; + if (vc) items.push({ label: 'Ventilatierooster', value: vc.label, color: vc.hex }); (parts ? parts.units : state.units).forEach(u => { const cov = parts ? u.roof.covering : (u.roof.covering || 'dakleer'); const c = RoofModel.COVERINGS[cov] || RoofModel.COVERINGS.dakleer; @@ -618,6 +639,8 @@ window.HousePdf = (() => { rows.push(['Onderplaat boeideel', fmt(e.soffitM2, 2) + ' m2']); } if (e.trimLengthM > 0) rows.push(['Daktrim', `${fmt(e.trimLengthM, 2)} m, ${e.trimHeight} mm hoog`]); + if (e.ventLengthM > 0) rows.push(['Ventilatierooster', `${fmt(e.ventLengthM, 2)} m op de gevel onder de boeidelen, ${RoofModel.VENT.HEIGHT_MM} mm hoog, ${e.ventColor.toLowerCase()}`]); + if (totals.trasraamCap && totals.trasraamCap.lengthM > 0) rows.push(['Afdekker trasraam', `${fmt(totals.trasraamCap.lengthM, 2)} m, ${totals.trasraamCap.color.toLowerCase()}`]); rows.push(['Boeideel-spots', e.spots + ' stuks']); rows.push(['Gevellampen', `${L.total} stuks (boven+onder ${L.both}, onder ${L.down}, boven ${L.up})`]); rows.push(['Binnenspots', `${L.indoor} stuks (plafond)`]); diff --git a/js/house/house.js b/js/house/house.js index 6828241..0ba9dad 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -1312,6 +1312,8 @@ function mountHouseEditor(container, callbacks) { const fmtDeg = (d) => String(Math.round(d * 10) / 10).replace('.', ','); const stoneOptions = Object.assign({}, ...WallModel.TRASRAAM_STONES.map(c => ({ [c]: `Steen ${c}` }))); const trasraamFinishes = Object.assign({ cementplaat: 'Cementplaat' }, stoneOptions); + const TRIM_OPTS = Object.fromEntries(Object.entries(WallModel.TRIM_COLORS).map(([k, c]) => [k, c.label])); + const NO_WOLF = Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')); const stucColors = Object.fromEntries(Object.entries(WallModel.STUC_COLORS).map(([k, c]) => [k, c.label])); const felsColors = Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, c.label])); const swatchOf = (hex) => `
`; @@ -1352,7 +1354,10 @@ function mountHouseEditor(container, callbacks) { (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) : '') + WallModel.isStoneFinish(st.trasraam.finish) ? thumb(WallModel.stoneThumbUrl(st.trasraam.finish, 64), st.trasraam.finish) : '') + + (WallModel.isStoneFinish(st.trasraam.finish) + ? pair(sel('hs-trasraam-cap', 'Afdekker', TRIM_OPTS, st.trasraam.capColor, { hint: 'Zetwerk op de steen waar de gevel erboven minder ver uitsteekt (rabat, felsplaat, stuc): 5° aflopend, neus 25 mm over de steen' }), + swatchOf(WallModel.trimHex(st.trasraam.capColor))) : '') : '') }, { 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` }) + @@ -1371,6 +1376,7 @@ function mountHouseEditor(container, callbacks) { 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.onSel('hs-trasraam-cap', v => { st.trasraam.capColor = v; }, rerender); UI.onToggle('hs-sandwich', v => { st.sandwich = v; }, rerender); UI.onRange('hs-ground', v => { if (v >= 0) st.groundLevel = v; }, after); } @@ -1381,9 +1387,9 @@ function mountHouseEditor(container, callbacks) { key: 'dak', label: 'Dak', order: 30, groups: () => { const r = state.settings.roof, hr = r.house; - const wolfOk = HouseModel.houseRoofFits(state); + const wolfOk = HouseModel.houseRoofFits(state) && !HouseModel.hasCover(state); houseRoofUi = hr.enabled && hr.form ? roofFormUi('hs', hr.form, { - types: wolfOk || hr.form.type === 'wolf' ? null : Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')), + types: wolfOk || hr.form.type === 'wolf' ? null : NO_WOLF, notice: !wolfOk && hr.form.type === 'wolf' ? 'Wolfsdak alleen als de units samen een rechthoek vormen; bij deze L-/T-vorm wordt het een zadeldak.' : null, span: () => { const sh = HouseModel.houseRoofShape(state); return sh ? sh.span : 1000; }, apply: () => HouseModel.applyGlobalSettings(state), @@ -1404,7 +1410,9 @@ function mountHouseEditor(container, callbacks) { { 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) }, + sel('hs-fascia-color', 'Kleur', Object.fromEntries(Object.entries(RoofModel.FASCIA_COLORS).map(([k, c]) => [k, c.label])), r.fasciaColor) + + toggle('hs-vent', 'Ventilatierooster', r.vent !== false, { hint: `${RoofModel.VENT.HEIGHT_MM} mm hoog op de gevel, direct onder alle boeidelen` }) + + (r.vent !== false ? pair(sel('hs-vent-color', 'Kleur rooster', TRIM_OPTS, r.ventColor || 'antraciet'), swatchOf(WallModel.trimHex(r.ventColor))) : '') }, { label: 'Daktrim', html: num('hs-trim-h', 'Hoogte', r.trimHeight, { step: 5, hint: 'Bij units zonder boeidelen' }) }, { label: 'Palen overkapping', html: toggle('hs-posts', 'Palen', r.posts.enabled, { hint: `Onder een dak dat minstens de min. uitkraging buiten de vloer steekt (veranda); per unit: Overkapping. Nu ${parts ? parts.posts.count : 0} st.` }) + @@ -1426,6 +1434,8 @@ function mountHouseEditor(container, callbacks) { 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.onToggle('hs-vent', v => { r.vent = v; }, rerender); + UI.onSel('hs-vent-color', v => { r.ventColor = v; }, rerender); UI.onNum('hs-trim-h', v => { r.trimHeight = v; }, after, pos); UI.onToggle('hs-posts', v => { r.posts.enabled = v; }, rerender); UI.onToggle('hs-posts-side', v => { r.posts.sideWall = v; }, rerender); @@ -1889,7 +1899,7 @@ function mountHouseEditor(container, callbacks) { const f = (k) => `${id}-roof-${k}`; const groups = () => { const g = [ - { label: 'Dakvorm', html: sel(f('type'), 'Vorm', o.types || UnitModel.ROOF_TYPES, form.type) + (o.notice ? note(o.notice) : '') + + { label: 'Dakvorm', html: sel(f('type'), 'Vorm', (typeof o.types === 'function' ? o.types() : o.types) || UnitModel.ROOF_TYPES, form.type) + (o.notice ? note(o.notice) : '') + (form.type === 'lessenaar' ? sel(f('dir'), 'Hoge zijde', SIDE_SHORT, form.dir) : '') + (UnitModel.hasRidge(form.type) ? sel(f('ridge'), 'Nok', UnitModel.RIDGES, form.ridge) : '') + UI.range(f('low'), sloped() ? 'Goot' : 'Hoogte', form.low, { min: 2100, max: LIM.eave, step: 10, hint: `Vanaf bovenkant vloer tot onderkant dak (max ${LIM.eave} mm)` }) } @@ -1959,6 +1969,7 @@ function mountHouseEditor(container, callbacks) { const form = UnitModel.roofForm(u.unit); const roofUi = roofFormUi('hp', form, { gable: true, + types: () => HouseModel.hasCover(state, u.id) ? NO_WOLF : null, span: () => { const pu = partsOf(); const s = UnitModel.WALL_SIDES.find(x => x.key === UnitModel.gableOf(form)); @@ -2030,13 +2041,15 @@ function mountHouseEditor(container, callbacks) { UI.onToggle('hp-fascia', v => { u.roof.fascia = v; }, rerender); UI.onToggle('hp-steel-ridge', v => { u.roof.steelRidge = v; }, rerender); UI.onToggle('hp-roof-follow', v => { u.roof.followFloor = v; }, rerender); + // Een overkapping maakt van een wolfsdak een zadeldak: dan het paneel opnieuw opbouwen. + const afterCover = () => (form.type === 'wolf' ? rerender : after)(); ['f', 'r', 'b', 'l'].forEach(k => UI.onNum('hp-ext-' + k, v => { u.roof.extend = Object.assign({ f: 0, r: 0, b: 0, l: 0 }, u.roof.extend, { [k]: Math.max(0, Math.min(HouseModel.EXTEND_MAX, v)) }); - }, after)); - 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); + }, afterCover)); + UI.onNum('hp-roof-w', v => { if (v >= 500) u.roof.deck.floor.width = v; }, afterCover); + UI.onNum('hp-roof-d', v => { if (v >= 500) u.roof.deck.floor.depth = v; }, afterCover); + UI.onNum('hp-roof-ox', v => { u.roof.offsetX = v; }, afterCover); + UI.onNum('hp-roof-oz', v => { u.roof.offsetZ = v; }, afterCover); } }; } @@ -2334,6 +2347,8 @@ function mountHouseEditor(container, callbacks) { const e = totals.roofEdge, rows = []; if (e.fasciaLengthM > 0) rows.push([`Boeidelen · ${e.fasciaHeight} mm`, f(e.fasciaLengthM, 2), 'm'], ['Onderplaat boeideel', f(e.soffitM2, 2), 'm²']); if (e.trimLengthM > 0) rows.push([`Daktrim · ${e.trimHeight} mm`, f(e.trimLengthM, 2), 'm']); + if (e.ventLengthM > 0) rows.push([`Ventilatierooster · ${e.ventColor.toLowerCase()}`, f(e.ventLengthM, 2), 'm']); + if (totals.trasraamCap.lengthM > 0) rows.push([`Afdekker trasraam · ${totals.trasraamCap.color.toLowerCase()}`, f(totals.trasraamCap.lengthM, 2), 'm']); rows.push(['Boeideel-spots', e.spots, 'st.'], ['Gevellampen', totals.lamps.total, 'st.'], ['Binnenspots', totals.lamps.indoor, 'st.']); cards.push(UI.specCard('Dakrand & verlichting', 'lightbulb', rows)); } diff --git a/js/light/light-model.js b/js/light/light-model.js index 0c03afb..2903779 100644 --- a/js/light/light-model.js +++ b/js/light/light-model.js @@ -77,7 +77,11 @@ window.LightModel = (() => { const t = base.settings.trasraam, stone = WallModel.isStoneFinish(t.finish) && t.finish !== 'cementplaat'; return { on: !!t.enabled && stone, finish: stone ? t.finish : null, height: t.height }; })(), - lighting: { mode: base.settings.lighting.mode, direction: base.settings.lighting.direction, indoor: base.settings.lighting.indoor !== false } + lighting: { mode: base.settings.lighting.mode, direction: base.settings.lighting.direction, indoor: base.settings.lighting.indoor !== false }, + // Boeidelen (aan = boeidelen, uit = daktrim) met kleur; een model met beide houdt zijn eigen indeling tot je kiest. + fascia: { on: base.units.some(u => u.roof.fascia !== false), color: RoofModel.FASCIA_COLORS[base.settings.roof.fasciaColor] ? base.settings.roof.fasciaColor : 'antraciet' }, + // Zetwerk: één kleur voor de afdekker op het trasraam en het ventilatierooster onder de boeidelen. + trim: WallModel.isTrimColor(base.settings.roof.ventColor) ? base.settings.roof.ventColor : 'antraciet' }; const model = { key, label: def.label, base, forms, hasAccent: acc.length > 0, frame: frameOf(key, opts && opts.frames), initial: clone(choices) }; const st = build(model, choices); @@ -222,6 +226,9 @@ window.LightModel = (() => { accents(st).forEach(o => o.set(a ? Object.assign({ type: a.type }, a.type === 'steen' ? { stone: a.color } : a.type === 'stuc' ? { stuc: a.color } : a.type === 'fels' ? { fels: a.color } : {}) : null)); } Object.assign(st.settings.lighting, { mode: ch.lighting.mode, direction: ch.lighting.direction, indoor: ch.lighting.indoor }); + st.settings.trasraam.capColor = st.settings.roof.ventColor = ch.trim; + if (ch.fascia.on !== model.initial.fascia.on) st.units.forEach(u => { u.roof.fascia = ch.fascia.on; }); + st.settings.roof.fasciaColor = ch.fascia.color; return st; } @@ -326,12 +333,17 @@ window.LightModel = (() => { ]; const tr = ch.trasraam; rows.push(['Trasraam', tr.on ? `Steen ${tr.finish}, ${nl(tr.height / 1000)} m hoog` : 'Geen']); + rows.push(['Dakrand', ch.fascia.on ? `Boeidelen ${RoofModel.FASCIA_COLORS[ch.fascia.color].label.toLowerCase()}` : 'Daktrim']); + const trimUse = trimUses(ch); + if (trimUse) rows.push(['Zetwerk', `${WallModel.TRIM_COLORS[ch.trim].label} (${trimUse})`]); if (model.hasAccent) rows.push(['Accentgevel', ch.accent ? cap(facade(ch.accent.type, ch.accent.type === 'rabat' ? fc.rabat : ch.accent.color)) : 'Geen']); rows.push(['Buitenverlichting', LIGHTING[ch.lighting.mode] + (ch.lighting.mode === 'gevel' ? ` (${LightingModel.DIRECTIONS[ch.lighting.direction].toLowerCase()})` : '')]); rows.push(['Binnenverlichting', ch.lighting.indoor ? 'Inbouwspots in het plafond' : 'Geen']); return rows; } const nl = (n) => n.toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + // Waar de zetwerk-kleur voor geldt (leeg: nergens). + const trimUses = (ch) => [ch.trasraam.on ? 'afdekker trasraam' : null, ch.fascia.on ? 'ventilatierooster' : null].filter(Boolean).join(' en '); - return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, create, build, check, fit, summary, describe, colorName }; + return { DEFAULT_FRAME, FRAMES, STEP, PITCH, SIDES, formOf, FACADES, STONES, LIGHTING, trimUses, create, build, check, fit, summary, describe, colorName }; })(); diff --git a/js/light/light.js b/js/light/light.js index 9d5be5a..ce98d87 100644 --- a/js/light/light.js +++ b/js/light/light.js @@ -35,7 +35,7 @@ window.TectumLight = (() => { // Doorsnede: snijhoogte boven de vloer (mm), zoals de doorsnede van de configurator. const SECTION = { start: 1500, min: 300, max: 3000 }; - const HINTS = { '3d': 'Slepen = draaien · rechts slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' }; + const HINTS = { '3d': 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', cut: 'Slepen = draaien · rechts of Shift+slepen = schuiven · klik en scroll = zoomen', plan: 'Noord boven · maten in mm' }; // tl-wrap is het raster; .tectum-light zelf is de container voor de container queries (breedte van de tool, niet // van het scherm: in de HUGO-site staat hij in een Bootstrap col-12). @@ -436,17 +436,22 @@ window.TectumLight = (() => { // Dak: per unit (of één keer bij een dak over de hele woning) vorm, richting en hoogtes; de bedekking geldt overal. const wolfOk = !houseRoof || HouseModel.houseRoofFits(cur.st); + const roofTypes = HouseModel.hasCover(cur.st, houseRoof ? null : unitSel) + ? Object.fromEntries(Object.entries(UnitModel.ROOF_TYPES).filter(([k]) => k !== 'wolf')) : UnitModel.ROOF_TYPES; const K = houseRoof ? 'house' : 'unit', c = houseRoof ? ch.house : u, f = NUM[K + 'High'].form(ch); const covItems = Object.entries(RoofModel.COVERINGS).map(([key, cv]) => ({ key, label: cv.label, hex: cv.color, img: RoofModel.coveringThumbUrl(key, 64) })); const roofHtml = (multi && !houseRoof ? field('Unit', seg('unit', Object.fromEntries(model.base.units.map(x => [x.id, x.id])), unitSel), null, 'tl-units') : '') + - field('Dakvorm', seg(K + 'Type', UnitModel.ROOF_TYPES, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) + + field('Dakvorm', seg(K + 'Type', roofTypes, c.type, (k) => k === 'wolf' && !wolfOk && 'Een wolfsdak kan alleen als de units samen een rechthoek vormen')) + (c.type === 'lessenaar' ? field('Hoge kant', seg(K + 'Dir', LightModel.SIDES, f.dir)) : '') + (UnitModel.hasRidge(c.type) ? field('Nok', seg(K + 'Ridge', UnitModel.RIDGES, f.ridge)) : '') + slider(K + 'Low', c.type === 'plat' ? 'Hoogte' : 'Goothoogte', c.low, model.frame.height, 'Vanaf de vloer tot onderkant dak, aan de lage kant') + (c.type !== 'plat' ? slider(K + 'High', c.type === 'lessenaar' ? 'Hoge kant' : 'Nokhoogte', f.high, [Math.ceil((f.low + 300) / LightModel.STEP) * LightModel.STEP, UnitModel.LIMITS.ridge], 'Vanaf de vloer tot onderkant dak', ` · ${nl(f.pitch, 0)}°`) : '') + (c.type === 'wolf' ? wolfSlider(K + 'Cut', f) : '') + - swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer')); + swatchField('Bedekking', swatches('covering', covItems, ch.covering, (k) => !sloped && RoofModel.COVERINGS[k].sloped && 'alleen op een hellend dak; een plat dak krijgt dakleer')) + + field('Dakrand', seg('fascia', { aan: 'Boeidelen', uit: 'Daktrim' }, ch.fascia.on ? 'aan' : 'uit')) + + (ch.fascia.on ? swatchField('Kleur boeidelen', swatches('fasciaColor', + Object.entries(RoofModel.FASCIA_COLORS).map(([key, c]) => ({ key, label: c.label, hex: '#' + c.hex.toString(16).padStart(6, '0') })), ch.fascia.color)) : ''); const fc = ch.facade; const facadeHtml = field('Bekleding', seg('facadeType', LightModel.FACADES, fc.type)) + @@ -459,7 +464,9 @@ window.TectumLight = (() => { const tr = ch.trasraam; const trasraamHtml = field('Trasraam stenen strook onderaan de gevel', seg('trasraamType', { geen: 'Geen', steen: 'Steen' }, tr.on ? 'steen' : 'geen')) + (tr.on ? swatchField('Steen trasraam', swatches('trasraamStone', facadeColors('steen', tr.finish), tr.finish)) : '') + - (tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : ''); + (tr.on ? slider('trasraamHigh', 'Hoogte trasraam', tr.height, [200, 1000], 'Vanaf bovenkant vloer') : '') + + (LightModel.trimUses(ch) ? swatchField(`Zetwerk ${LightModel.trimUses(ch)}`, + swatches('trim', Object.entries(WallModel.TRIM_COLORS).map(([key, c]) => ({ key, label: c.label, hex: c.hex })), ch.trim)) : ''); const L = ch.lighting; const lightHtml = field('Binnen', seg('indoor', { aan: 'Inbouwspots', uit: 'Geen' }, L.indoor ? 'aan' : 'uit')) + @@ -519,6 +526,9 @@ window.TectumLight = (() => { t.on = val === 'steen'; if (t.on && !t.finish) t.finish = fc.type === 'steen' ? fc.stone : LightModel.STONES[0]; } else if (key === 'trasraamStone') next.trasraam.finish = val; + else if (key === 'trim') next.trim = val; + else if (key === 'fascia') next.fascia.on = val === 'aan'; + else if (key === 'fasciaColor') next.fascia.color = val; else if (key === 'lightMode') next.lighting.mode = val; else if (key === 'lightDir') next.lighting.direction = val; else return; diff --git a/js/roof/roof-model.js b/js/roof/roof-model.js index 7d07deb..15f3f87 100644 --- a/js/roof/roof-model.js +++ b/js/roof/roof-model.js @@ -24,8 +24,35 @@ window.RoofModel = (() => { spotMin: 800, spotMax: 1200, trimHeight: 80, - fasciaColor: 'antraciet' + fasciaColor: 'antraciet', + vent: true, + ventColor: 'antraciet' }; + // Ventilatierooster onder de boeidelen: strook op de gevel, direct onder de onderplaat; sleuven in de textuur. + // Kleuren als de afdekker van het trasraam (WallModel.TRIM_COLORS). + const VENT = { HEIGHT_MM: 40, THICK_MM: 12, PITCH_MM: 12 }; + const ventMaterials = {}; + function ventMaterial(key) { + const k = WallModel.isTrimColor(key) ? key : 'antraciet'; + if (ventMaterials[k]) return ventMaterials[k]; + const base = new THREE.Color(WallModel.trimHex(k)); + const canvas = typeof document !== 'undefined' && document.createElement('canvas'); + const ctx = canvas && canvas.getContext && canvas.getContext('2d'); + let mat; + if (!ctx) mat = new THREE.MeshStandardMaterial({ color: base, roughness: 0.5, metalness: 0.3 }); + else { + canvas.width = 32; canvas.height = 32; + ctx.fillStyle = '#' + base.getHexString(); + ctx.fillRect(0, 0, 32, 32); + ctx.fillStyle = '#' + base.clone().multiplyScalar(0.18).getHexString(); + ctx.fillRect(11, 6, 11, 20); // sleuf, 4 mm breed en 25 mm hoog + const tex = new THREE.CanvasTexture(canvas); + tex.wrapS = THREE.RepeatWrapping; + tex.anisotropy = 8; + mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5, metalness: 0.3 }); + } + return (ventMaterials[k] = mat); + } const FASCIA_COLORS = { wit: { label: 'Wit', hex: 0xf2f2f0 }, @@ -988,7 +1015,8 @@ window.RoofModel = (() => { overRoof: !!nb && !dropped, dropped }; }); - const depRaw = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) || 0 : 0)); + const depIn = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) : 0)); + const depRaw = depIn.map(d => d || 0); const dep = depRaw.map(d => Math.min(0, d)); const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO); const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5; @@ -1002,7 +1030,8 @@ window.RoofModel = (() => { spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni]) }; }); - segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] })); + // facadeUnder: een eigen gevel onder de rand (niet bij een veranda of een hogere gevel ertegenaan). + segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i], facadeUnder: depIn[i] !== null && depIn[i] >= 0 })); // Rand zonder overstek (daktrim, felsrand): de felsrand ligt net vóór de gevelbekleding en loopt bij een hoek // door tot de buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand. @@ -1048,8 +1077,10 @@ window.RoofModel = (() => { return { segments, overhang: s.overhang, spotRatio: SPOT_RATIO, fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor, + vent: s.vent, ventColor: s.ventColor, top, edgeAbove: EDGE_ABOVE_MM, fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0), + ventLengthMM: s.vent ? segments.filter(sg => sg.fascia && sg.facadeUnder).reduce((t, sg) => t + Math.hypot(sg.b.x - sg.a.x, sg.b.z - sg.a.z), 0) : 0, // Felsrand: zetwerk van de dakplaten, zit in de prijs van de felsplaten. trimLengthMM: segments.filter(sg => !sg.fascia && !sg.fels).reduce((t, sg) => t + segLen(sg), 0), felsEdgeLengthMM: segments.filter(sg => sg.fels).reduce((t, sg) => t + segLen(sg), 0), @@ -1071,19 +1102,27 @@ window.RoofModel = (() => { return out; } - function prismGeometry(poly, topFn, h) { - const toM = 0.001, n = poly.length, pos = []; + // uAt(p): met textuur, u langs de rand; v = 0 onder, 1 boven. + function prismGeometry(poly, topFn, h, uAt) { + const toM = 0.001, n = poly.length, pos = [], uvs = []; const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]); const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]); + const U = uAt ? poly.map(uAt) : null; const tri = (a, b, c) => pos.push(...a, ...b, ...c); - for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); tri(B[0], B[k + 1], B[k]); } - for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); tri(T[k], B[j], T[j]); } + const uv = (...q) => { if (U) q.forEach(([k, v]) => uvs.push(U[k], v)); }; + for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); uv([0, 1], [k, 1], [k + 1, 1]); tri(B[0], B[k + 1], B[k]); uv([0, 0], [k + 1, 0], [k, 0]); } + for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); uv([k, 1], [k, 0], [j, 0]); tri(T[k], B[j], T[j]); uv([k, 1], [j, 0], [j, 1]); } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + if (U) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); geo.computeVertexNormals(); return geo; } + // Gevelvlak onder een rand (afstand langs de normaal): de dakrand plus de bekleding die erbuiten steekt. + function ventFace(sg) { + return sg.a.x * sg.normal.x + sg.a.z * sg.normal.z + Math.max(0, sg.facadeDepth || 0); + } // Hoogte van de rand vanaf sg.topAt naar beneden: boeideel, felsrand of daktrim (met opstand boven het dak). function edgeHeight(edge, sg) { return sg.fascia ? edge.fasciaHeight : sg.fels ? FELS_EDGE.heightMM : edge.trimHeight + edge.edgeAbove; @@ -1121,6 +1160,15 @@ window.RoofModel = (() => { mesh.visible = o.edgeVisible !== false; group.add(mesh); }); + // Rooster: op de gevel (voorkant bekleding), onder de onderplaat van het boeideel. + if (!sg.fascia || !sg.facadeUnder || !edge.vent) return; + const nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z, face = ventFace(sg); + const u = (p) => (alongZ ? p.z : p.x) / VENT.PITCH_MM; + polys.map(pl => clipPoly(clipPoly(pl, p => nd(p) - face), p => face + VENT.THICK_MM - nd(p))).filter(pl => pl.length >= 3).forEach(pl => { + const mesh = new THREE.Mesh(prismGeometry(pl, (p) => sg.topAt(p) - h, VENT.HEIGHT_MM, u), ventMaterial(edge.ventColor)); + mesh.visible = o.edgeVisible !== false; + group.add(mesh); + }); }); const spotR = 0.035, spotH = 0.012; @@ -1145,6 +1193,8 @@ window.RoofModel = (() => { s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover }; s.posts = PostModel.normalizeSettings(s.posts); if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor; + s.vent = s.vent !== false; + if (!WallModel.isTrimColor(s.ventColor)) s.ventColor = DEFAULT_SETTINGS.ventColor; return s; } @@ -1152,6 +1202,6 @@ window.RoofModel = (() => { DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl, createDefaultRoof, thickness, createMaterials, build3D, computeSpecs, computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions, - FELS_EDGE, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings + FELS_EDGE, VENT, ventFace, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings }; })(); diff --git a/js/vendor/OrbitControls.js b/js/vendor/OrbitControls.js index 1a1a7f2..561d775 100644 --- a/js/vendor/OrbitControls.js +++ b/js/vendor/OrbitControls.js @@ -4,7 +4,8 @@ // hebben om het origineel te vendoren; dekt precies de subset die wall.js // gebruikt (`new THREE.OrbitControls(camera, domElement)`, // `controls.target.set(...)`, `controls.update()`), plus interactie: -// links-slepen = roteren, scrollwiel = zoomen, rechts-slepen = pannen; +// links-slepen = roteren, scrollwiel = zoomen; zijwaarts schuiven (pannen) met rechts- of middelste-knop-slepen, +// Shift/Ctrl/⌘ + links-slepen, of de pijltjestoetsen terwijl de muis boven de weergave staat; // touch: één vinger = roteren, twee vingers = knijpen (zoomen) en samen schuiven (pannen). // // Moet NA three.min.js geladen worden (leunt op window.THREE). @@ -50,7 +51,9 @@ }; function onPointerDown(event) { - if (event.button === 2) { + const panButton = event.button === 2 || event.button === 1 || (event.button === 0 && (event.shiftKey || event.ctrlKey || event.metaKey)); + if (event.button === 1) event.preventDefault(); // geen autoscroll + if (panButton) { if (!scope.enablePan) return; isPanning = true; } else { @@ -138,11 +141,29 @@ function onContextMenu(event) { event.preventDefault(); } + // Pijltjestoetsen: schuiven terwijl de muis boven de weergave staat (niet in een invoerveld). + let hover = false; + const onEnter = () => { hover = true; }; + const onLeave = () => { hover = false; }; + const KEY_PAN = { ArrowLeft: [1, 0], ArrowRight: [-1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] }; + function onKeyDown(event) { + const d = KEY_PAN[event.key]; + if (!d || !hover || !scope.enablePan || event.altKey || event.ctrlKey || event.metaKey) return; + const t = event.target, tag = t && t.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (t && t.isContentEditable)) return; + event.preventDefault(); + const step = (scope.domElement.clientHeight || 400) * (event.shiftKey ? 0.25 : 0.08); + pan(d[0] * step, d[1] * step); + } + this.domElement.addEventListener('mousedown', onPointerDown); window.addEventListener('mousemove', onPointerMove); window.addEventListener('mouseup', onPointerUp); this.domElement.addEventListener('wheel', onWheel, { passive: false }); this.domElement.addEventListener('contextmenu', onContextMenu); + this.domElement.addEventListener('mouseenter', onEnter); + this.domElement.addEventListener('mouseleave', onLeave); + window.addEventListener('keydown', onKeyDown); this.domElement.addEventListener('touchstart', onTouchStart, { passive: false }); this.domElement.addEventListener('touchmove', onTouchMove, { passive: false }); this.domElement.addEventListener('touchend', onTouchEnd); @@ -154,6 +175,9 @@ window.removeEventListener('mouseup', onPointerUp); scope.domElement.removeEventListener('wheel', onWheel); scope.domElement.removeEventListener('contextmenu', onContextMenu); + scope.domElement.removeEventListener('mouseenter', onEnter); + scope.domElement.removeEventListener('mouseleave', onLeave); + window.removeEventListener('keydown', onKeyDown); scope.domElement.removeEventListener('touchstart', onTouchStart); scope.domElement.removeEventListener('touchmove', onTouchMove); scope.domElement.removeEventListener('touchend', onTouchEnd); diff --git a/js/wall/wall-model.js b/js/wall/wall-model.js index b8890ab..995cbd8 100644 --- a/js/wall/wall-model.js +++ b/js/wall/wall-model.js @@ -116,6 +116,23 @@ window.WallModel = (() => { return mat; } + // Afwerking in zetwerk (aluminium, gepoedercoat): afdekker op een stenen trasraam en het ventilatierooster onder + // de boeidelen. + const TRIM_COLORS = { + zwart: { label: 'Zwart', hex: '#1f2022' }, + antraciet: { label: 'Antraciet', hex: '#3d4146' }, + wit: { label: 'Wit', hex: '#f3f2ee' } + }; + const isTrimColor = (key) => !!TRIM_COLORS[key]; + const trimHex = (key) => (TRIM_COLORS[key] || TRIM_COLORS.antraciet).hex; + const trimMaterials = {}; + function trimMaterial(key) { + const k = isTrimColor(key) ? key : 'antraciet'; + return trimMaterials[k] || (trimMaterials[k] = new THREE.MeshStandardMaterial({ color: trimHex(k), roughness: 0.45, metalness: 0.3 })); + } + // Afdekker trasraam: bovenkant 5° aflopend (85° met de neus), neus 25 mm over de steen, 3 mm ervoor. + const TRAS_CAP = { SLOPE_DEG: 5, DROP_MM: 25, GAP_MM: 3, THICK_MM: 2 }; + function claddingOffset(buitenMateriaal, stone, stuc) { if (buitenMateriaal === 'vochtdoek') return 2 * BATTEN.thick + CLADDING_THICK_MM; if (buitenMateriaal !== 'cementplaat') return 0; @@ -175,9 +192,9 @@ window.WallModel = (() => { // Hoogte vanaf het loopvlak (= onderkant van de wand). const TRASRAAM_DEFAULT_MM = 350; - function trasraamFor(heightMM, finish) { + function trasraamFor(heightMM, finish, capColor) { if (!(heightMM > 0)) return null; - return { bottom: 0, top: heightMM, finish: finish || 'cementplaat' }; + return { bottom: 0, top: heightMM, finish: finish || 'cementplaat', capColor: isTrimColor(capColor) ? capColor : 'antraciet' }; } // Textuur 2062 × 1125 px op 2 mm/px. JOINT_MM: hart onderste voeg boven de onderrand (gemeten 1-2 px); @@ -849,7 +866,7 @@ window.WallModel = (() => { let trasraamAreaM2 = 0, trasraamVolumeM3 = 0; let stoneAreaM2 = 0, stoneIsolatieM3 = 0; let stucAreaM2 = 0; - let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0; + let trasraamStoneAreaM2 = 0, trasraamIsolatieM3 = 0, trasraamCapM = 0; let horizBeamCount = 0; function createExtrudedBeam(shape, depth, positionZ) { @@ -1108,6 +1125,41 @@ window.WallModel = (() => { return { areaM2: area(mid) + area(caps), isoM3: area(mid) * tIso }; } + // Afdekker op een stenen trasraam waar de gevel erboven minder ver uitsteekt (rabat, felsplaat, stuc): zetwerk + // over de bovenkant van de steen, achter de bekleding beginnend, aan de voorkant een neus over de steen. Onderbroken + // waar een opening door de bovenkant van het trasraam gaat; bij een buitenhoek tot voorbij de steen van de buurwand. + function stoneCap(tr) { + const C = TRAS_CAP, T = tr.top * toM; + const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM, zF = zB + STONE.THICK_MM * toM; + const zIn = zF + C.GAP_MM * toM, zOut = zIn + C.THICK_MM * toM, lip = zOut - zF; + const yFront = T + C.THICK_MM * toM, yBack = yFront + Math.tan(C.SLOPE_DEG * Math.PI / 180) * (zOut - zB); + const ext = extM(CONFIG.sheathingThick + 2 * SKIN_GAP_MM + STONE.THICK_MM); + const a = -halfW - ext.left - (ext.left > 0 ? lip : 0), b = halfW + ext.right + (ext.right > 0 ? lip : 0); + const yLo = T - C.DROP_MM * toM; + const cuts = outerHoles.filter(h => h.hy0 < yBack && h.hy1 > yLo).map(h => [h.hx0, h.hx1]).sort((p, q) => p[0] - q[0]); + const runs = []; + let x = a; + cuts.forEach(([c0, c1]) => { if (c0 > x) runs.push([x, Math.min(b, c0)]); x = Math.max(x, c1); }); + if (x < b) runs.push([x, b]); + const shape = new THREE.Shape([ + [zB, T + 0.0005], [zIn, T + 0.0005], [zIn, yLo], [zOut, yLo], [zOut, yFront], [zB, yBack] + ].map(([pz, py]) => new THREE.Vector2(pz, py))); + let lengthM = 0; + runs.forEach(([r0, r1]) => { + if (r1 - r0 <= edgeEps) return; + lengthM += r1 - r0; + if (!buitenVisible || !show('gevel')) return; + // Profiel in (z, y), langs de wand uitgetrokken: -90° om Y legt de extrusie langs -X. + const mesh = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: r1 - r0, bevelEnabled: false }), trimMaterial(tr.capColor)); + mesh.rotation.y = -Math.PI / 2; + mesh.position.x = r1; + mesh.castShadow = true; + mesh.receiveShadow = true; + wallGroup3D.add(mesh); + }); + return lengthM; + } + function stucBlock(colorKey, y0) { const zB = (maxDepth / 2) + skinGapM + sThickM + skinGapM; const t = STUC.THICK_MM * toM; @@ -1229,6 +1281,7 @@ window.WallModel = (() => { const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true }); trasraamStoneAreaM2 = r.areaM2; trasraamIsolatieM3 = r.isoM3; + if (stucKey) trasraamCapM = stoneCap(trasraam); y0 = trasraam.top * toM; } else if (trasraam) { const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { @@ -1306,6 +1359,7 @@ window.WallModel = (() => { const r = stoneBlock(trasraam.finish, trasraam.bottom * toM, trasraam.top * toM, { y: trasraam.top * toM, down: true }); trasraamStoneAreaM2 = r.areaM2; trasraamIsolatieM3 = r.isoM3; + trasraamCapM = stoneCap(trasraam); } else if (trasraam) { const tras = buildVerticalBoardSkin(cementplaatMaterial, 'front', buitenVisible && show('latwerk'), { y0: trasraam.bottom * toM, y1: trasraam.top * toM, zExtra: sThickM + (rowIndex + 1) * doekZStepM + skinGapM @@ -1445,7 +1499,7 @@ window.WallModel = (() => { stats: { woodVolumeM3, spaanplaatVolumeM3, spaanplaatAreaM2, cementplaatVolumeM3, cementplaatAreaM2, doekAreaM2, spaanplaatBuitenAreaM2, spaanplaatBuitenVolumeM3, horizBeamCount, - battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, stoneAreaM2, stoneIsolatieM3, stucAreaM2 + battenLengthM, battenVolumeM3, battenCount, battenPieces, rabatAreaM2, felsAreaM2, trasraamAreaM2, trasraamVolumeM3, trasraamStoneAreaM2, trasraamIsolatieM3, trasraamCapM, stoneAreaM2, stoneIsolatieM3, stucAreaM2 } }; } @@ -1674,7 +1728,7 @@ window.WallModel = (() => { } return { - STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, FELS, isFelsColor, felsHex, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText, + STANDARD_BEAM, STOCK_LENGTHS_MM, CORNER_STUDS, EXTERIOR_MATERIALS, OPENING_TYPES, BATTEN, CLADDING_DIRECTIONS, CLADDING_THICK_MM, RABAT, RABAT_COLORS, rabatHex, FELS, isFelsColor, felsHex, TRIM_COLORS, isTrimColor, trimHex, trimMaterial, TRAS_CAP, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText, stoneMaterial, stucMaterial, felsMaterial, claddingMaterial, sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame, createMaterials, build3D, computeSpecs