react-native-image-stitcher 0.15.2 → 0.16.1

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 (146) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/README.md +131 -5
  3. package/android/src/main/cpp/image_stitcher_jni.cpp +154 -13
  4. package/android/src/main/cpp/keyframe_gate_jni.cpp +15 -8
  5. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
  6. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +220 -87
  7. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +7 -36
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
  10. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
  11. package/cpp/crop_quad.cpp +162 -0
  12. package/cpp/crop_quad.hpp +163 -0
  13. package/cpp/keyframe_gate.cpp +54 -15
  14. package/cpp/keyframe_gate.hpp +33 -0
  15. package/cpp/stitcher.cpp +1122 -132
  16. package/cpp/stitcher.hpp +62 -0
  17. package/cpp/warp_guard.hpp +212 -0
  18. package/dist/camera/Camera.d.ts +209 -12
  19. package/dist/camera/Camera.js +575 -36
  20. package/dist/camera/CameraView.js +35 -16
  21. package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
  22. package/dist/camera/CaptureCountdownOverlay.js +239 -0
  23. package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
  24. package/dist/camera/CaptureFrameCounterOverlay.js +153 -0
  25. package/dist/camera/CaptureMemoryPill.d.ts +24 -8
  26. package/dist/camera/CaptureMemoryPill.js +37 -12
  27. package/dist/camera/CapturePreview.js +2 -1
  28. package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
  29. package/dist/camera/CaptureStatusOverlay.js +22 -5
  30. package/dist/camera/CaptureThumbnailStrip.js +2 -1
  31. package/dist/camera/LateralMotionModal.d.ts +85 -0
  32. package/dist/camera/LateralMotionModal.js +134 -0
  33. package/dist/camera/PanHowToOverlay.d.ts +76 -0
  34. package/dist/camera/PanHowToOverlay.js +222 -0
  35. package/dist/camera/PanoramaBandOverlay.d.ts +2 -1
  36. package/dist/camera/PanoramaBandOverlay.js +9 -3
  37. package/dist/camera/PanoramaSettings.d.ts +8 -6
  38. package/dist/camera/PanoramaSettings.js +19 -1
  39. package/dist/camera/PanoramaSettingsModal.js +4 -4
  40. package/dist/camera/RectCropPreview.d.ts +135 -0
  41. package/dist/camera/RectCropPreview.js +370 -0
  42. package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
  43. package/dist/camera/RotateToLandscapePrompt.js +138 -0
  44. package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
  45. package/dist/camera/buildPanoramaInitialSettings.js +9 -0
  46. package/dist/camera/cameraErrorMessages.d.ts +30 -1
  47. package/dist/camera/cameraErrorMessages.js +26 -10
  48. package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
  49. package/dist/camera/cameraGuidanceCopy.js +80 -0
  50. package/dist/camera/captureCountdown.d.ts +52 -0
  51. package/dist/camera/captureCountdown.js +76 -0
  52. package/dist/camera/captureWarnings.d.ts +90 -0
  53. package/dist/camera/captureWarnings.js +108 -0
  54. package/dist/camera/classifyStitchError.d.ts +30 -0
  55. package/dist/camera/classifyStitchError.js +42 -0
  56. package/dist/camera/cropGeometry.d.ts +136 -0
  57. package/dist/camera/cropGeometry.js +223 -0
  58. package/dist/camera/displayDecodeImageProps.d.ts +25 -0
  59. package/dist/camera/displayDecodeImageProps.js +29 -0
  60. package/dist/camera/guidanceGraphics.d.ts +58 -0
  61. package/dist/camera/guidanceGraphics.js +280 -0
  62. package/dist/camera/guidanceTokens.d.ts +54 -0
  63. package/dist/camera/guidanceTokens.js +58 -0
  64. package/dist/camera/panModeGate.d.ts +54 -0
  65. package/dist/camera/panModeGate.js +62 -0
  66. package/dist/camera/pickCaptureFormat.d.ts +71 -0
  67. package/dist/camera/pickCaptureFormat.js +85 -0
  68. package/dist/camera/stitchDebugInfo.d.ts +27 -0
  69. package/dist/camera/stitchDebugInfo.js +55 -0
  70. package/dist/camera/usePanMotion.d.ts +250 -0
  71. package/dist/camera/usePanMotion.js +451 -0
  72. package/dist/index.d.ts +24 -3
  73. package/dist/index.js +33 -2
  74. package/dist/stitching/computeInscribedRect.d.ts +40 -0
  75. package/dist/stitching/computeInscribedRect.js +55 -0
  76. package/dist/stitching/cropQuad.d.ts +78 -0
  77. package/dist/stitching/cropQuad.js +116 -0
  78. package/dist/stitching/incremental.d.ts +74 -0
  79. package/dist/stitching/useIncrementalStitcher.d.ts +7 -1
  80. package/dist/stitching/useIncrementalStitcher.js +7 -1
  81. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +154 -29
  82. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +4 -0
  83. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +15 -0
  84. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
  85. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
  86. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
  87. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +211 -7
  88. package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
  89. package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
  90. package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
  91. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
  92. package/package.json +5 -1
  93. package/src/camera/Camera.tsx +945 -47
  94. package/src/camera/CameraView.tsx +48 -16
  95. package/src/camera/CaptureCountdownOverlay.tsx +272 -0
  96. package/src/camera/CaptureFrameCounterOverlay.tsx +197 -0
  97. package/src/camera/CaptureMemoryPill.tsx +50 -12
  98. package/src/camera/CapturePreview.tsx +5 -0
  99. package/src/camera/CaptureStatusOverlay.tsx +35 -7
  100. package/src/camera/CaptureThumbnailStrip.tsx +4 -0
  101. package/src/camera/LateralMotionModal.tsx +199 -0
  102. package/src/camera/PanHowToOverlay.tsx +246 -0
  103. package/src/camera/PanoramaBandOverlay.tsx +9 -1
  104. package/src/camera/PanoramaSettings.ts +27 -7
  105. package/src/camera/PanoramaSettingsModal.tsx +4 -4
  106. package/src/camera/RectCropPreview.tsx +638 -0
  107. package/src/camera/RotateToLandscapePrompt.tsx +188 -0
  108. package/src/camera/buildPanoramaInitialSettings.ts +30 -1
  109. package/src/camera/cameraErrorMessages.ts +39 -2
  110. package/src/camera/cameraGuidanceCopy.ts +145 -0
  111. package/src/camera/captureCountdown.ts +83 -0
  112. package/src/camera/captureWarnings.ts +190 -0
  113. package/src/camera/classifyStitchError.ts +68 -0
  114. package/src/camera/cropGeometry.ts +268 -0
  115. package/src/camera/displayDecodeImageProps.ts +25 -0
  116. package/src/camera/guidanceGraphics.tsx +347 -0
  117. package/src/camera/guidanceTokens.ts +57 -0
  118. package/src/camera/panModeGate.ts +81 -0
  119. package/src/camera/pickCaptureFormat.ts +130 -0
  120. package/src/camera/stitchDebugInfo.ts +71 -0
  121. package/src/camera/usePanMotion.ts +667 -0
  122. package/src/index.ts +66 -3
  123. package/src/stitching/computeInscribedRect.ts +81 -0
  124. package/src/stitching/cropQuad.ts +167 -0
  125. package/src/stitching/incremental.ts +74 -0
  126. package/src/stitching/useIncrementalStitcher.ts +13 -0
  127. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +0 -100
  128. package/cpp/tests/CMakeLists.txt +0 -104
  129. package/cpp/tests/README.md +0 -86
  130. package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
  131. package/cpp/tests/pose_test.cpp +0 -74
  132. package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
  133. package/cpp/tests/stubs/jsi/jsi.h +0 -33
  134. package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
  135. package/cpp/tests/warp_guard_test.cpp +0 -48
  136. package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
  137. package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
  138. package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
  139. package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
  140. package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
  141. package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
  142. package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
  143. package/src/camera/__tests__/useContentRotation.test.ts +0 -89
  144. package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
  145. package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
  146. package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
