/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // Een dakvlak is een vloer-state, 180° gespiegeld: OSB-plaat onder (plafond), balken erboven. window.RoofModel = (() => { const DAKLEER_MM = 2; // visuele dikte const BESCHOT_MM = 18; const DENSITY_DAKLEER_KGM2 = 1.4; const TRIM_THICK_MM = 25; const EDGE_ABOVE_MM = 80; // Daktrim: voorkant 8 mm vóór de gevel, over de bovenste trimHeight van de gevel en 30 mm boven het dak; de opstand // is 20 mm diep, daarachter ligt een flens van 3 mm op het dak over de bovenkant van de bekleding. const TRIM = { OUT_MM: 8, ABOVE_MM: 30, DEPTH_MM: 20, FLANGE_MM: 3 }; // Felsplaat op dak én gevel: geen boeideel; de dakplaat zet om over de rand (net vóór de gevelbekleding), zonder // opstand (de waterkant van een lessenaar), tot over de bovenkant van de gevelplaten. const FELS_EDGE = { lipMM: 2, aboveMM: 2, heightMM: 120 }; const SPOT_RATIO = 0.4; const OVERHANG_MIN = 200, OVERHANG_MAX = 400; const DEFAULT_SETTINGS = { overhang: 300, fasciaHeight: 300, spotMin: 800, spotMax: 1200, trimHeight: 30, fasciaColor: 'antraciet', vent: true, ventColor: 'antraciet' }; // Ventilatierooster onder de boeidelen: strook op de gevel, direct onder de onderplaat; sleuven in de textuur. // Kleuren als de afdekker van het trasraam (WallModel.TRIM_COLORS). const VENT = { HEIGHT_MM: 40, THICK_MM: 12, PITCH_MM: 12 }; const ventMaterials = {}; function ventMaterial(key) { const k = WallModel.isTrimColor(key) ? key : 'antraciet'; if (ventMaterials[k]) return ventMaterials[k]; const base = new THREE.Color(WallModel.trimHex(k)); const canvas = typeof document !== 'undefined' && document.createElement('canvas'); const ctx = canvas && canvas.getContext && canvas.getContext('2d'); let mat; if (!ctx) mat = new THREE.MeshStandardMaterial({ color: base, roughness: 0.5, metalness: 0.3 }); else { canvas.width = 32; canvas.height = 32; ctx.fillStyle = '#' + base.getHexString(); ctx.fillRect(0, 0, 32, 32); ctx.fillStyle = '#' + base.clone().multiplyScalar(0.18).getHexString(); ctx.fillRect(11, 6, 11, 20); // sleuf, 4 mm breed en 25 mm hoog const tex = new THREE.CanvasTexture(canvas); tex.wrapS = THREE.RepeatWrapping; tex.anisotropy = 8; mat = new THREE.MeshStandardMaterial({ color: 0xffffff, map: tex, roughness: 0.5, metalness: 0.3 }); } return (ventMaterials[k] = mat); } const FASCIA_COLORS = { wit: { label: 'Wit', hex: 0xf2f2f0 }, lichtgrijs: { label: 'Lichtgrijs', hex: 0xbfc3c6 }, middelgrijs: { label: 'Middelgrijs', hex: 0x7d8286 }, antraciet: { label: 'Antraciet', hex: 0x3a3f44 }, zwart: { label: 'Zwart', hex: 0x151617 } }; // Riet e.d. alleen bij een hellend dak; plat valt terug op dakleer. // Riet: één keuze per textuur assets/textures/dak/riet_.jpg (nummer hier toevoegen). const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } }; // Kleuren en naadafstand gedeeld met felsplaten op de gevel (WallModel.FELS). const FELS = { thickMM: 25, kgM2: 6.5, seamMM: WallModel.FELS.SEAM_MM, colors: Object.fromEntries(Object.entries(WallModel.FELS.COLORS).map(([k, c]) => [k, ['Felsplaat ' + c.label.toLowerCase(), c.hex]])) }; const COVERINGS = Object.assign({ dakleer: { label: 'Dakleer (EPDM)', kind: 'dakleer', color: '#2a2a2a', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 } }, ...RIET.nrs.map(nr => ({ ['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; 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 }; } function coveringThumbUrl(key, px) { const c = COVERINGS[key]; return c && c.file ? tectumUrl(c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png'))) : null; } function coveringFor(key, shapeType) { const c = COVERINGS[key]; return c && !(c.sloped && shapeType === 'plat') ? key : 'dakleer'; } const coveringMaterials = {}; function coveringMaterial(key) { const c = COVERINGS[key]; 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(tectumUrl(c.file), (tex) => { tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(1000 / c.tileMM, 1000 / c.tileMM); tex.anisotropy = 8; mat.map = tex; mat.color.set(0xffffff); mat.needsUpdate = true; }); } coveringMaterials[key] = mat; return mat; } function clone(obj) { return JSON.parse(JSON.stringify(obj)); } function createDefaultRoof(floorState) { const deck = clone(floorState); deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 }; deck.openings = []; return { followFloor: true, offsetX: 0, offsetZ: 0, extend: { f: 0, r: 0, b: 0, l: 0 }, fascia: true, covering: 'dakleer', deck }; } function thickness(calcData, covering) { return calcData.joistH + calcData.underlayThick + BESCHOT_MM + (COVERINGS[covering] || COVERINGS.dakleer).thickMM; } function createMaterials() { return Object.assign(FloorModel.createMaterials(), { dakleerMaterial: new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.9, side: THREE.DoubleSide }), fasciaMaterial: new THREE.MeshStandardMaterial({ color: 0x3a3f44, roughness: 0.6, side: THREE.DoubleSide }), trimMaterial: new THREE.MeshStandardMaterial({ color: 0xb8bcc0, roughness: 0.4, metalness: 0.5, side: THREE.DoubleSide }), steelMaterial: new THREE.MeshStandardMaterial({ color: 0x5f666c, roughness: 0.5, metalness: 0.6 }), ceilingMaterial: new THREE.MeshStandardMaterial({ color: 0xf4f3ef, roughness: 0.92, side: THREE.DoubleSide }), spotOnMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe9a8, emissiveIntensity: 0.9 }) }); } // Vloer-3D gespiegeld met scale.y = -1 (three.js corrigeert de vlakoriëntatie zelf). function build3D(calcData, resolvedOpenings, opts) { const m = opts.materials; const toM = 0.001; const group = new THREE.Group(); const deckMats = opts.ceilingMaterial ? Object.assign({}, m, { osbMaterial: opts.ceilingMaterial }) : m; const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, { materials: deckMats, osbVisible: opts.osbVisible !== false, outerJoistsOnly: opts.osbVisible !== false }); const deckH = (calcData.joistH + calcData.underlayThick) * toM; deck.scale.y = -1; deck.position.y = deckH; group.add(deck); const dM = calcData.depth * toM; const shape = FloorModel.surfaceShapes(calcData, resolvedOpenings, toM); const cov = COVERINGS[opts.covering] || COVERINGS.dakleer; const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial; 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) { 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; } function computeSpecs(calcData, resolvedOpenings, covering) { const deck = FloorModel.computeSpecs(calcData, resolvedOpenings); const areaM2 = deck.osbAreaM2; 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 }); } // De daklijn hoort bij de wand zonder naden (wl.ref): is de wand door een naad langer of korter, // dan blijft het profiel gelijk. function wallProfile(unitState, layout, side) { const w = unitState.walls[side].wall; const wl = layout.walls.find(x => x.side === side); const s = UnitModel.WALL_SIDES.find(x => x.key === side); const c = s.axis === 'x' ? wl.cx : wl.cz; const ref = wl.ref || { w: wl.width, u0: 0 }; const at = (x) => c - s.dir * wl.width / 2 + s.dir * (x - ref.u0); const pts = [{ p: at(0), h: w.heightLeft }, { p: at(ref.w), h: w.heightRight }]; WallModel.wallTopPoints(w, ref.w, w.heightLeft, w.heightRight) .forEach(q => pts.push({ p: at(q.x), h: q.h })); return pts.sort((a, b) => a.p - b.p); } function computeShape(unitState, layout) { for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) { const profile = wallProfile(unitState, layout, side); if (!profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) continue; return shapeFromProfile(axis, profile); } const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws))); return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] }; } function shapeFromProfile(axis, profile) { if (profile.length === 4 && Math.abs(profile[1].h - profile[2].h) < 0.5) { const [L, A, B, R] = profile, sA = (A.h - L.h) / (A.p - L.p), sB = (R.h - B.h) / (R.p - B.p); const pr = Math.abs(sA - sB) > 1e-9 ? (B.h - sB * B.p - A.h + sA * A.p) / (sA - sB) : (A.p + B.p) / 2; const ridge = { p: pr, h: A.h + sA * (pr - A.p) }; if (sA > 0 && sB < 0 && pr > A.p && pr < B.p) { return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } }; } } const type = profile.length === 2 ? 'lessenaar' : 'zadel'; return { type, axis, profile }; } // Helling wolfseinden: die van het dak, maar minstens zo steil dat ze de nok halen vóór het midden. function hipGeom(shape, q0, q1) { const [, A, R, B] = shape.profile, hipH = shape.hip.h, top = R.h, qM = (q0 + q1) / 2; const sA = (top - A.h) / (R.p - A.p), sB = (top - B.h) / (B.p - R.p); const tH = Math.max((sA + sB) / 2, (top - hipH) / Math.max(1, qM - q0)); return { pA: A.p, pR: R.p, pB: B.p, hipH, top, sA, sB, tH, inset: (top - hipH) / tH, q0, q1, qM }; } // De daklijn is bol, dus het profiel is het minimum van zijn (doorgetrokken) stukken. function shapeLines(shape) { const pr = shape.profile; if (pr.length === 1) return [{ a: pr[0].h, bp: 0, bq: 0 }]; const out = pr.slice(0, -1).map((a, i) => { const b = pr[i + 1], s = (b.h - a.h) / (b.p - a.p); return { a: a.h - s * a.p, bp: s, bq: 0 }; }); if (shape.type === 'wolf' && shape.hip && shape.hip.q0 !== undefined) { const g = hipGeom(shape, shape.hip.q0, shape.hip.q1); out.push({ a: g.hipH - g.q0 * g.tH, bp: 0, bq: g.tH }, { a: g.hipH + g.q1 * g.tH, bp: 0, bq: -g.tH }); } return out; } // Buiten de wand loopt de laatste helling door (overstek, boeideel). function profileHeight(profile, p) { if (profile.length === 1) return profile[0].h; let i = 0; while (i < profile.length - 2 && p > profile[i + 1].p) i++; const a = profile[i], b = profile[i + 1]; return a.h + (p - a.p) / (b.p - a.p) * (b.h - a.h); } // Stalen nokbalk, per unit één (modules worden los gebouwd), opgelegd op de kopgevels. // Kleinste IPE die voldoet aan sterkte (1,2·G + 1,5·S, σ ≤ fy) en doorbuiging (G + S, ≤ L/250). // [naam, h, b, tw, tf (mm), kg/m, Iy (cm4), Wy (cm3)] const IPE = [ ['IPE 100', 100, 55, 4.1, 5.7, 8.1, 171, 34.2], ['IPE 120', 120, 64, 4.4, 6.3, 10.4, 318, 53.0], ['IPE 140', 140, 73, 4.7, 6.9, 12.9, 541, 77.3], ['IPE 160', 160, 82, 5.0, 7.4, 15.8, 869, 109], ['IPE 180', 180, 91, 5.3, 8.0, 18.8, 1317, 146], ['IPE 200', 200, 100, 5.6, 8.5, 22.4, 1943, 194], ['IPE 220', 220, 110, 5.9, 9.2, 26.2, 2772, 252], ['IPE 240', 240, 120, 6.2, 9.8, 30.7, 3892, 324], ['IPE 270', 270, 135, 6.6, 10.2, 36.1, 5790, 429], ['IPE 300', 300, 150, 7.1, 10.7, 42.2, 8356, 557], ['IPE 330', 330, 160, 7.5, 11.5, 49.1, 11770, 713], ['IPE 360', 360, 170, 8.0, 12.7, 57.1, 16270, 904], ['IPE 400', 400, 180, 8.6, 13.5, 66.3, 23130, 1160], ['IPE 450', 450, 190, 9.4, 14.6, 77.6, 33740, 1500], ['IPE 500', 500, 200, 10.2, 16.0, 90.7, 48200, 1930] ].map(([name, h, b, tw, tf, kgM, I, W]) => ({ name, h, b, tw, tf, kgM, I, W })); const STEEL_RIDGE = { fy: 235, E: 210000, // S235, N/mm2 snowKNm2: 0.56, // sneeuw op het dak (0,8 × 0,7 kN/m2) finishKNm2: 0.3, // afwerking/installaties onder het dak gammaG: 1.2, gammaQ: 1.5, deflection: 250 }; const ridgeTypes = ['zadel', 'wolf']; // span: [q0, q1] waar de nok echt loopt (dakaansluiting: tot de hoekkeper). // cuts: sparingen (schoorsteen) in plattegrondmaten; ligt er een op de nok, dan stopt de balk bear mm erin // (negatief: ervoor). function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2, span, cuts) { if (roofState.steelRidge === false || !ridgeTypes.includes(shape.type)) return null; const pr = shape.profile, inner = pr.slice(1, -1); if (!inner.length) return null; const R = inner.reduce((a, q) => q.h > a.h ? q : a); const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1; if (R.p < a0 - 0.5 || R.p >= a1 - 0.5) return null; const uq0 = alongX ? rect.z0 : rect.x0, uq1 = alongX ? rect.z1 : rect.x1; let q0 = uq0, q1 = uq1; if (shape.type === 'wolf' && shape.hip) { const hq = shape.hip.q0 !== undefined ? [shape.hip.q0, shape.hip.q1] : [uq0, uq1]; const g = hipGeom(shape, hq[0], hq[1]); 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]); } (cuts || []).forEach(c => { const [pa, pb] = alongX ? [c.x0, c.x1] : [c.z0, c.z1], [qa, qb] = alongX ? [c.z0, c.z1] : [c.x0, c.x1]; if (R.p < pa - 100 || R.p > pb + 100 || qb < q0 || qa > q1) return; if ((qa + qb) / 2 > (q0 + q1) / 2) q1 = Math.min(q1, qa + (c.bear || 0)); else q0 = Math.max(q0, qb - (c.bear || 0)); }); const L = q1 - q0; if (L < 100) return null; // q in N/mm = kN/m. const trib = (a1 - a0) / 2 / 1000; const gRoof = (kgM2 * 9.81e-3 + STEEL_RIDGE.finishKNm2) * trib, sQ = STEEL_RIDGE.snowKNm2 * trib; const check = (s) => { const g = gRoof + s.kgM * 9.81e-3; const M = (STEEL_RIDGE.gammaG * g + STEEL_RIDGE.gammaQ * sQ) * L * L / 8; const util = M / (s.W * 1e3 * STEEL_RIDGE.fy); const defl = 5 * (g + sQ) * Math.pow(L, 4) / (384 * STEEL_RIDGE.E * s.I * 1e4); return { util, defl, deflMax: L / STEEL_RIDGE.deflection, ok: util <= 1 && defl <= L / STEEL_RIDGE.deflection }; }; let sec = IPE.find(s => check(s).ok); const ok = !!sec; if (!sec) sec = IPE[IPE.length - 1]; const c = check(sec); const segs = pr.slice(0, -1).map((a, i) => { const b = pr[i + 1], sl = (b.h - a.h) / (b.p - a.p); return { at: (p) => a.h + (p - a.p) * sl, f: Math.sqrt(1 + sl * sl) }; }); const under = cd.underlayThick, jH = cd.joistH; const zone = (p, t) => Math.min(...segs.map(sg => sg.at(p) + t * sg.f)); const yTop = Math.min(zone(R.p - sec.b / 2, under + jH), zone(R.p + sec.b / 2, under + jH)); const yBottom = yTop - sec.h; const below = Math.max(0, Math.round(zone(R.p, 0) - yBottom)); const fillW = (sec.b - sec.tw) / 2, fillH = sec.h - 2 * sec.tf; const fillM3 = 2 * fillW * fillH * L / 1e9; return { profile: sec, ok, util: c.util, defl: c.defl, deflMax: c.deflMax, loadKNm: gRoof + sQ + sec.kgM * 9.81e-3, p: R.p, q0, q1, length: Math.round(L), yTop, yBottom, below, steelKg: sec.kgM * L / 1000, fillW, fillH, fillM3, fillKg: fillM3 * DENSITY_WOOD_KGM3 }; } function buildRidgeBeam3D(rb, xc, zc, alongX, m) { const toM = 0.001, s = rb.profile, L = rb.length * toM; const b = s.b * toM / 2, w = s.tw * toM / 2, h = s.h * toM, t = s.tf * toM; const shp = new THREE.Shape(); [[-b, 0], [b, 0], [b, t], [w, t], [w, h - t], [b, h - t], [b, h], [-b, h], [-b, h - t], [-w, h - t], [-w, t], [-b, t]] .forEach(([x, y], i) => i ? shp.lineTo(x, y) : shp.moveTo(x, y)); shp.closePath(); const g = new THREE.Group(); const beam = new THREE.Mesh(new THREE.ExtrudeGeometry(shp, { steps: 1, depth: L, bevelEnabled: false }), m.steelMaterial); beam.castShadow = beam.receiveShadow = true; g.add(beam); const fw = rb.fillW * toM, fh = rb.fillH * toM; [-1, 1].forEach(sd => { const fill = new THREE.Mesh(new THREE.BoxGeometry(fw, fh, L), m.woodMaterial); fill.position.set(sd * (w + fw / 2), t + fh / 2, L / 2); g.add(fill); }); // Lokaal: x dwars, y omhoog, z langs de nok (vanaf q0). if (alongX) g.position.set((rb.p - xc) * toM, rb.yBottom * toM, (rb.q0 - zc) * toM); else { g.rotation.y = Math.PI / 2; g.position.set((rb.q0 - xc) * toM, rb.yBottom * toM, (rb.p - zc) * toM); } return g; } function sumSpecs(list) { const out = Object.assign({}, list[0]); list.slice(1).forEach(sp => Object.keys(sp).forEach(k => { if (typeof sp[k] === 'number' && typeof out[k] === 'number') out[k] += sp[k]; })); return out; } // cuts: rechthoekige sparingen (schoorsteen) in plattegrondmaten van de unit: { x0, x1, z0, z1, bear }. function computeUnitRoof(roofState, shape, rect, rims, cutRects) { const roofCuts = cutRects || []; const alongX = shape.axis === 'x'; const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0; const xc = (rect.x0 + rect.x1) / 2, zc = (rect.z0 + rect.z1) / 2; const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1; const kinks = shape.profile.slice(1, -1).map(q => q.p).filter(p => p > a0 + 1 && p < a1 - 1); const cuts = [a0, ...kinks, a1]; const flat = FloorModel.computeFromState(roofState.deck, rims); const H = (p) => profileHeight(shape.profile, p); const covering = coveringFor(roofState.covering, shape.type); const holes = flat.resolvedOpenings.filter(o => !o.removed).map(o => { const px = xc + o.x, pz = rect.z0 + o.y; return { o, px, pz, q: alongX ? px : pz }; }); const planes = cuts.slice(0, -1).map((p0, i) => { const p1 = cuts[i + 1], last = i === cuts.length - 2; const h0 = H(p0), h1 = H(p1), run = p1 - p0; const slopeLen = Math.hypot(run, h1 - h0), cos = run / slopeLen; const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 }; let data; const deckCuts = roofCuts.map(c => { const [qa, qb] = alongX ? [c.x0, c.x1] : [c.z0, c.z1]; const a = Math.max(qa, p0), b = Math.min(qb, p1); if (b - a < 1) return null; const [x0, x1] = alongX ? [zc - c.z1, zc - c.z0] : [c.x0 - xc, c.x1 - xc]; return { x0, x1, y0: (a - p0) / cos, y1: (b - p0) / cos }; }).filter(Boolean); if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5 && !deckCuts.length) { data = flat; } else { const deck = clone(roofState.deck); deck.cuts = deckCuts; deck.floor.width = Math.round(alongX ? D : W); deck.floor.depth = Math.round(slopeLen); deck.openings = holes.filter(hl => hl.q >= p0 && (hl.q < p1 || last)).map(hl => ({ id: hl.o.id, diameter: hl.o.requestedDiameter, anchorX: 'left', // Bij helling langs X is het dek een kwartslag gedraaid: lokaal x -> -Z. offsetX: Math.round(alongX ? rect.z1 - hl.pz : hl.px - rect.x0), offsetY: Math.round((hl.q - p0) / cos) })); const pr = alongX ? {} : { front: !!rims.front && Math.abs(p0 - rect.z0) < 1, back: !!rims.back && Math.abs(p1 - rect.z1) < 1 }; data = FloorModel.computeFromState(deck, pr); } const mid = (p0 + p1) / 2; return Object.assign(plane, { data, specs: computeSpecs(data.calcData, data.resolvedOpenings, covering), cx: alongX ? mid - xc : 0, cz: alongX ? 0 : mid - zc, y: (h0 + h1) / 2, vThick: thickness(data.calcData, covering) / cos }); }); // 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) => { const pj = planes[i + 1], ai = Math.tan(pi.theta), aj = Math.tan(pj.theta); if (Math.abs(ai - aj) < 1e-4) return; const dp = (pj.vThick - pi.vThick) / (ai - aj); pi.miter1 = dp; pj.miter0 = dp; }); const mainUnder = (x, z) => H(alongX ? x : z); const mainTop = (x, z) => { const q = alongX ? x : z; return Math.min(...planes.map(pl => pl.h0 + (q - pl.p0) * Math.tan(pl.theta) + pl.vThick)); }; let cap = null, undersideAt = mainUnder, topAt = mainTop, outPlanes = planes; const pA = shape.type === 'wolf' && shape.hip ? shape.profile[1].p : 0, pB = pA && shape.profile[3].p; if (shape.type === 'wolf' && shape.hip && Math.min(pB, a1) - Math.max(pA, a0) > 1) cap = computeCap(roofState, shape, rect, alongX, covering); if (cap) { outPlanes = planes.filter(pl => !(pl.p0 >= pA - 0.5 && pl.p1 <= pB + 0.5)); const at = (x, z) => ({ x: alongX ? x : z, z: alongX ? z : x }); undersideAt = (x, z) => Math.min(mainUnder(x, z), ...cap.regions.map(rg => rg.under(at(x, z)))); topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick)); } 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), null, roofCuts); addRidgeSpecs(specs, ridgeBeam); return { shape, covering, planes: outPlanes, specs, ridgeBeam, undersideAt, topAt, cap, rect, alongX, ridges: kinks.map(value => ({ axis: shape.axis, value })), maxTop: cap ? cap.maxTop : Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick)) }; } // Stalen nok onder de naad van een dubbel lessenaardak (HouseModel): shape is het samengestelde zadel en rect // de twee units samen, beide in de lokale maten van deze unit; de balk hoort bij deze unit (3D, kosten). function sharedRidgeBeam(roofState, unitRoof, shape, rect, span, cuts) { const pl = unitRoof.planes[0]; if (!pl || !pl.data) return null; const r = unitRoof.rect, kgM2 = unitRoof.specs.totalKg / Math.max(0.1, (r.x1 - r.x0) * (r.z1 - r.z0) / 1e6); const rb = computeRidgeBeam(roofState, shape, rect, shape.axis === 'x', pl.data.calcData, kgM2, span, cuts); if (!rb) return null; rb.alongX = shape.axis === 'x'; rb.shared = true; unitRoof.ridgeBeam = rb; addRidgeSpecs(unitRoof.specs, rb); return rb; } 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); const uq0 = alongX ? rect.z0 : rect.x0, uq1 = alongX ? rect.z1 : rect.x1; const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1; const hq = shape.hip.q0 !== undefined ? [shape.hip.q0, shape.hip.q1] : [uq0, uq1]; const { pA, pR, pB, hipH, top, sA, sB, tH, inset, q0, q1, qM } = hipGeom(shape, hq[0], hq[1]); const fp = roofState.deck.floor; const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22; const cov = COVERINGS[covering] || COVERINGS.dakleer; const cp0 = Math.max(pA, a0), cp1 = Math.min(pB, a1), cq0 = Math.max(q0, uq0), cq1 = Math.min(q1, uq1); const clipBox = (poly) => [(p) => p.x - cp0, (p) => cp1 - p.x, (p) => p.z - cq0, (p) => cq1 - p.z] .reduce((pl, f) => pl.length >= 3 ? clipPoly(pl, f) : pl, poly); const plane = (key, slope, f, along) => { const cos = Math.cos(Math.atan(slope)); 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'), plane('m2', sB, (p) => H(pB) + (pB - p.x) * sB, 'p'), plane('h1', tH, (p) => hipH + (p.z - q0) * tH, 'q'), plane('h2', tH, (p) => hipH + (q1 - p.z) * tH, 'q') ]; const band = [{ x: pA, z: q0 }, { x: pB, z: q0 }, { x: pB, z: q1 }, { x: pA, z: q1 }]; const regions = fns.map((fi, i) => { let poly = band; fns.forEach((fj, j) => { if (j !== i && poly.length >= 3) poly = clipPoly(poly, (p) => fj.f(p) - fi.f(p)); }); 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.bV + fi.covV }); }).filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100); const clipped = regions.map(rg => Object.assign({}, rg, { poly: clipBox(rg.poly) })) .filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100); if (!clipped.some(rg => rg.along === 'q')) return null; 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) }]; const hasRidge = ends[1].z - ends[0].z >= 100; const kops = [q0 + hw, q1 - hw].map(qv => addBeam('kopbalk', hOf(qv), { x: pA, z: qv }, { x: pB, z: qv }, [ (p) => p.z - q0 - 0.01, (p) => q1 - p.z - 0.01])).filter(Boolean); const hips = band.map(c => { const e = c.z < qM ? ends[0] : ends[1], sp = Math.sign(pR - c.x), sq = Math.sign(qM - c.z); const clips = [(p) => sp * (pR - p.x)]; if (!hasRidge) clips.push((p) => sq * (qM - p.z)); clips.push((p) => sq * (c.z + sq * joistW - p.z) * -1); return addBeam('hoekkeper', mOf(c.x), c, e, clips, true); }).filter(Boolean); let ridgeBeam = null; if (hasRidge) { const mid = { x: pR, z: qM }; ridgeBeam = addBeam('nokbalk', mOf(pR - 1), ends[0], ends[1], hips.map(h => awayFrom(h, mid)), true); // Stalen nokbalk (computeRidgeBeam) in plaats van hout: wel om de sporen // tegen af te zagen, niet als houten balk. if (ridgeBeam && roofState.steelRidge !== false) beams.splice(beams.indexOf(ridgeBeam), 1); } const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []); 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 => { const span = cutLine(rg.poly, fixed, v); if (!span) return; const pt = (t) => fixed === 'z' ? { x: t, z: v } : { x: v, z: t }; const A = pt(span[0]), B = pt(span[1]), mid = pt((span[0] + span[1]) / 2); const clips = members.filter(m => distToSeg(A, m) < hw + 1 || distToSeg(B, m) < hw + 1).map(m => awayFrom(m, mid)); addBeam('spoor', rg, A, B, clips); }); }); 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); const plan = Math.max(...ts) - Math.min(...ts), slope = bm.slope; 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 = 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; 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); } // Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ. // Zo blijven dikte haaks op de helling en lengte gelijk, en staan de kopse kanten loodrecht (zoals de zaagsnede aan sporen). function buildUnit3D(unitRoof, opts) { const toM = 0.001; const group = new THREE.Group(); if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts)); if (unitRoof.ridgeBeam) { const r = unitRoof.rect; 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(); g.position.set(pl.cx * toM, pl.y * toM, pl.cz * toM); g.rotation.set(0, pl.yaw, 0); const shear = new THREE.Group(); const c = Math.cos(pl.theta), sn = Math.sin(pl.theta); shear.matrixAutoUpdate = false; shear.matrix.set( 1, 0, 0, 0, 0, 1 / c, sn, 0, 0, 0, c, 0, 0, 0, 0, 1); const deck = build3D(pl.data.calcData, pl.data.resolvedOpenings, Object.assign({ covering: unitRoof.covering }, opts)); if (pl.miter0 || pl.miter1) miterDeck(deck, pl, c); shear.add(deck); g.add(shear); group.add(g); }); return group; } function miterDeck(deck, pl, cos) { const toM = 0.001, cd = pl.data.calcData; const L = cd.depth * toM, T = pl.vThick * cos * toM; const d0 = pl.miter0 * toM / cos, d1 = pl.miter1 * toM / cos; const v = new THREE.Vector3(), rel = new THREE.Matrix4(), inv = new THREE.Matrix4(); deck.updateMatrixWorld(true); const base = new THREE.Matrix4().copy(deck.matrixWorld).invert(); deck.traverse(o => { const pos = o.geometry && o.geometry.attributes && o.geometry.attributes.position; if (!pos) return; rel.multiplyMatrices(base, o.matrixWorld); inv.copy(rel).invert(); for (let k = 0; k < pos.count; k++) { v.fromBufferAttribute(pos, k).applyMatrix4(rel); const t = Math.max(0, Math.min(1, v.y / T)); if (d1 && v.z > L / 2 - 1e-4) v.z = L / 2 + d1 * t; else if (d0 && v.z < -L / 2 + 1e-4) v.z = -L / 2 + d0 * t; else continue; v.applyMatrix4(inv); pos.setXYZ(k, v.x, v.y, v.z); } pos.needsUpdate = true; o.geometry.computeBoundingSphere(); if (o.geometry.attributes.normal) o.geometry.computeVertexNormals(); }); } function capRegions(unitRoof) { return unitRoof.cap ? unitRoof.cap.regions : []; } function buildCap3D(unitRoof, opts) { const toM = 0.001, m = opts.materials, c = unitRoof.cap, r = unitRoof.rect; const xc = (r.x0 + r.x1) / 2, zc = (r.z0 + r.z1) / 2; const group = new THREE.Group(); const osbOn = opts.osbVisible !== false, covOn = opts.dakleerVisible !== false; const covMat = coveringMaterial(unitRoof.covering) || m.dakleerMaterial; const P = (pt, y) => { const x = unitRoof.alongX ? pt.x : pt.z, z = unitRoof.alongX ? pt.z : pt.x; return [(x - xc) * toM, y * toM, (z - zc) * toM]; }; function slab(poly, bottom, h, topMat, sideMat, uvOf) { const n = poly.length, pos = [], uv = []; const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt))); const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0]; const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); }; 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); for (let k = 0; k < n; k++) { const j = (k + 1) % n; up ? (tri(T[k], B[k], B[j], z2, z2, z2), tri(T[k], B[j], T[j], z2, z2, z2)) : (tri(T[k], B[j], B[k], z2, z2, z2), tri(T[k], T[j], B[j], z2, z2, z2)); } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); geo.addGroup(0, topCount, 0); geo.addGroup(topCount, pos.length / 3 - topCount, 1); geo.computeVertexNormals(); const mesh = new THREE.Mesh(geo, [topMat, sideMat]); mesh.castShadow = true; mesh.receiveShadow = true; group.add(mesh); } c.regions.forEach(rg => { if (osbOn) slab(rg.poly, rg.under, rg.tV, opts.ceilingMaterial || m.osbMaterial, m.osbMaterial); if (osbOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV, rg.bV, m.osbMaterial, m.osbMaterial); 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]); }); c.beams.forEach(bm => { if (osbOn && bm.kind !== 'kopbalk') return; slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial); }); return group; } function splitAtCorners(pts, rects) { const corners = []; rects.forEach(r => corners.push({ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 })); const out = []; pts.forEach((a, k) => { const b = pts[(k + 1) % pts.length]; out.push(a); const len = Math.hypot(b.x - a.x, b.z - a.z); corners .map(c => ({ c, t: ((c.x - a.x) * (b.x - a.x) + (c.z - a.z) * (b.z - a.z)) / (len * len) })) .filter(({ c, t }) => t > 1e-6 && t < 1 - 1e-6 && Math.abs((c.x - a.x) * (b.z - a.z) - (c.z - a.z) * (b.x - a.x)) / len < 0.5) .sort((p, q) => p.t - q.t) .forEach(({ c }) => { if (!out.some(o => Math.abs(o.x - c.x) < 0.5 && Math.abs(o.z - c.z) < 0.5)) out.push({ x: c.x, z: c.z }); }); }); return out; } function onRectEdge(r, p) { const e = 0.5; const inX = p.x >= r.x0 - e && p.x <= r.x1 + e, inZ = p.z >= r.z0 - e && p.z <= r.z1 + e; return ((Math.abs(p.x - r.x0) < e || Math.abs(p.x - r.x1) < e) && inZ) || ((Math.abs(p.z - r.z0) < e || Math.abs(p.z - r.z1) < e) && inX); } function quadArea(pts) { return Math.abs(pts.reduce((s, p, k) => { const q = pts[(k + 1) % pts.length]; return s + p.x * q.z - q.x * p.z; }, 0) / 2); } // facadeDepth negatief = gevel van een hoger deel die naar dit dak kijkt: de rand stopt tegen die gevel. // facadeFels(a, b, normal): de gevel onder deze rand heeft felsplaten (met een felsdak: geen boeideel). function computeEdge(settings, roofs, facadeDepth, facadeFels) { const s = normalizeSettings(settings); const rects = roofs.map(r => r.rect); if (!roofs.length) return null; // Daken die tegen elkaar liggen delen één rand, tenzij het hoogteverschil langs de hele naad // minstens de boeideelhoogte is. const group = roofs.map((r, i) => i); const find = (i) => group[i] === i ? i : (group[i] = find(group[i])); const sharedEdge = (A, B) => { const e = 0.5; for (const [ax, bx, lo0, lo1, hi0, hi1, axis] of [ [A.x1, B.x0, A.z0, A.z1, B.z0, B.z1, 'x'], [B.x1, A.x0, A.z0, A.z1, B.z0, B.z1, 'x'], [A.z1, B.z0, A.x0, A.x1, B.x0, B.x1, 'z'], [B.z1, A.z0, A.x0, A.x1, B.x0, B.x1, 'z']]) { const s0 = Math.max(lo0, hi0), s1 = Math.min(lo1, hi1); if (Math.abs(ax - bx) < e && s1 - s0 > e) { return [s0, (s0 + s1) / 2, s1].map(t => axis === 'x' ? { x: ax, z: t } : { x: t, z: ax }); } } return null; }; roofs.forEach((A, i) => roofs.forEach((B, j) => { if (j <= i) return; const pts = sharedEdge(A.rect, B.rect); if (!pts) return; const diff = Math.min(...pts.map(p => Math.abs(A.topAt(p.x, p.z) - B.topAt(p.x, p.z)))); if (diff < s.fasciaHeight) group[find(i)] = find(j); })); const segments = []; [...new Set(roofs.map((r, i) => find(i)))].forEach(g => { const members = roofs.filter((r, i) => find(i) === g); const others = roofs.filter((r, i) => find(i) !== g); const loops = rectUnionLoops(members.map(r => r.rect)); if (!loops.length) return; const base = splitAtCorners(loops[0].pts, rects); const offBoei = offsetRectilinearLoop(base, s.overhang); const offTrim = offsetRectilinearLoop(base, TRIM_THICK_MM); const offSpot = offsetRectilinearLoop(base, s.overhang * SPOT_RATIO); const n = base.length; const segs = base.map((a, i) => { const j = (i + 1) % n, b = base[j]; const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 }; const owner = members.find(r => onRectEdge(r.rect, mid)) || members[0]; const normal = rectilinearNormal(a, b); const fels = coverKind(owner.covering) === 'fels' && !!facadeFels && !!facadeFels(a, b, normal); const fascia = owner.fascia !== false && !fels; const off = fascia ? offBoei : offTrim; // Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij zonder spots. const nb = others.find(r => onRectEdge(r.rect, mid)); const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z); const above = fels ? FELS_EDGE.aboveMM : fascia ? EDGE_ABOVE_MM : TRIM.ABOVE_MM; return { a, b, owner: owner.unitId, fascia, fels, covering: owner.covering, normal, above, outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j], topAt: (p) => owner.topAt(p.x, p.z) + above, ridges: owner.ridges || [], overRoof: !!nb && !dropped, dropped }; }); const depIn = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) : 0)); const depRaw = depIn.map(d => d || 0); const dep = depRaw.map(d => Math.min(0, d)); const spotD = depRaw.map(d => Math.max(0, d) + (s.overhang - Math.max(0, d)) * SPOT_RATIO); const perp = (m1, m2) => Math.abs(m1.x * m2.x + m1.z * m2.z) < 0.5; const shifted = segs.map((sg, i) => { const pi = (i - 1 + n) % n, ni = (i + 1) % n, pv = segs[pi], nx = segs[ni]; const at = (v, own, ownD, other, otherD) => perp(own, other) ? { x: v.x + own.x * ownD + other.x * otherD, z: v.z + own.z * ownD + other.z * otherD } : { x: v.x + own.x * ownD, z: v.z + own.z * ownD }; return { a: at(sg.a, sg.normal, dep[i], pv.normal, dep[pi]), b: at(sg.b, sg.normal, dep[i], nx.normal, dep[ni]), spotA: at(sg.a, sg.normal, spotD[i], pv.normal, spotD[pi]), spotB: at(sg.b, sg.normal, spotD[i], nx.normal, spotD[ni]) }; }); // facadeUnder: een eigen gevel onder de rand (niet bij een veranda of een hogere gevel ertegenaan). segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i], facadeUnder: depIn[i] !== null && depIn[i] >= 0 })); // Rand zonder overstek (daktrim, felsrand): ligt net vóór de gevelbekleding en loopt bij een hoek door tot de // buitenkant van de buurrand; een boeideel ernaast stopt op de buitenkant van zo'n rand. const edgeOff = (k) => segs[k].fels ? Math.max(TRIM_THICK_MM, depRaw[k] + FELS_EDGE.lipMM) : Math.max(0, depRaw[k]) + TRIM.OUT_MM; const mv = (p, nrm, d) => ({ x: p.x + nrm.x * d, z: p.z + nrm.z * d }); segs.forEach((sg, i) => { const ends = [['a', 'outerA', (i - 1 + n) % n], ['b', 'outerB', (i + 1) % n]]; if (!sg.fascia) ends.forEach(([k, ok, j]) => { const nb = segs[j], q = mv(sg[k], sg.normal, edgeOff(i)); sg[ok] = perp(sg.normal, nb.normal) && !nb.fascia && !nb.dropped ? mv(q, nb.normal, edgeOff(j)) : q; }); else if (sg.fascia) ends.forEach(([k, ok, j]) => { const nb = segs[j]; if (nb.fascia || nb.dropped || !perp(sg.normal, nb.normal)) return; sg[ok] = mv(mv(sg[k], sg.normal, s.overhang), nb.normal, edgeOff(j)); }); }); const insideOther = (p) => others.some(r => p.x > r.rect.x0 + 0.5 && p.x < r.rect.x1 - 0.5 && p.z > r.rect.z0 + 0.5 && p.z < r.rect.z1 - 0.5); segs.forEach((sg, i) => { if (sg.dropped) return; const prev = segs[(i - 1 + n) % n], next = segs[(i + 1) % n]; const alongZ = Math.abs(sg.a.x - sg.b.x) < 0.5; const at = (q, v) => alongZ ? { x: q.x, z: v.z } : { x: v.x, z: q.z }; [['a', 'outerA', 'spotA', 'b', prev], ['b', 'outerB', 'spotB', 'a', next]].forEach(([k, outerK, spotK, otherK, nb]) => { if (!nb.dropped) return; const v = sg[k], corner = sg[outerK]; if (insideOther({ x: (v.x + corner.x) / 2, z: (v.z + corner.z) / 2 })) { sg[outerK] = at(corner, v); const dir = Math.sign(alongZ ? sg[otherK].z - v.z : sg[otherK].x - v.x); const inset = dir * s.overhang * SPOT_RATIO; sg[spotK] = alongZ ? { x: sg[spotK].x, z: v.z + inset } : { x: v.x + inset, z: sg[spotK].z }; } else { sg[k] = at(v, corner); } }); }); segs.filter(sg => !sg.dropped).forEach(sg => segments.push(sg)); }); const segLen = (sg) => Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z); const top = Math.max(...segments.map(sg => Math.max(sg.topAt(sg.a), sg.topAt(sg.b)))); return { segments, overhang: s.overhang, spotRatio: SPOT_RATIO, fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, fasciaColor: s.fasciaColor, vent: s.vent, ventColor: s.ventColor, top, fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0), ventLengthMM: s.vent ? segments.filter(sg => sg.fascia && sg.facadeUnder).reduce((t, sg) => t + Math.hypot(sg.b.x - sg.a.x, sg.b.z - sg.a.z), 0) : 0, // Felsrand: zetwerk van de dakplaten, zit in de prijs van de felsplaten. trimLengthMM: segments.filter(sg => !sg.fascia && !sg.fels).reduce((t, sg) => t + segLen(sg), 0), felsEdgeLengthMM: segments.filter(sg => sg.fels).reduce((t, sg) => t + segLen(sg), 0), soffitM2: segments.filter(sg => sg.fascia).reduce((t, sg) => t + quadArea([sg.a, sg.b, sg.outerB, sg.outerA]), 0) / 1e6, spots: [] }; } function clipPoly(poly, f) { const out = []; poly.forEach((p, k) => { const q = poly[(k + 1) % poly.length], fp = f(p), fq = f(q); if (fp >= 0) out.push(p); if ((fp >= 0) !== (fq >= 0)) { const t = fp / (fp - fq); out.push({ x: p.x + (q.x - p.x) * t, z: p.z + (q.z - p.z) * t }); } }); return out; } // uAt(p): met textuur, u langs de rand; v = 0 onder, 1 boven. function prismGeometry(poly, topFn, h, uAt) { const toM = 0.001, n = poly.length, pos = [], uvs = []; const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]); const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]); const U = uAt ? poly.map(uAt) : null; const tri = (a, b, c) => pos.push(...a, ...b, ...c); const uv = (...q) => { if (U) q.forEach(([k, v]) => uvs.push(U[k], v)); }; for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); uv([0, 1], [k, 1], [k + 1, 1]); tri(B[0], B[k + 1], B[k]); uv([0, 0], [k + 1, 0], [k, 0]); } for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); uv([k, 1], [k, 0], [j, 0]); tri(T[k], B[j], T[j]); uv([k, 1], [j, 0], [j, 1]); } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); if (U) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); geo.computeVertexNormals(); return geo; } // Gevelvlak onder een rand (afstand langs de normaal): de dakrand plus de bekleding die erbuiten steekt. function ventFace(sg) { return sg.a.x * sg.normal.x + sg.a.z * sg.normal.z + Math.max(0, sg.facadeDepth || 0); } // Hoogte van de rand vanaf sg.topAt naar beneden: boeideel, felsrand of daktrim (met opstand boven het dak). function edgeHeight(edge, sg) { return sg.fascia ? edge.fasciaHeight : sg.fels ? FELS_EDGE.heightMM : edge.trimHeight + TRIM.ABOVE_MM; } // Kleur van de rand in tekeningen (hex-getal). function edgeColor(edge, sg) { if (sg.fascia) return (FASCIA_COLORS[edge.fasciaColor] || FASCIA_COLORS.antraciet).hex; const c = sg.fels && COVERINGS[sg.covering]; return c ? parseInt(c.color.slice(1), 16) : 0xb8bcc0; } function buildEdge3D(edge, materials, opts) { const o = opts || {}; const toM = 0.001; const group = new THREE.Group(); if (!edge) return group; // Gedeeld materiaal: kleur volgens de instelling. materials.fasciaMaterial.color.setHex((FASCIA_COLORS[edge.fasciaColor] || FASCIA_COLORS.antraciet).hex); edge.segments.forEach(sg => { let polys = [[sg.a, sg.b, sg.outerB, sg.outerA]]; sg.ridges.forEach(r => { const f = (p) => r.axis === 'x' ? p.x - r.value : p.z - r.value; polys = polys.flatMap(pl => [clipPoly(pl, f), clipPoly(pl, p => -f(p))]).filter(pl => pl.length >= 3); }); // Schoorsteen: de rand stopt ertegen (gaps langs de rand, in wereld-mm). const alongZ = Math.abs(sg.a.x - sg.b.x) < 0.5; (sg.gaps || []).forEach(([g0, g1]) => { const t = (p) => alongZ ? p.z : p.x; polys = polys.flatMap(pl => [clipPoly(pl, p => g0 - t(p)), clipPoly(pl, p => t(p) - g1)]).filter(pl => pl.length >= 3); }); const h = edgeHeight(edge, sg); const mat = sg.fascia ? materials.fasciaMaterial : sg.fels ? coveringMaterial(sg.covering) || materials.trimMaterial : materials.trimMaterial; const nd = (p) => p.x * sg.normal.x + p.z * sg.normal.z; const add = (pl, topFn, hh, m, u) => { const mesh = new THREE.Mesh(prismGeometry(pl, topFn, hh, u), m); mesh.visible = o.edgeVisible !== false; group.add(mesh); }; if (!sg.fascia && !sg.fels) { // Daktrim: opstand aan de voorkant, flens erachter op het dak. const inner = nd(sg.outerA) - TRIM.DEPTH_MM; polys.forEach(pl => { const up = clipPoly(pl, p => nd(p) - inner), fl = clipPoly(pl, p => inner - nd(p)); if (up.length >= 3) add(up, sg.topAt, h, mat); if (fl.length >= 3) add(fl, p => sg.topAt(p) - TRIM.ABOVE_MM + TRIM.FLANGE_MM, TRIM.FLANGE_MM, mat); }); } else polys.forEach(pl => add(pl, sg.topAt, h, mat)); // Rooster: op de gevel (voorkant bekleding), onder de onderplaat van het boeideel. if (!sg.fascia || !sg.facadeUnder || !edge.vent) return; const face = ventFace(sg); const u = (p) => (alongZ ? p.z : p.x) / VENT.PITCH_MM; polys.map(pl => clipPoly(clipPoly(pl, p => nd(p) - face), p => face + VENT.THICK_MM - nd(p))).filter(pl => pl.length >= 3) .forEach(pl => add(pl, (p) => sg.topAt(p) - h, VENT.HEIGHT_MM, ventMaterial(edge.ventColor), u)); }); const spotR = 0.035, spotH = 0.012; edge.spots.forEach(p => { const spot = new THREE.Mesh(new THREE.CylinderGeometry(spotR, spotR, spotH, 16), materials.spotOnMaterial); spot.position.set(p.x * toM, p.y * toM - spotH / 2 + 0.002, p.z * toM); spot.visible = o.spotsVisible !== false; spot.userData = { ref: p, glow: true }; group.add(spot); }); return group; } function normalizeSettings(settings) { const s = Object.assign({}, DEFAULT_SETTINGS, settings || {}); 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 || {}; 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 }; s.posts = PostModel.normalizeSettings(s.posts); if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor; s.vent = s.vent !== false; if (!WallModel.isTrimColor(s.ventColor)) s.ventColor = DEFAULT_SETTINGS.ventColor; return s; } return { DAKLEER_MM, BESCHOT_MM, EDGE_ABOVE_MM, TRIM, RIET, FELS, coverKind, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl, createDefaultRoof, thickness, createMaterials, build3D, computeSpecs, computeShape, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, sharedRidgeBeam, joinValue, buildUnit3D, capRegions, FELS_EDGE, VENT, ventFace, computeEdge, buildEdge3D, edgeHeight, edgeColor, normalizeSettings }; })();