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

1089 lines
56 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// Een dakvlak is een vloer-state, 180° gespiegeld: OSB-plaat onder (plafond), balken erboven.
window.RoofModel = (() => {
const DAKLEER_MM = 2; // visuele dikte
const BESCHOT_MM = 18;
const DENSITY_DAKLEER_KGM2 = 1.4;
const TRIM_THICK_MM = 25;
const EDGE_ABOVE_MM = 80;
const SPOT_RATIO = 0.4;
const OVERHANG_MIN = 200, OVERHANG_MAX = 400;
const DEFAULT_SETTINGS = {
overhang: 300,
fasciaHeight: 300,
spotMin: 800,
spotMax: 1200,
trimHeight: 80,
fasciaColor: 'antraciet'
};
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 }
};
// Riet e.d. alleen bij een hellend dak; plat valt terug op dakleer.
// Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer hier toevoegen).
const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'], colors: { '001': '#947d69', '002': '#7c4c3f', '003': '#735e43' } };
const FELS = { thickMM: 25, kgM2: 6.5, seamMM: 500, colors: { zink: ['Felsplaat zink', '#8e9497'], antraciet: ['Felsplaat antraciet', '#3d4146'], zwart: ['Felsplaat zwart', '#1f2022'] } };
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;
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
};
}
function coveringThumbUrl(key, px) {
const c = COVERINGS[key];
return c && c.file ? c.file.replace(/[^/]+$/, (f) => `thumb/${px || 64}/` + f.replace(/\.jpg$/, '.png')) : null;
}
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)); }
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, extend: { f: 0, r: 0, b: 0, l: 0 }, fascia: true, covering: 'dakleer', deck };
}
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 }),
steelMaterial: new THREE.MeshStandardMaterial({ color: 0x5f666c, roughness: 0.5, metalness: 0.6 }),
ceilingMaterial: new THREE.MeshStandardMaterial({ color: 0xf4f3ef, roughness: 0.92, side: THREE.DoubleSide }),
spotOnMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe9a8, emissiveIntensity: 0.9 })
});
}
// Vloer-3D gespiegeld met scale.y = -1 (three.js corrigeert de vlakoriëntatie zelf).
function build3D(calcData, resolvedOpenings, opts) {
const m = opts.materials;
const toM = 0.001;
const group = new THREE.Group();
const deckMats = opts.ceilingMaterial ? Object.assign({}, m, { osbMaterial: opts.ceilingMaterial }) : m;
const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, {
materials: deckMats, 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);
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 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) {
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;
}
function computeSpecs(calcData, resolvedOpenings, covering) {
const deck = FloorModel.computeSpecs(calcData, resolvedOpenings);
const areaM2 = deck.osbAreaM2;
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
});
}
// De daklijn hoort bij de wand zonder naden (wl.ref): is de wand door een naad langer of korter,
// dan blijft het profiel gelijk.
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 ref = wl.ref || { w: wl.width, u0: 0 };
const at = (x) => c - s.dir * wl.width / 2 + s.dir * (x - ref.u0);
const pts = [{ p: at(0), h: w.heightLeft }, { p: at(ref.w), h: w.heightRight }];
WallModel.wallTopPoints(w, ref.w, w.heightLeft, w.heightRight)
.forEach(q => pts.push({ p: at(q.x), h: q.h }));
return pts.sort((a, b) => a.p - b.p);
}
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;
return shapeFromProfile(axis, profile);
}
const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws)));
return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] };
}
function shapeFromProfile(axis, profile) {
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);
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' : 'zadel';
return { type, axis, profile };
}
// Helling wolfseinden: die van het dak, maar minstens zo steil dat ze de nok halen vóór het midden.
function hipGeom(shape, q0, q1) {
const [, A, R, B] = shape.profile, hipH = shape.hip.h, top = R.h, qM = (q0 + q1) / 2;
const sA = (top - A.h) / (R.p - A.p), sB = (top - B.h) / (B.p - R.p);
const tH = Math.max((sA + sB) / 2, (top - hipH) / Math.max(1, qM - q0));
return { pA: A.p, pR: R.p, pB: B.p, hipH, top, sA, sB, tH, inset: (top - hipH) / tH, q0, q1, qM };
}
// De daklijn is bol, dus het profiel is het minimum van zijn (doorgetrokken) stukken.
function shapeLines(shape) {
const pr = shape.profile;
if (pr.length === 1) return [{ a: pr[0].h, bp: 0, bq: 0 }];
const out = pr.slice(0, -1).map((a, i) => {
const b = pr[i + 1], s = (b.h - a.h) / (b.p - a.p);
return { a: a.h - s * a.p, bp: s, bq: 0 };
});
if (shape.type === 'wolf' && shape.hip && shape.hip.q0 !== undefined) {
const g = hipGeom(shape, shape.hip.q0, shape.hip.q1);
out.push({ a: g.hipH - g.q0 * g.tH, bp: 0, bq: g.tH }, { a: g.hipH + g.q1 * g.tH, bp: 0, bq: -g.tH });
}
return out;
}
// 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);
}
// Stalen nokbalk, per unit één (modules worden los gebouwd), opgelegd op de kopgevels.
// Kleinste IPE die voldoet aan sterkte (1,2·G + 1,5·S, σ ≤ fy) en doorbuiging (G + S, ≤ L/250).
// [naam, h, b, tw, tf (mm), kg/m, Iy (cm4), Wy (cm3)]
const IPE = [
['IPE 100', 100, 55, 4.1, 5.7, 8.1, 171, 34.2], ['IPE 120', 120, 64, 4.4, 6.3, 10.4, 318, 53.0],
['IPE 140', 140, 73, 4.7, 6.9, 12.9, 541, 77.3], ['IPE 160', 160, 82, 5.0, 7.4, 15.8, 869, 109],
['IPE 180', 180, 91, 5.3, 8.0, 18.8, 1317, 146], ['IPE 200', 200, 100, 5.6, 8.5, 22.4, 1943, 194],
['IPE 220', 220, 110, 5.9, 9.2, 26.2, 2772, 252], ['IPE 240', 240, 120, 6.2, 9.8, 30.7, 3892, 324],
['IPE 270', 270, 135, 6.6, 10.2, 36.1, 5790, 429], ['IPE 300', 300, 150, 7.1, 10.7, 42.2, 8356, 557],
['IPE 330', 330, 160, 7.5, 11.5, 49.1, 11770, 713], ['IPE 360', 360, 170, 8.0, 12.7, 57.1, 16270, 904],
['IPE 400', 400, 180, 8.6, 13.5, 66.3, 23130, 1160], ['IPE 450', 450, 190, 9.4, 14.6, 77.6, 33740, 1500],
['IPE 500', 500, 200, 10.2, 16.0, 90.7, 48200, 1930]
].map(([name, h, b, tw, tf, kgM, I, W]) => ({ name, h, b, tw, tf, kgM, I, W }));
const STEEL_RIDGE = {
fy: 235, E: 210000, // S235, N/mm2
snowKNm2: 0.56, // sneeuw op het dak (0,8 × 0,7 kN/m2)
finishKNm2: 0.3, // afwerking/installaties onder het dak
gammaG: 1.2, gammaQ: 1.5, deflection: 250
};
const ridgeTypes = ['zadel', 'wolf'];
// span: [q0, q1] waar de nok echt loopt (dakaansluiting: tot de hoekkeper).
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2, span) {
if (roofState.steelRidge === false || !ridgeTypes.includes(shape.type)) return null;
const pr = shape.profile, inner = pr.slice(1, -1);
if (!inner.length) return null;
const R = inner.reduce((a, q) => q.h > a.h ? q : a);
const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1;
if (R.p < a0 - 0.5 || R.p >= a1 - 0.5) return null;
const uq0 = alongX ? rect.z0 : rect.x0, uq1 = alongX ? rect.z1 : rect.x1;
let q0 = uq0, q1 = uq1;
if (shape.type === 'wolf' && shape.hip) {
const hq = shape.hip.q0 !== undefined ? [shape.hip.q0, shape.hip.q1] : [uq0, uq1];
const g = hipGeom(shape, hq[0], hq[1]);
q0 = Math.max(uq0, Math.min(g.qM, g.q0 + g.inset));
q1 = Math.min(uq1, Math.max(g.qM, g.q1 - g.inset));
}
if (span) { q0 = Math.max(q0, span[0]); q1 = Math.min(q1, span[1]); }
const L = q1 - q0;
if (L < 100) return null;
// q in N/mm = kN/m.
const trib = (a1 - a0) / 2 / 1000;
const gRoof = (kgM2 * 9.81e-3 + STEEL_RIDGE.finishKNm2) * trib, sQ = STEEL_RIDGE.snowKNm2 * trib;
const check = (s) => {
const g = gRoof + s.kgM * 9.81e-3;
const M = (STEEL_RIDGE.gammaG * g + STEEL_RIDGE.gammaQ * sQ) * L * L / 8;
const util = M / (s.W * 1e3 * STEEL_RIDGE.fy);
const defl = 5 * (g + sQ) * Math.pow(L, 4) / (384 * STEEL_RIDGE.E * s.I * 1e4);
return { util, defl, deflMax: L / STEEL_RIDGE.deflection, ok: util <= 1 && defl <= L / STEEL_RIDGE.deflection };
};
let sec = IPE.find(s => check(s).ok);
const ok = !!sec;
if (!sec) sec = IPE[IPE.length - 1];
const c = check(sec);
const segs = pr.slice(0, -1).map((a, i) => {
const b = pr[i + 1], sl = (b.h - a.h) / (b.p - a.p);
return { at: (p) => a.h + (p - a.p) * sl, f: Math.sqrt(1 + sl * sl) };
});
const under = cd.underlayThick, jH = cd.joistH;
const zone = (p, t) => Math.min(...segs.map(sg => sg.at(p) + t * sg.f));
const yTop = Math.min(zone(R.p - sec.b / 2, under + jH), zone(R.p + sec.b / 2, under + jH));
const yBottom = yTop - sec.h;
const below = Math.max(0, Math.round(zone(R.p, 0) - yBottom));
const fillW = (sec.b - sec.tw) / 2, fillH = sec.h - 2 * sec.tf;
const fillM3 = 2 * fillW * fillH * L / 1e9;
return {
profile: sec, ok, util: c.util, defl: c.defl, deflMax: c.deflMax, loadKNm: gRoof + sQ + sec.kgM * 9.81e-3,
p: R.p, q0, q1, length: Math.round(L), yTop, yBottom, below,
steelKg: sec.kgM * L / 1000, fillW, fillH, fillM3, fillKg: fillM3 * DENSITY_WOOD_KGM3
};
}
function buildRidgeBeam3D(rb, xc, zc, alongX, m) {
const toM = 0.001, s = rb.profile, L = rb.length * toM;
const b = s.b * toM / 2, w = s.tw * toM / 2, h = s.h * toM, t = s.tf * toM;
const shp = new THREE.Shape();
[[-b, 0], [b, 0], [b, t], [w, t], [w, h - t], [b, h - t], [b, h], [-b, h], [-b, h - t], [-w, h - t], [-w, t], [-b, t]]
.forEach(([x, y], i) => i ? shp.lineTo(x, y) : shp.moveTo(x, y));
shp.closePath();
const g = new THREE.Group();
const beam = new THREE.Mesh(new THREE.ExtrudeGeometry(shp, { steps: 1, depth: L, bevelEnabled: false }), m.steelMaterial);
beam.castShadow = beam.receiveShadow = true;
g.add(beam);
const fw = rb.fillW * toM, fh = rb.fillH * toM;
[-1, 1].forEach(sd => {
const fill = new THREE.Mesh(new THREE.BoxGeometry(fw, fh, L), m.woodMaterial);
fill.position.set(sd * (w + fw / 2), t + fh / 2, L / 2);
g.add(fill);
});
// Lokaal: x dwars, y omhoog, z langs de nok (vanaf q0).
if (alongX) g.position.set((rb.p - xc) * toM, rb.yBottom * toM, (rb.q0 - zc) * toM);
else { g.rotation.y = Math.PI / 2; g.position.set((rb.q0 - xc) * toM, rb.yBottom * toM, (rb.p - zc) * toM); }
return g;
}
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;
}
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);
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;
} 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,
vThick: thickness(data.calcData, covering) / cos
});
});
// Knik in de daklijn: het steilere vlak is verticaal dikker, dus verstek: onderkant op de knik,
// bovenkant op het snijpunt van de bovenvlakken.
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);
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));
};
let cap = null, undersideAt = mainUnder, topAt = mainTop, outPlanes = planes;
const pA = shape.type === 'wolf' && shape.hip ? shape.profile[1].p : 0, pB = pA && shape.profile[3].p;
if (shape.type === 'wolf' && shape.hip && Math.min(pB, a1) - Math.max(pA, a0) > 1) cap = computeCap(roofState, shape, rect, alongX, covering);
if (cap) {
outPlanes = planes.filter(pl => !(pl.p0 >= pA - 0.5 && pl.p1 <= pB + 0.5));
const at = (x, z) => ({ x: alongX ? x : z, z: alongX ? z : x });
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));
}
const specs = sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : []));
const ridgeBeam = computeRidgeBeam(roofState, shape, rect, alongX, flat.calcData, specs.totalKg / Math.max(0.1, W * D / 1e6));
addRidgeSpecs(specs, ridgeBeam);
return {
shape, covering, planes: outPlanes, specs, ridgeBeam,
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))
};
}
function addRidgeSpecs(specs, ridgeBeam) {
Object.assign(specs, {
nokStaalM: ridgeBeam ? ridgeBeam.length / 1000 : 0, nokStaalKg: ridgeBeam ? ridgeBeam.steelKg : 0,
nokVulhoutM3: ridgeBeam ? ridgeBeam.fillM3 : 0, nokVulhoutKg: ridgeBeam ? ridgeBeam.fillKg : 0
});
specs.totalKg += specs.nokStaalKg + specs.nokVulhoutKg;
}
// Dakaansluiting (HouseModel.roofJoins): vlakken h = a + bx·x + bz·z, samengevoegd volgens expr
// (vlaknummer of ['min' | 'max', ...]). extra(k): ophoging per vlak (bovenkant dak).
const planeAt = (pl, p) => pl.a + pl.bx * p.x + pl.bz * p.z;
function joinValue(join, p, extra) {
const at = (e) => Array.isArray(e) ? Math[e[0]](...e.slice(1).map(at)) : planeAt(join.planes[e], p) + (extra ? extra(e) : 0);
return at(join.expr);
}
function convexHull(pts) {
const sorted = pts.slice().sort((a, b) => a.x - b.x || a.z - b.z);
const cross = (o, a, b) => (a.x - o.x) * (b.z - o.z) - (a.z - o.z) * (b.x - o.x);
const half = (list) => list.reduce((h, q) => {
while (h.length >= 2 && cross(h[h.length - 2], h[h.length - 1], q) <= 0.01 * Math.hypot(q.x - h[h.length - 2].x, q.z - h[h.length - 2].z)) h.pop();
h.push(q);
return h;
}, []);
const lo = half(sorted), up = half(sorted.slice().reverse());
const out = lo.slice(0, -1).concat(up.slice(0, -1));
return out.filter((p, k) => { const q = out[(k + 1) % out.length]; return Math.hypot(q.x - p.x, q.z - p.z) > 0.5; });
}
// Dak van één unit in een dakaansluiting (join in unit-lokale maten): per vlak de convexe stukken binnen het dek,
// sporen haaks op de goot (kreupel tegen kil- en hoekkepers), kepers en nokken op de grenzen tussen de vlakken.
// Alles als houtwerk zoals het bovenste deel van een wolfsdak (cap); de eigen nok wordt de stalen nokbalk.
function computeJoinedRoof(roofState, join, rect, shape) {
const fp = roofState.deck.floor;
const joistW = fp.joistWidth || 70, joistH = fp.joistHeight || 220, underlay = fp.underlayThick || 22;
const covering = coveringFor(roofState.covering, shape.type);
const cov = COVERINGS[covering] || COVERINGS.dakleer;
const pls = join.planes.map((pl, k) => {
const slope = Math.hypot(pl.bx, pl.bz), cos = 1 / Math.sqrt(1 + slope * slope);
const tV = underlay / cos, jV = joistH / cos, bV = BESCHOT_MM / cos, covV = cov.thickMM / cos;
const under = (p) => planeAt(pl, p);
return { key: k, pl, slope, cos, tV, jV, bV, covV, vThick: tV + jV + bV + covV, under, f: under, along: Math.abs(pl.bx) >= Math.abs(pl.bz) ? 'p' : 'q' };
});
const activeAt = (p) => {
const h = joinValue(join, p);
return pls.reduce((a, b) => Math.abs(b.under(p) - h) < Math.abs(a.under(p) - h) ? b : a);
};
const box = [{ x: rect.x0, z: rect.z0 }, { x: rect.x1, z: rect.z0 }, { x: rect.x1, z: rect.z1 }, { x: rect.x0, z: rect.z1 }];
const clipBox = (poly) => [(p) => p.x - rect.x0, (p) => rect.x1 - p.x, (p) => p.z - rect.z0, (p) => rect.z1 - p.z]
.reduce((pl, f) => pl.length >= 3 ? clipPoly(pl, f) : pl, poly);
const pairs = [];
pls.forEach((A, i) => pls.slice(i + 1).forEach(B => {
if (Math.hypot(A.pl.bx - B.pl.bx, A.pl.bz - B.pl.bz) > 1e-9) pairs.push({ A, B, f: (p) => A.under(p) - B.under(p) });
}));
// Afgeronde wandhoogtes maken bijna samenvallende lijnen: splinters (gemiddeld < 0,5 mm breed) vallen weg.
const perim = (poly) => poly.reduce((t, p, k) => { const q = poly[(k + 1) % poly.length]; return t + Math.hypot(q.x - p.x, q.z - p.z); }, 0);
const sliver = (poly) => quadArea(poly) < Math.max(100, 0.25 * perim(poly));
let cells = [box];
pairs.forEach(({ f }) => {
cells = cells.flatMap(c => [clipPoly(c, f), clipPoly(c, p => -f(p))]).filter(c => c.length >= 3 && !sliver(c));
});
const centroid = (poly) => ({ x: poly.reduce((t, p) => t + p.x, 0) / poly.length, z: poly.reduce((t, p) => t + p.z, 0) / poly.length });
const regions = [];
pls.forEach(pl => {
const list = cells.filter(c => activeAt(centroid(c)) === pl).map(convexHull);
// Aangrenzende cellen samenvoegen zolang het geheel convex blijft.
let merged = true;
while (merged) {
merged = false;
for (let i = 0; i < list.length && !merged; i++) {
for (let j = i + 1; j < list.length && !merged; j++) {
const h = convexHull(list[i].concat(list[j]));
if (quadArea(h) - quadArea(list[i]) - quadArea(list[j]) < 0.5 * perim(h)) { list[i] = h; list.splice(j, 1); merged = true; }
}
}
}
list.filter(poly => poly.length >= 3).forEach(poly => regions.push(Object.assign({}, pl, { poly })));
});
// Grenzen tussen twee vlakken: bol = nok (waterpas) of hoekkeper, hol = kilkeper.
const members = [];
pairs.forEach(({ A, B, f }) => {
const gx = A.pl.bx - B.pl.bx, gz = A.pl.bz - B.pl.bz, g = Math.hypot(gx, gz), c0 = f({ x: 0, z: 0 });
const o = { x: -c0 * gx / (g * g), z: -c0 * gz / (g * g) }, d = { x: -gz / g, z: gx / g }, n = { x: gx / g, z: gz / g };
let t0 = -Infinity, t1 = Infinity;
[['x', rect.x0, rect.x1], ['z', rect.z0, rect.z1]].forEach(([k, lo, hi]) => {
if (Math.abs(d[k]) < 1e-9) { if (o[k] < lo || o[k] > hi) t1 = -Infinity; return; }
const a = (lo - o[k]) / d[k], b = (hi - o[k]) / d[k];
t0 = Math.max(t0, Math.min(a, b));
t1 = Math.min(t1, Math.max(a, b));
});
if (!(t1 - t0 >= 100)) return;
const pt = (t) => ({ x: o.x + d.x * t, z: o.z + d.z * t });
const ts = [t0, t1];
pairs.forEach(q => {
const dn = q.f(pt(1)) - q.f(pt(0));
if (Math.abs(dn) < 1e-9) return;
const t = -q.f(pt(0)) / dn;
if (t > t0 && t < t1) ts.push(t);
});
ts.sort((a, b) => a - b);
const segs = [];
let cur = null;
ts.slice(0, -1).forEach((ta, k) => {
const tb = ts[k + 1];
if (tb - ta < 1) return;
const m = pt((ta + tb) / 2), off = (s) => ({ x: m.x + n.x * s, z: m.z + n.z * s });
const s1 = activeAt(off(2)), s2 = activeAt(off(-2));
if (Math.abs(joinValue(join, m) - A.under(m)) > 0.5 || !((s1 === A && s2 === B) || (s1 === B && s2 === A))) { cur = null; return; }
const onA = off(s1 === A ? 2 : -2), convex = B.under(onA) > A.under(onA);
if (cur && cur.convex === convex) { cur.tb = tb; return; }
cur = { ta, tb, convex };
segs.push(cur);
});
// Waterpas en bol: nok als de vlakken van elkaar af aflopen, anders een knik.
const ridge = A.pl.bx * B.pl.bx + A.pl.bz * B.pl.bz < 0;
segs.forEach(sg => {
const P = pt(sg.ta), Q = pt(sg.tb), level = Math.abs(A.under(P) - A.under(Q)) < 1;
members.push({ kind: !sg.convex ? 'kilkeper' : !level ? 'hoekkeper' : ridge ? 'nokbalk' : 'knikbalk', A: P, B: Q, rg: A });
});
});
const alongX = shape.axis === 'x';
const own = members.find(m => m.kind === 'nokbalk' && (alongX ? Math.abs(m.A.x - m.B.x) < 1 : Math.abs(m.A.z - m.B.z) < 1));
const steel = own && roofState.steelRidge !== false;
const { hw, beams, awayFrom, addBeam } = beamTools(joistW);
members.forEach(m => { if (!(steel && m === own)) addBeam(m.kind, m.rg, m.A, m.B, [], true); });
pls.forEach(pl => {
const polys = regions.filter(rg => rg.key === pl.key).map(rg => rg.poly);
if (!polys.length) return;
const fixed = pl.along === 'p' ? 'z' : 'x';
const [lo, hi] = fixed === 'z' ? [rect.z0, rect.z1] : [rect.x0, rect.x1];
joistSpread(fp, hi - lo).map(v => lo + v).forEach(v => {
const spans = polys.map(poly => cutLine(poly, fixed, v)).filter(Boolean).sort((a, b) => a[0] - b[0]);
const runs = [];
spans.forEach(sp => {
const last = runs[runs.length - 1];
if (last && sp[0] <= last[1] + 1) last[1] = Math.max(last[1], sp[1]);
else runs.push(sp.slice());
});
runs.forEach(span => {
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', pl, A, B, clips);
});
});
});
const ownBeams = clipBeams(beams, clipBox);
const capSpecs = regionSpecs(ownBeams, regions, joistW, joistH, underlay, covering);
const kgM2 = capSpecs.totalKg / Math.max(0.1, (rect.x1 - rect.x0) * (rect.z1 - rect.z0) / 1e6);
const q = own ? (alongX ? [own.A.z, own.B.z] : [own.A.x, own.B.x]) : null;
// Wolfsdak: de nok loopt hier tot de aansluiting, de inkorting van de wolfseinden volgt uit span.
const ridgeShape = shape.type === 'wolf' ? Object.assign({}, shape, { type: 'zadel' }) : shape;
const ridgeBeam = steel ? computeRidgeBeam(roofState, ridgeShape, rect, alongX, { underlayThick: underlay, joistH }, kgM2, [Math.min(...q), Math.max(...q)]) : null;
if (ridgeBeam) ridgeBeam.alongX = alongX;
const specs = Object.assign({}, capSpecs);
addRidgeSpecs(specs, ridgeBeam);
const heights = regions.flatMap(rg => rg.poly.map(p => rg.under(p)));
const maxTop = Math.max(...regions.map(rg => Math.max(...rg.poly.map(p => rg.under(p) + rg.vThick))));
const cap = {
kind: 'join', pA: rect.x0, pB: rect.x1, q0: rect.z0, q1: rect.z1, eave: Math.min(...heights), ridgeH: Math.max(...heights),
slope: Math.max(...regions.map(rg => rg.slope)), joistW, joistH, regions, beams: ownBeams, specs: capSpecs, maxTop
};
// Knikpunten van de daklijn op de randen (nok, knik, wolfseind, kepers): daar knikt ook het boeideel.
const ridges = [];
members.forEach(m => [m.A, m.B].forEach(p => {
const onX = Math.abs(p.x - rect.x0) < 1 || Math.abs(p.x - rect.x1) < 1, onZ = Math.abs(p.z - rect.z0) < 1 || Math.abs(p.z - rect.z1) < 1;
const r = onX && !onZ ? { axis: 'z', value: p.z } : onZ && !onX ? { axis: 'x', value: p.x } : null;
if (r && !ridges.some(q => q.axis === r.axis && Math.abs(q.value - r.value) < 1)) ridges.push(r);
}));
return {
shape, covering, planes: [], specs, ridgeBeam, cap, rect, alongX: true, ridges, maxTop,
undersideAt: (x, z) => joinValue(join, { x, z }),
topAt: (x, z) => joinValue(join, { x, z }, k => pls[k].vThick)
};
}
const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel
function computeCap(roofState, shape, rect, alongX, covering) {
const H = (p) => profileHeight(shape.profile, p);
const uq0 = alongX ? rect.z0 : rect.x0, uq1 = alongX ? rect.z1 : rect.x1;
const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1;
const hq = shape.hip.q0 !== undefined ? [shape.hip.q0, shape.hip.q1] : [uq0, uq1];
const { pA, pR, pB, hipH, top, sA, sB, tH, inset, q0, q1, qM } = hipGeom(shape, hq[0], hq[1]);
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 cp0 = Math.max(pA, a0), cp1 = Math.min(pB, a1), cq0 = Math.max(q0, uq0), cq1 = Math.min(q1, uq1);
const clipBox = (poly) => [(p) => p.x - cp0, (p) => cp1 - p.x, (p) => p.z - cq0, (p) => cq1 - p.z]
.reduce((pl, f) => pl.length >= 3 ? clipPoly(pl, f) : pl, poly);
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)); });
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);
const clipped = regions.map(rg => Object.assign({}, rg, { poly: clipBox(rg.poly) }))
.filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100);
if (!clipped.some(rg => rg.along === 'q')) return null;
const { hw, beams, awayFrom, addBeam } = beamTools(joistW);
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));
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);
// Stalen nokbalk (computeRidgeBeam) in plaats van hout: wel om de sporen
// tegen af te zagen, niet als houten balk.
if (ridgeBeam && roofState.steelRidge !== false) beams.splice(beams.indexOf(ridgeBeam), 1);
}
const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []);
const qs = joistSpread(fp, q1 - q0).map(v => q0 + v), ps = joistSpread(fp, 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);
});
});
const ownBeams = clipBeams(beams, clipBox);
const specs = regionSpecs(ownBeams, clipped, joistW, joistH, underlay, covering);
const maxTop = Math.max(...clipped.map(rg => Math.max(...rg.poly.map(pt => rg.under(pt) + rg.vThick))));
return { pA: cp0, pR, pB: cp1, q0: cq0, q1: cq1, hipH, slope: sA, joistW, joistH, regions: clipped, beams: ownBeams, specs, maxTop };
}
// Houtwerk op dakvlakken met een eigen omtrek (wolfseinden, dakaansluiting); balken als rechthoek in bovenaanzicht.
function 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;
}
function 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));
}
function joistSpread(fp, len) {
return FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: fp.joistWidth || 70, joistHeight: fp.joistHeight || 220, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: fp.underlayThick || 22 })
.joists.map(x => x + len / 2);
}
function beamTools(joistW) {
const hw = joistW / 2, beams = [];
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 }];
};
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 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;
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, slope,
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
};
beams.push(bm);
return bm;
};
return { hw, beams, awayFrom, addBeam };
}
function clipBeams(beams, clip) {
return beams.map(bm => {
const poly = clip(bm.poly);
if (poly.length < 3 || quadArea(poly) < 100) return null;
const dx = bm.B.x - bm.A.x, dz = bm.B.z - bm.A.z, L = Math.hypot(dx, dz);
const ts = poly.map(p => ((p.x - bm.A.x) * dx + (p.z - bm.A.z) * dz) / L);
const plan = Math.max(...ts) - Math.min(...ts), slope = bm.slope;
if (plan < 50) return null;
return Object.assign({}, bm, { poly, length: Math.round(plan * Math.sqrt(1 + slope * slope)) });
}).filter(Boolean);
}
function regionSpecs(beams, regions, joistW, joistH, underlay, covering) {
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;
return 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);
}
// Afschuiving i.p.v. kanteling (lokaal +Z = oplopende as): plan = z·cos θ, Y = y/cos θ + z·sin θ.
// Zo blijven dikte haaks op de helling en lengte gelijk, en staan de kopse kanten loodrecht (zoals de zaagsnede aan sporen).
function buildUnit3D(unitRoof, opts) {
const toM = 0.001;
const group = new THREE.Group();
if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts));
if (unitRoof.ridgeBeam) {
const r = unitRoof.rect;
const rb = unitRoof.ridgeBeam;
group.add(buildRidgeBeam3D(rb, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, rb.alongX !== undefined ? rb.alongX : unitRoof.alongX, opts.materials));
}
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;
}
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();
});
}
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;
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];
};
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); };
const up = T.reduce((t, a, k) => { const b = T[(k + 1) % n]; return t + a[0] * b[2] - b[0] * a[2]; }, 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, opts.ceilingMaterial || m.osbMaterial, m.osbMaterial);
if (osbOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV, rg.bV, m.osbMaterial, m.osbMaterial);
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]);
});
c.beams.forEach(bm => {
if (osbOn && bm.kind !== 'kopbalk') return;
slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial);
});
return group;
}
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);
}
// facadeDepth negatief = gevel van een hoger deel die naar dit dak kijkt: de rand stopt tegen die gevel.
function computeEdge(settings, roofs, facadeDepth) {
const s = normalizeSettings(settings);
const rects = roofs.map(r => r.rect);
if (!roofs.length) return null;
// Daken die tegen elkaar liggen delen één rand, tenzij het hoogteverschil langs de hele naad
// minstens de boeideelhoogte is.
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;
// Tegen een hoger dak vervalt de ribbe (daar staat de gevel), boven een lager dak blijft hij 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) + EDGE_ABOVE_MM, ridges: owner.ridges || [],
overRoof: !!nb && !dropped, dropped
};
});
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] }));
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: []
};
}
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;
}
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;
}
function buildEdge3D(edge, materials, opts) {
const o = opts || {};
const toM = 0.001;
const group = new THREE.Group();
if (!edge) return group;
// Gedeeld materiaal: 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);
});
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;
spot.userData = { ref: p, glow: true };
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
const h = s.house || {};
const form = h.form ? Object.assign({}, h.form) : null;
// v0.6.2: mansarde vervallen, wordt een zadeldak met dezelfde nok.
if (form && form.type === 'mansarde') { form.type = 'zadel'; delete form.kneeH; delete form.kneeIn; delete form.kneeRatio; }
s.house = { enabled: !!h.enabled, form: form && UnitModel.ROOF_TYPES[form.type] ? form : null, cover: !!h.cover };
s.posts = PostModel.normalizeSettings(s.posts);
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, shapeFromProfile, shapeLines, wallProfile, profileHeight, computeUnitRoof, computeJoinedRoof, joinValue, buildUnit3D, capRegions,
computeEdge, buildEdge3D, normalizeSettings
};
})();