@photivra/engine 2.5.0 → 2.7.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 (87) hide show
  1. package/CHANGELOG.md +129 -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-radiance-batch.js +3 -2
  31. package/dist/facade/chart-radiance-batch.js.map +1 -1
  32. package/dist/facade/chart-scenes.d.ts +11 -0
  33. package/dist/facade/chart-scenes.js +21 -7
  34. package/dist/facade/chart-scenes.js.map +1 -1
  35. package/dist/facade/diffraction-provider.d.ts +10 -0
  36. package/dist/facade/diffraction-provider.js +38 -17
  37. package/dist/facade/diffraction-provider.js.map +1 -1
  38. package/dist/facade/display-view.d.ts +213 -0
  39. package/dist/facade/display-view.js +1543 -0
  40. package/dist/facade/display-view.js.map +1 -0
  41. package/dist/facade/scene-radiance.d.ts +45 -5
  42. package/dist/facade/scene-radiance.js +97 -28
  43. package/dist/facade/scene-radiance.js.map +1 -1
  44. package/dist/facade/simulate-capture.d.ts +233 -5
  45. package/dist/facade/simulate-capture.js +120 -37
  46. package/dist/facade/simulate-capture.js.map +1 -1
  47. package/dist/facade/simulate-display-view.d.ts +45 -0
  48. package/dist/facade/simulate-display-view.js +214 -0
  49. package/dist/facade/simulate-display-view.js.map +1 -0
  50. package/dist/facade/subject-framing.d.ts +16 -0
  51. package/dist/facade/subject-framing.js +8 -2
  52. package/dist/facade/subject-framing.js.map +1 -1
  53. package/dist/index.d.ts +3 -2
  54. package/dist/index.js +3 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/optics/diffraction-psf-profile.d.ts +9 -2
  57. package/dist/optics/diffraction-psf-profile.js +33 -4
  58. package/dist/optics/diffraction-psf-profile.js.map +1 -1
  59. package/dist/optics/irradiance-convolution.js +1 -1
  60. package/dist/optics/irradiance-convolution.js.map +1 -1
  61. package/dist/optics/scene-to-sensor-irradiance.d.ts +7 -0
  62. package/dist/optics/scene-to-sensor-irradiance.js +10 -5
  63. package/dist/optics/scene-to-sensor-irradiance.js.map +1 -1
  64. package/dist/output/print-plan.d.ts +8 -1
  65. package/dist/output/print-plan.js +8 -1
  66. package/dist/output/print-plan.js.map +1 -1
  67. package/dist/schema/scene-radiance.d.ts +16 -0
  68. package/dist/schema/scene-radiance.js +25 -4
  69. package/dist/schema/scene-radiance.js.map +1 -1
  70. package/dist/sensor/raw-readout.d.ts +12 -0
  71. package/dist/sensor/raw-readout.js +131 -9
  72. package/dist/sensor/raw-readout.js.map +1 -1
  73. package/dist/sensor/stochastic-seed.d.ts +13 -0
  74. package/dist/sensor/stochastic-seed.js +42 -8
  75. package/dist/sensor/stochastic-seed.js.map +1 -1
  76. package/docs/AGENT_GUIDE.md +1 -0
  77. package/docs/API_REFERENCE.md +19 -1
  78. package/docs/QUICK_START.md +203 -4
  79. package/docs/USAGE.md +15 -0
  80. package/docs/api/core-version.md +1 -1
  81. package/docs/api/exports.json +52 -2
  82. package/docs/api/facade-scene-radiance.md +34 -1
  83. package/docs/api/facade-simulate-capture.md +66 -2
  84. package/docs/api/facade-simulate-display-view.md +57 -0
  85. package/docs/api/facade-subject-framing.md +16 -0
  86. package/docs/api/output-print-plan.md +8 -1
  87. 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":"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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\";\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.7.0 / root contract 2.7.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) |
@@ -1161,6 +1162,8 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1161
1162
 
1162
1163
  | Export | Kind |
1163
1164
  | --- | --- |
