@photivra/engine 2.8.1 → 2.10.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 (91) hide show
  1. package/CHANGELOG.md +144 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +8 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +2 -0
  6. package/dist/capture/export-jpeg.d.ts +19 -0
  7. package/dist/capture/export-jpeg.js +190 -108
  8. package/dist/capture/export-jpeg.js.map +1 -1
  9. package/dist/capture/native-environment-lean.js +106 -12
  10. package/dist/capture/native-environment-lean.js.map +1 -1
  11. package/dist/capture/native-environment-tabulated.d.ts +37 -0
  12. package/dist/capture/native-environment-tabulated.js +42 -6
  13. package/dist/capture/native-environment-tabulated.js.map +1 -1
  14. package/dist/core/portable-math.d.ts +13 -0
  15. package/dist/core/portable-math.js +43 -0
  16. package/dist/core/portable-math.js.map +1 -1
  17. package/dist/core/version.d.ts +1 -1
  18. package/dist/core/version.js +1 -1
  19. package/dist/core/version.js.map +1 -1
  20. package/dist/facade/channel-response.d.ts +15 -0
  21. package/dist/facade/channel-response.js +33 -0
  22. package/dist/facade/channel-response.js.map +1 -0
  23. package/dist/facade/diffraction-provider.js +50 -15
  24. package/dist/facade/diffraction-provider.js.map +1 -1
  25. package/dist/facade/display-view.d.ts +75 -0
  26. package/dist/facade/display-view.js +471 -109
  27. package/dist/facade/display-view.js.map +1 -1
  28. package/dist/facade/expected-signal-setup.js +2 -1
  29. package/dist/facade/expected-signal-setup.js.map +1 -1
  30. package/dist/facade/expected-signal.d.ts +36 -4
  31. package/dist/facade/expected-signal.js +331 -43
  32. package/dist/facade/expected-signal.js.map +1 -1
  33. package/dist/facade/scene-radiance.d.ts +4 -1
  34. package/dist/facade/scene-radiance.js +4 -4
  35. package/dist/facade/scene-radiance.js.map +1 -1
  36. package/dist/facade/simulate-capture.d.ts +77 -5
  37. package/dist/facade/simulate-capture.js +232 -40
  38. package/dist/facade/simulate-capture.js.map +1 -1
  39. package/dist/facade/simulate-display-view.d.ts +25 -2
  40. package/dist/facade/simulate-display-view.js +55 -21
  41. package/dist/facade/simulate-display-view.js.map +1 -1
  42. package/dist/gpu/image-kernel-execution.d.ts +5 -0
  43. package/dist/gpu/image-kernel-execution.js +2 -2
  44. package/dist/gpu/image-kernel-execution.js.map +1 -1
  45. package/dist/index.d.ts +5 -4
  46. package/dist/index.js +3 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/optics/diffraction-psf-profile.d.ts +19 -17
  49. package/dist/optics/diffraction-psf-profile.js +49 -18
  50. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  51. package/dist/optics/irradiance-convolution.d.ts +11 -0
  52. package/dist/optics/irradiance-convolution.js +64 -24
  53. package/dist/optics/irradiance-convolution.js.map +1 -1
  54. package/dist/optics/scene-to-sensor-irradiance.d.ts +13 -0
  55. package/dist/optics/scene-to-sensor-irradiance.js +58 -24
  56. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  57. package/dist/optics/sensor-aperture-rays.js +2 -1
  58. package/dist/optics/sensor-aperture-rays.js.map +1 -1
  59. package/dist/optics/sensor-environment-query.js +4 -3
  60. package/dist/optics/sensor-environment-query.js.map +1 -1
  61. package/dist/optics/site-diffraction-kernel.d.ts +16 -0
  62. package/dist/optics/site-diffraction-kernel.js +60 -8
  63. package/dist/optics/site-diffraction-kernel.js.map +1 -1
  64. package/dist/output/print-jpeg.d.ts +28 -1
  65. package/dist/output/print-jpeg.js +79 -7
  66. package/dist/output/print-jpeg.js.map +1 -1
  67. package/dist/processing/demosaic.js +114 -37
  68. package/dist/processing/demosaic.js.map +1 -1
  69. package/dist/schema/scene-radiance.d.ts +8 -0
  70. package/dist/schema/scene-radiance.js +28 -17
  71. package/dist/schema/scene-radiance.js.map +1 -1
  72. package/dist/sensor/environment-photo-signal.d.ts +21 -0
  73. package/dist/sensor/environment-photo-signal.js +68 -2
  74. package/dist/sensor/environment-photo-signal.js.map +1 -1
  75. package/dist/sensor/stochastic-seed.d.ts +12 -0
  76. package/dist/sensor/stochastic-seed.js +25 -0
  77. package/dist/sensor/stochastic-seed.js.map +1 -1
  78. package/docs/API_REFERENCE.md +23 -1
  79. package/docs/QUICK_START.md +77 -8
  80. package/docs/USAGE.md +30 -0
  81. package/docs/api/capture-export-jpeg.md +10 -0
  82. package/docs/api/core-version.md +1 -1
  83. package/docs/api/exports.json +77 -2
  84. package/docs/api/facade-display-view.md +50 -0
  85. package/docs/api/facade-scene-radiance.md +2 -1
  86. package/docs/api/facade-simulate-capture.md +88 -8
  87. package/docs/api/facade-simulate-display-view.md +23 -1
  88. package/docs/api/gpu-image-kernel-execution.md +5 -0
  89. package/docs/api/optics-diffraction-psf-profile.md +1 -1
  90. package/docs/api/output-print-jpeg.md +41 -0
  91. package/package.json +1 -1
