@photivra/engine 2.9.0 → 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 (72) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +2 -0
  6. package/dist/capture/native-environment-lean.js +89 -5
  7. package/dist/capture/native-environment-lean.js.map +1 -1
  8. package/dist/capture/native-environment-tabulated.d.ts +37 -0
  9. package/dist/capture/native-environment-tabulated.js +42 -6
  10. package/dist/capture/native-environment-tabulated.js.map +1 -1
  11. package/dist/core/portable-math.d.ts +13 -0
  12. package/dist/core/portable-math.js +43 -0
  13. package/dist/core/portable-math.js.map +1 -1
  14. package/dist/core/version.d.ts +1 -1
  15. package/dist/core/version.js +1 -1
  16. package/dist/core/version.js.map +1 -1
  17. package/dist/facade/channel-response.d.ts +15 -0
  18. package/dist/facade/channel-response.js +33 -0
  19. package/dist/facade/channel-response.js.map +1 -0
  20. package/dist/facade/diffraction-provider.js +50 -15
  21. package/dist/facade/diffraction-provider.js.map +1 -1
  22. package/dist/facade/display-view.d.ts +75 -0
  23. package/dist/facade/display-view.js +471 -109
  24. package/dist/facade/display-view.js.map +1 -1
  25. package/dist/facade/expected-signal-setup.js +2 -1
  26. package/dist/facade/expected-signal-setup.js.map +1 -1
  27. package/dist/facade/expected-signal.d.ts +32 -3
  28. package/dist/facade/expected-signal.js +192 -24
  29. package/dist/facade/expected-signal.js.map +1 -1
  30. package/dist/facade/simulate-capture.d.ts +51 -4
  31. package/dist/facade/simulate-capture.js +130 -31
  32. package/dist/facade/simulate-capture.js.map +1 -1
  33. package/dist/facade/simulate-display-view.d.ts +25 -2
  34. package/dist/facade/simulate-display-view.js +55 -22
  35. package/dist/facade/simulate-display-view.js.map +1 -1
  36. package/dist/gpu/image-kernel-execution.d.ts +5 -0
  37. package/dist/gpu/image-kernel-execution.js +2 -2
  38. package/dist/gpu/image-kernel-execution.js.map +1 -1
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.js +2 -2
  41. package/dist/index.js.map +1 -1
  42. package/dist/optics/irradiance-convolution.d.ts +11 -0
  43. package/dist/optics/irradiance-convolution.js +64 -24
  44. package/dist/optics/irradiance-convolution.js.map +1 -1
  45. package/dist/optics/scene-to-sensor-irradiance.d.ts +13 -0
  46. package/dist/optics/scene-to-sensor-irradiance.js +58 -24
  47. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  48. package/dist/optics/sensor-aperture-rays.js +2 -1
  49. package/dist/optics/sensor-aperture-rays.js.map +1 -1
  50. package/dist/optics/sensor-environment-query.js +4 -3
  51. package/dist/optics/sensor-environment-query.js.map +1 -1
  52. package/dist/optics/site-diffraction-kernel.d.ts +16 -0
  53. package/dist/optics/site-diffraction-kernel.js +60 -8
  54. package/dist/optics/site-diffraction-kernel.js.map +1 -1
  55. package/dist/processing/demosaic.js +114 -37
  56. package/dist/processing/demosaic.js.map +1 -1
  57. package/dist/schema/scene-radiance.d.ts +8 -0
  58. package/dist/schema/scene-radiance.js +28 -17
  59. package/dist/schema/scene-radiance.js.map +1 -1
  60. package/dist/sensor/environment-photo-signal.d.ts +21 -0
  61. package/dist/sensor/environment-photo-signal.js +68 -2
  62. package/dist/sensor/environment-photo-signal.js.map +1 -1
  63. package/docs/API_REFERENCE.md +16 -1
  64. package/docs/QUICK_START.md +18 -8
  65. package/docs/USAGE.md +30 -0
  66. package/docs/api/core-version.md +1 -1
  67. package/docs/api/exports.json +42 -2
  68. package/docs/api/facade-display-view.md +50 -0
  69. package/docs/api/facade-simulate-capture.md +60 -8
  70. package/docs/api/facade-simulate-display-view.md +23 -1
  71. package/docs/api/gpu-image-kernel-execution.md +5 -0
  72. package/package.json +1 -1
