@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
@@ -104,6 +104,7 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
104
104
  | `output.exposureCompensationStops` | 0 | #556: a development exposure push, −2 to +5 stops, on the RAW before white balance (see [Development exposure push](#development-exposure-push-556)). |
105
105
  | `output.diffractionCorrection` | off | #552: `{ wavelengthNm?, regularization?, maximumGain?, kernelRadiusPixels? }`, a bounded deconvolution of the diffraction blur; needs `lens.diffraction`. It never restores detail beyond the diffraction cut-off (see [Sharpening and diffraction correction](#sharpening-and-diffraction-correction-552)). |
106
106
  | `output.sharpening` | off | #552: `{ radiusPixels?, amount?, threshold? }`, an unsharp mask after the tone curve; sRGB output only (see [Sharpening and diffraction correction](#sharpening-and-diffraction-correction-552)). |
107
+ | `output.lensCorrections` | off | #570, with `lens.catalogue`: `{ distortion?, lateralColor?, vignetting? }`, the lens's own corrections on the developed JPEG, PNG and samples; the DNG is never corrected. Works with `output.region` (see [Generic lens catalogue](#generic-lens-catalogue-549)). |
107
108
  | `output.whiteBalance` | `"scene-illuminant"` | Also `"auto"`, `"none"`, `{ kelvin, tint? }` and `{ channelGains }`. |
108
109
  | `output.jpegQuantizationStep` | 2 | JPEG only: constant step 1–255, not a manufacturer quality scale. |
