@photivra/engine 2.4.1 → 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 (85) hide show
  1. package/CHANGELOG.md +53 -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 +229 -5
  43. package/dist/facade/simulate-capture.js +119 -35
  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 +78 -0
  49. package/dist/facade/subject-framing.js +69 -20
  50. package/dist/facade/subject-framing.js.map +1 -1
  51. package/dist/index.d.ts +3 -2
  52. package/dist/index.js +3 -2
  53. package/dist/index.js.map +1 -1
  54. package/dist/optics/diffraction-psf-profile.d.ts +59 -3
  55. package/dist/optics/diffraction-psf-profile.js +147 -5
  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 +16 -1
  76. package/docs/QUICK_START.md +245 -9
  77. package/docs/USAGE.md +15 -0
  78. package/docs/api/core-version.md +1 -1
  79. package/docs/api/exports.json +37 -2
  80. package/docs/api/facade-simulate-capture.md +70 -2
  81. package/docs/api/facade-simulate-display-view.md +18 -0
  82. package/docs/api/facade-subject-framing.md +83 -0
  83. package/docs/api/optics-diffraction-psf-profile.md +8 -1
  84. package/docs/api/output-print-plan.md +8 -1
  85. 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",
@@ -120,6 +121,10 @@ export const SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX = Math.PI * sosReferenceLu
120
121
  * aperture with fill factor 1. At other areas the facade scales the electron-domain readout values by
121
122
  * area / this reference, so the same focal-plane exposure H gives the same output code (see `isoAreaScaledReadout`).
122
123
  */
124
+ /** 2.5: the largest accepted pixel pitch, µm (44 mm ÷ 48 pixels ≈ 917 µm, a whole full-frame or larger sensor at a small output). */
125
+ const SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS = 1000;
126
+ /** Pitches above this, µm, are larger than any camera photosite: the capture is a downsized whole-frame view (assumption sensor.wholeFrameView). */
127
+ const SIMULATE_CAPTURE_PHOTOSITE_PITCH_MAXIMUM_MICROMETERS = 100;
123
128
  const ISO_REFERENCE_APERTURE_AREA_SQUARE_MICROMETERS = DEFAULTS.pixelPitchMicrometers * DEFAULTS.pixelPitchMicrometers;
124
129
  /** Demosaic border, native sites per side. */
125
130
  const MARGIN = 1;
@@ -177,7 +182,7 @@ function parseAntiAliasing(value, pitch) {
177
182
  return fail("sensor.antiAliasing.kind must be four-spot, two-spot or point-splitting.");
178
183
  }
179
184
  /** Midpoint nodes of an N × N rule over a square photosite, as offsets from its centre in sites (+x right, +y down). */
180
- function apertureNodeOffsets(n) {
185
+ export function apertureNodeOffsets(n) {
181
186
  const out = [];
182
187
  for (let j = 0; j < n; j++)
183
188
  for (let i = 0; i < n; i++)
@@ -193,7 +198,7 @@ const IDS = Object.freeze({ frame: "facade-frame", mode: "native-still", cfa: "p
193
198
  capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa", stackAa: "facade-aa",
194
199
  stationarity: "facade-stationarity", operatingState: "facade-operating-state", dark: "facade-dark", completeness: "facade-completeness",
195
200
  chargeSampling: "facade-charge-sampling", capacity: "facade-capacity" });
196
- const CHANNELS = ["red", "green", "blue"];
201
+ export const CHANNELS = ["red", "green", "blue"];
197
202
  /**
198
203
  * Synthetic colorimetric sensor: channel EQE(λ) = a·cmf(λ)·(555 nm/λ), with cmf = x̄, ȳ, z̄. The λ factor turns
199
204
  * the energy-weighted CMF (colour-matching function) into a photon-counting response. So channel counts are
@@ -202,12 +207,12 @@ const CHANNELS = ["red", "green", "blue"];
202
207
  */
203
208
  const EQE_SCALE = Object.freeze({ red: 0.5, green: 0.5, blue: 0.25 });
204
209
  const CMF_KEY = Object.freeze({ red: "x", green: "y", blue: "z" });
205
- 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)));
206
211
  const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.blue / EQE_SCALE.green]];
207
212
  /** Synthetic dark current per site at the operating temperature, e⁻/s. */
208
- const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
213
+ export const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
209
214
  const OPERATING_TEMPERATURE_C = 20;