@@ -7,6 +7,7 @@
7
7
  import { InvalidConfigurationError } from "../core/configuration-error.js";
8
8
  import { siteDiffractionKernel } from "../optics/site-diffraction-kernel.js";
9
9
  import { resolveProjectionDistanceMm } from "../motion/camera-rotation.js";
10
+ import { hypot3 } from "../core/portable-math.js";
10
11
  /** The query offsets of each site and the expected-signal input (see the module comment). */
11
12
  export function buildExpectedSignalInput(input) {
12
13
  const { boxSamples, pitch, wavelengths, imagingArea, subjectDistanceM, metresPerFrameUnit } = input;
@@ -42,7 +43,7 @@ export function buildExpectedSignalInput(input) {
42
43
  const centerX = -imagingArea.widthMm / 2 + (pitch / 2 + x * pitch + 0) / 1000, centerY = -imagingArea.heightMm / 2 + (pitch / 2 + y * pitch + 0) / 1000;
43
44
  const sourceX = (centerX - siteWidthMm / 2 + node.xPhase * siteWidthMm) - node.aaX, sourceY = (centerY - siteWidthMm / 2 + node.yPhase * siteWidthMm) - node.aaY;
44
45
  const rx = projectionDistance * (sourceX / projectionDistance), ry = projectionDistance * (-sourceY / projectionDistance);
45
- const length = Math.hypot(rx, ry, projectionDistance), t = subjectDistanceM / (projectionDistance / length);
46
+ const length = hypot3(rx, ry, projectionDistance), t = subjectDistanceM / (projectionDistance / length);
46
47
  out[0] = rx / length * t / metresPerFrameUnit;
47
48
  out[1] = ry / length * t / metresPerFrameUnit;
48
49
  };
@@ -1 +1 @@
1
- {"version":3,"file":"expected-signal-setup.js","sourceRoot":"","sources":["../../src/facade/expected-signal-setup.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Internal (#394, not exported from the package): the input of the tabulated expected signal (`createDisplayViewExpectedSignal`)\n * for a resolved `simulateCapture` request, shared by the display view and the capture's tabulated RAW path so both build it\n * the same way: query offsets of each site, the environment's query points, the diffraction kernels and the scene tables.\n */\n\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport type { resolveLensPsfConvolutionKernels } from \"../optics/lens-psf-kernels.js\";\nimport { siteDiffractionKernel } from \"../optics/site-diffraction-kernel.js\";\nimport { resolveProjectionDistanceMm } from \"../motion/camera-rotation.js\";\nimport type { DisplayViewExpectedSignalInput, DisplayViewScene } from \"./display-view.js\";\n\ntype Psf = Parameters<typeof resolveLensPsfConvolutionKernels>[0][\"psf\"];\n\nexport interface ExpectedSignalSetupInput {\n nativeWidth: number;\n nativeHeight: number;\n /** Frame output width (plane coordinates are 2 / width frame units per site). */\n width: number;\n wavelengths: readonly number[];\n /** Per CFA channel (red, green, blue): expected electrons per unit spectral radiance at each node. */\n perNode: readonly [Float64Array, Float64Array, Float64Array];\n /** Pixel-aperture nodes in sites from the site centre (`apertureNodeOffsets(boxSamples)`) and the box sample count. */\n apertureNodes: readonly { x: number; y: number }[];\n boxSamples: number;\n antiAliasing?: { components: readonly { offsetMicrometers: { x: number; y: number }; normalizedWeight: number }[] } | undefined;\n pitch: number;\n /** Object-space diffraction: the kernel radius, the PSF and its profile wavelengths. */\n diffraction?: { radius: number; psf: Psf; profileWavelengths: readonly number[] } | undefined;\n /** Material scenes: the materials (neutral reflectances, or radiance per wavelength), the material lookup and the white radiance. */\n materials?: { list: readonly ({ neutral: number } | { radiance: (nm: number) => number })[]; materialAt(u: number, v: number): number; whiteRadiance(nm: number): number } | undefined;\n radianceAt(u: number, v: number, nm: number): number;\n imagingArea: { widthMm: number; heightMm: number };\n focalLengthMm: number;\n focusDistanceM: number;\n subjectDistanceM: number;\n metresPerFrameUnit: number;\n /** Prefix of the error messages (\"simulateDisplayView\", \"simulateCapture\"). */\n caller: string;\n}\n\n/** The query offsets of each site and the expected-signal input (see the module comment). */\nexport function buildExpectedSignalInput(input: ExpectedSignalSetupInput): { offsets: { x: number; y: number; weight: number }[]; signalInput: DisplayViewExpectedSignalInput } {\n const { boxSamples, pitch, wavelengths, imagingArea, subjectDistanceM, metresPerFrameUnit } = input;\n // Query offsets of each site: pixel-aperture nodes minus anti-aliasing split offsets (sites, +x right, +y down), as the environment forms them.\n const spots = input.antiAliasing ? input.antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 }, normalizedWeight: 1 }];\n const offsets = input.apertureNodes.flatMap((node) => spots.map((c) => ({ x: node.x - c.offsetMicrometers.x / pitch, y: node.y - c.offsetMicrometers.y / pitch,\n weight: c.normalizedWeight / (boxSamples * boxSamples) })));\n // Diffraction: the same per-node site kernels as the object-space convolution (createDiffractedPlanarProvider).\n let kernels: { radius: number; perNode: Float64Array[] } | undefined;\n const diffraction = input.diffraction;\n if (diffraction) {\n const radius = diffraction.radius;\n kernels = { radius, perNode: wavelengths.map((wavelengthNm) => {\n if (!diffraction.profileWavelengths.includes(wavelengthNm)) throw new InvalidConfigurationError(input.caller + \": a spectral node has no diffraction kernel.\");\n return siteDiffractionKernel(diffraction.psf, wavelengthNm, pitch, radius, input.caller + \": diffraction kernels must sample the site pitch exactly.\");\n }) };\n }\n // Chart presets and (2.7) caller material scenes take the material path: each material tabulated once per node.\n const materials = input.materials;\n const scene: DisplayViewScene = materials ? { kind: \"materials\", materialAt: materials.materialAt,\n materials: materials.list.map((m) => (\"neutral\" in m ? { neutral: m.neutral } : { radiance: Float64Array.from(wavelengths, (nm) => m.radiance(nm)) })),\n whiteRadiance: Float64Array.from(wavelengths, (nm) => materials.whiteRadiance(nm)) } :\n { kind: \"radiance\", radianceAt: input.radianceAt };\n const channelIndex = (x: number, y: number): number => ((y & 1) === 0 ? ((x & 1) === 0 ? 0 : 1) : ((x & 1) === 0 ? 1 : 2));\n // Without diffraction the environment's own query arithmetic (native-environment-lean.ts `geometry`, then the rectilinear\n // still-camera ray and the planar provider's plane point), so boundary points resolve as in the capture.\n const projectionDistance = resolveProjectionDistanceMm(input.focalLengthMm, input.focusDistanceM).projectionDistanceMm, siteWidthMm = pitch / 1000;\n const nodePhases = input.apertureNodes.flatMap((_, index) => spots.map((c) => ({ xPhase: (index % boxSamples + 0.5) / boxSamples, yPhase: (Math.floor(index / boxSamples) + 0.5) / boxSamples,\n aaX: c.offsetMicrometers.x / 1000, aaY: c.offsetMicrometers.y / 1000 })));\n const queryPoint = (x: number, y: number, o: number, out: Float64Array): void => {\n const node = nodePhases[o]!;\n const centerX = -imagingArea.widthMm / 2 + (pitch / 2 + x * pitch + 0) / 1000, centerY = -imagingArea.heightMm / 2 + (pitch / 2 + y * pitch + 0) / 1000;\n const sourceX = (centerX - siteWidthMm / 2 + node.xPhase * siteWidthMm) - node.aaX, sourceY = (centerY - siteWidthMm / 2 + node.yPhase * siteWidthMm) - node.aaY;\n const rx = projectionDistance * (sourceX / projectionDistance), ry = projectionDistance * (-sourceY / projectionDistance);\n const length = Math.hypot(rx, ry, projectionDistance), t = subjectDistanceM / (projectionDistance / length);\n out[0] = rx / length * t / metresPerFrameUnit; out[1] = ry / length * t / metresPerFrameUnit;\n };\n return { offsets, signalInput: { nativeWidth: input.nativeWidth, nativeHeight: input.nativeHeight, outputWidth: input.width, wavelengthsNm: wavelengths,\n electronsPerRadiance: input.perNode, offsets, ...(kernels ? { kernels } : { queryPoint }), scene, channelAt: channelIndex } };\n}\n"]}
1
+ {"version":3,"file":"expected-signal-setup.js","sourceRoot":"","sources":["../../src/facade/expected-signal-setup.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Internal (#394, not exported from the package): the input of the tabulated expected signal (`createDisplayViewExpectedSignal`)\n * for a resolved `simulateCapture` request, shared by the display view and the capture's tabulated RAW path so both build it\n * the same way: query offsets of each site, the environment's query points, the diffraction kernels and the scene tables.\n */\n\nimport { InvalidConfigurationError } from \"../core/configuration-error.js\";\nimport type { resolveLensPsfConvolutionKernels } from \"../optics/lens-psf-kernels.js\";\nimport { siteDiffractionKernel } from \"../optics/site-diffraction-kernel.js\";\nimport { resolveProjectionDistanceMm } from \"../motion/camera-rotation.js\";\nimport { hypot3 } from \"../core/portable-math.js\";\nimport type { DisplayViewExpectedSignalInput, DisplayViewScene } from \"./display-view.js\";\n\ntype Psf = Parameters<typeof resolveLensPsfConvolutionKernels>[0][\"psf\"];\n\nexport interface ExpectedSignalSetupInput {\n nativeWidth: number;\n nativeHeight: number;\n /** Frame output width (plane coordinates are 2 / width frame units per site). */\n width: number;\n wavelengths: readonly number[];\n /** Per CFA channel (red, green, blue): expected electrons per unit spectral radiance at each node. */\n perNode: readonly [Float64Array, Float64Array, Float64Array];\n /** Pixel-aperture nodes in sites from the site centre (`apertureNodeOffsets(boxSamples)`) and the box sample count. */\n apertureNodes: readonly { x: number; y: number }[];\n boxSamples: number;\n antiAliasing?: { components: readonly { offsetMicrometers: { x: number; y: number }; normalizedWeight: number }[] } | undefined;\n pitch: number;\n /** Object-space diffraction: the kernel radius, the PSF and its profile wavelengths. */\n diffraction?: { radius: number; psf: Psf; profileWavelengths: readonly number[] } | undefined;\n /** Material scenes: the materials (neutral reflectances, or radiance per wavelength), the material lookup and the white radiance. */\n materials?: { list: readonly ({ neutral: number } | { radiance: (nm: number) => number })[]; materialAt(u: number, v: number): number; whiteRadiance(nm: number): number } | undefined;\n radianceAt(u: number, v: number, nm: number): number;\n imagingArea: { widthMm: number; heightMm: number };\n focalLengthMm: number;\n focusDistanceM: number;\n subjectDistanceM: number;\n metresPerFrameUnit: number;\n /** Prefix of the error messages (\"simulateDisplayView\", \"simulateCapture\"). */\n caller: string;\n}\n\n/** The query offsets of each site and the expected-signal input (see the module comment). */\nexport function buildExpectedSignalInput(input: ExpectedSignalSetupInput): { offsets: { x: number; y: number; weight: number }[]; signalInput: DisplayViewExpectedSignalInput } {\n const { boxSamples, pitch, wavelengths, imagingArea, subjectDistanceM, metresPerFrameUnit } = input;\n // Query offsets of each site: pixel-aperture nodes minus anti-aliasing split offsets (sites, +x right, +y down), as the environment forms them.\n const spots = input.antiAliasing ? input.antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 }, normalizedWeight: 1 }];\n const offsets = input.apertureNodes.flatMap((node) => spots.map((c) => ({ x: node.x - c.offsetMicrometers.x / pitch, y: node.y - c.offsetMicrometers.y / pitch,\n weight: c.normalizedWeight / (boxSamples * boxSamples) })));\n // Diffraction: the same per-node site kernels as the object-space convolution (createDiffractedPlanarProvider).\n let kernels: { radius: number; perNode: Float64Array[] } | undefined;\n const diffraction = input.diffraction;\n if (diffraction) {\n const radius = diffraction.radius;\n kernels = { radius, perNode: wavelengths.map((wavelengthNm) => {\n if (!diffraction.profileWavelengths.includes(wavelengthNm)) throw new InvalidConfigurationError(input.caller + \": a spectral node has no diffraction kernel.\");\n return siteDiffractionKernel(diffraction.psf, wavelengthNm, pitch, radius, input.caller + \": diffraction kernels must sample the site pitch exactly.\");\n }) };\n }\n // Chart presets and (2.7) caller material scenes take the material path: each material tabulated once per node.\n const materials = input.materials;\n const scene: DisplayViewScene = materials ? { kind: \"materials\", materialAt: materials.materialAt,\n materials: materials.list.map((m) => (\"neutral\" in m ? { neutral: m.neutral } : { radiance: Float64Array.from(wavelengths, (nm) => m.radiance(nm)) })),\n whiteRadiance: Float64Array.from(wavelengths, (nm) => materials.whiteRadiance(nm)) } :\n { kind: \"radiance\", radianceAt: input.radianceAt };\n const channelIndex = (x: number, y: number): number => ((y & 1) === 0 ? ((x & 1) === 0 ? 0 : 1) : ((x & 1) === 0 ? 1 : 2));\n // Without diffraction the environment's own query arithmetic (native-environment-lean.ts `geometry`, then the rectilinear\n // still-camera ray and the planar provider's plane point), so boundary points resolve as in the capture.\n const projectionDistance = resolveProjectionDistanceMm(input.focalLengthMm, input.focusDistanceM).projectionDistanceMm, siteWidthMm = pitch / 1000;\n const nodePhases = input.apertureNodes.flatMap((_, index) => spots.map((c) => ({ xPhase: (index % boxSamples + 0.5) / boxSamples, yPhase: (Math.floor(index / boxSamples) + 0.5) / boxSamples,\n aaX: c.offsetMicrometers.x / 1000, aaY: c.offsetMicrometers.y / 1000 })));\n const queryPoint = (x: number, y: number, o: number, out: Float64Array): void => {\n const node = nodePhases[o]!;\n const centerX = -imagingArea.widthMm / 2 + (pitch / 2 + x * pitch + 0) / 1000, centerY = -imagingArea.heightMm / 2 + (pitch / 2 + y * pitch + 0) / 1000;\n const sourceX = (centerX - siteWidthMm / 2 + node.xPhase * siteWidthMm) - node.aaX, sourceY = (centerY - siteWidthMm / 2 + node.yPhase * siteWidthMm) - node.aaY;\n const rx = projectionDistance * (sourceX / projectionDistance), ry = projectionDistance * (-sourceY / projectionDistance);\n const length = hypot3(rx, ry, projectionDistance), t = subjectDistanceM / (projectionDistance / length);\n out[0] = rx / length * t / metresPerFrameUnit; out[1] = ry / length * t / metresPerFrameUnit;\n };\n return { offsets, signalInput: { nativeWidth: input.nativeWidth, nativeHeight: input.nativeHeight, outputWidth: input.width, wavelengthsNm: wavelengths,\n electronsPerRadiance: input.perNode, offsets, ...(kernels ? { kernels } : { queryPoint }), scene, channelAt: channelIndex } };\n}\n"]}
@@ -58,18 +58,47 @@ export interface DisplayViewExpectedSignal {
58
58
  /** Scene lookups performed so far (material or radiance queries), for diagnostics. */
59
59
  readonly lookups: number;
60
60
  /**
61
- * The chart material that every kernel tap of the last `site` call read, when they all read one material (its value is
62
- * then a function of that material and the site's CFA channel only); −1 when unknown or mixed.
61
+ * The chart material that every kernel tap (without diffraction: every query point) of the last `site` call read, when
62
+ * they all read one material (its value is then a function of that material and the site's CFA channel only); −1 when
63
+ * unknown or mixed.
63
64
  */
64
65
  readonly uniformMaterial: number;
65
- /** Whether `uniformMaterial` is tracked in the current window (otherwise it is not meaningful). */
66
+ /**
67
+ * Whether `uniformMaterial` is tracked in the current window (otherwise it is not meaningful): chart materials in a
68
+ * filled window or, without diffraction, in a window the direct ring serves (#444); never for radiance scenes.
69
+ */
66
70
  readonly tracksMaterial: boolean;
67
71
  /**
68
72
  * Whether the current window caches its sites: a second `site` call for a site inside the window then returns the stored
69
73
  * value without any work or lookup (#392).
70
74
  */
71
75
  readonly cachesSites: boolean;
76
+ /**
77
+ * Whether `markReads` is available in the current window: a filled window (chart materials with diffraction kernels),
78
+ * whose lookup count comes from the sites it computed. False otherwise: for windows without kernels (whose sites the
79
+ * direct ring already serves cheaply) and for the lazy window caches (radiance scenes with diffraction), whose count
80
+ * depends on the lattice points each computed site reads.
81
+ */
82
+ readonly marksReads: boolean;
83
+ /**
84
+ * Records `count` sites as read in the current window without computing them: site n at (xs[n], ys[n]) with value values[n]
85
+ * (as `site` returned it for that site) and uniform material materials[n] (as `uniformMaterial` reported it). Sites outside
86
+ * the window and sites the window already holds are skipped. For the window's lookup count and for later `site` calls this
87
+ * is the same as reading each site with `site`. Only valid while `marksReads` is true; it must do for a site the
88
+ * bookkeeping `site` does for a site the window's cache does not hold yet.
89
+ */
90
+ markReads(xs: Int32Array, ys: Int32Array, values: Float64Array, materials: Int32Array, count: number): void;
72
91
  }
92
+ /** Chart-material tables kept by content across input objects (#449), least recently used first. */
93
+ export declare const EXPECTED_SIGNAL_TABLE_CACHE_SIZE = 4;
94
+ /** Internal test hook: the kept table entries, and the tables built and found by content so far. */
95
+ export declare function expectedSignalTableCacheState(): {
96
+ size: number;
97
+ builds: number;
98
+ hits: number;
99
+ };
100
+ /** Internal test hook: forgets the tables kept by content (inputs already prepared keep theirs). */
101
+ export declare function clearExpectedSignalTableCache(): void;
73
102
  /**
74
103
  * 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
75
104
  * query point of offset o and K the diffraction kernel (a single unit tap without diffraction). This is the sum the
@@ -16,7 +16,85 @@ const BAND_LIMIT = 1 << 22;
16
16
  */
17
17
  const LATTICE_RING_ROWS = 128, LATTICE_RING_MAXIMUM_BAND_ROWS = 64, LATTICE_RING_MARGIN = 64;
18
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
+ }
19
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) {
20
98
  const nodes = input.wavelengthsNm.length, R = input.kernels ? input.kernels.radius : 0, size = 2 * R + 1;
21
99
  const A = input.electronsPerRadiance, scene = input.scene, offsets = input.offsets;
22
100
  const kernelAt = (node, t) => (input.kernels ? input.kernels.perNode[node][t] : 1);
@@ -26,11 +104,12 @@ function prepareSignal(input) {
26
104
  if (scene.kind === "materials") {
27
105
  reflectance = new Float64Array(scene.materials.length);
28
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.
29
108
  scene.materials.forEach((m, index) => { if ("neutral" in m)
30
109
  reflectance[index] = m.neutral;
31
110
  else {
32
111
  spectralIndex[index] = spectral.length;
33
- spectral.push(m.radiance);
112
+ spectral.push(Float64Array.from(m.radiance));
34
113
  } });
35
114
  }
36
115
  // Offsets grouped by fractional shift: offsets that differ by whole sites share one lattice.
@@ -40,7 +119,7 @@ function prepareSignal(input) {
40
119
  const fx = frac(o.x), fy = frac(o.y), ix = Math.round(o.x - fx), iy = Math.round(o.y - fy);
41
120
  let group = groups.find((q) => Math.abs(q.fx - fx) <= OFFSET_TOLERANCE && Math.abs(q.fy - fy) <= OFFSET_TOLERANCE);
42
121
  if (!group) {
43
- 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: [] };
44
123
  groups.push(group);
45
124
  }
46
125
  group.members.push({ ix, iy, weight: o.weight });
@@ -93,9 +172,10 @@ function prepareSignal(input) {
93
172
  }
94
173
  return sum;
95
174
  })) : [];