109
110
  | `sensor.pixelPitchMicrometers` | 6 | 0.5–1000 (2.5; 0.5–100 before). The sensor is `width × pitch` wide (see [Geometry](#geometry)); the ISO readout scales with the pixel area (see [ISO and pixel size](#iso-and-pixel-size)). Above 100 µm the capture is a downsized view of a whole frame (see [Whole-frame views](#whole-frame-views-25)). |
@@ -116,7 +117,7 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
116
117
  | `motion.angularVelocityRadPerSec` | still | `{ pitch?, yaw?, roll? }` in rad/s (see [Motion](#motion)). |
117
118
  | `motion.shutter` | `"global"` | Or `"rolling"`. |
118
119
  | `motion.rollingShutterSkewSeconds` | the body's readout skew | Rolling only. |
119
- | `signal`, `onProgress` | none | An `AbortSignal` cancels the running task; `onProgress` reports `raw`, `white-balance`, `development` and `jpeg` tiles. The last event of each stage has `completedTileCount === totalTileCount`. The capture yields to the host about every 8 ms with a `MessageChannel` message (else `setImmediate`, else `setTimeout`), so other queued tasks run in between: an abort sent from another task, such as a Web Worker `message` event, takes effect at the next yield. |
120
+ | `signal`, `onProgress` | none | An `AbortSignal` cancels the running task; `onProgress` reports `raw`, `white-balance`, `development`, `diffraction-correction` and `sharpening` (#571: output rows, with those options) and `jpeg` tiles. The last event of each stage has `completedTileCount === totalTileCount`. The capture yields to the host about every 8 ms with a `MessageChannel` message (else `setImmediate`, else `setTimeout`), so other queued tasks run in between: an abort sent from another task, such as a Web Worker `message` event, takes effect at the next yield. |
120
121
 
121
122
  Unknown fields are rejected, so a typo never silently falls back to a default.
122
123
 
@@ -212,10 +213,22 @@ and every number is a labelled approximation.
212
213
  - **Combinations:** `lens.defocus` convolves the disc into the lens's PSF (a combined kernel up to 16 sites in radius);
213
214
  `lens.focusScatter: { seed, shotIndex? }` moves the focus by a seeded autofocus error (`simulateGenericLensFocusError`), off
214
215
  by default. `prime`, `focalLengthMm`, `projection`, `distortion`, `vignetting`, `psf`, `diffraction` and `pupilSampling` are
215
- set by the lens and refused with it; the display view refuses `lens.catalogue`.
216
+ set by the lens and refused with it. The display view renders `lens.catalogue` since #569, with the PSF taken per field zone
217
+ ([Display view, Catalogue lenses](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#catalogue-lenses-569)).
216
218
  - **Cost:** one scene query per kernel tap through the lens field mapping, like `lens.distortion`: a 48 × 48 region of a full frame
217
219
  at 6 µm takes about 1 s with *PV 50 1.2 Pro* and 6 s with *PV 50 1.8* (Apple M5 Pro), against 0.01 s with `lens.prime`. Render
218
220
  regions, or use a coarser `sensor.spectralStepNanometers`.
221
+ - **Lens corrections (#570):** `output: { lensCorrections: { distortion: true, lateralColor: true, vignetting: true } }` applies the
222
+ lens's generic correction profile (`createGenericLensCorrectionProfile`) to the developed linear colour, each correction
223
+ switched on separately, as cameras do for their JPEGs. The RAW and the DNG are never corrected. The image keeps its size and is
224
+ magnified just enough to have no missing pixels (`magnification` in the `output.lensCorrections` assumption, 1 for the barrel
225
+ wide angles); vignetting is corrected to a flat field. Regions and parts are byte-identical to the whole frame. The costs are
226
+ real: stretched corners lose resolution and brightened corners are noisier
227
+ ([DIGITAL_OPTICS_FOUNDATION.md](https://github.com/Photivra/photivra/blob/main/docs/DIGITAL_OPTICS_FOUNDATION.md#lens-corrections-in-the-development-570)).
228
+ - **Fast method (#568):** `fast: true` (or `lens: { catalogue: { kind: "pv-50-1.8", fast: true } }`) blends the PSF over a field
229
+ grid and tabulates each site's signal, a labeled approximation within 2.3e-4 of the frame's peak signal of the exact method
230
+ (measured): a 768 × 512 whole full frame renders within 2× of the same `lens.prime` capture. It needs a still camera, site-center
231
+ queries and a chart or material scene; elsewhere the exact method runs (`diagnostics.fast` says which ran).
219
232
 
220
233
  ```ts
221
234
  import { resolveGenericLens, simulateCapture } from "@photivra/engine";
@@ -233,6 +246,60 @@ const capture = await simulateCapture({
233
246
  console.log(capture.assumptions.find((a) => a.id === "lens.catalogue.character")?.value);
234
247
  ```
235
248
 
249
+ ### Ideal lens at any focal length and f-number (#576)
250
+
251
+ `lens.ideal: { focalLengthMm }` is a plain ideal lens: an aberration-free thin lens named by its **physical** focal length, on
252
+ whatever sensor the frame is (output width × height × pixel pitch). Unlike `lens.prime` and `lens.catalogue` it is not scaled by
253
+ the crop factor, so a 50 mm lens is 50 mm on full frame and on APS-C, as when a full-frame lens is mounted on a smaller sensor
254
+ (narrower field of view, the same blur in millimetres).
255
+
256
+ - **Any f-number within the bounds:** `lens.fNumber` from f/0.5 to f/128, focal lengths from 1 to 4000 mm (`IDEAL_LENS_LIMITS`).
257
+ f/0.5 is the physical limit of a lens in air (the sine condition: N = 1/(2·sin θ′) ≥ 0.5); the others are engine bounds. Values
258
+ beyond them are refused by name. Shutter priority and program may choose any aperture in that range (in dim light, wider
259
+ than real lenses open).
260
+ - **An optional f-number range:** `lens.ideal: { focalLengthMm, widestFNumber, narrowestFNumber }` (either or both, within
261
+ `IDEAL_LENS_LIMITS`, the widest no larger than the narrowest; each defaults to its bound) narrows that range like a real
262
+ lens's: `lens.fNumber` must lie in it, and shutter priority and program choose apertures only in it. The assumption then
263
+ records the range (`{ lensId, focalLengthMm, widestFNumber, narrowestFNumber }`); without it nothing changes. A range without
264
+ f/8 takes the reference exposure at its aperture nearest f/8 (the same N²/t). Where that shutter would be shorter than the
265
+ body's shortest (a range narrower than about f/1, such as f/0.5–f/0.9), the anchor takes the shortest shutter instead, a lower
266
+ N²/t that the meter and the exposure modes measure from, so the exposure is the same (#585; refused in every mode before 2.19.0).
267
+ - **Program with a range (#583):** program follows the engine's program line (f/16 1/2000 s … f/8 1/125 s … f/4 1.024 s). A
268
+ range that holds f/4–f/16 keeps it unchanged. Otherwise the line's f/4 and f/16 move to the range (each clamped to it): the
269
+ same shape, with the aperture held at the range's end and the shutter taking up the stops (the same N²/t, so the same
270
+ exposure), still from 1/2000 s to 1.024 s. Beyond the line's ends the resolver holds them, as before (with `iso: "auto"` the
271
+ ISO rises in dim light; with a fixed ISO the capture is under- or overexposed and `exposure.standardExposureDeviationStops`
272
+ says by how much). For 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
273
+ `exposure.programLine` assumption then records the limited line (profile id `"facade-generic-program-line-lens-range"`, with its `nodes`). The same
274
+ applies to the `lens.prime` and `lens.catalogue` ranges that do not hold f/4–f/16 (the 800 mm and 1200 mm-equivalent primes;
275
+ *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*),
276
+ which program refused before 2.19.0. An approximation: a generic line, not a manufacturer's program.
277
+ - **The same blur models as `lens.prime`:** `lens.defocus` (the thin-lens blur circle of `calculateDefocusCircle`), `lens.diffraction`
278
+ (the Airy pattern) and `lens.vignetting` (a caller profile). Without them the ideal lens renders sharp and evenly lit. It focuses
279
+ at any distance beyond the focal length (no minimum focus distance; the subject must be at least twice the focal length away, as
280
+ for every lens).
281
+ - **Capture and display view:** `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` take it. It replaces the tier
282
+ lens's focal length and f-number range (its transmission is kept) and is recorded in the `lens.ideal` assumption
283
+ (`{ lensId: "ideal-50mm", focalLengthMm: 50 }`). At the same focal length and f-number it renders the same pixels as the tier
284
+ lens (`lens.focalLengthMm`) and the generic prime.
285
+ - **Not combinable** with `lens.prime`, `lens.catalogue` or `lens.focalLengthMm`.
286
+
287
+ A 50 mm f/1.8 lens on APS-C (23.5 × 15.6 mm: 235 × 156 pixels at 100 µm):
288
+
289
+ ```ts
290
+ import { simulateCapture } from "@photivra/engine";
291
+
292
+ const capture = await simulateCapture({
293
+ scene: { preset: "siemens-star" },
294
+ output: { width: 235, height: 156 },
295
+ sensor: { pixelPitchMicrometers: 100 },
296
+ subjectDistanceM: 3,
297
+ lens: { ideal: { focalLengthMm: 50 }, fNumber: 1.8, focusDistanceM: 2.5, defocus: { model: "geometric-disc" } },
298
+ exposure: { mode: "aperture-priority" }
299
+ });
300
+ console.log(capture.assumptions.find((a) => a.id === "lens.defocus")?.value); // diameterMicrometers 94.5 on any sensor
301
+ ```
302
+
236
303
  ### Fitting a chart to the frame (2.3)
237
304
 
238
305
  `calculateSubjectDistanceForFraming({ sensor, focalLengthMm | equivalentFocalLengthMm, chart, fit? })`
@@ -485,7 +552,7 @@ Exposure compensation applies to the meter target first, as with a numeric ISO.
485
552
  | `manual` | Your aperture and shutter; the ISO makes up the exposure (`resolveManualExposureMode`, automatic ISO). |
486
553
  | `aperture-priority` | Your aperture. The shutter follows the meter at the lowest ISO, but no slower than `exposure.autoIso.slowestPreferredShutterSeconds` (required; seconds within the body's shutter range, or `"auto"`, see below); the ISO makes up the rest. At the Auto ISO maximum, `afterMaximumIso` decides: `"allow-slower-shutter"` (the default) lengthens the shutter to reach the target, `"hold-preferred-shutter"` keeps it and leaves the exposure under target (`resolveAperturePriorityAutoIsoExposureMode`). |
487
554
  | `shutter-priority` | Your shutter. The aperture is chosen at the lowest ISO, no wider than the target needs, so it stops at the widest aperture; the ISO makes up the rest (`resolveShutterPriorityExposureMode`, policy `minimum-iso-aperture-first`). |
488
- | `program` | The generic program line at the lowest ISO; beyond the line's end (f/4, 1.024 s) the ISO rises (`resolveProgramAutoExposureMode`, automatic ISO). With `exposure.autoIso.slowestPreferredShutterSeconds` (optional, a number or `"auto"`, #504) the line's shutter is held at it before the ISO rises, and `afterMaximumIso` applies as in aperture priority, never past the line's own shutter. |
555
+ | `program` | The generic program line (limited to the lens's f-number range, #583) at the lowest ISO; beyond the line's end (f/4, 1.024 s) the ISO rises (`resolveProgramAutoExposureMode`, automatic ISO). With `exposure.autoIso.slowestPreferredShutterSeconds` (optional, a number or `"auto"`, #504) the line's shutter is held at it before the ISO rises, and `afterMaximumIso` applies as in aperture priority, never past the line's own shutter. |
489
556
 
490
557
  The result says what happened. `exposure.settings.iso` is the ISO used and `exposure.isoControl` is `"automatic"`.
491
558
  `exposure.autoIso` reports `idealIsoBeforeConstraints`, the Auto ISO range (`autoIsoMinimum`, `autoIsoMaximum`),
@@ -923,10 +990,14 @@ console.log(result.diagnostics.detail); // { diffractionCorrection: {...}, sharp
923
990
  noise; it adds no detail. Not with a wide-gamut `output.colorEncoding` or `output.gamutMapping: "perceptual"`.
924
991
  - **Reported:** the `output.diffractionCorrection` and `output.sharpening` assumptions (settings, cut-off, measured filter
925
992
  response, defaults used), `diagnostics.detail` (ringing, clipping and halo counts) and `diagnostics.options`.
926
- - **Not combinable** with `output.region`; not offered by `simulateDisplayView` or `simulateIdealReference`. The DNG holds the
927
- RAW codes and is unchanged; only `image.samples` (and the JPEG or PNG made from them) change.
928
- - **Cost:** both stages hold the whole developed image, as AHD does. At 256 × 256 on an Apple M5 Pro (Node.js 24.19.0) the
993
+ - **Not combinable** with `output.region`; not offered by `simulateDisplayView` or `simulateIdealReference`. A capture rendered
994
+ in region parts takes them through `simulateCaptureFromParts` (#571, [Developing a capture rendered in parts](#developing-a-capture-rendered-in-parts-571)).
995
+ The DNG holds the RAW codes and is unchanged; only `image.samples` (and the JPEG or PNG made from them) change.
996
+ - **Cost:** both stages need the whole developed image, as AHD does. At 256 × 256 on an Apple M5 Pro (Node.js 24.19.0) the
929
997
  development took 8 ms by default, 15 ms with sharpening and 18–20 ms with diffraction correction (R = 3–5), one run each.
998
+ Since #571 they run row by row in place, with the same sums in the same order (the same bytes as 2.18.0): one developed
999
+ image in memory instead of up to six, and `onProgress` reports each output row as the `diffraction-correction` and
1000
+ `sharpening` stages. At 24 MP see [Developing a capture rendered in parts](#developing-a-capture-rendered-in-parts-571).
930
1001
 
931
1002
  The trade-offs of these and the lens corrections (corner resolution after distortion correction, corner noise after
932
1003
  vignetting correction, residual longitudinal CA, halo overshoot, nothing beyond the cut-off) are measured in
@@ -1817,6 +1888,65 @@ const dng = await serializeSimulatedCaptureDngFromParts(dngFrame, parts);
1817
1888
  console.log(dng.bytes.length);
1818
1889
  ```
1819
1890
 
1891
+ ### Developing a capture rendered in parts (#571)
1892
+
1893
+ `simulateCaptureFromParts(options, parts)` develops the whole frame of `options` from region renders of the same
1894
+ request. It joins the parts' RAW exactly as `serializeSimulatedCaptureDngFromParts` does (the same parts, the same checks),
1895
+ then develops the joined RAW as `simulateCapture(options)` would, including the steps that filter across the whole image and
1896
+ that a region refuses: `output.diffractionCorrection` and `output.sharpening`. The joined RAW is the whole render's RAW, so the
1897
+ result's `image.samples`, JPEG or PNG bytes, `raw` and `plan` are those of the whole-frame capture, bit for bit. Pass the
1898
+ whole request (no `output.region`); render the parts with the same request plus `output.region`, without the detail stages,
1899
+ `output.dng` and auto white balance (none changes the RAW codes; auto white balance is measured here over the joined frame).
1900
+ `output.lensCorrections` (#570) is honored as in the whole-frame capture, bit for bit; a part may keep or drop it (a corrected
1901
+ region only widens its `region.nativeRect`). The corrections run on the whole developed frame and hold a second developed
1902
+ image while they resample it.
1903
+
1904
+ `onProgress` reports `development`, then `diffraction-correction` and `sharpening` once per output row of the whole image
1905
+ (each stage ends with `completedTileCount === totalTileCount`), then `jpeg`; no `raw` stage, since no RAW is rendered. A host
1906
+ can show these as "Applying diffraction correction to the whole image" and "Applying sharpening to the whole image". The
1907
+ lens corrections run between the two and report no stage of their own.
1908
+ `signal` cancels the step. In the result, `diagnostics.providerEvaluationCount` is 0, `diagnostics.timingsMs` are this call's
1909
+ (`raw` is the join), `diagnostics.fast` and the `raw.*` assumptions of how the RAW was rendered are in the parts' results
1910
+ instead, and a `parts` assumption records the join.
1911
+
1912
+ ```ts
1913
+ import { simulateCaptureFromParts, simulateCapture, type SimulateCaptureDngPart, type SimulateCaptureOptions } from "@photivra/engine";
1914
+
1915
+ const whole: SimulateCaptureOptions = { scene: { preset: "slanted-edge" }, lens: { fNumber: 16, diffraction: "circular-aperture" },
1916
+ output: { width: 96, height: 64, format: "jpeg", diffractionCorrection: {}, sharpening: {} } };
1917
+ // Two strips rendered without the whole-image stages (in an application, in separate Web Workers).
1918
+ const parts: SimulateCaptureDngPart[] = await Promise.all([0, 32].map(async (y) => {
1919
+ const part = await simulateCapture({ ...whole, output: { width: 96, height: 64, region: { x: 0, y, width: 96, height: 32 } } });
1920
+ return { raw: part.raw, region: part.region! };
1921
+ }));
1922
+ const developed = await simulateCaptureFromParts({ ...whole, onProgress: ({ stage, completedTileCount, totalTileCount }) => {
1923
+ if (stage === "sharpening") console.log("Applying sharpening to the whole image", completedTileCount / totalTileCount);
1924
+ } }, parts);
1925
+ console.log(developed.image.jpeg!.length); // the bytes of simulateCapture(whole).image.jpeg
1926
+ ```
1927
+
1928
+ Tests compare the result with the whole-frame capture byte for byte: JPEG with both stages, 16-bit sRGB with a thresholded
1929
+ mask, AHD with auto white balance, a development push and the four-spot filter, Display P3 PNG with diffraction correction,
1930
+ DNG with both stages, no stage at all, and a catalogue lens with all three lens corrections and sharpening (parts rendered
1931
+ with and without the corrections); and the row-by-row stages against whole-image passes on odd sizes.
1932
+
1933
+ **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,
1934
+ one thread, a shared machine, two runs; `node scripts/benchmark-joined-development.mjs`):
1935
+
1936
+ | Step | Time | Peak memory (process) |
1937
+ | --- | --- | --- |
1938
+ | Join and develop, no detail stage | 1.9–2.2 s (join 0.13–0.15 s) | 532–551 MiB |
1939
+ | … with sharpening (σ = 1) | 3.3–3.7 s (sharpening 1.9–2.1 s) | 1,080 MiB |
1940
+ | … with diffraction correction (R = 3, the default) | 3.7–3.9 s (correction 2.0 s) | 1,079–1,080 MiB |
1941
+ | … 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 MiB |
1942
+ | Whole `simulateCapture` with both, for comparison | 10.0–10.4 s (RAW 4.8–4.9 s) | 924 MiB |
1943
+
1944
+ The peak includes the parts' RAW read back (7 bytes per native site) and the joined RAW. The detail stages' share is the one
1945
+ developed image in 64-bit floats (24 bytes per pixel, 549 MiB at 24 MP), held as before by AHD and the wide-gamut paths. In
1946
+ 2.18.0 the stages held up to six such images: the same whole capture with both stages peaked at 3,670 MB (resident set
1947
+ sampled every 50 ms). The extra pass therefore costs about 5 s on one worker after the parts, against about 10 s to render
1948
+ the whole frame again on one worker.
1949
+
1820
1950
  ### A Print JPEG written in strips
1821
1951
 
1822
1952
  At 1:1 (output size equal to the crop), a Print JPEG's rows can be coded on separate workers.
package/docs/USAGE.md CHANGED
@@ -374,6 +374,8 @@ The actual focal length stays the physical optical quantity used by projection a
374
374
  - Focus distance is not part of conventional 35 mm-equivalent focal length. A later digital/output crop and the output resolution are also excluded.
375
375
  - If a product wants to describe the final digital framing, it should label it as output framing. It should not silently replace the optical focal length or the capture-equivalent focal length.
376
376
 
377
+ To render a lens by its physical focal length on any sensor, give `simulateCapture()` or the display view `lens.ideal: { focalLengthMm }` with any `lens.fNumber` within `IDEAL_LENS_LIMITS` (f/0.5–f/128, 1–4000 mm; optional `widestFNumber` and `narrowestFNumber` narrow the f-number range for the automatic exposure modes; in program the engine's program line is limited to that range, #583): an aberration-free thin lens that is not scaled by the crop factor, unlike `lens.prime` (see docs/QUICK_START.md, "Ideal lens at any focal length and f-number").
378
+
377
379
  `calculateImagingAreaMetrics()` provides the same canonical diagonal crop-factor calculation, independent of raster density, so other engine modules do not need to repeat that formula.
378
380
 
379
381
  ## Focus-breathing projection
@@ -1056,6 +1058,30 @@ T_stack = T_1 * T_2 * ... * T_n
1056
1058
 
1057
1059
  The stack keeps each filter's identity and evidence. The combined numeric uncertainty is deliberately reported as **not propagated**, unless it is established whether the filters' errors are correlated or independent. Each filter's own uncertainty stays attached to that filter.
1058
1060
 
1061
+ ### Exposure time with an ND filter
1062
+
1063
+ `calculateFilteredExposureTime({ shutterSeconds, filter, shutterDialSeconds? })` (model `filtered-exposure-time` 1.0.0, #578) gives the shutter time that keeps the same exposure at the same f-number and ISO after adding one filter or a stack (`filter` takes one profile or an array in light-path order): `filteredShutterSeconds = shutterSeconds / T`, with `T` the stack's combined transmittance from `composeFrontOfLensFilterTransmission()`. It also returns `attenuationStops`, `exposureFactor` (`1/T`) and the full `filterTransmission`. With a `shutterDialSeconds` list (for example `GENERIC_THIRD_STOP_SHUTTER_SECONDS`) it adds `dial`: the nearest dial value in stops (the shorter on a tie), its exposure error in stops, and `clamped: "maximum"` when the exact time is longer than the longest dial value (use bulb).
1064
+
1065
+ - Exact under the linear sensor model (exposure `H = E·t`); film reciprocity failure is not modelled.
1066
+ - Nominal filters vary: a "10-stop" filter (`T = 2^-10`) gives 1/125 s → 8.19 s, an ND1000 (OD 3.0, 9.97 stops) gives exactly 8 s. The result is only as good as the profile, and is an approximation whenever a profile is.
1067
+ - Generic presets: `GENERIC_ND_FILTER_PROFILES[3]`, `[6]` and `[10]` (keys in `GENERIC_ND_FILTER_STOPS`) are frozen, unbranded, spectrally flat profiles over 380–780 nm with a transmittance of exactly 2^-N (1/8, 1/64, 1/1024). They are idealized (`scientificStatus: "approximation"`, uncertainty not quantified), and any function that takes a front-of-lens filter profile accepts them. For other filters, build a profile with `parseFrontOfLensFilterProfile()`.
1068
+ - Filters are resolved at 550 nm unless `wavelengthNanometers` is given; neutral filters do not depend on it. `incidentPolarization` matters only with a polarizer in the stack.
1069
+
1070
+ ```ts
1071
+ import {
1072
+ GENERIC_ND_FILTER_PROFILES,
1073
+ GENERIC_THIRD_STOP_SHUTTER_SECONDS,
1074
+ calculateFilteredExposureTime
1075
+ } from "@photivra/engine";
1076
+
1077
+ const { value } = calculateFilteredExposureTime({
1078
+ shutterSeconds: 1 / 125,
1079
+ filter: GENERIC_ND_FILTER_PROFILES[10],
1080
+ shutterDialSeconds: GENERIC_THIRD_STOP_SHUTTER_SECONDS
1081
+ });
1082
+ console.log(value.filteredShutterSeconds.toFixed(2), value.dial?.shutterSeconds); // 8.19 8
1083
+ ```
1084
+
1059
1085
  ### Spectral boundary
1060
1086
 
1061
1087
  Spectral filter data stays wavelength-resolved all the way through the [scene-radiance-to-sensor bridge](#scene-radiance-to-sensor-irradiance). Photivra does not turn a spectral filter into an undocumented RGB tint.
@@ -5525,6 +5551,20 @@ All modes:
5525
5551
 
5526
5552
  Flash-aware behaviour, safety shift and the program behaviour of named cameras are separate work.
5527
5553
 
5554
+ ### The capture facade's program line and the lens's f-number range (#583)
5555
+
5556
+ `simulateCapture()` and the display view in `exposure.mode: "program"` use an engine-authored generic line (f/16 1/2000 s … f/8
5557
+ 1/125 s … f/4 1.024 s, assumption `exposure.programLine`). `resolveProgramAutoExposureMode()` refuses a line whose apertures leave
5558
+ the capability range, so with a lens range that does not hold f/4–f/16 (`lens.ideal` `widestFNumber`/`narrowestFNumber`,
5559
+ `lens.prime`, `lens.catalogue`) the facade limits the line to the range first: the line's f/4 and f/16 are clamped to the range, giving the same shape with
5560
+ 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²),
5561
+ still from 1/2000 s to 1.024 s. Where both lines run, the limited line's aperture is the old one clamped in log2 to the range.
5562
+ Beyond the line's ends the resolver holds them as before. A range that holds f/4–f/16, or no range, keeps the line and its
5563
+ bytes. The limited line is recorded as its profile (id `"facade-generic-program-line-lens-range"`), with node stops measured from the
5564
+ capture's exposure anchor (f/8, 1/125 s; the same N²/t at the range's aperture nearest f/8; or, #585, that aperture at the
5565
+ body's shortest shutter when the same N²/t would need a shorter one). An approximation of a generic
5566
+ program, not a manufacturer's.
5567
+
5528
5568
  ## Program Auto and Full Auto exposure
5529
5569
 
5530
5570
  Program Auto and Full Auto exposure both need a declared, versioned **program line**: a curve that says which aperture and shutter to use at each brightness level. The engine does not invent one "correct" aperture/shutter pair.
@@ -7478,9 +7518,11 @@ own designs, not real products). `resolveGenericLens` resolves one for a sensor,
7478
7518
  (scaled by the crop factor), aperture range, close focus, projection and its character, Seidel aberrations, axial and lateral
7479
7519
  colour, distortion and vignetting profiles, flare, breathing and focus scatter, each a labelled approximation.
7480
7520
  `simulateGenericLensFocusError` draws a seeded autofocus error; `createGenericLensCorrectionProfile` builds the lens's generic
7481
- correction profile for `resolveLensCorrectionPlan`. `simulateCapture` renders a catalogue lens with `lens.catalogue`
7482
- ([quick start](QUICK_START.md#generic-lens-catalogue-549)). Equations, tables and the chart measurements:
7483
- [LENS_CATALOGUE.md](https://github.com/Photivra/photivra/blob/main/docs/LENS_CATALOGUE.md).
7521
+ correction profile for `resolveLensCorrectionPlan`, which `simulateCapture` applies to its developed image with
7522
+ `output.lensCorrections` (#570; never to the RAW or the DNG). `simulateCapture` renders a catalogue lens with `lens.catalogue`
7523
+ ([quick start](QUICK_START.md#generic-lens-catalogue-549)); `fast: true` (or `lens: { catalogue: { kind, fast: true } }`) takes its
7524
+ fast method (#568), a labeled approximation that blends the PSF over a field grid, for interactive captures. Equations, tables and the
7525
+ chart measurements: [LENS_CATALOGUE.md](https://github.com/Photivra/photivra/blob/main/docs/LENS_CATALOGUE.md).
7484
7526
 
7485
7527
  ```ts
7486
7528
  import { GENERIC_LENS_CATALOGUE, resolveGenericLens, simulateGenericLensFocusError } from "@photivra/engine";
@@ -7739,6 +7781,12 @@ console.log(star.assumptions.filter((a) => a.kind === "default").map((a) => a.id
7739
7781
  console.log(star.request.siteInput(0).environment.optics.nominalFNumber);
7740
7782
  ```
7741
7783
 
7784
+ A capture can render its RAW in parallel parts (`output.region`, one call per part). `serializeSimulatedCaptureDngFromParts()`
7785
+ writes the whole frame's DNG from the parts, and `simulateCaptureFromParts()` (#571) develops the whole frame from them,
7786
+ including the whole-image stages `output.diffractionCorrection` and `output.sharpening` that a region refuses, with the same
7787
+ bytes as a whole-frame `simulateCapture()` and per-row progress (`diffraction-correction`, `sharpening`). See
7788
+ [Quick start, Developing a capture rendered in parts](QUICK_START.md#developing-a-capture-rendered-in-parts-571).
7789
+
7742
7790
  ### Color charts (2.14)
7743
7791
 
7744
7792
  The engine's own [color patches](https://github.com/Photivra/photivra/blob/main/docs/COLOR_PATCHES.md) are reflectance spectra lit by the capture's illuminant. Draw the
@@ -7999,3 +8047,41 @@ import { simulateCapture } from "@photivra/engine";
7999
8047
  const rolling = await simulateCapture({ scene: { preset: "checkerboard" }, motion: { angularVelocityRadPerSec: { pitch: 0.02 }, shutter: "rolling" }, output: { width: 6, height: 6 } });
8000
8048
  console.log(rolling.request.siteInput(7 * 8).charge.completenessProfile.startOffsetSecondsFromOpeningReference);
8001
8049
  ```
8050
+
8051
+ ## Equivalent f-number, sensor area and blur as a share of the frame
8052
+
8053
+ `calculateSensorEquivalence()` says what a lens on one sensor corresponds to on a 36 × 24 mm frame (or on a `reference` you pass). Call it once per sensor to fill a lens table:
8054
+
8055
+ ```ts
8056
+ import { calculateBlurShareOfFrame, calculateSensorEquivalence } from "@photivra/engine";
8057
+
8058
+ const apsC = calculateSensorEquivalence({
8059
+ sensor: { widthMm: 23.5, heightMm: 15.6 },
8060
+ focalLengthMm: 50,
8061
+ fNumber: 2
8062
+ }).value;
8063
+
8064
+ console.log(apsC.cropFactor); // ≈ 1.53
8065
+ console.log(apsC.equivalentFocalLengthMm); // ≈ 76.7 (framing)
8066
+ console.log(apsC.equivalentFNumber); // ≈ 3.07 (depth of field, blur and total light; not exposure)
8067
+ console.log(apsC.exposureFNumber); // 2 (exposure does not change with sensor size)
8068
+ console.log(apsC.areaRatio); // ≈ 0.42 of the full-frame area
8069
+ console.log(apsC.totalLightDifferenceStops); // ≈ -1.24 stops at the same f-number and shutter
8070
+ console.log(apsC.basis.equivalentFNumber.kind); // "approximation"
8071
+
8072
+ const share = calculateBlurShareOfFrame({
8073
+ blurCircleMm: 0.03,
8074
+ sensor: { widthMm: 36, heightMm: 24 },
8075
+ print: { widthMm: 300, heightMm: 200 }
8076
+ }).value;
8077
+
8078
+ console.log(share.shareOfDiagonal); // ≈ 0.00069 of the frame diagonal
8079
+ console.log(share.print?.blurOnPrintMm); // 0.25 mm on the print
8080
+ ```
8081
+
8082
+ - `basis` gives, for every number, `kind: "exact"` or `"approximation"` and the conditions. Crop factor, equivalent focal length, area ratio, total-light difference, entrance pupil and angles of view are exact under their stated conditions.
8083
+ - The equivalent f-number (crop factor × f-number) assumes the same framing, subject distance and output size. It is a thin-lens approximation that is exact only when the focus distance is much greater than the focal length; at close focus the blur shares match at `k·N·(s − f)/(s − k·f)`.
8084
+ - Exposure uses the f-number on the lens. Equivalence never changes shutter, ISO or exposure.
8085
+ - Equal shares of the frame diagonal look the same when images are shown at the same size. `calculateBlurShareOfFrame()` does not judge whether a blur is visible.
8086
+
8087
+ See [Physics Foundation](https://github.com/Photivra/photivra/blob/main/docs/PHYSICS_FOUNDATION.md#sensor-equivalence-f-number-area-and-blur-share).
@@ -0,0 +1,209 @@
1
+ # camera/sensor-equivalence.ts public contracts
2
+
3
+ The package and root API versions are recorded once in [Navigation](../API_REFERENCE.md) · [Developer guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Generated signatures retain independent schema/model versions. Only the exports listed here are root-package contracts; module-local helpers are not supported deep imports.
4
+
5
+ ## BlurShareOfFrame
6
+
7
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
8
+
9
+ ```ts
10
+ export interface BlurShareOfFrame {
11
+ /** Blur-circle diameter on the sensor in millimetres. */
12
+ blurCircleMm: number;
13
+ /** Sensor diagonal in millimetres. */
14
+ frameDiagonalMm: number;
15
+ /** Blur diameter / frame diagonal. Equal shares look the same at the same output size. */
16
+ shareOfDiagonal: number;
17
+ /** Blur diameter / frame width. */
18
+ shareOfWidth: number;
19
+ /** Blur diameter / frame height. */
20
+ shareOfHeight: number;
21
+ /** Blur on the print; present only when a print size was given. */
22
+ print?: BlurShareOfPrint;
23
+ }
24
+ ```
25
+
26
+ ## BlurShareOfPrint
27
+
28
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
29
+
30
+ ```ts
31
+ export interface BlurShareOfPrint {
32
+ /** Linear enlargement from sensor to print: the smaller of the long-side and short-side ratios. */
33
+ enlargement: number;
34
+ /** Blur-circle diameter on the print in millimetres: blurCircleMm × enlargement. */
35
+ blurOnPrintMm: number;
36
+ /** Size of the printed image in millimetres (equal to the print when the aspect ratios match). */
37
+ imageLongSideMm: number;
38
+ imageShortSideMm: number;
39
+ }
40
+ ```
41
+
42
+ ## calculateBlurShareOfFrame
43
+
44
+ A blur circle on the sensor as a share of the frame (diagonal, width and height) and, when a
45
+ print size is given, its diameter on that print.
46
+
47
+ Equal shares of the diagonal look the same when the images are shown at the same size, which
48
+ is how "same framing" comparisons across sensor sizes are stated. Every value is an exact ratio
49
+ or scaling; whether a blur is visible depends on viewing distance and acuity, which this
50
+ function does not judge.
51
+
52
+ ```ts
53
+ export function calculateBlurShareOfFrame(
54
+ input: CalculateBlurShareOfFrameInput
55
+ ): CalculationResult<BlurShareOfFrame>;
56
+ ```
57
+
58
+ This call is synchronous; any supplied provider must follow its explicit synchronous contract.
59
+
60
+ ## CalculateBlurShareOfFrameInput
61
+
62
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
63
+
64
+ ```ts
65
+ export interface CalculateBlurShareOfFrameInput {
66
+ /** Blur-circle diameter on the sensor in millimetres (for example from calculateDefocusCircle). Zero is allowed. */
67
+ blurCircleMm: number;
68
+ /** Physical active imaging area of the sensor. */
69
+ sensor: SensorImagingArea;
70
+ /**
71
+ * Optional print size. The whole frame is enlarged to fit inside it without cropping, long side
72
+ * to long side.
73
+ */
74
+ print?: { widthMm: number; heightMm: number };
75
+ }
76
+ ```
77
+
78
+ ## calculateSensorEquivalence
79
+
80
+ Equivalence of a lens (focal length, f-number) on one sensor against a reference format,
81
+ 36 × 24 mm by default: crop factor, equivalent focal length, equivalent f-number for depth of
82
+ field, blur and total light, the unchanged f-number for exposure, sensor area ratio, total-light
83
+ difference in stops and the angles of view.
84
+
85
+ Call it once per sensor for a lens table. Every quantity carries a `basis` entry saying whether it
86
+ is exact under its stated conditions or an approximation. The equivalent f-number is an
87
+ approximation (exact when the focus distance is much greater than the focal length) and never an
88
+ exposure setting: exposure uses `exposureFNumber`, the f-number on the lens.
89
+
90
+ Example: 50 mm f/2 on a 23.5 × 15.6 mm sensor gives crop factor ≈ 1.53, ≈ 77 mm and ≈ f/3.1.
91
+
92
+ ```ts
93
+ export function calculateSensorEquivalence(
94
+ input: CalculateSensorEquivalenceInput
95
+ ): CalculationResult<SensorEquivalence>;
96
+ ```
97
+
98
+ This call is synchronous; any supplied provider must follow its explicit synchronous contract.
99
+
100
+ ## CalculateSensorEquivalenceInput
101
+
102
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
103
+
104
+ ```ts
105
+ export interface CalculateSensorEquivalenceInput {
106
+ /** Physical active imaging area of the sensor the lens is used on. */
107
+ sensor: SensorImagingArea;
108
+ /** Physical focal length of the lens in millimetres. */
109
+ focalLengthMm: number;
110
+ /** f-number set on the lens (N = focal length / entrance-pupil diameter). */
111
+ fNumber: number;
112
+ /** Format to compare against. Defaults to the 36 × 24 mm frame. */
113
+ reference?: SensorImagingArea;
114
+ }
115
+ ```
116
+
117
+ ## EquivalenceExactness
118
+
119
+ Whether a value is exact under its stated conditions or an approximation.
120
+
121
+ ```ts
122
+ export type EquivalenceExactness = "exact" | "approximation";
123
+ ```
124
+
125
+ ## EquivalenceValueBasis
126
+
127
+ How one equivalence quantity was obtained, and under which conditions it holds.
128
+
129
+ ```ts
130
+ export interface EquivalenceValueBasis {
131
+ /** `"exact"` under the stated conditions, or `"approximation"`. */
132
+ kind: EquivalenceExactness;
133
+ /** Plain statement of the definition and its conditions. */
134
+ statement: string;
135
+ }
136
+ ```
137
+
138
+ ## SensorEquivalence
139
+
140
+ The exact contract is declared below; use the domain guide for assumptions and staged integration.
141
+
142
+ ```ts
143
+ export interface SensorEquivalence {
144
+ /** Physical focal length supplied by the caller, in millimetres. */
145
+ focalLengthMm: number;
146
+ /** f-number supplied by the caller. */
147
+ fNumber: number;
148
+ /** Sensor active imaging area used. */
149
+ sensor: SensorImagingArea;
150
+ /** Reference format used (36 × 24 mm unless the caller passed one). */
151
+ reference: SensorImagingArea;
152
+ /** Sensor diagonal in millimetres. */
153
+ sensorDiagonalMm: number;
154
+ /** Reference diagonal in millimetres. */
155
+ referenceDiagonalMm: number;
156
+ /** Diagonal crop factor: reference diagonal / sensor diagonal. Greater than 1 for a smaller sensor. */
157
+ cropFactor: number;
158
+ /** Focal length on the reference format with the same diagonal angle of view: cropFactor × focalLengthMm. */
159
+ equivalentFocalLengthMm: number;
160
+ /**
161
+ * f-number on the reference format that gives the same depth of field, background blur and total
162
+ * light for the same framing and output size: cropFactor × fNumber. Not an exposure setting.
163
+ */
164
+ equivalentFNumber: number;
165
+ /** f-number that sets exposure (image brightness): the f-number on the lens, unchanged by sensor size. */
166
+ exposureFNumber: number;
167
+ /** Entrance-pupil diameter f/N in millimetres; the equivalent lens has the same diameter. */
168
+ entrancePupilDiameterMm: number;
169
+ /** Sensor area / reference area. Less than 1 for a smaller sensor. */
170
+ areaRatio: number;
171
+ /**
172
+ * log2(areaRatio): total light collected by the sensor relative to the reference at the same
173
+ * f-number, shutter time and scene, in stops. Negative for a smaller sensor.
174
+ */
175
+ totalLightDifferenceStops: number;
176
+ /** Angles of view of this lens on the sensor (the equivalent lens gives the same diagonal angle). */
177
+ fieldOfView: SensorEquivalenceFieldOfView;
178
+ /** Exact or approximated, with the conditions, for every quantity above. */
179
+ basis: Readonly<Record<SensorEquivalenceQuantity, EquivalenceValueBasis>>;
180
+ }
181
+ ```
182
+
183
+ ## SensorEquivalenceFieldOfView
184
+
185
+ Angles of view of an ideal rectilinear lens focused at infinity, in degrees.
186
+
187
+ ```ts
188
+ export interface SensorEquivalenceFieldOfView {
189
+ horizontalDegrees: number;
190
+ verticalDegrees: number;
191
+ diagonalDegrees: number;
192
+ }
193
+ ```
194
+
195
+ ## SensorEquivalenceQuantity
196
+
197
+ Quantities of a sensor equivalence result that carry an exactness statement.
198
+
199
+ ```ts
200
+ export type SensorEquivalenceQuantity =
201
+ | "cropFactor"
202
+ | "equivalentFocalLengthMm"
203
+ | "equivalentFNumber"
204
+ | "exposureFNumber"
205
+ | "entrancePupilDiameterMm"
206
+ | "areaRatio"
207
+ | "totalLightDifferenceStops"
208
+ | "fieldOfView";
209
+ ```
@@ -11,5 +11,5 @@ version they were made with. Proof-of-concept (POC), schema and model contracts
11
11
  own versions. Release checks confirm it matches package.json and the packed package.
12
12
 
13
13
  ```ts
14
- ENGINE_API_VERSION = "2.17.0" as const
14
+ ENGINE_API_VERSION = "2.19.0" as const
15
15
  ```
@@ -0,0 +1,26 @@
1
+ # equipment/ideal-lens.ts public contracts
2
+
3
+ The package and root API versions are recorded once in [Navigation](../API_REFERENCE.md) · [Developer guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Generated signatures retain independent schema/model versions. Only the exports listed here are root-package contracts; module-local helpers are not supported deep imports.
4
+
5
+ ## IDEAL_LENS_LIMITS
6
+
7
+ Ideal lens (#576): the bounds of `simulateCapture`'s and the display view's `lens.ideal`, a plain aberration-free thin lens
8
+ named by its physical focal length, on any sensor. Its blur is that of the options the request gives, the same models as with
9
+ `lens.prime`: `lens.defocus` (the thin-lens blur circle, `calculateDefocusCircle`), `lens.diffraction` (the Airy pattern of a
10
+ circular pupil) and `lens.vignetting` (a caller profile). Without them it renders sharp and evenly lit.
11
+
12
+ - `fNumber.minimum` 0.5 is a physical limit: a lens in air that satisfies the Abbe sine condition (aplanatic) has
13
+ N = 1/(2·n·sin θ′) with n = 1 and sin θ′ ≤ 1, so N ≥ 0.5 (the sine condition: Born & Wolf, *Principles of Optics*, 7th ed.;
14
+ Kingslake & Johnson, *Lens Design Fundamentals*, 2nd ed.). The engine's thin-lens defocus and scalar Airy diffraction
15
+ are paraxial models, so near f/0.5 they are approximations further from a real lens than at f/2.
16
+ - `fNumber.maximum` 128 and both focal-length bounds are engine bounds, not physical limits: 1 mm is shorter than phone
17
+ camera lenses, 4000 mm longer than photographic lenses, and f/128 beyond large-format lenses' smallest stops.
18
+ - The ideal lens's f-number range is the whole of `fNumber`: manual and aperture priority take any `lens.fNumber` in it, and
19
+ shutter priority and program may choose any aperture in it (in dim light, wider than real lenses open).
20
+
21
+ ```ts
22
+ IDEAL_LENS_LIMITS = Object.freeze({
23
+ focalLengthMm: Object.freeze({ minimum: 1, maximum: 4000 }),
24
+ fNumber: Object.freeze({ minimum: 0.5, maximum: 128 })
25
+ })
26
+ ```