210
- function fail(message) { throw new InvalidConfigurationError(message); }
215
+ export function fail(message) { throw new InvalidConfigurationError(message); }
211
216
  /** Where `output.metadata` goes in each file format (reported in the `output.metadata` assumption). */
212
217
  const TEXT_METADATA_LAYOUT = Object.freeze({
213
218
  jpeg: "XMP packet in APP1 (dc:description, xmp:CreatorTool, dc:rights, exif:UserComment), plus a COM segment holding the comment",
@@ -226,7 +231,7 @@ function isoAreaScaledReadout(profile, areaScale) {
226
231
  preAdcSaturationElectronEquivalent: { ...regime.preAdcSaturationElectronEquivalent, value: regime.preAdcSaturationElectronEquivalent.value * areaScale } })) });
227
232
  }
228
233
  /** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
229
- function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
234
+ export function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
230
235
  function positive(value, name) {
231
236
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
232
237
  fail(name + " must be a positive finite number.");
@@ -280,6 +285,12 @@ function luminanceOf(radiance) {
280
285
  }
281
286
  return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
282
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
+ }
283
294
  function illuminantXyz(illuminant) {
284
295
  let x = 0, y = 0, z = 0;
285
296
  for (let nm = FACADE_SPECTRAL_RANGE_NANOMETERS.minimum; nm <= FACADE_SPECTRAL_RANGE_NANOMETERS.maximum; nm++) {
@@ -291,7 +302,7 @@ function illuminantXyz(illuminant) {
291
302
  return [x / y, 1, z / y];
292
303
  }
293
304
  /** Bilinear RGGB demosaic as explicit linear neighbourhood kernels (weights sum to one per output channel). */
294
- function bilinearPhaseProfiles() {
305
+ export function bilinearPhaseProfiles() {
295
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]];
296
307
  const average = (offsets, channel) => offsets.map(([x, y]) => ({ offsetX: x, offsetY: y, sourceChannelId: channel, weight: 1 / offsets.length }));
297
308
  const self = (channel) => [{ offsetX: 0, offsetY: 0, sourceChannelId: channel, weight: 1 }];
@@ -466,7 +477,7 @@ function encodeSdrFromLinear(rgb, encoding, bitDepth, mapping) {
466
477
  * underlying request/plan, provenance and every assumption made. See docs/QUICK_START.md.
467
478
  */
468
479
  export async function simulateCapture(options) {
469
- return await runCapture(options, false);
480
+ return await runCapture(options, "capture");
470
481
  }
471
482
  /**
472
483
  * The ideal reference of a capture request (2.3): the same scene, framing, chart size, output size, region and pixel
@@ -478,10 +489,30 @@ export async function simulateCapture(options) {
478
489
  * @see docs/QUICK_START.md, "Ideal reference".
479
490
  */
480
491
  export async function simulateIdealReference(options) {
481
- 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 } : {}) };
482
512
  }
483
- async function runCapture(options, idealReference) {
484
- 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";
485
516
  requireInputObject(options, "simulateCapture options");
486
517
  const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
487
518
  "illuminanceLux", "output", "sensor", "motion", "signal", "onProgress"]);
@@ -687,8 +718,8 @@ async function runCapture(options, idealReference) {
687
718
  fail("output.jpegQuantizationStep applies only to output.format \"jpeg\".");
688
719
  const sensorOptions = allowKeys(top.sensor, "sensor", ["pixelPitchMicrometers", "spectralStepNanometers", "temporalSampleCount", "noiseSeed", "antiAliasing", "pixelAperture"]);
689
720
  const pitch = positive(pick(sensorOptions, "pixelPitchMicrometers", "sensor.pixelPitchMicrometers", DEFAULTS.pixelPitchMicrometers, "6 µm pixels (about a 24 MP full-frame sensor). The simulated sensor is width × pitch wide, so small images are a central crop of a large sensor, not a downscaled full frame."), "sensor.pixelPitchMicrometers");
690
- if (pitch < 0.5 || pitch > 100)
691
- fail("sensor.pixelPitchMicrometers must be 0.5-100.");
721
+ if (pitch < 0.5 || pitch > SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS)
722
+ fail("sensor.pixelPitchMicrometers must be 0.5-" + SIMULATE_CAPTURE_PIXEL_PITCH_MAXIMUM_MICROMETERS + ".");
692
723
  const spectralStep = positive(pick(sensorOptions, "spectralStepNanometers", "sensor.spectralStepNanometers", DEFAULTS.spectralStepNanometers, "Spectral midpoint quadrature over 380-780 nm with subintervals of at most 20 nm."), "sensor.spectralStepNanometers");
693
724
  if (spectralStep < 1 || spectralStep > 100)
694
725
  fail("sensor.spectralStepNanometers must be 1-100.");
@@ -710,6 +741,29 @@ async function runCapture(options, idealReference) {
710
741
  }
711
742
  }
