v0.5.2: elektra (meterkast 3×25 A, schakelaars met letters op hun lampen, wcd, apparaatgroepen incl. perilex, groepenverklaring met fasen/aardlek, leidingen naar de meterkast met strekkende meters per type, PDF-blad + plattegrond, verplaatsbaar door slepen, bouwfase Elektra met installatiebuizen in 3D); ribbon, bouwfasen, kozijnen, kostenmodel en eerdere ongecommitte wijzigingen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-06 18:40:11 +02:00
co-authored by Claude Opus 5.5
parent 8cdfa21f46
commit e1a688728c
30 changed files with 6667 additions and 868 deletions
+315 -24
View File
@@ -13,8 +13,9 @@
// bovenop. Het "dek" van een dak is dus gewoon een vloer-state.
//
// Dakvorm volgt de wanden (computeShape): het paar tegenoverliggende wanden
// met een schuine bovenrand of middenpunt (nok) zijn de kopgevels, het dak
// volgt hun daklijn (plat, lessenaar of zadel). UnitModel.syncWallHeights
// met een schuine bovenrand of tussenpunten (nok, knikken) zijn de kopgevels,
// het dak volgt hun daklijn (plat, lessenaar, zadel, mansarde of wolfsdak;
// bij een wolfsdak komen daar de wolfseinden bij, zie computeShape). UnitModel.syncWallHeights
// houdt de wanden daarvoor consistent. Het dek (in de plattegrond) wordt per
// dakvlak omgerekend naar een schuin vlak met de balken als sporen (van
// laag naar hoog); doorvoeren gaan mee naar het vlak waarin ze vallen.
@@ -139,8 +140,10 @@ window.RoofModel = (() => {
const m = opts.materials;
const toM = 0.001;
const group = new THREE.Group();
// Plafondplaat én bedekking zichtbaar: de sporen ertussen zijn ingesloten.
const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, {
materials: m, osbVisible: opts.osbVisible !== false
materials: m, osbVisible: opts.osbVisible !== false,
outerJoistsOnly: opts.osbVisible !== false && opts.dakleerVisible !== false
});
const deckH = (calcData.joistH + calcData.underlayThick) * toM;
deck.scale.y = -1;
@@ -169,8 +172,7 @@ window.RoofModel = (() => {
leer.rotation.x = Math.PI / 2;
leer.position.y = deckH + cov.thickMM * toM;
leer.castShadow = cov.thickMM > DAKLEER_MM;
leer.visible = opts.dakleerVisible !== false;
group.add(leer);
if (opts.dakleerVisible !== false) group.add(leer);
return group;
}
@@ -202,22 +204,41 @@ window.RoofModel = (() => {
const s = UnitModel.WALL_SIDES.find(x => x.key === side);
const c = s.axis === 'x' ? wl.cx : wl.cz;
const pts = [{ p: c - s.dir * wl.width / 2, h: w.heightLeft }, { p: c + s.dir * wl.width / 2, h: w.heightRight }];
if (w.heightMid > 0) pts.push({ p: c, h: Math.max(w.heightMid, w.heightLeft, w.heightRight) });
// Tussenpunten (nok, knikken van mansarde/wolfsdak), x vanaf links.
WallModel.wallTopPoints(w, wl.width, w.heightLeft, w.heightRight)
.forEach(q => pts.push({ p: c - s.dir * wl.width / 2 + s.dir * q.x, h: q.h }));
return pts.sort((a, b) => a.p - b.p);
}
// Dakvorm van een unit. layout = UnitModel.computeLayout-resultaat.
// Geeft { type: 'plat'|'lessenaar'|'zadel', axis: 'x'|'z', profile }:
// de hoogte van de dakonderkant (vanaf het loopvlak) hangt alleen af van
// de positie langs axis (profileHeight). Kopgevels: de eerste wand met
// een schuine daklijn (noord/zuid -> dak helt langs X, west/oost -> langs Z).
// Zonder schuine wanden: plat op de hoogste wand.
// Geeft { type: 'plat'|'lessenaar'|'zadel'|'mansarde'|'wolf', axis: 'x'|'z',
// profile, hip? }: de hoogte van de dakonderkant (vanaf het loopvlak)
// hangt alleen af van de positie langs axis (profileHeight), behalve bij
// een wolfsdak (hip). Kopgevels: de eerste wand met een schuine daklijn
// (noord/zuid -> dak helt langs X, west/oost -> langs Z). Zonder schuine
// wanden: plat op de hoogste wand.
// zadel 3 punten (goot, nok, goot)
// mansarde 5 punten (goot, knik, nok, knik, goot)
// wolf 4 punten met een vlakke top: de kopgevel stopt op het
// wolfseind. Het profiel wordt dat van het zadeldak (de
// schuine stukken doorgetrokken tot de nok), met de knikken op
// het wolfseind; hip = { h }: de wolfseinden lopen vanaf de
// kopgevels op hoogte h omhoog tot de nok (computeCap).
function computeShape(unitState, layout) {
for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) {
const profile = wallProfile(unitState, layout, side);
if (profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) {
return { type: profile.length > 2 ? 'zadel' : 'lessenaar', axis, profile };
if (!profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) continue;
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);
// Snijpunt van de doorgetrokken schuine stukken = nok.
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 };
}
const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws)));
return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] };
@@ -297,22 +318,200 @@ window.RoofModel = (() => {
});
});
const planeAt = (q) => planes.find((pl, i) => q < pl.p1 || i === planes.length - 1);
const undersideAt = (x, z) => H(alongX ? x : z);
const topAt = (x, z) => {
// Knik tussen twee vlakken met een verschillende helling (mansarde):
// de bovenkanten snijden elkaar vóór (het steilere vlak is verticaal
// dikker), dus de vlakken sluiten in verstek aan: onderkant op de knik,
// bovenkant op het snijpunt van de bovenvlakken (miter = verschuiving
// van de bovenkant t.o.v. de knik, mm langs de as; 0 = verticaal).
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);
// Bovenkant: het laagste van de (doorgetrokken) bovenvlakken; de daklijn
// is bol, dus dat is precies het dak, ook rond een knik.
const mainTop = (x, z) => {
const q = alongX ? x : z;
const onKink = planes.filter(pl => Math.abs(q - pl.p0) < 0.5 || Math.abs(q - pl.p1) < 0.5);
const t = onKink.length > 1 ? Math.max(...onKink.map(pl => pl.vThick)) : planeAt(q).vThick;
return H(q) + t;
return Math.min(...planes.map(pl => pl.h0 + (q - pl.p0) * Math.tan(pl.theta) + pl.vThick));
};
// Wolfsdak: de vlakken tussen de wolfseind-knikken worden geen
// rechthoekige dekken; dat bovenste deel (cap, computeCap) is het laagste
// van de twee dakvlakken en de twee wolfseinden, met één top.
let cap = null, undersideAt = mainUnder, topAt = mainTop, outPlanes = planes;
if (shape.type === 'wolf' && shape.hip && kinks.length === 3) {
cap = computeCap(roofState, shape, rect, alongX, kinks, covering);
outPlanes = planes.filter(pl => !(pl.p0 >= cap.pA - 0.5 && pl.p1 <= cap.pB + 0.5));
const at = (x, z) => ({ x: alongX ? x : z, z: alongX ? z : x }); // (p, q)
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));
}
return {
shape, covering, planes, specs: sumSpecs(planes.map(pl => pl.specs)),
undersideAt, topAt,
shape, covering, planes: outPlanes, specs: sumSpecs(outPlanes.map(pl => pl.specs).concat(cap ? [cap.specs] : [])),
undersideAt, topAt, cap, rect, alongX,
ridges: kinks.map(value => ({ axis: shape.axis, value })),
maxTop: Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick))
maxTop: cap ? cap.maxTop : Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick))
};
}
// ------------------------------------------------------------
// Bovenste deel van een wolfsdak (cap). Coördinaten (p, q) als { x: p,
// z: q } (unit-lokaal mm): p langs de helling van het hoofddak, q langs de
// nok. Band p = pA..pB (wolfseind-knikken), q = q0..q1 (de kopgevels).
// Vier vlakken (onderkant dak): m1/m2 de twee dakvlakken, h1/h2 de
// wolfseinden: vanaf de kopgevel op hipH met de dakhelling omhoog tot de
// nokhoogte (driehoek), daartussen een rechte nok op pR. Is de unit te kort
// voor een nokstuk, dan worden de wolfseinden steiler en lopen ze in één
// punt samen. Per vlak het gebied waar het het laagste is (regions).
// Houtwerk zoals de dekken: plafondplaat, sporen (dakvlakken: op dezelfde
// q als de balken van het onderste dek; wolfseinden: verdeeld over pA..pB),
// hoekkepers van elke hoek van de band naar het nokeinde, nokbalk, kopbalk
// langs de voet van een wolfseind.
// ------------------------------------------------------------
const DENSITY_WOOD_KGM3 = 450, DENSITY_OSB_KGM3 = 650; // zelfde als FloorModel
function computeCap(roofState, shape, rect, alongX, kinks, covering) {
const H = (p) => profileHeight(shape.profile, p);
const [pA, pR, pB] = kinks, hipH = shape.hip.h, top = H(pR);
const q0 = alongX ? rect.z0 : rect.x0, q1 = alongX ? rect.z1 : rect.x1, qM = (q0 + q1) / 2;
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 sA = (top - H(pA)) / (pR - pA), sB = (top - H(pB)) / (pB - pR);
// Helling wolfseinden: die van het dak, maar minstens zo steil dat ze de
// nokhoogte halen vóór het midden tussen de kopgevels.
const tH = Math.max((sA + sB) / 2, (top - hipH) / Math.max(1, qM - q0));
const inset = (top - hipH) / tH; // nokeinde vanaf de kopgevel
const plane = (key, slope, f, along) => {
const cos = Math.cos(Math.atan(slope));
return { key, f, along, cos, tV: underlay / cos, jV: joistH / 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)); });
// Samenvallende punten (afsnijden door de top) weg.
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.covV });
}).filter(rg => rg.poly.length >= 3 && quadArea(rg.poly) > 100);
// Snijden van een lijn (vaste p of vaste q) met een convexe veelhoek.
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;
};
// Balken als veelhoek in de plattegrond (rechthoek van balkbreedte rond
// de hartlijn A-B, afgezaagd tegen de balken waar hij op aansluit) met
// de onderkant op het dakvlak (yAt: + plafondplaat). Lengte = langs de
// balk, hoek = helling van de balk.
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 }];
};
// Halfvlak aan de kant van `from` naast het zijvlak van balk m (>= 0 blijft).
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;
// Hartlijnbalk (hoekkeper, nokbalk): hoogte langs de hartlijn; anders op het vlak.
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,
length: Math.round(plan * Math.sqrt(1 + slope * slope)), angle: Math.round(Math.atan(slope) * 1800 / Math.PI) / 10
};
beams.push(bm);
return bm;
};
// Raamwerk: kopbalken (binnen de kopgevel, op de bovenregel), hoekkepers
// van elke hoek van de band naar het nokeinde, nokbalk ertussen. De
// hoekkepers staan op de kopbalk en sluiten bovenin in verstek op elkaar
// aan; de nokbalk is afgezaagd tegen de hoekkepers.
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));
// Op de kopbalk: vanaf zijn binnenkant.
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);
}
const members = kops.concat(hips, ridgeBeam ? [ridgeBeam] : []);
// Sporen: afgezaagd tegen de balk(en) waar ze op uitkomen.
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);
});
});
// Specificaties (zelfde sleutels als computeSpecs).
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 isLeer = !COVERINGS[covering] || covering === 'dakleer';
const covKg = areaM2 * cov.kgM2, osbVol = areaM2 * underlay * 1e-3;
const specs = {
joistWoodM3: woodM3, joistWoodKg: woodM3 * DENSITY_WOOD_KGM3,
osbAreaM2: areaM2, osbVolM3: osbVol, osbKg: osbVol * DENSITY_OSB_KGM3,
voidM3: Math.max(0, areaM2 * Hh - woodM3),
dakleerM2: isLeer ? areaM2 : 0, dakleerKg: isLeer ? covKg : 0, rietM2: isLeer ? 0 : areaM2, rietKg: isLeer ? 0 : covKg,
totalKg: woodM3 * DENSITY_WOOD_KGM3 + osbVol * DENSITY_OSB_KGM3 + covKg
};
const maxTop = top + Math.max(...regions.map(rg => rg.vThick));
return { pA, pR, pB, q0, q1, hipH, joistW, joistH, regions, beams, specs, maxTop };
}
// 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek,
// Y = 0 op het loopvlak): per dakvlak build3D, zo vervormd dat de
// onderkant van het plafond precies op de daklijn van de wanden ligt.
@@ -327,6 +526,7 @@ window.RoofModel = (() => {
function buildUnit3D(unitRoof, opts) {
const toM = 0.001;
const group = new THREE.Group();
if (unitRoof.cap) group.add(buildCap3D(unitRoof, opts));
unitRoof.planes.forEach(pl => {
const g = new THREE.Group();
g.position.set(pl.cx * toM, pl.y * toM, pl.cz * toM);
@@ -339,13 +539,104 @@ window.RoofModel = (() => {
0, 1 / c, sn, 0,
0, 0, c, 0,
0, 0, 0, 1);
shear.add(build3D(pl.data.calcData, pl.data.resolvedOpenings, Object.assign({ covering: unitRoof.covering }, opts)));
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;
}
// Verstek op de uiteinden van een dek (vóór de afschuiving; lokaal z
// langs de helling, -L/2 .. L/2, y = 0 onderkant .. T bovenkant): elk
// hoekpunt op een uiteinde schuift met de hoogte mee naar de verstekhoek
// (pl.miter0 / miter1 in mm langs de as, omgerekend langs de helling).
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();
});
}
// Bovenste deel van een wolfsdak (unitRoof.cap, zie computeCap): per
// hellend vlak plafondplaat en bedekking (verticale dikte, UV langs de
// helling), sporen/hoekkepers/kopbalken als balken met verticale kopse
// kanten (zoals de afgeschoven dekken). Plaat én bedekking zichtbaar:
// alleen de kopbalken (de rest is ingesloten).
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;
// (p, q) -> wereld (m, t.o.v. het dakmidden).
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];
};
// Plaat onder een convexe veelhoek: onderkant bottom(pt), dikte h (verticaal).
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); };
// Oriëntatie zo dat de bovenkant naar boven wijst.
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, m.osbMaterial, m.osbMaterial);
if (covOn) slab(rg.poly, (pt) => rg.under(pt) + rg.tV + rg.jV, rg.covV, covMat, covMat,
(pt) => rg.along === 'p' ? [pt.z * toM, pt.x * toM / rg.cos] : [pt.x * toM, pt.z * toM / rg.cos]);
});
// Balken: hun (afgezaagde) veelhoek in de plattegrond, onderkant op
// bm.yAt, hoogte jV verticaal.
c.beams.forEach(bm => {
if (osbOn && covOn && bm.kind !== 'kopbalk') return;
slab(bm.poly, bm.yAt, bm.jV, m.woodMaterial, m.woodMaterial);
});
return group;
}
// ------------------------------------------------------------
// Dakrand over de hele woning
// ------------------------------------------------------------
@@ -593,7 +884,7 @@ window.RoofModel = (() => {
return {
DAKLEER_MM, EDGE_ABOVE_MM, RIET, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor, coveringThumbUrl,
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D,
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D, capRegions,
computeEdge, buildEdge3D, normalizeSettings
};
})();