@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
@@ -72,9 +72,10 @@ import { createRollingShutterCaptureExposureSchedule, resolveElectronicRollingSh
72
72
  import { calculateCaptureExposureWindows } from "../sensor/exposure-window.js";
73
73
  import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature.js";
74
74
  import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
75
+ import { registerDiffractionPsfKey } from "../optics/site-diffraction-kernel.js";
75
76
  import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
76
77
  import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
77
- import { executeEnvironmentSensorPhotoSignal, planEnvironmentSensorPhotoSignal } from "../sensor/environment-photo-signal.js";
78
+ import { calculateChannelElectronsPerRadiance } from "./channel-response.js";
78
79
  import { calculateSiteEffectiveSaturationCode } from "../capture/raw-white-level.js";
79
80
  import { prepareSensorColorDevelopment } from "../color/sensor-color-development.js";
80
81
  import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
@@ -96,6 +97,12 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling"
96
97
  "lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
97
98
  "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
98
99
  "output.format.jpeg", "output.format.png", "output.format.dng"]);
100
+ /**
101
+ * The options that take the exact-or-fast flag (#395), in the path style of {@link SIMULATE_CAPTURE_COST_OPTIONS}: each accepts
102
+ * the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
103
+ * whether or not it has a fast method yet; one without runs exact and reports `false`.
104
+ */
105
+ export const SIMULATE_CAPTURE_FAST_OPTIONS = Object.freeze(["lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"]);
99
106
  export const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
100
107
  sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
101
108
  const DEFAULTS = Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
@@ -145,8 +152,52 @@ const AA_MAXIMUM_OFFSET_SITES = 4;
145
152
  /** Channel responses kept across calls (`axisChannelResponse`): one entry per distinct sensor, exposure and optics state. */
146
153
  const CHANNEL_RESPONSE_CACHE_SIZE = 8;
147
154
  const channelResponseCache = new Map();
155
+ /**
156
+ * #449: diffraction profiles kept across calls, by the JSON of their `createDiffractionLimitedPsfProfile` input (the last
157
+ * DIFFRACTION_PROFILE_CACHE_SIZE, most recently used last). Every region part and detail tile of one request builds the same
158
+ * input. The kept record is private: each call gets its own copy, as mutable as a newly built one, so a caller's change
159
+ * never reaches another call. The copy is made of plain records and arrays (plainDataCopy): the kernel set-up structured-clones
160
+ * the profile once per spectral node, and in a CPU profile of the 96 × 64 Quality tile that clone took about twice as long
161
+ * (24 instead of 12 ms per call) on a deep-frozen record or on a structuredClone copy as on a newly built record or this copy.
162
+ */
163
+ const DIFFRACTION_PROFILE_CACHE_SIZE = 8;
164
+ const diffractionProfileCache = new Map();
165
+ /** A copy of plain data (records, arrays, primitives; typed arrays are sliced), built from ordinary literals. */
166
+ function plainDataCopy(value) {
167
+ if (Array.isArray(value))
168
+ return value.map(plainDataCopy);
169
+ if (ArrayBuffer.isView(value))
170
+ return value.slice();
171
+ if (value === null || typeof value !== "object")
172
+ return value;
173
+ const copy = {};
174
+ for (const key of Object.keys(value))
175
+ copy[key] = plainDataCopy(value[key]);
176
+ return copy;
177
+ }
178
+ /** The profile of `input` (a pure function of the input record): a copy of the kept one when the same input was built before. */
179
+ function cachedDiffractionProfile(input) {
180
+ const key = JSON.stringify(input);
181
+ let kept = diffractionProfileCache.get(key);
182
+ if (kept)
183
+ diffractionProfileCache.delete(key);
184
+ else
185
+ kept = createDiffractionLimitedPsfProfile(input).value;
186
+ diffractionProfileCache.set(key, kept);
187
+ if (diffractionProfileCache.size > DIFFRACTION_PROFILE_CACHE_SIZE)
188
+ diffractionProfileCache.delete(diffractionProfileCache.keys().next().value);
189
+ // The same fields in the same order as the built record, frozen at the top level like it; `model` is the frozen constant.
190
+ return Object.freeze({ ...kept, profile: plainDataCopy(kept.profile), wavelengths: plainDataCopy(kept.wavelengths), quadrature: plainDataCopy(kept.quadrature) });
191
+ }
148
192
  /** Default birefringent separation in sites: the two-spot MTF |cos(π·f·d)| then has its first zero at Nyquist, f = 1/(2·pitch). */
