diff --git a/README.md b/README.md index bfdc987..4a7f256 100644 --- a/README.md +++ b/README.md @@ -97,26 +97,39 @@ assets/ logo, texturen, skybox geschilderd in de plafondkleur (Binnen › Wanden › Plafond), zolang "Geschilderd" aan staat; anders kale OSB. - **Dakvorm/wandhoogte:** per unit een dakvorm (Unit › Dakvorm: plat, - lessenaar, zadel, mansarde of wolfsdak, met schuiven en hellingshoek). - Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij mansarde - met twee knikken, bij een wolfsdak afgeknot op het wolfseind, met + lessenaar, zadel of wolfsdak, met schuiven en hellingshoek). + Daaruit volgen de wanden: de kopgevels krijgen de daklijn (bij een + wolfsdak afgeknot op het wolfseind, met daarboven driehoekige wolfseinden met de dakhelling tot de nok, met een rechte nok ertussen), de haakse wanden staan vlak op de goothoogte. Het bovenste deel van een wolfsdak is gewoon houtwerk: sporen, hoekkepers, nokbalk en kopbalken (ook in zaaglijst, specificaties en kosten). Goot en nok gelden over de volle vloermaat (goot boven de buitenkant van de gevel); staat de kopgevel tussen de haakse wanden, dan loopt zijn - daklijn daar precies op aan. Knik en wolfseind schalen mee met goot en - nok (verhouding). Bij een knik (mansarde) sluiten de dakvlakken in - verstek aan. De PDF heeft voor een wolfsdak een eigen blad met het + daklijn daar precies op aan. Het wolfseind schaalt mee met goot en + nok (verhouding). De PDF heeft voor een wolfsdak een eigen blad met het bovenste deel (bovenaanzicht houtwerk + stuklijst). Grenzen (`UnitModel.LIMITS`): nok max 5 m, goot / plat dak max 3 m (vanaf - bovenkant vloer tot onderkant dak), unit max 4,3 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen + bovenkant vloer tot onderkant dak), unit max 4,7 x 14 m. Platte daken van verschillende hoogte krijgen elk hun eigen boeideel (versprongen, het lage loopt door tot onder het hoge). Wijzig je een wandhoogte in de wand-editor, dan geldt dezelfde regel vanuit die wand. De daklijn van een wand hoort bij de wand zonder naden: loopt een wand door een naad langer door (bijv. de kopgevel naast een gekoppelde unit), dan blijven nok en knikken op hun plaats (`UnitModel.topLineOn`). +- **Dakaansluiting (L/T):** staat een unit met nokdak (zadel of + wolfsdak) met zijn nok haaks op de langszijde van een buur met nokdak + (vleugel tegen hoofdvolume), dan loopt de vleugel het hoofddak in tot + zijn nok, met kilkepers in de binnenhoeken (bij gelijke goot, nok en + overspanning onder 45°); het + wolfseind van de vleugel aan de naad vervalt. Ligt de vleugel gelijk met + een kop van het hoofdvolume, dan wordt die kop een schild in de vlakken + van de vleugel (hoekkeper, de goot loopt door; de kopgevel staat dan op + goothoogte, een wolfseind daar vervalt). Een vleugel die in een wolfseind + van het hoofdvolume zou steken sluit niet aan. Elke unit houdt zijn eigen + dakdeel (sporen, kreupele sporen, kepers, knik- en nokbalken; stalen + nokbalk alleen over de eigen nok) met een eigen PDF-blad + (`HouseModel.roofJoins`, `RoofModel.computeJoinedRoof`). Niet bij dak + over de hele woning; dakramen in zo'n dakdeel doen nog niet mee. - **Overkapping:** bij een dak over de hele woning met `cover` loopt het dak door tot de omhullende rechthoek waar geen unit staat (bijv. een terras naast een kortere module), met een afgewerkte onderplaat in de @@ -275,7 +288,11 @@ hout.balk.38x235;Constructiehout vuren C24 38×235;€/m;4,91;Houthandel Y;;2026 `examples/ontwerp_wetering.pdf`): 10,5 × 4,8 m, twee units van 2,6 en 2,2 m zonder naadwand (open woonkamer over de volle diepte), verticaal rabat grijs, plat dak met zwart boeideel, hoekkeuken met kookplaat, - badkamer, slaapkamer met kledingkast. De standaardmodellen staan in + badkamer, slaapkamer met kledingkast. Model **IJssel** + (`HouseModel.createIJssel`, naar `examples/ontwerp_ijssel.pdf`): L-vorm + 14 × 4,6 m met een vleugel van 4,6 × 3 m, twee units met zadeldak en + riet die met een kil en een hoekkeper op elkaar aansluiten, stucwerk wit, + openslaande deuren in de westgevel. De standaardmodellen staan in `HouseModel.MODELS`; Bestand › Snel nieuw en het venster Nieuw worden daaruit opgebouwd (nieuw model = één regel plus een `create`-functie die `modelKit` gebruikt). De L-woning (8 × 3,6 m + 4,5 × 3,6 m) blijft diff --git a/js/app.js b/js/app.js index 869d854..066d548 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.6.1'; +const APP_VERSION = '0.6.2'; // 0.0.X per kleine iteratie, 0.X.0 per mijlpaal, X.0.0 bij live-gang. let tabs = []; diff --git a/js/cost/cost-model.js b/js/cost/cost-model.js index 021c82a..83d0877 100644 --- a/js/cost/cost-model.js +++ b/js/cost/cost-model.js @@ -206,13 +206,13 @@ window.CostModel = (() => { const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id); const rs = u.roof.specs, D = S.dak; - add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] && u.roof.planes[0].data.calcData), 0); - u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true)); const cap = u.roof.unitRoof && u.roof.unitRoof.cap; + add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', joistWoodCost(rs.joistWoodM3, u.roof.planes[0] ? u.roof.planes[0].data.calcData : cap), 0); + u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true)); if (cap) { - const n = cap.beams.length; - add(rf, 'Hout', 'Zagen', `${sawLabel}: wolfseinden ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0)); - addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true); + const n = cap.beams.length, join = cap.kind === 'join'; + add(rf, 'Hout', 'Zagen', `${sawLabel}: ${join ? 'dak (sporen, kepers)' : 'wolfseinden'} ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0)); + addJoints(rf, n * V.verbindingenPerVloerbalk, join ? 'dakbalken' : '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); diff --git a/js/house/house-model.js b/js/house/house-model.js index 9171fd5..4d8851c 100644 --- a/js/house/house-model.js +++ b/js/house/house-model.js @@ -123,14 +123,14 @@ window.HouseModel = (() => { const K = (type, extra) => Object.assign(KozijnModel.defaultFill(type === 'raam' ? 'raam' : 'deur'), { out: 'zwart', in: 'zwart' }, extra || {}); const binnen = (extra) => Object.assign(KozijnModel.defaultFill('deur'), { out: 'wit', in: 'wit', glas: false }, extra || {}); const ops = (ws, list) => { ws.openings = list.map(([type, w, h, y, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y, anchor, offset, fill })); }; - const iw = (x1, z1, x2, z2, openings) => { - const wall = interiorWallDefaults(T, H); + const iw = (x1, z1, x2, z2, openings, t) => { + const wall = interiorWallDefaults(t || T, H); wall.openings = (openings || []).map(([type, w, h, anchor, offset, fill], i) => ({ id: i + 1, type, w, h, y: 0, anchor, offset, fill })); st.interiorWalls.push({ id: st.interiorWalls.length + 1, x1, z1, x2, z2, wall }); }; // zaadpunt t.o.v. de noordwesthoek van de unit - const room = (unitId, x, z, type, name) => - st.rooms.push(Object.assign(RoomModel.applyType({ id: st.rooms.length + 1, unitId, x, z, name: '' }, type), { name: name || '' })); + const room = (unitId, x, z, type, name, extra) => + st.rooms.push(Object.assign(RoomModel.applyType({ id: st.rooms.length + 1, unitId, x, z, name: '' }, type), { name: name || '' }, extra || {})); // Midden voetafdruk t.o.v. de noordwesthoek van de unit; rot = richting van de rug (0 = noord, 90 = oost). // anchor 'oost': x opgeven vanaf de westgevel, het meubel schuift mee met de oostgevel. const fx = (type, unitId, x, z, rot, size, opts) => { @@ -294,10 +294,85 @@ window.HouseModel = (() => { return st; } + // Model IJssel (examples/ontwerp_ijssel.pdf): L-vorm 14 × 4,6 m met een vleugel van 4,6 × 3 m aan de zuidoostkant, + // samen 14 × 7,6 m. Twee units: hoofdvolume met zadeldak west–oost, vleugel met zadeldak noord–zuid. Gelijke + // goot, nok en overspanning, dus de daken sluiten onder 45° aan (dakaansluiting, zie roofJoins): kil in de + // binnenhoek en, omdat de vleugel gelijk ligt met de oostgevel, een hoekkeper (de oostkop wordt een schild). + // Geen naadwanden; de wand tussen hal/badkamer en slaapkamer is een gewone binnenwand. Binnenwanden volgen het + // dak nog niet, dus alle ruimtes krijgen een vlak plafond op wandhoogte. Alles buitenwerks vanaf de noordwesthoek. + function createIJssel() { + const st = newModelState('IJssel'); + const S = st.settings, LO = 3000, HI = 4600, H = 2900, D = 4600, ROOF = 'riet_001'; + Object.assign(S.facade, { type: 'stuc', stucColor: 'wit' }); + Object.assign(S.trasraam, { enabled: true, height: 600, finish: 'R510' }); + S.roof.fasciaColor = 'zwart'; + S.lighting.mode = 'gevel'; + S.lighting.direction = 'both'; + if (S.elektra && S.elektra.apparaten) Object.assign(S.elektra.apparaten, { koken: true, oven: true, vaatwasser: true }); + + const A = createUnitEntry('A', null, { width: 14000, depth: D, height: LO, windowOn: [] }); + const B = createUnitEntry('B', { to: 'A', side: 'achter', align: 'end', offset: 0 }, { width: 4600, depth: 3000, height: LO, windowOn: [] }); + st.units.push(A, B); + UnitModel.applyRoofForm(A.unit, { type: 'zadel', ridge: 'x', low: LO, high: HI }); + UnitModel.applyRoofForm(B.unit, { type: 'zadel', ridge: 'z', low: LO, high: HI }); + [A, B].forEach(u => { u.roof.covering = ROOF; }); + + const { K, binnen, ops, iw, room, fx } = modelKit(st, H, 90); + // Noord 'right' en zuid 'left' = vanaf de westhoek; west 'left' en oost 'right' = vanaf de noordkant van de wand. + ops(A.unit.walls.voor, [['raam', 1500, 500, 1600, 'right', 5445, K('raam')], ['raam', 500, 700, 1400, 'right', 9950, K('raam')]]); + ops(A.unit.walls.links, [ + ['deur', 1424, 2100, 0, 'left', 610, K('deur', { leaf: 'rechts', hinge: 'links', swing: 'buiten', volglas: true })], + ['deur', 1424, 2100, 0, 'left', 2376, K('deur', { leaf: 'links', hinge: 'rechts', swing: 'buiten', volglas: true })]]); + ops(A.unit.walls.rechts, [['raam', 1400, 1100, 1000, 'right', 608, K('raam')], ['raam', 1200, 500, 1600, 'right', 3101, K('raam')]]); + ops(A.unit.walls.achter, [ + ['raam', 1800, 2800, 0, 'left', 3215, K('raam')], + ['voordeur', 1100, 2100, 0, 'left', 8240, K('deur', { hinge: 'rechts', swing: 'buiten', glas: true })]]); + ops(B.unit.walls.achter, [['raam', 500, 1400, 700, 'left', 410, K('raam')], ['raam', 500, 1400, 700, 'right', 1100, K('raam')]]); + A.lighting.walls.achter = 'openingen'; + + const DH = 2115; + iw(9580, 190, 9580, 3220, [], 160); // keuken | toilet, hal + iw(8060, 3265, 9660, 3265); // woonkamer | hal + iw(8105, 3310, 8105, 4410, [['deur', 930, DH, 'left', 86, binnen({ hinge: 'rechts', swing: 'buiten' })]]); // hal → woonkamer + iw(9660, 1633, 10760, 1633, [['deur', 930, DH, 'left', 85, binnen({ hinge: 'links', swing: 'buiten' })]]); // toilet | hal + iw(10805, 190, 10805, 4410, [['deur', 930, DH, 'left', 1725, binnen({ hinge: 'rechts', swing: 'binnen' })]]); // hal | berging, badkamer + iw(10850, 2965, 13810, 2965); // berging | badkamer + iw(9590, 4455, 13810, 4455, [ // hal, badkamer | slaapkamer + ['deur', 930, DH, 'left', 115, binnen({ hinge: 'links', swing: 'buiten' })], + ['deur', 980, DH, 'left', 2350, Object.assign(binnen(), { type: 'schuifdeur', leaves: 1, mount: 'opbouw', side: 'buiten', dir: 'links' })]]); + + const plafond = { ceiling: H }; + room('A', 3000, 1500, 'woonkeuken', 'Woonkamer/keuken', plafond); + room('A', 10210, 900, 'toilet'); + room('A', 12300, 1500, 'berging', 'Berging', plafond); + room('A', 9000, 3800, 'hal', 'Hal', plafond); + room('A', 12300, 3700, 'badkamer'); + room('B', 2000, 1400, 'slaapkamer', 'Slaapkamer', plafond); + + const xB = (x) => x - 9400, zB = (z) => z - D; + fx('bank', 'A', 2030, 690, 0, { w: 3600, d: 1000 }, { chaise: 'links', chaiseLengte: 1700 }); + fx('salontafel', 'A', 2410, 1980, 0, { w: 800, d: 800 }, { vorm: 'rond' }); + fx('fauteuil', 'A', 3120, 2950, 150); + fx('tvmeubel', 'A', 1060, 4210, 180, { w: 1200 }); + fx('eettafel', 'A', 6100, 1115, 0, { w: 2000, d: 1000 }, { vorm: 'ovaal', stoelen: 6 }); + fx('keuken', 'A', 9200, 1705, 90, { w: 3030 }, { front: 'antraciet', hogeKast: 'rechts', spoelbak: 0.16, kookplaat: true, kookplaatPlek: 0.8 }); + fx('toilet', 'A', 10210, 535, 0, { w: 1100 }); + fx('boiler', 'A', 11040, 415, 0); + fx('douche', 'A', 11300, 3710, 0, { w: 900, d: 1400 }, { glas: 'rechts', glasLengte: 1400 }); + fx('wastafel', 'A', 12300, 3240, 0, { w: 800 }); + fx('toilet', 'A', 13355, 3355, 0, { w: 900 }); + fx('bed', 'B', xB(11400), zB(6360), 180, { w: 1600, d: 2100 }, { nachtkastjes: false }); + fx('kledingkast', 'B', xB(13510), zB(5955), 90, { w: 2910, anchor: 'oost' }); + + computeParts(st, { skip3D: true }); + return st; + } + // Standaardmodellen: Bestand › Snel nieuw en het venster Nieuw. De sleutel is de template. const MODELS = { amstel: { label: 'Woning Amstel', icon: 'house-heart', create: createAmstel }, - wetering: { label: 'Woning Wetering', icon: 'house-door', create: createWetering } + wetering: { label: 'Woning Wetering', icon: 'house-door', create: createWetering }, + ijssel: { label: 'Woning IJssel', icon: 'houses', create: createIJssel } }; function createDefaultState(template) { @@ -532,6 +607,7 @@ window.HouseModel = (() => { }); state.interiorWalls.forEach(iw => { iw.wall.wall.minSpacing = sp.interior.min; iw.wall.wall.maxSpacing = sp.interior.max; }); applyHouseRoof(state); + applyRoofJoins(state); } const houseRoofOn = (state) => !!(state.settings.roof.house && state.settings.roof.house.enabled && state.settings.roof.house.form); @@ -607,7 +683,6 @@ window.HouseModel = (() => { const shape = houseRoofShape(state, rects); if (!shape) return; const form = state.settings.roof.house.form, ln = shape.line; - if (form.type === 'mansarde' && ln.length === 5) Object.assign(form, { kneeIn: Math.round(ln[1].u), kneeH: Math.round(ln[1].h) }); if (form.type === 'wolf' && ln.length === 4) form.cut = Math.round(ln[1].h); const planes = RoofModel.shapeLines(shape); state.units.forEach((u, i) => { @@ -636,16 +711,134 @@ window.HouseModel = (() => { const a = pts[k], b = pts[k + 2]; return q.h > a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h) + 0.5; }); - const wall = u.unit.walls[wl.side].wall; - const hL = Math.round(pts[0].h), hR = Math.round(pts[pts.length - 1].h); - const mid = inner.length === 1 && Math.abs(inner[0].x - W / 2) < 0.5; - Object.assign(wall, { heightLeft: hL, heightRight: hR, heightAll: Math.min(hL, hR), heightMid: mid ? Math.round(inner[0].h) : null }); - if (inner.length && !mid) wall.topPts = inner.map(q => ({ f: q.x / W, h: Math.round(q.h) })); - else delete wall.topPts; + setWallTop(u.unit.walls[wl.side].wall, W, pts[0].h, pts[pts.length - 1].h, inner); }); }); } + function setWallTop(wall, W, h0, h1, inner) { + const hL = Math.round(h0), hR = Math.round(h1); + const mid = inner.length === 1 && Math.abs(inner[0].x - W / 2) < 0.5; + Object.assign(wall, { heightLeft: hL, heightRight: hR, heightAll: Math.min(hL, hR), heightMid: mid ? Math.round(inner[0].h) : null }); + if (inner.length && !mid) wall.topPts = inner.map(q => ({ f: q.x / W, h: Math.round(q.h) })); + else delete wall.topPts; + } + + // Dakaansluiting (L/T): een vleugel met nokdak (zadel, wolfsdak), nok haaks op de naad, tegen de + // langszijde van een hoofdvolume met nokdak loopt het hoofddak in tot zijn nok (kilkepers). Het wolfseind van de + // vleugel aan de naad vervalt. Ligt de vleugel gelijk met een kop van het hoofdvolume, dan wordt die kop een + // schild in de vlakken van de vleugel (hoekkeper, goot loopt door; een wolfseind daar vervalt); hooguit één kop, + // anders heeft het hoofdvolume geen kopgevel meer om zijn eigen dakvorm uit af te leiden. Een vleugel die in een + // wolfseind van het hoofdvolume zou steken (niet gelijk met de kop) sluit niet aan. + // Vlakken in wereldmaten; h = min(verre vlakken, wolfseinden, schildvlakken, max(min(nabije vlakken), vleugels)), + // zie RoofModel.computeJoinedRoof. + const JOIN_TYPES = ['zadel', 'wolf']; + function roofJoins(state, rects) { + const out = {}; + if (houseRoofOn(state)) return out; + const rs = rects || unitRects(state); + if (rs.some(r => r.error) || overlappingPairs(rs).length) return out; + const at = (pl, [x, z]) => pl.a + pl.bx * x + pl.bz * z; + const info = state.units.map((u, i) => { + const r = rs[i], shape = RoofModel.computeShape(u.unit, UnitModel.computeLayout(u.unit, {})); + const planes = []; + if (!JOIN_TYPES.includes(shape.type)) return { u, r, shape, planes }; + const sx = shape.axis === 'x'; + const s = shape.type === 'wolf' && shape.hip + ? Object.assign({}, shape, { hip: Object.assign({}, shape.hip, sx ? { q0: -r.D / 2, q1: r.D / 2 } : { q0: -r.W / 2, q1: r.W / 2 }) }) + : shape; + const corners = [[r.x0, r.z0], [r.x1, r.z0], [r.x0, r.z1], [r.x1, r.z1]]; + RoofModel.shapeLines(s).forEach(l => { + const [bx, bz] = sx ? [l.bp, l.bq] : [l.bq, l.bp]; + const pl = { a: l.a - bx * r.cx - bz * r.cz, bx, bz }; + // Wolfsdak: onder- en bovenstuk liggen (op afronding na) in één vlak. + if (!planes.some(q => corners.every(c => Math.abs(at(q, c) - at(pl, c)) < 2))) planes.push(pl); + }); + return { u, r, shape, planes }; + }); + const groups = new Map(), role = {}; + info.forEach(M => info.forEach(W => { + if (M === W || !M.planes.length || !W.planes.length || M.shape.axis === W.shape.axis) return; + if (role[M.u.id] === 'vleugel' || role[W.u.id]) return; + const seamX = M.shape.axis === 'z'; // naad evenwijdig aan de nok van M + const [m0, m1, w0, w1] = seamX ? [M.r.x0, M.r.x1, W.r.x0, W.r.x1] : [M.r.z0, M.r.z1, W.r.z0, W.r.z1]; + const [a0, a1, b0, b1] = seamX ? [M.r.z0, M.r.z1, W.r.z0, W.r.z1] : [M.r.x0, M.r.x1, W.r.x0, W.r.x1]; + const seam = Math.abs(a1 - b0) < EPS ? a1 : Math.abs(b1 - a0) < EPS ? a0 : null; + if (seam === null || w0 < m0 - EPS || w1 > m1 + EPS || (w0 < m0 + EPS && w1 > m1 - EPS)) return; + const xz = (t) => seamX ? [t, seam] : [seam, t]; + const dir = seam === a1 ? 1 : -1; // vleugel aan de +kant (dwars op de naad) van M + const across = (pl) => seamX ? pl.bz : pl.bx, along = (pl) => seamX ? pl.bx : pl.bz; + const g = groups.get(M) || { M, seamX, wings: [], flush: null, drop: [] }; + const end = Math.abs(w1 - m1) < EPS ? 1 : Math.abs(w0 - m0) < EPS ? 0 : -1; + const toEnd = (pl) => end ? along(pl) < 0 : along(pl) > 0; // daalt naar die kop + const flush = end >= 0 && !g.flush ? { + planes: W.planes.filter(pl => Math.abs(along(pl)) > 1e-9 && toEnd(pl)), + drop: M.planes.filter(pl => Math.abs(across(pl)) <= 1e-9 && toEnd(pl)), + side: seamX ? (end ? 'rechts' : 'links') : (end ? 'achter' : 'voor') + } : null; + const wingTop = Math.max(...W.shape.profile.map(q => q.h)); + const hips = M.planes.filter(pl => Math.abs(across(pl)) <= 1e-9 && !g.drop.includes(pl) && !(flush && flush.drop.includes(pl))); + if (hips.some(pl => Math.min(at(pl, xz(w0)), at(pl, xz(w1))) < wingTop - 1)) return; + if (flush) { g.flush = flush; g.drop.push(...flush.drop); } + g.wings.push({ W, sign: -dir, roof: W.planes.filter(pl => Math.abs(along(pl)) > 1e-9 || across(pl) * dir < 0) }); + groups.set(M, g); + role[M.u.id] = 'hoofd'; + role[W.u.id] = 'vleugel'; + })); + groups.forEach(g => { + const planes = [], idx = (pl) => planes.includes(pl) ? planes.indexOf(pl) : planes.push(pl) - 1; + const cross = (pl) => g.seamX ? pl.bz : pl.bx, terms = []; + [-1, 1].forEach(sg => { + const side = g.M.planes.filter(pl => Math.abs(cross(pl)) > 1e-9 && Math.sign(cross(pl)) === sg); + const ws = g.wings.filter(w => w.sign === sg); + if (!ws.length) side.forEach(pl => terms.push(idx(pl))); + else terms.push(['max', ['min', ...side.map(idx)], ...ws.map(w => ['min', ...w.roof.map(idx)])]); + }); + g.M.planes.filter(pl => Math.abs(cross(pl)) <= 1e-9 && !g.drop.includes(pl)).forEach(pl => terms.push(idx(pl))); + if (g.flush) g.flush.planes.forEach(pl => terms.push(idx(pl))); + const expr = ['min', ...terms]; + [g.M, ...g.wings.map(w => w.W)].forEach(X => { out[X.u.id] = { planes, expr, flush: X === g.M && g.flush ? g.flush.side : null }; }); + }); + return out; + } + + // De kop die een schild wordt volgt het dak; vervalt de aansluiting, dan krijgt de unit zijn eigen dakvorm terug. + function applyRoofJoins(state) { + const rects = unitRects(state); + const joins = roofJoins(state, rects); + state.units.forEach((u, i) => { + const join = joins[u.id], side = join && join.flush; + if (Object.entries(u.unit.walls).some(([k, ws]) => ws.wall.joinTop && k !== side)) { + Object.values(u.unit.walls).forEach(ws => { delete ws.wall.joinTop; }); + UnitModel.applyRoofForm(u.unit, UnitModel.roofForm(u.unit)); + } + if (!side) return; + const r = rects[i]; + const wl = UnitModel.computeLayout(u.unit, {}).walls.find(w => w.side === side); + const s = UnitModel.WALL_SIDES.find(x => x.key === side), alongX = s.axis === 'x'; + const start = (alongX ? wl.cx + r.cx : wl.cz + r.cz) - s.dir * wl.width / 2; + const perp = alongX ? wl.cz + r.cz : wl.cx + r.cx, out = alongX ? wl.normal.y : wl.normal.x; + const faces = [perp + out * wl.thickness / 2, perp - out * wl.thickness / 2]; + const pos = (x, f) => alongX ? { x: start + s.dir * x, z: f } : { x: f, z: start + s.dir * x }; + const W = wl.width, xs = [0, W]; + // Knikken alleen waar twee vlakken langs een wandvlak even hoog zijn. + faces.forEach(f => join.planes.forEach((p1, j) => join.planes.slice(j + 1).forEach(p2 => { + const h = (pl, x) => { const q = pos(x, f); return pl.a + pl.bx * q.x + pl.bz * q.z; }; + const d0 = h(p1, 0) - h(p2, 0), d1 = h(p1, W) - h(p2, W); + if (Math.abs(d1 - d0) > 1e-9) { const x = -d0 / (d1 - d0) * W; if (x > 1 && x < W - 1) xs.push(x); } + }))); + const hAt = (x) => Math.min(...faces.map(f => { const q = pos(x, f); return RoofModel.joinValue(join, q); })); + const pts = [...new Set(xs.map(x => Math.round(x * 10) / 10))].sort((a, b) => a - b).map(x => ({ x, h: hAt(x) })); + const inner = pts.slice(1, -1).filter((q, k) => { + const a = pts[k], b = pts[k + 2]; + return Math.abs(q.h - (a.h + (q.x - a.x) / (b.x - a.x) * (b.h - a.h))) > 0.5; + }); + const wall = u.unit.walls[side].wall; + setWallTop(wall, W, pts[0].h, pts[pts.length - 1].h, inner); + wall.joinTop = true; + }); + } + function setHouseRoof(state, on) { const hr = state.settings.roof.house; if (!!on === houseRoofOn(state)) return; @@ -718,11 +911,12 @@ window.HouseModel = (() => { const { cuts, seams } = computeSeams(rects, wallThickness); const houseShape = houseRoofShape(state, rects); const coverExt = coverExtensions(state, rects); + const joins = roofJoins(state, rects); - function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc) { + function buildWall(config, frame, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop) { if (!build) return { group: null, stats: null, specs: null }; const built = WallModel.build3D(config, frame, { - materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, + materials: o.wallMaterials, buitenMateriaal, cladding, trasraam, skinExt, facade, skinFrom, spaanplaatBelow, stone, skinAbove, trimTop, battenAbove, stuc, skinTop, painted: state.settings.interiorPaint && shows('afwerking'), stage, rabatColor: state.settings.facade.rabatColor }); return { group: built.group, stats: built.stats, specs: WallModel.computeSpecs(config, built.stats, buitenMateriaal) }; @@ -776,9 +970,14 @@ window.HouseModel = (() => { const deckFrame = FloorModel.computeFrame(u.roof.deck.floor); const roofW = deckFrame.width, roofD = deckFrame.depth; const ox = u.roof.offsetX || 0, oz = u.roof.offsetZ || 0; - const ur = RoofModel.computeUnitRoof(u.roof, shape, { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }, rims); + const roofRect = { x0: ox - roofW / 2, x1: ox + roofW / 2, z0: oz - roofD / 2, z1: oz + roofD / 2 }; + const join = joins[u.id] && { + planes: joins[u.id].planes.map(pl => ({ a: pl.a + pl.bx * rect.cx + pl.bz * rect.cz, bx: pl.bx, bz: pl.bz })), + expr: joins[u.id].expr + }; + const ur = join ? RoofModel.computeJoinedRoof(u.roof, join, roofRect, shape) : RoofModel.computeUnitRoof(u.roof, shape, roofRect, rims); const roofCx = rect.cx + ox, roofCz = rect.cz + oz; - const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', mansarde: 'mansarde', wolf: 'wolfsdak' }[shape.type]; + const shapeLabel = { plat: 'plat', lessenaar: 'lessenaar', zadel: 'zadel', wolf: 'wolfsdak' }[shape.type]; const multi = ur.planes.length > 1; ur.planes.forEach((pl, i) => { pl.element = `${u.id}-D${i + 1}`; @@ -1003,6 +1202,24 @@ window.HouseModel = (() => { }); } + // Buitenlagen tot de bovenkant van het dak, per punt: een steil vlak (wolfseind) is verticaal dikker. + function skinTopLine(p, roof) { + const cfg = p.config, W = cfg.wallWidth, tl = WallModel.topLine(cfg), zF = p.place.thickness / 2; + const xs = new Set([0, W].concat((cfg.wallTop || []).map(q => q.x))); + for (let x = 100; x < W; x += 100) xs.add(x); + const pts = [...xs].sort((a, b) => a - b).map(x => { + const top = tl.at(x - W / 2), w = UnitModel.localToWorld(p.place, x - W / 2, zF), wallTop = p.place.floorTop + top; + return { x, h: top + Math.max(0, roof.topAt(w.x, w.z) - Math.min(wallTop, roof.ceilingAt(w.x, w.z))) }; + }); + const L = pts[0], R = pts[pts.length - 1]; + const inner = WallModel.wallTopPoints({ topPts: pts.slice(1, -1).map(q => ({ f: q.x / W, h: q.h })) }, W, Math.round(L.h), Math.round(R.h)); + const line = [L, ...inner, R]; + const at = (x) => { let i = 0; while (i < line.length - 2 && x > line[i + 1].x) i++; const a = line[i], b = line[i + 1]; return a.h + (x - a.x) / (b.x - a.x) * (b.h - a.h); }; + // Alleen een bolle daklijn: een kil of sprong (binnenhoek L-vorm) zou de lijn erboven trekken; dan vast skinAbove. + if (pts.some(q => at(q.x) > q.h + 5)) return null; + return { hL: Math.round(L.h), hR: Math.round(R.h), pts: inner }; + } + function topWedge(p, roof) { const cfg = p.config, half = cfg.wallWidth / 2, zF = cfg.studDepth / 2, tl = WallModel.topLine(cfg); const xs = new Set([-half, half].concat(tl.kinks)); @@ -1041,6 +1258,7 @@ window.HouseModel = (() => { p.skinAbove = covered !== null ? Math.max(0, Math.round(covered - 3 - wallTop)) : Math.max(0, Math.round(u.roof.topAt(m.x, m.z) - Math.min(wallTop, u.roof.ceilingAt(m.x, m.z)))); + p.skinTop = covered !== null ? null : skinTopLine(p, u.roof); p.underRoof = covered !== null; p.config.topWedge = topWedge(p, u.roof); // Binnenhoek-doorloop: tot de bovenkant van het dak van de buur geen buitenlagen (trimTop). @@ -1097,7 +1315,7 @@ window.HouseModel = (() => { assignFacades(extPieces.concat(interior.filter(p => p.skinFrom !== undefined))); const rooms = RoomModel.compute(state.rooms || [], units, extPieces.concat(interior)); extPieces.concat(interior).forEach(p => { - const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc); + const built = buildWall(p.config, p.frame, p.buitenMateriaal, p.cladding, p.trasraam, p.skinExt, p.facade, p.skinFrom, p.spaanplaatBelow, p.stone, p.skinAbove, p.trimTop, p.battenAbove, p.stuc, p.skinTop); if (built.group && p.shiftZ) { const outer = new THREE.Group(); built.group.position.z = p.shiftZ * 0.001; @@ -1437,6 +1655,7 @@ window.HouseModel = (() => { state.settings.elektra = ElektraModel.normalizeSettings(state.settings.elektra); state.units.forEach(u => { if (!u.unit.layout) u.unit.layout = { corners: 'voor-achter' }; + UnitModel.dropMansarde(u.unit); if (!u.roof) { u.roof = RoofModel.createDefaultRoof(u.unit.floor); delete u.roof.fascia; } if (u.roof.fascia === undefined) u.roof.fascia = oldRoof.edge !== 'daktrim'; if (!RoofModel.COVERINGS[u.roof.covering]) u.roof.covering = 'dakleer'; diff --git a/js/house/house-pdf.js b/js/house/house-pdf.js index 467aff9..a8030c3 100644 --- a/js/house/house-pdf.js +++ b/js/house/house-pdf.js @@ -32,7 +32,8 @@ window.HousePdf = (() => { list.push({ kind: 'floor', element: u.floorElement, label: `Unit ${u.id} vloer`, unit: u }); u.walls.forEach(w => w.pieces.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p }))); u.roof.planes.forEach(pl => list.push({ kind: 'roof', element: pl.element, label: pl.label, plane: pl })); - if (u.roof.unitRoof && u.roof.unitRoof.cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u }); + const cap = u.roof.unitRoof && u.roof.unitRoof.cap; + if (cap) list.push({ kind: 'cap', element: `${u.id}-D${u.roof.planes.length + 1}`, label: cap.kind === 'join' ? `Unit ${u.id} dak (aansluiting)` : `Unit ${u.id} dak bovenste deel (wolfsdak)`, unit: u }); }); parts.interior.forEach(p => list.push({ kind: 'wall', element: p.element, label: p.label, piece: p })); list.push({ kind: 'totals', element: 'TOT', label: 'Totalen' }); @@ -235,10 +236,10 @@ window.HousePdf = (() => { const W = p.place.width, T = p.place.thickness; const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), e = p.skinExt || {}; const xl = -W / 2 - (e.left > 0 ? e.left + c : 0), xr = W / 2 + (e.right > 0 ? e.right + c : 0); - const tl = WallModel.topLine(p.config), up = p.skinAbove || 0; - const top = (x) => tl.at(Math.max(-W / 2, Math.min(W / 2, x))) + up; + const tl = WallModel.topLine(WallModel.skinConfig(p.config, p.skinAbove || 0, p.skinTop)); + const top = (x) => Math.min(tl.at(x), tl.at(Math.max(-W / 2, Math.min(W / 2, x)))); const loc = [{ x: xl, y: y0 }, { x: xr, y: y0 }, { x: xr, y: top(xr) }] - .concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y + up }))) + .concat(tl.pts.slice().reverse().filter(q => q.x > xl + 1 && q.x < xr - 1).map(q => ({ x: q.x, y: q.y }))) .concat([{ x: xl, y: top(xl) }]); return loc; }; @@ -282,7 +283,7 @@ window.HousePdf = (() => { }; RoofModel.capRegions(r.unitRoof).forEach(rg => { pushRoof(rg.poly.map(pt => { - const x = u.rect.cx + (sh.axis === 'x' ? pt.x : pt.z), z = u.rect.cz + (sh.axis === 'x' ? pt.z : pt.x); + const ax = r.unitRoof.alongX, x = u.rect.cx + (ax ? pt.x : pt.z), z = u.rect.cz + (ax ? pt.z : pt.x); return { x, y: u.layout.floorTop + rg.under(pt) + rg.vThick, z }; })); }); @@ -457,7 +458,7 @@ window.HousePdf = (() => { PdfSheet.titleBlock(doc, sheetOpts.project, sheetOpts); } - const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', nokbalk: 'Nokbalk', kopbalk: 'Kopbalk' }; + const CAP_KINDS = { spoor: 'Spoor', hoekkeper: 'Hoekkeper', kilkeper: 'Kilkeper', nokbalk: 'Nokbalk', knikbalk: 'Knikbalk', kopbalk: 'Kopbalk' }; function drawCapSheet(doc, u, sheetOpts) { const { RED, BLUE, BLACK, GREY } = PdfSheet.COLORS; const ur = u.roof.unitRoof, c = ur.cap; @@ -474,7 +475,8 @@ window.HousePdf = (() => { }; doc.setTextColor(...BLACK); doc.setFontSize(11); - doc.text('Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20); + const join = c.kind === 'join'; + doc.text(join ? 'Bovenaanzicht dak (aansluiting: kil- en hoekkepers, kreupele sporen)' : 'Bovenaanzicht bovenste deel (wolfseinden en nok)', X0, 20); doc.setDrawColor(...GREY); doc.setLineWidth(0.15); c.regions.forEach(rg => poly(rg.poly.map(xz), 'S')); @@ -505,17 +507,22 @@ window.HousePdf = (() => { const H = (p) => RoofModel.profileHeight(ur.shape.profile, p); const deg = (t) => String(Math.round(Math.atan(t) * 1800 / Math.PI) / 10).replace('.', ','); const hip = c.regions.find(rg => rg.key === 'h1'); - const facts = [ + const facts = (join ? [ + ['Goot (onderkant dak)', `${Math.round(c.eave)} mm`], + ['Nok (onderkant dak)', `${Math.round(c.ridgeH)} mm`], + ['Helling dakvlakken', `${deg(c.slope)}°`] + ] : [ ['Wolfseind (onderkant dak)', `${Math.round(c.hipH)} mm`], ['Nok (onderkant dak)', `${Math.round(H(c.pR))} mm`], ['Helling dakvlakken', `${deg(c.slope)}°`], - ['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'], + ['Helling wolfseinden', hip ? `${deg(Math.tan(Math.acos(hip.cos)))}°` : '-'] + ]).concat([ ['Balken', `${c.joistW} x ${c.joistH} mm`], ['Oppervlak (schuin)', `${fmt(c.specs.osbAreaM2, 2)} m²`], ['Hout', `${fmt(c.specs.joistWoodM3, 3)} m³`] - ]; + ]); doc.setTextColor(...BLACK); - let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 }); + let y = PdfSheet.table(doc, 290, 20, [0, 52], ['Gegeven', 'Waarde'], facts, { title: join ? 'Dak met aansluiting' : 'Wolfsdak, bovenste deel', fontSize: 7, rowH: 4.6, lastColW: 40 }); PdfSheet.table(doc, 290, y + 6, [0, 32, 56, 78], ['Soort', 'Lengte (mm)', 'Hoek (°)', 'Aantal'], rows, { title: 'Stuklijst balken', fontSize: 7, rowH: 4.6, lastColW: 14, maxY: 240 }); doc.setFontSize(7); doc.setTextColor(...GREY); diff --git a/js/house/house.js b/js/house/house.js index c03e731..fa9aab3 100644 --- a/js/house/house.js +++ b/js/house/house.js @@ -1228,7 +1228,7 @@ function mountHouseEditor(container, callbacks) { toggle('hs-roof-house', 'Over hele woning', hr.enabled, { hint: 'Eén dakvorm over alle units (bijv. een grote wolfskap); uit = dak per unit. De daken per unit blijven bewaard.' }) + (houseRoofUi ? sel('hs-roof-cover', 'Bedekking', covOpts, RoofModel.coveringFor(state.units[0].roof.covering, shapeType), { hint: 'Voor alle units (per unit aan te passen)' }) : '') + - toggle('hs-steel-ridge', 'Stalen nokbalk', state.units.some(u => u.roof.steelRidge !== false), { hint: 'IPE onder de nok van zadel-, mansarde- en wolfsdaken (alle units); uit = verwijderen' }) } + toggle('hs-steel-ridge', 'Stalen nokbalk', state.units.some(u => u.roof.steelRidge !== false), { hint: 'IPE onder de nok van zadel- en wolfsdaken (alle units); uit = verwijderen' }) } ]; if (houseRoofUi) groups.push(...houseRoofUi.groups()); else groups.push({ label: 'Dakvorm', html: note('Vorm, hoogtes, helling en bedekking zijn per unit: selecteer een unit.') }); @@ -1256,7 +1256,8 @@ function mountHouseEditor(container, callbacks) { const el = body.querySelector('[data-live="roof-list"]'); if (!el || !parts) return; el.innerHTML = parts.units.map(u => { - const pl = u.roof.planes[0], deg = Math.abs(pl ? pl.theta : 0) * 180 / Math.PI; + const pl = u.roof.planes[0], cap = u.roof.unitRoof.cap; + const deg = (pl ? Math.abs(pl.theta) : cap ? Math.atan(cap.slope) : 0) * 180 / Math.PI; const cov = (RoofModel.COVERINGS[u.roof.covering] || {}).label || ''; return kv(`Unit ${u.id}`, `${(UnitModel.ROOF_TYPES[u.roof.shape.type] || u.roof.shape.type).toLowerCase()}${deg > 0.05 ? ' ' + fmtDeg(deg) + '°' : ''} · ${cov.toLowerCase()}`); }).join(''); @@ -1754,10 +1755,6 @@ function mountHouseEditor(container, callbacks) { const LIM = UnitModel.LIMITS; const runOf = () => UnitModel.hasRidge(form.type) ? o.span() / 2 : o.span(); const degOf = () => sloped() ? Math.atan(Math.max(0, form.high - form.low) / runOf()) * 180 / Math.PI : 0; - const mansardeDeg = () => { - const kIn = Math.max(1, form.kneeIn || 1), rest = Math.max(1, runOf() - kIn); - return [Math.atan(Math.max(0, form.kneeH - form.low) / kIn), Math.atan(Math.max(0, form.high - form.kneeH) / rest)].map(r => r * 180 / Math.PI); - }; const f = (k) => `${id}-roof-${k}`; const groups = () => { const g = [ @@ -1768,12 +1765,8 @@ function mountHouseEditor(container, callbacks) { ]; if (sloped()) g.push({ label: 'Helling', html: UI.range(f('high'), UnitModel.hasRidge(form.type) ? 'Nok' : 'Hoog', form.high, { min: 2100, max: LIM.ridge, step: 10, hint: `Max ${LIM.ridge} mm` }) + - (form.type === 'mansarde' ? '' : UI.range(f('deg'), 'Helling', Math.round(degOf() * 10) / 10, { min: 1, max: 60, step: 0.5, unit: '°', hint: 'Zet de hoge zijde/nok bij de huidige goothoogte' })) + + UI.range(f('deg'), 'Helling', Math.round(degOf() * 10) / 10, { min: 1, max: 60, step: 0.5, unit: '°', hint: 'Zet de hoge zijde/nok bij de huidige goothoogte' }) + (o.gable ? `
${kv('Kopgevel', `wand ${SIDE_SHORT[UnitModel.gableOf(form)].toLowerCase()} + overstaande`)}
` : '') }); - if (form.type === 'mansarde') g.push({ label: 'Mansarde', html: - UI.range(f('knee-h'), 'Knik', form.kneeH, { min: 2100, max: LIM.ridge, step: 10, hint: 'Hoogte van de knik tussen het steile en het flauwe dakvlak' }) + - UI.range(f('knee-in'), 'Knik in', form.kneeIn, { min: 100, max: 3000, step: 10, hint: 'Horizontale afstand van de gevel tot de knik' }) + - `
${kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / '))}
` }); if (form.type === 'wolf') g.push({ label: 'Wolfsdak', html: UI.range(f('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' }) }); return g; @@ -1789,13 +1782,6 @@ function mountHouseEditor(container, callbacks) { if (v !== 'plat' && !(form.high > form.low)) form.high = form.low + 700; if (v === 'lessenaar' && !form.dir) form.dir = 'voor'; if (UnitModel.hasRidge(v) && !form.ridge) form.ridge = 'z'; - if (v === 'mansarde') { - const run = runOf(); - form.kneeIn = Math.round(run * 0.35); - form.kneeH = Math.round(form.low + form.kneeIn * 2.1); - form.high = Math.min(LIM.ridge, Math.max(form.high, Math.round(form.kneeH + (run - form.kneeIn) * 0.5))); - form.kneeRatio = Math.min(0.9, (form.kneeH - form.low) / (form.high - form.low)); - } if (v === 'wolf') { if (form.high - form.low < 1000) form.high = form.low + 1500; form.cutRatio = 0.6; @@ -1806,13 +1792,9 @@ function mountHouseEditor(container, callbacks) { UI.onSel(f('ridge'), v => { form.ridge = v; apply(); }, rerender); const syncDeg = () => { const now = o.readBack(); - if (now && now.type === form.type) ['kneeH', 'kneeIn', 'cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; }); + if (now && now.type === form.type) ['cut'].forEach(k => { if (now[k] !== undefined) form[k] = now[k]; }); UI.setRange(f('deg'), Math.round(degOf() * 10) / 10); - UI.setRange(f('knee-h'), form.kneeH); - UI.setRange(f('knee-in'), form.kneeIn); UI.setRange(f('cut'), form.cut); - const md = body.querySelector('[data-live="roof-mansarde"]'); - if (md && form.type === 'mansarde') md.innerHTML = kv('Hellingen', mansardeDeg().map(d => fmtDeg(d) + '°').join(' / ')); }; UI.onRange(f('low'), v => { if (!(v >= 500)) return; @@ -1832,8 +1814,6 @@ function mountHouseEditor(container, callbacks) { apply(); syncDeg(); }, after); const ratioOf = (v) => Math.max(0.05, Math.min(0.95, (v - form.low) / Math.max(1, form.high - form.low))); - UI.onRange(f('knee-h'), v => { if (v >= 500) { form.kneeRatio = ratioOf(v); apply(); syncDeg(); } }, after); - UI.onRange(f('knee-in'), v => { if (v >= 50) { form.kneeIn = v; apply(); syncDeg(); } }, after); UI.onRange(f('cut'), v => { if (v >= 500) { form.cutRatio = ratioOf(v); apply(); syncDeg(); } }, after); }; return { groups, wire }; @@ -1877,7 +1857,7 @@ function mountHouseEditor(container, callbacks) { 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' }) + - (pu && pu.roof.unitRoof.ridgeBeam || u.roof.steelRidge === false && ['zadel', 'mansarde', 'wolf'].includes(shapeNow) + (pu && pu.roof.unitRoof.ridgeBeam || u.roof.steelRidge === false && ['zadel', 'wolf'].includes(shapeNow) ? toggle('hp-steel-ridge', 'Stalen nokbalk', u.roof.steelRidge !== false, { hint: pu && pu.roof.unitRoof.ridgeBeam ? `${pu.roof.unitRoof.ridgeBeam.profile.name}, ${Math.round(pu.roof.unitRoof.ridgeBeam.steelKg)} kg; uit = verwijderen` : 'Uit = verwijderd' }) : '') + toggle('hp-roof-follow', 'Maat volgt vloer', u.roof.followFloor) }); if (!u.roof.followFloor) groups.push({ label: 'Dakmaat', html: diff --git a/js/lighting/lighting-model.js b/js/lighting/lighting-model.js index 0ceb4d7..cf7c72a 100644 --- a/js/lighting/lighting-model.js +++ b/js/lighting/lighting-model.js @@ -10,7 +10,7 @@ window.LightingModel = (() => { mode: 'spots', direction: 'both', lampHeight: 1950, - lampMin: 1500, + lampMin: 500, lampMax: 3000, minWallWidth: 2000, endOffset: 300, diff --git a/js/roof/roof-model.js b/js/roof/roof-model.js index 2ed03ff..97163f3 100644 --- a/js/roof/roof-model.js +++ b/js/roof/roof-model.js @@ -212,7 +212,7 @@ window.RoofModel = (() => { return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } }; } } - const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel'; + const type = profile.length === 2 ? 'lessenaar' : 'zadel'; return { type, axis, profile }; } @@ -267,8 +267,9 @@ window.RoofModel = (() => { finishKNm2: 0.3, // afwerking/installaties onder het dak gammaG: 1.2, gammaQ: 1.5, deflection: 250 }; - const ridgeTypes = ['zadel', 'mansarde', 'wolf']; - function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2) { + const ridgeTypes = ['zadel', 'wolf']; + // span: [q0, q1] waar de nok echt loopt (dakaansluiting: tot de hoekkeper). + function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2, span) { if (roofState.steelRidge === false || !ridgeTypes.includes(shape.type)) return null; const pr = shape.profile, inner = pr.slice(1, -1); if (!inner.length) return null; @@ -283,6 +284,7 @@ window.RoofModel = (() => { q0 = Math.max(uq0, Math.min(g.qM, g.q0 + g.inset)); q1 = Math.min(uq1, Math.max(g.qM, g.q1 - g.inset)); } + if (span) { q0 = Math.max(q0, span[0]); q1 = Math.min(q1, span[1]); } const L = q1 - q0; if (L < 100) return null; // q in N/mm = kN/m. @@ -393,7 +395,7 @@ window.RoofModel = (() => { }); }); - // Mansardeknik: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik, + // Knik in de daklijn: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik, // bovenkant op het snijpunt van de bovenvlakken. planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; }); planes.slice(0, -1).forEach((pi, i) => { @@ -418,11 +420,7 @@ window.RoofModel = (() => { } const specs = sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : [])); const ridgeBeam = computeRidgeBeam(roofState, shape, rect, alongX, flat.calcData, specs.totalKg / Math.max(0.1, W * D / 1e6)); - Object.assign(specs, { - nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0, - nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0 - }); - specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg; + addRidgeSpecs(specs, ridgeBeam); return { shape, covering, planes: outPlanes, specs, ridgeBeam, undersideAt, topAt, cap, rect, alongX, @@ -431,6 +429,185 @@ window.RoofModel = (() => { }; } + function addRidgeSpecs(specs, ridgeBeam) { + Object.assign(specs, { + nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0, + nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0 + }); + specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg; + } + + // Dakaansluiting (HouseModel.roofJoins): vlakken h = a + bx·x + bz·z, samengevoegd volgens expr + // (vlaknummer of ['min' | 'max', ...]). extra(k): ophoging per vlak (bovenkant dak). + const planeAt = (pl, p) => pl.a + pl.bx * p.x + pl.bz * p.z; + function joinValue(join, p, extra) { + const at = (e) => Array.isArray(e) ? Math[e[0]](...e.slice(1).map(at)) : planeAt(join.planes[e], p) + (extra ? extra(e) : 0); + return at(join.expr); + } + function convexHull(pts) { + const sorted = pts.slice().sort((a, b) => a.x - b.x || a.z - b.z); + const cross = (o, a, b) => (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x); + const half = (list) => list.reduce((h, q) => { + while (h.length >= 2 && cross(h[h.length - 2], h[h.length - 1], q) <= 0.01 * Math.hypot(q.x - h[h.length - 2].x, q.z - h[h.length - 2].z)) h.pop(); + h.push(q); + return h; + }, []); + const lo = half(sorted), up = half(sorted.slice().reverse()); + const out = lo.slice(0, -1).concat(up.slice(0, -1)); + return out.filter((p, k) => { const q = out[(k + 1) % out.length]; return Math.hypot(q.x - p.x, q.z - p.z) > 0.5; }); + } + + // Dak van één unit in een dakaansluiting (join in unit-lokale maten): per vlak de convexe stukken binnen het dek, + // sporen haaks op de goot (kreupel tegen kil- en hoekkepers), kepers en nokken op de grenzen tussen de vlakken. + // Alles als houtwerk zoals het bovenste deel van een wolfsdak (cap); de eigen nok wordt de stalen nokbalk. + function computeJoinedRoof(roofState, join, rect, shape) { + const fp = roofState.deck.floor; + const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22; + const covering = coveringFor(roofState.covering, shape.type); + const cov = COVERINGS[covering] || COVERINGS.dakleer; + const pls = join.planes.map((pl, k) => { + const slope = Math.hypot(pl.bx, pl.bz), cos = 1 / Math.sqrt(1 + slope * slope); + const tV = underlay / cos, jV = joistH / cos, bV = BESCHOT_MM / cos, covV = cov.thickMM / cos; + const under = (p) => planeAt(pl, p); + return { key: k, pl, slope, cos, tV, jV, bV, covV, vThick: tV + jV + bV + covV, under, f: under, along: Math.abs(pl.bx) >= Math.abs(pl.bz) ? 'p' : 'q' }; + }); + const activeAt = (p) => { + const h = joinValue(join, p); + return pls.reduce((a, b) => Math.abs(b.under(p) - h) < Math.abs(a.under(p) - h) ? b : a); + }; + + const box = [{ x: rect.x0, z: rect.z0 }, { x: rect.x1, z: rect.z0 }, { x: rect.x1, z: rect.z1 }, { x: rect.x0, z: rect.z1 }]; + const clipBox = (poly) => [(p) => p.x - rect.x0, (p) => rect.x1 - p.x, (p) => p.z - rect.z0, (p) => rect.z1 - p.z] + .reduce((pl, f) => pl.length >= 3 ? clipPoly(pl, f) : pl, poly); + const pairs = []; + pls.forEach((A, i) => pls.slice(i + 1).forEach(B => { + if (Math.hypot(A.pl.bx - B.pl.bx, A.pl.bz - B.pl.bz) > 1e-9) pairs.push({ A, B, f: (p) => A.under(p) - B.under(p) }); + })); + // Afgeronde wandhoogtes maken bijna samenvallende lijnen: splinters (gemiddeld < 0,5 mm breed) vallen weg. + const perim = (poly) => poly.reduce((t, p, k) => { const q = poly[(k + 1) % poly.length]; return t + Math.hypot(q.x - p.x, q.z - p.z); }, 0); + const sliver = (poly) => quadArea(poly) < Math.max(100, 0.25 * perim(poly)); + let cells = [box]; + pairs.forEach(({ f }) => { + cells = cells.flatMap(c => [clipPoly(c, f), clipPoly(c, p => -f(p))]).filter(c => c.length >= 3 && !sliver(c)); + }); + const centroid = (poly) => ({ x: poly.reduce((t, p) => t + p.x, 0) / poly.length, z: poly.reduce((t, p) => t + p.z, 0) / poly.length }); + const regions = []; + pls.forEach(pl => { + const list = cells.filter(c => activeAt(centroid(c)) === pl).map(convexHull); + // Aangrenzende cellen samenvoegen zolang het geheel convex blijft. + let merged = true; + while (merged) { + merged = false; + for (let i = 0; i < list.length && !merged; i++) { + for (let j = i + 1; j < list.length && !merged; j++) { + const h = convexHull(list[i].concat(list[j])); + if (quadArea(h) - quadArea(list[i]) - quadArea(list[j]) < 0.5 * perim(h)) { list[i] = h; list.splice(j, 1); merged = true; } + } + } + } + list.filter(poly => poly.length >= 3).forEach(poly => regions.push(Object.assign({}, pl, { poly }))); + }); + + // Grenzen tussen twee vlakken: bol = nok (waterpas) of hoekkeper, hol = kilkeper. + const members = []; + pairs.forEach(({ A, B, f }) => { + const gx = A.pl.bx - B.pl.bx, gz = A.pl.bz - B.pl.bz, g = Math.hypot(gx, gz), c0 = f({ x: 0, z: 0 }); + const o = { x: -c0 * gx / (g * g), z: -c0 * gz / (g * g) }, d = { x: -gz / g, z: gx / g }, n = { x: gx / g, z: gz / g }; + let t0 = -Infinity, t1 = Infinity; + [['x', rect.x0, rect.x1], ['z', rect.z0, rect.z1]].forEach(([k, lo, hi]) => { + if (Math.abs(d[k]) < 1e-9) { if (o[k] < lo || o[k] > hi) t1 = -Infinity; return; } + const a = (lo - o[k]) / d[k], b = (hi - o[k]) / d[k]; + t0 = Math.max(t0, Math.min(a, b)); + t1 = Math.min(t1, Math.max(a, b)); + }); + if (!(t1 - t0 >= 100)) return; + const pt = (t) => ({ x: o.x + d.x * t, z: o.z + d.z * t }); + const ts = [t0, t1]; + pairs.forEach(q => { + const dn = q.f(pt(1)) - q.f(pt(0)); + if (Math.abs(dn) < 1e-9) return; + const t = -q.f(pt(0)) / dn; + if (t > t0 && t < t1) ts.push(t); + }); + ts.sort((a, b) => a - b); + const segs = []; + let cur = null; + ts.slice(0, -1).forEach((ta, k) => { + const tb = ts[k + 1]; + if (tb - ta < 1) return; + const m = pt((ta + tb) / 2), off = (s) => ({ x: m.x + n.x * s, z: m.z + n.z * s }); + const s1 = activeAt(off(2)), s2 = activeAt(off(-2)); + if (Math.abs(joinValue(join, m) - A.under(m)) > 0.5 || !((s1 === A && s2 === B) || (s1 === B && s2 === A))) { cur = null; return; } + const onA = off(s1 === A ? 2 : -2), convex = B.under(onA) > A.under(onA); + if (cur && cur.convex === convex) { cur.tb = tb; return; } + cur = { ta, tb, convex }; + segs.push(cur); + }); + // Waterpas en bol: nok als de vlakken van elkaar af aflopen, anders een knik. + const ridge = A.pl.bx * B.pl.bx + A.pl.bz * B.pl.bz < 0; + segs.forEach(sg => { + const P = pt(sg.ta), Q = pt(sg.tb), level = Math.abs(A.under(P) - A.under(Q)) < 1; + members.push({ kind: !sg.convex ? 'kilkeper' : !level ? 'hoekkeper' : ridge ? 'nokbalk' : 'knikbalk', A: P, B: Q, rg: A }); + }); + }); + + const alongX = shape.axis === 'x'; + const own = members.find(m => m.kind === 'nokbalk' && (alongX ? Math.abs(m.A.x - m.B.x) < 1 : Math.abs(m.A.z - m.B.z) < 1)); + const steel = own && roofState.steelRidge !== false; + const { hw, beams, awayFrom, addBeam } = beamTools(joistW); + members.forEach(m => { if (!(steel && m === own)) addBeam(m.kind, m.rg, m.A, m.B, [], true); }); + pls.forEach(pl => { + const polys = regions.filter(rg => rg.key === pl.key).map(rg => rg.poly); + if (!polys.length) return; + const fixed = pl.along === 'p' ? 'z' : 'x'; + const [lo, hi] = fixed === 'z' ? [rect.z0, rect.z1] : [rect.x0, rect.x1]; + joistSpread(fp, hi - lo).map(v => lo + v).forEach(v => { + const spans = polys.map(poly => cutLine(poly, fixed, v)).filter(Boolean).sort((a, b) => a[0] - b[0]); + const runs = []; + spans.forEach(sp => { + const last = runs[runs.length - 1]; + if (last && sp[0] <= last[1] + 1) last[1] = Math.max(last[1], sp[1]); + else runs.push(sp.slice()); + }); + runs.forEach(span => { + const pt = (t) => fixed === 'z' ? { x: t, z: v } : { x: v, z: t }; + const A = pt(span[0]), B = pt(span[1]), mid = pt((span[0] + span[1]) / 2); + const clips = members.filter(m => distToSeg(A, m) < hw + 1 || distToSeg(B, m) < hw + 1).map(m => awayFrom(m, mid)); + addBeam('spoor', pl, A, B, clips); + }); + }); + }); + + const ownBeams = clipBeams(beams, clipBox); + const capSpecs = regionSpecs(ownBeams, regions, joistW, joistH, underlay, covering); + const kgM2 = capSpecs.totalKg / Math.max(0.1, (rect.x1 - rect.x0) * (rect.z1 - rect.z0) / 1e6); + const q = own ? (alongX ? [own.A.z, own.B.z] : [own.A.x, own.B.x]) : null; + // Wolfsdak: de nok loopt hier tot de aansluiting, de inkorting van de wolfseinden volgt uit span. + const ridgeShape = shape.type === 'wolf' ? Object.assign({}, shape, { type: 'zadel' }) : shape; + const ridgeBeam = steel ? computeRidgeBeam(roofState, ridgeShape, rect, alongX, { underlayThick: underlay, joistH }, kgM2, [Math.min(...q), Math.max(...q)]) : null; + if (ridgeBeam) ridgeBeam.alongX = alongX; + const specs = Object.assign({}, capSpecs); + addRidgeSpecs(specs, ridgeBeam); + const heights = regions.flatMap(rg => rg.poly.map(p => rg.under(p))); + const maxTop = Math.max(...regions.map(rg => Math.max(...rg.poly.map(p => rg.under(p) + rg.vThick)))); + const cap = { + kind: 'join', pA: rect.x0, pB: rect.x1, q0: rect.z0, q1: rect.z1, eave: Math.min(...heights), ridgeH: Math.max(...heights), + slope: Math.max(...regions.map(rg => rg.slope)), joistW, joistH, regions, beams: ownBeams, specs: capSpecs, maxTop + }; + // Knikpunten van de daklijn op de randen (nok, knik, wolfseind, kepers): daar knikt ook het boeideel. + const ridges = []; + members.forEach(m => [m.A, m.B].forEach(p => { + const onX = Math.abs(p.x - rect.x0) < 1 || Math.abs(p.x - rect.x1) < 1, onZ = Math.abs(p.z - rect.z0) < 1 || Math.abs(p.z - rect.z1) < 1; + const r = onX && !onZ ? { axis: 'z', value: p.z } : onZ && !onX ? { axis: 'x', value: p.x } : null; + if (r && !ridges.some(q => q.axis === r.axis && Math.abs(q.value - r.value) < 1)) ridges.push(r); + })); + return { + shape, covering, planes: [], specs, ridgeBeam, cap, rect, alongX: true, ridges, maxTop, + undersideAt: (x, z) => joinValue(join, { x, z }), + topAt: (x, z) => joinValue(join, { x, z }, k => pls[k].vThick) + }; + } + const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel function computeCap(roofState, shape, rect, alongX, covering) { const H = (p) => profileHeight(shape.profile, p); @@ -465,53 +642,7 @@ window.RoofModel = (() => { .filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100); if (!clipped.some(rg => rg.along === 'q')) return null; - const cutLine = (poly, fixed, v) => { - const ts = []; - poly.forEach((a, k) => { - const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed]; - if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return; - const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x'; - ts.push(a[o] + (b[o] - a[o]) * t); - }); - return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null; - }; - const hw = joistW / 2; - const rectAround = (A, B) => { - const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw; - return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }]; - }; - const awayFrom = (m, from) => { - const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L; - const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz; - const sgn = d(from) >= 0 ? 1 : -1; - return (p) => sgn * d(p) - hw; - }; - const distToSeg = (p, m) => { - const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz; - const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2)); - return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t)); - }; - const beams = []; - const addBeam = (kind, rg, A, B, clips, lineHeight) => { - if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null; - let poly = rectAround(A, B); - (clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); }); - if (poly.length < 3) return null; - const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz); - const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV; - const yAt = lineHeight - ? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L))) - : (p) => rg.under(p) + rg.tV; - const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L); - const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L; - if (plan < 50) return null; - const bm = { - kind, A, B, poly, yAt, jV: rg.jV, slope, - length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10 - }; - beams.push(bm); - return bm; - }; + const { hw, beams, awayFrom, addBeam } = beamTools(joistW); const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0]; const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0]; const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }]; @@ -534,8 +665,7 @@ window.RoofModel = (() => { if (ridgeBeam && roofState.steelRidge !== false) beams.splice(beams.indexOf(ridgeBeam), 1); } const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []); - const spread = (len) => FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: joistW, joistHeight: joistH, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: underlay }).joists.map(x => x + len / 2); - const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(pB - pA).map(v => pA + v); + const qs = joistSpread(fp, q1 - q0).map(v => q0 + v), ps = joistSpread(fp, pB - pA).map(v => pA + v); regions.forEach(rg => { const fixed = rg.along === 'p' ? 'z' : 'x'; (rg.along === 'p' ? qs : ps).forEach(v => { @@ -548,8 +678,69 @@ window.RoofModel = (() => { }); }); - const ownBeams = beams.map(bm => { - const poly = clipBox(bm.poly); + const ownBeams = clipBeams(beams, clipBox); + const specs = regionSpecs(ownBeams, clipped, joistW, joistH, underlay, covering); + const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick)))); + return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop }; + } + + // Houtwerk op dakvlakken met een eigen omtrek (wolfseinden, dakaansluiting); balken als rechthoek in bovenaanzicht. + function cutLine(poly, fixed, v) { + const ts = []; + poly.forEach((a, k) => { + const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed]; + if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return; + const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x'; + ts.push(a[o] + (b[o] - a[o]) * t); + }); + return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null; + } + function distToSeg(p, m) { + const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz; + const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2)); + return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t)); + } + function joistSpread(fp, len) { + return FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: fp.joistWidth || 70, joistHeight: fp.joistHeight || 220, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: fp.underlayThick || 22 }) + .joists.map(x => x + len / 2); + } + function beamTools(joistW) { + const hw = joistW / 2, beams = []; + const rectAround = (A, B) => { + const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw; + return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }]; + }; + const awayFrom = (m, from) => { + const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L; + const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz; + const sgn = d(from) >= 0 ? 1 : -1; + return (p) => sgn * d(p) - hw; + }; + const addBeam = (kind, rg, A, B, clips, lineHeight) => { + if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null; + let poly = rectAround(A, B); + (clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); }); + if (poly.length < 3) return null; + const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz); + const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV; + const yAt = lineHeight + ? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L))) + : (p) => rg.under(p) + rg.tV; + const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L); + const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L; + if (plan < 50) return null; + const bm = { + kind, A, B, poly, yAt, jV: rg.jV, slope, + length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10 + }; + beams.push(bm); + return bm; + }; + return { hw, beams, awayFrom, addBeam }; + } + function clipBeams(beams, clip) { + return beams.map(bm => { + const poly = clip(bm.poly); if (poly.length < 3 || quadArea(poly) < 100) return null; const dx = bm.B.x - bm.A.x, dz = bm.B.z - bm.A.z, L = Math.hypot(dx, dz); const ts = poly.map(p => ((p.x - bm.A.x) * dx + (p.z - bm.A.z) * dz) / L); @@ -557,20 +748,19 @@ window.RoofModel = (() => { if (plan < 50) return null; return Object.assign({}, bm, { poly, length: Math.round(plan * Math.sqrt(1 + slope * slope)) }); }).filter(Boolean); - + } + function regionSpecs(beams, regions, joistW, joistH, underlay, covering) { const W = joistW * 1e-3, Hh = joistH * 1e-3; - const woodM3 = ownBeams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0); - const areaM2 = clipped.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6; + 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 cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3; - const specs = Object.assign({ + return 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), totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg }, cs); - const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick)))); - return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop }; } // Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ. @@ -581,7 +771,8 @@ window.RoofModel = (() => { if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts)); if (unitRoof.ridgeBeam) { const r = unitRoof.rect; - group.add(buildRidgeBeam3D(unitRoof.ridgeBeam, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, unitRoof.alongX, opts.materials)); + const rb = unitRoof.ridgeBeam; + group.add(buildRidgeBeam3D(rb, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, rb.alongX !== undefined ? rb.alongX : unitRoof.alongX, opts.materials)); } unitRoof.planes.forEach(pl => { const g = new THREE.Group(); @@ -647,7 +838,7 @@ window.RoofModel = (() => { const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt))); const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0]; const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); }; - const up = (T[1][0] - T[0][0]) * (T[2][2] - T[0][2]) - (T[1][2] - T[0][2]) * (T[2][0] - T[0][0]) < 0; + const up = T.reduce((t, a, k) => { const b = T[(k + 1) % n]; return t + a[0] * b[2] - b[0] * a[2]; }, 0) < 0; for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]); const topCount = pos.length / 3; for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2); @@ -879,7 +1070,10 @@ window.RoofModel = (() => { s.overhang = Math.min(OVERHANG_MAX, Math.max(OVERHANG_MIN, s.overhang)); delete s.edge; delete s.spots; // oud (v0.3.0): nu per unit const h = s.house || {}; - s.house = { enabled: !!h.enabled, form: h.form && UnitModel.ROOF_TYPES[h.form.type] ? Object.assign({}, h.form) : null, cover: !!h.cover }; + const form = h.form ? Object.assign({}, h.form) : null; + // v0.6.2: mansarde vervallen, wordt een zadeldak met dezelfde nok. + if (form && form.type === 'mansarde') { form.type = 'zadel'; delete form.kneeH; delete form.kneeIn; delete form.kneeRatio; } + s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover }; if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor; return s; } @@ -887,7 +1081,7 @@ window.RoofModel = (() => { return { 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, buildUnit3D, capRegions, + computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, joinValue, buildUnit3D, capRegions, computeEdge, buildEdge3D, normalizeSettings }; })(); diff --git a/js/unit/unit-model.js b/js/unit/unit-model.js index ede9971..511f9a2 100644 --- a/js/unit/unit-model.js +++ b/js/unit/unit-model.js @@ -306,8 +306,8 @@ window.UnitModel = (() => { return Math.max(w.heightLeft, w.heightRight, w.heightMid > 0 ? w.heightMid : 0, ...(w.topPts || []).map(q => q.h)); } - // kneeRatio / cutRatio gaan voor kneeH / cut, zodat ze meeschalen als goot of nok verandert. - const LIMITS = { ridge: 5000, eave: 3000, short: 4300, long: 14000 }; + // cutRatio gaat voor cut, zodat ze meeschalen als goot of nok verandert. + const LIMITS = { ridge: 5000, eave: 3000, short: 4700, long: 14000 }; function clampSize(floor, keep) { floor.width = Math.min(LIMITS.long, floor.width); floor.depth = Math.min(LIMITS.long, floor.depth); @@ -317,9 +317,9 @@ window.UnitModel = (() => { } return floor; } - const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', mansarde: 'Mansarde', wolf: 'Wolfsdak' }; + const ROOF_TYPES = { plat: 'Plat', lessenaar: 'Lessenaar', zadel: 'Zadel', wolf: 'Wolfsdak' }; const RIDGES = { z: 'Noord–zuid', x: 'West–oost' }; - const hasRidge = (type) => type === 'zadel' || type === 'mansarde' || type === 'wolf'; + const hasRidge = (type) => type === 'zadel' || type === 'wolf'; function gableOf(form) { if (hasRidge(form.type)) return form.ridge === 'x' ? 'links' : 'achter'; if (form.type === 'lessenaar') return form.dir === 'links' || form.dir === 'rechts' ? 'achter' : 'links'; @@ -333,10 +333,8 @@ window.UnitModel = (() => { const full = fullTopLine(w, gableSpan(unitState, side)); const F = full[full.length - 1].u, inner = full.slice(1, -1); const L = full[0], R = full[full.length - 1], low = Math.round(Math.min(L.h, R.h)); - if (inner.length === 3) { - const high = Math.round(inner[1].h); - return { type: 'mansarde', ridge, low, high, kneeH: Math.round(inner[0].h), kneeIn: Math.round(inner[0].u), kneeRatio: (inner[0].h - low) / Math.max(1, high - low) }; - } + // v0.6.2: mansarde vervallen; een opgeslagen mansarde wordt een zadeldak met dezelfde nok. + if (inner.length === 3) return { type: 'zadel', ridge, low, high: Math.round(inner[1].h) }; if (inner.length === 2 && Math.abs(inner[0].h - inner[1].h) < 0.5) { const slope = (inner[0].h - L.h) / Math.max(1, inner[0].u); const high = Math.round(L.h + slope * F / 2); @@ -351,6 +349,10 @@ window.UnitModel = (() => { const h = Math.max(...Object.values(W).map(wallTopOf)); return { type: 'plat', low: h, high: h }; } + function dropMansarde(unitState) { + const f = roofForm(unitState), side = f.type === 'zadel' ? gableOf(f) : null; + if (side && fullTopLine(unitState.walls[side].wall, gableSpan(unitState, side)).length === 5) applyRoofForm(unitState, f); + } function applyRoofForm(unitState, form) { const side = form.type !== 'plat' ? gableOf(form) : 'achter'; const g = gableSpan(unitState, side); @@ -365,16 +367,7 @@ window.UnitModel = (() => { const half = F / 2; let line = [{ u: 0, h: low }, { u: F, h: low }]; if (sloped && form.type === 'zadel') line = [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }]; - else if (sloped && form.type === 'mansarde') { - // Knik altijd boven de rechte lijn goot–nok, anders wordt het een hol dak. - const kIn = Math.max(eMin + 100, Math.min(half - 100, Math.round(form.kneeIn || F * 0.18))); - const minK = low + (high - low) * kIn / half + 20; - const want = form.kneeRatio > 0 ? low + form.kneeRatio * (high - low) : form.kneeH; - const kH = Math.round(Math.min(high - 20, Math.max(minK, want || minK))); - line = kH > minK - 1 && high - kH >= 20 - ? [{ u: 0, h: low }, { u: kIn, h: kH }, { u: half, h: high }, { u: F - kIn, h: kH }, { u: F, h: low }] - : [{ u: 0, h: low }, { u: half, h: high }, { u: F, h: low }]; - } else if (sloped && form.type === 'wolf') { + else if (sloped && form.type === 'wolf') { const minR = (eMin + 50) / half; const ratio = form.cutRatio > 0 ? form.cutRatio : form.cut ? (form.cut - low) / (high - low) : 0.6; const cut = Math.round(Math.max(low + 50, Math.min(high - 50, low + Math.max(0.05, minR, Math.min(0.95, ratio)) * (high - low)))); @@ -401,5 +394,5 @@ window.UnitModel = (() => { } } - return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, applyRoofForm, formLine, validateState }; + return { WALL_SIDES, CORNER_MODES, ROOF_TYPES, RIDGES, LIMITS, clampSize, hasRidge, clone, createDefaultState, localToWorld, computeLayout, normalizeSplits, splitFacadeAt, topLineOn, topParamsOn, topParamsFromLayout, wallPieces, syncWallHeights, wallTopOf, gableOf, roofForm, dropMansarde, applyRoofForm, formLine, validateState }; })(); diff --git a/js/wall/wall-model.js b/js/wall/wall-model.js index bf38e50..34ff34e 100644 --- a/js/wall/wall-model.js +++ b/js/wall/wall-model.js @@ -269,6 +269,17 @@ window.WallModel = (() => { }; } + function raiseTop(CONFIG, d) { + return d ? Object.assign({}, CONFIG, { + wallHeightLeft: CONFIG.wallHeightLeft + d, wallHeightRight: CONFIG.wallHeightRight + d, + wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + d })) + }) : CONFIG; + } + // Bovenrand van de buitenlagen: per punt op het dak (skinTop) of de wandrand + skinAbove. + function skinConfig(CONFIG, skinAbove, skinTop) { + return skinTop ? Object.assign({}, CONFIG, { wallHeightLeft: skinTop.hL, wallHeightRight: skinTop.hR, wallTop: skinTop.pts }) : raiseTop(CONFIG, skinAbove); + } + function topPlateOutlines(tl) { const n = tl.segs.length, L = tl.pts[0], R = tl.pts[n]; return tl.segs.map((sg, i) => [ @@ -769,16 +780,10 @@ window.WallModel = (() => { const startXM = -halfW; const topYAt = tl.at; const skinAbove = opts.skinAbove > 0 ? opts.skinAbove : 0; - const CONFIG_OUT = skinAbove ? Object.assign({}, CONFIG, { - wallHeightLeft: CONFIG.wallHeightLeft + skinAbove, wallHeightRight: CONFIG.wallHeightRight + skinAbove, - wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + skinAbove })) - }) : CONFIG; - const tlOut = skinAbove ? topLine(CONFIG_OUT, toM) : tl; + const CONFIG_OUT = skinConfig(CONFIG, skinAbove, opts.skinTop); + const tlOut = CONFIG_OUT === CONFIG ? tl : topLine(CONFIG_OUT, toM); const battenAbove = typeof opts.battenAbove === 'number' ? opts.battenAbove : skinAbove; - const CONFIG_BAT = battenAbove ? Object.assign({}, CONFIG, { - wallHeightLeft: CONFIG.wallHeightLeft + battenAbove, wallHeightRight: CONFIG.wallHeightRight + battenAbove, - wallTop: CONFIG.wallTop.map(q => ({ x: q.x, h: q.h + battenAbove })) - }) : CONFIG; + const CONFIG_BAT = raiseTop(CONFIG_OUT, battenAbove - skinAbove); const underPts = tl.underPts; // LET OP: xMin/xMax zijn wereld-coördinaten (-halfW..+halfW), niet lokaal (0..wallWidth). @@ -940,7 +945,9 @@ window.WallModel = (() => { uv.needsUpdate = true; return geo; }; - const topClampedOut = (x) => tlOut.at(Math.max(-halfW, Math.min(halfW, x))); + // Voorbij het wandeinde (hoekdoorloop) de daklijn volgen: vlak doorgetrokken steekt hij boven een steil dakvlak uit. + const beyondEnd = (T) => (x) => Math.min(T.at(x), T.at(Math.max(-halfW, Math.min(halfW, x)))); + const topClampedOut = beyondEnd(tlOut); const layerShapes = (a, b, y0, yCap) => { if (b - a <= edgeEps) return []; const capped = yCap === Infinity; @@ -1216,7 +1223,7 @@ window.WallModel = (() => { const rExt = extM(rabatDepthMM); const rxL = -halfW - rExt.left, rxR = halfW + rExt.right; const rY0 = Math.max(skinStartY(trasraam), skinFrom) * toM; - const rTop = (x) => tlBat.at(Math.max(-halfW, Math.min(halfW, x))); + const rTop = beyondEnd(tlBat); const rabatShapes = (rxR - rxL > edgeEps && tlBat.maxY > rY0 + edgeEps) ? columnMinusRects(rxL, rxR, rY0, rTop, outerHoles.map(holeRect), { extraXs: tlBat.kinks.concat([-halfW, halfW]) }).map(piece => { const shape = new THREE.Shape(piece.outer.map(q => new THREE.Vector2(q.x, q.y))); @@ -1546,7 +1553,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, STUC, STUC_COLORS, isStucColor, stucHex, claddingOffset, cornerExt, TRASRAAM_DEFAULT_MM, STONE, STONE_COLORS, TRASRAAM_STONES, isStoneFinish, stoneThumbUrl, trasraamFor, skinStartY, computeBattens, planPurchase, purchaseText, - sheathingHoles, skinSegmentsAtY, topLine, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY, + sheathingHoles, skinSegmentsAtY, topLine, skinConfig, wallTopPoints, topPlateOutlines, openingTopAt, openingTopMax, openingArea, openingSpanAtY, validateState, resolveOpenings, computeBOM, groupBOM, buildConfig, configFromState, thicknessOf, computeFrame, createMaterials, build3D, computeSpecs }; diff --git a/js/wall/wall.js b/js/wall/wall.js index 62b1698..3709a2d 100644 --- a/js/wall/wall.js +++ b/js/wall/wall.js @@ -1380,7 +1380,7 @@ function mountWallEditor(container, callbacks) { const inputs = document.querySelectorAll('#tab-wall input'); inputs.forEach(input => { - // Hoogte met de hand gewijzigd: de knikken van een mansarde-/wolfsdak vervallen. + // Hoogte met de hand gewijzigd: de knikken van een wolfsdak vervallen. if (/^inp-wall-(h-|peak)/.test(input.id)) input.addEventListener('input', () => { wallTopPts = null; }); input.addEventListener('input', updateAll); }); diff --git a/json/premade/vlinderwoning.json b/json/premade/vlinderwoning.json index 2c6376c..660cb4f 100644 --- a/json/premade/vlinderwoning.json +++ b/json/premade/vlinderwoning.json @@ -48,7 +48,7 @@ "mode": "spots", "direction": "both", "lampHeight": 1950, - "lampMin": 1500, + "lampMin": 500, "lampMax": 3000, "minWallWidth": 2000, "endOffset": 300,