96
- const prepared = { reflectance, spectralIndex, spectral, groups, neutralDirect, spectralDirect, uniformSite, rings: groups.map(() => undefined) };
97
- PREPARED.set(input, prepared);
98
- return prepared;
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 });
99
179
  }
100
180
  /**
101
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
@@ -120,9 +200,10 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
120
200
  const A = input.electronsPerRadiance, scene = input.scene, offsets = input.offsets, channelAt = input.channelAt;
121
201
  // The request's tables (materials, lattice groups, combined kernels, per-material sums) are the same for every render of a
122
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.
123
204
  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 }));
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 }));
126
207
  let lookups = 0;
127
208
  const point = new Float64Array(2);
128
209
  const materialSignalDirect = (m, c) => {
@@ -131,15 +212,21 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
131
212
  throw new InvalidConfigurationError("Unknown chart material.");
132
213
  return si < 0 ? reflectance[m] * neutralDirect[c] : spectralDirect[c][si];
133
214
  };
134
- /** 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
+ */
135
220
  function directSite(x, y) {
136
221
  const c = channelAt(x, y);
137
- let sum = 0;
222
+ let sum = 0, uniform = -1;
138
223
  for (let o = 0; o < offsets.length; o++) {
139
224
  input.queryPoint(x, y, o, point);
140
225
  let value;
141
226
  if (scene.kind === "materials") {
142
- 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);
143
230
  lookups++;
144
231
  }
145
232
  else {
@@ -150,6 +237,7 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
150
237
  }
151
238
  sum += offsets[o].weight * value;
152
239
  }
