react-native-image-stitcher 0.1.3 → 0.2.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.
@@ -1,235 +1,139 @@
1
1
  "use strict";
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  //
4
- // useIMUTranslationGate.ts — JS-side IMU translation tracker for the
5
- // non-AR translation-warning banner + (optional) gate force-accept.
6
- //
7
- // 2026-05-17 (Issue #4-A v3): rewritten on top of `expo-sensors`
8
- // `DeviceMotion` (which returns gravity-subtracted linear
9
- // acceleration via Apple's CoreMotion fusion on iOS and Android's
10
- // `TYPE_LINEAR_ACCELERATION` sensor on Android — both significantly
11
- // less noisy than raw accel + JS-side IIR gravity subtraction).
12
- // Tracks a SINGLE device-frame axis (device-X — the phone's lateral /
13
- // short side) rather than the 3D translation magnitude.
14
- //
15
- // Why exists
16
- // ──────────
17
- //
18
- // In non-AR mode the SDK has no ARSession pose stream, so the shared
19
- // C++ `KeyframeGate`'s translation-budget feature stays at zero and
20
- // never trips. This hook fills the gap on the JS side and emits a
21
- // budget-crossed callback the host can wire to either:
22
- //
23
- // (a) `markNextFrameAsLastKeyframe()`tell the gate "force-accept
24
- // the next frame regardless of overlap", so the trailing-edge
25
- // frame still lands when the operator translates instead of
26
- // rotates.
27
- //
28
- // (b) A user-facing warning banner ("Rotate the camera instead of
29
- // moving it sideways" — see AuditCaptureScreen).
30
- //
31
- // AuditCaptureScreen wires it to both.
4
+ // useIMUTranslationGate — JS-side IMU translation tracker that fires
5
+ // a callback when integrated lateral displacement on the device-X
6
+ // axis exceeds a budget. Drives `<Camera>`'s non-AR keyframe-
7
+ // acceptance path: every time the gate fires, the host calls the
8
+ // C++ engine's `markNextFrameAsLastKeyframe()` so the trailing frame
9
+ // lands as a keyframe regardless of what the flow-novelty algorithm
10
+ // alone would decide.
11
+ //
12
+ // V0.2 history note
13
+ // ─────────────────
14
+ // 0.1.x used `expo-sensors`' `DeviceMotion.acceleration`, which
15
+ // returned gravity-subtracted linear acceleration via CoreMotion's
16
+ // native fusion (iOS) / Android's `TYPE_LINEAR_ACCELERATION` sensor
17
+ // (Android) — both significantly less noisy than raw accel + JS-side
18
+ // gravity subtraction. v0.2 drops the Expo modules dependency
19
+ // (see CHANGELOG / docs/host-app-integration.md), so the gate is now
20
+ // implemented on `react-native-sensors`' raw `accelerometer` with a
21
+ // JS-side IIR low-pass to estimate the gravity vector. The IIR
22
+ // version is noisier — expect a few extra cm of apparent drift on a
23
+ // stationary phone over several seconds but the budget threshold
24
+ // (~8 cm at default `flowMaxTranslationCm = 8`) and the anchor
25
+ // resets (every accepted keyframe + recording start) keep the
26
+ // per-interval drift window short enough that the budget still
27
+ // meaningfully discriminates real translation from noise.
32
28
  //
33
29
  // Why device-X (the shorter side)
34
30
  // ───────────────────────────────
35
- //
36
31
  // We track motion ALONG the pan axis (the direction the operator is
37
32
  // supposed to be rotating-through but might be translating-through
38
- // instead) because translation orthogonal to the pan axis is
39
- // acceptable — vertical translation while panning horizontally in
40
- // portrait, for example, doesn't cause horizontal parallax.
41
- //
42
- // The pan axis maps to device-X in BOTH supported orientations
43
- // (per memory/ar-stitching-two-modes.md):
44
- //
45
- // Portrait + horizontal pan: device-X = user-left/right = pan axis.
33
+ // instead). In BOTH supported pan modes the pan axis maps to
34
+ // device-X:
35
+ // Portrait + horizontal pan: device-X = user-left/right.
46
36
  // Landscape + vertical pan: device-X has rotated 90° into the