@@ -0,0 +1,78 @@
1
+ /**
2
+ * cropQuad — item-7 perspective crop: rectify a user-dragged
3
+ * quadrilateral to an upright rectangle.
4
+ *
5
+ * The post-capture crop editor (`src/camera/RectCropPreview.tsx`) lets the
6
+ * user drag 4 independent corners over the stitched result. When that
7
+ * quad isn't ~axis-aligned, the host calls THIS wrapper instead of the
8
+ * cheap `cropToRect`: it hands the 4 IMAGE-PIXEL corners to the native
9
+ * `BatchStitcher.cropToQuad`, which runs
10
+ * `cv::getPerspectiveTransform` + `cv::warpPerspective` to produce an
11
+ * upright rectangle (averaged opposite-edge dimensions) and overwrites the
12
+ * file in place.
13
+ *
14
+ * This is the typed twin of the `cropToRect` call in
15
+ * `example/InscribedRectDebug.tsx` — same native module (`BatchStitcher`),
16
+ * same in-place overwrite + `{ width, height }` result contract, same
17
+ * platform-availability fallback posture as
18
+ * `src/quality/normaliseOrientation.ts`.
19
+ *
20
+ * Corner-order contract: `quadImagePoints` MUST be in canonical
21
+ * [TL, TR, BR, BL] (clockwise from top-left) order — exactly what
22
+ * `cropGeometry.ts:orderQuadCorners` produces and `RectCropResult.quad`
23
+ * carries. The native side rectifies into a rectangle whose corners map
24
+ * TL→(0,0), TR→(w,0), BR→(w,h), BL→(0,h); pass un-ordered points and the
25
+ * output is mirrored / rotated.
26
+ */
27
+ import type { Quad } from '../camera/cropGeometry';
28
+ /** Options for {@link cropQuad}. */
29
+ export interface CropQuadOptions {
30
+ /**
31
+ * JPEG quality for the re-encoded output, 1–100. Defaults to 90 (the
32
+ * native default, matching `cropToRect`).
33
+ */
34
+ quality?: number;
35
+ }
36
+ /** Resolved result of a successful {@link cropQuad}. */
37
+ export interface CropQuadResult {
38
+ /**
39
+ * The file the rectified image was written to. Equals the input
40
+ * `imagePath` (the native crop overwrites in place) — surfaced
41
+ * explicitly so callers don't have to assume the in-place contract.
42
+ */
43
+ outputPath: string;
44
+ /** Width of the rectified rectangle, in pixels. */
45
+ width: number;
46
+ /** Height of the rectified rectangle, in pixels. */
47
+ height: number;
48
+ }
49
+ /**
50
+ * Flatten the 4 ordered ([TL, TR, BR, BL]) image-pixel corners into the
51
+ * `[tlX, tlY, trX, trY, brX, brY, blX, blY]` array the native module
52
+ * expects. Exported for unit tests + reuse.
53
+ */
54
+ export declare function flattenQuad(quad: Quad): number[];
55
+ /**
56
+ * Perspective-rectify `quadImagePoints` out of `imagePath` into an upright
57
+ * rectangle, overwriting the file in place, and resolve the output path +
58
+ * rectified dimensions.
59
+ *
60
+ * @param imagePath file:// URI (or bare path) of the image to crop.
61
+ * @param quadImagePoints the 4 corners in IMAGE-PIXEL space, canonically
62
+ * ordered [TL, TR, BR, BL] (use
63
+ * `orderQuadCorners`). This is exactly
64
+ * `RectCropResult.quad`.
65
+ * @param outPath where to write the result. The native crop
66
+ * OVERWRITES IN PLACE, so this currently MUST equal
67
+ * `imagePath` (or be omitted — defaults to it).
68
+ * Passing a different path throws, surfacing the
69
+ * limitation rather than silently ignoring it; see
70
+ * the integrator note in the item-7 handoff.
71
+ * @param opts optional `{ quality }`.
72
+ *
73
+ * @throws if the native module isn't registered, if `outPath` differs from
74
+ * `imagePath`, or if the native crop rejects (degenerate quad,
75
+ * canvas guard, write failure).
76
+ */
77
+ export declare function cropQuad(imagePath: string, quadImagePoints: Quad, outPath?: string, opts?: CropQuadOptions): Promise<CropQuadResult>;
78
+ //# sourceMappingURL=cropQuad.d.ts.map
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * cropQuad — item-7 perspective crop: rectify a user-dragged
5
+ * quadrilateral to an upright rectangle.
6
+ *
7
+ * The post-capture crop editor (`src/camera/RectCropPreview.tsx`) lets the
8
+ * user drag 4 independent corners over the stitched result. When that
9
+ * quad isn't ~axis-aligned, the host calls THIS wrapper instead of the
10
+ * cheap `cropToRect`: it hands the 4 IMAGE-PIXEL corners to the native
11
+ * `BatchStitcher.cropToQuad`, which runs
12
+ * `cv::getPerspectiveTransform` + `cv::warpPerspective` to produce an
13
+ * upright rectangle (averaged opposite-edge dimensions) and overwrites the
14
+ * file in place.
15
+ *
16
+ * This is the typed twin of the `cropToRect` call in
17
+ * `example/InscribedRectDebug.tsx` — same native module (`BatchStitcher`),
18
+ * same in-place overwrite + `{ width, height }` result contract, same
19
+ * platform-availability fallback posture as
20
+ * `src/quality/normaliseOrientation.ts`.
21
+ *
22
+ * Corner-order contract: `quadImagePoints` MUST be in canonical
23
+ * [TL, TR, BR, BL] (clockwise from top-left) order — exactly what
24
+ * `cropGeometry.ts:orderQuadCorners` produces and `RectCropResult.quad`
25
+ * carries. The native side rectifies into a rectangle whose corners map
26
+ * TL→(0,0), TR→(w,0), BR→(w,h), BL→(0,h); pass un-ordered points and the
27
+ * output is mirrored / rotated.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.flattenQuad = flattenQuad;
31
+ exports.cropQuad = cropQuad;
32
+ const react_native_1 = require("react-native");
33
+ /**
34
+ * Resolve the native `cropToQuad` function off `NativeModules.BatchStitcher`,
35
+ * or `null` when the module / method isn't registered (e.g. an older native
36
+ * build). Same defensive lookup as `normaliseOrientation`.
37
+ */
38
+ function resolveCropToQuad() {
39
+ const native = react_native_1.NativeModules['BatchStitcher'];
40
+ if (native
41
+ && typeof native === 'object'
42
+ && typeof native.cropToQuad === 'function') {
43
+ return native.cropToQuad;
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Flatten the 4 ordered ([TL, TR, BR, BL]) image-pixel corners into the
49
+ * `[tlX, tlY, trX, trY, brX, brY, blX, blY]` array the native module
50
+ * expects. Exported for unit tests + reuse.
51
+ */
52
+ function flattenQuad(quad) {
53
+ const out = [];
54
+ for (const p of quad) {
55
+ out.push(p.x, p.y);
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * Perspective-rectify `quadImagePoints` out of `imagePath` into an upright
61
+ * rectangle, overwriting the file in place, and resolve the output path +
62
+ * rectified dimensions.
63
+ *
64
+ * @param imagePath file:// URI (or bare path) of the image to crop.
65
+ * @param quadImagePoints the 4 corners in IMAGE-PIXEL space, canonically
66
+ * ordered [TL, TR, BR, BL] (use
67
+ * `orderQuadCorners`). This is exactly
68
+ * `RectCropResult.quad`.
69
+ * @param outPath where to write the result. The native crop
70
+ * OVERWRITES IN PLACE, so this currently MUST equal
71
+ * `imagePath` (or be omitted — defaults to it).
72
+ * Passing a different path throws, surfacing the
73
+ * limitation rather than silently ignoring it; see
74
+ * the integrator note in the item-7 handoff.
75
+ * @param opts optional `{ quality }`.
76
+ *
77
+ * @throws if the native module isn't registered, if `outPath` differs from
78
+ * `imagePath`, or if the native crop rejects (degenerate quad,
79
+ * canvas guard, write failure).
80
+ */
81
+ async function cropQuad(imagePath, quadImagePoints, outPath, opts) {
82
+ if (outPath !== undefined && outPath !== imagePath) {
83
+ // The native cropToQuad (like cropToRect) only overwrites in place.
84
+ // Fail loudly rather than silently writing to imagePath and returning
85
+ // a path the file isn't at.
86
+ throw new Error('[capture-sdk] cropQuad: native crop overwrites in place; '
87
+ + 'outPath must equal imagePath (or be omitted).');
88
+ }
89
+ const fn = resolveCropToQuad();
90
+ if (!fn) {
91
+ throw new Error(`[capture-sdk] cropQuad: native module BatchStitcher.cropToQuad not `
92
+ + `available on ${react_native_1.Platform.OS}. Ensure the native module is registered.`);
93
+ }
94
+ const quality = clampQuality(opts?.quality);
95
+ const dims = await fn({
96
+ imagePath,
97
+ quad: flattenQuad(quadImagePoints),
98
+ quality,
99
+ });
100
+ return {
101
+ outputPath: imagePath,
102
+ width: dims.width,
103
+ height: dims.height,
104
+ };
105
+ }
106
+ /** Clamp the requested JPEG quality into [1, 100]; default 90. */
107
+ function clampQuality(quality) {
108
+ if (quality === undefined || Number.isNaN(quality))
109
+ return 90;
110
+ if (quality < 1)
111
+ return 1;
112
+ if (quality > 100)
113
+ return 100;
114
+ return Math.round(quality);
115
+ }
116
+ //# sourceMappingURL=cropQuad.js.map
@@ -639,6 +639,50 @@ export interface IncrementalFinalizeResult {
639
639
  * on the just-completed capture.
640
640
  */
641
641
  stitchModeResolved?: 'panorama' | 'scans';
642
+ /**
643
+ * 2026-06-15 (DEV) — gyro rotation magnitude of the capture, in RADIANS
644
+ * (angle between the first and last accepted keyframe camera-forward vectors).
645
+ * Surfaced so a dev tool can display it and tune the panorama-vs-SCANS
646
+ * rotation threshold from real captures. `0` when there is no pose-derived
647
+ * rotation signal (non-AR with no poses) — not necessarily "no rotation".
648
+ */
649
+ rRadians?: number;
650
+ /**
651
+ * 2026-06-16 (DEV) — translation magnitude (metres) and the auto decision
652
+ * ratio (`tScore/(tScore+rScore)`, `>=0.55` → SCANS) that drove the
653
+ * panorama-vs-SCANS choice. Surfaced alongside `rRadians` so a dev tool can
654
+ * display the full decision inputs and tune the threshold from real captures.
655
+ * `0` when there is no motion signal (non-AR with no poses / no movement).
656
+ */
657
+ tMeters?: number;
658
+ decisionRatio?: number;
659
+ /**
660
+ * 2026-06-14 (DEV overlay) — a semicolon-separated `key=value` trace of the
661
+ * stitcher's RUNTIME choices for this output, e.g.
662
+ * `"pipe=manual;warp=spherical;route=batch;seam=graphcut;blend=multiband"`.
663
+ * pipe: `manual` (cv::detail) | `highlevel` (cv::Stitcher)
664
+ * warp: `plane` | `cylindrical` | `spherical`
665
+ * route: `batch` (warp-all + seam) | `stream` (low-memory per-frame)
666
+ * seam: `graphcut` | `none`
667
+ * blend: `multiband` | `feather`
668
+ * Intended for a __DEV__-only overlay so the operator can see HOW the
669
+ * panorama was built (which warper, whether the low-memory stream/feather
670
+ * fallback kicked in, etc.). iOS only for now; undefined elsewhere.
671
+ */
672
+ debugSummary?: string;
673
+ /**
674
+ * 2026-06-15 (iOS) — the exact keyframe JPEG paths used for this stitch.
675
+ * Lets the host re-stitch the SAME frames on demand via `refinePanorama`
676
+ * (e.g. the high-level preview tab) without re-running the capture or
677
+ * enumerating the session directory. iOS only; undefined elsewhere.
678
+ */
679
+ batchKeyframePaths?: string[];
680
+ /**
681
+ * 2026-06-15 (iOS) — the capture orientation this stitch baked into the
682
+ * output. An on-demand re-stitch (refinePanorama) MUST pass this back or the
683
+ * result comes out in the raw sensor landscape (sideways). iOS only.
684
+ */
685
+ captureOrientation?: string;
642
686
  }
643
687
  /**
644
688
  * 2026-05-16 — input to `refinePanorama`. Mirrors the subset of
@@ -701,6 +745,15 @@ export interface IncrementalRefineOptions {
701
745
  stitchMode?: 'auto' | 'panorama' | 'scans';
702
746
  /** JPEG quality 1..100, default 90. */
703
747
  jpegQuality?: number;
748
+ /**
749
+ * 2026-06-15 (iOS) — which stitch pipeline to run. `true` = the manual
750
+ * `cv::detail` pipeline (the default batch-capture output); `false` = stock
751
+ * high-level `cv::Stitcher`. Default `false` on the refine path. This is
752
+ * how the on-demand "high-level" preview tab re-stitches the captured
753
+ * keyframes via cv::Stitcher without re-running the whole capture. iOS only
754
+ * (Android refine is always cv::Stitcher).
755
+ */
756
+ useManualPipeline?: boolean;
704
757
  }
705
758
  /**
706
759
  * 2026-05-16 — result of an explicit `refinePanorama` call. Mirrors
@@ -720,6 +773,15 @@ export interface IncrementalRefineResult {
720
773
  framesDropped: number;
721
774
  /** The confidence threshold that succeeded. -1 when not applicable. */
722
775
  finalConfidenceThresh: number;
776
+ /**
777
+ * 2026-06-15 (DEV overlay A/B-aware) — the stitcher's own semicolon-separated
778
+ * `key=value` runtime recipe for THIS refined output, e.g.
779
+ * `"pipe=highlevel;warp=spherical;route=batch;seam=graphcut;blend=multiband"`.
780
+ * Mirrors `IncrementalFinalizeResult.debugSummary`. Lets the on-demand
781
+ * high-level preview tab show its OWN recipe in the __DEV__ overlay pill
782
+ * instead of the manual primary's recipe. iOS only; undefined elsewhere.
783
+ */
784
+ debugSummary?: string;
723
785
  }
724
786
  /**
725
787
  * V15.0e — ARKit plane detection state, polled by the capture screen
@@ -799,6 +861,14 @@ interface NativeIncrementalModule {
799
861
  * are zero, matching legacy behaviour.
800
862
  */
801
863
  imuTranslationMetres?: number;
864
+ /**
865
+ * 2026-06-16 — the explicit lens the user selected (`'1x'` | `'0.5x'`).
866
+ * The reliable zoom signal for the high-level warper tree: `'0.5x'`
867
+ * (ultra-wide) → spherical warper. Replaces deriving zoom from the
868
+ * intrinsics FOV (unreliable on multi-cam 0.5x / non-AR fx=0). Omitted →
869
+ * treated as `'1x'`.
870
+ */
871
+ lens?: string;
802
872
  }): Promise<IncrementalFinalizeResult>;