149
193
  const AA_DEFAULT_SEPARATION_SITES = 1;
194
+ /** #395: the `fast` override of an option record (undefined when absent), which must be a Boolean. */
195
+ function fastOverride(record, path) {
196
+ const value = record.fast;
197
+ if (value !== undefined && typeof value !== "boolean")
198
+ fail(path + ".fast must be true or false.");
199
+ return value;
200
+ }
150
201
  /** Parses `sensor.antiAliasing` into engine point-splitting components (offsets in µm, +x right, +y down). */
151
202
  function parseAntiAliasing(value, pitch) {
152
203
  requireInputObject(value, "sensor.antiAliasing");
@@ -160,18 +211,18 @@ function parseAntiAliasing(value, pitch) {
160
211
  return d;
161
212
  };
162
213
  if (kind === "four-spot") {
163
- const record = allowKeys(value, "sensor.antiAliasing", ["kind", "separationSites"]), d = separation(record), h = d * pitch / 2;
214
+ const record = allowKeys(value, "sensor.antiAliasing", ["kind", "separationSites", "fast"]), d = separation(record), h = d * pitch / 2;
164
215
  return { kind, separationSites: d, components: [[-h, -h], [h, -h], [-h, h], [h, h]].map(([x, y]) => ({ offsetMicrometers: { x: x, y: y }, normalizedWeight: 0.25 })) };
165
216
  }
166
217
  if (kind === "two-spot") {
167
- const record = allowKeys(value, "sensor.antiAliasing", ["kind", "axis", "separationSites"]), d = separation(record), h = d * pitch / 2;
218
+ const record = allowKeys(value, "sensor.antiAliasing", ["kind", "axis", "separationSites", "fast"]), d = separation(record), h = d * pitch / 2;
168
219
  if (record.axis !== "horizontal" && record.axis !== "vertical")
169
220
  fail("sensor.antiAliasing.axis must be horizontal or vertical.");
170
221
  const axis = record.axis;
171
222
  return { kind, axis, separationSites: d, components: [-h, h].map((o) => ({ offsetMicrometers: axis === "horizontal" ? { x: o, y: 0 } : { x: 0, y: o }, normalizedWeight: 0.5 })) };
172
223
  }
173
224
  if (kind === "point-splitting") {
174
- const record = allowKeys(value, "sensor.antiAliasing", ["kind", "components"]);
225
+ const record = allowKeys(value, "sensor.antiAliasing", ["kind", "components", "fast"]);
175
226
  if (!Array.isArray(record.components) || record.components.length === 0 || record.components.length > AA_MAXIMUM_COMPONENTS) {
176
227
  fail("sensor.antiAliasing.components must be an array of 1 to " + AA_MAXIMUM_COMPONENTS + " components.");
177
228
  }
@@ -283,11 +334,25 @@ function spectrumDigestForm(raw, prepared) {
283
334
  values.push(prepared.evaluate(nm));
284
335
  return { kind: "function-sampled-1nm", rangeNanometers: [FACADE_SPECTRAL_RANGE_NANOMETERS.minimum, FACADE_SPECTRAL_RANGE_NANOMETERS.maximum], values };
285
336
  }
337
+ /**
338
+ * #449: the CIE 1931 ȳ values at luminanceOf's 5 nm nodes, evaluated once (estimateCie1931ColorMatchingFunctions is a pure
339
+ * function of the wavelength, so each entry is the value the loop would compute).
340
+ */
341
+ let luminanceCmfY;
342
+ function luminanceNodesY() {
343
+ if (luminanceCmfY)
344
+ return luminanceCmfY;
345
+ const values = [];
346
+ for (let nm = FACADE_SPECTRAL_RANGE_NANOMETERS.minimum; nm <= FACADE_SPECTRAL_RANGE_NANOMETERS.maximum; nm += 5)
347
+ values.push(estimateCie1931ColorMatchingFunctions(nm).y);
348
+ return (luminanceCmfY = Float64Array.from(values));
349
+ }
286
350
  function luminanceOf(radiance) {
351
+ const cmfY = luminanceNodesY();
287
352
  let sum = 0;
288
- for (let nm = FACADE_SPECTRAL_RANGE_NANOMETERS.minimum; nm <= FACADE_SPECTRAL_RANGE_NANOMETERS.maximum; nm += 5) {
353
+ for (let nm = FACADE_SPECTRAL_RANGE_NANOMETERS.minimum, i = 0; nm <= FACADE_SPECTRAL_RANGE_NANOMETERS.maximum; nm += 5, i++) {
289
354
  const w = nm === FACADE_SPECTRAL_RANGE_NANOMETERS.minimum || nm === FACADE_SPECTRAL_RANGE_NANOMETERS.maximum ? 2.5 : 5;
290
- sum += w * radiance(nm) * estimateCie1931ColorMatchingFunctions(nm).y;
355
+ sum += w * radiance(nm) * cmfY[i];
291
356
  }
292
357
  return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
293
358
  }
@@ -610,7 +675,7 @@ export async function runCapture(options, run, viewOption, renderView) {
610
675
  const started = Date.now(), idealReference = run === "ideal-reference", displayView = run === "display-view";
611
676
  requireInputObject(options, "simulateCapture options");
612
677
  const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
613
- "illuminanceLux", "output", "sensor", "motion", "signal", "onProgress"]);
678
+ "illuminanceLux", "output", "sensor", "motion", "fast", "signal", "onProgress"]);
614
679
  const signal = top.signal, onProgress = top.onProgress;