1165
+ | [`CALLER_MATERIAL_SCENE_MAX_MATERIALS`](api/facade-scene-radiance.md#caller_material_scene_max_materials) | Runtime |
1166
+ | [`CallerMaterialScene`](api/facade-scene-radiance.md#callermaterialscene) | Type |
1164
1167
  | [`CallerRadianceScene`](api/facade-scene-radiance.md#callerradiancescene) | Type |
1165
1168
  | [`CallerReflectanceScene`](api/facade-scene-radiance.md#callerreflectancescene) | Type |
1166
1169
  | [`CaptureIlluminant`](api/facade-scene-radiance.md#captureilluminant) | Type |
@@ -1171,6 +1174,7 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1171
1174
  | [`PlanarSceneRadianceProvider`](api/facade-scene-radiance.md#planarsceneradianceprovider) | Type |
1172
1175
  | [`resolveCaptureIlluminant`](api/facade-scene-radiance.md#resolvecaptureilluminant) | Runtime |
1173
1176
  | [`ResolvedCaptureIlluminant`](api/facade-scene-radiance.md#resolvedcaptureilluminant) | Type |
1177
+ | [`SceneMaterialPoint`](api/facade-scene-radiance.md#scenematerialpoint) | Type |
1174
1178
  | [`ScenePlanePoint`](api/facade-scene-radiance.md#sceneplanepoint) | Type |
1175
1179
 
1176
1180
  ## facade/simulate-capture.ts
@@ -1179,11 +1183,13 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1179
1183
 
1180
1184
  | Export | Kind |
1181
1185
  | --- | --- |
1186
+ | [`DisplayViewResult`](api/facade-simulate-capture.md#displayviewresult) | Type |
1182
1187
  | [`IdealReferenceResult`](api/facade-simulate-capture.md#idealreferenceresult) | Type |
1183
1188
  | [`SIMULATE_CAPTURE_BOX_APERTURE_DEFAULT_SAMPLES`](api/facade-simulate-capture.md#simulate_capture_box_aperture_default_samples) | Runtime |
1184
1189
  | [`SIMULATE_CAPTURE_COST_OPTIONS`](api/facade-simulate-capture.md#simulate_capture_cost_options) | Runtime |
1185
1190
  | [`SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX`](api/facade-simulate-capture.md#simulate_capture_default_illuminance_lux) | Runtime |
1186
1191
  | [`SIMULATE_CAPTURE_FACADE_VERSION`](api/facade-simulate-capture.md#simulate_capture_facade_version) | Runtime |
1192
+ | [`SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS`](api/facade-simulate-capture.md#simulate_display_view_default_samples_per_axis) | Runtime |
1187
1193
  | [`simulateCapture`](api/facade-simulate-capture.md#simulatecapture) | Runtime |
1188
1194
  | [`SimulateCaptureAssumption`](api/facade-simulate-capture.md#simulatecaptureassumption) | Type |
1189
1195
  | [`SimulateCaptureBorderHandling`](api/facade-simulate-capture.md#simulatecaptureborderhandling) | Type |
@@ -1200,8 +1206,20 @@ Start here to find a function by task. [Using Photivra from an agent](AGENT_GUID
1200
1206
  | [`SimulateCaptureResult`](api/facade-simulate-capture.md#simulatecaptureresult) | Type |
1201
1207
  | [`SimulateCaptureSensorAntiAliasing`](api/facade-simulate-capture.md#simulatecapturesensorantialiasing) | Type |
1202
1208
  | [`SimulateCaptureWhiteBalance`](api/facade-simulate-capture.md#simulatecapturewhitebalance) | Type |
1209
+ | [`SimulateDisplayViewOptions`](api/facade-simulate-capture.md#simulatedisplayviewoptions) | Type |
1203
1210
  | [`simulateIdealReference`](api/facade-simulate-capture.md#simulateidealreference) | Runtime |
1204
1211
 
1212
+ ## facade/simulate-display-view.ts
1213
+
1214
+ [Detailed contracts](api/facade-simulate-display-view.md) · [Source](https://github.com/Photivra/photivra/blob/main/src/facade/simulate-display-view.ts)
1215
+
1216
+ | Export | Kind |
1217
+ | --- | --- |
1218
+ | [`PreparedDisplayView`](api/facade-simulate-display-view.md#prepareddisplayview) | Type |
1219
+ | [`PreparedDisplayViewRenderOptions`](api/facade-simulate-display-view.md#prepareddisplayviewrenderoptions) | Type |
1220
+ | [`prepareDisplayView`](api/facade-simulate-display-view.md#preparedisplayview) | Runtime |
1221
+ | [`simulateDisplayView`](api/facade-simulate-display-view.md#simulatedisplayview) | Runtime |
1222
+
1205
1223
  ## facade/subject-framing.ts
1206
1224
 
1207
1225
  [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
@@ -440,6 +454,34 @@ const vignette = await simulateCapture({
440
454
  console.log(vignette.request.whiteBalanceState?.source);
441
455
  ```
442
456
 
457
+ **Piecewise-constant scenes (2.7).** A scene made of a few uniform reflectances, such as a chart with a printed label,
458
+ can be passed as `{ kind: "material-function", sceneId, materials, materialAt }`. `materials` lists reflectances as a
459
+ chart's patches take them: a neutral number in [0, 1], or a reflectance spectrum covering 380–780 nm. `materialAt`
460
+ receives the plane point without a wavelength and returns the index of its material.
461
+
462
+ The radiance at each point is the same as a reflectance function returning that material's reflectance. Captures and
463
+ ideal references are therefore byte-identical to the reflectance-function form. The display view, though, tabulates
464
+ each material once and takes the chart presets' path.
465
+
466
+ ```ts
467
+ import { resolveChartScene, simulateDisplayView } from "@photivra/engine";
468
+
469
+ const chart = resolveChartScene({ scene: { preset: "siemens-star" }, aspect: 2 / 3 });
470
+ const label = chart.materials.length; // one extra material: a printed label in the lower left
471
+ const view = await simulateDisplayView({
472
+ scene: { kind: "material-function", sceneId: "star-with-label",
473
+ materials: [...chart.materials.map((m) => (m.kind === "neutral" ? m.reflectance : 0.5)), 0.05],
474
+ materialAt: (p) => (p.u < -0.6 && p.v < -0.5 ? label : chart.materialAt(p.u, p.v)) },
475
+ output: { width: 600, height: 400 }
476
+ }, { width: 300, height: 200 });
477
+ console.log(view.diagnostics.sceneLookups);
478
+ ```
479
+
480
+ The display view's expected values match the reflectance-function form to rounding, because they are the same
481
+ quantities summed in another order. Measured on the app's chart with a maker label (a 24 MP frame, the 2028 × 1352 view's
482
+ centre strip, one thread), the material form took 1.15 s instead of 3.2 s with the defaults, and 3.0 s instead of 111 s
483
+ with diffraction, the four-spot filter and AHD.
484
+
443
485
  ## White balance
444
486
 
445
487
  | Value | Meaning |
@@ -686,6 +728,14 @@ The optics options feed only what the environment site contract accepts:
686
728
  lit circular pupil (scalar Fraunhofer diffraction), aberration-free and in focus, with pupil diameter
687
729
  f/N and the thin-lens image distance (working f-number N·v/f). Each kernel holds the PSF energy of
688
730
  site-sized cells, one kernel per spectral node.
731
+ - Sources: the Airy pattern is the textbook Fraunhofer diffraction pattern of a circular aperture
732
+ (Born & Wolf, *Principles of Optics*, 7th ed., §8.5.2; Goodman, *Introduction to Fourier Optics*,
733
+ 4th ed., §4.4). The kernels come from the pupil's incoherent optical transfer function (Goodman,
734
+ 4th ed., §7.3; MIT OpenCourseWare 2.71, lectures 21–22, free). The first dark ring is at
735
+ 1.22·λ·N_w (j₁,₁/π with j₁,₁ = 3.8317, the first zero of J₁), so the Airy disk diameter is
736
+ 2.44·λ·N_w, and the energy inside radius r is 1 − J₀(x)² − J₁(x)², x = π·r/(λ·N_w) (Wyant &
737
+ Creath 1992, §II, eqs. 1–4, free). The Bessel-function relations and expansions come from the
738
+ NIST DLMF (free). Full references are under [Sources](#sources).
689
739
  - The kernel window reaches the third dark ring at the longest node by default (2, 4 and 7 sites at
690
740
  f/4, f/8 and f/16 with 6 µm pixels); set `kernelRadiusSamples` (0–32) to change it. The Airy tail
691
741
  outside the window (2–6 % of the energy with the default) is listed per wavelength in the
@@ -927,6 +977,8 @@ Ratios below about 1.1× are within the run-to-run spread (the three default run
927
977
  (anti-aliasing spots, pupil rays, PSF taps, temporal samples) multiply the run time by about the same
928
978
  factor, and output-side options cost almost nothing.
929
979
 
980
+ The cost of `simulateDisplayView` is in [Display view](#display-view).
981
+
930
982
  ### Measuring cost on the user's device
931
983
 
932
984
  From 2.1 every result says what ran and how long each stage took, so an application can decide on the
@@ -1045,10 +1097,16 @@ How it stays identical:
1045
1097
  own edges the region raster ends exactly where the whole frame's does, so `borderHandling: "mirror"`
1046
1098
  mirrors at the same place. AHD reads at most 5 sites away, so its edge-directed choices see the same
1047
1099
  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.
1100
+ - **Diffraction (2.6: region-limited):** the object-space convolution covers only the scene lattice the
1101
+ region's native sites query: the native rectangle plus the anti-aliasing and box-aperture offsets on
1102
+ each side, in bands of at most 32 native rows, with the kernel radius of real scene radiance around it.
1103
+ Each kept value is the same gather as in the whole render (the same kernel, the same taps in the same
1104
+ order, and every tap inside the grid), so the values are bit-identical while the work follows the
1105
+ region plus its halo instead of the frame width. In 2.3–2.5 the bands spanned the whole frame width, so
1106
+ a small crop of a wide frame paid for the full width and k tile columns repeated it k times. The noise
1107
+ needs no frame-wide state: each site's stream is seeded by a hash of the capture seed and its absolute
1108
+ native index (`seedSchedule: "murmur3-capture-seed-native-index-stream-v2"`), so it is counter-based,
1109
+ not a position in one frame-wide stream; the demosaic and the filters read only the margin above.
1052
1110
 
1053
1111
  Measured on one core (Node 24, 4-vCPU Intel Xeon 2.80 GHz, built `dist`, one run each): a 256 × 256
1054
1112
  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 +1114,25 @@ one after another; with `lens.diffraction` 10.2 s whole and 12.9 s as four tiles
1056
1114
  convolution bands). A 64 × 64 crop of a 4000 × 3000 frame took 0.63 s, the same as a plain 64 × 64
1057
1115
  capture. Tiles in parallel workers divide the wall time by the number of cores.
1058
1116
 
1117
+ **A 100 % crop with the slow options (2.6).** A 192 × 128 region at the centre of a 6000 × 4000 frame at 6 µm,
1118
+ Siemens star, f/16, `output.maximumPixels: NATIVE_RAW_LIMITS.ceilingPixels` (one fresh Node 24.21.0 process per
1119
+ row, 4-vCPU Intel Xeon 2.80 GHz, built `dist`, one run each; `diagnostics.timingsMs`):
1120
+
1121
+ | Options | 2.5.0 total (convolution) | 2.6 total (convolution) | Peak resident memory, 2.5.0 → 2.6 |
1122
+ | --- | --- | --- | --- |
1123
+ | defaults | 3.6 s (—) | 3.6 s (—) | 320 → 304 MB |
1124
+ | diffraction, four-spot filter, AHD | 60.8 s (50.2 s) | 11.8 s (1.7 s) | 556 → 379 MB |
1125
+ | diffraction, four-spot filter, AHD, box 2 × 2 | 228.7 s (195.8 s) | 39.6 s (6.5 s) | 1 285 → 545 MB |
1126
+
1127
+ 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,
1128
+ 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
1129
+ scene queries per site (20 spectral nodes × the filter spots × the box nodes: 80 per site with the filter, 320
1130
+ with the filter and the 2 × 2 box) and with the region's sites, not with the frame.
1131
+
1132
+ `output.maximumPixels` is still needed for a region of a frame wider or taller than 4094 pixels: the request,
1133
+ plan and noise seeds are those of the whole frame, and the whole frame's native raster is checked against the
1134
+ limit. The allocations follow the region, so the opt-in costs no memory for the rest of the frame.
1135
+
1059
1136
  `result.image` holds the region, `result.raw` and `result.plan` describe the region's native sites (the
1060
1137
  plan's active area is the region; `result.region.nativeRect` gives them in the whole frame), and
1061
1138
  `result.region` reports the rectangle and the frame. `diagnostics.options` lists `output.region`.
@@ -1117,6 +1194,101 @@ const [capture, reference] = await Promise.all([simulateCapture(request), simula
1117
1194
  console.log(reference.kind, reference.label, capture.image.samples.length === reference.image.samples.length);
1118
1195
  ```
1119
1196
 
1197
+ ## Display view
1198
+
1199
+ `simulateDisplayView(options, view)` returns what the real full-resolution capture of `options` would look like
1200
+ resized to `view.width` × `view.height`, at a cost that follows the display pixels. Pass the same request you use
1201
+ for the export, with the real sensor's pixel count and pitch:
1202
+
1203
+ ```ts
1204
+ import { NATIVE_RAW_LIMITS, simulateCapture, simulateDisplayView, type SimulateCaptureOptions } from "@photivra/engine";
1205
+
1206
+ const request: SimulateCaptureOptions = { scene: { preset: "dead-leaves" }, lens: { prime: 35, fNumber: 2.8 }, subjectDistanceM: 3,
1207
+ sensor: { pixelPitchMicrometers: 6 }, output: { width: 6000, height: 4000, maximumPixels: NATIVE_RAW_LIMITS.ceilingPixels } };
1208
+ const preview = await simulateDisplayView(request, { width: 384, height: 256 }); // on screen
1209
+ console.log(preview.kind, preview.label, preview.diagnostics.timingsMs.view);
1210
+ // The export stays a real capture: simulateCapture(request), or tiles of it with output.region.
1211
+ const crop = await simulateCapture({ ...request, output: { ...request.output, region: { x: 2968, y: 1968, width: 64, height: 64 } } });
1212
+ console.log(crop.image.width);
1213
+ ```
1214
+
1215
+ **What each display pixel is.** The area-weighted mean, in linear light, of the real output pixels its rectangle covers
1216
+ (6000 ÷ 384 = 15.625 real pixels per axis here), sRGB-encoded and rounded: area (box) resampling, the standard
1217
+ reduction filter. Lens diffraction, the anti-aliasing filter and the pixel aperture act at the real 6 µm pitch; the
1218
+ colour mosaic, demosaic and development are those of the real pixels; the noise is that of the average of the real
1219
+ pixels, demosaic correlation included; exposure, white balance, tone curve and framing are the capture's (the same code
1220
+ resolves them).
1221
+
1222
+ **How it is computed** (details and error analysis in [DISPLAY_VIEW.md](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md)):
1223
+
1224
+ - The expected value of each display pixel is a stratified Monte Carlo average over K × K real pixels of its rectangle
1225
+ (`view.samplesPerAxis`, default 16), seeded and unbiased. For each sampled pixel the expected electrons of the real
1226
+ sites its demosaic reads are exact, and the clips and tone curve act on the pixel's own noise distribution. A pilot
1227
+ grid gives uniform areas K/4 samples per axis; their error is zero anyway.
1228
+ - The noise is one correlated normal draw per display pixel with the covariance of the area mean of the real pixels'
1229
+ independent site noises (shot, dark, read, ADC), including the demosaic's correlation.
1230
+ - AHD is represented by its two linear candidates; where the noise is large enough for its homogeneity choice to shift
1231
+ the colour, a control-variate correction runs the engine's AHD steps on drawn noisy neighbourhoods.
1232
+
1233
+ Validated against real full-resolution captures resized in linear light: three sensors (12 MP phone, 24 MP full frame,
1234
+ 100 MP medium format), five charts, f/2.8–f/16, ISO 100–6400, 96–800 display pixels wide, Speed and Quality options (see
1235
+ [Validation results](https://github.com/Photivra/photivra/blob/main/docs/DISPLAY_VIEW.md#validation-results)).
1236
+
1237
+ **Cost.** It follows the detailed display pixels × `samplesPerAxis`², not the real pixel count. Measured against the
1238
+ 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;
1239
+ 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)):
1240
+
1241
+ | Sensor | Options | 96 wide | 384 wide | 800 wide |
1242
+ | --- | --- | --- | --- | --- |
1243
+ | 24 MP full frame, 6 µm | Speed (defaults) | 2.4 s (2.3×) | 10.6 s (0.92×) | 26.6 s (0.65×) |
1244
+ | 12 MP phone, 1.4 µm | Speed | 2.2 s (2.4×) | 11.7 s (0.92×) | 32.1 s (0.59×) |
1245
+ | 100 MP medium format, 3.76 µm | Speed | 2.7 s (2.3×) | 15.8 s (1.2×) | 32.6 s (0.66×) |
1246
+ | 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×) |
1247
+ | 12 MP phone | Quality | 49.7 s (20×) | 106 s (3.8×) | 272 s (2.2×) |
1248
+ | 100 MP medium format | Quality | 18.2 s (3.7×) | 49.7 s (1.8×) | 126 s (1.0×) |
1249
+
1250
+ Quality costs more on small pixels. The diffraction kernel spans several real sites, and at 1.4 µm even ISO 100 is
1251
+ noisy enough for the AHD correction to run on every sampled pixel; its correction states are kept for later views of
1252
+ the same sensor, exposure and development in the same JavaScript realm (a second 384-wide phone view of the same
1253
+ request took 73 s instead of 103 s). The results are byte-identical to those of the display view as first merged,
1254
+ which took 222 s, 450 s and 837 s on the phone.
1255
+
1256
+ **Options.** `view.width` and `view.height` (1 to the capture's output size), `view.samplesPerAxis` (1–32),
1257
+ `view.format` (`"srgb-8"` default or `"srgb-16"`) and `view.region` (a rectangle in display pixels, byte-identical to
1258
+ the same pixels of the whole view, for tiling across workers). The capture's file-format options (`format`,
1259
+ `colorEncoding`, `bitDepth`, `gamutMapping`, `jpegQuantizationStep`, `dng`, `metadata`) do not apply. The request must
1260
+ be one `simulateCapture` accepts (a frame wider or taller than 4094 pixels needs `output.maximumPixels`, although the
1261
+ view allocates nothing at full resolution).
1262
+
1263
+ **Many regions of one view (2.7).** Each `simulateDisplayView` call repeats the request's setup: the camera, lens,
1264
+ scene and illuminant, the channel responses, the diffraction kernels and the colour tables. That is about 0.25 s with
1265
+ the engine defaults and 0.7 s with the Quality options on a 24 MP request. To render one view in many regions, prepare
1266
+ the request once and render each region from it; each region then costs only its own pixels:
1267
+
1268
+ ```ts
1269
+ import { prepareDisplayView, type SimulateCaptureOptions } from "@photivra/engine";
1270
+
1271
+ const request: SimulateCaptureOptions = { scene: { preset: "siemens-star" }, output: { width: 600, height: 400 } };
1272
+ const prepared = await prepareDisplayView(request); // request errors are raised here
1273
+ const tile = await prepared.render({ width: 300, height: 200, region: { x: 120, y: 80, width: 32, height: 32 } },
1274
+ { signal: AbortSignal.timeout(60_000) }); // cancels this render only
1275
+ console.log(tile.image.width, tile.diagnostics.timingsMs.view);
1276
+ ```
1277
+
1278
+ `render(view)` returns what `simulateDisplayView(request, view)` returns, byte for byte (only `diagnostics.timingsMs`
1279
+ differ, and they cover the render only). Renders may run concurrently and share only read-only setup. The request's own
1280
+ `signal` applies to preparing; give each render its own. Keep one prepared view per request in each worker: measured on
1281
+ a 24 MP request, an 8 × 8 region took 6–40 ms through a prepared view instead of 0.24–0.41 s as its own call (Speed).
1282
+
1283
+ **Not supported** (refused with an `InvalidConfigurationError`): `lens.pupilSampling`, `lens.projection`,
1284
+ `lens.distortion`, `lens.vignetting`, `lens.psf`, camera rotation, `output.whiteBalance: "auto"`, the luminance tone
1285
+ curves, `output.highlightMode: "reconstruct"` and `output.region` (use `view.region`). `onProgress` is not called;
1286
+ `signal` cancels.
1287
+
1288
+ The result is labelled `kind: "display-view"` with a `label` for the user interface; it has no RAW, plan or file, and
1289
+ the `view.*` assumptions state the definition, the estimator, the noise model, the demosaic treatment and which capture
1290
+ options were ignored. The whole-frame views of [Whole-frame views](#whole-frame-views-25) are unchanged.
1291
+
1120
1292
  ## Not available
1121
1293
 
1122
1294
  The facade offers no atmospheric haze or polarizer (see [Optics](#optics)), flash, video, HDR brackets
@@ -1125,6 +1297,33 @@ provided, for rights reasons.
1125
1297
 
1126
1298
  ## Sources
1127
1299
 
1300
+ - Diffraction (`lens.diffraction`, `createDiffractionLimitedPsfProfile`): M. Born, E. Wolf,
1301
+ *Principles of Optics*, 7th (expanded) ed., Cambridge University Press, 1999,
1302
+ doi:10.1017/CBO9781139644181, §8.5.2 "The circular aperture" (Fraunhofer diffraction at a circular
1303
+ aperture: the Airy pattern and its dark rings); J. W. Goodman, *Introduction to Fourier Optics*,
1304
+ 4th ed., W. H. Freeman, 2017, §4.4 (examples of Fraunhofer diffraction patterns, including the
1305
+ circular aperture) and §7.3 (frequency response for diffraction-limited incoherent imaging: the
1306
+ optical transfer function as the normalized pupil autocorrelation); J. C. Wyant, K. Creath, "Basic
1307
+ Wavefront Aberration Theory for Optical Metrology", *Applied Optics and Optical Engineering* XI,
1308
+ ch. 1, Academic Press, 1992, §II "Aberration-Free Image", eqs. 1–4, free author copy at
1309
+ <https://wp.optics.arizona.edu/jcwyant/wp-content/uploads/sites/13/2016/08/03-BasicAberrations_and_Optical_Testing.pdf>
1310
+ (the Airy irradiance at the effective f-number, the 2.44·λ·N Airy disk diameter and the encircled
1311
+ energy 1 − J₀² − J₁²); MIT OpenCourseWare 2.71 Optics (Spring 2009), lectures 21–22, free at
1312
+ <https://ocw.mit.edu/courses/2-71-optics-spring-2009/resources/mit2_71s09_lec22/> (the intensity PSF as the transform of the
1313
+ pupil autocorrelation and the circular-pupil MTF); F. W. J. Olver et al. (eds.), *NIST Digital
1314
+ Library of Mathematical Functions*, release 1.2.8 (2026-09-15), free at <https://dlmf.nist.gov/>,
1315
+ §10.6(i) (eqs. 10.6.2 and 10.6.3: J₀′ = −J₁ and J₁′ = J₀ − J₁/x, from which the encircled energy
1316
+ follows), eq. 10.17.3 and §10.17(iii) (Hankel's large-argument expansion of J₀ and J₁ and its error
1317
+ bound, used for cells much larger than the pattern) and eq. 10.9.2 (Bessel's integral, used as an
1318
+ independent test). The original analysis is G. B. Airy, "On the Diffraction of an Object-glass with
1319
+ Circular Aperture", *Transactions of the Cambridge Philosophical Society* 5, 1835, pp. 283–291. The
1320
+ 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)).
1321
+ - Print planning (`calculatePrintPlan`, `calculatePrintSizeLimit`): Z. Alomar, K. D. Bojikian, "Visual Acuity
1322
+ Testing in Adults", EyeWiki, American Academy of Ophthalmology, last edited June 16, 2026, free to read at
1323
+ <https://eyewiki.aao.org/Visual_Acuity_Testing_in_Adults> (the minimum angle of resolution of an ideal eye under optimal
1324
+ conditions is about 1 minute of arc; 20/20 letters are built from 1-arcmin elements). One arcminute per pixel is normal
1325
+ (20/20) acuity under optimal conditions and an approximate rule of thumb: some viewers resolve finer detail and many
1326
+ coarser. The planner uses the criterion the caller declares.
1128
1327
  - R. Palum, "Anti-Aliasing Filter Analysis for Digital Cameras", Proc. IS&T International Congress of
1129
1328
  Imaging Science (ICIS '06), 2006, free at
1130
1329
  <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.7.0" as const
15
15
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
- "packageVersion": "2.5.0",
3
- "engineApiVersion": "2.5.0",
2
+ "packageVersion": "2.7.0",
3
+ "engineApiVersion": "2.7.0",
4
4
  "exports": [
5
5
  {
6
6
  "name": "AbsoluteUncertaintyEstimate",
@@ -1417,6 +1417,16 @@
1417
1417
  "source": "src/sensor/radiometry-readiness.ts",
1418
1418
  "kind": "type"
1419
1419
  },
1420
+ {
1421
+ "name": "CALLER_MATERIAL_SCENE_MAX_MATERIALS",
1422
+ "source": "src/facade/scene-radiance.ts",
1423
+ "kind": "runtime"
1424
+ },
1425
+ {
1426
+ "name": "CallerMaterialScene",
1427
+ "source": "src/facade/scene-radiance.ts",
1428
+ "kind": "type"
1429
+ },
1420
1430
  {
1421
1431
  "name": "CallerRadianceScene",
1422
1432
  "source": "src/facade/scene-radiance.ts",
@@ -2582,6 +2592,11 @@
2582
2592
  "source": "src/core/spectral-composition.ts",
2583
2593
  "kind": "type"
2584
2594
  },
2595
+ {
2596
+ "name": "DisplayViewResult",
2597
+ "source": "src/facade/simulate-capture.ts",
2598
+ "kind": "type"
2599
+ },
2585
2600
  {
2586
2601
  "name": "DistributeIntegratedQuantityAcrossDiscreteLinesInput",
2587
2602
  "source": "src/core/spectral-composition.ts",
@@ -5367,6 +5382,16 @@
5367
5382
  "source": "src/color/color-matching-functions.ts",
5368
5383
  "kind": "type"
5369
5384
  },
5385
+ {
5386
+ "name": "PreparedDisplayView",
5387
+ "source": "src/facade/simulate-display-view.ts",
5388
+ "kind": "type"
5389
+ },
5390
+ {
5391
+ "name": "PreparedDisplayViewRenderOptions",
5392
+ "source": "src/facade/simulate-display-view.ts",
5393
+ "kind": "type"
5394
+ },
5370
5395
  {
5371
5396
  "name": "PreparedGeometricMapping",
5372
5397
  "source": "src/output/geometric-transforms.ts",
@@ -5377,6 +5402,11 @@
5377
5402
  "source": "src/composition/image-formation-plan.ts",
5378
5403
  "kind": "type"
5379
5404
  },
5405
+ {
5406
+ "name": "prepareDisplayView",
5407
+ "source": "src/facade/simulate-display-view.ts",
5408
+ "kind": "runtime"
5409
+ },
5380
5410
  {
5381
5411
  "name": "PreparedRgbGamutMapping",
5382
5412
  "source": "src/color/gamut-mapping.ts",
@@ -6802,6 +6832,11 @@
6802
6832
  "source": "src/schema/illumination.ts",
6803
6833
  "kind": "type"
6804
6834
  },
6835
+ {
6836
+ "name": "SceneMaterialPoint",
6837
+ "source": "src/facade/scene-radiance.ts",
6838
+ "kind": "type"
6839
+ },
6805
6840
  {
6806
6841
  "name": "SceneMaterialResponseDefinition",
6807
6842
  "source": "src/schema/scene-radiance.ts",
@@ -7907,6 +7942,11 @@
7907
7942
  "source": "src/facade/simulate-capture.ts",
7908
7943
  "kind": "runtime"
7909
7944
  },
7945
+ {
7946
+ "name": "SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS",
7947
+ "source": "src/facade/simulate-capture.ts",
7948
+ "kind": "runtime"
7949
+ },
7910
7950
  {
7911
7951
  "name": "simulateCapture",
7912
7952
  "source": "src/facade/simulate-capture.ts",
@@ -8002,6 +8042,16 @@
8002
8042
  "source": "src/capture/simulated-capture.ts",
8003
8043
  "kind": "type"
8004
8044
  },
8045
+ {
8046
+ "name": "simulateDisplayView",
8047
+ "source": "src/facade/simulate-display-view.ts",
8048
+ "kind": "runtime"
8049
+ },
8050
+ {
8051
+ "name": "SimulateDisplayViewOptions",
8052
+ "source": "src/facade/simulate-capture.ts",
8053
+ "kind": "type"
8054
+ },
8005
8055
  {
8006
8056
  "name": "simulateEnvironmentSensorRawFrame",
8007
8057
  "source": "src/capture/environment-raw-producer.ts",