/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // ============================================================ // roof/roof-model.js — RoofModel: ENIGE bron van de dak-logica. Geen DOM. // // Een dakvlak is opgebouwd als een vloer (FloorModel: balken, isolatie, // doorvoeren, OSB-plaat), maar 180° gespiegeld: de OSB-plaat zit aan de // onderzijde (plafond), de balken erboven, met dakleer als buitenlaag // bovenop. Het "dek" van een dak is dus gewoon een vloer-state. // // Dakvorm volgt de wanden (computeShape): het paar tegenoverliggende wanden // met een schuine bovenrand of tussenpunten (nok, knikken) zijn de kopgevels, // het dak volgt hun daklijn (plat, lessenaar, zadel, mansarde of wolfsdak; // bij een wolfsdak komen daar de wolfseinden bij, zie computeShape). UnitModel.syncWallHeights // houdt de wanden daarvoor consistent. Het dek (in de plattegrond) wordt per // dakvlak omgerekend naar een schuin vlak met de balken als sporen (van // laag naar hoog); doorvoeren gaan mee naar het vlak waarin ze vallen. // // Dakrand (over de hele woning, doorlopend over naden tussen units): de // omtrek van alle daken samen, opgeknipt per ribbe (en op elke unit-hoek). // Per ribbe bepaalt de unit waar die ribbe bij hoort of het een boeideel of // een daktrim is (boeidelen aan/uit per unit). De ribben sluiten in verstek // op elkaar aan, dus op een naad tussen units is niets te zien. // - boeidelen: een doos van 200..400 mm overstek (gemeten vanaf de // dakrand = standaard de buitenkant gevel), standaard 300 mm hoog. // Spots in de onderplaat staan op 40% van de overstek (spotLine); welke // spots er komen bepaalt LightingModel. // - zonder boeidelen: een daktrim van 80 mm hoog (onder het dakleer). // De bovenkant van boeideel en daktrim ligt EDGE_ABOVE_MM boven het dakleer // (opstand rondom), dus het dakleer ligt altijd lager dan de rand. // // Plain script, geladen na floor-model.js en core/geometry.js. // ============================================================ window.RoofModel = (() => { const DAKLEER_MM = 2; // visuele dikte van het dakleer const BESCHOT_MM = 18; // dakbeschot (OSB) bovenop de sporen, onder de bedekking const DENSITY_DAKLEER_KGM2 = 1.4; // EPDM ~1,4 kg/m2 const TRIM_THICK_MM = 25; // daktrim-profiel, uitstekend buiten de dakrand const EDGE_ABOVE_MM = 80; // bovenkant boeideel/daktrim ligt zoveel boven het dakleer const SPOT_RATIO = 0.4; // spots op 40% van de overstek, gemeten vanaf de gevel const OVERHANG_MIN = 200, OVERHANG_MAX = 400; // Woning-brede maten van de dakrand (aan/uit per unit: roof.fascia). const DEFAULT_SETTINGS = { overhang: 300, // mm, 200..400 fasciaHeight: 300, // mm spotMin: 800, // mm h.o.h. boeideel-spots spotMax: 1200, trimHeight: 80, // mm fasciaColor: 'antraciet' // FASCIA_COLORS }; // Kleur van het boeideel (egaal). 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 } }; // Dakbedekking per unit (roof.covering). Riet e.d. alleen bij een hellend // dak (lessenaar/zadel); plat valt terug op dakleer. thickMM telt mee in de // dakdikte (vThick/topAt), dus boeideel/daktrim en een opbouw ernaast // schuiven vanzelf mee omhoog. Texturen: assets/textures/dak/, naadloos; // de stengels lopen langs de helling. // Riet: één keuze per textuur assets/textures/dak/riet_.jpg (nummer // hier toevoegen); zelfde opbouw (180 mm, 3,8 kg/m2). // colors: gemiddelde kleur per textuur (vlakke weergave, PDF-aanzichten). const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } }; // Felsplaten (staande naad, zink/staal op scheidingslaag): 25 mm hoge // felsnaden elke seamMM langs de helling; alleen hellend. const FELS = { thickMM: 25, kgM2: 6.5, seamMM: 500, colors: { zink: ['Felsplaat zink', '#8e9497'], antraciet: ['Felsplaat antraciet', '#3d4146'], zwart: ['Felsplaat zwart', '#1f2022'] } }; // kind: soort bedekking (dakleer | riet | fels) voor specificaties en kosten. const COVERINGS = Object.assign({ dakleer: { label: 'Dakleer (EPDM)', 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; // Hoeveelheden per soort bedekking (zelfde sleutels in elke specs). function coverSpecs(covering, areaM2) { const c = COVERINGS[covering] || COVERINGS.dakleer, kg = areaM2 * c.kgM2, k = c.kind; return { dakleerM2: k === 'dakleer' ? areaM2 : 0, dakleerKg: k === 'dakleer' ? kg : 0, rietM2: k === 'riet' ? areaM2 : 0, rietKg: k === 'riet' ? kg : 0, felsM2: k === 'fels' ? areaM2 : 0, felsKg: k === 'fels' ? kg : 0, coverKg: kg }; } // Voorvertoning (assets/textures/dak/thumb//.png) of null. function coveringThumbUrl(key, px) { const c = COVERINGS[key]; return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null; } // Werkelijke bedekking voor een dakvorm (riet niet op een plat dak). 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(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)); } // Dak-state per unit: volgt standaard de vloermaat (followFloor), maar mag // groter/kleiner en verschoven worden (override). 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, fascia: true, covering: 'dakleer', deck }; } // Dikte dek (plafondplaat + sporen + dakbeschot) + bedekking (mm, haaks op het dakvlak). 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 }), spotOnMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe9a8, emissiveIntensity: 0.9 }) }); } // 3D van één dak (meters, gecentreerd op het dakmidden, Y = 0 onderkant // plafondplaat). Het vloer-3D (FloorModel.build3D) wordt gespiegeld // (scale.y = -1; three.js corrigeert de vlakoriëntatie zelf), daarna // komen het dakbeschot (OSB, fase beplating) en de bedekking (fase gevel) // er bovenop. Felsplaten: dunne plaat met staande naden langs de helling. function build3D(calcData, resolvedOpenings, opts) { const m = opts.materials; const toM = 0.001; const group = new THREE.Group(); // Plafondplaat én bedekking zichtbaar: de sporen ertussen zijn ingesloten. // Plafondplaat én dakbeschot zichtbaar (fase beplating): de sporen ertussen zijn ingesloten. const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, { materials: m, osbVisible: opts.osbVisible !== false, outerJoistsOnly: opts.osbVisible !== false }); const deckH = (calcData.joistH + calcData.underlayThick) * toM; deck.scale.y = -1; deck.position.y = deckH; group.add(deck); // Dakleer: vlak over het hele dak, met dezelfde ronde uitsparingen als // de doorvoeren (zelfde opbouw als de OSB-plaat in FloorModel). const wM = calcData.width * toM, dM = calcData.depth * toM; const shape = new THREE.Shape(); shape.moveTo(-wM / 2, -dM / 2); shape.lineTo(wM / 2, -dM / 2); shape.lineTo(wM / 2, dM / 2); shape.lineTo(-wM / 2, dM / 2); shape.closePath(); resolvedOpenings.filter(o => !o.removed && o.r > 0).forEach(o => { const hole = new THREE.Path(); hole.absarc(o.x * toM, o.y * toM - dM / 2, o.r * toM, 0, Math.PI * 2, false); shape.holes.push(hole); }); // Andere bedekking (opts.covering, bijv. riet): een laag van thickMM met // de textuur (vorm-coördinaten in m = UV; langs de helling = v). const cov = COVERINGS[opts.covering] || COVERINGS.dakleer; const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial; const bT = BESCHOT_MM * toM; if (opts.osbVisible !== false) { const beschot = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: bT, bevelEnabled: false }), m.osbMaterial); beschot.rotation.x = Math.PI / 2; beschot.position.y = deckH + bT; beschot.castShadow = true; group.add(beschot); } if (opts.dakleerVisible !== false) { // Felsplaat: dunne plaat + naden; anders de volle laag (dakleer, riet). const slabT = (cov.kind === 'fels' ? 1.5 : cov.thickMM) * toM; const leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: slabT, bevelEnabled: false }), covMat); leer.rotation.x = Math.PI / 2; leer.position.y = deckH + bT + slabT; leer.castShadow = cov.thickMM > DAKLEER_MM; group.add(leer); if (cov.kind === 'fels') { const n = Math.max(0, Math.floor(calcData.width / cov.seamMM)); const x0 = -(n * cov.seamMM) / 2; for (let i = 0; i <= n; i++) { const seam = new THREE.Mesh(new THREE.BoxGeometry(0.01, cov.thickMM * toM, dM), covMat); seam.position.set((x0 + i * cov.seamMM) * toM, deckH + bT + cov.thickMM * toM / 2, 0); seam.castShadow = true; group.add(seam); } } } return group; } // Specificaties: die van het dek (FloorModel; osb = plafondplaat) + het // dakbeschot (beschot*) + de bedekking (coverSpecs: dakleer/riet/fels). function computeSpecs(calcData, resolvedOpenings, covering) { const deck = FloorModel.computeSpecs(calcData, resolvedOpenings); const areaM2 = deck.osbAreaM2; // zelfde vlak minus doorvoeren const 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 }); } // ------------------------------------------------------------ // Dakvorm per unit (volgt de wanden) // ------------------------------------------------------------ // Daklijn van één wand als profiel langs zijn as (unit-lokaal mm): // [{ p, h }] gesorteerd op p, h vanaf het loopvlak. Zelfde definitie als // UnitModel.wallPieces: links/rechts op de uiteinden van de wand (van // buitenaf gezien), het middenpunt (nok) midden op de wand. 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 pts = [{ p: c - s.dir * wl.width / 2, h: w.heightLeft }, { p: c + s.dir * wl.width / 2, h: w.heightRight }]; // Tussenpunten (nok, knikken van mansarde/wolfsdak), x vanaf links. WallModel.wallTopPoints(w, wl.width, w.heightLeft, w.heightRight) .forEach(q => pts.push({ p: c - s.dir * wl.width / 2 + s.dir * q.x, h: q.h })); return pts.sort((a, b) => a.p - b.p); } // Dakvorm van een unit. layout = UnitModel.computeLayout-resultaat. // Geeft { type: 'plat'|'lessenaar'|'zadel'|'mansarde'|'wolf', axis: 'x'|'z', // profile, hip? }: de hoogte van de dakonderkant (vanaf het loopvlak) // hangt alleen af van de positie langs axis (profileHeight), behalve bij // een wolfsdak (hip). Kopgevels: de eerste wand met een schuine daklijn // (noord/zuid -> dak helt langs X, west/oost -> langs Z). Zonder schuine // wanden: plat op de hoogste wand. // zadel 3 punten (goot, nok, goot) // mansarde 5 punten (goot, knik, nok, knik, goot) // wolf 4 punten met een vlakke top: de kopgevel stopt op het // wolfseind. Het profiel wordt dat van het zadeldak (de // schuine stukken doorgetrokken tot de nok), met de knikken op // het wolfseind; hip = { h }: de wolfseinden lopen vanaf de // kopgevels op hoogte h omhoog tot de nok (computeCap). function computeShape(unitState, layout) { for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) { const profile = wallProfile(unitState, layout, side); if (!profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) continue; if (profile.length === 4 && Math.abs(profile[1].h - profile[2].h) < 0.5) { const [L, A, B, R] = profile, sA = (A.h - L.h) / (A.p - L.p), sB = (R.h - B.h) / (R.p - B.p); // Snijpunt van de doorgetrokken schuine stukken = nok. const pr = Math.abs(sA - sB) > 1e-9 ? (B.h - sB * B.p - A.h + sA * A.p) / (sA - sB) : (A.p + B.p) / 2; const ridge = { p: pr, h: A.h + sA * (pr - A.p) }; if (sA > 0 && sB < 0 && pr > A.p && pr < B.p) { return { type: 'wolf', axis, profile: [L, A, ridge, B, R], hip: { h: A.h } }; } } const type = profile.length === 2 ? 'lessenaar' : profile.length === 5 ? 'mansarde' : 'zadel'; return { type, axis, profile }; } const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws))); return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] }; } // Hoogte van het profiel op positie p; 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); } // Tel alle getallen van een lijst specificaties op. 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; } // Het dak van één unit: de dakrechthoek (unit-lokaal mm { x0, x1, z0, z1 }, // = maat van roofState.deck) geknipt op de nok in dakvlakken. Per vlak een // eigen dek (vloer-state) met breedte = maat dwars op de helling en diepte // = lengte langs de helling, zodat de balken als sporen van laag naar hoog // lopen. rims: zie FloorModel.computeFrame (alleen bij helling langs Z). // Geeft { shape, planes, specs, undersideAt(x, z), topAt(x, z), ridges, maxTop } // (unit-lokaal, hoogtes vanaf het loopvlak). Vlak-posities (cx, cz) zijn // t.o.v. het midden van de dakrechthoek. function computeUnitRoof(roofState, shape, rect, rims) { 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); // Doorvoeren: positie in de plattegrond (zoals in de dak-editor) -> vlak. 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; if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5) { data = flat; // plat dak: het dek zoals het is } else { const deck = clone(roofState.deck); 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, // Verticale dikte (dek + bedekking) van dit vlak. vThick: thickness(data.calcData, covering) / cos }); }); // Knik tussen twee vlakken met een verschillende helling (mansarde): // de bovenkanten snijden elkaar vóór (het steilere vlak is verticaal // dikker), dus de vlakken sluiten in verstek aan: onderkant op de knik, // bovenkant op het snijpunt van de bovenvlakken (miter = verschuiving // van de bovenkant t.o.v. de knik, mm langs de as; 0 = verticaal). planes.forEach((pl, i) => { pl.miter0 = 0; pl.miter1 = 0; }); planes.slice(0, -1).forEach((pi, i) => { const pj = planes[i + 1], ai = Math.tan(pi.theta), aj = Math.tan(pj.theta); if (Math.abs(ai - aj) < 1e-4) return; const dp = (pj.vThick - pi.vThick) / (ai - aj); pi.miter1 = dp; pj.miter0 = dp; }); const mainUnder = (x, z) => H(alongX ? x : z); // Bovenkant: het laagste van de (doorgetrokken) bovenvlakken; de daklijn // is bol, dus dat is precies het dak, ook rond een knik. const mainTop = (x, z) => { const q = alongX ? x : z; return Math.min(...planes.map(pl => pl.h0 + (q - pl.p0) * Math.tan(pl.theta) + pl.vThick)); }; // Wolfsdak: de vlakken tussen de wolfseind-knikken worden geen // rechthoekige dekken; dat bovenste deel (cap, computeCap) is het laagste // van de twee dakvlakken en de twee wolfseinden, met één top. let cap = null, undersideAt = mainUnder, topAt = mainTop, outPlanes = planes; if (shape.type === 'wolf' && shape.hip && kinks.length === 3) { cap = computeCap(roofState, shape, rect, alongX, kinks, covering); outPlanes = planes.filter(pl => !(pl.p0 >= cap.pA - 0.5 && pl.p1 <= cap.pB + 0.5)); const at = (x, z) => ({ x: alongX ? x : z, z: alongX ? z : x }); // (p, q) undersideAt = (x, z) => Math.min(mainUnder(x, z), ...cap.regions.map(rg => rg.under(at(x, z)))); topAt = (x, z) => Math.min(mainTop(x, z), ...cap.regions.map(rg => rg.under(at(x, z)) + rg.vThick)); } return { shape, covering, planes: outPlanes, specs: sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : [])), undersideAt, topAt, cap, rect, alongX, ridges: kinks.map(value => ({ axis: shape.axis, value })), maxTop: cap ? cap.maxTop : Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick)) }; } // ------------------------------------------------------------ // Bovenste deel van een wolfsdak (cap). Coördinaten (p, q) als { x: p, // z: q } (unit-lokaal mm): p langs de helling van het hoofddak, q langs de // nok. Band p = pA..pB (wolfseind-knikken), q = q0..q1 (de kopgevels). // Vier vlakken (onderkant dak): m1/m2 de twee dakvlakken, h1/h2 de // wolfseinden: vanaf de kopgevel op hipH met de dakhelling omhoog tot de // nokhoogte (driehoek), daartussen een rechte nok op pR. Is de unit te kort // voor een nokstuk, dan worden de wolfseinden steiler en lopen ze in één // punt samen. Per vlak het gebied waar het het laagste is (regions). // Houtwerk zoals de dekken: plafondplaat, sporen (dakvlakken: op dezelfde // q als de balken van het onderste dek; wolfseinden: verdeeld over pA..pB), // hoekkepers van elke hoek van de band naar het nokeinde, nokbalk, kopbalk // langs de voet van een wolfseind. // ------------------------------------------------------------ const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel function computeCap(roofState, shape, rect, alongX, kinks, covering) { const H = (p) => profileHeight(shape.profile, p); const [pA, pR, pB] = kinks, hipH = shape.hip.h, top = H(pR); const q0 = alongX ? rect.z0 : rect.x0, q1 = alongX ? rect.z1 : rect.x1, qM = (q0 + q1) / 2; const fp = roofState.deck.floor; const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22; const cov = COVERINGS[covering] || COVERINGS.dakleer; const sA = (top - H(pA)) / (pR - pA), sB = (top - H(pB)) / (pB - pR); // Helling wolfseinden: die van het dak, maar minstens zo steil dat ze de // nokhoogte halen vóór het midden tussen de kopgevels. const tH = Math.max((sA + sB) / 2, (top - hipH) / Math.max(1, qM - q0)); const inset = (top - hipH) / tH; // nokeinde vanaf de kopgevel const plane = (key, slope, f, along) => { const cos = Math.cos(Math.atan(slope)); return { key, f, along, cos, tV: underlay / cos, jV: joistH / cos, 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)); }); // Samenvallende punten (afsnijden door de top) weg. poly = poly.filter((p, k) => { const q = poly[(k + 1) % poly.length]; return Math.hypot(q.x - p.x, q.z - p.z) > 0.5; }); return Object.assign({}, fi, { poly, under: fi.f, vThick: fi.tV + fi.jV + fi.bV + fi.covV }); }).filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100); // Snijden van een lijn (vaste p of vaste q) met een convexe veelhoek. const cutLine = (poly, fixed, v) => { const ts = []; poly.forEach((a, k) => { const b = poly[(k + 1) % poly.length], av = a[fixed], bv = b[fixed]; if ((av - v) * (bv - v) > 0 || Math.abs(bv - av) < 1e-9) return; const t = (v - av) / (bv - av), o = fixed === 'x' ? 'z' : 'x'; ts.push(a[o] + (b[o] - a[o]) * t); }); return ts.length >= 2 ? [Math.min(...ts), Math.max(...ts)] : null; }; // Balken als veelhoek in de plattegrond (rechthoek van balkbreedte rond // de hartlijn A-B, afgezaagd tegen de balken waar hij op aansluit) met // de onderkant op het dakvlak (yAt: + plafondplaat). Lengte = langs de // balk, hoek = helling van de balk. const hw = joistW / 2; const rectAround = (A, B) => { const L = Math.hypot(B.x - A.x, B.z - A.z), nx = -(B.z - A.z) / L * hw, nz = (B.x - A.x) / L * hw; return [{ x: A.x - nx, z: A.z - nz }, { x: B.x - nx, z: B.z - nz }, { x: B.x + nx, z: B.z + nz }, { x: A.x + nx, z: A.z + nz }]; }; // Halfvlak aan de kant van `from` naast het zijvlak van balk m (>= 0 blijft). const awayFrom = (m, from) => { const L = Math.hypot(m.B.x - m.A.x, m.B.z - m.A.z), nx = -(m.B.z - m.A.z) / L, nz = (m.B.x - m.A.x) / L; const d = (p) => (p.x - m.A.x) * nx + (p.z - m.A.z) * nz; const sgn = d(from) >= 0 ? 1 : -1; return (p) => sgn * d(p) - hw; }; const distToSeg = (p, m) => { const dx = m.B.x - m.A.x, dz = m.B.z - m.A.z, L2 = dx * dx + dz * dz; const t = Math.max(0, Math.min(1, ((p.x - m.A.x) * dx + (p.z - m.A.z) * dz) / L2)); return Math.hypot(p.x - (m.A.x + dx * t), p.z - (m.A.z + dz * t)); }; const beams = []; const addBeam = (kind, rg, A, B, clips, lineHeight) => { if (Math.hypot(B.x - A.x, B.z - A.z) < 100) return null; let poly = rectAround(A, B); (clips || []).forEach(f => { if (poly.length >= 3) poly = clipPoly(poly, f); }); if (poly.length < 3) return null; const dx = B.x - A.x, dz = B.z - A.z, L = Math.hypot(dx, dz); const yA = rg.under(A) + rg.tV, yB = rg.under(B) + rg.tV; // Hartlijnbalk (hoekkeper, nokbalk): hoogte langs de hartlijn; anders op het vlak. const yAt = lineHeight ? (p) => yA + (yB - yA) * Math.max(0, Math.min(1, ((p.x - A.x) * dx + (p.z - A.z) * dz) / (L * L))) : (p) => rg.under(p) + rg.tV; const ts = poly.map(p => ((p.x - A.x) * dx + (p.z - A.z) * dz) / L); const plan = Math.max(...ts) - Math.min(...ts), slope = Math.abs(yB - yA) / L; if (plan < 50) return null; const bm = { kind, A, B, poly, yAt, jV: rg.jV, length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10 }; beams.push(bm); return bm; }; // Raamwerk: kopbalken (binnen de kopgevel, op de bovenregel), hoekkepers // van elke hoek van de band naar het nokeinde, nokbalk ertussen. De // hoekkepers staan op de kopbalk en sluiten bovenin in verstek op elkaar // aan; de nokbalk is afgezaagd tegen de hoekkepers. const mOf = (x) => regions.find(rg => rg.key === (x < pR ? 'm1' : 'm2')) || regions[0]; const hOf = (z) => regions.find(rg => rg.key === (z < qM ? 'h1' : 'h2')) || regions[0]; const ends = [{ x: pR, z: Math.min(qM, q0 + inset) }, { x: pR, z: Math.max(qM, q1 - inset) }]; const hasRidge = ends[1].z - ends[0].z >= 100; const kops = [q0 + hw, q1 - hw].map(qv => addBeam('kopbalk', hOf(qv), { x: pA, z: qv }, { x: pB, z: qv }, [ (p) => p.z - q0 - 0.01, (p) => q1 - p.z - 0.01])).filter(Boolean); const hips = band.map(c => { const e = c.z < qM ? ends[0] : ends[1], sp = Math.sign(pR - c.x), sq = Math.sign(qM - c.z); const clips = [(p) => sp * (pR - p.x)]; if (!hasRidge) clips.push((p) => sq * (qM - p.z)); // Op de kopbalk: vanaf zijn binnenkant. clips.push((p) => sq * (c.z + sq * joistW - p.z) * -1); return addBeam('hoekkeper', mOf(c.x), c, e, clips, true); }).filter(Boolean); let ridgeBeam = null; if (hasRidge) { const mid = { x: pR, z: qM }; ridgeBeam = addBeam('nokbalk', mOf(pR - 1), ends[0], ends[1], hips.map(h => awayFrom(h, mid)), true); } const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []); // Sporen: afgezaagd tegen de balk(en) waar ze op uitkomen. const spread = (len) => FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: joistW, joistHeight: joistH, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: underlay }).joists.map(x => x + len / 2); const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(pB - pA).map(v => pA + v); regions.forEach(rg => { const fixed = rg.along === 'p' ? 'z' : 'x'; (rg.along === 'p' ? qs : ps).forEach(v => { const span = cutLine(rg.poly, fixed, v); if (!span) return; const pt = (t) => fixed === 'z' ? { x: t, z: v } : { x: v, z: t }; const A = pt(span[0]), B = pt(span[1]), mid = pt((span[0] + span[1]) / 2); const clips = members.filter(m => distToSeg(A, m) < hw + 1 || distToSeg(B, m) < hw + 1).map(m => awayFrom(m, mid)); addBeam('spoor', rg, A, B, clips); }); }); // Specificaties (zelfde sleutels als computeSpecs). const W = joistW * 1e-3, Hh = joistH * 1e-3; const woodM3 = beams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0); const areaM2 = regions.reduce((t, rg) => t + quadArea(rg.poly) / rg.cos, 0) / 1e6; const cs = coverSpecs(covering, areaM2), osbVol = areaM2 * underlay * 1e-3, bVol = areaM2 * BESCHOT_MM * 1e-3; const specs = Object.assign({ joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3, osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3, beschotM2: areaM2, beschotVolM3: bVol, beschotKg: bVol * DENSITY_OSB_KGM3, voidM3: Math.max(0, areaM2 * Hh - woodM3), totalKg: woodM3 * DENSITY_WOOD_KGM3 + (osbVol + bVol) * DENSITY_OSB_KGM3 + cs.coverKg }, cs); const maxTop = top + Math.max(...regions.map(rg => rg.vThick)); return { pA, pR, pB, q0, q1, hipH, joistW, joistH, regions, beams, specs, maxTop }; } // 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek, // Y = 0 op het loopvlak): per dakvlak build3D, zo vervormd dat de // onderkant van het plafond precies op de daklijn van de wanden ligt. // Geen kanteling maar een afschuiving (lokaal +Z = oplopende as): // plan = z·cos θ, Y = y/cos θ + z·sin θ // De onderkant ligt dan op de helling, de dikte haaks op de helling blijft // gelijk (verticaal = dikte/cos θ, zoals vThick), de lengte langs de // helling ook (balklengtes), en de kopse kanten staan loodrecht (zoals een // loodrechte zaagsnede aan sporen). Bij kantelen zouden de kopse kanten // haaks op de helling staan en ligt de bovenkant aan de hoge kant // dikte·sin θ terug van de dakrand. function buildUnit3D(unitRoof, opts) { const toM = 0.001; const group = new THREE.Group(); if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts)); unitRoof.planes.forEach(pl => { const g = new THREE.Group(); g.position.set(pl.cx * toM, pl.y * toM, pl.cz * toM); 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; } // Verstek op de uiteinden van een dek (vóór de afschuiving; lokaal z // langs de helling, -L/2 .. L/2, y = 0 onderkant .. T bovenkant): elk // hoekpunt op een uiteinde schuift met de hoogte mee naar de verstekhoek // (pl.miter0 / miter1 in mm langs de as, omgerekend langs de helling). function miterDeck(deck, pl, cos) { const toM = 0.001, cd = pl.data.calcData; const L = cd.depth * toM, T = pl.vThick * cos * toM; const d0 = pl.miter0 * toM / cos, d1 = pl.miter1 * toM / cos; const v = new THREE.Vector3(), rel = new THREE.Matrix4(), inv = new THREE.Matrix4(); deck.updateMatrixWorld(true); const base = new THREE.Matrix4().copy(deck.matrixWorld).invert(); deck.traverse(o => { const pos = o.geometry && o.geometry.attributes && o.geometry.attributes.position; if (!pos) return; rel.multiplyMatrices(base, o.matrixWorld); inv.copy(rel).invert(); for (let k = 0; k < pos.count; k++) { v.fromBufferAttribute(pos, k).applyMatrix4(rel); const t = Math.max(0, Math.min(1, v.y / T)); if (d1 && v.z > L / 2 - 1e-4) v.z = L / 2 + d1 * t; else if (d0 && v.z < -L / 2 + 1e-4) v.z = -L / 2 + d0 * t; else continue; v.applyMatrix4(inv); pos.setXYZ(k, v.x, v.y, v.z); } pos.needsUpdate = true; o.geometry.computeBoundingSphere(); if (o.geometry.attributes.normal) o.geometry.computeVertexNormals(); }); } // Bovenste deel van een wolfsdak (unitRoof.cap, zie computeCap): per // hellend vlak plafondplaat en bedekking (verticale dikte, UV langs de // helling), sporen/hoekkepers/kopbalken als balken met verticale kopse // kanten (zoals de afgeschoven dekken). Plaat én bedekking zichtbaar: // alleen de kopbalken (de rest is ingesloten). function capRegions(unitRoof) { return unitRoof.cap ? unitRoof.cap.regions : []; } function buildCap3D(unitRoof, opts) { const toM = 0.001, m = opts.materials, c = unitRoof.cap, r = unitRoof.rect; const xc = (r.x0 + r.x1) / 2, zc = (r.z0 + r.z1) / 2; const group = new THREE.Group(); const osbOn = opts.osbVisible !== false, covOn = opts.dakleerVisible !== false; const covMat = coveringMaterial(unitRoof.covering) || m.dakleerMaterial; // (p, q) -> wereld (m, t.o.v. het dakmidden). const P = (pt, y) => { const x = unitRoof.alongX ? pt.x : pt.z, z = unitRoof.alongX ? pt.z : pt.x; return [(x - xc) * toM, y * toM, (z - zc) * toM]; }; // Plaat onder een convexe veelhoek: onderkant bottom(pt), dikte h (verticaal). function slab(poly, bottom, h, topMat, sideMat, uvOf) { const n = poly.length, pos = [], uv = []; const T = poly.map(pt => P(pt, bottom(pt) + h)), B = poly.map(pt => P(pt, bottom(pt))); const UV = poly.map(pt => uvOf ? uvOf(pt) : [0, 0]), z2 = [0, 0]; const tri = (a, b, cc, ua, ub, uc) => { pos.push(...a, ...b, ...cc); uv.push(...ua, ...ub, ...uc); }; // Oriëntatie zo dat de bovenkant naar boven wijst. const up = (T[1][0] - T[0][0]) * (T[2][2] - T[0][2]) - (T[1][2] - T[0][2]) * (T[2][0] - T[0][0]) < 0; for (let k = 1; k < n - 1; k++) up ? tri(T[0], T[k], T[k + 1], UV[0], UV[k], UV[k + 1]) : tri(T[0], T[k + 1], T[k], UV[0], UV[k + 1], UV[k]); const topCount = pos.length / 3; for (let k = 1; k < n - 1; k++) up ? tri(B[0], B[k + 1], B[k], z2, z2, z2) : tri(B[0], B[k], B[k + 1], z2, z2, z2); for (let k = 0; k < n; k++) { const j = (k + 1) % n; up ? (tri(T[k], B[k], B[j], z2, z2, z2), tri(T[k], B[j], T[j], z2, z2, z2)) : (tri(T[k], B[j], B[k], z2, z2, z2), tri(T[k], T[j], B[j], z2, z2, z2)); } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); geo.addGroup(0, topCount, 0); geo.addGroup(topCount, pos.length / 3 - topCount, 1); geo.computeVertexNormals(); const mesh = new THREE.Mesh(geo, [topMat, sideMat]); mesh.castShadow = true; mesh.receiveShadow = true; group.add(mesh); } c.regions.forEach(rg => { if (osbOn) slab(rg.poly, rg.under, rg.tV, m.osbMaterial, m.osbMaterial); if (osbOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV, rg.bV, m.osbMaterial, m.osbMaterial); // dakbeschot if (covOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV + rg.bV, rg.covV, covMat, covMat, (pt) => rg.along === 'p' ? [pt.z * toM, pt.x * toM / rg.cos] : [pt.x * toM, pt.z * toM / rg.cos]); }); // Balken: hun (afgezaagde) veelhoek in de plattegrond, onderkant op // bm.yAt, hoogte jV verticaal. c.beams.forEach(bm => { if (osbOn && bm.kind !== 'kopbalk') return; // ingesloten tussen plafondplaat en dakbeschot slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial); }); return group; } // ------------------------------------------------------------ // Dakrand over de hele woning // ------------------------------------------------------------ // Splits de ribben van de omtrek op elke hoek van een dak-rechthoek die op // die ribbe ligt ("hoek van een unit"), zodat spots daar ook beginnen/eindigen. 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); } // roofs: [{ unitId, rect: { x0, x1, z0, z1 }, fascia: bool, topAt(x, z), // ridges: [{ axis, value }] }] (mm, woning-assen); topAt = bovenkant // dakleer (absoluut, loopt buiten het dak door met de helling). Geeft per // ribbe (segments) de geometrie terug; edge.spots wordt door LightingModel // gevuld (met y = onderkant boeideel ter plaatse). // facadeDepth(a, b, normal) (optioneel, mm): dikte van de gevelafwerking // (doek + latwerk, steen) buiten het wandvlak langs die ribbe. De gevel // loopt achter het boeideel door tot de bovenkant van het dak; het // boeideel / de daktrim valt er overheen en sluit aan de dakkant aan op // het dak (binnenrand = dakrand, van boven dicht). De spots komen in de // onderplaat vóór de gevel. Negatief = gevel van een ander (hoger) deel // die naar dit dak toe kijkt: de rand stopt tegen die afgewerkte gevel. function computeEdge(settings, roofs, facadeDepth) { const s = normalizeSettings(settings); const rects = roofs.map(r => r.rect); if (!roofs.length) return null; // Hoogtegroepen: daken die tegen elkaar liggen horen bij één doorlopende // rand, tenzij het hoogteverschil langs de hele naad minstens de // boeideelhoogte is. Dan krijgt het hoge dak een eigen rand rondom (ook // boven het lage dak), en loopt de rand van het lage dak tot de gevel // van het hoge. 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 fascia = owner.fascia !== false; const off = fascia ? offBoei : offTrim; // Ribbe tegen een dak van een andere groep: tegen een hoger dak // vervalt hij (daar staat de gevel van het hoge), boven een lager // dak blijft hij, maar 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); return { a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b), outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j], // Bovenkant van de rand (dakleer + opstand). topAt: (p) => owner.topAt(p.x, p.z) + EDGE_ABOVE_MM, ridges: owner.ridges || [], overRoof: !!nb && !dropped, dropped }; }); // Binnenrand: alleen tegen de gevel van een hoger deel (negatieve // diepte) inkorten; spotlijn vóór de gevel. Op een hoek van twee haakse // ribben het snijpunt van beide verschoven lijnen, anders loodrecht. const depRaw = segs.map(sg => (facadeDepth ? facadeDepth(sg.a, sg.b, sg.normal) || 0 : 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]) }; }); segs.forEach((sg, i) => Object.assign(sg, shifted[i], { facadeDepth: depRaw[i] })); // Naast een vervallen ribbe (tegen een hoger dak): // - ligt het verlengde van de rand in het hoge deel, dan loopt hij // recht door tot die gevel (spot op spotafstand van de gevel); // - ligt het verlengde ernaast (de rand loopt langs de zijgevel van // het hoge deel), dan loopt hij recht door tot de overstek, onder // de rand van het hoge dak (van bovenaf overlappen ze). 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, top, edgeAbove: EDGE_ABOVE_MM, fasciaLengthMM: segments.filter(sg => sg.fascia).reduce((t, sg) => t + segLen(sg), 0), trimLengthMM: segments.filter(sg => !sg.fascia).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: [] }; } // Convexe veelhoek (plattegrond, mm) knippen met een halfvlak // (Sutherland-Hodgman): houd de punten waar f(p) >= 0. 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; } // Prisma onder een convexe veelhoek: bovenkant per hoekpunt op topFn(p), // onderkant h lager (mm -> meters). function prismGeometry(poly, topFn, h) { const toM = 0.001, n = poly.length, pos = []; 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 tri = (a, b, c) => pos.push(...a, ...b, ...c); for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); tri(B[0], B[k + 1], B[k]); } for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); tri(T[k], B[j], T[j]); } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.computeVertexNormals(); return geo; } // 3D van de dakrand: per ribbe een vierhoek (dakrand -> buitenrand, in // verstek), naar beneden over de hoogte van boeideel resp. daktrim. De // bovenkant volgt het dak (sg.topAt); op een nok wordt de ribbe geknipt. // Plus de boeideel-spots (edge.spots) in de onderplaat. // opts: { edgeVisible (volgt "Beplating"), spotsVisible (volgt "Lampen") } function buildEdge3D(edge, materials, opts) { const o = opts || {}; const toM = 0.001; const group = new THREE.Group(); if (!edge) return group; // Het boeidelen-materiaal wordt gedeeld: 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); }); // Boeideel: totale hoogte fasciaHeight vanaf de bovenkant van de rand; // daktrim: trimHeight onder het dakleer + de opstand erboven. const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight + edge.edgeAbove; polys.forEach(pl => { const mesh = new THREE.Mesh(prismGeometry(pl, sg.topAt, h), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial); mesh.visible = o.edgeVisible !== false; group.add(mesh); }); }); 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; 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 if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor; return s; } 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, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions, computeEdge, buildEdge3D, normalizeSettings }; })();