803
873
  cancel(): Promise<{
804
874
  ok: true;
@@ -830,6 +900,10 @@ interface NativeIncrementalModule {
830
900
  * one-true-number for "how close are we to OOM?". Returns -1
831
901
  * on task_info failure (very rare). Resolves immediately. */
832
902
  getMemoryFootprintMB(): Promise<number>;
903
+ /** 2026-06-16 — total physical RAM in MB. Lets the DEV memory pill derive
904
+ * RAM-aware pressure bands instead of iPhone-fixed thresholds. -1 on
905
+ * failure. Resolves immediately. */
906
+ getDeviceTotalRamMB?(): Promise<number>;
833
907
  /**
834
908
  * 2026-05-16 — realtime+batch fusion API foundation. Run the
835
909
  * shared C++ `cv::Stitcher` pipeline over a caller-supplied list
@@ -61,7 +61,13 @@ export interface UseIncrementalStitcherReturn {
61
61
  * (e.g. in AR mode the native side has its own pose-driven
62
62
  * translation magnitude and prefers that).
63
63
  */
64
- imuTranslationMetres?: number) => Promise<IncrementalFinalizeResult>;
64
+ imuTranslationMetres?: number,
65
+ /**
66
+ * 2026-06-16 — the EXPLICIT lens the user selected (`'1x'` | `'0.5x'`).
67
+ * The reliable zoom signal for the high-level warper tree (`'0.5x'`
68
+ * ultra-wide → spherical). Omit ⇒ treated as `'1x'`.
69
+ */
70
+ lens?: string) => Promise<IncrementalFinalizeResult>;
65
71
  /** Abort the capture without producing output. */
