react-native-image-stitcher 0.1.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 (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. package/src/types.ts +78 -0
@@ -0,0 +1,347 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // useIMUTranslationGate.ts — JS-side IMU translation tracker for the
4
+ // non-AR translation-warning banner + (optional) gate force-accept.
5
+ //
6
+ // 2026-05-17 (Issue #4-A v3): rewritten on top of `expo-sensors`
7
+ // `DeviceMotion` (which returns gravity-subtracted linear
8
+ // acceleration via Apple's CoreMotion fusion on iOS and Android's
9
+ // `TYPE_LINEAR_ACCELERATION` sensor on Android — both significantly
10
+ // less noisy than raw accel + JS-side IIR gravity subtraction).
11
+ // Tracks a SINGLE device-frame axis (device-X — the phone's lateral /
12
+ // short side) rather than the 3D translation magnitude.
13
+ //
14
+ // Why exists
15
+ // ──────────
16
+ //
17
+ // In non-AR mode the SDK has no ARSession pose stream, so the shared
18
+ // C++ `KeyframeGate`'s translation-budget feature stays at zero and
19
+ // never trips. This hook fills the gap on the JS side and emits a
20
+ // budget-crossed callback the host can wire to either:
21
+ //
22
+ // (a) `markNextFrameAsLastKeyframe()` — tell the gate "force-accept
23
+ // the next frame regardless of overlap", so the trailing-edge
24
+ // frame still lands when the operator translates instead of
25
+ // rotates.
26
+ //
27
+ // (b) A user-facing warning banner ("Rotate the camera instead of
28
+ // moving it sideways" — see AuditCaptureScreen).
29
+ //
30
+ // AuditCaptureScreen wires it to both.
31
+ //
32
+ // Why device-X (the shorter side)
33
+ // ───────────────────────────────
34
+ //
35
+ // We track motion ALONG the pan axis (the direction the operator is
36
+ // supposed to be rotating-through but might be translating-through
37
+ // instead) because translation orthogonal to the pan axis is
38
+ // acceptable — vertical translation while panning horizontally in
39
+ // portrait, for example, doesn't cause horizontal parallax.
40
+ //
41
+ // The pan axis maps to device-X in BOTH supported orientations
42
+ // (per memory/ar-stitching-two-modes.md):
43
+ //
44
+ // Portrait + horizontal pan: device-X = user-left/right = pan axis.
45
+ // Landscape + vertical pan: device-X has rotated 90° into the
46
+ // user's up/down direction = pan axis.
47
+ //
48
+ // The lateral axis of the phone (its short side) always aligns with
49
+ // the pan direction in either supported mode, so a single-axis
50
+ // tracker works without needing to know which orientation we're in.
51
+ //
52
+ // Drift mitigation
53
+ // ────────────────
54
+ //
55
+ // `DeviceMotion.acceleration` (gravity removed in native code via
56
+ // IMU fusion) has a noise floor roughly 30-50 % lower than what the
57
+ // previous raw-accel + JS IIR pipeline produced. Single-axis math
58
+ // further reduces apparent drift by ≈√3 vs the prior 3D magnitude.
59
+ // Together they should keep the typical "stationary phone" reading
60
+ // below ~5-10 cm even after several seconds.
61
+ //
62
+ // Anchor resets happen at (a) recording start (via the host calling
63
+ // `resetAnchor()` from handleHoldStart) and (b) every accepted
64
+ // keyframe — these bound the per-interval drift window to typically
65
+ // 0.3-2 s.
66
+ //
67
+ // What we no longer do
68
+ // ────────────────────
69
+ //
70
+ // - JS-side 1-pole IIR for gravity subtraction (native API gives
71
+ // gravity-subtracted accel directly).
72
+ // - 3D vector magnitude (now single device-X axis).
73
+ // - Velocity damping (kept as a safety net at 5%/sample so a
74
+ // persistent noise-floor offset doesn't slowly drift the axis —
75
+ // low cost, high robustness).
76
+
77
+ import { useCallback, useEffect, useRef } from 'react';
78
+ import { DeviceMotion } from 'expo-sensors';
79
+ import type { DeviceMotionMeasurement } from 'expo-sensors';
80
+
81
+ // expo-sensors doesn't re-export Subscription from its index, but
82
+ // `addListener` returns one — use the inferred return type so we
83
+ // don't have to chase the right deep-import path.
84
+ type DeviceMotionSubscription = ReturnType<typeof DeviceMotion.addListener>;
85
+
86
+
87
+ export interface UseIMUTranslationGateOptions {
88
+ /**
89
+ * Whether the gate is engaged. Pass `false` to skip the subscription
90
+ * entirely — useful when the host is in AR mode (where the gate
91
+ * gets pose-derived translation natively). Hot-toggleable;
92
+ * subscribing/unsubscribing is cheap.
93
+ */
94
+ enabled: boolean;
95
+
96
+ /**
97
+ * Translation budget in METRES along the device-X (pan) axis.
98
+ * When the integrated displacement magnitude exceeds this since
99
+ * the last accept, the hook fires `onBudgetExceeded`. Default
100
+ * 0.40 m / 40 cm (80 % of the 50 cm default
101
+ * `flowMaxTranslationCm`). Caller typically passes
102
+ * `panoramaSettings.flowMaxTranslationCm * 0.8 / 100`.
103
+ */
104
+ budgetMeters?: number;
105
+
106
+ /**
107
+ * Update interval in MILLISECONDS for the DeviceMotion sensor.
108
+ * Default 20 ms ≈ 50 Hz. Lower (faster sampling) = more accurate
109
+ * integration; higher = lower CPU + battery. Matches the previous
110
+ * raw-accel cadence so reset/integrate behaviour stays comparable.
111
+ */
112
+ sampleIntervalMs?: number;
113
+
114
+ /**
115
+ * Fired exactly once per "budget crossing" — i.e., when the
116
+ * running translation along device-X crosses `budgetMeters` from
117
+ * below. The host is responsible for both (a) calling
118
+ * `IncrementalStitcher.markNextFrameAsLastKeyframe()` and
119
+ * (b) invoking the returned `resetAnchor()` once the next
120
+ * keyframe actually accepts, so the integrator restarts from zero.
121
+ */
122
+ onBudgetExceeded: () => void;
123
+
124
+ /**
125
+ * 2026-05-18 (Issue #4 investigation) — when true, log every Nth
126
+ * accelerometer sample (default N=20 ≈ 400 ms at 50 Hz) showing
127
+ * the current `acceleration.x`, accumulated `posX`, and time
128
+ * since anchor reset. Helps diagnose drift behaviour vs real
129
+ * translation magnitude in field testing. Defaults to false —
130
+ * production captures stay quiet.
131
+ */
132
+ debug?: boolean;
133
+ }
134
+
135
+
136
+ export interface UseIMUTranslationGateReturn {
137
+ /**
138
+ * Reset the running translation to zero. Call this at recording
139
+ * start AND after each confirmed keyframe accept — the typical
140
+ * wiring is to subscribe to `IncrementalStateUpdate` and
141
+ * call `resetAnchor()` from inside the listener AND from the host's
142
+ * `handleHoldStart`.
143
+ */
144
+ resetAnchor: () => void;
145
+
146
+ /**
147
+ * Read the current running displacement along device-X in METRES.
148
+ * Returns the absolute value (sign is uninteresting — either left
149
+ * or right counts the same toward the budget).
150
+ * Useful for the on-screen debug HUD ("translation since last
151
+ * accept: 0.07 m"). Not exposed via state — host polls if needed.
152
+ */
153
+ getCurrentTranslationM: () => number;
154
+ }
155
+
156
+
157
+ /**
158
+ * IMU-based translation tracker — single-axis (device-X / pan axis),
159
+ * fused IMU via `expo-sensors` `DeviceMotion`. See file header for
160
+ * algorithm + rationale. No platform-specific code; the underlying
161
+ * native fusion is platform-aware (CoreMotion on iOS, fused
162
+ * `TYPE_LINEAR_ACCELERATION` on Android).
163
+ */
164
+ export function useIMUTranslationGate(
165
+ options: UseIMUTranslationGateOptions,
166
+ ): UseIMUTranslationGateReturn {
167
+ const {
168
+ enabled,
169
+ budgetMeters = 0.40,
170
+ sampleIntervalMs = 20,
171
+ onBudgetExceeded,
172
+ debug = false,
173
+ } = options;
174
+
175
+ // Integrator state, kept in refs so the listener can write without
176
+ // re-creating its closure on every render.
177
+ // ─ velX : velocity along device-X (m/s)
178
+ // ─ posX : position along device-X (m)
179
+ // ─ lastMs: epoch ms of the previous sample (for dt)
180
+ // ─ budgetCrossed: debounce flag — clears on resetAnchor
181
+ // ─ sampleCount: rolling counter for debug log throttle
182
+ // ─ anchorMs: timestamp of the most recent resetAnchor (or first
183
+ // sample) — gives "time since anchor" in debug output
184
+ const velX = useRef<number>(0);
185
+ const posX = useRef<number>(0);
186
+ const lastMs = useRef<number>(0);
187
+ const budgetCrossed = useRef<boolean>(false);
188
+ const sampleCount = useRef<number>(0);
189
+ const anchorMs = useRef<number>(0);
190
+
191
+ // Keep the callback in a ref so we don't tear down + re-subscribe
192
+ // on every prop change. React idiom for stable callback identity.
193
+ const onBudgetExceededRef = useRef(onBudgetExceeded);
194
+ useEffect(() => { onBudgetExceededRef.current = onBudgetExceeded; },
195
+ [onBudgetExceeded]);
196
+
197
+ useEffect(() => {
198
+ if (debug) {
199
+ // eslint-disable-next-line no-console
200
+ console.log(
201
+ `[IMUTransGate] effect re-run: enabled=${enabled} `
202
+ + `budget=${budgetMeters.toFixed(2)}m sampleIntervalMs=${sampleIntervalMs}`,
203
+ );
204
+ }
205
+ if (!enabled) return;
206
+
207
+ // Lock in the DeviceMotion update rate. Other expo-sensors
208
+ // consumers in the SDK can override later; the LAST setter wins
209
+ // per Expo's docs, which is fine because our budget logic
210
+ // tolerates a wide range of cadences.
211
+ DeviceMotion.setUpdateInterval(sampleIntervalMs);
212
+
213
+ // Reset state on (re-)engage so the first measurement after
214
+ // enabled-toggles-true doesn't carry stale velocity from a
215
+ // previous capture session.
216
+ velX.current = 0;
217
+ posX.current = 0;
218
+ lastMs.current = 0;
219
+ budgetCrossed.current = false;
220
+ sampleCount.current = 0;
221
+ anchorMs.current = Date.now();
222
+
223
+ // 2026-05-18 (Issue #3 diagnostics) — track whether we've ever
224
+ // received a non-null `acceleration` for this subscription. If
225
+ // the user reports "no logs" we can correlate with these
226
+ // start-of-subscription and first-real-data markers.
227
+ let everGotData = false;
228
+ let nullSampleCount = 0;
229
+ if (debug) {
230
+ // eslint-disable-next-line no-console
231
+ console.log('[IMUTransGate] subscribing to DeviceMotion');
232
+ }
233
+
234
+ const sub: DeviceMotionSubscription = DeviceMotion.addListener((m: DeviceMotionMeasurement) => {
235
+ const a = m.acceleration; // gravity-subtracted (m/s²)
236
+ if (!a) {
237
+ nullSampleCount += 1;
238
+ if (debug && nullSampleCount === 1) {
239
+ // eslint-disable-next-line no-console
240
+ console.log(
241
+ '[IMUTransGate] first sample: acceleration=null '
242
+ + '(CoreMotion warming up; will retry on next sample)',
243
+ );
244
+ }
245
+ if (debug && nullSampleCount > 0 && nullSampleCount % 100 === 0) {
246
+ // eslint-disable-next-line no-console
247
+ console.log(
248
+ `[IMUTransGate] STILL receiving null acceleration after `
249
+ + `${nullSampleCount} samples — sensor source may be broken`,
250
+ );
251
+ }
252
+ return; // can be null briefly on cold start
253
+ }
254
+ if (debug && !everGotData) {
255
+ everGotData = true;
256
+ // eslint-disable-next-line no-console
257
+ console.log(
258
+ `[IMUTransGate] first real sample: ax=${a.x.toFixed(3)} `
259
+ + `ay=${a.y.toFixed(3)} az=${a.z.toFixed(3)} m/s² `
260
+ + `(after ${nullSampleCount} null sample(s))`,
261
+ );
262
+ }
263
+ const now = Date.now();
264
+ if (lastMs.current === 0) {
265
+ lastMs.current = now;
266
+ return;
267
+ }
268
+ const dt = Math.max(0, Math.min(0.1, (now - lastMs.current) / 1000.0));
269
+ lastMs.current = now;
270
+ if (dt === 0) return;
271
+
272
+ // Single-axis integration along device-X (lateral / pan axis).
273
+ // See file header for why device-X is the right axis in both
274
+ // portrait and landscape captures.
275
+ velX.current += a.x * dt;
276
+ velX.current *= 0.95; // 5%/sample damping — see file header
277
+ posX.current += velX.current * dt;
278
+
279
+ const mag = Math.abs(posX.current);
280
+
281
+ // 2026-05-18 (Issue #4 investigation) — debug-gated diagnostic
282
+ // log. Throttled to every 20th sample (~400 ms at 50 Hz) so
283
+ // the log isn't a firehose. When this runs and we still see
284
+ // posX hovering at < 5 cm during a real translation, the
285
+ // sensor source isn't capturing what we think it is.
286
+ sampleCount.current += 1;
287
+ if (debug && sampleCount.current % 20 === 0) {
288
+ const secs = (now - anchorMs.current) / 1000.0;
289
+ // eslint-disable-next-line no-console
290
+ console.log(
291
+ `[IMUTransGate] t+${secs.toFixed(2)}s `
292
+ + `ax=${a.x.toFixed(3)}m/s² `
293
+ + `velX=${velX.current.toFixed(4)}m/s `
294
+ + `posX=${posX.current.toFixed(4)}m `
295
+ + `(|mag|=${mag.toFixed(4)}m, budget=${budgetMeters.toFixed(2)}m, crossed=${budgetCrossed.current})`,
296
+ );
297
+ }
298
+
299
+ // Budget crossing — fire exactly once per crossing (the
300
+ // `budgetCrossed` flag clears on `resetAnchor`).
301
+ if (!budgetCrossed.current && mag >= budgetMeters) {
302
+ budgetCrossed.current = true;
303
+ if (debug) {
304
+ // eslint-disable-next-line no-console
305
+ console.log(
306
+ `[IMUTransGate] BUDGET CROSSED at posX=${posX.current.toFixed(4)}m `
307
+ + `(budget=${budgetMeters.toFixed(2)}m)`,
308
+ );
309
+ }
310
+ onBudgetExceededRef.current();
311
+ }
312
+ });
313
+
314
+ return () => {
315
+ if (debug) {
316
+ // eslint-disable-next-line no-console
317
+ console.log(
318
+ `[IMUTransGate] unsubscribing (everGotData=${everGotData}, `
319
+ + `nullSamples=${nullSampleCount}, realSamples=${sampleCount.current})`,
320
+ );
321
+ }
322
+ sub.remove();
323
+ };
324
+ }, [enabled, budgetMeters, sampleIntervalMs, debug]);
325
+
326
+ // 2026-05-18 (Issue B meta-bug fix): wrap the returned functions in
327
+ // useCallback so the hook's return value is REFERENTIALLY STABLE
328
+ // across renders. Consumer code (AuditCaptureScreen) puts `imuGate`
329
+ // in a useEffect dep array; without stability that effect re-runs
330
+ // every render, wiping out the prevAcceptedCount delta-tracker
331
+ // (which is what caused the resetAnchor-too-often bug we just
332
+ // diagnosed). These functions only touch refs, so empty-deps
333
+ // useCallback is safe — no stale-closure risk.
334
+ const resetAnchor = useCallback(() => {
335
+ velX.current = 0;
336
+ posX.current = 0;
337
+ lastMs.current = 0;
338
+ budgetCrossed.current = false;
339
+ sampleCount.current = 0;
340
+ anchorMs.current = Date.now();
341
+ }, []);
342
+ const getCurrentTranslationM = useCallback(
343
+ () => Math.abs(posX.current),
344
+ [],
345
+ );
346
+ return { resetAnchor, getCurrentTranslationM };
347
+ }
File without changes
@@ -0,0 +1,198 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * IncrementalStitcherView — live preview component for the panorama
4
+ * engine. Renders the latest snapshot JPEG written by the native
5
+ * side, with confidence + hint overlays.
6
+ *
7
+ * Why <Image> + cache-bust query string instead of a custom native
8
+ * view: per the design doc's open question, the JPEG-write approach
9
+ * is V1; if perf measurements show we're hitting RN's image cache
10
+ * too hard, swap in an `Animated.Image` or a native UIView with
11
+ * an in-memory bitmap. Until then, the simple path keeps the
12
+ * cross-platform surface tiny.
13
+ */
14
+
15
+ import React, { useMemo } from 'react';
16
+ import {
17
+ ActivityIndicator,
18
+ Image,
19
+ StyleSheet,
20
+ Text,
21
+ View,
22
+ type ViewStyle,
23
+ } from 'react-native';
24
+ import type { IncrementalState } from './incremental';
25
+ import type { IncrementalHint } from './useIncrementalStitcher';
26
+
27
+
28
+ export interface IncrementalStitcherViewProps {
29
+ /** Latest engine state — typically `useIncrementalStitcher().state`. */
30
+ state: IncrementalState | null;
31
+ /**
32
+ * Active hint to surface as a banner overlay. Pass
33
+ * `useIncrementalStitcher().hint` directly; the view picks the
34
+ * right wording.
35
+ */
36
+ hint: IncrementalHint;
37
+ /**
38
+ * Confidence ring colour driver — typically
39
+ * `useIncrementalStitcher().confidenceLevel`.
40
+ */
41
+ confidenceLevel?: 'high' | 'medium' | null;
42
+ /** Outer container style (size, position). Required: the view
43
+ * has no intrinsic size since the panorama dimensions vary. */
44
+ style?: ViewStyle;
45
+ /**
46
+ * Optional override for the spinner shown before the first frame
47
+ * is accepted. Default is a subtle "Pan to begin" caption.
48
+ */
49
+ emptyText?: string;
50
+ }
51
+
52
+
53
+ function hintMessage(hint: IncrementalHint): string | null {
54
+ switch (hint) {
55
+ case 'slow-down': return 'Slow down — alignment lost';
56
+ case 'scene-uniform': return 'Pan to a textured area';
57
+ case 'alignment-lost': return 'Slow down — re-acquiring alignment';
58
+ case 'tracking-poor': return 'Hold steady — AR re-acquiring';
59
+ default: return null;
60
+ }
61
+ }
62
+
63
+
64
+ export function IncrementalStitcherView({
65
+ state,
66
+ hint,
67
+ confidenceLevel,
68
+ style,
69
+ emptyText = 'Pan to begin capturing',
70
+ }: IncrementalStitcherViewProps): React.JSX.Element {
71
+ // Cache-bust the panorama URI. The native side rotates through
72
+ // 4 filenames so the path itself changes between snapshots, plus
73
+ // we tag with acceptedCount as belt-and-suspenders since RN's
74
+ // image cache on iOS sometimes ignores file:// query strings.
75
+ const imageUri = useMemo(() => {
76
+ if (!state?.panoramaPath) return null;
77
+ return `file://${state.panoramaPath}?v=${state.acceptedCount}`;
78
+ }, [state?.panoramaPath, state?.acceptedCount]);
79
+
80
+ // Use the panorama's NATURAL aspect ratio so the strip widens as
81
+ // the user pans across. Falls back to 4:3 (a single frame's
82
+ // shape) before any snapshot has been written. Without this the
83
+ // PiP was forced into a 3:1 letterbox, cropping the actual
84
+ // panorama to a thin slice across the middle.
85
+ const naturalAspect = state?.width && state?.height && state.height > 0
86
+ ? state.width / state.height
87
+ : 4 / 3;
88
+
89
+ const ringColor = confidenceLevel === 'high'
90
+ ? '#1aaf5d'
91
+ : confidenceLevel === 'medium'
92
+ ? '#e6b800'
93
+ : 'rgba(255,255,255,0.35)';
94
+
95
+ const message = hintMessage(hint);
96
+
97
+ return (
98
+ <View style={[styles.container, { aspectRatio: naturalAspect }, style]}>
99
+ {imageUri ? (
100
+ // `contain` so the FULL panorama is visible inside the
101
+ // strip, not cropped to a slice. Background fills the
102
+ // letterbox edges. Key={acceptedCount} forces RN to
103
+ // remount the Image component each accept — the surest
104
+ // way to defeat the native image cache on file:// URIs.
105
+ <Image
106
+ key={state?.acceptedCount ?? 0}
107
+ source={{ uri: imageUri }}
108
+ style={StyleSheet.absoluteFill}
109
+ resizeMode="contain"
110
+ fadeDuration={0}
111
+ />
112
+ ) : (
113
+ <View style={styles.empty}>
114
+ <ActivityIndicator color="#fff" />
115
+ <Text style={styles.emptyText}>{emptyText}</Text>
116
+ </View>
117
+ )}
118
+
119
+ {/* Confidence ring — subtle border that picks up colour for
120
+ medium-confidence accepts. Always visible (white-translucent
121
+ when no confidence signal) so the operator can see exactly
122
+ where the live preview is on screen. */}
123
+ <View
124
+ pointerEvents="none"
125
+ style={[styles.ring, { borderColor: ringColor }]}
126
+ />
127
+
128
+ {message ? (
129
+ <View pointerEvents="none" style={styles.hintBanner}>
130
+ <Text style={styles.hintText}>{message}</Text>
131
+ </View>
132
+ ) : null}
133
+
134
+ {state ? (
135
+ <View pointerEvents="none" style={styles.counterPill}>
136
+ <Text style={styles.counterText}>
137
+ {state.acceptedCount} frame{state.acceptedCount === 1 ? '' : 's'}
138
+ </Text>
139
+ </View>
140
+ ) : null}
141
+ </View>
142
+ );
143
+ }
144
+
145
+
146
+ const styles = StyleSheet.create({
147
+ container: {
148
+ backgroundColor: 'rgba(0,0,0,0.7)',
149
+ overflow: 'hidden',
150
+ borderRadius: 8,
151
+ },
152
+ empty: {
153
+ ...StyleSheet.absoluteFillObject,
154
+ alignItems: 'center',
155
+ justifyContent: 'center',
156
+ gap: 6,
157
+ },
158
+ emptyText: {
159
+ color: '#fff',
160
+ fontSize: 12,
161
+ opacity: 0.85,
162
+ },
163
+ ring: {
164
+ ...StyleSheet.absoluteFillObject,
165
+ borderRadius: 8,
166
+ borderWidth: 2,
167
+ },
168
+ hintBanner: {
169
+ position: 'absolute',
170
+ left: 8,
171
+ right: 8,
172
+ bottom: 8,
173
+ paddingVertical: 4,
174
+ paddingHorizontal: 8,
175
+ backgroundColor: 'rgba(220, 53, 69, 0.92)',
176
+ borderRadius: 6,
177
+ },
178
+ hintText: {
179
+ color: '#fff',
180
+ fontSize: 11,
181
+ textAlign: 'center',
182
+ fontWeight: '500',
183
+ },
184
+ counterPill: {
185
+ position: 'absolute',
186
+ top: 6,
187
+ right: 6,
188
+ paddingVertical: 2,
189
+ paddingHorizontal: 8,
190
+ backgroundColor: 'rgba(0, 0, 0, 0.55)',
191
+ borderRadius: 10,
192
+ },
193
+ counterText: {
194
+ color: '#fff',
195
+ fontSize: 10,
196
+ fontWeight: '500',
197
+ },
198
+ });