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,667 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * usePanMotion — one sensor-fed hook that exposes the three motion
4
+ * signals the first-time-user GUIDANCE surfaces share, so the screen
5
+ * spins up ONE gyroscope + ONE accelerometer subscription instead of
6
+ * three independent ones.
7
+ *
8
+ * Consumers
9
+ * - Item 3 (pan how-to / direction arrow) wants `resolvedAxis` to
10
+ * know whether the user is panning horizontally or vertically.
11
+ * - Item 4 ("Moving too fast, slow down") wants `panSpeedBucket`.
12
+ * - Item 6 (lateral-drift → finalize + popup) wants `lateralCm` /
13
+ * `lateralExceeded`.
14
+ *
15
+ * Why one hook (and not three components each subscribing)
16
+ * `react-native-sensors` is global: every `gyroscope.subscribe` /
17
+ * `accelerometer.subscribe` adds a listener to the same underlying
18
+ * native sensor, and `setUpdateIntervalForType` is process-wide.
19
+ * Three subscribers means three JS callbacks per native sample +
20
+ * three teardown paths to get right. Funnelling the shared signals
21
+ * through one hook keeps the sensor wiring in a single place.
22
+ *
23
+ * ── Speed bucket (Item 4) ────────────────────────────────────────
24
+ * Reuses `PanoramaGuidance`'s gyro logic verbatim (see `bucketFor`
25
+ * below, lifted from that file): take the dominant rotation axis for
26
+ * the current pan direction and map |rad/s| onto good / warn / bad.
27
+ * horizontal pan (portrait, Mode B) → gyro Y dominates.
28
+ * vertical pan (landscape, Mode A) → gyro X dominates.
29
+ * Defaults 0.5 / 1.0 rad/s match `PanoramaGuidance`'s SCANS tuning.
30
+ *
31
+ * ── Lateral drift (Item 6) ───────────────────────────────────────
32
+ * This is the subtle part. `useIMUTranslationGate` integrates the
33
+ * accelerometer along **device-X**, because in BOTH pan modes the
34
+ * pan axis maps to device-X (portrait: user left/right; landscape:
35
+ * device-X has rotated 90° into user up/down). That gate's X
36
+ * integrator RESETS at every accepted keyframe (and auto-rearms on
37
+ * each budget fire) — see its header — because it measures
38
+ * translation-*along*-the-pan between keyframes.
39
+ *
40
+ * Lateral drift is the ORTHOGONAL motion: the operator sliding the
41
+ * phone sideways out of the pan plane. Orthogonal to device-X is
42
+ * **device-Y**, in both modes. So we integrate device-Y here.
43
+ *
44
+ * Crucially this accumulator must measure drift over the WHOLE
45
+ * capture, not per-keyframe — a slow continuous sideways creep would
46
+ * never trip a per-keyframe-reset budget. So unlike the gate's
47
+ * `posX`, our `posY` resets ONLY on `active` false → true (capture
48
+ * start). It is never reset by keyframe accepts (this hook doesn't
49
+ * even know about them).
50
+ *
51
+ * We borrow the gate's drift-mitigation recipe (per-axis IIR gravity
52
+ * estimate + per-sample velocity damping + iOS G→m/s² scaling) so the
53
+ * lateral integrator has the same noise floor characteristics.
54
+ *
55
+ * Grace window
56
+ * A short slide as the operator settles their grip at capture start
57
+ * shouldn't fire the "you drifted" popup. `lateralExceeded` only
58
+ * latches once the budget has been *continuously* exceeded for
59
+ * `LATERAL_GRACE_MS` (default 500 ms). A dip back under budget
60
+ * resets the grace timer, so a single wobble that crosses and
61
+ * immediately recrosses the threshold never latches. Once latched
62
+ * it STAYS latched until the next capture (matches Item 6's
63
+ * product decision: finalize what's captured, then show the popup —
64
+ * we don't un-finalize if the phone wobbles back).
65
+ *
66
+ * Performance
67
+ * Gyro at ~30 Hz, accel at ~50 Hz, all integrator state in refs.
68
+ * `setState` fires only on a *qualitative* change (bucket flips, or
69
+ * the exceeded latch trips) — never per sample. `lateralCm` is the
70
+ * one exception consumers may want live; it's exposed via the
71
+ * returned object but only re-rendered on the throttled tick (see
72
+ * `LATERAL_EMIT_INTERVAL_MS`) so a debug/HUD readout updates without
73
+ * a 50 Hz re-render storm.
74
+ */
75
+
76
+ import { useEffect, useRef, useState } from 'react';
77
+ import { Platform } from 'react-native';
78
+ import {
79
+ accelerometer,
80
+ gyroscope,
81
+ setUpdateIntervalForType,
82
+ SensorTypes,
83
+ } from 'react-native-sensors';
84
+ import type { Subscription } from 'rxjs';
85
+
86
+ import { useDeviceOrientation } from './useDeviceOrientation';
87
+
88
+
89
+ export type PanSpeedBucket = 'good' | 'warn' | 'bad';
90
+
91
+ /**
92
+ * Pan axis in user-perceived terms:
93
+ * 'horizontal' → portrait, pan left↔right (Mode B).
94
+ * 'vertical' → landscape, pan up↕down (Mode A).
95
+ * Same vocabulary as `PanoramaGuidance`'s `PanAxis`.
96
+ */
97
+ export type PanAxis = 'horizontal' | 'vertical';
98
+
99
+
100
+ export interface UsePanMotionOptions {
101
+ /**
102
+ * Subscribe to the sensors only while this is true. Typically the
103
+ * host's `statusPhase === 'recording'`. Teardown on inactive so
104
+ * the gyro/accel aren't running the rest of the time the screen is
105
+ * up. The lateral accumulator zeroes on every false → true edge.
106
+ */
107
+ active: boolean;
108
+
109
+ /**
110
+ * Force the pan axis instead of auto-detecting from device
111
+ * orientation. Matches `PanoramaGuidance`'s `axis` prop: hosts
112
+ * that lock orientation but want the user to pan the orthogonal
113
+ * axis pass this. Default: auto-detect — 'horizontal' in portrait,
114
+ * 'vertical' in landscape.
115
+ */
116
+ axis?: PanAxis;
117
+
118
+ /**
119
+ * Gyro rate (rad/s) at/below which the pan is 'good'. Default 0.5,
120
+ * same as `PanoramaGuidance`'s SCANS tuning.
121
+ */
122
+ goodMaxRadPerSec?: number;
123
+
124
+ /**
125
+ * Gyro rate (rad/s) at/below which the pan is 'warn' (above 'good').
126
+ * Above it is 'bad'. Default 1.0.
127
+ */
128
+ warnMaxRadPerSec?: number;
129
+
130
+ /**
131
+ * Lateral (cross-pan) translation budget in CENTIMETRES. Once the
132
+ * integrated |lateral| exceeds this for `LATERAL_GRACE_MS`,
133
+ * `lateralExceeded` latches true. Default 5 cm.
134
+ */
135
+ lateralBudgetCm?: number;
136
+ }
137
+
138
+
139
+ export interface UsePanMotionReturn {
140
+ /** Qualitative pan speed for the dominant gyro axis. */
141
+ panSpeedBucket: PanSpeedBucket;
142
+ /**
143
+ * Signed lateral (cross-pan) translation since capture start, in
144
+ * centimetres. Updates on a throttled tick (~10 Hz), not per
145
+ * sample. Useful for a debug/HUD readout; the latch decision uses
146
+ * the un-throttled internal value.
147
+ */
148
+ lateralCm: number;
149
+ /**
150
+ * `true` once |lateralCm| has exceeded `lateralBudgetCm`
151
+ * continuously for the grace window. Latching — stays true until
152
+ * the next capture (`active` false → true).
153
+ */
154
+ lateralExceeded: boolean;
155
+ /** Resolved pan axis (after auto-detect / `axis` override). */
156
+ resolvedAxis: PanAxis;
157
+ }
158
+
159
+
160
+ // ── Speed-bucket constants ────────────────────────────────────────
161
+ // v0.16: lowered from 0.5 / 1.0. On-device the old 1.0 rad/s (~57°/s)
162
+ // "bad" trip point almost never fired for a hand pan that's genuinely too
163
+ // fast for good keyframe overlap — a brisk-but-too-fast sweep sits around
164
+ // 0.5–0.8 rad/s. 0.6 rad/s (~34°/s) is the new "too fast" line; tune via
165
+ // the `panTooFastThreshold` prop (or the __DEV__ [panMotion] gyro logs).
166
+ const DEFAULT_GOOD_RAD_PER_SEC = 0.4;
167
+ const DEFAULT_WARN_RAD_PER_SEC = 0.6;
168
+
169
+ // ── Lateral-drift constants ───────────────────────────────────────
170
+ // v0.16: lowered 5 → 4 cm so a deliberate sideways slide trips sooner.
171
+ // NOTE: the cm budget now only feeds the (secondary) accel readout; the
172
+ // PRIMARY lateral trigger is the gyro cross-axis below.
173
+ const DEFAULT_LATERAL_BUDGET_CM = 4;
174
+
175
+ /**
176
+ * Lateral-drift trip point on the SMOOTHED cross-pan gyro rate (EMA of
177
+ * `|gyro.x|`), in rad/s.
178
+ *
179
+ * v0.16 — on-device traces showed a user "moving perpendicular to the arrow"
180
+ * is really a ROTATION about the cross-pan axis (gyro X), not a sideways
181
+ * translation — so the old accel double-integration never saw it. But the
182
+ * raw cross rate is NOISY (dips between samples), so a continuous-over-
183
+ * threshold dwell reset on every dip and never latched. We instead smooth
184
+ * `|gyro.x|` with an EMA (rides the dips, gives a ~0.4 s natural dwell) and
185
+ * latch when the SMOOTHED rate stays above this line. A clean pan smooths
186
+ * to ~0.04; the user's two cross-turns smoothed to ~0.3 and ~0.7 — so 0.15
187
+ * separates them with a comfortable margin.
188
+ */
189
+ const DEFAULT_LATERAL_TURN_RAD_PER_SEC = 0.15;
190
+
191
+ /**
192
+ * EMA smoothing factor for the cross-pan gyro rate (per ~33 ms gyro sample).
193
+ * ~0.08 gives a time constant of ~12 samples (~0.4 s) — enough to ride
194
+ * through the inter-sample noise yet still respond within ~0.4 s of a
195
+ * sustained cross-turn.
196
+ */
197
+ const LATERAL_CROSS_EMA_ALPHA = 0.08;
198
+
199
+ /**
200
+ * Continuous-over-budget dwell before `lateralExceeded` latches.
201
+ * Filters out a settle-the-grip slide at capture start + single
202
+ * wobbles that cross and recross the threshold. Documented in the
203
+ * task as a permitted design decision.
204
+ */
205
+ const LATERAL_GRACE_MS = 500;
206
+
207
+ /**
208
+ * Accelerometer sample interval (≈50 Hz). Matches
209
+ * `useIMUTranslationGate` so the integration math + drift profile are
210
+ * identical.
211
+ */
212
+ const ACCEL_SAMPLE_INTERVAL_MS = 20;
213
+
214
+ /**
215
+ * Gyro sample interval (≈30 Hz). Matches `PanoramaGuidance` so each
216
+ * sample maps to roughly one recording frame's pan.
217
+ */
218
+ const GYRO_SAMPLE_INTERVAL_MS = 33;
219
+
220
+ /**
221
+ * How often the throttled `lateralCm` React value is emitted. The
222
+ * integrator runs at 50 Hz but we don't want 50 re-renders/sec for a
223
+ * cosmetic readout, so we coalesce to ~10 Hz.
224
+ */
225
+ const LATERAL_EMIT_INTERVAL_MS = 100;
226
+
227
+ // ── Drift-mitigation constants (lifted from useIMUTranslationGate) ─
228
+ /// Per-sample multiplicative damping on the velocity integrator.
229
+ const VELOCITY_DAMPING_PER_SAMPLE = 0.05;
230
+ /// IIR low-pass coefficient for the per-axis gravity estimate.
231
+ const GRAVITY_IIR_ALPHA = 0.9;
232
+ /// 1 G in m/s². `react-native-sensors` reports iOS accel in G's,
233
+ /// Android in m/s²; we scale iOS into m/s² so the integrator is in
234
+ /// standard units.
235
+ const G_TO_MPS2 = 9.81;
236
+ /// 1 metre = 100 centimetres. The integrator works in metres; the
237
+ /// public API is centimetres (matches the design copy + budget unit).
238
+ const M_TO_CM = 100;
239
+
240
+
241
+ /**
242
+ * Map a signed rotation rate (rad/s) onto the qualitative speed
243
+ * bucket. Pure — exported for tests. Lifted verbatim from
244
+ * `PanoramaGuidance.bucketFor` so the two surfaces never diverge.
245
+ *
246
+ * Thresholds are INCLUSIVE of the lower band: `|rate| <= good` is
247
+ * 'good', `|rate| <= warn` is 'warn', otherwise 'bad'.
248
+ */
249
+ export function _bucketForRate(
250
+ rate: number,
251
+ good: number,
252
+ warn: number,
253
+ ): PanSpeedBucket {
254
+ const abs = Math.abs(rate);
255
+ if (abs <= good) return 'good';
256
+ if (abs <= warn) return 'warn';
257
+ return 'bad';
258
+ }
259
+
260
+
261
+ /**
262
+ * Pick the dominant gyro axis value for a pan direction. Mirrors
263
+ * `PanoramaGuidance`'s `resolvedAxis === 'horizontal' ? y : x`:
264
+ * horizontal pan (portrait) → gyro Y dominates.
265
+ * vertical pan (landscape) → gyro X dominates.
266
+ * Pure — exported for tests.
267
+ */
268
+ export function _gyroRateForAxis(
269
+ axis: PanAxis,
270
+ gyro: { x: number; y: number },
271
+ ): number {
272
+ return axis === 'horizontal' ? gyro.y : gyro.x;
273
+ }
274
+
275
+
276
+ /**
277
+ * Resolve the pan axis the same way `PanoramaGuidance` does:
278
+ * explicit `axis` override wins; otherwise portrait → 'horizontal',
279
+ * landscape → 'vertical'. Pure — exported for tests.
280
+ */
281
+ export function _resolvePanAxis(
282
+ orientation:
283
+ | 'portrait'
284
+ | 'portrait-upside-down'
285
+ | 'landscape-left'
286
+ | 'landscape-right',
287
+ override?: PanAxis,
288
+ ): PanAxis {
289
+ if (override) return override;
290
+ const isPortrait =
291
+ orientation === 'portrait' || orientation === 'portrait-upside-down';
292
+ return isPortrait ? 'horizontal' : 'vertical';
293
+ }
294
+
295
+
296
+ /**
297
+ * Internal lateral-integrator state. One device axis (device-Y, the
298
+ * cross-pan axis) integrated to a position, with the same IIR-gravity
299
+ * + velocity-damping recipe as `useIMUTranslationGate`'s X gate.
300
+ *
301
+ * Unlike that gate, `pos` is NEVER reset by keyframe accepts — only by
302
+ * `resetLateralState` at capture start — so it accumulates total
303
+ * cross-pan drift across the whole capture.
304
+ */
305
+ export interface LateralState {
306
+ /** Integrated cross-pan position, METRES. */
307
+ pos: number;
308
+ /** Integrated cross-pan velocity, m/s. */
309
+ vel: number;
310
+ /** IIR-estimated gravity component on the cross-pan axis (m/s²). */
311
+ gravity: number;
312
+ /**
313
+ * `true` once the latch has tripped; stays true for the capture.
314
+ * Mirrors `useIMUTranslationGate`'s `fired`, but here it never
315
+ * auto-rearms (drift is a one-shot finalize, not a re-trigger).
316
+ */
317
+ exceeded: boolean;
318
+ /**
319
+ * Timestamp (ms, performance.now-style) at which |pos| first went
320
+ * over budget in the current continuous over-budget run, or `null`
321
+ * if currently under budget. Drives the grace window.
322
+ */
323
+ overBudgetSinceMs: number | null;
324
+ }
325
+
326
+
327
+ /**
328
+ * Result of one grace-window latch evaluation: the (possibly latched)
329
+ * exceeded flag + the (possibly cleared/started) continuous-over-budget
330
+ * timer. See `_evalGraceLatch`.
331
+ */
332
+ export interface GraceLatchResult {
333
+ exceeded: boolean;
334
+ overBudgetSinceMs: number | null;
335
+ }
336
+
337
+
338
+ /**
339
+ * Pure grace-window latch decision, factored out of the integrator so
340
+ * the debounce is testable without constructing a physical
341
+ * acceleration profile. Given whether |pos| is currently over budget,
342
+ * the clock, and the prior latch/timer state, decide the next state:
343
+ *
344
+ * - under budget → clear the timer; never un-latch.
345
+ * - over budget, no timer → start the timer (note: NOT yet latched).
346
+ * - over budget, timer old → latch once `now - since >= graceMs`.
347
+ * - already latched → stays latched forever (one-shot
348
+ * finalize; see header).
349
+ *
350
+ * @param overBudget is |pos| currently over the budget?
351
+ * @param nowMs monotonic clock, ms
352
+ * @param prevSinceMs timestamp |pos| first went over in the current
353
+ * continuous run, or `null` if previously under
354
+ * @param prevExceeded already-latched flag from the prior sample
355
+ * @param graceMs continuous dwell required before latching
356
+ */
357
+ export function _evalGraceLatch(
358
+ overBudget: boolean,
359
+ nowMs: number,
360
+ prevSinceMs: number | null,
361
+ prevExceeded: boolean,
362
+ graceMs: number,
363
+ ): GraceLatchResult {
364
+ // One-shot: once latched, stay latched (don't even touch the timer).
365
+ if (prevExceeded) {
366
+ return { exceeded: true, overBudgetSinceMs: prevSinceMs };
367
+ }
368
+ if (!overBudget) {
369
+ // Dipped back under budget — reset the dwell timer so a wobble
370
+ // that crosses and recrosses never accumulates grace.
371
+ return { exceeded: false, overBudgetSinceMs: null };
372
+ }
373
+ // Over budget, not yet latched.
374
+ if (prevSinceMs === null) {
375
+ // First sample of a new over-budget run — start the clock.
376
+ return { exceeded: false, overBudgetSinceMs: nowMs };
377
+ }
378
+ // Continuous over-budget run in progress — latch once it's old
379
+ // enough.
380
+ if (nowMs - prevSinceMs >= graceMs) {
381
+ return { exceeded: true, overBudgetSinceMs: prevSinceMs };
382
+ }
383
+ return { exceeded: false, overBudgetSinceMs: prevSinceMs };
384
+ }
385
+
386
+
387
+ /** A fresh integrator, as seeded at every capture start. */
388
+ export function _freshLateralState(): LateralState {
389
+ return {
390
+ pos: 0,
391
+ vel: 0,
392
+ // NaN = uninitialised; the first sample seeds gravity from itself
393
+ // (same convention as useIMUTranslationGate).
394
+ gravity: NaN,
395
+ exceeded: false,
396
+ overBudgetSinceMs: null,
397
+ };
398
+ }
399
+
400
+
401
+ /**
402
+ * Reset the integrator in place for a new capture. Zeroes position,
403
+ * velocity, the latch, and the grace timer, and re-arms gravity
404
+ * seeding. Pure (mutates the passed object, returns it) — exported so
405
+ * tests can assert the "resets only at capture start" contract.
406
+ */
407
+ export function _resetLateralState(s: LateralState): LateralState {
408
+ s.pos = 0;
409
+ s.vel = 0;
410
+ s.gravity = NaN;
411
+ s.exceeded = false;
412
+ s.overBudgetSinceMs = null;
413
+ return s;
414
+ }
415
+
416
+
417
+ /**
418
+ * Advance the lateral integrator by one accelerometer sample. Pure
419
+ * (mutates + returns the passed state) so the integration math is
420
+ * unit-testable without a sensor or a React render.
421
+ *
422
+ * @param s running integrator state (mutated in place)
423
+ * @param rawAxis raw cross-pan accel reading for this sample, in the
424
+ * platform's native unit (G's on iOS, m/s² on
425
+ * Android) — caller has NOT yet applied `scale`
426
+ * @param scale unit scale (G_TO_MPS2 on iOS, 1 on Android)
427
+ * @param dt sample period, seconds
428
+ * @param budgetM lateral budget, METRES
429
+ * @param graceMs continuous-over-budget dwell before latching, ms
430
+ * @param nowMs monotonic clock for this sample, ms
431
+ * @returns the same `s` (mutated): `pos` is the new cross-pan position
432
+ * in metres; `exceeded` is the latched flag.
433
+ *
434
+ * NOTE the first call only seeds gravity and returns with `pos`
435
+ * unchanged (matches `useIMUTranslationGate`'s first-sample handling)
436
+ * — the first reading is assumed to be ~stationary at capture start.
437
+ */
438
+ export function _integrateLateralSample(
439
+ s: LateralState,
440
+ rawAxis: number,
441
+ scale: number,
442
+ dt: number,
443
+ budgetM: number,
444
+ graceMs: number,
445
+ nowMs: number,
446
+ ): LateralState {
447
+ const a = rawAxis * scale; // cross-pan acceleration, m/s²
448
+
449
+ // First sample seeds the gravity estimate from itself.
450
+ if (Number.isNaN(s.gravity)) {
451
+ s.gravity = a;
452
+ return s;
453
+ }
454
+
455
+ // IIR low-pass to track the gravity component on the cross-pan axis.
456
+ s.gravity = GRAVITY_IIR_ALPHA * s.gravity + (1 - GRAVITY_IIR_ALPHA) * a;
457
+
458
+ // Linear acceleration = raw - gravity estimate.
459
+ const lin = a - s.gravity;
460
+
461
+ // Single integration with per-sample velocity damping.
462
+ s.vel = (s.vel + lin * dt) * (1 - VELOCITY_DAMPING_PER_SAMPLE);
463
+ s.pos += s.vel * dt;
464
+
465
+ // Grace-windowed latch — delegated to the pure decision helper so
466
+ // the debounce is unit-testable independently of the integrator
467
+ // physics.
468
+ const latch = _evalGraceLatch(
469
+ Math.abs(s.pos) > budgetM,
470
+ nowMs,
471
+ s.overBudgetSinceMs,
472
+ s.exceeded,
473
+ graceMs,
474
+ );
475
+ s.exceeded = latch.exceeded;
476
+ s.overBudgetSinceMs = latch.overBudgetSinceMs;
477
+
478
+ return s;
479
+ }
480
+
481
+
482
+ export function usePanMotion({
483
+ active,
484
+ axis,
485
+ goodMaxRadPerSec = DEFAULT_GOOD_RAD_PER_SEC,
486
+ warnMaxRadPerSec = DEFAULT_WARN_RAD_PER_SEC,
487
+ lateralBudgetCm = DEFAULT_LATERAL_BUDGET_CM,
488
+ }: UsePanMotionOptions): UsePanMotionReturn {
489
+ // Physical orientation (sensor-based — works under portrait-lock),
490
+ // same source `PanoramaGuidance` uses to pick the pan axis.
491
+ const deviceOrientation = useDeviceOrientation();
492
+ const resolvedAxis = _resolvePanAxis(deviceOrientation, axis);
493
+
494
+ // Qualitative pan speed — state so a bucket *flip* re-renders, but
495
+ // per-sample updates don't.
496
+ const [panSpeedBucket, setPanSpeedBucket] = useState<PanSpeedBucket>('good');
497
+ const lastBucketRef = useRef<PanSpeedBucket>('good');
498
+
499
+ // Lateral integrator state lives in a ref so the 50 Hz accelerometer
500
+ // callback never forces a re-render.
501
+ const lateralRef = useRef<LateralState>(_freshLateralState());
502
+
503
+ // PRIMARY lateral-drift signal (v0.16): an EMA of the CROSS-pan GYRO rate
504
+ // (`|gyro.x|`), updated in the gyro handler. Smoothing rides through the
505
+ // raw rate's inter-sample noise (which defeated a binary dwell latch).
506
+ // Reset to 0 at capture start.
507
+ const crossEmaRef = useRef(0);
508
+
509
+ // The throttled, render-visible lateral magnitude (cm) + the latched
510
+ // exceeded flag. These DO go through state because consumers render
511
+ // them, but they update at most ~10 Hz / once respectively.
512
+ const [lateralCm, setLateralCm] = useState(0);
513
+ const [lateralExceeded, setLateralExceeded] = useState(false);
514
+
515
+ // ── Gyroscope → pan-speed bucket + lateral-drift latch ─────────
516
+ // One gyro subscription drives BOTH item-4 (too fast) and item-6
517
+ // (lateral drift). Item 6 keys on the CROSS-pan axis (gyro X): a user
518
+ // veering perpendicular to the arrow rotates about it (on-device traces
519
+ // showed |gyro.x| → 1.27 on a cross-turn vs < 0.1 on a clean pan).
520
+ const lateralEnabled = lateralBudgetCm > 0;
521
+ useEffect(() => {
522
+ if (!active) {
523
+ lastBucketRef.current = 'good';
524
+ setPanSpeedBucket('good');
525
+ return;
526
+ }
527
+ // Capture start: clear the cross-axis EMA.
528
+ crossEmaRef.current = 0;
529
+
530
+ setUpdateIntervalForType(SensorTypes.gyroscope, GYRO_SAMPLE_INTERVAL_MS);
531
+
532
+ // Throttle for the optional dev diagnostic log (raw axes + rate).
533
+ let lastGyroLogMs = 0;
534
+
535
+ let sub: Subscription | null = gyroscope.subscribe({
536
+ next: ({ x, y }) => {
537
+ const now = Date.now();
538
+ // Item 4 — axis-AGNOSTIC pan rate: the magnitude of rotation in the
539
+ // x–y (tilt) plane, ignoring roll about Z. v0.16 — replaces the
540
+ // single-axis pick (`gyro.x` in landscape / `gyro.y` in portrait),
541
+ // which read ~0 and never tripped when the device's dominant pan
542
+ // rotation landed on the OTHER axis for the user's actual hold.
543
+ const rate = Math.hypot(x, y);
544
+ const next = _bucketForRate(rate, goodMaxRadPerSec, warnMaxRadPerSec);
545
+ if (next !== lastBucketRef.current) {
546
+ lastBucketRef.current = next;
547
+ setPanSpeedBucket(next);
548
+ }
549
+
550
+ // Item 6 — lateral drift via the CROSS-pan gyro axis (device X).
551
+ // Smooth |gyro.x| with an EMA so the noisy raw rate's dips don't
552
+ // reset the trigger; latch once the SMOOTHED rate stays over the
553
+ // threshold (the EMA's time constant IS the dwell).
554
+ let crossEma = crossEmaRef.current;
555
+ if (lateralEnabled) {
556
+ crossEma =
557
+ crossEma * (1 - LATERAL_CROSS_EMA_ALPHA)
558
+ + Math.abs(x) * LATERAL_CROSS_EMA_ALPHA;
559
+ crossEmaRef.current = crossEma;
560
+ if (crossEma > DEFAULT_LATERAL_TURN_RAD_PER_SEC) {
561
+ setLateralExceeded((prev) => (prev ? prev : true));
562
+ }
563
+ }
564
+
565
+ if (__DEV__ && now - lastGyroLogMs >= 400) {
566
+ lastGyroLogMs = now;
567
+ // eslint-disable-next-line no-console
568
+ console.log(
569
+ `[panMotion] gyro x=${x.toFixed(2)} y=${y.toFixed(2)} `
570
+ + `rate=${rate.toFixed(2)} bucket=${next} `
571
+ + `crossEma=${crossEma.toFixed(2)} `
572
+ + `latThresh=${DEFAULT_LATERAL_TURN_RAD_PER_SEC}`,
573
+ );
574
+ }
575
+ },
576
+ error: (err) => {
577
+ // eslint-disable-next-line no-console
578
+ console.warn('[usePanMotion] gyroscope error', err);
579
+ },
580
+ });
581
+
582
+ return () => {
583
+ sub?.unsubscribe();
584
+ sub = null;
585
+ };
586
+ // resolvedAxis intentionally NOT a dep: the rate is now axis-agnostic
587
+ // (hypot), so an orientation flip must not needlessly re-subscribe the
588
+ // gyro mid-capture.
589
+ }, [active, goodMaxRadPerSec, warnMaxRadPerSec, lateralEnabled]);
590
+
591
+ // ── Accelerometer → lateral-drift integrator ───────────────────
592
+ // NOTE: this effect intentionally does NOT depend on `resolvedAxis`.
593
+ // The cross-pan device axis is device-Y in BOTH pan modes (it is
594
+ // always orthogonal to the gate's device-X pan axis — see header),
595
+ // so re-subscribing on a portrait↔landscape flip would only reset
596
+ // the accumulator mid-capture for no benefit. We do depend on
597
+ // `lateralBudgetCm` because the latch threshold changes with it.
598
+ useEffect(() => {
599
+ if (!active) {
600
+ // Reflect a clean slate while idle.
601
+ setLateralCm(0);
602
+ setLateralExceeded(false);
603
+ return;
604
+ }
605
+
606
+ // Capture start (false → true): zero the persistent accumulator.
607
+ // This is the ONLY place `pos` resets — NOT per keyframe.
608
+ _resetLateralState(lateralRef.current);
609
+ setLateralCm(0);
610
+ setLateralExceeded(false);
611
+
612
+ setUpdateIntervalForType(
613
+ SensorTypes.accelerometer,
614
+ ACCEL_SAMPLE_INTERVAL_MS,
615
+ );
616
+ const scale = Platform.OS === 'ios' ? G_TO_MPS2 : 1;
617
+ const dt = ACCEL_SAMPLE_INTERVAL_MS / 1000.0;
618
+ const budgetM = lateralBudgetCm / M_TO_CM;
619
+
620
+ let lastEmitMs = 0;
621
+ let lastAccelLogMs = 0;
622
+
623
+ // Integrate device-Y — the cross-pan axis (orthogonal to the
624
+ // gate's device-X pan axis). We read X too, only to log it for the
625
+ // on-device axis-verification (does a sideways slide spike X or Y?).
626
+ const sub: Subscription = accelerometer.subscribe(({ x, y }) => {
627
+ const now = Date.now();
628
+ const s = _integrateLateralSample(
629
+ lateralRef.current,
630
+ y,
631
+ scale,
632
+ dt,
633
+ budgetM,
634
+ LATERAL_GRACE_MS,
635
+ now,
636
+ );
637
+
638
+ if (__DEV__ && now - lastAccelLogMs >= 400) {
639
+ lastAccelLogMs = now;
640
+ // Raw x/y let us confirm the cross-pan axis on a real device: a
641
+ // deliberate sideways slide should spike the integrated axis. If
642
+ // lateralCm stays ~0 while X spikes, swap the integrated axis y→x.
643
+ // eslint-disable-next-line no-console
644
+ console.log(
645
+ `[panMotion] accel x=${x.toFixed(2)} y=${y.toFixed(2)} `
646
+ + `lateralCm=${(s.pos * M_TO_CM).toFixed(1)} `
647
+ + `budget=${lateralBudgetCm} exceeded=${s.exceeded}`,
648
+ );
649
+ }
650
+
651
+ // Latch the exceeded flag once (state write only on the edge).
652
+ if (s.exceeded) {
653
+ setLateralExceeded((prev) => (prev ? prev : true));
654
+ }
655
+
656
+ // Throttle the cosmetic cm readout to ~10 Hz.
657
+ if (now - lastEmitMs >= LATERAL_EMIT_INTERVAL_MS) {
658
+ lastEmitMs = now;
659
+ setLateralCm(s.pos * M_TO_CM);
660
+ }
661
+ });
662
+
663
+ return () => sub.unsubscribe();
664
+ }, [active, lateralBudgetCm]);
665
+
666
+ return { panSpeedBucket, lateralCm, lateralExceeded, resolvedAxis };
667
+ }