Files
Tectum/js/roof/roof-model.js
T

467 lines
22 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. 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 middenpunt (nok) zijn de kopgevels, het dak
// volgt hun daklijn (plat, lessenaar of zadel). 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.
//
// 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 });
}
// ------------------------------------------------------------
// 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 }];
if (w.heightMid > 0) pts.push({ p: c, h: Math.max(w.heightMid, w.heightLeft, w.heightRight) });
return pts.sort((a, b) => a.p - b.p);
}
// Dakvorm van een unit. layout = UnitModel.computeLayout-resultaat.
// Geeft { type: 'plat'|'lessenaar'|'zadel', axis: 'x'|'z', profile }:
// de hoogte van de dakonderkant (vanaf het loopvlak) hangt alleen af van
// de positie langs axis (profileHeight). 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.
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)) {
return { type: profile.length > 2 ? 'zadel' : 'lessenaar', 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);
// 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),
cx: alongX ? mid - xc : 0, cz: alongX ? 0 : mid - zc, y: (h0 + h1) / 2,
// Verticale dikte (dek + dakleer) van dit vlak.
vThick: thickness(data.calcData) / cos
});
});
const planeAt = (q) => planes.find((pl, i) => q < pl.p1 || i === planes.length - 1);
const undersideAt = (x, z) => H(alongX ? x : z);
const topAt = (x, z) => {
const q = alongX ? x : z;
const onKink = planes.filter(pl => Math.abs(q - pl.p0) < 0.5 || Math.abs(q - pl.p1) < 0.5);
const t = onKink.length > 1 ? Math.max(...onKink.map(pl => pl.vThick)) : planeAt(q).vThick;
return H(q) + t;
};
return {
shape, planes, specs: sumSpecs(planes.map(pl => pl.specs)),
undersideAt, topAt,
ridges: kinks.map(value => ({ axis: shape.axis, value })),
maxTop: Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick))
};
}
// 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek,
// Y = 0 op het loopvlak): per dakvlak build3D, gekanteld om de onderkant
// van het plafond zodat die precies op de daklijn van de wanden ligt.
function buildUnit3D(unitRoof, opts) {
const toM = 0.001;
const group = new THREE.Group();
unitRoof.planes.forEach(pl => {
const g = new THREE.Group();
g.add(build3D(pl.data.calcData, pl.data.resolvedOpenings, opts));
g.position.set(pl.cx * toM, pl.y * toM, pl.cz * toM);
// Eerst kantelen om lokaal X (lokaal +Z = oplopende as), dan draaien.
g.rotation.set(-pl.theta, pl.yaw, 0, 'YXZ');
group.add(g);
});
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).
function computeEdge(settings, roofs) {
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],
topAt: (p) => owner.topAt(p.x, p.z), ridges: owner.ridges || [],
overRoof: !!nb && !dropped, dropped
};
});
// Naast een vervallen ribbe loopt de rand recht door tot de gevel.
segs.forEach((sg, i) => {
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 square = (key, v) => { sg[key] = alongZ ? { x: sg[key].x, z: v.z } : { x: v.x, z: sg[key].z }; };
if (!sg.dropped && prev.dropped) square('outerA', sg.a);
if (!sg.dropped && next.dropped) square('outerB', sg.b);
});
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,
top, trimAbove: 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: []
};
}
// 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;
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);
});
const h = sg.fascia ? edge.fasciaHeight : edge.trimHeight;
const topFn = sg.fascia ? sg.topAt : (p) => sg.topAt(p) + edge.trimAbove;
polys.forEach(pl => {
const mesh = new THREE.Mesh(prismGeometry(pl, topFn, 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
return s;
}
return {
DAKLEER_MM, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D,
computeEdge, buildEdge3D, normalizeSettings
};
})();