@photivra/engine 2.4.1 → 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 (85) hide show
  1. package/CHANGELOG.md +53 -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 +229 -5
  43. package/dist/facade/simulate-capture.js +119 -35
  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 +78 -0
  49. package/dist/facade/subject-framing.js +69 -20
  50. package/dist/facade/subject-framing.js.map +1 -1
  51. package/dist/index.d.ts +3 -2
  52. package/dist/index.js +3 -2
  53. package/dist/index.js.map +1 -1
  54. package/dist/optics/diffraction-psf-profile.d.ts +59 -3
  55. package/dist/optics/diffraction-psf-profile.js +147 -5
  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 +16 -1
  76. package/docs/QUICK_START.md +245 -9
  77. package/docs/USAGE.md +15 -0
  78. package/docs/api/core-version.md +1 -1
  79. package/docs/api/exports.json +37 -2
  80. package/docs/api/facade-simulate-capture.md +70 -2
  81. package/docs/api/facade-simulate-display-view.md +18 -0
  82. package/docs/api/facade-subject-framing.md +83 -0
  83. package/docs/api/optics-diffraction-psf-profile.md +8 -1
  84. package/docs/api/output-print-plan.md +8 -1
  85. 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.4.1 / root contract 2.4.1** 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,16 +1203,28 @@ 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)
1208
1220
 
1209
1221
  | Export | Kind |
1210
1222
  | --- | --- |