240
+ lastMaterial = uniform < 0 ? -1 : uniform;
153
241
  return sum;
154
242
  }
155
243
  // Lattice point (p, q) of a group: the facade's lattice formula (createDiffractedPlanarProvider).
@@ -226,7 +314,8 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
226
314
  changeLeft: new Int32Array(0), changeUp: new Int32Array(0), v: new Float64Array(0) }));
227
315
  // Lattice rows of material indices per group, kept across windows (filled windows only): rows LATTICE_RING_ROWS apart share a
228
316
  // 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.
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).
230
319
  const ringWide = scene.kind === "materials" && scene.materials.length <= 32767;
231
320
  const rings = prepared.rings;
232
321
  function latticeRing(g) {
@@ -412,8 +501,9 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
412
501
  return v;
413
502
  }
414
503
  // Without diffraction (#392): site values persist across windows the same way, in their own ring (a site's value does not
415
- // depend on the window). A ring hit counts the lookups directSite would have made, so `lookups` is unchanged.
416
- 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);
417
507
  const directLookups = offsets.length * (scene.kind === "materials" ? 1 : nodes);
418
508
  function directRead(x, y) {
419
509
  const slot = y & (directRows - 1), q = slot * W + x;
@@ -421,6 +511,7 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
421
511
  const v = directRing[q];
422
512
  if (v === v) {
423
513
  lookups += directLookups;
514
+ lastMaterial = directRingMaterial[q];
424
515
  return v;
425
516
  }
426
517
  }
