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.
Files changed (151) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +21 -0
  4. package/README.md +189 -0
  5. package/RNImageStitcher.podspec +76 -0
  6. package/android/build.gradle +224 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/CMakeLists.txt +124 -0
  9. package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
  10. package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
  11. package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
  12. package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
  13. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
  14. package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
  15. package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
  16. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
  17. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
  18. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
  19. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
  20. package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
  21. package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
  22. package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
  23. package/cpp/ar_frame_pose.h +63 -0
  24. package/cpp/keyframe_gate.cpp +927 -0
  25. package/cpp/keyframe_gate.hpp +240 -0
  26. package/cpp/stitcher.cpp +2207 -0
  27. package/cpp/stitcher.hpp +275 -0
  28. package/dist/ar/useARSession.d.ts +102 -0
  29. package/dist/ar/useARSession.js +133 -0
  30. package/dist/camera/ARCameraView.d.ts +93 -0
  31. package/dist/camera/ARCameraView.js +170 -0
  32. package/dist/camera/Camera.d.ts +134 -0
  33. package/dist/camera/Camera.js +688 -0
  34. package/dist/camera/CameraShutter.d.ts +80 -0
  35. package/dist/camera/CameraShutter.js +237 -0
  36. package/dist/camera/CameraView.d.ts +65 -0
  37. package/dist/camera/CameraView.js +117 -0
  38. package/dist/camera/CaptureControlsBar.d.ts +87 -0
  39. package/dist/camera/CaptureControlsBar.js +82 -0
  40. package/dist/camera/CaptureHeader.d.ts +62 -0
  41. package/dist/camera/CaptureHeader.js +81 -0
  42. package/dist/camera/CapturePreview.d.ts +70 -0
  43. package/dist/camera/CapturePreview.js +188 -0
  44. package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
  45. package/dist/camera/CaptureStatusOverlay.js +326 -0
  46. package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
  47. package/dist/camera/CaptureThumbnailStrip.js +177 -0
  48. package/dist/camera/IncrementalPanGuide.d.ts +83 -0
  49. package/dist/camera/IncrementalPanGuide.js +267 -0
  50. package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
  51. package/dist/camera/PanoramaBandOverlay.js +399 -0
  52. package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
  53. package/dist/camera/PanoramaConfirmModal.js +128 -0
  54. package/dist/camera/PanoramaGuidance.d.ts +79 -0
  55. package/dist/camera/PanoramaGuidance.js +246 -0
  56. package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
  57. package/dist/camera/PanoramaSettingsModal.js +611 -0
  58. package/dist/camera/ViewportCropOverlay.d.ts +46 -0
  59. package/dist/camera/ViewportCropOverlay.js +67 -0
  60. package/dist/camera/useCapture.d.ts +111 -0
  61. package/dist/camera/useCapture.js +160 -0
  62. package/dist/camera/useDeviceOrientation.d.ts +48 -0
  63. package/dist/camera/useDeviceOrientation.js +131 -0
  64. package/dist/camera/useVideoCapture.d.ts +79 -0
  65. package/dist/camera/useVideoCapture.js +151 -0
  66. package/dist/index.d.ts +26 -0
  67. package/dist/index.js +39 -0
  68. package/dist/quality/normaliseOrientation.d.ts +36 -0
  69. package/dist/quality/normaliseOrientation.js +62 -0
  70. package/dist/quality/runQualityCheck.d.ts +41 -0
  71. package/dist/quality/runQualityCheck.js +98 -0
  72. package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
  73. package/dist/sensors/useIMUTranslationGate.js +235 -0
  74. package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
  75. package/dist/stitching/IncrementalStitcherView.js +157 -0
  76. package/dist/stitching/incremental.d.ts +930 -0
  77. package/dist/stitching/incremental.js +133 -0
  78. package/dist/stitching/stitchFrames.d.ts +55 -0
  79. package/dist/stitching/stitchFrames.js +56 -0
  80. package/dist/stitching/stitchVideo.d.ts +119 -0
  81. package/dist/stitching/stitchVideo.js +57 -0
  82. package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
  83. package/dist/stitching/useIncrementalJSDriver.js +199 -0
  84. package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
  85. package/dist/stitching/useIncrementalStitcher.js +172 -0
  86. package/dist/types.d.ts +58 -0
  87. package/dist/types.js +15 -0
  88. package/ios/Package.swift +72 -0
  89. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
  90. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
  91. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
  92. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
  93. package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
  94. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
  95. package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
  96. package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
  97. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
  98. package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
  99. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
  100. package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
  101. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
  102. package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
  103. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
  104. package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
  105. package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
  106. package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
  107. package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
  108. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
  109. package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
  110. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
  111. package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
  112. package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
  113. package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
  114. package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
  115. package/package.json +73 -0
  116. package/react-native.config.js +34 -0
  117. package/scripts/opencv-version.txt +1 -0
  118. package/scripts/postinstall-fetch-binaries.js +286 -0
  119. package/src/ar/useARSession.ts +210 -0
  120. package/src/camera/.gitkeep +0 -0
  121. package/src/camera/ARCameraView.tsx +256 -0
  122. package/src/camera/Camera.tsx +1053 -0
  123. package/src/camera/CameraShutter.tsx +292 -0
  124. package/src/camera/CameraView.tsx +157 -0
  125. package/src/camera/CaptureControlsBar.tsx +204 -0
  126. package/src/camera/CaptureHeader.tsx +184 -0
  127. package/src/camera/CapturePreview.tsx +318 -0
  128. package/src/camera/CaptureStatusOverlay.tsx +391 -0
  129. package/src/camera/CaptureThumbnailStrip.tsx +277 -0
  130. package/src/camera/IncrementalPanGuide.tsx +328 -0
  131. package/src/camera/PanoramaBandOverlay.tsx +498 -0
  132. package/src/camera/PanoramaConfirmModal.tsx +206 -0
  133. package/src/camera/PanoramaGuidance.tsx +327 -0
  134. package/src/camera/PanoramaSettingsModal.tsx +1357 -0
  135. package/src/camera/ViewportCropOverlay.tsx +81 -0
  136. package/src/camera/useCapture.ts +279 -0
  137. package/src/camera/useDeviceOrientation.ts +140 -0
  138. package/src/camera/useVideoCapture.ts +236 -0
  139. package/src/index.ts +53 -0
  140. package/src/quality/.gitkeep +0 -0
  141. package/src/quality/normaliseOrientation.ts +79 -0
  142. package/src/quality/runQualityCheck.ts +131 -0
  143. package/src/sensors/useIMUTranslationGate.ts +347 -0
  144. package/src/stitching/.gitkeep +0 -0
  145. package/src/stitching/IncrementalStitcherView.tsx +198 -0
  146. package/src/stitching/incremental.ts +1021 -0
  147. package/src/stitching/stitchFrames.ts +88 -0
  148. package/src/stitching/stitchVideo.ts +153 -0
  149. package/src/stitching/useIncrementalJSDriver.ts +273 -0
  150. package/src/stitching/useIncrementalStitcher.ts +252 -0
  151. 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
+ }