@photivra/engine 2.5.0 → 2.6.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 (84) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/THIRD_PARTY.md +1 -0
  6. package/dist/capture/native-environment-lean.d.ts +1 -1
  7. package/dist/capture/native-environment-lean.js +17 -3
  8. package/dist/capture/native-environment-lean.js.map +1 -1
  9. package/dist/capture/native-environment-raw.d.ts +0 -4
  10. package/dist/capture/native-environment-raw.js +6 -2
  11. package/dist/capture/native-environment-raw.js.map +1 -1
  12. package/dist/color/illuminants.d.ts +15 -0
  13. package/dist/color/illuminants.js +81 -24
  14. package/dist/color/illuminants.js.map +1 -1
  15. package/dist/color/srgb-transfer.d.ts +6 -0
  16. package/dist/color/srgb-transfer.js +9 -0
  17. package/dist/color/srgb-transfer.js.map +1 -1
  18. package/dist/color/white-balance.d.ts +6 -0
  19. package/dist/color/white-balance.js +15 -2
  20. package/dist/color/white-balance.js.map +1 -1
  21. package/dist/core/internal-radiance-evaluator.d.ts +6 -0
  22. package/dist/core/internal-radiance-evaluator.js +33 -0
  23. package/dist/core/internal-radiance-evaluator.js.map +1 -0
  24. package/dist/core/portable-math.d.ts +33 -0
  25. package/dist/core/portable-math.js +342 -0
  26. package/dist/core/portable-math.js.map +1 -1
  27. package/dist/core/version.d.ts +1 -1
  28. package/dist/core/version.js +1 -1
  29. package/dist/core/version.js.map +1 -1
  30. package/dist/facade/chart-scenes.d.ts +9 -0
  31. package/dist/facade/chart-scenes.js +17 -4
  32. package/dist/facade/chart-scenes.js.map +1 -1
  33. package/dist/facade/diffraction-provider.d.ts +10 -0
  34. package/dist/facade/diffraction-provider.js +38 -17
  35. package/dist/facade/diffraction-provider.js.map +1 -1
  36. package/dist/facade/display-view.d.ts +208 -0
  37. package/dist/facade/display-view.js +1461 -0
  38. package/dist/facade/display-view.js.map +1 -0
  39. package/dist/facade/scene-radiance.d.ts +7 -0
  40. package/dist/facade/scene-radiance.js +18 -5
  41. package/dist/facade/scene-radiance.js.map +1 -1
  42. package/dist/facade/simulate-capture.d.ts +224 -4
  43. package/dist/facade/simulate-capture.js +104 -33
  44. package/dist/facade/simulate-capture.js.map +1 -1
  45. package/dist/facade/simulate-display-view.d.ts +21 -0
  46. package/dist/facade/simulate-display-view.js +168 -0
  47. package/dist/facade/simulate-display-view.js.map +1 -0
  48. package/dist/facade/subject-framing.d.ts +16 -0
  49. package/dist/facade/subject-framing.js +8 -2
  50. package/dist/facade/subject-framing.js.map +1 -1
  51. package/dist/index.d.ts +2 -1
  52. package/dist/index.js +2 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/optics/diffraction-psf-profile.d.ts +9 -2
  55. package/dist/optics/diffraction-psf-profile.js +33 -4
  56. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  57. package/dist/optics/irradiance-convolution.js +1 -1
  58. package/dist/optics/irradiance-convolution.js.map +1 -1
  59. package/dist/optics/scene-to-sensor-irradiance.d.ts +7 -0
  60. package/dist/optics/scene-to-sensor-irradiance.js +10 -5
  61. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  62. package/dist/output/print-plan.d.ts +8 -1
  63. package/dist/output/print-plan.js +8 -1
  64. package/dist/output/print-plan.js.map +1 -1
  65. package/dist/schema/scene-radiance.d.ts +16 -0
  66. package/dist/schema/scene-radiance.js +25 -4
  67. package/dist/schema/scene-radiance.js.map +1 -1
  68. package/dist/sensor/raw-readout.d.ts +12 -0
  69. package/dist/sensor/raw-readout.js +131 -9
  70. package/dist/sensor/raw-readout.js.map +1 -1
  71. package/dist/sensor/stochastic-seed.d.ts +13 -0
  72. package/dist/sensor/stochastic-seed.js +42 -8
  73. package/dist/sensor/stochastic-seed.js.map +1 -1
  74. package/docs/AGENT_GUIDE.md +1 -0
  75. package/docs/API_REFERENCE.md +13 -1
  76. package/docs/QUICK_START.md +155 -4
  77. package/docs/USAGE.md +15 -0
  78. package/docs/api/core-version.md +1 -1
  79. package/docs/api/exports.json +22 -2
  80. package/docs/api/facade-simulate-capture.md +65 -1
  81. package/docs/api/facade-simulate-display-view.md +18 -0
  82. package/docs/api/facade-subject-framing.md +16 -0
  83. package/docs/api/output-print-plan.md +8 -1
  84. package/package.json +3 -2
@@ -16,6 +16,7 @@
16
16
  * correlation tests, not this hash alone, show that the resulting noise is statistically acceptable.
17
17
  */
18
18
  import { InvalidScientificInputError } from "../core/validation.js";
19
+ import { portableCosTwoPi, portableLog, portableSinTwoPi } from "../core/portable-math.js";
19
20
  /** Stream IDs. Changing them changes every simulated RAW code. */
20
21
  export const NATIVE_SITE_CHARGE_STREAM_ID = 0;
21
22
  export const NATIVE_SITE_READ_NOISE_STREAM_ID = 1;
