@photivra/engine 2.5.0 → 2.6.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 (84) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +1 -0
  6. package/dist/capture/native-environment-lean.d.ts +1 -1
  7. package/dist/capture/native-environment-lean.js +17 -3
  8. package/dist/capture/native-environment-lean.js.map +1 -1
  9. package/dist/capture/native-environment-raw.d.ts +0 -4
  10. package/dist/capture/native-environment-raw.js +6 -2
  11. package/dist/capture/native-environment-raw.js.map +1 -1
  12. package/dist/color/illuminants.d.ts +15 -0
  13. package/dist/color/illuminants.js +81 -24
  14. package/dist/color/illuminants.js.map +1 -1
  15. package/dist/color/srgb-transfer.d.ts +6 -0
  16. package/dist/color/srgb-transfer.js +9 -0
  17. package/dist/color/srgb-transfer.js.map +1 -1
  18. package/dist/color/white-balance.d.ts +6 -0
  19. package/dist/color/white-balance.js +15 -2
  20. package/dist/color/white-balance.js.map +1 -1
  21. package/dist/core/internal-radiance-evaluator.d.ts +6 -0
  22. package/dist/core/internal-radiance-evaluator.js +33 -0
  23. package/dist/core/internal-radiance-evaluator.js.map +1 -0
  24. package/dist/core/portable-math.d.ts +33 -0
  25. package/dist/core/portable-math.js +342 -0
  26. package/dist/core/portable-math.js.map +1 -1
  27. package/dist/core/version.d.ts +1 -1
  28. package/dist/core/version.js +1 -1
  29. package/dist/core/version.js.map +1 -1
  30. package/dist/facade/chart-scenes.d.ts +9 -0
  31. package/dist/facade/chart-scenes.js +17 -4
  32. package/dist/facade/chart-scenes.js.map +1 -1
  33. package/dist/facade/diffraction-provider.d.ts +10 -0
  34. package/dist/facade/diffraction-provider.js +38 -17
  35. package/dist/facade/diffraction-provider.js.map +1 -1
  36. package/dist/facade/display-view.d.ts +208 -0
  37. package/dist/facade/display-view.js +1461 -0
  38. package/dist/facade/display-view.js.map +1 -0
  39. package/dist/facade/scene-radiance.d.ts +7 -0
  40. package/dist/facade/scene-radiance.js +18 -5
  41. package/dist/facade/scene-radiance.js.map +1 -1
  42. package/dist/facade/simulate-capture.d.ts +224 -4
  43. package/dist/facade/simulate-capture.js +104 -33
  44. package/dist/facade/simulate-capture.js.map +1 -1
  45. package/dist/facade/simulate-display-view.d.ts +21 -0
  46. package/dist/facade/simulate-display-view.js +168 -0
  47. package/dist/facade/simulate-display-view.js.map +1 -0
  48. package/dist/facade/subject-framing.d.ts +16 -0
  49. package/dist/facade/subject-framing.js +8 -2
  50. package/dist/facade/subject-framing.js.map +1 -1
  51. package/dist/index.d.ts +2 -1
  52. package/dist/index.js +2 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/optics/diffraction-psf-profile.d.ts +9 -2
  55. package/dist/optics/diffraction-psf-profile.js +33 -4
  56. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  57. package/dist/optics/irradiance-convolution.js +1 -1
  58. package/dist/optics/irradiance-convolution.js.map +1 -1
  59. package/dist/optics/scene-to-sensor-irradiance.d.ts +7 -0
  60. package/dist/optics/scene-to-sensor-irradiance.js +10 -5
  61. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  62. package/dist/output/print-plan.d.ts +8 -1
  63. package/dist/output/print-plan.js +8 -1
  64. package/dist/output/print-plan.js.map +1 -1
  65. package/dist/schema/scene-radiance.d.ts +16 -0
  66. package/dist/schema/scene-radiance.js +25 -4
  67. package/dist/schema/scene-radiance.js.map +1 -1
  68. package/dist/sensor/raw-readout.d.ts +12 -0
  69. package/dist/sensor/raw-readout.js +131 -9
  70. package/dist/sensor/raw-readout.js.map +1 -1
  71. package/dist/sensor/stochastic-seed.d.ts +13 -0
  72. package/dist/sensor/stochastic-seed.js +42 -8
  73. package/dist/sensor/stochastic-seed.js.map +1 -1
  74. package/docs/AGENT_GUIDE.md +1 -0
  75. package/docs/API_REFERENCE.md +13 -1
  76. package/docs/QUICK_START.md +155 -4
  77. package/docs/USAGE.md +15 -0
  78. package/docs/api/core-version.md +1 -1
  79. package/docs/api/exports.json +22 -2
  80. package/docs/api/facade-simulate-capture.md +65 -1
  81. package/docs/api/facade-simulate-display-view.md +18 -0
  82. package/docs/api/facade-subject-framing.md +16 -0
  83. package/docs/api/output-print-plan.md +8 -1
  84. package/package.json +3 -2
