@photivra/engine 2.19.0 → 2.21.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 (242) hide show
  1. package/CHANGELOG.md +289 -1
  2. package/CITATION.cff +1 -1
  3. package/NODE_SUPPORT.md +7 -5
  4. package/README.md +7 -6
  5. package/SECURITY.md +1 -1
  6. package/THIRD_PARTY.md +21 -10
  7. package/dist/capture/dng-lens-correction-opcodes.d.ts +32 -0
  8. package/dist/capture/dng-lens-correction-opcodes.js +119 -0
  9. package/dist/capture/dng-lens-correction-opcodes.js.map +1 -0
  10. package/dist/capture/exposure-build-up-stage.d.ts +16 -0
  11. package/dist/capture/exposure-build-up-stage.js +4 -0
  12. package/dist/capture/exposure-build-up-stage.js.map +1 -0
  13. package/dist/capture/native-environment-lean.d.ts +2 -1
  14. package/dist/capture/native-environment-lean.js +42 -78
  15. package/dist/capture/native-environment-lean.js.map +1 -1
  16. package/dist/capture/native-environment-tabulated.js +4 -3
  17. package/dist/capture/native-environment-tabulated.js.map +1 -1
  18. package/dist/capture/native-raw-dng.d.ts +6 -0
  19. package/dist/capture/native-raw-dng.js +16 -2
  20. package/dist/capture/native-raw-dng.js.map +1 -1
  21. package/dist/capture/native-raw.d.ts +3 -2
  22. package/dist/capture/native-raw.js +5 -4
  23. package/dist/capture/native-raw.js.map +1 -1
  24. package/dist/capture/sensor-raw-producer.d.ts +4 -1
  25. package/dist/capture/sensor-raw-producer.js +15 -8
  26. package/dist/capture/sensor-raw-producer.js.map +1 -1
  27. package/dist/core/shared-helpers.d.ts +2 -0
  28. package/dist/core/shared-helpers.js +4 -0
  29. package/dist/core/shared-helpers.js.map +1 -1
  30. package/dist/core/version.d.ts +1 -1
  31. package/dist/core/version.js +1 -1
  32. package/dist/core/version.js.map +1 -1
  33. package/dist/equipment/generic-lens-catalogue.d.ts +11 -4
  34. package/dist/equipment/generic-lens-catalogue.js +49 -7
  35. package/dist/equipment/generic-lens-catalogue.js.map +1 -1
  36. package/dist/equipment/generic-tier-assets.d.ts +32 -0
  37. package/dist/equipment/generic-tier-assets.js +18 -14
  38. package/dist/equipment/generic-tier-assets.js.map +1 -1
  39. package/dist/equipment/generic-tier-presets.js +2 -3
  40. package/dist/equipment/generic-tier-presets.js.map +1 -1
  41. package/dist/equipment/sensor-profile-body.d.ts +78 -0
  42. package/dist/equipment/sensor-profile-body.js +77 -0
  43. package/dist/equipment/sensor-profile-body.js.map +1 -0
  44. package/dist/equipment/sensor-profiles.d.ts +8 -0
  45. package/dist/equipment/sensor-profiles.js +82 -0
  46. package/dist/equipment/sensor-profiles.js.map +1 -0
  47. package/dist/facade/catalogue-lens-signal.d.ts +2 -2
  48. package/dist/facade/catalogue-lens-signal.js +58 -71
  49. package/dist/facade/catalogue-lens-signal.js.map +1 -1
  50. package/dist/facade/chart-radiance-batch.js +2 -1
  51. package/dist/facade/chart-radiance-batch.js.map +1 -1
  52. package/dist/facade/defocus-image-space-provider.d.ts +7 -0
  53. package/dist/facade/defocus-image-space-provider.js +64 -76
  54. package/dist/facade/defocus-image-space-provider.js.map +1 -1
  55. package/dist/facade/display-view-gpu-session.js +2 -0
  56. package/dist/facade/display-view-gpu-session.js.map +1 -1
  57. package/dist/facade/display-view-gpu.d.ts +13 -5
  58. package/dist/facade/display-view-gpu.js +4 -4
  59. package/dist/facade/display-view-gpu.js.map +1 -1
  60. package/dist/facade/display-view-lens-signal.d.ts +1 -1
  61. package/dist/facade/display-view-lens-signal.js +30 -26
  62. package/dist/facade/display-view-lens-signal.js.map +1 -1
  63. package/dist/facade/display-view.d.ts +3 -1
  64. package/dist/facade/display-view.js +6 -24
  65. package/dist/facade/display-view.js.map +1 -1
  66. package/dist/facade/expected-signal-setup.d.ts +3 -1
  67. package/dist/facade/expected-signal-setup.js +1 -1
  68. package/dist/facade/expected-signal-setup.js.map +1 -1
  69. package/dist/facade/expected-signal.d.ts +47 -14
  70. package/dist/facade/expected-signal.js +117 -112
  71. package/dist/facade/expected-signal.js.map +1 -1
  72. package/dist/facade/exposure-build-up.d.ts +70 -0
  73. package/dist/facade/exposure-build-up.js +164 -0
  74. package/dist/facade/exposure-build-up.js.map +1 -0
  75. package/dist/facade/fast-methods.d.ts +50 -0
  76. package/dist/facade/fast-methods.js +58 -0
  77. package/dist/facade/fast-methods.js.map +1 -0
  78. package/dist/facade/fast-resolution.d.ts +12 -0
  79. package/dist/facade/fast-resolution.js +18 -0
  80. package/dist/facade/fast-resolution.js.map +1 -0
  81. package/dist/facade/generic-lens-capture.d.ts +5 -4
  82. package/dist/facade/generic-lens-capture.js +5 -4
  83. package/dist/facade/generic-lens-capture.js.map +1 -1
  84. package/dist/facade/image-plane-enable.d.ts +7 -0
  85. package/dist/facade/image-plane-enable.js +17 -0
  86. package/dist/facade/image-plane-enable.js.map +1 -0
  87. package/dist/facade/image-plane-lattice-data.d.ts +6 -0
  88. package/dist/facade/image-plane-lattice-data.js +10 -0
  89. package/dist/facade/image-plane-lattice-data.js.map +1 -0
  90. package/dist/facade/image-plane-support.d.ts +33 -0
  91. package/dist/facade/image-plane-support.js +11 -0
  92. package/dist/facade/image-plane-support.js.map +1 -0
  93. package/dist/facade/image-plane.d.ts +36 -0
  94. package/dist/facade/image-plane.js +353 -0
  95. package/dist/facade/image-plane.js.map +1 -0
  96. package/dist/facade/mixed-illumination.d.ts +108 -0
  97. package/dist/facade/mixed-illumination.js +174 -0
  98. package/dist/facade/mixed-illumination.js.map +1 -0
  99. package/dist/facade/representative-light-sources.d.ts +72 -0
  100. package/dist/facade/representative-light-sources.js +124 -0
  101. package/dist/facade/representative-light-sources.js.map +1 -0
  102. package/dist/facade/scene-radiance.d.ts +129 -4
  103. package/dist/facade/scene-radiance.js +48 -13
  104. package/dist/facade/scene-radiance.js.map +1 -1
  105. package/dist/facade/simulate-capture.d.ts +88 -19
  106. package/dist/facade/simulate-capture.js +374 -145
  107. package/dist/facade/simulate-capture.js.map +1 -1
  108. package/dist/facade/simulate-display-view.d.ts +3 -2
  109. package/dist/facade/simulate-display-view.js +36 -18
  110. package/dist/facade/simulate-display-view.js.map +1 -1
  111. package/dist/gpu/chart-composite-gpu.d.ts +13 -1
  112. package/dist/gpu/chart-composite-gpu.js +48 -9
  113. package/dist/gpu/chart-composite-gpu.js.map +1 -1
  114. package/dist/gpu/chart-radiance-gpu.js +4 -30
  115. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  116. package/dist/gpu/display-view-composite-material-module.d.ts +10 -4
  117. package/dist/gpu/display-view-composite-material-module.js +39 -13
  118. package/dist/gpu/display-view-composite-material-module.js.map +1 -1
  119. package/dist/gpu/display-view-image-material-module.d.ts +86 -0
  120. package/dist/gpu/display-view-image-material-module.js +339 -0
  121. package/dist/gpu/display-view-image-material-module.js.map +1 -0
  122. package/dist/gpu/display-view-image-quality.d.ts +45 -0
  123. package/dist/gpu/display-view-image-quality.js +380 -0
  124. package/dist/gpu/display-view-image-quality.js.map +1 -0
  125. package/dist/gpu/display-view-quality-gpu.d.ts +67 -1
  126. package/dist/gpu/display-view-quality-gpu.js +136 -205
  127. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  128. package/dist/gpu/display-view-speed-gpu.d.ts +51 -3
  129. package/dist/gpu/display-view-speed-gpu.js +131 -93
  130. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  131. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +29 -15
  132. package/dist/gpu/wgsl-display-view-quality-kernel.js +58 -36
  133. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  134. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +21 -6
  135. package/dist/gpu/wgsl-display-view-speed-kernel.js +46 -11
  136. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  137. package/dist/gpu/wgsl-exact-sum.d.ts +20 -0
  138. package/dist/gpu/wgsl-exact-sum.js +42 -0
  139. package/dist/gpu/wgsl-exact-sum.js.map +1 -0
  140. package/dist/index.d.ts +18 -3
  141. package/dist/index.js +12 -1
  142. package/dist/index.js.map +1 -1
  143. package/dist/motion/subject-motion.d.ts +66 -0
  144. package/dist/motion/subject-motion.js +52 -0
  145. package/dist/motion/subject-motion.js.map +1 -0
  146. package/dist/optics/aperture-diameter.d.ts +47 -0
  147. package/dist/optics/aperture-diameter.js +50 -0
  148. package/dist/optics/aperture-diameter.js.map +1 -0
  149. package/dist/optics/detail-limit.d.ts +182 -0
  150. package/dist/optics/detail-limit.js +336 -0
  151. package/dist/optics/detail-limit.js.map +1 -0
  152. package/dist/optics/generic-lens-lateral-color.d.ts +83 -0
  153. package/dist/optics/generic-lens-lateral-color.js +77 -0
  154. package/dist/optics/generic-lens-lateral-color.js.map +1 -0
  155. package/dist/optics/generic-lens-psf.d.ts +18 -2
  156. package/dist/optics/generic-lens-psf.js +79 -48
  157. package/dist/optics/generic-lens-psf.js.map +1 -1
  158. package/dist/optics/lens-psf-profile.d.ts +7 -0
  159. package/dist/optics/lens-psf-profile.js +2 -1
  160. package/dist/optics/lens-psf-profile.js.map +1 -1
  161. package/dist/optics/scene-to-sensor-irradiance.d.ts +13 -0
  162. package/dist/optics/scene-to-sensor-irradiance.js +7 -0
  163. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  164. package/dist/output/crop-to-match-focal-length.d.ts +62 -0
  165. package/dist/output/crop-to-match-focal-length.js +63 -0
  166. package/dist/output/crop-to-match-focal-length.js.map +1 -0
  167. package/dist/output/developed-lens-corrections.d.ts +19 -5
  168. package/dist/output/developed-lens-corrections.js +30 -11
  169. package/dist/output/developed-lens-corrections.js.map +1 -1
  170. package/dist/output/generic-lens-corrections.d.ts +41 -3
  171. package/dist/output/generic-lens-corrections.js +57 -9
  172. package/dist/output/generic-lens-corrections.js.map +1 -1
  173. package/dist/output/lens-corrections.d.ts +12 -2
  174. package/dist/output/lens-corrections.js +22 -7
  175. package/dist/output/lens-corrections.js.map +1 -1
  176. package/dist/output/print-contrast-reference.js +1 -1
  177. package/dist/output/print-contrast-reference.js.map +1 -1
  178. package/dist/output/print-plan.d.ts +41 -5
  179. package/dist/output/print-plan.js +59 -14
  180. package/dist/output/print-plan.js.map +1 -1
  181. package/dist/output/print-size-blur-noise.d.ts +110 -0
  182. package/dist/output/print-size-blur-noise.js +138 -0
  183. package/dist/output/print-size-blur-noise.js.map +1 -0
  184. package/dist/sensor/binomial-sample.d.ts +3 -0
  185. package/dist/sensor/binomial-sample.js +86 -0
  186. package/dist/sensor/binomial-sample.js.map +1 -0
  187. package/dist/sensor/eqe-electron-rate.js +1 -7
  188. package/dist/sensor/eqe-electron-rate.js.map +1 -1
  189. package/dist/sensor/exposure-window.d.ts +2 -0
  190. package/dist/sensor/exposure-window.js +2 -2
  191. package/dist/sensor/exposure-window.js.map +1 -1
  192. package/dist/sensor/readout-timing.js +2 -23
  193. package/dist/sensor/readout-timing.js.map +1 -1
  194. package/dist/sensor/response-application-compatibility.js +1 -6
  195. package/dist/sensor/response-application-compatibility.js.map +1 -1
  196. package/dist/sensor/responsivity-photocurrent.js +1 -7
  197. package/dist/sensor/responsivity-photocurrent.js.map +1 -1
  198. package/dist/sensor/sensor-characteristics.d.ts +147 -0
  199. package/dist/sensor/sensor-characteristics.js +126 -0
  200. package/dist/sensor/sensor-characteristics.js.map +1 -0
  201. package/dist/sensor/spatial-sample-reduction.js +1 -5
  202. package/dist/sensor/spatial-sample-reduction.js.map +1 -1
  203. package/dist/sensor/spectral-quadrature.js +1 -6
  204. package/dist/sensor/spectral-quadrature.js.map +1 -1
  205. package/dist/sensor/temporal-photo-signal.d.ts +3 -0
  206. package/dist/sensor/temporal-photo-signal.js +5 -2
  207. package/dist/sensor/temporal-photo-signal.js.map +1 -1
  208. package/dist/stabilization/system.d.ts +9 -0
  209. package/dist/stabilization/system.js +5 -6
  210. package/dist/stabilization/system.js.map +1 -1
  211. package/docs/AGENT_GUIDE.md +5 -0
  212. package/docs/API_REFERENCE.md +173 -2
  213. package/docs/QUICK_START.md +213 -6
  214. package/docs/USAGE.md +157 -3
  215. package/docs/api/capture-native-raw-dng.md +6 -0
  216. package/docs/api/core-version.md +1 -1
  217. package/docs/api/equipment-generic-lens-catalogue.md +17 -7
  218. package/docs/api/equipment-sensor-profile-body.md +64 -0
  219. package/docs/api/equipment-sensor-profiles.md +86 -0
  220. package/docs/api/exports.json +357 -2
  221. package/docs/api/facade-display-view-gpu.md +5 -2
  222. package/docs/api/facade-exposure-build-up.md +105 -0
  223. package/docs/api/facade-fast-methods.md +99 -0
  224. package/docs/api/facade-image-plane-enable.md +16 -0
  225. package/docs/api/facade-mixed-illumination.md +67 -0
  226. package/docs/api/facade-representative-light-sources.md +131 -0
  227. package/docs/api/facade-scene-radiance.md +54 -3
  228. package/docs/api/facade-simulate-capture.md +60 -15
  229. package/docs/api/facade-simulate-display-view.md +3 -2
  230. package/docs/api/motion-subject-motion.md +23 -0
  231. package/docs/api/optics-aperture-diameter.md +51 -0
  232. package/docs/api/optics-detail-limit.md +203 -0
  233. package/docs/api/optics-generic-lens-lateral-color.md +79 -0
  234. package/docs/api/output-crop-to-match-focal-length.md +67 -0
  235. package/docs/api/output-developed-lens-corrections.md +10 -1
  236. package/docs/api/output-generic-lens-corrections.md +40 -2
  237. package/docs/api/output-lens-corrections.md +11 -1
  238. package/docs/api/output-print-plan.md +35 -1
  239. package/docs/api/output-print-size-blur-noise.md +115 -0
  240. package/docs/api/sensor-sensor-characteristics.md +105 -0
  241. package/llms.txt +6 -0
  242. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,294 @@
