react-native-image-stitcher 0.2.0 → 0.3.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 (49) hide show
  1. package/CHANGELOG.md +363 -1
  2. package/README.md +1 -1
  3. package/android/src/main/cpp/keyframe_gate_jni.cpp +138 -0
  4. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +412 -40
  5. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +128 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +87 -45
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +46 -4
  8. package/cpp/stitcher.cpp +101 -1
  9. package/cpp/stitcher.hpp +8 -0
  10. package/dist/camera/Camera.d.ts +9 -0
  11. package/dist/camera/Camera.js +118 -8
  12. package/dist/camera/CaptureDebugOverlay.d.ts +45 -0
  13. package/dist/camera/CaptureDebugOverlay.js +146 -0
  14. package/dist/camera/CaptureKeyframePill.d.ts +28 -0
  15. package/dist/camera/CaptureKeyframePill.js +60 -0
  16. package/dist/camera/CaptureMemoryPill.d.ts +28 -0
  17. package/dist/camera/CaptureMemoryPill.js +109 -0
  18. package/dist/camera/CaptureOrientationPill.d.ts +22 -0
  19. package/dist/camera/CaptureOrientationPill.js +44 -0
  20. package/dist/camera/CaptureStitchStatsToast.d.ts +45 -0
  21. package/dist/camera/CaptureStitchStatsToast.js +133 -0
  22. package/dist/camera/PanoramaSettingsModal.d.ts +6 -5
  23. package/dist/index.d.ts +10 -0
  24. package/dist/index.js +15 -1
  25. package/dist/sensors/useIMUTranslationGate.d.ts +26 -0
  26. package/dist/sensors/useIMUTranslationGate.js +83 -1
  27. package/dist/stitching/incremental.d.ts +25 -0
  28. package/dist/stitching/useIncrementalStitcher.d.ts +12 -1
  29. package/dist/stitching/useIncrementalStitcher.js +7 -1
  30. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +321 -7
  31. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +8 -0
  32. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +12 -0
  33. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +13 -0
  34. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +15 -0
  35. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +1 -0
  36. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +17 -4
  37. package/ios/Sources/RNImageStitcher/Stitcher.swift +6 -1
  38. package/package.json +1 -1
  39. package/src/camera/Camera.tsx +165 -7
  40. package/src/camera/CaptureDebugOverlay.tsx +180 -0
  41. package/src/camera/CaptureKeyframePill.tsx +77 -0
  42. package/src/camera/CaptureMemoryPill.tsx +96 -0
  43. package/src/camera/CaptureOrientationPill.tsx +57 -0
  44. package/src/camera/CaptureStitchStatsToast.tsx +155 -0
  45. package/src/camera/PanoramaSettingsModal.tsx +6 -5
  46. package/src/index.ts +19 -0
  47. package/src/sensors/useIMUTranslationGate.ts +112 -1
  48. package/src/stitching/incremental.ts +25 -0
  49. package/src/stitching/useIncrementalStitcher.ts +18 -0
