From b5510d56c7ff7b099f171dc283ce8eee1813adaf Mon Sep 17 00:00:00 2001 From: Machiel Date: Tue, 6 Oct 2026 20:54:49 +0200 Subject: [PATCH] v0.5.3: elektra-buizen min. 50 mm uit elkaar (gebundeld alleen naar de meterkast), vloer-wcd met leiding door de vloer, elektra op de PDF-bladen per wand/vloer/dakvlak, kosten elektra (buis + draad per meter, boorgaten 90 s geteld op stijlen/regels/balken, dozen, groepenkast, keuring), dakbeschot OSB 18 mm bovenop de sporen, felsplaten als dakbedekking Co-Authored-By: Claude Opus 5.5 --- README.md | 15 ++- js/app.js | 2 +- js/core/build-stages.js | 6 +- js/cost/cost-defaults.js | 54 +++++++- js/cost/cost-model.js | 73 +++++++++- js/elektra/elektra-model.js | 260 ++++++++++++++++++++++++++++++++++-- js/elektra/elektra-pdf.js | 127 +++++++++++++++++- js/floor/floor-pdf.js | 4 + js/house/house-model.js | 4 +- js/house/house-pdf.js | 8 +- js/house/house.js | 67 +++++++++- js/roof/roof-model.js | 108 ++++++++++----- js/wall/wall-pdf.js | 4 + 13 files changed, 668 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index a8ff6d9..e4a7a5a 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,12 @@ assets/ logo, texturen, skybox 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. + Buizen minstens 50 mm uit elkaar (gebundeld alleen het laatste stuk naar + de meterkast). Vloer-wcd's met leiding door de vloer. Elektra ook op de + PDF-bladen per wand, vloer en dakvlak (plafond). Alles verplaatsbaar door + te slepen in de plattegrond. Kosten (groep Elektra): buis + draad per + meter, boorgaten (geteld op stijlen, regels en balken), dozen, + afmonteren, groepenkast en keuring. - **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 @@ -108,9 +113,11 @@ assets/ logo, texturen, skybox - **Dak:** per unit opgebouwd als gespiegelde vloer + dakleer, in de vorm die de wanden aangeven: plat, lessenaar (schuine kopgevels) of zadel (nok). Elk dakvlak is een eigen dek met de balken als sporen en een eigen - PDF-blad; doorvoeren gaan naar het vlak waarin ze vallen. Bedekking per - unit: dakleer, of bij een hellend dak riet (180 mm, 3,8 kg/m²); de - dakrand schuift dan mee omhoog. De dakrand + PDF-blad; doorvoeren gaan naar het vlak waarin ze vallen. Opbouw: + plafondplaat (OSB) binnen, sporen, dakbeschot (OSB 18 mm) bovenop en + daarop de bedekking per unit: dakleer, of bij een hellend dak riet + (180 mm, 3,8 kg/m²) of felsplaten (staande naad, zink/antraciet/zwart); + de dakrand schuift dan mee omhoog. De dakrand (boeidelen/daktrim, spots) loopt als één rand door en volgt de helling. Is een dak langs de naad minstens een boeideelhoogte hoger dan het aangrenzende, dan krijgt het een eigen rand rondom (boven het lage dak diff --git a/js/app.js b/js/app.js index 8e21bb8..1622721 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.5.2'; +const APP_VERSION = '0.5.3'; // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. let tabs = []; diff --git a/js/core/build-stages.js b/js/core/build-stages.js index 827bcf6..54b68ca 100644 --- a/js/core/build-stages.js +++ b/js/core/build-stages.js @@ -11,10 +11,10 @@ // // 0 regelwerk vloer-, wand- en dakbalken // 1 elektra installatiebuizen, dozen en meterkast (ElektraModel) -// 2 beplating spaanplaat binnen, OSB vloer/plafond, cementplaat buiten +// 2 beplating spaanplaat binnen, OSB vloer/plafond/dakbeschot, cementplaat buiten // 3 doek vochtdoek // 4 latwerk kruislat, trasraamplaat -// 5 gevel rabat, steen, kozijnen, dakbedekking, sandwichpanelen +// 5 gevel rabat, steen, kozijnen, dakbedekking (dakleer, riet, felsplaten), sandwichpanelen // 6 afwerking dakrand, verlichting, vloerafwerking, verf binnen // // Fasen zijn cumulatief: fase n toont alles t/m n. Een laag die in de @@ -29,7 +29,7 @@ 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: 'beplating', label: 'Beplating', hint: 'Spaanplaat binnen, OSB vloer, plafond en dakbeschot, cementplaat buiten' }, { key: 'doek', label: 'Vochtdoek', hint: 'Vochtdoek op de buitenzijde' }, { key: 'latwerk', label: 'Latwerk', hint: 'Kruislat en trasraamplaat' }, { key: 'gevel', label: 'Gevel & dak', hint: 'Rabat, steen, kozijnen, dakbedekking, sandwichpanelen' }, diff --git a/js/cost/cost-defaults.js b/js/cost/cost-defaults.js index e33c9fc..c418b45 100644 --- a/js/cost/cost-defaults.js +++ b/js/cost/cost-defaults.js @@ -122,6 +122,10 @@ window.COST_DEFAULTS = { 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 }, + beschotM2: { label: 'Dakbeschot OSB 18 mm (bovenop de sporen)', unit: '€/m²', value: 14 }, + beschotMontage: { label: 'Montage dakbeschot', unit: 's/m²', value: 150 }, + felsM2: { label: 'Felsplaten staande naad (zink/staal, klangen, scheidingslaag)', unit: '€/m²', value: 65 }, + felsMontage: { label: 'Montage felsplaten', unit: 's/m²', value: 1800 }, boeideelM: { label: 'Boeideel', unit: '€/m', value: 25 }, boeideelMontage: { label: 'Montage boeideel', unit: 's/m', value: 600 }, onderplaatM2: { label: 'Onderplaat boeideel', unit: '€/m²', value: 30 }, @@ -157,7 +161,55 @@ window.COST_DEFAULTS = { 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 } + montage: { label: 'Montage per lamp/spot (armatuur plaatsen en aansluiten; leidingen en dozen onder Elektra)', unit: 's', value: 300 } + } + }, + + // Indicatieve groothandelsprijzen (excl. btw) en tijden; zie ElektraModel + // voor de hoeveelheden (strekkende meters per leidingtype, boorgaten, dozen). + elektra: { + label: 'Elektra', + items: { + personeel: { label: 'Personeel bij elektra (i.p.v. de bezetting)', unit: 'man', value: 1 }, + boorgat: { label: 'Boorgat door stijl, regel of balk (aftekenen, boren, ontbramen)', unit: 's', value: 90 }, + buisLeggen: { label: 'Installatiebuis leggen en bevestigen', unit: 's/m', value: 60 }, + draadTrekken: { label: 'Draad trekken (per meter buis, alle aders)', unit: 's/m', value: 20 }, + kabelLeggen: { label: 'XMvK-kabel leggen en bevestigen (buiten)', unit: 's/m', value: 40 }, + doosPlaatsen: { label: 'Inbouwdoos plaatsen (luchtdicht, hollewand)', unit: 's', value: 240 }, + verdeeldoos: { label: 'Verdeeldoos plaatsen en verbinden', unit: 's', value: 600 }, + spotdoos: { label: 'Spotdoos plaatsen (luchtdicht plafond)', unit: 's', value: 300 }, + vloerdoos: { label: 'Vloerdoos plaatsen (gat in vloer, aansluiten)', unit: 's', value: 1800 }, + afmonteren: { label: 'Afmonteren schakelaar/wcd/apparaataansluiting', unit: 's', value: 300 }, + steekverbinderMontage: { label: 'Steekverbinder module-naad monteren (set m/v)', unit: 's', value: 300 }, + kastMontage: { label: 'Groepenkast plaatsen (kast, hoofdschakelaar, aardlek)', unit: 's', value: 3600 }, + groepMontage: { label: 'Per groep: automaat plaatsen, bedraden, labelen', unit: 's', value: 900 }, + keuring: { label: 'Meten, testen en opleveren (NEN 1010 / NEN 3140)', unit: 's', value: 10800 }, + buis16M: { label: 'Installatiebuis flexibel Ø16', unit: '€/m', value: 0.35 }, + buis19M: { label: 'Installatiebuis flexibel Ø19', unit: '€/m', value: 0.45 }, + buis25M: { label: 'Installatiebuis flexibel Ø25', unit: '€/m', value: 0.8 }, + draad15M: { label: 'VD-draad 1,5 mm² (per ader)', unit: '€/m', value: 0.28 }, + draad25M: { label: 'VD-draad 2,5 mm² (per ader)', unit: '€/m', value: 0.45 }, + xmvkM: { label: 'XMvK 3×1,5 mm²', unit: '€/m', value: 1.25 }, + inbouwdoos: { label: 'Luchtdichte hollewanddoos', unit: '€/st.', value: 1.6 }, + verdeeldoosSt: { label: 'Luchtdichte verdeeldoos', unit: '€/st.', value: 4.5 }, + spotdoosSt: { label: 'Luchtdichte spotdoos', unit: '€/st.', value: 7.5 }, + vloerdoosSt: { label: 'Vloerdoos met dubbele wcd', unit: '€/st.', value: 95 }, + schakelaar: { label: 'Schakelaar enkelpolig (inbouw)', unit: '€/st.', value: 9 }, + afdekraam1: { label: 'Afdekraam 1-voudig', unit: '€/st.', value: 3 }, + afdekraam2: { label: 'Afdekraam 2-voudig', unit: '€/st.', value: 5 }, + wcdDubbel: { label: 'Wandcontactdoos dubbel met randaarde', unit: '€/st.', value: 10 }, + wcdEnkel: { label: 'Wandcontactdoos enkel met randaarde', unit: '€/st.', value: 6 }, + perilex: { label: 'Perilex-wandcontactdoos (kookgroep)', unit: '€/st.', value: 18 }, + werkschakelaar: { label: 'Werkschakelaar (warmtepomp)', unit: '€/st.', value: 28 }, + lasklem: { label: 'Lasklem 3-voudig', unit: '€/st.', value: 0.35 }, + steekverbinder3: { label: 'Steekverbinder 3-polig (set m/v)', unit: '€/set', value: 9 }, + steekverbinder5: { label: 'Steekverbinder 5-polig (set m/v)', unit: '€/set', value: 14 }, + doorvoer: { label: 'Luchtdichte kabeldoorvoer naar buiten', unit: '€/st.', value: 3.5 }, + kast: { label: 'Groepenkast 3-fase leeg (behuizing, kammen, aansluitmateriaal)', unit: '€/st.', value: 180 }, + hoofdschakelaar: { label: 'Hoofdschakelaar 4-polig 40 A', unit: '€/st.', value: 55 }, + aardlek: { label: 'Aardlekschakelaar 2-polig 40 A / 30 mA', unit: '€/st.', value: 40 }, + automaat: { label: 'Installatieautomaat 1P+N (B10/B16)', unit: '€/st.', value: 14 }, + automaat2f: { label: 'Installatieautomaat 2-fase (kookgroep)', unit: '€/st.', value: 30 } } }, diff --git a/js/cost/cost-model.js b/js/cost/cost-model.js index d044d28..344ee4c 100644 --- a/js/cost/cost-model.js +++ b/js/cost/cost-model.js @@ -77,8 +77,8 @@ window.CostModel = (() => { // ------------------------------------------------------------ // 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 CAT_ORDER = ['Hout', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting', 'Elektra']; + const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting', 'Elektra']; const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl']; const DORPEL_KINDS = ['bovendorpel', 'onderdorpel']; @@ -233,9 +233,11 @@ window.CostModel = (() => { addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true); } addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage); + addSheet(rf, 'Plaatwerk', `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm`, rs.beschotM2 || 0, D.beschotM2, D.beschotMontage); 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); + add(rf, 'Dak', 'Dak', 'Felsplaten (staande naad)', rs.felsM2 || 0, 'm²', (rs.felsM2 || 0) * D.felsM2, (rs.felsM2 || 0) * D.felsMontage); }); // --- Woning-breed: dakrand, sandwichpanelen, verlichting --- @@ -257,6 +259,73 @@ window.CostModel = (() => { 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); + // --- Elektra (ElektraModel): per unit de leidingen (strekkende meters + // per type: buis + draad per ader, of XMvK), boorgaten, steekverbinders, + // dozen en aansluitingen; meterkast, groepen en keuring bij de unit waar + // de meterkast hangt. Arbeid met E.personeel man. + const elk = parts.elektra, E = S.elektra; + if (elk && elk.enabled) { + const unitIds = new Set(parts.units.map(u => u.id)), elOf = {}; + const elFor = (uid) => { + const id = unitIds.has(uid) ? uid : null, k = id || '-'; + return elOf[k] || (elOf[k] = newEl(id ? `${id}-EL` : 'EL', id ? `Unit ${id} elektra` : 'Elektra', id)); + }; + const ea = (uid, label, qty, unit, mat, sec) => add(elFor(uid), 'Elektra', 'Elektra', label, qty, unit, mat, sec, E.personeel); + const mkUnit = elk.meterkast ? elk.meterkast.unitId : null; + const groupUnit = (nr) => (elk.groups.find(g => g.nr === nr) || {}).unitId || mkUnit; + const buisPrijs = (d) => d >= 25 ? E.buis25M : d >= 19 ? E.buis19M : E.buis16M; + const agg = {}; + elk.runs.forEach(r => { + const uid = groupUnit(r.group), a = agg[uid] = agg[uid] || { types: {}, holes: 0, cross: {} }; + a.types[r.type] = (a.types[r.type] || 0) + r.lengthMM / 1000; + a.holes += r.holes || 0; + if (r.crossings) { const n = ElektraModel.cableInfo(r.type).cores.length; a.cross[n] = (a.cross[n] || 0) + r.crossings; } + }); + Object.keys(agg).forEach(uid => { + const a = agg[uid]; + Object.keys(a.types).sort().forEach(type => { + const c = ElektraModel.cableInfo(type), m = a.types[type]; + if (c.conduit) { + const draad = c.mm2 >= 2.5 ? E.draad25M : E.draad15M; + ea(uid, `${c.label}: ${c.use.toLowerCase()} (buis + ${c.cores.length} aders)`, m, 'm', m * (buisPrijs(c.conduit) + c.cores.length * draad), m * (E.buisLeggen + E.draadTrekken)); + } else ea(uid, `${c.label}: ${c.use.toLowerCase()}`, m, 'm', m * E.xmvkM, m * E.kabelLeggen); + }); + ea(uid, `Boorgaten stijlen/regels/balken (${E.boorgat} s per gat)`, a.holes, 'st.', 0, a.holes * E.boorgat); + Object.keys(a.cross).forEach(n => { + const k = a.cross[n]; + ea(uid, `Steekverbinder module-naad ${n}-polig`, k, 'set', k * (Number(n) >= 5 ? E.steekverbinder5 : E.steekverbinder3), k * E.steekverbinderMontage); + }); + }); + const count = (list, uid) => list.filter(x => x.unitId === uid).length; + parts.units.forEach(u => { + const uid = u.id; + const nSw = count(elk.switches, uid), combos = elk.combos.filter(c => c.unitId === uid); + const raam1 = combos.filter(c => c.switches.length === 1).length, raam2 = combos.length - raam1; + ea(uid, `Schakelaars (${raam1}× 1-voudig, ${raam2}× 2-voudig raam)`, nSw, 'st.', + nSw * (E.schakelaar + E.inbouwdoos) + raam1 * E.afdekraam1 + raam2 * E.afdekraam2, nSw * (E.doosPlaatsen + E.afmonteren)); + const nW = count(elk.wcds, uid); + ea(uid, 'Wandcontactdozen dubbel (2 inbouwdozen)', nW, 'st.', nW * (E.wcdDubbel + 2 * E.inbouwdoos), nW * (2 * E.doosPlaatsen + E.afmonteren)); + elk.apps.filter(a => a.unitId === uid).forEach(a => { + const price = a.app === 'koken' ? E.perilex : a.app === 'warmtepomp' ? E.werkschakelaar : E.wcdEnkel; + ea(uid, `Aansluitpunt ${a.label.toLowerCase()}`, 1, 'st.', price + E.inbouwdoos, E.doosPlaatsen + E.afmonteren); + }); + const nV = count(elk.floorWcds, uid); + ea(uid, 'Vloerdoos met dubbele wcd', nV, 'st.', nV * E.vloerdoosSt, nV * (E.vloerdoos + E.afmonteren)); + const nB = count(elk.boxes, uid), nKl = nB * 3 + nSw + nW; + ea(uid, `Verdeeldozen plafond (+ ${nKl} lasklemmen)`, nB, 'st.', nB * E.verdeeldoosSt + nKl * E.lasklem, nB * E.verdeeldoos); + const nSpot = elk.switches.filter(sw => sw.unitId === uid).reduce((t, sw) => t + sw.lamps.filter(l => l.kind === 'binnenspot').length, 0); + ea(uid, 'Spotdozen plafond (luchtdicht)', nSpot, 'st.', nSpot * E.spotdoosSt, nSpot * E.spotdoos); + const nDv = elk.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && groupUnit(r.group) === uid).length; + ea(uid, 'Kabeldoorvoer naar buiten', nDv, 'st.', nDv * E.doorvoer, 0); + }); + if (elk.meterkast) { + const g1 = elk.groups.filter(g => (g.poles || 1) === 1).length, g2 = elk.groups.length - g1; + ea(mkUnit, `Groepenkast 3×25 A: kast, hoofdschakelaar, ${elk.rcdCount} aardlek`, 1, 'st.', E.kast + E.hoofdschakelaar + elk.rcdCount * E.aardlek, E.kastMontage); + ea(mkUnit, `Groepen: ${g1}× automaat 1P+N${g2 ? `, ${g2}× 2-fase` : ''}`, elk.groups.length, 'groep', g1 * E.automaat + g2 * E.automaat2f, elk.groups.length * E.groepMontage); + ea(mkUnit, 'Meten, testen en opleveren', 1, 'woning', 0, E.keuring); + } + } + // --- 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))); diff --git a/js/elektra/elektra-model.js b/js/elektra/elektra-model.js index 747040a..ded4bc9 100644 --- a/js/elektra/elektra-model.js +++ b/js/elektra/elektra-model.js @@ -31,6 +31,9 @@ // 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). +// - Vloer-wcd's (settings.vloerWcd = [{ id, unitId, x, z }], x/z vanaf de +// noordwesthoek van de unit): vloerdoos met dubbele wcd (bijv. bij de +// eettafel), op de wcd-groep van die unit, leiding door de vloer. // - 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 @@ -47,6 +50,16 @@ // 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 %. +// Boorgaten per leiding (run.holes, voor de kosten): in een wand de stijlen +// (horizontaal stuk) of de regels/dorpels (verticaal stuk) die de buis +// kruist (WallModel.computeBOM), in plafond en vloer de balken dwars op de +// buis (balkposities van het dek), een gat in de vloerplaat waar een buis +// de vloer in gaat, en een doorvoer naar buiten voor de buitenverlichting. +// Buizen houden onderling minstens CLEAR (50 mm) vrij: na het routeren +// schuift elk te dicht (evenwijdig, overlappend) padstuk naar een eigen baan +// met een korte knik ervoor en erna (separateRuns). Alleen het laatste stuk +// naar de meterkast is gebundeld: de buizen komen tegen elkaar aan de kast +// binnen. Leidingen naar een vloer-wcd lopen in de vloer (onder de OSB). // // Plain script, geladen na lighting-model.js en unit-model.js. // ============================================================ @@ -62,8 +75,11 @@ window.ElektraModel = (() => { 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) + place: {}, // handmatige plekken (zie boven) + vloerWcd: [] // vloer-wcd's (zie boven) }; + const CLEAR = 50; // min. vrije ruimte tussen buizen + const FLOOR_DEPTH = 100; // buis in de vloer: zoveel onder de bovenkant vloer // Apparaten met een eigen groep. code = label op de plattegrond (twee // letters: geen verwarring met de schakelaarletters). const APPLIANCES = { @@ -103,6 +119,7 @@ window.ElektraModel = (() => { 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.vloerWcd = Array.isArray(i.vloerWcd) ? i.vloerWcd.filter(o => o && o.unitId).map(o => Object.assign({}, o)) : []; s.enabled = s.enabled !== false; // v2: schakelaars 1050 -> 1500 mm, wcd 300 -> 400 mm (oude standaard). if (!i.v) { @@ -205,10 +222,17 @@ window.ElektraModel = (() => { const r = u.rect; return u.roof.ceilingAt(Math.max(r.x0 + 1, Math.min(r.x1 - 1, x)), Math.max(r.z0 + 1, Math.min(r.z1 - 1, z))); }; + const floorAt = (x, z) => { const u = unitAt(x, z) || units[0]; return u.layout.floorTop; }; // 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). + // ligt, ceil = boven het plafond, floor = in de vloer (voor de buizen in + // de 3D en de bladen per wand/vloer/plafond). function path(a, b) { - const P = (p, wall, ceil) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil }); + const P = (p, wall, ceil, floor) => ({ x: p.x, y: p.y, z: p.z, wall: wall || null, ceil: !!ceil, floor: !!floor }); + if (a.mount === 'vloer' || b.mount === 'vloer') { // door de vloer + const fa = floorAt(a.x, a.z) - FLOOR_DEPTH, fb = floorAt(b.x, b.z) - FLOOR_DEPTH; + return [P(a, a.face, false, a.mount === 'vloer'), P({ x: a.x, y: fa, z: a.z }, a.face, false, true), P({ x: b.x, y: fa, z: a.z }, null, false, true), + P({ x: b.x, y: fb, z: b.z }, b.face, false, true), P(b, b.face, false, b.mount === 'vloer')]; + } 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); @@ -237,7 +261,7 @@ window.ElektraModel = (() => { }); return n; } - return { unitAt, ceilAt, path, length, cost, crossings }; + return { unitAt, ceilAt, floorAt, path, length, cost, crossings }; } // ------------------------------------------------------------ @@ -250,7 +274,7 @@ window.ElektraModel = (() => { 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: [], + enabled: s.enabled, settings: s, meterkast: null, combos: [], switches: [], wcds: [], apps: [], floorWcds: [], boxes: [], groups: [], runs: [], cables: [], wires: [], conduits: [], materials: [], warnings: [], items: [], faces: [] }; if (!s.enabled || !units.length) return out; @@ -435,11 +459,20 @@ window.ElektraModel = (() => { 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) }); }); + // 6c. Vloer-wcd's (handmatig toegevoegd, x/z vanaf de noordwesthoek van de unit). + s.vloerWcd.forEach(o => { + const u = units.find(x => x.id === o.unitId); + if (!u) return; + const x = u.rect.x0 + Math.max(100, Math.min(u.rect.W - 100, num(o.x, u.rect.W / 2))); + const z = u.rect.z0 + Math.max(100, Math.min(u.rect.D - 100, num(o.z, u.rect.D / 2))); + out.floorWcds.push({ key: 'vwcd:' + o.id, id: o.id, unitId: u.id, x, z, y: u.layout.floorTop }); + }); + // 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: [] }); + if (out.wcds.some(w => w.unitId === u.id) || out.floorWcds.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) }); }); @@ -472,6 +505,7 @@ window.ElektraModel = (() => { } 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.floorWcds.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. @@ -480,8 +514,7 @@ window.ElektraModel = (() => { 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' }); + out.runs.push({ type, group, letter: letter || null, from: a.name || '', to: b.name || '', path: pts, mk: !!(a.mk || b.mk), outdoor: a.mount === 'buiten' || b.mount === 'buiten' }); }; // Minimale opspannende boom vanaf root (Prim, op de snelle schatting). const tree = (root, nodes, typeOf, group, letter) => { @@ -514,19 +547,33 @@ window.ElektraModel = (() => { 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('') }); + // Naar de eigen inbouwdoos (of het midden ervan) van deze groep. + const uSw = e.switches.reduce((t, x) => t + x.u, 0) / e.switches.length; + const swNode = Object.assign(facePoint(combo.face, uSw, combo.y, 0), { 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}` })); + 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}` })) + .concat(out.floorWcds.filter(w => w.group === g.nr).map((w, i) => ({ x: w.x, y: w.y, z: w.z, mount: 'vloer', name: `VWCD${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)); + // 8b. Buizen uit elkaar (min. CLEAR), daarna lengtes, naad-overgangen en boorgaten. + separateRuns(out.runs); + const holesOf = makeHoleCounter(units, R); + out.runs.forEach(r => { + r.rawMM = R.length(r.path); + r.lengthMM = (r.rawMM + s.reserve + (r.mk ? s.meterkastReserve : 0)) * (1 + s.slack / 100); + r.crossings = R.crossings(r.path); + r.holes = holesOf(r); + }); + out.holes = out.runs.reduce((a, r) => a + r.holes, 0); + // 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; }); @@ -578,6 +625,7 @@ window.ElektraModel = (() => { 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); + add('Vloerdoos met dubbele wcd (randaarde)', out.floorWcds.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])); @@ -600,6 +648,122 @@ window.ElektraModel = (() => { 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)); + out.floorWcds.forEach(w => { + const u = units.find(x => x.id === w.unitId); + out.items.push({ key: w.key, kind: 'vwcd', label: `Vloer-wcd unit ${w.unitId} (groep ${w.group})`, unitId: w.unitId, x: w.x - u.rect.x0, z: w.z - u.rect.z0, manual: true, marker: { x: w.x, z: w.z } }); + }); + return out; + } + + // ------------------------------------------------------------ + // Buizen uit elkaar (muteert run.path). Padstukken zijn haaks (x, y of z). + // Te dicht = evenwijdig, overlappend langs de as en minder dan CLEAR vrij + // (buitenkant tot buitenkant). Zo'n stuk schuift naar de eerste vrije baan + // (±1, ±2, ... × CLEAR + buis-Ø): in een wand langs de wand (verticaal + // stuk) of omhoog/omlaag (horizontaal stuk), in plafond/vloer horizontaal + // dwars. Een knik ervoor en erna verbindt het weer met de rest van het pad. + // Uitzondering: het laatste stuk naar de meterkast. Daar komen de buizen + // naast elkaar (tegen elkaar aan) de kast binnen. + // ------------------------------------------------------------ + function separateRuns(runs) { + const add = (p, d, k) => Object.assign({}, p, { x: p.x + d.x * k, y: p.y + d.y * k, z: p.z + d.z * k }); + const axisOf = (p, q) => { + const dx = Math.abs(q.x - p.x), dy = Math.abs(q.y - p.y), dz = Math.abs(q.z - p.z); + return dx >= dy && dx >= dz ? 'x' : dz >= dy ? 'z' : 'y'; + }; + const lenOf = (p, q) => Math.hypot(q.x - p.x, q.y - p.y, q.z - p.z); + const seg = (run, p, q, r) => { + const axis = axisOf(p, q), perp = {}; + ['x', 'y', 'z'].filter(k => k !== axis).forEach(k => { perp[k] = (p[k] + q[k]) / 2; }); + return { run, axis, lo: Math.min(p[axis], q[axis]), hi: Math.max(p[axis], q[axis]), perp, r }; + }; + const placed = []; + const conflict = (sg) => placed.some(t => t.run !== sg.run && t.axis === sg.axis && Math.min(t.hi, sg.hi) - Math.max(t.lo, sg.lo) > 1 && + Math.hypot(...Object.keys(sg.perp).map(k => sg.perp[k] - t.perp[k])) < CLEAR + sg.r + t.r - 0.5); + const faceDir = (f) => { const L = Math.hypot(f.b.x - f.a.x, f.b.z - f.a.z) || 1; return { x: (f.b.x - f.a.x) / L, y: 0, z: (f.b.z - f.a.z) / L }; }; + const shiftDir = (p, q, axis) => { + const plane = (p.ceil || p.floor) && (q.ceil || q.floor); + if (axis === 'y') { const f = p.wall || q.wall; return f ? faceDir(f) : { x: 1, y: 0, z: 0 }; } + if (!plane && (p.wall || q.wall)) return { x: 0, y: 1, z: 0 }; + return axis === 'x' ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 }; + }; + const conduit = runs.filter(r => cableInfo(r.type).conduit > 0); + const radius = (r) => cableInfo(r.type).conduit / 2; + + // 1. Meterkast: per aansluitpunt (boven/onder) de buizen naast elkaar, + // tegen elkaar aan, gesorteerd naar de kant waar ze heen gaan. + const bundles = {}; + conduit.filter(r => r.mk && r.path[0].wall).forEach(r => { + const k = Math.round(r.path[0].y); + (bundles[k] = bundles[k] || []).push(r); + }); + Object.values(bundles).forEach(list => { + const d = faceDir(list[0].path[0].wall); + const along = (r) => { const q = r.path[Math.min(2, r.path.length - 1)]; return (q.x - r.path[0].x) * d.x + (q.z - r.path[0].z) * d.z; }; + list.sort((a, b) => along(a) - along(b)); + const total = list.reduce((a, r) => a + 2 * radius(r), 0); + let off = -total / 2; + list.forEach(r => { + const o = off + radius(r); + off += 2 * radius(r); + const orig = r.path, dAx = Math.abs(d.x) > Math.abs(d.z) ? 'x' : 'z'; + const out = orig.map(p => p); + out[0] = add(orig[0], d, o); + for (let k = 0; k < orig.length - 1; k++) { + const len = lenOf(orig[k], orig[k + 1]); + if (len >= 1 && axisOf(orig[k], orig[k + 1]) === dAx) break; // loopt langs de kast: alleen langer/korter + if (k + 1 === orig.length - 1) { out.splice(k + 1, 0, add(orig[k + 1], d, o)); break; } // eindpunt blijft: knik ervoor + out[k + 1] = add(orig[k + 1], d, o); + } + r.path = out; + placed.push(seg(r, out[0], out[1], radius(r))); // gebundeld stuk: vast + r.fixed = 1; + }); + }); + + // 2. De rest: per padstuk een vrije baan zoeken. + conduit.forEach(r => { + const rad = radius(r), step = CLEAR + 2 * rad; + let pts = r.path; + for (let i = r.fixed || 0; i < pts.length - 1; i++) { + const p = pts[i], q = pts[i + 1]; + if (lenOf(p, q) < 1) continue; + const sg = seg(r, p, q, rad); + if (!conflict(sg)) { placed.push(sg); continue; } + const dir = shiftDir(p, q, sg.axis); + // Knikken tellen ook mee, behalve direct aan een doos/lamp (daar + // komen buizen samen) en aan het eind van de meterkastbundel. + const freeStart = i === 0 || (r.fixed && i === r.fixed), freeEnd = i + 1 === pts.length - 1; + let done = false; + for (let k = 1; k <= 12 && !done; k++) { + for (const sign of [1, -1]) { + const kk = sign * k * step, p2 = add(p, dir, kk), q2 = add(q, dir, kk), s2 = seg(r, p2, q2, rad); + const j1 = seg(r, p, p2, rad), j2 = seg(r, q2, q, rad); + if (conflict(s2) || (!freeStart && conflict(j1)) || (!freeEnd && conflict(j2))) continue; + pts = pts.slice(0, i + 1).concat([p2, q2], pts.slice(i + 1)); + placed.push(s2, j1, j2); + i += 2; // verder vanaf de knik terug naar q + done = true; + break; + } + } + if (!done) placed.push(sg); + } + r.path = simplifyPath(pts); + }); + runs.forEach(r => { delete r.fixed; }); + } + // Dubbele punten en heen-en-weer-knikken eruit. + function simplifyPath(pts) { + const same = (a, b) => Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.z - b.z) < 0.5; + let out = pts.filter((p, i) => i === 0 || !same(p, pts[i - 1])); + let changed = true; + while (changed) { + changed = false; + for (let i = 1; i < out.length - 1; i++) { + if (i + 1 < out.length && same(out[i - 1], out[i + 1])) { out.splice(i, 2); changed = true; break; } + } + } return out; } @@ -663,6 +827,7 @@ window.ElektraModel = (() => { el.combos.forEach(c => box(c.width, PLATE, 10, c.center, c.rotY, mats.plateMaterial)); el.wcds.forEach(w => box(WCD_W, PLATE, 12, w.center, w.rotY, mats.plateMaterial)); el.apps.forEach(a => box(PLATE, PLATE, 12, a.center, a.rotY, mats.plateMaterial)); + el.floorWcds.forEach(w => box(130, 8, 130, { x: w.x, y: w.y + 4, z: w.z }, 0, mats.plateMaterial)); // deksel vloerdoos } if (!rough) return group; @@ -702,6 +867,7 @@ window.ElektraModel = (() => { 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)); + el.floorWcds.forEach(w => box(100, 80, 100, { x: w.x, y: w.y - 40, z: w.z }, 0, mats.boxMaterial)); // vloerdoos // 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 => { @@ -712,5 +878,77 @@ window.ElektraModel = (() => { return group; } - return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, cableInfo, normalizeSettings, compute, snapToWall, createMaterials, build3D }; + // Plek van een punt op een wandvlak: { u, y } (wand-x en hoogte boven de vloer). + const wallCoords = (f, p) => ({ u: uAt(f, p), y: p.y - f.piece.place.floorTop }); + + // Lijnstuk p-q (wereld-mm) binnen grenzen b = { x0, x1, z0, z1 } (plattegrond); null = erbuiten. + function clipPlan(p, q, b) { + let t0 = 0, t1 = 1; + for (const [k, lo, hi] of [['x', b.x0, b.x1], ['z', b.z0, b.z1]]) { + const d = q[k] - p[k]; + if (Math.abs(d) < 1e-9) { if (p[k] < lo - 0.5 || p[k] > hi + 0.5) return null; continue; } + let a = (lo - p[k]) / d, c = (hi - p[k]) / d; + if (a > c) [a, c] = [c, a]; + t0 = Math.max(t0, a); t1 = Math.min(t1, c); + if (t0 > t1) return null; + } + const at = (t) => Object.assign({}, p, { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t, z: p.z + (q.z - p.z) * t }); + return [at(t0), at(t1)]; + } + // Dakvlak pl van unit: deel van de plattegrond (bounds) en omrekening naar + // het dek zoals de dakbladen (map: { sx, sy }, mm vanaf linksvoor), gelijk + // aan de doorvoeren in RoofModel.computeUnitRoof. De balken van het dek + // staan op sx = joist + breedte/2 en lopen langs sy. + function roofDeckMap(unit, pl) { + const alongX = Math.abs(Math.sin(pl.yaw)) > 0.5, rr = unit.roof.rect, r = unit.rect; + const qOf = (p) => alongX ? p.x - r.cx : p.z - r.cz; + return { + bounds: alongX + ? { x0: Math.max(r.x0, r.cx + pl.p0), x1: Math.min(r.x1, r.cx + pl.p1), z0: r.z0, z1: r.z1 } + : { x0: r.x0, x1: r.x1, z0: Math.max(r.z0, r.cz + pl.p0), z1: Math.min(r.z1, r.cz + pl.p1) }, + map: (p) => ({ sx: alongX ? rr.z1 - p.z : p.x - rr.x0, sy: (qOf(p) - pl.p0) / pl.cos }) + }; + } + // Boorgaten van een leiding (zie kop). Telt per padstuk. + const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl']; + function makeHoleCounter(units, R) { + const boms = new Map(); + const bomOf = (piece) => { + if (!boms.has(piece)) boms.set(piece, piece.frame ? WallModel.computeBOM(piece.config, piece.frame) : []); + return boms.get(piece); + }; + const between = (v, a, b) => v > Math.min(a, b) + 1 && v < Math.max(a, b) - 1; + const joistsBetween = (cd, a, b) => cd.joists.filter(j => between(j + cd.width / 2, a, b)).length; + return (run) => { + if (!cableInfo(run.type).conduit) return run.type === 'buiten' && run.from.startsWith('VD') ? 1 : 0; // doorvoer naar buiten + let n = 0; + run.path.slice(1).forEach((q, i) => { + const p = run.path[i]; + const vertical = Math.abs(q.y - p.y) > Math.hypot(q.x - p.x, q.z - p.z); + if (p.wall && q.wall && p.wall.piece === q.wall.piece && !(p.ceil && q.ceil)) { + const f = p.wall, a = wallCoords(f, p), b = wallCoords(f, q), bom = bomOf(f.piece); + if (vertical) { + const x = a.u - f.W / 2; + n += bom.filter(bp => !VERTICAL_KINDS.includes(bp.kind) && Math.abs(x - bp.x) <= bp.length / 2 && between(bp.y, a.y, b.y)).length; + if (p.floor !== q.floor) n += 1; // door de vloerplaat/randbalk de vloer in + } else { + n += bom.filter(bp => VERTICAL_KINDS.includes(bp.kind) && between(bp.x + f.W / 2, a.u, b.u) && Math.abs(a.y - bp.y) <= bp.length / 2).length; + } + } else if (!vertical && p.ceil && q.ceil) { + const mid = { x: (p.x + q.x) / 2, z: (p.z + q.z) / 2 }, u = R.unitAt(mid.x, mid.z); + if (u) u.roof.planes.forEach(pl => { + const dm = roofDeckMap(u, pl), c = clipPlan(p, q, dm.bounds); + if (c) n += joistsBetween(pl.data.calcData, dm.map(c[0]).sx, dm.map(c[1]).sx); + }); + } else if (!vertical && p.floor && q.floor) { + const u = R.unitAt((p.x + q.x) / 2, (p.z + q.z) / 2); + const c = u && clipPlan(p, q, u.rect); + if (c) n += joistsBetween(u.floorData.calcData, c[0].x - u.rect.x0, c[1].x - u.rect.x0); + } else if (vertical && (p.floor || q.floor) && !(p.wall || q.wall)) n += 1; // vloerdoos: gat in de vloerplaat + }); + return n; + }; + } + + return { DEFAULT_SETTINGS, CABLES, CORE_COLORS, APPLIANCES, PLATE, WCD_W, cableInfo, normalizeSettings, compute, snapToWall, wallCoords, clipPlan, roofDeckMap, createMaterials, build3D }; })(); diff --git a/js/elektra/elektra-pdf.js b/js/elektra/elektra-pdf.js index ed8f04d..36772af 100644 --- a/js/elektra/elektra-pdf.js +++ b/js/elektra/elektra-pdf.js @@ -94,6 +94,17 @@ window.ElektraPdf = (() => { 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' }); }); + // Vloer-wcd: vierkantje met V + groepnummer. + el.floorWcds.forEach(w => { + doc.setDrawColor(...RUN_COLORS.wcd); + doc.setFillColor(255, 255, 255); + doc.setLineWidth(0.2); + doc.rect(px(w.x) - 1.1, py(w.z) - 1.1, 2.2, 2.2, 'FD'); + doc.setTextColor(...RUN_COLORS.wcd); + doc.setFontSize(3.6); + doc.text('V' + w.group, px(w.x), py(w.z) + 0.5, { align: 'center' }); + }); + // Lampletters (zelfde letter als de schakelaar). doc.setFontSize(3.6); doc.setTextColor(...BLACK); @@ -102,7 +113,7 @@ window.ElektraPdf = (() => { // 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).' + 'rood kadertje = apparaat (KK kookgroep, WM wasmachine, DR droger, WP warmtepomp, OV oven, VW vaatwasser) met groepnummer, V = vloer-wcd; gestippeld = leidingen (geel licht, bruin schakeldraad, rood wcd/apparaat, groen buiten).' ]; function drawSheet(doc, el, sheetOpts) { @@ -150,5 +161,117 @@ window.ElektraPdf = (() => { PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); } - return { RUN_COLORS, drawPlan, drawSheet, PLAN_LEGEND }; + // ------------------------------------------------------------ + // Elektra op de bladen per onderdeel. Elk geeft een overlay-functie voor + // WallPdf/FloorPdf (opts.overlay) of null als er niets te tekenen is. + // ------------------------------------------------------------ + const lineCol = (doc, type) => doc.setDrawColor(...runColor(type)); + const clip = (p, q, b) => ElektraModel.clipPlan(p, q, b); + // Leidingstukken van alle buizen waarvoor test(p, q) geldt. + const conduitSegs = (el, test) => { + const out = []; + el.runs.forEach(r => { + if (!ElektraModel.cableInfo(r.type).conduit) return; + r.path.slice(1).forEach((q, i) => { const p = r.path[i]; if (test(p, q)) out.push({ type: r.type, p, q }); }); + }); + return out; + }; + const labelBox = (doc, x, y, text, color) => { + const w = 1.6 + text.length * 1.05; + doc.setDrawColor(...color); + doc.setFillColor(255, 255, 255); + doc.setLineWidth(0.2); + doc.rect(x - w / 2, y - 1.2, w, 2.4, 'FD'); + doc.setTextColor(...color); + doc.setFontSize(5); + doc.text(text, x, y + 0.7, { align: 'center' }); + }; + + // Wand (vooraanzicht, wand-x zoals de openingen): buizen in de wand, + // schakelaars, wcd's, apparaten en de meterkast op dit wandstuk. + function wallOverlay(el, piece) { + if (!el || !el.enabled) return null; + const on = (p) => p.wall && p.wall.piece === piece; + const segs = conduitSegs(el, (p, q) => on(p) && on(q)); + const combos = el.combos.filter(c => c.face.piece === piece), wcds = el.wcds.filter(w => w.face.piece === piece); + const apps = el.apps.filter(a => a.face.piece === piece), mk = el.meterkast && el.meterkast.face.piece === piece ? el.meterkast : null; + if (!segs.length && !combos.length && !wcds.length && !apps.length && !mk) return null; + const BLACK = PdfSheet.COLORS.BLACK; + return (doc, pu, py) => { + doc.setLineWidth(0.35); + segs.forEach(sg => { + const a = ElektraModel.wallCoords(sg.p.wall, sg.p), b = ElektraModel.wallCoords(sg.q.wall, sg.q); + lineCol(doc, sg.type); + doc.line(pu(a.u), py(a.y), pu(b.u), py(b.y)); + }); + if (mk) { + doc.setDrawColor(...BLACK); + doc.setFillColor(110, 110, 110); + doc.setLineWidth(0.25); + doc.rect(pu(mk.u - mk.w / 2), py(mk.y + mk.h), (pu(mk.u + mk.w / 2) - pu(mk.u - mk.w / 2)), py(mk.y) - py(mk.y + mk.h), 'FD'); + doc.setTextColor(255, 255, 255); + doc.setFontSize(6); + doc.text('MK', pu(mk.u), py(mk.y + mk.h / 2) + 1, { align: 'center' }); + } + combos.forEach(c => labelBox(doc, pu(c.u), py(c.y), c.switches.map(x => x.letter).join(' '), BLACK)); + wcds.forEach(w => labelBox(doc, pu(w.u), py(w.y), `wcd ${w.group}`, RUN_COLORS.wcd)); + apps.forEach(a => labelBox(doc, pu(a.u), py(a.y), `${a.code} ${a.group}`, RUN_COLORS.wcd)); + }; + } + + // Vloer van een unit (bovenaanzicht vanaf linksvoor): buizen in de vloer + vloer-wcd's. + function floorOverlay(el, unit) { + if (!el || !el.enabled) return null; + const r = unit.rect, b = { x0: r.x0, x1: r.x1, z0: r.z0, z1: r.z1 }; + const segs = conduitSegs(el, (p, q) => p.floor && q.floor).map(sg => Object.assign(sg, { c: clip(sg.p, sg.q, b) })).filter(sg => sg.c); + const vw = el.floorWcds.filter(w => w.unitId === unit.id); + if (!segs.length && !vw.length) return null; + return (doc, px, py) => { + doc.setLineWidth(0.35); + segs.forEach(sg => { + const [a, c] = sg.c; + if (Math.hypot(c.x - a.x, c.z - a.z) < 1) return; + lineCol(doc, sg.type); + doc.line(px(a.x - r.x0), py(a.z - r.z0), px(c.x - r.x0), py(c.z - r.z0)); + }); + vw.forEach(w => labelBox(doc, px(w.x - r.x0), py(w.z - r.z0), `vloer-wcd ${w.group}`, RUN_COLORS.wcd)); + }; + } + + // Dakvlak (dek zoals de dakbladen, zelfde omrekening als de doorvoeren in + // RoofModel.computeUnitRoof): buizen boven het plafond, verdeeldozen en + // binnenspots (met letter) in dit vlak. + function roofOverlay(el, unit, pl) { + if (!el || !el.enabled) return null; + const dm = ElektraModel.roofDeckMap(unit, pl), map = dm.map, b = dm.bounds; + const inside = (p) => p.x >= b.x0 - 0.5 && p.x <= b.x1 + 0.5 && p.z >= b.z0 - 0.5 && p.z <= b.z1 + 0.5; + const segs = conduitSegs(el, (p, q) => p.ceil && q.ceil).map(sg => Object.assign(sg, { c: clip(sg.p, sg.q, b) })).filter(sg => sg.c); + const boxes = el.boxes.filter(bx => inside(bx)); + const lamps = []; + el.switches.forEach(sw => sw.lamps.forEach(l => { if (l.kind === 'binnenspot' && inside(l)) lamps.push({ l, letter: sw.letter }); })); + if (!segs.length && !boxes.length && !lamps.length) return null; + const BLACK = PdfSheet.COLORS.BLACK; + return (doc, px, py) => { + doc.setLineWidth(0.35); + segs.forEach(sg => { + const a = map(sg.c[0]), c = map(sg.c[1]); + if (Math.hypot(c.sx - a.sx, c.sy - a.sy) < 1) return; + lineCol(doc, sg.type); + doc.line(px(a.sx), py(a.sy), px(c.sx), py(c.sy)); + }); + doc.setLineWidth(0.2); + boxes.forEach(bx => { const m = map(bx); lineCol(doc, 'schakel'); doc.rect(px(m.sx) - 0.9, py(m.sy) - 0.9, 1.8, 1.8, 'S'); }); + doc.setFontSize(4); + lamps.forEach(({ l, letter }) => { + const m = map(l); + doc.setDrawColor(...BLACK); + doc.setFillColor(255, 233, 168); + doc.circle(px(m.sx), py(m.sy), 0.8, 'FD'); + doc.setTextColor(...BLACK); + doc.text(letter, px(m.sx) + 1.1, py(m.sy) - 0.8); + }); + }; + } + + return { RUN_COLORS, drawPlan, drawSheet, PLAN_LEGEND, wallOverlay, floorOverlay, roofOverlay }; })(); diff --git a/js/floor/floor-pdf.js b/js/floor/floor-pdf.js index 8bb9663..bf82ec7 100644 --- a/js/floor/floor-pdf.js +++ b/js/floor/floor-pdf.js @@ -117,6 +117,10 @@ window.FloorPdf = (() => { doc.text('#' + o.id, cx, cy - o.r * scale - 2, { align: 'center' }); }); + // Extra laag van de aanroeper (bijv. elektra, ElektraPdf): overlay(doc, px, py), + // px/py = mm vanaf linksvoor van het dek -> blad-mm. + if (opts.overlay) opts.overlay(doc, px, py); + const drawDimLine = (x1, y1, x2, y2) => PdfSheet.dimLine(doc, x1, y1, x2, y2); doc.setDrawColor(...BLUE); diff --git a/js/house/house-model.js b/js/house/house-model.js index f39c2f5..5f3dcac 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -934,7 +934,7 @@ window.HouseModel = (() => { const gevelOpen = ext.reduce((a, p) => a + openingsMM2(p), 0); const glas = ext.reduce((a, p) => a + (p.config.openings || []).filter(op => op.type === 'raam').reduce((b, op) => b + WallModel.openingArea(op), 0), 0); const binnen = all.reduce((a, p) => a + ((p.stats && p.stats.spaanplaatAreaM2) || 0) + ((p.stats && p.stats.spaanplaatBuitenAreaM2) || 0), 0); - const dakM2 = parts.units.reduce((a, u) => a + (u.roof.specs.dakleerM2 || 0) + (u.roof.specs.rietM2 || 0), 0); + const dakM2 = parts.units.reduce((a, u) => a + (u.roof.specs.dakleerM2 || 0) + (u.roof.specs.rietM2 || 0) + (u.roof.specs.felsM2 || 0), 0); const dakPlanMM2 = parts.units.reduce((a, u) => a + u.roof.rect.W * u.roof.rect.D, 0); // Inhoud (bruto): per unit gemiddelde hoogte bovenkant dak boven de // onderkant vloer, bemonsterd op een raster. @@ -1089,7 +1089,9 @@ window.HouseModel = (() => { }, { naam: 'OSB-plaat (vloeren + plafonds dak)', m2: sumU(u => u.floorSpecs.osbAreaM2 + u.roof.specs.osbAreaM2), m3: sumU(u => u.floorSpecs.osbVolM3 + u.roof.specs.osbVolM3), kg: sumU(u => u.floorSpecs.osbKg + u.roof.specs.osbKg) }, { naam: 'Dakleer (EPDM)', m2: sumU(u => u.roof.specs.dakleerM2), m3: null, kg: sumU(u => u.roof.specs.dakleerKg) }, + { naam: `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm (bovenop de sporen)`, m2: sumU(u => u.roof.specs.beschotM2 || 0), m3: sumU(u => u.roof.specs.beschotVolM3 || 0), kg: sumU(u => u.roof.specs.beschotKg || 0) }, { naam: `Riet (${RoofModel.RIET.thickMM} mm)`, m2: sumU(u => u.roof.specs.rietM2 || 0), m3: null, kg: sumU(u => u.roof.specs.rietKg || 0) }, + { naam: `Felsplaten (staande naad, ${RoofModel.FELS.thickMM} mm)`, m2: sumU(u => u.roof.specs.felsM2 || 0), m3: null, kg: sumU(u => u.roof.specs.felsKg || 0) }, { naam: 'Cementplaat (buitenzijde)', m2: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatAreaM2, 0), m3: byMat('cementplaat').reduce((a, p) => a + p.stats.cementplaatVolumeM3, 0), kg: byMat('cementplaat').reduce((a, p) => a + p.specs.buitenKg, 0) diff --git a/js/house/house-pdf.js b/js/house/house-pdf.js index 51badb2..49f2d4d 100644 --- a/js/house/house-pdf.js +++ b/js/house/house-pdf.js @@ -730,7 +730,8 @@ window.HousePdf = (() => { 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)); + WallPdf.drawSheet(doc, sheet.piece.config, sheet.piece.frame, Object.assign({ buitenMateriaal: sheet.piece.buitenMateriaal, cladding: sheet.piece.cladding, trasraam: sheet.piece.trasraam, skinExt: sheet.piece.skinExt, facade: sheet.piece.facade, skinFrom: sheet.piece.skinFrom, lamps: sheet.piece.lamps || [], + overlay: ElektraPdf.wallOverlay(parts.elektra, sheet.piece) }, opts)); drawElementCost(doc, costOf(sheet.element)); }; const detailPages = paginateCostRows(costDetailRows(cost)); @@ -745,10 +746,11 @@ window.HousePdf = (() => { 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); + FloorPdf.drawSheet(doc, sheet.unit.floorData.calcData, sheet.unit.floorData.resolvedOpenings, Object.assign({ overlay: ElektraPdf.floorOverlay(parts.elektra, sheet.unit) }, 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)); + const pu = parts.units.find(u => u.roof.planes.includes(sheet.plane)); + FloorPdf.drawSheet(doc, sheet.plane.data.calcData, sheet.plane.data.resolvedOpenings, Object.assign({ roof: true, overlay: pu && ElektraPdf.roofOverlay(parts.elektra, pu, sheet.plane) }, 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'); } diff --git a/js/house/house.js b/js/house/house.js index 63816f6..0b3ed44 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -582,7 +582,10 @@ function mountHouseEditor(container, callbacks) { 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); + // Vloer-wcd: vrij in een unit (10 mm raster); de rest langs de wanden. + elDrag.snap = elDrag.key.startsWith('vwcd:') + ? (parts.rects.some(rc => w.x > rc.x0 && w.x < rc.x1 && w.z > rc.z0 && w.z < rc.z1) ? { floor: true, x: Math.round(w.x / 10) * 10, z: Math.round(w.z / 10) * 10 } : null) + : ElektraModel.snapToWall(parts.elektra, w.x, w.z); drawPlan(); }); function onElektraDrop() { @@ -592,8 +595,14 @@ function mountHouseEditor(container, callbacks) { 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 }; + if (d.snap.floor) { + const rc = parts.rects.find(q => d.snap.x > q.x0 && d.snap.x < q.x1 && d.snap.z > q.z0 && d.snap.z < q.z1); + const vw = state.settings.elektra.vloerWcd.find(o => 'vwcd:' + o.id === d.key); + if (rc && vw) Object.assign(vw, { unitId: rc.id, x: Math.round(d.snap.x - rc.x0), z: Math.round(d.snap.z - rc.z0) }); + } else { + 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(); } @@ -635,6 +644,8 @@ function mountHouseEditor(container, callbacks) { if (['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; if (e.key === 'Escape') { if (drawStart) { drawStart = null; updateHint(); drawPlan(); } else if (tool === 'iw') setTool('select'); + } else if (e.key === 'Delete' && selection && selection.type === 'elektra' && String(selection.id).startsWith('vwcd:')) { + removeSelectedFloorWcd(); } else if (e.key === 'Delete' && selection && selection.type === 'iw') { state.interiorWalls = state.interiorWalls.filter(w => w.id !== selection.id); selection = null; @@ -834,7 +845,16 @@ function mountHouseEditor(container, callbacks) { 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) { + el.floorWcds.forEach(w => { + const q = toPx(w.x, w.z); + ctx.fillStyle = '#fff'; ctx.strokeStyle = rgb(RC.wcd, 1); ctx.lineWidth = 1.5; + ctx.fillRect(q.x - 7, q.y - 7, 14, 14); ctx.strokeRect(q.x - 7, q.y - 7, 14, 14); + ctx.fillStyle = rgb(RC.wcd, 1); ctx.font = 'bold 8px sans-serif'; ctx.fillText('V' + w.group, q.x, q.y + 0.5); + }); + if (elDrag && elDrag.snap && elDrag.snap.floor) { + const q = toPx(elDrag.snap.x, elDrag.snap.z); + ctx.strokeStyle = '#4da6ff'; ctx.lineWidth = 2; ctx.strokeRect(q.x - 9, q.y - 9, 18, 18); + } else 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(); @@ -1131,6 +1151,8 @@ function mountHouseEditor(container, callbacks) { 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: 'Vloer', html: big('Vloer-wcd', 'plus-square', 'data-he-add-vw title="Vloerdoos met dubbele wcd in de geselecteerde (of eerste) unit"') + + note(`${e.vloerWcd.length} vloer-wcd('s); leiding door de vloer.`) }); 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' }) + @@ -1158,6 +1180,15 @@ function mountHouseEditor(container, callbacks) { const cb = document.getElementById(`he-app-${k}`); if (cb) cb.addEventListener('change', () => { e.apparaten[k] = cb.checked; refresh(); }); }); + const av = body.querySelector('[data-he-add-vw]'); + if (av) av.addEventListener('click', () => { + const uid = selection && selection.type === 'unit' ? selection.id : state.units[0].id; + const rc = parts && parts.rects.find(q => q.id === uid); + const id = e.vloerWcd.reduce((m, o) => Math.max(m, o.id || 0), 0) + 1; + e.vloerWcd.push({ id, unitId: uid, x: rc ? Math.round(rc.W / 2) : 1000, z: rc ? Math.round(rc.D / 2) : 1000 }); + selection = { type: 'elektra', id: 'vwcd:' + id }; + refresh(); + }); const rs = body.querySelector('[data-he-reset]'); if (rs) rs.addEventListener('click', () => { e.place = {}; refresh(); }); const show = () => { @@ -1220,10 +1251,36 @@ function mountHouseEditor(container, callbacks) { return null; } + function removeSelectedFloorWcd() { + const e = state.settings.elektra; + e.vloerWcd = e.vloerWcd.filter(o => 'vwcd:' + o.id !== selection.id); + selection = null; + refresh(); + } + // 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; + if (it.kind === 'vwcd') { + const vw = e.vloerWcd.find(o => 'vwcd:' + o.id === it.key); + return { + key: 'sel', label: it.label, icon: 'plug', contextual: true, order: 100, + groups: () => [ + { label: 'Vloer-wcd', html: kv('Unit', it.unitId) + note('Vloerdoos met dubbele wcd; leiding door de vloer naar de meterkast.') }, + { label: 'Plaats', html: num('he-vw-x', 'Vanaf west', Math.round(it.x), { step: 10, hint: 'Hart, vanaf de westrand van de unit' }) + + num('he-vw-z', 'Vanaf noord', Math.round(it.z), { step: 10, hint: 'Hart, vanaf de noordrand van de unit' }) }, + { label: 'Bewerken', html: note('Sleep de vloer-wcd in de plattegrond (ook naar een andere unit).') + big('Verwijderen', 'trash', 'data-he-vw-del title="Vloer-wcd verwijderen (Del)"') } + ], + wire: (body) => { + live = []; + UI.onNum('he-vw-x', v => { if (vw) vw.x = v; }, after); + UI.onNum('he-vw-z', v => { if (vw) vw.z = v; }, after); + const b = body.querySelector('[data-he-vw-del]'); + if (b) b.addEventListener('click', removeSelectedFloorWcd); + } + }; + } 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); @@ -1299,7 +1356,7 @@ function mountHouseEditor(container, callbacks) { 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²` }), + pair(sel('hp-roof-cover', 'Bedekking', covOpts, covNow, { hint: shapeNow === 'plat' ? 'Riet en felsplaten alleen bij een hellend dak' : `${c.thickMM} mm, ${String(c.kgM2).replace('.', ',')} kg/m²` }), 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) }); diff --git a/js/roof/roof-model.js b/js/roof/roof-model.js index fc84cff..a9aaaa1 100644 --- a/js/roof/roof-model.js +++ b/js/roof/roof-model.js @@ -38,6 +38,7 @@ window.RoofModel = (() => { const DAKLEER_MM = 2; // visuele dikte van het dakleer + const BESCHOT_MM = 18; // dakbeschot (OSB) bovenop de sporen, onder de bedekking const DENSITY_DAKLEER_KGM2 = 1.4; // EPDM ~1,4 kg/m2 const TRIM_THICK_MM = 25; // daktrim-profiel, uitstekend buiten de dakrand const EDGE_ABOVE_MM = 80; // bovenkant boeideel/daktrim ligt zoveel boven het dakleer @@ -72,11 +73,28 @@ window.RoofModel = (() => { // hier toevoegen); zelfde opbouw (180 mm, 3,8 kg/m2). // colors: gemiddelde kleur per textuur (vlakke weergave, PDF-aanzichten). const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } }; + // Felsplaten (staande naad, zink/staal op scheidingslaag): 25 mm hoge + // felsnaden elke seamMM langs de helling; alleen hellend. + const FELS = { thickMM: 25, kgM2: 6.5, seamMM: 500, colors: { zink: ['Felsplaat zink', '#8e9497'], antraciet: ['Felsplaat antraciet', '#3d4146'], zwart: ['Felsplaat zwart', '#1f2022'] } }; + // kind: soort bedekking (dakleer | riet | fels) voor specificaties en kosten. const COVERINGS = Object.assign({ - dakleer: { label: 'Dakleer (EPDM)', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 } + dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 } }, ...RIET.nrs.map(nr => ({ - ['riet_' + nr]: { label: 'Riet ' + nr, color: RIET.colors[nr] || '#8a7560', thickMM: RIET.thickMM, kgM2: RIET.kgM2, file: `assets/textures/dak/riet_${nr}.jpg`, tileMM: RIET.tileMM, sloped: true } + ['riet_' + nr]: { label: 'Riet ' + nr, kind: 'riet', color: RIET.colors[nr] || '#8a7560', thickMM: RIET.thickMM, kgM2: RIET.kgM2, file: `assets/textures/dak/riet_${nr}.jpg`, tileMM: RIET.tileMM, sloped: true } + })), ...Object.entries(FELS.colors).map(([k, [label, color]]) => ({ + ['fels_' + k]: { label, kind: 'fels', color, thickMM: FELS.thickMM, kgM2: FELS.kgM2, seamMM: FELS.seamMM, sloped: true } }))); + const coverKind = (key) => (COVERINGS[key] || COVERINGS.dakleer).kind; + // Hoeveelheden per soort bedekking (zelfde sleutels in elke specs). + function coverSpecs(covering, areaM2) { + const c = COVERINGS[covering] || COVERINGS.dakleer, kg = areaM2 * c.kgM2, k = c.kind; + return { + dakleerM2: k === 'dakleer' ? areaM2 : 0, dakleerKg: k === 'dakleer' ? kg : 0, + rietM2: k === 'riet' ? areaM2 : 0, rietKg: k === 'riet' ? kg : 0, + felsM2: k === 'fels' ? areaM2 : 0, felsKg: k === 'fels' ? kg : 0, + coverKg: kg + }; + } // Voorvertoning (assets/textures/dak/thumb//.png) of null. function coveringThumbUrl(key, px) { const c = COVERINGS[key]; @@ -90,8 +108,13 @@ window.RoofModel = (() => { const coveringMaterials = {}; function coveringMaterial(key) { const c = COVERINGS[key]; - if (!c || !c.file) return null; + if (!c) return null; if (coveringMaterials[key]) return coveringMaterials[key]; + if (c.kind === 'fels') { + coveringMaterials[key] = new THREE.MeshStandardMaterial({ color: c.color, roughness: 0.4, metalness: 0.55, side: THREE.DoubleSide }); + return coveringMaterials[key]; + } + if (!c.file) return null; const mat = new THREE.MeshStandardMaterial({ color: 0x9c8466, roughness: 0.95 }); if (typeof document !== 'undefined' && THREE.TextureLoader) { new THREE.TextureLoader().load(c.file, (tex) => { @@ -118,9 +141,9 @@ window.RoofModel = (() => { return { followFloor: true, offsetX: 0, offsetZ: 0, fascia: true, covering: 'dakleer', deck }; } - // Dikte dek + bedekking (mm, haaks op het dakvlak). + // Dikte dek (plafondplaat + sporen + dakbeschot) + bedekking (mm, haaks op het dakvlak). function thickness(calcData, covering) { - return calcData.joistH + calcData.underlayThick + (COVERINGS[covering] || COVERINGS.dakleer).thickMM; + return calcData.joistH + calcData.underlayThick + BESCHOT_MM + (COVERINGS[covering] || COVERINGS.dakleer).thickMM; } function createMaterials() { @@ -135,15 +158,17 @@ window.RoofModel = (() => { // 3D van één dak (meters, gecentreerd op het dakmidden, Y = 0 onderkant // plafondplaat). Het vloer-3D (FloorModel.build3D) wordt gespiegeld // (scale.y = -1; three.js corrigeert de vlakoriëntatie zelf), daarna - // komt het dakleer er bovenop. + // komen het dakbeschot (OSB, fase beplating) en de bedekking (fase gevel) + // er bovenop. Felsplaten: dunne plaat met staande naden langs de helling. function build3D(calcData, resolvedOpenings, opts) { const m = opts.materials; const toM = 0.001; const group = new THREE.Group(); // Plafondplaat én bedekking zichtbaar: de sporen ertussen zijn ingesloten. + // Plafondplaat én dakbeschot zichtbaar (fase beplating): de sporen ertussen zijn ingesloten. const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, { materials: m, osbVisible: opts.osbVisible !== false, - outerJoistsOnly: opts.osbVisible !== false && opts.dakleerVisible !== false + outerJoistsOnly: opts.osbVisible !== false }); const deckH = (calcData.joistH + calcData.underlayThick) * toM; deck.scale.y = -1; @@ -168,25 +193,46 @@ window.RoofModel = (() => { // de textuur (vorm-coördinaten in m = UV; langs de helling = v). const cov = COVERINGS[opts.covering] || COVERINGS.dakleer; const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial; - const leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: cov.thickMM * toM, bevelEnabled: false }), covMat); - leer.rotation.x = Math.PI / 2; - leer.position.y = deckH + cov.thickMM * toM; - leer.castShadow = cov.thickMM > DAKLEER_MM; - if (opts.dakleerVisible !== false) group.add(leer); + const bT = BESCHOT_MM * toM; + if (opts.osbVisible !== false) { + const beschot = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: bT, bevelEnabled: false }), m.osbMaterial); + beschot.rotation.x = Math.PI / 2; + beschot.position.y = deckH + bT; + beschot.castShadow = true; + group.add(beschot); + } + if (opts.dakleerVisible !== false) { + // Felsplaat: dunne plaat + naden; anders de volle laag (dakleer, riet). + const slabT = (cov.kind === 'fels' ? 1.5 : cov.thickMM) * toM; + const leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: slabT, bevelEnabled: false }), covMat); + leer.rotation.x = Math.PI / 2; + leer.position.y = deckH + bT + slabT; + leer.castShadow = cov.thickMM > DAKLEER_MM; + group.add(leer); + if (cov.kind === 'fels') { + const n = Math.max(0, Math.floor(calcData.width / cov.seamMM)); + const x0 = -(n * cov.seamMM) / 2; + for (let i = 0; i <= n; i++) { + const seam = new THREE.Mesh(new THREE.BoxGeometry(0.01, cov.thickMM * toM, dM), covMat); + seam.position.set((x0 + i * cov.seamMM) * toM, deckH + bT + cov.thickMM * toM / 2, 0); + seam.castShadow = true; + group.add(seam); + } + } + } return group; } - // Specificaties: die van het dek (FloorModel) + de bedekking (dakleer of - // bijv. riet: coveringM2/Kg, dakleer dan 0). + // Specificaties: die van het dek (FloorModel; osb = plafondplaat) + het + // dakbeschot (beschot*) + de bedekking (coverSpecs: dakleer/riet/fels). function computeSpecs(calcData, resolvedOpenings, covering) { const deck = FloorModel.computeSpecs(calcData, resolvedOpenings); const areaM2 = deck.osbAreaM2; // zelfde vlak minus doorvoeren - const isLeer = !COVERINGS[covering] || covering === 'dakleer'; - const kg = areaM2 * (COVERINGS[covering] || COVERINGS.dakleer).kgM2; - return Object.assign({}, deck, { - dakleerM2: isLeer ? areaM2 : 0, dakleerKg: isLeer ? kg : 0, - rietM2: isLeer ? 0 : areaM2, rietKg: isLeer ? 0 : kg, - totalKg: deck.totalKg + kg + const cs = coverSpecs(covering, areaM2); + const bVol = areaM2 * BESCHOT_MM * 1e-3, bKg = bVol * DENSITY_OSB_KGM3; + return Object.assign({}, deck, cs, { + beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bKg, + totalKg: deck.totalKg + bKg + cs.coverKg }); } @@ -385,7 +431,7 @@ window.RoofModel = (() => { 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 }; + return { key, f, along, cos, tV: underlay / cos, jV: joistH / cos, bV: BESCHOT_MM / cos, covV: cov.thickMM / cos }; }; const fns = [ plane('m1', sA, (p) => H(pA) + (p.x - pA) * sA, 'p'), @@ -399,7 +445,7 @@ window.RoofModel = (() => { 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 }); + return Object.assign({}, fi, { poly, under: fi.f, vThick: fi.tV + fi.jV + fi.bV + 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. @@ -499,15 +545,14 @@ window.RoofModel = (() => { 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 = { + const cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3; + const specs = Object.assign({ joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3, osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3, + beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bVol * 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 - }; + totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg + }, cs); const maxTop = top + Math.max(...regions.map(rg => rg.vThick)); return { pA, pR, pB, q0, q1, hipH, joistW, joistH, regions, beams, specs, maxTop }; } @@ -625,13 +670,14 @@ window.RoofModel = (() => { } 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, + if (osbOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV, rg.bV, m.osbMaterial, m.osbMaterial); // dakbeschot + if (covOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV + rg.bV, 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; + if (osbOn && bm.kind !== 'kopbalk') return; // ingesloten tussen plafondplaat en dakbeschot slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial); }); return group; @@ -882,7 +928,7 @@ window.RoofModel = (() => { } return { - DAKLEER_MM, EDGE_ABOVE_MM, RIET, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl, + 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, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions, computeEdge, buildEdge3D, normalizeSettings diff --git a/js/wall/wall-pdf.js b/js/wall/wall-pdf.js index 2b955a9..625f149 100644 --- a/js/wall/wall-pdf.js +++ b/js/wall/wall-pdf.js @@ -231,6 +231,10 @@ window.WallPdf = (() => { doc.text('L', lx + 2, ly + 1); }); + // --- Extra laag van de aanroeper (bijv. elektra, ElektraPdf): + // overlay(doc, pu, py), pu = wand-x (0..breedte) -> blad-mm. + if (opts.overlay) opts.overlay(doc, (u) => px(-wallWidth / 2 + u), py); + // --- Bovenaanzicht (doorsnede op stijlhoogte, zoals de voorbeeld- // tekeningen): binnenzijde boven, buitenzijde onder (= de kant van // waaruit het vooraanzicht bekeken wordt). Zelfde horizontale schaal.