@photivra/engine 2.17.0 → 2.19.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 (288) hide show
  1. package/CHANGELOG.md +116 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/camera/sensor-equivalence.d.ts +156 -0
  6. package/dist/camera/sensor-equivalence.js +208 -0
  7. package/dist/camera/sensor-equivalence.js.map +1 -0
  8. package/dist/capture/focus-control.js +2 -75
  9. package/dist/capture/focus-control.js.map +1 -1
  10. package/dist/capture/native-environment-lean.d.ts +1 -0
  11. package/dist/capture/native-environment-lean.js +1 -1
  12. package/dist/capture/native-environment-lean.js.map +1 -1
  13. package/dist/capture/native-environment-optical-stage.d.ts +1 -0
  14. package/dist/capture/native-environment-optical-stage.js +2 -5
  15. package/dist/capture/native-environment-optical-stage.js.map +1 -1
  16. package/dist/capture/native-environment-raw.js +1 -5
  17. package/dist/capture/native-environment-raw.js.map +1 -1
  18. package/dist/capture/native-environment-tabulated.d.ts +5 -0
  19. package/dist/capture/native-environment-tabulated.js +1 -1
  20. package/dist/capture/native-environment-tabulated.js.map +1 -1
  21. package/dist/capture/release-sequence.js +2 -47
  22. package/dist/capture/release-sequence.js.map +1 -1
  23. package/dist/color/white-balance.d.ts +2 -0
  24. package/dist/color/white-balance.js +3 -56
  25. package/dist/color/white-balance.js.map +1 -1
  26. package/dist/composition/image-formation-plan.d.ts +1 -0
  27. package/dist/composition/image-formation-plan.js +3 -49
  28. package/dist/composition/image-formation-plan.js.map +1 -1
  29. package/dist/core/evidence-provenance.js +1 -16
  30. package/dist/core/evidence-provenance.js.map +1 -1
  31. package/dist/core/record-validation.d.ts +54 -0
  32. package/dist/core/record-validation.js +176 -0
  33. package/dist/core/record-validation.js.map +1 -1
  34. package/dist/core/scientific-assurance.js +1 -18
  35. package/dist/core/scientific-assurance.js.map +1 -1
  36. package/dist/core/shared-helpers.d.ts +33 -0
  37. package/dist/core/shared-helpers.js +34 -0
  38. package/dist/core/shared-helpers.js.map +1 -0
  39. package/dist/core/spectral-composition.js +1 -36
  40. package/dist/core/spectral-composition.js.map +1 -1
  41. package/dist/core/spectral.js +1 -19
  42. package/dist/core/spectral.js.map +1 -1
  43. package/dist/core/version.d.ts +1 -1
  44. package/dist/core/version.js +1 -1
  45. package/dist/core/version.js.map +1 -1
  46. package/dist/equipment/exposure-capabilities.d.ts +1 -0
  47. package/dist/equipment/exposure-capabilities.js +2 -36
  48. package/dist/equipment/exposure-capabilities.js.map +1 -1
  49. package/dist/equipment/ideal-lens.d.ts +25 -0
  50. package/dist/equipment/ideal-lens.js +21 -0
  51. package/dist/equipment/ideal-lens.js.map +1 -0
  52. package/dist/equipment/iso-capabilities.js +2 -49
  53. package/dist/equipment/iso-capabilities.js.map +1 -1
  54. package/dist/equipment/metering-capabilities.js +1 -26
  55. package/dist/equipment/metering-capabilities.js.map +1 -1
  56. package/dist/equipment/release-capabilities.js +1 -53
  57. package/dist/equipment/release-capabilities.js.map +1 -1
  58. package/dist/exposure/auto-iso-slowest-shutter.js +1 -7
  59. package/dist/exposure/auto-iso-slowest-shutter.js.map +1 -1
  60. package/dist/exposure/exposure-duration-control.js +1 -16
  61. package/dist/exposure/exposure-duration-control.js.map +1 -1
  62. package/dist/exposure/exposure-mode-resolver.js +1 -17
  63. package/dist/exposure/exposure-mode-resolver.js.map +1 -1
  64. package/dist/exposure/filtered-exposure-time.d.ts +93 -0
  65. package/dist/exposure/filtered-exposure-time.js +129 -0
  66. package/dist/exposure/filtered-exposure-time.js.map +1 -0
  67. package/dist/exposure/flash-ttl.d.ts +1 -0
  68. package/dist/exposure/flash-ttl.js +1 -1
  69. package/dist/exposure/flash-ttl.js.map +1 -1
  70. package/dist/exposure/flash.js +1 -49
  71. package/dist/exposure/flash.js.map +1 -1
  72. package/dist/exposure/metering-scene-radiance.js +1 -25
  73. package/dist/exposure/metering-scene-radiance.js.map +1 -1
  74. package/dist/exposure/metering-target.js +1 -30
  75. package/dist/exposure/metering-target.js.map +1 -1
  76. package/dist/exposure/metering-temporal.js +1 -8
  77. package/dist/exposure/metering-temporal.js.map +1 -1
  78. package/dist/exposure/metering.js +1 -72
  79. package/dist/exposure/metering.js.map +1 -1
  80. package/dist/exposure/program-line.js +1 -45
  81. package/dist/exposure/program-line.js.map +1 -1
  82. package/dist/facade/catalogue-lens-signal.d.ts +94 -0
  83. package/dist/facade/catalogue-lens-signal.js +362 -0
  84. package/dist/facade/catalogue-lens-signal.js.map +1 -0
  85. package/dist/facade/defocus-image-space-provider.js +5 -2
  86. package/dist/facade/defocus-image-space-provider.js.map +1 -1
  87. package/dist/facade/develop-joined-parts.d.ts +29 -0
  88. package/dist/facade/develop-joined-parts.js +33 -0
  89. package/dist/facade/develop-joined-parts.js.map +1 -0
  90. package/dist/facade/display-view-lens-signal.d.ts +32 -1
  91. package/dist/facade/display-view-lens-signal.js +85 -10
  92. package/dist/facade/display-view-lens-signal.js.map +1 -1
  93. package/dist/facade/expected-signal-setup.d.ts +8 -0
  94. package/dist/facade/expected-signal-setup.js +7 -0
  95. package/dist/facade/expected-signal-setup.js.map +1 -1
  96. package/dist/facade/expected-signal.d.ts +5 -0
  97. package/dist/facade/expected-signal.js +5 -1
  98. package/dist/facade/expected-signal.js.map +1 -1
  99. package/dist/facade/generic-lens-capture.d.ts +5 -2
  100. package/dist/facade/generic-lens-capture.js +5 -2
  101. package/dist/facade/generic-lens-capture.js.map +1 -1
  102. package/dist/facade/simulate-capture.d.ts +67 -8
  103. package/dist/facade/simulate-capture.js +262 -126
  104. package/dist/facade/simulate-capture.js.map +1 -1
  105. package/dist/facade/simulate-display-view.js +1 -1
  106. package/dist/facade/simulate-display-view.js.map +1 -1
  107. package/dist/gpu/chart-composite-gpu.js +1 -3
  108. package/dist/gpu/chart-composite-gpu.js.map +1 -1
  109. package/dist/gpu/chart-radiance-gpu.js +1 -5
  110. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  111. package/dist/gpu/display-view-quality-gpu.d.ts +13 -1
  112. package/dist/gpu/display-view-quality-gpu.js +4 -8
  113. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  114. package/dist/gpu/display-view-speed-gpu.js +3 -25
  115. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  116. package/dist/gpu/sensor-stage-gpu.js +1 -5
  117. package/dist/gpu/sensor-stage-gpu.js.map +1 -1
  118. package/dist/gpu/webgpu-runtime.d.ts +2 -0
  119. package/dist/gpu/webgpu-runtime.js +3 -0
  120. package/dist/gpu/webgpu-runtime.js.map +1 -1
  121. package/dist/index.d.ts +6 -1
  122. package/dist/index.js +5 -0
  123. package/dist/index.js.map +1 -1
  124. package/dist/motion/camera-rotation.js +1 -6
  125. package/dist/motion/camera-rotation.js.map +1 -1
  126. package/dist/motion/capture-rotation-inverse-mapping.d.ts +12 -0
  127. package/dist/motion/capture-rotation-inverse-mapping.js +2 -2
  128. package/dist/motion/capture-rotation-inverse-mapping.js.map +1 -1
  129. package/dist/motion/capture-rotation-temporal-quadrature.d.ts +1 -0
  130. package/dist/motion/capture-rotation-temporal-quadrature.js +1 -1
  131. package/dist/motion/capture-rotation-temporal-quadrature.js.map +1 -1
  132. package/dist/motion/capture-rotation-trajectory.js +2 -17
  133. package/dist/motion/capture-rotation-trajectory.js.map +1 -1
  134. package/dist/motion/capture-translation-parallax-temporal-quadrature.d.ts +1 -0
  135. package/dist/motion/capture-translation-parallax-temporal-quadrature.js +3 -14
  136. package/dist/motion/capture-translation-parallax-temporal-quadrature.js.map +1 -1
  137. package/dist/motion/extended-object-projection.js +5 -40
  138. package/dist/motion/extended-object-projection.js.map +1 -1
  139. package/dist/optics/atmospheric-haze.js +1 -7
  140. package/dist/optics/atmospheric-haze.js.map +1 -1
  141. package/dist/optics/brown-conrady-distortion.js +1 -7
  142. package/dist/optics/brown-conrady-distortion.js.map +1 -1
  143. package/dist/optics/complex-pupil-psf.d.ts +1 -0
  144. package/dist/optics/complex-pupil-psf.js +2 -84
  145. package/dist/optics/complex-pupil-psf.js.map +1 -1
  146. package/dist/optics/focus-state.js +1 -11
  147. package/dist/optics/focus-state.js.map +1 -1
  148. package/dist/optics/front-of-lens-filter.d.ts +5 -0
  149. package/dist/optics/front-of-lens-filter.js +3 -65
  150. package/dist/optics/front-of-lens-filter.js.map +1 -1
  151. package/dist/optics/generic-nd-filter-presets.d.ts +12 -0
  152. package/dist/optics/generic-nd-filter-presets.js +48 -0
  153. package/dist/optics/generic-nd-filter-presets.js.map +1 -0
  154. package/dist/optics/illumination-vignetting.js +1 -6
  155. package/dist/optics/illumination-vignetting.js.map +1 -1
  156. package/dist/optics/lens-psf-profile.d.ts +4 -1
  157. package/dist/optics/lens-psf-profile.js +12 -111
  158. package/dist/optics/lens-psf-profile.js.map +1 -1
  159. package/dist/optics/longitudinal-chromatic-aberration.js +1 -7
  160. package/dist/optics/longitudinal-chromatic-aberration.js.map +1 -1
  161. package/dist/optics/pupil-vignetting.js +1 -7
  162. package/dist/optics/pupil-vignetting.js.map +1 -1
  163. package/dist/optics/radial-distortion.js +1 -6
  164. package/dist/optics/radial-distortion.js.map +1 -1
  165. package/dist/optics/scene-to-sensor-irradiance.js +4 -96
  166. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  167. package/dist/optics/stray-light.d.ts +1 -0
  168. package/dist/optics/stray-light.js +1 -1
  169. package/dist/optics/stray-light.js.map +1 -1
  170. package/dist/optics/zernike-wavefront.js +1 -7
  171. package/dist/optics/zernike-wavefront.js.map +1 -1
  172. package/dist/output/capture-geometry.js +1 -8
  173. package/dist/output/capture-geometry.js.map +1 -1
  174. package/dist/output/developed-lens-corrections.d.ts +62 -0
  175. package/dist/output/developed-lens-corrections.js +135 -0
  176. package/dist/output/developed-lens-corrections.js.map +1 -0
  177. package/dist/output/generic-lens-corrections.d.ts +2 -6
  178. package/dist/output/generic-lens-corrections.js +3 -6
  179. package/dist/output/generic-lens-corrections.js.map +1 -1
  180. package/dist/output/geometric-transforms.js +1 -4
  181. package/dist/output/geometric-transforms.js.map +1 -1
  182. package/dist/output/native-capture-sdr.d.ts +2 -2
  183. package/dist/output/native-capture-sdr.js +3 -2
  184. package/dist/output/native-capture-sdr.js.map +1 -1
  185. package/dist/processing/development-detail.d.ts +29 -0
  186. package/dist/processing/development-detail.js +128 -107
  187. package/dist/processing/development-detail.js.map +1 -1
  188. package/dist/schema/illumination-temporal.js +3 -72
  189. package/dist/schema/illumination-temporal.js.map +1 -1
  190. package/dist/schema/illumination.d.ts +1 -0
  191. package/dist/schema/illumination.js +2 -65
  192. package/dist/schema/illumination.js.map +1 -1
  193. package/dist/schema/scene-radiance-temporal.js +2 -14
  194. package/dist/schema/scene-radiance-temporal.js.map +1 -1
  195. package/dist/schema/scene-radiance.js +2 -60
  196. package/dist/schema/scene-radiance.js.map +1 -1
  197. package/dist/schema/validation.d.ts +2 -0
  198. package/dist/schema/validation.js +2 -17
  199. package/dist/schema/validation.js.map +1 -1
  200. package/dist/sensor/accumulated-charge.js +2 -59
  201. package/dist/sensor/accumulated-charge.js.map +1 -1
  202. package/dist/sensor/architecture.js +1 -16
  203. package/dist/sensor/architecture.js.map +1 -1
  204. package/dist/sensor/camera-saturation-capacity.d.ts +1 -0
  205. package/dist/sensor/camera-saturation-capacity.js +3 -55
  206. package/dist/sensor/camera-saturation-capacity.js.map +1 -1
  207. package/dist/sensor/capture-color-sampling-binding.js +1 -26
  208. package/dist/sensor/capture-color-sampling-binding.js.map +1 -1
  209. package/dist/sensor/capture-mode-timing.js +1 -39
  210. package/dist/sensor/capture-mode-timing.js.map +1 -1
  211. package/dist/sensor/capture-mode.js +1 -23
  212. package/dist/sensor/capture-mode.js.map +1 -1
  213. package/dist/sensor/color-sampling.js +1 -26
  214. package/dist/sensor/color-sampling.js.map +1 -1
  215. package/dist/sensor/constant-rate-temporal-integration.js +2 -46
  216. package/dist/sensor/constant-rate-temporal-integration.js.map +1 -1
  217. package/dist/sensor/dark-current.js +2 -45
  218. package/dist/sensor/dark-current.js.map +1 -1
  219. package/dist/sensor/eqe-electron-rate.js +2 -35
  220. package/dist/sensor/eqe-electron-rate.js.map +1 -1
  221. package/dist/sensor/exposure-window.d.ts +5 -0
  222. package/dist/sensor/exposure-window.js +7 -22
  223. package/dist/sensor/exposure-window.js.map +1 -1
  224. package/dist/sensor/iso-gain.js +1 -7
  225. package/dist/sensor/iso-gain.js.map +1 -1
  226. package/dist/sensor/iso-signal-chain.js +1 -43
  227. package/dist/sensor/iso-signal-chain.js.map +1 -1
  228. package/dist/sensor/optical-stack.js +1 -25
  229. package/dist/sensor/optical-stack.js.map +1 -1
  230. package/dist/sensor/photon-energy.js +1 -26
  231. package/dist/sensor/photon-energy.js.map +1 -1
  232. package/dist/sensor/physical-charge-capacity.js +3 -77
  233. package/dist/sensor/physical-charge-capacity.js.map +1 -1
  234. package/dist/sensor/radiometry-readiness.js +2 -23
  235. package/dist/sensor/radiometry-readiness.js.map +1 -1
  236. package/dist/sensor/raw-readout.d.ts +3 -0
  237. package/dist/sensor/raw-readout.js +4 -66
  238. package/dist/sensor/raw-readout.js.map +1 -1
  239. package/dist/sensor/raw-reconstruction.js +2 -64
  240. package/dist/sensor/raw-reconstruction.js.map +1 -1
  241. package/dist/sensor/readout-exposure-linkage.js +1 -9
  242. package/dist/sensor/readout-exposure-linkage.js.map +1 -1
  243. package/dist/sensor/readout-timing.js +3 -57
  244. package/dist/sensor/readout-timing.js.map +1 -1
  245. package/dist/sensor/response-application-compatibility.d.ts +2 -0
  246. package/dist/sensor/response-application-compatibility.js +3 -29
  247. package/dist/sensor/response-application-compatibility.js.map +1 -1
  248. package/dist/sensor/response-operating-range.d.ts +1 -0
  249. package/dist/sensor/response-operating-range.js +3 -79
  250. package/dist/sensor/response-operating-range.js.map +1 -1
  251. package/dist/sensor/responsivity-photocurrent.js +4 -108
  252. package/dist/sensor/responsivity-photocurrent.js.map +1 -1
  253. package/dist/sensor/sampling-aperture.js +1 -25
  254. package/dist/sensor/sampling-aperture.js.map +1 -1
  255. package/dist/sensor/spatial-sample-reduction.js +2 -19
  256. package/dist/sensor/spatial-sample-reduction.js.map +1 -1
  257. package/dist/sensor/spatial-sampling-quadrature.js +1 -3
  258. package/dist/sensor/spatial-sampling-quadrature.js.map +1 -1
  259. package/dist/sensor/spatio-spectral-reduction.js +1 -18
  260. package/dist/sensor/spatio-spectral-reduction.js.map +1 -1
  261. package/dist/sensor/spectral-coverage.d.ts +10 -1
  262. package/dist/sensor/spectral-coverage.js +1 -1
  263. package/dist/sensor/spectral-coverage.js.map +1 -1
  264. package/dist/sensor/spectral-quadrature.js +2 -35
  265. package/dist/sensor/spectral-quadrature.js.map +1 -1
  266. package/dist/sensor/spectral-response.js +1 -47
  267. package/dist/sensor/spectral-response.js.map +1 -1
  268. package/dist/stabilization/camera-shake.js +1 -6
  269. package/dist/stabilization/camera-shake.js.map +1 -1
  270. package/dist/stabilization/system.js +2 -80
  271. package/dist/stabilization/system.js.map +1 -1
  272. package/docs/AGENT_GUIDE.md +4 -0
  273. package/docs/API_REFERENCE.md +69 -2
  274. package/docs/QUICK_START.md +136 -6
  275. package/docs/USAGE.md +89 -3
  276. package/docs/api/camera-sensor-equivalence.md +209 -0
  277. package/docs/api/core-version.md +1 -1
  278. package/docs/api/equipment-ideal-lens.md +26 -0
  279. package/docs/api/exports.json +118 -3
  280. package/docs/api/exposure-filtered-exposure-time.md +105 -0
  281. package/docs/api/facade-develop-joined-parts.md +32 -0
  282. package/docs/api/facade-simulate-capture.md +62 -8
  283. package/docs/api/optics-generic-nd-filter-presets.md +34 -0
  284. package/docs/api/optics-lens-psf-profile.md +3 -1
  285. package/docs/api/output-developed-lens-corrections.md +12 -0
  286. package/docs/api/output-generic-lens-corrections.md +0 -9
  287. package/llms.txt +3 -0
  288. package/package.json +1 -1