@@ -0,0 +1,155 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * CaptureStitchStatsToast — auto-dismissing toast that shows the
4
+ * batch-stitcher's leaveBiggestComponent telemetry + the resolved
5
+ * cv::Stitcher mode after every successful finalize.
6
+ *
7
+ * Pattern: top-center capsule, dark translucent background, dismisses
8
+ * itself after `dismissAfterMs` (default 4500). Replaces the
9
+ * Alert.alert blocking modal that used to interrupt the next
10
+ * capture. See `useStitchStatsToast` hook for the matching
11
+ * imperative API.
12
+ *
13
+ * Layer-2 hosts can mount this directly + pass their own message;
14
+ * Layer-1 `<Camera>` mounts it under `settings.debug` and feeds
15
+ * the formatted message from the finalize result automatically.
16
+ */
17
+
18
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
19
+ import { StyleSheet, Text, View } from 'react-native';
20
+
21
+ import type { IncrementalFinalizeResult } from '../stitching/incremental';
22
+
23
+ export interface CaptureStitchStatsToastProps {
24
+ /** Toast message to show. Pass null to hide. */
25
+ message: string | null;
26
+ /** Top inset for safe-area placement. Toast pinned `topInset + 12`. */
27
+ topInset?: number;
28
+ }
29
+
30
+ export function CaptureStitchStatsToast({
31
+ message,
32
+ topInset = 0,
33
+ }: CaptureStitchStatsToastProps): React.JSX.Element | null {
34
+ if (message === null) return null;
35
+ return (
36
+ <View
37
+ pointerEvents="none"
38
+ style={[
39
+ styles.wrap,
40
+ { top: topInset + 12 },
41
+ ]}
42
+ >
43
+ <View
44
+ style={styles.capsule}
45
+ accessibilityRole="alert"
46
+ accessibilityLiveRegion="polite"
47
+ >
48
+ <Text style={styles.text} numberOfLines={3}>
49
+ {message}
50
+ </Text>
51
+ </View>
52
+ </View>
53
+ );
54
+ }
55
+
56
+ const styles = StyleSheet.create({
57
+ wrap: {
58
+ position: 'absolute',
59
+ left: 24,
60
+ right: 24,
61
+ alignItems: 'center',
62
+ zIndex: 110,
63
+ },
64
+ capsule: {
65
+ paddingHorizontal: 16,
66
+ paddingVertical: 10,
67
+ borderRadius: 16,
68
+ backgroundColor: 'rgba(15, 23, 42, 0.92)',
69
+ maxWidth: '100%',
70
+ },
71
+ text: {
72
+ color: '#ffffff',
73
+ fontSize: 13,
74
+ fontWeight: '600',
75
+ textAlign: 'center',
76
+ },
77
+ });
78
+
79
+
80
+ /**
81
+ * Imperative API for showing transient stitch-stats toasts.
82
+ *
83
+ * Returns `{ message, showFor, showResult }`:
84
+ * - `message` — current toast text (pass to CaptureStitchStatsToast)
85
+ * - `showFor` — show an arbitrary string, auto-dismiss
86
+ * - `showResult` — format an `IncrementalFinalizeResult` into the
87
+ * standard "Stitch: N/M frames • thresh X.XX •
88
+ * N attempt(s) • mode" line and show it. Convenience
89
+ * for hosts that just want the canonical format.
90
+ *
91
+ * Auto-clears its setTimeout on unmount so callers don't have to
92
+ * worry about setState-on-unmounted warnings.
93
+ */
94
+ export interface UseStitchStatsToastReturn {
95
+ message: string | null;
96
+ showFor: (msg: string, ms?: number) => void;
97
+ showResult: (result: IncrementalFinalizeResult, ms?: number) => void;
98
+ }
99
+
100
+ const DEFAULT_DISMISS_MS = 4500;
101
+
102
+ export function useStitchStatsToast(): UseStitchStatsToastReturn {
103
+ const [message, setMessage] = useState<string | null>(null);
104
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
105
+
106
+ const showFor = useCallback((msg: string, ms = DEFAULT_DISMISS_MS) => {
107
+ if (timerRef.current) clearTimeout(timerRef.current);
108
+ setMessage(msg);
109
+ timerRef.current = setTimeout(() => {
110
+ setMessage(null);
111
+ timerRef.current = null;
112
+ }, ms);
113
+ }, []);
114
+
115
+ const showResult = useCallback(
116
+ (result: IncrementalFinalizeResult, ms = DEFAULT_DISMISS_MS) => {
117
+ // Format mirrors the RetaiLens debug toast that operators
118
+ // already recognise. Includes the new (audit F2g) resolved
119
+ // stitchMode as a fourth segment when present.
120
+ const requested = result.framesRequested;
121
+ const included = result.framesIncluded;
122
+ const thresh = result.finalConfidenceThresh;
123
+ const mode = result.stitchModeResolved;
124
+ // The retry-attempt count is derived deterministically from
125
+ // the threshold used on the successful attempt (1.0→1, 0.5→2,
126
+ // 0.3→3) per cpp/stitcher.cpp's retry loop.
127
+ const attempts =
128
+ typeof thresh === 'number'
129
+ ? thresh >= 0.99 ? 1
130
+ : thresh >= 0.49 ? 2
131
+ : thresh >= 0.29 ? 3
132
+ : null
133
+ : null;
134
+ const reqStr = typeof requested === 'number' ? requested : '?';
135
+ const incStr = typeof included === 'number' ? included : '?';
136
+ const threshStr =
137
+ typeof thresh === 'number' && thresh >= 0
138
+ ? thresh.toFixed(2)
139
+ : 'n/a';
140
+ const attStr = attempts !== null ? `${attempts} attempt${attempts > 1 ? 's' : ''}` : '? attempts';
141
+ const modeStr = mode ? ` • ${mode}` : '';
142
+ showFor(
143
+ `Stitch: ${incStr}/${reqStr} frames • thresh ${threshStr} • ${attStr}${modeStr}`,
144
+ ms,
145
+ );
146
+ },
147
+ [showFor],
148
+ );
149
+
150
+ useEffect(() => () => {
151
+ if (timerRef.current) clearTimeout(timerRef.current);
152
+ }, []);
153
+
154
+ return { message, showFor, showResult };
155
+ }
@@ -323,11 +323,12 @@ export interface PanoramaSettings {
323
323
  * PlaneWarper. Canvas size bounded by sum of frame areas.
324
324
  * Slight quality drop on pure rotations but works for them too.
325
325
  *
326
- * iOS NOTE: as of 2026-05-14 the iOS stitcher uses a hand-rolled
327
- * PANORAMA-style pipeline (OpenCVStitcher.mm:600+) regardless of
328
- * this setting. Setting is passed through to iOS but ignored.
329
- * Android honours it via image_stitcher_jni.cpp. Bridging iOS is
330
- * a follow-up.
326
+ * Both platforms honour this as of 2026-05-22 (audit F2). Android
327
+ * routes through `image_stitcher_jni.cpp` `cpp/stitcher.cpp`;
328
+ * iOS routes through `OpenCVStitcher.stitchFramePaths(stitchMode:)`
329
+ * `cpp/stitcher.cpp`. Both 'auto' resolutions use the same
330
+ * translation/rotation ratio heuristic
331
+ * (`resolveStitchModeAuto` on each side).
331
332
  */
332
333
  stitchMode: 'auto' | 'panorama' | 'scans';
333
334
  }
