@photivra/engine 2.10.0 → 2.12.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 (115) hide show
  1. package/CHANGELOG.md +156 -1
  2. package/CITATION.cff +1 -1
  3. package/NODE_SUPPORT.md +19 -3
  4. package/README.md +6 -6
  5. package/SECURITY.md +1 -1
  6. package/dist/core/portable-math.d.ts +6 -0
  7. package/dist/core/portable-math.js +39 -0
  8. package/dist/core/portable-math.js.map +1 -1
  9. package/dist/core/version.d.ts +1 -1
  10. package/dist/core/version.js +1 -1
  11. package/dist/core/version.js.map +1 -1
  12. package/dist/facade/chart-material-layers.d.ts +115 -0
  13. package/dist/facade/chart-material-layers.js +224 -0
  14. package/dist/facade/chart-material-layers.js.map +1 -0
  15. package/dist/facade/chart-radiance-batch.d.ts +8 -2
  16. package/dist/facade/chart-radiance-batch.js +11 -4
  17. package/dist/facade/chart-radiance-batch.js.map +1 -1
  18. package/dist/facade/chart-scenes.d.ts +134 -0
  19. package/dist/facade/chart-scenes.js +32 -22
  20. package/dist/facade/chart-scenes.js.map +1 -1
  21. package/dist/facade/diffraction-provider.d.ts +8 -2
  22. package/dist/facade/diffraction-provider.js +71 -2
  23. package/dist/facade/diffraction-provider.js.map +1 -1
  24. package/dist/facade/display-view-gpu-session.d.ts +18 -0
  25. package/dist/facade/display-view-gpu-session.js +280 -0
  26. package/dist/facade/display-view-gpu-session.js.map +1 -0
  27. package/dist/facade/display-view-gpu.d.ts +146 -0
  28. package/dist/facade/display-view-gpu.js +59 -0
  29. package/dist/facade/display-view-gpu.js.map +1 -0
  30. package/dist/facade/display-view.d.ts +48 -1
  31. package/dist/facade/display-view.js +7 -7
  32. package/dist/facade/display-view.js.map +1 -1
  33. package/dist/facade/expected-signal-setup.d.ts +5 -0
  34. package/dist/facade/expected-signal-setup.js +16 -0
  35. package/dist/facade/expected-signal-setup.js.map +1 -1
  36. package/dist/facade/expected-signal.d.ts +48 -3
  37. package/dist/facade/expected-signal.js +8 -0
  38. package/dist/facade/expected-signal.js.map +1 -1
  39. package/dist/facade/scene-radiance.d.ts +4 -2
  40. package/dist/facade/scene-radiance.js +7 -4
  41. package/dist/facade/scene-radiance.js.map +1 -1
  42. package/dist/facade/simulate-capture.d.ts +108 -8
  43. package/dist/facade/simulate-capture.js +226 -33
  44. package/dist/facade/simulate-capture.js.map +1 -1
  45. package/dist/facade/simulate-display-view.d.ts +31 -3
  46. package/dist/facade/simulate-display-view.js +73 -13
  47. package/dist/facade/simulate-display-view.js.map +1 -1
  48. package/dist/gpu/chart-composite-gpu.d.ts +119 -0
  49. package/dist/gpu/chart-composite-gpu.js +572 -0
  50. package/dist/gpu/chart-composite-gpu.js.map +1 -0
  51. package/dist/gpu/chart-radiance-gpu.d.ts +6 -6
  52. package/dist/gpu/chart-radiance-gpu.js +9 -11
  53. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  54. package/dist/gpu/display-view-chart-material-module.d.ts +10 -0
  55. package/dist/gpu/display-view-chart-material-module.js +58 -0
  56. package/dist/gpu/display-view-chart-material-module.js.map +1 -0
  57. package/dist/gpu/display-view-composite-material-module.d.ts +68 -0
  58. package/dist/gpu/display-view-composite-material-module.js +248 -0
  59. package/dist/gpu/display-view-composite-material-module.js.map +1 -0
  60. package/dist/gpu/display-view-quality-gpu.d.ts +185 -0
  61. package/dist/gpu/display-view-quality-gpu.js +1000 -0
  62. package/dist/gpu/display-view-quality-gpu.js.map +1 -0
  63. package/dist/gpu/display-view-speed-gpu.d.ts +156 -0
  64. package/dist/gpu/display-view-speed-gpu.js +524 -0
  65. package/dist/gpu/display-view-speed-gpu.js.map +1 -0
  66. package/dist/gpu/image-kernel-accelerator.d.ts +23 -4
  67. package/dist/gpu/image-kernel-accelerator.js +58 -10
  68. package/dist/gpu/image-kernel-accelerator.js.map +1 -1
  69. package/dist/gpu/image-kernel-execution.d.ts +55 -3
  70. package/dist/gpu/image-kernel-execution.js.map +1 -1
  71. package/dist/gpu/sensor-stage-gpu.d.ts +11 -0
  72. package/dist/gpu/sensor-stage-gpu.js +42 -13
  73. package/dist/gpu/sensor-stage-gpu.js.map +1 -1
  74. package/dist/gpu/webgpu-runtime.d.ts +4 -2
  75. package/dist/gpu/webgpu-runtime.js +13 -4
  76. package/dist/gpu/webgpu-runtime.js.map +1 -1
  77. package/dist/gpu/webgpu-switch.d.ts +4 -0
  78. package/dist/gpu/webgpu-switch.js +29 -0
  79. package/dist/gpu/webgpu-switch.js.map +1 -0
  80. package/dist/gpu/wgsl-chart-composite-kernel.d.ts +52 -0
  81. package/dist/gpu/wgsl-chart-composite-kernel.js +515 -0
  82. package/dist/gpu/wgsl-chart-composite-kernel.js.map +1 -0
  83. package/dist/gpu/wgsl-chart-kernel.d.ts +11 -9
  84. package/dist/gpu/wgsl-chart-kernel.js +33 -27
  85. package/dist/gpu/wgsl-chart-kernel.js.map +1 -1
  86. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +95 -0
  87. package/dist/gpu/wgsl-display-view-quality-kernel.js +769 -0
  88. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -0
  89. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +75 -0
  90. package/dist/gpu/wgsl-display-view-speed-kernel.js +386 -0
  91. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -0
  92. package/dist/gpu/wgsl-sensor-kernel.d.ts +28 -9
  93. package/dist/gpu/wgsl-sensor-kernel.js +94 -50
  94. package/dist/gpu/wgsl-sensor-kernel.js.map +1 -1
  95. package/dist/index.d.ts +5 -2
  96. package/dist/index.js +2 -0
  97. package/dist/index.js.map +1 -1
  98. package/dist/optics/defocus-disc-kernel.d.ts +61 -0
  99. package/dist/optics/defocus-disc-kernel.js +191 -0
  100. package/dist/optics/defocus-disc-kernel.js.map +1 -0
  101. package/docs/API_REFERENCE.md +47 -1
  102. package/docs/QUICK_START.md +150 -5
  103. package/docs/USAGE.md +81 -3
  104. package/docs/api/core-version.md +1 -1
  105. package/docs/api/exports.json +127 -2
  106. package/docs/api/facade-chart-material-layers.md +137 -0
  107. package/docs/api/facade-chart-radiance-batch.md +7 -2
  108. package/docs/api/facade-display-view-gpu.md +94 -0
  109. package/docs/api/facade-scene-radiance.md +3 -2
  110. package/docs/api/facade-simulate-capture.md +95 -11
  111. package/docs/api/facade-simulate-display-view.md +17 -2
  112. package/docs/api/gpu-image-kernel-accelerator.md +10 -3
  113. package/docs/api/gpu-image-kernel-execution.md +71 -4
  114. package/docs/api/gpu-webgpu-switch.md +23 -0
  115. package/package.json +3 -1