712
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
+ }
713
767
  const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
714
768
  layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
715
769
  // Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
@@ -748,13 +802,17 @@ async function runCapture(options, idealReference) {
748
802
  if (region && regionNative)
749
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), " +
750
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. " +
751
- "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."));
752
809
  if (raisedPixels !== undefined) {
753
810
  if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
754
811
  fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
755
812
  " sites (the output plus the demosaic margin), at most " + NATIVE_RAW_LIMITS.maximumDimension + " per side.");
756
813
  }
757
- 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)
758
816
  fail("output.demosaic \"ahd\" admits at most " + PROCESSING_MAX_PIXELS + " native sites.");
759
817
  }
760
818
  const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
@@ -843,10 +901,16 @@ async function runCapture(options, idealReference) {
843
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.");
844
902
  const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
845
903
  checkAbort();
846
- const provider = buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene, sceneId: IDS.scene, providerProfileId: IDS.provider,
847
- 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,
848
906
  ...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
849
- 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);
850
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.");
851
915
  // Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
852
916
  const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops"]);
@@ -947,6 +1011,15 @@ async function runCapture(options, idealReference) {
947
1011
  "the aperture area: a design consequence of holding the tier's ISO rating and highlight headroom fixed, not a measured full-well model (published sensor data grow less than " +
948
1012
  "proportionally with area across technologies).");
949
1013
  }
1014
+ // Whole-frame views (2.5): a pitch above 100 µm is larger than any camera photosite, so the capture is a downsized view of a frame width × pitch wide.
1015
+ if (pitch > SIMULATE_CAPTURE_PHOTOSITE_PITCH_MAXIMUM_MICROMETERS) {
1016
+ assume("sensor.wholeFrameView", "model", { pixelPitchMicrometers: pitch, frameWidthMm: width * pitch / 1000, frameHeightMm: height * pitch / 1000 }, "A pixel pitch above 100 µm is larger than any camera photosite, so each output pixel stands for a " + pitch + " µm square of a " + width * pitch / 1000 + " × " + height * pitch / 1000 +
1017
+ " mm frame: a downsized view of the whole frame, with the framing, crop factor, generic prime and minimum focus of that frame. Its signal is the charge of the whole square " +
1018
+ "(sensor.isoAreaNormalization), so its shot noise is that of all the real photosites in the square summed or averaged (Poisson counts add), and the image brightness is the " +
1019
+ "same as at any other pitch. Read noise and dark current are applied once per output pixel, not once per real photosite, so in deep shadows the noise is lower than a real " +
1020
+ "sensor's binned output. This is the noise of a binned, downsized view, not the real sensor's per-pixel noise. A point pixel aperture samples one point of each square; " +
1021
+ "use sensor.pixelAperture box (or sensor.antiAliasing) to average scene detail finer than the pitch.");
1022
+ }
950
1023
  assume("sensor.darkCurrent", "model", { electronsPerSecond: DARK_CURRENT_ELECTRONS_PER_SECOND, temperatureC: OPERATING_TEMPERATURE_C }, "Synthetic uniform dark current of 1 e⁻/s per site at 20 °C (the tier presets declare none); dark shot noise follows from it.");
951
1024
  assume("sensor.spectralResponse", "model", { kind: "colorimetric-cmf-eqe", peakScale: EQE_SCALE }, "Idealized colorimetric sensor: channel EQE = a·{x̄, ȳ, z̄}(λ)·555/λ (the engine's analytic CIE 1931 fit), so RAW channels are proportional to CIE XYZ (Luther–Ives) and the camera matrix is diagonal. Not a real camera's response; CMF air wavelengths are used as vacuum wavelengths (difference about 0.03 %).");