package/src/index.ts CHANGED
@@ -85,6 +85,25 @@ export { CapturePreview } from './camera/CapturePreview';
85
85
  export type { CapturePreviewAction } from './camera/CapturePreview';
86
86
  export { CaptureStatusOverlay } from './camera/CaptureStatusOverlay';
87
87
  export type { CaptureStatusPhase } from './camera/CaptureStatusOverlay';
88
+ export { CaptureDebugOverlay } from './camera/CaptureDebugOverlay';
89
+ export type { CaptureDebugOverlayProps } from './camera/CaptureDebugOverlay';
90
+ // 2026-05-22 (audit F9) — composable debug pills. Layer-1 <Camera>
91
+ // mounts all of them automatically when settings.debug is on;
92
+ // Layer-2 hosts compose their own debug surface from these primitives.
93
+ export { CaptureMemoryPill } from './camera/CaptureMemoryPill';
94
+ export type { CaptureMemoryPillProps } from './camera/CaptureMemoryPill';
95
+ export { CaptureKeyframePill } from './camera/CaptureKeyframePill';
96
+ export type { CaptureKeyframePillProps } from './camera/CaptureKeyframePill';
97
+ export { CaptureOrientationPill } from './camera/CaptureOrientationPill';
98
+ export type { CaptureOrientationPillProps } from './camera/CaptureOrientationPill';
99
+ export {
100
+ CaptureStitchStatsToast,
101
+ useStitchStatsToast,
102
+ } from './camera/CaptureStitchStatsToast';
103
+ export type {
104
+ CaptureStitchStatsToastProps,
105
+ UseStitchStatsToastReturn,
106
+ } from './camera/CaptureStitchStatsToast';
88
107
  export { CaptureThumbnailStrip } from './camera/CaptureThumbnailStrip';
89
108
  export type { CaptureThumbnailItem } from './camera/CaptureThumbnailStrip';
90
109
  export { IncrementalPanGuide } from './camera/IncrementalPanGuide';
