@photivra/engine 2.14.1 → 2.16.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 (80) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +14 -0
  6. package/dist/capture/native-environment-lean.js +1 -1
  7. package/dist/capture/native-environment-lean.js.map +1 -1
  8. package/dist/capture/native-raw-development.d.ts +15 -0
  9. package/dist/capture/native-raw-development.js +12 -6
  10. package/dist/capture/native-raw-development.js.map +1 -1
  11. package/dist/capture/raw-development-kernel.d.ts +10 -3
  12. package/dist/capture/raw-development-kernel.js +25 -6
  13. package/dist/capture/raw-development-kernel.js.map +1 -1
  14. package/dist/core/internal-radiance-evaluator.d.ts +5 -2
  15. package/dist/core/internal-radiance-evaluator.js.map +1 -1
  16. package/dist/core/version.d.ts +1 -1
  17. package/dist/core/version.js +1 -1
  18. package/dist/core/version.js.map +1 -1
  19. package/dist/facade/defocus-image-space-provider.d.ts +109 -0
  20. package/dist/facade/defocus-image-space-provider.js +359 -0
  21. package/dist/facade/defocus-image-space-provider.js.map +1 -0
  22. package/dist/facade/diffraction-provider.d.ts +16 -0
  23. package/dist/facade/diffraction-provider.js +3 -3
  24. package/dist/facade/diffraction-provider.js.map +1 -1
  25. package/dist/facade/display-view.d.ts +21 -0
  26. package/dist/facade/display-view.js +66 -8
  27. package/dist/facade/display-view.js.map +1 -1
  28. package/dist/facade/simulate-capture.d.ts +112 -3
  29. package/dist/facade/simulate-capture.js +235 -48
  30. package/dist/facade/simulate-capture.js.map +1 -1
  31. package/dist/facade/simulate-display-view.js +7 -3
  32. package/dist/facade/simulate-display-view.js.map +1 -1
  33. package/dist/gpu/display-view-quality-gpu.d.ts +2 -0
  34. package/dist/gpu/display-view-quality-gpu.js +57 -13
  35. package/dist/gpu/display-view-quality-gpu.js.map +1 -1
  36. package/dist/gpu/display-view-speed-gpu.d.ts +2 -0
  37. package/dist/gpu/display-view-speed-gpu.js +45 -16
  38. package/dist/gpu/display-view-speed-gpu.js.map +1 -1
  39. package/dist/gpu/image-kernel-accelerator.js +3 -0
  40. package/dist/gpu/image-kernel-accelerator.js.map +1 -1
  41. package/dist/gpu/wgsl-display-view-quality-kernel.d.ts +4 -0
  42. package/dist/gpu/wgsl-display-view-quality-kernel.js +53 -2
  43. package/dist/gpu/wgsl-display-view-quality-kernel.js.map +1 -1
  44. package/dist/gpu/wgsl-display-view-speed-kernel.d.ts +15 -0
  45. package/dist/gpu/wgsl-display-view-speed-kernel.js +42 -1
  46. package/dist/gpu/wgsl-display-view-speed-kernel.js.map +1 -1
  47. package/dist/index.d.ts +3 -2
  48. package/dist/index.js +2 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/optics/defocus-disc-kernel.d.ts +91 -1
  51. package/dist/optics/defocus-disc-kernel.js +59 -5
  52. package/dist/optics/defocus-disc-kernel.js.map +1 -1
  53. package/dist/optics/diffraction-psf-profile.d.ts +9 -4
  54. package/dist/optics/diffraction-psf-profile.js +14 -6
  55. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  56. package/dist/optics/lens-psf-kernels.d.ts +13 -2
  57. package/dist/optics/lens-psf-kernels.js +23 -6
  58. package/dist/optics/lens-psf-kernels.js.map +1 -1
  59. package/dist/optics/sensor-environment-query.d.ts +31 -0
  60. package/dist/optics/sensor-environment-query.js +45 -13
  61. package/dist/optics/sensor-environment-query.js.map +1 -1
  62. package/dist/optics/site-diffraction-kernel.d.ts +1 -1
  63. package/dist/optics/site-diffraction-kernel.js +37 -17
  64. package/dist/optics/site-diffraction-kernel.js.map +1 -1
  65. package/dist/processing/development-detail.d.ts +132 -0
  66. package/dist/processing/development-detail.js +325 -0
  67. package/dist/processing/development-detail.js.map +1 -0
  68. package/docs/AGENT_GUIDE.md +1 -0
  69. package/docs/API_REFERENCE.md +17 -1
  70. package/docs/QUICK_START.md +210 -16
  71. package/docs/USAGE.md +31 -3
  72. package/docs/api/capture-native-raw-development.md +16 -0
  73. package/docs/api/core-version.md +1 -1
  74. package/docs/api/exports.json +47 -2
  75. package/docs/api/facade-simulate-capture.md +129 -4
  76. package/docs/api/optics-defocus-disc-kernel.md +87 -0
  77. package/docs/api/optics-diffraction-psf-profile.md +6 -3
  78. package/docs/api/optics-lens-psf-kernels.md +3 -2
  79. package/llms.txt +1 -0
  80. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,73 @@