@@ -30,6 +30,7 @@
30
30
  */
31
31
  import { markInternalNativeTileReader, markInternalSceneRadianceEvaluator, registerInternalRadianceAccess } from "../core/internal-radiance-evaluator.js";
32
32
  import { InvalidConfigurationError } from "../core/configuration-error.js";
33
+ import { InvalidScientificResultError } from "../core/validation.js";
33
34
  import { requireInputObject } from "../core/record-validation.js";
34
35
  import { ENGINE_API_VERSION } from "../core/version.js";
35
36
  import { GENERIC_TIER_ASSETS } from "../equipment/generic-tier-assets.js";
@@ -39,7 +40,7 @@ import { resolveGenericEquipmentExposureCapabilities } from "../equipment/exposu
39
40
  import { resolveGenericIsoSignalChain } from "../sensor/iso-signal-chain.js";
40
41
  import { meterRelativeExposure } from "../exposure/metering.js";
41
42
  import { createExposureMeterTargetFromMeteringResult, setExposureCompensationOnMeterTarget } from "../exposure/metering-target.js";
42
- import { resolveManualExposureMode, resolveAperturePriorityExposureMode, resolveShutterPriorityExposureMode, resolveProgramAutoExposureMode } from "../exposure/exposure-mode-resolver.js";
43
+ import { resolveManualExposureMode, resolveAperturePriorityExposureMode, resolveAperturePriorityAutoIsoExposureMode, resolveShutterPriorityExposureMode, resolveProgramAutoExposureMode } from "../exposure/exposure-mode-resolver.js";
43
44
  import { parseExposureProgramLineProfile } from "../exposure/program-line.js";
44
45
  import { resolveCaptureGeometry } from "../output/capture-geometry.js";
45
46
  import { estimateCie1931ColorMatchingFunctions } from "../color/color-matching-functions.js";