@@ -69,17 +70,21 @@ export function deriveNativeSiteStreamSeedUint32(captureSeedUint32, nativeSiteIn
69
70
  * Changing any of this changes simulated RAW output and needs a model and replay review.
70
71
  */
71
72
  export function createDeterministicUniformStream(seedUint32) {
72
- let state = fmix32((seedUint32 ^ 0x9e3779b9) >>> 0);
73
- if (state === 0) {
74
- state = 0x6d2b79f5;
73
+ // The state lives in a Uint32Array: storing the int32 result there is the same `>>> 0` reduction, and a uint32 above
74
+ // 2^30 kept in a closure variable would be boxed on every step (half the speed). The draws are unchanged.
75
+ const state = new Uint32Array(1);
76
+ state[0] = fmix32((seedUint32 ^ 0x9e3779b9) >>> 0);
77
+ if (state[0] === 0) {
78
+ state[0] = 0x6d2b79f5;
75
79
  }
76
80
  return {
77
81
  next() {
78
- state ^= state << 13;
79
- state ^= state >>> 17;
80
- state ^= state << 5;
81
- state >>>= 0;
82
- return (state + 0.5) / 0x1_0000_0000;
82
+ let x = state[0];
83
+ x ^= x << 13;
84
+ x ^= x >>> 17;
85
+ x ^= x << 5;
86
+ state[0] = x;
87
+ return (state[0] + 0.5) / 0x1_0000_0000;
83
88
  }
84
89
  };
85
90
  }
@@ -93,6 +98,35 @@ export function sampleDeterministicStandardNormal(random) {
93
98
  const u2 = random.next();
94
99
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
95
100
  }
101
+ /**
102
+ * The same Box–Muller draw (same two uniforms, same order) with the engine's portable logarithm and cosine
103
+ * (`portableLog`, `portableCosTwoPi`), so the sample has the same bits in every JavaScript engine. Used by the
104
+ * display view; the native noise keeps `sampleDeterministicStandardNormal`.
105
+ */
106
+ export function samplePortableStandardNormal(random) {
107
+ const u1 = random.next();
108
+ const u2 = random.next();
109
+ return Math.sqrt(-2 * portableLog(u1)) * portableCosTwoPi(u2);
110
+ }
111
+ /**
112
+ * Standard normal samples from one stream, two per pair of uniforms: both Box–Muller outputs, r·cos(2πu₂) and then
113
+ * r·sin(2πu₂) with r = √(−2 ln u₁) (Box & Muller 1958; the two are independent standard normals), with the portable
114
+ * functions. Each call returns the next sample; the stream advances by two uniforms on every other call. Used by the
115
+ * display view's AHD correction states, which draw many normals from one stream.
116
+ */
117
+ export function createPortableStandardNormalPairs(random) {
118
+ let spare = 0, hasSpare = false;
119
+ return () => {
120
+ if (hasSpare) {
121
+ hasSpare = false;
122
+ return spare;
123
+ }
124
+ const u1 = random.next(), u2 = random.next(), r = Math.sqrt(-2 * portableLog(u1));
125
+ spare = r * portableSinTwoPi(u2);
126
+ hasSpare = true;
127
+ return r * portableCosTwoPi(u2);
128
+ };
129
+ }
96
130
  /**
97
131
  * Fixed-pattern stream IDs. They seed spatial patterns that belong to one physical sensor, never
98
132
  * per-capture noise, and are hashed with the sensor-instance seed instead of the capture seed.
@@ -1 +1 @@
1
- {"version":3,"file":"stochastic-seed.js","sourceRoot":"","sources":["../../src/sensor/stochastic-seed.ts"],"names":[],"mappings":"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","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Seeds for the random-number streams behind simulated sensor noise. The same inputs always give\n * the same noise.\n *\n * Each per-site stream seed is a strong 32-bit hash of the capture seed, the absolute native\n * row-major site index and a fixed stream ID. The hash is MurmurHash3_x86_32 (Austin Appleby,\n * placed in the public domain) of the 12-byte little-endian message `[captureSeed, siteIndex,\n * streamId]` with hash seed 0. Its avalanche finalizer (`fmix32`) makes a one-bit input change flip\n * each output bit with probability close to 1/2. So neighbouring sites, the two streams of one site\n * and captures with nearby seeds get unrelated seeds. A linear schedule such as `seed + 2*index` fed\n * into a linear generator (xorshift) lacks this property, and produced correlated noise with too\n * little variance (issue #270).\n *\n * This is deterministic and not cryptographic; it does not claim unpredictability. The moment and\n * correlation tests, not this hash alone, show that the resulting noise is statistically acceptable.\n */\n\nimport { InvalidScientificInputError } from \"../core/validation.js\";\n\n/** Stream IDs. Changing them changes every simulated RAW code. */\nexport const NATIVE_SITE_CHARGE_STREAM_ID = 0 as const;\nexport const NATIVE_SITE_READ_NOISE_STREAM_ID = 1 as const;\n\nconst MURMUR3_C1 = 0xcc9e2d51;\nconst MURMUR3_C2 = 0x1b873593;\n\n/** MurmurHash3 32-bit avalanche finalizer; a bijection on uint32 with fmix32(0) = 0. */\nexport function fmix32(value: number): number {\n let h = value >>> 0;\n h ^= h >>> 16;\n h = Math.imul(h, 0x85ebca6b);\n h ^= h >>> 13;\n h = Math.imul(h, 0xc2b2ae35);\n h ^= h >>> 16;\n return h >>> 0;\n}\n\nfunction mixBlock(hash: number, block: number): number {\n let k = Math.imul(block >>> 0, MURMUR3_C1);\n k = (k << 15) | (k >>> 17);\n k = Math.imul(k, MURMUR3_C2);\n let h = (hash ^ k) >>> 0;\n h = (h << 13) | (h >>> 19);\n return (Math.imul(h, 5) + 0xe6546b64) >>> 0;\n}\n\nfunction isUint32(value: number): boolean {\n return Number.isInteger(value) && value >= 0 && value <= 0xffffffff;\n}\n\n/**\n * Returns the uint32 seed of one random stream for one absolute native site.\n * All inputs must be uint32 integers. The site index is the full-frame native row-major\n * index, never relative to a tile or chunk, so how the work is split cannot change it.\n */\nexport function deriveNativeSiteStreamSeedUint32(\n captureSeedUint32: number,\n nativeSiteIndex: number,\n streamId: number\n): number {\n if (!isUint32(captureSeedUint32) || !isUint32(nativeSiteIndex) || !isUint32(streamId)) {\n throw new InvalidScientificInputError(\"Stochastic stream seed inputs must be uint32 integers.\");\n }\n let h = 0;\n h = mixBlock(h, captureSeedUint32);\n h = mixBlock(h, nativeSiteIndex);\n h = mixBlock(h, streamId);\n return fmix32(h ^ 12);\n}\n\n/** One deterministic stream of uniforms strictly inside (0,1). */\nexport interface DeterministicUniformStream {\n next(): number;\n}\n\n/**\n * Creates the deterministic, non-cryptographic xorshift32 random stream for one charge, read-noise\n * or fixed-pattern identity.\n *\n * The seed is first scrambled by the MurmurHash3 `fmix32` avalanche finalizer. xorshift32 is linear\n * over GF(2), so an unscrambled seed would give nearby seeds almost identical first draws, and noise\n * that is correlated across the sensor with too little variance (issue #270). The nonzero fallback\n * avoids the zero state, which xorshift can never leave. Adding 0.5 before dividing gives values\n * strictly inside (0,1), so the logarithms in the Poisson and normal transforms never see 0 or 1.\n * Changing any of this changes simulated RAW output and needs a model and replay review.\n */\nexport function createDeterministicUniformStream(seedUint32: number): DeterministicUniformStream {\n let state = fmix32((seedUint32 ^ 0x9e3779b9) >>> 0);\n if (state === 0) {\n state = 0x6d2b79f5;\n }\n return {\n next(): number {\n state ^= state << 13;\n state ^= state >>> 17;\n state ^= state << 5;\n state >>>= 0;\n return (state + 0.5) / 0x1_0000_0000;\n }\n };\n}\n\n/**\n * Box–Muller transform of two uniform values in (0,1) (Box & Muller 1958). Each call uses two values\n * and returns one standard normal sample. The second possible sample is not cached, so the order of\n * draws from the stream never shifts.\n */\nexport function sampleDeterministicStandardNormal(random: DeterministicUniformStream): number {\n const u1 = random.next();\n const u2 = random.next();\n return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n/**\n * Fixed-pattern stream IDs. They seed spatial patterns that belong to one physical sensor, never\n * per-capture noise, and are hashed with the sensor-instance seed instead of the capture seed.\n */\nexport const SENSOR_FIXED_PATTERN_PRNU_STREAM_ID = 16 as const;\nexport const SENSOR_FIXED_PATTERN_DSNU_STREAM_ID = 17 as const;\nexport const SENSOR_FIXED_PATTERN_HOT_PIXEL_STREAM_ID = 18 as const;\nexport const SENSOR_FIXED_PATTERN_ROW_STREAM_ID = 19 as const;\nexport const SENSOR_FIXED_PATTERN_COLUMN_STREAM_ID = 20 as const;\n\n/**\n * Returns the uint32 seed of one fixed-pattern stream for one physical colour-sampling site: the\n * MurmurHash3_x86_32 hash (hash seed 0) of the 16-byte little-endian message\n * `[sensorInstanceSeed, siteX, siteY, streamId]`. Site coordinates are absolute indices in the\n * colour-sampling grid, so the pattern belongs to the physical sensor and does not change with crop,\n * tiling or the per-capture seed. Deterministic and not cryptographic.\n */\nexport function deriveSensorFixedPatternSeedUint32(\n sensorInstanceSeedUint32: number,\n siteX: number,\n siteY: number,\n streamId: number\n): number {\n if (!isUint32(sensorInstanceSeedUint32) || !isUint32(siteX) || !isUint32(siteY) || !isUint32(streamId)) {\n throw new InvalidScientificInputError(\"Fixed-pattern seed inputs must be uint32 integers.\");\n }\n let h = 0;\n h = mixBlock(h, sensorInstanceSeedUint32);\n h = mixBlock(h, siteX);\n h = mixBlock(h, siteY);\n h = mixBlock(h, streamId);\n return fmix32(h ^ 16);\n}\n"]}
1
+ {"version":3,"file":"stochastic-seed.js","sourceRoot":"","sources":["../../src/sensor/stochastic-seed.ts"],"names":[],"mappings":"AAAA,sCAAsC;AAEtC;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,2BAA2B,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAE3F,kEAAkE;AAClE,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAU,CAAC;AACvD,MAAM,CAAC,MAAM,gCAAgC,GAAG,CAAU,CAAC;AAE3D,MAAM,UAAU,GAAG,UAAU,CAAC;AAC9B,MAAM,UAAU,GAAG,UAAU,CAAC;AAE9B,wFAAwF;AACxF,MAAM,UAAU,MAAM,CAAC,KAAa;IAClC,IAAI,CAAC,GAAG,KAAK,KAAK,CAAC,CAAC;IACpB,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;IACd,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC;IAC7B,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;IACd,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC;IAC7B,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;IACd,OAAO,CAAC,KAAK,CAAC,CAAC;AACjB,CAAC;AAED,SAAS,QAAQ,CAAC,IAAY,EAAE,KAAa;IAC3C,IAAI,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,KAAK,CAAC,EAAE,UAAU,CAAC,CAAC;IAC3C,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;IAC3B,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC;IAC7B,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IACzB,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;IAC3B,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;AAC9C,CAAC;AAED,SAAS,QAAQ,CAAC,KAAa;IAC7B,OAAO,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,KAAK,IAAI,UAAU,CAAC;AACtE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gCAAgC,CAC9C,iBAAyB,EACzB,eAAuB,EACvB,QAAgB;IAEhB,IAAI,CAAC,QAAQ,CAAC,iBAAiB,CAAC,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QACtF,MAAM,IAAI,2BAA2B,CAAC,wDAAwD,CAAC,CAAC;IAClG,CAAC;IACD,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,CAAC,GAAG,QAAQ,CAAC,CAAC,EAAE,iBAAiB,CAAC,CAAC;IACnC,CAAC,GAAG,QAAQ,CAAC,CAAC,EAAE,eAAe,CAAC,CAAC;IACjC,CAAC,GAAG,QAAQ,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;IAC1B,OAAO,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;AACxB,CAAC;AAOD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,gCAAgC,CAAC,UAAkB;IACjE,qHAAqH;IACrH,0GAA0G;IAC1G,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC;IACjC,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,UAAU,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC;IACnD,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC;QACnB,KAAK,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC;IACxB,CAAC;IACD,OAAO;QACL,IAAI;YACF,IAAI,CAAC,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;YAClB,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;YACb,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC;YACd,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACZ,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;YACb,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,aAAa,CAAC;QAC1C,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iCAAiC,CAAC,MAAkC;IAClF,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC;IACzB,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC;IACzB,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC;AACnE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,4BAA4B,CAAC,MAAkC;IAC7E,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC;IACzB,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC;IACzB,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,GAAG,gBAAgB,CAAC,EAAE,CAAC,CAAC;AAChE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,iCAAiC,CAAC,MAAkC;IAClF,IAAI,KAAK,GAAG,CAAC,EAAE,QAAQ,GAAG,KAAK,CAAC;IAChC,OAAO,GAAG,EAAE;QACV,IAAI,QAAQ,EAAE,CAAC;YAAC,QAAQ,GAAG,KAAK,CAAC;YAAC,OAAO,KAAK,CAAC;QAAC,CAAC;QACjD,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,EAAE,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC;QAClF,KAAK,GAAG,CAAC,GAAG,gBAAgB,CAAC,EAAE,CAAC,CAAC;QAAC,QAAQ,GAAG,IAAI,CAAC;QAClD,OAAO,CAAC,GAAG,gBAAgB,CAAC,EAAE,CAAC,CAAC;IAClC,CAAC,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,MAAM,mCAAmC,GAAG,EAAW,CAAC;AAC/D,MAAM,CAAC,MAAM,mCAAmC,GAAG,EAAW,CAAC;AAC/D,MAAM,CAAC,MAAM,wCAAwC,GAAG,EAAW,CAAC;AACpE,MAAM,CAAC,MAAM,kCAAkC,GAAG,EAAW,CAAC;AAC9D,MAAM,CAAC,MAAM,qCAAqC,GAAG,EAAW,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,UAAU,kCAAkC,CAChD,wBAAgC,EAChC,KAAa,EACb,KAAa,EACb,QAAgB;IAEhB,IAAI,CAAC,QAAQ,CAAC,wBAAwB,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QACvG,MAAM,IAAI,2BAA2B,CAAC,oDAAoD,CAAC,CAAC;IAC9F,CAAC;IACD,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,CAAC,GAAG,QAAQ,CAAC,CAAC,EAAE,wBAAwB,CAAC,CAAC;IAC1C,CAAC,GAAG,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IACvB,CAAC,GAAG,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;IACvB,CAAC,GAAG,QAAQ,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;IAC1B,OAAO,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;AACxB,CAAC","sourcesContent":["// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Seeds for the random-number streams behind simulated sensor noise. The same inputs always give\n * the same noise.\n *\n * Each per-site stream seed is a strong 32-bit hash of the capture seed, the absolute native\n * row-major site index and a fixed stream ID. The hash is MurmurHash3_x86_32 (Austin Appleby,\n * placed in the public domain) of the 12-byte little-endian message `[captureSeed, siteIndex,\n * streamId]` with hash seed 0. Its avalanche finalizer (`fmix32`) makes a one-bit input change flip\n * each output bit with probability close to 1/2. So neighbouring sites, the two streams of one site\n * and captures with nearby seeds get unrelated seeds. A linear schedule such as `seed + 2*index` fed\n * into a linear generator (xorshift) lacks this property, and produced correlated noise with too\n * little variance (issue #270).\n *\n * This is deterministic and not cryptographic; it does not claim unpredictability. The moment and\n * correlation tests, not this hash alone, show that the resulting noise is statistically acceptable.\n */\n\nimport { InvalidScientificInputError } from \"../core/validation.js\";\nimport { portableCosTwoPi, portableLog, portableSinTwoPi } from \"../core/portable-math.js\";\n\n/** Stream IDs. Changing them changes every simulated RAW code. */\nexport const NATIVE_SITE_CHARGE_STREAM_ID = 0 as const;\nexport const NATIVE_SITE_READ_NOISE_STREAM_ID = 1 as const;\n\nconst MURMUR3_C1 = 0xcc9e2d51;\nconst MURMUR3_C2 = 0x1b873593;\n\n/** MurmurHash3 32-bit avalanche finalizer; a bijection on uint32 with fmix32(0) = 0. */\nexport function fmix32(value: number): number {\n let h = value >>> 0;\n h ^= h >>> 16;\n h = Math.imul(h, 0x85ebca6b);\n h ^= h >>> 13;\n h = Math.imul(h, 0xc2b2ae35);\n h ^= h >>> 16;\n return h >>> 0;\n}\n\nfunction mixBlock(hash: number, block: number): number {\n let k = Math.imul(block >>> 0, MURMUR3_C1);\n k = (k << 15) | (k >>> 17);\n k = Math.imul(k, MURMUR3_C2);\n let h = (hash ^ k) >>> 0;\n h = (h << 13) | (h >>> 19);\n return (Math.imul(h, 5) + 0xe6546b64) >>> 0;\n}\n\nfunction isUint32(value: number): boolean {\n return Number.isInteger(value) && value >= 0 && value <= 0xffffffff;\n}\n\n/**\n * Returns the uint32 seed of one random stream for one absolute native site.\n * All inputs must be uint32 integers. The site index is the full-frame native row-major\n * index, never relative to a tile or chunk, so how the work is split cannot change it.\n */\nexport function deriveNativeSiteStreamSeedUint32(\n captureSeedUint32: number,\n nativeSiteIndex: number,\n streamId: number\n): number {\n if (!isUint32(captureSeedUint32) || !isUint32(nativeSiteIndex) || !isUint32(streamId)) {\n throw new InvalidScientificInputError(\"Stochastic stream seed inputs must be uint32 integers.\");\n }\n let h = 0;\n h = mixBlock(h, captureSeedUint32);\n h = mixBlock(h, nativeSiteIndex);\n h = mixBlock(h, streamId);\n return fmix32(h ^ 12);\n}\n\n/** One deterministic stream of uniforms strictly inside (0,1). */\nexport interface DeterministicUniformStream {\n next(): number;\n}\n\n/**\n * Creates the deterministic, non-cryptographic xorshift32 random stream for one charge, read-noise\n * or fixed-pattern identity.\n *\n * The seed is first scrambled by the MurmurHash3 `fmix32` avalanche finalizer. xorshift32 is linear\n * over GF(2), so an unscrambled seed would give nearby seeds almost identical first draws, and noise\n * that is correlated across the sensor with too little variance (issue #270). The nonzero fallback\n * avoids the zero state, which xorshift can never leave. Adding 0.5 before dividing gives values\n * strictly inside (0,1), so the logarithms in the Poisson and normal transforms never see 0 or 1.\n * Changing any of this changes simulated RAW output and needs a model and replay review.\n */\nexport function createDeterministicUniformStream(seedUint32: number): DeterministicUniformStream {\n // The state lives in a Uint32Array: storing the int32 result there is the same `>>> 0` reduction, and a uint32 above\n // 2^30 kept in a closure variable would be boxed on every step (half the speed). The draws are unchanged.\n const state = new Uint32Array(1);\n state[0] = fmix32((seedUint32 ^ 0x9e3779b9) >>> 0);\n if (state[0] === 0) {\n state[0] = 0x6d2b79f5;\n }\n return {\n next(): number {\n let x = state[0]!;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n state[0] = x;\n return (state[0] + 0.5) / 0x1_0000_0000;\n }\n };\n}\n\n/**\n * Box–Muller transform of two uniform values in (0,1) (Box & Muller 1958). Each call uses two values\n * and returns one standard normal sample. The second possible sample is not cached, so the order of\n * draws from the stream never shifts.\n */\nexport function sampleDeterministicStandardNormal(random: DeterministicUniformStream): number {\n const u1 = random.next();\n const u2 = random.next();\n return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n/**\n * The same Box–Muller draw (same two uniforms, same order) with the engine's portable logarithm and cosine\n * (`portableLog`, `portableCosTwoPi`), so the sample has the same bits in every JavaScript engine. Used by the\n * display view; the native noise keeps `sampleDeterministicStandardNormal`.\n */\nexport function samplePortableStandardNormal(random: DeterministicUniformStream): number {\n const u1 = random.next();\n const u2 = random.next();\n return Math.sqrt(-2 * portableLog(u1)) * portableCosTwoPi(u2);\n}\n\n/**\n * Standard normal samples from one stream, two per pair of uniforms: both Box–Muller outputs, r·cos(2πu₂) and then\n * r·sin(2πu₂) with r = √(−2 ln u₁) (Box & Muller 1958; the two are independent standard normals), with the portable\n * functions. Each call returns the next sample; the stream advances by two uniforms on every other call. Used by the\n * display view's AHD correction states, which draw many normals from one stream.\n */\nexport function createPortableStandardNormalPairs(random: DeterministicUniformStream): () => number {\n let spare = 0, hasSpare = false;\n return () => {\n if (hasSpare) { hasSpare = false; return spare; }\n const u1 = random.next(), u2 = random.next(), r = Math.sqrt(-2 * portableLog(u1));\n spare = r * portableSinTwoPi(u2); hasSpare = true;\n return r * portableCosTwoPi(u2);\n };\n}\n\n/**\n * Fixed-pattern stream IDs. They seed spatial patterns that belong to one physical sensor, never\n * per-capture noise, and are hashed with the sensor-instance seed instead of the capture seed.\n */\nexport const SENSOR_FIXED_PATTERN_PRNU_STREAM_ID = 16 as const;\nexport const SENSOR_FIXED_PATTERN_DSNU_STREAM_ID = 17 as const;\nexport const SENSOR_FIXED_PATTERN_HOT_PIXEL_STREAM_ID = 18 as const;\nexport const SENSOR_FIXED_PATTERN_ROW_STREAM_ID = 19 as const;\nexport const SENSOR_FIXED_PATTERN_COLUMN_STREAM_ID = 20 as const;\n\n/**\n * Returns the uint32 seed of one fixed-pattern stream for one physical colour-sampling site: the\n * MurmurHash3_x86_32 hash (hash seed 0) of the 16-byte little-endian message\n * `[sensorInstanceSeed, siteX, siteY, streamId]`. Site coordinates are absolute indices in the\n * colour-sampling grid, so the pattern belongs to the physical sensor and does not change with crop,\n * tiling or the per-capture seed. Deterministic and not cryptographic.\n */\nexport function deriveSensorFixedPatternSeedUint32(\n sensorInstanceSeedUint32: number,\n siteX: number,\n siteY: number,\n streamId: number\n): number {\n if (!isUint32(sensorInstanceSeedUint32) || !isUint32(siteX) || !isUint32(siteY) || !isUint32(streamId)) {\n throw new InvalidScientificInputError(\"Fixed-pattern seed inputs must be uint32 integers.\");\n }\n let h = 0;\n h = mixBlock(h, sensorInstanceSeedUint32);\n h = mixBlock(h, siteX);\n h = mixBlock(h, siteY);\n h = mixBlock(h, streamId);\n return fmix32(h ^ 16);\n}\n"]}
@@ -20,6 +20,7 @@ one-call capture, see the [Quick Start](QUICK_START.md). For every export, see t
20
20
  | Task | Call | Notes |