1
1
  # Changelog
2
2
 
3
- ## 2.19.0 — unreleased (prepared November 9, 2026)
3
+ ## 2.21.0 — unreleased (prepared November 10, 2026)
4
+
5
+ A minor release that combines the 2.21.0 and 2.22.0 parts (owner decision 2026-11-10): the chart-signal design choice on every render path, sensor profiles and phone lenses (#554), the image-plane scene (#581), catalogue-lens boundary ties (#588) and lateral color (#589), exposure build-up (#534) with subject motion (#551) and mixed lighting (#550), the lateral color fringe width (#580), Photivra's own light-source spectra (#557) and the print planner's contain fit (#365). See the [2.21.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_21_0.md).
6
+
7
+ - **New:** `sensor.profile` and the profile catalogue `SENSOR_PROFILES` / `resolveSensorProfile`, with three phone lenses (#554); the `image-plane` scene and `prepareImagePlaneScenes` (#581); `simulateExposureBuildUp` (#534); `motion.subject` (#551); `illumination` (#550); `calculateChannelEffectiveDispersion` (#589); `calculateLateralColorFringe` (#580, with an optional `channelDispersion`); the representative light sources and `createRepresentativeLightSourceIlluminant` (#557); `fit: { kind: "contain" }` in the print planner (#365).
8
+ - **Changed (shipped images and files; see "Default changes"):** chart signals are integrated from the material spectrum per site on every render path (a design choice; samples unchanged but for rounding, Quality paths slower); catalogue PSF kernels carry the PSF's own centroid (#589); catalogue-lens ties resolve as on the lattice (#588); `output.lensCorrections` and DNG lens-correction opcodes use the capture's own channel dispersion (#589); `PrintPlanInput.fit` is a union (#365).
9
+ - **Root exports:** 41 new, 1,890 → 1,931 (runtime exports 637 → 654): 6 from #554 (4 runtime), 3 from #581 (1 runtime), 6 from #534 (3 runtime), 1 from #551 (type), 5 from #550 (types), 3 from #589 (1 runtime), 4 from #580 (1 runtime), 11 from #557 (7 runtime), 2 from #365 (types). Nothing is removed.
10
+ - **Why minor:** new root exports and options; every default change below keeps the old behavior one option away or is a model correction. #365's union type needs a narrowing where code reads `fit.maximumRelativeAspectError`. Not in this release: #590 (sample counts by format, with the spiky-light 5 nm step) and #591 (the channel-response helper).
11
+
12
+ ### Default changes (migration)
13
+
14
+ | What changed | Before (2.20.0) | Now (2.21.0) | Keep the old behavior |
15
+ | --- | --- | --- | --- |
16
+ | Chart signals on every render path (design choice, owner decision 2026-11-09) | per-material channel values (and a white-integral shortcut for neutral materials) looked up per site | each site's signal integrated from its materials' spectra when it is read, on the CPU and GPU, capture, display view and ideal reference | no option (same samples but for rounding: 1 sample in 442,368 by one code in the measured Quality view; Quality paths 1.5–14× slower, the owner accepted; GPU Speed emulation about +14 %) |
17
+ | Catalogue PSF kernels (#589) | the cells' quantized centroid | each node kernel's centroid moved onto the PSF's own first moment (coma's tilt plus lateral color): rendered lateral color 96–101 % of the lens model | no option (a model correction; off-axis catalogue captures and views change) |
18
+ | Catalogue fast grid (#589) | at least 4 cells per field interval | at least 10 (no run-time fallbacks; 768-wide fast frames 36–40 % slower) | no option |
19
+ | Catalogue-lens ties (#588) | each tap's own point on the exact method | each tap read at its lattice point; under rolling-shutter rotation (or a moving subject) the per-tap points are `lens.catalogue`'s fast method (fast on screen, full for DNG, JPEG and PNG) | `lens: { catalogue: { kind, fast: true } }` keeps per-tap points there |
20
+ | `output.lensCorrections` lateral color and DNG `lensCorrectionOpcodes` (#589, #587) | channel scales at single wavelengths 610, 540 and 465 nm | each channel's effective dispersion for the capture's own channels and light (reported as `channelDispersion` in both assumptions) | no option; `createGenericLensCorrectionProfile` without `channelDispersion` keeps the single wavelengths |
21
+ | `PrintPlanInput.fit` type (#365) | one record kind | the union `PrintFit` (`confirmed-native-aspect` or `contain`) | narrow on `fit.kind`; `confirmed-native-aspect` plans are byte-identical |
22
+ | Catalogue lenses with `sensor.profile` (#554) | (new option) | framed on the profile's real sensor (its centered region of the output's aspect) | without `sensor.profile` nothing changes |
23
+ | Auto ISO's whole-stop grid (#554) | counted from ISO 100 | counted from the body's lowest ISO | the tiers are unchanged (their lowest is ISO 100) |
24
+ | Image-plane print surround (#581) | (new) | its channel signals integrated from its spectrum like a pixel's | — |
25
+
26
+ ### Integration (this release)
27
+
28
+ - **Merged** with `--no-ff` in this order: `release-2.20.0` (to join the squashed 2.20.0 history; tree unchanged), `e221-chart-signals`, `e221-588`, `e221-589`, `e221-554`, `e221-581`, `e222-534` (with #551 and #550 merged in), `e222-580`, `e222-557`, `e222-365`.
29
+ - **Speed versus accuracy (2.20.0's scheme):** #588's rolling-shutter choice resolves through `resolveFast(catalogueFast, fastTop)`, so `diagnostics.fast.requested` is the resolved top-level value with `defaultedFrom`, and PNG counts as an export; #565's and #588's lattice providers are one branch (the two never combine) with one assumption method name, `per-tap-source-points`; the registry gains `lens.catalogue/rolling-shutter-taps` (and #551's `motion.subject/temporal-midpoints`).
30
+ - **Design-around on the merged paths:** #581's GPU Speed adapter integrates the material's spectrum in the kernel (`material_signal`) instead of 581's per-material table; the image-plane print's surround is integrated from its spectrum instead of the white integral; #534's display-view lens signal integrates each material's spectrum at #551's times. Mixed light over an image-plane print is refused (its pixel signals are integrated under one illuminant).
31
+ - **#589 with #587:** the DNG opcodes take the same capture-channel dispersion as `output.lensCorrections`, so they still reproduce the developed correction exactly (as 2.20.0's test requires).
32
+ - **#580:** takes #589's dispersion (owner decision) and is re-measured against the RAW centroid measure.
33
+ - **#554 with #567:** the phone lenses' bokeh (the consumer construction; the 24 mm one has a cat's eye) is covered by the bokeh-value test.
34
+ - **CI capacity (owner-approved):** this release's suite is about 28 % longer (CI test legs 2,612 → 3,350 s), and three of 2.20.0's four coverage shards reached the 20-minute job limit. Coverage now runs as six time-balanced shards in `ci.yml` and `publish.yml` (the `coverage gate` job still merges them and enforces the thresholds on the whole suite), the five chart cases of `display-view-quality-gpu.test.ts` run from their own file, and `test/shard-weights.json` is re-estimated from this pull request's CI times. No test was removed and no job limit was raised.
35
+ - **Size:** one pass for savings, then the capture-worker limit raised by the remaining overage (owner rule for this release): 365,164 → 363,695 B after making #554's profile parser and #550's module static imports (as lazy chunks they split the core modules into shared chunks); limit 356,000 → 363,700 B. The display view's added size stays inside its unchanged budget (46,270 B minified, 18,327 B gzip). See the release record's "Sizes".
36
+
37
+ ### Chart scenes: signals integrated from the material spectrum per site
38
+
39
+ - **Changed (design choice, owner decision 2026-11-09): every chart-scene render path integrates each site's signal from its material's spectrum when the site is read.** A chart material is kept as its spectral radiance at the spectral nodes (a neutral one: its reflectance times the white radiance), and a site's signal is formed from the spectra of the materials it reads against its own channel's response (and kernel). No per-material channel value, and no white-integral shortcut for neutral materials, remains:
40
+ - **Capture** (the tabulated RAW path, #394, and #564's large-kernel tiles), **CPU display view** (direct and kernel paths, #563/#569 lens zones) and **ideal reference**: per-node kernels C_c(t, λ) = Σ_members w·A_c(λ)·K_λ(t), and per site the sums of C over the taps of each material it reads, then Σ_m Σ_λ L_m(λ)·P_m(λ) (`MaterialSums`, `src/facade/expected-signal.ts`). `neutralDirect`, `spectralDirect`, the per-material combined kernels and `uniformSite` are gone; a site that reads one material only takes the channel's kernel sums in the same order, so it is the same value.
41
+ - **Catalogue fast method (#568):** grid kernels per spectral node (A_c(λ)·K_{g,λ}), no material class folded in; each tap integrates its material's spectrum against the blend. The blend is taken along y once per grid column and native row.
42
+ - **Display view:** the per-material sample memo is removed (the per-real-pixel memo, within one render, stays).
43
+ - **GPU Speed:** the host uploads the channel responses and the material spectra (rounded once); the kernel integrates Σ_λ A_c(λ)·L_m(λ) per site in blocks of 16 nodes, inside the unchanged 64E site budget (up to 256 nodes).
44
+ - **GPU Quality:** the host uploads the per-node kernel values and the spectra; the site passes accumulate one partial per node and sum the partials and the dark electrons in integer arithmetic (`src/gpu/wgsl-exact-sum.ts`, rounded once), so the (T + 3)·E and blocked (cw + ch + G + 3)·E bounds are unchanged (at most 96 nodes; more fall back to the CPU). The factored neutral table and its material-row binding are gone; the uncertain-tap spread is bounded from the spectra's envelope.
45
+ - **`evaluateChartRadiance`, spectral-integral mode:** the GPU returns each query's material and the host integrates its spectrum against the channel weights per query (the same float64 operations; values unchanged).
46
+ - **Results.** Output samples are unchanged in every measured request but one: in the Quality display view of a 384 × 384 region of the composite chart, 1 of 442,368 samples moves by one code (an expected value's last bits crossing a code boundary). Float64 internals move by rounding: 11 display-view hash locks re-recorded (x64 with x64 Node 24.1.0 under Rosetta 2, which reproduces every earlier x64 lock; arm64 natively), expected values within 1.9·10⁻¹⁴ relative, except the #444 AHD case, where 30 of 360 expected components at a patch edge move by at most 0.025 8-bit codes (AHD's choice is now taken where the two candidates' means were equal to the last bit). Every GPU case stays inside its bound (emulation).
47
+ - **Cost** (Node, one thread, measured before and after interleaved in one run; the app's request shape: 6000 × 4000, 6 µm, 2 × 2 box, f/8, Quality with diffraction, the four-spot filter and AHD; the app's composite chart, all neutral, and the spectral `color-patches` chart; 21 spectral nodes):
48
+ - Capture 24 MP, Speed: 11.2 → 11.5 s (composite), 9.4 → 9.6 s (patches). Quality: 13.6 → 96.3 s, 10.9 → 15.9 s.
49
+ - 48-pixel quick frame, Speed: 21 → 23 ms, 20 → 20 ms. Quality: 65 → 68 ms, 64 → 65 ms.
50
+ - Display view, a 384 × 384 region of a 2028 × 1352 view, Speed: 1.06 → 1.06 s, 0.62 → 0.79 s. Quality: 2.7 → 33.9 s, 1.10 → 2.07 s.
51
+ - Display view, a 48-pixel view of the 24 MP frame, Speed: 0.73 → 0.93 s, 0.43 → 0.54 s. Quality: 5.6 → 76.7 s, 2.4 → 6.4 s.
52
+ - Ideal reference, 24 MP: 4.1 → 5.8 s, 2.9 → 3.1 s.
53
+ - Catalogue fast method (#568), 768-pixel quick frame: 193 → 242 ms (Siemens star), 207 → 242 ms (patches).
54
+ - GPU float32 emulation, a 384 × 384 Speed region: 0.93 → 1.07 s (checkerboard), 6.2 → 6.3 s (patches); Quality site pass of a 2028 × 32 band: 1.0 → 5.2 s (pixel pass unchanged, 14 s).
55
+ - The Quality paths integrate N products per kernel tap (400 taps at f/8) instead of reading one value, as the owner accepted.
56
+ - **Size.** The app's capture-worker shape measures 351,992 B gzip (main: 351,775 B; limit 352,000 B, not raised).
57
+ - **Tests:** `test/exact-sum.test.ts` (new); hash locks in `test/display-view.test.ts`, `test/display-view-pixel-memo.test.ts`, `test/display-view-speed-material.test.ts`.
58
+
59
+ ### #588 Catalogue lenses: read PSF quadrature taps at lattice points (chart-boundary ties)
60
+
61
+ - **Fixed:** `simulateCapture` with `lens.catalogue` (the exact method, the environment PSF quadrature) formed each tap's source point from its own site's arithmetic, so a tap exactly on a chart boundary (a Siemens star's diagonals through a symmetric frame's center, its vertical spokes through an odd frame's center column) could resolve to the other material than on the lattice that the fast method (#568), the display view (#569) and #548's image-space provider read. The quadrature's provider is now #548's image-space provider with a one-tap kernel (as #565 does for `lens.diffraction`), so each tap reads the scene at its lattice point; the fast method already read the lattice. A catalogue fisheye's lattice points beyond the horizon (outside the 180° image circle) read zero radiance, as the provider does (new internal `zeroBeyondHorizon` input of the image-space provider). With rotation under `motion.shutter: "rolling"` the reading follows `fast` (below).
62
+ - **Which results change:** exact-method catalogue captures, only at such ties. On a 20 × 14 Siemens star at 60 µm, *PV 24 1.8* wide open: 20 of 352 native sites (largest 15× the lattice value); 21 × 14: 8 sites (0.7 %); *PV 8 3.5 Fisheye Pro*: 18 and 8 sites. Elsewhere the expected electrons agree to 2.4e-15. No API, option or default change; `src/gpu` unchanged.
63
+ - **Rolling shutter with camera rotation (owner decision, 2.21.0):** the per-tap points are `lens.catalogue`'s fast choice and the lattice reading its full method, folded into the existing `fast` override (no new option name): the record's `fast`, else the top-level `fast`, else fast for `srgb-8`/`srgb-16` and full for `output.format` `"dng"`, `"jpeg"` or `"png"`. The field-grid method never runs with rotation, so there `diagnostics.fast.applied["lens.catalogue"]` is `true` exactly when the per-tap points ran (`assurance: "approximation-with-a-priori-bound"`, assumption `fast.lens.catalogue` with `method: "per-tap-source-points"`, as for `lens.diffraction`); `requested` follows 2.20.0's scheme (the resolved top-level value), so the on-screen default reports `requested: true, defaultedFrom: "format", applied: true`. The integration shares one lattice provider with #565's `lens.diffraction` branch. The lattice reading there costs 1.07–1.09× the per-tap time (docs/LENS_CATALOGUE.md). Which results change: on-screen captures keep 2.19.0's per-tap RAW; DNG, JPEG and PNG captures of this case now read the lattice (they differ only at ties). Joined parts match the whole frame only when every part reads the same way: set `fast` explicitly. Nothing else changes: rotation with a global shutter always reads the lattice, and the field-grid method's resolution is unchanged.
64
+ - **Cost:** in a CPU profile of a 96 × 64 exact capture (*PV 50 1.8*, f/8, 20 nm) the lattice reader takes about 2.5 % of the time; interleaved wall-clock runs (load average 6–78 from other work) did not separate it from noise (median ratios 0.95–1.03). The image-space provider now replaces an engine-built base result's radiance in place instead of copying the result per query (saves about 1 % there; also for `lens.defocus` in image space).
65
+ - **Bundle:** capture worker +150 B gzip on 2.19.0 (351,775 → 351,925 B; limit 352,000 B, not raised): the lattice branch +117 B and the rolling-shutter fast choice +118 B, less 38 B from sharing the image-space provider's lattice-point mapping and per-node sampling between its band and run-lattice paths and one error site (values bit-identical), and the catalogue's two fast-report updates sharing one function. The display view's added size: 18,153 → 18,154 B gzip (budget 18,550 B); the capture entry chunk 261,002 → 261,167 B.
66
+ - **Tests and docs:** `test/simulate-capture-lens-catalogue-ties.test.ts` (new; *PV 24 1.8* and *PV 8 3.5 Fisheye Pro*, 20 × 14 and 21 × 14 stars: the exact method's RAW equals the lattice reading's, the fast method's sum with exact kernels, and the per-tap points differ at ties; fails on 2.19.0; and, with rolling rotation, the default by format, every explicit override, the report, and equal RAW on dead leaves through both lenses). docs/LENS_CATALOGUE.md ("Ties on chart boundaries"), docs/DISPLAY_VIEW.md (the catalogue section's tie note), docs/API_STYLE.md ("Exact and fast modes"), API reference regenerated (`SimulateCaptureLensCatalogueRecord`).
67
+
68
+ ### #589 Catalogue lenses: check rendered lateral color against the lens model on large-blur corners
69
+
70
+ - **Measured:** the blue-minus-red displacement in RAW, independent of blur and demosaicing: the per-CFA-channel centroid of an isolated lattice-point source in a 6 µm region's expected electrons (summed over the four points of a 2 × 2 cell, so each channel sees every kernel tap), fast and exact. Against the lens model at the channels' effective dispersion (below; 0.833 of the single-wavelength L·R·H·(D(465) − D(610)) for the default sensor), *PV 24 1.8* wide open rendered 101 % / 109 % (x / y) at the corner and 121 % / 83 % mid-field, *PV 24 1.4 Pro* 73 % / 38 % and 19 % / 12 %. The large-blur consumer corner was not short of lateral color; the site cells quantized a narrow PSF's centroid and the kernel window's truncation moved a wide one's.
71
+ - **Fixed (owner decision):** each catalogue PSF node kernel's centroid is moved (a linear-interpolation shift between neighboring taps) onto the PSF's own first moment, the pupil-mean wavefront tilt 2·N_w·⟨∇W⟩ (coma's W131·H) plus the lateral color L·R·H·D(λ) (Fourier moment theorem; Goodman, *Introduction to Fourier Optics*, ch. 4). Now 96–101 % of the model for both lenses, corner and mid-field. On-axis kernels are unchanged. A cost-free accuracy gain in time (the profile builds in the same time), so no `fast` choice; an approximation with a blur cost: a move of f sites adds f·(1 − f) sites² of variance, so the 6 µm corner edge rise went from 1.09 to 1.32 px (pro) and from 1.44 to 1.56 px (consumer); the center is unchanged.
72
+ - **Channel effective dispersion (owner decision):** new `calculateChannelEffectiveDispersion({ wavelengthsNm, channelResponse, illuminant })` (approximation, model `generic-lens-channel-effective-dispersion` 1.0.0) gives each channel's D̄_c = Σ response·illuminant·D(λ) / Σ response·illuminant. `createGenericLensCorrectionProfile` takes it as the optional `channelDispersion` (scales 1 + L·D̄_c; without it, D at 610, 540 and 465 nm as before). `output.lensCorrections` now uses the capture's own channels and illuminant and reports `channelDispersion` in its assumption: for the default sensor the red scale is 1 + 0.20·L instead of 1 − 0.11·L, the blue 1 + 1.04·L instead of 1 + 0.90·L. `GENERIC_LENS_CORRECTION_CHANNEL_WAVELENGTHS_NM` is unchanged and remains the profile's default. Root exports +3 (1,860 → 1,863): `calculateChannelEffectiveDispersion` (runtime), the types `CalculateChannelEffectiveDispersionInput` and `ChannelDispersion`.
73
+ - **Which results change:** every `lens.catalogue` capture and display view off the axis, and every capture with `output.lensCorrections.lateralColor` (its channel scales and fill). The chart measures (docs/LENS_CATALOGUE.md): corner rise above; coma 0.02 → 0.10 px (pro), 0.36 → 0.35 px (consumer); the developed corner edge's blue-minus-red 0.31 → 0.44 px (pro), 0.71 → 0.68 px (consumer). Hash lock `catalogue50` in `test/program-line-lens-range.test.ts`: samples and codes changed (off-axis kernels), exposure and assumptions unchanged.
74
+ - **Fast method (owner decision):** the field grid's minimum subdivisions go from 4 to 10 cells per interval (`CATALOGUE_FAST_GRID_MINIMUM_SUBDIVISIONS`, internal), so the run-time verification never falls back. Measured over 3,600 whole frames (every lens, widest and narrowest f-number, 14 charts, 192–6000 pixels wide): with 4 cells and aligned kernels 15 fell back to exact (0.42 %, all *PV 15 2.8 Fisheye Pro* at f/2.8 on 192 pixels), with 10 none (worst 8.0e-4 of the 5e-3 tolerance). Against the exact kernels: at most 2.0e-4 of the peak and 3.4e-3 of a site (the test's 1e-3 and 5e-3 bounds kept); 3.9 % of the codes differ at 375 µm (bound 2 % → 5 %). Cost: 768-wide fast captures 36–40 % slower (2.2–2.5× `lens.prime`, past #568's 2× target); 6000 × 4000 unchanged (25.2 s against 24.7 s). Fast results change accordingly; `fast.lens.catalogue` reports `minimumSubdivisions: 10`.
75
+ - **Why the developed edge differs from the model:** the channels' effective wavelengths, the color matrix that undoes their spectral overlap (it amplifies the blue image's offset from the green), the 50 % point being the line spread function's median under an asymmetric PSF, and the demosaic (docs/LENS_CATALOGUE.md, "Lateral color in RAW").
76
+ - **Bundle:** capture worker 351,775 → 351,997 B gzip (+222 B; limit 352,000 B, not raised). Added: the alignment, the channel dispersion and the two-step correction set-up. Saved, with bit-identical results: row FFTs in place, the phase-step scan in one loop, the window check without a running sum and mirroring folded into the crop (`src/optics/generic-lens-psf.ts`); one kernel centroid function shared by the PSF builder, the fast grid and the profile interpolation (`kernelCentroid`, now exported internally from `src/optics/lens-psf-profile.ts`); the lens corrections' corner vignetting read from the already resolved lens instead of resolving it again. Display view's added size unchanged.
77
+ - **Tests and docs:** `test/simulate-capture-lens-catalogue-lateral-color.test.ts` (new: the RAW measure for both lenses, corner and mid-field, fast and exact, which fails on 2.19.0; every node kernel's centroid on all 30 lenses at 1.5, 6 and 24 µm within 0.005 sites of the first moment; the helper's values and refusals, and the capture reporting the dispersion it corrects with); `test/simulate-capture-lens-catalogue.test.ts` measured values (its lateral check now pro < consumer on the developed edge); `test/simulate-capture-lens-corrections.test.ts` (the profile with the capture's reported dispersion equals its scales). docs/LENS_CATALOGUE.md ("Lateral color in RAW (#589)", "Channel effective dispersion", the measured and fast tables), docs/DIGITAL_OPTICS_FOUNDATION.md (the correction mapping), API reference regenerated.
78
+
79
+ ### #554 Sensor profile catalogue
80
+
81
+ - **New (#554): `sensor.profile`** for `simulateCapture` and `simulateDisplayView`: a sensor profile record (`SensorProfile`, checked strictly: every field, no unknown field, ranges, ISO markings, the dual-gain fields). Eight representative, unnamed profiles (`SENSOR_PROFILES`, `SENSOR_PROFILE_IDS`, `resolveSensorProfile(id)`) come from manufacturer-published specifications and an open paper, every value labelled published, derived or design; their sources are owner-approved (2026-11-09) and linked in docs/SENSOR_PROFILES.md. The capture never imports the catalogue (owner, 2026-11-09): the caller passes the record, so a capture worker does not bundle it. A profile replaces the body's ISO range, readout regimes (full well, read noise of each conversion gain, dual-gain switch, high-gain charge storage, ADC bit depth and black level), readout time and fixed-pattern noise, and sets the default pitch and peak QE; the #555 characteristics override it one at a time. The record's parser and body builder are a lazy chunk. Recorded in the assumption `sensor.profile`.
82
+ - **New (#554): three phone lenses in the generic lens catalogue**, a third set `"phone"`: PV 13 2.2 Phone, PV 24 1.8 Phone and PV 77 2.8 Phone (`pv-13-2.2-phone`, `pv-24-1.8-phone`, `pv-77-2.8-phone`; the owner's 13, 24 and 77 mm equivalents at fixed f/2.2, f/1.8 and f/2.8), with a phone set baseline and adjustments that are design values. They render only with a profile whose `fixedLenses` is `"phone"`, and such a profile takes only them. A fixed-aperture lens now defaults `lens.fNumber` to its one f-number (before, f/8 and a refusal). The catalogue has 33 lenses (`GENERIC_LENS_CATALOGUE`).
83
+ - **New (#554): catalogue lenses on a profile's real sensor** (owner, 2026-11-09). With `sensor.profile`, `lens.catalogue` is resolved on the profile's whole sensor (real focal length and field of view) and the capture is its largest centred region of the output's aspect (pitch = min(W/w, H/h); the other axis cropped, never stretched), or the centred crop an explicit pitch gives (a frame beyond the sensor is refused). A site of N photosites sums their charge and their read noise in quadrature (×√N; also explicit `readNoise`); `sensor.fixedPatternNoise` needs the profile's own pitch. Reported in the assumption `sensor.profileView`; the exact and fast catalogue paths and the display view agree. Without a profile nothing changes.
84
+ - **Shared:** #555's range checker (`checkFieldRanges` in `src/sensor/sensor-characteristics.ts`) now also checks the profile record (a predicate and a required mode added); #555's messages are unchanged.
85
+ - **ISO rating (proposal, owner question):** each profile's base ISO is re-rated from its full well, QE and pixel area, S0 = 100·(η/0.5)·(A/36 µm²)·(60 000 e⁻/FW), rounded to a third-stop marking (`SENSOR_PROFILE_ISO_ANCHOR`): the RAW white at base ISO is the full well and the engine's ISO anchor holds on every profile (the rounding, at most 1/6 stop, is `sensitivityOffsetStops`). The high gain's charge storage is the white charge at the switch ISO. A profile whose range starts above ISO 100 defaults to its base ISO.
86
+ - **Changed:** Auto ISO's whole-stop grid counts from the body's lowest ISO (unchanged for the tiers, whose lowest is ISO 100). The tiers' body builder takes a profile's ISO settings and provenance (tier assets byte-identical).
87
+ - **Root exports:** 6 new (4 runtime: `SENSOR_PROFILES`, `SENSOR_PROFILE_IDS`, `SENSOR_PROFILE_ISO_ANCHOR`, `resolveSensorProfile`; 2 types: `SensorProfile`, `SensorProfileValueKind`); `GenericLensSet` gains `"phone"`. Requests without `sensor.profile` and without a phone lens are byte-identical.
88
+ - **Docs and tests:** new docs/SENSOR_PROFILES.md; THIRD_PARTY.md (source review signed off), docs/LENS_CATALOGUE.md ("Phone lenses"), docs/QUICK_START.md, docs/USAGE.md, docs/GENERIC_TIER_PRESETS.md, README.md; `test/sensor-profiles.test.ts` (per profile: pitch, saturation, read noise, readout, ADC, dynamic range = full well ÷ read noise ⊕ quantization at base and switch ISO; the ISO anchor across profiles; overrides; fixed pattern; display view; no maker names; strict record checks; a modified record; the phone lenses on the phone profile only), `test/generic-lens-catalogue.test.ts` (33 lenses, fixed apertures).
89
+ - **Bundle:** see the part report for the capture-worker delta over 2.20.0 part A (limit 352,000 B). The catalogue data are not in it.
90
+
91
+ ### #581 Image-plane scene: a photo as a flat print in front of the camera
92
+
93
+ - **New: `prepareImagePlaneScenes()` and the `image-plane` scene.** Call `await prepareImagePlaneScenes()` once: it loads the scene's module and lattice data (a lazy chunk of 14,212 B gzip, and 7,857 B more for the GPU code; about +22.6 KB in all on the worker shape). A bundle that never imports it carries none of those bytes. Then the scene is (`{ kind: "image-plane", sceneId, image: { width, height, channels: 3 | 4, encoding: "srgb-8" | "srgb-16" | "linear-srgb", data }, paper?, interpolation?, maximumPixels? }`, types `ImagePlaneScene`, `ImagePlaneImage`). An sRGB (8- or 16-bit) or linear image prints as a flat matte Lambertian print on the scene plane, lit by the request's illuminant. It is taken by `simulateCapture`, `simulateIdealReference`, `simulateDisplayView` and `prepareDisplayView`, with exposure, noise, diffraction, defocus of the plane, distortion and vignetting acting on it. `createPlanarSceneRadianceProvider` refuses it. See docs/IMAGE_PLANE.md.
94
+ - **Method (owner decision 2026-11-09): the print reflectance lattice.**
95
+ - **Lattice.** 5 × 5 × 5 nodes at the sRGB code values 0, ¼, ½, ¾ and 1. The nodes are flat on the neutral axis; the other 120 are Photivra's own fits of Burns' LHTSS method (arXiv:1710.05732 v5, pp. 24–26, implemented from the equations; the CC BY-SA code is not used). They are fitted against CIE D65 and the CIE 1931 2° observer at fit time only (`scripts/fit-image-plane-lattice.mjs`) and stored at 10 nm over 380–780 nm in 16 bits.
96
+ - **Per pixel.** Each pixel is the trilinear interpolation of its cell's spectra in linear sRGB, then the paper: ρ = black + (white − black)·ρ. Defaults white 0.90 and black 0.01, labeled assumptions.
97
+ - **Round trip under D65** (the fit's check over a 17³ grid of sRGB codes): ΔE00 at most 6.4·10⁻⁴. Every spectrum stays in [0, 1].
98
+ - **Under other lights** the print is a metamer. The spread is measured on synthetic spectra and recorded in the docs, and the result is labeled an approximation (assumption `scene.imagePlane`).
99
+ - **No `fast` option.** The lattice is piecewise linear in RGB, so the tabulated paths stay exact.
100
+ - **Input and placement.** Alpha is composited over the paper. Linear samples outside [0, 1] are clipped and counted (`scene.parameters.clippedSampleCount`, assumption `scene.image.clippedSamples`). The image is limited to 24 MP by default, raised with `maximumPixels`. Reconstruction is bilinear by default (the pixels' spectra, so bilinear in linear sRGB under D65), with `nearest` as the option. `chartSize` sets the print, its height defaulting to the image's aspect; without it the print is fitted inside the frame on the 18 % surround. The scene digest covers the image's SHA-256.
101
+ - **GPU (owner decision 2026-11-09: Speed and Quality).** `prepareDisplayView` renders photos on WebGPU in both display-view paths, so a photo falls back to the CPU only for the reasons a chart does.
102
+ - **Speed.** A new image-plane module (`src/gpu/display-view-image-material-module.ts`). The Speed kernel's site lookup became `scene_value(p, c)`; material modules get a table adapter with the same value, so chart bytes are unchanged. The module's bound is ten extra roundings (γ_74 instead of γ_64 for that module).
103
+ - **Banded pixel data (owner decision 2026-11-09).** A photo whose 12-byte-per-pixel signals exceed one storage binding uploads per band only the pixel rows the band reaches, and the renderer caps a band's rows so they fit. Bounds and self-check are unchanged; a 2400 × 1600 photo under a 16 MB limit renders byte-identical to the whole upload.
104
+ - **Host memory (owner decision 2026-11-09).** The host builds the pixel signals 4 MiB of rows at a time, for the whole upload and per band, and keeps none: a 24 MP photo needs about 4 MiB for them instead of 288 MB per scene (576 MB with the two kept scenes). Every byte is unchanged (the same float64 operations, the lattice cell found once per pixel). A banded 24 MP render builds its rows each time.
105
+ - **Signals are integrated from the reconstructed spectrum per site (design choice 2026-11-09).** Every path that forms a sensor signal from a photo (capture, ideal reference, display view on the CPU, GPU Speed and Quality) integrates the pixel's reconstructed spectrum (its cell's node spectra blended at each spectral node, then the paper, times the illuminant) against the reading site's channel response when it is read; no per-channel values of the lattice nodes are formed. `ResolvedImagePlaneScene.channelTables` is replaced by `pixelSignals` (internal). Output bytes of the measured requests are unchanged; bounds are unchanged and every GPU case stays inside them.
106
+ - **Cost** (Node, one thread, 6000 × 4000 photo, 24 MP 6 µm frame, 20 spectral nodes): capture 24 MP 13.6 → 12.6 s, 48-pixel quick frame 0.08 s both; display view of a 384 × 384 region 0.95 → 0.92 s, a 48-pixel view of the 24 MP frame 1.16 → 1.39 s. GPU Speed's host side on the emulation: the whole upload's one-time build 0.75 → 1.8 s, a banded 24 MP render 2.0 → 2.8 s.
107
+ - **Recovered host time (owner decision 2026-11-09).** Each pixel's three channel sums are formed in one pass over its spectrum (the same sums in the same order), and a band's rows shared with the previous band are copied on the device from that band's buffer, which is kept until the next band; only the other rows are built, so a banded render builds each photo row it reaches once (it built 1.6× before). The bytes are unchanged, the host still holds at most one 4 MiB chunk of signals, and the device holds at most two band buffers at once. Before this, the one-time build took 2.2 s and a banded 24 MP render 4.5 s.
108
+ - **Quality.** Diffraction, `lens.defocus`, the anti-aliasing filter's spots and AHD, through the print's own passes 1 and 2 (`src/gpu/display-view-image-quality.ts`), behind a hook of `renderDisplayViewQualityOnGpu`.
109
+ - Pass 1: the radiance per spectral node at each lattice point, from per-band pixel lattice records.
110
+ - Pass 2: the blocked sum against the reference's per-node combined kernels, bounded at (32 + N + cw + ch + G + 4)·E relative.
111
+ - Flagged lattice points are resolved on the host with the reference's radiance when there are site kernels; without kernels their sites' pixels are recomputed.
112
+ - The pixel pass and finishing are unchanged.
113
+ - **Self-check.** The module checks itself (Speed and Quality) on a device's first photo; the renderer's own cases are unchanged.
114
+ - **Measured** against the CPU reference on the emulation and SwiftShader, in 11 Speed and Quality cases plus two print-edge cases: ΔE2000 at most 7.3·10⁻⁴ (Quality) and 4.2·10⁻⁵ (Speed), every component inside its bound, samples within one code. The SwiftShader display-view suite passes all 69 tests.
115
+ - **Size.** The app's capture-worker shape measures 351,950 B gzip (2.19.0: 351,775 B; limit 352,000 B, not raised).
116
+ - The scene's module, its lattice data and its GPU code load only through `prepareImagePlaneScenes()`, which that shape does not import. An app that calls it adds about 22.6 KB.
117
+ - The GPU display-view chunk is 54,591 B, 116 B below 2.19.0, because its photo routing and banding are paid for by sharing code: the self-checks' fixtures and verdict, the support checks, the display-pixel recomputation, the lattice formula and the materials' channel signals. The entry is +283 B (the facade's dispatch).
118
+ - **Root exports:** 3 new (`prepareImagePlaneScenes`, types `ImagePlaneScene`, `ImagePlaneImage`).
119
+ - **Tests:** `test/image-plane.test.ts`, `test/display-view-image-gpu.test.ts` (float32 emulation), image-plane cases in `test/webgpu/display-view.webgpu.ts` (SwiftShader).
120
+
121
+ ### #534 Exposure build-up
122
+
123
+ - **New (#534): `simulateExposureBuildUp(options, { fractions })`** renders fractions of one exposure as one accumulating process, like a camera's live readout of a long exposure.
124
+ - **Signal:** each fraction fⱼ < 1 is a capture of the request in manual mode at the whole exposure's resolved f-number and ISO and the shutter fⱼ·t. Its expected signal is integrated from the spectrum over [0, fⱼ·t] by the capture itself, so camera rotation, rolling shutter, `motion.subject` (#551) and mixed lighting (#550, which does not change over time) are supported (owner decision 2026-11-09). No per-channel signal is precomputed.
125
+ - **Counts:** going down the fractions, each site's photo and dark counts are split as N(fⱼ) ~ Binomial(N(fⱼ₊₁), λ(fⱼ)/λ(fⱼ₊₁)), with λ that render's expected mean at the site. This is the conditional law of a Poisson process with any rate over time. Each fraction's counts are therefore Poisson with its own mean (a capture's mean and variance at fⱼ·t), never decrease, and covary as the increment model says. Where the temporal quadrature puts an earlier mean above a later one, the count is kept and counted (`nonMonotoneSites` in the assumption `exposure.buildUp`).
126
+ - **Sensor:** fixed-pattern noise is the same in every fraction. Each fraction is clipped at the full well and read out once with its own read-noise stream, dual gain, pre-ADC/ADC saturation and ADC bits (#555; #554 profiles feed the same stage).
127
+ - **Byte-identity:** the fraction at 1 is `simulateCapture(options)` byte for byte, including its plan and assumptions (owner decision 2026-11-06).
128
+ - **Reports:** a fraction's `exposure` and `sensor` reports are the whole exposure's (owner decision 2026-11-09).
129
+ - **Limits:** on screen only, `"srgb-8"` and `"srgb-16"`; other formats are refused (owner decision 2026-11-09).
130
+ - **Fast:** the request's `fast` applies to every fraction. The build-up adds no fast method, so there is nothing to register.
131
+ - **Internal:** an exact seeded binomial sampler (`src/sensor/binomial-sample.ts`: inversion below a mean of 10, Hörmann's BTRS above; not a root export). The sensor stage takes an optional build-up stage (`src/capture/exposure-build-up-stage.ts`) in `calculateNativeRawSite`, the native RAW loop, the lean templates and the tabulated path. The stage is found by the native RAW input the facade builds (a callback of the internal `runCapture` sees it) and gets each site's expected means and ordinary draws. Without a stage, every path is unchanged.
132
+ - **Merge of #551 and #550 (here; owner decision 2026-11-09):** `motion.subject` works with `illumination`. #551's revealed 18 % background takes the plane's irradiance at each revealed point: the blend and #550's point sources, from the spectrum at use, in the capture and in the display view. The display view still refuses a rolling shutter and a subject rotation, as for any moving subject. Tests: `test/subject-motion-mixed-lighting.test.ts` (gradient blend and point source against a still 18 % scene under the same lighting, the display view, the build-up with both). +13 B gzip in the capture worker.
133
+ - **Capture-worker refactor (results bit-identical):** the lean sensor templates use the flat sensor stage (#449) instead of a second copy of the same stage (`leanSensorSite` removed), and one `reproducesSite` check serves both verifications.
134
+ - **Root exports:** 6 new (3 runtime): `simulateExposureBuildUp`, `EXPOSURE_BUILD_UP_MAXIMUM_FRACTIONS`, `EXPOSURE_BUILD_UP_STREAM_BASE` and the types `SimulateExposureBuildUpOptions`, `SimulateExposureBuildUpFrame`, `SimulateExposureBuildUpResult`.
135
+ - **Bundle:** #534 alone on 2.20.0 shrinks the capture-worker shape by 134 B gzip (355,994 → 355,860 B). This branch, with #551 and #550 (point sources included) merged, measures 359,781 B.
136
+ - **Docs and tests:** docs/QUICK_START.md ("Exposure build-up (#534)"), docs/USAGE.md, API reference; `test/exposure-build-up.test.ts`, `test/binomial-sample.test.ts`.
137
+
138
+ ### #551 Subject motion
139
+
140
+ - **New:** `motion.subject` (type `SimulateCaptureSubjectMotion`): the whole scene, or one declared rectangle of it over a still background, moves on the scene plane during the exposure: `velocityMPerSec` `{ x?, y? }` in m/s (+x right, +y up) and an optional `angularVelocityRadPerSec` about the optical axis (counter-clockwise as seen from the camera; about the region's center for a region). Each scene query reads the subject at its own time, through the same temporal midpoints as camera rotation (`sensor.temporalSampleCount`, default 8 with a moving subject), so a rolling shutter skews a moving subject by its readout skew, and a camera yaw that follows the subject (panning) keeps it sharp and streaks the background. A region's rest rectangle shows a neutral 18 % background once the subject leaves it. It combines with `lens.diffraction`, `lens.defocus` (global shutter) and the lens options; `lens.pupilSampling` is refused. Off by default: requests without it are byte-identical.
141
+ - **Display view:** renders sideways motion (global shutter) through the lens signal's temporal midpoints; refuses `motion.subject.angularVelocityRadPerSec` and a rolling shutter with a reason (`simulateCapture` renders both).
142
+ - **Fast method** (registered, `motion.subject/temporal-midpoints`; `diagnostics.fast.applied["motion.subject"]`, assumption `fast.motion.subject`): with the default count and a still camera, fast takes one temporal midpoint per site of the subject's streak plus one when that is fewer than 8; exact keeps 8; an explicit `sensor.temporalSampleCount` always wins. `"motion.subject"` joins `SIMULATE_CAPTURE_FAST_OPTIONS` and `SIMULATE_CAPTURE_COST_OPTIONS`.
143
+ - **Root exports:** 1 new type (`SimulateCaptureSubjectMotion`).
144
+
145
+ ### #550 Spatially varying illumination (mixed lighting)
146
+
147
+ - **New:** `simulateCapture` (and `simulateIdealReference`, `simulateDisplayView`, `prepareDisplayView`) option `illumination`: two to eight sources, each an illuminant at an illuminance, blended across the chart by a `linear-gradient`, a `radial` falloff or `regions` (docs/MIXED_ILLUMINATION.md). The irradiance at a point is Σ w_i·E_i(λ) with weights summing to 1 (exact for the declared blend; not a falloff model). It replaces `illuminant` and `illuminanceLux`. Root type exports: `MixedIllumination`, `MixedIlluminationBlend`, `MixedIlluminationPoint`, `MixedIlluminationPointSource`, `MixedIlluminationSource` (5 types, no runtime export).
148
+ - **Point sources (owner decision 2026-11-10, a step toward flash):** `illumination.points`, up to eight, each an illuminant, a position in metres in front of the plane and an intensity (`{ candela }` or `{ illuminanceLux, distanceM }`). Irradiance E = I·cos θ/d² (inverse square with Lambert's cosine law, from the CIE ILV definitions of luminous intensity and illuminance), added to the blend; exact for an ideal isotropic point, no occlusion. One source with points needs no blend; a point alone is allowed. Root type export `MixedIlluminationPointSource`.
149
+ - **Exposure and white balance:** scene-average metering reads the lit frame; the grey card sits at the frame center and reads the light there (`exposure.meteringCard`); `"scene-illuminant"` white balance uses the source strongest at the frame center (`output.whiteBalanceSource`); an explicit white balance wins. The assumption `scene.illumination` reports the blend.
150
+ - **Display view:** the CPU reference reads the radiance under the blend at every query point, as the capture does (no approximation, so no fast method). The GPU renderers refuse it with a reason (`unsupported-on-backend`, no device requested).
151
+ - **Capture:** a chart or material scene under a blend keeps the tabulated expected signal (#394), reading the radiance at each lattice point instead of per-material tables, verified per CFA phase against the chain (1e-9); caller reflectance functions take the chain.
152
+ - **Off by default:** requests without `illumination` keep their bytes. The module (`src/facade/mixed-illumination.ts`) was a lazy chunk on its branch; the integration made it a static import (size pass).
153
+ - **Size (rolldown 1.2.9, gzip level 9):** the app's capture-worker shape 355,994 → 358,671 B in total (+2,677 B: the lazily loaded `mixed-illumination` chunk 2,222 B with the point sources, the facade glue and the chunk split the rest), above its 356,000 B limit; per the owner, the integrator raises the limit by the exact remainder at integration. The capture-only entry chunk 263,817 → 264,706 B (+889 B); the display view's added entry size 18,187 → 16,197 B (the bundler moved shared modules between chunks).
154
+
155
+ ### #580 Lateral color as a fringe width in pixels for a lens and sensor
156
+
157
+ - **New: `calculateLateralColorFringe({ lensId, activeImagingArea, pixelPitchMicrometers, fieldPosition? })`** (model `generic-lens-lateral-color-fringe` 1.0.0, for the app's Guide page on color fringing): a catalogue lens's blue-minus-red image displacement at a field position (`{ x, y }` in fractions of the half-width and half-height; default the top-right corner), radial and as x and y parts, in µm and in pixels, plus the fringe width |Δr| and the lens's F-to-C displacement. It reads the lens's own lateral-color model Δr(λ) = L·R·H·D(λ) at the lens corrections' channel wavelengths (610 and 465 nm), an approximation. Full frame, corner: PV 24 1.8 7.24 µm (1.21 px at 6 µm), PV 24 1.4 Pro 2.11 µm (0.35 px).
158
+ - **Tests** (`test/generic-lens-lateral-color.test.ts`): the formula written out from the Fraunhofer lines, linearity in the field height, direction in every quadrant, sensor scaling, every catalogue lens, refusals, and the chart measurements of the catalogue tests: at the corner region's centre it predicts 0.28 px (pro, measured 0.31 px) and 0.96 px (consumer, measured 0.71 px; the rendered edge also carries the consumer lens's larger blur, the demosaic and the color conversion).
159
+ - **Integration (2.21.0, owner decision):** an optional `channelDispersion` (#589's `calculateChannelEffectiveDispersion`) takes the camera's own channels; the result reports `channelDispersion` (and `channelWavelengthsNm: null` when it is given). Re-measured against #589's RAW measure at the corner region's centre (6 µm, default sensor and light): 0.232 px for *PV 24 1.4 Pro* (RAW 0.22 px) and 0.797 px for *PV 24 1.8* (RAW 0.80 px); the single-wavelength default gives 0.278 and 0.957 px.
160
+ - **Root exports:** 4 new (1 runtime): `calculateLateralColorFringe` and the types `CalculateLateralColorFringeInput`, `LateralColorFringe`, `LateralColorDisplacement`. A pure function in its own module (`src/optics/generic-lens-lateral-color.ts`); the capture worker does not import it.
161
+ - **Docs:** docs/LENS_CATALOGUE.md ("Lateral color as a fringe width"), docs/USAGE.md, API reference regenerated.
162
+
163
+ ### #557 Representative light-source spectra designed by Photivra
164
+
165
+ - **New:** four representative light sources designed by Photivra (owner decision 2026-11-06: Photivra's own parametric spectra, no CIE data, no named products), each labeled "representative, not a named product": `tri-phosphor-fluorescent-4000k`, `white-led-low-rendering-4000k`, `white-led-high-rendering-4000k` and `warm-white-led-2700k`. Each is S(λ) = b + Σ aₖ·exp(−½((λ − μₖ)/σₖ)²) on 360–830 nm; formulas and design reasons in docs/LIGHT_SOURCES.md. `createRepresentativeLightSourceIlluminant({ sourceId })` returns `{ kind: "spectrum", spectrum, label }` for any `illuminant` option; `createRepresentativeLightSourceSpectrum({ sourceId })` the spectrum alone; `getRepresentativeLightSourceSet()` the frozen set.
166
+ - **Checked:** each spectrum's CCT with the engine's own locus search is within 1 K of its stated CCT (4000.8, 3999.6, 4000.0, 2699.9 K) with |Duv| ≤ 0.0001, and McCamy within 10 K. The same-CCT pair (low vs high color rendering, 4000 K) differs by ΔE00 5.05 on PV-S1, 4.67 on PV-P2 and 5.86 on PV-G1 (mean 3.68 over the 24 colored patches); against a Planckian reference at the same CCT the mean patch ΔE00 is 2.87, 4.00, 0.67 and 1.18. This is Photivra's own figure from its own patches, not CIE Ra or TM-30 (their data is not bundled).
167
+ - **Unchanged:** `illuminants.ts`, `scene-radiance.ts` and `simulateCapture` are untouched; the existing illuminants resolve byte-identically (hash locks of CIE A and Planckian 2700/6500 K, portable path). Use `sensor.spectralStepNanometers: 5` with these sources: at the default 20 nm the fluorescent's narrow bands leave a strong cast (documented and measured).
168
+ - **Root exports:** 11 new (7 runtime): `REPRESENTATIVE_LIGHT_SOURCE_SET_ID`, `REPRESENTATIVE_LIGHT_SOURCE_SET_VERSION`, `REPRESENTATIVE_LIGHT_SOURCE_IDS`, `REPRESENTATIVE_LIGHT_SOURCE_WAVELENGTH_RANGE_NANOMETERS`, `getRepresentativeLightSourceSet`, `createRepresentativeLightSourceSpectrum`, `createRepresentativeLightSourceIlluminant` and the types `RepresentativeLightSource`, `RepresentativeLightSourceBand`, `RepresentativeLightSourceId`, `RepresentativeLightSourceSet`.
169
+ - **Bundle:** the capture-worker shape is unchanged at 351,770 B gzip (limit 352,000 B); the sources reach only bundles that import them (+1,553 B gzip).
170
+ - **Docs and tests:** docs/LIGHT_SOURCES.md (new), docs/USAGE.md, docs/AGENT_GUIDE.md, llms.txt, THIRD_PARTY.md, API reference; `test/representative-light-sources.test.ts`.
171
+
172
+ ### #365 Print planner: fit an image inside paper of another aspect ratio (contain), with the borders reported
173
+
174
+ - **New: `fit: { kind: "contain", paperOrientation: "as-given" | "match-image" }`** for `calculatePrintPlan` and `calculatePrintSizeLimit`: `printedImage` is then the paper; the image keeps the native aspect ratio, is scaled to the largest size inside the paper (scale `min(P_w/W, P_h/H)`, the "contain" sizing of CSS Images Level 3 §5.5) and centered, and is planned exactly as a `confirmed-native-aspect` plan of that size. `match-image` turns the paper a quarter turn to the image's orientation. The plan adds `contain: { paperMm, paperRotated, bordersMm: { left, right, top, bottom } }` (null without a native raster) and the size limit adds `maximumPaperMm`. Exact geometry (binary64 rounding reported in `relativeAspectError`); layout only, no crop or resampling. 6000 × 4000 on 8 × 10 in gives a 10 × 6.667 in image with 0.667 in borders on the long edges; 3:2 paper gives zero borders.
175
+ - **Unchanged:** `confirmed-native-aspect` plans and size limits are byte-identical to 2.19.0 (no new keys; checked over 720 inputs across every sampling kind, provider constraint, unit, orientation and unavailable source).
176
+ - **Type change:** `PrintPlanInput.fit` is now the union `PrintFit`; code that reads `fit.maximumRelativeAspectError` must first narrow on `fit.kind === "confirmed-native-aspect"`.
177
+ - **Root exports:** 2 new types, `PrintFit` and `PrintContainLayout`. Pure module (`src/output/print-plan.ts`); not in the capture worker.
178
+ - **Docs and tests:** docs/PRINT_PLANNING.md ("Contain fit"), docs/USAGE.md, API reference regenerated; `test/print-plan-contain.test.ts` (the issue's 8 × 10 in and 3:2 cases, as-given paper, portrait and square images, equality with the direct plan, size limit, unknown raster, input errors).
179
+
180
+ ## 2.20.0 — published November 10, 2026
181
+
182
+ A minor release of ten issues and one speed-versus-accuracy scheme: adjustable sensor characteristics and the mid-gray noise figure (#555, #535), the aperture diameter (#577), the crop that matches a longer lens (#579), which limits detail, the lens or the sensor (#575), the print size limit with blur and noise (#574), the lens profile's vignetting area factor (#584), lens corrections as optional DNG opcodes (#587), each catalogue lens's own bokeh (#567), chart-boundary ties in the diffraction PSF quadrature (#565) and the CI Node drift check (#231). See the [2.20.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_20_0.md).
183
+
184
+ - **New:** `calculateApertureDiameter` (#577), `calculateCropToMatchFocalLength` (#579), `calculateDetailLimit` (#575), `calculatePrintSizeLimitWithBlurAndNoise` (#574, with a `capture-noise-figure` input for #535's figure), the `sensor` characteristics and `result.sensor` (#555, #535), `output.dng.lensCorrectionOpcodes` (#587), `diagnostics.fast.defaultedFrom`, `SimulateCaptureDngPart.fast`.
185
+ - **Changed (shipped images and files; see "Default changes"):** `fast` follows the output format; `prepareDisplayView`'s `gpu` follows `fast`; JPEG is saved at quantization step 1; catalogue lenses with `lens.defocus` render their own bokeh (#567); `createGenericLensCorrectionProfile` profiles are version 1.1.0 (#584); in-focus diffraction quadrature ties resolve as on the lattice (#565).
186
+ - **Root exports:** 30 new, 1,860 → 1,890 (runtime exports 631 → 637): 3 from #577 (1 runtime), 3 from #579 (1 runtime), 12 from #575 (2 runtime), 7 from #574 (1 runtime), 3 from #555/#535 (types), and the scheme's registry `SIMULATE_CAPTURE_FAST_METHODS` with its type `SimulateCaptureFastMethod`.
187
+ - **Why minor:** new root exports and options. Several defaults change (below); each old behavior stays one option away. Nothing is removed. Part F's `lens.catalogue.catsEye` and part G's `lens.diffraction.rollingShutterLatticeTaps` were never released; the integration folded them into the `fast` scheme.
188
+
189
+ ### Default changes (migration)
190
+
191
+ | What changed | Before (2.19.0) | Now (2.20.0) | Keep the old behavior |
192
+ | --- | --- | --- | --- |
193
+ | `fast` when omitted | exact (`false`) for every request | resolved by format: fast for `output.format` "srgb-8" and "srgb-16" and for the display view; exact for "dng", "jpeg", "png" and `simulateIdealReference` (`diagnostics.fast.defaultedFrom: "format"`). An sRGB capture with `lens.catalogue` now uses #568's fast method | `fast: false` |
194
+ | `prepareDisplayView` `gpu` when omitted | `"off"` | `"auto"` when the request resolves to fast (the view's default), `"off"` with `fast: false`; an explicit value wins | `gpu: "off"` (or `fast: false`) |
195
+ | `output.jpegQuantizationStep` | 2 | 1 (100 % quality; detailed scenes about 25–35 % larger files, flat ones about 2.4×) | `jpegQuantizationStep: 2` |
196
+ | Catalogue lens with `lens.defocus` | the plain disc | the lens's own bokeh: blades and its spherical aberration always; its cat's eye (fast wides and normals) on exact requests, so on DNG, JPEG and PNG by default (#567; the owner approved the sample images and blade values) | no option (a corrected model); `fast: true` leaves the cat's eye out |
197
+ | The cat's eye on exports | n/a (new) | on for "dng", "jpeg" and "png" (PNG counts as an export) | `fast: true` |
198
+ | Rolling-shutter rotation with in-focus `lens.diffraction` | each PSF tap at its own point | lattice points on exact requests (DNG, JPEG, PNG by default), each tap's own point on fast ones (#565) | `lens.diffraction: { kind: "circular-aperture", fast: true }` |
199
+ | `createGenericLensCorrectionProfile` | profile 1.0.0, gain without the distortion's area factor | profile and vignetting component 1.1.0 with `distortionAreaFactor` (#584) | build the component without `distortionAreaFactor` |
200
+ | `result.sensor.midGrey` | n/a (new) | computed only when the request resolves to exact | `fast: false` |
201
+ | Display view AHD correction, uniform-pixel sampling, catalogue PSF zones | engine-reference label | unchanged values, now labeled as the view's fast methods: `diagnostics.fast.applied` names them and `diagnostics.fast.assurance` is `"approximation-with-a-priori-bound"` by default for AHD and catalogue-lens views (owner accepted, 2026-11-09); `fast: false` selects their exact methods (8192 draws at the same 0.01 threshold; full sampling; one PSF zone per site), which are slower | `fast: false` for the exact methods and the `"engine-reference"` label |
202
+
203
+ ### Integration (this release)
204
+
205
+ - **One speed-versus-accuracy scheme** (owner decisions 2026-11-09): every speed-versus-accuracy choice resolves through `resolveFast` and `fastFormatDefault` (`src/facade/fast-resolution.ts`): an option record's `fast`, else the top-level `fast`, else the format default. `src/facade/fast-methods.ts` registers every choice (`SIMULATE_CAPTURE_FAST_METHODS`) and `test/fast-methods-registry.test.ts` fails when a record accepts `fast` without an entry, an entry lacks its resolver or report, or a format default leaves the rule (docs/API_STYLE.md, "Exact and fast modes"). `diagnostics.fast.requested` is the resolved value, with the new `defaultedFrom`. The display view gains three fast methods with exact alternatives: its catalogue PSF zones (`lens.catalogue`), its AHD low-signal correction (`output.demosaic.ahd`: 8192 instead of 2048 draws per state) and its quarter sampling of uniform pixels (top-level `fast`). `SimulateCaptureDngPart` takes an optional `fast` (whether a fast method rendered the part); `serializeSimulatedCaptureDngFromParts` and `simulateCaptureFromParts` refuse a fast part unless their own request resolves to fast. The app's calculator passes `fast: false`.
206
+ - **#535 in #574:** `calculatePrintSizeLimitWithBlurAndNoise` takes `noise: { kind: "capture-noise-figure", midGrey }`, a capture's own figure, through the same `resolvePixelSnr` (a clipped figure is refused).
207
+ - **Shared helpers (capture-worker size, results bit-identical, every hash lock passes):** one `approximatelyEqual` (core/shared-helpers.ts) replaces six private copies; `validateActiveCaptureRect` and `validateShutterMechanism` of `exposure-window.ts` replace `readout-timing.ts`'s identical copies; the photo-signal flag lists (`PHOTO_SIGNAL_TRUE_FIELDS`, `PHOTO_SIGNAL_DOWNSTREAM_FALSE_FIELDS`), the scene-to-sensor bridge scope (`SCENE_TO_SENSOR_BRIDGE_SCOPE`) and the stabilization scope flags (`STABILIZATION_UNMODELED_SCOPE`) are one module constant each, spread at the same position so every record keeps its key order. 415 B saved in all (none is a root export).
208
+ - **Docs:** the catalogue's optical vignetting fades over four stops of stopping down (log2(N/N0) = 2; the comment and docs said two; no image change). US spelling in #575's prose; #577's uncited "also see" reference removed.
209
+ - **CI (#231, owner decisions):** the primary leg (the coverage shards and the required `verify (lts/*)` check, whose name is kept) is pinned to Node 24, the tooling major; Node 26 is tested by the `node` leg; a `test` leg that resolves to the primary leg's release (`lts/-1` today) skips its test work while its required check still runs. The drift check accepts the pin and requires the skip.
210
+ - **Licences (#544):** the owner signed off all five pending reviews on 2026-11-09 (`THIRD_PARTY.md`).
211
+ - **Bundle:** the app's capture-worker shape is 355,994 B gzip (2.19.0: 351,775 B): the eight parts merged measured 355,892 B (#555/#535 +2,157 B, #587 +1,197 B, #567 +595 B, #565 +197 B), the scheme +517 B, the shared helpers above −415 B. **`test/capture-bundle.test.ts`'s limit is raised from 352,000 to 356,000 B** (the measured total rounded up to the next 100 B; owner-approved 2026-11-09 for 2.20.0 only, for the 2.20.0 features, after one refactor pass for savings; docs/RELEASE_2_20_0.md, "Sizes"). The display view adds 46,008 B minified and 18,118 B gzip (budgets 46,500 B and 18,550 B).
212
+
213
+ ### #231 CI Node matrix drift test and runner-minute baseline
214
+
215
+ - **CI only; no package, API or bundle change.** `.github/node-ci-roles.mjs` derives the CI Node roles (exact `engines.node` minimum, maintained LTS majors, Current major, primary `lts/*` leg, tooling major) from `.github/node-lts-policy.mjs` (new export `lifecycleAt`) and checks `ci.yml`, `publish.yml`, `NODE_SUPPORT.md` and `package.json` against them. It runs in the existing `change scope` job (under a second; no new job, no install). Drift fails; owner-decision findings print as notices.
216
+ - **Tests:** `.github/test-node-ci-roles.mjs` (alias resolution before and after Node 26 entered LTS and at Node 22's end of life, deduplication findings, and nine kinds of drift).
217
+ - **Runner-minute baseline:** recorded per leg in docs/DEVELOPERS.md from 25 pull-request runs (2026-10-31 to 2026-11-09): median 90.1 Linux job-minutes per run (101 billed), 121.2 over the last three.
218
+ - **Docs:** NODE_SUPPORT.md describes the check.
219
+
220
+ ### #577 Aperture (entrance pupil) diameter in mm
221
+
222
+ - **New: `calculateApertureDiameter({ focalLengthMm, fNumber })`** (model `aperture-diameter` 1.0.0, for the app's Guide page "F-stops explained" and its aperture animation): the entrance-pupil diameter `D = f/N` in millimetres, returned as `entrancePupilDiameterMm` with the inputs. 50 mm at f/2 gives 25 mm. Exact by the definition of the f-number for an object at infinity (Greivenkamp, OPTI-502 course notes, sec. 12, free). Stated, not modelled: the close-focus working f-number `N·(1 + m/P)` with pupil magnification `P`, and focal-length changes while focusing. `D` is the entrance pupil, not the diaphragm's physical opening. `calculateSensorEquivalence` already reported the same number as `entrancePupilDiameterMm` (tested equal).
223
+ - **Root exports:** 3 new (1 runtime): `calculateApertureDiameter` and the types `CalculateApertureDiameterInput`, `ApertureDiameter` (new module `src/optics/aperture-diameter.ts`). Pure function; no capture, RAW or bundle change (the app's capture-worker shape measures 351,775 B gzip, unchanged; the display view's added size is unchanged).
224
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Aperture (entrance-pupil) diameter"), docs/USAGE.md (a new last section with a runnable example, so no fragment index shifts), docs/AGENT_GUIDE.md, llms.txt, API reference task index; `test/aperture-diameter-and-crop-to-match.test.ts`; strict-input list in `test/root-export-input-errors.test.ts`.
225
+
226
+ ### #579 Crop to match a longer focal length: factor and remaining pixels
227
+
228
+ - **New: `calculateCropToMatchFocalLength({ focalLengthMm, targetFocalLengthMm, pixelWidth, pixelHeight })`** (model `crop-to-match-focal-length` 1.0.0, for the app's Guide page "Cropping vs a longer focal length"): the linear crop factor `targetFocalLengthMm / focalLengthMm` and the centred crop it leaves (`pixelWidth`, `pixelHeight`, `megapixels`, `sourceMegapixels`, `retainedAreaFraction`), the crop being `calculateCenteredCrop`'s (aspect ratio kept to whole pixels). 24 MP (6000 × 4000) at 50 mm matched to 100 mm: factor 2, 3000 × 2000, 6 MP. Exact for ideal rectilinear lenses focused at infinity, from the same camera position (image height `f·tan θ`, Greivenkamp sec. 12). Stated, not applied: at close focus the thin-lens matching factor is `F·(d − f)/(f·(d − F))` at object distance `d` (2.11 instead of 2 for 50 → 100 mm at 1 m); distortion and focus breathing. A target shorter than the focal length is refused. A new function rather than a change to `calculateCenteredCrop`, whose contract stays as it is.
229
+ - **Root exports:** 3 new (1 runtime): `calculateCropToMatchFocalLength` and the types `CalculateCropToMatchFocalLengthInput`, `CropToMatchFocalLength` (new module `src/output/crop-to-match-focal-length.ts`). Pure function; no capture, RAW or bundle change (capture-worker shape unchanged at 351,775 B gzip).
230
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Crop to match a longer focal length"), docs/USAGE.md, docs/AGENT_GUIDE.md, llms.txt, the API reference task index row for crops; `test/aperture-diameter-and-crop-to-match.test.ts`.
231
+
232
+ ### #575 Which limits detail: the lens or the sensor
233
+
234
+ - **New: `calculateDetailLimit({ sensor, lens, fNumber, focus?, fieldPosition?, wavelengthNm? })`** (model `detail-limit` 1.0.0, for the app's Learn section): says whether the lens (diffraction at this f-number, aberrations, defocus) or the sensor's pixel pitch limits detail. `lens` is `{ kind: "ideal", focalLengthMm }` or `{ kind: "catalogue", lensId }` (the generic catalogue's Seidel aberrations at `fieldPosition`, 0 center to 1 corner); `sensor` is `{ widthMm, heightMm, pixelWidth, colorFilterArray? }` (`"bayer"` by default, or `"none"`). Returns `limitedBy` (`"lens"` | `"sensor"`), `limitingFactor` (`"diffraction"` | `"aberrations"` | `"defocus"` | `"pixel-pitch"`), `opticsToNyquistRatio`, the optics (working f-number, cut-off 1/(λN_w), MTF50 tangential and sagittal, the diffraction-only MTF50, MTF at Nyquist, RMS wavefront error of the aberrations and of the defocus, the defocus blur circle), the sampling (pitch, Nyquist 1/(2p), the Bayer red and blue sites' 1/(4p), pixel aperture MTF), the system (optics × pixel aperture MTF50, also in line pairs per picture height) and `diffractionLimitedFNumber` (≈ f/8.8 on 6 µm pixels at 550 nm).
235
+ - **Model:** the optics MTF is the exact scalar incoherent OTF (pupil autocorrelation, MIT OCW 2.71 lectures 21–22; Goodman §7.3–7.4) of the stated wavefront at one wavelength, by Gauss–Legendre quadrature (doubling the nodes changes it by under 1e-9; an independent brute-force grid agrees within its 3e-3 edge error). Defocus W020 = dz/(8N_w²) (Wyant & Creath 1992 eq. 18). **Approximations:** the decision rule (the sensor limits when the optics MTF50 reaches the Nyquist frequency; the lens through diffraction when its RMS wavefront error, piston and tilt removed, is within λ/14, the Maréchal criterion, else aberrations or defocus, whichever is larger), the catalogue lenses' wavefronts, the ideal square pixel aperture (fill factor 1). Monochromatic (550 nm by default); demosaicing, the optical low-pass filter, lateral color, noise and motion are excluded. Wavefronts above 64 waves are refused (a geometric blur; `calculateDefocusCircle`), `DETAIL_LIMIT_LIMITS`.
236
+ - Examples: an ideal 50 mm at f/22 on 36 × 24 mm, 6000 pixels wide: `"diffraction"` (MTF50 ≈ 33.4 cycles/mm, Nyquist ≈ 83.3); *PV 600 6.3* wide open at the corner on 9504 pixels: `"aberrations"`; *PV 50 1.2 Pro* at f/5.6 on 3000 pixels: `"pixel-pitch"`; a subject at 2.5 m with focus at 3 m, 50 mm f/2: `"defocus"`.
237
+ - **Root exports:** 12 new (2 runtime), 1,860 → 1,872 (runtime 631 → 633): `calculateDetailLimit`, `DETAIL_LIMIT_LIMITS` and the types `CalculateDetailLimitInput`, `DetailLimit`, `DetailLimitBasis`, `DetailLimitCatalogueLens`, `DetailLimitIdealLens`, `DetailLimitOptics`, `DetailLimitSampling`, `DetailLimitSystem`, `DetailLimitedBy`, `DetailLimitingFactor`. New module `src/optics/detail-limit.ts`, a pure function; no capture, RAW, schema or `src/gpu` change. The export block sits at the end of `src/index.ts` so the bundler's module order, and so the app's capture-worker shape, is unchanged: 0 B gzip (the display view's budget too).
238
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Which limits detail: the lens or the sensor"), docs/USAGE.md (a runnable example, appended at the end so no fragment index shifts), docs/AGENT_GUIDE.md, llms.txt, API reference task index; `test/detail-limit.test.ts` (the three cases of the issue, defocus, the switch at the diffraction-limited f-number, quadrature convergence and a brute-force check, aberrations never above the diffraction limit, refusals).
239
+
240
+ ### #574 Print size limit that counts blur and noise, not just pixels
241
+
242
+ - **New: `calculatePrintSizeLimitWithBlurAndNoise({ raster, pixelPitchMm, viewingDistance, criterion, blur, noise })`** (model `print-size-limit-blur-noise` 1.0.0, an approximation; for the app's Learn question "Can I print this, and how big?"): the largest print, at a fixed viewing distance and the raster's aspect ratio, at which the capture still looks sharp and clean, the limiting factor (`pixels`, `blur` or `noise`) and each factor's own limit. With s = 2·d·tan(θ/2) the criterion angle on the print: pixels W ≤ N_w·s; blur W ≤ N_w·s/b (b the blur diameter in pixels; parts in root-sum-square, exact for Gaussian spots); noise W ≤ N_w·s·SNR_px/SNR_min (the eye averages the pixels inside s; none when SNR_px ≥ SNR_min). Noise comes from the engine's noise model (`calculateSignalToNoise`, shot plus read noise) or a caller's per-pixel SNR. The criterion (default-free: one arcminute is normal acuity, EyeWiki; the SNR threshold is the caller's) and its sources are in docs/PRINT_PLANNING.md. 6000 × 4000 pixels of 4 µm at 500 mm and 1 arcmin: 872.7 mm wide pixel-limited, 268.5 mm with 13 µm of blur, 427.9 mm at a pixel SNR of 9.8 (100 e⁻, 2 e⁻ read noise) against 20.
243
+ - **Root exports:** 7 new (1 runtime): `calculatePrintSizeLimitWithBlurAndNoise` and the types `PrintBlurInput`, `PrintNoiseInput`, `PrintSharpCleanCriterion`, `PrintSizeLimitWithBlurAndNoiseInput`, `PrintImageMm`, `PrintSizeLimitWithBlurAndNoise`. Pure function in its own module (`src/output/print-size-blur-noise.ts`); no capture, RAW or bundle change.
244
+ - **Docs and tests:** docs/PRINT_PLANNING.md ("Print size limit with blur and noise", with a runnable example), docs/USAGE.md, docs/AGENT_GUIDE.md, llms.txt, API reference regenerated; `test/print-size-blur-noise.test.ts` (pixel-, blur- and noise-limited captures, agreement with `calculatePrintSizeLimit`, the shot-noise square-root scaling, distance scaling, zero signal and input errors).
245
+
246
+ ### #584 Lens correction profile: include the distortion's area factor in the vignetting correction
247
+
248
+ - **Changed (versioned): `createGenericLensCorrectionProfile` profiles are version 1.1.0** and their `vignetting` component is version 1.1.0 with the new optional field `distortionAreaFactor` (the lens's own radial distortion profile). A capture through a distorting lens also scales irradiance by its area factor 1/det J (the environment's `inverse-distortion-jacobian`); the component now corrects T = E(ρ_s)/det J(ρ_i), so the profile flattens a flat-field catalogue capture. Without it the sensor corner's gain was off by exactly 1/det J: 23.8 % too bright on PV 16 2.8 (barrel), 6.6 % too dark on PV 300 5.6 (pincushion).
249
+ - **New field: `IlluminationLensCorrection.distortionAreaFactor?: RadialDistortionProfile`**, parsed strictly by `parseGenericLensCorrectionProfile` (geometry and lateral-colour components refuse it). With it, `calculatePeripheralIlluminationCorrection` reads `imagePointMm` as the capture's sensor point and `calculateLensCorrectedCapture` evaluates the gain at each output pixel's source point (per channel), where the capture applied it. Components without the field (every caller-built or 1.0.0 profile) give exactly the previous gain at the previous point.
250
+ - **Decision recorded:** `output.lensCorrections` keeps its own inlined kernel (`src/output/developed-lens-corrections.ts`, about five times faster than a call per sample); a test pins its gain to the profile's to 1e-9.
251
+ - **Tests:** `test/lens-correction-area-factor.test.ts`: flat-field captures through PV 16 2.8 and PV 300 5.6 corrected through the profile APIs come out flat within 0.06 % and 0.16 % (corners against the centre; without the area factor off by several per cent), with and without the geometric corrections; the closed-form 1/det J at the corner; strict parsing; equality with the #570 kernel. `test/generic-lens-catalogue.test.ts` updated for the new corner gain (2^stops · det J).
252
+ - **Docs:** docs/DIGITAL_OPTICS_FOUNDATION.md, "Generic camera correction profiles"; API reference regenerated. No root export added; no bundle change (the profile modules are not in the capture worker).
253
+
254
+ ### #587 DNG: embed the lens corrections as DNG opcodes (non-destructive), each switchable
255
+
256
+ - **New option: `output.dng.lensCorrectionOpcodes: { distortion?, lateralColor?, vignetting? }`** (with `lens.catalogue`, format "dng", whole frame): the catalogue lens's corrections written into the DNG's OpcodeList3 (tag 51022) as optional DNG 1.3 opcodes (flag bit 0 set): FixVignetteRadial (gain fitted to the #584 profile gain det J/E; near-minimax, at most 0.062 % over the whole catalogue; a fit worse than 0.1 % is refused), then WarpRectilinear (one plane, or red, green, blue for lateral colour; exactly `output.lensCorrections`' mapping with the zoom-to-fill folded into kr0). Each switches separately; all off or omitted writes byte-identical DNGs; the RAW codes are never changed. The assumption `output.dng.lensCorrectionOpcodes` records the opcodes, coefficients, magnification and fit error. `serializeSimulatedCaptureDngFromParts` writes the same bytes from parts.
257
+ - **New input: `serializeNativeRawDng({ ..., opcodeList3? })`**: caller OpcodeList3 bytes, framing checked; without it the bytes are unchanged.
258
+ - **Limits (documented):** some editors apply DNG opcodes automatically with no control; the guaranteed switch is leaving a correction out in Photivra. Spec: Adobe DNG Specification, "Opcode List Processing" (opcode lists from 1.3.0.0).
259
+ - **Tests:** `test/dng-lens-correction-opcodes.test.ts` (independent opcode-list parser and the spec's equations: mapping to 1e-9 px and gain within 0.1 % on PV 16 2.8 and PV 300 5.6; byte identity with none; RAW codes and every parsed tag unchanged; all catalogue lenses fit; parts; refusals).
260
+ - **Bundle:** capture-worker shape 352,972 B gzip (+1,197 B: entry +412 B, the lazily loaded catalogue chunk +785 B), over the 352,000 B limit, which is not raised; for the integrator and the owner. Display view delta 45,821 B minified / 18,158 B gzip (budgets 46,500 / 18,550).
261
+
262
+ ### #565 Capture: in-focus diffraction PSF quadrature resolves chart-boundary ties differently from the lattice path
263
+
264
+ - **Fixed:** `simulateCapture` with `lens.diffraction` in focus and `lens.vignetting`, `lens.distortion`, `lens.projection` or camera rotation (the environment PSF quadrature) formed each tap's source point from its own site, while the request without those options (the object-space convolution), the display view and the out-of-focus capture read the scene on the site lattice. A source point exactly on a chart boundary could therefore resolve to the other material: on a 20 × 14 Siemens star at f/11, an identity vignetting profile (every coefficient 0) changed the site next to the centre from 3,560 to 12,663 e⁻. The quadrature now reads each tap's source at its lattice point's own plane point (#548's lattice formula: the image-space provider with a one-tap kernel), so a tie resolves the same way on every path; the identity profile now gives the same expected electrons (within 1e-9 relative, measured ≤ 1e-15) and the same RAW codes as the request without it. With camera rotation under `motion.shutter: "rolling"` (every row has its own instants, so the lattice is read once per instant) it takes the new flag below; the catalogue lens PSF quadrature is unchanged (#588).
265
+ - **Which results change:** in-focus `lens.diffraction` captures with one of those lens options, only where a tap's source point lies exactly on a scene boundary (symmetric charts in symmetric frames; for example a Siemens star with radial distortion, vignetting and the four-spot filter changes RAW codes along its diagonals). Elsewhere a value changes by at most rounding (≤ 1e-15 relative; one multiplication and division by the field factor). Site inputs (`request.siteInput`), `request.diffraction.execution` (`"environment-psf-quadrature"`) and the provider evaluation count are unchanged; the `lens.diffraction` assumption adds that each tap reads the scene at its lattice point. No hash lock or golden image changed.
266
+ - **Rolling-shutter rotation: lens.diffraction's fast method** (owner decision 2026-11-09, "build both, default to the faster"; the part's `rollingShutterLatticeTaps` flag was folded into the `fast` scheme at integration): with camera rotation under `motion.shutter: "rolling"`, the exact method reads each tap at its lattice point as above, and the fast method keeps each tap's own point. The format default decides (fast for sRGB samples; exact for DNG, JPEG and PNG); `lens.diffraction: { kind, fast }` or the top-level `fast` set it. The fast method reports `diagnostics.fast.applied["lens.diffraction"]: true` and the assumption `fast.lens.diffraction`. No effect without rolling-shutter rotation and in-focus diffraction. Cost about 1.2× (dead leaves 48 × 32 at f/8: 4.0–4.1 s → 4.8–5.0 s; Siemens star 32 × 24 at f/16: 6.8 s → 8.1–8.2 s; interleaved twice). The two readings differ only at boundary ties; with the default the RAW depends on `output.format`, so region renders joined into one frame should set the flag explicitly (docs/QUICK_START.md, "Optics"). No root export added (a new optional field of `SimulateCaptureLensDiffraction`).
267
+ - **Cost:** no slowdown measured (scratch benchmark, main against this change interleaved twice, load average 14–25): dead leaves 40 × 28 with vignetting 2.7–4.2 s → 2.6–2.9 s; a 24 × 16 star with distortion, vignetting and four-spot 17–24 s → 16–19 s; rotation 2.2–2.5 s → 2.1–2.5 s.
268
+ - **Bundle:** capture worker +197 B gzip in all (351,775 → 351,972 B; limit 352,000 B, not raised): +113 B for the lattice reading, +84 B for the flag after reusing the existing `queryOffsetsSites` in the object-space provider branches (the same values; it saved about 50 B). Display view's addition +4 B gzip (18,153 → 18,157 B).
269
+ - **Tests and docs:** `test/simulate-capture-boundary-ties.test.ts` (new; a 20 × 14 star and an odd-width 21 × 14 star: same RAW and expected electrons with and without the identity profile, fails on 2.19.0; the flag's defaults, a DNG, a JPEG and explicit values on a pitch-rotated odd-width star, whose centre column lies on the vertical spokes, and dead leaves unchanged by the flag); docs/DISPLAY_VIEW.md ("Ties on chart boundaries"), docs/QUICK_START.md ("Optics", the `lens.diffraction` row) and the #563 test comment (the generated API reference does not change).
270
+
271
+ ### #567 Lens catalogue: feed each lens's spherical aberration and character into bokeh
272
+
273
+ - **Changed (shipped images):** `simulateCapture` (and the display view, which reads the same PSF) with `lens.catalogue` and `lens.defocus` out of focus now convolves the lens's own bokeh kernel into its PSF instead of the plain disc of 2.17.0–2.19.0: #553's shaped kernel (`createDefocusBokehSiteKernel`) with the lens's blades, its own resolved W040 as `sphericalAberration`, and, for the fast (f/2 or faster) rectilinear wides and normals (at most 50 mm equivalent), a cat's eye. The defocus is referred to the lens's balanced focus (W020 = −W040), so the kernel is #553's for the signed blur σc − b (b = 8·N·W040/pitch). With a cat's eye each of the PSF's 5 × 5 field nodes gets its own field point's kernel (rectilinear chief-ray tangent, distortion neglected). Kernel rows are flipped to the profile's +Y-up convention. A geometric approximation, labeled as such. Requests without `lens.catalogue` + `lens.defocus`, and catalogue captures in focus or without `lens.defocus`, are byte-identical.
274
+ - **New (public field, no new root export):** `resolveGenericLens(...).character.bokeh` (`GenericLensCharacter.bokeh`, a `DefocusBokehOptions`): blades (professional 9 at curvature 0.7, consumer 7 at 0.3, circular at the widest aperture), `sphericalAberration` (the resolved W040 in µm at this f-number) and `catsEye` (one clipping circle of the widest pupil's radius, shifted so that wide open the open pupil at the frame corner is 2^−s_o, s_o the lens's optical vignetting in stops).
275
+ - **The cat's eye follows lens.catalogue's `fast` (owner speed rule; the part's `catsEye` flag was folded into the `fast` scheme at integration).** The cat's eye makes the PSF vary over the field, so the fast catalogue method would need a much finer field grid: measured 2.2–3.4× slower on the fast path (for example a whole 750 × 500 frame with *PV 35 1.8* at f/1.8: 0.69 s → 2.33 s), no measurable cost on the exact path. The exact method has it; the fast one leaves it out (assumption `fast.lens.catalogue.catsEye`). The format default decides: on for DNG, JPEG and PNG, off for sRGB samples and the display view. Blades and spherical aberration cost nothing measurable and always apply (fast path 0.31–0.42 s against 2.19.0's 0.35–0.39 s on the same regions).
276
+ - **Both catalogue methods:** the bokeh is in the PSF profile both read. The exact method interpolates it per site; the fast method resolves it on its field grid (refined automatically where the cat's eye moves the kernels' centroids) with its usual run-time check against the exact chain. Measured: fast and exact agree within 2 % of the peak on the cat's-eye point.
277
+ - **Kept:** `lens.defocus.blades`, `sphericalAberration` and `catsEye` stay refused with `lens.catalogue` (the lens sets all three; the message now says so). The combined 16-site limit is unchanged; #564's run path is not used with a catalogue lens.
278
+ - **Assumptions:** `lens.defocus` (catalogue) reports `bokeh: { catsEye, method, sphericalAberrationSites, axisOpenAreaRatio }` and its actual kernel radius; `lens.catalogue.character` reports `bokeh` (no longer under `notRendered`); the catalogue PSF profile's `responseIncludes.pupilClippingShape` is true with a cat's eye. Every catalogue capture's assumptions therefore change (its image, RAW and exposure do not without `lens.defocus`): `test/program-line-lens-range.test.ts` re-pins `catalogue50`'s assumptions hash.
279
+ - **Tests:** `test/simulate-capture-lens-catalogue-bokeh.test.ts` (values and cat's-eye rule for all 30 lenses; the flag's defaults and its refusal of a non-boolean; exact against fast; on the point grid the *PV 50 1.8* f/4 heptagon's seven-fold outline in the kernel's orientation, its f/1.8 cat's eye's centroid shift and tilt at the top-left point against #553's kernel, and the *PV 200 5.6* soft-edged behind / bright-rimmed in front). Each check fails for the 2.19.0 disc.
280
+ - **Docs:** docs/LENS_CATALOGUE.md ("Bokeh (#567)", with each value's reason), docs/QUICK_START.md, docs/USAGE.md, docs/DISPLAY_VIEW.md; API reference regenerated.
281
+ - **Bundle:** the capture-worker shape +595 B gzip (351,775 → 352,370 B; entry chunk +97 B, catalogue chunk +500 B), over the 352,000 B limit by 370 B on this branch alone; the limit is not raised (`test/capture-bundle.test.ts` fails on this branch until the integrator settles the total). The capture-only entry +95 B; the display view's added size +10 B (18,163 B, budget 18,550 B).
282
+
283
+ ### #555 Adjustable sensor characteristics and #535 noise figure
284
+
285
+ - **New (#555): sensor characteristics under `simulateCapture({ sensor })`**, each off by default (`SimulateCaptureSensorCharacteristics`): `peakQuantumEfficiency` (green EQE at 555 nm, 0.05–0.9; every channel's curve scaled, shape kept), `fullWell` (`{ electrons, link?: "pixel-area" | "none", referencePixelPitchMicrometers? }`; linked to pixel area by default), `readNoise` (`{ lowGainElectrons?, highGainElectrons?, highGainFromIso? }`, the dual-gain switch ISO), `readoutSeconds` (the rolling-shutter skew), `adcBitDepth` (12, 14 or 16; 16-bit RAW and DNG are new) and `fixedPatternNoise` (the body's synthetic pattern; refused by `simulateDisplayView`). Every value set is listed in the new assumption `sensor.characteristics`, with `chargeScale` and `sensitivityOffsetStops` (how far the output per unit exposure moves from the body's ISO rating) and a note when the full well is unusual for the pitch. A request that sets none is byte-identical to 2.19.0.
286
+ - **New (#555, #535): `result.sensor`** (`SimulateCaptureSensorReport`, also on `DisplayViewResult`): the characteristics used, the engineering dynamic range log₂(S_sat / √(σ_read² + σ_q²)) at the capture's ISO and per ISO setting (EMVA 1288 Release 4.0 temporal dark noise, limited by the charge capacity and the ADC range), and (on exact requests, `fast: false`, the exports' default; integration decision) `midGrey`, the expected SNR of an 18 % Lambertian gray patch on the green channel at the capture's exposure, split into photon shot, read, dark-current, quantization and (opt-in) fixed-pattern noise, in electrons, as a ratio and in dB. Model figures, not measurements.
287
+ - **Root exports:** 3 new types (`SimulateCaptureSensorCharacteristics`, `SimulateCaptureSensorReport`, `SimulateCaptureNoiseComponents`); no runtime export. The result gains the `sensor` field (additive).
288
+ - **Docs and tests:** docs/QUICK_START.md ("Sensor characteristics (#555)"); `test/simulate-capture-sensor-characteristics.test.ts` (QE scales the charge, full-well link and unlink, read noise and switch ISO in dark photon-transfer variance, readout skew, 16-bit DNG round trip, fixed pattern, dynamic range against the formula, mid-grey figure against the measured variance of a uniform patch, its change with exposure and ISO, the display view); API reference regenerated.
289
+ - **Bundle:** the capture-worker shape grows by 2,157 B gzip (351,775 → 353,932 B, over the 352,000 B limit), all in the new options' code and the report that `simulateCapture` computes on every capture; see the part report.
290
+
291
+ ## 2.19.0 — published November 9, 2026
4
292
 
5
293
  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
294
 
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.19.0"
12
+ version: "2.21.0"
13
13
  keywords:
14
14
  - photography
15
15
  - camera optics
package/NODE_SUPPORT.md CHANGED
@@ -35,17 +35,17 @@ An upstream line being eligible does not mean this repository has tested it. The
35
35
 
36
36
  This repository tests Node 22, 24 and 26. That includes the current patched release of each line and a regression job on the Node 22.13.0 minimum.
37
37
 
38
- CI picks the patched lines with setup-node aliases that move by themselves:
38
+ CI picks the patched lines with setup-node versions; the test legs use aliases that move by themselves:
39
39
 
40
40
  | Alias | Line | Role |
41
41
  | --- | --- | --- |
42
- | `lts/*` | newest LTS | Primary leg. Also runs the static, coverage, documentation and package-surface gates. |
43
- | `lts/-1` | the LTS line before it | Test leg. |
44
- | `node` | newest stable release (Current, or the newest LTS while no Current line has a stable build) | Test leg. |
42
+ | `24` | Node 24, the tooling major (pinned) | Primary leg (owner decision for 2.20.0, #231). Runs the coverage shards and the static, coverage, documentation and package-surface gates; its required check keeps the name `verify (lts/*)` (owner decision for 2.20.0: the name is a required status check of the repository's `main` ruleset, so renaming it would mean changing the ruleset). |
43
+ | `lts/-1` | the LTS line before the newest | Test leg. While it resolves to the primary leg's release (Node 24 since Node 26 entered LTS), it skips its test work; its required `verify (lts/-1)` check still runs. |
44
+ | `node` | newest stable release (Current, or the newest LTS while no Current line has a stable build) | Test leg (Node 26 since it entered LTS). |
45
45
 
46
46
  CI resolves `node` itself to the newest stable version in `https://nodejs.org/dist/index.json` (`.github/node-lts-lookahead.mjs --newest-stable`), because setup-node's alias takes the index's first entry, which can be a prerelease: `v27.0.0-alpha.0.0.0` was listed first on 2026-10-29 and every `node` job failed the guard with "Use a stable, exact Node/typings version" ([#511](https://github.com/photivra/photivra/pull/511)).
47
47
 
48
- Every leg runs the test suite and the packed-consumer acceptance check.
48
+ Every leg runs the test suite and the packed-consumer acceptance check, except a leg that resolves to exactly the primary leg's release (`node .github/node-lts-lookahead.mjs --same-as-primary`): the primary leg already runs the whole suite (under coverage) and the packed-consumer acceptance on it, so that leg skips them and its required check passes.
49
49
 
50
50
  - The minimum-version leg matches `engines.node`. It rises when that line reaches end of life.
51
51
  - The policy guard runs before installation. If an alias moves to an unreviewed or end-of-life major, CI fails instead of adopting it. An alias cannot silently widen the support set.
@@ -70,6 +70,8 @@ A broad `engines.node` range is a compatibility hint. It is not evidence of supp
70
70
 
71
71
  `.github/test-node-lts-policy.mjs` tests the acceptance and rejection boundaries, the grace period and the look-ahead against recorded release data. Both scripts run before dependency installation in every Node CI job. That includes each verification matrix leg and the `native-performance.yml` benchmark job (path-filtered and nightly).
72
72
 
73
+ `.github/node-ci-roles.mjs` derives the CI roles from the same snapshot: the exact `engines.node` minimum, the maintained LTS majors, the Current major, the primary leg (Node 24, pinned to the tooling major) and the tooling major. The `change scope` job checks the files against them on every pull request and merge-queue entry, so the matrix cannot drift from the policy unnoticed. It fails when the `test` and `verify` lines differ, when an exact-version leg is not the `engines.node` minimum, when the primary jobs or `publish.yml` leave their roles, when the alias table above differs from the matrix, when a supported line has no leg, or when the `test` job lacks the skip of a leg on the primary leg's release. It reports two legs on one runtime, a primary leg off the tooling major and a line tested only at its minimum as notices for review. `.github/test-node-ci-roles.mjs` tests it on the repository's files across the lifecycle transitions.
74
+
73
75
  The lifecycle snapshot is local and reviewed. Ordinary CI does not fetch it from the network. It uses UTC dates and fails closed on the recorded end-of-life date.
74
76
 
75
77
  Reverify the official schedule:
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.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:
35
+ Install the published ESM package below. The [2.21.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_21_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.19.0
38
+ npm install @photivra/engine@2.21.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.19.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.21.0` tag.
79
79
 
80
80
  ## Documentation
81
81
 
@@ -363,13 +363,14 @@ The application can then use those results, while the photography calculations t
363
363
 
364
364
  ## Status
365
365
 
366
- - Repository package version: `2.19.0` (release candidate prepared November 9, 2026; publication is separate)
367
- - Engine API contract: `2.19.0`
366
+ - Repository package version: `2.21.0` (release candidate prepared November 10, 2026; publication is separate)
367
+ - Engine API contract: `2.21.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
+ - Sensor profiles from published specifications (#554, unnamed): [SENSOR_PROFILES.md](https://github.com/Photivra/photivra/blob/main/docs/SENSOR_PROFILES.md).
369
370
  - Composed POC simulation API contract: `1.0.0`
370
371
  - Stability: 1.0 root-package compatibility policy; bounded scientific models and independent POC contract
371
372
 
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
+ From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.21.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
374
 
374
375
  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
376
 
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.18.0**, published on November 9, 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.20.0**, published on November 10, 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