@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
@@ -1,9 +1,11 @@
1
1
  import type { EvidenceProvenance } from "../core/evidence-provenance.js";
2
2
  import { type GenericEquipmentTier, type GenericEquipmentTierPreset } from "../equipment/generic-tier-assets.js";
3
- import { type ResolvedWhiteBalanceState } from "../color/white-balance.js";
3
+ import { resolveColorTemperatureWhiteBalance, type ResolvedWhiteBalanceState } from "../color/white-balance.js";
4
+ import { type CaptureWhiteBalanceIntent } from "../capture/simulated-capture.js";
4
5
  import { type NativeRawOutput, type NativeRawPlan } from "../capture/native-raw.js";
5
6
  import { type NativeEnvironmentRawInput } from "../capture/native-environment-raw.js";
6
7
  import { type NativeRawDevelopmentInput, type RawHighlightDiagnostics } from "../capture/native-raw-development.js";
8
+ import type { RawFrameReconstructionPhaseProfile } from "../capture/raw-frame-reconstruction.js";
7
9
  import { type SdrToneCurve, type SdrToneCurveParameters, type SdrRenderingResult } from "../output/sdr-rendering.js";
8
10
  import type { RawHighlightMode } from "../color/sensor-color-development.js";
9
11
  import type { SimulateEnvironmentSensorRawFrameInput } from "../capture/environment-raw-producer.js";
@@ -12,11 +14,12 @@ import { type LensProjectionModel } from "../camera/lens-projection.js";
12
14
  import type { RadialDistortionProfile } from "../optics/radial-distortion.js";
13
15
  import type { BrownConradyDistortionProfile } from "../optics/brown-conrady-distortion.js";
14
16
  import type { IlluminationVignettingProfile } from "../optics/illumination-vignetting.js";
17
+ import type { CalculateSensorPsfIrradianceQuadratureInput } from "../optics/sensor-psf-quadrature.js";
15
18
  import type { CalculateEnvironmentSensorPhotoSignalInput } from "../sensor/environment-photo-signal.js";
16
19
  import { type DiffractionLimitedPsfProfile } from "../optics/diffraction-psf-profile.js";
17
20
  import { type AntiAliasingPointSplitComponent } from "../sensor/optical-stack.js";
18
- import { type ChartScene } from "./chart-scenes.js";
19
- import { type ChartSizeOption, type CaptureIlluminant, type CallerRadianceScene, type CallerReflectanceScene } from "./scene-radiance.js";
21
+ import { type ChartScene, type ResolvedChartScene } from "./chart-scenes.js";
22
+ import { type ChartSizeOption, type CaptureIlluminant, type CallerMaterialScene, type CallerRadianceScene, type CallerReflectanceScene, type ResolvedCallerScene, type PlanarSceneRadianceProvider, type ResolvedCaptureIlluminant } from "./scene-radiance.js";
20
23
  /** Version of how the facade builds requests. It changes whenever the same input would build a different request. */
21
24
  export declare const SIMULATE_CAPTURE_FACADE_VERSION: "1.0.0";
22
25
  export type SimulateCaptureExposureMode = "manual" | "aperture-priority" | "shutter-priority" | "program";
@@ -160,7 +163,7 @@ export interface SimulateCaptureOptions {
160
163
  compensationStops?: number;
161
164
  };
162
165
  /** Required: an engine chart (for example `{ preset: "uniform" }`, an 18 % grey card) or a caller reflectance/radiance function. */
163
- scene: ChartScene | CallerReflectanceScene | CallerRadianceScene;
166
+ scene: ChartScene | CallerReflectanceScene | CallerRadianceScene | CallerMaterialScene;
164
167
  /** Distance from the camera to the scene plane in metres. Default 5. */
165
168
  subjectDistanceM?: number;
166
169
  /**
@@ -227,7 +230,9 @@ export interface SimulateCaptureOptions {
227
230
  * the whole render: framing, metering, exposure, noise seeds (absolute native site indices), rolling-shutter row
228
231
  * timing, diffraction and demosaic all use the whole frame. Use it to tile a large frame across workers or to
229
232
  * render a 100 % crop. `result.image` is the region; `result.region` reports it. Not combinable with
230
- * `whiteBalance: "auto"`, whose statistics would come from the region only.
233
+ * `whiteBalance: "auto"`, whose statistics would come from the region only. From 2.6 the diffraction convolution
234
+ * covers only the region plus the kernel and filter reach, so a small region of a large frame costs about as much
235
+ * as a capture of the region's size.
231
236
  */
232
237
  region?: {
233
238
  x: number;
@@ -451,12 +456,38 @@ export interface SimulateCaptureResult {
451
456
  };
452
457
  assumptions: readonly SimulateCaptureAssumption[];
453
458
  }
459
+ export declare const FACADE_EVIDENCE: readonly EvidenceProvenance[];
454
460
  /**
455
461
  * Default illuminance, lux: an 18 % Lambertian grey card (L = 0.18·E/π) then gets exactly the CIPA DC-004 standard
456
462
  * output sensitivity reference exposure H = 10/S at f/8, 1/125 s, ISO 100:
457
463
  * E = π·10·N²/(0.65·t·S·0.18) = π·80 000/11.7 ≈ 21 480.98 lux.
458
464
  */
459
465
  export declare const SIMULATE_CAPTURE_DEFAULT_ILLUMINANCE_LUX: number;