@@ -82,6 +82,7 @@ import { DEFOCUS_DIRECT_KERNEL_MAXIMUM_RADIUS_SITES, siteKernelRuns } from "../o
82
82
  import { createDefocusBokehSiteKernel, resolveDefocusBokehOptions } from "../optics/defocus-bokeh-kernel.js";
83
83
  import { planDefocusBokehZones } from "./defocus-bokeh-zones.js";
84
84
  import { resolveGenericPrimeLens } from "../equipment/generic-prime-lenses.js";
85
+ import { IDEAL_LENS_LIMITS } from "../equipment/ideal-lens.js";
85
86
  import { calculateChannelElectronsPerRadiance } from "./channel-response.js";
86
87
  import { calculateSiteEffectiveSaturationCode } from "../capture/raw-white-level.js";
87
88
  import { prepareSensorColorDevelopment } from "../color/sensor-color-development.js";
@@ -89,7 +90,7 @@ import { resolveAntiAliasingSpatialKernel } from "../sensor/optical-stack.js";
89
90
  import { multiplyMatrices } from "../color/colorimetry-math.js";
90
91
  import { prepareRgbGamutMapping } from "../color/gamut-mapping.js";
91
92
  import { serializeNativeRawDng } from "../capture/native-raw-dng.js";
92
- import { applyDiffractionCorrection, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMask, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
93
+ import { correctDiffractionInPlace, defaultDiffractionCorrectionRadiusPixels, designDiffractionCorrectionFilter, parseDiffractionCorrectionSettings, parseUnsharpMaskSettings, renderSdrWithUnsharpMaskInPlace, DEVELOPMENT_DETAIL_MODEL_VERSION } from "../processing/development-detail.js";
93
94
  import { parseSensorReadoutConversionProfile, resolveSensorReadoutRegime } from "../sensor/raw-readout.js";
94
95
  import { resolveChartScene, chartMaterialInputs, CHART_SCENE_PRESETS } from "./chart-scenes.js";
95
96
  import { resolveChartMaterialLayers } from "./chart-material-layers.js";
@@ -106,13 +107,13 @@ export const SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES = 4;
106
107
  export const SIMULATE_CAPTURE_COST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.pupilSampling", "lens.projection", "lens.distortion", "lens.vignetting", "lens.psf",
107
108
  "lens.diffraction", "lens.defocus", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
108
109
  "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
109
- "output.diffractionCorrection", "output.sharpening", "output.format.jpeg", "output.format.png", "output.format.dng"]);
110
+ "output.diffractionCorrection", "output.sharpening", "output.lensCorrections", "output.format.jpeg", "output.format.png", "output.format.dng"]);
110
111
  /**
111
112
  * The options that take the exact-or-fast flag (#395), in the path style of {@link SIMULATE_CAPTURE_COST_OPTIONS}: each accepts
112
113
  * the record form with `fast` and is reported in `diagnostics.fast.applied` when it runs. Every one of them takes the flag,
113
114
  * whether or not it has a fast method yet; one without runs exact and reports `false`.
114
115
  */