1223
+ | [`calculateChartFraming`](api/facade-subject-framing.md#calculatechartframing) | Runtime |
1224
+ | [`CalculateChartFramingInput`](api/facade-subject-framing.md#calculatechartframinginput) | Type |
1211
1225
  | [`calculateSubjectDistanceForFraming`](api/facade-subject-framing.md#calculatesubjectdistanceforframing) | Runtime |
1212
1226
  | [`CalculateSubjectDistanceForFramingInput`](api/facade-subject-framing.md#calculatesubjectdistanceforframinginput) | Type |
1227
+ | [`ChartFraming`](api/facade-subject-framing.md#chartframing) | Type |
1213
1228
  | [`SubjectFraming`](api/facade-subject-framing.md#subjectframing) | Type |
1214
1229
  | [`SubjectFramingFit`](api/facade-subject-framing.md#subjectframingfit) | Type |
1215
1230
 
@@ -98,7 +98,7 @@ modelling choices (such as "no lens PSF") as `kind: "model"`.
98
98
  | `output.highlightMode` | `"clip"` | Or `"reconstruct"`. |
99
99
  | `output.whiteBalance` | `"scene-illuminant"` | Also `"auto"`, `"none"`, `{ kelvin, tint? }` and `{ channelGains }`. |
100
100
  | `output.jpegQuantizationStep` | 2 | JPEG only: constant step 1–255, not a manufacturer quality scale. |
101
- | `sensor.pixelPitchMicrometers` | 6 | 0.5–100. The sensor is `width × pitch` wide (see [Geometry](#geometry)); the ISO readout scales with the pixel area (see [ISO and pixel size](#iso-and-pixel-size)). |
101
+ | `sensor.pixelPitchMicrometers` | 6 | 0.5–1000 (2.5; 0.5–100 before). The sensor is `width × pitch` wide (see [Geometry](#geometry)); the ISO readout scales with the pixel area (see [ISO and pixel size](#iso-and-pixel-size)). Above 100 µm the capture is a downsized view of a whole frame (see [Whole-frame views](#whole-frame-views-25)). |
102
102
  | `sensor.spectralStepNanometers` | 20 | Spectral quadrature over 380–780 nm. |
103
103
  | `sensor.temporalSampleCount` | 1, or 8 when the camera rotates | Midpoints across each site's exposure window. |
104
104
  | `sensor.noiseSeed` | 1 | Output is deterministic for a given seed. |
@@ -151,7 +151,8 @@ console.log(a3.scene.chart?.frameWidthFraction, a3.scene.chart?.magnification, a
151
151
  The simulated sensor has real pixels of `sensor.pixelPitchMicrometers`. A 64 × 64 capture with 6 µm
152
152
  pixels is therefore a 0.38 mm crop of a large sensor, not a downscaled full frame. This keeps the
153
153
  signal per pixel, and so the noise and the ISO behaviour, physically consistent with the tier's
154
- sensor values.
154
+ sensor values. To see a whole frame at a small output size instead, raise the pitch (see
155
+ [Whole-frame views](#whole-frame-views-25)).
155
156
 
156
157
  ### Generic prime lenses (2.3)
157
158
 
@@ -162,7 +163,8 @@ real products, and carry no brand:
162
163
 
163
164
  - **Focal length** = equivalent ÷ the diagonal crop factor of the sensor (`calculateEquivalentFocalLength35Mm`,
164
165
  36 × 24 mm reference). In `simulateCapture` the sensor is the simulated frame, output width × height ×
165
- pixel pitch: to simulate a whole full-frame sensor at 768 × 512, set the pitch to 36 mm / 768 = 46.875 µm.
166
+ pixel pitch: to simulate a whole full-frame sensor at 768 × 512, set the pitch to 36 mm / 768 = 46.875 µm (or 750 µm at
167
+ 48 × 32, see [Whole-frame views](#whole-frame-views-25)).
166
168
  - **Aperture range:** widest f/2.8 (10–16 mm), f/1.8 (20–28 mm), f/1.4 (35–85 mm), f/2 (105–135 mm),
167
169
  f/2.8 (200–300 mm), f/4 (400–600 mm), f/5.6 (800 mm), f/8 (1200 mm); narrowest f/16 up to 16 mm, f/22 up
168
170
  to 135 mm, f/32 beyond. Any f-number inside the range is accepted; `fNumbers` lists the marked
@@ -190,7 +192,8 @@ cropped on its other side). The result reports the magnification, image distance
190
192
  `coverage` (the fraction of the frame width and height the chart covers), the generic prime of that
191
193
  equivalent focal length if there is one, the prime family's minimum focus distance at that focal length
192
194
  and `belowMinimumFocus`, plus assumptions in the same form as `simulateCapture`. It is the same framing
193
- `simulateCapture` uses, so `chartSize` at the returned distance gives `frameWidthFraction` 1 (tested).
195
+ `simulateCapture` uses, so `chartSize` at the returned distance gives `frameWidthFraction` 1 (tested). For the coverage at any other distance, see
196
+ [Chart coverage at any distance](#chart-coverage-at-any-distance-25).
194
197
 
195
198
  An A3 chart (0.42 × 0.28 m) on a full-frame sensor: 0.127 m at 10 mm, 0.633 m at 50 mm, 2.53 m at
196
199
  200 mm and 15.2 m at 1200 mm (magnification 0.0857, minimum focus 0.10, 0.38, 1.53 and 9.2 m). On a
@@ -208,6 +211,101 @@ const capture = await simulateCapture({ scene: { preset: "siemens-star" }, lens:
208
211
  console.log(capture.scene.chart?.frameWidthFraction);
209
212
  ```
210
213
 
214
+ ### Chart coverage at any distance (2.5)
215
+
216
+ `calculateChartFraming({ sensor, focalLengthMm | equivalentFocalLengthMm, chart, subjectDistanceM, focusDistanceM? })`
217
+ answers the inverse question: how much of the frame does a chart of physical size `chart` (`{ widthM, heightM? }`, as
218
+ for `chartSize`) cover at a given distance? It uses the facade's own thin-lens framing: image distance
219
+ v = f·s/(s − f) at the focus distance s (default: the subject distance), magnification m = v/d on the chart plane at
220
+ d, and a frame that spans sensor side ÷ m. The result reports the magnification, image distance, framed plane size,
221
+ `coverage` (`frameWidthFraction` and `frameHeightFraction`; above 1 when the chart is larger than the frame and
222
+ cropped), the chart's image size in millimetres, the generic prime of that equivalent focal length if there is one,
223
+ the prime family's minimum focus distance and `belowMinimumFocus` (the subject or focus distance is closer than it,
224
+ which `simulateCapture` with `lens.prime` refuses), plus assumptions.
225
+
226
+ An A3 chart (0.42 × 0.28 m) with the 50 mm full-frame prime covers 130 % of the frame width at 0.5 m (cropped),
227
+ 61.4 % at 1 m, 29.9 % at 2 m and 11.8 % at 5 m (magnification 0.111, 0.0526, 0.0256 and 0.0101).
228
+
229
+ ```ts
230
+ import { calculateChartFraming } from "@photivra/engine";
231
+
232
+ const coverage = calculateChartFraming({ sensor: { widthMm: 36, heightMm: 24 }, equivalentFocalLengthMm: 50, chart: { widthM: 0.42, heightM: 0.28 }, subjectDistanceM: 2 });
233
+ console.log(coverage.coverage.frameWidthFraction, coverage.magnification, coverage.belowMinimumFocus);
234
+ ```
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
+
246
+ Tests check that at `calculateSubjectDistanceForFraming`'s distance the coverage is 1 on the fitted side (every fit,
247
+ three sensor shapes, four primes), and that at any distance it equals `result.scene.chart` from `simulateCapture` with
248
+ `chartSize` (magnification, both coverage fractions, the chart's image size and the framed size, within 1e-12
249
+ relative), including a separate `lens.focusDistanceM`.
250
+
251
+ ### Whole-frame views (2.5)
252
+
253
+ To render the whole sensor frame at a small output size, set the pitch to sensor width ÷ output width:
254
+ 36 mm at 48 pixels is 750 µm, 44 mm at 48 pixels 917 µm. From 2.5 the pitch may be up to 1000 µm. The
255
+ frame is then the real sensor's size, so `lens.prime` (its crop factor and minimum focus),
256
+ `calculateSubjectDistanceForFraming` and `chartSize` all match that sensor, and the image is a shrunk view of
257
+ the whole frame. No new option is needed: the facade has always simulated a sensor `width × pitch` wide.
258
+
259
+ ```ts
260
+ import { simulateCapture } from "@photivra/engine";
261
+
262
+ const view = await simulateCapture({ scene: { preset: "dead-leaves" }, lens: { prime: 35, fNumber: 2.8 }, subjectDistanceM: 3,
263
+ sensor: { pixelPitchMicrometers: 36000 / 48, pixelAperture: "box" }, output: { width: 48, height: 32 } });
264
+ console.log(view.assumptions.find((a) => a.id === "sensor.wholeFrameView")?.value); // { pixelPitchMicrometers: 750, frameWidthMm: 36, frameHeightMm: 24 }
265
+ ```
266
+
267
+ Above 100 µm (larger than any camera photosite) the assumption `sensor.wholeFrameView` records the frame size.
268
+ What changes and what does not:
269
+
270
+ - **Brightness is the same as at any pitch.** The ISO area normalization (below) multiplies the conversion gain
271
+ and saturation by the area ratio, up to 27 778 at 1000 µm, so the RAW codes and the image are those of a small
272
+ pitch. Tested at 150–1000 µm against 6 µm: every CFA channel's mean RAW code within half a code (plus the 6 µm
273
+ reference's own noise), the 8-bit sRGB mean within one code, and a 64× overexposure clipping at the same white
274
+ code. Charge counts reach 1.7 × 10⁹ e⁻; the Poisson sampler's normal branch above 10⁹ keeps variance/mean at 1.
275
+ - **Noise is that of a binned, downsized view, not the real sensor's per-pixel noise.** Each output pixel
276
+ collects the charge of its whole square, so its shot noise equals that of all the real photosites in the
277
+ square summed (Poisson counts add) or averaged, and its SNR √N_e grows in proportion to the pitch. On an 18 %
278
+ card at f/8, 1/125 s, ISO 100 a 917 µm pixel collects about 1.6 × 10⁸ e⁻ (SNR about 12 700): under a fifth of a
279
+ RAW code, so the image looks noise-free, as a downsized photograph does. Read noise and dark current are applied
280
+ once per output pixel, not once per real photosite, so in deep shadows the noise is lower than a real sensor's
281
+ binned sum (√n times the read noise for n photosites). Tested by photon transfer at 150, 458 and 917 µm, ISO
282
+ 12 800: the code variance matches N_e/K² + σ_read²/K² + 1/12 within 12 % (EMVA 1288 Release 4.0, §2, free).
283
+ - **Sampling.** The default point aperture samples one point of each square, so detail finer than the pitch
284
+ aliases at full contrast. Use `sensor.pixelAperture: "box"` (or `{ kind: "box", samplesPerAxis }` up to 16) to
285
+ average over the square, or the four-spot filter, whose separation scales with the pitch.
286
+ - **Diffraction.** The Airy pattern (a few µm) is far smaller than a pixel. The default kernel is 3 × 3 sites and
287
+ the centre site keeps 1 − 8√2/(π³β) of the energy, β = pitch/(λ·N_w): 99.96 % at 780 nm, f/1.4 and 917 µm.
288
+ Cells larger than 192 cut-off periods are integrated in real space (see [PSF foundation](https://github.com/Photivra/photivra/blob/main/docs/PSF_FOUNDATION.md#diffraction-limited-sampled-profile-21)),
289
+ so a 48 × 32 view of a 44 mm frame with `lens.diffraction` at f/1.4 took 1.0 s (the frequency-domain cells took
290
+ 296 s there; both measured on the same machine in Node).
291
+ - **Motion, rolling shutter, anti-aliasing filter, AHD and the box aperture** follow the same angles at any pitch:
292
+ a yaw of ω moves the image ω·f/pitch pixels per second. A test renders each of them at 917 µm and at 9.17 µm with
293
+ the same 50 mm-equivalent prime (so f scales with the frame) and finds the same image within the small pitch's
294
+ own noise (mean difference under 1 code; AHD under 2.5).
295
+ - **Region tiles** stay byte-identical to the whole render (tested with diffraction, the four-spot filter, the box
296
+ aperture, AHD, rotation and a rolling shutter at 640 and 917 µm).
297
+ - **WebGPU.** `simulateCapture` runs on the CPU. The optional `webgpu-f32` sensor stage of
298
+ `createImageKernelAccelerator` handles capacities up to 2^24 e⁻, which the tier's 60 000 e⁻ reach at 100 µm; above
299
+ that it fails closed with `unsupported-float32-domain` (see [WebGPU backend](https://github.com/Photivra/photivra/blob/main/docs/WEBGPU.md)). The `cpu-reference`
300
+ backend runs it. The convolution, temporal-sum and chart stages work in sites and frame units, so the pitch does
301
+ not enter them.
302
+
303
+ Requests at 0.5–100 µm are byte-identical to 2.4.1.
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
+
211
309
  ### ISO and pixel size
212
310
 
213
311
  The tier's ISO signal chain (conversion gain, saturation signal and read noise per ISO setting) is
@@ -226,7 +324,8 @@ independent of pixel size, so the same exposure gives the same image brightness
226
324
  What you see: brightness stays the same, and per-pixel noise rises as pixels shrink, because a
227
325
  smaller pixel collects fewer electrons for the same exposure (shot-noise SNR = √N_e). At f/8, 1/125 s,
228
326
  ISO 100 on an 18 % grey card, the 8-bit sRGB mean is 101.2 at 30, 6 and 3 µm, and the green standard
229
- deviation is 0.18, 0.93 and 1.83 codes. Below about 1 µm the 8-bit sRGB mean falls slightly (about 1 code at 0.55 µm)
327
+ deviation is 0.18, 0.93 and 1.83 codes. At whole-frame pitches (above 100 µm) the per-pixel noise falls below one
328
+ code; see [Whole-frame views](#whole-frame-views-25). Below about 1 µm the 8-bit sRGB mean falls slightly (about 1 code at 0.55 µm)
230
329
  although the linear RAW mean is unchanged (within 0.3 %): strong noise passed through the concave
231
330
  sRGB curve lowers the mean of the encoded values.
232
331
 
@@ -601,6 +700,14 @@ The optics options feed only what the environment site contract accepts:
601
700
  lit circular pupil (scalar Fraunhofer diffraction), aberration-free and in focus, with pupil diameter
602
701
  f/N and the thin-lens image distance (working f-number N·v/f). Each kernel holds the PSF energy of
603
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).
604
711
  - The kernel window reaches the third dark ring at the longest node by default (2, 4 and 7 sites at
605
712
  f/4, f/8 and f/16 with 6 µm pixels); set `kernelRadiusSamples` (0–32) to change it. The Airy tail
606
713
  outside the window (2–6 % of the energy with the default) is listed per wavelength in the
@@ -842,6 +949,8 @@ Ratios below about 1.1× are within the run-to-run spread (the three default run
842
949
  (anti-aliasing spots, pupil rays, PSF taps, temporal samples) multiply the run time by about the same
843
950
  factor, and output-side options cost almost nothing.
844
951
 
952
+ The cost of `simulateDisplayView` is in [Display view](#display-view).
953
+
845
954
  ### Measuring cost on the user's device
846
955
 
847
956
  From 2.1 every result says what ran and how long each stage took, so an application can decide on the
@@ -960,10 +1069,16 @@ How it stays identical:
960
1069
  own edges the region raster ends exactly where the whole frame's does, so `borderHandling: "mirror"`
961
1070
  mirrors at the same place. AHD reads at most 5 sites away, so its edge-directed choices see the same
962
1071
  neighbourhood.
963
- - **Diffraction:** the object-space convolution is computed in whole-width bands of 32 native rows
964
- around the region's rows, as in the whole render (the same bands, kernels and summation order). A
965
- frame split into k tile columns therefore repeats that convolution k times; tile by rows (full-width
966
- 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.
967
1082
 
968
1083
  Measured on one core (Node 24, 4-vCPU Intel Xeon 2.80 GHz, built `dist`, one run each): a 256 × 256
969
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
@@ -971,6 +1086,25 @@ one after another; with `lens.diffraction` 10.2 s whole and 12.9 s as four tiles
971
1086
  convolution bands). A 64 × 64 crop of a 4000 × 3000 frame took 0.63 s, the same as a plain 64 × 64
972
1087
  capture. Tiles in parallel workers divide the wall time by the number of cores.
973
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
+
974
1108
  `result.image` holds the region, `result.raw` and `result.plan` describe the region's native sites (the
975
1109
  plan's active area is the region; `result.region.nativeRect` gives them in the whole frame), and
976
1110
  `result.region` reports the rectangle and the frame. `diagnostics.options` lists `output.region`.
@@ -1032,6 +1166,81 @@ const [capture, reference] = await Promise.all([simulateCapture(request), simula
1032
1166
  console.log(reference.kind, reference.label, capture.image.samples.length === reference.image.samples.length);
1033
1167
  ```
1034
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
+
1035
1244
  ## Not available
1036
1245
 
1037
1246
  The facade offers no atmospheric haze or polarizer (see [Optics](#optics)), flash, video, HDR brackets
@@ -1040,6 +1249,33 @@ provided, for rights reasons.
1040
1249
 
1041
1250
  ## Sources
1042
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.
1043
1279
  - R. Palum, "Anti-Aliasing Filter Analysis for Digital Cameras", Proc. IS&T International Congress of
1044
1280
  Imaging Science (ICIS '06), 2006, free at
1045
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.4.1" as const
14
+ ENGINE_API_VERSION = "2.6.0" as const
15
15
  ```