@photivra/engine 2.8.1 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +60 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +8 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/export-jpeg.d.ts +19 -0
  6. package/dist/capture/export-jpeg.js +190 -108
  7. package/dist/capture/export-jpeg.js.map +1 -1
  8. package/dist/capture/native-environment-lean.js +19 -9
  9. package/dist/capture/native-environment-lean.js.map +1 -1
  10. package/dist/core/version.d.ts +1 -1
  11. package/dist/core/version.js +1 -1
  12. package/dist/core/version.js.map +1 -1
  13. package/dist/facade/expected-signal.d.ts +4 -1
  14. package/dist/facade/expected-signal.js +151 -31
  15. package/dist/facade/expected-signal.js.map +1 -1
  16. package/dist/facade/scene-radiance.d.ts +4 -1
  17. package/dist/facade/scene-radiance.js +4 -4
  18. package/dist/facade/scene-radiance.js.map +1 -1
  19. package/dist/facade/simulate-capture.d.ts +26 -1
  20. package/dist/facade/simulate-capture.js +102 -9
  21. package/dist/facade/simulate-capture.js.map +1 -1
  22. package/dist/facade/simulate-display-view.js +2 -1
  23. package/dist/facade/simulate-display-view.js.map +1 -1
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.js +2 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/optics/diffraction-psf-profile.d.ts +19 -17
  28. package/dist/optics/diffraction-psf-profile.js +49 -18
  29. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  30. package/dist/output/print-jpeg.d.ts +28 -1
  31. package/dist/output/print-jpeg.js +79 -7
  32. package/dist/output/print-jpeg.js.map +1 -1
  33. package/dist/sensor/stochastic-seed.d.ts +12 -0
  34. package/dist/sensor/stochastic-seed.js +25 -0
  35. package/dist/sensor/stochastic-seed.js.map +1 -1
  36. package/docs/API_REFERENCE.md +8 -1
  37. package/docs/QUICK_START.md +59 -0
  38. package/docs/api/capture-export-jpeg.md +10 -0
  39. package/docs/api/core-version.md +1 -1
  40. package/docs/api/exports.json +37 -2
  41. package/docs/api/facade-scene-radiance.md +2 -1
  42. package/docs/api/facade-simulate-capture.md +28 -0
  43. package/docs/api/optics-diffraction-psf-profile.md +1 -1
  44. package/docs/api/output-print-jpeg.md +41 -0
  45. package/package.json +1 -1
@@ -6,4 +6,4 @@
6
6
  * own versions. Release checks confirm it matches package.json and the packed package.
7
7
  * @see docs/RELEASE_1_0_1.md
8
8
  */
9
- export declare const ENGINE_API_VERSION: "2.8.1";
9
+ export declare const ENGINE_API_VERSION: "2.9.0";
@@ -7,5 +7,5 @@
7
7
  * own versions. Release checks confirm it matches package.json and the packed package.
8
8
  * @see docs/RELEASE_1_0_1.md
9
9
  */
