568 lines
27 KiB
JavaScript
568 lines
27 KiB
JavaScript
/*!
|
|
* Tectum
|
|
* Copyright © 2026 Machiel Heinen.
|
|
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
|
|
* Zie LICENTIE.md.
|
|
*/
|
|
// ============================================================
|
|
// roof/roof-model.js — RoofModel: ENIGE bron van de dak-logica. Geen DOM.
|
|
//
|
|
// Een dakvlak is opgebouwd als een vloer (FloorModel: balken, isolatie,
|
|
// doorvoeren, OSB-plaat), maar 180° gespiegeld: de OSB-plaat zit aan de
|
|
// onderzijde (plafond), de balken erboven, met dakleer als buitenlaag
|
|
// bovenop. Het "dek" van een dak is dus gewoon een vloer-state.
|
|
//
|
|
// Dakvorm volgt de wanden (computeShape): het paar tegenoverliggende wanden
|
|
// met een schuine bovenrand of middenpunt (nok) zijn de kopgevels, het dak
|
|
// volgt hun daklijn (plat, lessenaar of zadel). UnitModel.syncWallHeights
|
|
// houdt de wanden daarvoor consistent. Het dek (in de plattegrond) wordt per
|
|
// dakvlak omgerekend naar een schuin vlak met de balken als sporen (van
|
|
// laag naar hoog); doorvoeren gaan mee naar het vlak waarin ze vallen.
|
|
//
|
|
// Dakrand (over de hele woning, doorlopend over naden tussen units): de
|
|
// omtrek van alle daken samen, opgeknipt per ribbe (en op elke unit-hoek).
|
|
// Per ribbe bepaalt de unit waar die ribbe bij hoort of het een boeideel of
|
|
// een daktrim is (boeidelen aan/uit per unit). De ribben sluiten in verstek
|
|
// op elkaar aan, dus op een naad tussen units is niets te zien.
|
|
// - boeidelen: een doos van 200..400 mm overstek (gemeten vanaf de
|
|
// dakrand = standaard de buitenkant gevel), standaard 300 mm hoog.
|
|
// Spots in de onderplaat staan op 40% van de overstek (spotLine); welke
|
|
// spots er komen bepaalt LightingModel.
|
|
// - zonder boeidelen: een daktrim van 80 mm hoog (onder het dakleer).
|
|
// De bovenkant van boeideel en daktrim ligt EDGE_ABOVE_MM boven het dakleer
|
|
// (opstand rondom), dus het dakleer ligt altijd lager dan de rand.
|
|
//
|
|
// Plain script, geladen na floor-model.js en core/geometry.js.
|
|
// ============================================================
|
|
|
|
window.RoofModel = (() => {
|
|
const DAKLEER_MM = 2; // visuele dikte van het dakleer
|
|
const DENSITY_DAKLEER_KGM2 = 1.4; // EPDM ~1,4 kg/m2
|
|
const TRIM_THICK_MM = 25; // daktrim-profiel, uitstekend buiten de dakrand
|
|
const EDGE_ABOVE_MM = 80; // bovenkant boeideel/daktrim ligt zoveel boven het dakleer
|
|
const SPOT_RATIO = 0.4; // spots op 40% van de overstek, gemeten vanaf de gevel
|
|
const OVERHANG_MIN = 200, OVERHANG_MAX = 400;
|
|
|
|
// Woning-brede maten van de dakrand (aan/uit per unit: roof.fascia).
|
|
const DEFAULT_SETTINGS = {
|
|
overhang: 300, // mm, 200..400
|
|
fasciaHeight: 300, // mm
|
|
spotMin: 800, // mm h.o.h. boeideel-spots
|
|
spotMax: 1200,
|
|
trimHeight: 80, // mm
|
|
fasciaColor: 'antraciet' // FASCIA_COLORS
|
|
};
|
|
|
|
// Kleur van het boeideel (egaal).
|
|
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 }
|
|
};
|
|
|
|
// Dakbedekking per unit (roof.covering). Riet e.d. alleen bij een hellend
|
|
// dak (lessenaar/zadel); plat valt terug op dakleer. thickMM telt mee in de
|
|
// dakdikte (vThick/topAt), dus boeideel/daktrim en een opbouw ernaast
|
|
// schuiven vanzelf mee omhoog. Texturen: assets/textures/dak/, naadloos;
|
|
// de stengels lopen langs de helling.
|
|
// Riet: één keuze per textuur assets/textures/dak/riet_<nr>.jpg (nummer
|
|
// hier toevoegen); zelfde opbouw (180 mm, 3,8 kg/m2).
|
|
const RIET = { thickMM: 180, kgM2: 3.8, tileMM: 1000, nrs: ['001', '002', '003'] };
|
|
const COVERINGS = Object.assign({
|
|
dakleer: { label: 'Dakleer (EPDM)', thickMM: DAKLEER_MM, kgM2: DENSITY_DAKLEER_KGM2 }
|
|
}, ...RIET.nrs.map(nr => ({
|
|
['riet_' + nr]: { label: 'Riet ' + nr, thickMM: RIET.thickMM, kgM2: RIET.kgM2, file: `assets/textures/dak/riet_${nr}.jpg`, tileMM: RIET.tileMM, sloped: true }
|
|
})));
|
|
// Werkelijke bedekking voor een dakvorm (riet niet op een plat dak).
|
|
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 || !c.file) return null;
|
|
if (coveringMaterials[key]) return coveringMaterials[key];
|
|
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)); }
|
|
|
|
// Dak-state per unit: volgt standaard de vloermaat (followFloor), maar mag
|
|
// groter/kleiner en verschoven worden (override).
|
|
function createDefaultRoof(floorState) {
|
|
const deck = clone(floorState);
|
|
deck.chassis = { enabled: false, wheelDiameter: 400, trackWidth: 1200, wheelbase: 1200 };
|
|
deck.openings = [];
|
|
return { followFloor: true, offsetX: 0, offsetZ: 0, fascia: true, covering: 'dakleer', deck };
|
|
}
|
|
|
|
// Dikte dek + bedekking (mm, haaks op het dakvlak).
|
|
function thickness(calcData, covering) {
|
|
return calcData.joistH + calcData.underlayThick + (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 }),
|
|
spotOnMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe9a8, emissiveIntensity: 0.9 })
|
|
});
|
|
}
|
|
|
|
// 3D van één dak (meters, gecentreerd op het dakmidden, Y = 0 onderkant
|
|
// plafondplaat). Het vloer-3D (FloorModel.build3D) wordt gespiegeld
|
|
// (scale.y = -1; three.js corrigeert de vlakoriëntatie zelf), daarna
|
|
// komt het dakleer er bovenop.
|
|
function build3D(calcData, resolvedOpenings, opts) {
|
|
const m = opts.materials;
|
|
const toM = 0.001;
|
|
const group = new THREE.Group();
|
|
const deck = FloorModel.build3D(calcData, resolvedOpenings, { wheels: [] }, {
|
|
materials: m, osbVisible: opts.osbVisible !== false
|
|
});
|
|
const deckH = (calcData.joistH + calcData.underlayThick) * toM;
|
|
deck.scale.y = -1;
|
|
deck.position.y = deckH;
|
|
group.add(deck);
|
|
|
|
// Dakleer: vlak over het hele dak, met dezelfde ronde uitsparingen als
|
|
// de doorvoeren (zelfde opbouw als de OSB-plaat in FloorModel).
|
|
const wM = calcData.width * toM, dM = calcData.depth * toM;
|
|
const shape = new THREE.Shape();
|
|
shape.moveTo(-wM / 2, -dM / 2);
|
|
shape.lineTo(wM / 2, -dM / 2);
|
|
shape.lineTo(wM / 2, dM / 2);
|
|
shape.lineTo(-wM / 2, dM / 2);
|
|
shape.closePath();
|
|
resolvedOpenings.filter(o => !o.removed && o.r > 0).forEach(o => {
|
|
const hole = new THREE.Path();
|
|
hole.absarc(o.x * toM, o.y * toM - dM / 2, o.r * toM, 0, Math.PI * 2, false);
|
|
shape.holes.push(hole);
|
|
});
|
|
// Andere bedekking (opts.covering, bijv. riet): een laag van thickMM met
|
|
// de textuur (vorm-coördinaten in m = UV; langs de helling = v).
|
|
const cov = COVERINGS[opts.covering] || COVERINGS.dakleer;
|
|
const covMat = coveringMaterial(opts.covering) || m.dakleerMaterial;
|
|
const leer = new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { steps: 1, depth: cov.thickMM * toM, bevelEnabled: false }), covMat);
|
|
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);
|
|
return group;
|
|
}
|
|
|
|
// Specificaties: die van het dek (FloorModel) + de bedekking (dakleer of
|
|
// bijv. riet: coveringM2/Kg, dakleer dan 0).
|
|
function computeSpecs(calcData, resolvedOpenings, covering) {
|
|
const deck = FloorModel.computeSpecs(calcData, resolvedOpenings);
|
|
const areaM2 = deck.osbAreaM2; // zelfde vlak minus doorvoeren
|
|
const isLeer = !COVERINGS[covering] || covering === 'dakleer';
|
|
const kg = areaM2 * (COVERINGS[covering] || COVERINGS.dakleer).kgM2;
|
|
return Object.assign({}, deck, {
|
|
dakleerM2: isLeer ? areaM2 : 0, dakleerKg: isLeer ? kg : 0,
|
|
rietM2: isLeer ? 0 : areaM2, rietKg: isLeer ? 0 : kg,
|
|
totalKg: deck.totalKg + kg
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// Dakvorm per unit (volgt de wanden)
|
|
// ------------------------------------------------------------
|
|
|
|
// Daklijn van één wand als profiel langs zijn as (unit-lokaal mm):
|
|
// [{ p, h }] gesorteerd op p, h vanaf het loopvlak. Zelfde definitie als
|
|
// UnitModel.wallPieces: links/rechts op de uiteinden van de wand (van
|
|
// buitenaf gezien), het middenpunt (nok) midden op de wand.
|
|
function wallProfile(unitState, layout, side) {
|
|
const w = unitState.walls[side].wall;
|
|
const wl = layout.walls.find(x => x.side === side);
|
|
const s = UnitModel.WALL_SIDES.find(x => x.key === side);
|
|
const c = s.axis === 'x' ? wl.cx : wl.cz;
|
|
const pts = [{ p: c - s.dir * wl.width / 2, h: w.heightLeft }, { p: c + s.dir * wl.width / 2, h: w.heightRight }];
|
|
if (w.heightMid > 0) pts.push({ p: c, h: Math.max(w.heightMid, w.heightLeft, w.heightRight) });
|
|
return pts.sort((a, b) => a.p - b.p);
|
|
}
|
|
|
|
// Dakvorm van een unit. layout = UnitModel.computeLayout-resultaat.
|
|
// Geeft { type: 'plat'|'lessenaar'|'zadel', axis: 'x'|'z', profile }:
|
|
// de hoogte van de dakonderkant (vanaf het loopvlak) hangt alleen af van
|
|
// de positie langs axis (profileHeight). Kopgevels: de eerste wand met
|
|
// een schuine daklijn (noord/zuid -> dak helt langs X, west/oost -> langs Z).
|
|
// Zonder schuine wanden: plat op de hoogste wand.
|
|
function computeShape(unitState, layout) {
|
|
for (const [side, axis] of [['achter', 'x'], ['voor', 'x'], ['links', 'z'], ['rechts', 'z']]) {
|
|
const profile = wallProfile(unitState, layout, side);
|
|
if (profile.some(q => Math.abs(q.h - profile[0].h) > 0.5)) {
|
|
return { type: profile.length > 2 ? 'zadel' : 'lessenaar', axis, profile };
|
|
}
|
|
}
|
|
const h = Math.max(...Object.values(unitState.walls).map(ws => UnitModel.wallTopOf(ws)));
|
|
return { type: 'plat', axis: 'z', profile: [{ p: 0, h }] };
|
|
}
|
|
|
|
// Hoogte van het profiel op positie p; buiten de wand loopt de laatste
|
|
// helling door (overstek, boeideel).
|
|
function profileHeight(profile, p) {
|
|
if (profile.length === 1) return profile[0].h;
|
|
let i = 0;
|
|
while (i < profile.length - 2 && p > profile[i + 1].p) i++;
|
|
const a = profile[i], b = profile[i + 1];
|
|
return a.h + (p - a.p) / (b.p - a.p) * (b.h - a.h);
|
|
}
|
|
|
|
// Tel alle getallen van een lijst specificaties op.
|
|
function sumSpecs(list) {
|
|
const out = Object.assign({}, list[0]);
|
|
list.slice(1).forEach(sp => Object.keys(sp).forEach(k => {
|
|
if (typeof sp[k] === 'number' && typeof out[k] === 'number') out[k] += sp[k];
|
|
}));
|
|
return out;
|
|
}
|
|
|
|
// Het dak van één unit: de dakrechthoek (unit-lokaal mm { x0, x1, z0, z1 },
|
|
// = maat van roofState.deck) geknipt op de nok in dakvlakken. Per vlak een
|
|
// eigen dek (vloer-state) met breedte = maat dwars op de helling en diepte
|
|
// = lengte langs de helling, zodat de balken als sporen van laag naar hoog
|
|
// lopen. rims: zie FloorModel.computeFrame (alleen bij helling langs Z).
|
|
// Geeft { shape, planes, specs, undersideAt(x, z), topAt(x, z), ridges, maxTop }
|
|
// (unit-lokaal, hoogtes vanaf het loopvlak). Vlak-posities (cx, cz) zijn
|
|
// t.o.v. het midden van de dakrechthoek.
|
|
function computeUnitRoof(roofState, shape, rect, rims) {
|
|
const alongX = shape.axis === 'x';
|
|
const W = rect.x1 - rect.x0, D = rect.z1 - rect.z0;
|
|
const xc = (rect.x0 + rect.x1) / 2, zc = (rect.z0 + rect.z1) / 2;
|
|
const a0 = alongX ? rect.x0 : rect.z0, a1 = alongX ? rect.x1 : rect.z1;
|
|
const kinks = shape.profile.slice(1, -1).map(q => q.p).filter(p => p > a0 + 1 && p < a1 - 1);
|
|
const cuts = [a0, ...kinks, a1];
|
|
const flat = FloorModel.computeFromState(roofState.deck, rims);
|
|
const H = (p) => profileHeight(shape.profile, p);
|
|
const covering = coveringFor(roofState.covering, shape.type);
|
|
|
|
// Doorvoeren: positie in de plattegrond (zoals in de dak-editor) -> vlak.
|
|
const holes = flat.resolvedOpenings.filter(o => !o.removed).map(o => {
|
|
const px = xc + o.x, pz = rect.z0 + o.y;
|
|
return { o, px, pz, q: alongX ? px : pz };
|
|
});
|
|
|
|
const planes = cuts.slice(0, -1).map((p0, i) => {
|
|
const p1 = cuts[i + 1], last = i === cuts.length - 2;
|
|
const h0 = H(p0), h1 = H(p1), run = p1 - p0;
|
|
const slopeLen = Math.hypot(run, h1 - h0), cos = run / slopeLen;
|
|
const plane = { p0, p1, h0, h1, theta: Math.atan2(h1 - h0, run), cos, yaw: alongX ? Math.PI / 2 : 0 };
|
|
let data;
|
|
if (cuts.length === 2 && !alongX && Math.abs(h1 - h0) < 0.5) {
|
|
data = flat; // plat dak: het dek zoals het is
|
|
} else {
|
|
const deck = clone(roofState.deck);
|
|
deck.floor.width = Math.round(alongX ? D : W);
|
|
deck.floor.depth = Math.round(slopeLen);
|
|
deck.openings = holes.filter(hl => hl.q >= p0 && (hl.q < p1 || last)).map(hl => ({
|
|
id: hl.o.id, diameter: hl.o.requestedDiameter, anchorX: 'left',
|
|
// Bij helling langs X is het dek een kwartslag gedraaid: lokaal x -> -Z.
|
|
offsetX: Math.round(alongX ? rect.z1 - hl.pz : hl.px - rect.x0),
|
|
offsetY: Math.round((hl.q - p0) / cos)
|
|
}));
|
|
const pr = alongX ? {} : { front: !!rims.front && Math.abs(p0 - rect.z0) < 1, back: !!rims.back && Math.abs(p1 - rect.z1) < 1 };
|
|
data = FloorModel.computeFromState(deck, pr);
|
|
}
|
|
const mid = (p0 + p1) / 2;
|
|
return Object.assign(plane, {
|
|
data, specs: computeSpecs(data.calcData, data.resolvedOpenings, covering),
|
|
cx: alongX ? mid - xc : 0, cz: alongX ? 0 : mid - zc, y: (h0 + h1) / 2,
|
|
// Verticale dikte (dek + bedekking) van dit vlak.
|
|
vThick: thickness(data.calcData, covering) / cos
|
|
});
|
|
});
|
|
|
|
const planeAt = (q) => planes.find((pl, i) => q < pl.p1 || i === planes.length - 1);
|
|
const undersideAt = (x, z) => H(alongX ? x : z);
|
|
const topAt = (x, z) => {
|
|
const q = alongX ? x : z;
|
|
const onKink = planes.filter(pl => Math.abs(q - pl.p0) < 0.5 || Math.abs(q - pl.p1) < 0.5);
|
|
const t = onKink.length > 1 ? Math.max(...onKink.map(pl => pl.vThick)) : planeAt(q).vThick;
|
|
return H(q) + t;
|
|
};
|
|
return {
|
|
shape, covering, planes, specs: sumSpecs(planes.map(pl => pl.specs)),
|
|
undersideAt, topAt,
|
|
ridges: kinks.map(value => ({ axis: shape.axis, value })),
|
|
maxTop: Math.max(...planes.map(pl => Math.max(pl.h0, pl.h1) + pl.vThick))
|
|
};
|
|
}
|
|
|
|
// 3D van het dak van één unit (meters, gecentreerd op de dakrechthoek,
|
|
// Y = 0 op het loopvlak): per dakvlak build3D, zo vervormd dat de
|
|
// onderkant van het plafond precies op de daklijn van de wanden ligt.
|
|
// Geen kanteling maar een afschuiving (lokaal +Z = oplopende as):
|
|
// plan = z·cos θ, Y = y/cos θ + z·sin θ
|
|
// De onderkant ligt dan op de helling, de dikte haaks op de helling blijft
|
|
// gelijk (verticaal = dikte/cos θ, zoals vThick), de lengte langs de
|
|
// helling ook (balklengtes), en de kopse kanten staan loodrecht (zoals een
|
|
// loodrechte zaagsnede aan sporen). Bij kantelen zouden de kopse kanten
|
|
// haaks op de helling staan en ligt de bovenkant aan de hoge kant
|
|
// dikte·sin θ terug van de dakrand.
|
|
function buildUnit3D(unitRoof, opts) {
|
|
const toM = 0.001;
|
|
const group = new THREE.Group();
|
|
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);
|
|
shear.add(build3D(pl.data.calcData, pl.data.resolvedOpenings, Object.assign({ covering: unitRoof.covering }, opts)));
|
|
g.add(shear);
|
|
group.add(g);
|
|
});
|
|
return group;
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// Dakrand over de hele woning
|
|
// ------------------------------------------------------------
|
|
|
|
// Splits de ribben van de omtrek op elke hoek van een dak-rechthoek die op
|
|
// die ribbe ligt ("hoek van een unit"), zodat spots daar ook beginnen/eindigen.
|
|
function splitAtCorners(pts, rects) {
|
|
const corners = [];
|
|
rects.forEach(r => corners.push({ x: r.x0, z: r.z0 }, { x: r.x1, z: r.z0 }, { x: r.x1, z: r.z1 }, { x: r.x0, z: r.z1 }));
|
|
const out = [];
|
|
pts.forEach((a, k) => {
|
|
const b = pts[(k + 1) % pts.length];
|
|
out.push(a);
|
|
const len = Math.hypot(b.x - a.x, b.z - a.z);
|
|
corners
|
|
.map(c => ({ c, t: ((c.x - a.x) * (b.x - a.x) + (c.z - a.z) * (b.z - a.z)) / (len * len) }))
|
|
.filter(({ c, t }) => t > 1e-6 && t < 1 - 1e-6 &&
|
|
Math.abs((c.x - a.x) * (b.z - a.z) - (c.z - a.z) * (b.x - a.x)) / len < 0.5)
|
|
.sort((p, q) => p.t - q.t)
|
|
.forEach(({ c }) => { if (!out.some(o => Math.abs(o.x - c.x) < 0.5 && Math.abs(o.z - c.z) < 0.5)) out.push({ x: c.x, z: c.z }); });
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function onRectEdge(r, p) {
|
|
const e = 0.5;
|
|
const inX = p.x >= r.x0 - e && p.x <= r.x1 + e, inZ = p.z >= r.z0 - e && p.z <= r.z1 + e;
|
|
return ((Math.abs(p.x - r.x0) < e || Math.abs(p.x - r.x1) < e) && inZ) ||
|
|
((Math.abs(p.z - r.z0) < e || Math.abs(p.z - r.z1) < e) && inX);
|
|
}
|
|
function quadArea(pts) {
|
|
return Math.abs(pts.reduce((s, p, k) => { const q = pts[(k + 1) % pts.length]; return s + p.x * q.z - q.x * p.z; }, 0) / 2);
|
|
}
|
|
|
|
// roofs: [{ unitId, rect: { x0, x1, z0, z1 }, fascia: bool, topAt(x, z),
|
|
// ridges: [{ axis, value }] }] (mm, woning-assen); topAt = bovenkant
|
|
// dakleer (absoluut, loopt buiten het dak door met de helling). Geeft per
|
|
// ribbe (segments) de geometrie terug; edge.spots wordt door LightingModel
|
|
// gevuld (met y = onderkant boeideel ter plaatse).
|
|
function computeEdge(settings, roofs) {
|
|
const s = normalizeSettings(settings);
|
|
const rects = roofs.map(r => r.rect);
|
|
if (!roofs.length) return null;
|
|
|
|
// Hoogtegroepen: daken die tegen elkaar liggen horen bij één doorlopende
|
|
// rand, tenzij het hoogteverschil langs de hele naad minstens de
|
|
// boeideelhoogte is. Dan krijgt het hoge dak een eigen rand rondom (ook
|
|
// boven het lage dak), en loopt de rand van het lage dak tot de gevel
|
|
// van het hoge.
|
|
const group = roofs.map((r, i) => i);
|
|
const find = (i) => group[i] === i ? i : (group[i] = find(group[i]));
|
|
const sharedEdge = (A, B) => {
|
|
const e = 0.5;
|
|
for (const [ax, bx, lo0, lo1, hi0, hi1, axis] of [
|
|
[A.x1, B.x0, A.z0, A.z1, B.z0, B.z1, 'x'], [B.x1, A.x0, A.z0, A.z1, B.z0, B.z1, 'x'],
|
|
[A.z1, B.z0, A.x0, A.x1, B.x0, B.x1, 'z'], [B.z1, A.z0, A.x0, A.x1, B.x0, B.x1, 'z']]) {
|
|
const s0 = Math.max(lo0, hi0), s1 = Math.min(lo1, hi1);
|
|
if (Math.abs(ax - bx) < e && s1 - s0 > e) {
|
|
return [s0, (s0 + s1) / 2, s1].map(t => axis === 'x' ? { x: ax, z: t } : { x: t, z: ax });
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
roofs.forEach((A, i) => roofs.forEach((B, j) => {
|
|
if (j <= i) return;
|
|
const pts = sharedEdge(A.rect, B.rect);
|
|
if (!pts) return;
|
|
const diff = Math.min(...pts.map(p => Math.abs(A.topAt(p.x, p.z) - B.topAt(p.x, p.z))));
|
|
if (diff < s.fasciaHeight) group[find(i)] = find(j);
|
|
}));
|
|
|
|
const segments = [];
|
|
[...new Set(roofs.map((r, i) => find(i)))].forEach(g => {
|
|
const members = roofs.filter((r, i) => find(i) === g);
|
|
const others = roofs.filter((r, i) => find(i) !== g);
|
|
const loops = rectUnionLoops(members.map(r => r.rect));
|
|
if (!loops.length) return;
|
|
const base = splitAtCorners(loops[0].pts, rects);
|
|
const offBoei = offsetRectilinearLoop(base, s.overhang);
|
|
const offTrim = offsetRectilinearLoop(base, TRIM_THICK_MM);
|
|
const offSpot = offsetRectilinearLoop(base, s.overhang * SPOT_RATIO);
|
|
const n = base.length;
|
|
const segs = base.map((a, i) => {
|
|
const j = (i + 1) % n, b = base[j];
|
|
const mid = { x: (a.x + b.x) / 2, z: (a.z + b.z) / 2 };
|
|
const owner = members.find(r => onRectEdge(r.rect, mid)) || members[0];
|
|
const fascia = owner.fascia !== false;
|
|
const off = fascia ? offBoei : offTrim;
|
|
// Ribbe tegen een dak van een andere groep: tegen een hoger dak
|
|
// vervalt hij (daar staat de gevel van het hoge), boven een lager
|
|
// dak blijft hij, maar zonder spots.
|
|
const nb = others.find(r => onRectEdge(r.rect, mid));
|
|
const dropped = !!nb && nb.topAt(mid.x, mid.z) > owner.topAt(mid.x, mid.z);
|
|
return {
|
|
a, b, owner: owner.unitId, fascia, normal: rectilinearNormal(a, b),
|
|
outerA: off[i], outerB: off[j], spotA: offSpot[i], spotB: offSpot[j],
|
|
// Bovenkant van de rand (dakleer + opstand).
|
|
topAt: (p) => owner.topAt(p.x, p.z) + EDGE_ABOVE_MM, ridges: owner.ridges || [],
|
|
overRoof: !!nb && !dropped, dropped
|
|
};
|
|
});
|
|
// Naast een vervallen ribbe (tegen een hoger dak):
|
|
// - ligt het verlengde van de rand in het hoge deel, dan loopt hij
|
|
// recht door tot die gevel (spot op spotafstand van de gevel);
|
|
// - ligt het verlengde ernaast (de rand loopt langs de zijgevel van
|
|
// het hoge deel), dan loopt hij recht door tot de overstek, onder
|
|
// de rand van het hoge dak (van bovenaf overlappen ze).
|
|
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: []
|
|
};
|
|
}
|
|
|
|
// Convexe veelhoek (plattegrond, mm) knippen met een halfvlak
|
|
// (Sutherland-Hodgman): houd de punten waar f(p) >= 0.
|
|
function clipPoly(poly, f) {
|
|
const out = [];
|
|
poly.forEach((p, k) => {
|
|
const q = poly[(k + 1) % poly.length], fp = f(p), fq = f(q);
|
|
if (fp >= 0) out.push(p);
|
|
if ((fp >= 0) !== (fq >= 0)) {
|
|
const t = fp / (fp - fq);
|
|
out.push({ x: p.x + (q.x - p.x) * t, z: p.z + (q.z - p.z) * t });
|
|
}
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// Prisma onder een convexe veelhoek: bovenkant per hoekpunt op topFn(p),
|
|
// onderkant h lager (mm -> meters).
|
|
function prismGeometry(poly, topFn, h) {
|
|
const toM = 0.001, n = poly.length, pos = [];
|
|
const T = poly.map(p => [p.x * toM, topFn(p) * toM, p.z * toM]);
|
|
const B = poly.map(p => [p.x * toM, (topFn(p) - h) * toM, p.z * toM]);
|
|
const tri = (a, b, c) => pos.push(...a, ...b, ...c);
|
|
for (let k = 1; k < n - 1; k++) { tri(T[0], T[k], T[k + 1]); tri(B[0], B[k + 1], B[k]); }
|
|
for (let k = 0; k < n; k++) { const j = (k + 1) % n; tri(T[k], B[k], B[j]); tri(T[k], B[j], T[j]); }
|
|
const geo = new THREE.BufferGeometry();
|
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
|
geo.computeVertexNormals();
|
|
return geo;
|
|
}
|
|
|
|
// 3D van de dakrand: per ribbe een vierhoek (dakrand -> buitenrand, in
|
|
// verstek), naar beneden over de hoogte van boeideel resp. daktrim. De
|
|
// bovenkant volgt het dak (sg.topAt); op een nok wordt de ribbe geknipt.
|
|
// Plus de boeideel-spots (edge.spots) in de onderplaat.
|
|
// opts: { edgeVisible (volgt "Beplating"), spotsVisible (volgt "Lampen") }
|
|
function buildEdge3D(edge, materials, opts) {
|
|
const o = opts || {};
|
|
const toM = 0.001;
|
|
const group = new THREE.Group();
|
|
if (!edge) return group;
|
|
// Het boeidelen-materiaal wordt gedeeld: 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);
|
|
});
|
|
// Boeideel: totale hoogte fasciaHeight vanaf de bovenkant van de rand;
|
|
// daktrim: trimHeight onder het dakleer + de opstand erboven.
|
|
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;
|
|
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
|
|
if (!FASCIA_COLORS[s.fasciaColor]) s.fasciaColor = DEFAULT_SETTINGS.fasciaColor;
|
|
return s;
|
|
}
|
|
|
|
return {
|
|
DAKLEER_MM, EDGE_ABOVE_MM, RIET, OVERHANG_MIN, OVERHANG_MAX, DEFAULT_SETTINGS, SPOT_RATIO, FASCIA_COLORS, COVERINGS, coveringFor,
|
|
createDefaultRoof, thickness, createMaterials, build3D, computeSpecs,
|
|
computeShape, wallProfile, profileHeight, computeUnitRoof, buildUnit3D,
|
|
computeEdge, buildEdge3D, normalizeSettings
|
|
};
|
|
})();
|