@photivra/engine 2.17.0 → 2.18.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 (52) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/camera/sensor-equivalence.d.ts +156 -0
  6. package/dist/camera/sensor-equivalence.js +208 -0
  7. package/dist/camera/sensor-equivalence.js.map +1 -0
  8. package/dist/capture/native-environment-tabulated.d.ts +5 -0
  9. package/dist/capture/native-environment-tabulated.js +1 -1
  10. package/dist/capture/native-environment-tabulated.js.map +1 -1
  11. package/dist/core/version.d.ts +1 -1
  12. package/dist/core/version.js +1 -1
  13. package/dist/core/version.js.map +1 -1
  14. package/dist/equipment/ideal-lens.d.ts +25 -0
  15. package/dist/equipment/ideal-lens.js +21 -0
  16. package/dist/equipment/ideal-lens.js.map +1 -0
  17. package/dist/facade/catalogue-lens-signal.d.ts +94 -0
  18. package/dist/facade/catalogue-lens-signal.js +362 -0
  19. package/dist/facade/catalogue-lens-signal.js.map +1 -0
  20. package/dist/facade/defocus-image-space-provider.js +5 -2
  21. package/dist/facade/defocus-image-space-provider.js.map +1 -1
  22. package/dist/facade/display-view-lens-signal.d.ts +32 -1
  23. package/dist/facade/display-view-lens-signal.js +85 -10
  24. package/dist/facade/display-view-lens-signal.js.map +1 -1
  25. package/dist/facade/expected-signal-setup.d.ts +8 -0
  26. package/dist/facade/expected-signal-setup.js +7 -0
  27. package/dist/facade/expected-signal-setup.js.map +1 -1
  28. package/dist/facade/expected-signal.d.ts +5 -0
  29. package/dist/facade/expected-signal.js +5 -1
  30. package/dist/facade/expected-signal.js.map +1 -1
  31. package/dist/facade/generic-lens-capture.d.ts +1 -0
  32. package/dist/facade/generic-lens-capture.js +1 -0
  33. package/dist/facade/generic-lens-capture.js.map +1 -1
  34. package/dist/facade/simulate-capture.d.ts +31 -4
  35. package/dist/facade/simulate-capture.js +117 -38
  36. package/dist/facade/simulate-capture.js.map +1 -1
  37. package/dist/facade/simulate-display-view.js +1 -1
  38. package/dist/facade/simulate-display-view.js.map +1 -1
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +2 -0
  41. package/dist/index.js.map +1 -1
  42. package/docs/AGENT_GUIDE.md +3 -0
  43. package/docs/API_REFERENCE.md +29 -1
  44. package/docs/QUICK_START.md +49 -1
  45. package/docs/USAGE.md +43 -2
  46. package/docs/api/camera-sensor-equivalence.md +209 -0
  47. package/docs/api/core-version.md +1 -1
  48. package/docs/api/equipment-ideal-lens.md +26 -0
  49. package/docs/api/exports.json +67 -2
  50. package/docs/api/facade-simulate-capture.md +27 -4
  51. package/llms.txt +2 -0
  52. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,6 +1,54 @@
1
1
  # Changelog
2
2
 
