@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
package/CHANGELOG.md CHANGED
@@ -1,6 +1,121 @@
1
1
  # Changelog
2
2
 
3
- ## 2.17.0 — unreleased (prepared November 7, 2026)
3
+ ## 2.19.0 — unreleased (prepared November 9, 2026)
4
+
5
+ A minor release of six issues: exposure time with an ND filter and generic ND presets (#578), the program line within the lens's f-number range (#583), very fast `lens.ideal` ranges (#585), developing a capture rendered in parts (#571), lens corrections in the development (#570) and the sampled-PSF orientation fix (#566). See the [2.19.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_19_0.md).
6
+
7
+ - **New:** `calculateFilteredExposureTime` and the generic ND presets `GENERIC_ND_FILTER_PROFILES` / `GENERIC_ND_FILTER_STOPS` (#578); `simulateCaptureFromParts` (#571); `output.lensCorrections` (#570); the progress stages `"diffraction-correction"` and `"sharpening"` (#571).
8
+ - **Changed:** program mode limits the generic program line to a lens range that does not hold f/4–f/16 instead of refusing it (#583); a `lens.ideal` range narrower than about f/1 now resolves in every exposure mode (#585).
9
+ - **Breaking fix (#566):** the rotationally symmetric `LensSampledPsfProfile` interpolation no longer mirrors asymmetric kernels in y (caller-supplied profiles in that mode only; no built-in lens uses it).
10
+ - **Root exports:** 10 new, 1,850 → 1,860 (runtime exports 626 → 631): 8 from #578 (4 runtime), 1 from #571 (runtime), 1 from #570 (type). `GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM` moves module (same value, still a root export).
11
+ - **Why minor:** new root exports and opt-in options; requests that 2.18.0 refused now resolve (#583, #585). #566 changes results only for caller-supplied profiles in the rotationally symmetric mode, a breaking fix the owner decided to ship in a minor release (2026-11-07, confirmed 2026-11-09). Nothing is removed, and no default changes.
12
+ - **Integration (this release):** `simulateCaptureFromParts` honors `output.lensCorrections` exactly as the whole-frame capture does (byte-identical; a part may keep or drop the option); the lens corrections run between the `diffraction-correction` and `sharpening` progress stages and report no stage of their own. 285 private copies of the same field checks and helpers in the domain parsers (for example `requireRecord` in 44 modules) now share one export each, and 35 identical private helpers found in two modules each are shared too; messages, error classes and results are unchanged.
13
+ - **Bundle:** the app's capture-worker shape (every chunk of `NATIVE_RAW_LIMITS`, `prepareDisplayView`, `serializeSimulatedCaptureDngFromParts`, `simulateCapture`, `simulateIdealReference`) is 351,775 B gzip (2.18.0: 351,859 B; limit 352,000 B, not raised). The six parts merged measured 353,811 B; sharing the duplicated helpers saved 2,036 B.
14
+
15
+ ### #566 LensSampledPsfProfile rotationally symmetric mode mirrors asymmetric PSFs in y
16
+
17
+ - **Breaking fix (owner decision 2026-11-07; confirmed 2026-11-09: a breaking fix, as this entry states):** the `rotationally-symmetric-radial-field-centroid-aligned` interpolation of `LensSampledPsfProfile` rotated each kernel as if its row index ran towards image −Y, while the environment PSF quadrature (`sensorPsfSourcePoint`) applies the row index as image +Y. At field azimuth θ the kernel was therefore turned to −θ: the y mirror of the intended kernel. It now rotates with the quadrature's convention, so an asymmetric kernel renders with the same orientation as a 2-D grid node declared at that field point.
18
+ - **Which results change:** only caller-supplied profiles that use this interpolation mode, at field points off the horizontal axis (`fieldYmm ≠ 0`), with a kernel that is not rotationally symmetric (a radial shift such as lateral color, radial elongation, coma, a decentered blur). Their `resolveLensSampledPsf` kernels (and the internal `resolveLensSampledPsfKernel`), and every capture, display view, RAW and convolution kernel built from them (`simulateCapture`, the native environment paths, `calculateSensorPsfIrradianceQuadrature`, `resolveLensPsfConvolutionKernels`), stop being mirrored in y. Kernels on the horizontal axis and rotationally symmetric kernels are unchanged (at θ = 0 and π the old and new rotations agree; a rotationally symmetric kernel is invariant). No built-in lens uses this mode (the generic lens catalogue uses the 2-D grid mode), so every hash lock holds. No API, schema or `src/gpu` change; the model id is unchanged.
19
+ - **Tests** (`test/lens-psf-interpolation.test.ts`): an asymmetric kernel (a radially shifted core plus a blob to the counter-clockwise side) resolved in the rotationally symmetric mode and as an exact 2-D grid node, at (±6, ±6) mm in all four quadrants; the light of a point source, placed through `sensorPsfSourcePoint`, has the declared radial (2.25 samples) and across-radius (0.6 samples) moments in both modes, the kernels differ by L1 < 0.25 (bilinear resampling) and the rotated kernel is far from the grid kernel's y mirror (L1 > 1). The test fails on 2.18.0. The test helpers now build kernels with the row index increasing with image +Y; the existing four-orientation rotation test, which used the old convention in its helpers, is unchanged otherwise.
20
+ - **Docs:** docs/PSF_FOUNDATION.md states the row convention of the rotation; docs/LENS_CATALOGUE.md drops the caveat; API reference regenerated (`LensSampledPsfInterpolation`).
21
+
22
+ ### #578 Exposure time with an ND filter
23
+
24
+ - **New: `calculateFilteredExposureTime({ shutterSeconds, filter, shutterDialSeconds?, wavelengthNanometers?, wavelengthBasis?, incidentPolarization? })`** (model `filtered-exposure-time` 1.0.0, for the app's Guide page on ND filters and the calculator's ND table): the shutter time that keeps the same exposure at the same f-number and ISO after adding front-of-lens filters, `shutterSeconds / T`, with `T` from the existing filter profiles through `composeFrontOfLensFilterTransmission` (one profile or a stack). Returns `filteredShutterSeconds`, `linearTransmissionFactor`, `attenuationStops`, `exposureFactor`, the full `filterTransmission` and, with a dial list, `dial` (nearest value in stops, shorter on a tie, its exposure error in stops, and `clamped` outside the dial). 1/125 s with a 10-stop filter gives 1024/125 ≈ 8.19 s, 8 s on `GENERIC_THIRD_STOP_SHUTTER_SECONDS`; an ND1000 (OD 3.0) gives 8 s. Exact under the linear sensor model (H = E·t, CIPA DC-004-2020 §5.5, EMVA 1288 §2); reciprocity failure is not modelled; nominal filters vary, and the envelope is an approximation whenever a profile is. Filters are resolved at 550 nm by default (neutral filters do not depend on it).
25
+ - **New: generic ND presets `GENERIC_ND_FILTER_PROFILES[3 | 6 | 10]` and `GENERIC_ND_FILTER_STOPS`** (owner decision 2026-11-09): frozen, unbranded front-of-lens filter profiles (`generic-nd-3-stop`, `generic-nd-6-stop`, `generic-nd-10-stop`) that transmit exactly 2^-N (1/8, 1/64, 1/1024; "N-stop" means exactly 2^-N), spectrally flat (`neutral-linear-transmission`) over 380–780 nm in air, and marked idealized (`scientificStatus: "approximation"`, uncertainty not quantified, evidence note "Idealized"). 1/125 s with the 10-stop preset gives exactly 8.192 s, 8 s on the third-stop dial.
26
+ - **Root exports:** 8 new (4 runtime): `calculateFilteredExposureTime`, `FILTERED_EXPOSURE_DEFAULT_WAVELENGTH_NANOMETERS`, `GENERIC_ND_FILTER_PROFILES`, `GENERIC_ND_FILTER_STOPS` and the types `CalculateFilteredExposureTimeInput`, `FilteredExposureTime`, `FilteredExposureDialSetting`, `GenericNdFilterStops`. Pure function and constant data; no capture, RAW or bundle change.
27
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Exposure time with an ND filter"), docs/USAGE.md (with a runnable example; later USAGE.md fragment indices in docs/validation/documentation-fragments.json shift by one), docs/AGENT_GUIDE.md, llms.txt, API reference task index; `test/filtered-exposure-time.test.ts` (including exact preset values, flatness, freezing and parser round trip).
28
+
29
+ ### #583 Program mode: the program line follows the lens's f-number range
30
+
31
+ - **Changed (requests that 2.18.0 refused now resolve):** in `exposure.mode: "program"`, `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` limit the engine's generic program line (f/16 1/2000 s … f/8 1/125 s … f/4 1.024 s) to the lens's f-number range when the range does not hold f/4–f/16, where 2.18.0 refused it ("Program-line aperture lies outside the resolved equipment capability range."). This lifts the 2.18.0 limitation for `lens.ideal` `widestFNumber`/`narrowestFNumber` (#576; deferred to 2.19.0 by the owner on 2026-11-09), and applies equally to the `lens.prime` and `lens.catalogue` ranges that do not hold the line: the 800 and 1200 mm-equivalent primes, *PV 800 5.6 Pro*, *PV 1200 8 Pro*, *PV 200 5.6*, *PV 300 5.6*, *PV 400 5.6*, *PV 500 5.6*, *PV 600 6.3* and *PV 800 9*.
32
+ - **Model (an approximation; a generic line, not a manufacturer's program):** the line's f/4 and f/16 are clamped to the range, giving the same shape: the aperture held at the range's end and the shutter keeping each point's exposure (t/N², camera equation H = 0.65·L·t/N²), still from 1/2000 s to 1.024 s. Where both lines run, the aperture is the old line's clamped in log2 to the range; beyond the line's ends the resolver holds them as before (Auto ISO raises the ISO; a fixed ISO under- or overexposes, reported by `standardExposureDeviationStops`). Example: f/8–f/32 gives f/16 1/2000 s, f/16 1/125 s, f/8 1/125 s, f/8 1.024 s. The `exposure.programLine` assumption records a limited line's profile (id `"facade-generic-program-line-lens-range"`, with its nodes) (`defaultProgramLine` in `src/facade/simulate-capture.ts`).
33
+ - **Unchanged:** requests with no range, and every lens whose range holds f/4–f/16, keep the line, its assumption and their bytes (pinned SHA-256 digests of samples, RAW codes, exposure and assumptions from 3f9d65b for the tier lens, Auto ISO, `lens.ideal` without and with a holding range, `lens.prime: 50` and `pv-50-1.8`). No root export added; no `src/gpu` change. The app's capture-worker shape measured 351,989 B gzip with this part alone (2.18.0: 351,859 B; limit 352,000 B, not raised).
34
+ - **Owner decision (2026-11-09):** keep the line's 1/2000–1.024 s span; the limited line applies to `lens.prime` and `lens.catalogue` ranges as well as `lens.ideal`.
35
+ - **Tests:** `test/program-line-lens-range.test.ts` (byte pins; the limited line equals the old line clamped to the range at the same exposure at 201 points for ten ranges, from 1/2000 s to 1.024 s; program resolves within the range for seven ideal ranges at 5, 2000 and 200,000 lx; the 800 mm prime and *PV 800 9*); `test/ideal-lens.test.ts` updated (the refusal is now an exposure).
36
+ - **Docs:** docs/QUICK_START.md ("Program with a range"), docs/USAGE.md ("The capture facade's program line and the lens's f-number range").
37
+
38
+ ### #585 lens.ideal: a range narrower than about f/1 is refused in every exposure mode
39
+
40
+ - **Fix (requests that 2.18.0 refused now resolve):** a `lens.ideal` range whose narrowest f-number N0 is below about f/1 (for example f/0.5–f/0.9) was refused in every exposure mode ("referenceExposure.shutterSeconds lies outside the resolved equipment capability range."): #569's exposure anchor at the range's aperture nearest f/8 with the same N²/t needs t = 1/125 s × (N0/8)², shorter than the body's shortest shutter (1/9877 s at f/0.9 against 1/8000 s). The anchor now takes the body's shortest shutter when that t is shorter, a lower N²/t. The anchor is only where the stops are measured from (CIPA DC-004 reference luminance from the camera equation at the anchor, the meter's relative signals and the resolvers all use it), so exposures are the same as with an f/8 anchor: aperture and shutter priority at f/0.7 and 1/4000 s resolve the open ideal lens's settings to 1e-12. The #583 program line's node stops are measured from the anchor. The `exposure.referenceExposure` explanation says "the shortest shutter at f/N0" in that case. Owner decision 2026-11-09: fix in 2.19.0.
41
+ - **Unchanged:** every request that resolved before gives the same bytes (SHA-256 pins of samples, RAW codes, exposure and assumptions for ten requests, including anchors off f/8 at f/9 and f/4 and a fixed f/1 lens). The app's capture-worker shape measures 351,990 B gzip (limit 352,000 B, not raised); the fix's code was paid for by sharing the range clamp between the anchor, the program line and the f-number check, and by a shorter program-line node table.
42
+ - **Tests:** `test/program-line-lens-range.test.ts` (f/0.5–f/0.9 in program with fixed and Auto ISO, aperture priority, shutter priority and manual; the pins).
43
+ - **Docs:** docs/QUICK_START.md ("An optional f-number range"), docs/USAGE.md.
44
+
45
+ ### #571 Develop joined parts: whole-image sharpening and diffraction correction
46
+
47
+ - **New: `simulateCaptureFromParts(options, parts)`** (#571, for Photivra/apps#253's "Applying … to the whole image" step): develops the whole frame of `options` from region renders of the same request (the `SimulateCaptureDngPart` records `serializeSimulatedCaptureDngFromParts` takes, joined by the same code with the same checks), including `output.diffractionCorrection` and `output.sharpening`, which a region refuses. The joined RAW is the whole render's RAW, so `image.samples`, the JPEG or PNG bytes, `raw` and `plan` equal `simulateCapture(options)` bit for bit. Differences, documented: `diagnostics.providerEvaluationCount` is 0, `timingsMs` are the call's own (`raw` is the join), `diagnostics.fast` and the `raw.*` assumptions stay in the parts' results, and a new `parts` assumption (`{ partCount }`) records the join.
48
+ - **Progress:** `SimulateCaptureProgress.stage` gains `"diffraction-correction"` and `"sharpening"`, one event per output row of the whole image (last event `completedTileCount === totalTileCount`), reported by `simulateCapture` too when those options are on; the stages yield to the host about every 8 ms, so `signal` now cancels them. `simulateCaptureFromParts` reports no `raw` stage. Code with an exhaustive `switch` over `stage` sees two new values.
49
+ - **Memory:** the detail stages run row by row in place (`correctDiffractionInPlace`: an (R + 1)-row ring; `renderSdrWithUnsharpMaskInPlace`: each row encoded in place by the native SDR kernel, which takes `maximum` 0 for unquantized output, and a (2r + 1)-row ring of the horizontal pass), with the same sums in the same order: one developed image in memory instead of up to six. At 24 MP the whole `simulateCapture` with both stages peaked at 924 MiB (2.18.0: 3,670 MB, resident set sampled every 50 ms). Captures, files, diagnostics and assumptions of every request without `simulateCaptureFromParts` are unchanged (byte comparison of six detail requests against 2.18.0's build: JPEG, 16-bit, thresholded mask, Display P3 PNG, AHD with auto white balance and a push, a 3 × 2 image with σ = 8).
50
+ - **Measured at 24 MP** (6000 × 4000 at 6 µm, slanted edge at f/8 with `lens.diffraction`, 8 parts; Apple M5 Pro, Node.js 24.19.0, one thread, shared machine, two runs; `node scripts/benchmark-joined-development.mjs`): join and develop 1.9–2.2 s and 532–551 MiB peak without a detail stage; with sharpening 3.3–3.7 s, with diffraction correction (R = 3) 3.7–3.9 s, with both 5.1–5.4 s (development 1.2 s, correction 2.0–2.1 s, sharpening 1.7–1.8 s), 1,079–1,080 MiB peak each; the whole-frame `simulateCapture` with both took 10.0–10.4 s. The peak includes the parts' RAW and the joined RAW (7 bytes per native site each) and one 64-bit developed image (549 MiB).
51
+ - **Root exports:** 1 new (runtime), 1,850 → 1,851: `simulateCaptureFromParts` (new module `src/facade/develop-joined-parts.ts`), named in the `simulate*` family of docs/API_STYLE.md (orchestration returning a `SimulateCaptureResult`), since "develop" is not one of the documented verb families. Messages of the parts join now read "Part k: …" and "The parts …" (were "DNG part k: …" and "The DNG parts …").
52
+ - **Decisions (coordinator, 2026-11-09):** the name `simulateCaptureFromParts`; the extra Native performance workflow run that the change to `src/output/native-capture-sdr.ts` triggers is accepted; the two new progress stages count as additive (code with an exhaustive `switch` over `stage` must add them).
53
+ - **Bundle (part branch):** the app's capture-worker shape was 351,929 B gzip (2.18.0: 351,852 B; limit 352,000 B, unchanged); with `simulateCaptureFromParts` added to the worker's imports, 351,937 B. The row-by-row stages add about 250 B; reusing the native SDR kernel for the unsharp mask's encoding and sharing the facade's row-progress and wide-gamut encoding code save about 170 B.
54
+ - **Tests:** `test/simulate-capture-develop-parts.test.ts` (whole frame against joined parts byte for byte on six requests: JPEG with both stages, 16-bit with a thresholded mask, AHD with auto white balance, a push and the four-spot filter, Display P3 PNG with correction, DNG with both stages, no stage; progress order and rows; refusals and abort; the in-place stages against whole-image passes on odd sizes, R up to 16 on a 12 × 20 image).
55
+ - **Docs:** docs/QUICK_START.md ("Developing a capture rendered in parts (#571)", the `onProgress` row, the sharpening section), docs/USAGE.md; API reference regenerated.
56
+
57
+ ### #570 Development option: apply lens corrections to the JPEG
58
+
59
+ - **New: `output.lensCorrections: { distortion?, lateralColor?, vignetting? }`** (#570, for Photivra/apps#253 story 3.1), with `lens.catalogue`: the lens's generic correction profile (`createGenericLensCorrectionProfile`, #549) applied to the developed linear colour (after demosaic, colour, white balance and `diffractionCorrection`; before the tone curve and `sharpening`), for the JPEG, PNG and `image.samples`. Each correction is off unless `true`; all off (or omitted) develops byte for byte as before. **The RAW and the DNG are never corrected** (tested: identical DNG bytes).
60
+ - **Model** (labelled approximation, the new `output.lensCorrections` assumption): inverse mapping s = q(1 + k1|q|²/R²), q = m·a_c·p, bilinear, one resampling per channel; the image keeps its size and is magnified by 1/m, the smallest that leaves no missing pixel (1 for the barrel wide angles, 1.017 for *PV 300 5.6*). Vignetting is corrected to a flat field with det J/E at the source point, E the lens's relative illumination and 1/det J its distortion's area factor, which the capture applies (flat to 0.03 %; E alone would leave a *PV 16 2.8* corner about 18 % bright). The kernel matches `calculateLensCorrectedCapture` to 1e-12 for distortion and lateral colour.
61
+ - **Regions and parts:** byte-identical to the whole frame (barrel, pincushion with fill, AHD 16-bit PNG). A region develops the frame pixels its corrected pixels read (`developedRect`; `region.nativeRect`, `raw` and `plan` describe that rectangle).
62
+ - **Trade-offs measured through the option** (#552's): distortion correction × 0.706 corner detail (centre × 0.998); vignetting correction raises corner signal × 2.44 and noise × 2.50 (SNR unchanged).
63
+ - **Not offered** by `simulateDisplayView` or `simulateIdealReference` (refused by name); refused without `lens.catalogue`.
64
+ - **Cost:** about 1.3 s per 24 MP frame on one thread (Apple M5 Pro), loaded with the catalogue chunk on demand.
65
+ - **API:** one new root type, `SimulateCaptureLensCorrections`; `SIMULATE_CAPTURE_COST_OPTIONS` gains `"output.lensCorrections"`. `GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM` now lives in `src/output/developed-lens-corrections.ts` (same value, still a root export).
66
+ - **Owner decisions (2026-11-09):** zoom to fill (no missing pixels, no change of output size); the vignetting correction includes the distortion's area factor (the correction profile's own vignetting component, which holds E only, is follow-up #584); the display view refuses `output.lensCorrections` for now; the region rectangles (`developedRect` in the assumption; `region.nativeRect`, `raw` and `plan` for the developed rectangle) are accepted as they are.
67
+ - **Bundle:** on its own the part grew the app's capture-worker shape by 1,735 B gzip (entry +756 B, catalogue chunk +979 B), over the 352,000 B limit; the integration's shared helpers bring the release under it (see the 2.19.0 summary). The limit is not raised.
68
+ - **Docs and tests:** docs/DIGITAL_OPTICS_FOUNDATION.md ("Lens corrections in the development"), docs/QUICK_START.md, docs/USAGE.md; `test/simulate-capture-lens-corrections.test.ts`.
69
+
70
+ ## 2.18.0 — published November 9, 2026
71
+
72
+ A minor release of four issues: equivalence numbers for teaching (#573), an ideal lens at any physical focal length and f-number (#576), catalogue lenses in the display view (#569) and fast catalogue lens captures (#568). Captures, RAW codes, files and assumptions are the same as 2.17.0 for every request that 2.17.0 renders and that uses none of the new options (every hash lock holds).
73
+ - **New:** `calculateSensorEquivalence` and `calculateBlurShareOfFrame` (#573); `lens.ideal: { focalLengthMm, widestFNumber?, narrowestFNumber? }` and `IDEAL_LENS_LIMITS` (#576; the optional f-number range added at the owner's request, 2026-11-09); `simulateDisplayView` and `prepareDisplayView` render `lens.catalogue` (#569); a fast method for `lens.catalogue` behind `fast` or `lens.catalogue: { kind, fast }` (#568).
74
+ - **Fixes** (requests that 2.17.0 refused): `subjectDistanceM` may equal twice the focal length, so the Macro's 1:1 close focus renders (#569); a lens whose range excludes f/8 (*PV 800 9*, f/9–f/32) takes the reference exposure at the aperture of its range nearest f/8 with the same N²/t, where every request with it failed (#569).
75
+ - **Root exports:** 13 new, 1,837 → 1,850 (runtime exports 623 → 626): 11 from #573 (2 runtime), 1 from #576 (runtime), 1 from #568 (type). #569 adds none.
76
+ - **Why minor:** new root exports and new opt-in options; requests that 2.17.0 refused now render (the display view with `lens.catalogue`, a subject at twice the focal length, *PV 800 9*). Nothing is removed or changed incompatibly, and no default changes. No `src/gpu` change.
77
+ - **Bundle:** the app's capture-worker shape (every chunk of `NATIVE_RAW_LIMITS`, `prepareDisplayView`, `serializeSimulatedCaptureDngFromParts`, `simulateCapture`, `simulateIdealReference`) is 351,859 B gzip (2.17.0: 347,775 B): #568's lazily loaded fast catalogue code +2,907 B, #569's display-view catalogue PSF +550 B, the entry +397 B (the three parts' option handling), the ideal lens's f-number range +147 B. The integration saves 19 B (one closure for the tabulated paths' tables; unused grid axes removed). `test/capture-bundle.test.ts`'s limit is raised from 348,000 to 352,000 B (owner-approved 2026-11-09 for engine 2.18.0, #568's fast catalogue chunk). The display view adds 45,807 B minified and 18,128 B gzip to a capture bundle (2.17.0: 45,791 B and 18,159 B; budgets 46,500 B and 18,550 B, unchanged).
78
+ - **Moved to 2.19.0:** #570, #571 and #566 (the owner's decision of 2026-11-09 to publish these four parts now; #566's breaking orientation fix, decided for 2.18.0 on 2026-11-07, moves with them).
79
+
80
+ ### #573 Equivalence for teaching: equivalent f-number, sensor area ratio and blur as a share of the frame
81
+
82
+ - **New: `calculateSensorEquivalence({ sensor, focalLengthMm, fNumber, reference? })`** (model `sensor-equivalence` 1.0.0): for a lens on one sensor against 36 × 24 mm (or a `reference`), the crop factor, equivalent focal length, equivalent f-number (k·N, for depth of field, background blur and total light at the same framing and output size), the unchanged exposure f-number, entrance-pupil diameter, sensor area ratio, total-light difference in stops at the same f-number and shutter, and horizontal, vertical and diagonal angles of view. A `basis` record states, for every value, `"exact"` or `"approximation"` and its conditions; the equivalent f-number is a thin-lens approximation, exact only when the focus distance is much greater than the focal length (the exact thin-lens factor k·N·(s − f)/(s − k·f) is stated and tested). 50 mm f/2 on 23.5 × 15.6 mm gives ≈ 76.7 mm and ≈ f/3.07. With the default reference the crop factor and equivalent focal length are bit-identical to `calculateEquivalentFocalLength35Mm`.
83
+ - **New: `calculateBlurShareOfFrame({ blurCircleMm, sensor, print? })`** (model `blur-share-of-frame` 1.0.0): a blur-circle diameter as a share of the frame diagonal, width and height, and with a print size, the enlargement (whole frame fitted, long side to long side) and the blur on the print.
84
+ - **Root exports:** 11 new (2 runtime): the two functions and the types `CalculateSensorEquivalenceInput`, `SensorEquivalence`, `SensorEquivalenceFieldOfView`, `SensorEquivalenceQuantity`, `EquivalenceExactness`, `EquivalenceValueBasis`, `CalculateBlurShareOfFrameInput`, `BlurShareOfFrame`, `BlurShareOfPrint`. Pure functions; no capture, RAW or bundle change.
85
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Sensor equivalence: f-number, area and blur share"), docs/USAGE.md, docs/AGENT_GUIDE.md, llms.txt, API reference task index; `test/sensor-equivalence.test.ts` (hand-checked values, the thin-lens check of the approximation, validation).
86
+
87
+ ### #576 Ideal lens at any physical focal length and f-number (capture and display view)
88
+
89
+ - **New: `lens.ideal: { focalLengthMm }` (#576, for Photivra/apps#246, #250 and #251).** An ideal lens, an aberration-free thin lens named by its physical focal length, on whatever sensor the simulated frame is (no crop-factor scaling, unlike `lens.prime` and `lens.catalogue`), at any `lens.fNumber` from f/0.5 to f/128 and focal lengths of 1–4000 mm. f/0.5 is the physical limit of an aplanatic lens in air (the sine condition, N = 1/(2·sin θ′)); the other bounds are engine bounds. Values beyond them are refused by name (`lens.ideal.focalLengthMm must be 1–4000 mm (IDEAL_LENS_LIMITS).`, `lens.fNumber 0.45 is outside the ideal-50mm range f/0.5–f/128.`).
90
+ - **Model:** it replaces the tier lens's focal length and f-number range, as `lens.prime` does (the tier lens's transmission is kept); its blur is that of `lens.defocus`, `lens.diffraction` and `lens.vignetting`, the same models. No minimum focus distance beyond the thin-lens limits (subject at least twice the focal length away, with #569). Shutter priority and program may choose any aperture in f/0.5–f/128. Recorded in the new `lens.ideal` assumption (`{ lensId: "ideal-50mm", focalLengthMm: 50 }`).
91
+ - **Optional f-number range (owner, 2026-11-09):** `lens.ideal.widestFNumber` and `narrowestFNumber`, within `IDEAL_LENS_LIMITS` and widest ≤ narrowest (each defaults to its bound), narrow the range like a real lens's: `lens.fNumber` must lie in it and shutter priority chooses apertures only in it (1/4000 s, ISO 100 in 2000 lx: wider than f/1.4 over the whole range, f/1.4 with `widestFNumber: 1.4`). Program follows the engine's program line (f/4–f/16) and refuses a range that does not hold it, as for `lens.prime` and `lens.catalogue`. The assumption records the range only when given, so requests without it are unchanged. Refused: `lens.ideal.widestFNumber and narrowestFNumber must be f-numbers in f/0.5–f/128 (IDEAL_LENS_LIMITS), the widest no larger than the narrowest.`
92
+ - **Where:** `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` (it goes through the shared request path). Not combinable with `lens.prime`, `lens.catalogue` or `lens.focalLengthMm`.
93
+ - **Tests** (`test/ideal-lens.test.ts`): 50 mm f/1.8 focused at 2.5 m with an edge at 3 m on full frame (7200 × 4800) and APS-C 23.5 × 15.6 mm (4700 × 3120) at 5 µm: the blur is `calculateDefocusCircle`'s 94.48 µm on both; the display view of the defocused edge equals the view of the disc's closed-form edge-spread function within 2.2e-14, and the capture equals the capture of that closed form byte for byte. Same pixels as the tier lens (`lens.focalLengthMm: 50`, f/4; with defocus and diffraction) and as the 50 mm-equivalent prime on a 36 × 24 mm frame at f/1.8. Bounds, every exposure mode and refusals.
94
+ - **API:** new root export `IDEAL_LENS_LIMITS` (runtime). `SimulateCaptureOptions.lens` gains `ideal`. The f-number range check of `lens.prime` and `lens.catalogue` now shares one code path (same messages).
95
+ - **Compatibility:** requests without `lens.ideal` give the same captures, RAW codes, files and assumptions. No `src/gpu` change.
96
+ - **Docs:** docs/QUICK_START.md ("Ideal lens at any focal length and f-number"), docs/USAGE.md, docs/AGENT_GUIDE.md; API reference regenerated.
97
+
98
+ ### #569 Display view: render catalogue lenses
99
+
100
+ - **New: the display view renders `lens.catalogue` (#569, for Photivra/apps#251 and #252).** `simulateDisplayView` and `prepareDisplayView` accept the 30 generic catalogue lenses at every f-number of each lens's range and down to its close focus, with `lens.defocus` and `lens.focusScatter`, where 2.17.0 refused `lens.catalogue`. The lens's distortion, vignetting and fisheye projection go through #563's lens signal; its PSF is taken per field zone.
101
+ - **Model (an approximation, the new `view.lensPsf` assumption):** the native raster is cut into square zones of ⌈max(W, H) / 64⌉ sites (94 on 6000 × 4000; one site on frames of at most 64 sites a side), and every site of a zone takes the PSF the capture's own resolver gives at the zone's centre, at each spectral node; every tap reads the scene through the lens with its own field factor, as the capture's environment PSF quadrature does. Catalogue fisheyes see zero radiance beyond 90°, as in the capture. `createDisplayViewLensPsf` in `src/facade/display-view-lens-signal.ts`, loaded on demand; the zones' tables are the expected signal's own (`buildTables`, now exported internally).
102
+ - **Accuracy** (per site, against the capture's own site record and PSF quadrature): ≤ 2e-15 with one-site zones; 2.5e-3 with the box aperture, four-spot filter and defocus (the capture resolves the PSF at each query point); on a whole 6 µm full frame at most 0.66 % per site and 1.2e-4 on average over four hard lenses; all 30 lenses at their widest and narrowest f-number at close focus within 1 %. A slanted edge against the box-downsized capture is within the rectilinear test's 0.6-code RMS and 3-code maximum. Siemens-star boundary ties behave as with `lens.diffraction` (#565).
103
+ - **Cost** on the app's request (24 MP, 2028 × 1352 in 96 × 96 parts, one thread, loaded host): part medians 0.2–0.8 s for most lenses (1.9 s for the Macro at f/22) against 50–80 ms without a lens; 1.9–39× the no-lens view per display pixel (docs/DISPLAY_VIEW.md, "Catalogue lenses"). The CPU renders these requests; `gpu: "auto"` falls back, labelled `unsupported-on-backend`. `scripts/benchmark-display-view.mjs` gains `LENS=catalogue:<id>[:open|:<f-number>]`.
104
+ - **Capture fixes** (only requests that failed before change): `subjectDistanceM` may equal twice the focal length (the Macro's 1:1 close focus was refused); a lens whose range excludes f/8 (*PV 800 9*) takes the reference exposure at the aperture of its range nearest f/8 (its widest for *PV 800 9*) with the same N²/t, where every request with it failed.
105
+ - **Unchanged:** every request without `lens.catalogue` gives the same bytes; no root export is added; no `src/gpu` change. `lensGeometry`'s image-space mapping (#548) now honours `outsideImageCircle: "backward-ray"`, which only catalogue fisheyes set.
106
+ - **Docs and tests:** docs/DISPLAY_VIEW.md ("Catalogue lenses"), docs/LENS_CATALOGUE.md, docs/QUICK_START.md; `test/display-view-lens-catalogue.test.ts`.
107
+
108
+ ### #568 Fast catalogue lens captures for interactive use
109
+
110
+ - **New: a fast method for `lens.catalogue` (#568, for Photivra/apps#251).** `fast: true` on a `simulateCapture` request, or the record form `lens: { catalogue: { kind: "<lens id>", fast }, fNumber }` (whose `fast` overrides the top-level flag), renders a catalogue lens from tabulated expected electrons instead of the environment PSF quadrature's per-tap scene queries. It is the first option with a fast method under the #395 convention: `lens.catalogue` joins `SIMULATE_CAPTURE_FAST_OPTIONS`, and without the flag the exact method runs unchanged.
111
+ - **Approximation (labeled):** the catalogue PSF is resolved exactly on a field grid (at least 4 cells between neighboring profile field nodes, more where the node kernels' centroids move by over 0.05 site per cell) and each site takes the bilinear blend of its four grid points' kernels instead of its own; distortion, vignetting and the fisheye projection are the exact method's. Reported in `diagnostics.fast` (`applied: { "lens.catalogue": true }`, `assurance: "approximation-with-a-priori-bound"`), the new `fast.lens.catalogue` assumption and `raw.expectedSignal` (method `tabulated-expected-electrons-catalogue-psf-field-grid`). One site per CFA phase runs the exact chain at run time and must agree within 0.5 %, or the exact method runs.
112
+ - **Accuracy against the exact method:** without the blend the sum equals the exact chain's within 1e-9; with it, site by site within 2.3e-4 of the frame's peak and 2.0e-3 of the site's own value (tested bounds 1e-3 and 5e-3); on the #549 charts the edge rise within 0.010 px, coma within 0.0001 px, lateral color within 0.003 px, distortion and vignetting unchanged (tested bounds 0.02 px and 0.005 stops).
113
+ - **Cost** (Apple M5 Pro, Node 24.19.0, one thread; Siemens star, whole full frame, f/8, PV 50 1.8 against `lens.prime` 50): 768 × 512 renders in 0.209 s against 0.145 s (1.44×; the issue's target was 2×) and 185 s with the exact method; 384 × 256 in 0.084 s (exact 47.5 s). `node scripts/benchmark-catalogue-capture.mjs` reproduces it (docs/LENS_CATALOGUE.md, "Fast method").
114
+ - **Where:** a still camera, site-center queries (no anti-aliasing filter or box pixel aperture) and a chart or material scene, in whole frames and regions, with or without `lens.defocus`; elsewhere the exact method runs and `applied` is `false`. The display view renders `lens.catalogue` with #569's per-zone PSF and never takes the fast method (`applied` is `false` there).
115
+ - **API:** one new root type export, `SimulateCaptureLensCatalogueRecord`; `SimulateCaptureOptions.lens.catalogue` also takes the record; `SIMULATE_CAPTURE_FAST_OPTIONS` gains `lens.catalogue`. Internal: `renderTabulatedNativeRaw` takes an optional verification tolerance.
116
+ - **Compatibility:** every request without `fast: true` (or the record's `fast`) renders byte-identically to 2.17.0. A request that already passed `fast: true` with `lens.catalogue` now takes the fast method.
117
+
118
+ ## 2.17.0 — published November 8, 2026
4
119
 
5
120
  A minor release of four issues: large defocus discs (#564), bokeh character (#553), lens projection, distortion, vignetting and rotation in the display view (#563), and the generic lens catalogue (#549). Captures, RAW codes, files and assumptions are the same as 2.16.0 for every request that 2.16.0 renders and that uses none of the new options (every hash lock holds).
6
121
  - **New:** `lens.defocus` up to a kernel radius of 512 sites (#564); `lens.defocus.blades`, `sphericalAberration` and `catsEye` and `calculateDefocusBokehKernel` (#553); the display view with `lens.projection`, `lens.distortion`, `lens.vignetting` and global-shutter rotation (#563); `lens.catalogue`, `lens.focusScatter` and the 30-lens `GENERIC_LENS_CATALOGUE`, each lens with a short `summary` (#549).
package/CITATION.cff CHANGED
@@ -9,7 +9,7 @@ abstract: "An open TypeScript engine for the science and mathematics of photogra
9
9
  repository-code: "https://github.com/photivra/photivra"
10
10
  url: "https://photivra.com"
11
11
  license: "Apache-2.0"
12
- version: "2.17.0"
12
+ version: "2.19.0"
13
13
  keywords:
14
14
  - photography
15
15
  - camera optics
package/README.md CHANGED
@@ -32,10 +32,10 @@ The source is licensed under Apache-2.0.
32
32
 
33
33
  ## Quick start
34
34
 
35
- Install the published ESM package below. The [2.17.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_17_0.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
35
+ Install the published ESM package below. The [2.19.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_19_0.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
36
36
 
37
37
  ```sh
38
- npm install @photivra/engine@2.17.0
38
+ npm install @photivra/engine@2.19.0
39
39
  ```
40
40
 
41
41
  Simulate a photograph of a test chart with a generic camera, then read the developed sRGB pixels:
@@ -75,7 +75,7 @@ console.log(horizontal.provenance);
75
75
 
76
76
  Start with the [Developer Guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Then use the [Usage Guide](https://github.com/Photivra/photivra/blob/main/docs/USAGE.md) and the [API reference](https://github.com/Photivra/photivra/blob/main/docs/API_REFERENCE.md), which is generated from the package exports.
77
77
 
78
- Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.17.0` tag.
78
+ Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.19.0` tag.
79
79
 
80
80
  ## Documentation
81
81
 
@@ -363,13 +363,13 @@ The application can then use those results, while the photography calculations t
363
363
 
364
364
  ## Status
365
365
 
366
- - Repository package version: `2.17.0` (release candidate prepared November 7, 2026; publication is separate)
367
- - Engine API contract: `2.17.0`
366
+ - Repository package version: `2.19.0` (release candidate prepared November 9, 2026; publication is separate)
367
+ - Engine API contract: `2.19.0`
368
368
  - Versioned generic tier reference assets: [GENERIC_TIER_PRESETS.md](https://github.com/Photivra/photivra/blob/main/docs/GENERIC_TIER_PRESETS.md).
369
369
  - Composed POC simulation API contract: `1.0.0`
370
370
  - Stability: 1.0 root-package compatibility policy; bounded scientific models and independent POC contract
371
371
 
372
- From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.17.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
372
+ From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.19.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
373
 
374
374
  A breaking change to the root contracts now needs a new package major version. Additive and corrective changes follow [compatibility and migration](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_1_3_0.md). A 1.0 package is not a claim of calibrated physical accuracy.
375
375
 
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ Please do not open a public issue that contains vulnerability details.
4
4
 
5
5
  ## Supported versions
6
6
 
7
- The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.16.0**, published on November 7, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
7
+ The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.18.0**, published on November 9, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
8
8
 
9
9
  Security fixes target the current maintained development and release line. Do not assume that older 1.x releases, snapshots or pre-1.0 releases will receive fixes.
10
10
 
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Format equivalence for teaching: what a lens of focal length f at f-number N on one sensor
3
+ * corresponds to on a reference format (36 × 24 mm by default), and how large a blur circle is
4
+ * as a share of the frame or of a print.
5
+ *
6
+ * With the diagonal crop factor k = referenceDiagonal / sensorDiagonal:
7
+ * - equivalent focal length k·f (same diagonal angle of view; exact by the diagonal convention);
8
+ * - equivalent f-number k·N for depth of field, background blur and total light (same framing,
9
+ * same output size). It keeps the entrance-pupil diameter f/N unchanged. It is an
10
+ * approximation: in the thin-lens defocus-circle equation it is exact only when the focus
11
+ * distance is much greater than the focal length;
12
+ * - the f-number for exposure stays N: image illuminance depends on N, not on the sensor size;
13
+ * - sensor area ratio and the total-light difference in stops at the same f-number and shutter.
14
+ *
15
+ * Sources (free): thin-lens defocus-circle diameter f²|s − d|/(N d (s − f)) and f-number N = f/D,
16
+ * Greivenkamp, OPTI-502 course notes, sec. 12 (Objectives),
17
+ * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-12-Objectives.pdf;
18
+ * image illuminance proportional to 1/N², sec. 11 (Radiative transfer),
19
+ * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-11-Radiative-Transfer.pdf.
20
+ * @see docs/PHYSICS_FOUNDATION.md "Sensor equivalence" for the derivation, the exact and
21
+ * approximated quantities, and support limits.
22
+ */
23
+ import { type CalculationResult } from "../core/calculation-result.js";
24
+ import { type SensorImagingArea } from "../sensor/sensor-geometry.js";
25
+ /** Whether a value is exact under its stated conditions or an approximation. */
26
+ export type EquivalenceExactness = "exact" | "approximation";
27
+ /** How one equivalence quantity was obtained, and under which conditions it holds. */
28
+ export interface EquivalenceValueBasis {
29
+ /** `"exact"` under the stated conditions, or `"approximation"`. */
30
+ kind: EquivalenceExactness;
31
+ /** Plain statement of the definition and its conditions. */
32
+ statement: string;
33
+ }
34
+ export interface CalculateSensorEquivalenceInput {
35
+ /** Physical active imaging area of the sensor the lens is used on. */
36
+ sensor: SensorImagingArea;
37
+ /** Physical focal length of the lens in millimetres. */
38
+ focalLengthMm: number;
39
+ /** f-number set on the lens (N = focal length / entrance-pupil diameter). */
40
+ fNumber: number;
41
+ /** Format to compare against. Defaults to the 36 × 24 mm frame. */
42
+ reference?: SensorImagingArea;
43
+ }
44
+ /** Angles of view of an ideal rectilinear lens focused at infinity, in degrees. */
45
+ export interface SensorEquivalenceFieldOfView {
46
+ horizontalDegrees: number;
47
+ verticalDegrees: number;
48
+ diagonalDegrees: number;
49
+ }
50
+ /** Quantities of a sensor equivalence result that carry an exactness statement. */
51
+ export type SensorEquivalenceQuantity = "cropFactor" | "equivalentFocalLengthMm" | "equivalentFNumber" | "exposureFNumber" | "entrancePupilDiameterMm" | "areaRatio" | "totalLightDifferenceStops" | "fieldOfView";
52
+ export interface SensorEquivalence {
53
+ /** Physical focal length supplied by the caller, in millimetres. */
54
+ focalLengthMm: number;
55
+ /** f-number supplied by the caller. */
56
+ fNumber: number;
57
+ /** Sensor active imaging area used. */
58
+ sensor: SensorImagingArea;
59
+ /** Reference format used (36 × 24 mm unless the caller passed one). */
60
+ reference: SensorImagingArea;
61
+ /** Sensor diagonal in millimetres. */
62
+ sensorDiagonalMm: number;
63
+ /** Reference diagonal in millimetres. */
64
+ referenceDiagonalMm: number;
65
+ /** Diagonal crop factor: reference diagonal / sensor diagonal. Greater than 1 for a smaller sensor. */
66
+ cropFactor: number;
67
+ /** Focal length on the reference format with the same diagonal angle of view: cropFactor × focalLengthMm. */
68
+ equivalentFocalLengthMm: number;
69
+ /**
70
+ * f-number on the reference format that gives the same depth of field, background blur and total
71
+ * light for the same framing and output size: cropFactor × fNumber. Not an exposure setting.
72
+ */
73
+ equivalentFNumber: number;
74
+ /** f-number that sets exposure (image brightness): the f-number on the lens, unchanged by sensor size. */
75
+ exposureFNumber: number;
76
+ /** Entrance-pupil diameter f/N in millimetres; the equivalent lens has the same diameter. */
77
+ entrancePupilDiameterMm: number;
78
+ /** Sensor area / reference area. Less than 1 for a smaller sensor. */
79
+ areaRatio: number;
80
+ /**
81
+ * log2(areaRatio): total light collected by the sensor relative to the reference at the same
82
+ * f-number, shutter time and scene, in stops. Negative for a smaller sensor.
83
+ */
84
+ totalLightDifferenceStops: number;
85
+ /** Angles of view of this lens on the sensor (the equivalent lens gives the same diagonal angle). */
86
+ fieldOfView: SensorEquivalenceFieldOfView;
87
+ /** Exact or approximated, with the conditions, for every quantity above. */
88
+ basis: Readonly<Record<SensorEquivalenceQuantity, EquivalenceValueBasis>>;
89
+ }
90
+ /**
91
+ * Equivalence of a lens (focal length, f-number) on one sensor against a reference format,
92
+ * 36 × 24 mm by default: crop factor, equivalent focal length, equivalent f-number for depth of
93
+ * field, blur and total light, the unchanged f-number for exposure, sensor area ratio, total-light
94
+ * difference in stops and the angles of view.
95
+ *
96
+ * Call it once per sensor for a lens table. Every quantity carries a `basis` entry saying whether it
97
+ * is exact under its stated conditions or an approximation. The equivalent f-number is an
98
+ * approximation (exact when the focus distance is much greater than the focal length) and never an
99
+ * exposure setting: exposure uses `exposureFNumber`, the f-number on the lens.
100
+ *
101
+ * Example: 50 mm f/2 on a 23.5 × 15.6 mm sensor gives crop factor ≈ 1.53, ≈ 77 mm and ≈ f/3.1.
102
+ *
103
+ * @param input Sensor active area, focal length, f-number and optional reference format.
104
+ * @returns The equivalence numbers with a per-quantity exactness statement.
105
+ */
106
+ export declare function calculateSensorEquivalence(input: CalculateSensorEquivalenceInput): CalculationResult<SensorEquivalence>;
107
+ export interface CalculateBlurShareOfFrameInput {
108
+ /** Blur-circle diameter on the sensor in millimetres (for example from calculateDefocusCircle). Zero is allowed. */
109
+ blurCircleMm: number;
110
+ /** Physical active imaging area of the sensor. */
111
+ sensor: SensorImagingArea;
112
+ /**
113
+ * Optional print size. The whole frame is enlarged to fit inside it without cropping, long side
114
+ * to long side.
115
+ */
116
+ print?: {
117
+ widthMm: number;
118
+ heightMm: number;
119
+ };
120
+ }
121
+ export interface BlurShareOfPrint {
122
+ /** Linear enlargement from sensor to print: the smaller of the long-side and short-side ratios. */
123
+ enlargement: number;
124
+ /** Blur-circle diameter on the print in millimetres: blurCircleMm × enlargement. */
125
+ blurOnPrintMm: number;
126
+ /** Size of the printed image in millimetres (equal to the print when the aspect ratios match). */
127
+ imageLongSideMm: number;
128
+ imageShortSideMm: number;
129
+ }
130
+ export interface BlurShareOfFrame {
131
+ /** Blur-circle diameter on the sensor in millimetres. */
132
+ blurCircleMm: number;
133
+ /** Sensor diagonal in millimetres. */
134
+ frameDiagonalMm: number;
135
+ /** Blur diameter / frame diagonal. Equal shares look the same at the same output size. */
136
+ shareOfDiagonal: number;
137
+ /** Blur diameter / frame width. */
138
+ shareOfWidth: number;
139
+ /** Blur diameter / frame height. */
140
+ shareOfHeight: number;
141
+ /** Blur on the print; present only when a print size was given. */
142
+ print?: BlurShareOfPrint;
143
+ }
144
+ /**
145
+ * A blur circle on the sensor as a share of the frame (diagonal, width and height) and, when a
146
+ * print size is given, its diameter on that print.
147
+ *
148
+ * Equal shares of the diagonal look the same when the images are shown at the same size, which
149
+ * is how "same framing" comparisons across sensor sizes are stated. Every value is an exact ratio
150
+ * or scaling; whether a blur is visible depends on viewing distance and acuity, which this
151
+ * function does not judge.
152
+ *
153
+ * @param input Blur diameter in millimetres, sensor active area and optional print size.
154
+ * @returns Shares of the frame, and the blur on the print when a print is given.
155
+ */
156
+ export declare function calculateBlurShareOfFrame(input: CalculateBlurShareOfFrameInput): CalculationResult<BlurShareOfFrame>;
@@ -0,0 +1,208 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Format equivalence for teaching: what a lens of focal length f at f-number N on one sensor
4
+ * corresponds to on a reference format (36 × 24 mm by default), and how large a blur circle is
5
+ * as a share of the frame or of a print.
6
+ *
7
+ * With the diagonal crop factor k = referenceDiagonal / sensorDiagonal:
8
+ * - equivalent focal length k·f (same diagonal angle of view; exact by the diagonal convention);
9
+ * - equivalent f-number k·N for depth of field, background blur and total light (same framing,
10
+ * same output size). It keeps the entrance-pupil diameter f/N unchanged. It is an
11
+ * approximation: in the thin-lens defocus-circle equation it is exact only when the focus
12
+ * distance is much greater than the focal length;
13
+ * - the f-number for exposure stays N: image illuminance depends on N, not on the sensor size;
14
+ * - sensor area ratio and the total-light difference in stops at the same f-number and shutter.
15
+ *
16
+ * Sources (free): thin-lens defocus-circle diameter f²|s − d|/(N d (s − f)) and f-number N = f/D,
17
+ * Greivenkamp, OPTI-502 course notes, sec. 12 (Objectives),
18
+ * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-12-Objectives.pdf;
19
+ * image illuminance proportional to 1/N², sec. 11 (Radiative transfer),
20
+ * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-11-Radiative-Transfer.pdf.
21
+ * @see docs/PHYSICS_FOUNDATION.md "Sensor equivalence" for the derivation, the exact and
22
+ * approximated quantities, and support limits.
23
+ */
24
+ import { approximationResult, calculatedResult } from "../core/calculation-result.js";
25
+ import { InvalidScientificInputError, requirePositiveFinite } from "../core/validation.js";
26
+ import { portableHypot, portableLog2 } from "../core/portable-math.js";
27
+ import { requireInputKeys, requireInputObject } from "../core/record-validation.js";
28
+ import { calculateImagingAreaMetrics } from "../sensor/sensor-geometry.js";
29
+ /** The default equivalence reference: the 36 × 24 mm frame. */
30
+ const REFERENCE_FULL_FRAME = Object.freeze({ widthMm: 36, heightMm: 24 });
31
+ const SENSOR_EQUIVALENCE_BASIS = Object.freeze({
32
+ cropFactor: Object.freeze({
33
+ kind: "exact",
34
+ statement: "Ratio of the reference diagonal to the sensor diagonal (diagonal convention)."
35
+ }),
36
+ equivalentFocalLengthMm: Object.freeze({
37
+ kind: "exact",
38
+ statement: "Same diagonal angle of view on the reference format, for an ideal rectilinear lens focused at infinity. " +
39
+ "Horizontal and vertical angles match too only when the aspect ratios match."
40
+ }),
41
+ equivalentFNumber: Object.freeze({
42
+ kind: "approximation",
43
+ statement: "Same depth of field and background blur as a share of the frame, at the same subject distance, framing and output size, " +
44
+ "and the same entrance-pupil diameter (so the same total light at the same shutter time). In the thin-lens defocus equation it is exact " +
45
+ "only when the focus distance s is much greater than the focal length f; with focal lengths in the ratio cropFactor, the blur shares match " +
46
+ "at cropFactor·N·(s − f)/(s − cropFactor·f). " +
47
+ "Total light matches exactly only when the aspect ratios match. It does not set exposure."
48
+ }),
49
+ exposureFNumber: Object.freeze({
50
+ kind: "exact",
51
+ statement: "Image illuminance is proportional to 1/N², independent of sensor size (ideal lens; transmission, vignetting and " +
52
+ "close-focus bellows factor excluded). Equivalence never changes the exposure setting."
53
+ }),
54
+ entrancePupilDiameterMm: Object.freeze({
55
+ kind: "exact",
56
+ statement: "Entrance-pupil diameter D = f/N by the definition of the f-number. The equivalent lens (cropFactor·f at cropFactor·N) has the same D."
57
+ }),
58
+ areaRatio: Object.freeze({
59
+ kind: "exact",
60
+ statement: "Product of width and height of the sensor divided by that of the reference."
61
+ }),
62
+ totalLightDifferenceStops: Object.freeze({
63
+ kind: "exact",
64
+ statement: "log2 of the area ratio: at the same f-number, shutter time and scene the image illuminance is the same, so total light scales " +
65
+ "with area (uniform illuminance; vignetting excluded)."
66
+ }),
67
+ fieldOfView: Object.freeze({
68
+ kind: "exact",
69
+ statement: "2·atan(dimension / 2f) for an ideal rectilinear lens focused at infinity; distortion and focus breathing excluded."
70
+ })
71
+ });
72
+ function requireImagingArea(area, label) {
73
+ requireInputObject(area, label);
74
+ requireInputKeys(area, label, ["widthMm", "heightMm"]);
75
+ const record = area;
76
+ requirePositiveFinite(`${label}.widthMm`, record.widthMm);
77
+ requirePositiveFinite(`${label}.heightMm`, record.heightMm);
78
+ }
79
+ /** √((rw² + rh²)/(w² + h²)), with only correctly rounded operations when nothing overflows (portable). */
80
+ function diagonalRatio(reference, sensor) {
81
+ const referenceSquared = reference.widthMm * reference.widthMm + reference.heightMm * reference.heightMm;
82
+ const sensorSquared = sensor.widthMm * sensor.widthMm + sensor.heightMm * sensor.heightMm;
83
+ const ratio = referenceSquared / sensorSquared;
84
+ return Number.isFinite(referenceSquared) && Number.isFinite(sensorSquared) && sensorSquared > 0 && ratio > 0 && Number.isFinite(ratio)
85
+ ? Math.sqrt(ratio)
86
+ : portableHypot(reference.widthMm, reference.heightMm) / portableHypot(sensor.widthMm, sensor.heightMm);
87
+ }
88
+ function rectilinearAngleDegrees(dimensionMm, focalLengthMm) {
89
+ return (2 * Math.atan(dimensionMm / (2 * focalLengthMm)) * 180) / Math.PI;
90
+ }
91
+ /**
92
+ * Equivalence of a lens (focal length, f-number) on one sensor against a reference format,
93
+ * 36 × 24 mm by default: crop factor, equivalent focal length, equivalent f-number for depth of
94
+ * field, blur and total light, the unchanged f-number for exposure, sensor area ratio, total-light
95
+ * difference in stops and the angles of view.
96
+ *
97
+ * Call it once per sensor for a lens table. Every quantity carries a `basis` entry saying whether it
98
+ * is exact under its stated conditions or an approximation. The equivalent f-number is an
99
+ * approximation (exact when the focus distance is much greater than the focal length) and never an
100
+ * exposure setting: exposure uses `exposureFNumber`, the f-number on the lens.
101
+ *
102
+ * Example: 50 mm f/2 on a 23.5 × 15.6 mm sensor gives crop factor ≈ 1.53, ≈ 77 mm and ≈ f/3.1.
103
+ *
104
+ * @param input Sensor active area, focal length, f-number and optional reference format.
105
+ * @returns The equivalence numbers with a per-quantity exactness statement.
106
+ */
107
+ export function calculateSensorEquivalence(input) {
108
+ requireInputKeys(input, "calculateSensorEquivalence", ["sensor", "focalLengthMm", "fNumber", "reference"]);
109
+ requireImagingArea(input.sensor, "sensor");
110
+ requirePositiveFinite("focalLengthMm", input.focalLengthMm);
111
+ requirePositiveFinite("fNumber", input.fNumber);
112
+ if (input.reference !== undefined)
113
+ requireImagingArea(input.reference, "reference");
114
+ const sensor = { widthMm: input.sensor.widthMm, heightMm: input.sensor.heightMm };
115
+ const reference = input.reference === undefined
116
+ ? { ...REFERENCE_FULL_FRAME }
117
+ : { widthMm: input.reference.widthMm, heightMm: input.reference.heightMm };
118
+ // The default reference reuses the canonical 35 mm crop factor, so it is bit-identical to
119
+ // calculateEquivalentFocalLength35Mm and calculateImagingAreaMetrics.
120
+ const cropFactor = input.reference === undefined
121
+ ? calculateImagingAreaMetrics(sensor).value.cropFactor35Mm
122
+ : diagonalRatio(reference, sensor);
123
+ const areaRatio = (sensor.widthMm / reference.widthMm) * (sensor.heightMm / reference.heightMm);
124
+ const sensorDiagonalMm = portableHypot(sensor.widthMm, sensor.heightMm);
125
+ if (!(areaRatio > 0) || !Number.isFinite(areaRatio)) {
126
+ throw new InvalidScientificInputError("sensor and reference areas must differ by a ratio that is a finite number greater than zero.");
127
+ }
128
+ return approximationResult({
129
+ focalLengthMm: input.focalLengthMm,
130
+ fNumber: input.fNumber,
131
+ sensor,
132
+ reference,
133
+ sensorDiagonalMm,
134
+ referenceDiagonalMm: portableHypot(reference.widthMm, reference.heightMm),
135
+ cropFactor,
136
+ equivalentFocalLengthMm: input.focalLengthMm * cropFactor,
137
+ equivalentFNumber: input.fNumber * cropFactor,
138
+ exposureFNumber: input.fNumber,
139
+ entrancePupilDiameterMm: input.focalLengthMm / input.fNumber,
140
+ areaRatio,
141
+ totalLightDifferenceStops: portableLog2(areaRatio),
142
+ fieldOfView: {
143
+ horizontalDegrees: rectilinearAngleDegrees(sensor.widthMm, input.focalLengthMm),
144
+ verticalDegrees: rectilinearAngleDegrees(sensor.heightMm, input.focalLengthMm),
145
+ diagonalDegrees: rectilinearAngleDegrees(sensorDiagonalMm, input.focalLengthMm)
146
+ },
147
+ basis: SENSOR_EQUIVALENCE_BASIS
148
+ }, "sensor-equivalence", "1.0.0", [
149
+ "Crop factor and equivalence are diagonal-based against the reference format (36 × 24 mm unless given).",
150
+ "Equivalent f-number concerns depth of field, background blur and total light for the same framing and output size; it is not an exposure setting.",
151
+ "Equivalent f-number is a thin-lens approximation, exact when the focus distance is much greater than the focal length.",
152
+ "Exposure f-number is the f-number on the lens: image illuminance does not depend on sensor size.",
153
+ "Ideal rectilinear lens focused at infinity; distortion, vignetting, transmission and focus breathing excluded.",
154
+ "Physical focal length and f-number are not changed; see basis for each quantity."
155
+ ]);
156
+ }
157
+ /**
158
+ * A blur circle on the sensor as a share of the frame (diagonal, width and height) and, when a
159
+ * print size is given, its diameter on that print.
160
+ *
161
+ * Equal shares of the diagonal look the same when the images are shown at the same size, which
162
+ * is how "same framing" comparisons across sensor sizes are stated. Every value is an exact ratio
163
+ * or scaling; whether a blur is visible depends on viewing distance and acuity, which this
164
+ * function does not judge.
165
+ *
166
+ * @param input Blur diameter in millimetres, sensor active area and optional print size.
167
+ * @returns Shares of the frame, and the blur on the print when a print is given.
168
+ */
169
+ export function calculateBlurShareOfFrame(input) {
170
+ requireInputKeys(input, "calculateBlurShareOfFrame", ["blurCircleMm", "sensor", "print"]);
171
+ if (typeof input.blurCircleMm !== "number" || !Number.isFinite(input.blurCircleMm) || input.blurCircleMm < 0) {
172
+ throw new InvalidScientificInputError("blurCircleMm must be a finite number greater than or equal to zero.");
173
+ }
174
+ requireImagingArea(input.sensor, "sensor");
175
+ if (input.print !== undefined)
176
+ requireImagingArea(input.print, "print");
177
+ const { widthMm, heightMm } = input.sensor;
178
+ const blurCircleMm = input.blurCircleMm;
179
+ const frameDiagonalMm = portableHypot(widthMm, heightMm);
180
+ let print;
181
+ if (input.print !== undefined) {
182
+ const sensorLong = Math.max(widthMm, heightMm), sensorShort = Math.min(widthMm, heightMm);
183
+ const printLong = Math.max(input.print.widthMm, input.print.heightMm), printShort = Math.min(input.print.widthMm, input.print.heightMm);
184
+ const enlargement = Math.min(printLong / sensorLong, printShort / sensorShort);
185
+ if (!(enlargement > 0) || !Number.isFinite(enlargement)) {
186
+ throw new InvalidScientificInputError("print enlargement must be a finite number greater than zero.");
187
+ }
188
+ print = {
189
+ enlargement,
190
+ blurOnPrintMm: blurCircleMm * enlargement,
191
+ imageLongSideMm: sensorLong * enlargement,
192
+ imageShortSideMm: sensorShort * enlargement
193
+ };
194
+ }
195
+ return calculatedResult({
196
+ blurCircleMm,
197
+ frameDiagonalMm,
198
+ shareOfDiagonal: blurCircleMm / frameDiagonalMm,
199
+ shareOfWidth: blurCircleMm / widthMm,
200
+ shareOfHeight: blurCircleMm / heightMm,
201
+ ...(print === undefined ? {} : { print })
202
+ }, "blur-share-of-frame", "1.0.0", [
203
+ "Shares are exact ratios of the blur diameter to the active frame's diagonal, width and height.",
204
+ "A print shows the whole frame enlarged to fit inside it without cropping, long side to long side.",
205
+ "Visibility of the blur depends on viewing distance and acuity; no acceptability criterion is applied."
206
+ ]);
207
+ }
208
+ //# sourceMappingURL=sensor-equivalence.js.map