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

455 lines
21 KiB
JavaScript

/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.LightingModel = (() => {
const DEFAULT_SETTINGS = {
mode: 'spots',
direction: 'both',
lampHeight: 1950,
lampMin: 500,
lampMax: 3000,
minWallWidth: 2000,
endOffset: 300,
openingOffset: 500,
indoor: true,
indoorMin: 800,
indoorMax: 1500,
indoorOffset: 600
};
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' };
function createDefaultUnitLighting() {
return { walls: {} };
}
function normalizeSettings(settings) {
return Object.assign({}, DEFAULT_SETTINGS, settings || {});
}
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 }));
}
// Te dicht bij elkaar: samen naar het midden, tenzij daar een opening zit (dan vervalt de tweede).
function enforceMinSpacing(positions, min, blockedAt) {
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) {
const mid = (pts[i] + pts[i + 1]) / 2;
pts.splice(i, 2, blockedAt && blockedAt(mid) ? pts[i] : mid);
changed = true;
break;
}
}
}
return pts;
}
// Naast openingen: op openingOffset van elke opening; is de tussenruimte daarvoor te klein, dan één lamp in
// het midden ervan (of geen, als die tegen de kozijnen zou hangen). Posities in u (mm vanaf de linkerkant).
function besideOpenings(cfg, s) {
const W = cfg.wallWidth, off = s.openingOffset, clear = cfg.studWidth + 150;
const ops = cfg.openings.map(op => [op.x, op.x + op.w]).sort((p, q) => p[0] - q[0]);
const out = [];
ops.forEach(([x0, x1], i) => {
const next = ops[i + 1];
if (i === 0) { const u = Math.max(x0 - off, s.endOffset); if (x0 - u >= clear) out.push(u); }
if (next) {
const gap = next[0] - x1;
if (gap >= 2 * off + 1000) out.push(x1 + off, next[0] - off);
else if (gap >= 2 * clear) out.push((x1 + next[0]) / 2);
} else {
const u = Math.min(x1 + off, W - s.endOffset);
if (u - x1 >= clear) out.push(u);
}
});
return out;
}
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);
}
function compute(settings, roofSettings, units, stateUnits, edge) {
const s = normalizeSettings(settings);
const overrides = {};
stateUnits.forEach(u => { overrides[u.id] = (u.lighting && u.lighting.walls) || {}; });
const segs = [];
units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => {
p.lamps = [];
if (p.baseY) return; // opbouw boven een lager dak: geen gevellampen
const W = p.place.width;
const T = p.place.thickness + 2 * WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
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 tL = Math.max(0, -((p.skinExt || {}).left || 0)), tR = Math.max(0, -((p.skinExt || {}).right || 0));
const q0 = UnitModel.localToWorld(p.place, -W / 2 + tL, T / 2), q1 = UnitModel.localToWorld(p.place, W / 2 - tR, T / 2);
// Gevel loopt tot de afgewerkte hoek, zodat endOffset vanaf de zichtbare hoek telt.
const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc), e = p.skinExt || {};
const extL = (e.left > 0 ? e.left : 0) + c, extR = (e.right > 0 ? e.right : 0) + c;
const up = q1[axis] >= q0[axis];
const corners = [[-W / 2, -p.place.thickness / 2], [W / 2, -p.place.thickness / 2], [-W / 2, T / 2], [W / 2, T / 2]]
.map(([lx, lz]) => UnitModel.localToWorld(p.place, lx, lz));
segs.push({
piece: p, normal, axis, perp, line: q0[perp],
sAt0: q0[axis], sAtW: q1[axis],
s0: up ? q0[axis] - extL : q1[axis] - extR, s1: up ? q1[axis] + extR : q0[axis] + extL,
foot: { x0: Math.min(...corners.map(q => q.x)), x1: Math.max(...corners.map(q => q.x)), z0: Math.min(...corners.map(q => q.z)), z1: Math.max(...corners.map(q => q.z)) },
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;
const runs = groupFacadeRuns(segs);
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 = [], openings = [];
const pos = [];
// Delen achter een andere wand (bijv. een stuk dat de naad in loopt) zijn geen zichtbare gevel.
segs.filter(o => !run.segs.includes(o)).forEach(o => {
const f = o.foot;
const pMin = run.axis === 'x' ? f.z0 : f.x0, pMax = run.axis === 'x' ? f.z1 : f.x1;
const aMin = run.axis === 'x' ? f.x0 : f.z0, aMax = run.axis === 'x' ? f.x1 : f.z1;
if (run.line > pMin + 1 && run.line < pMax - 1 && aMax > run.s0 && aMin < run.s1) blocked.push([Math.max(run.s0, aMin), Math.min(run.s1, aMax)]);
});
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; }
const own = [];
cfg.openings.filter(op => op.y - sW <= H && WallModel.openingTopMax(op) + sW >= H).forEach(op => {
const a = uToS(sg, op.x - sW), b = uToS(sg, op.x + op.w + sW);
own.push([Math.min(a, b), Math.max(a, b)]);
});
openings.push(...own);
if (sg.override === 'openingen') {
blocked.push([sg.s0, sg.s1]);
besideOpenings(cfg, s).forEach(u => pos.push(uToS(sg, u)));
return;
}
blocked.push(...own);
});
// Automatisch: per vrij wandstuk gelijk verdeeld, midden in gelijke delen van ca. lampMax (één lamp
// midden tussen twee ramen), niet tegen de kozijnen of de hoek aan.
freeIntervals(run.s0, run.s1, blocked).forEach(([a, b]) => {
if (b - a < s.minWallWidth) return;
const k = Math.max(1, Math.round((b - a) / s.lampMax));
for (let i = 0; i < k; i++) pos.push(a + (b - a) * (i + 0.5) / k);
});
// Min. h.o.h. geldt over alle lampen van de gevel samen (ook overrides en de grens tussen wanden).
const inOpening = (v) => openings.some(([a, b]) => v > a && v < b);
enforceMinSpacing(pos, s.lampMin, inOpening).filter(v => !inOpening(v)).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))));
}
if (edge) {
const spots = [];
if (s.mode === 'spots') {
const rs = RoofModel.normalizeSettings(roofSettings);
edge.segments.filter(sg => sg.fascia && !sg.overRoof).forEach(sg => {
const from = spots.length;
spotsBetween(sg.spotA, sg.spotB, rs.spotMin, rs.spotMax, spots);
for (let k = from; k < spots.length; k++) {
spots[k].normal = { x: sg.normal.x, z: sg.normal.z };
spots[k].y = sg.topAt(spots[k]) - edge.fasciaHeight;
}
});
}
edge.spots = [];
spots.forEach(p => {
const q = edge.spots.find(e => Math.hypot(e.x - p.x, e.z - p.z) < 5);
if (!q) edge.spots.push({ x: p.x, z: p.z, y: p.y, normal: p.normal });
else if (p.normal && q.normal) q.normal = { x: q.normal.x + p.normal.x, z: q.normal.z + p.normal.z };
});
}
return { lamps };
}
function gridPositions(a, b, min, max) {
const L = b - a;
if (L < min) return [(a + b) / 2];
return [a, ...calculateSegmentPositions(L, min, max).map(p => a + p), b];
}
function distToSegment(p, a, b) {
const dx = b.x - a.x, dz = b.z - a.z, len2 = dx * dx + dz * dz || 1;
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.z - a.z) * dz) / len2));
return Math.hypot(p.x - (a.x + t * dx), p.z - (a.z + t * dz));
}
function unitInnerRect(u, interior) {
const T = {};
u.walls.forEach(w => { T[w.side] = w.removed ? 0 : w.thickness; });
const r = u.rect;
interior.filter(iw => iw.seamUnit === u.id).forEach(iw => {
const p = iw.place, horiz = Math.abs(Math.sin(p.rotY)) < 0.5;
const [side, d] = horiz
? (Math.abs(p.cz - r.z0) < Math.abs(p.cz - r.z1) ? ['voor', p.cz - r.z0] : ['achter', r.z1 - p.cz])
: (Math.abs(p.cx - r.x0) < Math.abs(p.cx - r.x1) ? ['links', p.cx - r.x0] : ['rechts', r.x1 - p.cx]);
T[side] = Math.max(T[side] || 0, d + p.thickness / 2);
});
return { x0: r.x0 + (T.links || 0), x1: r.x1 - (T.rechts || 0), z0: r.z0 + (T.voor || 0), z1: r.z1 - (T.achter || 0) };
}
function computeIndoor(settings, units, interior) {
const s = normalizeSettings(settings);
if (!s.indoor) return [];
const out = [];
units.forEach(u => {
const off = s.indoorOffset, inner = unitInnerRect(u, interior);
const own = interior.filter(iw => !iw.seamUnit || iw.seamUnit === u.id);
const xs = gridPositions(inner.x0 + off, inner.x1 - off, s.indoorMin, s.indoorMax);
const zs = gridPositions(inner.z0 + off, inner.z1 - off, s.indoorMin, s.indoorMax);
xs.forEach(x => zs.forEach(z => {
const tooClose = own.some(iw => {
const half = iw.place.width / 2, c = Math.cos(iw.place.rotY), sn = Math.sin(iw.place.rotY);
const a = { x: iw.place.cx - half * c, z: iw.place.cz + half * sn }, b = { x: iw.place.cx + half * c, z: iw.place.cz - half * sn };
return distToSegment({ x, z }, a, b) < iw.place.thickness / 2 + off - 1;
});
if (!tooClose) out.push({ unitId: u.id, x, z, y: u.roof.ceilingAt(x, z), floorY: u.layout.floorTop });
}));
});
return out;
}
const ROOM_SPOT_MIN_M2 = 0.6;
function roomSpots(settings, indoor, rooms, units) {
const s = normalizeSettings(settings);
if (!s.indoor || !rooms) return indoor;
rooms.rooms.concat(rooms.free).forEach(r => {
if (r.areaM2 < ROOM_SPOT_MIN_M2) return;
if (indoor.some(p => { const g = rooms.regionAt(p.x, p.z); return g && g.idx === r.key; })) return;
const c = r.label, u = units.find(q => c.x >= q.rect.x0 && c.x <= q.rect.x1 && c.z >= q.rect.z0 && c.z <= q.rect.z1);
if (!u) return;
indoor.push({ unitId: u.id, x: c.x, z: c.z, y: u.roof.ceilingAt(c.x, c.z), floorY: u.layout.floorTop, room: true });
});
return indoor;
}
let offMaterial = null;
function applySwitches(group, isOff) {
if (!group) return;
offMaterial = offMaterial || new THREE.MeshStandardMaterial({ color: 0xcfcbc0, roughness: 0.5 });
group.traverse(o => {
const ref = o.userData.ref;
if (!ref) return;
const off = !!isOff(ref);
if (o.userData.glow) {
if (!o.userData.onMat) o.userData.onMat = o.material;
o.material = off ? offMaterial : o.userData.onMat;
} else if (o.isLight || o.userData.wash) o.visible = !off;
else if (o.userData.beam) { if (off) o.visible = false; }
});
}
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 }),
indoorMaterial: new THREE.MeshStandardMaterial({ color: 0xfff4d0, emissive: 0xffe2a0, emissiveIntensity: 0.8 }),
beamMaterial: new THREE.MeshBasicMaterial({ color: 0xffe9a8, transparent: true, opacity: 0.16, depthWrite: false, side: THREE.DoubleSide })
};
}
function build3D(lamps, materials, indoor) {
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);
g.userData = { ref: l, glow: true };
group.add(g);
const cone = new THREE.Mesh(new THREE.ConeGeometry(beamR, beamH, 20, 1, true), materials.beamMaterial);
cone.userData = { beam: true, ref: l };
if (sign > 0) cone.rotation.x = Math.PI;
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);
});
(indoor || []).forEach(p => {
const spot = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.01, 16), materials.indoorMaterial);
spot.position.set(p.x * toM, p.y * toM - 0.006, p.z * toM);
spot.userData = { ref: p, glow: true };
group.add(spot);
});
return group;
}
// Geen schaduw per lamp (één schaduwkaart per lamp, de browser kan er ~16), dus licht door muren
// geometrisch voorkomen: buitenlampen en boeideel-spots half hun bundelhoek naar buiten gekanteld,
// binnenspots met een harde reikwijdte net voorbij de vloer (INDOOR_REACH).
const OUTDOOR = { lamp: 1.1, spot: 0.9, angle: 0.62, penumbra: 0.85 };
const INDOOR = { intensity: 1.4, angle: 1.3, penumbra: 0.9, reach: 1.12, decay: 1.0 };
const DAY_OUTDOOR_FACTOR = 0.35;
function buildLights3D(lamps, spots, spotY, indoor, o) {
const toM = 0.001;
const fo = (o && o.outdoorFactor !== undefined) ? o.outdoorFactor : 1;
const group = new THREE.Group();
const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra, ref) => {
const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay);
light.userData.ref = ref;
light.position.set(x, y, z);
light.target.position.set(x + tx, y + ty, z + tz);
group.add(light);
group.add(light.target);
};
const tilt = Math.tan(OUTDOOR.angle);
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;
const nx = l.normal.x * tilt, nz = l.normal.z * tilt;
if (l.direction !== 'up') add(x, y - 0.09, z, nx, -1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
if (l.direction !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra, l);
});
(spots || []).forEach(p => {
const n = p.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
add(p.x * toM, (p.y !== undefined ? p.y : spotY) * toM - 0.02, p.z * toM, n.x / len * tilt, -1, n.z / len * tilt, OUTDOOR.spot * fo, OUTDOOR.angle, 5.5, 1.5, OUTDOOR.penumbra, p);
});
(indoor || []).forEach(p => {
const drop = (p.y - p.floorY) * toM;
add(p.x * toM, p.y * toM - 0.02, p.z * toM, 0, -1, 0, INDOOR.intensity, INDOOR.angle, drop * INDOOR.reach, INDOOR.decay, INDOOR.penumbra, p);
});
if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo));
return group;
}
// Nep-lichtvlekken op de gevel: echte spots zonder schaduw zouden door de muur naar binnen schijnen.
const WASH = { color: 0xffd9a0, spot: 0.55, lamp: 0.45, spread: 0.5, lampReach: 1800 };
let washTexture = null;
function getWashTexture() {
if (washTexture) return washTexture;
const W = 128, H = 256;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
for (let j = 0; j < H; j++) {
const t = j / (H - 1);
const half = 0.12 + 0.88 * Math.pow(t, 0.6);
const fade = Math.min(1, t * 12) * Math.pow(1 - t, 1.6);
for (let i = 0; i < W; i++) {
const x = Math.abs((i + 0.5) / W * 2 - 1) / half;
const v = x >= 1 ? 0 : fade * Math.pow(1 - x * x, 2);
const k = (j * W + i) * 4;
img.data[k] = img.data[k + 1] = img.data[k + 2] = Math.round(255 * v);
img.data[k + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
washTexture = new THREE.CanvasTexture(canvas);
return washTexture;
}
function washMesh(widthM, heightM, opacity, up) {
const mat = new THREE.MeshBasicMaterial({
map: getWashTexture(), color: WASH.color, transparent: true, opacity,
blending: THREE.AdditiveBlending, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2
});
const geo = new THREE.PlaneGeometry(widthM, heightM);
geo.translate(0, up ? heightM / 2 : -heightM / 2, 0);
if (up) {
const uv = geo.attributes.uv;
for (let i = 0; i < uv.count; i++) uv.setY(i, 1 - uv.getY(i));
}
const m = new THREE.Mesh(geo, mat);
m.renderOrder = 2;
return m;
}
function buildWash3D(lamps, spots, spotY, walls, groundY, fo) {
const toM = 0.001;
const group = new THREE.Group();
const facadeBehind = (q, n) => {
let best = null;
walls.forEach(p => {
const pl = p.place;
if (pl.normal.x * n.x + pl.normal.y * n.z < 0.9) return;
const c = Math.cos(pl.rotY), sn = Math.sin(pl.rotY);
const dx = q.x - pl.cx, dz = q.z - pl.cz;
const lx = dx * c - dz * sn, lz = dx * sn + dz * c;
const face = pl.thickness / 2 + WallModel.claddingOffset(p.buitenMateriaal, p.stone, p.stuc);
const d = lz - face;
if (Math.abs(lx) > pl.width / 2 + 50 || d < -5 || d > 1500) return;
if (!best || d < best.d) best = { d, piece: p, lx, face };
});
return best;
};
const place = (mesh, pl, lx, lz, yMM, ref) => {
mesh.userData.ref = ref;
mesh.userData.wash = true;
const w = UnitModel.localToWorld(pl, lx, lz);
mesh.position.set(w.x * toM, yMM * toM, w.z * toM);
mesh.rotation.y = pl.rotY;
group.add(mesh);
};
(spots || []).forEach(sp => {
const n = sp.normal || { x: 0, z: 0 }, len = Math.hypot(n.x, n.z) || 1;
const nn = { x: n.x / len, z: n.z / len };
if (Math.abs(nn.x) > 0.1 && Math.abs(nn.z) > 0.1) return;
const top = (sp.y !== undefined ? sp.y : spotY) - 30;
const hit = facadeBehind(sp, nn);
if (!hit || top - groundY < 300) return;
const H = (top - groundY) * toM;
place(washMesh(2 * (0.1 + H * WASH.spread), H, WASH.spot * fo), hit.piece.place, hit.lx, hit.face + 6, top, sp);
});
(lamps || []).forEach(l => {
const n = { x: l.normal.x, z: l.normal.z };
const hit = facadeBehind({ x: l.x + n.x * 20, z: l.z + n.z * 20 }, n);
if (!hit) return;
const pl = hit.piece.place;
if (l.direction !== 'up') {
const H = Math.min(WASH.lampReach, l.y - 90 - groundY) * toM;
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo), pl, hit.lx, hit.face + 6, l.y - 90, l);
}
if (l.direction !== 'down') {
const wallTop = pl.floorTop + WallModel.topLine(hit.piece.config).maxY + (hit.piece.skinAbove || 0);
const H = Math.min(WASH.lampReach, wallTop - l.y - 90) * toM;
if (H > 0.2) place(washMesh(2 * (0.06 + H * WASH.spread), H, WASH.lamp * fo, true), pl, hit.lx, hit.face + 6, l.y + 90, l);
}
});
return group;
}
return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, roomSpots, applySwitches, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D };
})();