47
- // user's up/down direction = pan axis.
48
- //
49
- // The lateral axis of the phone (its short side) always aligns with
50
- // the pan direction in either supported mode, so a single-axis
51
- // tracker works without needing to know which orientation we're in.
37
+ // user's up/down direction.
38
+ // So a single-axis tracker works without knowing the orientation.
52
39
  //
53
40
  // Drift mitigation
54
41
  // ────────────────
55
- //
56
- // `DeviceMotion.acceleration` (gravity removed in native code via
57
- // IMU fusion) has a noise floor roughly 30-50 % lower than what the
58
- // previous raw-accel + JS IIR pipeline produced. Single-axis math
59
- // further reduces apparent drift by ≈√3 vs the prior 3D magnitude.
60
- // Together they should keep the typical "stationary phone" reading
61
- // below ~5-10 cm even after several seconds.
62
- //
63
- // Anchor resets happen at (a) recording start (via the host calling
64
- // `resetAnchor()` from handleHoldStart) and (b) every accepted
65
- // keyframethese bound the per-interval drift window to typically
66
- // 0.3-2 s.
67
- //
68
- // What we no longer do
69
- // ────────────────────
70
- //
71
- // - JS-side 1-pole IIR for gravity subtraction (native API gives
72
- // gravity-subtracted accel directly).
73
- // - 3D vector magnitude (now single device-X axis).
74
- // - Velocity damping (kept as a safety net at 5%/sample so a
75
- // persistent noise-floor offset doesn't slowly drift the axis —
76
- // low cost, high robustness).
42
+ // 1. IIR low-pass on the raw X accel estimates the gravity offset.
43
+ // Subtracting that gives linear-acceleration-on-X. Alpha = 0.9
44
+ // at the default 50 Hz sample rate ~200 ms gravity tracking
45
+ // time constant. Slow enough that hand motion (>1 Hz) gets
46
+ // through; fast enough to converge after device rotations within
47
+ // ~1 second.
48
+ // 2. Per-sample velocity damping at 5 % so a constant noise-floor
49
+ // offset decays to ~1 % of its initial value in 2 s. This caps
50
+ // apparent drift for a stationary phone.
51
+ // 3. Anchor reset on recording start AND every accepted keyframe
52
+ // (callers do this) bounds the integration window to typically
53
+ // 0.3-2 s, well inside the regime where IIR-estimated linear
54
+ // accel is usable.
55
+ //
56
+ // Platform unit handling
57
+ // ──────────────────────
58
+ // `react-native-sensors`' accelerometer reports:
59
+ // iOS: values in G's (multiples of 9.81 m/s²), via CoreMotion.
60
+ // Android: values in m/s², via Sensor.TYPE_ACCELEROMETER.
61
+ // We scale iOS by `G_TO_MPS2` so the integration math stays in
62
+ // standard m/s², m/s, m units. Sign convention doesn't matter for
63
+ // the gate because the gravity offset is estimated and subtracted
64
+ // per-axis; what's left is the platform-agnostic linear acceleration.
77
65
  Object.defineProperty(exports, "__esModule", { value: true });
78
66
  exports.useIMUTranslationGate = useIMUTranslationGate;
79
67
  const react_1 = require("react");