615
680
  if (signal !== undefined && (typeof signal !== "object" || signal === null || typeof signal.aborted !== "boolean"))
616
681
  fail("signal must be an AbortSignal.");
@@ -714,16 +779,21 @@ export async function runCapture(options, run, viewOption, renderView) {
714
779
  lensPsf = p;
715
780
  }
716
781
  // Diffraction (2.1): validated here, built once the f-number is resolved.
717
- let diffractionRadiusOption;
782
+ let diffractionRadiusOption, diffractionFast;
718
783
  if (lensOptions.diffraction !== undefined) {
719
784
  const d = lensOptions.diffraction;
720
785
  if (d === "circular-aperture")
721
786
  diffractionRadiusOption = null;
722
787
  else {
723
- const record = allowKeys(d, "lens.diffraction", ["aperture", "kernelRadiusSamples"]);
724
- if (record.aperture !== "circular-aperture")
725
- fail("lens.diffraction must be \"circular-aperture\" or { aperture: \"circular-aperture\", kernelRadiusSamples }.");
788
+ const record = allowKeys(d, "lens.diffraction", ["kind", "aperture", "kernelRadiusSamples", "fast"]);
789
+ // #395: the record names the model by `kind`; `aperture` is its deprecated alias (until 3.0.0).
790
+ if (record.kind !== undefined && record.aperture !== undefined)
791
+ fail("lens.diffraction takes kind or its deprecated alias aperture, not both.");
792
+ const model = record.kind !== undefined ? record.kind : record.aperture;
793
+ if (model !== "circular-aperture")
794
+ fail("lens.diffraction must be \"circular-aperture\" or { kind: \"circular-aperture\", kernelRadiusSamples, fast }.");
726
795
  diffractionRadiusOption = record.kernelRadiusSamples === undefined ? null : integerIn(record.kernelRadiusSamples, "lens.diffraction.kernelRadiusSamples", 0, 32);
796
+ diffractionFast = fastOverride(record, "lens.diffraction");
727
797
  }
728
798
  if (lensPsf)
729
799
  fail("lens.diffraction cannot be combined with lens.psf: diffraction would be counted twice.");
@@ -824,16 +894,18 @@ export async function runCapture(options, run, viewOption, renderView) {
824
894
  pick(sensorOptions, "temporalSampleCount", "sensor.temporalSampleCount", DEFAULTS.temporalSampleCount, "One temporal sample: the static scene and still camera make more samples unnecessary."), "sensor.temporalSampleCount", 1, 256);
825
895
  const noiseSeed = integerIn(pick(sensorOptions, "noiseSeed", "sensor.noiseSeed", DEFAULTS.noiseSeed, "Fixed noise seed 1 (deterministic output)."), "sensor.noiseSeed", 0, 0xffffffff);
826
896
  const antiAliasing = sensorOptions.antiAliasing === undefined ? undefined : parseAntiAliasing(sensorOptions.antiAliasing, pitch);
897
+ const antiAliasingFast = antiAliasing ? fastOverride(sensorOptions.antiAliasing, "sensor.antiAliasing") : undefined;
827
898
  // Pixel aperture (2.3): N × N midpoint nodes over the square photosite; 1 = the default centre sample.
828
- let boxSamples = 1;
899
+ let boxSamples = 1, boxFast;
829
900
  if (sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point") {
830
901
  const a = sensorOptions.pixelAperture;
831
902
  if (a === "box")
832
903
  boxSamples = SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES;
833
904
  else {
834
- const record = allowKeys(a, "sensor.pixelAperture", ["kind", "samplesPerAxis"]);
905
+ const record = allowKeys(a, "sensor.pixelAperture", ["kind", "samplesPerAxis", "fast"]);
835
906
  if (record.kind !== "box")
836
- fail("sensor.pixelAperture must be \"point\", \"box\" or { kind: \"box\", samplesPerAxis }.");
907
+ fail("sensor.pixelAperture must be \"point\", \"box\" or { kind: \"box\", samplesPerAxis, fast }.");
908
+ boxFast = fastOverride(record, "sensor.pixelAperture");
837
909
  boxSamples = record.samplesPerAxis === undefined ? SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES : integerIn(record.samplesPerAxis, "sensor.pixelAperture.samplesPerAxis", 1, 16);
838
910
  }
839
911
  }
@@ -865,10 +937,35 @@ export async function runCapture(options, run, viewOption, renderView) {
865
937
  layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
866
938
  // Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
867
939
  // one-site rendered border are used unchanged.
868
- const demosaicOption = outputOptions.demosaic, borderOption = outputOptions.borderHandling;
940
+ // #395: the record form { kind, fast } names the same demosaic.
941
+ let demosaicOption, demosaicFast;
942
+ if (outputOptions.demosaic !== undefined && typeof outputOptions.demosaic === "object" && outputOptions.demosaic !== null) {
943
+ const record = allowKeys(outputOptions.demosaic, "output.demosaic", ["kind", "fast"]);
944
+ if (record.kind !== "bilinear" && record.kind !== "ahd")
945
+ fail("output.demosaic must be bilinear or ahd, or { kind, fast }.");
946
+ demosaicOption = record.kind;
947
+ demosaicFast = fastOverride(record, "output.demosaic");
948
+ }
949
+ else
950
+ demosaicOption = outputOptions.demosaic;
951
+ const borderOption = outputOptions.borderHandling;
869
952
  if (demosaicOption !== undefined && demosaicOption !== "bilinear" && demosaicOption !== "ahd")
870
953
  fail("output.demosaic must be bilinear or ahd.");
871
954
  const ahd = demosaicOption === "ahd";
955
+ // #395: exact or fast per option. No option has a fast method yet, so each runs exact and reports false.
956
+ if (top.fast !== undefined && typeof top.fast !== "boolean")
957
+ fail("fast must be true or false.");
958
+ const fastTop = top.fast === true;
959
+ const fastInUse = [["lens.diffraction", diffractionRadiusOption !== undefined, diffractionFast],
960
+ ["sensor.antiAliasing", antiAliasing !== undefined, antiAliasingFast], ["sensor.pixelAperture.box", boxAperture, boxFast], ["output.demosaic.ahd", ahd, demosaicFast]];
961
+ const fastApplied = {};
962
+ for (const [option, used] of fastInUse)
963
+ if (used)
964
+ fastApplied[option] = false;
965
+ const fastReport = Object.freeze({
966
+ requested: fastTop || [diffractionFast, antiAliasingFast, boxFast, demosaicFast].some((value) => value === true),
967
+ applied: Object.freeze(fastApplied), assurance: "engine-reference"
968
+ });
872
969
  if (borderOption !== undefined && borderOption !== "margin" && borderOption !== "mirror")
873
970
  fail("output.borderHandling must be margin or mirror.");
874
971
  const builtInDemosaic = demosaicOption === undefined ? undefined : createBuiltInBayerDemosaicProfiles({ kernel: "bilinear",
@@ -1343,13 +1440,18 @@ export async function runCapture(options, run, viewOption, renderView) {
1343
1440
  // larger tail energy outside it is listed per wavelength like any other (2.2.1 states the cap in the explanation).
1344
1441
  const thirdRingSites = Math.max(1, Math.ceil(AIRY_THIRD_ZERO_PER_LAMBDA_N * lambdaN / pitch));
1345
1442
  const kernelRadiusSamples = diffractionRadiusOption ?? Math.min(32, thirdRingSites);
1346
- diffraction = createDiffractionLimitedPsfProfile({ aperture: "circular", focalLengthMm, apertureFNumber: fNumber, focus: { kind: "finite", distanceM: focusDistanceM },
1443
+ const profileInput = { aperture: "circular", focalLengthMm, apertureFNumber: fNumber, focus: { kind: "finite", distanceM: focusDistanceM },
1347
1444
  wavelengthsNm: wavelengths, samplePitchMicrometers: pitch, kernelRadiusSamples, fieldHalfExtentMm: aaReachMm === 0 ? { x: imagingArea.widthMm / 2, y: imagingArea.heightMm / 2 } :
1348
1445
  { x: imagingArea.widthMm / 2 + aaReachMm, y: imagingArea.heightMm / 2 + aaReachMm },
1349
- profileId: "facade-diffraction-airy" }).value;
1446
+ profileId: "facade-diffraction-airy" };
1447
+ // #449: a copy of the profile kept for this input; it equals createDiffractionLimitedPsfProfile(profileInput).value field by
1448
+ // field, so the registration below (keyed by profileInput) holds for it.
1449
+ diffraction = cachedDiffractionProfile(profileInput);
1350
1450
  diffractionPsf = { kind: "sampled-local", configuration: { psf: { profile: diffraction.profile, focalLengthMm, focus: { kind: "finite", distanceM: focusDistanceM },
1351
1451
  apertureFNumber: fNumber, signedDefocusImagePlaneMicrometers: 0 }, psfWavelengthBasis: { value: "vacuum", evidence },
1352
1452
  spatialModel: { kind: "destination-local-shift-invariant-approximation", evidence, limitation: "Diffraction-limited Airy PSF, the same at every field point." } } };
1453
+ // Requests with the same profile input and PSF fields share their site diffraction kernels (optics/site-diffraction-kernel.ts).
1454
+ registerDiffractionPsfKey(diffractionPsf.configuration.psf, profileInput);
1353
1455
  assume("lens.diffraction", "model", { model: diffraction.model, aperture: "circular-aperture", fNumber, workingFNumber: diffraction.workingFNumber,
1354
1456
  kernelRadiusSamples, samplePitchMicrometers: pitch, maximumTruncatedEnergyFraction: diffraction.maximumTruncatedEnergyFraction,
1355
1457
  truncatedEnergyByWavelength: diffraction.wavelengths.map((w) => ({ wavelengthNm: w.wavelengthNm, truncatedEnergyFraction: w.truncatedEnergyFraction })),
@@ -1441,9 +1543,11 @@ export async function runCapture(options, run, viewOption, renderView) {
1441
1543
  delete environment.evaluateApertureRadiance;
1442
1544
  environments[channel] = environment;
1443
1545
  }
1444
- // The responses are a function of these records alone (the radiance is replaced per node), and a request split into region
1445
- // tiles builds the same records for every tile, so they are kept across calls (the last CHANNEL_RESPONSE_CACHE_SIZE).
1446
- const key = JSON.stringify(CHANNELS.map((c) => environments[c]));
1546
+ // The responses are a function of these records without their scene bindings: the radiance is replaced at every node,
1547
+ // and the bindings (provider, illumination and material profiles) are only checked, never enter a number. So a chart,
1548
+ // illuminant or illuminance change keeps the entry (#443). A request split into region tiles builds the same records for
1549
+ // every tile. The entries are kept across calls (the last CHANNEL_RESPONSE_CACHE_SIZE).
1550
+ const key = JSON.stringify(CHANNELS.map((c) => ({ ...environments[c], sceneBindings: undefined })));
1447
1551
  const copy = (responses) => Object.fromEntries(CHANNELS.map((c) => [c, new Map(responses[c])]));
1448
1552
  const cached = channelResponseCache.get(key);
1449
1553
  let electronsPerRadiance;
@@ -1454,15 +1558,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1454
1558
  }
1455
1559
  else {
1456
1560
  electronsPerRadiance = {};
1457
- for (const channel of CHANNELS) {
1458
- // Planned once per channel and executed per spectral node: calculateEnvironmentSensorPhotoSignal is this plan and execution.
1459
- const photoPlan = planEnvironmentSensorPhotoSignal(environments[channel]);
1460
- const electrons = (radiance) => executeEnvironmentSensorPhotoSignal(photoPlan, (request) => ({ ...provider.evaluateRadiance(request), spectralRadianceWattsPerSquareMeterSteradianNanometer: radiance(request.wavelengthNanometers) })).value
1461
- .photo.value.photoSignal.expectedGeneratedElectronCount;
1462
- const nodes = new Set();
1463
- electrons((nm) => { nodes.add(nm); return 1; });
1464
- electronsPerRadiance[channel] = new Map([...nodes].sort((a, b) => a - b).map((node) => [node, electrons((nm) => (nm === node ? 1 : 0))]));
1465
- }
1561
+ // One chain execution per channel, each node's electrons read from its own rate term (#443; exact, see channel-response.ts).
1562
+ // The records are built above for this call only, so they are planned as engine-owned data.
1563
+ for (const channel of CHANNELS)
1564
+ electronsPerRadiance[channel] = calculateChannelElectronsPerRadiance(environments[channel], (request) => provider.evaluateRadiance(request), caller);
1466
1565
  // Callers get their own maps; the cached ones are never handed out.
1467
1566
  channelResponseCache.set(key, copy(electronsPerRadiance));
1468
1567
  if (channelResponseCache.size > CHANNEL_RESPONSE_CACHE_SIZE)
@@ -1485,7 +1584,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1485
1584
  // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1486
1585
  // that only captures does not contain the display view.
1487
1586
  if (displayView)
1488
- return await renderView({ ahd, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, chartReport, chartSize, diffraction, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
1587
+ return await renderView({ ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, chartReport, chartSize, diffraction, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
1489
1588
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1490
1589
  if (idealReference) {
1491
1590
  const referenceStarted = clockMs();
@@ -1849,7 +1948,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1849
1948
  timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started,
1850
1949
  ...(diffraction && diffractionObjectSpace ? { diffraction: diffractionMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
1851
1950
  ...(encodeMs === undefined ? {} : { encode: encodeMs }) },
1852
- options: Object.freeze(SIMULATE_CAPTURE_COST_OPTIONS.filter((option) => ranOptions.has(option))) },
1951
+ options: Object.freeze(SIMULATE_CAPTURE_COST_OPTIONS.filter((option) => ranOptions.has(option))), fast: fastReport },
1853
1952
  provenance: { scientificStatus: "approximation", cameraPresetId: preset.presetId, cameraPresetVersion: preset.presetVersion,
1854
1953
  evidence: [...FACADE_EVIDENCE, ...preset.evidence],
1855
1954
  composedContracts: ["resolveGenericEquipmentExposureCapabilities", "meterRelativeExposure", "createExposureMeterTargetFromMeteringResult",