466
+ interface ParsedAntiAliasing {
467
+ kind: SimulateCaptureSensorAntiAliasing["kind"];
468
+ separationSites?: number;
469
+ axis?: "horizontal" | "vertical";
470
+ components: AntiAliasingPointSplitComponent[];
471
+ }
472
+ /** Midpoint nodes of an N × N rule over a square photosite, as offsets from its centre in sites (+x right, +y down). */
473
+ export declare function apertureNodeOffsets(n: number): {
474
+ x: number;
475
+ y: number;
476
+ }[];
477
+ export declare const CHANNELS: readonly ["red", "green", "blue"];
478
+ type Channel = typeof CHANNELS[number];
479
+ export declare const CAMERA_TO_XYZ: readonly (readonly number[])[];
480
+ /** Synthetic dark current per site at the operating temperature, e⁻/s. */
481
+ export declare const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
482
+ export declare function fail(message: string): never;
483
+ type ReadoutProfile = GenericEquipmentTierPreset["body"]["readout"][number]["profile"];
484
+ /** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
485
+ export declare function clockMs(): number;
486
+ export declare function allowKeys(value: unknown, name: string, keys: readonly string[]): Record<string, unknown>;
487
+ /** The white balance measured from a perfect white diffuser under the scene illuminant ("scene-illuminant"). */
488
+ export declare function sceneIlluminantWhiteBalance(illuminant: ResolvedCaptureIlluminant): ResolvedWhiteBalanceState;
489
+ /** Bilinear RGGB demosaic as explicit linear neighbourhood kernels (weights sum to one per output channel). */
490
+ export declare function bilinearPhaseProfiles(): RawFrameReconstructionPhaseProfile[];
460
491
  /**
461
492
  * Simulates one still capture of a planar scene and returns the developed image, the packed RAW, the full
462
493
  * underlying request/plan, provenance and every assumption made. See docs/QUICK_START.md.
@@ -524,5 +555,202 @@ export interface IdealReferenceResult {
524
555
  /** The capture request's assumptions, then `reference.kind`, `reference.excludes`, `reference.exposure` and `reference.sampling`. */
525
556
  assumptions: readonly SimulateCaptureAssumption[];
526
557
  }
558
+ /** Default stratified samples per axis of a display view (`view.samplesPerAxis`): 16 × 16 sampled real pixels per display pixel with detail, 4 × 4 where it is uniform. */
559
+ export declare const SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS = 16;
560
+ /** The display size and sampling of {@link simulateDisplayView}. */
561
+ export interface SimulateDisplayViewOptions {
562
+ /** Display pixels, 1 to the capture's output width and height. */
563
+ width: number;
564
+ height: number;
565
+ /** Stratified Monte Carlo samples per axis for each display pixel's expected value (1–32, default 8: 64 real pixels). */
566
+ samplesPerAxis?: number;
567
+ /** "srgb-8" (default) or "srgb-16". */
568
+ format?: "srgb-8" | "srgb-16";
569
+ /** Render only this rectangle of the display view, in display pixels; byte-identical to the same pixels of the whole view. */
570
+ region?: {
571
+ x: number;
572
+ y: number;
573
+ width: number;
574
+ height: number;
575
+ };
576
+ }
577
+ /** Result of {@link simulateDisplayView}: the full-resolution capture resized for display, estimated; not a capture. */
578
+ export interface DisplayViewResult {
579
+ kind: "display-view";
580
+ /** Human-readable label for a user interface. */
581
+ label: string;
582
+ facadeVersion: typeof SIMULATE_CAPTURE_FACADE_VERSION;
583
+ engineApiVersion: string;
584
+ /** The display view (or its region): interleaved sRGB samples, row by row from the top-left. */
585
+ image: {
586
+ width: number;
587
+ height: number;
588
+ format: "srgb-8" | "srgb-16";
589
+ samples: Uint8Array | Uint16Array;
590
+ };
591
+ /**
592
+ * The view: display size, the capture frame it shows (output pixels), real pixels per display pixel along each axis,
593
+ * the samples per axis and, with `view.region`, the rendered rectangle.
594
+ */
595
+ view: {
596
+ width: number;
597
+ height: number;
598
+ frameWidth: number;
599
+ frameHeight: number;
600
+ scaleX: number;
601
+ scaleY: number;
602
+ samplesPerAxis: number;
603
+ region?: {
604
+ x: number;
605
+ y: number;
606
+ width: number;
607
+ height: number;
608
+ };
609
+ };
610
+ /** The resolved exposure of the capture this view shows. */
611
+ exposure: {
612
+ mode: SimulateCaptureExposureMode;
613
+ settings: {
614
+ fNumber: number;
615
+ shutterSeconds: number;
616
+ iso: number;
617
+ };
618
+ ev100: number;
619
+ };
620
+ scene: SimulateCaptureResult["scene"];
621
+ /**
622
+ * Work done: real pixels sampled, real-site expectations evaluated, scene queries, display pixels sampled in full (detail) or with
623
+ * a quarter of the samples per axis (uniform), display pixels whose value includes the AHD low-signal correction, and wall-clock
624
+ * milliseconds (diagnostic only; never affects the samples).
625
+ */
626
+ diagnostics: {
627
+ sampleCount: number;
628
+ siteEvaluations: number;
629
+ sceneLookups: number;
630
+ detailPixelCount: number;
631
+ uniformPixelCount: number;
632
+ ahdCorrectedPixelCount: number;
633
+ timingsMs: {
634
+ view: number;
635
+ total: number;
636
+ };
637
+ };
638
+ provenance: {
639
+ scientificStatus: "approximation";
640
+ cameraPresetId: string;
641
+ cameraPresetVersion: string;
642
+ evidence: readonly EvidenceProvenance[];
643
+ composedContracts: readonly string[];
644
+ limitations: readonly string[];
645
+ };
646
+ /** The capture request's assumptions, then `view.definition`, `view.estimation`, `view.noise`, `view.demosaic` and `view.source`. */
647
+ assumptions: readonly SimulateCaptureAssumption[];
648
+ }
649
+ /** Internal: the resolved request state that the display view's renderer (simulate-display-view.ts) reads. */
650
+ export interface DisplayViewRunContext {
651
+ ahd: boolean;
652
+ antiAliasing: ParsedAntiAliasing | undefined;
653
+ aspect: number;
654
+ assume: (id: string, kind: SimulateCaptureAssumption["kind"], value: unknown, explanation: string) => void;
655
+ assumptions: SimulateCaptureAssumption[];
656
+ axisChannelResponse: (caller: string) => {
657
+ electronsPerRadiance: Record<Channel, Map<number, number>>;
658
+ wavelengths: number[];
659
+ relativePerElectron: Record<Channel, number>;
660
+ };
661
+ boxSamples: number;
662
+ buildProvider: (sceneIlluminant: ResolvedCaptureIlluminant, scale: number) => PlanarSceneRadianceProvider;
663
+ callerScene: ResolvedCallerScene | undefined;
664
+ chart: ResolvedChartScene | undefined;
665
+ chartReport: {
666
+ widthM: number;
667
+ heightM: number;
668
+ surroundReflectance: number;
669
+ frameWidthFraction: number;
670
+ frameHeightFraction: number;
671
+ magnification: number;
672
+ imageWidthMm: number;
673
+ imageHeightMm: number;
674
+ imageWidthPixels: number;
675
+ imageHeightPixels: number;
676
+ } | undefined;
677
+ chartSize: {
678
+ widthM: number;
679
+ heightM: number;
680
+ surroundReflectance: number;
681
+ defaulted: ("heightM" | "surroundReflectance")[];
682
+ } | undefined;
683
+ diffraction: DiffractionLimitedPsfProfile | undefined;
684
+ diffractionPsf: {
685
+ kind: "sampled-local";
686
+ configuration: Pick<CalculateSensorPsfIrradianceQuadratureInput, "psf" | "psfWavelengthBasis" | "spatialModel">;
687
+ } | undefined;
688
+ fNumber: number;
689
+ focalLengthMm: number;
690
+ focusDistanceM: number;
691
+ height: number;
692
+ highlightMode: RawHighlightMode;
693
+ illuminanceLux: number;
694
+ illuminant: ResolvedCaptureIlluminant;
695
+ illuminantOption: CaptureIlluminant;
696
+ imagingArea: {
697
+ widthMm: number;
698
+ heightMm: number;
699
+ };
700
+ intentOf: (state: ResolvedWhiteBalanceState | null) => CaptureWhiteBalanceIntent | null;
701
+ metresPerFrameUnit: number;
702
+ noiseSeed: number;
703
+ outputOptions: Record<string, unknown>;
704
+ parsedView: ParsedDisplayView | undefined;
705
+ pitch: number;
706
+ preset: GenericEquipmentTierPreset;
707
+ provider: PlanarSceneRadianceProvider;
708
+ regimeByChannel: Record<"red" | "green" | "blue", {
709
+ readoutProfile: ReadoutProfile;
710
+ regimeId: string;
711
+ }>;
712
+ resolvedIso: number;
713
+ sceneOption: unknown;
714
+ scenePreset: "composite" | "uniform" | "patches" | "slanted-edge" | "siemens-star" | "step-wedge" | "grey-ramp" | "checkerboard" | "point-grid" | "dead-leaves" | "bar-groups" | "hyperbolic-wedge" | "zone-plate" | "slanted-squares" | "line-grid" | "fiducial" | "reflectance-function" | "radiance-function" | "material-function";
715
+ shutterSeconds: number;
716
+ started: number;
717
+ subjectDistanceM: number;
718
+ toneCurve: SdrToneCurve;
719
+ whiteBalanceOption: SimulateCaptureWhiteBalance;
720
+ whiteBalanceState: ResolvedWhiteBalanceState | null;
721
+ width: number;
722
+ checkAbort: () => void;
723
+ margin: number;
724
+ mode: SimulateCaptureExposureMode;
725
+ nativeHeight: number;
726
+ nativeWidth: number;
727
+ sourceSha256: string;
728
+ yieldControl: () => Promise<void>;
729
+ /** The Kelvin white balance request, which the view resolves with the portable functions. */
730
+ kelvinWhiteBalanceInput: Parameters<typeof resolveColorTemperatureWhiteBalance>[0] | undefined;
731
+ }
732
+ export interface ParsedDisplayView {
733
+ width: number;
734
+ height: number;
735
+ samplesPerAxis: number;
736
+ bitDepth: 8 | 16;
737
+ format: "srgb-8" | "srgb-16";
738
+ region?: {
739
+ x: number;
740
+ y: number;
741
+ width: number;
742
+ height: number;
743
+ };
744
+ }
745
+ /** Internal: validates a display view against the capture's output size and fills its defaults. */
746
+ export declare function parseDisplayViewOption(value: unknown, frameWidth: number, frameHeight: number): ParsedDisplayView;
747
+ /** Internal: passed as the view of a "display-view" run to resolve the request without a view (`prepareDisplayView`, #387). */
748
+ export declare const DISPLAY_VIEW_WITHOUT_VIEW: unique symbol;
749
+ /**
750
+ * Internal: the request path shared by {@link simulateCapture}, {@link simulateIdealReference} and the display view, whose renderer is
751
+ * passed in; for a display view the renderer's value is returned (a result, or a prepared view with DISPLAY_VIEW_WITHOUT_VIEW).
752
+ */
753
+ export declare function runCapture(options: SimulateCaptureOptions, run: "capture" | "ideal-reference" | "display-view", viewOption?: unknown, renderView?: (context: DisplayViewRunContext) => Promise<unknown>): Promise<unknown>;
527
754
  /** Chart preset ids accepted by `simulateCapture({ scene: { preset } })`. */