@@ -116,6 +116,32 @@ export interface UseIMUTranslationGateReturn {
116
116
  * benefits from continuous history across anchors.
117
117
  */
118
118
  resetAnchor: () => void;
119
+ /**
120
+ * 2026-05-22 (audit follow-up) — read the latest integrated
121
+ * translation magnitude in METRES. Useful for debug overlays
122
+ * that want to surface "how much translation has the operator
123
+ * accumulated since the last keyframe accept" so they can sanity-
124
+ * check whether the budget is going to fire. Cheap: returns the
125
+ * ref value, no React state subscription (the integrator runs at
126
+ * 50 Hz and we don't want to force a re-render every sample).
127
+ * Callers that want a live UI value should poll on an interval
128
+ * or use a frame-driven re-render trigger.
129
+ */
130
+ getTranslationMetres: () => number;
131
+ /**
132
+ * 2026-05-22 (audit F2f) — cumulative |segment displacement|
133
+ * across the entire capture, in METRES. Includes:
134
+ * (a) magnitudes banked at every prior anchor reset (whether
135
+ * triggered by IMU budget auto-rearm or by host-side
136
+ * resetAnchor on a non-IMU frame accept), PLUS
137
+ * (b) the magnitude of the current (unfinished) segment.
138
+ *
139
+ * This is the right input for the stitchMode auto-resolver in
140
+ * non-AR mode — it captures total operator travel regardless of
141
+ * which gate accepted intermediate frames. Resets to 0 only on
142
+ * subscription start (new capture).
143
+ */
144
+ getTotalAbsMetres: () => number;
119
145
  }
120
146
 
121
147
 