@@ -11,23 +11,92 @@ 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
+ /** Chart-material tables kept by content across input objects (#449), least recently used first. */
20
+ export const EXPECTED_SIGNAL_TABLE_CACHE_SIZE = 4;
21
+ const tableCache = [];
22
+ let tableBuilds = 0, tableHits = 0;
23
+ /** Internal test hook: the kept table entries, and the tables built and found by content so far. */
24
+ export function expectedSignalTableCacheState() { return { size: tableCache.length, builds: tableBuilds, hits: tableHits }; }
25
+ /** Internal test hook: forgets the tables kept by content (inputs already prepared keep theirs). */
26
+ export function clearExpectedSignalTableCache() { tableCache.length = 0; }
27
+ /**
28
+ * Every number the tables read, with the lengths that delimit them, as the bits of doubles (so -0, NaN and every digit are kept
29
+ * apart): the spectral nodes, the three channel responses, the offsets, the kernel radius and taps, each material's reflectance or
30
+ * radiance, and the white radiance. Undefined for radiance scenes, whose tables are not kept by content.
31
+ */
32
+ function tableSignature(input) {
33
+ const scene = input.scene;
34
+ if (scene.kind !== "materials")
35
+ return undefined;
36
+ const values = [];
37
+ const push = (a) => { values.push(a.length); for (let i = 0; i < a.length; i++)
38
+ values.push(a[i]); };
39
+ push(input.wavelengthsNm);
40
+ values.push(input.electronsPerRadiance.length);
41
+ for (const a of input.electronsPerRadiance)
42
+ push(a);
43
+ values.push(input.offsets.length);
44
+ for (const o of input.offsets)
45
+ values.push(o.x, o.y, o.weight);
46
+ if (input.kernels) {
47
+ values.push(1, input.kernels.radius, input.kernels.perNode.length);
48
+ for (const k of input.kernels.perNode)
49
+ push(k);
50
+ }
51
+ else
52
+ values.push(0);
53
+ values.push(scene.materials.length);
54
+ for (const m of scene.materials)
55
+ if ("neutral" in m)
56
+ values.push(0, m.neutral);
57
+ else {
58
+ values.push(1);
59
+ push(m.radiance);
60
+ }
61
+ push(scene.whiteRadiance);
62
+ return new Uint32Array(Float64Array.from(values).buffer);
63
+ }
64
+ function sameBits(a, b) {
65
+ if (a.length !== b.length)
66
+ return false;
67
+ for (let i = 0; i < a.length; i++)
68
+ if (a[i] !== b[i])
69
+ return false;
70
+ return true;
71
+ }
72
+ function prepareSignal(input) {
73
+ const signature = tableSignature(input);
74
+ let tables;
75
+ if (signature) {
76
+ const at = tableCache.findIndex((entry) => sameBits(entry.signature, signature));
77
+ if (at >= 0) {
78
+ const [entry] = tableCache.splice(at, 1);
79
+ tableCache.push(entry);
80
+ tables = entry.tables;
81
+ tableHits++;
82
+ }
83
+ }
84
+ if (!tables) {
85
+ tables = buildTables(input);
86
+ tableBuilds++;
87
+ if (signature) {
88
+ tableCache.push({ signature, tables });
89
+ while (tableCache.length > EXPECTED_SIGNAL_TABLE_CACHE_SIZE)
90
+ tableCache.shift();
91
+ }
92
+ }
93
+ const prepared = { tables, rings: tables.groups.map(() => undefined) };
94
+ PREPARED.set(input, prepared);
95
+ return prepared;
96
+ }
97
+ function buildTables(input) {
98
+ const nodes = input.wavelengthsNm.length, R = input.kernels ? input.kernels.radius : 0, size = 2 * R + 1;
99
+ const A = input.electronsPerRadiance, scene = input.scene, offsets = input.offsets;
31
100
  const kernelAt = (node, t) => (input.kernels ? input.kernels.perNode[node][t] : 1);
32
101
  // Materials: neutral ones by reflectance, spectral ones by index.
33
102
  let reflectance = new Float64Array(0), spectralIndex = new Int32Array(0);
@@ -35,11 +104,12 @@ export function createDisplayViewExpectedSignal(input) {
35
104
  if (scene.kind === "materials") {
36
105
  reflectance = new Float64Array(scene.materials.length);
37
106
  spectralIndex = new Int32Array(scene.materials.length).fill(-1);
107
+ // A copy of each spectral radiance: the tables may outlive the input and serve other inputs of the same content.
38
108
  scene.materials.forEach((m, index) => { if ("neutral" in m)
39
109
  reflectance[index] = m.neutral;
40
110
  else {
41
111
  spectralIndex[index] = spectral.length;
42
- spectral.push(m.radiance);
112
+ spectral.push(Float64Array.from(m.radiance));
43
113
  } });
44
114
  }
45
115
  // Offsets grouped by fractional shift: offsets that differ by whole sites share one lattice.
@@ -49,7 +119,7 @@ export function createDisplayViewExpectedSignal(input) {
49
119
  const fx = frac(o.x), fy = frac(o.y), ix = Math.round(o.x - fx), iy = Math.round(o.y - fy);
50
120
  let group = groups.find((q) => Math.abs(q.fx - fx) <= OFFSET_TOLERANCE && Math.abs(q.fy - fy) <= OFFSET_TOLERANCE);
51
121
  if (!group) {
52
- group = { fx, fy, members: [], minIx: ix, maxIx: ix, minIy: iy, maxIy: iy, cw: 0, ch: 0, neutral: [], spectral: [], perNode: [], cache: new Int32Array(0), cx0: 0, cy0: 0, cpw: 0, cph: 0 };
122
+ group = { fx, fy, members: [], minIx: ix, maxIx: ix, minIy: iy, maxIy: iy, cw: 0, ch: 0, neutral: [], spectral: [], perNode: [] };
53
123
  groups.push(group);
54
124
  }
55
125
  group.members.push({ ix, iy, weight: o.weight });
@@ -92,6 +162,48 @@ export function createDisplayViewExpectedSignal(input) {
92
162
  s += A[c][n] * scene.whiteRadiance[n]; return s; }) : [];
93
163
  const spectralDirect = [0, 1, 2].map((c) => spectral.map((radiance) => { let s = 0; for (let n = 0; n < nodes; n++)
94
164
  s += A[c][n] * radiance[n]; return s; }));
165
+ const uniformSite = scene.kind === "materials" && input.kernels ? [0, 1, 2].map((c) => Float64Array.from(scene.materials, (_, m) => {
166
+ const si = spectralIndex[m];
167
+ let sum = 0;
168
+ for (const group of groups) {
169
+ const nk = group.neutral[c], sk = si < 0 ? nk : group.spectral[c][si];
170
+ for (let t = 0; t < group.cw * group.ch; t++)
171
+ sum += si < 0 ? reflectance[m] * nk[t] : sk[t];
172
+ }
173
+ return sum;
174
+ })) : [];
175
+ // Frozen records: a render copies each group before it gives it a window (see createDisplayViewExpectedSignal).
176
+ for (const group of groups)
177
+ Object.freeze(group);
178
+ return Object.freeze({ reflectance, spectralIndex, spectral, groups, neutralDirect, spectralDirect, uniformSite });
179
+ }
180
+ /**
181
+ * 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
182
+ * query point of offset o and K the diffraction kernel (a single unit tap without diffraction). This is the sum the
183
+ * facade's environment chain forms for a still, rectilinear, distortion-free planar scene, where the
184
+ * radiance-to-irradiance factor is the same at every site (docs/DISPLAY_VIEW.md, "Expected signal").
185
+ *
186
+ * With diffraction, offsets that share a fractional shift read one lattice, and their kernels are combined in advance:
187
+ * C_c(d) = Σ_members w·Σ_λ A_c(λ)·K_λ(t)·L(λ) over the taps t that reach lattice offset d. A neutral material's radiance
188
+ * is its reflectance times the white radiance, so all neutral materials share one combined kernel per channel.
189
+ *
190
+ * `spanColumns`: how many native columns the render's windows move across beyond one window's width (a filled band is never
191
+ * built wider). Only a speed hint: a window past it rebuilds its band, with the same values.
192
+ */
193
+ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
194
+ const W = input.nativeWidth, H = input.nativeHeight, g = 2 / input.outputWidth, nodes = input.wavelengthsNm.length;
195
+ const R = input.kernels ? input.kernels.radius : 0;
196
+ if (input.kernels && input.kernels.perNode.length !== nodes)
197
+ throw new InvalidConfigurationError("Display view kernels must match the spectral nodes.");
198
+ if (!input.kernels && !input.queryPoint)
199
+ throw new InvalidConfigurationError("Display view needs the environment query points without diffraction.");
200
+ const A = input.electronsPerRadiance, scene = input.scene, offsets = input.offsets, channelAt = input.channelAt;
201
+ // The request's tables (materials, lattice groups, combined kernels, per-material sums) are the same for every render of a
202
+ // prepared view, which passes the same input object: computed once per input. Each render gets its own groups' window caches.
203
+ // Chart-material tables are also shared by inputs of the same content (#449): each render gets its own groups' window state.
204
+ const prepared = PREPARED.get(input) ?? prepareSignal(input);
205
+ const { reflectance, spectralIndex, spectral, neutralDirect, spectralDirect, uniformSite } = prepared.tables;
206
+ const groups = prepared.tables.groups.map((group) => ({ ...group, cache: new Int32Array(0), cx0: 0, cy0: 0, cpw: 0, cph: 0 }));
95
207
  let lookups = 0;
96
208
  const point = new Float64Array(2);
97
209
  const materialSignalDirect = (m, c) => {
@@ -100,15 +212,21 @@ export function createDisplayViewExpectedSignal(input) {
100
212
  throw new InvalidConfigurationError("Unknown chart material.");
101
213
  return si < 0 ? reflectance[m] * neutralDirect[c] : spectralDirect[c][si];
102
214
  };
103
- /** Without diffraction: one site from the environment's own query points. */
215
+ /**
216
+ * Without diffraction: one site from the environment's own query points. Also sets lastMaterial: the chart material every
217
+ * query point read, or −1 when they read more than one (or the scene is a radiance scene). A site whose points all read
218
+ * material m sums the same operands in the same order as every other such site of its channel, so it holds the same value.
219
+ */
104
220
  function directSite(x, y) {
105
221
  const c = channelAt(x, y);
106
- let sum = 0;
222
+ let sum = 0, uniform = -1;
107
223
  for (let o = 0; o < offsets.length; o++) {
108
224
  input.queryPoint(x, y, o, point);
109
225
  let value;
110
226
  if (scene.kind === "materials") {
111
- value = materialSignalDirect(scene.materialAt(point[0], point[1]), c);
227
+ const m = scene.materialAt(point[0], point[1]);
228
+ uniform = o === 0 || uniform === m ? m : -2;
229
+ value = materialSignalDirect(m, c);
112
230
  lookups++;
113
231
  }
114
232
  else {
@@ -119,6 +237,7 @@ export function createDisplayViewExpectedSignal(input) {
119
237
  }
120
238
  sum += offsets[o].weight * value;
121
239
  }
240
+ lastMaterial = uniform < 0 ? -1 : uniform;
122
241
  return sum;
123
242
  }
124
243
  // Lattice point (p, q) of a group: the facade's lattice formula (createDiffractedPlanarProvider).
@@ -190,24 +309,32 @@ export function createDisplayViewExpectedSignal(input) {
190
309
  // kernelSite, so it is bit-identical. The scene lookup count stays the count kernelSite's lazy window cache makes (each
191
310
  // cache point a computed site reads counts once per window, points outside the cache once per read): the rectangles of
192
311
  // 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
312
  const allNeutral = spectral.length === 0;
204
313
  const bands = groups.map(() => ({ x0: 0, y0: 0, rows: 0, capacity: 0, filled: 0, material: new Int32Array(0), reflectance: new Float64Array(0),
205
314
  changeLeft: new Int32Array(0), changeUp: new Int32Array(0), v: new Float64Array(0) }));
315
+ // Lattice rows of material indices per group, kept across windows (filled windows only): rows LATTICE_RING_ROWS apart share a
316
+ // slot; columns from −LATTICE_RING_MARGIN. Int16 when every index fits (about 3 MB for four groups of a 6000-site row). A
317
+ // lattice point's material depends only on the input, so the rings are kept per input object, across its renders (not with
318
+ // the tables kept by content: they depend on `materialAt` and the raster).
319
+ const ringWide = scene.kind === "materials" && scene.materials.length <= 32767;
320
+ const rings = prepared.rings;
321
+ function latticeRing(g) {
322
+ let ring = rings[g];
323
+ if (!ring) {
324
+ const width = W + 2 * LATTICE_RING_MARGIN, size = LATTICE_RING_ROWS * width;
325
+ ring = rings[g] = { width, material: ringWide ? new Int16Array(size) : new Int32Array(size), rowId: new Int32Array(LATTICE_RING_ROWS).fill(-2147483648) };
326
+ }
327
+ return ring;
328
+ }
206
329
  /** 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
330
  function coverBand(g, x0, x1, y0, rows) {
208
331
  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)));
332
+ if (!(y0 >= band.y0 && y0 + rows <= band.y0 + band.rows && x0 >= band.x0 && x1 - band.x0 <= band.capacity)) {
333
+ // A band with the ring is built twice as tall, so the next display rows' windows (a few lattice rows lower) reuse its
334
+ // filled columns; a lattice point's material and the change counts inside a rectangle do not depend on the band's origin.
335
+ if (rows <= LATTICE_RING_MAXIMUM_BAND_ROWS)
336
+ rows *= 2;
337
+ const capacity = Math.max(x1 - x0, Math.min(Math.floor(BAND_LIMIT / rows), W + 2 * (x1 - x0), spanColumns + x1 - x0));
211
338
  const size = rows * capacity, tables = (rows + 1) * (capacity + 1);
212
339
  band.x0 = x0;
213
340
  band.y0 = y0;
@@ -234,10 +361,29 @@ export function createDisplayViewExpectedSignal(input) {
234
361
  band.v[j] = latticeV(group, y0 + j);
235
362
  }
236
363
  const stride = band.capacity, S1 = stride + 1, material = band.material, left = band.changeLeft, up = band.changeUp;
364
+ rows = band.rows;
365
+ y0 = band.y0;
366
+ const ring = rows <= 2 * LATTICE_RING_MAXIMUM_BAND_ROWS ? latticeRing(g) : undefined;
237
367
  for (let i = band.filled; i < x1 - band.x0; i++) {
238
- const u = latticeU(group, band.x0 + i);
368
+ const p = band.x0 + i, u = latticeU(group, p), column = p + LATTICE_RING_MARGIN;
239
369
  for (let j = 0; j < rows; j++) {
240
- const m = materials.materialAt(u, band.v[j]);
370
+ let m;
371
+ if (ring && column >= 0 && column < ring.width) {
372
+ // A lattice point's material is a function of the point alone, so one looked up for an earlier window (the rows a
373
+ // display row shares with the next) is the same material; the ring only spares the repeated lookup.
374
+ const q = y0 + j, slot = q & (LATTICE_RING_ROWS - 1), index = slot * ring.width + column;
375
+ if (ring.rowId[slot] !== q) {
376
+ ring.rowId[slot] = q;
377
+ ring.material.fill(-1, slot * ring.width, (slot + 1) * ring.width);
378
+ }
379
+ m = ring.material[index];
380
+ if (m < 0) {
381
+ m = materials.materialAt(u, band.v[j]);
382
+ ring.material[index] = m;
383
+ }
384
+ }
385
+ else
386
+ m = materials.materialAt(u, band.v[j]);
241
387
  if (spectralIndex[m] === undefined)
242
388
  throw new InvalidConfigurationError("Unknown chart material.");
243
389
  material[j * stride + i] = m;
@@ -355,8 +501,9 @@ export function createDisplayViewExpectedSignal(input) {
355
501
  return v;
356
502
  }
357
503
  // Without diffraction (#392): site values persist across windows the same way, in their own ring (a site's value does not
358
- // depend on the window). A ring hit counts the lookups directSite would have made, so `lookups` is unchanged.
359
- let directRows = 0, directOn = false, directRing = new Float64Array(0), directRowId = new Int32Array(0);
504
+ // depend on the window). A ring hit counts the lookups directSite would have made, so `lookups` is unchanged. The site's
505
+ // material (directSite's lastMaterial) is kept beside its value, so the display view's one-material memo applies (#444).
506
+ let directRows = 0, directOn = false, directRing = new Float64Array(0), directRowId = new Int32Array(0), directRingMaterial = new Int32Array(0);
360
507
  const directLookups = offsets.length * (scene.kind === "materials" ? 1 : nodes);
361
508
  function directRead(x, y) {
362
509
  const slot = y & (directRows - 1), q = slot * W + x;
@@ -364,6 +511,7 @@ export function createDisplayViewExpectedSignal(input) {
364
511
  const v = directRing[q];
365
512
  if (v === v) {
366
513
  lookups += directLookups;
514
+ lastMaterial = directRingMaterial[q];
367
515
  return v;
368
516
  }
369
517
  }
@@ -373,14 +521,17 @@ export function createDisplayViewExpectedSignal(input) {
373
521
  }
374
522
  const v = directSite(x, y);
375
523
  directRing[q] = v;
524
+ directRingMaterial[q] = lastMaterial;
376
525
  return v;
377
526
  }
378
527
  /** A site not in the window's site cache: from a ring, a filled window without the ring, the lazy window caches, or uncached. */
379
528
  function siteMiss(x, y, i, j, cached) {
380
529
  if (directOn) {
381
530
  const v = directRead(x, y);
382
- if (cached)
531
+ if (cached) {
383
532
  siteCache[j * sw + i] = v;
533
+ windowMaterial[j * sw + i] = lastMaterial;
534
+ }
384
535
  return v;
385
536
  }
386
537
  if (ringOn) {
@@ -391,23 +542,114 @@ export function createDisplayViewExpectedSignal(input) {
391
542
  const v = ringRead(x, y, i, j);
392
543
  siteCache[j * sw + i] = v;
393
544
  windowMaterial[j * sw + i] = lastMaterial;
545
+ markFilled(i, j);
394
546
  return v;
395
547
  }
396
548
  if (filled && !cached)
397
549
  outsideRead(i, j);
398
550
  const v = filled ? filledSite(x, y, i, j) : input.kernels ? kernelSite(x, y) : directSite(x, y);
399
- if (cached)
551
+ if (cached) {
400
552
  siteCache[j * sw + i] = v;
553
+ if (filled)
554
+ markFilled(i, j);
555
+ }
401
556
  return v;
402
557
  }
403
558
  /**
404
559
  * Lattice points of the closing window that kernelSite's lazy cache would have looked up: per group, the window cache points
405
560
  * covered by the rectangles of the computed sites (the site cache's filled entries and the outside sites), a box dilation.
561
+ * A group's count depends only on the computed sites and its geometry (cw, ch, cpw, cph), so a group shaped like an
562
+ * earlier one takes that group's count (the app's Quality groups all have one shape).
406
563
  */
564
+ const groupTotals = new Float64Array(groups.length);
565
+ function sameGeometry(g) {
566
+ const group = groups[g];
567
+ for (let h = 0; h < g; h++) {
568
+ const o = groups[h];
569
+ if (o.cw === group.cw && o.ch === group.ch && o.cpw === group.cpw && o.cph === group.cph)
570
+ return h;
571
+ }
572
+ return -1;
573
+ }
407
574
  function windowLookups() {
408
575
  let total = 0;
409
- for (const group of groups) {
410
- const cw = group.cw, ch = group.ch, cpw = group.cpw, cph = group.cph, ew = cpw + cw - 1, eh = cph + ch - 1;
576
+ if (outsideSites.length === 0 && filledCount === 0)
577
+ return 0;
578
+ if (outsideSites.length === 0 && filledCount === sw * sh) {
579
+ // Every site computed (each once) and none outside: the marks at (i + cw − 1, j + ch − 1) cover cache columns
580
+ // 0 … sw + cw − 2 and rows 0 … sh + ch − 2, the whole cpw × cph cache of every group.
581
+ for (const group of groups)
582
+ total += group.cpw * group.cph;
583
+ return total;
584
+ }
585
+ if (outsideSites.length === 0 && sw <= 31) {
586
+ // Computed sites as a bit mask per window row. Cache point (ci, cj) is covered when a computed site (i, j) has
587
+ // i ≤ ci < i + cw and j ≤ cj < j + ch; a union of dilations is the dilation of the union, so per cache row the
588
+ // covered columns are the dilation of the OR of the masks of window rows cj − ch + 1 … cj. That OR is a prefix OR
589
+ // when the rows start at fy0 and a suffix OR when they end at fy1 (OR is associative and commutative).
590
+ if (prefixOr.length < sh) {
591
+ prefixOr = new Int32Array(sh);
592
+ suffixOr = new Int32Array(sh);
593
+ }
594
+ for (let j = fy0, m = 0; j <= fy1; j++) {
595
+ m |= rowMask[j];
596
+ prefixOr[j] = m;
597
+ }
598
+ for (let j = fy1, m = 0; j >= fy0; j--) {
599
+ m |= rowMask[j];
600
+ suffixOr[j] = m;
601
+ }
602
+ for (let g = 0; g < groups.length; g++) {
603
+ const same = sameGeometry(g);
604
+ if (same >= 0) {
605
+ total += groupTotals[same];
606
+ groupTotals[g] = groupTotals[same];
607
+ continue;
608
+ }
609
+ const group = groups[g], cw = group.cw, ch = group.ch, cpw = group.cpw, cph = group.cph, last = Math.min(cph - 1, fy1 + ch - 1), before = total;
610
+ let previous = -1, width = 0;
611
+ for (let cj = fy0; cj <= last; cj++) {
612
+ let m = 0;
613
+ const a = Math.max(fy0, cj - ch + 1), b = Math.min(fy1, cj);
614
+ if (a === fy0)
615
+ m = prefixOr[b];
616
+ else if (b === fy1)
617
+ m = suffixOr[a];
618
+ else
619
+ for (let j = a; j <= b; j++)
620
+ m |= rowMask[j];
621
+ if (m !== previous) {
622
+ previous = m;
623
+ width = 0;
624
+ let end = -1;
625
+ for (let i = 0; m >>> i !== 0; i++) {
626
+ if (((m >>> i) & 1) === 0)
627
+ continue;
628
+ const to = Math.min(i + cw - 1, cpw - 1);
629
+ if (i > end)
630
+ width += to - i + 1;
631
+ else if (to > end)
632
+ width += to - end;
633
+ if (to > end)
634
+ end = to;
635
+ }
636
+ }
637
+ total += width;
638
+ }
639
+ groupTotals[g] = total - before;
640
+ }
641
+ return total;
642
+ }
643
+ for (let g = 0; g < groups.length; g++) {
644
+ // The marks are the computed sites shifted by (cw − 1, ch − 1), so the count depends on the group's geometry only.
645
+ const same = sameGeometry(g);
646
+ if (same >= 0) {
647
+ total += groupTotals[same];
648
+ groupTotals[g] = groupTotals[same];
649
+ continue;
650
+ }
651
+ const group = groups[g], cw = group.cw, ch = group.ch, cpw = group.cpw, cph = group.cph, ew = cpw + cw - 1, eh = cph + ch - 1, before = total;
652
+ groupTotals[g] = 0;
411
653
  // Top-left marks in extended coordinates (window coordinates + (cw − 1, ch − 1)), and their bounding box.
412
654
  if (marks.length < ew * eh) {
413
655
  marks = new Uint8Array(ew * eh);
@@ -462,22 +704,63 @@ export function createDisplayViewExpectedSignal(input) {
462
704
  total++;
463
705
  }
464
706
  }
707
+ groupTotals[g] = total - before;
465
708
  }
466
709
  return total;
467
710
  }
468
711
  let sx0 = 0, sy0 = 0, sw = 0, sh = 0, siteCache = new Float64Array(0);
712
+ // The computed entries of a filled window's site cache (windowLookups): their count, bounding box and a bit mask per row
713
+ // (columns below 31).
714
+ let filledCount = 0, fx0 = 0, fx1 = -1, fy0 = 0, fy1 = -1;
715
+ let rowMask = new Int32Array(0), prefixOr = new Int32Array(0), suffixOr = new Int32Array(0);
716
+ function markFilled(i, j) {
717
+ if (i < 31)
718
+ rowMask[j] = rowMask[j] | (1 << i);
719
+ if (filledCount++ === 0) {
720
+ fx0 = fx1 = i;
721
+ fy0 = fy1 = j;
722
+ return;
723
+ }
724
+ if (i < fx0)
725
+ fx0 = i;
726
+ else if (i > fx1)
727
+ fx1 = i;
728
+ if (j < fy0)
729
+ fy0 = j;
730
+ else if (j > fy1)
731
+ fy1 = j;
732
+ }
469
733
  const pilotNode = input.wavelengthsNm[Math.floor(nodes / 2)];
470
734
  return {
471
735
  get lookups() { return lookups + (filled ? windowLookups() : 0); },
472
736
  get uniformMaterial() { return lastMaterial; },
473
- get tracksMaterial() { return ringOn; },
737
+ // Chart materials are tracked on the filled path and, without diffraction, on the direct ring (radiance scenes never).
738
+ get tracksMaterial() { return ringOn || (directOn && scene.kind === "materials"); },
474
739
  get cachesSites() { return sw > 0; },
740
+ get marksReads() { return filled; },
741
+ markReads(xs, ys, values, materials, count) {
742
+ for (let n = 0; n < count; n++) {
743
+ const i = xs[n] - sx0, j = ys[n] - sy0;
744
+ if (!(i >= 0 && j >= 0 && i < sw && j < sh))
745
+ continue;
746
+ const w = j * sw + i;
747
+ if (siteCache[w] === siteCache[w])
748
+ continue;
749
+ // As siteMiss for a site inside a filled window: the computed site is marked (windowLookups counts from the marks)
750
+ // and its material kept.
751
+ siteCache[w] = values[n];
752
+ windowMaterial[w] = materials[n];
753
+ markFilled(i, j);
754
+ }
755
+ },
475
756
  window(x0, y0, x1, y1) {
476
757
  if (filled)
477
758
  lookups += windowLookups();
478
759
  filled = false;
479
760
  ringOn = false;
480
761
  outsideSites = [];
762
+ filledCount = 0;
763
+ rowMask.fill(0, 0, sh);
481
764
  sx0 = x0;
482
765
  sy0 = y0;
483
766
  sw = Math.max(0, x1 - x0);
@@ -488,6 +771,8 @@ export function createDisplayViewExpectedSignal(input) {
488
771
  }
489
772
  if (siteCache.length < sw * sh)
490
773
  siteCache = new Float64Array(sw * sh);
774
+ if (rowMask.length < sh)
775
+ rowMask = new Int32Array(sh);
491
776
  siteCache.fill(NaN, 0, sw * sh);
492
777
  if (!input.kernels) {
493
778
  // A ring of at least twice the window's rows keeps the rows of the previous display row's windows.
@@ -499,9 +784,12 @@ export function createDisplayViewExpectedSignal(input) {
499
784
  directRows = rows;
500
785
  directRing = new Float64Array(rows * W).fill(NaN);
501
786
  directRowId = new Int32Array(rows).fill(-1);
787
+ directRingMaterial = new Int32Array(rows * W);
502
788
  }
503
789
  }
504
790
  directOn = sh > 0 && directRows >= 2 * sh;
791
+ if (directOn && windowMaterial.length < sw * sh)
792
+ windowMaterial = new Int32Array(sw * sh);
505
793
  return;
506
794
  }
507
795
  for (const group of groups) {
@@ -555,7 +843,7 @@ export function createDisplayViewExpectedSignal(input) {
555
843
  if (cached) {
556
844
  const w = j * sw + i, v = siteCache[w];
557
845
  if (v === v) {
558
- if (ringOn)
846
+ if (ringOn || directOn)
559
847
  lastMaterial = windowMaterial[w];
560
848
  return v;
561
849
  }