528
755
  export declare const SIMULATE_CAPTURE_CHART_PRESETS: readonly ("composite" | "uniform" | "patches" | "slanted-edge" | "siemens-star" | "step-wedge" | "grey-ramp" | "checkerboard" | "point-grid" | "dead-leaves" | "bar-groups" | "hyperbolic-wedge" | "zone-plate" | "slanted-squares" | "line-grid" | "fiducial")[];
756
+ export {};
@@ -28,6 +28,7 @@
28
28
  * without it every encoded file is byte-identical to 2.3.
29
29
  * @see docs/QUICK_START.md
30
30
  */
31
+ import { markInternalSceneRadianceEvaluator } from "../core/internal-radiance-evaluator.js";
31
32
  import { InvalidConfigurationError } from "../core/configuration-error.js";
32
33
  import { requireInputObject } from "../core/record-validation.js";
33
34
  import { ENGINE_API_VERSION } from "../core/version.js";
@@ -92,7 +93,7 @@ export const SIMULATE_CAPTURE_COST_OPTIONS = Object.freeze(["lens.pupilSampling"
92
93
  "lens.diffraction", "sensor.antiAliasing", "sensor.pixelAperture.box", "sensor.temporalSampleCount", "motion.rotation", "motion.shutter.rolling", "output.borderHandling.mirror", "output.region",
93
94
  "output.whiteBalance.auto", "output.demosaic.ahd", "output.highlightMode.reconstruct", "output.colorEncoding.wide-gamut", "output.gamutMapping.perceptual",
94
95
  "output.format.jpeg", "output.format.png", "output.format.dng"]);
95
- const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
96
+ export const FACADE_EVIDENCE = Object.freeze([Object.freeze({ sourceOrigin: "photivra",
96
97
  sourceReference: "photivra:simulate-capture-facade", reuseStatus: "photivra-owned" })]);