952
1025
  if (boxAperture) {
@@ -1007,7 +1080,7 @@ async function runCapture(options, idealReference) {
1007
1080
  else
1008
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.");
1009
1082
  // White balance
1010
- let whiteBalanceState = null, needsAuto = false;
1083
+ let whiteBalanceState = null, needsAuto = false, kelvinWhiteBalanceInput;
1011
1084
  if (whiteBalanceOption === "none")
1012
1085
  whiteBalanceState = null;
1013
1086
  else if (whiteBalanceOption === "auto") {
@@ -1015,17 +1088,16 @@ async function runCapture(options, idealReference) {
1015
1088
  fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
1016
1089
  needsAuto = true;
1017
1090
  }
1018
- else if (whiteBalanceOption === "scene-illuminant") {
1019
- const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
1020
- whiteBalanceState = resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
1021
- inputDomain: "relative-pre-wb-camera-linear-rgb", samples: [{ sampleId: "white", red: camera[0], green: camera[1], blue: camera[2], weight: 1, clipped: false }] } });
1022
- }
1091
+ else if (whiteBalanceOption === "scene-illuminant")
1092
+ whiteBalanceState = sceneIlluminantWhiteBalance(illuminant);
1023
1093
  else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "kelvin" in whiteBalanceOption) {
1024
1094
  const wb = allowKeys(whiteBalanceOption, "output.whiteBalance", ["kelvin", "tint"]);
1025
1095
  const intent = createColorTemperatureWhiteBalanceIntent({ intentId: "facade-wb-cct", colorTemperatureKelvin: positive(wb.kelvin, "output.whiteBalance.kelvin"),
1026
1096
  tint: wb.tint === undefined ? 0 : wb.tint });
1027
- whiteBalanceState = resolveColorTemperatureWhiteBalance({ stateId: "facade-wb-cct", intent, whiteLocus: "planckian", tintInterpretation: "duv-cie-1960-uv",
1028
- 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;
1029
1101
  }
1030
1102
  else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "channelGains" in whiteBalanceOption) {
1031
1103
  allowKeys(whiteBalanceOption, "output.whiteBalance", ["channelGains"]);
@@ -1223,6 +1295,8 @@ async function runCapture(options, idealReference) {
1223
1295
  const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1224
1296
  wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1225
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 } : {}),
1226
1300
  // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
1227
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;
1228
1302
  if (antiAliasing && perSite > 100_000)
@@ -1232,13 +1306,9 @@ async function runCapture(options, idealReference) {
1232
1306
  const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
1233
1307
  const environmentRaw = { raw: buildRawInput(intentOf(whiteBalanceState)), sceneBinding,
1234
1308
  maximumProviderEvaluations: Math.min(nativeRawMaximumProviderEvaluations(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels }), Math.max(nativeWidth * nativeHeight, evaluations)) };
1235
- // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1236
- if (idealReference) {
1237
- const referenceStarted = clockMs();
1238
- if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
1239
- fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
1240
- if (needsAuto)
1241
- 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) {
1242
1312
  // 1. Electrons per unit spectral radiance at each spectral node, per channel: the facade's own environment chain at a
1243
1313
  // site next to the optical axis, with the ideal optics (no PSF, pupil, distortion, vignetting or motion). The signal
1244
1314
  // is linear in the radiance node values and, for this planar scene, the same at every site (paraxial π/(4N_w²)).
@@ -1247,7 +1317,7 @@ async function runCapture(options, idealReference) {
1247
1317
  const electronsPerRadiance = {};
1248
1318
  for (const channel of CHANNELS) {
1249
1319
  if (channelAt(channelSites[channel] % nativeWidth, Math.floor(channelSites[channel] / nativeWidth)) !== channel)
1250
- fail("simulateIdealReference needs at least a 2 × 2 native raster.");
1320
+ fail(caller + " needs at least a 2 × 2 native raster.");
1251
1321
  const environment = { ...siteInput(channelSites[channel]).environment };
1252
1322
  environment.psf = { kind: "not-applied", evidence, limitation: "Ideal reference: no lens PSF." };
1253
1323
  environment.fieldThroughput = { kind: "unity", evidence, limitation: "Ideal reference: no vignetting." };
@@ -1275,6 +1345,20 @@ async function runCapture(options, idealReference) {
1275
1345
  systemConversionGainElectronsPerCode: gain, blackLevelCode: black, digitalSaturationCode: regime.adc.digitalSaturationCode, adcMaximumCode: 2 ** regime.adc.bitDepth - 1, digitalGain }, readoutProfile, regimeId);
1276
1346
  relativePerElectron[channel] = digitalGain / (gain * (white - black));
1277
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");
1278
1362
  // 3. Scene radiance per sample: chart materials once (and the fixed-size chart's surround), caller functions per sample.
1279
1363
  const signalOf = (radiance) => {
1280
1364
  const values = new Map(wavelengths.map((nm) => [nm, radiance(nm)]));