@@ -28,6 +28,7 @@
28
28
  * without it every encoded file is byte-identical to 2.3.
29
29
  * @see docs/QUICK_START.md
30
30
  */
31
+ import { markInternalSceneRadianceEvaluator } from "../core/internal-radiance-evaluator.js";
31
32
  import { InvalidConfigurationError } from "../core/configuration-error.js";
32
33
  import { requireInputObject } from "../core/record-validation.js";
33
34
  import { ENGINE_API_VERSION } from "../core/version.js";
@@ -92,7 +93,7 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling"
92
93
  "lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
93
94
  "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
94
95
  "output.format.jpeg", "output.format.png", "output.format.dng"]);
95
- const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
96
+ export const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
96
97
  sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
97
98
  const DEFAULTS = Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
98
99
  subjectDistanceM: 5, width: 64, height: 64, format: "srgb-8", toneCurve: "identity", highlightMode: "clip",
@@ -181,7 +182,7 @@ function parseAntiAliasing(value, pitch) {
181
182
  return fail("sensor.antiAliasing.kind must be four-spot, two-spot or point-splitting.");
182
183
  }
183
184
  /** Midpoint nodes of an N × N rule over a square photosite, as offsets from its centre in sites (+x right, +y down). */
184
- function apertureNodeOffsets(n) {
185
+ export function apertureNodeOffsets(n) {
185
186
  const out = [];
186
187
  for (let j = 0; j < n; j++)
187
188
  for (let i = 0; i < n; i++)
@@ -197,7 +198,7 @@ const IDS = Object.freeze({ frame: "facade-frame", mode: "native-still", cfa: "p
197
198
  capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa", stackAa: "facade-aa",
198
199
  stationarity: "facade-stationarity", operatingState: "facade-operating-state", dark: "facade-dark", completeness: "facade-completeness",
199
200
  chargeSampling: "facade-charge-sampling", capacity: "facade-capacity" });
200
- const CHANNELS = ["red", "green", "blue"];
201
+ export const CHANNELS = ["red", "green", "blue"];
201
202
  /**
202
203
  * Synthetic colorimetric sensor: channel EQE(λ) = a·cmf(λ)·(555 nm/λ), with cmf = x̄, ȳ, z̄. The λ factor turns
203
204
  * the energy-weighted CMF (colour-matching function) into a photon-counting response. So channel counts are
@@ -206,12 +207,12 @@ const CHANNELS = ["red", "green", "blue"];
206
207
  */
207
208
  const EQE_SCALE = Object.freeze({ red: 0.5, green: 0.5, blue: 0.25 });
208
209
  const CMF_KEY = Object.freeze({ red: "x", green: "y", blue: "z" });
209
- const CAMERA_TO_XYZ = Object.freeze([[EQE_SCALE.green / EQE_SCALE.red, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.green / EQE_SCALE.blue]].map((r) => Object.freeze(r)));
210
+ export const CAMERA_TO_XYZ = Object.freeze([[EQE_SCALE.green / EQE_SCALE.red, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.green / EQE_SCALE.blue]].map((r) => Object.freeze(r)));
210
211
  const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.blue / EQE_SCALE.green]];