97
98
  const DEFAULTS = Object.freeze({ camera: "professional", fNumber: 8, shutterSeconds: 1 / 125, iso: 100, compensationStops: 0,
98
99
  subjectDistanceM: 5, width: 64, height: 64, format: "srgb-8", toneCurve: "identity", highlightMode: "clip",
@@ -181,7 +182,7 @@ function parseAntiAliasing(value, pitch) {
181
182
  return fail("sensor.antiAliasing.kind must be four-spot, two-spot or point-splitting.");
182
183
  }
183
184
  /** Midpoint nodes of an N × N rule over a square photosite, as offsets from its centre in sites (+x right, +y down). */
184
- function apertureNodeOffsets(n) {
185
+ export function apertureNodeOffsets(n) {
185
186
  const out = [];
186
187
  for (let j = 0; j < n; j++)
187
188
  for (let i = 0; i < n; i++)
@@ -197,7 +198,7 @@ const IDS = Object.freeze({ frame: "facade-frame", mode: "native-still", cfa: "p
197
198
  capture: "facade-capture", spectral: "facade-colorimetric-eqe", sampling: "facade-sampling", stack: "facade-no-aa", stackAa: "facade-aa",
198
199
  stationarity: "facade-stationarity", operatingState: "facade-operating-state", dark: "facade-dark", completeness: "facade-completeness",
199
200
  chargeSampling: "facade-charge-sampling", capacity: "facade-capacity" });
200
- const CHANNELS = ["red", "green", "blue"];
201
+ export const CHANNELS = ["red", "green", "blue"];
201
202
  /**
202
203
  * Synthetic colorimetric sensor: channel EQE(λ) = a·cmf(λ)·(555 nm/λ), with cmf = x̄, ȳ, z̄. The λ factor turns
203
204
  * the energy-weighted CMF (colour-matching function) into a photon-counting response. So channel counts are
@@ -206,12 +207,12 @@ const CHANNELS = ["red", "green", "blue"];
206
207
  */
207
208
  const EQE_SCALE = Object.freeze({ red: 0.5, green: 0.5, blue: 0.25 });
208
209
  const CMF_KEY = Object.freeze({ red: "x", green: "y", blue: "z" });
209
- const CAMERA_TO_XYZ = Object.freeze([[EQE_SCALE.green / EQE_SCALE.red, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.green / EQE_SCALE.blue]].map((r) => Object.freeze(r)));
210
+ export const CAMERA_TO_XYZ = Object.freeze([[EQE_SCALE.green / EQE_SCALE.red, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.green / EQE_SCALE.blue]].map((r) => Object.freeze(r)));
210
211
  const XYZ_TO_CAMERA = [[EQE_SCALE.red / EQE_SCALE.green, 0, 0], [0, 1, 0], [0, 0, EQE_SCALE.blue / EQE_SCALE.green]];
211
212
  /** Synthetic dark current per site at the operating temperature, e⁻/s. */
212
- const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
213
+ export const DARK_CURRENT_ELECTRONS_PER_SECOND = 1;
213
214
  const OPERATING_TEMPERATURE_C = 20;
214
- function fail(message) { throw new InvalidConfigurationError(message); }
215
+ export function fail(message) { throw new InvalidConfigurationError(message); }
215
216
  /** Where `output.metadata` goes in each file format (reported in the `output.metadata` assumption). */
216
217
  const TEXT_METADATA_LAYOUT = Object.freeze({
217
218
  jpeg: "XMP packet in APP1 (dc:description, xmp:CreatorTool, dc:rights, exif:UserComment), plus a COM segment holding the comment",
@@ -230,7 +231,7 @@ function isoAreaScaledReadout(profile, areaScale) {
230
231
  preAdcSaturationElectronEquivalent: { ...regime.preAdcSaturationElectronEquivalent, value: regime.preAdcSaturationElectronEquivalent.value * areaScale } })) });
231
232
  }
232
233
  /** Diagnostic clock for the 2.1 stage timings: `performance.now()` where the host has it. Never affects output. */
233
- function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
234
+ export function clockMs() { return globalThis.performance?.now() ?? Date.now(); }
234
235
  function positive(value, name) {
235
236
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
236
237
  fail(name + " must be a positive finite number.");
@@ -241,7 +242,7 @@ function integerIn(value, name, minimum, maximum) {
241
242
  fail(name + " must be an integer from " + minimum + " to " + maximum + ".");
242
243
  return value;
243
244
  }
244
- function allowKeys(value, name, keys) {
245
+ export function allowKeys(value, name, keys) {
245
246
  if (value === undefined)
246
247
  return {};
247
248
  requireInputObject(value, name);
@@ -284,6 +285,12 @@ function luminanceOf(radiance) {
284
285
  }
285
286
  return PHOTOPIC_MAXIMUM_LUMINOUS_EFFICACY_LM_PER_W * sum;
286
287
  }
288
+ /** The white balance measured from a perfect white diffuser under the scene illuminant ("scene-illuminant"). */
289
+ export function sceneIlluminantWhiteBalance(illuminant) {
290
+ const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
291
+ return resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
292
+ inputDomain: "relative-pre-wb-camera-linear-rgb", samples: [{ sampleId: "white", red: camera[0], green: camera[1], blue: camera[2], weight: 1, clipped: false }] } });
293
+ }
287
294
  function illuminantXyz(illuminant) {
288
295
  let x = 0, y = 0, z = 0;
289
296
  for (let nm = FACADE_SPECTRAL_RANGE_NANOMETERS.minimum; nm <= FACADE_SPECTRAL_RANGE_NANOMETERS.maximum; nm++) {
@@ -295,7 +302,7 @@ function illuminantXyz(illuminant) {
295
302
  return [x / y, 1, z / y];
296
303
  }
297
304
  /** Bilinear RGGB demosaic as explicit linear neighbourhood kernels (weights sum to one per output channel). */
298
- function bilinearPhaseProfiles() {
305
+ export function bilinearPhaseProfiles() {
299
306
  const cross = [[1, 0], [-1, 0], [0, 1], [0, -1]], diagonal = [[1, 1], [1, -1], [-1, 1], [-1, -1]], horizontal = [[1, 0], [-1, 0]], vertical = [[0, 1], [0, -1]];
300
307
  const average = (offsets, channel) => offsets.map(([x, y]) => ({ offsetX: x, offsetY: y, sourceChannelId: channel, weight: 1 / offsets.length }));
301
308
  const self = (channel) => [{ offsetX: 0, offsetY: 0, sourceChannelId: channel, weight: 1 }];
@@ -470,7 +477,7 @@ function encodeSdrFromLinear(rgb, encoding, bitDepth, mapping) {
470
477
  * underlying request/plan, provenance and every assumption made. See docs/QUICK_START.md.
471
478
  */
472
479
  export async function simulateCapture(options) {
473
- return await runCapture(options, false);
480
+ return await runCapture(options, "capture");
474
481
  }
475
482
  /**
476
483
  * The ideal reference of a capture request (2.3): the same scene, framing, chart size, output size, region and pixel
@@ -482,10 +489,36 @@ export async function simulateCapture(options) {
482
489
  * @see docs/QUICK_START.md, "Ideal reference".
483
490
  */
484
491
  export async function simulateIdealReference(options) {
485
- return await runCapture(options, true);
492
+ return await runCapture(options, "ideal-reference");
493
+ }
494
+ /** Default stratified samples per axis of a display view (`view.samplesPerAxis`): 16 × 16 sampled real pixels per display pixel with detail, 4 × 4 where it is uniform. */
495
+ export const SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS = 16;
496
+ /** Internal: validates a display view against the capture's output size and fills its defaults. */
497
+ export function parseDisplayViewOption(value, frameWidth, frameHeight) {
498
+ if (value === undefined)
499
+ fail("simulateDisplayView needs a view, for example { width: 384, height: 256 }.");
500
+ const v = allowKeys(value, "view", ["width", "height", "samplesPerAxis", "format", "region"]);
501
+ const width = integerIn(v.width, "view.width", 1, frameWidth), height = integerIn(v.height, "view.height", 1, frameHeight);
502
+ const samplesPerAxis = v.samplesPerAxis === undefined ? SIMULATE_DISPLAY_VIEW_DEFAULT_SAMPLES_PER_AXIS : integerIn(v.samplesPerAxis, "view.samplesPerAxis", 1, 32);
503
+ const format = v.format === undefined ? "srgb-8" : v.format;
504
+ if (format !== "srgb-8" && format !== "srgb-16")
505
+ fail("view.format must be srgb-8 or srgb-16.");
506
+ let region;
507
+ if (v.region !== undefined) {
508
+ const r = allowKeys(v.region, "view.region", ["x", "y", "width", "height"]);
509
+ const x = integerIn(r.x, "view.region.x", 0, width - 1), y = integerIn(r.y, "view.region.y", 0, height - 1);
510
+ region = { x, y, width: integerIn(r.width, "view.region.width", 1, width - x), height: integerIn(r.height, "view.region.height", 1, height - y) };
511
+ }
512
+ return { width, height, samplesPerAxis, bitDepth: format === "srgb-8" ? 8 : 16, format: format, ...(region ? { region } : {}) };
486
513
  }
487
- async function runCapture(options, idealReference) {
488
- const started = Date.now();
514
+ /** Internal: passed as the view of a "display-view" run to resolve the request without a view (`prepareDisplayView`, #387). */
515
+ export const DISPLAY_VIEW_WITHOUT_VIEW = Symbol("display view without view");
516
+ /**
517
+ * Internal: the request path shared by {@link simulateCapture}, {@link simulateIdealReference} and the display view, whose renderer is
518
+ * passed in; for a display view the renderer's value is returned (a result, or a prepared view with DISPLAY_VIEW_WITHOUT_VIEW).
519
+ */
520
+ export async function runCapture(options, run, viewOption, renderView) {
521
+ const started = Date.now(), idealReference = run === "ideal-reference", displayView = run === "display-view";
489
522
  requireInputObject(options, "simulateCapture options");
490
523
  const top = allowKeys(options, "simulateCapture options", ["camera", "lens", "exposure", "scene", "subjectDistanceM", "chartSize", "illuminant",
491
524
  "illuminanceLux", "output", "sensor", "motion", "signal", "onProgress"]);
@@ -714,6 +747,29 @@ async function runCapture(options, idealReference) {
714
747
  }
715
748
  }
716
749
  const boxAperture = sensorOptions.pixelAperture !== undefined && sensorOptions.pixelAperture !== "point";
750
+ // Display view: its own parameters, and the capture options it reproduces, checked before any scene work.
751
+ const parsedView = displayView && viewOption !== DISPLAY_VIEW_WITHOUT_VIEW ? parseDisplayViewOption(viewOption, width, height) : undefined;
752
+ if (displayView) {
753
+ const unsupported = (option, reason) => fail("simulateDisplayView does not support " + option + ": " + reason);
754
+ if (pupil)
755
+ unsupported("lens.pupilSampling", "defocus moves each query off the site lattice the expected-signal tables use.");
756
+ if (lensFieldMapping)
757
+ unsupported("lens.projection or lens.distortion", "the expected signal assumes the ideal rectilinear, distortion-free mapping of the site lattice.");
758
+ if (vignetting)
759
+ unsupported("lens.vignetting", "the expected signal assumes the same radiance-to-irradiance factor at every site.");
760
+ if (lensPsf)
761
+ unsupported("lens.psf", "only the engine-built diffraction kernels (lens.diffraction) are tabulated.");
762
+ if (rotating)
763
+ unsupported("motion.angularVelocityRadPerSec", "camera rotation moves the queries off the site lattice.");
764
+ if (outputOptions.region !== undefined)
765
+ unsupported("output.region", "pass the rectangle as view.region, in display pixels.");
766
+ if (outputOptions.whiteBalance === "auto")
767
+ unsupported("output.whiteBalance \"auto\"", "the capture measures it from its noisy full-resolution RAW; use \"scene-illuminant\" or a fixed white balance.");
768
+ if (outputOptions.toneCurve !== undefined && outputOptions.toneCurve !== "identity" && outputOptions.toneCurve !== "positive-reinhard-per-channel")
769
+ unsupported("output.toneCurve " + String(outputOptions.toneCurve), "luminance tone curves couple the channels; the view supports identity and positive-reinhard-per-channel.");
770
+ if (outputOptions.highlightMode === "reconstruct")
771
+ unsupported("output.highlightMode \"reconstruct\"", "the view supports the per-channel highlight clip.");
772
+ }
717
773
  const colorSamplingProfile = { schemaVersion: "0.1.0", profileId: IDS.cfa, evidence: FACADE_EVIDENCE, coordinateSystem: "native-sensor-color-sampling-site-index",
718
774
  layout: { kind: "periodic-mosaic", repeatWidthSites: 2, repeatHeightSites: 2, siteChannelIds: ["red", "green", "green", "blue"], anchor: "native-sensor-top-left-site" } };
719
775
  // Demosaic (2.0): built-in Bayer kernels and border handling. Omitted, the 1.0 bilinear kernels and a
@@ -752,13 +808,17 @@ async function runCapture(options, idealReference) {
752
808
  if (region && regionNative)
753
809
  assume("output.region", "model", { ...region, frameWidth: width, frameHeight: height, nativeRect: { ...regionNative } }, "Only this rectangle of the " + width + " × " + height + " frame is rendered. Framing, metering, exposure, diffraction kernels and every site's physics, noise seeds and exposure window (rolling-shutter row timing) are those of the whole frame (absolute native indices), " +
754
810
  "and the native rectangle includes the demosaic reach on each side (" + (margin > 0 ? margin : "mirror-border") + "), so the pixels are byte-identical to the same pixels of the whole render. " +
755
- "With lens.diffraction the object-space convolution runs over whole-width bands of 32 native rows that the region touches.");
811
+ // 2.6: with lens.diffraction the convolution is limited to the region; the sentence of 2.3–2.5 is kept verbatim otherwise, so
812
+ // region results without diffraction stay byte-identical to 2.5.0.
813
+ (diffractionRadiusOption !== undefined ? "With lens.diffraction the object-space convolution (when it applies) covers only the lattice the region's native sites query (plus the kernel radius), in bands of at most 32 native rows; its values are the whole render's." :
814
+ "With lens.diffraction the object-space convolution runs over whole-width bands of 32 native rows that the region touches."));
756
815
  if (raisedPixels !== undefined) {
757
816
  if (nativeWidth > NATIVE_RAW_LIMITS.maximumDimension || nativeHeight > NATIVE_RAW_LIMITS.maximumDimension || nativeWidth * nativeHeight > raisedPixels) {
758
817
  fail("output.maximumPixels (" + raisedPixels + ") must cover the native sensor raster of " + nativeWidth + " × " + nativeHeight +
759
818
  " sites (the output plus the demosaic margin), at most " + NATIVE_RAW_LIMITS.maximumDimension + " per side.");
760
819
  }
761
- if (ahd && (regionNative ? regionNative.width * regionNative.height : nativeWidth * nativeHeight) > PROCESSING_MAX_PIXELS)
820
+ // The display view allocates no full-resolution raster, so the AHD working limit does not apply to it.
821
+ if (ahd && !displayView && (regionNative ? regionNative.width * regionNative.height : nativeWidth * nativeHeight) > PROCESSING_MAX_PIXELS)
762
822
  fail("output.demosaic \"ahd\" admits at most " + PROCESSING_MAX_PIXELS + " native sites.");
763
823
  }
764
824
  const imagingArea = { widthMm: nativeWidth * pitch / 1000, heightMm: nativeHeight * pitch / 1000 };
@@ -839,18 +899,29 @@ async function runCapture(options, idealReference) {
839
899
  illuminantRecord?.kind === "daylight" ? { basisDatasetId: illuminantRecord.basis?.datasetId, spectrum: spectrumDigestForm(illuminant.spectrum, illuminant.shape) } : undefined;
840
900
  const materialInputs = chart ? chartMaterialInputs(chart) : [];
841
901
  const sceneSpectra = chart ? chart.materials.flatMap((m, index) => m.kind !== "spectrum" ? [] : [{ material: index, spectrum: spectrumDigestForm(materialInputs[index], m.spectrum) }]) : [];
902
+ // 2.7: a material scene's materials enter the digest as data (neutral reflectances, and caller spectra as above).
903
+ const sceneMaterials = callerScene?.kind === "material-function" ? callerScene.materials.map((m, index) => (m.kind === "neutral" ? m.reflectance :
904
+ spectrumDigestForm(callerScene.materialInputs[index], m.spectrum))) : undefined;
842
905
  const sceneDescription = { facadeVersion: SIMULATE_CAPTURE_FACADE_VERSION, scene: chart ? { preset: chart.preset, parameters: chart.parameters } :
843
906
  { kind: callerScene.kind, sceneId: callerScene.sceneId }, illuminant: illuminant.id, illuminanceLux, subjectDistanceM, framedWidthM: 2 * metresPerFrameUnit, aspect,
844
907
  ...(chartSize ? { chartSize: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}),
845
- ...(illuminantSpectrum === undefined ? {} : { illuminantSpectrum }), ...(sceneSpectra.length === 0 ? {} : { sceneSpectra }) };
846
- if (callerScene)
908
+ ...(illuminantSpectrum === undefined ? {} : { illuminantSpectrum }), ...(sceneSpectra.length === 0 ? {} : { sceneSpectra }), ...(sceneMaterials ? { sceneMaterials } : {}) };
909
+ if (callerScene?.kind === "material-function")
910
+ assume("scene.digest", "model", "caller-function-not-hashed", "The source digest covers the caller's sceneId, its materials and the framing, not the body of the caller's materialAt function.");
911
+ else if (callerScene)
847
912
  assume("scene.digest", "model", "caller-function-not-hashed", "The source digest covers the caller's sceneId and the framing, not the body of the caller's evaluate function.");
848
913
  const sourceSha256 = await sha256Hex(JSON.stringify(sceneDescription, (_key, value) => (typeof value === "object" && value !== null && "evaluate" in value ? "caller-spectrum" : value)));
849
914
  checkAbort();
850
- const provider = buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene, sceneId: IDS.scene, providerProfileId: IDS.provider,
851
- illuminant, irradianceScale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
915
+ const buildProvider = (sceneIlluminant, scale) => buildPlanarSceneRadianceProvider({ scene: chart ?? callerScene,
916
+ sceneId: IDS.scene, providerProfileId: IDS.provider, illuminant: sceneIlluminant, irradianceScale: scale, geometry: { distanceM: subjectDistanceM, metresPerFrameUnit,
852
917
  ...(chartSize ? { chart: { widthM: chartSize.widthM, heightM: chartSize.heightM, surroundReflectance: chartSize.surroundReflectance } } : {}) }, evidence: FACADE_EVIDENCE,
853
- limitations: ["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."] });
918
+ limitations: Object.freeze(["Planar Lambertian scene under uniform illumination; no shadows, inter-reflection, gloss or fluorescence."]) });
919
+ const provider = buildProvider(illuminant, irradianceScale);
920
+ // 2.6: a chart preset's evaluator is engine-built end to end (its results: the schema's exact keys, constant fields,
921
+ // the request's identifiers, deep-frozen FACADE_EVIDENCE, UNCERTAINTY and limitations, and an engine-computed
922
+ // radiance), so the native task may skip re-parsing the fixed parts. Caller scenes keep the full validation.
923
+ if (chart)
924
+ markInternalSceneRadianceEvaluator(provider.evaluateRadiance);
854
925
  assume("model.scene-radiometry", "model", "lambertian-plane", "Radiance = reflectance × irradiance / π on one plane; illuminance from K_m = 683 lm/W and the engine's analytic CIE 1931 ȳ fit.");
855
926
  // Metering (CIPA DC-004 standard output sensitivity and camera equation) and exposure-mode resolution
856
927
  const exposureOptions = allowKeys(top.exposure, "exposure", ["mode", "iso", "shutterSeconds", "compensationStops"]);
@@ -1020,7 +1091,7 @@ async function runCapture(options, idealReference) {
1020
1091
  else
1021
1092
  assume("lens.projection", "model", projection, "Ideal " + projection + " projection through the environment lens field mapping. At finite focus its scale is the thin-lens image distance (an approximation); the chart is still sized with the rectilinear thin-lens scale, so its edges move with the projection.");
1022
1093
  // White balance
1023
- let whiteBalanceState = null, needsAuto = false;
1094
+ let whiteBalanceState = null, needsAuto = false, kelvinWhiteBalanceInput;
1024
1095
  if (whiteBalanceOption === "none")
1025
1096
  whiteBalanceState = null;
1026
1097
  else if (whiteBalanceOption === "auto") {
@@ -1028,17 +1099,16 @@ async function runCapture(options, idealReference) {
1028
1099
  fail("output.region cannot be combined with output.whiteBalance \"auto\": grey-world statistics of a region differ from the whole frame's.");
1029
1100
  needsAuto = true;
1030
1101
  }
1031
- else if (whiteBalanceOption === "scene-illuminant") {
1032
- const xyz = illuminantXyz(illuminant), camera = XYZ_TO_CAMERA.map((row) => row[0] * xyz[0] + row[1] * xyz[1] + row[2] * xyz[2]);
1033
- whiteBalanceState = resolveCustomWhiteBalance({ stateId: "facade-wb-scene-illuminant", sampleSet: { imageStateId: "facade-white-diffuser",
1034
- inputDomain: "relative-pre-wb-camera-linear-rgb", samples: [{ sampleId: "white", red: camera[0], green: camera[1], blue: camera[2], weight: 1, clipped: false }] } });
1035
- }
1102
+ else if (whiteBalanceOption === "scene-illuminant")
1103
+ whiteBalanceState = sceneIlluminantWhiteBalance(illuminant);
1036
1104
  else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "kelvin" in whiteBalanceOption) {
1037
1105
  const wb = allowKeys(whiteBalanceOption, "output.whiteBalance", ["kelvin", "tint"]);
1038
1106
  const intent = createColorTemperatureWhiteBalanceIntent({ intentId: "facade-wb-cct", colorTemperatureKelvin: positive(wb.kelvin, "output.whiteBalance.kelvin"),
1039
1107
  tint: wb.tint === undefined ? 0 : wb.tint });
1040
- whiteBalanceState = resolveColorTemperatureWhiteBalance({ stateId: "facade-wb-cct", intent, whiteLocus: "planckian", tintInterpretation: "duv-cie-1960-uv",
1041
- camera: { profileId: IDS.spectral, profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, xyzToCameraRgb: XYZ_TO_CAMERA.map((r) => [...r]) } });
1108
+ const kelvinInput = { stateId: "facade-wb-cct", intent, whiteLocus: "planckian", tintInterpretation: "duv-cie-1960-uv",
1109
+ camera: { profileId: IDS.spectral, profileVersion: SIMULATE_CAPTURE_FACADE_VERSION, xyzToCameraRgb: XYZ_TO_CAMERA.map((r) => [...r]) } };
1110
+ whiteBalanceState = resolveColorTemperatureWhiteBalance(kelvinInput);
1111
+ kelvinWhiteBalanceInput = kelvinInput;
1042
1112
  }
1043
1113
  else if (typeof whiteBalanceOption === "object" && whiteBalanceOption !== null && "channelGains" in whiteBalanceOption) {
1044
1114
  allowKeys(whiteBalanceOption, "output.whiteBalance", ["channelGains"]);
@@ -1236,6 +1306,8 @@ async function runCapture(options, idealReference) {
1236
1306
  const rawProvider = diffraction && diffractionObjectSpace ? createDiffractedPlanarProvider({ base: provider, psf: diffractionPsf.configuration.psf,
1237
1307
  wavelengthsNm: diffraction.profile.axes.wavelengthNm, kernelRadius: diffraction.kernelRadiusSamples, nativeWidth, nativeHeight, outputWidth: width,
1238
1308
  sitePitchMicrometers: pitch, distanceM: subjectDistanceM, metresPerFrameUnit, onConvolution: (ms) => { diffractionMs += ms; },
1309
+ // Region (2.6): convolve only the lattice the region's native sites query; the values are the whole render's.
1310
+ ...(regionNative ? { window: regionNative } : {}),
1239
1311
  // The environment queries each site at each aperture node minus each split offset (sites, +x right, +y down).
1240
1312
  ...(antiAliasing || boxSamples > 1 ? { queryOffsetsSites: apertureNodeOffsets(boxSamples).flatMap((node) => (antiAliasing ? antiAliasing.components : [{ offsetMicrometers: { x: 0, y: 0 } }]).map((c) => ({ x: node.x - c.offsetMicrometers.x / pitch, y: node.y - c.offsetMicrometers.y / pitch }))) } : {}) }) : provider;
1241
1313
  if (antiAliasing && perSite > 100_000)
@@ -1245,13 +1317,9 @@ async function runCapture(options, idealReference) {
1245
1317
  const tileWidth = Math.max(1, Math.min(256, Math.floor(100_000 / perSite)));
1246
1318
  const environmentRaw = { raw: buildRawInput(intentOf(whiteBalanceState)), sceneBinding,
1247
1319
  maximumProviderEvaluations: Math.min(nativeRawMaximumProviderEvaluations(raisedPixels === undefined ? {} : { maximumPixels: raisedPixels }), Math.max(nativeWidth * nativeHeight, evaluations)) };
1248
- // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1249
- if (idealReference) {
1250
- const referenceStarted = clockMs();
1251
- if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
1252
- fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
1253
- if (needsAuto)
1254
- fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
1320
+ // The facade's environment chain at a site next to the optical axis, shared by the ideal reference (2.3) and the display
1321
+ // view: expected electrons per unit spectral radiance per channel and node, and the normalized RAW signal per electron.
1322
+ function axisChannelResponse(caller) {
1255
1323
  // 1. Electrons per unit spectral radiance at each spectral node, per channel: the facade's own environment chain at a
1256
1324
  // site next to the optical axis, with the ideal optics (no PSF, pupil, distortion, vignetting or motion). The signal
1257
1325
  // is linear in the radiance node values and, for this planar scene, the same at every site (paraxial π/(4N_w²)).
@@ -1260,7 +1328,7 @@ async function runCapture(options, idealReference) {
1260
1328
  const electronsPerRadiance = {};
1261
1329
  for (const channel of CHANNELS) {
1262
1330
  if (channelAt(channelSites[channel] % nativeWidth, Math.floor(channelSites[channel] / nativeWidth)) !== channel)
1263
- fail("simulateIdealReference needs at least a 2 × 2 native raster.");
1331
+ fail(caller + " needs at least a 2 × 2 native raster.");
1264
1332
  const environment = { ...siteInput(channelSites[channel]).environment };
1265
1333
  environment.psf = { kind: "not-applied", evidence, limitation: "Ideal reference: no lens PSF." };
1266
1334
  environment.fieldThroughput = { kind: "unity", evidence, limitation: "Ideal reference: no vignetting." };
@@ -1288,13 +1356,28 @@ async function runCapture(options, idealReference) {
1288
1356
  systemConversionGainElectronsPerCode: gain, blackLevelCode: black, digitalSaturationCode: regime.adc.digitalSaturationCode, adcMaximumCode: 2 ** regime.adc.bitDepth - 1, digitalGain }, readoutProfile, regimeId);
1289
1357
  relativePerElectron[channel] = digitalGain / (gain * (white - black));
1290
1358
  }
1359
+ return { electronsPerRadiance, wavelengths, relativePerElectron };
1360
+ }
1361
+ // Display view: the renderer that simulate-display-view.ts passes in reads this request's resolved state, so a bundle
1362
+ // that only captures does not contain the display view.
1363
+ if (displayView)
1364
+ return await renderView({ ahd, antiAliasing, aspect, assume, assumptions, axisChannelResponse, boxSamples, buildProvider, callerScene, chart, chartReport, chartSize, diffraction, diffractionPsf, fNumber, focalLengthMm, focusDistanceM, height, highlightMode, illuminanceLux, illuminant, illuminantOption, imagingArea, intentOf, metresPerFrameUnit, noiseSeed, outputOptions, parsedView, pitch, preset, provider, regimeByChannel, resolvedIso, sceneOption, scenePreset, shutterSeconds, started, subjectDistanceM, toneCurve, whiteBalanceOption, whiteBalanceState, width, checkAbort, margin, mode, nativeHeight, nativeWidth, sourceSha256, yieldControl, kelvinWhiteBalanceInput });
1365
+ // Ideal reference (2.3): the scene without camera effects, at the capture's own exposure, white balance and encoding.
1366
+ if (idealReference) {
1367
+ const referenceStarted = clockMs();
1368
+ if (format !== "srgb-8" && format !== "srgb-16" && format !== "png")
1369
+ fail("simulateIdealReference supports output.format srgb-8, srgb-16 and png: a reference has no RAW (dng) and should not carry JPEG compression artefacts.");
1370
+ if (needsAuto)
1371
+ fail("simulateIdealReference cannot reproduce output.whiteBalance \"auto\": the capture measures it from its noisy RAW. Use \"scene-illuminant\" or a fixed white balance.");
1372
+ const { electronsPerRadiance, wavelengths, relativePerElectron } = axisChannelResponse("simulateIdealReference");
1291
1373
  // 3. Scene radiance per sample: chart materials once (and the fixed-size chart's surround), caller functions per sample.
1292
1374
  const signalOf = (radiance) => {
1293
1375
  const values = new Map(wavelengths.map((nm) => [nm, radiance(nm)]));
1294
1376
  return CHANNELS.map((c) => { let e = 0; for (const [nm, a] of electronsPerRadiance[c])
1295
1377
  e += a * values.get(nm); return e * relativePerElectron[c]; });
1296
1378
  };
1297
- const materialSignals = provider.chartMaterialAt ? Array.from({ length: chart.materials.length + (chartSize ? 1 : 0) }, (_, m) => signalOf((nm) => provider.materialRadiance(m, nm))) : undefined;
1379
+ const materialCount = chart ? chart.materials.length : callerScene?.kind === "material-function" ? callerScene.materials.length : 0;
1380
+ const materialSignals = provider.chartMaterialAt ? Array.from({ length: materialCount + (chartSize ? 1 : 0) }, (_, m) => signalOf((nm) => provider.materialRadiance(m, nm))) : undefined;
1298
1381
  // 4. Pixels: the native sites of the output (or region) pixels, each centre-sampled or box-integrated like the capture.
1299
1382
  const x0 = (region ? region.x : 0) + margin, y0 = (region ? region.y : 0) + margin, n = boxSamples, unit = 2 / width;
1300
1383
  const referenceIntent = intentOf(whiteBalanceState), referenceGains = referenceIntent ? [referenceIntent.channelGains.red, referenceIntent.channelGains.green, referenceIntent.channelGains.blue] : null;