1
1
  # Changelog
2
2
 
3
- ## 2.14.1 — unreleased (prepared November 3, 2026)
3
+ ## 2.16.0 — unreleased (prepared November 7, 2026)
4
+
5
+ A minor release of five issues: the display view's highlight count (#533), a development exposure push (#556), sharpening and diffraction correction (#552), `lens.defocus` with projection, distortion, vignetting and camera rotation (#548), and docs (#400). Captures, RAW codes, files and assumptions are the same as 2.15.0 for every request that 2.15.0 renders and that uses none of the new options; the display view's result gains one diagnostic.
6
+ - **New:** `DisplayViewResult.diagnostics.highlight` (#533); `output.exposureCompensationStops` (#556); `output.sharpening` and `output.diffractionCorrection` (#552); out-of-focus `lens.defocus` with `lens.projection`, `lens.distortion`, `lens.vignetting` or camera rotation under a global shutter, in image space (#548).
7
+ - **Docs:** 2.15.0 recorded as published; display-view cost rows re-measured (#400).
8
+ - **Root exports:** 5 new, 1,805 → 1,810 (runtime exports 613 → 614): the type `DisplayViewHighlightDiagnostics` (#533); `NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS` (#556); the types `SimulateCaptureSharpening`, `SimulateCaptureDiffractionCorrection` and `SimulateCaptureDetailDiagnostics` (#552). #548 adds none.
9
+ - **Why minor:** new root exports and new opt-in options; requests that 2.15.0 refused (#548) now render. Nothing is removed or changed incompatibly, and no default changes.
10
+ - **GPU:** #533 changes `src/gpu` (the display view's Speed and Quality kernels); the hardware gates passed on an Apple M5 Pro (docs/WEBGPU_DEVICES.md, docs/RELEASE_2_16_0.md).
11
+
12
+ - **New: the display view reports its highlight count (#533, for Photivra/apps#158, #223 and #240).** `simulateDisplayView` and `prepareDisplayView(...).render` return `diagnostics.highlight: { saturatedPixelCount, statistic: "expected-value" }`: the real capture's saturated output pixels (at least one clipped contributing RAW site) in the rendered region. `simulateCapture` counts these on its one noise sample as `diagnostics.highlight.saturatedPixelCount`. Until now an app could only show the count of a small quick capture.
13
+ - **Definition:** the expected value over the noise, Σ_j w_j·(1 − Π_k (1 − p_k)). j runs over the region's real pixels, with w_j the part of each inside the region. p_k is the probability that a site pixel j's demosaic reads is at or above the white level, under the view's own site model (normal, with exact expected electrons and shot, dark, read and ADC noise). It is estimated like the expected value (exact area mean, or stratified), so it is a real number. It equals the capture's count exactly where every site is more than 8.5 σ from white. With AHD it counts the 25–29 sites the view's candidates read, not the capture's full 7 × 7 square: an approximation that can be lower at clipped corners and specks (docs/DISPLAY_VIEW.md, "Highlight count").
14
+ - **Additive over parts:** a display pixel's share is the same in every region, so the counts of regions that tile a view sum to the whole view's.
15
+ - **No change to the view:** samples, expected values, noise, every other diagnostic and the assumptions are unchanged. The hash locks of `test/display-view.test.ts`, `display-view-pixel-memo.test.ts` and `display-view-speed-material.test.ts` leave the new field out and keep their recorded hashes.
16
+ - **GPU (`src/gpu`):** the Speed and Quality kernels mark each site certainly unclipped, certainly clipped (beyond twice the reference's 8.5 σ after their error bounds) or undecided. They return a bit per real pixel that is certainly saturated, and flag a display pixel with an undecided real pixel. The host forms the count with the reference's arithmetic and takes an undecided pixel's count from a one-pixel reference render; that pixel's samples stay the GPU's. The count is therefore the reference's bit for bit and `diagnosticsExact` stays true. The run-time self-checks now also compare the count. The flag output grows from one to three words per display pixel (Speed band: 48 → 56 bytes per display pixel).
17
+ - **Cost:** on the app request (Apple M5 Pro, Node 24.19.0, load average about 6), part medians were 38–41 ms against 2.15.0's 39 ms (Speed) and 74–79 ms against 74–78 ms (Quality). The extrapolated whole view was 0–4 % slower.
18
+ - **Tests:** `test/display-view-highlight.test.ts`: the capture's count reproduced exactly (bilinear and AHD, three exact views); the mean of 8 noise seeds near white; additivity per display pixel and over regions; a prepared view; the GPU float32 emulation (Speed and Quality, decided and undecided pixels) against the reference bit for bit.
19
+ - **API:** one new root export, the type `DisplayViewHighlightDiagnostics`; `DisplayViewResult.diagnostics` gains `highlight`. Nothing is removed or changed.
20
+ - **Docs (#400):**
21
+ - 2.15.0 recorded as published (`README.md`, `SECURITY.md`, `docs/DEVELOPERS.md`, its record, this changelog).
22
+ - App-request display-view rows re-measured on 2.15.0 (docs/DISPLAY_VIEW.md, "A device-pixel view rendered in parts"; docs/QUICK_START.md, "Cost").
23
+ - A stale "no GPU display-view renderer exists yet" removed from docs/USAGE.md.
24
+ - **New: development exposure push, `output.exposureCompensationStops` (#556).** Underexpose, then brighten the same RAW in development, like a raw editor's exposure slider, so dynamic range, read noise and bit depth become visible in recovered shadows (for the app's "Brighten in development" control).
25
+ - **Model:** a linear gain 2^stops, −2 to +5 stops (whole or fractional), on each site's black-subtracted, white-normalized value (code − black)/(white − black), before demosaic, white balance, the highlight mode, colour, the tone curve and encoding. No photons are added: raised shadows show the RAW's own shot, dark and read noise and its ADC quantization, scaled by the gain. A site pushed to or past white clips at white and counts in `diagnostics.highlight.saturatedPixelCount`; `highlightMode: "reconstruct"` rolls it off. The SDR profile's `renderingExposureEv` is a different, post-clip scale and stays 0.
26
+ - **Where:** the developed samples (`image.samples`, also with format `"dng"`), JPEG, PNG (every encoding), bilinear and AHD, `simulateIdealReference` and `simulateDisplayView` (its per-site readout moments are scaled by G and G²) honour it. The RAW codes, `result.raw` and the DNG stay the unmodified RAW; `output.whiteBalance: "auto"` is measured on the unpushed RAW.
27
+ - **Reported** in the assumption `output.exposureCompensationStops` (`{ stops, gain, appliedTo, dng }`) when the option is given. A non-zero push is carried in `request.development` and replayed by `createNativeRawDevelopmentTask`, which gains the same optional `exposureCompensationStops`. The WebGPU accelerator's `developNativeRaw` rejects a non-zero push with `unsupported-on-backend`.
28
+ - **Tests** (`test/simulate-capture-exposure-push.test.ts`): a +3-stop push of a 3-stop underexposure matches the normal exposure's mean within noise (0.4 % here, with about √8 times its noise) and keeps the underexposed RAW codes; after a +5-stop push of a 5-stop underexposure, a sensor with 20 e⁻ read noise is noisier than one with 1 e⁻; a pushed noise-free gradient shows 12-bit quantization plateaus and none at 14 or 16 bits; pushed highlights clip, and reconstruct rolls them off; JPEG, PNG (sRGB and Display P3), AHD, the DNG format's samples, the ideal reference and the display view are brighter by the gain, while the DNG bytes are unchanged; validation in the facade and the development task.
29
+ - **Compatibility:** one new root export, `NATIVE_RAW_DEVELOPMENT_EXPOSURE_RANGE_STOPS` (`{ minimum: -2, maximum: 5 }`); `NativeRawDevelopmentInput` and the facade's `output` gain an optional field. Without the option (or with 0), captures, RAW codes, files, requests and assumptions are byte-identical. Docs: `docs/QUICK_START.md` ("Development exposure push"), `docs/USAGE.md`, `docs/DISPLAY_VIEW.md`, `docs/WEBGPU.md`; API reference regenerated.
30
+ - **New: sharpening and diffraction correction, with honest trade-offs (#552).** Two opt-in development stages of `simulateCapture`, off by default, for the app's per-correction toggles (on for the JPEG, off in the DNG).
31
+ - **`output.diffractionCorrection`** `{ wavelengthNm?, regularization?, maximumGain?, kernelRadiusPixels? }`, which needs `lens.diffraction`: a bounded, regularized deconvolution of the developed linear colour with the diffraction OTF of the ideal circular pupil at the capture's working f-number, H(s) = (2/π)(acos s − s√(1 − s²)), s = f/f_c, f_c = 1/(λ·N_w) (MIT OCW 2.71). The gain min(maximumGain, H(1 + α)/(H² + α)) is Wiener deconvolution with a constant noise-to-signal ratio α, built as a tapered (2R + 1)² filter with unit DC gain; its response is measured and reported. Defaults: 550 nm, α = 0.01, gain bound 4, R = the Airy third dark ring (1–16 pixels). Frequencies beyond the cut-off never reached the sensor and stay lost: the design gain there is 0, and the assumption says `recoversBeyondCutoff: false`.
32
+ - **`output.sharpening`** `{ radiusPixels?, amount?, threshold? }`: an unsharp mask y = x + amount·(x − G_σ∗x) where |x − G_σ∗x| ≥ threshold (as in GIMP and ImageMagick), per channel on the sRGB-encoded values after the tone curve and gamut handling, before quantization. Defaults: σ = 1 pixel, amount 0.5, threshold 0. sRGB output only.
33
+ - **Order** (the `output.developmentOrder` assumption): demosaic → colour and white balance → diffraction correction → rendering exposure and tone curve → gamut and transfer → unsharp mask → quantization. With a development exposure push (#556) the order starts with it, on the RAW.
34
+ - **Reported** in the `output.diffractionCorrection` and `output.sharpening` assumptions (settings, defaults used, cut-off, measured filter response), in `diagnostics.detail` (ringing below black and above white; sharpened, held and clipped samples; the largest halo increase and decrease) and in `diagnostics.options`.
35
+ - **Limits:** neither combines with `output.region`; neither is offered by `simulateDisplayView` or `simulateIdealReference`. Both hold the whole developed image, as AHD does. The DNG is never modified: with both stages a DNG request writes the same DNG bytes.
36
+ - **Trade-off tests** (`test/lens-correction-tradeoffs.test.ts`, `test/development-detail.test.ts`; numbers in docs/DIGITAL_OPTICS_FOUNDATION.md): distortion correction stretches the corners, so a 0.25 cycle/pixel source grating lands at 0.186 cycles per output pixel in the corner (0.250 at the centre) with lower modulation; vignetting correction raises corner noise by the gain (×1.29 over the corner patch) and leaves the SNR unchanged; lateral CA correction registers the edges (to 0.005 px) but red and blue stay 1.8–2.2 px wider than green (longitudinal CA); sharpening overshoots and undershoots a step by 0.367·amount·h at σ = 1.5, as predicted; diffraction correction leaves a grating beyond the cut-off at exactly zero and restores one at 0.55·f_c only from 0.34 to 0.82 of its contrast.
37
+ - **Compatibility:** 3 new root type exports (`SimulateCaptureSharpening`, `SimulateCaptureDiffractionCorrection`, `SimulateCaptureDetailDiagnostics`); `SIMULATE_CAPTURE_COST_OPTIONS` gains `output.diffractionCorrection` and `output.sharpening`; `diagnostics.detail` is new and present only when a stage ran. Requests without the two options give the same captures, RAW codes, files and assumptions. No version bump here.
38
+ - **`lens.defocus` out of focus with `lens.projection`, `lens.distortion`, `lens.vignetting` or camera rotation (#548, for Photivra/apps#252, Photivra/apps#230, #549 and #553).** Until now `simulateCapture` refused these combinations, because the defocus disc ran as an object-space convolution on the plane lattice the sites see, and each of these options moves the sites' scene points off that lattice (or, for vignetting, gives each point its own radiance-to-irradiance factor).
39
+ - **Now supported, in image space:** for each spectral node (and, with rotation, each temporal midpoint, in that instant's camera frame) the sensor-plane irradiance before any PSF is formed on the sensor's site lattice through the inverse distortion, the declared projection, the rotation and the field factor (vignetting × distortion area factor), convolved with the disc, and each site query is answered with the result divided by the queried point's field factor (`src/facade/defocus-image-space-provider.ts`). It is the environment PSF quadrature with the disc as a sampled PSF, summed in another order. With `lens.diffraction`, each node's diffraction kernel convolved with the disc is the kernel.
40
+ - **Decisions per combination:** vignetting: supported, no approximation beyond the disc (field throughput applied where the light enters, before the blur, as for any environment PSF; no commutation error). Projection and distortion: supported; the disc has the same diameter at every field point, centred on each chief ray (exact for the rectilinear distortion-free thin lens), and the `lens.defocus` assumption records `fieldVariation` bounds for the request (`distortionMaximumLocalScaleDeviation`, `projectionMaximumDiameterChangeSites`). Camera rotation with a global shutter: supported, per temporal midpoint; `rotationMaximumDiameterChangeSites` bounds the tilt of the plane under pitch and yaw (0 for roll). Still refused, each with a message that says why: rotation with `motion.shutter: "rolling"` (every row has its own instants), and a vignetting or distortion profile that does not reach the frame's corners plus the kernel radius. `lens.pupilSampling` stays refused with projection and distortion (a traced reference needs the 3D ray provider, #358). `simulateDisplayView` and `prepareDisplayView` refuse projection, distortion, vignetting and rotation in any focus, as before, so they share no #548 refusal and do not change.
41
+ - **Accuracy:** RAW codes equal those of the same request with the disc passed as `lens.psf` (the per-tap environment quadrature), for each combination, all together, and with `lens.diffraction`; expected electrons agree to 1e-12 per site. The identity mapping (`lens.projection: "rectilinear"`) gives the object-space path's RAW. Against a fine-grid oracle (the disc sampled 5 times finer, 3 times with the 4 × 4 box), the lattice-sampling error is that of the rectilinear capture: on a 16 × 12 slanted edge with a 5.95-site disc, max / RMS relative difference in expected electrons 0.110 / 0.026 (point) and 0.032 / 0.006 (4 × 4 box) for the rectilinear capture, vignetting, a fisheye and radial distortion alike, and lower with rotation (0.069 / 0.016 and 0.017 / 0.003). The field-variation bounds on the app's full frame (50 mm f/2.8, focus 1 m, a 5.0-site disc): equisolid fisheye 0.43 sites, radial k1 −0.1 a local scale 0.28 off 1 at the corners, typical shake rotation 8e-4 sites.
42
+ - **Performance** (app defaults: Speed preset, 384 × 256 whole frame at 93.75 µm with the 2 × 2 box, Siemens star, 50 mm f/2.8, subject 2 m, focus 1 m, disc 5.0 sites, R 3; Apple M5 Pro, Node.js 24.19.0, medians of 3 on a shared machine): vignetting 1.0 s in focus, 1.6 s out of focus (R 3) and 1.7 s (R 6); rotation 2.1–3.3 s in focus, 7.5–8.8 s (R 3) and 6.5 s (R 6); fisheye 11 s, 15–19 s; radial distortion 18–21 s, 19–20 s; all four together 94–97 s, 101–124 s. Out of focus without these options stays on the object-space path (0.05–0.07 s). Table in docs/QUICK_START.md ("Optics"). Without rotation and lens mapping, and with rotation, the lean executor reads the image-space values by lattice key (`InternalRadianceAccess.keyOfDirection` now takes the request time; internal).
43
+ - **API:** `request.diffraction.execution` gains `"image-space-convolution"`; the `lens.defocus` assumption gains `execution: "image-space-convolution"`, `imageSpaceOptions` and `fieldVariation`. No new root export. Every request that 2.15.0 renders gives the same RAW codes, files and assumptions (in focus, and out of focus without these options).
44
+ - **Tests:** `test/simulate-capture-defocus-optics.test.ts` (each combination against the environment quadrature, 1e-12 per site, diffraction, identity mapping, in focus byte-identical, bounds, refusals, regions with the Quality filters); `test/simulate-capture-defocus-render.test.ts` updated. Docs: `docs/QUICK_START.md` ("Optics", "Modelling choices").
45
+
46
+ - **Integration of #556 with #552 and #548 (this release).** With `output.sharpening` or `output.diffractionCorrection` the capture develops through `developLinearSrgb` (the native development's own linear operator) instead of `createNativeRawDevelopmentTask`; the exposure push applies on both paths, on the black-subtracted, white-normalized RAW, so pushed sites clip at white before the diffraction correction. `output.developmentOrder` lists `"development exposure push (RAW)"` first when a push is given.
47
+ - **Tests** (`test/development-detail-integration.test.ts`): a +2-stop push with an unsharp mask that changes nothing is byte-identical to the push alone (the two paths); a +2-stop push of a quarter exposure (1/32 s), sharpened and diffraction-corrected, matches the full exposure (1/8 s) column by column within noise, with the same clipped highlight (saturated counts within 5 %) and the unclipped quarter-exposure RAW; image-space `lens.defocus` (vignetting, distortion and diffraction) with each of `output.exposureCompensationStops`, `output.sharpening` and `output.diffractionCorrection`, and all three: the RAW is unchanged, the push doubles linear light per sample at +1 stop, the mask adds halos, the correction raises contrast and reports that defocus is not inverted.
48
+
49
+ ## 2.15.0 — published November 7, 2026
50
+
51
+ A minor release: `lens.diffraction` at every spectral step (a fix for the app's color chart), and the defocus size limit with a pre-check as root exports (#532, #560). Captures, RAW codes, files and assumptions are the same as 2.14.1 for every request that 2.14.1 renders.
52
+ - **Fixed:** `lens.diffraction` with `sensor.spectralStepNanometers` under 6.25 nm, for example the color chart at the recommended 5 nm.
53
+ - **New:** `DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES` and `calculateDefocusDiscKernelRadius` (#532).
54
+ - **Why minor:** 4 new root exports, and two public limits that accept more input (64 → 400 wavelengths). Nothing is removed and no default changes.
55
+
56
+ - **Fixed: `lens.diffraction` at every spectral step (owner report from the app, 2026-11-06).** The color chart with diffraction failed with "wavelengthsNm must list 1 to 64 wavelengths." The app renders the `color-patches` chart at `sensor.spectralStepNanometers: 5`, as docs/COLOR_PATCHES.md recommends for color work. That step gives the sensor quadrature 80 spectral nodes over 380–780 nm, and `lens.diffraction` builds one Airy kernel per node with `createDiffractionLimitedPsfProfile`, which took at most 64. So every step under 6.25 nm failed with diffraction, on every scene, in `simulateCapture`, `simulateDisplayView`, `prepareDisplayView` and `simulateIdealReference`.
57
+ - **Fix, exact:** `DIFFRACTION_LIMITED_PSF_PROFILE_LIMITS.maximumWavelengths` and `LENS_PSF_CONVOLUTION_KERNELS_MAXIMUM_WAVELENGTHS` are now 400, the node count of the finest allowed step (1 nm). Each node keeps its own kernel at its own wavelength: no kernel is shared, banded or interpolated, and each equals a one-wavelength profile's kernel bit for bit. The refusal messages now state the limit ("… 1 to 400 wavelengths.").
58
+ - **Cost made linear in the node count:** every node's site kernel used to clone and parse the whole profile, O(N²). Now one private clone, parsed once, serves a request's nodes, with the same kernels. At radius 32 that took 80 nodes from 5.1 s to 0.12 s and 400 from 124 s to 1.4 s. The Airy cell-table cache holds 800 tables (was 256), and the capture's profile cache is also limited to the kernel values it could hold before.
59
+ - **Measured** (Apple M5 Pro, Node.js 24.19.0): a 4000 × 2666 color chart capture at 6 µm and f/16 takes 3.9 s without diffraction. With diffraction it takes 4.4 s at 20 nm, 4.3 s at 5 nm (heap 146 MiB against 128 MiB) and 4.5 s at 1 nm (218 MiB). The worst case, `kernelRadiusSamples: 32` at 1 nm on 1200 × 800, takes 35.9 s and 675 MiB. The full table is in docs/PSF_FOUNDATION.md, "Spectral nodes (2.15.0)"; also see docs/QUICK_START.md ("Optics") and docs/COLOR_PATCHES.md.
60
+ - **Tests** (`test/diffraction-spectral-grid.test.ts`): the reproduction through all four entry points; node counts from 1 nm to 100 nm; kernels against one-wavelength profiles; every chart preset and both caller scene kinds at 1 nm and 5 nm on the tabulated and per-site paths; every option that changes how diffraction runs; the display view's GPU Quality kernels (float32 emulation) at 80 nodes; the limits.
61
+ - **Compatibility:** no new export. Two public limits accept more input (64 → 400). Requests that 2.14.1 renders give the same values, because the kernels are bit-identical and only the cache sizes changed.
62
+
63
+ - **The defocus size limit and a pre-check are root exports (#532, for Photivra/apps#154 and Photivra/apps#157).** `lens.defocus: { model: "geometric-disc" }` refuses a disc whose kernel radius is over 64 sites (129 sites across), for example 50 mm f/1.4 focused at 1 m on a plane at 2 m with 6 µm pixels. A caller can now check a request before rendering it, instead of reading the refusal afterwards.
64
+ - **API:** `DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES` (64) and `calculateDefocusDiscKernelRadius({ focalLengthMm, aperture, focusDistanceM, subjectDistanceM, pixelPitchMicrometers })`, which returns a `CalculationResult` (model `defocus-disc-kernel-radius` 1.0.0) with `diameterMicrometers`, `diameterSites`, `kernelRadiusSites` (0 in focus), `maximumKernelRadiusSites`, `maximumDiameterSites` (129), `withinLimit` and `excessKernelRadiusSites`. Pure: no kernel is built and nothing is rendered.
65
+ - **Same rule as the renderer:** it calls the capture's own disc resolution (`resolveDefocusDisc`, from `calculateDefocusCircle` 1.1.0) and the same limit test as `requireDefocusDiscKernelRadius`, which `simulateCapture`, `simulateDisplayView`, `prepareDisplayView` and `simulateIdealReference` run. Pass the f-number the capture uses (in an automatic mode, `exposure.settings.fNumber`). The limit is on the disc alone; with `lens.diffraction` the combined kernel has no further limit. The other refusals of `lens.defocus` (rotation, projection, distortion, vignetting, pupil sampling, a caller PSF) are not checked.
66
+ - **Validation:** unknown fields fail with `InvalidConfigurationError`; a missing, non-finite or non-positive input, or a distance inside the focal length, with `InvalidScientificInputError`.
67
+ - **Tests** (`test/defocus-disc-limit.test.ts`): the limit is 64 and a disc of 129 sites is the largest within it; the closed form and the capture's resolved disc; radius 0 in focus and for a disc inside one site; at two adjacent focus distances (bisected to neighbouring doubles), the inside one renders with radius 64 and the outside one (radius 65, 1 over) is refused by `simulateCapture`, `prepareDisplayView` and `simulateIdealReference`; the same disc as a program-mode capture at its resolved f-number; input validation. `test/root-export-input-errors.test.ts` lists it as a strict calculator.
68
+ - **Compatibility:** 4 new root exports (1,805; runtime exports 611 → 613): `DEFOCUS_DISC_KERNEL_MAXIMUM_RADIUS_SITES`, `calculateDefocusDiscKernelRadius` and the types `CalculateDefocusDiscKernelRadiusInput` and `DefocusDiscKernelRadius`. None removed or changed; captures, RAW codes, files, assumptions and error messages are unchanged. Docs: `docs/QUICK_START.md` ("Optics", `lens.defocus`), `docs/USAGE.md` ("Depth of field and defocus"), `docs/AGENT_GUIDE.md`, `llms.txt`; API reference regenerated.
69
+
70
+ ## 2.14.1 — published November 3, 2026
4
71
 
5
72
  A patch release of docs and legal files only. **No API, behavior or value change from 2.14.0:** `src/` differs only in `ENGINE_API_VERSION` and one source comment, root exports are unchanged (1,801), and captures, RAW codes, files and assumptions are the same.
6
73
  - **Copyright holder and trademark owner (#543, #545):** `NOTICE` names Shackelford Creative, LLC as the copyright holder (`Copyright 2026 Shackelford Creative, LLC`) and as the owner of the Photivra trade names, trademarks, logos and branding; `README.md` ("Licensing") says the same and gains a **Trademarks** paragraph for the third-party names it mentions (ColorChecker, RAL and others). `CONTRIBUTING.md` and the licence scan's allowed holder follow.
@@ -398,6 +465,7 @@ A minor release: new functions for writing a whole-frame DNG from region parts a
398
465
  - **Faster preview:** in the app's Quality preview (Chromium, four workers), the quick frame takes 0.7 s instead of 4.0 s, and the fully sharp preview 65 s instead of 170 s.
399
466
  - **Faster captures:** a 24 MP chart capture takes 16.4 s instead of 18.5 s, and with AHD 28.5 s instead of 33.6 s (Node, one thread), with the same bytes.
400
467
  - **Compatibility:** five new root exports (1,734); no option, default or result field changes. `createDisplayViewExpectedSignal`, which is internal, takes an optional span hint.
468
+ - **Known limit** (line added after release, #400): the Quality noise limit of 2.6.0 remains (#379), unchanged from 2.8.1; 2.9.0 did not fix it, and no release is scheduled for the fix.
401
469
 
402
470
  ### Added
403
471
 
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.14.1"
12
+ version: "2.16.0"
13
13
  keywords:
14
14
  - photography
15
15
  - camera optics
package/README.md CHANGED
@@ -32,10 +32,10 @@ The source is licensed under Apache-2.0.
32
32
 
33
33
  ## Quick start
34
34
 
35
- Install the published ESM package below. The [2.14.1 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_14_1.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.16.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_16_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.14.1
38
+ npm install @photivra/engine@2.16.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.14.1` 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.16.0` tag.
79
79
 
80
80
  ## Documentation
81
81
 
@@ -363,13 +363,13 @@ The application can then use those results, while the photography calculations t
363
363
 
364
364
  ## Status
365
365
 
366
- - Repository package version: `2.14.1` (release candidate prepared November 3, 2026; publication is separate)
367
- - Engine API contract: `2.14.1`
366
+ - Repository package version: `2.16.0` (release candidate prepared November 7, 2026; publication is separate)
367
+ - Engine API contract: `2.16.0`
368
368
  - Versioned generic tier reference assets: [GENERIC_TIER_PRESETS.md](https://github.com/Photivra/photivra/blob/main/docs/GENERIC_TIER_PRESETS.md).
369
369
  - Composed POC simulation API contract: `1.0.0`
370
370
  - Stability: 1.0 root-package compatibility policy; bounded scientific models and independent POC contract
371
371
 
372
- From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.14.1; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
372
+ From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.16.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
373
 
374
374
  A breaking change to the root contracts now needs a new package major version. Additive and corrective changes follow [compatibility and migration](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_1_3_0.md). A 1.0 package is not a claim of calibrated physical accuracy.
375
375
 
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ Please do not open a public issue that contains vulnerability details.
4
4
 
5
5
  ## Supported versions
6
6
 
7
- The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.14.0**, published on November 1, 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.15.0**, published on November 7, 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
 
package/THIRD_PARTY.md CHANGED
@@ -203,6 +203,20 @@ factual constants only, independently implemented; no code or data copied):
203
203
  - Bradford cone matrix (Lam 1985), already used by the ICC serializer, now also
204
204
  for ForwardMatrix1.
205
205
 
206
+ Development detail references (#552; published equations and documented
207
+ behaviour only, independently implemented in `src/processing/development-detail.ts`;
208
+ no code, table or data copied):
209
+
210
+ - Circular-pupil incoherent OTF and its cut-off 1/(λN): MIT OpenCourseWare 2.71
211
+ *Optics* (Spring 2009), lectures 21–22 (already cited for `lens.diffraction`).
212
+ - Wiener deconvolution with a constant noise-to-signal ratio: the scikit-image
213
+ `skimage.restoration.wiener` documentation (BSD-3-Clause project; documentation
214
+ read, no code used) and Orieux, Giovannelli & Rodet (2010), JOSA A 27(7).
215
+ - Unsharp mask (radius, amount, threshold): the GIMP 2.10 manual, "Unsharp Mask",
216
+ and ImageMagick's `-unsharp` option documentation (documented behaviour only).
217
+ - Patent status: no public-record patent check has been recorded for these two
218
+ long-published methods; it is left for the owner's review with the others.
219
+
206
220
  The DNG patent-license notice appears in `NOTICE` and exporter source. These
207
221
  references do not authorize copying SDK code or scientific datasets. Human
208
222
  provenance/license review remains required before release.
@@ -287,7 +287,7 @@ function geometry(t, k, frame, environment, index, access) {
287
287
  return undefined;
288
288
  // #394: the key the evaluator would read for this request (undefined where it would throw: the full path decides).
289
289
  if (access !== undefined) {
290
- key = access.keyOfDirection(direction);
290
+ key = access.keyOfDirection(direction, time);
291
291
  if (key === undefined)
292
292
  return undefined;
293
293
  }