211
212
  /** Synthetic dark current per site at the operating temperature, e⁻/s. */
212
- const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
213
+ export const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
213
214
  const OPERATING_TEMPERATURE_C = 20;
214
- function fail(message) { throw new InvalidConfigurationError(message); }
215
+ export function fail(message) { throw new InvalidConfigurationError(message); }
215
216
  /** Where `output.metadata` goes in each file format (reported in the `output.metadata` assumption). */
216
217
  const TEXT_METADATA_LAYOUT = Object.freeze({
217
218
  jpeg: "XMP packet in APP1 (dc:description, xmp:CreatorTool, dc:rights, exif:UserComment), plus a COM segment holding the comment",
@@ -230,7 +231,7 @@ function isoAreaScaledReadout(profile, areaScale) {
230
231
  preAdcSaturationElectronEquivalent: { ...regime.preAdcSaturationElectronEquivalent, value: regime.preAdcSaturationElectronEquivalent.value * areaScale } })) });
231
232
  }
232
233
  /** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
233
- function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
234
+ export function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
234
235
  function positive(value, name) {
235
236
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
236
237
  fail(name + " must be a positive finite number.");
@@ -284,6 +285,12 @@ function luminanceOf(radiance) {
284
285
  }
285
286
  return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
286
287
  }
288
+ /** The white balance measured from a perfect white diffuser under the scene illuminant ("scene-illuminant"). */
289
+ export function sceneIlluminantWhiteBalance(illuminant) {
290
+ const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
291
+ return resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
292
+ inputDomain: "relative-pre-wb-camera-linear-rgb", samples: [{ sampleId: "white", red: camera[0], green: camera[1], blue: camera[2], weight: 1, clipped: false }] } });
293
+ }
287
294
  function illuminantXyz(illuminant) {
288
295
  let x = 0, y = 0, z = 0;
289
296
  for (let nm = FACADE_SPECTRAL_RANGE_NANOMETERS.minimum; nm <= FACADE_SPECTRAL_RANGE_NANOMETERS.maximum; nm++) {
@@ -295,7 +302,7 @@ function illuminantXyz(illuminant) {
295
302
  return [x / y, 1, z / y];
296
303
  }
297
304
  /** Bilinear RGGB demosaic as explicit linear neighbourhood kernels (weights sum to one per output channel). */
298
- function bilinearPhaseProfiles() {
305
+ export function bilinearPhaseProfiles() {
299
306
  const cross = [[1, 0], [-1, 0], [0, 1], [0, -1]], diagonal = [[1, 1], [1, -1], [-1, 1], [-1, -1]], horizontal = [[1, 0], [-1, 0]], vertical = [[0, 1], [0, -1]];
300
307
  const average = (offsets, channel) => offsets.map(([x, y]) => ({ offsetX: x, offsetY: y, sourceChannelId: channel, weight: 1 / offsets.length }));
301
308
  const self = (channel) => [{ offsetX: 0, offsetY: 0, sourceChannelId: channel, weight: 1 }];
@@ -470,7 +477,7 @@ function encodeSdrFromLinear(rgb, encoding, bitDepth, mapping) {
470
477
  * underlying request/plan, provenance and every assumption made. See docs/QUICK_START.md.
471
478
  */
472
479
  export async function simulateCapture(options) {
473
- return await runCapture(options, false);
480
+ return await runCapture(options, "capture");
474
481
  }
475
482
  /**
476
483
  * The ideal reference of a capture request (2.3): the same scene, framing, chart size, output size, region and pixel
@@ -482,10 +489,30 @@ export async function simulateCapture(options) {
482
489
  * @see docs/QUICK_START.md, "Ideal reference".
483
490
  */
484
491
  export async function simulateIdealReference(options) {
485
- return await runCapture(options, true);
492
+ return await runCapture(options, "ideal-reference");
493
+ }
494
+ /** Default stratified samples per axis of a display view (`view.samplesPerAxis`): 16 × 16 sampled real pixels per display pixel with detail, 4 × 4 where it is uniform. */
495
+ export const SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS = 16;
496
+ function parseDisplayViewOption(value, frameWidth, frameHeight) {
497
+ if (value === undefined)
498
+ fail("simulateDisplayView needs a view, for example { width: 384, height: 256 }.");
499
+ const v = allowKeys(value, "view", ["width", "height", "samplesPerAxis", "format", "region"]);
500
+ const width = integerIn(v.width, "view.width", 1, frameWidth), height = integerIn(v.height, "view.height", 1, frameHeight);
501
+ const samplesPerAxis = v.samplesPerAxis === undefined ? SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS : integerIn(v.samplesPerAxis, "view.samplesPerAxis", 1, 32);
502
+ const format = v.format === undefined ? "srgb-8" : v.format;
503
+ if (format !== "srgb-8" && format !== "srgb-16")
504
+ fail("view.format must be srgb-8 or srgb-16.");
505
+ let region;
506
+ if (v.region !== undefined) {
507
+ const r = allowKeys(v.region, "view.region", ["x", "y", "width", "height"]);
508
+ const x = integerIn(r.x, "view.region.x", 0, width - 1), y = integerIn(r.y, "view.region.y", 0, height - 1);
509
+ region = { x, y, width: integerIn(r.width, "view.region.width", 1, width - x), height: integerIn(r.height, "view.region.height", 1, height - y) };
510
+ }
511
+ return { width, height, samplesPerAxis, bitDepth: format === "srgb-8" ? 8 : 16, format: format, ...(region ? { region } : {}) };
486
512
  }
487
- async function runCapture(options, idealReference) {
488
- const started = Date.now();
513
+ /** Internal: the request path shared by {@link simulateCapture}, {@link simulateIdealReference} and the display view, whose renderer is passed in. */
514
+ export async function runCapture(options, run, viewOption, renderView) {
515
+ const started = Date.now(), idealReference = run === "ideal-reference", displayView = run === "display-view";
489
516
  requireInputObject(options, "simulateCapture options");
490
517
  const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
491
518
  "illuminanceLux", "output", "sensor", "motion", "signal", "onProgress"]);
@@ -714,6 +741,29 @@ async function runCapture(options, idealReference) {
714
741
  }
715
742
  }
716
743
  const boxAperture = sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point";
744
+ // Display view: its own parameters, and the capture options it reproduces, checked before any scene work.
745
+ const parsedView = displayView ? parseDisplayViewOption(viewOption, width, height) : undefined;
746
+ if (displayView) {
747
+ const unsupported = (option, reason) => fail("simulateDisplayView does not support " + option + ": " + reason);
748
+ if (pupil)
749
+ unsupported("lens.pupilSampling", "defocus moves each query off the site lattice the expected-signal tables use.");
750
+ if (lensFieldMapping)
751
+ unsupported("lens.projection or lens.distortion", "the expected signal assumes the ideal rectilinear, distortion-free mapping of the site lattice.");
752
+ if (vignetting)
753
+ unsupported("lens.vignetting", "the expected signal assumes the same radiance-to-irradiance factor at every site.");
754
+ if (lensPsf)
755
+ unsupported("lens.psf", "only the engine-built diffraction kernels (lens.diffraction) are tabulated.");
756
+ if (rotating)
757
+ unsupported("motion.angularVelocityRadPerSec", "camera rotation moves the queries off the site lattice.");
758
+ if (outputOptions.region !== undefined)
759
+ unsupported("output.region", "pass the rectangle as view.region, in display pixels.");
760
+ if (outputOptions.whiteBalance === "auto")
761
+ unsupported("output.whiteBalance \"auto\"", "the capture measures it from its noisy full-resolution RAW; use \"scene-illuminant\" or a fixed white balance.");
762
+ if (outputOptions.toneCurve !== undefined && outputOptions.toneCurve !== "identity" && outputOptions.toneCurve !== "positive-reinhard-per-channel")
763
+ unsupported("output.toneCurve " + String(outputOptions.toneCurve), "luminance tone curves couple the channels; the view supports identity and positive-reinhard-per-channel.");
764
+ if (outputOptions.highlightMode === "reconstruct")
765
+ unsupported("output.highlightMode \"reconstruct\"", "the view supports the per-channel highlight clip.");
766
+ }
717
767
  const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
718
768
  layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
719
769
  // Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
@@ -752,13 +802,17 @@ async function runCapture(options, idealReference) {
752
802
  if (region && regionNative)
753
803
  assume("output.region", "model", { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } }, "Only this rectangle of the " + width + " × " + height + " frame is rendered. Framing, metering, exposure, diffraction kernels and every site's physics, noise seeds and exposure window (rolling-shutter row timing) are those of the whole frame (absolute native indices), " +
754
804
  "and the native rectangle includes the demosaic reach on each side (" + (margin > 0 ? margin : "mirror-border") + "), so the pixels are byte-identical to the same pixels of the whole render. " +
755
- "With lens.diffraction the object-space convolution runs over whole-width bands of 32 native rows that the region touches.");
805
+ // 2.6: with lens.diffraction the convolution is limited to the region; the sentence of 2.3–2.5 is kept verbatim otherwise, so
806
+ // region results without diffraction stay byte-identical to 2.5.0.
807
+ (diffractionRadiusOption !== undefined ? "With lens.diffraction the object-space convolution (when it applies) covers only the lattice the region's native sites query (plus the kernel radius), in bands of at most 32 native rows; its values are the whole render's." :
808
+ "With lens.diffraction the object-space convolution runs over whole-width bands of 32 native rows that the region touches."));
756
809
  if (raisedPixels !== undefined) {
757
810
  if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
758
811
  fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
759
812
  " sites (the output plus the demosaic margin), at most " + NATIVE_RAW_LIMITS.maximumDimension + " per side.");
760
813
  }
761
- if (ahd && (regionNative ? regionNative.width * regionNative.height : nativeWidth * nativeHeight) > PROCESSING_MAX_PIXELS)
814
+ // The display view allocates no full-resolution raster, so the AHD working limit does not apply to it.
815
+ if (ahd && !displayView && (regionNative ? regionNative.width * regionNative.height : nativeWidth * nativeHeight) > PROCESSING_MAX_PIXELS)
762
816
  fail("output.demosaic \"ahd\" admits at most " + PROCESSING_MAX_PIXELS + " native sites.");
763
817
  }
764
818
  const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
@@ -847,10 +901,16 @@ async function runCapture(options, idealReference) {
847
901
  assume("scene.digest", "model", "caller-function-not-hashed", "The source digest covers the caller's sceneId and the framing, not the body of the caller's evaluate function.");
848
902
  const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
849
903
  checkAbort();
850
- const provider = buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene, sceneId: IDS.scene, providerProfileId: IDS.provider,
851
- illuminant, irradianceScale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
904
+ const buildProvider = (sceneIlluminant, scale) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
905
+ sceneId: IDS.scene, providerProfileId: IDS.provider, illuminant: sceneIlluminant, irradianceScale: scale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
852
906
  ...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
853
- limitations: ["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."] });
907
+ limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."]) });
908
+ const provider = buildProvider(illuminant, irradianceScale);
909
+ // 2.6: a chart preset's evaluator is engine-built end to end (its results: the schema's exact keys, constant fields,
910
+ // the request's identifiers, deep-frozen FACADE_EVIDENCE, UNCERTAINTY and limitations, and an engine-computed
911
+ // radiance), so the native task may skip re-parsing the fixed parts. Caller scenes keep the full validation.
912
+ if (chart)
913
+ markInternalSceneRadianceEvaluator(provider.evaluateRadiance);
854
914
  assume("model.scene-radiometry", "model", "lambertian-plane", "Radiance = reflectance × irradiance / π on one plane; illuminance from K_m = 683 lm/W and the engine's analytic CIE 1931 ȳ fit.");