21
21
  | --- | --- | --- |
22
22
  | Simulate a full photo | [`simulateCapture`](api/facade-simulate-capture.md#simulatecapture) | Async. Returns an image, the RAW data, `assumptions` and `provenance`. |
23
+ | Preview a whole frame as the real capture resized for display | [`simulateDisplayView`](api/facade-simulate-display-view.md#simulatedisplayview) | Async. Takes the capture's request plus a display size; costs follow the display pixels. Labelled `kind: "display-view"`: a preview, never a capture. |
23
24
  | Exposure value (EV) of a setting | [`calculateExposureValue100`](api/exposure-exposure.md#calculateexposurevalue100) | EV at ISO 100 = log2(N²/t). |
24
25
  | Keep brightness after changing aperture or shutter | [`calculateEquivalentIso`](api/exposure-exposure.md#calculateequivalentiso) | Returns the compensating ISO. Not a noise model. |
25
26
  | Compare two exposures in stops | [`calculateRelativeRenderedExposure`](api/exposure-exposure.md#calculaterelativerenderedexposure) | Includes ISO as gain. |
@@ -1,6 +1,6 @@
1
1
  # Public API reference
2
2
 
3
- Generated from the exact root exports for **@photivra/engine 2.5.0 / root contract 2.5.0** by `node scripts/generate-api-reference.mjs`. No module deep import is supported.
3
+ Generated from the exact root exports for **@photivra/engine 2.6.0 / root contract 2.6.0** by `node scripts/generate-api-reference.mjs`. No module deep import is supported.
4
4
 
5
5
  Use [the developer guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md) for executable paths and scientific boundaries. Types are contracts, not runtime validation: parse untrusted data, preserve units/reference frames, read assessment blockers, and retain evidence/limitations. Optional properties do not imply a universal default. Model/schema IDs remain independent of the distribution.
6
6
 
@@ -11,6 +11,7 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
11
11
  | Task | Functions |
12
12
  | --- | --- |
13
13
  | Simulate a full photo (async) | [`simulateCapture`](api/facade-simulate-capture.md#simulatecapture) |
14
+ | Preview a whole frame as the real capture resized (async) | [`simulateDisplayView`](api/facade-simulate-display-view.md#simulatedisplayview) |
14
15
  | Exposure value and equivalent settings | [`calculateExposureValue100`](api/exposure-exposure.md#calculateexposurevalue100), [`calculateEquivalentIso`](api/exposure-exposure.md#calculateequivalentiso), [`calculateRelativeRenderedExposure`](api/exposure-exposure.md#calculaterelativerenderedexposure) |
15
16
  | Depth of field, hyperfocal distance and defocus blur | [`calculateDepthOfField`](api/optics-depth-of-field.md#calculatedepthoffield), [`calculateDefocusCircle`](api/optics-depth-of-field.md#calculatedefocuscircle) |
16
17
  | Field of view | [`calculateFieldOfView`](api/camera-field-of-view.md#calculatefieldofview) |
@@ -1179,11 +1180,13 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1179
1180
 
1180
1181
  | Export | Kind |
1181
1182
  | --- | --- |
1183
+ | [`DisplayViewResult`](api/facade-simulate-capture.md#displayviewresult) | Type |
1182
1184
  | [`IdealReferenceResult`](api/facade-simulate-capture.md#idealreferenceresult) | Type |
1183
1185
  | [`SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES`](api/facade-simulate-capture.md#simulate_capture_box_aperture_default_samples) | Runtime |
1184
1186
  | [`SIMULATE_CAPTURE_COST_OPTIONS`](api/facade-simulate-capture.md#simulate_capture_cost_options) | Runtime |
1185
1187
  | [`SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX`](api/facade-simulate-capture.md#simulate_capture_default_illuminance_lux) | Runtime |
1186
1188
  | [`SIMULATE_CAPTURE_FACADE_VERSION`](api/facade-simulate-capture.md#simulate_capture_facade_version) | Runtime |
1189
+ | [`SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS`](api/facade-simulate-capture.md#simulate_display_view_default_samples_per_axis) | Runtime |
1187
1190
  | [`simulateCapture`](api/facade-simulate-capture.md#simulatecapture) | Runtime |
1188
1191
  | [`SimulateCaptureAssumption`](api/facade-simulate-capture.md#simulatecaptureassumption) | Type |
1189
1192
  | [`SimulateCaptureBorderHandling`](api/facade-simulate-capture.md#simulatecaptureborderhandling) | Type |
@@ -1200,8 +1203,17 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1200
1203
  | [`SimulateCaptureResult`](api/facade-simulate-capture.md#simulatecaptureresult) | Type |
1201
1204
  | [`SimulateCaptureSensorAntiAliasing`](api/facade-simulate-capture.md#simulatecapturesensorantialiasing) | Type |
1202
1205
  | [`SimulateCaptureWhiteBalance`](api/facade-simulate-capture.md#simulatecapturewhitebalance) | Type |
1206
+ | [`SimulateDisplayViewOptions`](api/facade-simulate-capture.md#simulatedisplayviewoptions) | Type |
1203
1207
  | [`simulateIdealReference`](api/facade-simulate-capture.md#simulateidealreference) | Runtime |
1204
1208
 
1209
+ ## facade/simulate-display-view.ts
1210
+
1211
+ [Detailed contracts](api/facade-simulate-display-view.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/facade/simulate-display-view.ts)
1212
+
1213
+ | Export | Kind |
1214
+ | --- | --- |
1215
+ | [`simulateDisplayView`](api/facade-simulate-display-view.md#simulatedisplayview) | Runtime |
1216
+
1205
1217
  ## facade/subject-framing.ts
1206
1218
 
1207
1219
  [Detailed contracts](api/facade-subject-framing.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/facade/subject-framing.ts)
@@ -233,6 +233,16 @@ const coverage = calculateChartFraming({ sensor: { widthMm: 36, heightMm: 24 },
233
233
  console.log(coverage.coverage.frameWidthFraction, coverage.magnification, coverage.belowMinimumFocus);
234
234
  ```
235
235
 
236
+ **Close-up light (2.6).** Focused close, the lens delivers less light: the working f-number is N·(1 + m) with m = v/s at
237
+ the focus distance (ideal symmetric thin lens, unity pupil magnification), so the image-plane irradiance falls by
238
+ 1/(1 + m)² compared with the same f-number at infinity focus. `calculateChartFraming` and
239
+ `calculateSubjectDistanceForFraming` report it as `closeUpTransmission` (1/(1 + m)²) and `closeUpLossStops` (2·log₂(1 + m),
240
+ how many stops darker). Both come from the same function as the factor `simulateCapture` applies in the scene-to-sensor
241
+ irradiance, so they cannot drift apart; a test checks the RAW signal ratio of a uniform chart at 0.35 m and 50 m against
242
+ them within 0.5 %. With the 50 mm prime: 0.81 (0.30 stop darker) at 0.5 m, 0.90 (0.15 stop) at 1 m, 0.95 (0.07 stop) at
243
+ 2 m and 0.98 (0.03 stop) at 5 m. Show it to users as "about N stops darker at this distance"; it is the thin-lens
244
+ model, not a measured lens (real lenses with non-unity pupil magnification differ).
245
+
236
246
  Tests check that at `calculateSubjectDistanceForFraming`'s distance the coverage is 1 on the fitted side (every fit,
237
247
  three sensor shapes, four primes), and that at any distance it equals `result.scene.chart` from `simulateCapture` with
238
248
  `chartSize` (magnification, both coverage fractions, the chart's image size and the framed size, within 1e-12
@@ -292,6 +302,10 @@ What changes and what does not:
292
302
 
293
303
  Requests at 0.5–100 µm are byte-identical to 2.4.1.
294
304
 
305
+ A whole-frame view simulates a camera with giant pixels. For a preview that looks like the real camera's photo
306
+ shrunk to the frame size (the real pixel pitch for the optics, the real mosaic and the noise of the averaged real
307
+ pixels), use [Display view](#display-view) instead.
308
+
295
309
  ### ISO and pixel size
296
310
 
297
311
  The tier's ISO signal chain (conversion gain, saturation signal and read noise per ISO setting) is
@@ -686,6 +700,14 @@ The optics options feed only what the environment site contract accepts:
686
700
  lit circular pupil (scalar Fraunhofer diffraction), aberration-free and in focus, with pupil diameter
687
701
  f/N and the thin-lens image distance (working f-number N·v/f). Each kernel holds the PSF energy of
688
702
  site-sized cells, one kernel per spectral node.
703
+ - Sources: the Airy pattern is the textbook Fraunhofer diffraction pattern of a circular aperture
704
+ (Born & Wolf, *Principles of Optics*, 7th ed., §8.5.2; Goodman, *Introduction to Fourier Optics*,
705
+ 4th ed., §4.4). The kernels come from the pupil's incoherent optical transfer function (Goodman,
706
+ 4th ed., §7.3; MIT OpenCourseWare 2.71, lectures 21–22, free). The first dark ring is at
707
+ 1.22·λ·N_w (j₁,₁/π with j₁,₁ = 3.8317, the first zero of J₁), so the Airy disk diameter is
708
+ 2.44·λ·N_w, and the energy inside radius r is 1 − J₀(x)² − J₁(x)², x = π·r/(λ·N_w) (Wyant &
709
+ Creath 1992, §II, eqs. 1–4, free). The Bessel-function relations and expansions come from the
710
+ NIST DLMF (free). Full references are under [Sources](#sources).
689
711
  - The kernel window reaches the third dark ring at the longest node by default (2, 4 and 7 sites at
690
712
  f/4, f/8 and f/16 with 6 µm pixels); set `kernelRadiusSamples` (0–32) to change it. The Airy tail
691
713
  outside the window (2–6 % of the energy with the default) is listed per wavelength in the
@@ -927,6 +949,8 @@ Ratios below about 1.1× are within the run-to-run spread (the three default run
927
949
  (anti-aliasing spots, pupil rays, PSF taps, temporal samples) multiply the run time by about the same
928
950
  factor, and output-side options cost almost nothing.
929
951
 
952
+ The cost of `simulateDisplayView` is in [Display view](#display-view).
953
+
930
954
  ### Measuring cost on the user's device
931
955
 
932
956
  From 2.1 every result says what ran and how long each stage took, so an application can decide on the
@@ -1045,10 +1069,16 @@ How it stays identical:
1045
1069
  own edges the region raster ends exactly where the whole frame's does, so `borderHandling: "mirror"`
1046
1070
  mirrors at the same place. AHD reads at most 5 sites away, so its edge-directed choices see the same
1047
1071
  neighbourhood.
1048
- - **Diffraction:** the object-space convolution is computed in whole-width bands of 32 native rows
1049
- around the region's rows, as in the whole render (the same bands, kernels and summation order). A
1050
- frame split into k tile columns therefore repeats that convolution k times; tile by rows (full-width
1051
- strips) to avoid it.
1072
+ - **Diffraction (2.6: region-limited):** the object-space convolution covers only the scene lattice the
1073
+ region's native sites query: the native rectangle plus the anti-aliasing and box-aperture offsets on
1074
+ each side, in bands of at most 32 native rows, with the kernel radius of real scene radiance around it.
1075
+ Each kept value is the same gather as in the whole render (the same kernel, the same taps in the same
1076
+ order, and every tap inside the grid), so the values are bit-identical while the work follows the
1077
+ region plus its halo instead of the frame width. In 2.3–2.5 the bands spanned the whole frame width, so
1078
+ a small crop of a wide frame paid for the full width and k tile columns repeated it k times. The noise
1079
+ needs no frame-wide state: each site's stream is seeded by a hash of the capture seed and its absolute
1080
+ native index (`seedSchedule: "murmur3-capture-seed-native-index-stream-v2"`), so it is counter-based,
1081
+ not a position in one frame-wide stream; the demosaic and the filters read only the margin above.
1052
1082
 
1053
1083
  Measured on one core (Node 24, 4-vCPU Intel Xeon 2.80 GHz, built `dist`, one run each): a 256 × 256
1054
1084
  slanted edge took 8.45 s whole, 9.03 s as four 256 × 64 strips and 8.44 s as four 128 × 128 tiles run
@@ -1056,6 +1086,25 @@ one after another; with `lens.diffraction` 10.2 s whole and 12.9 s as four tiles
1056
1086
  convolution bands). A 64 × 64 crop of a 4000 × 3000 frame took 0.63 s, the same as a plain 64 × 64
1057
1087
  capture. Tiles in parallel workers divide the wall time by the number of cores.
1058
1088
 
1089
+ **A 100 % crop with the slow options (2.6).** A 192 × 128 region at the centre of a 6000 × 4000 frame at 6 µm,
1090
+ Siemens star, f/16, `output.maximumPixels: NATIVE_RAW_LIMITS.ceilingPixels` (one fresh Node 24.21.0 process per
1091
+ row, 4-vCPU Intel Xeon 2.80 GHz, built `dist`, one run each; `diagnostics.timingsMs`):
1092
+
1093
+ | Options | 2.5.0 total (convolution) | 2.6 total (convolution) | Peak resident memory, 2.5.0 → 2.6 |
1094
+ | --- | --- | --- | --- |
1095
+ | defaults | 3.6 s (—) | 3.6 s (—) | 320 → 304 MB |
1096
+ | diffraction, four-spot filter, AHD | 60.8 s (50.2 s) | 11.8 s (1.7 s) | 556 → 379 MB |
1097
+ | diffraction, four-spot filter, AHD, box 2 × 2 | 228.7 s (195.8 s) | 39.6 s (6.5 s) | 1 285 → 545 MB |
1098
+
1099
+ On a GitHub-hosted `ubuntu-latest` runner (4 vCPU AMD EPYC 7763, Node 24, one run each) the same rows took 2.7 → 2.6 s,
1100
+ 44.1 → 8.3 s and 170.9 → 27.7 s; from 2.6 the box row takes 19.4 s, because the engine no longer re-parses the fixed parts of its own chart scenes' results (caller scenes keep the full validation). The samples are byte-identical between the two versions. What remains is the per-site work, which grows with the
1101
+ scene queries per site (20 spectral nodes × the filter spots × the box nodes: 80 per site with the filter, 320
1102
+ with the filter and the 2 × 2 box) and with the region's sites, not with the frame.
1103
+
1104
+ `output.maximumPixels` is still needed for a region of a frame wider or taller than 4094 pixels: the request,
1105
+ plan and noise seeds are those of the whole frame, and the whole frame's native raster is checked against the
1106
+ limit. The allocations follow the region, so the opt-in costs no memory for the rest of the frame.
1107
+
1059
1108
  `result.image` holds the region, `result.raw` and `result.plan` describe the region's native sites (the
1060
1109
  plan's active area is the region; `result.region.nativeRect` gives them in the whole frame), and
1061
1110
  `result.region` reports the rectangle and the frame. `diagnostics.options` lists `output.region`.
@@ -1117,6 +1166,81 @@ const [capture, reference] = await Promise.all([simulateCapture(request), simula
1117
1166
  console.log(reference.kind, reference.label, capture.image.samples.length === reference.image.samples.length);
1118
1167
  ```
1119
1168
 
1169
+ ## Display view
1170
+
1171
+ `simulateDisplayView(options, view)` returns what the real full-resolution capture of `options` would look like
1172
+ resized to `view.width` × `view.height`, at a cost that follows the display pixels. Pass the same request you use
1173
+ for the export, with the real sensor's pixel count and pitch:
1174
+
1175
+ ```ts
1176
+ import { NATIVE_RAW_LIMITS, simulateCapture, simulateDisplayView, type SimulateCaptureOptions } from "@photivra/engine";
1177
+
1178
+ const request: SimulateCaptureOptions = { scene: { preset: "dead-leaves" }, lens: { prime: 35, fNumber: 2.8 }, subjectDistanceM: 3,
1179
+ sensor: { pixelPitchMicrometers: 6 }, output: { width: 6000, height: 4000, maximumPixels: NATIVE_RAW_LIMITS.ceilingPixels } };
1180
+ const preview = await simulateDisplayView(request, { width: 384, height: 256 }); // on screen
1181
+ console.log(preview.kind, preview.label, preview.diagnostics.timingsMs.view);
1182
+ // The export stays a real capture: simulateCapture(request), or tiles of it with output.region.
1183
+ const crop = await simulateCapture({ ...request, output: { ...request.output, region: { x: 2968, y: 1968, width: 64, height: 64 } } });
1184
+ console.log(crop.image.width);
1185
+ ```
1186
+
1187
+ **What each display pixel is.** The area-weighted mean, in linear light, of the real output pixels its rectangle covers
1188
+ (6000 ÷ 384 = 15.625 real pixels per axis here), sRGB-encoded and rounded: area (box) resampling, the standard
1189
+ reduction filter. Lens diffraction, the anti-aliasing filter and the pixel aperture act at the real 6 µm pitch; the
1190
+ colour mosaic, demosaic and development are those of the real pixels; the noise is that of the average of the real
1191
+ pixels, demosaic correlation included; exposure, white balance, tone curve and framing are the capture's (the same code
1192
+ resolves them).
1193
+
1194
+ **How it is computed** (details and error analysis in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md)):
1195
+
1196
+ - The expected value of each display pixel is a stratified Monte Carlo average over K × K real pixels of its rectangle
1197
+ (`view.samplesPerAxis`, default 16), seeded and unbiased. For each sampled pixel the expected electrons of the real
1198
+ sites its demosaic reads are exact, and the clips and tone curve act on the pixel's own noise distribution. A pilot
1199
+ grid gives uniform areas K/4 samples per axis; their error is zero anyway.
1200
+ - The noise is one correlated normal draw per display pixel with the covariance of the area mean of the real pixels'
1201
+ independent site noises (shot, dark, read, ADC), including the demosaic's correlation.
1202
+ - AHD is represented by its two linear candidates; where the noise is large enough for its homogeneity choice to shift
1203
+ the colour, a control-variate correction runs the engine's AHD steps on drawn noisy neighbourhoods.
1204
+
1205
+ Validated against real full-resolution captures resized in linear light: three sensors (12 MP phone, 24 MP full frame,
1206
+ 100 MP medium format), five charts, f/2.8–f/16, ISO 100–6400, 96–800 display pixels wide, Speed and Quality options (see
1207
+ [Validation results](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#validation-results)).
1208
+
1209
+ **Cost.** It follows the detailed display pixels × `samplesPerAxis`², not the real pixel count. Measured against the
1210
+ whole-frame view of the same request (Siemens star, f/8, ISO 100, one run, Node 24.21.0 on a 4-vCPU 2.80 GHz Xeon;
1211
+ every row, and the times before the exact speed-ups, in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#cost)):
1212
+
1213
+ | Sensor | Options | 96 wide | 384 wide | 800 wide |
1214
+ | --- | --- | --- | --- | --- |
1215
+ | 24 MP full frame, 6 µm | Speed (defaults) | 2.4 s (2.3×) | 10.6 s (0.92×) | 26.6 s (0.65×) |
1216
+ | 12 MP phone, 1.4 µm | Speed | 2.2 s (2.4×) | 11.7 s (0.92×) | 32.1 s (0.59×) |
1217
+ | 100 MP medium format, 3.76 µm | Speed | 2.7 s (2.3×) | 15.8 s (1.2×) | 32.6 s (0.66×) |
1218
+ | 24 MP full frame | Quality (diffraction, four-spot filter, AHD) | 5.2 s (1.6×) | 30.9 s (1.3×) | 84.3 s (0.76×) |
1219
+ | 12 MP phone | Quality | 49.7 s (20×) | 106 s (3.8×) | 272 s (2.2×) |
1220
+ | 100 MP medium format | Quality | 18.2 s (3.7×) | 49.7 s (1.8×) | 126 s (1.0×) |
1221
+
1222
+ Quality costs more on small pixels. The diffraction kernel spans several real sites, and at 1.4 µm even ISO 100 is
1223
+ noisy enough for the AHD correction to run on every sampled pixel; its correction states are kept for later views of
1224
+ the same sensor, exposure and development in the same JavaScript realm (a second 384-wide phone view of the same
1225
+ request took 73 s instead of 103 s). The results are byte-identical to those of the display view as first merged,
1226
+ which took 222 s, 450 s and 837 s on the phone.
1227
+
1228
+ **Options.** `view.width` and `view.height` (1 to the capture's output size), `view.samplesPerAxis` (1–32),
1229
+ `view.format` (`"srgb-8"` default or `"srgb-16"`) and `view.region` (a rectangle in display pixels, byte-identical to
1230
+ the same pixels of the whole view, for tiling across workers). The capture's file-format options (`format`,
1231
+ `colorEncoding`, `bitDepth`, `gamutMapping`, `jpegQuantizationStep`, `dng`, `metadata`) do not apply. The request must
1232
+ be one `simulateCapture` accepts (a frame wider or taller than 4094 pixels needs `output.maximumPixels`, although the
1233
+ view allocates nothing at full resolution).
1234
+
1235
+ **Not supported** (refused with an `InvalidConfigurationError`): `lens.pupilSampling`, `lens.projection`,
1236
+ `lens.distortion`, `lens.vignetting`, `lens.psf`, camera rotation, `output.whiteBalance: "auto"`, the luminance tone
1237
+ curves, `output.highlightMode: "reconstruct"` and `output.region` (use `view.region`). `onProgress` is not called;
1238
+ `signal` cancels.
1239
+
1240
+ The result is labelled `kind: "display-view"` with a `label` for the user interface; it has no RAW, plan or file, and
1241
+ the `view.*` assumptions state the definition, the estimator, the noise model, the demosaic treatment and which capture
1242
+ options were ignored. The whole-frame views of [Whole-frame views](#whole-frame-views-25) are unchanged.
1243
+
1120
1244
  ## Not available
1121
1245
 
1122
1246
  The facade offers no atmospheric haze or polarizer (see [Optics](#optics)), flash, video, HDR brackets
@@ -1125,6 +1249,33 @@ provided, for rights reasons.
1125
1249
 
1126
1250
  ## Sources
1127
1251
 
1252
+ - Diffraction (`lens.diffraction`, `createDiffractionLimitedPsfProfile`): M. Born, E. Wolf,
1253
+ *Principles of Optics*, 7th (expanded) ed., Cambridge University Press, 1999,
1254
+ doi:10.1017/CBO9781139644181, §8.5.2 "The circular aperture" (Fraunhofer diffraction at a circular
1255
+ aperture: the Airy pattern and its dark rings); J. W. Goodman, *Introduction to Fourier Optics*,
1256
+ 4th ed., W. H. Freeman, 2017, §4.4 (examples of Fraunhofer diffraction patterns, including the
1257
+ circular aperture) and §7.3 (frequency response for diffraction-limited incoherent imaging: the
1258
+ optical transfer function as the normalized pupil autocorrelation); J. C. Wyant, K. Creath, "Basic
1259
+ Wavefront Aberration Theory for Optical Metrology", *Applied Optics and Optical Engineering* XI,
1260
+ ch. 1, Academic Press, 1992, §II "Aberration-Free Image", eqs. 1–4, free author copy at
1261
+ <https://wp.optics.arizona.edu/jcwyant/wp-content/uploads/sites/13/2016/08/03-BasicAberrations_and_Optical_Testing.pdf>
1262
+ (the Airy irradiance at the effective f-number, the 2.44·λ·N Airy disk diameter and the encircled
1263
+ energy 1 − J₀² − J₁²); MIT OpenCourseWare 2.71 Optics (Spring 2009), lectures 21–22, free at
1264
+ <https://ocw.mit.edu/courses/2-71-optics-spring-2009/resources/mit2_71s09_lec22/> (the intensity PSF as the transform of the
1265
+ pupil autocorrelation and the circular-pupil MTF); F. W. J. Olver et al. (eds.), *NIST Digital
1266
+ Library of Mathematical Functions*, release 1.2.8 (2026-09-15), free at <https://dlmf.nist.gov/>,
1267
+ §10.6(i) (eqs. 10.6.2 and 10.6.3: J₀′ = −J₁ and J₁′ = J₀ − J₁/x, from which the encircled energy
1268
+ follows), eq. 10.17.3 and §10.17(iii) (Hankel's large-argument expansion of J₀ and J₁ and its error
1269
+ bound, used for cells much larger than the pattern) and eq. 10.9.2 (Bessel's integral, used as an
1270
+ independent test). The original analysis is G. B. Airy, "On the Diffraction of an Object-glass with
1271
+ Circular Aperture", *Transactions of the Cambridge Philosophical Society* 5, 1835, pp. 283–291. The
1272
+ engine's implementation and its tests are its own (see [PSF foundation](https://github.com/Photivra/photivra/blob/main/docs/PSF_FOUNDATION.md#diffraction-limited-sampled-profile-21)).
1273
+ - Print planning (`calculatePrintPlan`, `calculatePrintSizeLimit`): Z. Alomar, K. D. Bojikian, "Visual Acuity
1274
+ Testing in Adults", EyeWiki, American Academy of Ophthalmology, last edited June 16, 2026, free to read at
1275
+ <https://eyewiki.aao.org/Visual_Acuity_Testing_in_Adults> (the minimum angle of resolution of an ideal eye under optimal
1276
+ conditions is about 1 minute of arc; 20/20 letters are built from 1-arcmin elements). One arcminute per pixel is normal
1277
+ (20/20) acuity under optimal conditions and an approximate rule of thumb: some viewers resolve finer detail and many
1278
+ coarser. The planner uses the criterion the caller declares.
1128
1279
  - R. Palum, "Anti-Aliasing Filter Analysis for Digital Cameras", Proc. IS&T International Congress of
1129
1280
  Imaging Science (ICIS '06), 2006, free at
1130
1281
  <https://www.imaging.org/common/uploaded%20files/pdfs/Papers/2006/ICIS-0-736/33683.pdf>
package/docs/USAGE.md CHANGED
@@ -7501,6 +7501,21 @@ The chart presets are also available on their own:
7501
7501
  - `createPlanarSceneRadianceProvider()` turns a chart into an `evaluateRadiance` callback for the native
7502
7502
  environment RAW contract.
7503
7503
 
7504
+ ### Display view of a capture
7505
+
7506
+ `simulateDisplayView(options, view)` takes a capture request and a display size and returns the real
7507
+ full-resolution capture resized to that size (area average in linear light), estimated at a cost that follows the
7508
+ display pixels. It is a preview (`kind: "display-view"`), never a capture; see [Display view](QUICK_START.md#display-view)
7509
+ and the design and validation in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md).
7510
+
7511
+ ```ts
7512
+ import { simulateDisplayView } from "@photivra/engine";
7513
+
7514
+ const view = await simulateDisplayView({ scene: { preset: "slanted-edge" }, sensor: { pixelPitchMicrometers: 3 }, output: { width: 600, height: 400 } },
7515
+ { width: 60, height: 40 });
7516
+ console.log(view.kind, view.view.scaleX, view.assumptions.find((a) => a.id === "view.definition")?.value);
7517
+ ```
7518
+
7504
7519
  ### Facade 2.0 options: output, demosaic, optics and motion
7505
7520
 
7506
7521
  Each 2.0 option is opt-in and uses an existing public contract. Without them, the facade's output is
@@ -11,5 +11,5 @@ version they were made with. Proof-of-concept (POC), schema and model contracts
11
11
  own versions. Release checks confirm it matches package.json and the packed package.
12
12
 
13
13
  ```ts
14
- ENGINE_API_VERSION = "2.5.0" as const
14
+ ENGINE_API_VERSION = "2.6.0" as const
15
15
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
- "packageVersion": "2.5.0",
3
- "engineApiVersion": "2.5.0",
2
+ "packageVersion": "2.6.0",
3
+ "engineApiVersion": "2.6.0",
4
4
  "exports": [
5
5
  {
6
6
  "name": "AbsoluteUncertaintyEstimate",
@@ -2582,6 +2582,11 @@
2582
2582
  "source": "src/core/spectral-composition.ts",
2583
2583
  "kind": "type"
2584
2584
  },
2585
+ {
2586
+ "name": "DisplayViewResult",
2587
+ "source": "src/facade/simulate-capture.ts",
2588
+ "kind": "type"
2589
+ },
2585
2590
  {
2586
2591
  "name": "DistributeIntegratedQuantityAcrossDiscreteLinesInput",
2587
2592
  "source": "src/core/spectral-composition.ts",
@@ -7907,6 +7912,11 @@
7907
7912
  "source": "src/facade/simulate-capture.ts",
7908
7913
  "kind": "runtime"
7909
7914
  },
7915
+ {
7916
+ "name": "SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS",
7917
+ "source": "src/facade/simulate-capture.ts",
7918
+ "kind": "runtime"
7919
+ },
7910
7920
  {
7911
7921
  "name": "simulateCapture",
7912
7922
  "source": "src/facade/simulate-capture.ts",
@@ -8002,6 +8012,16 @@
8002
8012
  "source": "src/capture/simulated-capture.ts",
8003
8013
  "kind": "type"
8004
8014
  },
8015
+ {
8016
+ "name": "simulateDisplayView",
8017
+ "source": "src/facade/simulate-display-view.ts",
8018
+ "kind": "runtime"
8019
+ },
8020
+ {
8021
+ "name": "SimulateDisplayViewOptions",
8022
+ "source": "src/facade/simulate-capture.ts",
8023
+ "kind": "type"
8024
+ },
8005
8025
  {
8006
8026
  "name": "simulateEnvironmentSensorRawFrame",
8007
8027
  "source": "src/capture/environment-raw-producer.ts",
@@ -2,6 +2,42 @@
2
2
 
3
3
  The package and root API versions are recorded once in [Navigation](../API_REFERENCE.md) · [Developer guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Generated signatures retain independent schema/model versions. Only the exports listed here are root-package contracts; module-local helpers are not supported deep imports.
4
4
 
5
+ ## DisplayViewResult
6
+
7
+ Result of {@link simulateDisplayView }: the full-resolution capture resized for display, estimated; not a capture.
8
+
9
+ ```ts
10
+ export interface DisplayViewResult {
11
+ kind: "display-view";
12
+ /** Human-readable label for a user interface. */
13
+ label: string;
14
+ facadeVersion: typeof SIMULATE_CAPTURE_FACADE_VERSION;
15
+ engineApiVersion: string;
16
+ /** The display view (or its region): interleaved sRGB samples, row by row from the top-left. */
17
+ image: { width: number; height: number; format: "srgb-8" | "srgb-16"; samples: Uint8Array | Uint16Array };
18
+ /**
19
+ * The view: display size, the capture frame it shows (output pixels), real pixels per display pixel along each axis,
20
+ * the samples per axis and, with `view.region`, the rendered rectangle.
21
+ */
22
+ view: { width: number; height: number; frameWidth: number; frameHeight: number; scaleX: number; scaleY: number; samplesPerAxis: number;
23
+ region?: { x: number; y: number; width: number; height: number } };
24
+ /** The resolved exposure of the capture this view shows. */
25
+ exposure: { mode: SimulateCaptureExposureMode; settings: { fNumber: number; shutterSeconds: number; iso: number }; ev100: number };
26
+ scene: SimulateCaptureResult["scene"];
27
+ /**
28
+ * Work done: real pixels sampled, real-site expectations evaluated, scene queries, display pixels sampled in full (detail) or with
29
+ * a quarter of the samples per axis (uniform), display pixels whose value includes the AHD low-signal correction, and wall-clock
30
+ * milliseconds (diagnostic only; never affects the samples).
31
+ */
32
+ diagnostics: { sampleCount: number; siteEvaluations: number; sceneLookups: number; detailPixelCount: number; uniformPixelCount: number; ahdCorrectedPixelCount: number;
33
+ timingsMs: { view: number; total: number } };
34
+ provenance: { scientificStatus: "approximation"; cameraPresetId: string; cameraPresetVersion: string; evidence: readonly EvidenceProvenance[];
35
+ composedContracts: readonly string[]; limitations: readonly string[] };
36
+ /** The capture request's assumptions, then `view.definition`, `view.estimation`, `view.noise`, `view.demosaic` and `view.source`. */
37
+ assumptions: readonly SimulateCaptureAssumption[];
38
+ }
39
+ ```
40
+
5
41
  ## IdealReferenceResult
6
42
 
7
43
  Result of {@link simulateIdealReference}: an ideal reference image, not a capture (no RAW, plan or request).
@@ -65,6 +101,14 @@ Version of how the facade builds requests. It changes whenever the same input wo
65
101
  SIMULATE_CAPTURE_FACADE_VERSION = "1.0.0" as const
66
102
  ```
67
103
 
104
+ ## SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS
105
+
106
+ Default stratified samples per axis of a display view (`view.samplesPerAxis`): 16 × 16 sampled real pixels per display pixel with detail, 4 × 4 where it is uniform.
107
+
108
+ ```ts
109
+ SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS = 16
110
+ ```
111
+
68
112
  ## simulateCapture
69
113
 
70
114
  Simulates one still capture of a planar scene and returns the developed image, the packed RAW, the full
@@ -285,7 +329,9 @@ export interface SimulateCaptureOptions {
285
329
  * the whole render: framing, metering, exposure, noise seeds (absolute native site indices), rolling-shutter row
286
330
  * timing, diffraction and demosaic all use the whole frame. Use it to tile a large frame across workers or to
287
331
  * render a 100 % crop. `result.image` is the region; `result.region` reports it. Not combinable with
288
- * `whiteBalance: "auto"`, whose statistics would come from the region only.
332
+ * `whiteBalance: "auto"`, whose statistics would come from the region only. From 2.6 the diffraction convolution
333
+ * covers only the region plus the kernel and filter reach, so a small region of a large frame costs about as much
334
+ * as a capture of the region's size.
289
335
  */
290
336
  region?: { x: number; y: number; width: number; height: number };
291
337
  };
@@ -503,6 +549,24 @@ export type SimulateCaptureWhiteBalance =
503
549
  | { channelGains: { red: number; green: number; blue: number } };
504
550
  ```
505
551
 
552
+ ## SimulateDisplayViewOptions
553
+
554
+ The display size and sampling of {@link simulateDisplayView }.
555
+
556
+ ```ts
557
+ export interface SimulateDisplayViewOptions {
558
+ /** Display pixels, 1 to the capture's output width and height. */
559
+ width: number;
560
+ height: number;
561
+ /** Stratified Monte Carlo samples per axis for each display pixel's expected value (1–32, default 8: 64 real pixels). */
562
+ samplesPerAxis?: number;
563
+ /** "srgb-8" (default) or "srgb-16". */
564
+ format?: "srgb-8" | "srgb-16";
565
+ /** Render only this rectangle of the display view, in display pixels; byte-identical to the same pixels of the whole view. */
566
+ region?: { x: number; y: number; width: number; height: number };
567
+ }
568
+ ```
569
+
506
570
  ## simulateIdealReference
507
571
 
508
572
  The ideal reference of a capture request (2.3): the same scene, framing, chart size, output size, region and pixel
@@ -0,0 +1,18 @@
1
+ # facade/simulate-display-view.ts public contracts
2
+
3
+ The package and root API versions are recorded once in [Navigation](../API_REFERENCE.md) · [Developer guide](https://github.com/Photivra/photivra/blob/main/docs/DEVELOPERS.md). Generated signatures retain independent schema/model versions. Only the exports listed here are root-package contracts; module-local helpers are not supported deep imports.
4
+
5
+ ## simulateDisplayView
6
+
7
+ The display view of a capture request: the image the real full-resolution capture (`simulateCapture(options)`) would
8
+ give after it is resized to `view.width` × `view.height` by area averaging in linear light, estimated at a cost that
9
+ follows the display pixels, not the capture's. Optics (lens diffraction, the anti-aliasing filter, the pixel aperture)
10
+ act at the real pixel pitch; the colour mosaic, demosaic and development are those of the real pixels; the noise is
11
+ that of the average of the real pixels, demosaic correlation included; exposure, white balance and tone are the
12
+ capture's. The result is labelled `kind: "display-view"`; it is not a capture.
13
+
14
+ ```ts
15
+ export async function simulateDisplayView(options: SimulateCaptureOptions, view: SimulateDisplayViewOptions): Promise<DisplayViewResult>;
16
+ ```
17
+
18
+ This call is asynchronous; byte hashing uses the caller runtime's Web Crypto.
@@ -86,6 +86,14 @@ export interface ChartFraming {
86
86
  coverage: { frameWidthFraction: number; frameHeightFraction: number };
87
87
  /** The chart's image on the sensor, mm. */
88
88
  chartImageMm: { width: number; height: number };
89
+ /**
90
+ * 2.6: the thin-lens close-up light factor at the focus distance, 1/(1 + m)² with m = v/s: the image-plane irradiance
91
+ * relative to the same lens and f-number focused at infinity (working f-number N·(1 + m)). It is the factor
92
+ * `simulateCapture` applies through the scene-to-sensor irradiance (same function, same value).
93
+ */
94
+ closeUpTransmission: number;
95
+ /** 2.6: the same factor in stops, 2·log₂(1 + m): how much darker the image is than at infinity focus. */
96
+ closeUpLossStops: number;
89
97
  /** The generic prime of this equivalent focal length, when there is one. */
90
98
  prime: GenericPrimeLens | null;
91
99
  /** The generic prime rule max(0.10 m, f·(1 + 1/0.15)) at this focal length (the prime's own value when there is one). */
@@ -119,6 +127,14 @@ export interface SubjectFraming {
119
127
  framedHeightM: number;
120
128
  /** Fraction of the frame width and height the chart covers (1 on the fitted side; above 1 means cropped). */
121
129
  coverage: { frameWidthFraction: number; frameHeightFraction: number };
130
+ /**
131
+ * 2.6: the thin-lens close-up light factor at the focus distance, 1/(1 + m)² with m = v/s: the image-plane irradiance
132
+ * relative to the same lens and f-number focused at infinity (working f-number N·(1 + m)). It is the factor
133
+ * `simulateCapture` applies through the scene-to-sensor irradiance (same function, same value).
134
+ */
135
+ closeUpTransmission: number;
136
+ /** 2.6: the same factor in stops, 2·log₂(1 + m): how much darker the image is than at infinity focus. */
137
+ closeUpLossStops: number;
122
138
  /** The generic prime of this equivalent focal length, when there is one. */
123
139
  prime: GenericPrimeLens | null;
124
140
  /** The generic prime rule max(0.10 m, f·(1 + 1/0.15)) at this focal length (the prime's own value when there is one). */