Files
Tectum/js/roof/roof-model.js
T
MachielandClaude Opus 5.5 c690e72d13 Tectum v0.4.0 — mijlpaal gevelopbouw
Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units,
woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op
vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:38:54 +02:00

235 lines
11 KiB
JavaScript

/*!
* 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, isolatieVisible: opts.isolatieVisible !== 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
};
})();