894 lines
45 KiB
JavaScript
894 lines
45 KiB
JavaScript
/*!
|
||
* 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, 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' : profile.length === 5 ? 'mansarde' : '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', 'mansarde', 'wolf'];
|
||
function computeRidgeBeam(roofState, shape, rect, alongX, cd, kgM2) {
|
||
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));
|
||
}
|
||
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
|
||
});
|
||
});
|
||
|
||
// Mansardeknik: 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));
|
||
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;
|
||
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))
|
||
};
|
||
}
|
||
|
||
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 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;
|
||
};
|
||
const hw = joistW / 2;
|
||
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 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));
|
||
};
|
||
const beams = [];
|
||
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;
|
||
};
|
||
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 spread = (len) => FloorModel.computeFrame({ width: len, depth: 1000, joistWidth: joistW, joistHeight: joistH, minSpacing: fp.minSpacing, maxSpacing: fp.maxSpacing, underlayThick: underlay }).joists.map(x => x + len / 2);
|
||
const qs = spread(q1 - q0).map(v => q0 + v), ps = spread(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 = beams.map(bm => {
|
||
const poly = clipBox(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);
|
||
|
||
const W = joistW * 1e-3, Hh = joistH * 1e-3;
|
||
const woodM3 = ownBeams.reduce((t, bm) => t + bm.length * 1e-3 * W * Hh, 0);
|
||
const areaM2 = clipped.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;
|
||
const specs = 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);
|
||
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 };
|
||
}
|
||
|
||
// 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;
|
||
group.add(buildRidgeBeam3D(unitRoof.ridgeBeam, (r.x0 + r.x1) / 2, (r.z0 + r.z1) / 2, 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[1][0] - T[0][0]) * (T[2][2] - T[0][2]) - (T[1][2] - T[0][2]) * (T[2][0] - T[0][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 || {};
|
||
s.house = { enabled: !!h.enabled, form: h.form && UnitModel.ROOF_TYPES[h.form.type] ? Object.assign({}, h.form) : null, cover: !!h.cover };
|
||
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, buildUnit3D, capRegions,
|
||
computeEdge, buildEdge3D, normalizeSettings
|
||
};
|
||
})();
|