80
- const expo_sensors_1 = require("expo-sensors");
81
- /**
82
- * IMU-based translation tracker — single-axis (device-X / pan axis),
83
- * fused IMU via `expo-sensors` `DeviceMotion`. See file header for
84
- * algorithm + rationale. No platform-specific code; the underlying
85
- * native fusion is platform-aware (CoreMotion on iOS, fused
86
- * `TYPE_LINEAR_ACCELERATION` on Android).
87
- */
88
- function useIMUTranslationGate(options) {
89
- const { enabled, budgetMeters = 0.40, sampleIntervalMs = 20, onBudgetExceeded, debug = false, } = options;
90
- // Integrator state, kept in refs so the listener can write without
91
- // re-creating its closure on every render.
92
- // velX : velocity along device-X (m/s)
93
- // posX : position along device-X (m)
94
- // lastMs: epoch ms of the previous sample (for dt)
95
- // budgetCrossed: debounce flag — clears on resetAnchor
96
- // sampleCount: rolling counter for debug log throttle
97
- // anchorMs: timestamp of the most recent resetAnchor (or first
98
- // sample) gives "time since anchor" in debug output
99
- const velX = (0, react_1.useRef)(0);
100
- const posX = (0, react_1.useRef)(0);
101
- const lastMs = (0, react_1.useRef)(0);
102
- const budgetCrossed = (0, react_1.useRef)(false);
103
- const sampleCount = (0, react_1.useRef)(0);
104
- const anchorMs = (0, react_1.useRef)(0);
105
- // Keep the callback in a ref so we don't tear down + re-subscribe
106
- // on every prop change. React idiom for stable callback identity.
107
- const onBudgetExceededRef = (0, react_1.useRef)(onBudgetExceeded);
108
- (0, react_1.useEffect)(() => { onBudgetExceededRef.current = onBudgetExceeded; }, [onBudgetExceeded]);
68
+ const react_native_1 = require("react-native");
69
+ const react_native_sensors_1 = require("react-native-sensors");
70
+ const DEFAULT_BUDGET_METERS = 0.40;
71
+ const DEFAULT_SAMPLE_INTERVAL_MS = 20;
72
+ /// Per-sample multiplicative damping on the velocity integrator.
73
+ /// 5 % at 50 Hz constant offset decays to ~1 % in 2 s. Bounds
74
+ /// the apparent-drift window for a stationary phone.
75
+ const VELOCITY_DAMPING_PER_SAMPLE = 0.05;
76
+ /// IIR low-pass coefficient for the gravity estimate. At 50 Hz
77
+ /// this gives ~200 ms time constant. Higher = slower gravity
78
+ /// tracking (more lag during device rotation, less hand-motion
79
+ /// bleed into the gravity estimate); lower = faster.
80
+ const GRAVITY_IIR_ALPHA = 0.9;
81
+ /// 1 G in m/s². Standard gravity per CGPM 1901 (good to all the
82
+ /// digits anyone cares about for this application).
83
+ const G_TO_MPS2 = 9.81;
84
+ function useIMUTranslationGate({ enabled, budgetMeters = DEFAULT_BUDGET_METERS, sampleIntervalMs = DEFAULT_SAMPLE_INTERVAL_MS, onBudgetExceeded, }) {
85
+ // All running-integrator state lives in a single ref so the
86
+ // subscription callback can update it without forcing a re-render
87
+ // every frame (50 Hz worth of re-renders would tank performance).
88
+ const stateRef = (0, react_1.useRef)({
89
+ posX: 0,
90
+ velX: 0,
91
+ /// NaN sentinel for "uninitialised"; first sample seeds it.
92
+ gravityX: NaN,
93
+ fired: false,
94
+ });
95
+ // Latest onBudgetExceeded callback in a ref so callers can pass
96
+ // an inline closure that captures fresh state without us re-
97
+ // subscribing the sensor (which would reset the integrators).
98
+ const onExceededRef = (0, react_1.useRef)(onBudgetExceeded);
99
+ onExceededRef.current = onBudgetExceeded;
100
+ const resetAnchor = (0, react_1.useCallback)(() => {
101
+ const s = stateRef.current;
102
+ s.posX = 0;
103
+ s.velX = 0;
104
+ s.fired = false;
105
+ // s.gravityX is intentionally preserved — see header.
106
+ }, []);
109
107
  (0, react_1.useEffect)(() => {
110
- if (debug) {
111
- // eslint-disable-next-line no-console
112
- console.log(`[IMUTransGate] effect re-run: enabled=${enabled} `
113
- + `budget=${budgetMeters.toFixed(2)}m sampleIntervalMs=${sampleIntervalMs}`);
114
- }
115
108
  if (!enabled)
116
109
  return;
117
- // Lock in the DeviceMotion update rate. Other expo-sensors
118
- // consumers in the SDK can override later; the LAST setter wins
119
- // per Expo's docs, which is fine because our budget logic
120
- // tolerates a wide range of cadences.
121
- expo_sensors_1.DeviceMotion.setUpdateInterval(sampleIntervalMs);
122
- // Reset state on (re-)engage so the first measurement after
123
- // enabled-toggles-true doesn't carry stale velocity from a
124
- // previous capture session.
125
- velX.current = 0;
126
- posX.current = 0;
127
- lastMs.current = 0;
128
- budgetCrossed.current = false;
129
- sampleCount.current = 0;
130
- anchorMs.current = Date.now();
131
- // 2026-05-18 (Issue #3 diagnostics) — track whether we've ever
132
- // received a non-null `acceleration` for this subscription. If
133
- // the user reports "no logs" we can correlate with these
134
- // start-of-subscription and first-real-data markers.
135
- let everGotData = false;
136
- let nullSampleCount = 0;
137
- if (debug) {
138
- // eslint-disable-next-line no-console
139
- console.log('[IMUTransGate] subscribing to DeviceMotion');
140
- }
141
- const sub = expo_sensors_1.DeviceMotion.addListener((m) => {
142
- const a = m.acceleration; // gravity-subtracted (m/s²)
143
- if (!a) {
144
- nullSampleCount += 1;
145
- if (debug && nullSampleCount === 1) {
146
- // eslint-disable-next-line no-console
147
- console.log('[IMUTransGate] first sample: acceleration=null '
148
- + '(CoreMotion warming up; will retry on next sample)');
149
- }
150
- if (debug && nullSampleCount > 0 && nullSampleCount % 100 === 0) {
151
- // eslint-disable-next-line no-console
152
- console.log(`[IMUTransGate] STILL receiving null acceleration after `
153
- + `${nullSampleCount} samples — sensor source may be broken`);
154
- }
155
- return; // can be null briefly on cold start
156
- }
157
- if (debug && !everGotData) {
158
- everGotData = true;
159
- // eslint-disable-next-line no-console
160
- console.log(`[IMUTransGate] first real sample: ax=${a.x.toFixed(3)} `
161
- + `ay=${a.y.toFixed(3)} az=${a.z.toFixed(3)} m/s² `
162
- + `(after ${nullSampleCount} null sample(s))`);
163
- }
164
- const now = Date.now();
165
- if (lastMs.current === 0) {
166
- lastMs.current = now;
110
+ (0, react_native_sensors_1.setUpdateIntervalForType)(react_native_sensors_1.SensorTypes.accelerometer, sampleIntervalMs);
111
+ const scale = react_native_1.Platform.OS === 'ios' ? G_TO_MPS2 : 1;
112
+ const dt = sampleIntervalMs / 1000.0;
113
+ const sub = react_native_sensors_1.accelerometer.subscribe(({ x }) => {
114
+ const ax = x * scale; // device-X acceleration in m/s²
115
+ const s = stateRef.current;
116
+ // First sample: seed gravity from this reading. Assumes the
117
+ // phone is roughly stationary at recording start — true in
118
+ // practice because the operator just tap-and-held the shutter.
119
+ if (Number.isNaN(s.gravityX)) {
120
+ s.gravityX = ax;
167
121
  return;
168
122
  }
169
- const dt = Math.max(0, Math.min(0.1, (now - lastMs.current) / 1000.0));
170
- lastMs.current = now;
171
- if (dt === 0)
172
- return;
173
- // Single-axis integration along device-X (lateral / pan axis).
174
- // See file header for why device-X is the right axis in both
175
- // portrait and landscape captures.
176
- velX.current += a.x * dt;
177
- velX.current *= 0.95; // 5%/sample damping — see file header
178
- posX.current += velX.current * dt;
179
- const mag = Math.abs(posX.current);
180
- // 2026-05-18 (Issue #4 investigation) — debug-gated diagnostic
181
- // log. Throttled to every 20th sample (~400 ms at 50 Hz) so
182
- // the log isn't a firehose. When this runs and we still see
183
- // posX hovering at < 5 cm during a real translation, the
184
- // sensor source isn't capturing what we think it is.
185
- sampleCount.current += 1;
186
- if (debug && sampleCount.current % 20 === 0) {
187
- const secs = (now - anchorMs.current) / 1000.0;
188
- // eslint-disable-next-line no-console
189
- console.log(`[IMUTransGate] t+${secs.toFixed(2)}s `
190
- + `ax=${a.x.toFixed(3)}m/s² `
191
- + `velX=${velX.current.toFixed(4)}m/s `
192
- + `posX=${posX.current.toFixed(4)}m `
193
- + `(|mag|=${mag.toFixed(4)}m, budget=${budgetMeters.toFixed(2)}m, crossed=${budgetCrossed.current})`);
194
- }
195
- // Budget crossing — fire exactly once per crossing (the
196
- // `budgetCrossed` flag clears on `resetAnchor`).
197
- if (!budgetCrossed.current && mag >= budgetMeters) {
198
- budgetCrossed.current = true;
199
- if (debug) {
200
- // eslint-disable-next-line no-console
201
- console.log(`[IMUTransGate] BUDGET CROSSED at posX=${posX.current.toFixed(4)}m `
202
- + `(budget=${budgetMeters.toFixed(2)}m)`);
203
- }
204
- onBudgetExceededRef.current();
123
+ // IIR low-pass to track the gravity component on device-X.
124
+ s.gravityX = GRAVITY_IIR_ALPHA * s.gravityX + (1 - GRAVITY_IIR_ALPHA) * ax;
125
+ // Linear acceleration on X = raw - gravity estimate.
126
+ const linX = ax - s.gravityX;
127
+ // Single integration with per-sample velocity damping.
128
+ s.velX = (s.velX + linX * dt) * (1 - VELOCITY_DAMPING_PER_SAMPLE);
129
+ s.posX += s.velX * dt;
130
+ if (!s.fired && Math.abs(s.posX) > budgetMeters) {
131
+ s.fired = true;
132
+ onExceededRef.current();
205
133
  }
206
134
  });
207
- return () => {
208
- if (debug) {
209
- // eslint-disable-next-line no-console
210
- console.log(`[IMUTransGate] unsubscribing (everGotData=${everGotData}, `
211
- + `nullSamples=${nullSampleCount}, realSamples=${sampleCount.current})`);
212
- }
213
- sub.remove();
214
- };
215
- }, [enabled, budgetMeters, sampleIntervalMs, debug]);
216
- // 2026-05-18 (Issue B meta-bug fix): wrap the returned functions in
217
- // useCallback so the hook's return value is REFERENTIALLY STABLE
218
- // across renders. Consumer code (AuditCaptureScreen) puts `imuGate`
219
- // in a useEffect dep array; without stability that effect re-runs
220
- // every render, wiping out the prevAcceptedCount delta-tracker
221
- // (which is what caused the resetAnchor-too-often bug we just
222
- // diagnosed). These functions only touch refs, so empty-deps
223
- // useCallback is safe — no stale-closure risk.
224
- const resetAnchor = (0, react_1.useCallback)(() => {
225
- velX.current = 0;
226
- posX.current = 0;
227
- lastMs.current = 0;
228
- budgetCrossed.current = false;
229
- sampleCount.current = 0;
230
- anchorMs.current = Date.now();
231
- }, []);
232
- const getCurrentTranslationM = (0, react_1.useCallback)(() => Math.abs(posX.current), []);
233
- return { resetAnchor, getCurrentTranslationM };
135
+ return () => sub.unsubscribe();
136
+ }, [enabled, budgetMeters, sampleIntervalMs]);
137
+ return { resetAnchor };
234
138
  }
235
139
  //# sourceMappingURL=useIMUTranslationGate.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -53,7 +53,6 @@
53
53
  "homepage": "https://github.com/bhargavkanda/react-native-image-stitcher#readme",
54
54
  "devDependencies": {
55
55
  "@types/react": "^19.0.0",
56
- "expo-sensors": "^14.0.0",
57
56
  "react": "^19.0.0",
58
57
  "react-native": "^0.84.0",
59
58
  "react-native-safe-area-context": "^4.0.0",
@@ -67,7 +66,6 @@
67
66
  "react-native": ">=0.72.0",
68
67
  "react-native-vision-camera": ">=4.7.0",
69
68
  "react-native-sensors": ">=7.0.0",
70
- "expo-sensors": ">=14.0.0",
71
69
  "react-native-safe-area-context": ">=4.0.0"
72
70
  }
73
71
  }
@@ -661,7 +661,11 @@ export function Camera(props: CameraProps): React.JSX.Element {
661
661
  return () => { cancelled = true; };
662
662
  }, [isAR, lens]);
663
663
 
664
- // IMU translation gate — only in non-AR mode.
664
+ // IMU translation gate — only engaged in non-AR mode. Fires when
665
+ // the operator's lateral hand motion exceeds the budget, telling
666
+ // the C++ engine to force-accept the next frame. This is what
667
+ // keeps non-AR captures producing keyframes at all (the flow-
668
+ // novelty algorithm alone is too strict in practice).
665
669
  const imuGate = useIMUTranslationGate({
666
670
  enabled:
667
671
  isNonAR
@@ -12,42 +12,49 @@
12
12
  * the app is orientation-locked: window dimensions don't change
13
13
  * when only the device rotates.
14
14
  *
15
- * 2026-05-18 (Issue #3) — rewritten on top of `expo-sensors`
16
- * `DeviceMotion` (CoreMotion-fused on iOS, SensorManager on
17
- * Android). The previous implementation used
18
- * `react-native-sensors` raw accelerometer with an Android-only
19
- * sign convention (`y > 0` portrait), which silently failed on
20
- * iOSApple's CoreMotion convention is `y < 0` ⇒ portrait
21
- * because device-Y points from the phone's bottom to the top,
22
- * and gravity in that frame is `-Y`. Users on iOS saw the hook
23
- * stuck at its initial value ('portrait') regardless of physical
24
- * rotation, which cascaded into wrong panorama bake-rotation and
25
- * a broken landscape band layout.
15
+ * 2026-05-21 (v0.2 — Expo modules removal) — rewritten back onto
16
+ * `react-native-sensors` accelerometer. `expo-sensors`'
17
+ * `DeviceMotion` was used previously (Issue #3 / 2026-05-18) because
18
+ * it normalised Android signs to iOS convention for us, but that
19
+ * pulled the entire Expo modules runtime into every consuming
20
+ * host app a heavy tax for one orientation hook (see
21
+ * `docs/host-app-integration.md`). We now do the same sign
22
+ * normalisation explicitly in JS and stay on `react-native-sensors`
23
+ * (already a peer dep for the pan-guide gyroscope).
26
24
  *
27
25
  * Sign conventions used here (per platform docs):
28
26
  *
29
- * iOS (CMDeviceMotion.accelerationIncludingGravity, reported in
30
- * m/s² in the device reference frame):
31
- * portrait → y ≈ -9.8
32
- * portrait-upside-down → y ≈ +9.8
33
- * landscape-left (home indicator on user's RIGHT) → x ≈ +9.8
34
- * landscape-right (home indicator on user's LEFT) → x ≈ -9.8
27
+ * iOS (CMAccelerometerData, reported in G's; react-native-sensors
28
+ * passes through, in m/s²-ish G-multiples):
29
+ * portrait → y ≈ -1 (gravity along device -Y)
30
+ * portrait-upside-down → y ≈ +1
31
+ * landscape-left (home indicator on user's RIGHT) → x ≈ +1
32
+ * landscape-right (home indicator on user's LEFT) → x ≈ -1
35
33
  *
36
- * Android (Sensor.TYPE_ACCELEROMETER, reaction-force convention):
37
- * portrait → y ≈ +9.8 ← opposite sign vs iOS
34
+ * Android (Sensor.TYPE_ACCELEROMETER, reaction-force convention,
35
+ * m/s²):
36
+ * portrait → y ≈ +9.8 ← OPPOSITE SIGN vs iOS
38
37
  * portrait-upside-down → y ≈ -9.8
39
38
  * landscape-left → x ≈ -9.8
40
39
  * landscape-right → x ≈ +9.8
41
40
  *
42
- * We flip the Android x/y to match the iOS convention before
43
- * classification so the rest of the logic stays platform-
44
- * independent. The classification then unambiguously maps to
45
- * the user-visible `DeviceOrientation` enum.
41
+ * We flip the Android x/y signs to match the iOS convention
42
+ * before classification, so the classifier stays platform-
43
+ * agnostic and operates entirely in iOS-convention values.
44
+ * (Previous react-native-sensors implementation, pre-Issue-#3,
45
+ * forgot this — Apple's CoreMotion convention is `y < 0` ⇒
46
+ * portrait, but the old code used `y > 0` ⇒ portrait, so iOS
47
+ * was stuck at the initial value regardless of rotation. Don't
48
+ * regress.)
46
49
  */
47
50
 
48
51
  import { useEffect, useState } from 'react';
49
- import { DeviceMotion } from 'expo-sensors';
50
- import type { DeviceMotionMeasurement } from 'expo-sensors';
52
+ import { Platform } from 'react-native';
53
+ import {
54
+ accelerometer,
55
+ setUpdateIntervalForType,
56
+ SensorTypes,
57
+ } from 'react-native-sensors';
51
58
 
52
59
 
53
60
  export type DeviceOrientation =
@@ -57,59 +64,43 @@ export type DeviceOrientation =
57
64
  | 'landscape-right';
58
65
 
59
66
 
60
- /// Threshold (m/s²) above which gravity dominance is considered
61
- /// conclusive. 5 m/s² out of ~9.8 means the phone is at least ~30°
62
- /// tilted toward that axis comfortable for stable orientation
63
- /// classification without flipping on minor wobbles.
64
- const DOMINANT_AXIS_THRESHOLD = 5.0;
67
+ /// Threshold above which a single axis is considered to dominate.
68
+ /// Phone-at-rest under gravity reads ~1 G on whichever axis is
69
+ /// aligned with vertical; the off-axis reading is ~0. Anything
70
+ /// more than half a G (~5 m/s² on Android, ~0.5 on iOS in G's) is
71
+ /// safely in "dominant" territory without flipping on small wobbles.
72
+ /// We compare against the magnitude after sign-normalisation, so
73
+ /// the threshold is platform-dependent: iOS reports in G's,
74
+ /// Android in m/s².
75
+ const DOMINANT_AXIS_THRESHOLD_IOS = 0.5; // G's
76
+ const DOMINANT_AXIS_THRESHOLD_ANDROID = 5.0; // m/s²
65
77
 
66
78
  /// Sample at ~10 Hz — plenty for orientation detection (phones
67
79
  /// don't physically flip faster than this).
68
80
  const SAMPLE_INTERVAL_MS = 100;
69
81
 
70
82
 
71
- function classify(x: number, y: number): DeviceOrientation | null {
72
- // 2026-05-18 (Issue #3 round 2) — re-derived sign convention.
73
- //
74
- // Through expo-sensors, BOTH platforms normalize to the iOS
75
- // CoreMotion gravity-vector convention: stationary phone reports
76
- // the gravity vector itself in the device frame. Device axes:
77
- // +X points from phone-left to phone-right; +Y from phone-bottom
78
- // to phone-top; +Z out of the screen toward the viewer.
79
- //
80
- // Per-orientation gravity-vector signs in the device frame:
81
- //
82
- // portrait (upright) y ≈ -9.8
83
- // Phone-Y points up in world; gravity is along device -Y.
84
- //
85
- // portrait-upside-down → y ≈ +9.8
86
- // Phone-Y points down in world; gravity is along device +Y.
87
- //
88
- // landscape-left (Apple: home indicator on user's RIGHT;
89
- // phone rotated 90° CCW from portrait):
90
- // phone-X axis points from user-bottom to user-top in this
91
- // orientation, so gravity (world-down) is along device -X.
92
- // → x ≈ -9.8
93
- //
94
- // landscape-right (Apple: home indicator on user's LEFT;
95
- // phone rotated 90° CW from portrait):
96
- // phone-X axis points from user-top to user-bottom, so
97
- // gravity is along device +X.
98
- // → x ≈ +9.8
99
- //
100
- // The earlier implementation had an Android-specific axis flip
101
- // baked in. Removed — expo-sensors normalizes Android signs to
102
- // match iOS, and the platform branch was producing wrong values
103
- // (Android portrait → reported as portrait-upside-down; iOS
104
- // landscape-left → reported as landscape-right).
83
+ function classify(
84
+ x: number,
85
+ y: number,
86
+ threshold: number,
87
+ ): DeviceOrientation | null {
88
+ // Inputs are in iOS-convention gravity-vector signs:
89
+ // +X points from phone-left to phone-right; +Y from phone-
90
+ // bottom to phone-top; +Z out of the screen toward the viewer.
91
+ // At rest under gravity:
92
+ // portrait (upright) → y ≈ -g (phone-Y points up; gravity is -Y)
93
+ // portrait-upside-down → y ≈ +g
94
+ // landscape-left x ≈ -g (phone-X points up; gravity is -X)
95
+ // landscape-right → x +g
105
96
  if (Math.abs(y) > Math.abs(x)) {
106
- if (y < -DOMINANT_AXIS_THRESHOLD) return 'portrait';
107
- if (y > DOMINANT_AXIS_THRESHOLD) return 'portrait-upside-down';
97
+ if (y < -threshold) return 'portrait';
98
+ if (y > threshold) return 'portrait-upside-down';
108
99
  } else {
109
- if (x < -DOMINANT_AXIS_THRESHOLD) return 'landscape-left';
110
- if (x > DOMINANT_AXIS_THRESHOLD) return 'landscape-right';
100
+ if (x < -threshold) return 'landscape-left';
101
+ if (x > threshold) return 'landscape-right';
111
102
  }
112
- // Phone face-up or face-down (z dominates): keep the previous
103
+ // Phone face-up or face-down (z dominates) keep the previous
113
104
  // orientation rather than flicker.
114
105
  return null;
115
106
  }
@@ -119,21 +110,26 @@ export function useDeviceOrientation(): DeviceOrientation {
119
110
  const [orientation, setOrientation] = useState<DeviceOrientation>('portrait');
120
111
 
121
112
  useEffect(() => {
122
- DeviceMotion.setUpdateInterval(SAMPLE_INTERVAL_MS);
113
+ setUpdateIntervalForType(SensorTypes.accelerometer, SAMPLE_INTERVAL_MS);
114
+
115
+ const isAndroid = Platform.OS === 'android';
116
+ const threshold = isAndroid
117
+ ? DOMINANT_AXIS_THRESHOLD_ANDROID
118
+ : DOMINANT_AXIS_THRESHOLD_IOS;
123
119
 
124
120
  let last: DeviceOrientation = 'portrait';
125
- const sub = DeviceMotion.addListener((m: DeviceMotionMeasurement) => {
126
- const g = m.accelerationIncludingGravity;
127
- // First emissions can be null on cold start while CoreMotion
128
- // warms up; skip until data arrives.
129
- if (!g) return;
130
- const next = classify(g.x, g.y);
121
+ const sub = accelerometer.subscribe(({ x, y }) => {
122
+ // Normalise Android reaction-force convention to iOS gravity
123
+ // convention by flipping signs. No-op on iOS.
124
+ const gx = isAndroid ? -x : x;
125
+ const gy = isAndroid ? -y : y;
126
+ const next = classify(gx, gy, threshold);
131
127
  if (next && next !== last) {
132
128
  last = next;
133
129
  setOrientation(next);
134
130
  }
135
131
  });
136
- return () => sub.remove();
132
+ return () => sub.unsubscribe();
137
133
  }, []);
138
134
 
139
135
  return orientation;
package/src/index.ts CHANGED
@@ -54,6 +54,9 @@ export type {
54
54
  // ─────────────────────────────────────────────────────────────────────
55
55
  // Hosts running their own non-AR capture flow can reuse this hook to
56
56
  // get the same translation-budget gating logic <Camera> uses internally.
57
+ // As of v0.2 this hook is implemented on `react-native-sensors` raw
58
+ // accelerometer + JS IIR gravity subtraction (was `expo-sensors`'
59
+ // fused DeviceMotion through 0.1.x — see the hook's file header).
57
60
  export { useIMUTranslationGate } from './sensors/useIMUTranslationGate';
58
61
  export type {
59
62
  UseIMUTranslationGateOptions,