/*! * 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 (voorlopig // alleen plat dak). Geen DOM. // // Een plat dak 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. // // 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. // // Plain script, geladen na floor-model.js en core/geometry.js. // ============================================================ window.RoofModel = (() => { const DAKLEER_MM = 2; // visuele dikte van het dakleer const DENSITY_DAKLEER_KGM2 = 1.4; // EPDM ~1,4 kg/m2 const TRIM_THICK_MM = 25; // daktrim-profiel, uitstekend buiten de dakrand const TRIM_ABOVE_MM = 30; // daktrim steekt zoveel boven het dakleer uit 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 }; 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, deck }; } function thickness(calcData) { return calcData.joistH + calcData.underlayThick + DAKLEER_MM; } 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 // komt het dakleer er bovenop. function build3D(calcData, resolvedOpenings, opts) { const m = opts.materials; const toM = 0.001; const group = new THREE.Group(); const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, { materials: m, osbVisible: 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); }); const leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: DAKLEER_MM * toM, bevelEnabled: false }), m.dakleerMaterial); leer.rotation.x = Math.PI / 2; leer.position.y = deckH + DAKLEER_MM * toM; leer.visible = opts.dakleerVisible !== false; group.add(leer); return group; } // Specificaties: die van het dek (FloorModel) + dakleer. function computeSpecs(calcData, resolvedOpenings) { const deck = FloorModel.computeSpecs(calcData, resolvedOpenings); const dakleerM2 = deck.osbAreaM2; // zelfde vlak minus doorvoeren const dakleerKg = dakleerM2 * DENSITY_DAKLEER_KGM2; return Object.assign({}, deck, { dakleerM2, dakleerKg, totalKg: deck.totalKg + dakleerKg }); } // ------------------------------------------------------------ // 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 }] (mm, woning- // assen); topY = bovenkant dakleer (mm). Geeft per ribbe (segments) de // geometrie terug; edge.spots wordt door LightingModel gevuld. function computeEdge(settings, roofs, topY) { const s = normalizeSettings(settings); const rects = roofs.map(r => r.rect); const loops = rectUnionLoops(rects); if (!loops.length) return null; 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 segments = 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 = roofs.find(r => onRectEdge(r.rect, mid)) || roofs[0]; const fascia = owner.fascia !== false; const off = fascia ? offBoei : offTrim; return { a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b), outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j] }; }); const segLen = (sg) => Math.hypot(sg.outerB.x - sg.outerA.x, sg.outerB.z - sg.outerA.z); return { base, segments, overhang: s.overhang, spotRatio: SPOT_RATIO, fasciaHeight: s.fasciaHeight, trimHeight: s.trimHeight, top: topY, trimTop: topY + TRIM_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: [] }; } // 3D van de dakrand: per ribbe een geëxtrudeerd vierhoek (dakrand -> // buitenrand, in verstek), naar beneden over de hoogte van boeideel resp. // daktrim. 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; edge.segments.forEach(sg => { const pts = [sg.a, sg.b, sg.outerB, sg.outerA]; const shape = new THREE.Shape(pts.map(p => new THREE.Vector2(p.x * toM, p.z * toM))); const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight; const mesh = new THREE.Mesh( new THREE.ExtrudeGeometry(shape, { steps: 1, depth: h * toM, bevelEnabled: false }), sg.fascia ? materials.fasciaMaterial : materials.trimMaterial ); mesh.rotation.x = Math.PI / 2; // shape-y -> wereld-Z, extrusie naar beneden mesh.position.y = (sg.fascia ? edge.top : edge.trimTop) * toM; 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, (edge.top - edge.fasciaHeight) * 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 return s; } return { DAKLEER_MM, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, createDefaultRoof, thickness, createMaterials, build3D, computeSpecs, computeEdge, buildEdge3D, normalizeSettings }; })();