115
- export const SIMULATE_CAPTURE_FAST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"]);
116
+ export const SIMULATE_CAPTURE_FAST_OPTIONS = /* @__PURE__ */ Object.freeze(["lens.diffraction", "lens.catalogue", "sensor.antiAliasing", "sensor.pixelAperture.box", "output.demosaic.ahd"]);
116
117
  export const FACADE_EVIDENCE = /* @__PURE__ */ Object.freeze([/* @__PURE__ */ Object.freeze({ sourceOrigin: "photivra",
117
118
  sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
118
119
  const DEFAULTS = /* @__PURE__ */ Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
@@ -552,20 +553,20 @@ function resolveAutomaticSlowestShutterOption(value, context) {
552
553
  "reciprocal rule is a rule of thumb and is not used.");
553
554
  return report;
554
555
  }
555
- function defaultProgramLine() {
556
+ function defaultProgramLine(wide, narrow, anchor) {
556
557
  // Generic educational program line written for the engine, relative to the f/8, 1/125 s reference. For less light
557
558
  // it first opens the aperture toward f/4, then lengthens the shutter. For more light it first closes toward
558
- // f/16, then shortens the shutter.
559
- return parseExposureProgramLineProfile({ schemaVersion: "0.1.0", profileId: "facade-generic-program-line", profileVersion: "1.0.0",
559
+ // f/16, then shortens the shutter. #583: a lens range without f/4–f/16 moves those ends to the range (wide/narrow, the line's
560
+ // f/4 and f/16 clamped to it): the same shape, the aperture held at the range's end and the shutter taking up the stops
561
+ // (stops = log2 of t/N² over the anchor's t/N²; #585 may move the anchor), still from 1/2000 s to 1.024 s.
562
+ const own = wide === 4 && narrow === 16;
563
+ return parseExposureProgramLineProfile({ schemaVersion: "0.1.0", profileId: "facade-generic-program-line" + (own ? "" : "-lens-range"), profileVersion: "1.0.0",
560
564
  scientificStatus: "approximation", policyKind: "generic-program-line", interpolation: "log2-aperture-shutter", evidence: FACADE_EVIDENCE,
561
565
  limitations: ["Generic educational program line authored for the capture facade; not any manufacturer's program."],
562
- nodes: [
563
- { nodeId: "minus-6", opticalExposureStopsFromReference: -6, aperture: 16, shutterSeconds: 1 / 2000 },
564
- { nodeId: "minus-2", opticalExposureStopsFromReference: -2, aperture: 16, shutterSeconds: 1 / 125 },
565
- { nodeId: "zero", opticalExposureStopsFromReference: 0, aperture: 8, shutterSeconds: 1 / 125 },
566
- { nodeId: "plus-2", opticalExposureStopsFromReference: 2, aperture: 4, shutterSeconds: 1 / 125 },
567
- { nodeId: "plus-9", opticalExposureStopsFromReference: 9, aperture: 4, shutterSeconds: 128 / 125 }
568
- ] });
566
+ nodes: (own ? [["minus-6", -6, 16, 1 / 2000], ["minus-2", -2, 16, 1 / 125], ["zero", 0, 8, 1 / 125], ["plus-2", 2, 4, 1 / 125], ["plus-9", 9, 4, 128 / 125]] :
567
+ [[narrow, 1 / 2000], [narrow, 1 / 125], [wide, 1 / 125], [wide, 128 / 125]].filter((_, i) => i !== 2 || wide < narrow)
568
+ .map(([aperture, shutterSeconds], i) => ["n" + i, Math.log2(shutterSeconds * anchor.aperture ** 2 / anchor.shutterSeconds / aperture ** 2), aperture, shutterSeconds]))
569
+ .map(([nodeId, opticalExposureStopsFromReference, aperture, shutterSeconds]) => ({ nodeId, opticalExposureStopsFromReference, aperture, shutterSeconds })) });
569
570
  }
570
571
  /** PNG colour signalling: the sRGB chunk for sRGB, ITU-T H.273 code points through cICP otherwise. */
571
572
  function pngColourEncoding(encoding) {
@@ -743,13 +744,13 @@ export async function serializeSimulatedCaptureDngFromParts(options, parts) {
743
744
  return await runCapture(options, "dng-from-parts", parts);
744
745
  }
745
746
  /** The whole frame's packed RAW from region parts (see `serializeSimulatedCaptureDngFromParts`). */
746
- function joinNativeRawParts(value, plan, width, height) {
747
+ function joinNativeRawParts(value, plan, width, height, caller) {
747
748
  if (!Array.isArray(value) || value.length === 0)
748
- throw new InvalidConfigurationError("serializeSimulatedCaptureDngFromParts needs at least one part.");
749
+ throw new InvalidConfigurationError(caller + " needs at least one part.");
749
750
  const parts = value, count = width * height;
750
751
  const codes = new Uint16Array(count), blackLevels = new Uint16Array(count), digitalSaturationCodes = new Uint16Array(count), saturationFlags = new Uint8Array(count);
751
752
  const covered = new Uint8Array(count), white = new Uint16Array(4);
752
- const bad = (k, what) => { throw new InvalidConfigurationError("DNG part " + k + ": " + what); };
753
+ const bad = (k, what) => { throw new InvalidConfigurationError("Part " + k + ": " + what); };
753
754
  parts.forEach((part, k) => {
754
755
  const rect = part?.region?.nativeRect, raw = part?.raw;
755
756
  if (!rect || !raw)
@@ -791,9 +792,9 @@ function joinNativeRawParts(value, plan, width, height) {
791
792
  }
792
793
  });
793
794
  if (covered.includes(0))
794
- throw new InvalidConfigurationError("The DNG parts do not cover every native site of the frame.");
795
+ throw new InvalidConfigurationError("The parts do not cover every native site of the frame.");
795
796
  if (white.includes(0))
796
- throw new InvalidConfigurationError("The DNG parts lack a white level for a CFA phase.");
797
+ throw new InvalidConfigurationError("The parts lack a white level for a CFA phase.");
797
798
  return { plan, codes, blackLevels, digitalSaturationCodes, saturationFlags, effectiveSaturationCodes: white };
798
799
  }
799
800
  /**
@@ -959,35 +960,65 @@ export async function runCapture(options, run, viewOption, renderView) {
959
960
  const body = preset.body, lens = preset.lens;
960
961
  // Lens
961
962
  const lensOptions = allowKeys(top.lens, "lens", ["focalLengthMm", "fNumber", "focusDistanceM", "pupilSampling", "projection", "distortion", "vignetting", "psf", "diffraction", "prime", "defocus",
962
- "catalogue", "focusScatter"]);
963
+ "catalogue", "focusScatter", "ideal"]);
963
964
  // Generic prime (2.3): resolved on the simulated frame's size, which the output and sensor sections validate again below.
964
965
  let prime;
966
+ // The simulated frame in mm (output width × height × pixel pitch), validated again below.
967
+ const frameMm = () => {
968
+ const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
969
+ const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ?? DEFAULTS.pixelPitchMicrometers, "sensor.pixelPitchMicrometers");
970
+ return { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 };
971
+ };
965
972
  if (lensOptions.prime !== undefined) {
966
973
  if (lensOptions.focalLengthMm !== undefined)
967
974
  fail("lens.prime and lens.focalLengthMm cannot both be given: the prime sets the focal length.");
968
- const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
969
- const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ?? DEFAULTS.pixelPitchMicrometers, "sensor.pixelPitchMicrometers");
975
+ const activeImagingArea = frameMm();
970
976
  if (typeof lensOptions.prime !== "number")
971
977
  fail("lens.prime must be a generic prime's full-frame-equivalent focal length in mm.");
972
- prime = resolveGenericPrimeLens({ equivalentFocalLengthMm: lensOptions.prime, activeImagingArea: { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 } });
978
+ prime = resolveGenericPrimeLens({ equivalentFocalLengthMm: lensOptions.prime, activeImagingArea });
973
979
  assume("lens.prime", "model", { lensId: prime.lensId, equivalentFocalLengthMm: prime.equivalentFocalLengthMm, focalLengthMm: prime.focalLengthMm, cropFactor35Mm: prime.cropFactor35Mm,
974
980
  widestFNumber: prime.widestFNumber, narrowestFNumber: prime.narrowestFNumber, minimumFocusDistanceM: prime.minimumFocusDistanceM }, "Generic prime (not a real product): focal length = equivalent ÷ the simulated frame's diagonal crop factor, f/" + prime.widestFNumber + "–f/" + prime.narrowestFNumber +
975
981
  ", minimum focus max(0.10 m, f·(1 + 1/0.15)) (thin-lens approximation). It replaces the tier lens's focal length and f-number range; the tier lens's transmission is kept.");
976
982
  }
983
+ // Ideal lens (#576): a physical focal length on any frame, every f-number within IDEAL_LENS_LIMITS.
984
+ let ideal;
985
+ if (lensOptions.ideal !== undefined) {
986
+ for (const key of ["prime", "catalogue", "focalLengthMm"])
987
+ if (lensOptions[key] !== undefined)
988
+ fail("lens.ideal cannot be combined with lens." + key + ".");
989
+ const io = allowKeys(lensOptions.ideal, "lens.ideal", ["focalLengthMm", "widestFNumber", "narrowestFNumber"]), f = io.focalLengthMm, { focalLengthMm: b, fNumber: n } = IDEAL_LENS_LIMITS;
990
+ if (!(typeof f === "number" && f >= b.minimum && f <= b.maximum))
991
+ fail("lens.ideal.focalLengthMm must be " + b.minimum + "–" + b.maximum + " mm (IDEAL_LENS_LIMITS).");
992
+ // #576 (owner, 2026-11-09): an optional f-number range within IDEAL_LENS_LIMITS, for the automatic exposure modes; default the whole range.
993
+ const ranged = io.widestFNumber !== undefined || io.narrowestFNumber !== undefined, lo = io.widestFNumber === undefined ? n.minimum : io.widestFNumber, hi = io.narrowestFNumber === undefined ? n.maximum : io.narrowestFNumber;
994
+ if (!(typeof lo === "number" && typeof hi === "number" && lo >= n.minimum && hi <= n.maximum && lo <= hi))
995
+ fail("lens.ideal.widestFNumber and narrowestFNumber must be f-numbers in f/" + n.minimum + "–f/" + n.maximum + " (IDEAL_LENS_LIMITS), the widest no larger than the narrowest.");
996
+ const lensId = "ideal-" + f + "mm";
997
+ ideal = { id: lensId, name: lensId, focalLengthMm: f, widestFNumber: lo, narrowestFNumber: hi };
998
+ assume("lens.ideal", "model", { lensId, focalLengthMm: f, ...(ranged ? { widestFNumber: lo, narrowestFNumber: hi } : {}) }, "Ideal thin lens without aberrations (not a real product) at this physical focal length" +
999
+ (ranged ? ", f/" + lo + "–f/" + hi : "") + "; the tier lens's transmission is kept.");
1000
+ }
977
1001
  // Generic lens catalogue (#549): resolved on the simulated frame like the prime; its character is built once the f-number is known.
978
1002
  let catalogue, catalogueFrame;
979
1003
  let lensKit;
1004
+ // #568: the shorthand id or the record { kind: <id>, fast }.
1005
+ let catalogueId, catalogueFast;
1006
+ if (lensOptions.catalogue !== undefined && typeof lensOptions.catalogue === "object" && lensOptions.catalogue !== null) {
1007
+ const record = allowKeys(lensOptions.catalogue, "lens.catalogue", ["kind", "fast"]);
1008
+ if (typeof record.kind !== "string")
1009
+ fail("lens.catalogue.kind must be a catalogue lens id (GENERIC_LENS_IDS).");
1010
+ catalogueId = record.kind;
1011
+ catalogueFast = fastOverride(record, "lens.catalogue");
1012
+ }
1013
+ else if (lensOptions.catalogue !== undefined)
1014
+ catalogueId = lensOptions.catalogue;
980
1015
  if (lensOptions.catalogue !== undefined) {
981
1016
  for (const key of ["prime", "focalLengthMm", "projection", "distortion", "vignetting", "psf", "diffraction", "pupilSampling"])
982
1017
  if (lensOptions[key] !== undefined)
983
1018
  fail("lens.catalogue cannot be combined with lens." + key + ": the catalogue lens sets its own focal length, projection, distortion, vignetting and PSF (with diffraction).");
984
- if (displayView)
985
- fail("simulateDisplayView does not support lens.catalogue: the catalogue lens's field-dependent PSF, distortion and vignetting run through the capture's environment quadrature.");
986
- const o = (typeof top.output === "object" && top.output !== null ? top.output : {}), sn = (typeof top.sensor === "object" && top.sensor !== null ? top.sensor : {});
987
- const frameW = positive(o.width ?? DEFAULTS.width, "output.width"), frameH = positive(o.height ?? DEFAULTS.height, "output.height"), framePitch = positive(sn.pixelPitchMicrometers ?? DEFAULTS.pixelPitchMicrometers, "sensor.pixelPitchMicrometers");
988
- catalogueFrame = { widthMm: frameW * framePitch / 1000, heightMm: frameH * framePitch / 1000 };
1019
+ catalogueFrame = frameMm();
989
1020
  lensKit = await import("./generic-lens-capture.js");
990
- catalogue = lensKit.resolveGenericLens({ lensId: lensOptions.catalogue, activeImagingArea: catalogueFrame });
1021
+ catalogue = lensKit.resolveGenericLens({ lensId: catalogueId, activeImagingArea: catalogueFrame });
991
1022
  assume("lens.catalogue", "model", { lensId: catalogue.lensId, name: catalogue.name, set: catalogue.set, kind: catalogue.kind, equivalentFocalLengthMm: catalogue.equivalentFocalLengthMm,
992
1023
  focalLengthMm: catalogue.focalLengthMm, cropFactor35Mm: catalogue.cropFactor35Mm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber,
993
1024
  minimumFocusDistanceM: catalogue.minimumFocusDistanceM, maximumMagnification: catalogue.maximumMagnification, projection: catalogue.projection, fisheyeImage: catalogue.fisheyeImage }, "Generic catalogue lens " + catalogue.name + " (#549; Photivra's own design, not a real product): focal length = equivalent ÷ the simulated frame's diagonal crop factor, f/" +
@@ -996,8 +1027,8 @@ export async function runCapture(options, run, viewOption, renderView) {
996
1027
  }
997
1028
  else if (lensOptions.focusScatter !== undefined)
998
1029
  fail("lens.focusScatter needs lens.catalogue: the focus scatter is a catalogue lens's own.");
999
- const lensRange = prime ? { id: prime.lensId, focalLengthMm: prime.focalLengthMm, widestFNumber: prime.widestFNumber, narrowestFNumber: prime.narrowestFNumber } :
1000
- catalogue ? { id: catalogue.lensId, focalLengthMm: catalogue.focalLengthMm, widestFNumber: catalogue.widestFNumber, narrowestFNumber: catalogue.narrowestFNumber } : undefined;
1030
+ const listed = prime ?? catalogue, lensRange = listed ? { id: listed.lensId, name: catalogue ? catalogue.name : listed.lensId, focalLengthMm: listed.focalLengthMm,
1031
+ widestFNumber: listed.widestFNumber, narrowestFNumber: listed.narrowestFNumber } : ideal;
1001
1032
  const lensExposure = lensRange ? { ...lens.exposure, profileId: "photivra-" + lensRange.id + ":exposure",
1002
1033
  focalLengthMmRange: { ...lens.exposure.focalLengthMmRange, value: { minimum: lensRange.focalLengthMm, maximum: lensRange.focalLengthMm } },
1003
1034
  aperture: { ...lens.exposure.aperture, widestAvailableFNumber: { kind: "constant", fNumber: { value: lensRange.widestFNumber, evidence: FACADE_EVIDENCE } },
@@ -1007,8 +1038,9 @@ export async function runCapture(options, run, viewOption, renderView) {
1007
1038
  positive(pick(lensOptions, "focalLengthMm", "lens.focalLengthMm", focalDefault, "The preset lens's focal length (the generic primes are 50 mm)."), "lens.focalLengthMm");
1008
1039
  const capabilities = resolveGenericEquipmentExposureCapabilities({ bodyProfile: body.exposure, lensProfile: lensExposure, selectedFocalLengthMm: focalLengthMm });
1009
1040
  const subjectDistanceM = positive(pick(top, "subjectDistanceM", "subjectDistanceM", DEFAULTS.subjectDistanceM, "Scene plane 5 m in front of the camera (the generic tiers' reference focus distance)."), "subjectDistanceM");
1010
- if (subjectDistanceM * 1000 <= focalLengthMm * 2)
1011
- fail("subjectDistanceM must exceed twice the focal length.");
1041
+ // #569: twice the focal length itself is admitted (magnification 1, the catalogue Macro's close focus f·(1 + 1/1)).
1042
+ if (subjectDistanceM * 1000 < focalLengthMm * 2)
1043
+ fail("subjectDistanceM must be at least twice the focal length.");
1012
1044
  let focusDistanceM = positive(pick(lensOptions, "focusDistanceM", "lens.focusDistanceM", subjectDistanceM, "Focused on the scene plane."), "lens.focusDistanceM");
1013
1045
  if (focusDistanceM * 1000 <= focalLengthMm)
1014
1046
  fail("lens.focusDistanceM must exceed the focal length.");
@@ -1149,7 +1181,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1149
1181
  const diffractionObjectSpace = diffractionRadiusOption !== undefined && !rotating && !lensFieldMapping && !vignetting;
1150
1182
  // Output and sensor
1151
1183
  const outputOptions = allowKeys(top.output, "output", ["width", "height", "format", "toneCurve", "toneCurveParameters", "highlightMode", "exposureCompensationStops", "whiteBalance", "jpegQuantizationStep",
1152
- "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata", "diffractionCorrection", "sharpening"]);
1184
+ "colorEncoding", "bitDepth", "gamutMapping", "demosaic", "borderHandling", "dng", "maximumPixels", "region", "metadata", "diffractionCorrection", "sharpening", "lensCorrections"]);
1153
1185
  // Opt-in large images: a caller-raised native pixel limit. Without it the 4094-pixel sides are unchanged.
1154
1186
  const raisedPixels = outputOptions.maximumPixels === undefined ? undefined : integerIn(outputOptions.maximumPixels, "output.maximumPixels", 1, NATIVE_RAW_LIMITS.ceilingPixels);
1155
1187
  const maximumSide = raisedPixels === undefined ? 4094 : NATIVE_RAW_LIMITS.maximumDimension;
@@ -1271,6 +1303,8 @@ export async function runCapture(options, run, viewOption, renderView) {
1271
1303
  unsupported("output.highlightMode \"reconstruct\"", "the view supports the per-channel highlight clip.");
1272
1304
  if (outputOptions.diffractionCorrection !== undefined || outputOptions.sharpening !== undefined)
1273
1305
  unsupported("output.diffractionCorrection or output.sharpening", "the development detail stages filter the whole developed capture image.");
1306
+ if (outputOptions.lensCorrections !== undefined)
1307
+ unsupported("output.lensCorrections", "the corrections resample the developed capture image.");
1274
1308
  if (defocusBokeh?.catsEye)
1275
1309
  unsupported("lens.defocus.catsEye", "the expected-signal tables hold one kernel per spectral node, and the cat's eye varies over the field; " +
1276
1310
  "simulateCapture renders it. lens.defocus.blades and lens.defocus.sphericalAberration are supported.");
@@ -1294,19 +1328,22 @@ export async function runCapture(options, run, viewOption, renderView) {
1294
1328
  if (demosaicOption !== undefined && demosaicOption !== "bilinear" && demosaicOption !== "ahd")
1295
1329
  fail("output.demosaic must be bilinear or ahd.");
1296
1330
  const ahd = demosaicOption === "ahd";
1297
- // #395: exact or fast per option. No option has a fast method yet, so each runs exact and reports false.
1331
+ // #395: exact or fast per option. Only lens.catalogue has a fast method (#568); it reports true below when it ran.
1298
1332
  if (top.fast !== undefined && typeof top.fast !== "boolean")
1299
1333
  fail("fast must be true or false.");
1300
1334
  const fastTop = top.fast === true;
1301
1335
  const fastInUse = [["lens.diffraction", diffractionRadiusOption !== undefined, diffractionFast],
1336
+ ["lens.catalogue", catalogue !== undefined, catalogueFast],
1302
1337
  ["sensor.antiAliasing", antiAliasing !== undefined, antiAliasingFast], ["sensor.pixelAperture.box", boxAperture, boxFast], ["output.demosaic.ahd", ahd, demosaicFast]];
1303
1338
  const fastApplied = {};
1304
1339
  for (const [option, used] of fastInUse)
1305
1340
  if (used)
1306
1341
  fastApplied[option] = false;
1307
- const fastReport = Object.freeze({
1308
- requested: fastTop || [diffractionFast, antiAliasingFast, boxFast, demosaicFast].some((value) => value === true),
1309
- applied: Object.freeze(fastApplied), assurance: "engine-reference"
1342
+ // #568: the catalogue's fast method, when requested (its record's flag, else the top-level one); whether it runs is decided with the RAW path.
1343
+ const catalogueFastRequested = catalogue !== undefined && (catalogueFast ?? fastTop);
1344
+ let fastReport = Object.freeze({
1345
+ requested: fastTop || [diffractionFast, catalogueFast, antiAliasingFast, boxFast, demosaicFast].some((value) => value === true),
1346
+ applied: Object.freeze({ ...fastApplied }), assurance: "engine-reference"
1310
1347
  });
1311
1348
  if (borderOption !== undefined && borderOption !== "margin" && borderOption !== "mirror")
1312
1349
  fail("output.borderHandling must be margin or mirror.");
@@ -1320,23 +1357,46 @@ export async function runCapture(options, run, viewOption, renderView) {
1320
1357
  fail("output.demosaic \"ahd\" with mirror borders needs an output of at least 3 × 3 pixels.");
1321
1358
  // Geometry: the demosaic border (if any) on each side; output crop is the active area.
1322
1359
  const nativeWidth = width + 2 * margin, nativeHeight = height + 2 * margin;
1360
+ // Lens corrections (#570): the catalogue lens's corrections on the developed linear colour; all off is no option at all.
1361
+ let lensCorrection, lensCorrectionSelection;
1362
+ if (outputOptions.lensCorrections !== undefined) {
1363
+ const keys = ["distortion", "lateralColor", "vignetting"], o = allowKeys(outputOptions.lensCorrections, "output.lensCorrections", keys), selection = {};
1364
+ for (const key of keys) {
1365
+ if (o[key] !== undefined && typeof o[key] !== "boolean")
1366
+ fail("output.lensCorrections." + key + " must be true or false.");
1367
+ selection[key] = o[key] === true;
1368
+ }
1369
+ if (keys.some((key) => selection[key])) {
1370
+ if (idealReference)
1371
+ fail("simulateIdealReference does not take output.lensCorrections: a reference has no camera development.");
1372
+ if (!catalogue || !lensKit)
1373
+ fail("output.lensCorrections needs lens.catalogue: the corrections are the catalogue lens's own profile.");
1374
+ lensCorrectionSelection = selection;
1375
+ lensCorrection = lensKit.prepareDevelopedLensCorrection(catalogue, selection, width, height, pitch / 1000, lensKit.genericLensDispersion);
1376
+ }
1377
+ }
1323
1378
  // Region (2.3): a rectangle of the whole frame. Its native sites are the region plus the demosaic reach on each side
1324
1379
  // (clipped to the raster, where mirror borders apply exactly as in the whole render), with the origin rounded down to
1325
1380
  // the 2 × 2 CFA repeat so the region raster keeps the frame's CFA phase.
1326
- let region, regionNative;
1381
+ let region, regionNative, imageRegion;
1327
1382
  if (outputOptions.region !== undefined) {
1328
1383
  const r = allowKeys(outputOptions.region, "output.region", ["x", "y", "width", "height"]);
1329
1384
  const x = integerIn(r.x, "output.region.x", 0, width - 1), y = integerIn(r.y, "output.region.y", 0, height - 1);
1330
1385
  region = { x, y, width: integerIn(r.width, "output.region.width", 1, width - x), height: integerIn(r.height, "output.region.height", 1, height - y) };
1386
+ // #570: a corrected region develops the frame pixels its corrected pixels read.
1387
+ if (lensCorrection) {
1388
+ imageRegion = region;
1389
+ region = lensKit.developedLensCorrectionSourceRect(lensCorrection, region);
1390
+ }
1331
1391
  const reach = margin > 0 ? margin : ahd ? AHD_MARGIN : builtInDemosaic ? builtInDemosaic.kernelReachSites : MARGIN;
1332
1392
  let x0 = Math.max(0, region.x + margin - reach), y0 = Math.max(0, region.y + margin - reach);
1333
1393
  x0 -= x0 % 2;
1334
1394
  y0 -= y0 % 2;
1335
1395
  regionNative = { x: x0, y: y0, width: Math.min(nativeWidth, region.x + region.width + margin + reach) - x0, height: Math.min(nativeHeight, region.y + region.height + margin + reach) - y0 };
1336
1396
  }
1337
- const outputWidth = region ? region.width : width, outputHeight = region ? region.height : height;
1397
+ let outputWidth = region ? region.width : width, outputHeight = region ? region.height : height;
1338
1398
  if (region && regionNative)
1339
- 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), " +
1399
+ assume("output.region", "model", { ...imageRegion ?? 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), " +
1340
1400
  "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. " +
1341
1401
  // 2.6: with lens.diffraction the convolution is limited to the region; the sentence of 2.3–2.5 is kept verbatim otherwise, so
1342
1402
  // region results without diffraction stay byte-identical to 2.5.0.
@@ -1518,9 +1578,18 @@ export async function runCapture(options, run, viewOption, renderView) {
1518
1578
  fail("exposure.compensationStops must be within ±10.");
1519
1579
  if (exposureOptions.compensationStops === undefined)
1520
1580
  assume("exposure.compensationStops", "default", 0, "No exposure compensation.");
1521
- const reference = { aperture: DEFAULTS.fNumber, shutterSeconds: DEFAULTS.shutterSeconds, iso: DEFAULTS.iso };
1581
+ // #569: a lens whose range excludes f/8 (the catalogue's PV 800 9, f/9–f/32; an ideal lens with a narrowed range) takes the same reference
1582
+ // exposure at the aperture of its range nearest f/8, N0 with t = 1/125 s × (N0/8)², which has the same N²/t and so the same reference
1583
+ // luminance; every other request keeps f/8, 1/125 s. #585: when that t is shorter than the body's shortest shutter (N0 below about f/1),
1584
+ // the anchor takes the shortest shutter instead, a lower N²/t; the meter, the resolvers and the program line all measure from the anchor.
1585
+ // An f-number clamped to the lens's range (#583 limits the program line's f/4 and f/16 with it).
1586
+ const clamp = (n) => lensRange ? Math.min(lensRange.narrowestFNumber, Math.max(lensRange.widestFNumber, n)) : n, referenceAperture = clamp(DEFAULTS.fNumber);
1587
+ // (N0/8)² is exactly 1 at f/8, so f/8 keeps 1/125 s exactly.
1588
+ const sameShutter = DEFAULTS.shutterSeconds * (referenceAperture / DEFAULTS.fNumber) ** 2;
1589
+ const reference = { aperture: referenceAperture, shutterSeconds: Math.max(sameShutter, capabilities.shutter.minimumSeconds), iso: DEFAULTS.iso };
1522
1590
  const referenceLuminance = sosReferenceLuminance(reference.aperture, reference.shutterSeconds, reference.iso);
1523
- assume("exposure.referenceExposure", "model", { ...reference, meteredLuminanceCdPerSquareMeter: referenceLuminance }, "Exposure anchor: f/8, 1/125 s, ISO 100 gives the CIPA DC-004 standard output sensitivity reference exposure H = 10/S = 0.1 lx·s for an average scene luminance of 10·N²/(0.65·t·S) ≈ 1230.77 cd/m² (camera equation H = 0.65·L·t/N²).");
1591
+ assume("exposure.referenceExposure", "model", { ...reference, meteredLuminanceCdPerSquareMeter: referenceLuminance }, "Exposure anchor: f/8, 1/125 s, ISO 100 gives the CIPA DC-004 standard output sensitivity reference exposure H = 10/S = 0.1 lx·s for an average scene luminance of 10·N²/(0.65·t·S) ≈ 1230.77 cd/m² (camera equation H = 0.65·L·t/N²)" +
1592
+ (referenceAperture === DEFAULTS.fNumber ? "." : "; the lens excludes f/8: " + (reference.shutterSeconds > sameShutter ? "the shortest shutter" : "the same N²/t") + " at f/" + referenceAperture + "."));
1524
1593
  const meterProfile = body.metering[0].profile, zones = 16, zoneSamples = [];
1525
1594
  let luminanceSum = 0;
1526
1595
  for (let j = 0; j < zones; j++)
@@ -1551,12 +1620,13 @@ export async function runCapture(options, run, viewOption, renderView) {
1551
1620
  target = setExposureCompensationOnMeterTarget({ targetId: "facade-target-ec", baseTarget: target, exposureCompensationStops: compensation });
1552
1621
  let resolution;
1553
1622
  let fNumber, shutterSeconds;
1623
+ const checkFNumber = () => {
1624
+ if (clamp(fNumber) !== fNumber)
1625
+ fail("lens.fNumber " + fNumber + " is outside the " + lensRange.name + " range f/" + lensRange.widestFNumber + "–f/" + lensRange.narrowestFNumber + ".");
1626
+ };
1554
1627
  if (mode === "manual" || mode === "aperture-priority") {
1555
1628
  fNumber = positive(pick(lensOptions, "fNumber", "lens.fNumber", DEFAULTS.fNumber, "f/8."), "lens.fNumber");
1556
- if (prime && (fNumber < prime.widestFNumber || fNumber > prime.narrowestFNumber))
1557
- fail("lens.fNumber " + fNumber + " is outside the " + prime.lensId + " range f/" + prime.widestFNumber + "–f/" + prime.narrowestFNumber + ".");
1558
- if (catalogue && (fNumber < catalogue.widestFNumber || fNumber > catalogue.narrowestFNumber))
1559
- fail("lens.fNumber " + fNumber + " is outside the " + catalogue.name + " range f/" + catalogue.widestFNumber + "–f/" + catalogue.narrowestFNumber + ".");
1629
+ checkFNumber();
1560
1630
  }
1561
1631
  else {
1562
1632
  if (lensOptions.fNumber !== undefined)
@@ -1653,8 +1723,11 @@ export async function runCapture(options, run, viewOption, renderView) {
1653
1723
  { kind: "manual", iso, apertureQuantizationPolicy: "nearest-log2-narrower-on-tie" } });
1654
1724
  }
1655
1725
  else {
1656
- 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.");
1657
- resolution = resolveProgramAutoExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, programLine: defaultProgramLine(),
1726
+ // #583: a lens range without f/4–f/16 limits the line to it; every other request keeps the line and its assumption.
1727
+ const programLine = defaultProgramLine(clamp(4), clamp(16), reference);
1728
+ // A limited line (#583) records its nodes under the profile id "facade-generic-program-line-lens-range".
1729
+ assume("exposure.programLine", "model", programLine.nodes.length > 4 ? programLine.profileId : programLine, "Engine-authored generic program line (f/16 1/2000 s … f/8 1/125 s … f/4 1.024 s); not a manufacturer program.");
1730
+ resolution = resolveProgramAutoExposureMode({ target, capabilities: resolverCapabilities, referenceExposure: reference, programLine,
1658
1731
  isoControl: automaticIso ? { kind: "automatic", ...autoIsoBaseline, ...limitsPolicy, ...(autoIsoShutterPolicy ? { slowestPreferredShutter: { ...autoIsoShutterPolicy } } : {}) } :
1659
1732
  { kind: "manual", iso } });
1660
1733
  }
@@ -1671,11 +1744,10 @@ export async function runCapture(options, run, viewOption, renderView) {
1671
1744
  const exposureGreyCardDeviationStops = callerScene?.kind === "radiance-function" ? null : greyCardDeviationStops(fNumber, shutterSeconds, resolvedIso, illuminanceLux);
1672
1745
  // #549: the catalogue lens's character at the resolved f-number and focus: focus scatter, the defocus disc folded into its PSF, then
1673
1746
  // its distortion and vignetting profiles over the field the PSF and the queries reach, and its PSF profile.
1674
- let catalogueTaps, catalogueDefocusApplied = false;
1747
+ let catalogueTaps, catalogueDefocusApplied = false, catalogueKernelRadius = 0;
1675
1748
  if (catalogue && lensKit) {
1676
1749
  const { GENERIC_LENS_EVIDENCE, genericLensDistortionProfile, genericLensVignettingProfile, resolveGenericLens, simulateGenericLensFocusError, createGenericLensPsfProfile, genericLensPsfKernelRadius, GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES } = lensKit;
1677
- if (fNumber < catalogue.widestFNumber || fNumber > catalogue.narrowestFNumber)
1678
- fail("lens.fNumber " + fNumber + " is outside the " + catalogue.name + " range f/" + catalogue.widestFNumber + "–f/" + catalogue.narrowestFNumber + ".");
1750
+ checkFNumber();
1679
1751
  if (lensOptions.focusScatter !== undefined) {
1680
1752
  const scatterOptions = allowKeys(lensOptions.focusScatter, "lens.focusScatter", ["seed", "shotIndex"]);
1681
1753
  const sample = simulateGenericLensFocusError({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber, focusDistanceM, seed: scatterOptions.seed,
@@ -1743,6 +1815,7 @@ export async function runCapture(options, run, viewOption, renderView) {
1743
1815
  (built.kernelRadiusSites - built.aberrationKernelRadiusSites) + "), over the limit of " + GENERIC_LENS_PSF_MAXIMUM_COMBINED_RADIUS_SITES + " (" + (2 * built.kernelRadiusSites + 1) ** 2 +
1744
1816
  " scene queries per site and spectral node through the environment quadrature). Bring the focus closer to the subject, stop down, or use larger pixels.");
1745
1817
  catalogueTaps = (2 * built.kernelRadiusSites + 1) ** 2;
1818
+ catalogueKernelRadius = built.kernelRadiusSites;
1746
1819
  psfReport = { aberrationKernelRadiusSites: built.aberrationKernelRadiusSites, kernelRadiusSites: built.kernelRadiusSites, maximumTruncatedEnergyFraction: built.maximumTruncatedEnergyFraction,
1747
1820
  fieldNodesMm: { x: built.fieldXmm, y: built.fieldYmm }, wavelengthNodesNm: built.wavelengthsNm, transformSize: built.transformSize, maximumPhaseStepWaves: built.maximumPhaseStepWaves };
1748
1821
  }
@@ -2312,12 +2385,18 @@ export async function runCapture(options, run, viewOption, renderView) {
2312
2385
  // that only captures does not contain the display view.
2313
2386
  // #563: the view's expected signal through the lens, from the capture's own geometry (#548), checked to cover its farthest source point: the outer
2314
2387
  // site centre plus the kernel radius and the largest query offset.
2315
- const lensSignal = displayView && lensGeometry ? (await import("./display-view-lens-signal.js")).prepareDisplayViewLensSignal({ geometry: lensGeometry,
2388
+ // #569: a catalogue lens's field-dependent PSF, read per field zone by the lens signal (display-view-lens-signal.ts, loaded on demand).
2389
+ const viewLensKit = displayView && lensGeometry ? await import("./display-view-lens-signal.js") : undefined;
2390
+ const displayViewLensPsf = viewLensKit && catalogue && lensPsf ?
2391
+ viewLensKit.createDisplayViewLensPsf(lensPsf.configuration.psf, catalogueKernelRadius, nativeWidth, nativeHeight, pitch) : undefined;
2392
+ if (displayViewLensPsf)
2393
+ assume("view.lensPsf", "model", { method: "piecewise-constant-field-zones", zoneSites: displayViewLensPsf.zoneSites, kernelRadiusSites: catalogueKernelRadius, scientificStatus: "approximation" }, "Each site takes the lens PSF at its field zone's centre, where the capture resolves it at each query point (docs/DISPLAY_VIEW.md, \"Catalogue lenses\").");
2394
+ const lensSignal = viewLensKit ? viewLensKit.prepareDisplayViewLensSignal({ geometry: lensGeometry,
2316
2395
  times: rotating ? Array.from({ length: temporalSampleCount }, (_, i) => (i + 0.5) * (shutterSeconds / temporalSampleCount)) : [0], distanceM: subjectDistanceM, metresPerFrameUnit,
2317
- pitch, imagingArea, boxSamples, spots: antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }] }, nativeWidth, nativeHeight, (diffraction ? diffraction.kernelRadiusSamples : 0) + (displayViewDefocus ? (bokehAxisKernel?.radius ?? defocusDiscKernelRadius(displayViewDefocus.diameterSites)) : 0) +
2318
- Math.max(0, ...queryOffsetsSites.map((o) => Math.max(Math.abs(o.x), Math.abs(o.y)))) - 0.5, imageSpaceOptions.join(", ")) : undefined;
2396
+ pitch, imagingArea, boxSamples, spots: antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }], psf: displayViewLensPsf, zeroBeyondHorizon: catalogue?.kind === "fisheye" }, nativeWidth, nativeHeight, (diffraction ? diffraction.kernelRadiusSamples : 0) + (displayViewDefocus ? (bokehAxisKernel?.radius ?? defocusDiscKernelRadius(displayViewDefocus.diameterSites)) : 0) + catalogueKernelRadius +
2397
+ Math.max(0, ...queryOffsetsSites.map((o) => Math.max(Math.abs(o.x), Math.abs(o.y)))) - 0.5, catalogue ? "lens.catalogue" : imageSpaceOptions.join(", ")) : undefined;
2319
2398
  if (displayView)
2320
- return await renderView({ lens: lensSignal, ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, exposureGain, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, metering, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
2399
+ return await renderView({ lens: lensSignal, lensPsf: displayViewLensPsf, ahd, fastReport, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, materialLayers, chartReport, chartSize, diffraction, defocus: displayViewDefocus, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, exposureGain, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, isoReport, standardExposureDeviationStops: exposureDeviationStops, standardExposureIlluminanceLux: exposureIlluminanceLux, greyCardDeviationStops: exposureGreyCardDeviationStops, metering, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
2321
2400
  // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
2322
2401
  if (idealReference) {
2323
2402
  const referenceStarted = clockMs();
@@ -2435,18 +2514,43 @@ export async function runCapture(options, run, viewOption, renderView) {
2435
2514
  // pupil rays or caller PSF) takes its expected electrons from the tabulated signal, verified per CFA phase against the chain.
2436
2515
  const tabulatedScene = chart !== undefined || callerScene?.kind === "material-function";
2437
2516
  // #391: a DNG from region parts takes the whole frame's RAW from the parts instead of rendering it.
2438
- if (run === "dng-from-parts" && regionNative)
2439
- fail("serializeSimulatedCaptureDngFromParts writes the whole frame: the options take no output.region.");
2440
- const dngParts = run === "dng-from-parts" ? viewOption : undefined;
2517
+ // #571: simulateCaptureFromParts takes the RAW from the parts in the same way, then develops it as a capture does.
2518
+ const dngParts = run === "dng-from-parts" || run === "develop-from-parts" ? viewOption : undefined, partsCaller = run === "dng-from-parts" ? "serializeSimulatedCaptureDngFromParts" : "simulateCaptureFromParts";
2519
+ if (dngParts !== undefined && regionNative)
2520
+ fail(partsCaller + " takes the whole frame: the options take no output.region.");
2441
2521
  let tabulated;
2442
- // #553: a cat's eye varies the kernel over the field, which the expected-signal tables (one kernel per node) do not take.
2443
- if (dngParts === undefined && tabulatedScene && !pupil && !lensFieldMapping && !vignetting && !lensPsf && !rotating && !bokehZones && (!diffraction || diffractionObjectSpace) && provider.chartMaterialAt && provider.materialRadiance) {
2522
+ // #568: lens.catalogue's fast method: tabulated expected electrons with the PSF blended over a field grid (catalogue-lens-signal.ts), for a
2523
+ // still camera, site-centre queries and a chart or material scene; verified per CFA phase against the exact chain within its tolerance.
2524
+ let catalogueFastRun;
2525
+ const catalogueFastEligible = catalogueFastRequested && catalogueTaps !== undefined && lensKit !== undefined && lensPsf !== undefined && dngParts === undefined && tabulatedScene &&
2526
+ !pupil && !rotating && boxSamples === 1 && !antiAliasing && distortion?.kind === "radial" && provider.chartMaterialAt !== undefined && provider.materialRadiance !== undefined;
2527
+ // The tabulated paths' channel response per spectral node and material tables (the fast catalogue path and the expected-signal path).
2528
+ const tabulatedTables = () => {
2444
2529
  const { electronsPerRadiance, wavelengths } = axisChannelResponse("simulateCapture");
2445
- const perNode = CHANNELS.map((c) => Float64Array.from(wavelengths, (nm) => electronsPerRadiance[c].get(nm) ?? 0));
2446
2530
  const sceneMaterials = chart ? chart.materials : callerScene.materials, materialRadiance = provider.materialRadiance;
2447
- const tabulatedMaterials = { materialAt: provider.chartMaterialAt, whiteRadiance: (nm) => irradianceScale * illuminant.shape.evaluate(nm) / Math.PI,
2448
- list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
2449
- ...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] };
2531
+ return { wavelengths, perNode: CHANNELS.map((c) => Float64Array.from(wavelengths, (nm) => electronsPerRadiance[c].get(nm) ?? 0)),
2532
+ materials: { materialAt: provider.chartMaterialAt, whiteRadiance: (nm) => irradianceScale * illuminant.shape.evaluate(nm) / Math.PI,
2533
+ list: [...sceneMaterials.map((m, index) => (m.kind === "neutral" ? { neutral: m.reflectance } : { radiance: (nm) => materialRadiance(index, nm) })),
2534
+ ...(chartSize ? [{ neutral: chartSize.surroundReflectance }] : [])] } };
2535
+ };
2536
+ if (catalogueFastEligible) {
2537
+ const { wavelengths, perNode, materials } = tabulatedTables();
2538
+ const { signalInput } = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(1), boxSamples: 1, antiAliasing: undefined, pitch,
2539
+ materials, radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" });
2540
+ const radial = distortion.profile;
2541
+ const signal = lensKit.createCatalogueLensSignal({ nativeWidth, nativeHeight, pitch, psf: lensPsf.configuration.psf, wavelengthsNm: wavelengths, electronsPerRadiance: perNode,
2542
+ scene: signalInput.scene, channelAt: signalInput.channelAt,
2543
+ distortion: { normalizationRadiusMm: radial.normalizationRadiusMm, coefficients: { k1: radial.coefficients.k1, k2: radial.coefficients.k2 ?? 0, k3: radial.coefficients.k3 ?? 0 } },
2544
+ ...(vignetting ? { vignetting } : {}), projection: projection === "fisheye-equisolid-angle" ? projection : "rectilinear", outsideImageCircleSeesNothing: catalogue.kind === "fisheye",
2545
+ focalLengthMm, focusDistanceM, distanceM: subjectDistanceM, metresPerFrameUnit });
2546
+ tabulated = await renderTabulatedNativeRaw({ input: environmentRaw, region: regionNative, siteInput, evaluateRadiance: rawProvider().evaluateRadiance,
2547
+ electrons: signal, relativeTolerance: lensKit.CATALOGUE_FAST_VERIFICATION_TOLERANCE, onTile: rawTick, tileWidth, yieldControl: () => yieldControl(), checkAbort });
2548
+ if (tabulated)
2549
+ catalogueFastRun = { ...signal.grid, verificationRelativeDifference: tabulated.verificationRelativeDifference };
2550
+ }
2551
+ // #553: a cat's eye varies the kernel over the field, which the expected-signal tables (one kernel per node) do not take.
2552
+ else if (dngParts === undefined && tabulatedScene && !pupil && !lensFieldMapping && !vignetting && !lensPsf && !rotating && !bokehZones && (!diffraction || diffractionObjectSpace) && provider.chartMaterialAt && provider.materialRadiance) {
2553
+ const { wavelengths, perNode, materials: tabulatedMaterials } = tabulatedTables();
2450
2554
  const { signalInput: builtSignalInput } = buildExpectedSignalInput({ nativeWidth, nativeHeight, width, wavelengths, perNode, apertureNodes: apertureNodeOffsets(boxSamples), boxSamples, antiAliasing, pitch,
2451
2555
  diffraction: diffraction && !defocusSiteKernels ? { radius: diffraction.kernelRadiusSamples, psf: diffractionPsf.configuration.psf, profileWavelengths: diffraction.profile.axes.wavelengthNm } : undefined,
2452
2556
  materials: tabulatedMaterials, radianceAt: provider.radianceAt, imagingArea, focalLengthMm, focusDistanceM, subjectDistanceM, metresPerFrameUnit, caller: "simulateCapture" });
@@ -2472,8 +2576,18 @@ export async function runCapture(options, run, viewOption, renderView) {
2472
2576
  electrons: createDisplayViewExpectedSignal(signalInput), onTile: rawTick, tileWidth, yieldControl: () => yieldControl(), checkAbort });
2473
2577
  }
2474
2578
  let rawOutput;
2475
- if (dngParts !== undefined)
2476
- rawOutput = { raw: joinNativeRawParts(dngParts, plan, nativeWidth, nativeHeight), providerEvaluationCount: 0 };
2579
+ if (dngParts !== undefined) {
2580
+ rawOutput = { raw: joinNativeRawParts(dngParts, plan, nativeWidth, nativeHeight, partsCaller), providerEvaluationCount: 0 };
2581
+ if (run === "develop-from-parts")
2582
+ assume("parts", "model", { partCount: dngParts.length }, "The whole render's RAW, joined from region renders of this request (#571); how they were rendered is in their results.");
2583
+ }
2584
+ else if (tabulated && catalogueFastRun) {
2585
+ rawOutput = { raw: tabulated.raw, providerEvaluationCount: tabulated.providerEvaluationCount };
2586
+ fastApplied["lens.catalogue"] = true;
2587
+ fastReport = Object.freeze({ ...fastReport, applied: Object.freeze({ ...fastApplied }), assurance: "approximation-with-a-priori-bound" });
2588
+ for (const a of lensKit.catalogueFastAssumptions(catalogueFastRun))
2589
+ assume(a.id, "model", a.value, a.explanation);
2590
+ }
2477
2591
  else if (tabulated) {
2478
2592
  rawOutput = { raw: tabulated.raw, providerEvaluationCount: tabulated.providerEvaluationCount };
2479
2593
  // The verification's relative difference itself is not reported: it is a last-bits quantity, and captures use the JavaScript
@@ -2563,7 +2677,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2563
2677
  assume("output.dng", "model", { writer: "serializeNativeRawDng", schemaVersion: dng.schemaVersion, whiteLevel: dng.whiteLevel, blackLevels: dng.blackLevels,
2564
2678
  noiseProfile: dng.noiseProfile, unrepresentedSaturationFlagCount: dng.unrepresentedSaturationFlagCount }, "Lossless uncompressed 16-bit CFA DNG of exactly the packed native RAW codes: per-phase BlackLevel, WhiteLevel = smallest per-phase effective white, ActiveArea and DefaultCrop from the capture geometry, ColorMatrix1/ForwardMatrix1 from the facade's diagonal camera→XYZ profile (D65), AsShotNeutral from the white-balance intent, BaselineExposure 0, and NoiseProfile from each channel's readout regime (conversion gain ÷ digital gain, combined read noise; Poisson + Gaussian + quantization only). No linear master or developed image is declared; saturation flags below WhiteLevel are not representable and are counted.");
2565
2679
  }
2566
- if (dngParts !== undefined)
2680
+ if (run === "dng-from-parts")
2567
2681
  return dng;
2568
2682
  // Development
2569
2683
  const development = { phaseProfiles, colorProfile,
@@ -2596,60 +2710,94 @@ export async function runCapture(options, run, viewOption, renderView) {
2596
2710
  "Below f_c the gain is bounded, so attenuated detail is only partly restored and noise is amplified by up to the measured responsePeakGain. " +
2597
2711
  "Not inverted: the pixel aperture, optical anti-aliasing filter, demosaic blur, defocus and aberrations. One design wavelength for all channels (the simulated blur is polychromatic).");
2598
2712
  }
2599
- const correctLinear = (rgb) => {
2600
- if (!diffractionFilter)
2601
- return rgb;
2602
- const corrected = applyDiffractionCorrection(rgb, outputWidth, outputHeight, diffractionFilter);
2603
- detailDiagnostics.diffractionCorrection = { ...corrected.diagnostics, cutoffCyclesPerPixel: diffractionFilter.cutoffCyclesPerPixel,
2604
- responsePeakGain: diffractionFilter.responsePeakGain, responseMaximumBeyondCutoff: diffractionFilter.responseMaximumBeyondCutoff };
2605
- return corrected.samples;
2713
+ // #570: the lens corrections after the diffraction correction (which acts on the sensor's own lattice), before the tone curve.
2714
+ let cornerStops = null;
2715
+ if (lensCorrection) {
2716
+ const s = lensCorrectionSelection, c = lensCorrection, k = (channel) => c.scales[channel];
2717
+ if (s.vignetting)
2718
+ cornerStops = lensKit.resolveGenericLens({ lensId: catalogue.lensId, activeImagingArea: catalogueFrame, fNumber }).character.vignetting.cornerStops;
2719
+ assume("output.lensCorrections", "model", { profileId: "photivra-" + catalogue.lensId + ":corrections", ...s, k1: c.k1, channelScales: { red: k(0), green: k(1), blue: k(2) },
2720
+ cornerStops, cornerGain: cornerStops === null ? 1 : 2 ** cornerStops, magnification: 1 / c.fill, ...(imageRegion ? { developedRect: region } : {}), dng: "uncorrected" }, "The catalogue lens's correction profile (createGenericLensCorrectionProfile; an approximation) on the developed linear colour: pixel p reads channel c bilinearly at q(1 + k1|q|²/R²), q = a_c·p/magnification " +
2721
+ "(the smallest magnification ≥ 1 that keeps every source in the frame), times det J/E at the source, so a flat field stays flat (E = (1 − bρ²)², 1/det J the distortion's area factor). " +
2722
+ "Corners lose resolution, corner noise rises with the gain, axial colour stays. The DNG is not corrected.");
2723
+ }
2724
+ // Row progress of a development stage, yielding to the host about every 8 ms. #571: the detail stages run row by row in place
2725
+ // (one developed image in memory) and report each output row.
2726
+ const rowProgress = (stage) => {
2727
+ let lastYield = Date.now();
2728
+ return async (completed, total) => {
2729
+ onProgress?.({ stage, completedTileCount: completed, totalTileCount: total });
2730
+ if (Date.now() - lastYield >= 8) {
2731
+ await yieldControl();
2732
+ checkAbort();
2733
+ lastYield = Date.now();
2734
+ }
2735
+ };
2736
+ };
2737
+ const detailRows = async (stage, rows) => {
2738
+ const tick = rowProgress(stage);
2739
+ let step = rows.next();
2740
+ for (; !step.done; step = rows.next())
2741
+ await tick(step.value, outputHeight);
2742
+ return step.value;
2743
+ };
2744
+ const correctLinear = async (rgb) => {
2745
+ if (diffractionFilter) {
2746
+ const corrected = await detailRows("diffraction-correction", correctDiffractionInPlace(rgb, outputWidth, outputHeight, diffractionFilter));
2747
+ detailDiagnostics.diffractionCorrection = { ...corrected, cutoffCyclesPerPixel: diffractionFilter.cutoffCyclesPerPixel,
2748
+ responsePeakGain: diffractionFilter.responsePeakGain, responseMaximumBeyondCutoff: diffractionFilter.responseMaximumBeyondCutoff };
2749
+ }
2750
+ if (lensCorrection) {
2751
+ const into = imageRegion ?? { x: 0, y: 0, width, height };
2752
+ rgb = lensKit.applyDevelopedLensCorrection(lensCorrection, rgb, region ?? into, into, cornerStops);
2753
+ outputWidth = into.width;
2754
+ outputHeight = into.height;
2755
+ }
2756
+ return rgb;
2606
2757
  };
2607
2758
  if (sharpening)
2608
2759
  assume("output.sharpening", "model", { model: "development-detail", modelVersion: DEVELOPMENT_DETAIL_MODEL_VERSION, method: "unsharp-mask", ...sharpening,
2609
2760
  kernelRadiusPixels: Math.ceil(3 * sharpening.radiusPixels), domain: "srgb-encoded", stage: "after the tone curve, gamut handling and sRGB transfer; before quantization", edgeHandling: "clamp" }, "Unsharp mask y = x + amount·(x − G_σ∗x) where |x − G_σ∗x| ≥ threshold (the GIMP and ImageMagick unsharp mask: radius, amount, threshold), per channel on the sRGB-encoded values in [0, 1], " +
2610
2761
  "separable Gaussian truncated at ⌈3σ⌉ with clamped edges, then clipped to [0, 1] and rounded. It raises contrast at edges and creates halos (overshoot and undershoot; see diagnostics.detail.sharpening) " +
2611
2762
  "and sharpens noise above the threshold. It adds no optical detail.");
2612
- if (detailRequested)
2763
+ if (detailRequested || lensCorrection)
2613
2764
  assume("output.developmentOrder", "model", [...(exposureGain === 1 ? [] : ["development exposure push (RAW)"]), "demosaic", "colour and white balance", ...(diffractionCorrectionOption ? ["diffraction correction (linear)"] : []),
2765
+ ...(lensCorrection ? ["lens corrections (linear)"] : []),
2614
2766
  "rendering exposure and tone curve", "gamut handling", "transfer function", ...(sharpening ? ["unsharp mask"] : []), "quantization"], "Development detail order: the deconvolution acts on linear light, where the blur is a convolution; the unsharp mask acts on the encoded values just before quantization." +
2615
2767
  (exposureGain === 1 ? "" : " A development exposure push (output.exposureCompensationStops) scales the black-subtracted, white-normalized RAW first, so pushed sites clip at white before the deconvolution sees them."));
2616
- if (ahd || (detailRequested && !linearPath)) {
2617
- let lastYield = Date.now();
2618
- const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, async (completed, total) => {
2619
- onProgress?.({ stage: "development", completedTileCount: completed, totalTileCount: total });
2620
- if (Date.now() - lastYield >= 8) {
2621
- await yieldControl();
2622
- checkAbort();
2623
- lastYield = Date.now();
2768
+ // Wide gamut: linear sRGB/D65 → target primaries (perceptual gamut mapping when asked), encoded.
2769
+ const encodeLinear = (rgb, highlight) => {
2770
+ const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2771
+ const encoded = encodeSdrFromLinear(rgb, colorEncoding, pngBitDepth, mapping);
2772
+ if (mapping)
2773
+ gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
2774
+ adjustedPixelCount: encoded.adjustedPixelCount, lightnessClippedPixelCount: encoded.lightnessClippedPixelCount };
2775
+ return { raw: rawOutput.raw, integerSamples: encoded.samples, highlight, diagnostics: encoded.diagnostics };
2776
+ };
2777
+ if (ahd || ((detailRequested || lensCorrection) && !linearPath)) {
2778
+ const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, rowProgress("development"), ahd ? { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } } : undefined);
2779
+ const rgb = await correctLinear(linear.rgb);
2780
+ if (!linearPath) {
2781
+ let codes, counts;
2782
+ if (sharpening) {
2783
+ const rendered = await detailRows("sharpening", renderSdrWithUnsharpMaskInPlace(rgb, outputWidth, outputHeight, rendering, sharpening));
2784
+ detailDiagnostics.sharpening = rendered.sharpening;
2785
+ ({ codes, counts } = rendered);
2786
+ }
2787
+ else {
2788
+ // The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
2789
+ const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
2790
+ codes = rendering.bitDepth === 8 ? new Uint8Array(outputWidth * outputHeight * 3) : new Uint16Array(outputWidth * outputHeight * 3);
2791
+ counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
2792
+ for (let y = 0; y < outputHeight; y++)
2793
+ renderSdrRunInto(rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * outputWidth * 3, scratch, counts);
2624
2794
  }
2625
- }, ahd ? { yieldControl: () => yieldControl(), checkAbort, onDemosaicMs: (ms) => { demosaicMs = ms; } } : undefined);
2626
- const rgb = correctLinear(linear.rgb);
2627
- if (!linearPath && sharpening) {
2628
- const rendered = renderSdrWithUnsharpMask(rgb, outputWidth, outputHeight, rendering, sharpening);
2629
- detailDiagnostics.sharpening = rendered.sharpening;
2630
- developed = { raw: rawOutput.raw, integerSamples: rendered.codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: rendered.counts.negative,
2631
- renderingAboveReferenceSampleCount: rendered.counts.above, toneChangedSampleCount: rendered.counts.changed, gamutClippedLowSampleCount: rendered.counts.low,
2632
- gamutClippedHighSampleCount: rendered.counts.high, captureSaturation: "consumed-in-raw-development" } };
2633
- }
2634
- else if (!linearPath) {
2635
- // The native development's SDR kernel, row by row, on the AHD-developed linear sRGB.
2636
- const maximum = 2 ** rendering.bitDepth - 1, tone = prepareSdrPixelTone(rendering), scratch = new Float64Array(3);
2637
- const codes = rendering.bitDepth === 8 ? new Uint8Array(outputWidth * outputHeight * 3) : new Uint16Array(outputWidth * outputHeight * 3);
2638
- const counts = { negative: 0, above: 0, changed: 0, low: 0, high: 0, outOfGamut: false };
2639
- for (let y = 0; y < outputHeight; y++)
2640
- renderSdrRunInto(rgb, y * outputWidth * 3, outputWidth, rendering, tone, 2 ** rendering.renderingExposureEv, maximum, codes, y * outputWidth * 3, scratch, counts);
2641
2795
  developed = { raw: rawOutput.raw, integerSamples: codes, highlight: linear.highlight, diagnostics: { renderingNegativeSampleCount: counts.negative,
2642
2796
  renderingAboveReferenceSampleCount: counts.above, toneChangedSampleCount: counts.changed, gamutClippedLowSampleCount: counts.low,
2643
2797
  gamutClippedHighSampleCount: counts.high, captureSaturation: "consumed-in-raw-development" } };
2644
2798
  }
2645
- else {
2646
- const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2647
- const encoded = encodeSdrFromLinear(rgb, colorEncoding, pngBitDepth, mapping);
2648
- developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
2649
- if (mapping)
2650
- gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
2651
- adjustedPixelCount: encoded.adjustedPixelCount, lightnessClippedPixelCount: encoded.lightnessClippedPixelCount };
2652
- }
2799
+ else
2800
+ developed = encodeLinear(rgb, linear.highlight);
2653
2801
  }
2654
2802
  else if (!linearPath) {
2655
2803
  const devTask = createNativeRawDevelopmentTask({ ...development, raw: rawOutput.raw }, { yieldControl,
@@ -2659,22 +2807,10 @@ export async function runCapture(options, run, viewOption, renderView) {
2659
2807
  }
2660
2808
  else {
2661
2809
  // Wide gamut: the native development operator up to linear sRGB/D65 (unclipped), then XYZ → target primaries.
2662
- let lastYield = Date.now();
2663
- const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, async (completed, total) => {
2664
- onProgress?.({ stage: "development", completedTileCount: completed, totalTileCount: total });
2665
- if (Date.now() - lastYield >= 8) {
2666
- await yieldControl();
2667
- checkAbort();
2668
- lastYield = Date.now();
2669
- }
2670
- });
2810
+ const linear = await developLinearSrgb({ ...development, raw: rawOutput.raw }, rowProgress("development"));
2671
2811
  {
2672
- const mapping = perceptual ? prepareRgbGamutMapping({ colorSpace: colorEncoding, method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION }) : undefined;
2673
- const encoded = encodeSdrFromLinear(correctLinear(linear.rgb), colorEncoding, pngBitDepth, mapping);
2674
- developed = { raw: rawOutput.raw, integerSamples: encoded.samples, highlight: linear.highlight, diagnostics: encoded.diagnostics };
2675
- if (mapping) {
2676
- gamutMappingDiagnostics = { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION,
2677
- adjustedPixelCount: encoded.adjustedPixelCount, lightnessClippedPixelCount: encoded.lightnessClippedPixelCount };
2812
+ developed = encodeLinear(await correctLinear(linear.rgb), linear.highlight);
2813
+ if (perceptual) {
2678
2814
  assume("output.gamutMapping", "model", { method: "cielab-hue-preserving-chroma-compression", kneeFraction: PERCEPTUAL_GAMUT_KNEE_FRACTION, colorSpace: colorEncoding }, "prepareRgbGamutMapping: CIELAB L* and hue held, chroma above 90 % of the boundary chroma C_max(L*, h) compressed with a tanh knee so it never reaches C_max (Morovič 2008, §10). In-gamut colours below the knee are unchanged; saturated in-gamut colours above it are desaturated slightly. L* ≥ 100 (above the RAW white) maps to white. CIELAB hue is not perceptually constant for saturated blues, so they can shift toward purple.");
2679
2815
  }
2680
2816
  assume("output.colorEncoding.conversion", "model", { from: "linear-srgb-d65 (unclipped)", via: "CIE XYZ", to: colorEncoding, gamutHandling: perceptual ? "cielab-hue-preserving-chroma-compression" : "clip-components",
@@ -2733,14 +2869,14 @@ export async function runCapture(options, run, viewOption, renderView) {
2733
2869
  ...(needsAuto ? ["output.whiteBalance.auto"] : []), ...(ahd ? ["output.demosaic.ahd"] : []),
2734
2870
  ...(highlightMode === "reconstruct" ? ["output.highlightMode.reconstruct"] : []), ...(wideGamut ? ["output.colorEncoding.wide-gamut"] : []),
2735
2871
  ...(perceptual ? ["output.gamutMapping.perceptual"] : []), ...(diffractionFilter ? ["output.diffractionCorrection"] : []),
2736
- ...(sharpening ? ["output.sharpening"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
2872
+ ...(sharpening ? ["output.sharpening"] : []), ...(lensCorrection ? ["output.lensCorrections"] : []), ...(format === "jpeg" || format === "png" || format === "dng" ? ["output.format." + format] : [])]);
2737
2873
  // #552: the detail stages' contracts, in execution order inside the development.
2738
- const detailContracts = diffractionFilter ? ["designDiffractionCorrectionFilter", "applyDiffractionCorrection"] : [], sdrContract = sharpening ? "renderSdrWithUnsharpMask" : "SDR rendering kernel";
2874
+ const detailContracts = [...(diffractionFilter ? ["designDiffractionCorrectionFilter", "applyDiffractionCorrection"] : []), ...(lensCorrection ? ["createGenericLensCorrectionProfile (developed linear colour)"] : [])], sdrContract = sharpening ? "renderSdrWithUnsharpMask" : "SDR rendering kernel";
2739
2875
  return Object.freeze({
2740
2876
  facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, engineApiVersion: ENGINE_API_VERSION,
2741
2877
  image: { width: outputWidth, height: outputHeight, format, samples: developed.integerSamples, ...(jpeg ? { jpeg } : {}), ...(png ? { png, colorEncoding: colorEncoding } : {}), ...(dng ? { dng: dng.bytes } : {}) },
2742
2878
  raw: developed.raw, plan: developmentPlan,
2743
- ...(region && regionNative ? { region: { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
2879
+ ...(region && regionNative ? { region: { ...imageRegion ?? region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } } } : {}),
2744
2880
  request: { environmentRaw: finalRawInput, siteInput, development, whiteBalanceState,
2745
2881
  ...(diffraction ? { diffraction: { profile: diffraction, psf: diffractionPsf, execution: diffractionObjectSpace ? "object-space-convolution" :
2746
2882
  defocusImageSpace ? "image-space-convolution" : "environment-psf-quadrature" } } : {}) },
@@ -2767,7 +2903,7 @@ export async function runCapture(options, run, viewOption, renderView) {
2767
2903
  ...(builtInDemosaic ? ["createBuiltInBayerDemosaicProfiles"] : []), ...(ahd ? ["createAhdDemosaicTask"] : []),
2768
2904
  ...(ahd ? ["native RAW development operator (colour, after AHD)", ...detailContracts, ...(linearPath ? ["resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] : [sdrContract])] :
2769
2905
  linearPath ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, "resolveRgbColorSpace", ...(perceptual ? ["prepareRgbGamutMapping"] : [])] :
2770
- detailRequested ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, sdrContract] : ["createNativeRawDevelopmentTask"]),
2906
+ detailRequested || lensCorrection ? ["native RAW development operator (linear, pre-clip)", ...detailContracts, sdrContract] : ["createNativeRawDevelopmentTask"]),
2771
2907
  ...(jpeg ? ["createPrintJpegTask"] : []), ...(png ? ["serializePngImage"] : []), ...(dng ? ["resolveSensorReadoutRegime", "serializeNativeRawDng"] : [])],
2772
2908
  limitations: ["Educational approximation: synthetic tier sensor values, idealized colorimetric sensor, planar Lambertian scene and point optics; not a calibrated camera emulation.",
2773
2909
  "Not offered by the facade: atmospheric haze and polarizers (the scene-radiance provider contract declares no volumetric transport or polarization), flash, video, HDR brackets and processing operators; compose those APIs directly."] },