Tectum v0.4.0 — mijlpaal gevelopbouw

Configurator voor Van Emous Mantelzorgwoningen: wanden, vloeren, units,
woningen (L-vorm), plat dak met dakrand, buitenverlichting, latwerk op
vochtdoek, trasraam, sandwichpanelen en PDF-tekenbladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-09-30 11:38:54 +02:00
co-authored by Claude Opus 5.5
commit c690e72d13
36 changed files with 9954 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
// ============================================================
// lighting/lighting-model.js — LightingModel: ENIGE bron van de
// buitenverlichting van een woning. Geen DOM.
//
// Woning-breed (settings.lighting): type, lichtrichting en alle afstanden.
// mode: 'geen' | 'spots' (boeideel-spots, alleen waar boeidelen zijn) |
// 'gevel' (gevellampen), direction: 'both' | 'down' | 'up'.
//
// Gevellampen worden per GEVEL verdeeld, niet per wand: wandstukken in
// hetzelfde gevelvlak die op elkaar aansluiten (ook over een naad tussen
// units) vormen samen één gevel van hoek tot hoek. De balklogica per wand
// blijft ongewijzigd; alleen de lampen lopen uniform door.
// Op lamphoogte (standaard 1950 mm) worden openingen incl. raamwerk
// (koningsstijl/dorpels) van de gevel afgetrokken. Op elk vrij stuk van
// minstens minWallWidth (2000 mm) komen lampen: de eerste en laatste op
// endOffset (300 mm) van het uiteinde (hoek of opening), daartussen
// verdeeld met min/max h.o.h. (zelfde verdeling als stijlen). Smallere vrije
// stukken krijgen geen lampen. Gevellampen en boeideel-spots sluiten elkaar
// uit: bij 'gevel' komen er nooit spots.
// Per wand(zijde) blijft een harde override mogelijk
// (unit.lighting.walls[side]): 'auto' (standaard), 'openingen' (lampen op
// openingOffset = 500 mm naast elke opening, bijv. slaapkamers) of 'geen'.
//
// Plain script, geladen na core/geometry.js en roof-model.js.
// ============================================================
window.LightingModel = (() => {
const DEFAULT_SETTINGS = {
mode: 'spots', // 'geen' | 'spots' | 'gevel'
direction: 'both', // gevellampen: 'both' | 'down' | 'up'
lampHeight: 1950, // mm boven vloer
lampMin: 1500, // mm h.o.h. gevellampen
lampMax: 3000,
minWallWidth: 2000, // kleinste vrije gevelbreedte voor gevellampen
endOffset: 300, // eerste/laatste lamp vanaf hoek/uiteinde
openingOffset: 500 // override: lampen zoveel naast elke opening
};
const MODES = { geen: 'Geen', spots: 'Boeideel-spots', gevel: 'Gevellampen' };
const DIRECTIONS = { both: 'Boven én onder', down: 'Alleen onder', up: 'Alleen boven' };
const WALL_OVERRIDES = { auto: 'Automatisch', openingen: 'Naast openingen (override)', geen: 'Geen lampen' };
// Per unit alleen nog de override per wandzijde.
function createDefaultUnitLighting() {
return { walls: {} };
}
function normalizeSettings(settings) {
return Object.assign({}, DEFAULT_SETTINGS, settings || {});
}
// Spots op een lijnstuk A -> B: op beide uiteinden + daartussen verdeeld.
function spotsBetween(A, B, min, max, out) {
const len = Math.hypot(B.x - A.x, B.z - A.z);
out.push({ x: A.x, z: A.z }, { x: B.x, z: B.z });
if (len < 1) return;
calculateSegmentPositions(len, min, max).forEach(p => out.push({ x: A.x + (B.x - A.x) * p / len, z: A.z + (B.z - A.z) * p / len }));
}
// Lampen die dichter dan `min` bij elkaar staan worden samengevoegd tot één
// lamp in het midden, tot alle onderlinge afstanden >= min zijn.
function enforceMinSpacing(positions, min) {
let pts = positions.slice().sort((a, b) => a - b);
let changed = true;
while (changed && pts.length > 1) {
changed = false;
for (let i = 0; i < pts.length - 1; i++) {
if (pts[i + 1] - pts[i] < min - 0.5) {
pts.splice(i, 2, (pts[i] + pts[i + 1]) / 2);
changed = true;
break;
}
}
}
return pts;
}
// Aftrekken van intervallen: [lo, hi] min blocked -> vrije intervallen.
function freeIntervals(lo, hi, blocked) {
const free = [];
let cursor = lo;
blocked.slice().sort((p, q) => p[0] - q[0]).forEach(([a, b]) => {
if (a > cursor) free.push([cursor, Math.min(a, hi)]);
cursor = Math.max(cursor, b);
});
if (cursor < hi) free.push([cursor, hi]);
return free.filter(([a, b]) => b - a > 1);
}
// Alles voor de hele woning. units = HouseModel-parts.units (met
// walls[].pieces[].config/place), stateUnits = state.units (overrides per
// wand), edge = RoofModel.computeEdge-resultaat (edge.spots wordt hier
// gevuld). Zet per wandstuk p.lamps = [{ u, y }] (voor editor/PDF).
// Geeft { lamps: [{ x, y, z, normal, direction, element, u, yLocal }] }.
function compute(settings, roofSettings, units, stateUnits, edge) {
const s = normalizeSettings(settings);
const overrides = {};
stateUnits.forEach(u => { overrides[u.id] = (u.lighting && u.lighting.walls) || {}; });
// 1. Buiten-wandstukken als lijnstukken in hun gevelvlak (wereld-mm).
const segs = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
p.lamps = [];
const W = p.place.width;
// Lampen hangen op de afgewerkte gevel: wandvlak + kruislat + bekleding.
const T = p.place.thickness + 2 * WallModel.claddingOffset(p.buitenMateriaal);
const normal = { x: p.place.normal.x, z: p.place.normal.y }; // place.normal is {x, y} (plattegrond)
const axis = Math.abs(normal.z) > 0.5 ? 'x' : 'z';
const perp = axis === 'x' ? 'z' : 'x';
const q0 = UnitModel.localToWorld(p.place, -W / 2, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2, T / 2);
segs.push({
piece: p, normal, axis, perp, line: q0[perp],
sAt0: q0[axis], sAtW: q1[axis], s0: Math.min(q0[axis], q1[axis]), s1: Math.max(q0[axis], q1[axis]),
override: overrides[u.id][w.side] || 'auto'
});
})));
const uToS = (sg, u) => sg.sAt0 + (sg.sAtW - sg.sAt0) * (u / sg.piece.place.width);
const sToU = (sg, v) => (v - sg.sAt0) / (sg.sAtW - sg.sAt0) * sg.piece.place.width;
// 2. Gevels: zelfde normaal + zelfde gevelvlak + aansluitend.
const runs = groupFacadeRuns(segs); // core/geometry.js
// 3. Lampen per gevel.
const lamps = [];
const H = s.lampHeight;
const addLamp = (run, v) => {
const sg = run.segs.find(x => v >= x.s0 - 0.5 && v <= x.s1 + 0.5);
if (!sg) return;
const u = sToU(sg, v);
const pt = run.axis === 'x' ? { x: v, z: run.line } : { x: run.line, z: v };
sg.piece.lamps.push({ u, y: H });
lamps.push({ x: pt.x, z: pt.z, y: sg.piece.place.floorTop + H, normal: run.normal, direction: s.direction, element: sg.piece.element, u, yLocal: H });
};
if (s.mode === 'gevel') {
runs.forEach(run => {
const blocked = [];
const pos = []; // lampposities langs deze gevel (wereld-mm)
run.segs.forEach(sg => {
const cfg = sg.piece.config, sW = cfg.studWidth;
const low = Math.min(cfg.wallHeightLeft, cfg.wallHeightRight) < H + 100;
if (sg.override === 'geen' || low) { blocked.push([sg.s0, sg.s1]); return; }
if (sg.override === 'openingen') {
// Harde override: lampen naast elke opening, dit stuk doet verder niet mee.
blocked.push([sg.s0, sg.s1]);
cfg.openings.forEach(op => [op.x - s.openingOffset, op.x + op.w + s.openingOffset]
.filter(u => u >= 0 && u <= cfg.wallWidth).forEach(u => pos.push(uToS(sg, u))));
return;
}
cfg.openings.filter(op => op.y - sW <= H && op.y + op.h + sW >= H).forEach(op => {
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
blocked.push([Math.min(a, b), Math.max(a, b)]);
});
});
freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => {
if (b - a < s.minWallWidth) return; // te smal: geen lampen
const first = a + s.endOffset, last = b - s.endOffset;
pos.push(first);
calculateSegmentPositions(last - first, s.lampMin, s.lampMax).forEach(p => pos.push(first + p));
pos.push(last);
});
// Min. h.o.h. geldt over ALLE lampen van de gevel samen (ook bij
// afstand hoek/opening, overrides en de grens tussen wanden).
enforceMinSpacing(pos, s.lampMin).forEach(v => addLamp(run, v));
});
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => p.lamps.sort((a, b) => a.u - b.u))));
}
// 4. Boeideel-spots per dakrand-ribbe (alleen bij type 'spots').
if (edge) {
const spots = [];
if (s.mode === 'spots') {
const rs = RoofModel.normalizeSettings(roofSettings);
edge.segments.filter(sg => sg.fascia).forEach(sg => spotsBetween(sg.spotA, sg.spotB, rs.spotMin, rs.spotMax, spots));
}
edge.spots = spots.filter((p, i) => spots.findIndex(q => Math.hypot(q.x - p.x, q.z - p.z) < 5) === i);
}
return { lamps };
}
function createMaterials() {
return {
housingMaterial: new THREE.MeshStandardMaterial({ color: 0x2b2f33, roughness: 0.5, metalness: 0.3 }),
glowMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe2a0, emissiveIntensity: 1 }),
beamMaterial: new THREE.MeshBasicMaterial({ color: 0xffe9a8, transparent: true, opacity: 0.16, depthWrite: false, side: THREE.DoubleSide })
};
}
// 3D: behuizing op het gevelvlak + lichtbundel(s) omhoog/omlaag.
function build3D(lamps, materials) {
const toM = 0.001;
const group = new THREE.Group();
const w = 0.08, h = 0.16, d = 0.07, beamH = 0.7, beamR = 0.22;
lamps.forEach(l => {
const cx = l.x * toM + l.normal.x * d / 2, cz = l.z * toM + l.normal.z * d / 2, cy = l.y * toM;
const alongX = Math.abs(l.normal.z) > 0.5;
const box = new THREE.Mesh(new THREE.BoxGeometry(alongX ? w : d, h, alongX ? d : w), materials.housingMaterial);
box.position.set(cx, cy, cz);
group.add(box);
const addGlow = (sign) => {
const g = new THREE.Mesh(new THREE.BoxGeometry(alongX ? w * 0.8 : d * 0.8, 0.006, alongX ? d * 0.8 : w * 0.8), materials.glowMaterial);
g.position.set(cx, cy + sign * h / 2, cz);
group.add(g);
const cone = new THREE.Mesh(new THREE.ConeGeometry(beamR, beamH, 20, 1, true), materials.beamMaterial);
cone.userData.beam = true; // verborgen in de volle render (dan schijnt echt licht)
if (sign > 0) cone.rotation.x = Math.PI; // punt bij de lamp, breed naar boven
cone.position.set(cx, cy + sign * (h / 2 + beamH / 2), cz);
group.add(cone);
};
if (l.direction !== 'up') addGlow(-1);
if (l.direction !== 'down') addGlow(1);
});
return group;
}
// Volle render: elke gevellamp (per lichtrichting) en elke boeideel-spot
// wordt een echte SpotLight. Gevellampen staan net buiten het gevelvlak en
// schijnen langs de gevel omhoog/omlaag; spots schijnen recht naar beneden.
// spots = [{ x, z }] (mm), spotY = hoogte onderkant boeideel (mm).
function buildLights3D(lamps, spots, spotY) {
const toM = 0.001;
const group = new THREE.Group();
const add = (x, y, z, dirY, intensity, angle, distance) => {
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, 0.6, 1.5);
light.position.set(x, y, z);
light.target.position.set(x, y + dirY * 2, z);
group.add(light);
group.add(light.target);
};
lamps.forEach(l => {
const x = l.x * toM + l.normal.x * 0.12, z = l.z * toM + l.normal.z * 0.12, y = l.y * toM;
if (l.direction !== 'up') add(x, y - 0.09, z, -1, 3, 0.55, 4.5);
if (l.direction !== 'down') add(x, y + 0.09, z, 1, 3, 0.55, 4.5);
});
(spots || []).forEach(p => add(p.x * toM, spotY * toM - 0.02, p.z * toM, -1, 2.5, 0.6, 5.5));
return group;
}
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, normalizeSettings, compute, createMaterials, build3D, buildLights3D };
})();