@@ -72,7 +73,8 @@ import { createRollingShutterCaptureExposureSchedule, resolveElectronicRollingSh
72
73
  import { calculateCaptureExposureWindows } from "../sensor/exposure-window.js";
73
74
  import { calculateSensorSpectralQuadrature } from "../sensor/spectral-quadrature.js";
74
75
  import { createDiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
75
- import { registerDiffractionPsfKey } from "../optics/site-diffraction-kernel.js";
76
+ import { registerDiffractionPsfKey, siteDiffractionKernel } from "../optics/site-diffraction-kernel.js";
77
+ import { convolveSiteKernels, createDefocusDiscSiteKernel, requireDefocusDiscKernelRadius, resolveDefocusDisc } from "../optics/defocus-disc-kernel.js";
76
78
  import { createDiffractedPlanarProvider } from "./diffraction-provider.js";
77
79
  import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
78
80
  import { calculateChannelElectronsPerRadiance } from "./channel-response.js";
@@ -84,6 +86,7 @@ import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
84
86
  import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
85
87
  import { parseSensorReadoutConversionProfile, resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
86
88
  import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
89
+ import { resolveChartMaterialLayers } from "./chart-material-layers.js";
87
90
  import { resolveCaptureIlluminant, calculateIlluminantIrradianceScale, buildPlanarSceneRadianceProvider, requireCallerScene, FACADE_SPECTRAL_RANGE_NANOMETERS, resolveChartSizeOption, PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W, planarProviderMaterialOfDirection } from "./scene-radiance.js";
88
91
  /** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
89
92
  export const SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0";
@@ -94,7 +97,7 @@ export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
94
97
  * a capture does. `motion.rotation` is a non-zero angular velocity; `sensor.temporalSampleCount` a count above 1.
95
98
  */
96
99
  export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
97
- "lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
100
+ "lens.diffraction", "lens.defocus", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
98
101
  "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
99
102
  "output.format.jpeg", "output.format.png", "output.format.dng"]);
100
103
  /**
@@ -119,6 +122,21 @@ const GREY_CARD_REFLECTANCE = 0.18;
119
122
  function sosReferenceLuminance(fNumber, shutterSeconds, iso) {
120
123
  return SOS_REFERENCE_EXPOSURE_NUMERATOR_LUX_SECONDS * fNumber ** 2 / (CAMERA_EQUATION_CONSTANT * shutterSeconds * iso);
121
124
  }
125
+ /**
126
+ * #515: deviation, in stops, of the exposure the settings give from the CIPA DC-004-2020 standard exposure for the metered
127
+ * luminance L: Δ = log2(N²/t) − log2(0.65·L·S/10) = log2(L_ref(N, t, S) / L), with L_ref the luminance that the settings
128
+ * expose at H = 10/S (S = 10/H, §3.2; H = 0.65·L·t/N², §5.5 (4)). Positive is darker than the standard exposure (under).
129
+ * A scene with no metered luminance (L = 0, which manual mode still captures) has no standard exposure: null, never ±Infinity.
130
+ */
131
+ function standardExposureDeviationStops(fNumber, shutterSeconds, iso, meteredLuminance) {
132
+ if (!(meteredLuminance > 0))
133
+ return null;
134
+ const deviation = Math.log2(sosReferenceLuminance(fNumber, shutterSeconds, iso) / meteredLuminance);
135
+ if (!Number.isFinite(deviation))
136
+ throw new InvalidScientificResultError("exposure.standardExposureDeviationStops is not finite (metered scene luminance " +
137
+ meteredLuminance + " cd/m²).");
138
+ return deviation;
139
+ }
122
140
  /**
123
141
  * Default illuminance, lux: an 18 % Lambertian grey card (L = 0.18·E/π) then gets exactly the CIPA DC-004 standard
124
142
  * output sensitivity reference exposure H = 10/S at f/8, 1/125 s, ISO 100:
@@ -390,6 +408,21 @@ export function bilinearPhaseProfiles() {
390
408
  kernels: CHANNELS.map((c) => ({ outputChannelId: c, contributions: kernels[px + "," + py][c] })), evidence: FACADE_EVIDENCE,
391
409
  limitations: ["Bilinear interpolation of the RGGB mosaic; no edge-directed demosaic, false-colour suppression or calibration."] } }));
392
410
  }
411
+ /**
412
+ * #516: the result's Auto ISO report, read from a resolved Auto ISO resolution (every mode's resolved Auto ISO branch carries
413
+ * `idealIsoBeforeConstraints`, `isoResolution` and a resolved `targetResidual`; aperture priority adds its fallback flag).
414
+ */
415
+ function autoIsoReport(resolution, iso, aperturePriority) {
416
+ const r = resolution;
417
+ const ideal = r.idealIsoBeforeConstraints, residual = r.targetResidual?.residualStops, clamped = r.isoResolution?.clamped;
418
+ if (typeof ideal !== "number" || !Number.isFinite(ideal) || typeof residual !== "number" || !Number.isFinite(residual) || r.targetResidual?.status !== "resolved" ||
419
+ (clamped !== false && clamped !== "minimum" && clamped !== "maximum"))
420
+ throw new InvalidScientificResultError("Auto ISO resolution did not report a finite ideal ISO and residual.");
421
+ // The resolvers' Auto ISO range: the body's Auto ISO bounds, else its ISO range.
422
+ return { idealIsoBeforeConstraints: ideal, autoIsoMinimum: iso.autoIsoMinimum ?? iso.minimum, autoIsoMaximum: iso.autoIsoMaximum ?? iso.maximum, clamped,
423
+ limitingConstraint: r.targetResidual.limitingConstraint, residualStops: residual,
424
+ ...(aperturePriority ? { ...aperturePriority, afterMaximumIsoFallbackUsed: r.afterMaximumIsoFallbackUsed === true } : {}) };
425
+ }
393
426
  function defaultProgramLine() {
394
427
  // Generic educational program line written for the engine, relative to the f/8, 1/125 s reference. For less light
395
428
  // it first opens the aperture toward f/4, then lengthens the shutter. For more light it first closes toward
@@ -665,6 +698,53 @@ export function parseDisplayViewOption(value, frameWidth, frameHeight) {
665
698
  }
666
699
  return { width, height, samplesPerAxis, bitDepth: format === "srgb-8" ? 8 : 16, format: format, ...(region ? { region } : {}) };
667
700
  }
701
+ /**
702
+ * `lens.defocus` (#492): resolves the geometric defocus disc at the resolved f-number on the site pitch and records it.
703
+ * In focus (c = 0) no kernel exists and nothing else changes (undefined). The capture (step 2a) applies the disc as an
704
+ * object-space convolution and the display view (step 2b) as a site kernel in its expected-signal tables, and both get it
705
+ * back; the ideal reference, which has no camera effects, only records it.
706
+ */
707
+ function resolveDefocusOption(input) {
708
+ const { run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch } = input;
709
+ const disc = resolveDefocusDisc({ focalLengthMm, aperture: fNumber, focusDistanceM, subjectDistanceM, pitchMicrometers: pitch });
710
+ const size = disc.diameterMicrometers + " µm (" + disc.diameterSites + " sites of " + pitch + " µm)";
711
+ let radius;
712
+ try {
713
+ radius = requireDefocusDiscKernelRadius(disc.diameterSites);
714
+ }
715
+ catch (error) {
716
+ fail("lens.defocus: the blur circle is " + size + ". " + error.message);
717
+ }
718
+ const value = { model: "geometric-disc", scientificStatus: "approximation", diameterMicrometers: disc.diameterMicrometers, diameterSites: disc.diameterSites,
719
+ kernelRadiusSites: disc.diameterSites === 0 ? null : radius, fNumber, focusDistanceM, subjectDistanceM, samplePitchMicrometers: pitch,
720
+ defocusCircleModel: { id: "thin-lens-defocus-circle", version: "1.1.0" } };
721
+ const model = "Geometric defocus of the flat scene plane (an approximation): the thin-lens blur circle of an ideal uniform circular pupil, " +
722
+ "c = f²·|s − d| / (N·d·(s − f)) (calculateDefocusCircle 1.1.0), as a uniform disc sampled on the site lattice (exact disc area in each site's cell). " +
723
+ "No diffraction of the defocused wave, aberrations or pupil vignetting. ";
724
+ if (disc.diameterSites === 0) {
725
+ assume("lens.defocus", "model", value, model + "In focus (lens.focusDistanceM equals subjectDistanceM): the disc has zero diameter, so no defocus kernel is applied.");
726
+ return undefined;
727
+ }
728
+ if (run === "ideal-reference") {
729
+ assume("lens.defocus", "model", value, model + "Resolved but not applied: the ideal reference has no camera effects.");
730
+ return undefined;
731
+ }
732
+ // The display view (#492, step 2b): a site kernel in its expected-signal tables (simulate-display-view.ts).
733
+ if (run === "display-view") {
734
+ assume("lens.defocus", "model", { ...value, execution: "expected-signal-site-kernel", combinedWithDiffraction: input.diffraction }, model +
735
+ "Applied in the display view's expected-signal tables as a site kernel on the lattice" + (input.diffraction ? " (with lens.diffraction, each spectral node's " +
736
+ "diffraction kernel convolved with the disc, a further approximation)" : "") + "; see the view.defocus assumption.");
737
+ return disc;
738
+ }
739
+ // The capture (#492, step 2a): an object-space convolution of the scene radiance on the site lattice.
740
+ assume("lens.defocus", "model", { ...value, execution: "object-space-convolution", combinedWithDiffraction: input.diffraction }, model +
741
+ "Applied as an object-space convolution: the scene radiance on the site lattice is convolved with the disc, the same kernel at every spectral node, " +
742
+ "and each site query is answered with the result (for a flat scene and a still, distortion-free rectilinear camera every disc point a site sees is the scene point of another site). " +
743
+ (input.diffraction ? "With lens.diffraction, each spectral node's diffraction kernel is convolved with the disc and applied as one kernel. That is a further approximation: " +
744
+ "the defocused wave PSF of a circular pupil is not exactly the in-focus Airy pattern convolved with the geometric disc. " : "") +
745
+ "The disc kernel is a sampling of the disc on the site lattice; lens.pupilSampling (ray-sampled, no diffraction) is the independent reference.");
746
+ return disc;
747
+ }
668
748
  /** Internal: passed as the view of a "display-view" run to resolve the request without a view (`prepareDisplayView`, #387). */
669
749
  export const DISPLAY_VIEW_WITHOUT_VIEW = Symbol("display view without view");
670
750
  /**
@@ -701,7 +781,7 @@ export async function runCapture(options, run, viewOption, renderView) {
701
781
  preset = parseCallerCameraPreset(cameraOption);
702
782
  const body = preset.body, lens = preset.lens;
703
783
  // Lens
704
- const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime"]);
784
+ const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime", "defocus"]);
705
785
  // Generic prime (2.3): resolved on the simulated frame's size, which the output and sensor sections validate again below.
706
786
  let prime;
707
787
  if (lensOptions.prime !== undefined) {
@@ -734,7 +814,9 @@ export async function runCapture(options, run, viewOption, renderView) {
734
814
  fail("The " + prime.lensId + " focuses no closer than " + prime.minimumFocusDistanceM + " m (max(0.10 m, f·(1 + 1/0.15))); subjectDistanceM and lens.focusDistanceM must be at least that.");
735
815
  let pupil;
736
816
  if (lensOptions.pupilSampling === undefined)
737
- assume("lens.pupilSampling", "default", null, "No pupil sampling: each scene query follows the central (chief) ray, so defocus and depth of field are not rendered. Enable lens.pupilSampling for an ideal circular pupil.");
817
+ assume("lens.pupilSampling", "default", null, lensOptions.defocus === undefined ?
818
+ "No pupil sampling: each scene query follows the central (chief) ray, so defocus and depth of field are not rendered. Enable lens.pupilSampling for an ideal circular pupil." :
819
+ "No pupil sampling: each scene query follows the central (chief) ray; defocus of the scene plane is the lens.defocus option's (see the lens.defocus assumption).");
738
820
  else {
739
821
  const p = allowKeys(lensOptions.pupilSampling, "lens.pupilSampling", ["radialSamples", "angularSamples"]);
740
822
  // The environment pupil contract admits 4..64 azimuthal midpoints and at most 256 rays.
@@ -800,6 +882,18 @@ export async function runCapture(options, run, viewOption, renderView) {
800
882
  if (pupil)
801
883
  fail("lens.diffraction cannot be combined with lens.pupilSampling: the environment contract does not mix a sampled PSF with pupil rays.");
802
884
  }
885
+ // Defocus (#492): validated here, resolved once the f-number is.
886
+ let defocusModel;
887
+ if (lensOptions.defocus !== undefined) {
888
+ const record = allowKeys(lensOptions.defocus, "lens.defocus", ["model"]);
889
+ if (record.model !== "geometric-disc")
890
+ fail("lens.defocus.model must be \"geometric-disc\".");
891
+ if (pupil)
892
+ fail("lens.defocus cannot be combined with lens.pupilSampling: the pupil rays already render defocus, so it would be counted twice.");
893
+ if (lensPsf)
894
+ fail("lens.defocus cannot be combined with lens.psf: a caller PSF declares its own defocus, so it could be counted twice.");
895
+ defocusModel = "geometric-disc";
896
+ }
803
897
  // Motion (2.0): constant camera rotation and the shutter type.
804
898
  const motionOptions = allowKeys(top.motion, "motion", ["angularVelocityRadPerSec", "shutter", "rollingShutterSkewSeconds"]);
805
899
  const angular = { pitch: 0, yaw: 0, roll: 0 };
@@ -915,7 +1009,7 @@ export async function runCapture(options, run, viewOption, renderView) {
915
1009
  if (displayView) {
916
1010
  const unsupported = (option, reason) => fail("simulateDisplayView does not support " + option + ": " + reason);
917
1011
  if (pupil)
918
- unsupported("lens.pupilSampling", "defocus moves each query off the site lattice the expected-signal tables use.");
1012
+ unsupported("lens.pupilSampling", "pupil rays move each query off the site lattice the expected-signal tables use; lens.defocus renders the defocus of the scene plane on the lattice.");
919
1013
  if (lensFieldMapping)
920
1014
  unsupported("lens.projection or lens.distortion", "the expected signal assumes the ideal rectilinear, distortion-free mapping of the site lattice.");
921
1015
  if (vignetting)
@@ -1066,10 +1160,14 @@ export async function runCapture(options, run, viewOption, renderView) {
1066
1160
  const illuminant = resolveCaptureIlluminant(illuminantOption);
1067
1161
  const illuminanceLux = positive(pick(top, "illuminanceLux", "illuminanceLux", SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX, "π·10·N²/(0.65·t·S·0.18) lux at f/8, 1/125 s, ISO 100 (CIPA DC-004 standard output sensitivity H = 10/S and camera equation H = 0.65·L·t/N²): an 18 % grey card then gets the reference exposure for the default settings. It does not follow the caller's exposure settings."), "illuminanceLux");
1068
1162
  const irradianceScale = calculateIlluminantIrradianceScale(illuminant.shape, illuminanceLux);
1069
- let chart, callerScene;
1163
+ let chart, callerScene, materialLayers;
1070
1164
  const sceneRecord = sceneOption;
1071
1165
  if ("kind" in sceneRecord) {
1072
- callerScene = requireCallerScene(sceneOption);
1166
+ // #491: a declared material-layers scene is its callerScene (the same validation, materials, digest and bytes as passing that scene);
1167
+ // the resolved layers are kept for the display view's GPU renderer.
1168
+ if (sceneRecord.kind === "material-layers")
1169
+ materialLayers = resolveChartMaterialLayers({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
1170
+ callerScene = requireCallerScene(materialLayers ? materialLayers.callerScene : sceneOption);
1073
1171
  }
1074
1172
  else {
1075
1173
  chart = resolveChartScene({ scene: sceneOption, aspect: chartSize ? chartSize.heightM / chartSize.widthM : aspect });
@@ -1119,11 +1217,18 @@ export async function runCapture(options, run, viewOption, renderView) {
1119
1217
  registerInternalRadianceAccess(provider.evaluateRadiance, { keyOfDirection: materialOfDirection, radianceOfKey: provider.materialRadiance, tabulated: true });
1120
1218
  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.");
1121
1219
  // Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
1122
- const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops"]);
1220
+ const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops", "autoIso"]);
1123
1221
  const mode = pick(exposureOptions, "mode", "exposure.mode", "manual", "Manual exposure.");
1124
1222
  if (!["manual", "aperture-priority", "shutter-priority", "program"].includes(mode))
1125
1223
  fail("Unknown exposure mode.");
1126
- const iso = positive(pick(exposureOptions, "iso", "exposure.iso", DEFAULTS.iso, "ISO 100, the tiers' base ISO."), "exposure.iso");
1224
+ const isoOption = pick(exposureOptions, "iso", "exposure.iso", DEFAULTS.iso, "ISO 100, the tiers' base ISO.");
1225
+ // #516: "auto" resolves the ISO with the mode's Auto ISO resolver; NaN marks the ISO as not yet known.
1226
+ const automaticIso = isoOption === "auto";
1227
+ if (typeof isoOption === "string" && !automaticIso)
1228
+ fail('exposure.iso must be a positive finite number or "auto".');
1229
+ const iso = automaticIso ? NaN : positive(isoOption, "exposure.iso");
1230
+ if (exposureOptions.autoIso !== undefined && !(automaticIso && mode === "aperture-priority"))
1231
+ fail('exposure.autoIso applies only to exposure.mode "aperture-priority" with exposure.iso "auto"; omit it.');
1127
1232
  const compensation = exposureOptions.compensationStops === undefined ? DEFAULTS.compensationStops : exposureOptions.compensationStops;
1128
1233
  if (typeof compensation !== "number" || !Number.isFinite(compensation) || Math.abs(compensation) > 10)
1129
1234
  fail("exposure.compensationStops must be within ±10.");
@@ -1171,9 +1276,34 @@ export async function runCapture(options, run, viewOption, renderView) {
1171
1276
  fail("exposure.shutterSeconds is resolved automatically in " + mode + " mode; omit it.");
1172
1277
  shutterSeconds = NaN;
1173
1278
  }
1279
+ // #516: the resolvers' Auto ISO policies; their baseline is the lowest selectable ISO in the body's Auto ISO range.
1280
+ const autoIsoBaseline = { isoBaseline: "minimum-selectable", isoQuantizationPolicy: "nearest-log2-lower-on-tie" };
1281
+ let autoIsoAperturePriority;
1282
+ if (automaticIso) {
1283
+ if (capabilities.iso.autoIsoAvailability !== "supported")
1284
+ fail('exposure.iso "auto" needs a body with Auto ISO; this camera\'s Auto ISO is ' + capabilities.iso.autoIsoAvailability + ".");
1285
+ assume("exposure.autoIso", "model", { minimum: capabilities.iso.autoIsoMinimum ?? capabilities.iso.minimum, maximum: capabilities.iso.autoIsoMaximum ?? capabilities.iso.maximum, ...autoIsoBaseline }, "Auto ISO: the engine's exposure-mode resolver chooses the ISO from the meter target, starting from the lowest selectable ISO in the body's Auto ISO range and " +
1286
+ "snapping to the nearest selectable ISO in log2 (the lower on a tie), held at the range's ends. A generic policy, not a manufacturer's Auto ISO.");
1287
+ }
1288
+ if (automaticIso && mode === "aperture-priority") {
1289
+ const autoIsoOptions = allowKeys(exposureOptions.autoIso, "exposure.autoIso", ["slowestPreferredShutterSeconds", "afterMaximumIso"]);
1290
+ if (autoIsoOptions.slowestPreferredShutterSeconds === undefined)
1291
+ fail('exposure.autoIso.slowestPreferredShutterSeconds is required with exposure.mode "aperture-priority" and exposure.iso "auto": the slowest shutter, in seconds, before Auto ISO raises the ISO.');
1292
+ const slowestPreferredShutterSeconds = positive(autoIsoOptions.slowestPreferredShutterSeconds, "exposure.autoIso.slowestPreferredShutterSeconds");
1293
+ if (slowestPreferredShutterSeconds < capabilities.shutter.minimumSeconds || slowestPreferredShutterSeconds > capabilities.shutter.maximumSeconds)
1294
+ fail("exposure.autoIso.slowestPreferredShutterSeconds must be within the body's shutter range, " + capabilities.shutter.minimumSeconds + " s to " + capabilities.shutter.maximumSeconds + " s.");
1295
+ const afterMaximumIso = pick(autoIsoOptions, "afterMaximumIso", "exposure.autoIso.afterMaximumIso", "allow-slower-shutter", "At the Auto ISO maximum, the shutter may get slower than the slowest preferred shutter to reach the meter target.");
1296
+ if (afterMaximumIso !== "allow-slower-shutter" && afterMaximumIso !== "hold-preferred-shutter")
1297
+ fail('exposure.autoIso.afterMaximumIso must be "allow-slower-shutter" or "hold-preferred-shutter".');
1298
+ autoIsoAperturePriority = { slowestPreferredShutterSeconds, afterMaximumIso };
1299
+ }
1174
1300
  if (mode === "manual") {
1175
1301
  resolution = resolveManualExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualShutterSeconds: shutterSeconds,
1176
- isoControl: { kind: "manual", iso } });
1302
+ isoControl: automaticIso ? { kind: "automatic", quantizationPolicy: autoIsoBaseline.isoQuantizationPolicy } : { kind: "manual", iso } });
1303
+ }
1304
+ else if (autoIsoAperturePriority) {
1305
+ resolution = resolveAperturePriorityAutoIsoExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber,
1306
+ policy: { kind: "minimum-iso-until-slowest-preferred-shutter", ...autoIsoBaseline, shutterSelectionPolicy: "not-longer-than-target", ...autoIsoAperturePriority } });
1177
1307
  }
1178
1308
  else if (mode === "aperture-priority") {
1179
1309
  resolution = resolveAperturePriorityExposureMode({ target, capabilities, referenceExposure: reference, manualAperture: fNumber, manualIso: iso,
@@ -1181,17 +1311,36 @@ export async function runCapture(options, run, viewOption, renderView) {
1181
1311
  }
1182
1312
  else if (mode === "shutter-priority") {
1183
1313
  resolution = resolveShutterPriorityExposureMode({ target, capabilities, referenceExposure: reference, manualShutterSeconds: shutterSeconds,
1184
- isoControl: { kind: "manual", iso, apertureQuantizationPolicy: "nearest-log2-narrower-on-tie" } });
1314
+ isoControl: automaticIso ? { kind: "automatic", policy: { kind: "minimum-iso-aperture-first", apertureSelectionPolicy: "not-wider-than-target", ...autoIsoBaseline } } :
1315
+ { kind: "manual", iso, apertureQuantizationPolicy: "nearest-log2-narrower-on-tie" } });
1185
1316
  }
1186
1317
  else {
1187
1318
  assume("exposure.programLine", "model", "facade-generic-program-line", "Engine-authored generic program line (f/16 1/2000 s … f/8 1/125 s … f/4 1.024 s); not a manufacturer program.");
1188
- resolution = resolveProgramAutoExposureMode({ target, capabilities, referenceExposure: reference, programLine: defaultProgramLine(), isoControl: { kind: "manual", iso } });
1319
+ resolution = resolveProgramAutoExposureMode({ target, capabilities, referenceExposure: reference, programLine: defaultProgramLine(),
1320
+ isoControl: automaticIso ? { kind: "automatic", ...autoIsoBaseline } : { kind: "manual", iso } });
1189
1321
  }
1190
1322
  if (resolution.status !== "resolved" || !resolution.resolvedSettings)
1191
1323
  fail("Exposure could not be resolved (" + resolution.status + "); the scene may have no signal.");
1192
1324
  fNumber = resolution.resolvedSettings.aperture ?? fNumber;
1193
1325
  shutterSeconds = resolution.resolvedSettings.shutterSeconds ?? shutterSeconds;
1194
1326
  const resolvedIso = resolution.resolvedSettings.iso ?? iso;
1327
+ if (!Number.isFinite(resolvedIso) || resolvedIso <= 0)
1328
+ fail("Exposure could not be resolved: the resolved ISO is not a positive finite number.");
1329
+ const isoReport = automaticIso ? { isoControl: "automatic", autoIso: autoIsoReport(resolution, capabilities.iso, autoIsoAperturePriority) } : { isoControl: "manual" };
1330
+ const exposureDeviationStops = standardExposureDeviationStops(fNumber, shutterSeconds, resolvedIso, meteredLuminance);
1331
+ const defocusDisc = defocusModel === undefined ? undefined :
1332
+ resolveDefocusOption({ run, assume, focalLengthMm, fNumber, focusDistanceM, subjectDistanceM, pitch, diffraction: diffractionRadiusOption !== undefined });
1333
+ // #492: the capture applies the disc as an object-space convolution, which needs every disc point a site sees to be another site's scene point.
1334
+ if (defocusDisc) {
1335
+ const reason = rotating ? "camera rotation (motion.angularVelocityRadPerSec)" : lensFieldMapping ? "lens.projection or lens.distortion" : vignetting ? "lens.vignetting" : undefined;
1336
+ if (reason !== undefined)
1337
+ fail("simulateCapture does not support lens.defocus out of focus with " + reason + ": the defocus disc is applied as an object-space convolution " +
1338
+ "on the site lattice, and " + reason + " moves the scene points the sites see off that lattice. Set lens.focusDistanceM equal to subjectDistanceM, or omit one of the two options.");
1339
+ }
1340
+ // #492: the disc on the site lattice (radius 0 for a disc inside one cell: the identity, still applied).
1341
+ const defocusKernel = defocusDisc && !displayView ? createDefocusDiscSiteKernel(defocusDisc.diameterSites) ?? undefined : undefined;
1342
+ // The display view's disc (#492, step 2b): its expected-signal tables apply it (simulate-display-view.ts).
1343
+ const displayViewDefocus = displayView ? defocusDisc : undefined;
1195
1344
  // Sensor readout for the ISO setting. Standard output sensitivity S = 10/H (CIPA DC-004 §3.2) is defined at the
1196
1345
  // output, independent of pixel size, while the collected charge is proportional to the sensitive-aperture area
1197
1346
  // (EMVA 1288 Release 4.0, Eq. 3). The tier's signal chain is anchored to the 6 µm aperture; at another area the
@@ -1407,7 +1556,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1407
1556
  uncertainty: { kind: "not-quantified", limitation: "Analytic planar scene." },
1408
1557
  fidelity: { spectral: "wavelength-resolved", material: "spectral-data", visibility: "resolved", directTransport: "approximation", indirectTransport: "not-modeled" },
1409
1558
  wavelengthChangingTransportModeled: false, volumetricTransportModeled: false, polarizationModeled: false, evidence, limitations: ["Engine-authored planar chart or caller scene function.",
1410
- ...(diffractionObjectSpace ? ["Radiance is the scene radiance convolved with the lens diffraction PSF mapped onto the scene plane (object-space convolution, request.diffraction)."] : [])] }
1559
+ ...(diffractionObjectSpace ? ["Radiance is the scene radiance convolved with the lens diffraction PSF mapped onto the scene plane (object-space convolution, request.diffraction)."] : []),
1560
+ ...(defocusKernel ? ["Radiance is the scene radiance convolved with the geometric defocus disc mapped onto the scene plane (object-space convolution, lens.defocus)."] : [])] }
1411
1561
  };
1412
1562
  const lensId = lensExposure.profileId;
1413
1563
  const opticsProfile = { schemaVersion: "0.1.0", profileId: lensId + ":facade-transmission", profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, lensProfileId: lensId,
@@ -1430,9 +1580,11 @@ export async function runCapture(options, run, viewOption, renderView) {
1430
1580
  maximumSubintervalWidthNanometers: spectralStep }]));
1431
1581
  // Diffraction (2.1): Airy kernels at the resolved f-number on the site pitch, one per spectral node of the sensor quadrature.
1432
1582
  let diffraction, diffractionPsf;
1583
+ // The spectral nodes of the sensor quadrature, over all channels, ascending.
1584
+ const quadratureWavelengths = () => [...new Set(CHANNELS.flatMap((channelId) => calculateSensorSpectralQuadrature({ ...spectralSamplingByChannel[channelId], colorSamplingProfile,
1585
+ spectralResponseProfile: spectralResponseProfiles[channelId] }).value.nodes.map((node) => node.wavelengthNanometers)))].sort((a, b) => a - b);
1433
1586
  if (diffractionRadiusOption !== undefined) {
1434
- const wavelengths = [...new Set(CHANNELS.flatMap((channelId) => calculateSensorSpectralQuadrature({ ...spectralSamplingByChannel[channelId], colorSamplingProfile,
1435
- spectralResponseProfile: spectralResponseProfiles[channelId] }).value.nodes.map((node) => node.wavelengthNanometers)))].sort((a, b) => a - b);
1587
+ const wavelengths = quadratureWavelengths();
1436
1588
  const lambdaN = wavelengths[wavelengths.length - 1] / 1000 * fNumber * imageDistanceMm / focalLengthMm;
1437
1589
  // The environment resolves the PSF at each pre-filter source point, up to the largest split offset outside the imaging area.
1438
1590
  const aaReachMm = antiAliasing ? Math.max(...antiAliasing.components.map((c) => Math.max(Math.abs(c.offsetMicrometers.x), Math.abs(c.offsetMicrometers.y)))) / 1000 : 0;
@@ -1463,9 +1615,36 @@ export async function runCapture(options, run, viewOption, renderView) {
1463
1615
  "Applied through the environment PSF quadrature (one scene query per kernel tap), because camera rotation, a lens field mapping or vignetting moves the taps off the site lattice. ") +
1464
1616
  "No defocus, aberration or pupil clipping; " + (boxAperture ? "the pixel aperture is integrated as declared by sensor.pixelAperture." : "the sensor pixel aperture is still point-sampled at the site centre."));
1465
1617
  }
1618
+ // #492: each spectral node's kernel for lens.defocus out of focus: the disc, or with lens.diffraction (always on the object-space path here)
1619
+ // the node's diffraction kernel convolved with the disc, radius R_diffraction + R_defocus (built on first use, kept for the request).
1620
+ let defocusSiteKernels;
1621
+ if (defocusKernel) {
1622
+ if (diffraction) {
1623
+ const diffractionRadius = diffraction.kernelRadiusSamples, combined = new Map(), profileWavelengths = diffraction.profile.axes.wavelengthNm;
1624
+ const radius = diffractionRadius + defocusKernel.radius;
1625
+ // At most 32 + 64 = 96 sites (the two caps). Neither the expected-signal table nor the provider's own gather (diffraction-provider.ts)
1626
+ // uses the public 32-site limit of calculateIrradianceConvolution; the combination costs at most about 0.1 s per spectral node
1627
+ // (measured, docs/QUICK_START.md "Optics"), so no tighter limit applies.
1628
+ const psfConfiguration = diffractionPsf.configuration.psf;
1629
+ defocusSiteKernels = { radius, wavelengthsNm: profileWavelengths, at(wavelengthNm) {
1630
+ let kernel = combined.get(wavelengthNm);
1631
+ if (!kernel) {
1632
+ if (!profileWavelengths.includes(wavelengthNm))
1633
+ fail("simulateCapture: a spectral node has no diffraction kernel.");
1634
+ kernel = convolveSiteKernels(siteDiffractionKernel(psfConfiguration, wavelengthNm, pitch, diffractionRadius, "simulateCapture: diffraction kernels must sample the site pitch exactly."), diffractionRadius, defocusKernel.weights, defocusKernel.radius);
1635
+ combined.set(wavelengthNm, kernel);
1636
+ }
1637
+ return kernel;
1638
+ } };
1639
+ }
1640
+ else
1641
+ defocusSiteKernels = { radius: defocusKernel.radius, wavelengthsNm: quadratureWavelengths(), at: () => defocusKernel.weights };
1642
+ }
1466
1643
  const psf = lensPsf ?? (diffractionObjectSpace ? { kind: "not-applied", evidence,
1467
1644
  limitation: "No environment PSF: lens diffraction is applied upstream by the scene provider as an object-space convolution (request.diffraction)." } : undefined) ??
1468
- diffractionPsf ?? { kind: "not-applied", evidence, limitation: "No lens PSF (point optics)." };
1645
+ diffractionPsf ?? (defocusSiteKernels ? { kind: "not-applied", evidence,
1646
+ limitation: "No environment PSF: lens defocus is applied upstream by the scene provider as an object-space convolution (lens.defocus)." } : undefined) ??
1647
+ { kind: "not-applied", evidence, limitation: "No lens PSF (point optics)." };
1469
1648
  const pupilRecord = pupil ? { kind: "ideal-uniform-circular-pupil", radialSampleCount: pupil.radialSamples, angularSampleCount: pupil.angularSamples, evidence,
1470
1649
  limitation: "Ideal uniform circular pupil; finite ray sampling, no convergence claim." } : undefined;
1471
1650
  const chargeSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.chargeSampling, completenessProfileId: IDS.completeness, scientificStatus: "approximation",
@@ -1502,14 +1681,19 @@ export async function runCapture(options, run, viewOption, renderView) {
1502
1681
  const readTile = async (request) => ({ ...request, sites: Array.from({ length: request.width }, (_, j) => siteInput(request.y * nativeWidth + request.x + j)) });
1503
1682
  const yieldControl = createHostYield().yieldControl;
1504
1683
  const perSite = queriesPerSite * (diffraction && psf === diffractionPsf ? (2 * diffraction.kernelRadiusSamples + 1) ** 2 : 1);
1505
- let diffractionMs = 0, demosaicMs, encodeMs;
1506
- const diffractedProvider = (window) => diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1507
- wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1508
- sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1509
- // Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
1684
+ let diffractionMs = 0, defocusMs = 0, demosaicMs, encodeMs;
1685
+ const diffractedProvider = (window) => defocusSiteKernels ? createDiffractedPlanarProvider({ base: provider, siteKernels: defocusSiteKernels.at,
1686
+ wavelengthsNm: defocusSiteKernels.wavelengthsNm, kernelRadius: defocusSiteKernels.radius, nativeWidth, nativeHeight, outputWidth: width,
1687
+ sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { defocusMs += ms; },
1510
1688
  ...(window ? { window } : {}),
1511
- // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
1512
- ...(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;
1689
+ ...(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 }))) } : {}) }) :
1690
+ diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1691
+ wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1692
+ sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1693
+ // Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
1694
+ ...(window ? { window } : {}),
1695
+ // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
1696
+ ...(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;
1513
1697
  // Built on first use: a capture that takes the tabulated path (#394 step 2) never queries it, and its kernels cost a setup.
1514
1698
  let rawProviderBuilt;
1515
1699
  const rawProvider = () => (rawProviderBuilt ??= diffractedProvider(regionNative));
@@ -1584,7 +1768,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1584
1768
  // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1585
1769
  // that only captures does not contain the display view.
1586
1770
  if (displayView)
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 });
1771
+ return await renderView({ ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
1588
1772
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1589
1773
  if (idealReference) {
1590
1774
  const referenceStarted = clockMs();
@@ -1657,7 +1841,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1657
1841
  "Each pixel samples the scene at its centre, like the capture's default point aperture; use sensor.pixelAperture \"box\" for photosite-averaged pixels.");
1658
1842
  return Object.freeze({ kind: "ideal-reference", label: "Ideal reference (no camera effects); not a capture.", facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
1659
1843
  image: { width: outputWidth, height: outputHeight, format, samples, ...(png ? { png, colorEncoding: colorEncoding } : {}) },
1660
- exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds) },
1844
+ exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport },
1661
1845
  scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
1662
1846
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
1663
1847
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
@@ -1709,12 +1893,20 @@ export async function runCapture(options, run, viewOption, renderView) {
1709
1893
  const { electronsPerRadiance, wavelengths } = axisChannelResponse("simulateCapture");
1710
1894
  const perNode = CHANNELS.map((c) => Float64Array.from(wavelengths, (nm) => electronsPerRadiance[c].get(nm) ?? 0));
1711
1895
  const sceneMaterials = chart ? chart.materials : callerScene.materials, materialRadiance = provider.materialRadiance;
1712
- const { signalInput } = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(boxSamples), boxSamples, antiAliasing, pitch,
1713
- diffraction: diffraction ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
1896
+ const { signalInput: builtSignalInput } = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(boxSamples), boxSamples, antiAliasing, pitch,
1897
+ diffraction: diffraction && !defocusSiteKernels ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
1714
1898
  materials: { materialAt: provider.chartMaterialAt, whiteRadiance: (nm) => irradianceScale * illuminant.shape.evaluate(nm) / Math.PI,
1715
1899
  list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
1716
1900
  ...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] },
1717
1901
  radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" });
1902
+ // #492: with lens.defocus the table convolves the lattice radiance with each node's defocus (or combined) kernel, on the
1903
+ // lattice formula, exactly as the object-space provider does.
1904
+ let signalInput = builtSignalInput;
1905
+ if (defocusSiteKernels) {
1906
+ const lattice = { ...builtSignalInput };
1907
+ delete lattice.queryPoint;
1908
+ signalInput = { ...lattice, kernels: { radius: defocusSiteKernels.radius, perNode: wavelengths.map((nm) => defocusSiteKernels.at(nm)) } };
1909
+ }
1718
1910
  // The verification reads only the region's top-left 2 × 2 block, so its diffraction convolves only that block's lattice; the
1719
1911
  // window never changes a value (see diffraction-provider.ts), so the chain it runs is the whole render's.
1720
1912
  const verificationProvider = diffractedProvider({ x: regionNative?.x ?? 0, y: regionNative?.y ?? 0, width: 2, height: 2 });
@@ -1926,7 +2118,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1926
2118
  }
1927
2119
  const ranOptions = new Set([...(pupil ? ["lens.pupilSampling"] : []), ...(projection !== undefined ? ["lens.projection"] : []),
1928
2120
  ...(distortion ? ["lens.distortion"] : []), ...(vignetting ? ["lens.vignetting"] : []), ...(lensPsf ? ["lens.psf"] : []),
1929
- ...(diffraction ? ["lens.diffraction"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []), ...(boxAperture ? ["sensor.pixelAperture.box"] : []),
2121
+ ...(diffraction ? ["lens.diffraction"] : []), ...(defocusSiteKernels ? ["lens.defocus"] : []), ...(antiAliasing ? ["sensor.antiAliasing"] : []), ...(boxAperture ? ["sensor.pixelAperture.box"] : []),
1930
2122
  ...(temporalSampleCount > 1 ? ["sensor.temporalSampleCount"] : []), ...(rotating ? ["motion.rotation"] : []),
1931
2123
  ...(rollingSchedule ? ["motion.shutter.rolling"] : []), ...(mirrorBorder ? ["output.borderHandling.mirror"] : []), ...(region ? ["output.region"] : []),
1932
2124
  ...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
@@ -1939,14 +2131,14 @@ export async function runCapture(options, run, viewOption, renderView) {
1939
2131
  ...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
1940
2132
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
1941
2133
  ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" : "environment-psf-quadrature" } } : {}) },
1942
- exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds),
1943
- meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, resolution },
2134
+ exposure: { mode, settings: { fNumber, shutterSeconds, iso: resolvedIso }, ev100: Math.log2(fNumber ** 2 / shutterSeconds), ...isoReport,
2135
+ meteredSceneLuminanceCdPerSquareMeter: meteredLuminance, standardExposureDeviationStops: exposureDeviationStops, resolution },
1944
2136
  scene: { sceneId: chart ? "chart:" + chart.preset : callerScene.sceneId, preset: scenePreset, parameters: chart ? chart.parameters : {},
1945
2137
  illuminant: { id: illuminant.id, description: illuminant.description, illuminanceLux }, distanceM: subjectDistanceM,
1946
2138
  framedWidthM: 2 * metresPerFrameUnit, framedHeightM: 2 * metresPerFrameUnit * aspect, ...(chartReport ? { chart: chartReport } : {}), sourceSha256 },
1947
2139
  diagnostics: { providerEvaluationCount, highlight: developed.highlight, rendering: developed.diagnostics, ...(gamutMappingDiagnostics ? { gamutMapping: gamutMappingDiagnostics } : {}),
1948
2140
  timingsMs: { raw: rawMs, whiteBalance: wbMs, development: devMs, jpeg: jpegMs, total: Date.now() - started,
1949
- ...(diffraction && diffractionObjectSpace ? { diffraction: diffractionMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
2141
+ ...(diffraction && diffractionObjectSpace && !defocusSiteKernels ? { diffraction: diffractionMs } : {}), ...(defocusSiteKernels ? { defocus: defocusMs } : {}), ...(demosaicMs === undefined ? {} : { demosaic: demosaicMs }),
1950
2142
  ...(encodeMs === undefined ? {} : { encode: encodeMs }) },
1951
2143
  options: Object.freeze(SIMULATE_CAPTURE_COST_OPTIONS.filter((option) => ranOptions.has(option))), fast: fastReport },
1952
2144
  provenance: { scientificStatus: "approximation", cameraPresetId: preset.presetId, cameraPresetVersion: preset.presetVersion,
@@ -1954,7 +2146,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1954
2146
  composedContracts: ["resolveGenericEquipmentExposureCapabilities", "meterRelativeExposure", "createExposureMeterTargetFromMeteringResult",
1955
2147
  "resolve" + (mode === "manual" ? "Manual" : mode === "aperture-priority" ? "AperturePriority" : mode === "shutter-priority" ? "ShutterPriority" : "ProgramAuto") + "ExposureMode",
1956
2148
  ...(rollingSchedule ? ["resolveElectronicRollingShutterExposureSchedule", "createRollingShutterCaptureExposureSchedule"] : []),
1957
- ...(diffraction ? ["createDiffractionLimitedPsfProfile", ...(diffractionObjectSpace ? ["resolveLensPsfConvolutionKernels", "calculateIrradianceConvolution"] : [])] : []),
2149
+ ...(diffraction ? ["createDiffractionLimitedPsfProfile", ...(diffractionObjectSpace ? ["resolveLensPsfConvolutionKernels", ...(defocusSiteKernels ? [] : ["calculateIrradianceConvolution"])] : [])] : []),
2150
+ ...(defocusSiteKernels ? ["calculateDefocusCircle", "defocus disc site kernel (object-space convolution)"] : []),
1958
2151
  ...(antiAliasing ? ["resolveAntiAliasingSpatialKernel"] : []), "resolveGenericIsoSignalChain", "createNativeEnvironmentRawTask", ...(needsAuto ? ["estimateAutoWhiteBalance"] : []),
1959
2152
  ...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []), ...(ahd ? ["createAhdDemosaicTask"] : []),
1960
2153
  ...(ahd ? ["native RAW development operator (colour, after AHD)", ...(linearPath ? ["resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : ["SDR rendering kernel"])] : linearPath ? ["native RAW development operator (linear, pre-clip)", "resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : ["createNativeRawDevelopmentTask"]),