1/** 2 * VibeLux path-tracer worker. 3 * 4 * Forward Monte Carlo path tracer. Emits samples from each fixture using 5 * IES-weighted importance sampling (or Lambertian fall-back), lets them 6 * bounce off room walls and cart side panels, and deposits their weight 7 * onto tier-plane grids when they land inside a cart footprint. 8 * 9 * Scene units: meters. PPF in umol/s. Reflectances in 0..1. 10 * 11 * Message contract is defined in src/lib/raytrace/types.ts. 12 */ 13 14'use strict'; 15 16/* ========================================================================= 17 * RNG â tiny 32-bit mulberry for reproducibility. Good enough for MC. 18 * ========================================================================= */ 19function makeRng(seed) { 20 let s = (seed >>> 0) || 1; 21 return function rng() { 22 s |= 0; 23 s = (s + 0x6D2B79F5) | 0; 24 let t = Math.imul(s ^ (s >>> 15), 1 | s); 25 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; 26 return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 27 }; 28} 29 30/* ========================================================================= 31 * Vector helpers (inlined, scalar-fast). 32 * ========================================================================= */ 33function vnorm(v) { 34 const m = Math.hypot(v[0], v[1], v[2]) || 1; 35 return [v[0] / m, v[1] / m, v[2] / m]; 36} 37function vscale(v, s) { return [v[0] * s, v[1] * s, v[2] * s]; } 38function vadd(a, b) { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; } 39function vdot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } 40function vcross(a, b) { 41 return [ 42 a[1] * b[2] - a[2] * b[1], 43 a[2] * b[0] - a[0] * b[2], 44 a[0] * b[1] - a[1] * b[0], 45 ]; 46} 47 48/** 49 * Build a tangent-space basis given a unit normal. 50 */ 51function orthonormalBasis(n) { 52 // Pick an arbitrary non-parallel axis then Gram-Schmidt. 53 const a = Math.abs(n[0]) > 0.9 ? [0, 1, 0] : [1, 0, 0]; 54 const t = vnorm(vcross(a, n)); 55 const b = vcross(n, t); 56 return [t, b, n]; 57} 58 59/** 60 * Cosine-weighted hemisphere sample around normal n. 61 */ 62function sampleCosineHemisphere(rng, n) { 63 const u1 = rng(); 64 const u2 = rng(); 65 const r = Math.sqrt(u1); 66 const phi = 2 * Math.PI * u2; 67 const x = r * Math.cos(phi); 68 const y = r * Math.sin(phi); 69 const z = Math.sqrt(Math.max(0, 1 - u1)); 70 const [t, b, nn] = orthonormalBasis(n); 71 return [ 72 t[0] * x + b[0] * y + nn[0] * z, 73 t[1] * x + b[1] * y + nn[1] * z, 74 t[2] * x + b[2] * y + nn[2] * z, 75 ]; 76} 77 78/* ========================================================================= 79 * IES / Lambertian emission sampling. 80 * 81 * The IES grid is (gammaSteps x phiSteps). gamma is the polar angle from 82 * the fixture's down-direction (0 = on-axis), phi the azimuth around it. 83 * We build a 1D CDF over grid cells weighted by I * sin(gamma) to get 84 * uniform-in-solid-angle sampling proportional to intensity. 85 * ========================================================================= */ 86function prepIesCdf(ies) { 87 if (!ies) return null; 88 const G = ies.gammaSteps; 89 const P = ies.phiSteps; 90 const vals = ies.values; 91 const cdf = new Float64Array(G * P); 92 let acc = 0; 93 for (let gi = 0; gi < G; gi++) { 94 // Solid-angle weight per cell; mid-point sample of gamma. 95 const gamma = (gi + 0.5) * Math.PI / G; 96 const sinG = Math.sin(gamma); 97 for (let pi = 0; pi < P; pi++) { 98 const idx = gi * P + pi; 99 acc += Math.max(0, vals[idx]) * sinG; 100 cdf[idx] = acc; 101 } 102 } 103 // Degenerate table: fall back to isotropic-cos sampling. 104 if (acc <= 0) return null; 105 return { cdf, total: acc, G, P }; 106} 107 108/** 109 * Binary-search a CDF for the bin containing r*total. 110 */ 111function cdfPick(cdf, total, r) { 112 const target = r * total; 113 let lo = 0, hi = cdf.length - 1; 114 while (lo < hi) { 115 const mid = (lo + hi) >> 1; 116 if (cdf[mid] < target) lo = mid + 1; 117 else hi = mid; 118 } 119 return lo; 120} 121 122/** 123 * Sample an emission direction in the fixture's LOCAL frame 124 * (z' = aim direction). Returns a unit vector. 125 */ 126function sampleEmissionLocal(rng, iesPrepped) { 127 if (!iesPrepped) { 128 // Lambertian emitter on the hemisphere centered on +z' (local). 129 return sampleCosineHemisphere(rng, [0, 0, 1]); 130 } 131 const { cdf, total, G, P }
131 = iesPrepped; 132 const bin = cdfPick(cdf, total, rng()); 133 const gi = (bin / P) | 0; 134 const pi = bin - gi * P; 135 // Jitter within the cell. 136 const gamma = (gi + rng()) * Math.PI / G; 137 const phi = (pi + rng()) * 2 * Math.PI / P; 138 const sg = Math.sin(gamma); 139 // In local frame, aim direction is +z'. gamma = 0 -> along +z'. 140 // But real fixtures aim DOWN, so we will rotate after. 141 return [sg * Math.cos(phi), sg * Math.sin(phi), Math.cos(gamma)]; 142} 143 144/** 145 * Rotate a local-frame direction into world frame using fixture's aim dir. 146 * The aim direction is the "forward" (gamma=0) axis. 147 */ 148function rotateIntoAim(localDir, aim) { 149 // Build basis where +z world-local = aim 150 const [t, b, n] = orthonormalBasis(vnorm(aim)); 151 return [ 152 t[0] * localDir[0] + b[0] * localDir[1] + n[0] * localDir[2], 153 t[1] * localDir[0] + b[1] * localDir[1] + n[1] * localDir[2], 154 t[2] * localDir[0] + b[2] * localDir[1] + n[2] * localDir[2], 155 ]; 156} 157 158/* ========================================================================= 159 * Scene surfaces. 160 * 161 * Two surface kinds: bounded AA rectangles (for room walls, cart sides, 162 * cart tiers). Each surface records: 163 * - axis: which world axis is its normal (0=x, 1=y, 2=z) 164 * - sign: +1 or -1 (normal direction along that axis) 165 * - plane: constant on that axis 166 * - umin,umax,vmin,vmax: bounds in the other two axes 167 * - kind: 'room' | 'cartWall' | 'tier' 168 * - rho: reflectance 0..1 169 * - tau: transmittance 0..1 (tiers only; 0 otherwise) 170 * - ref: backref object for tiers (cart index, tier index, grid) 171 * ========================================================================= */ 172function buildScene(req) { 173 const surfaces = []; 174 const tiers = []; // flat list of tier refs (cartIdx, tierIdx, grid, bounds) 175 176 // Room: 6 inner planes (normals face INTO the room). 177 const R = req.room; 178 const rho = R.reflectances; 179 // Floor: z=0, normal +z 180 surfaces.push(makeRect(2, +1, 0, 0, R.width, 0, R.length, 'room', rho.floor, 0, null)); 181 // Ceiling: z=H, normal -z 182 surfaces.push(makeRect(2, -1, R.height, 0, R.width, 0, R.length, 'room', rho.ceiling, 0, null)); 183 // West wall: x=0, normal +x 184 surfaces.push(makeRect(0, +1, 0, 0, R.length, 0, R.height, 'room', rho.walls, 0, null)); 185 // East wall: x=W, normal -x 186 surfaces.push(makeRect(0, -1, R.width, 0, R.length, 0, R.height, 'room', rho.walls, 0, null)); 187 // South wall: y=0, normal +y 188 surfaces.push(makeRect(1, +1, 0, 0, R.width, 0, R.height, 'room', rho.walls, 0, null)); 189 // North wall: y=L, normal -y 190 surfaces.push(makeRect(1, -1, R.length, 0, R.width, 0, R.height, 'room', rho.walls, 0, null)); 191 192 // Carts. 193 req.carts.forEach((cart, ci) => { 194 const x0 = cart.x - cart.width / 2; 195 const x1 = cart.x + cart.width / 2; 196 const y0 = cart.y - cart.length / 2; 197 const y1 = cart.y + cart.length / 2; 198 // Tiers â horizontal planes, doubled (top face +z, bottom face -z) so 199 // rays from above and below both see a surface. 200 cart.tiers.forEach((tier, ti) => { 201 const grid = new Float32Array(tier.gridRows * tier.gridCols); 202 const cellW = cart.width / tier.gridCols; 203 const cellL = cart.length / tier.gridRows; 204 const tierRef = { 205 cartIdx: ci, 206 tierIdx: ti, 207 cartId: cart.id, 208 tierId: tier.id, 209 grid, 210 gridRows: tier.gridRows, 211 gridCols: tier.gridCols, 212 cellWidth: cellW, 213 cellLength: cellL, 214 cellArea: cellW * cellL, 215 bounds: { x0, x1, y0, y1 }, 216 targetPPFD: tier.targetPPFD, 217 trays: Array.isArray(tier.trays) ? tier.trays : [], 218 }; 219 tiers.push(tierRef); 220 // Top face (normal +z) 221 surfaces.push(makeRect(2, +1, tier.z, x0, x1, y0, y1, 'tier', tier.reflectance, tier.transmittance, tierRef)); 222 // Bottom face (normal -z) â for interreflected light coming from below 223 surfaces.push(makeRect(2, -1, tier.z, x0, x1, y0, y1, 'tier', tier.reflectance, tier.transmittance, tierRef)); 224 }); 225 }); 226 227 // Calc-surface meter planes (transparent, deposit-then-continue). 228 const calcs = []; 229 const calcSurfaceList = Array.isArray(req.calcSurfaces) ? req.calcSurfaces : []; 230 calcSurfaceList.forEach((cs, csi) => { 231 const grid = new Float32Array(cs.gridRows * cs.gridCols); 232 const cellW = (cs.xMax - cs.xMin) / cs.gridCols; 233 const cellL = (cs.yMax - cs.yMin) / cs.gridRows; 234 const csRef = { 235 calcIdx: csi, 236 id: cs.id, 237 grid, 238 gridRows: cs.gridRows, 239 gridCols: cs.gridCols, 240 cellWidth: cellW, 241 cellLength: cellL, 242 cellArea: cellW * cellL, 243 bounds: { x0: cs.xMin, x1: cs.xMax, y0: cs.yMin, y1: cs.yMax }, 244 targetPPFD: cs.targetPPFD, 245 }; 246 calcs.push(csRef); 247 // Two-sided meter plane so it records flux from both above and below. 248 surfaces.push(makeRect(2, +1, cs.z, cs.xMin, cs.xMax, cs.yMin, cs.yMax, 'meter', 0, 1, csRef)); 249 surfaces.push(makeRect(2, -1, cs.z, cs.xMin, cs.xMax, cs.yMin, cs.yMax, 'meter', 0, 1, csRef)); 250 }); 251 252 // Separate meter surfaces â they don't participate in nearest-hit 253 // intersection. They deposit along the ray path instead. 254 const meterSurfaces = surfaces.filter(function(sx) { return sx.
254kind === 'meter'; }); 255 const solidSurfaces = surfaces.filter(function(sx) { return sx.kind !== 'meter'; }); 256 return { surfaces: solidSurfaces, meters: meterSurfaces, tiers: tiers, calcs: calcs, room: R }; 257} 258 259function makeRect(axis, sign, plane, umin, umax, vmin, vmax, kind, rho, tau, ref) { 260 return { axis, sign, plane, umin, umax, vmin, vmax, kind, rho, tau, ref }; 261} 262 263/* ========================================================================= 264 * Ray-surface intersection. 265 * 266 * For each AA rectangle: plane test on `axis`, then bounds test on the 267 * other two axes. Keeps only the nearest hit in front of the ray. 268 * ========================================================================= */ 269function intersectScene(origin, dir, surfaces, tMin) { 270 let bestT = Infinity; 271 let bestSurf = null; 272 for (let i = 0; i < surfaces.length; i++) { 273 const s = surfaces[i]; 274 const od = dir[s.axis]; 275 if (od === 0) continue; 276 // Only hit surfaces whose normal faces the incoming ray. 277 // normal = sign along axis; ray hits front face when -sign * od > 0. 278 if (-s.sign * od <= 0) continue; 279 const t = (s.plane - origin[s.axis]) / od; 280 if (t <= tMin || t >= bestT) continue; 281 // Bounds check on the other two axes. 282 const ax0 = (s.axis + 1) % 3; 283 const ax1 = (s.axis + 2) % 3; 284 const u = origin[ax0] + t * dir[ax0]; 285 const v = origin[ax1] + t * dir[ax1]; 286 if (u < s.umin || u > s.umax || v < s.vmin || v > s.vmax) continue; 287 bestT = t; 288 bestSurf = s; 289 } 290 return bestSurf ? { t: bestT, surf: bestSurf } : null; 291} 292 293/* ========================================================================= 294 * Trace one ray from a fixture. Deposits onto tier grids along the way. 295 * ========================================================================= */ 296function traceRay(scene, rng, origin, dir, weightPPF, maxBounces) { 297 let o = origin; 298 let d = vnorm(dir); 299 let w = weightPPF; 300 const EPS = 1e-5; 301 const surfaces = scene.surfaces; 302 303 for (let bounce = 0; bounce <= maxBounces; bounce++) { 304 if (w <= 0) return; 305 const hit = intersectScene(o, d, surfaces, EPS); 306 const hitT = hit ? hit.t : Infinity; 307 308 // Meter-plane crossings: deposit along the segment (EPS, hitT]. 309 // Transparent â does not consume weight or alter direction. 310 const meters = scene.meters; 311 if (meters && meters.length > 0) { 312 for (let mi = 0; mi < meters.length; mi++) { 313 const ms = meters[mi]; 314 const od = d[ms.axis]; 315 if (od === 0) continue; 316 if (-ms.sign * od <= 0) continue; 317 const tm = (ms.plane - o[ms.axis]) / od; 318 if (tm <= EPS || tm > hitT) continue; 319 const ax0 = (ms.axis + 1) % 3; 320 const ax1 = (ms.axis + 2) % 3; 321 const u = o[ax0] + tm * d[ax0]; 322 const v = o[ax1] + tm * d[ax1]; 323 if (u < ms.umin || u > ms.umax || v < ms.vmin || v > ms.vmax) continue; 324 const ref = ms.ref; 325 const b0 = ref.bounds; 326 const cx0 = u - b0.x0; 327 const cy0 = v - b0.y0; 328 const col0 = Math.min(ref.gridCols - 1, Math.max(0, Math.floor(cx0 / ref.cellWidth))); 329 const row0 = Math.min(ref.gridRows - 1, Math.max(0, Math.floor(cy0 / ref.cellLength))); 330 ref.grid[row0 * ref.gridCols + col0] += w / ref.cellArea; 331 } 332 } 333 334 if (!hit) return; 335 const s = hit.surf; 336 const t = hit.t; 337 const p = [o[0] + t * d[0], o[1] + t * d[1], o[2] + t * d[2]]; 338 const n = [0, 0, 0]; 339 n[s.axis] = s.sign; 340 341 if (s.kind === 'tier') { 342 // Deposit incident flux onto the grid cell. 343 const ref = s.ref; 344 const b = ref.bounds; 345 const cx = p[0] - b.x0; 346 const cy = p[1] - b.y0; 347 const col = Math.min(ref.gridCols - 1, Math.max(0, Math.floor(cx / ref.cellWidth))); 348 const row = Math.min(ref.gridRows - 1, Math.max(0, Math.floor(cy / ref.cellLength))); 349 // PPFD contribution: photon flux (umol/s) per cell area (m^2). 350 ref.grid[row * ref.gridCols + col] += w / ref.cellArea; 351 352 // Branch on tau (pass-through), rho (reflect), absorb. 353 const tau = s.tau; 354 const rho = s.rho; 355 const r = rng(); 356 if (r < tau) { 357 // Pass through with same direction, reduced weight. 358 o = vadd(p, vscale(d, EPS)); 359 w *= 1; // tau already implied by branch probability 360 continue; 361 } else if (r < tau + rho) { 362 // Lambertian reflect, same-side hemisphere. 363 o = vadd(p, vscale(n, EPS));
364 d = sampleCosineHemisphere(rng, n); 365 // weight unchanged â branch probability already accounts for rho. 366 continue; 367 } else { 368 return; // absorbed 369 } 370 } else { 371 // Room wall or cart sidewall â lambertian reflect or absorb. 372 if (rng() < s.rho) { 373 o = vadd(p, vscale(n, EPS)); 374 d = sampleCosineHemisphere(rng, n); 375 // weight unchanged 376 continue; 377 } 378 return; 379 } 380 } 381} 382 383/* ========================================================================= 384 * Main trace loop. 385 * ========================================================================= */ 386function runTrace(req) { 387 const rng = makeRng(req.seed || 1); 388 const scene = buildScene(req); 389 const fixIes = req.fixtures.map((fx) => prepIesCdf(fx.ies)); 390 const N = req.samplesPerFixture; 391 const F = req.fixtures.length; 392 const totalSamples = N * F; 393 394 const tStart = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); 395 let traced = 0; 396 let lastReported = 0; 397 const progressStep = Math.max(1, Math.floor(totalSamples / 50)); 398 399 for (let fi = 0; fi < F; fi++) { 400 const fx = req.fixtures[fi]; 401 const iesP = fixIes[fi]; 402 const aim = vnorm(fx.dir); 403 const originPos = [fx.x, fx.y, fx.z]; 404 const weightPPF = fx.ppf / N; 405 406 for (let i = 0; i < N; i++) { 407 const localDir = sampleEmissionLocal(rng, iesP); 408 const worldDir = rotateIntoAim(localDir, aim); 409 traceRay(scene, rng, originPos, worldDir, weightPPF, req.maxBounces); 410 traced++; 411 if (traced - lastReported >= progressStep) { 412 lastReported = traced; 413 self.postMessage({ 414 type: 'progress', 415 runId: req.runId, 416 progress: traced / totalSamples, 417 samplesTraced: traced, 418 }); 419 } 420 } 421 } 422 423 const elapsedMs = ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) - tStart; 424 425 // Aggregate per-tier + per-tray stats. Transfer grid buffers for speed. 426 const tierResults = []; 427 const transfer = []; 428 for (const t of scene.tiers) { 429 const g = t.grid; 430 let sum = 0, mn = Infinity, mx = 0; 431 for (let k = 0; k < g.length; k++) { 432 const v = g[k]; 433 if (v < mn) mn = v; 434 if (v > mx) mx = v; 435 sum += v; 436 } 437 const avg = sum / g.length; 438 const unif = avg > 0 ? (mn / avg) : 0; 439 440 // Per-tray aggregation: for each tray, find the row/col cell range 441 // whose centres fall inside the tray bounds and compute stats. 442 const trayResults = []; 443 for (const tr of (t.trays || [])) { 444 let tSum = 0, tMin = Infinity, tMax = 0, tCells = 0; 445 // Cell indices spanning the tray (inclusive). 446 const cColMin = Math.max(0, Math.floor(tr.uMin / t.cellWidth)); 447 const cColMax = Math.min(t.gridCols - 1, Math.floor((tr.uMax - 1e-9) / t.cellWidth)); 448 const cRowMin = Math.max(0, Math.floor(tr.vMin / t.cellLength)); 449 const cRowMax = Math.min(t.gridRows - 1, Math.floor((tr.vMax - 1e-9) / t.cellLength)); 450 for (let r = cRowMin; r <= cRowMax; r++) { 451 for (let c = cColMin; c <= cColMax; c++) { 452 const v = g[r * t.gridCols + c]; 453 if (v < tMin) tMin = v; 454 if (v > tMax) tMax = v; 455 tSum += v; 456 tCells++; 457 } 458 } 459 const tAvg = tCells > 0 ? tSum / tCells : 0; 460 const tUnif = tAvg > 0 ? (tMin / tAvg) : 0; 461 trayResults.push({ 462 id: tr.id, 463 uMin: tr.uMin, uMax: tr.uMax, vMin: tr.vMin, vMax: tr.vMax, 464 cellCount: tCells, 465 min: tMin === Infinity ? 0 : tMin, 466 avg: tAvg, 467 max: tMax, 468 uniformity: tUnif, 469 }); 470 } 471 472 tierResults.push({ 473 cartId: t.cartId, 474 tierId: t.tierId, 475 grid: g, 476 gridRows: t.gridRows, 477 gridCols: t.gridCols, 478 cellWidth: t.cellWidth, 479 cellLength: t.cellLength, 480 min: mn === Infinity ? 0 : mn, 481 avg, 482 max: mx, 483 uniformity: unif, 484 targetPPFD: t.targetPPFD, 485 trays: trayResults, 486 }); 487 transfer.push(g.buffer); 488 } 489 490 // Calc-surface aggregates. 491 const calcResults = []; 492 const calcs = scene.calcs || []; 493 for (const c of calcs) { 494 const g = c.grid; 495 let sum = 0, mn = Infinity, mx = 0; 496 for (let k = 0; k < g.length; k++) { 497 const v = g[k]; 498 if (v < mn) mn = v; 499 if (v > mx) mx = v; 500 sum += v; 501 } 502 const avg = sum / g.length; 503 const unif = avg > 0 ? (mn / avg) : 0; 504 calcResults.push({ 505 id: c.id, 506 grid: g, 507 gridRows: c.gridRows, 508 gridCols: c.gridCols, 509 cellWidth: c.cellWidth, 510 cellLength: c.cellLength, 511 min: mn === Infinity ? 0 : mn, 512 avg, 513 max: mx, 514 uniformity: unif, 515 targetPPFD: c.targetPPFD, 516 }); 517 transfer.push(g.buffer); 518 } 519 520 self.postMessage( 521 { 522 type: 'result',
523 runId: req.runId, 524 tiers: tierResults, 525 calcSurfaces: calcResults, 526 elapsedMs, 527 samplesTotal: totalSamples, 528 }, 529 transfer 530 ); 531} 532 533/* ========================================================================= 534 * Message dispatch. 535 * ========================================================================= */ 536self.addEventListener('message', function (e) { 537 const msg = e.data; 538 if (!msg || msg.type !== 'trace') return; 539 try { 540 runTrace(msg); 541 } catch (err) { 542 self.postMessage({ 543 type: 'error', 544 runId: msg.runId || '', 545 message: (err && err.message) || String(err), 546 }); 547 } 548});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.