66
72
  cancel: () => Promise<void>;
67
73
  }
@@ -110,7 +110,7 @@ function useIncrementalStitcher() {
110
110
  setState(null);
111
111
  lastHintRef.current = null;
112
112
  }, [native]);
113
- const finalize = (0, react_1.useCallback)(async (outputPath, quality = 90, captureOrientation, imuTranslationMetres) => {
113
+ const finalize = (0, react_1.useCallback)(async (outputPath, quality = 90, captureOrientation, imuTranslationMetres, lens) => {
114
114
  if (!native) {
115
115
  throw new Error('useIncrementalStitcher: native module unavailable');
116
116
  }
@@ -128,6 +128,12 @@ function useIncrementalStitcher() {
128
128
  // doesn't carry tx/ty/tz, so pose-derived translation is 0).
129
129
  // Native side treats it as a magnitude (always ≥ 0).
130
130
  imuTranslationMetres: Math.max(0, imuTranslationMetres ?? 0),
131
+ // 2026-06-16 — the EXPLICIT lens the user selected ('1x' | '0.5x').
132
+ // This is the reliable zoom signal for the high-level warper tree
133
+ // (0.5x ultra-wide → spherical); deriving zoom from intrinsics FOV was
134
+ // unreliable (multi-cam 0.5x reaches the ultra-wide by zoom without
135
+ // changing the reported fx, and the non-AR path may supply fx=0).
136
+ lens,
131
137
  });
132
138
  setIsRunning(false);
133
139
  // Clear React state on finalize so the next start doesn't
@@ -223,6 +223,15 @@ struct FinalizePayload {
223
223
  /// resolved upstream by `resolveStitchModeAuto` before this snapshot
224
224
  /// is captured; this field never carries 'auto'.
225
225
  let batchStitchModeResolved: String
226
+ /// Gyro rotation magnitude (radians) of the capture — surfaced to JS for the
227
+ /// dev 3-tab preview's rRadians readout (threshold tuning). 0.0 when there
228
+ /// is no pose-derived rotation signal (non-AR with no poses).
229
+ let rRadians: Double
230
+ /// Translation magnitude (metres) + the auto decision ratio
231
+ /// (tScore/(tScore+rScore), >=0.55 → SCANS) that drove the panorama-vs-SCANS
232
+ /// choice — surfaced to JS for the dev tuning readout alongside rRadians.
233
+ let tMeters: Double
234
+ let decisionRatio: Double
226
235
  let keyframeExifOrientation: Int
227
236
  /// AR-STITCHING-TWO-MODES (memory/ar-stitching-two-modes.md):
228
237
  /// capture-time hold orientation for the bake-rotation pass.
@@ -430,6 +439,10 @@ public final class IncrementalStitcher: NSObject {
430
439
  /// AR mode (where pose-derived tx/ty/tz is always 0). Set to 0
431
440
  /// at start() and overwritten at finalize() entry.
432
441
  private var batchImuTranslationMetres: Double = 0.0
442
+ /// 2026-06-16 — the explicit lens the user selected ('1x' | '0.5x'), set at
443
+ /// finalize() entry from JS. The zoom signal for the high-level warper tree
444
+ /// (0.5x ultra-wide → spherical). Defaults to '1x'.
445
+ private var batchLens: String = "1x"
433
446
  /// AR-STITCHING-TWO-MODES — see memory/ar-stitching-two-modes.md
434
447
  ///
435
448
  /// Physical phone orientation at start() time, sourced from the
@@ -492,6 +505,14 @@ public final class IncrementalStitcher: NSObject {
492
505
  stateLock.unlock()
493
506
  }
494
507
 
508
+ /// 2026-06-16 — store the explicit lens ('1x' | '0.5x') JS supplies at
509
+ /// finalize() entry; the high-level warper tree reads it (0.5x → spherical).
510
+ @objc public func updateLens(_ lens: String) {
511
+ stateLock.lock()
512
+ self.batchLens = lens
513
+ stateLock.unlock()
514
+ }
515
+
495
516
  /// 2026-05-18 (Iss 3) — return the current capture's keyframe
496
517
  /// session directory, or nil if no capture is in flight / engine
497
518
  /// isn't using a per-session keyframe collector.
@@ -829,6 +850,7 @@ public final class IncrementalStitcher: NSObject {
829
850
  // too. Updated at finalize() entry from JS-supplied
830
851
  // option value.
831
852
  self.batchImuTranslationMetres = 0.0
853
+ self.batchLens = "1x" // overwritten at finalize() from JS (updateLens)
832
854
  self.batchKeyframeMode = true
833
855
  os_log(.fault, log: Self.diagLog,
834
856
  "[V16-batch-keyframe] start mode=batch-keyframe rotation=0 (was %d, forced to 0 to match pose intrinsics) sessionDir=%{public}@",
@@ -943,7 +965,7 @@ public final class IncrementalStitcher: NSObject {
943
965
  } else if let v = configOverrides["maxKeyframeIntervalMs"] as? Int {
944
966
  self.keyframeGate.maxKeyframeIntervalMs = max(0.0, Double(v))
945
967
  } else {
946
- self.keyframeGate.maxKeyframeIntervalMs = 2000.0
968
+ self.keyframeGate.maxKeyframeIntervalMs = 1500.0
947
969
  }
948
970
  // V16 — novelty aggregation percentile. Clamp at start to
949
971
  // [0.5, 0.99]; the bridge re-clamps but matching it here
@@ -1147,20 +1169,33 @@ public final class IncrementalStitcher: NSObject {
1147
1169
  // translation/rotation magnitude ratio between first + last
1148
1170
  // accepted keyframe poses → SCANS (translation-heavy) or
1149
1171
  // PANORAMA (rotation-heavy). Non-auto values pass through.
1172
+ // Resolve once so the dev readout gets the SAME tMeters / ratio / rRadians
1173
+ // that drove the decision — and gets them even when the mode is forced
1174
+ // (informative: shows what auto WOULD have picked). Captured into the
1175
+ // payload here so the C2-invariant finalize closure can read them via
1176
+ // payload (no self/ivar access inside the closure).
1177
+ let autoResolution = resolveStitchModeAuto(
1178
+ first: batchFirstAcceptedPose,
1179
+ last: batchLastAcceptedPose,
1180
+ imuTranslationMetres: batchImuTranslationMetres)
1150
1181
  let stitchModeResolved: String
1151
1182
  switch batchStitchMode {
1152
1183
  case "panorama": stitchModeResolved = "panorama"
1153
1184
  case "scans": stitchModeResolved = "scans"
1154
- default: stitchModeResolved = resolveStitchModeAuto(
1155
- first: batchFirstAcceptedPose,
1156
- last: batchLastAcceptedPose,
1157
- imuTranslationMetres: batchImuTranslationMetres
1158
- )
1185
+ default: stitchModeResolved = autoResolution.mode
1159
1186
  }
1187
+ let rRadiansResolved = autoResolution.rRadians
1188
+ // 2026-06-16 — HIGH-LEVEL ACROSS THE BOARD (mirrors Android). Pick the
1189
+ // warper from the (motion, Mode A/B, lens) tree; the dispatch below now
1190
+ // forces useManualPipeline=false + stitchMode="panorama". batchWarperType
1191
+ // (settings) is superseded by the tree.
1192
+ let highLevelWarper = pickHighLevelWarper(
1193
+ orientation: captureOrientation,
1194
+ lens: batchLens)
1160
1195
  os_log(.fault, log: Self.diagLog,
1161
- "[V16-batch-keyframe.stitchMode] configured=%{public}@ resolved=%{public}@ paths=%d imuT=%.3fm",
1162
- batchStitchMode, stitchModeResolved, Int32(paths.count),
1163
- batchImuTranslationMetres)
1196
+ "[V16-batch-keyframe.stitchMode] configured=%{public}@ resolved=%{public}@ warper=%{public}@ lens=%{public}@ paths=%d imuT=%.3fm",
1197
+ batchStitchMode, stitchModeResolved, highLevelWarper, batchLens,
1198
+ Int32(paths.count), batchImuTranslationMetres)
1164
1199
 
1165
1200
  let payload = FinalizePayload(
1166
1201
  cleaned: cleaned,
@@ -1168,11 +1203,14 @@ public final class IncrementalStitcher: NSObject {
1168
1203
  inBatchKeyframeMode: inBatchKeyframeMode,
1169
1204
  collector: collector,
1170
1205
  paths: paths,
1171
- batchWarperType: batchWarperType,
1206
+ batchWarperType: highLevelWarper,
1172
1207
  batchBlenderType: batchBlenderType,
1173
1208
  batchSeamFinderType: batchSeamFinderType,
1174
1209
  batchEnableInscribedRectCrop: batchEnableInscribedRectCrop,
1175
1210
  batchStitchModeResolved: stitchModeResolved,
1211
+ rRadians: rRadiansResolved,
1212
+ tMeters: autoResolution.tMeters,
1213
+ decisionRatio: autoResolution.ratio,
1176
1214
  keyframeExifOrientation: keyframeExifOrientation,
1177
1215
  captureOrientation: captureOrientation,
1178
1216
  drops: drops,
@@ -1438,7 +1476,15 @@ public final class IncrementalStitcher: NSObject {
1438
1476
  seamFinderType: payload.batchSeamFinderType,
1439
1477
  captureOrientation: payload.captureOrientation,
1440
1478
  useInscribedRectCrop: payload.batchEnableInscribedRectCrop,
1441
- stitchMode: payload.batchStitchModeResolved
1479
+ // 2026-06-16 — HIGH-LEVEL ACROSS THE BOARD (mirrors
1480
+ // Android): always cv::Stitcher PANORAMA with the
1481
+ // tree-chosen warper (payload.batchWarperType is now
1482
+ // highLevelWarper). The manual path's OOM hardening
1483
+ // was ported to high-level (catch ladder + two-phase
1484
+ // canvas guard + RAM-aware compositingResol + spherical
1485
+ // rescue), so this is now memory-safe.
1486
+ stitchMode: "panorama",
1487
+ useManualPipeline: false
1442
1488
  )
1443
1489
  // V16 fix-attempt 9 (verified on device,
1444
1490
  // 2026-05-13) — sentinel-result detection.
@@ -1501,6 +1547,17 @@ public final class IncrementalStitcher: NSObject {
1501
1547
  "batchKeyframeSessionDir":
1502
1548
  payload.collector?.sessionDir ?? "",
1503
1549
  "batchKeyframeCount": payload.paths.count,
1550
+ // 2026-06-15 — the exact keyframe JPEG paths used for
1551
+ // this stitch, so JS can re-stitch them ON DEMAND via
1552
+ // refinePanorama (the high-level tab) without listing
1553
+ // the session dir itself.
1554
+ "batchKeyframePaths": payload.paths,
1555
+ // The orientation this stitch baked into the output.
1556
+ // The on-demand high-level re-stitch MUST pass the
1557
+ // same value or it comes out in the raw sensor
1558
+ // landscape (sideways) — refinePanorama otherwise
1559
+ // defaults to "portrait" (no bake-rotation).
1560
+ "captureOrientation": payload.captureOrientation,
1504
1561
  ]
1505
1562
  if r.framesRequested >= 0 {
1506
1563
  batchDict["framesRequested"] = Int(r.framesRequested)
@@ -1523,6 +1580,17 @@ public final class IncrementalStitcher: NSObject {
1523
1580
  // helps the operator understand why the
1524
1581
  // panorama looks the way it does.
1525
1582
  batchDict["stitchModeResolved"] = payload.batchStitchModeResolved
1583
+ batchDict["rRadians"] = payload.rRadians
1584
+ // Dev tuning readout — translation magnitude + the auto
1585
+ // decision ratio that drove panorama-vs-SCANS.
1586
+ batchDict["tMeters"] = payload.tMeters
1587
+ batchDict["decisionRatio"] = payload.decisionRatio
1588
+ // 2026-06-14 (DEV overlay) — the stitcher's runtime
1589
+ // choices (pipeline/warper/route/seam/blend) for this
1590
+ // output, shown on the preview in __DEV__.
1591
+ if !r.debugSummary.isEmpty {
1592
+ batchDict["debugSummary"] = r.debugSummary
1593
+ }
1526
1594
  completion(batchDict, nil)
1527
1595
  } catch let stitchErr as NSError {
1528
1596
  completion(nil, stitchErr)
@@ -1641,6 +1709,11 @@ public final class IncrementalStitcher: NSObject {
1641
1709
  // at line 738 of src/stitching/incremental.ts). JS callers
1642
1710
  // can override by passing config["stitchMode"].
1643
1711
  let refineStitchMode = (config["stitchMode"] as? String) ?? "scans"
1712
+ // 2026-06-15 — pipeline is caller-selectable. The on-demand high-level
1713
+ // tab calls refinePanorama with useManualPipeline:false to re-stitch the
1714
+ // captured keyframes via stock cv::Stitcher. Default false (high-level)
1715
+ // preserves the refine path's historical cv::Stitcher behaviour.
1716
+ let refineManual = (config["useManualPipeline"] as? Bool) ?? false
1644
1717
  let quality = max(1, min(100, (config["jpegQuality"] as? Int) ?? 90))
1645
1718
  let cleanedOutput = outputPath.hasPrefix("file://")
1646
1719
  ? String(outputPath.dropFirst(7))
@@ -1679,7 +1752,8 @@ public final class IncrementalStitcher: NSObject {
1679
1752
  seamFinderType: seam,
1680
1753
  captureOrientation: orientation,
1681
1754
  useInscribedRectCrop: useInscribed,
1682
- stitchMode: refineStitchMode
1755
+ stitchMode: refineStitchMode,
1756
+ useManualPipeline: refineManual
1683
1757
  )
1684
1758
  // fix-9 sentinel detection — see the finalize() path
1685
1759
  // for the full rationale. A 0×0 result means
@@ -1717,7 +1791,13 @@ public final class IncrementalStitcher: NSObject {
1717
1791
  frames: frameCount,
1718
1792
  errorMessage: nil
1719
1793
  )
1720
- completion([
1794
+ // 2026-06-15 (DEV overlay A/B-aware) — carry the stitcher's
1795
+ // own runtime recipe up to JS so the preview's DEV pill shows
1796
+ // the HIGH-LEVEL recipe (pipe=highlevel;warp=spherical;…) while
1797
+ // the user views the high-level tab, instead of the manual
1798
+ // primary's recipe. Mirrors the batch finalize's batchDict
1799
+ // (guard empty — empty string means unavailable).
1800
+ var refineDict: [String: Any] = [
1721
1801
  "panoramaPath": r.outputPath,
1722
1802
  "width": Int(r.width),
1723
1803
  "height": Int(r.height),
@@ -1725,7 +1805,11 @@ public final class IncrementalStitcher: NSObject {
1725
1805
  "framesIncluded": frameCount,
1726
1806
  "framesDropped": 0,
1727
1807
  "finalConfidenceThresh": -1.0,
1728
- ], nil)
1808
+ ]
1809
+ if !r.debugSummary.isEmpty {
1810
+ refineDict["debugSummary"] = r.debugSummary
1811
+ }
1812
+ completion(refineDict, nil)
1729
1813
  } catch let err as NSError {
1730
1814
  self?.emitRefineProgress(
1731
1815
  stage: "error",
@@ -2413,13 +2497,13 @@ public final class IncrementalStitcher: NSObject {
2413
2497
  first: [Double]?,
2414
2498
  last: [Double]?,
2415
2499
  imuTranslationMetres: Double
2416
- ) -> String {
2500
+ ) -> (mode: String, rRadians: Double, tMeters: Double, ratio: Double) {
2417
2501
  guard let firstPose = first, firstPose.count == 7,
2418
2502
  let lastPose = last, lastPose.count == 7 else {
2419
2503
  // No pose data at all — fall back on whichever signal we
2420
2504
  // do have. imuTranslationMetres > 0 hints "scans"; 0
2421
- // hints "panorama".
2422
- return imuTranslationMetres > 0.05 ? "scans" : "panorama"
2505
+ // hints "panorama". rRadians 0.0 — no gyro signal.
2506
+ return (imuTranslationMetres > 0.05 ? "scans" : "panorama", 0.0, 0.0, 0.0)
2423
2507
  }
2424
2508
  // Translation magnitude (Euclidean, in metres).
2425
2509
  let dtx = lastPose[0] - firstPose[0]
@@ -2431,14 +2515,7 @@ public final class IncrementalStitcher: NSObject {
2431
2515
  // the only signal we have.
2432
2516
  let tMeters = max(tPose, imuTranslationMetres)
2433
2517
  // Rotation magnitude — angle between camera-forward vectors.
2434
- // Camera-forward in body frame is (0, 0, -1) for ARKit/ARCore.
2435
- let fwdFirst = qrotForwardZneg(
2436
- firstPose[3], firstPose[4], firstPose[5], firstPose[6])
2437
- let fwdLast = qrotForwardZneg(
2438
- lastPose[3], lastPose[4], lastPose[5], lastPose[6])
2439
- let dot = max(-1.0, min(1.0,
2440
- fwdFirst.0 * fwdLast.0 + fwdFirst.1 * fwdLast.1 + fwdFirst.2 * fwdLast.2))
2441
- let rRadians = acos(dot)
2518
+ let rRadians = rotationRadians(first: firstPose, last: lastPose)
2442
2519
  // Normalisation: 10 cm of translation ≈ 1 rad of rotation as
2443
2520
  // "equivalent magnitude" for the ratio. Shelf scans cover
2444
2521
  // ~30 cm translation with ~10° (0.17 rad) rotation:
@@ -2448,13 +2525,61 @@ public final class IncrementalStitcher: NSObject {
2448
2525
  let tScore = tMeters / 0.10
2449
2526
  let rScore = rRadians / 1.00
2450
2527
  let denom = tScore + rScore
2451
- if denom <= 1e-9 { return "panorama" } // no motion either way
2528
+ if denom <= 1e-9 { return ("panorama", rRadians, tMeters, 0.0) } // no motion either way
2452
2529
  let ratio = tScore / denom
2530
+
2531
+ // 2026-06-15 — LOW-ROTATION GUARD. The gyro rotation (rRadians) is
2532
+ // trustworthy; the IMU translation (tMeters, in non-AR) is NOT — a
2533
+ // continuous rotation leaks gravity into the double-integrated accel and
2534
+ // inflates it, which can falsely push `ratio` over 0.55 → SCANS, whose
2535
+ // affine warper can't represent the rotation. When the gyro shows a
2536
+ // clear pan (> ~20°) with only modest translation, force PANORAMA
2537
+ // regardless of the (possibly-inflated) translation. Genuine shelf
2538
+ // scans (low rotation, large real translation) skip this and still
2539
+ // reach SCANS via the ratio.
2540
+ let lowRotationGuard = rRadians > 0.35 && tMeters < 0.25
2541
+ let mode = (!lowRotationGuard && ratio >= 0.55) ? "scans" : "panorama"
2453
2542
  os_log(.fault, log: Self.diagLog,
2454
- "[stitchMode.auto] tPose=%.3fm tImu=%.3fm r=%.3frad ratio=%.3f → %{public}@",
2543
+ "[stitchMode.auto] tPose=%.3fm tImu=%.3fm r=%.3frad ratio=%.3f rotGuard=%d → %{public}@",
2455
2544
  tPose, imuTranslationMetres, rRadians, ratio,
2456
- ratio >= 0.55 ? "scans" : "panorama")
2457
- return ratio >= 0.55 ? "scans" : "panorama"
2545
+ lowRotationGuard ? 1 : 0, mode)
2546
+ return (mode, rRadians, tMeters, ratio)
2547
+ }
2548
+
2549
+ /// 2026-06-16 — high-level warper decision tree (mirrors Android's
2550
+ /// pickHighLevelWarper). The pipeline is now ALWAYS high-level cv::Stitcher
2551
+ /// PANORAMA. Warper is a pure function of (lens, pan direction); the
2552
+ /// rotation-vs-translation (ex-SCANS) distinction was DROPPED as redundant —
2553
+ /// at 1x the same direction-based warpers serve both, and 0.5x is always
2554
+ /// spherical. orientation = capture hold ("landscape*" = Mode A vertical
2555
+ /// pan; else Mode B horizontal); lens = the EXPLICIT lens ("0.5x" | "1x").
2556
+ ///
2557
+ /// 0.5x ultra-wide → spherical (bounded both axes; any pan)
2558
+ /// 1x + Mode A (vertical) → plane
2559
+ /// 1x + Mode B (horizontal) → cylindrical
2560
+ ///
2561
+ /// Quality-preferred warper; the C++ memory ladder force-falls to spherical
2562
+ /// (and downscales compositingResol) under pressure.
2563
+ private func pickHighLevelWarper(
2564
+ orientation: String,
2565
+ lens: String
2566
+ ) -> String {
2567
+ if lens == "0.5x" { return "spherical" } // ultra-wide → always spherical
2568
+ let verticalPanModeA = orientation.hasPrefix("landscape")
2569
+ return verticalPanModeA ? "plane" : "cylindrical" // 1x: A→plane, B→cylindrical
2570
+ }
2571
+
2572
+ /// Gyro rotation magnitude (radians) between two 7-element poses
2573
+ /// `[tx,ty,tz,qx,qy,qz,qw]` — angle between camera-forward vectors.
2574
+ /// Returns 0.0 if either pose is missing/malformed (non-AR, no pose).
2575
+ /// Shared by `resolveStitchModeAuto` + the finalize `rRadians` readout (DRY).
2576
+ private func rotationRadians(first: [Double]?, last: [Double]?) -> Double {
2577
+ guard let f = first, f.count == 7, let l = last, l.count == 7 else { return 0.0 }
2578
+ let fwdFirst = qrotForwardZneg(f[3], f[4], f[5], f[6])
2579
+ let fwdLast = qrotForwardZneg(l[3], l[4], l[5], l[6])
2580
+ let dot = max(-1.0, min(1.0,
2581
+ fwdFirst.0 * fwdLast.0 + fwdFirst.1 * fwdLast.1 + fwdFirst.2 * fwdLast.2))
2582
+ return acos(dot)
2458
2583
  }
2459
2584
 
2460
2585
  /// Closed-form q · (0,0,-1) · q⁻¹ — rotates the camera-forward