855
915
  // Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
856
916
  const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops"]);
@@ -1020,7 +1080,7 @@ async function runCapture(options, idealReference) {
1020
1080
  else
1021
1081
  assume("lens.projection", "model", projection, "Ideal " + projection + " projection through the environment lens field mapping. At finite focus its scale is the thin-lens image distance (an approximation); the chart is still sized with the rectilinear thin-lens scale, so its edges move with the projection.");
1022
1082
  // White balance
1023
- let whiteBalanceState = null, needsAuto = false;
1083
+ let whiteBalanceState = null, needsAuto = false, kelvinWhiteBalanceInput;
1024
1084
  if (whiteBalanceOption === "none")
1025
1085
  whiteBalanceState = null;
1026
1086
  else if (whiteBalanceOption === "auto") {
@@ -1028,17 +1088,16 @@ async function runCapture(options, idealReference) {
1028
1088
  fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
1029
1089
  needsAuto = true;
1030
1090
  }
1031
- else if (whiteBalanceOption === "scene-illuminant") {
1032
- const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
1033
- whiteBalanceState = resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
1034
- inputDomain: "relative-pre-wb-camera-linear-rgb", samples: [{ sampleId: "white", red: camera[0], green: camera[1], blue: camera[2], weight: 1, clipped: false }] } });
1035
- }
1091
+ else if (whiteBalanceOption === "scene-illuminant")
1092
+ whiteBalanceState = sceneIlluminantWhiteBalance(illuminant);
1036
1093
  else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "kelvin" in whiteBalanceOption) {
1037
1094
  const wb = allowKeys(whiteBalanceOption, "output.whiteBalance", ["kelvin", "tint"]);
1038
1095
  const intent = createColorTemperatureWhiteBalanceIntent({ intentId: "facade-wb-cct", colorTemperatureKelvin: positive(wb.kelvin, "output.whiteBalance.kelvin"),
1039
1096
  tint: wb.tint === undefined ? 0 : wb.tint });
1040
- whiteBalanceState = resolveColorTemperatureWhiteBalance({ stateId: "facade-wb-cct", intent, whiteLocus: "planckian", tintInterpretation: "duv-cie-1960-uv",
1041
- camera: { profileId: IDS.spectral, profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, xyzToCameraRgb: XYZ_TO_CAMERA.map((r) => [...r]) } });
1097
+ const kelvinInput = { stateId: "facade-wb-cct", intent, whiteLocus: "planckian", tintInterpretation: "duv-cie-1960-uv",
1098
+ camera: { profileId: IDS.spectral, profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, xyzToCameraRgb: XYZ_TO_CAMERA.map((r) => [...r]) } };
1099
+ whiteBalanceState = resolveColorTemperatureWhiteBalance(kelvinInput);
1100
+ kelvinWhiteBalanceInput = kelvinInput;
1042
1101
  }
