/*! * 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'. // // Binnenspots (computeIndoor) worden PER UNIT berekend, net als de spots in // het boeideel verdeeld tussen min/max h.o.h.: een raster in het plafond // binnen de unit, minstens indoorOffset van de binnenzijde van elke wand (of // van de unitrand waar een naad met een andere unit is), met in beide // richtingen indoorMin..indoorMax h.o.h. Past er in een richting geen // indoorMin tussen, dan één spot in het midden. Spots te dicht bij een // binnenwand vervallen. // // 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 // Binnenspots in het plafond, per unit (computeIndoor). indoor: true, indoorMin: 800, // mm h.o.h., in beide richtingen indoorMax: 1500, indoorOffset: 600 // min. afstand tot wand (binnenzijde) of unitrand (naad) }; 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 = []; if (p.baseY) return; // opbouw boven een lager dak: geen gevellampen const W = p.place.width; // Lampen hangen op de afgewerkte gevel: wandvlak + kruislat + bekleding. const T = p.place.thickness + 2 * WallModel.claddingOffset(p.buitenMateriaal, p.stone); 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'; // Binnenhoek-doorloop (negatieve skinExt): de gevel begint pas daarna. 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 altijd vanaf de // zichtbare hoek telt: elk uiteinde + afwerking (latwerk + bekleding van // de haakse wand die eroverheen loopt), en waar dit stuk de kopse kant // van een doorlopende wand bedekt (skinExt) ook nog die wanddikte. // Binnenhoeken en naden: zie de voetafdrukken in 3 en de overlap in 2. const c = WallModel.claddingOffset(p.buitenMateriaal, p.stone), 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]; // Voetafdruk van dit stuk incl. afwerking (om bedekte delen van andere // gevels af te trekken, zie 3). 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; // 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) // Delen van deze gevel die achter een andere wand liggen (bijv. het // stuk van een wand dat de naad in loopt om een binnenhoek te sluiten) // zijn geen zichtbare gevel: de gevel begint pas bij de afgewerkte // buitenkant van die andere wand. 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; } 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 && WallModel.openingTopMax(op) + 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 && !sg.overRoof).forEach(sg => { // niet boven een lager dak 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; // onderkant boeideel (volgt de dakvorm) } }); } // Dubbele spots (hoeken) samenvoegen; de buitenrichting wordt dan de // som van beide gevels (diagonaal naar buiten). 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 }; } // Posities op [a, b]: beide uiteinden + verdeeld (calculateSegmentPositions); // korter dan min (of negatief) = één positie in het midden. 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]; } // Afstand van punt p tot lijnstuk a-b (mm). 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)); } // Binnenruimte van een unit (woning-mm): de rect min de buitenwanden, en // waar een naadwanddeel van deze unit tegen de naad staat, tot diens // binnenzijde (net als bij een buitenwand). Ook gebruikt door ElektraModel. 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) }; } // Binnenspots per unit. units = HouseModel-parts.units (rect, walls, roof.ceilingAt), // interior = parts.interior (binnenwanden met place). Geeft // [{ unitId, x, z, y, floorY }] (mm, y = onderkant plafond, floorY = loopvlak). 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; } 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 }) }; } // 3D: behuizing op het gevelvlak + lichtbundel(s) omhoog/omlaag, en de // binnenspots (indoor, zie computeIndoor) als schijfje in het plafond. 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); 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); }); (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); group.add(spot); }); return group; } // Render: elke gevellamp (per lichtrichting), boeideel-spot en binnenspot // wordt een echte SpotLight. Echte schaduw per lamp is te zwaar (één // schaduwkaart per lamp; de browser kan er maar ~16), dus licht door muren // wordt geometrisch voorkomen: // - buitenlampen en boeideel-spots zijn half hun bundelhoek naar buiten // gekanteld: de binnenrand van de bundel loopt verticaal langs de gevel; // - binnenspots hebben een harde reikwijdte net voorbij de vloer // (INDOOR_REACH), zodat ze niet tot de grond buiten reiken. // spots = [{ x, z, y }] (mm, boeideel; y = onderkant boeideel, anders spotY), // indoor = [{ x, z, y, floorY }] (mm). o.outdoorFactor schaalt de buiten- // lampen (overdag lager: dan vallen ze nauwelijks op). // Zacht en breed i.p.v. fel en smal: lagere intensiteit, grotere bundel // en een brede zachte rand (penumbra), zodat naast elkaar liggende spots // niet optellen tot felle vlekken. 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(); // Spot op (x, y, z) die naar (x + tx, y + ty, z + tz) schijnt. const add = (x, y, z, tx, ty, tz, intensity, angle, distance, decay, penumbra) => { const light = new THREE.SpotLight(0xffd9a0, intensity, distance, angle, penumbra, decay); 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); // bundelas half de bundelhoek naar buiten 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); if (l.direction !== 'down') add(x, y + 0.09, z, nx, 1, nz, OUTDOOR.lamp * fo, OUTDOOR.angle, 4.5, 1.5, OUTDOOR.penumbra); }); // Boeideel-spots: naar buiten gekanteld (normaal van de dakrand; op een // hoek diagonaal). (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); }); (indoor || []).forEach(p => { const drop = (p.y - p.floorY) * toM; // plafond -> loopvlak 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); }); if (o && o.walls) group.add(buildWash3D(lamps, spots, spotY, o.walls, o.groundY || 0, fo)); return group; } // ------------------------------------------------------------ // Nep-lichtvlekken (wall-wash) voor de render. Echte spots mogen niet op // de gevel schijnen (geen schaduw: het licht zou door de muur naar binnen // gaan, zie buildLights3D). In plaats daarvan komt op het gevelvlak een // additief, zacht kegelvormig verloop: smal en fel bij de lamp, breder en // zwakker naar beneden (of naar boven bij een up-light). Geen licht- // berekening, dus geen grens aan het aantal. // walls = buitenwandstukken (place, buitenMateriaal, stone); groundY = mm. // ------------------------------------------------------------ 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); // 0 = bij de lamp, 1 = einde bundel const half = 0.12 + 0.88 * Math.pow(t, 0.6); // bundelbreedte (fractie van de halve breedte) const fade = Math.min(1, t * 12) * Math.pow(1 - t, 1.6); // zacht begin, uitdoven 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); // Lamp aan de bovenrand (down) resp. onderrand (up). geo.translate(0, up ? heightM / 2 : -heightM / 2, 0); if (up) { // textuur gespiegeld: lamp onderaan 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(); // Gevelvlak achter een punt (mm) met buitenrichting n: dichtstbijzijnde // wandstuk met dezelfde normaal waar het punt vóór ligt. 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); 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) => { 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); }; // Boeideel-spots: van de spot omlaag tot het maaiveld (op een hoek, // diagonale normaal, geen vlek). (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); }); // Gevellampen (op de gevel): bundel omlaag en/of omhoog. (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); } if (l.direction !== 'down') { // Niet boven de gevel uit (tot de bovenkant van het dak). 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); } }); return group; } return { DEFAULT_SETTINGS, MODES, DIRECTIONS, WALL_OVERRIDES, createDefaultUnitLighting, DAY_OUTDOOR_FACTOR, normalizeSettings, compute, computeIndoor, unitInnerRect, freeIntervals, createMaterials, build3D, buildLights3D }; })();