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>
235 lines
11 KiB
JavaScript
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
|
|
};
|
|
})();
|