10
- export const ENGINE_API_VERSION = "2.8.1";
10
+ export const ENGINE_API_VERSION = "2.9.0";
11
11
  //# sourceMappingURL=version.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","sourceRoot":"","sources":["../../src/core/version.ts"],"names":[],"mappings":"AAAA,sCAAsC;AAEtC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,OAAgB,CAAC","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Version of the engine release. Since 1.0.1 it equals the npm package version.\n *\n * New captures and plans record this version as their creator. Archived records keep the\n * version they were made with. Proof-of-concept (POC), schema and model contracts have their\n * own versions. Release checks confirm it matches package.json and the packed package.\n * @see docs/RELEASE_1_0_1.md\n */\nexport const ENGINE_API_VERSION = \"2.8.1\" as const;\n"]}
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../../src/core/version.ts"],"names":[],"mappings":"AAAA,sCAAsC;AAEtC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,OAAgB,CAAC","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Version of the engine release. Since 1.0.1 it equals the npm package version.\n *\n * New captures and plans record this version as their creator. Archived records keep the\n * version they were made with. Proof-of-concept (POC), schema and model contracts have their\n * own versions. Release checks confirm it matches package.json and the packed package.\n * @see docs/RELEASE_1_0_1.md\n */\nexport const ENGINE_API_VERSION = \"2.9.0\" as const;\n"]}
@@ -79,5 +79,8 @@ export interface DisplayViewExpectedSignal {
79
79
  * With diffraction, offsets that share a fractional shift read one lattice, and their kernels are combined in advance:
80
80
  * C_c(d) = Σ_members w·Σ_λ A_c(λ)·K_λ(t)·L(λ) over the taps t that reach lattice offset d. A neutral material's radiance
81
81
  * is its reflectance times the white radiance, so all neutral materials share one combined kernel per channel.
82
+ *
83
+ * `spanColumns`: how many native columns the render's windows move across beyond one window's width (a filled band is never
84
+ * built wider). Only a speed hint: a window past it rebuilds its band, with the same values.
82
85
  */
83
- export declare function createDisplayViewExpectedSignal(input: DisplayViewExpectedSignalInput): DisplayViewExpectedSignal;
86
+ export declare function createDisplayViewExpectedSignal(input: DisplayViewExpectedSignalInput, spanColumns?: number): DisplayViewExpectedSignal;
@@ -11,23 +11,14 @@ const CACHE_LIMIT = 1 << 22;
11
11
  /** Largest band of looked-up lattice materials per group, in lattice points (a band that would be larger restarts at the window). */
12
12
  const BAND_LIMIT = 1 << 22;
13
13
  /**
14
- * Expected photo electrons per site: μ_c(i, j) = Σ_o w_o Σ_λ A_c(λ) Σ_t K_λ(t)·L(p_o(i, j) − t, λ), where p_o is the
15
- * query point of offset o and K the diffraction kernel (a single unit tap without diffraction). This is the sum the
16
- * facade's environment chain forms for a still, rectilinear, distortion-free planar scene, where the
17
- * radiance-to-irradiance factor is the same at every site (docs/DISPLAY_VIEW.md, "Expected signal").
18
- *
19
- * With diffraction, offsets that share a fractional shift read one lattice, and their kernels are combined in advance:
20
- * C_c(d) = Σ_members w·Σ_λ A_c(λ)·K_λ(t)·L(λ) over the taps t that reach lattice offset d. A neutral material's radiance
21
- * is its reflectance times the white radiance, so all neutral materials share one combined kernel per channel.
14
+ * Lattice-row ring (refinement windows): rows kept per group, the tallest band that uses it, and the columns kept beyond each
15
+ * raster edge (kernel radius, demosaic reach and query offsets; columns beyond it are looked up directly).
22
16
  */
23
- export function createDisplayViewExpectedSignal(input) {
24
- const W = input.nativeWidth, H = input.nativeHeight, g = 2 / input.outputWidth, nodes = input.wavelengthsNm.length;
25
- const R = input.kernels ? input.kernels.radius : 0, size = 2 * R + 1;
26
- if (input.kernels && input.kernels.perNode.length !== nodes)
27
- throw new InvalidConfigurationError("Display view kernels must match the spectral nodes.");
28
- if (!input.kernels && !input.queryPoint)
29
- throw new InvalidConfigurationError("Display view needs the environment query points without diffraction.");
30
- const A = input.electronsPerRadiance, scene = input.scene, offsets = input.offsets, channelAt = input.channelAt;
17
+ const LATTICE_RING_ROWS = 128, LATTICE_RING_MAXIMUM_BAND_ROWS = 64, LATTICE_RING_MARGIN = 64;
18
+ const PREPARED = new WeakMap();
19
+ function prepareSignal(input) {
20
+ const nodes = input.wavelengthsNm.length, R = input.kernels ? input.kernels.radius : 0, size = 2 * R + 1;
21
+ const A = input.electronsPerRadiance, scene = input.scene, offsets = input.offsets;
31
22
  const kernelAt = (node, t) => (input.kernels ? input.kernels.perNode[node][t] : 1);
32
23
  // Materials: neutral ones by reflectance, spectral ones by index.
33
24
  let reflectance = new Float64Array(0), spectralIndex = new Int32Array(0);
@@ -92,6 +83,46 @@ export function createDisplayViewExpectedSignal(input) {
92
83
  s += A[c][n] * scene.whiteRadiance[n]; return s; }) : [];
93
84
  const spectralDirect = [0, 1, 2].map((c) => spectral.map((radiance) => { let s = 0; for (let n = 0; n < nodes; n++)
94
85
  s += A[c][n] * radiance[n]; return s; }));
86
+ const uniformSite = scene.kind === "materials" && input.kernels ? [0, 1, 2].map((c) => Float64Array.from(scene.materials, (_, m) => {
87
+ const si = spectralIndex[m];
88
+ let sum = 0;
89
+ for (const group of groups) {
90
+ const nk = group.neutral[c], sk = si < 0 ? nk : group.spectral[c][si];
91
+ for (let t = 0; t < group.cw * group.ch; t++)
92
+ sum += si < 0 ? reflectance[m] * nk[t] : sk[t];
93
+ }
94
+ return sum;
95
+ })) : [];
96
+ const prepared = { reflectance, spectralIndex, spectral, groups, neutralDirect, spectralDirect, uniformSite, rings: groups.map(() => undefined) };
97
+ PREPARED.set(input, prepared);
98
+ return prepared;
99
+ }
100
+ /**
101
+ * Expected photo electrons per site: μ_c(i, j) = Σ_o w_o Σ_λ A_c(λ) Σ_t K_λ(t)·L(p_o(i, j) − t, λ), where p_o is the
102
+ * query point of offset o and K the diffraction kernel (a single unit tap without diffraction). This is the sum the
103
+ * facade's environment chain forms for a still, rectilinear, distortion-free planar scene, where the
104
+ * radiance-to-irradiance factor is the same at every site (docs/DISPLAY_VIEW.md, "Expected signal").
105
+ *
106
+ * With diffraction, offsets that share a fractional shift read one lattice, and their kernels are combined in advance:
107
+ * C_c(d) = Σ_members w·Σ_λ A_c(λ)·K_λ(t)·L(λ) over the taps t that reach lattice offset d. A neutral material's radiance
108
+ * is its reflectance times the white radiance, so all neutral materials share one combined kernel per channel.
109
+ *
110
+ * `spanColumns`: how many native columns the render's windows move across beyond one window's width (a filled band is never
111
+ * built wider). Only a speed hint: a window past it rebuilds its band, with the same values.
112
+ */
113
+ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
114
+ const W = input.nativeWidth, H = input.nativeHeight, g = 2 / input.outputWidth, nodes = input.wavelengthsNm.length;
115
+ const R = input.kernels ? input.kernels.radius : 0;
116
+ if (input.kernels && input.kernels.perNode.length !== nodes)
117
+ throw new InvalidConfigurationError("Display view kernels must match the spectral nodes.");
118
+ if (!input.kernels && !input.queryPoint)
119
+ throw new InvalidConfigurationError("Display view needs the environment query points without diffraction.");
120
+ const A = input.electronsPerRadiance, scene = input.scene, offsets = input.offsets, channelAt = input.channelAt;
121
+ // The request's tables (materials, lattice groups, combined kernels, per-material sums) are the same for every render of a
122
+ // prepared view, which passes the same input object: computed once per input. Each render gets its own groups' window caches.
123
+ const prepared = PREPARED.get(input) ?? prepareSignal(input);
124
+ const { reflectance, spectralIndex, spectral, neutralDirect, spectralDirect, uniformSite } = prepared;
125
+ const groups = prepared.groups.map((group) => ({ ...group, cache: new Int32Array(0), cx0: 0, cy0: 0, cpw: 0, cph: 0 }));
95
126
  let lookups = 0;
96
127
  const point = new Float64Array(2);
97
128
  const materialSignalDirect = (m, c) => {
@@ -190,24 +221,31 @@ export function createDisplayViewExpectedSignal(input) {
190
221
  // kernelSite, so it is bit-identical. The scene lookup count stays the count kernelSite's lazy window cache makes (each
191
222
  // cache point a computed site reads counts once per window, points outside the cache once per read): the rectangles of
192
223
  // the sites computed in a window are counted when the window closes (windowLookups).
193
- const uniformSite = scene.kind === "materials" && input.kernels ? [0, 1, 2].map((c) => Float64Array.from(scene.materials, (_, m) => {
194
- const si = spectralIndex[m];
195
- let sum = 0;
196
- for (const group of groups) {
197
- const nk = group.neutral[c], sk = si < 0 ? nk : group.spectral[c][si];
198
- for (let t = 0; t < group.cw * group.ch; t++)
199
- sum += si < 0 ? reflectance[m] * nk[t] : sk[t];
200
- }
201
- return sum;
202
- })) : [];
203
224
  const allNeutral = spectral.length === 0;
204
225
  const bands = groups.map(() => ({ x0: 0, y0: 0, rows: 0, capacity: 0, filled: 0, material: new Int32Array(0), reflectance: new Float64Array(0),
205
226
  changeLeft: new Int32Array(0), changeUp: new Int32Array(0), v: new Float64Array(0) }));
227
+ // Lattice rows of material indices per group, kept across windows (filled windows only): rows LATTICE_RING_ROWS apart share a
228
+ // slot; columns from −LATTICE_RING_MARGIN. Int16 when every index fits (about 3 MB for four groups of a 6000-site row). A
229
+ // lattice point's material depends only on the input, so the rings are kept with its prepared tables, across renders.
230
+ const ringWide = scene.kind === "materials" && scene.materials.length <= 32767;
231
+ const rings = prepared.rings;
232
+ function latticeRing(g) {
233
+ let ring = rings[g];
234
+ if (!ring) {
235
+ const width = W + 2 * LATTICE_RING_MARGIN, size = LATTICE_RING_ROWS * width;
236
+ ring = rings[g] = { width, material: ringWide ? new Int16Array(size) : new Int32Array(size), rowId: new Int32Array(LATTICE_RING_ROWS).fill(-2147483648) };
237
+ }
238
+ return ring;
239
+ }
206
240
  /** Makes the band of group g cover lattice columns [x0, x1) of rows [y0, y0 + rows), extending it to the right when it can. */
207
241
  function coverBand(g, x0, x1, y0, rows) {
208
242
  const group = groups[g], band = bands[g], materials = scene;
209
- if (!(band.rows === rows && band.y0 === y0 && x0 >= band.x0 && x1 - band.x0 <= band.capacity)) {
210
- const capacity = Math.max(x1 - x0, Math.min(Math.floor(BAND_LIMIT / rows), W + 2 * (x1 - x0)));
243
+ if (!(y0 >= band.y0 && y0 + rows <= band.y0 + band.rows && x0 >= band.x0 && x1 - band.x0 <= band.capacity)) {
244
+ // A band with the ring is built twice as tall, so the next display rows' windows (a few lattice rows lower) reuse its
245
+ // filled columns; a lattice point's material and the change counts inside a rectangle do not depend on the band's origin.
246
+ if (rows <= LATTICE_RING_MAXIMUM_BAND_ROWS)
247
+ rows *= 2;
248
+ const capacity = Math.max(x1 - x0, Math.min(Math.floor(BAND_LIMIT / rows), W + 2 * (x1 - x0), spanColumns + x1 - x0));
211
249
  const size = rows * capacity, tables = (rows + 1) * (capacity + 1);
212
250
  band.x0 = x0;
213
251
  band.y0 = y0;
@@ -234,10 +272,29 @@ export function createDisplayViewExpectedSignal(input) {
234
272
  band.v[j] = latticeV(group, y0 + j);
235
273
  }
236
274
  const stride = band.capacity, S1 = stride + 1, material = band.material, left = band.changeLeft, up = band.changeUp;
275
+ rows = band.rows;
276
+ y0 = band.y0;
277
+ const ring = rows <= 2 * LATTICE_RING_MAXIMUM_BAND_ROWS ? latticeRing(g) : undefined;
237
278
  for (let i = band.filled; i < x1 - band.x0; i++) {
238
- const u = latticeU(group, band.x0 + i);
279
+ const p = band.x0 + i, u = latticeU(group, p), column = p + LATTICE_RING_MARGIN;
239
280
  for (let j = 0; j < rows; j++) {
240
- const m = materials.materialAt(u, band.v[j]);
281
+ let m;
282
+ if (ring && column >= 0 && column < ring.width) {
283
+ // A lattice point's material is a function of the point alone, so one looked up for an earlier window (the rows a
284
+ // display row shares with the next) is the same material; the ring only spares the repeated lookup.
285
+ const q = y0 + j, slot = q & (LATTICE_RING_ROWS - 1), index = slot * ring.width + column;
286
+ if (ring.rowId[slot] !== q) {
287
+ ring.rowId[slot] = q;
288
+ ring.material.fill(-1, slot * ring.width, (slot + 1) * ring.width);
289
+ }
290
+ m = ring.material[index];
291
+ if (m < 0) {
292
+ m = materials.materialAt(u, band.v[j]);
293
+ ring.material[index] = m;
294
+ }
295
+ }
296
+ else
297
+ m = materials.materialAt(u, band.v[j]);
241
298
  if (spectralIndex[m] === undefined)
242
299
  throw new InvalidConfigurationError("Unknown chart material.");
243
300
  material[j * stride + i] = m;
@@ -391,13 +448,17 @@ export function createDisplayViewExpectedSignal(input) {
391
448
  const v = ringRead(x, y, i, j);
392
449
  siteCache[j * sw + i] = v;
393
450
  windowMaterial[j * sw + i] = lastMaterial;
451
+ markFilled(i, j);
394
452
  return v;
395
453
  }
396
454
  if (filled && !cached)
397
455
  outsideRead(i, j);
398
456
  const v = filled ? filledSite(x, y, i, j) : input.kernels ? kernelSite(x, y) : directSite(x, y);
399
- if (cached)
457
+ if (cached) {
400
458
  siteCache[j * sw + i] = v;
459
+ if (filled)
460
+ markFilled(i, j);
461
+ }
401
462
  return v;
402
463
  }
403
464
  /**
@@ -406,6 +467,40 @@ export function createDisplayViewExpectedSignal(input) {
406
467
  */
407
468
  function windowLookups() {
408
469
  let total = 0;
470
+ if (outsideSites.length === 0 && filledCount === 0)
471
+ return 0;
472
+ if (outsideSites.length === 0 && sw <= 31) {
473
+ // Computed sites as a bit mask per window row. Cache point (ci, cj) is covered when a computed site (i, j) has
474
+ // i ≤ ci < i + cw and j ≤ cj < j + ch; a union of dilations is the dilation of the union, so per cache row the
475
+ // covered columns are the dilation of the OR of the masks of window rows cj − ch + 1 … cj.
476
+ for (const group of groups) {
477
+ const cw = group.cw, ch = group.ch, cpw = group.cpw, cph = group.cph, last = Math.min(cph - 1, fy1 + ch - 1);
478
+ let previous = -1, width = 0;
479
+ for (let cj = fy0; cj <= last; cj++) {
480
+ let m = 0;
481
+ for (let j = Math.max(fy0, cj - ch + 1), top = Math.min(fy1, cj); j <= top; j++)
482
+ m |= rowMask[j];
483
+ if (m !== previous) {
484
+ previous = m;
485
+ width = 0;
486
+ let end = -1;
487
+ for (let i = 0; m >>> i !== 0; i++) {
488
+ if (((m >>> i) & 1) === 0)
489
+ continue;
490
+ const to = Math.min(i + cw - 1, cpw - 1);
491
+ if (i > end)
492
+ width += to - i + 1;
493
+ else if (to > end)
494
+ width += to - end;
495
+ if (to > end)
496
+ end = to;
497
+ }
498
+ }
499
+ total += width;
500
+ }
501
+ }
502
+ return total;
503
+ }
409
504
  for (const group of groups) {
410
505
  const cw = group.cw, ch = group.ch, cpw = group.cpw, cph = group.cph, ew = cpw + cw - 1, eh = cph + ch - 1;
411
506
  // Top-left marks in extended coordinates (window coordinates + (cw − 1, ch − 1)), and their bounding box.
@@ -466,6 +561,27 @@ export function createDisplayViewExpectedSignal(input) {
466
561
  return total;
467
562
  }
468
563
  let sx0 = 0, sy0 = 0, sw = 0, sh = 0, siteCache = new Float64Array(0);
564
+ // The computed entries of a filled window's site cache (windowLookups): their count, bounding box and a bit mask per row
565
+ // (columns below 31).
566
+ let filledCount = 0, fx0 = 0, fx1 = -1, fy0 = 0, fy1 = -1;
567
+ let rowMask = new Int32Array(0);
568
+ function markFilled(i, j) {
569
+ if (i < 31)
570
+ rowMask[j] = rowMask[j] | (1 << i);
571
+ if (filledCount++ === 0) {
572
+ fx0 = fx1 = i;
573
+ fy0 = fy1 = j;
574
+ return;
575
+ }
576
+ if (i < fx0)
577
+ fx0 = i;
578
+ else if (i > fx1)
579
+ fx1 = i;
580
+ if (j < fy0)
581
+ fy0 = j;
582
+ else if (j > fy1)
583
+ fy1 = j;
584
+ }
469
585
  const pilotNode = input.wavelengthsNm[Math.floor(nodes / 2)];
470
586
  return {
471
587
  get lookups() { return lookups + (filled ? windowLookups() : 0); },
@@ -478,6 +594,8 @@ export function createDisplayViewExpectedSignal(input) {
478
594
  filled = false;
479
595
  ringOn = false;
480
596
  outsideSites = [];
597
+ filledCount = 0;
598
+ rowMask.fill(0, 0, sh);
481
599
  sx0 = x0;
482
600
  sy0 = y0;
483
601
  sw = Math.max(0, x1 - x0);
@@ -488,6 +606,8 @@ export function createDisplayViewExpectedSignal(input) {
488
606
  }
489
607
  if (siteCache.length < sw * sh)
490
608
  siteCache = new Float64Array(sw * sh);
609
+ if (rowMask.length < sh)
610
+ rowMask = new Int32Array(sh);
491
611
  siteCache.fill(NaN, 0, sw * sh);
492
612
  if (!input.kernels) {
493
613
  // A ring of at least twice the window's rows keeps the rows of the previous display row's windows.