react-native-image-stitcher 0.1.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 +96 -0
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +189 -0
- package/RNImageStitcher.podspec +76 -0
- package/android/build.gradle +224 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +124 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
- package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
- package/cpp/ar_frame_pose.h +63 -0
- package/cpp/keyframe_gate.cpp +927 -0
- package/cpp/keyframe_gate.hpp +240 -0
- package/cpp/stitcher.cpp +2207 -0
- package/cpp/stitcher.hpp +275 -0
- package/dist/ar/useARSession.d.ts +102 -0
- package/dist/ar/useARSession.js +133 -0
- package/dist/camera/ARCameraView.d.ts +93 -0
- package/dist/camera/ARCameraView.js +170 -0
- package/dist/camera/Camera.d.ts +134 -0
- package/dist/camera/Camera.js +688 -0
- package/dist/camera/CameraShutter.d.ts +80 -0
- package/dist/camera/CameraShutter.js +237 -0
- package/dist/camera/CameraView.d.ts +65 -0
- package/dist/camera/CameraView.js +117 -0
- package/dist/camera/CaptureControlsBar.d.ts +87 -0
- package/dist/camera/CaptureControlsBar.js +82 -0
- package/dist/camera/CaptureHeader.d.ts +62 -0
- package/dist/camera/CaptureHeader.js +81 -0
- package/dist/camera/CapturePreview.d.ts +70 -0
- package/dist/camera/CapturePreview.js +188 -0
- package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
- package/dist/camera/CaptureStatusOverlay.js +326 -0
- package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
- package/dist/camera/CaptureThumbnailStrip.js +177 -0
- package/dist/camera/IncrementalPanGuide.d.ts +83 -0
- package/dist/camera/IncrementalPanGuide.js +267 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
- package/dist/camera/PanoramaBandOverlay.js +399 -0
- package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
- package/dist/camera/PanoramaConfirmModal.js +128 -0
- package/dist/camera/PanoramaGuidance.d.ts +79 -0
- package/dist/camera/PanoramaGuidance.js +246 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
- package/dist/camera/PanoramaSettingsModal.js +611 -0
- package/dist/camera/ViewportCropOverlay.d.ts +46 -0
- package/dist/camera/ViewportCropOverlay.js +67 -0
- package/dist/camera/useCapture.d.ts +111 -0
- package/dist/camera/useCapture.js +160 -0
- package/dist/camera/useDeviceOrientation.d.ts +48 -0
- package/dist/camera/useDeviceOrientation.js +131 -0
- package/dist/camera/useVideoCapture.d.ts +79 -0
- package/dist/camera/useVideoCapture.js +151 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +39 -0
- package/dist/quality/normaliseOrientation.d.ts +36 -0
- package/dist/quality/normaliseOrientation.js +62 -0
- package/dist/quality/runQualityCheck.d.ts +41 -0
- package/dist/quality/runQualityCheck.js +98 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
- package/dist/sensors/useIMUTranslationGate.js +235 -0
- package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
- package/dist/stitching/IncrementalStitcherView.js +157 -0
- package/dist/stitching/incremental.d.ts +930 -0
- package/dist/stitching/incremental.js +133 -0
- package/dist/stitching/stitchFrames.d.ts +55 -0
- package/dist/stitching/stitchFrames.js +56 -0
- package/dist/stitching/stitchVideo.d.ts +119 -0
- package/dist/stitching/stitchVideo.js +57 -0
- package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
- package/dist/stitching/useIncrementalJSDriver.js +199 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
- package/dist/stitching/useIncrementalStitcher.js +172 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +15 -0
- package/ios/Package.swift +72 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
- package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
- package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
- package/package.json +73 -0
- package/react-native.config.js +34 -0
- package/scripts/opencv-version.txt +1 -0
- package/scripts/postinstall-fetch-binaries.js +286 -0
- package/src/ar/useARSession.ts +210 -0
- package/src/camera/.gitkeep +0 -0
- package/src/camera/ARCameraView.tsx +256 -0
- package/src/camera/Camera.tsx +1053 -0
- package/src/camera/CameraShutter.tsx +292 -0
- package/src/camera/CameraView.tsx +157 -0
- package/src/camera/CaptureControlsBar.tsx +204 -0
- package/src/camera/CaptureHeader.tsx +184 -0
- package/src/camera/CapturePreview.tsx +318 -0
- package/src/camera/CaptureStatusOverlay.tsx +391 -0
- package/src/camera/CaptureThumbnailStrip.tsx +277 -0
- package/src/camera/IncrementalPanGuide.tsx +328 -0
- package/src/camera/PanoramaBandOverlay.tsx +498 -0
- package/src/camera/PanoramaConfirmModal.tsx +206 -0
- package/src/camera/PanoramaGuidance.tsx +327 -0
- package/src/camera/PanoramaSettingsModal.tsx +1357 -0
- package/src/camera/ViewportCropOverlay.tsx +81 -0
- package/src/camera/useCapture.ts +279 -0
- package/src/camera/useDeviceOrientation.ts +140 -0
- package/src/camera/useVideoCapture.ts +236 -0
- package/src/index.ts +53 -0
- package/src/quality/.gitkeep +0 -0
- package/src/quality/normaliseOrientation.ts +79 -0
- package/src/quality/runQualityCheck.ts +131 -0
- package/src/sensors/useIMUTranslationGate.ts +347 -0
- package/src/stitching/.gitkeep +0 -0
- package/src/stitching/IncrementalStitcherView.tsx +198 -0
- package/src/stitching/incremental.ts +1021 -0
- package/src/stitching/stitchFrames.ts +88 -0
- package/src/stitching/stitchVideo.ts +153 -0
- package/src/stitching/useIncrementalJSDriver.ts +273 -0
- package/src/stitching/useIncrementalStitcher.ts +252 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn
|
|
3
|
+
|
|
4
|
+
import android.app.Activity
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import com.facebook.react.bridge.Arguments
|
|
8
|
+
import com.facebook.react.bridge.Promise
|
|
9
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
10
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
11
|
+
import com.facebook.react.bridge.ReactMethod
|
|
12
|
+
import com.google.ar.core.ArCoreApk
|
|
13
|
+
import com.google.ar.core.Config
|
|
14
|
+
import com.google.ar.core.Plane
|
|
15
|
+
import com.google.ar.core.Pose
|
|
16
|
+
import com.google.ar.core.Session
|
|
17
|
+
import com.google.ar.core.TrackingState
|
|
18
|
+
import com.google.ar.core.exceptions.UnavailableException
|
|
19
|
+
import kotlin.math.abs
|
|
20
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
21
|
+
import java.util.concurrent.locks.ReentrantReadWriteLock
|
|
22
|
+
import kotlin.concurrent.read
|
|
23
|
+
import kotlin.concurrent.write
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Android twin of iOS's `RNSARSession`.
|
|
27
|
+
*
|
|
28
|
+
* Phase 4 foundation for the AR measurement plan
|
|
29
|
+
* (docs/site-content/design/2026-04-29-ar-measurement-and-detection.md).
|
|
30
|
+
* Wraps ARCore in a singleton + RN bridge with the same JS surface
|
|
31
|
+
* as iOS:
|
|
32
|
+
*
|
|
33
|
+
* isSupported() → Promise<boolean>
|
|
34
|
+
* start() → Promise<void>
|
|
35
|
+
* stop() → Promise<void>
|
|
36
|
+
* getState() → Promise<{ isRunning, trackingState }>
|
|
37
|
+
* snapshotPoseLog() → Promise<FramePose[]>
|
|
38
|
+
* clearPoseLog() → Promise<void>
|
|
39
|
+
*
|
|
40
|
+
* Trade-offs vs iOS:
|
|
41
|
+
* - ARCore needs an `Activity` context to install Play Services
|
|
42
|
+
* for AR if the user doesn't have it; we keep a soft-ref to
|
|
43
|
+
* `currentActivity` from the React context.
|
|
44
|
+
* - Pose updates come from `Frame.getCamera().getPose()` polled
|
|
45
|
+
* each frame (caller drives the polling — typically the
|
|
46
|
+
* ARCore-backed CameraView). Phase 4.4 wires that up.
|
|
47
|
+
*/
|
|
48
|
+
class RNSARSession(reactContext: ReactApplicationContext)
|
|
49
|
+
: ReactContextBaseJavaModule(reactContext) {
|
|
50
|
+
|
|
51
|
+
override fun getName(): String = "RNSARSession"
|
|
52
|
+
|
|
53
|
+
/// Tracking state values mirror the iOS enum exactly.
|
|
54
|
+
/// 0 = notAvailable, 1 = initialising, 2 = tracking, 3 = limited.
|
|
55
|
+
/// JS code does not need conditional branching across platforms.
|
|
56
|
+
private val trackingStateRef = AtomicReference(TRACKING_NOT_AVAILABLE)
|
|
57
|
+
private val sessionRef = AtomicReference<Session?>(null)
|
|
58
|
+
private val poseLog = mutableListOf<RNSARFramePose>()
|
|
59
|
+
private val poseLogLock = ReentrantReadWriteLock()
|
|
60
|
+
|
|
61
|
+
@ReactMethod
|
|
62
|
+
fun isSupported(promise: Promise) {
|
|
63
|
+
// `checkAvailability` can return UNKNOWN_CHECKING if the
|
|
64
|
+
// device's support status hasn't been polled yet — that's
|
|
65
|
+
// the first-launch case. Treat UNKNOWN as "available" so
|
|
66
|
+
// the UI shows the AR feature; the actual `start` call will
|
|
67
|
+
// surface a clearer error if the device truly can't run AR.
|
|
68
|
+
val availability = ArCoreApk.getInstance().checkAvailability(reactApplicationContext)
|
|
69
|
+
val supported = availability.isSupported || availability.isTransient
|
|
70
|
+
promise.resolve(supported)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@ReactMethod
|
|
74
|
+
fun start(promise: Promise) {
|
|
75
|
+
// ReactContextBaseJavaModule.getCurrentActivity() — Java
|
|
76
|
+
// getter, no Kotlin property syntax. ARCore's installer
|
|
77
|
+
// path needs an Activity to attach the consent dialog to.
|
|
78
|
+
val activity: Activity? = reactApplicationContext.currentActivity
|
|
79
|
+
if (activity == null) {
|
|
80
|
+
promise.reject(
|
|
81
|
+
"no-activity",
|
|
82
|
+
"AR session requires an active Activity; was none attached when start() was called.",
|
|
83
|
+
)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
// ArCoreApk install path — kicks off Play Services for
|
|
88
|
+
// AR install dialog on first call if needed. We call
|
|
89
|
+
// it synchronously from the same Activity each time
|
|
90
|
+
// start() is invoked; subsequent calls are no-ops once
|
|
91
|
+
// installation is complete.
|
|
92
|
+
val installStatus = ArCoreApk.getInstance().requestInstall(activity, true)
|
|
93
|
+
when (installStatus) {
|
|
94
|
+
ArCoreApk.InstallStatus.INSTALL_REQUESTED -> {
|
|
95
|
+
// User was prompted; system will resume our Activity
|
|
96
|
+
// when the dialog returns. Caller should call
|
|
97
|
+
// start() again from onResume().
|
|
98
|
+
trackingStateRef.set(TRACKING_NOT_AVAILABLE)
|
|
99
|
+
promise.resolve(null)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
ArCoreApk.InstallStatus.INSTALLED -> { /* fall through */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
val session = sessionRef.get() ?: Session(reactApplicationContext).also {
|
|
106
|
+
sessionRef.set(it)
|
|
107
|
+
}
|
|
108
|
+
val config = Config(session).apply {
|
|
109
|
+
// Smoothed depth is the ARCore equivalent of iOS
|
|
110
|
+
// sceneDepth — only available on Depth-API-supported
|
|
111
|
+
// devices. Toggle on if available; the resume() call
|
|
112
|
+
// below validates configuration and will reject if
|
|
113
|
+
// the device can't deliver what we asked for.
|
|
114
|
+
if (session.isDepthModeSupported(Config.DepthMode.AUTOMATIC)) {
|
|
115
|
+
depthMode = Config.DepthMode.AUTOMATIC
|
|
116
|
+
}
|
|
117
|
+
// HORIZONTAL_AND_VERTICAL (not VERTICAL-only) — ARCore
|
|
118
|
+
// bootstraps its world model from SfM on whatever planes
|
|
119
|
+
// it can find. Field testing showed VERTICAL-only mode
|
|
120
|
+
// yields trackingV=0 indefinitely on plain walls (the
|
|
121
|
+
// user's Galaxy A35), because ARCore can't establish a
|
|
122
|
+
// gravity-aligned world reference without seeing the
|
|
123
|
+
// floor/desk first. Detecting horizontal planes too
|
|
124
|
+
// gives ARCore the world anchor it needs, which then
|
|
125
|
+
// unblocks vertical plane detection on the shelf wall.
|
|
126
|
+
// We still filter to vertical-only at evaluation time
|
|
127
|
+
// in evaluatePlanesForFrame — the JS shutter-gate only
|
|
128
|
+
// unlocks on a latched VERTICAL plane.
|
|
129
|
+
planeFindingMode = Config.PlaneFindingMode.HORIZONTAL_AND_VERTICAL
|
|
130
|
+
focusMode = Config.FocusMode.AUTO
|
|
131
|
+
lightEstimationMode = Config.LightEstimationMode.DISABLED
|
|
132
|
+
updateMode = Config.UpdateMode.LATEST_CAMERA_IMAGE
|
|
133
|
+
}
|
|
134
|
+
session.configure(config)
|
|
135
|
+
session.resume()
|
|
136
|
+
|
|
137
|
+
clearPoseLogInternal()
|
|
138
|
+
trackingStateRef.set(TRACKING_INITIALISING)
|
|
139
|
+
promise.resolve(null)
|
|
140
|
+
} catch (e: UnavailableException) {
|
|
141
|
+
trackingStateRef.set(TRACKING_NOT_AVAILABLE)
|
|
142
|
+
promise.reject("ar-unavailable", e.message, e)
|
|
143
|
+
} catch (t: Throwable) {
|
|
144
|
+
promise.reject("ar-start-failed", t.message, t)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@ReactMethod
|
|
149
|
+
fun stop(promise: Promise) {
|
|
150
|
+
try {
|
|
151
|
+
sessionRef.getAndSet(null)?.pause()
|
|
152
|
+
trackingStateRef.set(TRACKING_NOT_AVAILABLE)
|
|
153
|
+
clearPoseLogInternal()
|
|
154
|
+
promise.resolve(null)
|
|
155
|
+
} catch (t: Throwable) {
|
|
156
|
+
promise.reject("ar-stop-failed", t.message, t)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Internal lifecycle hooks for the AR camera view ──────────────────
|
|
161
|
+
//
|
|
162
|
+
// Mirror of iOS' `RNSARSession.shared.start()` /
|
|
163
|
+
// `.stop()` calls from `RNSARCameraView.didMoveToWindow`.
|
|
164
|
+
// The Promise-based `start(promise)` / `stop(promise)` above
|
|
165
|
+
// remain the canonical JS-facing API; these synchronous twins
|
|
166
|
+
// exist so the native view can self-bootstrap its session
|
|
167
|
+
// without round-tripping through the JS bridge.
|
|
168
|
+
//
|
|
169
|
+
// Key differences vs the Promise variants:
|
|
170
|
+
// - No Promise (return a Boolean from `startForView`;
|
|
171
|
+
// `stopForView` is fire-and-forget).
|
|
172
|
+
// - Errors are LOGGED, not thrown. Failure leaves the view
|
|
173
|
+
// in its cleared-black state; the user can recover via
|
|
174
|
+
// navigating away + back, or via a future explicit start().
|
|
175
|
+
// - `startForView` does NOT clear the pose log. Pose log is
|
|
176
|
+
// host-controlled (per iOS comment in didMoveToWindow:
|
|
177
|
+
// "Don't clear the pose log here; the host explicitly
|
|
178
|
+
// clears between captures via clearPoseLog()").
|
|
179
|
+
// - Both methods are idempotent. Multiple ARCameraView
|
|
180
|
+
// instances mounting/unmounting concurrently won't race
|
|
181
|
+
// destructively (AtomicReference does the sequencing).
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Ensure the AR session is running. Called from
|
|
185
|
+
* [RNSARCameraView.onAttachedToWindow]. Returns true
|
|
186
|
+
* iff a session is now running.
|
|
187
|
+
*
|
|
188
|
+
* Return-value semantics:
|
|
189
|
+
* - true: session is now (or was already) running. Caller
|
|
190
|
+
* can immediately borrow it via `getSessionForView()`.
|
|
191
|
+
* - false: session is NOT running. Possible reasons:
|
|
192
|
+
* * no current Activity attached
|
|
193
|
+
* * ARCore install dialog was shown (INSTALL_REQUESTED) —
|
|
194
|
+
* caller should expect a follow-up onAttachedToWindow
|
|
195
|
+
* after the user returns from the install flow
|
|
196
|
+
* * ARCore reports the device unsupported or transient
|
|
197
|
+
* unavailable
|
|
198
|
+
* * configure / resume threw — see logcat for the cause
|
|
199
|
+
*
|
|
200
|
+
* Threading: must be called on the main thread (ARCore's
|
|
201
|
+
* `Session.resume()` requires it). `onAttachedToWindow` is
|
|
202
|
+
* guaranteed to be on the main thread, so callers don't need
|
|
203
|
+
* to hop queues.
|
|
204
|
+
*/
|
|
205
|
+
internal fun startForView(): Boolean {
|
|
206
|
+
// Fast path: session already running.
|
|
207
|
+
sessionRef.get()?.let {
|
|
208
|
+
Log.i(TAG, "startForView: session already running")
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
val activity: Activity = reactApplicationContext.currentActivity ?: run {
|
|
212
|
+
Log.w(
|
|
213
|
+
TAG,
|
|
214
|
+
"startForView: no current Activity; deferring AR start " +
|
|
215
|
+
"(view will retry on next onAttachedToWindow)",
|
|
216
|
+
)
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
return try {
|
|
220
|
+
// ArCoreApk install path — shows the Play Services for
|
|
221
|
+
// AR install dialog on first call if the user doesn't
|
|
222
|
+
// have it. Subsequent calls return INSTALLED quickly.
|
|
223
|
+
when (ArCoreApk.getInstance().requestInstall(activity, true)) {
|
|
224
|
+
ArCoreApk.InstallStatus.INSTALL_REQUESTED -> {
|
|
225
|
+
Log.i(
|
|
226
|
+
TAG,
|
|
227
|
+
"startForView: ARCore install prompt shown; " +
|
|
228
|
+
"will retry on next view attach",
|
|
229
|
+
)
|
|
230
|
+
trackingStateRef.set(TRACKING_NOT_AVAILABLE)
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
ArCoreApk.InstallStatus.INSTALLED -> { /* fall through */ }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
val session = Session(reactApplicationContext).also {
|
|
237
|
+
sessionRef.set(it)
|
|
238
|
+
}
|
|
239
|
+
val config = Config(session).apply {
|
|
240
|
+
if (session.isDepthModeSupported(Config.DepthMode.AUTOMATIC)) {
|
|
241
|
+
depthMode = Config.DepthMode.AUTOMATIC
|
|
242
|
+
}
|
|
243
|
+
// HORIZONTAL_AND_VERTICAL (not VERTICAL-only) — ARCore
|
|
244
|
+
// bootstraps its world model from SfM on whatever planes
|
|
245
|
+
// it can find. Field testing showed VERTICAL-only mode
|
|
246
|
+
// yields trackingV=0 indefinitely on plain walls (the
|
|
247
|
+
// user's Galaxy A35), because ARCore can't establish a
|
|
248
|
+
// gravity-aligned world reference without seeing the
|
|
249
|
+
// floor/desk first. Detecting horizontal planes too
|
|
250
|
+
// gives ARCore the world anchor it needs, which then
|
|
251
|
+
// unblocks vertical plane detection on the shelf wall.
|
|
252
|
+
// We still filter to vertical-only at evaluation time
|
|
253
|
+
// in evaluatePlanesForFrame — the JS shutter-gate only
|
|
254
|
+
// unlocks on a latched VERTICAL plane.
|
|
255
|
+
planeFindingMode = Config.PlaneFindingMode.HORIZONTAL_AND_VERTICAL
|
|
256
|
+
focusMode = Config.FocusMode.AUTO
|
|
257
|
+
lightEstimationMode = Config.LightEstimationMode.DISABLED
|
|
258
|
+
updateMode = Config.UpdateMode.LATEST_CAMERA_IMAGE
|
|
259
|
+
}
|
|
260
|
+
session.configure(config)
|
|
261
|
+
session.resume()
|
|
262
|
+
|
|
263
|
+
trackingStateRef.set(TRACKING_INITIALISING)
|
|
264
|
+
Log.i(TAG, "startForView: AR session started successfully")
|
|
265
|
+
true
|
|
266
|
+
} catch (e: UnavailableException) {
|
|
267
|
+
Log.w(TAG, "startForView: AR unavailable: ${e.message}", e)
|
|
268
|
+
trackingStateRef.set(TRACKING_NOT_AVAILABLE)
|
|
269
|
+
sessionRef.set(null)
|
|
270
|
+
false
|
|
271
|
+
} catch (t: Throwable) {
|
|
272
|
+
Log.w(TAG, "startForView: unexpected failure: ${t.message}", t)
|
|
273
|
+
sessionRef.set(null)
|
|
274
|
+
false
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Pause + release the AR session. Called from
|
|
280
|
+
* [RNSARCameraView.onDetachedFromWindow]. Frees the
|
|
281
|
+
* hardware camera so other consumers (vision-camera, packaged
|
|
282
|
+
* camera app via picker, etc.) can claim it.
|
|
283
|
+
*
|
|
284
|
+
* Does NOT clear the pose log — see iOS parity comment above.
|
|
285
|
+
*/
|
|
286
|
+
internal fun stopForView() {
|
|
287
|
+
try {
|
|
288
|
+
val prev = sessionRef.getAndSet(null)
|
|
289
|
+
if (prev == null) {
|
|
290
|
+
// Nothing to stop — view detached without a session.
|
|
291
|
+
// Common on first-attach failures (no Activity, etc.).
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
prev.pause()
|
|
295
|
+
trackingStateRef.set(TRACKING_NOT_AVAILABLE)
|
|
296
|
+
Log.i(TAG, "stopForView: AR session paused")
|
|
297
|
+
} catch (t: Throwable) {
|
|
298
|
+
Log.w(TAG, "stopForView: pause failed: ${t.message}", t)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@ReactMethod
|
|
303
|
+
fun getState(promise: Promise) {
|
|
304
|
+
val map = Arguments.createMap()
|
|
305
|
+
map.putBoolean("isRunning", sessionRef.get() != null)
|
|
306
|
+
map.putInt("trackingState", trackingStateRef.get())
|
|
307
|
+
promise.resolve(map)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
@ReactMethod
|
|
311
|
+
fun snapshotPoseLog(promise: Promise) {
|
|
312
|
+
val out = Arguments.createArray()
|
|
313
|
+
poseLogLock.read {
|
|
314
|
+
for (pose in poseLog) {
|
|
315
|
+
out.pushMap(pose.toWritableMap())
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
promise.resolve(out)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
@ReactMethod
|
|
322
|
+
fun clearPoseLog(promise: Promise) {
|
|
323
|
+
clearPoseLogInternal()
|
|
324
|
+
promise.resolve(null)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Phase 5 (Android parity) — AR-backed photo + video capture ──
|
|
328
|
+
//
|
|
329
|
+
// iOS exposes takePhoto / startRecording / stopRecording on
|
|
330
|
+
// RNSARSession.shared. These are the matching @ReactMethods.
|
|
331
|
+
//
|
|
332
|
+
// For `takePhoto`, the actual frame capture happens on the GL
|
|
333
|
+
// render thread inside RNSARCameraView (because ARCore Frame
|
|
334
|
+
// objects can't be safely accessed from arbitrary threads).
|
|
335
|
+
// We delegate via the bound camera view; the view's
|
|
336
|
+
// `requestTakePhoto` stores the request, the next render tick
|
|
337
|
+
// consumes it.
|
|
338
|
+
//
|
|
339
|
+
// startRecording / stopRecording are stubbed pending Android
|
|
340
|
+
// AVAssetWriter equivalent (MediaRecorder + Surface ingest from
|
|
341
|
+
// the GL background renderer). Until that lands they reject
|
|
342
|
+
// with a clear "not yet supported" message — better than the
|
|
343
|
+
// generic "method not found" the bridge would otherwise emit.
|
|
344
|
+
@ReactMethod
|
|
345
|
+
fun takePhoto(options: com.facebook.react.bridge.ReadableMap, promise: Promise) {
|
|
346
|
+
val view = attachedView
|
|
347
|
+
if (view == null) {
|
|
348
|
+
promise.reject(
|
|
349
|
+
"ar-photo-no-view",
|
|
350
|
+
"takePhoto: no RNSARCameraView is currently bound — mount the AR camera view first.",
|
|
351
|
+
)
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
val rawPath = if (options.hasKey("path")) options.getString("path") ?: "" else ""
|
|
355
|
+
val quality = if (options.hasKey("quality")) options.getInt("quality") else 90
|
|
356
|
+
val resolvedPath: String = if (rawPath.isNotEmpty()) {
|
|
357
|
+
rawPath
|
|
358
|
+
} else {
|
|
359
|
+
val tmpDir = reactApplicationContext.cacheDir
|
|
360
|
+
java.io.File(
|
|
361
|
+
tmpDir,
|
|
362
|
+
"RNImageStitcher-ar-${java.util.UUID.randomUUID()}.jpg",
|
|
363
|
+
).absolutePath
|
|
364
|
+
}
|
|
365
|
+
view.requestTakePhoto(resolvedPath, quality, promise)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
@ReactMethod
|
|
369
|
+
fun startRecording(options: com.facebook.react.bridge.ReadableMap, promise: Promise) {
|
|
370
|
+
promise.reject(
|
|
371
|
+
"ar-recording-unsupported-android",
|
|
372
|
+
"startRecording is not yet implemented on Android. Use the photo capture path " +
|
|
373
|
+
"(takePhoto) or the non-AR sweep-video recorder (via vision-camera). Tracking " +
|
|
374
|
+
"issue: react-native-image-stitcher#android-ar-video.",
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
@ReactMethod
|
|
379
|
+
fun stopRecording(promise: Promise) {
|
|
380
|
+
promise.reject(
|
|
381
|
+
"ar-recording-unsupported-android",
|
|
382
|
+
"stopRecording is not yet implemented on Android (see startRecording).",
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Internal entry point used by the (Phase 4.4) AR-backed
|
|
388
|
+
* camera view to push a fresh pose into the log. Called on
|
|
389
|
+
* the GL render thread once per frame. Bounded by
|
|
390
|
+
* MAX_POSE_LOG.
|
|
391
|
+
*/
|
|
392
|
+
internal fun appendPose(pose: RNSARFramePose) {
|
|
393
|
+
poseLogLock.write {
|
|
394
|
+
poseLog.add(pose)
|
|
395
|
+
if (poseLog.size > MAX_POSE_LOG) {
|
|
396
|
+
val drop = poseLog.size - MAX_POSE_LOG
|
|
397
|
+
repeat(drop) { poseLog.removeAt(0) }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Find the pose closest to `targetMs` (timestamps in ms since
|
|
404
|
+
* session start), within `maxToleranceMs`.
|
|
405
|
+
*/
|
|
406
|
+
internal fun poseClosestTo(targetMs: Double, maxToleranceMs: Double = 50.0): RNSARFramePose? {
|
|
407
|
+
var best: RNSARFramePose? = null
|
|
408
|
+
var bestDelta = Double.POSITIVE_INFINITY
|
|
409
|
+
poseLogLock.read {
|
|
410
|
+
for (p in poseLog) {
|
|
411
|
+
val d = Math.abs(p.timestampMs - targetMs)
|
|
412
|
+
if (d < bestDelta) {
|
|
413
|
+
bestDelta = d
|
|
414
|
+
best = p
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return if (bestDelta > maxToleranceMs) null else best
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Update tracking state — called by the camera view as ARCore
|
|
423
|
+
* reports tracking changes.
|
|
424
|
+
*/
|
|
425
|
+
internal fun updateTrackingState(arState: TrackingState) {
|
|
426
|
+
val mapped = when (arState) {
|
|
427
|
+
TrackingState.TRACKING -> TRACKING_TRACKING
|
|
428
|
+
TrackingState.PAUSED -> TRACKING_LIMITED
|
|
429
|
+
TrackingState.STOPPED -> TRACKING_NOT_AVAILABLE
|
|
430
|
+
}
|
|
431
|
+
trackingStateRef.set(mapped)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── V15.0e — Vertical plane detection (iOS parity) ────────────────
|
|
435
|
+
//
|
|
436
|
+
// Mirror of iOS' RNSARSession.swift planar-detection state +
|
|
437
|
+
// relatchPlaneFromCurrentAnchors algorithm. iOS runs evaluation
|
|
438
|
+
// continuously via ARKit's ARSessionDelegate didUpdate callbacks;
|
|
439
|
+
// ARCore on Android exposes per-frame plane trackables only from
|
|
440
|
+
// session.update() (which the camera view drives). We therefore
|
|
441
|
+
// run evaluatePlanesForFrame() from RNSARCameraView.onDrawFrame.
|
|
442
|
+
//
|
|
443
|
+
// State is read by JS via getARPlaneStatus() at 2 Hz; the shutter
|
|
444
|
+
// gate in AuditCaptureScreen.tsx (planeShutterGate) flips to enabled
|
|
445
|
+
// when status == "ready".
|
|
446
|
+
//
|
|
447
|
+
// The algorithm:
|
|
448
|
+
// 1. Iterate all currently-tracking VERTICAL planes.
|
|
449
|
+
// 2. Skip subsumed planes (ARCore merges overlapping planes into
|
|
450
|
+
// a larger one; the subsumed-by reference flags the merged-into
|
|
451
|
+
// child).
|
|
452
|
+
// 3. Compute alignment = |planeNormal · cameraForward| — must
|
|
453
|
+
// exceed planeAlignmentThreshold (default 0.6 ≈ 53° max
|
|
454
|
+
// off-camera) to be considered.
|
|
455
|
+
// 4. Reject planes with area < MIN_PLANE_AREA_M2 (0.20 m²) —
|
|
456
|
+
// these are typically ARCore artifacts (sign edges, reflective
|
|
457
|
+
// patches) that briefly fit but aren't real scan targets.
|
|
458
|
+
// 5. Among passing planes, pick the CLOSEST by perpendicular
|
|
459
|
+
// distance — closest plane is most likely the foreground scan
|
|
460
|
+
// target (the V15.0g.3 heuristic on iOS).
|
|
461
|
+
// 6. Track the best REJECTED alignment so the JS UI can show
|
|
462
|
+
// "found plane but off-axis (best 0.45)" guidance.
|
|
463
|
+
|
|
464
|
+
/// Minimum plane area to be considered for latching.
|
|
465
|
+
/// Matches iOS' kMinPlaneArea in RNSARSession.swift.
|
|
466
|
+
private val minPlaneAreaM2: Float = 0.20f // 0.45m × 0.45m
|
|
467
|
+
|
|
468
|
+
/// V15.0d — minimum |planeNormal · cameraForward| for a plane to
|
|
469
|
+
/// be eligible for latching. Tunable from JS via the existing
|
|
470
|
+
/// `arkitPlaneAlignmentThreshold` setting in the panorama config.
|
|
471
|
+
/// Range [0, 1]: 0 = accept any vertical plane; 1 = only accept
|
|
472
|
+
/// perfectly camera-facing planes. Default 0.6 (≈ 53°).
|
|
473
|
+
@Volatile var planeAlignmentThreshold: Float = 0.6f
|
|
474
|
+
|
|
475
|
+
/// V15.0e — best alignment seen on a candidate plane that was
|
|
476
|
+
/// REJECTED by the alignment filter. -1.0 = no candidate seen
|
|
477
|
+
/// yet. Drives the "evaluating" UI state in getARPlaneStatus().
|
|
478
|
+
/// Read on the JS thread, written on the GL render thread.
|
|
479
|
+
@Volatile private var bestRejectedAlignment: Float = -1.0f
|
|
480
|
+
|
|
481
|
+
/// Pose of the currently latched plane (or null if no lock). We
|
|
482
|
+
/// store the centerPose (not the Plane reference) so the value is
|
|
483
|
+
/// stable across the plane being subsumed or losing tracking — the
|
|
484
|
+
/// pose snapshot is what the stitcher cares about anyway.
|
|
485
|
+
/// Read on the JS thread, written on the GL render thread.
|
|
486
|
+
@Volatile private var latchedPlanePose: Pose? = null
|
|
487
|
+
|
|
488
|
+
/// Public read-only view of the latch status, used by hasPlaneDetected
|
|
489
|
+
/// getter and by the bridge method. iOS parity: hasPlaneDetected
|
|
490
|
+
/// is a getter that reads detectedPlaneTransformInternal.
|
|
491
|
+
internal val hasPlaneDetected: Boolean
|
|
492
|
+
get() = latchedPlanePose != null
|
|
493
|
+
|
|
494
|
+
/// Returns the latched plane transform as a Pose, or null if no
|
|
495
|
+
/// plane is currently latched. Used by the slit-scan engine
|
|
496
|
+
/// (when ported) for plane-projected stitching.
|
|
497
|
+
internal val latchedPlaneTransform: Pose?
|
|
498
|
+
get() = latchedPlanePose
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Per-frame plane evaluation — called from
|
|
502
|
+
* [RNSARCameraView.onDrawFrame] AFTER session.update().
|
|
503
|
+
*
|
|
504
|
+
* Mirrors iOS' RNSARSession.swift::relatchPlaneFromCurrentAnchors,
|
|
505
|
+
* but runs every frame (ARKit re-runs internally on iOS; we mirror
|
|
506
|
+
* by polling every ARCore frame at ~60 Hz). Continuous evaluation
|
|
507
|
+
* means the JS 2 Hz getARPlaneStatus poll sees a live answer
|
|
508
|
+
* without the user having to press anything.
|
|
509
|
+
*
|
|
510
|
+
* @param cameraForwardWorld unit vector pointing where the camera
|
|
511
|
+
* is looking, in world space (precomputed
|
|
512
|
+
* by the camera view — usually -zAxis of
|
|
513
|
+
* the camera pose).
|
|
514
|
+
* @param cameraPosWorld camera origin in world space (translation
|
|
515
|
+
* component of the camera pose).
|
|
516
|
+
*/
|
|
517
|
+
/**
|
|
518
|
+
* Rate-limit diagnostic logging — log at most once every N frames
|
|
519
|
+
* so we don't flood logcat at 60 Hz. 30 = ~2 logs per second.
|
|
520
|
+
*/
|
|
521
|
+
private var planeEvalLogTick: Int = 0
|
|
522
|
+
private val planeEvalLogStride: Int = 30
|
|
523
|
+
|
|
524
|
+
internal fun evaluatePlanesForFrame(
|
|
525
|
+
cameraForwardWorld: FloatArray,
|
|
526
|
+
cameraPosWorld: FloatArray,
|
|
527
|
+
) {
|
|
528
|
+
val session = sessionRef.get() ?: return
|
|
529
|
+
|
|
530
|
+
var bestPlane: Plane? = null
|
|
531
|
+
var bestPerpDist = Float.POSITIVE_INFINITY
|
|
532
|
+
var bestAlignment = -1.0f
|
|
533
|
+
var thisFrameBestRejected = -1.0f
|
|
534
|
+
// Diagnostic counters — surfaced via the rate-limited log
|
|
535
|
+
// below so field testing can see WHY plane detection is slow.
|
|
536
|
+
var seenVertical = 0
|
|
537
|
+
var seenHorizontal = 0
|
|
538
|
+
var seenSubsumed = 0
|
|
539
|
+
var seenNotTracking = 0
|
|
540
|
+
var rejectedAlignment = 0
|
|
541
|
+
var rejectedArea = 0
|
|
542
|
+
|
|
543
|
+
for (plane in session.getAllTrackables(Plane::class.java)) {
|
|
544
|
+
if (plane.trackingState != TrackingState.TRACKING) {
|
|
545
|
+
seenNotTracking++
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
548
|
+
// Skip planes that were merged into a parent — ARCore keeps
|
|
549
|
+
// both alive but only the parent is the real geometry.
|
|
550
|
+
if (plane.subsumedBy != null) {
|
|
551
|
+
seenSubsumed++
|
|
552
|
+
continue
|
|
553
|
+
}
|
|
554
|
+
// Count by type for diagnostic visibility.
|
|
555
|
+
when (plane.type) {
|
|
556
|
+
Plane.Type.VERTICAL -> seenVertical++
|
|
557
|
+
Plane.Type.HORIZONTAL_UPWARD_FACING,
|
|
558
|
+
Plane.Type.HORIZONTAL_DOWNWARD_FACING -> seenHorizontal++
|
|
559
|
+
else -> { /* none */ }
|
|
560
|
+
}
|
|
561
|
+
// Vertical only — shelf-scanning use case.
|
|
562
|
+
if (plane.type != Plane.Type.VERTICAL) continue
|
|
563
|
+
|
|
564
|
+
// ARCore convention: plane.centerPose's Y axis is the plane
|
|
565
|
+
// normal (the plane lies in the X-Z plane of its pose).
|
|
566
|
+
// ARCore 1.45's Pose.getYAxis() returns a new FloatArray —
|
|
567
|
+
// no two-arg fill-buffer overload in this version.
|
|
568
|
+
val normal = plane.centerPose.yAxis
|
|
569
|
+
|
|
570
|
+
val alignment = abs(
|
|
571
|
+
normal[0] * cameraForwardWorld[0]
|
|
572
|
+
+ normal[1] * cameraForwardWorld[1]
|
|
573
|
+
+ normal[2] * cameraForwardWorld[2]
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if (alignment < planeAlignmentThreshold) {
|
|
577
|
+
if (alignment > thisFrameBestRejected) {
|
|
578
|
+
thisFrameBestRejected = alignment
|
|
579
|
+
}
|
|
580
|
+
rejectedAlignment++
|
|
581
|
+
continue
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// extentX and extentZ are the plane size along the local X
|
|
585
|
+
// and Z axes (Y is the normal).
|
|
586
|
+
val area = plane.extentX * plane.extentZ
|
|
587
|
+
if (area < minPlaneAreaM2) {
|
|
588
|
+
rejectedArea++
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Perpendicular distance from camera to plane:
|
|
593
|
+
// |(planeCenter - cameraPos) · planeNormal|
|
|
594
|
+
// Lower = closer. Closer wins (V15.0g.3 heuristic).
|
|
595
|
+
val center = plane.centerPose
|
|
596
|
+
val dx = center.tx() - cameraPosWorld[0]
|
|
597
|
+
val dy = center.ty() - cameraPosWorld[1]
|
|
598
|
+
val dz = center.tz() - cameraPosWorld[2]
|
|
599
|
+
val perpDist = abs(dx * normal[0] + dy * normal[1] + dz * normal[2])
|
|
600
|
+
|
|
601
|
+
if (perpDist < bestPerpDist) {
|
|
602
|
+
bestPlane = plane
|
|
603
|
+
bestPerpDist = perpDist
|
|
604
|
+
bestAlignment = alignment
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Publish atomic state changes. Latching: a plane found this
|
|
609
|
+
// frame replaces any prior latch. Once latched, a subsequent
|
|
610
|
+
// frame with NO eligible planes does NOT un-latch (matches iOS
|
|
611
|
+
// behaviour — once a plane is locked, the user can pan around
|
|
612
|
+
// and we keep the lock until they explicitly relatch).
|
|
613
|
+
if (bestPlane != null) {
|
|
614
|
+
latchedPlanePose = bestPlane.centerPose
|
|
615
|
+
}
|
|
616
|
+
bestRejectedAlignment = thisFrameBestRejected
|
|
617
|
+
|
|
618
|
+
// ── Rate-limited diagnostic log ──────────────────────────────
|
|
619
|
+
// Surfaces WHY plane detection is or isn't latching. Logs
|
|
620
|
+
// once every planeEvalLogStride frames (default ~2 Hz). Field
|
|
621
|
+
// testing protocol: tail logcat with `adb logcat -s
|
|
622
|
+
// RNSARSession:V`, navigate to AuditCapture, watch this
|
|
623
|
+
// tick to understand whether ARCore is finding planes at all,
|
|
624
|
+
// and if it is, why they're being rejected.
|
|
625
|
+
if (planeEvalLogTick++ % planeEvalLogStride == 0) {
|
|
626
|
+
val latched = latchedPlanePose != null
|
|
627
|
+
// Read ARCore camera tracking state — updated each frame
|
|
628
|
+
// by the view's appendPose path. TRACKING=2 means ARCore
|
|
629
|
+
// has a confident world model; LIMITED=3 means it's still
|
|
630
|
+
// bootstrapping; NOT_AVAILABLE=0 means the session is
|
|
631
|
+
// initialising. Plane detection ONLY happens once
|
|
632
|
+
// tracking == TRACKING.
|
|
633
|
+
val trackingStateInt = trackingStateRef.get()
|
|
634
|
+
val trackingLabel = when (trackingStateInt) {
|
|
635
|
+
TRACKING_TRACKING -> "TRACKING"
|
|
636
|
+
TRACKING_LIMITED -> "LIMITED"
|
|
637
|
+
TRACKING_INITIALISING -> "INITIALISING"
|
|
638
|
+
TRACKING_NOT_AVAILABLE -> "NOT_AVAILABLE"
|
|
639
|
+
else -> "UNKNOWN($trackingStateInt)"
|
|
640
|
+
}
|
|
641
|
+
Log.i(
|
|
642
|
+
TAG,
|
|
643
|
+
"evaluatePlanes: " +
|
|
644
|
+
"track=$trackingLabel " +
|
|
645
|
+
"vert=$seenVertical " +
|
|
646
|
+
"horiz=$seenHorizontal " +
|
|
647
|
+
"notTracking=$seenNotTracking " +
|
|
648
|
+
"subsumed=$seenSubsumed " +
|
|
649
|
+
"rejAlign=$rejectedAlignment " +
|
|
650
|
+
"rejArea=$rejectedArea " +
|
|
651
|
+
"bestThisAlign=${"%.2f".format(bestAlignment)} " +
|
|
652
|
+
"bestRejAlign=${"%.2f".format(thisFrameBestRejected)} " +
|
|
653
|
+
"thresh=${"%.2f".format(planeAlignmentThreshold)} " +
|
|
654
|
+
"latched=$latched",
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Force-clear the latch and best-rejected. Called by
|
|
661
|
+
* relatchARPlane bridge method — next ARCore frame's
|
|
662
|
+
* evaluatePlanesForFrame will re-populate.
|
|
663
|
+
*/
|
|
664
|
+
internal fun clearPlaneLatch() {
|
|
665
|
+
latchedPlanePose = null
|
|
666
|
+
bestRejectedAlignment = -1.0f
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ── Helpers consumed by IncrementalStitcher's @ReactMethod ─
|
|
670
|
+
//
|
|
671
|
+
// iOS exposes getARPlaneStatus / relatchARPlane on the JS module
|
|
672
|
+
// `IncrementalStitcher` (the IncrementalStitcherBridge —
|
|
673
|
+
// see iOS IncrementalStitcherBridge.swift); both methods delegate
|
|
674
|
+
// to `RNSARSession.shared`. We mirror that JS-callable
|
|
675
|
+
// surface: the `@ReactMethod` versions live on
|
|
676
|
+
// IncrementalStitcher.kt and call these helpers. This
|
|
677
|
+
// keeps JS unchanged across platforms (it calls
|
|
678
|
+
// `NativeIncrementalModule.getARPlaneStatus()`, not
|
|
679
|
+
// `RNSARSession.getARPlaneStatus()`).
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Build the plane-status payload — caller resolves the Promise.
|
|
683
|
+
* Shape MUST match iOS' `getARPlaneStatus()` exactly so the JS
|
|
684
|
+
* TypeScript interface ARPlaneStatus is satisfied identically.
|
|
685
|
+
*/
|
|
686
|
+
internal fun buildARPlaneStatusMap(): com.facebook.react.bridge.WritableMap {
|
|
687
|
+
val hasPlane = latchedPlanePose != null
|
|
688
|
+
val rejected = bestRejectedAlignment
|
|
689
|
+
val status = when {
|
|
690
|
+
hasPlane -> "ready"
|
|
691
|
+
rejected > 0.0f -> "evaluating"
|
|
692
|
+
else -> "searching"
|
|
693
|
+
}
|
|
694
|
+
val map = Arguments.createMap()
|
|
695
|
+
map.putString("status", status)
|
|
696
|
+
map.putBoolean("hasPlane", hasPlane)
|
|
697
|
+
map.putDouble("bestAlignment", rejected.toDouble())
|
|
698
|
+
map.putDouble("threshold", planeAlignmentThreshold.toDouble())
|
|
699
|
+
return map
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/// Used by `RNSARCameraView` to borrow the underlying
|
|
703
|
+
/// ARCore Session for rendering + per-frame `update()`. Returns
|
|
704
|
+
/// null when the session hasn't been started yet (the view will
|
|
705
|
+
/// retry on the next render frame).
|
|
706
|
+
internal fun getSessionForView(): Session? = sessionRef.get()
|
|
707
|
+
|
|
708
|
+
/// Camera view registers + unregisters itself so the bridge can
|
|
709
|
+
/// keep track of who's actively rendering. Currently used only
|
|
710
|
+
/// for diagnostics (the view feeds frames into the engine via
|
|
711
|
+
/// the bridge module's static reference, no fan-out needed yet).
|
|
712
|
+
@Volatile private var attachedView: RNSARCameraView? = null
|
|
713
|
+
|
|
714
|
+
internal fun bindCameraView(view: RNSARCameraView) {
|
|
715
|
+
attachedView = view
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
internal fun unbindCameraView(view: RNSARCameraView) {
|
|
719
|
+
if (attachedView === view) attachedView = null
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private fun clearPoseLogInternal() {
|
|
723
|
+
poseLogLock.write { poseLog.clear() }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
companion object {
|
|
727
|
+
// Mirrors RNSARTrackingState on iOS for cross-platform
|
|
728
|
+
// identical JS behaviour.
|
|
729
|
+
const val TRACKING_NOT_AVAILABLE = 0
|
|
730
|
+
const val TRACKING_INITIALISING = 1
|
|
731
|
+
const val TRACKING_TRACKING = 2
|
|
732
|
+
const val TRACKING_LIMITED = 3
|
|
733
|
+
|
|
734
|
+
private const val TAG = "RNSARSession"
|
|
735
|
+
private const val MAX_POSE_LOG = 600 // ~10 s @ 60Hz
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Convenience accessor for the AR camera view (in Phase 4.4)
|
|
739
|
+
* to reach the singleton-installed module instance. We use
|
|
740
|
+
* a static accessor rather than dependency injection because
|
|
741
|
+
* the AR camera view is constructed by RN's view manager,
|
|
742
|
+
* which doesn't have easy access to the bridge module
|
|
743
|
+
* registry.
|
|
744
|
+
*/
|
|
745
|
+
@JvmStatic
|
|
746
|
+
@Volatile
|
|
747
|
+
var instance: RNSARSession? = null
|
|
748
|
+
private set
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
init {
|
|
752
|
+
// Singleton-style: keep the most recently-constructed
|
|
753
|
+
// instance accessible to the AR camera view. RN may
|
|
754
|
+
// reconstruct modules across reloads; the AR camera view
|
|
755
|
+
// always uses the latest reference.
|
|
756
|
+
instance = this
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Plain data class for a single frame's pose. Mirror of iOS'
|
|
763
|
+
* `RNSARFramePose`; same JSON shape so the JS bridge sees
|
|
764
|
+
* identical data on both platforms.
|
|
765
|
+
*/
|
|
766
|
+
internal data class RNSARFramePose(
|
|
767
|
+
val tx: Double, val ty: Double, val tz: Double,
|
|
768
|
+
val qx: Double, val qy: Double, val qz: Double, val qw: Double,
|
|
769
|
+
val fx: Double, val fy: Double, val cx: Double, val cy: Double,
|
|
770
|
+
val imageWidth: Int, val imageHeight: Int,
|
|
771
|
+
val timestampMs: Double,
|
|
772
|
+
val trackingState: Int,
|
|
773
|
+
) {
|
|
774
|
+
fun toWritableMap(): com.facebook.react.bridge.WritableMap {
|
|
775
|
+
val m = com.facebook.react.bridge.Arguments.createMap()
|
|
776
|
+
m.putDouble("tx", tx); m.putDouble("ty", ty); m.putDouble("tz", tz)
|
|
777
|
+
m.putDouble("qx", qx); m.putDouble("qy", qy); m.putDouble("qz", qz); m.putDouble("qw", qw)
|
|
778
|
+
m.putDouble("fx", fx); m.putDouble("fy", fy); m.putDouble("cx", cx); m.putDouble("cy", cy)
|
|
779
|
+
m.putInt("imageWidth", imageWidth); m.putInt("imageHeight", imageHeight)
|
|
780
|
+
m.putDouble("timestampMs", timestampMs)
|
|
781
|
+
m.putInt("trackingState", trackingState)
|
|
782
|
+
return m
|
|
783
|
+
}
|
|
784
|
+
}
|