@@ -144,12 +170,27 @@ export function useIMUTranslationGate({
144
170
  // All running-integrator state lives in a single ref so the
145
171
  // subscription callback can update it without forcing a re-render
146
172
  // every frame (50 Hz worth of re-renders would tank performance).
173
+ //
174
+ // 2026-05-22 (audit F2f) — `totalAbsMetres` is a separate, never-
175
+ // reset-within-capture accumulator of the |segment displacement|
176
+ // that's banked each time the current segment ends (either by
177
+ // auto-rearm on budget fire, or by host-side `resetAnchor` on a
178
+ // non-IMU frame accept). This decouples the display-side
179
+ // segment integrator (`posX`, resets on every accept) from the
180
+ // measurement-side cumulative translation (`totalAbsMetres`,
181
+ // resets only on subscription start). Pre-F2f the cumulative
182
+ // translation was reconstructed as `fires × budget + |residual|`
183
+ // — that undercounted whenever a non-IMU accept reset the
184
+ // integrator before the budget threshold was reached.
147
185
  const stateRef = useRef({
148
186
  posX: 0,
149
187
  velX: 0,
150
188
  /// NaN sentinel for "uninitialised"; first sample seeds it.
151
189
  gravityX: NaN,
152
190
  fired: false,
191
+ /// Cumulative |segment displacement| banked across all anchor
192
+ /// resets in this capture. Reset only on subscription start.
193
+ totalAbsMetres: 0,
153
194
  });
154
195
 
155
196
  // Latest onBudgetExceeded callback in a ref so callers can pass
@@ -160,6 +201,13 @@ export function useIMUTranslationGate({
160
201
 
161
202
  const resetAnchor = useCallback(() => {
162
203
  const s = stateRef.current;
204
+ // 2026-05-22 (audit F2f) — bank current segment magnitude into
205
+ // the cumulative accumulator BEFORE zeroing. This preserves
206
+ // total translation across non-IMU-driven anchor resets (e.g.
207
+ // when a flow-novelty accept arrives at 5 cm — short of the
208
+ // IMU budget — we want the 5 cm to count toward the
209
+ // auto-resolver's total, not be lost).
210
+ s.totalAbsMetres += Math.abs(s.posX);
163
211
  s.posX = 0;
164
212
  s.velX = 0;
165
213
  s.fired = false;
@@ -169,6 +217,41 @@ export function useIMUTranslationGate({
169
217
  useEffect(() => {
170
218
  if (!enabled) return;
171
219
 
220
+ // 2026-05-22 (audit follow-up) — reset ALL integrator state when
221
+ // the subscription is (re)established, not just on the host's
222
+ // resetAnchor() call. Two reasons:
223
+ //
224
+ // 1. Race with statusPhase update: handleHoldStart sets
225
+ // `statusPhase='recording'` synchronously, which flips
226
+ // `enabled` and re-runs this effect immediately. Samples
227
+ // start arriving before the awaited `incremental.start()`
228
+ // returns + the host gets a chance to call `resetAnchor()`.
229
+ // During that window `posX` accumulates drift, and the
230
+ // operator sees a non-zero starting `imuΔ` in the debug
231
+ // overlay.
232
+ //
233
+ // 2. Stale gravity bias: `gravityX` was intentionally preserved
234
+ // across `resetAnchor` calls to keep IIR history. But
235
+ // between captures the phone might be at a different
236
+ // orientation; the stale gravity estimate biases `linX` for
237
+ // the ~200ms IIR convergence window, and that bias compounds
238
+ // into `posX` each capture. Forcing NaN here makes the
239
+ // first sample re-seed gravity cleanly — costs us one
240
+ // sample of accuracy but eliminates the cross-capture drift.
241
+ //
242
+ // The host's `resetAnchor()` remains as the in-capture reset
243
+ // (called after each force-accept fire, etc).
244
+ {
245
+ const s = stateRef.current;
246
+ s.posX = 0;
247
+ s.velX = 0;
248
+ s.fired = false;
249
+ s.gravityX = NaN;
250
+ // 2026-05-22 (audit F2f) — new subscription = new capture =
251
+ // zero the cumulative accumulator too.
252
+ s.totalAbsMetres = 0;
253
+ }
254
+
172
255
  setUpdateIntervalForType(SensorTypes.accelerometer, sampleIntervalMs);
173
256
  const scale = Platform.OS === 'ios' ? G_TO_MPS2 : 1;
174
257
  const dt = sampleIntervalMs / 1000.0;
@@ -196,13 +279,41 @@ export function useIMUTranslationGate({
196
279
  s.posX += s.velX * dt;
197
280
 
198
281
  if (!s.fired && Math.abs(s.posX) > budgetMeters) {
282
+ // Fire the callback (host-side force-accept hook).
199
283
  s.fired = true;
200
284
  onExceededRef.current();
285
+ // 2026-05-22 (audit follow-up) — auto-rearm the integrator
286
+ // so the gate fires EVERY `budgetMeters` of translation, not
287
+ // just once per capture. Pre-audit behaviour was "fire once,
288
+ // wait for host to call resetAnchor()" — but Camera.tsx only
289
+ // calls resetAnchor at the start of a capture, so the gate
290
+ // latched after the first force-accept and never re-fired,
291
+ // even though the operator kept translating further (user
292
+ // observation: 8cm fires once, then 16cm/24cm/… don't
293
+ // re-trigger).
294
+ //
295
+ // 2026-05-22 (audit F2f) — bank the segment magnitude into
296
+ // the cumulative accumulator BEFORE zeroing (matches the
297
+ // resetAnchor path for symmetry — both paths represent
298
+ // anchor transitions, just driven by different triggers).
299
+ s.totalAbsMetres += Math.abs(s.posX);
300
+ s.posX = 0;
301
+ s.velX = 0;
302
+ s.fired = false;
201
303
  }
202
304
  });
203
305
 
204
306
  return () => sub.unsubscribe();
205
307
  }, [enabled, budgetMeters, sampleIntervalMs]);
206
308
 
207
- return { resetAnchor };
309
+ const getTranslationMetres = useCallback(() => {
310
+ return stateRef.current.posX;
311
+ }, []);
312
+
313
+ const getTotalAbsMetres = useCallback(() => {
314
+ const s = stateRef.current;
315
+ return s.totalAbsMetres + Math.abs(s.posX);
316
+ }, []);
317
+
318
+ return { resetAnchor, getTranslationMetres, getTotalAbsMetres };
208
319
  }
@@ -678,6 +678,21 @@ export interface IncrementalFinalizeResult {
678
678
  framesIncluded?: number;
679
679
  framesDropped?: number;
680
680
  finalConfidenceThresh?: number;
681
+ /**
682
+ * 2026-05-22 (audit F2g) — which cv::Stitcher pipeline the batch
683
+ * finalize actually ran, after the engine's `auto` resolution
684
+ * heuristic (or the operator's explicit choice). Values: `'panorama'`
685
+ * (rotation-only, ORB + BundleAdjusterRay + SphericalWarper) or
686
+ * `'scans'` (translational, affine + BundleAdjusterAffine +
687
+ * PlaneWarper). Undefined on non-batch engines (hybrid/slit-scan)
688
+ * which don't go through cv::Stitcher at finalize.
689
+ *
690
+ * Host code can surface this on the output preview (e.g. a small
691
+ * pill labelled "scans" / "panorama") and in the debug toast to
692
+ * help operators understand what choice the auto-resolver made
693
+ * on the just-completed capture.
694
+ */
695
+ stitchModeResolved?: 'panorama' | 'scans';
681
696
  }
682
697
 
683
698
 
@@ -838,6 +853,16 @@ interface NativeIncrementalModule {
838
853
  * legacy start-time behaviour.
839
854
  */
840
855
  captureOrientation?: string;
856
+ /**
857
+ * 2026-05-22 (audit F2b) — JS-measured cumulative IMU translation
858
+ * magnitude in METRES. Used by the auto-resolver in non-AR mode
859
+ * where the engine has no pose-driven translation source. In AR
860
+ * mode native uses pose-derived translation and ignores this
861
+ * signal. Defaults to 0 (back-compat) — auto-resolver always
862
+ * picks `panorama` when both pose-derived and IMU translation
863
+ * are zero, matching legacy behaviour.
864
+ */
865
+ imuTranslationMetres?: number;
841
866
  }): Promise<IncrementalFinalizeResult>;
842
867
  cancel(): Promise<{ ok: true }>;
843
868
  getState(): Promise<IncrementalState | null>;
@@ -74,6 +74,17 @@ export interface UseIncrementalStitcherReturn {
74
74
  outputPath?: string,
75
75
  quality?: number,
76
76
  captureOrientation?: string,
77
+ /**
78
+ * 2026-05-22 (audit F2b) — measured cumulative translation
79
+ * magnitude in METRES from the JS-side IMU translation gate.
80
+ * Used by the auto-resolver in non-AR mode where the engine has
81
+ * no pose-driven translation source — without this signal the
82
+ * auto-resolver always picks `panorama` even for shelf scans.
83
+ * Omit (or pass 0) when no IMU translation data is available
84
+ * (e.g. in AR mode the native side has its own pose-driven
85
+ * translation magnitude and prefers that).
86
+ */
87
+ imuTranslationMetres?: number,
77
88
  ) => Promise<IncrementalFinalizeResult>;
78
89
  /** Abort the capture without producing output. */
79
90
  cancel: () => Promise<void>;
@@ -186,6 +197,7 @@ export function useIncrementalStitcher(): UseIncrementalStitcherReturn {
186
197
  outputPath?: string,
187
198
  quality = 90,
188
199
  captureOrientation?: string,
200
+ imuTranslationMetres?: number,
189
201
  ): Promise<IncrementalFinalizeResult> => {
190
202
  if (!native) {
191
203
  throw new Error('useIncrementalStitcher: native module unavailable');
@@ -198,6 +210,12 @@ export function useIncrementalStitcher(): UseIncrementalStitcherReturn {
198
210
  // instead of the start-time snapshot. Undefined = keep
199
211
  // legacy start-time behaviour.
200
212
  captureOrientation,
213
+ // 2026-05-22 (audit F2b) — fold JS-side IMU translation into
214
+ // the native auto-resolver. In non-AR mode this is the only
215
+ // translation signal the resolver has (the JS-driver path
216
+ // doesn't carry tx/ty/tz, so pose-derived translation is 0).
217
+ // Native side treats it as a magnitude (always ≥ 0).
218
+ imuTranslationMetres: Math.max(0, imuTranslationMetres ?? 0),
201
219
  });
202
220
  setIsRunning(false);
203
221
  // Clear React state on finalize so the next start doesn't