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,113 +1,97 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
//
|
|
3
|
-
// useIMUTranslationGate
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// `
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// (b) A user-facing warning banner ("Rotate the camera instead of
|
|
28
|
-
// moving it sideways" — see AuditCaptureScreen).
|
|
29
|
-
//
|
|
30
|
-
// AuditCaptureScreen wires it to both.
|
|
3
|
+
// useIMUTranslationGate — JS-side IMU translation tracker that fires
|
|
4
|
+
// a callback when integrated lateral displacement on the device-X
|
|
5
|
+
// axis exceeds a budget. Drives `<Camera>`'s non-AR keyframe-
|
|
6
|
+
// acceptance path: every time the gate fires, the host calls the
|
|
7
|
+
// C++ engine's `markNextFrameAsLastKeyframe()` so the trailing frame
|
|
8
|
+
// lands as a keyframe regardless of what the flow-novelty algorithm
|
|
9
|
+
// alone would decide.
|
|
10
|
+
//
|
|
11
|
+
// V0.2 history note
|
|
12
|
+
// ─────────────────
|
|
13
|
+
// 0.1.x used `expo-sensors`' `DeviceMotion.acceleration`, which
|
|
14
|
+
// returned gravity-subtracted linear acceleration via CoreMotion's
|
|
15
|
+
// native fusion (iOS) / Android's `TYPE_LINEAR_ACCELERATION` sensor
|
|
16
|
+
// (Android) — both significantly less noisy than raw accel + JS-side
|
|
17
|
+
// gravity subtraction. v0.2 drops the Expo modules dependency
|
|
18
|
+
// (see CHANGELOG / docs/host-app-integration.md), so the gate is now
|
|
19
|
+
// implemented on `react-native-sensors`' raw `accelerometer` with a
|
|
20
|
+
// JS-side IIR low-pass to estimate the gravity vector. The IIR
|
|
21
|
+
// version is noisier — expect a few extra cm of apparent drift on a
|
|
22
|
+
// stationary phone over several seconds — but the budget threshold
|
|
23
|
+
// (~8 cm at default `flowMaxTranslationCm = 8`) and the anchor
|
|
24
|
+
// resets (every accepted keyframe + recording start) keep the
|
|
25
|
+
// per-interval drift window short enough that the budget still
|
|
26
|
+
// meaningfully discriminates real translation from noise.
|
|
31
27
|
//
|
|
32
28
|
// Why device-X (the shorter side)
|
|
33
29
|
// ───────────────────────────────
|
|
34
|
-
//
|
|
35
30
|
// We track motion ALONG the pan axis (the direction the operator is
|
|
36
31
|
// supposed to be rotating-through but might be translating-through
|
|
37
|
-
// instead)
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// The pan axis maps to device-X in BOTH supported orientations
|
|
42
|
-
// (per memory/ar-stitching-two-modes.md):
|
|
43
|
-
//
|
|
44
|
-
// Portrait + horizontal pan: device-X = user-left/right = pan axis.
|
|
32
|
+
// instead). In BOTH supported pan modes the pan axis maps to
|
|
33
|
+
// device-X:
|
|
34
|
+
// Portrait + horizontal pan: device-X = user-left/right.
|
|
45
35
|
// Landscape + vertical pan: device-X has rotated 90° into the
|
|
46
|
-
// user's up/down direction
|
|
47
|
-
//
|
|
48
|
-
// The lateral axis of the phone (its short side) always aligns with
|
|
49
|
-
// the pan direction in either supported mode, so a single-axis
|
|
50
|
-
// tracker works without needing to know which orientation we're in.
|
|
36
|
+
// user's up/down direction.
|
|
37
|
+
// So a single-axis tracker works without knowing the orientation.
|
|
51
38
|
//
|
|
52
39
|
// Drift mitigation
|
|
53
40
|
// ────────────────
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
41
|
+
// 1. IIR low-pass on the raw X accel estimates the gravity offset.
|
|
42
|
+
// Subtracting that gives linear-acceleration-on-X. Alpha = 0.9
|
|
43
|
+
// at the default 50 Hz sample rate → ~200 ms gravity tracking
|
|
44
|
+
// time constant. Slow enough that hand motion (>1 Hz) gets
|
|
45
|
+
// through; fast enough to converge after device rotations within
|
|
46
|
+
// ~1 second.
|
|
47
|
+
// 2. Per-sample velocity damping at 5 % so a constant noise-floor
|
|
48
|
+
// offset decays to ~1 % of its initial value in 2 s. This caps
|
|
49
|
+
// apparent drift for a stationary phone.
|
|
50
|
+
// 3. Anchor reset on recording start AND every accepted keyframe
|
|
51
|
+
// (callers do this) — bounds the integration window to typically
|
|
52
|
+
// 0.3-2 s, well inside the regime where IIR-estimated linear
|
|
53
|
+
// accel is usable.
|
|
54
|
+
//
|
|
55
|
+
// Platform unit handling
|
|
56
|
+
// ──────────────────────
|
|
57
|
+
// `react-native-sensors`' accelerometer reports:
|
|
58
|
+
// iOS: values in G's (multiples of 9.81 m/s²), via CoreMotion.
|
|
59
|
+
// Android: values in m/s², via Sensor.TYPE_ACCELEROMETER.
|
|
60
|
+
// We scale iOS by `G_TO_MPS2` so the integration math stays in
|
|
61
|
+
// standard m/s², m/s, m units. Sign convention doesn't matter for
|
|
62
|
+
// the gate because the gravity offset is estimated and subtracted
|
|
63
|
+
// per-axis; what's left is the platform-agnostic linear acceleration.
|
|
76
64
|
|
|
77
65
|
import { useCallback, useEffect, useRef } from 'react';
|
|
78
|
-
import {
|
|
79
|
-
import
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
type
|
|
66
|
+
import { Platform } from 'react-native';
|
|
67
|
+
import {
|
|
68
|
+
accelerometer,
|
|
69
|
+
setUpdateIntervalForType,
|
|
70
|
+
SensorTypes,
|
|
71
|
+
} from 'react-native-sensors';
|
|
72
|
+
import type { Subscription } from 'rxjs';
|
|
85
73
|
|
|
86
74
|
|
|
87
75
|
export interface UseIMUTranslationGateOptions {
|
|
88
76
|
/**
|
|
89
|
-
* Whether the gate is engaged. Pass `false` to skip the
|
|
90
|
-
* entirely — useful when the host is in AR mode
|
|
91
|
-
* gets pose-derived translation natively).
|
|
92
|
-
* subscribing/unsubscribing is cheap.
|
|
77
|
+
* Whether the gate is engaged. Pass `false` to skip the
|
|
78
|
+
* subscription entirely — useful when the host is in AR mode
|
|
79
|
+
* (where the gate gets pose-derived translation natively).
|
|
80
|
+
* Hot-toggleable; subscribing/unsubscribing is cheap.
|
|
93
81
|
*/
|
|
94
82
|
enabled: boolean;
|
|
95
83
|
|
|
96
84
|
/**
|
|
97
85
|
* Translation budget in METRES along the device-X (pan) axis.
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* 0.40 m / 40 cm (80 % of the 50 cm default
|
|
101
|
-
* `flowMaxTranslationCm`). Caller typically passes
|
|
102
|
-
* `panoramaSettings.flowMaxTranslationCm * 0.8 / 100`.
|
|
86
|
+
* Default 0.40 m / 40 cm. Callers in `<Camera>` typically pass
|
|
87
|
+
* `panoramaSettings.flowMaxTranslationCm / 100.0` (default 8 cm).
|
|
103
88
|
*/
|
|
104
89
|
budgetMeters?: number;
|
|
105
90
|
|
|
106
91
|
/**
|
|
107
|
-
* Update interval in MILLISECONDS for the
|
|
108
|
-
* Default 20 ms ≈ 50 Hz. Lower
|
|
109
|
-
*
|
|
110
|
-
* raw-accel cadence so reset/integrate behaviour stays comparable.
|
|
92
|
+
* Update interval in MILLISECONDS for the accelerometer.
|
|
93
|
+
* Default 20 ms ≈ 50 Hz. Lower = more accurate integration;
|
|
94
|
+
* higher = lower CPU + battery.
|
|
111
95
|
*/
|
|
112
96
|
sampleIntervalMs?: number;
|
|
113
97
|
|
|
@@ -115,233 +99,110 @@ export interface UseIMUTranslationGateOptions {
|
|
|
115
99
|
* Fired exactly once per "budget crossing" — i.e., when the
|
|
116
100
|
* running translation along device-X crosses `budgetMeters` from
|
|
117
101
|
* below. The host is responsible for both (a) calling
|
|
118
|
-
* `IncrementalStitcher.markNextFrameAsLastKeyframe()`
|
|
119
|
-
*
|
|
120
|
-
* keyframe actually accepts, so
|
|
102
|
+
* `IncrementalStitcher.markNextFrameAsLastKeyframe()` to force-
|
|
103
|
+
* accept the next frame, and (b) invoking the returned
|
|
104
|
+
* `resetAnchor()` once that next keyframe actually accepts, so
|
|
105
|
+
* the integrator restarts from zero.
|
|
121
106
|
*/
|
|
122
107
|
onBudgetExceeded: () => void;
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* 2026-05-18 (Issue #4 investigation) — when true, log every Nth
|
|
126
|
-
* accelerometer sample (default N=20 ≈ 400 ms at 50 Hz) showing
|
|
127
|
-
* the current `acceleration.x`, accumulated `posX`, and time
|
|
128
|
-
* since anchor reset. Helps diagnose drift behaviour vs real
|
|
129
|
-
* translation magnitude in field testing. Defaults to false —
|
|
130
|
-
* production captures stay quiet.
|
|
131
|
-
*/
|
|
132
|
-
debug?: boolean;
|
|
133
108
|
}
|
|
134
109
|
|
|
135
110
|
|
|
136
111
|
export interface UseIMUTranslationGateReturn {
|
|
137
112
|
/**
|
|
138
|
-
* Reset the
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* `handleHoldStart`.
|
|
113
|
+
* Reset the position + velocity integrators to zero AND clear the
|
|
114
|
+
* "already fired" latch so `onBudgetExceeded` can fire again.
|
|
115
|
+
* The gravity IIR estimate is intentionally preserved — it
|
|
116
|
+
* benefits from continuous history across anchors.
|
|
143
117
|
*/
|
|
144
118
|
resetAnchor: () => void;
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Read the current running displacement along device-X in METRES.
|
|
148
|
-
* Returns the absolute value (sign is uninteresting — either left
|
|
149
|
-
* or right counts the same toward the budget).
|
|
150
|
-
* Useful for the on-screen debug HUD ("translation since last
|
|
151
|
-
* accept: 0.07 m"). Not exposed via state — host polls if needed.
|
|
152
|
-
*/
|
|
153
|
-
getCurrentTranslationM: () => number;
|
|
154
119
|
}
|
|
155
120
|
|
|
156
121
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
122
|
+
const DEFAULT_BUDGET_METERS = 0.40;
|
|
123
|
+
const DEFAULT_SAMPLE_INTERVAL_MS = 20;
|
|
124
|
+
/// Per-sample multiplicative damping on the velocity integrator.
|
|
125
|
+
/// 5 % at 50 Hz → constant offset decays to ~1 % in 2 s. Bounds
|
|
126
|
+
/// the apparent-drift window for a stationary phone.
|
|
127
|
+
const VELOCITY_DAMPING_PER_SAMPLE = 0.05;
|
|
128
|
+
/// IIR low-pass coefficient for the gravity estimate. At 50 Hz
|
|
129
|
+
/// this gives ~200 ms time constant. Higher = slower gravity
|
|
130
|
+
/// tracking (more lag during device rotation, less hand-motion
|
|
131
|
+
/// bleed into the gravity estimate); lower = faster.
|
|
132
|
+
const GRAVITY_IIR_ALPHA = 0.9;
|
|
133
|
+
/// 1 G in m/s². Standard gravity per CGPM 1901 (good to all the
|
|
134
|
+
/// digits anyone cares about for this application).
|
|
135
|
+
const G_TO_MPS2 = 9.81;
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
export function useIMUTranslationGate({
|
|
139
|
+
enabled,
|
|
140
|
+
budgetMeters = DEFAULT_BUDGET_METERS,
|
|
141
|
+
sampleIntervalMs = DEFAULT_SAMPLE_INTERVAL_MS,
|
|
142
|
+
onBudgetExceeded,
|
|
143
|
+
}: UseIMUTranslationGateOptions): UseIMUTranslationGateReturn {
|
|
144
|
+
// All running-integrator state lives in a single ref so the
|
|
145
|
+
// subscription callback can update it without forcing a re-render
|
|
146
|
+
// every frame (50 Hz worth of re-renders would tank performance).
|
|
147
|
+
const stateRef = useRef({
|
|
148
|
+
posX: 0,
|
|
149
|
+
velX: 0,
|
|
150
|
+
/// NaN sentinel for "uninitialised"; first sample seeds it.
|
|
151
|
+
gravityX: NaN,
|
|
152
|
+
fired: false,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Latest onBudgetExceeded callback in a ref so callers can pass
|
|
156
|
+
// an inline closure that captures fresh state without us re-
|
|
157
|
+
// subscribing the sensor (which would reset the integrators).
|
|
158
|
+
const onExceededRef = useRef(onBudgetExceeded);
|
|
159
|
+
onExceededRef.current = onBudgetExceeded;
|
|
190
160
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
161
|
+
const resetAnchor = useCallback(() => {
|
|
162
|
+
const s = stateRef.current;
|
|
163
|
+
s.posX = 0;
|
|
164
|
+
s.velX = 0;
|
|
165
|
+
s.fired = false;
|
|
166
|
+
// s.gravityX is intentionally preserved — see header.
|
|
167
|
+
}, []);
|
|
196
168
|
|
|
197
169
|
useEffect(() => {
|
|
198
|
-
if (debug) {
|
|
199
|
-
// eslint-disable-next-line no-console
|
|
200
|
-
console.log(
|
|
201
|
-
`[IMUTransGate] effect re-run: enabled=${enabled} `
|
|
202
|
-
+ `budget=${budgetMeters.toFixed(2)}m sampleIntervalMs=${sampleIntervalMs}`,
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
170
|
if (!enabled) return;
|
|
206
171
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// tolerates a wide range of cadences.
|
|
211
|
-
DeviceMotion.setUpdateInterval(sampleIntervalMs);
|
|
172
|
+
setUpdateIntervalForType(SensorTypes.accelerometer, sampleIntervalMs);
|
|
173
|
+
const scale = Platform.OS === 'ios' ? G_TO_MPS2 : 1;
|
|
174
|
+
const dt = sampleIntervalMs / 1000.0;
|
|
212
175
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
velX.current = 0;
|
|
217
|
-
posX.current = 0;
|
|
218
|
-
lastMs.current = 0;
|
|
219
|
-
budgetCrossed.current = false;
|
|
220
|
-
sampleCount.current = 0;
|
|
221
|
-
anchorMs.current = Date.now();
|
|
176
|
+
const sub: Subscription = accelerometer.subscribe(({ x }) => {
|
|
177
|
+
const ax = x * scale; // device-X acceleration in m/s²
|
|
178
|
+
const s = stateRef.current;
|
|
222
179
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
let nullSampleCount = 0;
|
|
229
|
-
if (debug) {
|
|
230
|
-
// eslint-disable-next-line no-console
|
|
231
|
-
console.log('[IMUTransGate] subscribing to DeviceMotion');
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const sub: DeviceMotionSubscription = DeviceMotion.addListener((m: DeviceMotionMeasurement) => {
|
|
235
|
-
const a = m.acceleration; // gravity-subtracted (m/s²)
|
|
236
|
-
if (!a) {
|
|
237
|
-
nullSampleCount += 1;
|
|
238
|
-
if (debug && nullSampleCount === 1) {
|
|
239
|
-
// eslint-disable-next-line no-console
|
|
240
|
-
console.log(
|
|
241
|
-
'[IMUTransGate] first sample: acceleration=null '
|
|
242
|
-
+ '(CoreMotion warming up; will retry on next sample)',
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
if (debug && nullSampleCount > 0 && nullSampleCount % 100 === 0) {
|
|
246
|
-
// eslint-disable-next-line no-console
|
|
247
|
-
console.log(
|
|
248
|
-
`[IMUTransGate] STILL receiving null acceleration after `
|
|
249
|
-
+ `${nullSampleCount} samples — sensor source may be broken`,
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
return; // can be null briefly on cold start
|
|
253
|
-
}
|
|
254
|
-
if (debug && !everGotData) {
|
|
255
|
-
everGotData = true;
|
|
256
|
-
// eslint-disable-next-line no-console
|
|
257
|
-
console.log(
|
|
258
|
-
`[IMUTransGate] first real sample: ax=${a.x.toFixed(3)} `
|
|
259
|
-
+ `ay=${a.y.toFixed(3)} az=${a.z.toFixed(3)} m/s² `
|
|
260
|
-
+ `(after ${nullSampleCount} null sample(s))`,
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
const now = Date.now();
|
|
264
|
-
if (lastMs.current === 0) {
|
|
265
|
-
lastMs.current = now;
|
|
180
|
+
// First sample: seed gravity from this reading. Assumes the
|
|
181
|
+
// phone is roughly stationary at recording start — true in
|
|
182
|
+
// practice because the operator just tap-and-held the shutter.
|
|
183
|
+
if (Number.isNaN(s.gravityX)) {
|
|
184
|
+
s.gravityX = ax;
|
|
266
185
|
return;
|
|
267
186
|
}
|
|
268
|
-
const dt = Math.max(0, Math.min(0.1, (now - lastMs.current) / 1000.0));
|
|
269
|
-
lastMs.current = now;
|
|
270
|
-
if (dt === 0) return;
|
|
271
187
|
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
// portrait and landscape captures.
|
|
275
|
-
velX.current += a.x * dt;
|
|
276
|
-
velX.current *= 0.95; // 5%/sample damping — see file header
|
|
277
|
-
posX.current += velX.current * dt;
|
|
188
|
+
// IIR low-pass to track the gravity component on device-X.
|
|
189
|
+
s.gravityX = GRAVITY_IIR_ALPHA * s.gravityX + (1 - GRAVITY_IIR_ALPHA) * ax;
|
|
278
190
|
|
|
279
|
-
|
|
191
|
+
// Linear acceleration on X = raw - gravity estimate.
|
|
192
|
+
const linX = ax - s.gravityX;
|
|
280
193
|
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// posX hovering at < 5 cm during a real translation, the
|
|
285
|
-
// sensor source isn't capturing what we think it is.
|
|
286
|
-
sampleCount.current += 1;
|
|
287
|
-
if (debug && sampleCount.current % 20 === 0) {
|
|
288
|
-
const secs = (now - anchorMs.current) / 1000.0;
|
|
289
|
-
// eslint-disable-next-line no-console
|
|
290
|
-
console.log(
|
|
291
|
-
`[IMUTransGate] t+${secs.toFixed(2)}s `
|
|
292
|
-
+ `ax=${a.x.toFixed(3)}m/s² `
|
|
293
|
-
+ `velX=${velX.current.toFixed(4)}m/s `
|
|
294
|
-
+ `posX=${posX.current.toFixed(4)}m `
|
|
295
|
-
+ `(|mag|=${mag.toFixed(4)}m, budget=${budgetMeters.toFixed(2)}m, crossed=${budgetCrossed.current})`,
|
|
296
|
-
);
|
|
297
|
-
}
|
|
194
|
+
// Single integration with per-sample velocity damping.
|
|
195
|
+
s.velX = (s.velX + linX * dt) * (1 - VELOCITY_DAMPING_PER_SAMPLE);
|
|
196
|
+
s.posX += s.velX * dt;
|
|
298
197
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
budgetCrossed.current = true;
|
|
303
|
-
if (debug) {
|
|
304
|
-
// eslint-disable-next-line no-console
|
|
305
|
-
console.log(
|
|
306
|
-
`[IMUTransGate] BUDGET CROSSED at posX=${posX.current.toFixed(4)}m `
|
|
307
|
-
+ `(budget=${budgetMeters.toFixed(2)}m)`,
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
onBudgetExceededRef.current();
|
|
198
|
+
if (!s.fired && Math.abs(s.posX) > budgetMeters) {
|
|
199
|
+
s.fired = true;
|
|
200
|
+
onExceededRef.current();
|
|
311
201
|
}
|
|
312
202
|
});
|
|
313
203
|
|
|
314
|
-
return () =>
|
|
315
|
-
|
|
316
|
-
// eslint-disable-next-line no-console
|
|
317
|
-
console.log(
|
|
318
|
-
`[IMUTransGate] unsubscribing (everGotData=${everGotData}, `
|
|
319
|
-
+ `nullSamples=${nullSampleCount}, realSamples=${sampleCount.current})`,
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
sub.remove();
|
|
323
|
-
};
|
|
324
|
-
}, [enabled, budgetMeters, sampleIntervalMs, debug]);
|
|
204
|
+
return () => sub.unsubscribe();
|
|
205
|
+
}, [enabled, budgetMeters, sampleIntervalMs]);
|
|
325
206
|
|
|
326
|
-
|
|
327
|
-
// useCallback so the hook's return value is REFERENTIALLY STABLE
|
|
328
|
-
// across renders. Consumer code (AuditCaptureScreen) puts `imuGate`
|
|
329
|
-
// in a useEffect dep array; without stability that effect re-runs
|
|
330
|
-
// every render, wiping out the prevAcceptedCount delta-tracker
|
|
331
|
-
// (which is what caused the resetAnchor-too-often bug we just
|
|
332
|
-
// diagnosed). These functions only touch refs, so empty-deps
|
|
333
|
-
// useCallback is safe — no stale-closure risk.
|
|
334
|
-
const resetAnchor = useCallback(() => {
|
|
335
|
-
velX.current = 0;
|
|
336
|
-
posX.current = 0;
|
|
337
|
-
lastMs.current = 0;
|
|
338
|
-
budgetCrossed.current = false;
|
|
339
|
-
sampleCount.current = 0;
|
|
340
|
-
anchorMs.current = Date.now();
|
|
341
|
-
}, []);
|
|
342
|
-
const getCurrentTranslationM = useCallback(
|
|
343
|
-
() => Math.abs(posX.current),
|
|
344
|
-
[],
|
|
345
|
-
);
|
|
346
|
-
return { resetAnchor, getCurrentTranslationM };
|
|
207
|
+
return { resetAnchor };
|
|
347
208
|
}
|