3
- ## 2.17.0 — unreleased (prepared November 7, 2026)
3
+ ## 2.18.0 — unreleased (prepared November 9, 2026)
4
+
5
+ A minor release of four issues: equivalence numbers for teaching (#573), an ideal lens at any physical focal length and f-number (#576), catalogue lenses in the display view (#569) and fast catalogue lens captures (#568). Captures, RAW codes, files and assumptions are the same as 2.17.0 for every request that 2.17.0 renders and that uses none of the new options (every hash lock holds).
6
+ - **New:** `calculateSensorEquivalence` and `calculateBlurShareOfFrame` (#573); `lens.ideal: { focalLengthMm, widestFNumber?, narrowestFNumber? }` and `IDEAL_LENS_LIMITS` (#576; the optional f-number range added at the owner's request, 2026-11-09); `simulateDisplayView` and `prepareDisplayView` render `lens.catalogue` (#569); a fast method for `lens.catalogue` behind `fast` or `lens.catalogue: { kind, fast }` (#568).
7
+ - **Fixes** (requests that 2.17.0 refused): `subjectDistanceM` may equal twice the focal length, so the Macro's 1:1 close focus renders (#569); a lens whose range excludes f/8 (*PV 800 9*, f/9–f/32) takes the reference exposure at the aperture of its range nearest f/8 with the same N²/t, where every request with it failed (#569).
8
+ - **Root exports:** 13 new, 1,837 → 1,850 (runtime exports 623 → 626): 11 from #573 (2 runtime), 1 from #576 (runtime), 1 from #568 (type). #569 adds none.
9
+ - **Why minor:** new root exports and new opt-in options; requests that 2.17.0 refused now render (the display view with `lens.catalogue`, a subject at twice the focal length, *PV 800 9*). Nothing is removed or changed incompatibly, and no default changes. No `src/gpu` change.
10
+ - **Bundle:** the app's capture-worker shape (every chunk of `NATIVE_RAW_LIMITS`, `prepareDisplayView`, `serializeSimulatedCaptureDngFromParts`, `simulateCapture`, `simulateIdealReference`) is 351,859 B gzip (2.17.0: 347,775 B): #568's lazily loaded fast catalogue code +2,907 B, #569's display-view catalogue PSF +550 B, the entry +397 B (the three parts' option handling), the ideal lens's f-number range +147 B. The integration saves 19 B (one closure for the tabulated paths' tables; unused grid axes removed). `test/capture-bundle.test.ts`'s limit is raised from 348,000 to 352,000 B (owner-approved 2026-11-09 for engine 2.18.0, #568's fast catalogue chunk). The display view adds 45,807 B minified and 18,128 B gzip to a capture bundle (2.17.0: 45,791 B and 18,159 B; budgets 46,500 B and 18,550 B, unchanged).
11
+ - **Moved to 2.19.0:** #570, #571 and #566 (the owner's decision of 2026-11-09 to publish these four parts now; #566's breaking orientation fix, decided for 2.18.0 on 2026-11-07, moves with them).
12
+
13
+ ### #573 Equivalence for teaching: equivalent f-number, sensor area ratio and blur as a share of the frame
14
+
15
+ - **New: `calculateSensorEquivalence({ sensor, focalLengthMm, fNumber, reference? })`** (model `sensor-equivalence` 1.0.0): for a lens on one sensor against 36 × 24 mm (or a `reference`), the crop factor, equivalent focal length, equivalent f-number (k·N, for depth of field, background blur and total light at the same framing and output size), the unchanged exposure f-number, entrance-pupil diameter, sensor area ratio, total-light difference in stops at the same f-number and shutter, and horizontal, vertical and diagonal angles of view. A `basis` record states, for every value, `"exact"` or `"approximation"` and its conditions; the equivalent f-number is a thin-lens approximation, exact only when the focus distance is much greater than the focal length (the exact thin-lens factor k·N·(s − f)/(s − k·f) is stated and tested). 50 mm f/2 on 23.5 × 15.6 mm gives ≈ 76.7 mm and ≈ f/3.07. With the default reference the crop factor and equivalent focal length are bit-identical to `calculateEquivalentFocalLength35Mm`.
16
+ - **New: `calculateBlurShareOfFrame({ blurCircleMm, sensor, print? })`** (model `blur-share-of-frame` 1.0.0): a blur-circle diameter as a share of the frame diagonal, width and height, and with a print size, the enlargement (whole frame fitted, long side to long side) and the blur on the print.
17
+ - **Root exports:** 11 new (2 runtime): the two functions and the types `CalculateSensorEquivalenceInput`, `SensorEquivalence`, `SensorEquivalenceFieldOfView`, `SensorEquivalenceQuantity`, `EquivalenceExactness`, `EquivalenceValueBasis`, `CalculateBlurShareOfFrameInput`, `BlurShareOfFrame`, `BlurShareOfPrint`. Pure functions; no capture, RAW or bundle change.
18
+ - **Docs and tests:** docs/PHYSICS_FOUNDATION.md ("Sensor equivalence: f-number, area and blur share"), docs/USAGE.md, docs/AGENT_GUIDE.md, llms.txt, API reference task index; `test/sensor-equivalence.test.ts` (hand-checked values, the thin-lens check of the approximation, validation).
19
+
20
+ ### #576 Ideal lens at any physical focal length and f-number (capture and display view)
21
+
22
+ - **New: `lens.ideal: { focalLengthMm }` (#576, for Photivra/apps#246, #250 and #251).** An ideal lens, an aberration-free thin lens named by its physical focal length, on whatever sensor the simulated frame is (no crop-factor scaling, unlike `lens.prime` and `lens.catalogue`), at any `lens.fNumber` from f/0.5 to f/128 and focal lengths of 1–4000 mm. f/0.5 is the physical limit of an aplanatic lens in air (the sine condition, N = 1/(2·sin θ′)); the other bounds are engine bounds. Values beyond them are refused by name (`lens.ideal.focalLengthMm must be 1–4000 mm (IDEAL_LENS_LIMITS).`, `lens.fNumber 0.45 is outside the ideal-50mm range f/0.5–f/128.`).
23
+ - **Model:** it replaces the tier lens's focal length and f-number range, as `lens.prime` does (the tier lens's transmission is kept); its blur is that of `lens.defocus`, `lens.diffraction` and `lens.vignetting`, the same models. No minimum focus distance beyond the thin-lens limits (subject at least twice the focal length away, with #569). Shutter priority and program may choose any aperture in f/0.5–f/128. Recorded in the new `lens.ideal` assumption (`{ lensId: "ideal-50mm", focalLengthMm: 50 }`).
24
+ - **Optional f-number range (owner, 2026-11-09):** `lens.ideal.widestFNumber` and `narrowestFNumber`, within `IDEAL_LENS_LIMITS` and widest ≤ narrowest (each defaults to its bound), narrow the range like a real lens's: `lens.fNumber` must lie in it and shutter priority chooses apertures only in it (1/4000 s, ISO 100 in 2000 lx: wider than f/1.4 over the whole range, f/1.4 with `widestFNumber: 1.4`). Program follows the engine's program line (f/4–f/16) and refuses a range that does not hold it, as for `lens.prime` and `lens.catalogue`. The assumption records the range only when given, so requests without it are unchanged. Refused: `lens.ideal.widestFNumber and narrowestFNumber must be f-numbers in f/0.5–f/128 (IDEAL_LENS_LIMITS), the widest no larger than the narrowest.`
25
+ - **Where:** `simulateCapture`, `simulateDisplayView` and `prepareDisplayView` (it goes through the shared request path). Not combinable with `lens.prime`, `lens.catalogue` or `lens.focalLengthMm`.
26
+ - **Tests** (`test/ideal-lens.test.ts`): 50 mm f/1.8 focused at 2.5 m with an edge at 3 m on full frame (7200 × 4800) and APS-C 23.5 × 15.6 mm (4700 × 3120) at 5 µm: the blur is `calculateDefocusCircle`'s 94.48 µm on both; the display view of the defocused edge equals the view of the disc's closed-form edge-spread function within 2.2e-14, and the capture equals the capture of that closed form byte for byte. Same pixels as the tier lens (`lens.focalLengthMm: 50`, f/4; with defocus and diffraction) and as the 50 mm-equivalent prime on a 36 × 24 mm frame at f/1.8. Bounds, every exposure mode and refusals.
27
+ - **API:** new root export `IDEAL_LENS_LIMITS` (runtime). `SimulateCaptureOptions.lens` gains `ideal`. The f-number range check of `lens.prime` and `lens.catalogue` now shares one code path (same messages).
28
+ - **Compatibility:** requests without `lens.ideal` give the same captures, RAW codes, files and assumptions. No `src/gpu` change.
29
+ - **Docs:** docs/QUICK_START.md ("Ideal lens at any focal length and f-number"), docs/USAGE.md, docs/AGENT_GUIDE.md; API reference regenerated.
30
+
31
+ ### #569 Display view: render catalogue lenses
32
+
33
+ - **New: the display view renders `lens.catalogue` (#569, for Photivra/apps#251 and #252).** `simulateDisplayView` and `prepareDisplayView` accept the 30 generic catalogue lenses at every f-number of each lens's range and down to its close focus, with `lens.defocus` and `lens.focusScatter`, where 2.17.0 refused `lens.catalogue`. The lens's distortion, vignetting and fisheye projection go through #563's lens signal; its PSF is taken per field zone.
34
+ - **Model (an approximation, the new `view.lensPsf` assumption):** the native raster is cut into square zones of ⌈max(W, H) / 64⌉ sites (94 on 6000 × 4000; one site on frames of at most 64 sites a side), and every site of a zone takes the PSF the capture's own resolver gives at the zone's centre, at each spectral node; every tap reads the scene through the lens with its own field factor, as the capture's environment PSF quadrature does. Catalogue fisheyes see zero radiance beyond 90°, as in the capture. `createDisplayViewLensPsf` in `src/facade/display-view-lens-signal.ts`, loaded on demand; the zones' tables are the expected signal's own (`buildTables`, now exported internally).
35
+ - **Accuracy** (per site, against the capture's own site record and PSF quadrature): ≤ 2e-15 with one-site zones; 2.5e-3 with the box aperture, four-spot filter and defocus (the capture resolves the PSF at each query point); on a whole 6 µm full frame at most 0.66 % per site and 1.2e-4 on average over four hard lenses; all 30 lenses at their widest and narrowest f-number at close focus within 1 %. A slanted edge against the box-downsized capture is within the rectilinear test's 0.6-code RMS and 3-code maximum. Siemens-star boundary ties behave as with `lens.diffraction` (#565).
36
+ - **Cost** on the app's request (24 MP, 2028 × 1352 in 96 × 96 parts, one thread, loaded host): part medians 0.2–0.8 s for most lenses (1.9 s for the Macro at f/22) against 50–80 ms without a lens; 1.9–39× the no-lens view per display pixel (docs/DISPLAY_VIEW.md, "Catalogue lenses"). The CPU renders these requests; `gpu: "auto"` falls back, labelled `unsupported-on-backend`. `scripts/benchmark-display-view.mjs` gains `LENS=catalogue:<id>[:open|:<f-number>]`.
37
+ - **Capture fixes** (only requests that failed before change): `subjectDistanceM` may equal twice the focal length (the Macro's 1:1 close focus was refused); a lens whose range excludes f/8 (*PV 800 9*) takes the reference exposure at the aperture of its range nearest f/8 (its widest for *PV 800 9*) with the same N²/t, where every request with it failed.
38
+ - **Unchanged:** every request without `lens.catalogue` gives the same bytes; no root export is added; no `src/gpu` change. `lensGeometry`'s image-space mapping (#548) now honours `outsideImageCircle: "backward-ray"`, which only catalogue fisheyes set.
39
+ - **Docs and tests:** docs/DISPLAY_VIEW.md ("Catalogue lenses"), docs/LENS_CATALOGUE.md, docs/QUICK_START.md; `test/display-view-lens-catalogue.test.ts`.
40
+
41
+ ### #568 Fast catalogue lens captures for interactive use
42
+
43
+ - **New: a fast method for `lens.catalogue` (#568, for Photivra/apps#251).** `fast: true` on a `simulateCapture` request, or the record form `lens: { catalogue: { kind: "<lens id>", fast }, fNumber }` (whose `fast` overrides the top-level flag), renders a catalogue lens from tabulated expected electrons instead of the environment PSF quadrature's per-tap scene queries. It is the first option with a fast method under the #395 convention: `lens.catalogue` joins `SIMULATE_CAPTURE_FAST_OPTIONS`, and without the flag the exact method runs unchanged.
44
+ - **Approximation (labeled):** the catalogue PSF is resolved exactly on a field grid (at least 4 cells between neighboring profile field nodes, more where the node kernels' centroids move by over 0.05 site per cell) and each site takes the bilinear blend of its four grid points' kernels instead of its own; distortion, vignetting and the fisheye projection are the exact method's. Reported in `diagnostics.fast` (`applied: { "lens.catalogue": true }`, `assurance: "approximation-with-a-priori-bound"`), the new `fast.lens.catalogue` assumption and `raw.expectedSignal` (method `tabulated-expected-electrons-catalogue-psf-field-grid`). One site per CFA phase runs the exact chain at run time and must agree within 0.5 %, or the exact method runs.
45
+ - **Accuracy against the exact method:** without the blend the sum equals the exact chain's within 1e-9; with it, site by site within 2.3e-4 of the frame's peak and 2.0e-3 of the site's own value (tested bounds 1e-3 and 5e-3); on the #549 charts the edge rise within 0.010 px, coma within 0.0001 px, lateral color within 0.003 px, distortion and vignetting unchanged (tested bounds 0.02 px and 0.005 stops).
46
+ - **Cost** (Apple M5 Pro, Node 24.19.0, one thread; Siemens star, whole full frame, f/8, PV 50 1.8 against `lens.prime` 50): 768 × 512 renders in 0.209 s against 0.145 s (1.44×; the issue's target was 2×) and 185 s with the exact method; 384 × 256 in 0.084 s (exact 47.5 s). `node scripts/benchmark-catalogue-capture.mjs` reproduces it (docs/LENS_CATALOGUE.md, "Fast method").
47
+ - **Where:** a still camera, site-center queries (no anti-aliasing filter or box pixel aperture) and a chart or material scene, in whole frames and regions, with or without `lens.defocus`; elsewhere the exact method runs and `applied` is `false`. The display view renders `lens.catalogue` with #569's per-zone PSF and never takes the fast method (`applied` is `false` there).
48
+ - **API:** one new root type export, `SimulateCaptureLensCatalogueRecord`; `SimulateCaptureOptions.lens.catalogue` also takes the record; `SIMULATE_CAPTURE_FAST_OPTIONS` gains `lens.catalogue`. Internal: `renderTabulatedNativeRaw` takes an optional verification tolerance.
49
+ - **Compatibility:** every request without `fast: true` (or the record's `fast`) renders byte-identically to 2.17.0. A request that already passed `fast: true` with `lens.catalogue` now takes the fast method.
50
+
51
+ ## 2.17.0 — published November 8, 2026
4
52
 
5
53
  A minor release of four issues: large defocus discs (#564), bokeh character (#553), lens projection, distortion, vignetting and rotation in the display view (#563), and the generic lens catalogue (#549). Captures, RAW codes, files and assumptions are the same as 2.16.0 for every request that 2.16.0 renders and that uses none of the new options (every hash lock holds).
6
54
  - **New:** `lens.defocus` up to a kernel radius of 512 sites (#564); `lens.defocus.blades`, `sphericalAberration` and `catsEye` and `calculateDefocusBokehKernel` (#553); the display view with `lens.projection`, `lens.distortion`, `lens.vignetting` and global-shutter rotation (#563); `lens.catalogue`, `lens.focusScatter` and the 30-lens `GENERIC_LENS_CATALOGUE`, each lens with a short `summary` (#549).
package/CITATION.cff CHANGED
@@ -9,7 +9,7 @@ abstract: "An open TypeScript engine for the science and mathematics of photogra
9
9
  repository-code: "https://github.com/photivra/photivra"
10
10
  url: "https://photivra.com"
11
11
  license: "Apache-2.0"
12
- version: "2.17.0"
12
+ version: "2.18.0"
13
13
  keywords:
14
14
  - photography
15
15
  - camera optics
package/README.md CHANGED
@@ -32,10 +32,10 @@ The source is licensed under Apache-2.0.
32
32
 
33
33
  ## Quick start
34
34
 
35
- Install the published ESM package below. The [2.17.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_17_0.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
35
+ Install the published ESM package below. The [2.18.0 release record](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_2_18_0.md) and [changelog](https://github.com/Photivra/photivra/blob/main/CHANGELOG.md) describe the current release:
36
36
 
37
37
  ```sh
38
- npm install @photivra/engine@2.17.0
38
+ npm install @photivra/engine@2.18.0
39
39
  ```
40
40
 
41
41
  Simulate a photograph of a test chart with a generic camera, then read the developed sRGB pixels:
@@ -75,7 +75,7 @@ console.log(horizontal.provenance);
75
75
 
76
76
  Start with the [Developer Guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Then use the [Usage Guide](https://github.com/Photivra/photivra/blob/main/docs/USAGE.md) and the [API reference](https://github.com/Photivra/photivra/blob/main/docs/API_REFERENCE.md), which is generated from the package exports.
77
77
 
78
- Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.17.0` tag.
78
+ Repository `main` may describe newer, unreleased work. For the stable contract, use the docs bundled in the published package, or the exact `v2.18.0` tag.
79
79
 
80
80
  ## Documentation
81
81
 
@@ -363,13 +363,13 @@ The application can then use those results, while the photography calculations t
363
363
 
364
364
  ## Status
365
365
 
366
- - Repository package version: `2.17.0` (release candidate prepared November 7, 2026; publication is separate)
367
- - Engine API contract: `2.17.0`
366
+ - Repository package version: `2.18.0` (release candidate prepared November 9, 2026; publication is separate)
367
+ - Engine API contract: `2.18.0`
368
368
  - Versioned generic tier reference assets: [GENERIC_TIER_PRESETS.md](https://github.com/Photivra/photivra/blob/main/docs/GENERIC_TIER_PRESETS.md).
369
369
  - Composed POC simulation API contract: `1.0.0`
370
370
  - Stability: 1.0 root-package compatibility policy; bounded scientific models and independent POC contract
371
371
 
372
- From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.17.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
372
+ From 1.0.1, the package version and `ENGINE_API_VERSION` are equal. New captures/plans record 2.18.0; existing archives keep their original creator identities. Schema, model and POC contract versions stay independent.
373
373
 
374
374
  A breaking change to the root contracts now needs a new package major version. Additive and corrective changes follow [compatibility and migration](https://github.com/Photivra/photivra/blob/main/docs/RELEASE_1_3_0.md). A 1.0 package is not a claim of calibrated physical accuracy.
375
375
 
package/SECURITY.md CHANGED
@@ -4,7 +4,7 @@ Please do not open a public issue that contains vulnerability details.
4
4
 
5
5
  ## Supported versions
6
6
 
7
- The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.16.0**, published on November 7, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
7
+ The published 1.0.0 release started the 1.x root-package compatibility line, and 2.0.0 started the 2.x line. The current release is **2.17.0**, published on November 8, 2026 (see [CHANGELOG.md](CHANGELOG.md)).
8
8
 
9
9
  Security fixes target the current maintained development and release line. Do not assume that older 1.x releases, snapshots or pre-1.0 releases will receive fixes.
10
10
 
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Format equivalence for teaching: what a lens of focal length f at f-number N on one sensor
3
+ * corresponds to on a reference format (36 × 24 mm by default), and how large a blur circle is
4
+ * as a share of the frame or of a print.
5
+ *
6
+ * With the diagonal crop factor k = referenceDiagonal / sensorDiagonal:
7
+ * - equivalent focal length k·f (same diagonal angle of view; exact by the diagonal convention);
8
+ * - equivalent f-number k·N for depth of field, background blur and total light (same framing,
9
+ * same output size). It keeps the entrance-pupil diameter f/N unchanged. It is an
10
+ * approximation: in the thin-lens defocus-circle equation it is exact only when the focus
11
+ * distance is much greater than the focal length;
12
+ * - the f-number for exposure stays N: image illuminance depends on N, not on the sensor size;
13
+ * - sensor area ratio and the total-light difference in stops at the same f-number and shutter.
14
+ *
15
+ * Sources (free): thin-lens defocus-circle diameter f²|s − d|/(N d (s − f)) and f-number N = f/D,
16
+ * Greivenkamp, OPTI-502 course notes, sec. 12 (Objectives),
17
+ * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-12-Objectives.pdf;
18
+ * image illuminance proportional to 1/N², sec. 11 (Radiative transfer),
19
+ * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-11-Radiative-Transfer.pdf.
20
+ * @see docs/PHYSICS_FOUNDATION.md "Sensor equivalence" for the derivation, the exact and
21
+ * approximated quantities, and support limits.
22
+ */
23
+ import { type CalculationResult } from "../core/calculation-result.js";
24
+ import { type SensorImagingArea } from "../sensor/sensor-geometry.js";
25
+ /** Whether a value is exact under its stated conditions or an approximation. */
26
+ export type EquivalenceExactness = "exact" | "approximation";
27
+ /** How one equivalence quantity was obtained, and under which conditions it holds. */
28
+ export interface EquivalenceValueBasis {
29
+ /** `"exact"` under the stated conditions, or `"approximation"`. */
30
+ kind: EquivalenceExactness;
31
+ /** Plain statement of the definition and its conditions. */
32
+ statement: string;
33
+ }
34
+ export interface CalculateSensorEquivalenceInput {
35
+ /** Physical active imaging area of the sensor the lens is used on. */
36
+ sensor: SensorImagingArea;
37
+ /** Physical focal length of the lens in millimetres. */
38
+ focalLengthMm: number;
39
+ /** f-number set on the lens (N = focal length / entrance-pupil diameter). */
40
+ fNumber: number;
41
+ /** Format to compare against. Defaults to the 36 × 24 mm frame. */
42
+ reference?: SensorImagingArea;
43
+ }
44
+ /** Angles of view of an ideal rectilinear lens focused at infinity, in degrees. */
45
+ export interface SensorEquivalenceFieldOfView {
46
+ horizontalDegrees: number;
47
+ verticalDegrees: number;
48
+ diagonalDegrees: number;
49
+ }
50
+ /** Quantities of a sensor equivalence result that carry an exactness statement. */
51
+ export type SensorEquivalenceQuantity = "cropFactor" | "equivalentFocalLengthMm" | "equivalentFNumber" | "exposureFNumber" | "entrancePupilDiameterMm" | "areaRatio" | "totalLightDifferenceStops" | "fieldOfView";
52
+ export interface SensorEquivalence {
53
+ /** Physical focal length supplied by the caller, in millimetres. */
54
+ focalLengthMm: number;
55
+ /** f-number supplied by the caller. */
56
+ fNumber: number;
57
+ /** Sensor active imaging area used. */
58
+ sensor: SensorImagingArea;
59
+ /** Reference format used (36 × 24 mm unless the caller passed one). */
60
+ reference: SensorImagingArea;
61
+ /** Sensor diagonal in millimetres. */
62
+ sensorDiagonalMm: number;
63
+ /** Reference diagonal in millimetres. */
64
+ referenceDiagonalMm: number;
65
+ /** Diagonal crop factor: reference diagonal / sensor diagonal. Greater than 1 for a smaller sensor. */
66
+ cropFactor: number;
67
+ /** Focal length on the reference format with the same diagonal angle of view: cropFactor × focalLengthMm. */
68
+ equivalentFocalLengthMm: number;
69
+ /**
70
+ * f-number on the reference format that gives the same depth of field, background blur and total
71
+ * light for the same framing and output size: cropFactor × fNumber. Not an exposure setting.
72
+ */
73
+ equivalentFNumber: number;
74
+ /** f-number that sets exposure (image brightness): the f-number on the lens, unchanged by sensor size. */
75
+ exposureFNumber: number;
76
+ /** Entrance-pupil diameter f/N in millimetres; the equivalent lens has the same diameter. */
77
+ entrancePupilDiameterMm: number;
78
+ /** Sensor area / reference area. Less than 1 for a smaller sensor. */
79
+ areaRatio: number;
80
+ /**
81
+ * log2(areaRatio): total light collected by the sensor relative to the reference at the same
82
+ * f-number, shutter time and scene, in stops. Negative for a smaller sensor.
83
+ */
84
+ totalLightDifferenceStops: number;
85
+ /** Angles of view of this lens on the sensor (the equivalent lens gives the same diagonal angle). */
86
+ fieldOfView: SensorEquivalenceFieldOfView;
87
+ /** Exact or approximated, with the conditions, for every quantity above. */
88
+ basis: Readonly<Record<SensorEquivalenceQuantity, EquivalenceValueBasis>>;
89
+ }
90
+ /**
91
+ * Equivalence of a lens (focal length, f-number) on one sensor against a reference format,
92
+ * 36 × 24 mm by default: crop factor, equivalent focal length, equivalent f-number for depth of
93
+ * field, blur and total light, the unchanged f-number for exposure, sensor area ratio, total-light
94
+ * difference in stops and the angles of view.
95
+ *
96
+ * Call it once per sensor for a lens table. Every quantity carries a `basis` entry saying whether it
97
+ * is exact under its stated conditions or an approximation. The equivalent f-number is an
98
+ * approximation (exact when the focus distance is much greater than the focal length) and never an
99
+ * exposure setting: exposure uses `exposureFNumber`, the f-number on the lens.
100
+ *
101
+ * Example: 50 mm f/2 on a 23.5 × 15.6 mm sensor gives crop factor ≈ 1.53, ≈ 77 mm and ≈ f/3.1.
102
+ *
103
+ * @param input Sensor active area, focal length, f-number and optional reference format.
104
+ * @returns The equivalence numbers with a per-quantity exactness statement.
105
+ */
106
+ export declare function calculateSensorEquivalence(input: CalculateSensorEquivalenceInput): CalculationResult<SensorEquivalence>;
107
+ export interface CalculateBlurShareOfFrameInput {
108
+ /** Blur-circle diameter on the sensor in millimetres (for example from calculateDefocusCircle). Zero is allowed. */
109
+ blurCircleMm: number;
110
+ /** Physical active imaging area of the sensor. */
111
+ sensor: SensorImagingArea;
112
+ /**
113
+ * Optional print size. The whole frame is enlarged to fit inside it without cropping, long side
114
+ * to long side.
115
+ */
116
+ print?: {
117
+ widthMm: number;
118
+ heightMm: number;
119
+ };
120
+ }
121
+ export interface BlurShareOfPrint {
122
+ /** Linear enlargement from sensor to print: the smaller of the long-side and short-side ratios. */
123
+ enlargement: number;
124
+ /** Blur-circle diameter on the print in millimetres: blurCircleMm × enlargement. */
125
+ blurOnPrintMm: number;
126
+ /** Size of the printed image in millimetres (equal to the print when the aspect ratios match). */
127
+ imageLongSideMm: number;
128
+ imageShortSideMm: number;
129
+ }
130
+ export interface BlurShareOfFrame {
131
+ /** Blur-circle diameter on the sensor in millimetres. */
132
+ blurCircleMm: number;
133
+ /** Sensor diagonal in millimetres. */
134
+ frameDiagonalMm: number;
135
+ /** Blur diameter / frame diagonal. Equal shares look the same at the same output size. */
136
+ shareOfDiagonal: number;
137
+ /** Blur diameter / frame width. */
138
+ shareOfWidth: number;
139
+ /** Blur diameter / frame height. */
140
+ shareOfHeight: number;
141
+ /** Blur on the print; present only when a print size was given. */
142
+ print?: BlurShareOfPrint;
143
+ }
144
+ /**
145
+ * A blur circle on the sensor as a share of the frame (diagonal, width and height) and, when a
146
+ * print size is given, its diameter on that print.
147
+ *
148
+ * Equal shares of the diagonal look the same when the images are shown at the same size, which
149
+ * is how "same framing" comparisons across sensor sizes are stated. Every value is an exact ratio
150
+ * or scaling; whether a blur is visible depends on viewing distance and acuity, which this
151
+ * function does not judge.
152
+ *
153
+ * @param input Blur diameter in millimetres, sensor active area and optional print size.
154
+ * @returns Shares of the frame, and the blur on the print when a print is given.
155
+ */
156
+ export declare function calculateBlurShareOfFrame(input: CalculateBlurShareOfFrameInput): CalculationResult<BlurShareOfFrame>;
@@ -0,0 +1,208 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Format equivalence for teaching: what a lens of focal length f at f-number N on one sensor
4
+ * corresponds to on a reference format (36 × 24 mm by default), and how large a blur circle is
5
+ * as a share of the frame or of a print.
6
+ *
7
+ * With the diagonal crop factor k = referenceDiagonal / sensorDiagonal:
8
+ * - equivalent focal length k·f (same diagonal angle of view; exact by the diagonal convention);
9
+ * - equivalent f-number k·N for depth of field, background blur and total light (same framing,
10
+ * same output size). It keeps the entrance-pupil diameter f/N unchanged. It is an
11
+ * approximation: in the thin-lens defocus-circle equation it is exact only when the focus
12
+ * distance is much greater than the focal length;
13
+ * - the f-number for exposure stays N: image illuminance depends on N, not on the sensor size;
14
+ * - sensor area ratio and the total-light difference in stops at the same f-number and shutter.
15
+ *
16
+ * Sources (free): thin-lens defocus-circle diameter f²|s − d|/(N d (s − f)) and f-number N = f/D,
17
+ * Greivenkamp, OPTI-502 course notes, sec. 12 (Objectives),
18
+ * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-12-Objectives.pdf;
19
+ * image illuminance proportional to 1/N², sec. 11 (Radiative transfer),
20
+ * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-11-Radiative-Transfer.pdf.
21
+ * @see docs/PHYSICS_FOUNDATION.md "Sensor equivalence" for the derivation, the exact and
22
+ * approximated quantities, and support limits.
23
+ */
24
+ import { approximationResult, calculatedResult } from "../core/calculation-result.js";
25
+ import { InvalidScientificInputError, requirePositiveFinite } from "../core/validation.js";
26
+ import { portableHypot, portableLog2 } from "../core/portable-math.js";
27
+ import { requireInputKeys, requireInputObject } from "../core/record-validation.js";
28
+ import { calculateImagingAreaMetrics } from "../sensor/sensor-geometry.js";
29
+ /** The default equivalence reference: the 36 × 24 mm frame. */
30
+ const REFERENCE_FULL_FRAME = Object.freeze({ widthMm: 36, heightMm: 24 });
31
+ const SENSOR_EQUIVALENCE_BASIS = Object.freeze({
32
+ cropFactor: Object.freeze({
33
+ kind: "exact",
34
+ statement: "Ratio of the reference diagonal to the sensor diagonal (diagonal convention)."
35
+ }),
36
+ equivalentFocalLengthMm: Object.freeze({
37
+ kind: "exact",
38
+ statement: "Same diagonal angle of view on the reference format, for an ideal rectilinear lens focused at infinity. " +
39
+ "Horizontal and vertical angles match too only when the aspect ratios match."
40
+ }),
41
+ equivalentFNumber: Object.freeze({
42
+ kind: "approximation",
43
+ statement: "Same depth of field and background blur as a share of the frame, at the same subject distance, framing and output size, " +
44
+ "and the same entrance-pupil diameter (so the same total light at the same shutter time). In the thin-lens defocus equation it is exact " +
45
+ "only when the focus distance s is much greater than the focal length f; with focal lengths in the ratio cropFactor, the blur shares match " +
46
+ "at cropFactor·N·(s − f)/(s − cropFactor·f). " +
47
+ "Total light matches exactly only when the aspect ratios match. It does not set exposure."
48
+ }),
49
+ exposureFNumber: Object.freeze({
50
+ kind: "exact",
51
+ statement: "Image illuminance is proportional to 1/N², independent of sensor size (ideal lens; transmission, vignetting and " +
52
+ "close-focus bellows factor excluded). Equivalence never changes the exposure setting."
53
+ }),
54
+ entrancePupilDiameterMm: Object.freeze({
55
+ kind: "exact",
56
+ statement: "Entrance-pupil diameter D = f/N by the definition of the f-number. The equivalent lens (cropFactor·f at cropFactor·N) has the same D."
57
+ }),
58
+ areaRatio: Object.freeze({
59
+ kind: "exact",
60
+ statement: "Product of width and height of the sensor divided by that of the reference."
61
+ }),
62
+ totalLightDifferenceStops: Object.freeze({
63
+ kind: "exact",
64
+ statement: "log2 of the area ratio: at the same f-number, shutter time and scene the image illuminance is the same, so total light scales " +
65
+ "with area (uniform illuminance; vignetting excluded)."
66
+ }),
67
+ fieldOfView: Object.freeze({
68
+ kind: "exact",
69
+ statement: "2·atan(dimension / 2f) for an ideal rectilinear lens focused at infinity; distortion and focus breathing excluded."
70
+ })
71
+ });
72
+ function requireImagingArea(area, label) {
73
+ requireInputObject(area, label);
74
+ requireInputKeys(area, label, ["widthMm", "heightMm"]);
75
+ const record = area;
76
+ requirePositiveFinite(`${label}.widthMm`, record.widthMm);
77
+ requirePositiveFinite(`${label}.heightMm`, record.heightMm);
78
+ }
79
+ /** √((rw² + rh²)/(w² + h²)), with only correctly rounded operations when nothing overflows (portable). */
80
+ function diagonalRatio(reference, sensor) {
81
+ const referenceSquared = reference.widthMm * reference.widthMm + reference.heightMm * reference.heightMm;
82
+ const sensorSquared = sensor.widthMm * sensor.widthMm + sensor.heightMm * sensor.heightMm;
83
+ const ratio = referenceSquared / sensorSquared;
84
+ return Number.isFinite(referenceSquared) && Number.isFinite(sensorSquared) && sensorSquared > 0 && ratio > 0 && Number.isFinite(ratio)
85
+ ? Math.sqrt(ratio)
86
+ : portableHypot(reference.widthMm, reference.heightMm) / portableHypot(sensor.widthMm, sensor.heightMm);
87
+ }
88
+ function rectilinearAngleDegrees(dimensionMm, focalLengthMm) {
89
+ return (2 * Math.atan(dimensionMm / (2 * focalLengthMm)) * 180) / Math.PI;
90
+ }
91
+ /**
92
+ * Equivalence of a lens (focal length, f-number) on one sensor against a reference format,
93
+ * 36 × 24 mm by default: crop factor, equivalent focal length, equivalent f-number for depth of
94
+ * field, blur and total light, the unchanged f-number for exposure, sensor area ratio, total-light
95
+ * difference in stops and the angles of view.
96
+ *
97
+ * Call it once per sensor for a lens table. Every quantity carries a `basis` entry saying whether it
98
+ * is exact under its stated conditions or an approximation. The equivalent f-number is an
99
+ * approximation (exact when the focus distance is much greater than the focal length) and never an
100
+ * exposure setting: exposure uses `exposureFNumber`, the f-number on the lens.
101
+ *
102
+ * Example: 50 mm f/2 on a 23.5 × 15.6 mm sensor gives crop factor ≈ 1.53, ≈ 77 mm and ≈ f/3.1.
103
+ *
104
+ * @param input Sensor active area, focal length, f-number and optional reference format.
105
+ * @returns The equivalence numbers with a per-quantity exactness statement.
106
+ */
107
+ export function calculateSensorEquivalence(input) {
108
+ requireInputKeys(input, "calculateSensorEquivalence", ["sensor", "focalLengthMm", "fNumber", "reference"]);
109
+ requireImagingArea(input.sensor, "sensor");
110
+ requirePositiveFinite("focalLengthMm", input.focalLengthMm);
111
+ requirePositiveFinite("fNumber", input.fNumber);
112
+ if (input.reference !== undefined)
113
+ requireImagingArea(input.reference, "reference");
114
+ const sensor = { widthMm: input.sensor.widthMm, heightMm: input.sensor.heightMm };
115
+ const reference = input.reference === undefined
116
+ ? { ...REFERENCE_FULL_FRAME }
117
+ : { widthMm: input.reference.widthMm, heightMm: input.reference.heightMm };
118
+ // The default reference reuses the canonical 35 mm crop factor, so it is bit-identical to
119
+ // calculateEquivalentFocalLength35Mm and calculateImagingAreaMetrics.
120
+ const cropFactor = input.reference === undefined
121
+ ? calculateImagingAreaMetrics(sensor).value.cropFactor35Mm
122
+ : diagonalRatio(reference, sensor);
123
+ const areaRatio = (sensor.widthMm / reference.widthMm) * (sensor.heightMm / reference.heightMm);
124
+ const sensorDiagonalMm = portableHypot(sensor.widthMm, sensor.heightMm);
125
+ if (!(areaRatio > 0) || !Number.isFinite(areaRatio)) {
126
+ throw new InvalidScientificInputError("sensor and reference areas must differ by a ratio that is a finite number greater than zero.");
127
+ }
128
+ return approximationResult({
129
+ focalLengthMm: input.focalLengthMm,
130
+ fNumber: input.fNumber,
131
+ sensor,
132
+ reference,
133
+ sensorDiagonalMm,
134
+ referenceDiagonalMm: portableHypot(reference.widthMm, reference.heightMm),
135
+ cropFactor,
136
+ equivalentFocalLengthMm: input.focalLengthMm * cropFactor,
137
+ equivalentFNumber: input.fNumber * cropFactor,
138
+ exposureFNumber: input.fNumber,
139
+ entrancePupilDiameterMm: input.focalLengthMm / input.fNumber,
140
+ areaRatio,
141
+ totalLightDifferenceStops: portableLog2(areaRatio),
142
+ fieldOfView: {
143
+ horizontalDegrees: rectilinearAngleDegrees(sensor.widthMm, input.focalLengthMm),
144
+ verticalDegrees: rectilinearAngleDegrees(sensor.heightMm, input.focalLengthMm),
145
+ diagonalDegrees: rectilinearAngleDegrees(sensorDiagonalMm, input.focalLengthMm)
146
+ },
147
+ basis: SENSOR_EQUIVALENCE_BASIS
148
+ }, "sensor-equivalence", "1.0.0", [
149
+ "Crop factor and equivalence are diagonal-based against the reference format (36 × 24 mm unless given).",
150
+ "Equivalent f-number concerns depth of field, background blur and total light for the same framing and output size; it is not an exposure setting.",
151
+ "Equivalent f-number is a thin-lens approximation, exact when the focus distance is much greater than the focal length.",
152
+ "Exposure f-number is the f-number on the lens: image illuminance does not depend on sensor size.",
153
+ "Ideal rectilinear lens focused at infinity; distortion, vignetting, transmission and focus breathing excluded.",
154
+ "Physical focal length and f-number are not changed; see basis for each quantity."
155
+ ]);
156
+ }
157
+ /**
158
+ * A blur circle on the sensor as a share of the frame (diagonal, width and height) and, when a
159
+ * print size is given, its diameter on that print.
160
+ *
161
+ * Equal shares of the diagonal look the same when the images are shown at the same size, which
162
+ * is how "same framing" comparisons across sensor sizes are stated. Every value is an exact ratio
163
+ * or scaling; whether a blur is visible depends on viewing distance and acuity, which this
164
+ * function does not judge.
165
+ *
166
+ * @param input Blur diameter in millimetres, sensor active area and optional print size.
167
+ * @returns Shares of the frame, and the blur on the print when a print is given.
168
+ */
169
+ export function calculateBlurShareOfFrame(input) {
170
+ requireInputKeys(input, "calculateBlurShareOfFrame", ["blurCircleMm", "sensor", "print"]);
171
+ if (typeof input.blurCircleMm !== "number" || !Number.isFinite(input.blurCircleMm) || input.blurCircleMm < 0) {
172
+ throw new InvalidScientificInputError("blurCircleMm must be a finite number greater than or equal to zero.");
173
+ }
174
+ requireImagingArea(input.sensor, "sensor");
175
+ if (input.print !== undefined)
176
+ requireImagingArea(input.print, "print");
177
+ const { widthMm, heightMm } = input.sensor;
178
+ const blurCircleMm = input.blurCircleMm;
179
+ const frameDiagonalMm = portableHypot(widthMm, heightMm);
180
+ let print;
181
+ if (input.print !== undefined) {
182
+ const sensorLong = Math.max(widthMm, heightMm), sensorShort = Math.min(widthMm, heightMm);
183
+ const printLong = Math.max(input.print.widthMm, input.print.heightMm), printShort = Math.min(input.print.widthMm, input.print.heightMm);
184
+ const enlargement = Math.min(printLong / sensorLong, printShort / sensorShort);
185
+ if (!(enlargement > 0) || !Number.isFinite(enlargement)) {
186
+ throw new InvalidScientificInputError("print enlargement must be a finite number greater than zero.");
187
+ }
188
+ print = {
189
+ enlargement,
190
+ blurOnPrintMm: blurCircleMm * enlargement,
191
+ imageLongSideMm: sensorLong * enlargement,
192
+ imageShortSideMm: sensorShort * enlargement
193
+ };
194
+ }
195
+ return calculatedResult({
196
+ blurCircleMm,
197
+ frameDiagonalMm,
198
+ shareOfDiagonal: blurCircleMm / frameDiagonalMm,
199
+ shareOfWidth: blurCircleMm / widthMm,
200
+ shareOfHeight: blurCircleMm / heightMm,
201
+ ...(print === undefined ? {} : { print })
202
+ }, "blur-share-of-frame", "1.0.0", [
203
+ "Shares are exact ratios of the blur diameter to the active frame's diagonal, width and height.",
204
+ "A print shows the whole frame enlarged to fit inside it without cropping, long side to long side.",
205
+ "Visibility of the blur depends on viewing distance and acuity; no acceptability criterion is applied."
206
+ ]);
207
+ }
208
+ //# sourceMappingURL=sensor-equivalence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sensor-equivalence.js","sourceRoot":"","sources":["../../src/camera/sensor-equivalence.ts"],"names":[],"mappings":"AAAA,sCAAsC;AAEtC;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EACL,mBAAmB,EACnB,gBAAgB,EAEjB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,2BAA2B,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAC3F,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACvE,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAC;AACpF,OAAO,EAAE,2BAA2B,EAA0B,MAAM,8BAA8B,CAAC;AAEnG,+DAA+D;AAC/D,MAAM,oBAAoB,GAAgC,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC,CAAC;AAiFvG,MAAM,wBAAwB,GAAuE,MAAM,CAAC,MAAM,CAAC;IACjH,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC;QACxB,IAAI,EAAE,OAAO;QACb,SAAS,EAAE,+EAA+E;KAC3F,CAAC;IACF,uBAAuB,EAAE,MAAM,CAAC,MAAM,CAAC;QACrC,IAAI,EAAE,OAAO;QACb,SAAS,EAAE,0GAA0G;YACnH,6EAA6E;KAChF,CAAC;IACF,iBAAiB,EAAE,MAAM,CAAC,MAAM,CAAC;QAC/B,IAAI,EAAE,eAAe;QACrB,SAAS,EAAE,0HAA0H;YACnI,yIAAyI;YACzI,4IAA4I;YAC5I,8CAA8C;YAC9C,0FAA0F;KAC7F,CAAC;IACF,eAAe,EAAE,MAAM,CAAC,MAAM,CAAC;QAC7B,IAAI,EAAE,OAAO;QACb,SAAS,EAAE,kHAAkH;YAC3H,uFAAuF;KAC1F,CAAC;IACF,uBAAuB,EAAE,MAAM,CAAC,MAAM,CAAC;QACrC,IAAI,EAAE,OAAO;QACb,SAAS,EAAE,uIAAuI;KACnJ,CAAC;IACF,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC;QACvB,IAAI,EAAE,OAAO;QACb,SAAS,EAAE,6EAA6E;KACzF,CAAC;IACF,yBAAyB,EAAE,MAAM,CAAC,MAAM,CAAC;QACvC,IAAI,EAAE,OAAO;QACb,SAAS,EAAE,gIAAgI;YACzI,uDAAuD;KAC1D,CAAC;IACF,WAAW,EAAE,MAAM,CAAC,MAAM,CAAC;QACzB,IAAI,EAAE,OAAO;QACb,SAAS,EAAE,oHAAoH;KAChI,CAAC;CACH,CAAC,CAAC;AAEH,SAAS,kBAAkB,CAAC,IAAa,EAAE,KAAa;IACtD,kBAAkB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAChC,gBAAgB,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC;IACvD,MAAM,MAAM,GAAG,IAAyB,CAAC;IACzC,qBAAqB,CAAC,GAAG,KAAK,UAAU,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;IAC1D,qBAAqB,CAAC,GAAG,KAAK,WAAW,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;AAC9D,CAAC;AAED,0GAA0G;AAC1G,SAAS,aAAa,CAAC,SAA4B,EAAE,MAAyB;IAC5E,MAAM,gBAAgB,GAAG,SAAS,CAAC,OAAO,GAAG,SAAS,CAAC,OAAO,GAAG,SAAS,CAAC,QAAQ,GAAG,SAAS,CAAC,QAAQ,CAAC;IACzG,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,GAAG,MAAM,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;IAC1F,MAAM,KAAK,GAAG,gBAAgB,GAAG,aAAa,CAAC;IAC/C,OAAO,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAC,IAAI,aAAa,GAAG,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QACpI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC;QAClB,CAAC,CAAC,aAAa,CAAC,SAAS,CAAC,OAAO,EAAE,SAAS,CAAC,QAAQ,CAAC,GAAG,aAAa,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;AAC5G,CAAC;AAED,SAAS,uBAAuB,CAAC,WAAmB,EAAE,aAAqB;IACzE,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC;AAC5E,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,0BAA0B,CACxC,KAAsC;IAEtC,gBAAgB,CAAC,KAAK,EAAE,4BAA4B,EAAE,CAAC,QAAQ,EAAE,eAAe,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC,CAAC;IAC3G,kBAAkB,CAAC,KAAK,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAC3C,qBAAqB,CAAC,eAAe,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC;IAC5D,qBAAqB,CAAC,SAAS,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;IAChD,IAAI,KAAK,CAAC,SAAS,KAAK,SAAS;QAAE,kBAAkB,CAAC,KAAK,CAAC,SAAS,EAAE,WAAW,CAAC,CAAC;IAEpF,MAAM,MAAM,GAAsB,EAAE,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;IACrG,MAAM,SAAS,GAAsB,KAAK,CAAC,SAAS,KAAK,SAAS;QAChE,CAAC,CAAC,EAAE,GAAG,oBAAoB,EAAE;QAC7B,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC;IAC7E,0FAA0F;IAC1F,sEAAsE;IACtE,MAAM,UAAU,GAAG,KAAK,CAAC,SAAS,KAAK,SAAS;QAC9C,CAAC,CAAC,2BAA2B,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,cAAc;QAC1D,CAAC,CAAC,aAAa,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IACrC,MAAM,SAAS,GAAG,CAAC,MAAM,CAAC,OAAO,GAAG,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAC;IAChG,MAAM,gBAAgB,GAAG,aAAa,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;IACxE,IAAI,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;QACpD,MAAM,IAAI,2BAA2B,CAAC,8FAA8F,CAAC,CAAC;IACxI,CAAC;IAED,OAAO,mBAAmB,CACxB;QACE,aAAa,EAAE,KAAK,CAAC,aAAa;QAClC,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,MAAM;QACN,SAAS;QACT,gBAAgB;QAChB,mBAAmB,EAAE,aAAa,CAAC,SAAS,CAAC,OAAO,EAAE,SAAS,CAAC,QAAQ,CAAC;QACzE,UAAU;QACV,uBAAuB,EAAE,KAAK,CAAC,aAAa,GAAG,UAAU;QACzD,iBAAiB,EAAE,KAAK,CAAC,OAAO,GAAG,UAAU;QAC7C,eAAe,EAAE,KAAK,CAAC,OAAO;QAC9B,uBAAuB,EAAE,KAAK,CAAC,aAAa,GAAG,KAAK,CAAC,OAAO;QAC5D,SAAS;QACT,yBAAyB,EAAE,YAAY,CAAC,SAAS,CAAC;QAClD,WAAW,EAAE;YACX,iBAAiB,EAAE,uBAAuB,CAAC,MAAM,CAAC,OAAO,EAAE,KAAK,CAAC,aAAa,CAAC;YAC/E,eAAe,EAAE,uBAAuB,CAAC,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,aAAa,CAAC;YAC9E,eAAe,EAAE,uBAAuB,CAAC,gBAAgB,EAAE,KAAK,CAAC,aAAa,CAAC;SAChF;QACD,KAAK,EAAE,wBAAwB;KAChC,EACD,oBAAoB,EACpB,OAAO,EACP;QACE,wGAAwG;QACxG,mJAAmJ;QACnJ,wHAAwH;QACxH,kGAAkG;QAClG,gHAAgH;QAChH,kFAAkF;KACnF,CACF,CAAC;AACJ,CAAC;AAuCD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,yBAAyB,CACvC,KAAqC;IAErC,gBAAgB,CAAC,KAAK,EAAE,2BAA2B,EAAE,CAAC,cAAc,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC;IAC1F,IAAI,OAAO,KAAK,CAAC,YAAY,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,KAAK,CAAC,YAAY,GAAG,CAAC,EAAE,CAAC;QAC7G,MAAM,IAAI,2BAA2B,CAAC,qEAAqE,CAAC,CAAC;IAC/G,CAAC;IACD,kBAAkB,CAAC,KAAK,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAC3C,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS;QAAE,kBAAkB,CAAC,KAAK,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;IAExE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC;IAC3C,MAAM,YAAY,GAAG,KAAK,CAAC,YAAY,CAAC;IACxC,MAAM,eAAe,GAAG,aAAa,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IACzD,IAAI,KAAmC,CAAC;IACxC,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;QAC9B,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,QAAQ,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;QAC1F,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QACxI,MAAM,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,GAAG,UAAU,EAAE,UAAU,GAAG,WAAW,CAAC,CAAC;QAC/E,IAAI,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC;YACxD,MAAM,IAAI,2BAA2B,CAAC,8DAA8D,CAAC,CAAC;QACxG,CAAC;QACD,KAAK,GAAG;YACN,WAAW;YACX,aAAa,EAAE,YAAY,GAAG,WAAW;YACzC,eAAe,EAAE,UAAU,GAAG,WAAW;YACzC,gBAAgB,EAAE,WAAW,GAAG,WAAW;SAC5C,CAAC;IACJ,CAAC;IAED,OAAO,gBAAgB,CACrB;QACE,YAAY;QACZ,eAAe;QACf,eAAe,EAAE,YAAY,GAAG,eAAe;QAC/C,YAAY,EAAE,YAAY,GAAG,OAAO;QACpC,aAAa,EAAE,YAAY,GAAG,QAAQ;QACtC,GAAG,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC;KAC1C,EACD,qBAAqB,EACrB,OAAO,EACP;QACE,gGAAgG;QAChG,mGAAmG;QACnG,uGAAuG;KACxG,CACF,CAAC;AACJ,CAAC","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Format equivalence for teaching: what a lens of focal length f at f-number N on one sensor\n * corresponds to on a reference format (36 × 24 mm by default), and how large a blur circle is\n * as a share of the frame or of a print.\n *\n * With the diagonal crop factor k = referenceDiagonal / sensorDiagonal:\n * - equivalent focal length k·f (same diagonal angle of view; exact by the diagonal convention);\n * - equivalent f-number k·N for depth of field, background blur and total light (same framing,\n * same output size). It keeps the entrance-pupil diameter f/N unchanged. It is an\n * approximation: in the thin-lens defocus-circle equation it is exact only when the focus\n * distance is much greater than the focal length;\n * - the f-number for exposure stays N: image illuminance depends on N, not on the sensor size;\n * - sensor area ratio and the total-light difference in stops at the same f-number and shutter.\n *\n * Sources (free): thin-lens defocus-circle diameter f²|s − d|/(N d (s − f)) and f-number N = f/D,\n * Greivenkamp, OPTI-502 course notes, sec. 12 (Objectives),\n * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-12-Objectives.pdf;\n * image illuminance proportional to 1/N², sec. 11 (Radiative transfer),\n * https://wp.optics.arizona.edu/jgreivenkamp/wp-content/uploads/sites/11/2019/08/502-11-Radiative-Transfer.pdf.\n * @see docs/PHYSICS_FOUNDATION.md \"Sensor equivalence\" for the derivation, the exact and\n * approximated quantities, and support limits.\n */\n\nimport {\n approximationResult,\n calculatedResult,\n type CalculationResult\n} from \"../core/calculation-result.js\";\nimport { InvalidScientificInputError, requirePositiveFinite } from \"../core/validation.js\";\nimport { portableHypot, portableLog2 } from \"../core/portable-math.js\";\nimport { requireInputKeys, requireInputObject } from \"../core/record-validation.js\";\nimport { calculateImagingAreaMetrics, type SensorImagingArea } from \"../sensor/sensor-geometry.js\";\n\n/** The default equivalence reference: the 36 × 24 mm frame. */\nconst REFERENCE_FULL_FRAME: Readonly<SensorImagingArea> = Object.freeze({ widthMm: 36, heightMm: 24 });\n\n/** Whether a value is exact under its stated conditions or an approximation. */\nexport type EquivalenceExactness = \"exact\" | \"approximation\";\n\n/** How one equivalence quantity was obtained, and under which conditions it holds. */\nexport interface EquivalenceValueBasis {\n /** `\"exact\"` under the stated conditions, or `\"approximation\"`. */\n kind: EquivalenceExactness;\n /** Plain statement of the definition and its conditions. */\n statement: string;\n}\n\nexport interface CalculateSensorEquivalenceInput {\n /** Physical active imaging area of the sensor the lens is used on. */\n sensor: SensorImagingArea;\n /** Physical focal length of the lens in millimetres. */\n focalLengthMm: number;\n /** f-number set on the lens (N = focal length / entrance-pupil diameter). */\n fNumber: number;\n /** Format to compare against. Defaults to the 36 × 24 mm frame. */\n reference?: SensorImagingArea;\n}\n\n/** Angles of view of an ideal rectilinear lens focused at infinity, in degrees. */\nexport interface SensorEquivalenceFieldOfView {\n horizontalDegrees: number;\n verticalDegrees: number;\n diagonalDegrees: number;\n}\n\n/** Quantities of a sensor equivalence result that carry an exactness statement. */\nexport type SensorEquivalenceQuantity =\n | \"cropFactor\"\n | \"equivalentFocalLengthMm\"\n | \"equivalentFNumber\"\n | \"exposureFNumber\"\n | \"entrancePupilDiameterMm\"\n | \"areaRatio\"\n | \"totalLightDifferenceStops\"\n | \"fieldOfView\";\n\nexport interface SensorEquivalence {\n /** Physical focal length supplied by the caller, in millimetres. */\n focalLengthMm: number;\n /** f-number supplied by the caller. */\n fNumber: number;\n /** Sensor active imaging area used. */\n sensor: SensorImagingArea;\n /** Reference format used (36 × 24 mm unless the caller passed one). */\n reference: SensorImagingArea;\n /** Sensor diagonal in millimetres. */\n sensorDiagonalMm: number;\n /** Reference diagonal in millimetres. */\n referenceDiagonalMm: number;\n /** Diagonal crop factor: reference diagonal / sensor diagonal. Greater than 1 for a smaller sensor. */\n cropFactor: number;\n /** Focal length on the reference format with the same diagonal angle of view: cropFactor × focalLengthMm. */\n equivalentFocalLengthMm: number;\n /**\n * f-number on the reference format that gives the same depth of field, background blur and total\n * light for the same framing and output size: cropFactor × fNumber. Not an exposure setting.\n */\n equivalentFNumber: number;\n /** f-number that sets exposure (image brightness): the f-number on the lens, unchanged by sensor size. */\n exposureFNumber: number;\n /** Entrance-pupil diameter f/N in millimetres; the equivalent lens has the same diameter. */\n entrancePupilDiameterMm: number;\n /** Sensor area / reference area. Less than 1 for a smaller sensor. */\n areaRatio: number;\n /**\n * log2(areaRatio): total light collected by the sensor relative to the reference at the same\n * f-number, shutter time and scene, in stops. Negative for a smaller sensor.\n */\n totalLightDifferenceStops: number;\n /** Angles of view of this lens on the sensor (the equivalent lens gives the same diagonal angle). */\n fieldOfView: SensorEquivalenceFieldOfView;\n /** Exact or approximated, with the conditions, for every quantity above. */\n basis: Readonly<Record<SensorEquivalenceQuantity, EquivalenceValueBasis>>;\n}\n\nconst SENSOR_EQUIVALENCE_BASIS: Readonly<Record<SensorEquivalenceQuantity, EquivalenceValueBasis>> = Object.freeze({\n cropFactor: Object.freeze({\n kind: \"exact\",\n statement: \"Ratio of the reference diagonal to the sensor diagonal (diagonal convention).\"\n }),\n equivalentFocalLengthMm: Object.freeze({\n kind: \"exact\",\n statement: \"Same diagonal angle of view on the reference format, for an ideal rectilinear lens focused at infinity. \" +\n \"Horizontal and vertical angles match too only when the aspect ratios match.\"\n }),\n equivalentFNumber: Object.freeze({\n kind: \"approximation\",\n statement: \"Same depth of field and background blur as a share of the frame, at the same subject distance, framing and output size, \" +\n \"and the same entrance-pupil diameter (so the same total light at the same shutter time). In the thin-lens defocus equation it is exact \" +\n \"only when the focus distance s is much greater than the focal length f; with focal lengths in the ratio cropFactor, the blur shares match \" +\n \"at cropFactor·N·(s − f)/(s − cropFactor·f). \" +\n \"Total light matches exactly only when the aspect ratios match. It does not set exposure.\"\n }),\n exposureFNumber: Object.freeze({\n kind: \"exact\",\n statement: \"Image illuminance is proportional to 1/N², independent of sensor size (ideal lens; transmission, vignetting and \" +\n \"close-focus bellows factor excluded). Equivalence never changes the exposure setting.\"\n }),\n entrancePupilDiameterMm: Object.freeze({\n kind: \"exact\",\n statement: \"Entrance-pupil diameter D = f/N by the definition of the f-number. The equivalent lens (cropFactor·f at cropFactor·N) has the same D.\"\n }),\n areaRatio: Object.freeze({\n kind: \"exact\",\n statement: \"Product of width and height of the sensor divided by that of the reference.\"\n }),\n totalLightDifferenceStops: Object.freeze({\n kind: \"exact\",\n statement: \"log2 of the area ratio: at the same f-number, shutter time and scene the image illuminance is the same, so total light scales \" +\n \"with area (uniform illuminance; vignetting excluded).\"\n }),\n fieldOfView: Object.freeze({\n kind: \"exact\",\n statement: \"2·atan(dimension / 2f) for an ideal rectilinear lens focused at infinity; distortion and focus breathing excluded.\"\n })\n});\n\nfunction requireImagingArea(area: unknown, label: string): asserts area is SensorImagingArea {\n requireInputObject(area, label);\n requireInputKeys(area, label, [\"widthMm\", \"heightMm\"]);\n const record = area as SensorImagingArea;\n requirePositiveFinite(`${label}.widthMm`, record.widthMm);\n requirePositiveFinite(`${label}.heightMm`, record.heightMm);\n}\n\n/** √((rw² + rh²)/(w² + h²)), with only correctly rounded operations when nothing overflows (portable). */\nfunction diagonalRatio(reference: SensorImagingArea, sensor: SensorImagingArea): number {\n const referenceSquared = reference.widthMm * reference.widthMm + reference.heightMm * reference.heightMm;\n const sensorSquared = sensor.widthMm * sensor.widthMm + sensor.heightMm * sensor.heightMm;\n const ratio = referenceSquared / sensorSquared;\n return Number.isFinite(referenceSquared) && Number.isFinite(sensorSquared) && sensorSquared > 0 && ratio > 0 && Number.isFinite(ratio)\n ? Math.sqrt(ratio)\n : portableHypot(reference.widthMm, reference.heightMm) / portableHypot(sensor.widthMm, sensor.heightMm);\n}\n\nfunction rectilinearAngleDegrees(dimensionMm: number, focalLengthMm: number): number {\n return (2 * Math.atan(dimensionMm / (2 * focalLengthMm)) * 180) / Math.PI;\n}\n\n/**\n * Equivalence of a lens (focal length, f-number) on one sensor against a reference format,\n * 36 × 24 mm by default: crop factor, equivalent focal length, equivalent f-number for depth of\n * field, blur and total light, the unchanged f-number for exposure, sensor area ratio, total-light\n * difference in stops and the angles of view.\n *\n * Call it once per sensor for a lens table. Every quantity carries a `basis` entry saying whether it\n * is exact under its stated conditions or an approximation. The equivalent f-number is an\n * approximation (exact when the focus distance is much greater than the focal length) and never an\n * exposure setting: exposure uses `exposureFNumber`, the f-number on the lens.\n *\n * Example: 50 mm f/2 on a 23.5 × 15.6 mm sensor gives crop factor ≈ 1.53, ≈ 77 mm and ≈ f/3.1.\n *\n * @param input Sensor active area, focal length, f-number and optional reference format.\n * @returns The equivalence numbers with a per-quantity exactness statement.\n */\nexport function calculateSensorEquivalence(\n input: CalculateSensorEquivalenceInput\n): CalculationResult<SensorEquivalence> {\n requireInputKeys(input, \"calculateSensorEquivalence\", [\"sensor\", \"focalLengthMm\", \"fNumber\", \"reference\"]);\n requireImagingArea(input.sensor, \"sensor\");\n requirePositiveFinite(\"focalLengthMm\", input.focalLengthMm);\n requirePositiveFinite(\"fNumber\", input.fNumber);\n if (input.reference !== undefined) requireImagingArea(input.reference, \"reference\");\n\n const sensor: SensorImagingArea = { widthMm: input.sensor.widthMm, heightMm: input.sensor.heightMm };\n const reference: SensorImagingArea = input.reference === undefined\n ? { ...REFERENCE_FULL_FRAME }\n : { widthMm: input.reference.widthMm, heightMm: input.reference.heightMm };\n // The default reference reuses the canonical 35 mm crop factor, so it is bit-identical to\n // calculateEquivalentFocalLength35Mm and calculateImagingAreaMetrics.\n const cropFactor = input.reference === undefined\n ? calculateImagingAreaMetrics(sensor).value.cropFactor35Mm\n : diagonalRatio(reference, sensor);\n const areaRatio = (sensor.widthMm / reference.widthMm) * (sensor.heightMm / reference.heightMm);\n const sensorDiagonalMm = portableHypot(sensor.widthMm, sensor.heightMm);\n if (!(areaRatio > 0) || !Number.isFinite(areaRatio)) {\n throw new InvalidScientificInputError(\"sensor and reference areas must differ by a ratio that is a finite number greater than zero.\");\n }\n\n return approximationResult(\n {\n focalLengthMm: input.focalLengthMm,\n fNumber: input.fNumber,\n sensor,\n reference,\n sensorDiagonalMm,\n referenceDiagonalMm: portableHypot(reference.widthMm, reference.heightMm),\n cropFactor,\n equivalentFocalLengthMm: input.focalLengthMm * cropFactor,\n equivalentFNumber: input.fNumber * cropFactor,\n exposureFNumber: input.fNumber,\n entrancePupilDiameterMm: input.focalLengthMm / input.fNumber,\n areaRatio,\n totalLightDifferenceStops: portableLog2(areaRatio),\n fieldOfView: {\n horizontalDegrees: rectilinearAngleDegrees(sensor.widthMm, input.focalLengthMm),\n verticalDegrees: rectilinearAngleDegrees(sensor.heightMm, input.focalLengthMm),\n diagonalDegrees: rectilinearAngleDegrees(sensorDiagonalMm, input.focalLengthMm)\n },\n basis: SENSOR_EQUIVALENCE_BASIS\n },\n \"sensor-equivalence\",\n \"1.0.0\",\n [\n \"Crop factor and equivalence are diagonal-based against the reference format (36 × 24 mm unless given).\",\n \"Equivalent f-number concerns depth of field, background blur and total light for the same framing and output size; it is not an exposure setting.\",\n \"Equivalent f-number is a thin-lens approximation, exact when the focus distance is much greater than the focal length.\",\n \"Exposure f-number is the f-number on the lens: image illuminance does not depend on sensor size.\",\n \"Ideal rectilinear lens focused at infinity; distortion, vignetting, transmission and focus breathing excluded.\",\n \"Physical focal length and f-number are not changed; see basis for each quantity.\"\n ]\n );\n}\n\nexport interface CalculateBlurShareOfFrameInput {\n /** Blur-circle diameter on the sensor in millimetres (for example from calculateDefocusCircle). Zero is allowed. */\n blurCircleMm: number;\n /** Physical active imaging area of the sensor. */\n sensor: SensorImagingArea;\n /**\n * Optional print size. The whole frame is enlarged to fit inside it without cropping, long side\n * to long side.\n */\n print?: { widthMm: number; heightMm: number };\n}\n\nexport interface BlurShareOfPrint {\n /** Linear enlargement from sensor to print: the smaller of the long-side and short-side ratios. */\n enlargement: number;\n /** Blur-circle diameter on the print in millimetres: blurCircleMm × enlargement. */\n blurOnPrintMm: number;\n /** Size of the printed image in millimetres (equal to the print when the aspect ratios match). */\n imageLongSideMm: number;\n imageShortSideMm: number;\n}\n\nexport interface BlurShareOfFrame {\n /** Blur-circle diameter on the sensor in millimetres. */\n blurCircleMm: number;\n /** Sensor diagonal in millimetres. */\n frameDiagonalMm: number;\n /** Blur diameter / frame diagonal. Equal shares look the same at the same output size. */\n shareOfDiagonal: number;\n /** Blur diameter / frame width. */\n shareOfWidth: number;\n /** Blur diameter / frame height. */\n shareOfHeight: number;\n /** Blur on the print; present only when a print size was given. */\n print?: BlurShareOfPrint;\n}\n\n/**\n * A blur circle on the sensor as a share of the frame (diagonal, width and height) and, when a\n * print size is given, its diameter on that print.\n *\n * Equal shares of the diagonal look the same when the images are shown at the same size, which\n * is how \"same framing\" comparisons across sensor sizes are stated. Every value is an exact ratio\n * or scaling; whether a blur is visible depends on viewing distance and acuity, which this\n * function does not judge.\n *\n * @param input Blur diameter in millimetres, sensor active area and optional print size.\n * @returns Shares of the frame, and the blur on the print when a print is given.\n */\nexport function calculateBlurShareOfFrame(\n input: CalculateBlurShareOfFrameInput\n): CalculationResult<BlurShareOfFrame> {\n requireInputKeys(input, \"calculateBlurShareOfFrame\", [\"blurCircleMm\", \"sensor\", \"print\"]);\n if (typeof input.blurCircleMm !== \"number\" || !Number.isFinite(input.blurCircleMm) || input.blurCircleMm < 0) {\n throw new InvalidScientificInputError(\"blurCircleMm must be a finite number greater than or equal to zero.\");\n }\n requireImagingArea(input.sensor, \"sensor\");\n if (input.print !== undefined) requireImagingArea(input.print, \"print\");\n\n const { widthMm, heightMm } = input.sensor;\n const blurCircleMm = input.blurCircleMm;\n const frameDiagonalMm = portableHypot(widthMm, heightMm);\n let print: BlurShareOfPrint | undefined;\n if (input.print !== undefined) {\n const sensorLong = Math.max(widthMm, heightMm), sensorShort = Math.min(widthMm, heightMm);\n const printLong = Math.max(input.print.widthMm, input.print.heightMm), printShort = Math.min(input.print.widthMm, input.print.heightMm);\n const enlargement = Math.min(printLong / sensorLong, printShort / sensorShort);\n if (!(enlargement > 0) || !Number.isFinite(enlargement)) {\n throw new InvalidScientificInputError(\"print enlargement must be a finite number greater than zero.\");\n }\n print = {\n enlargement,\n blurOnPrintMm: blurCircleMm * enlargement,\n imageLongSideMm: sensorLong * enlargement,\n imageShortSideMm: sensorShort * enlargement\n };\n }\n\n return calculatedResult(\n {\n blurCircleMm,\n frameDiagonalMm,\n shareOfDiagonal: blurCircleMm / frameDiagonalMm,\n shareOfWidth: blurCircleMm / widthMm,\n shareOfHeight: blurCircleMm / heightMm,\n ...(print === undefined ? {} : { print })\n },\n \"blur-share-of-frame\",\n \"1.0.0\",\n [\n \"Shares are exact ratios of the blur diameter to the active frame's diagonal, width and height.\",\n \"A print shows the whole frame enlarged to fit inside it without cropping, long side to long side.\",\n \"Visibility of the blur depends on viewing distance and acuity; no acceptability criterion is applied.\"\n ]\n );\n}\n"]}
@@ -60,6 +60,11 @@ export interface TabulatedNativeRawInput {
60
60
  /** Called once per native tile-equivalent (progress), as the environment task reads its tiles. */
61
61
  onTile?: () => void;
62
62
  tileWidth: number;
63
+ /**
64
+ * #568: the largest relative difference the verification accepts (default `TABULATED_RELATIVE_TOLERANCE`): a fast method that
65
+ * approximates the chain (`lens.catalogue`'s field-grid PSF) passes its own measured bound.
66
+ */
67
+ relativeTolerance?: number;
63
68
  yieldControl(): Promise<void>;
64
69
  checkAbort(): void;
65
70
  }
@@ -78,7 +78,7 @@ export async function renderTabulatedNativeRaw(t) {
78
78
  t.electrons.window(x, y, x + 1, y + 1);
79
79
  const chain = photo.expectedGeneratedElectronCount, tabulated = testHook?.tabulatedElectrons ? testHook.tabulatedElectrons(t.electrons.site(x, y), x, y) : t.electrons.site(x, y);
80
80
  const difference = chain === tabulated ? 0 : Math.abs(tabulated - chain) / Math.max(Math.abs(chain), Number.MIN_VALUE);
81
- if (!(difference <= TABULATED_RELATIVE_TOLERANCE))
81
+ if (!(difference <= (t.relativeTolerance ?? TABULATED_RELATIVE_TOLERANCE)))
82
82
  return undefined;
83
83
  worst = Math.max(worst, difference);
84
84
  readouts[(y & 1) * 2 + (x & 1)] = readout;