@@ -430,14 +521,17 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
430
521
  }
431
522
  const v = directSite(x, y);
432
523
  directRing[q] = v;
524
+ directRingMaterial[q] = lastMaterial;
433
525
  return v;
434
526
  }
435
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. */
436
528
  function siteMiss(x, y, i, j, cached) {
437
529
  if (directOn) {
438
530
  const v = directRead(x, y);
439
- if (cached)
531
+ if (cached) {
440
532
  siteCache[j * sw + i] = v;
533
+ windowMaterial[j * sw + i] = lastMaterial;
534
+ }
441
535
  return v;
442
536
  }
443
537
  if (ringOn) {
@@ -464,22 +558,66 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
464
558
  /**
465
559
  * Lattice points of the closing window that kernelSite's lazy cache would have looked up: per group, the window cache points
466
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).
467
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
+ }
468
574
  function windowLookups() {
469
575
  let total = 0;
470
576
  if (outsideSites.length === 0 && filledCount === 0)
471
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
+ }
472
585
  if (outsideSites.length === 0 && sw <= 31) {
473
586
  // Computed sites as a bit mask per window row. Cache point (ci, cj) is covered when a computed site (i, j) has
474
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
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);
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;
478
610
  let previous = -1, width = 0;
479
611
  for (let cj = fy0; cj <= last; cj++) {
480
612
  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];
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];
483
621
  if (m !== previous) {
484
622
  previous = m;
485
623
  width = 0;
@@ -498,11 +636,20 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
498
636
  }
499
637
  total += width;
500
638
  }
639
+ groupTotals[g] = total - before;
501
640
  }
502
641
  return total;
503
642
  }
504
- for (const group of groups) {
505
- const cw = group.cw, ch = group.ch, cpw = group.cpw, cph = group.cph, ew = cpw + cw - 1, eh = cph + ch - 1;
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;
506
653
  // Top-left marks in extended coordinates (window coordinates + (cw − 1, ch − 1)), and their bounding box.
507
654
  if (marks.length < ew * eh) {
508
655
  marks = new Uint8Array(ew * eh);
@@ -557,6 +704,7 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
557
704
  total++;
558
705
  }
559
706
  }
707
+ groupTotals[g] = total - before;
560
708
  }
561
709
  return total;
562
710
  }
@@ -564,7 +712,7 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
564
712
  // The computed entries of a filled window's site cache (windowLookups): their count, bounding box and a bit mask per row
565
713
  // (columns below 31).
566
714
  let filledCount = 0, fx0 = 0, fx1 = -1, fy0 = 0, fy1 = -1;
567
- let rowMask = new Int32Array(0);
715
+ let rowMask = new Int32Array(0), prefixOr = new Int32Array(0), suffixOr = new Int32Array(0);
568
716
  function markFilled(i, j) {
569
717
  if (i < 31)
570
718
  rowMask[j] = rowMask[j] | (1 << i);
@@ -586,8 +734,25 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
586
734
  return {
587
735
  get lookups() { return lookups + (filled ? windowLookups() : 0); },
588
736
  get uniformMaterial() { return lastMaterial; },
589
- 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"); },
590
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
+ },
591
756
  window(x0, y0, x1, y1) {
592
757
  if (filled)
593
758
  lookups += windowLookups();
@@ -619,9 +784,12 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
619
784
  directRows = rows;
620
785
  directRing = new Float64Array(rows * W).fill(NaN);
621
786
  directRowId = new Int32Array(rows).fill(-1);
787
+ directRingMaterial = new Int32Array(rows * W);
622
788
  }
623
789
  }
624
790
  directOn = sh > 0 && directRows >= 2 * sh;
791
+ if (directOn && windowMaterial.length < sw * sh)
792
+ windowMaterial = new Int32Array(sw * sh);
625
793
  return;
626
794
  }
627
795
  for (const group of groups) {
@@ -675,7 +843,7 @@ export function createDisplayViewExpectedSignal(input, spanColumns = Infinity) {
675
843
  if (cached) {
676
844
  const w = j * sw + i, v = siteCache[w];
677
845
  if (v === v) {
678
- if (ringOn)
846
+ if (ringOn || directOn)
679
847
  lastMaterial = windowMaterial[w];
680
848
  return v;
681
849
  }