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.
- package/CHANGELOG.md +72 -1
- package/dist/camera/Camera.js +5 -1
- package/dist/camera/useDeviceOrientation.d.ts +26 -23
- package/dist/camera/useDeviceOrientation.js +64 -77
- package/dist/index.js +3 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +18 -46
- package/dist/sensors/useIMUTranslationGate.js +115 -211
- package/package.json +1 -3
- package/src/camera/Camera.tsx +5 -1
- package/src/camera/useDeviceOrientation.ts +73 -77
- package/src/index.ts +3 -0
- package/src/sensors/useIMUTranslationGate.ts +145 -284
|
@@ -1,235 +1,139 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
//
|
|
4
|
-
// useIMUTranslationGate
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// `
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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)
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
|
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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Single
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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.
|
|
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
|
}
|
package/src/camera/Camera.tsx
CHANGED
|
@@ -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-
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 (
|
|
30
|
-
* m/s
|
|
31
|
-
* portrait → y ≈ -
|
|
32
|
-
* portrait-upside-down → y ≈ +
|
|
33
|
-
* landscape-left (home indicator on user's RIGHT) → x ≈ +
|
|
34
|
-
* landscape-right (home indicator on user's LEFT) → x ≈ -
|
|
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
|
-
*
|
|
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
|
|
43
|
-
* classification so the
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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 {
|
|
50
|
-
import
|
|
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
|
|
61
|
-
///
|
|
62
|
-
///
|
|
63
|
-
///
|
|
64
|
-
|
|
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(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// to phone-top; +Z out of the screen toward the viewer.
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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 < -
|
|
107
|
-
if (y >
|
|
97
|
+
if (y < -threshold) return 'portrait';
|
|
98
|
+
if (y > threshold) return 'portrait-upside-down';
|
|
108
99
|
} else {
|
|
109
|
-
if (x < -
|
|
110
|
-
if (x >
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const next = classify(
|
|
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.
|
|
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,
|