1043
1102
  else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "channelGains" in whiteBalanceOption) {
1044
1103
  allowKeys(whiteBalanceOption, "output.whiteBalance", ["channelGains"]);
@@ -1236,6 +1295,8 @@ async function runCapture(options, idealReference) {
1236
1295
  const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1237
1296
  wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1238
1297
  sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1298
+ // Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
1299
+ ...(regionNative ? { window: regionNative } : {}),
1239
1300
  // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
1240
1301
  ...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites: apertureNodeOffsets(boxSamples).flatMap((node) => (antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }]).map((c) => ({ x: node.x - c.offsetMicrometers.x / pitch, y: node.y - c.offsetMicrometers.y / pitch }))) } : {}) }) : provider;
1241
1302
  if (antiAliasing && perSite > 100_000)
@@ -1245,13 +1306,9 @@ async function runCapture(options, idealReference) {
1245
1306
  const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
1246
1307
  const environmentRaw = { raw: buildRawInput(intentOf(whiteBalanceState)), sceneBinding,
1247
1308
  maximumProviderEvaluations: Math.min(nativeRawMaximumProviderEvaluations(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels }), Math.max(nativeWidth * nativeHeight, evaluations)) };
1248
- // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1249
- if (idealReference) {
1250
- const referenceStarted = clockMs();
1251
- if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
1252
- fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
1253
- if (needsAuto)
1254
- fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
1309
+ // The facade's environment chain at a site next to the optical axis, shared by the ideal reference (2.3) and the display
1310
+ // view: expected electrons per unit spectral radiance per channel and node, and the normalized RAW signal per electron.
1311
+ function axisChannelResponse(caller) {
1255
1312
  // 1. Electrons per unit spectral radiance at each spectral node, per channel: the facade's own environment chain at a
1256
1313
  // site next to the optical axis, with the ideal optics (no PSF, pupil, distortion, vignetting or motion). The signal
1257
1314
  // is linear in the radiance node values and, for this planar scene, the same at every site (paraxial π/(4N_w²)).
@@ -1260,7 +1317,7 @@ async function runCapture(options, idealReference) {
1260
1317
  const electronsPerRadiance = {};
1261
1318
  for (const channel of CHANNELS) {
1262
1319
  if (channelAt(channelSites[channel] % nativeWidth, Math.floor(channelSites[channel] / nativeWidth)) !== channel)
1263
- fail("simulateIdealReference needs at least a 2 × 2 native raster.");
1320
+ fail(caller + " needs at least a 2 × 2 native raster.");
1264
1321
  const environment = { ...siteInput(channelSites[channel]).environment };
1265
1322
  environment.psf = { kind: "not-applied", evidence, limitation: "Ideal reference: no lens PSF." };
1266
1323
  environment.fieldThroughput = { kind: "unity", evidence, limitation: "Ideal reference: no vignetting." };
@@ -1288,6 +1345,20 @@ async function runCapture(options, idealReference) {
1288
1345
  systemConversionGainElectronsPerCode: gain, blackLevelCode: black, digitalSaturationCode: regime.adc.digitalSaturationCode, adcMaximumCode: 2 ** regime.adc.bitDepth - 1, digitalGain }, readoutProfile, regimeId);
1289
1346
  relativePerElectron[channel] = digitalGain / (gain * (white - black));
1290
1347
  }
1348
+ return { electronsPerRadiance, wavelengths, relativePerElectron };
1349
+ }
1350
+ // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1351
+ // that only captures does not contain the display view.
1352
+ if (displayView)
1353
+ 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 });
1354
+ // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1355
+ if (idealReference) {
1356
+ const referenceStarted = clockMs();
1357
+ if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
1358
+ fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
1359
+ if (needsAuto)
1360
+ fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
1361
+ const { electronsPerRadiance, wavelengths, relativePerElectron } = axisChannelResponse("simulateIdealReference");
1291
1362
  // 3. Scene radiance per sample: chart materials once (and the fixed-size chart's surround), caller functions per sample.
1292
1363
  const signalOf = (radiance) => {
1293
1364
  const values = new Map(wavelengths.map((nm) => [nm, radiance(nm)]));