react-native-image-stitcher 0.19.0 → 0.20.1

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 (31) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/android/src/main/java/io/imagestitcher/rn/AROverlayRenderer.kt +406 -0
  3. package/android/src/main/java/io/imagestitcher/rn/AROverlayStore.kt +441 -0
  4. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +290 -0
  5. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +30 -5
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +68 -0
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +99 -0
  8. package/dist/camera/ARCameraView.d.ts +33 -1
  9. package/dist/camera/ARCameraView.js +33 -2
  10. package/dist/camera/Camera.d.ts +45 -1
  11. package/dist/camera/Camera.js +24 -6
  12. package/dist/camera/arOverlayController.d.ts +52 -0
  13. package/dist/camera/arOverlayController.js +132 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.js +5 -2
  16. package/dist/stitching/AROverlay.d.ts +97 -0
  17. package/dist/stitching/AROverlay.js +4 -0
  18. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +15 -8
  19. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +22 -0
  20. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +14 -0
  21. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +81 -0
  22. package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +37 -0
  23. package/ios/Sources/RNImageStitcher/RNISAROverlay.swift +409 -0
  24. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +301 -3
  25. package/ios/Sources/RNImageStitcher/RNSARSession.swift +70 -65
  26. package/package.json +1 -1
  27. package/src/camera/ARCameraView.tsx +73 -2
  28. package/src/camera/Camera.tsx +71 -2
  29. package/src/camera/arOverlayController.ts +184 -0
  30. package/src/index.ts +15 -0
  31. package/src/stitching/AROverlay.ts +105 -0
@@ -11,7 +11,9 @@ import android.util.Log
11
11
  import android.view.Surface
12
12
  import android.view.WindowManager
13
13
  import android.widget.FrameLayout
14
+ import com.google.ar.core.Anchor
14
15
  import com.google.ar.core.Camera
16
+ import com.google.ar.core.Pose
15
17
  import com.google.ar.core.Session
16
18
  import com.google.ar.core.TrackingState
17
19
  import com.google.ar.core.exceptions.CameraNotAvailableException
@@ -78,6 +80,38 @@ class RNSARCameraView @JvmOverloads constructor(
78
80
  private val backgroundRenderer = BackgroundRenderer()
79
81
  private val sessionRef = AtomicReference<Session?>(null)
80
82
  private var sessionTextureBound = false
83
+
84
+ // ── 0.20.0 — AR overlay/annotation renderer ─────────────────────────
85
+ //
86
+ // [overlayStore] is the single source of truth for the overlays to
87
+ // draw (UNION of JS-set + native-plugin-set; see [AROverlayStore]);
88
+ // [overlayRenderer] is the transparent Canvas View stacked ABOVE the
89
+ // GLSurfaceView in this FrameLayout. Per frame, [onDrawFrame] snapshots
90
+ // the camera view/projection matrices + the letterbox box and pushes
91
+ // them into the renderer (which reprojects + redraws on the UI thread).
92
+ //
93
+ // The store is exposed (via [overlayStore] internal) so the native
94
+ // plugin path ([RNSARPluginRegistry.setOverlays] etc.) and the JS
95
+ // imperative path ([RNSARSession] @ReactMethods → bound view) both write
96
+ // to it; the JS imperative path uses the JS namespace, plugins the
97
+ // plugin namespace.
98
+ val overlayStore = AROverlayStore()
99
+ private val overlayRenderer = AROverlayRenderer(context, overlayStore)
100
+
101
+ // v0.20.0 — one ARCore Anchor per world-anchored overlay (parity with
102
+ // iOS' ARAnchor) so ARCore refines the pose against drift / re-localization
103
+ // instead of trusting a frozen world coordinate. GL-thread only (created /
104
+ // read / detached inside [reconcileOverlayAnchors] from onDrawFrame).
105
+ private val overlayAnchors = HashMap<String, Anchor>()
106
+ // The source world point each anchor was created at — used to detect when
107
+ // an overlay's position changed (JS re-set it) and recreate the anchor.
108
+ private val overlayAnchorSrc = HashMap<String, FloatArray>()
109
+
110
+ // Scratch matrices for the per-frame overlay camera snapshot — reused
111
+ // each frame so the GL thread does no per-frame allocation when overlays
112
+ // are active. ARCore returns COLUMN-MAJOR (OpenGL) matrices.
113
+ private val overlayViewMatrix = FloatArray(16)
114
+ private val overlayProjMatrix = FloatArray(16)
81
115
  /// Last known display rotation; consulted on each setDisplayGeometry
82
116
  /// call so we can recompute when the user rotates the device.
83
117
  private var lastDisplayRotation: Int = -1
@@ -131,6 +165,21 @@ class RNSARCameraView @JvmOverloads constructor(
131
165
  glView.requestRender()
132
166
  }
133
167
 
168
+ /// Pending crosshair raycast (v0.20.0). Fulfilled on the next render
169
+ /// tick with the live ARCore frame — `Frame.hitTest` must run on the GL
170
+ /// thread, so a JS `raycast()` call can't resolve synchronously.
171
+ private val pendingRaycast =
172
+ AtomicReference<com.facebook.react.bridge.Promise?>(null)
173
+
174
+ /// Called from the bridge (RNSARSession.raycast @ReactMethod). Stores a
175
+ /// promise fulfilled on the next render tick by hitTest from the screen
176
+ /// centre. Supersedes any queued raycast.
177
+ internal fun requestRaycast(promise: com.facebook.react.bridge.Promise) {
178
+ val previous = pendingRaycast.getAndSet(promise)
179
+ previous?.resolve(null) // superseded → null (caller falls back)
180
+ glView.requestRender()
181
+ }
182
+
134
183
  init {
135
184
  // Black background avoids a flash before the GL surface starts
136
185
  // clearing itself black each frame (the GL-level letterbox draws
@@ -140,6 +189,14 @@ class RNSARCameraView @JvmOverloads constructor(
140
189
  glView,
141
190
  LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
142
191
  )
192
+ // 0.20.0 — overlay renderer stacked ABOVE the GLSurfaceView (added
193
+ // after glView so it draws on top). Full-screen + transparent; it
194
+ // reprojects world overlays into the same letterbox box the camera
195
+ // feed fills, so its coordinate space matches the preview.
196
+ addView(
197
+ overlayRenderer,
198
+ LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
199
+ )
143
200
  }
144
201
 
145
202
  override fun onAttachedToWindow() {
@@ -215,6 +272,11 @@ class RNSARCameraView @JvmOverloads constructor(
215
272
  // Pause the GL thread so we stop drawing frames.
216
273
  glView.onPause()
217
274
  sessionTextureBound = false
275
+ // 0.20.0 — stop drawing overlays; we drop the camera snapshot so a
276
+ // stale pose isn't reused if the view re-attaches. The overlay SET
277
+ // itself is left intact (the host may keep the same overlays across
278
+ // a remount); only the per-frame camera state is cleared.
279
+ overlayRenderer.clear()
218
280
  IncrementalStitcher.bridgeInstance?.unbindArCameraView(this)
219
281
  RNSARSession.instance?.unbindCameraView(this)
220
282
  // iOS parity (didMoveToWindow else-branch): stop the session
@@ -398,6 +460,18 @@ class RNSARCameraView @JvmOverloads constructor(
398
460
  // contract was already in place for Phase 4.
399
461
  appendPose(camera, frame.timestamp)
400
462
 
463
+ // ── 0.20.0 — feed the overlay renderer this frame's camera ───────
464
+ //
465
+ // Only when overlays exist (cheap AtomicReference emptiness check),
466
+ // snapshot the view + projection matrices and the letterbox box so
467
+ // the overlay View can reproject world points → screen and redraw.
468
+ // Runs every render frame so overlays track at display rate.
469
+ //
470
+ // First reconcile ARCore anchors + publish their drift-corrected poses
471
+ // so the renderer projects the refined positions, not frozen coords.
472
+ reconcileOverlayAnchors(session)
473
+ maybeUpdateOverlayCamera(camera, box)
474
+
401
475
  // Forward to the incremental stitcher when capture is engaged,
402
476
  // OR when an AR frame-processor host worklet is registered (the
403
477
  // v0.8.0 Phase 4b.iii fan-out forwards preview frames whenever
@@ -445,6 +519,65 @@ class RNSARCameraView @JvmOverloads constructor(
445
519
  pendingTakePhoto.getAndSet(null)?.let { req ->
446
520
  fulfilTakePhoto(frame, req)
447
521
  }
522
+
523
+ // raycast consumer (v0.20.0) — same pattern: a JS raycast() request
524
+ // is fulfilled here with the live frame's hitTest from the crosshair.
525
+ pendingRaycast.getAndSet(null)?.let { promise ->
526
+ fulfilRaycast(frame, camera, promise)
527
+ }
528
+ }
529
+
530
+ /// Raycast from the screen-centre crosshair to the nearest real-world
531
+ /// surface; resolve `{ worldPosition: [x,y,z] }` (or null when nothing is
532
+ /// hit / not tracking). Runs on the GL render thread from onDrawFrame.
533
+ /// hitTest coordinates are in the `setDisplayGeometry` space — which we
534
+ /// feed the LETTERBOX BOX — so the crosshair is the box centre. Mirrors
535
+ /// iOS `RNSARSession.raycast`; the JS controller falls back to a fixed
536
+ /// 1 m-ahead point when this resolves null.
537
+ private fun fulfilRaycast(
538
+ frame: com.google.ar.core.Frame,
539
+ camera: Camera,
540
+ promise: com.facebook.react.bridge.Promise,
541
+ ) {
542
+ try {
543
+ if (camera.trackingState != TrackingState.TRACKING) {
544
+ promise.resolve(null)
545
+ return
546
+ }
547
+ val box = letterboxBox()
548
+ val cx = box[2] / 2f // box width / 2
549
+ val cy = box[3] / 2f // box height / 2
550
+ val hits = frame.hitTest(cx, cy)
551
+ // hits are sorted near→far. Prefer a plane hit inside the
552
+ // detected polygon, then a depth / feature point, then any hit.
553
+ val hit = hits.firstOrNull { h ->
554
+ when (val t = h.trackable) {
555
+ is com.google.ar.core.Plane ->
556
+ t.trackingState == TrackingState.TRACKING &&
557
+ t.isPoseInPolygon(h.hitPose)
558
+ is com.google.ar.core.DepthPoint -> true
559
+ is com.google.ar.core.Point -> true
560
+ else -> false
561
+ }
562
+ } ?: hits.firstOrNull()
563
+ if (hit == null) {
564
+ promise.resolve(null)
565
+ return
566
+ }
567
+ val p = hit.hitPose
568
+ val wp = com.facebook.react.bridge.Arguments.createArray().apply {
569
+ pushDouble(p.tx().toDouble())
570
+ pushDouble(p.ty().toDouble())
571
+ pushDouble(p.tz().toDouble())
572
+ }
573
+ val result = com.facebook.react.bridge.Arguments.createMap().apply {
574
+ putArray("worldPosition", wp)
575
+ }
576
+ promise.resolve(result)
577
+ } catch (t: Throwable) {
578
+ Log.w(TAG, "raycast failed: ${t.message}")
579
+ promise.resolve(null)
580
+ }
448
581
  }
449
582
 
450
583
  /// Capture the current ARCore frame to JPEG and resolve / reject
@@ -547,6 +680,163 @@ class RNSARCameraView @JvmOverloads constructor(
547
680
  RNSARSession.instance?.updateTrackingState(camera.trackingState)
548
681
  }
549
682
 
683
+ /// v0.20.0 — keep one ARCore Anchor per world-anchored overlay so ARCore
684
+ /// refines its pose against drift / re-localization (parity with iOS'
685
+ /// ARAnchor). Runs on the GL thread each frame: create anchors for new
686
+ /// overlays, recreate when an overlay's source pose changes, detach removed
687
+ /// ones, then publish the live anchor positions to the renderer (which uses
688
+ /// them instead of the frozen world coordinates). Cheap no-op when there
689
+ /// are neither overlays nor lingering anchors.
690
+ private fun reconcileOverlayAnchors(session: Session) {
691
+ if (overlayStore.isEmpty() && overlayAnchors.isEmpty()) return
692
+
693
+ val overlays = overlayStore.snapshot()
694
+ val live = HashSet<String>(overlays.size)
695
+ val positions = HashMap<String, FloatArray>(overlays.size)
696
+
697
+ for (o in overlays) {
698
+ val target = anchorTarget(o) ?: continue
699
+ live.add(o.id)
700
+ val prev = overlayAnchorSrc[o.id]
701
+ if (prev == null || !prev.contentEquals(target)) {
702
+ // New overlay, or its position changed → (re)create the anchor.
703
+ overlayAnchors.remove(o.id)?.detach()
704
+ val anchor = try {
705
+ session.createAnchor(
706
+ Pose(target, floatArrayOf(0f, 0f, 0f, 1f)),
707
+ )
708
+ } catch (t: Throwable) {
709
+ Log.w(TAG, "createAnchor failed for '${o.id}': ${t.message}")
710
+ null
711
+ }
712
+ if (anchor != null) {
713
+ overlayAnchors[o.id] = anchor
714
+ overlayAnchorSrc[o.id] = target.copyOf()
715
+ } else {
716
+ overlayAnchorSrc.remove(o.id)
717
+ }
718
+ }
719
+ // Publish the live (tracking) anchor pose, else the source pose.
720
+ val a = overlayAnchors[o.id]
721
+ positions[o.id] = if (a != null && a.trackingState == TrackingState.TRACKING) {
722
+ val p = a.pose
723
+ floatArrayOf(p.tx(), p.ty(), p.tz())
724
+ } else {
725
+ overlayAnchorSrc[o.id] ?: target
726
+ }
727
+ }
728
+
729
+ // Detach anchors for overlays that no longer exist.
730
+ if (overlayAnchors.keys.any { it !in live }) {
731
+ for (id in overlayAnchors.keys.filter { it !in live }) {
732
+ overlayAnchors.remove(id)?.detach()
733
+ overlayAnchorSrc.remove(id)
734
+ }
735
+ }
736
+
737
+ overlayRenderer.setAnchorPositions(positions)
738
+ }
739
+
740
+ /// The world point to anchor an overlay at: its `worldPosition`, or the
741
+ /// centroid of its `worldQuad`. null when it has no world geometry.
742
+ private fun anchorTarget(o: AROverlayData): FloatArray? {
743
+ o.worldPosition?.let { return it }
744
+ val q = o.worldQuad ?: return null
745
+ if (q.isEmpty()) return null
746
+ var cx = 0f; var cy = 0f; var cz = 0f
747
+ for (v in q) { cx += v[0]; cy += v[1]; cz += v[2] }
748
+ val n = q.size.toFloat()
749
+ return floatArrayOf(cx / n, cy / n, cz / n)
750
+ }
751
+
752
+ // ── 0.20.0 — per-frame overlay camera snapshot + JS imperative API ──
753
+
754
+ /**
755
+ * Snapshot this frame's camera view + projection matrices and the
756
+ * letterbox box into the [overlayRenderer], then trigger a redraw.
757
+ *
758
+ * Cheap no-op when no overlays are set (single AtomicReference
759
+ * emptiness check) so the common no-overlay preview path pays almost
760
+ * nothing. ARCore's `getViewMatrix` / `getProjectionMatrix` are
761
+ * COLUMN-MAJOR (OpenGL) — exactly what `android.opengl.Matrix` and the
762
+ * renderer expect. The near/far planes (0.05 m / 100 m) bound depth
763
+ * precision; they don't affect XY projection.
764
+ *
765
+ * @param camera the ARCore camera for this frame (pose + intrinsics).
766
+ * @param glBox the letterbox box [x, y, w, h] in GL pixel space
767
+ * (origin BOTTOM-left, as used by `glViewport`). We flip
768
+ * Y to the overlay View's TOP-left origin here.
769
+ */
770
+ private fun maybeUpdateOverlayCamera(camera: Camera, glBox: IntArray) {
771
+ if (overlayStore.isEmpty()) return
772
+
773
+ // ARCore projection matrix: near=0.05 m, far=100 m (matches the
774
+ // BackgroundRenderer's typical range; depth bounds only).
775
+ try {
776
+ camera.getViewMatrix(overlayViewMatrix, 0)
777
+ camera.getProjectionMatrix(overlayProjMatrix, 0, 0.05f, 100f)
778
+ } catch (t: Throwable) {
779
+ // Camera not ready (early frames) — skip this frame's overlay
780
+ // update; the feed keeps drawing and we retry next frame.
781
+ return
782
+ }
783
+
784
+ // GL viewport box → overlay View box. GL origin is bottom-left;
785
+ // the View is top-left. Both share surfaceWidth × surfaceHeight.
786
+ val boxX = glBox[0].toFloat()
787
+ val boxW = glBox[2].toFloat()
788
+ val boxH = glBox[3].toFloat()
789
+ val boxYTop = (surfaceHeight - (glBox[1] + glBox[3])).toFloat()
790
+
791
+ val tracking = camera.trackingState == TrackingState.TRACKING
792
+
793
+ overlayRenderer.updateCamera(
794
+ viewMatrix = overlayViewMatrix,
795
+ projectionMatrix = overlayProjMatrix,
796
+ boxX = boxX,
797
+ boxY = boxYTop,
798
+ boxW = boxW,
799
+ boxH = boxH,
800
+ tracking = tracking,
801
+ )
802
+ }
803
+
804
+ // ── JS imperative overlay API (forwarded from RNSARSession) ──────────
805
+ //
806
+ // The JS `ARCameraViewHandle` / `<Camera>` ref methods (setOverlays /
807
+ // addOverlay / updateOverlay / removeOverlay / clearOverlays) route
808
+ // through the singleton `RNSARSession` native module — the SAME idiom
809
+ // takePhoto uses — which forwards to the bound view's [overlayStore]
810
+ // (JS namespace). These run on the bridge thread; the store's
811
+ // AtomicReferences make that safe against the GL thread's per-frame
812
+ // read. An overlay set change triggers an immediate redraw so the
813
+ // overlay appears without waiting for the next AR frame's snapshot.
814
+
815
+ internal fun setOverlaysFromJs(overlays: List<AROverlayData>) {
816
+ overlayStore.setJsOverlays(overlays)
817
+ overlayRenderer.postInvalidateOnAnimation()
818
+ }
819
+
820
+ internal fun addOverlayFromJs(overlay: AROverlayData) {
821
+ overlayStore.addJsOverlay(overlay)
822
+ overlayRenderer.postInvalidateOnAnimation()
823
+ }
824
+
825
+ internal fun updateOverlayFromJs(id: String, patch: com.facebook.react.bridge.ReadableMap) {
826
+ overlayStore.updateJsOverlay(id, patch)
827
+ overlayRenderer.postInvalidateOnAnimation()
828
+ }
829
+
830
+ internal fun removeOverlayFromJs(id: String) {
831
+ overlayStore.removeJsOverlay(id)
832
+ overlayRenderer.postInvalidateOnAnimation()
833
+ }
834
+
835
+ internal fun clearOverlaysFromJs() {
836
+ overlayStore.clearJsOverlays()
837
+ overlayRenderer.postInvalidateOnAnimation()
838
+ }
839
+
550
840
  /// P3-G diagnostic — rate-limit the per-frame log so we can see
551
841
  /// at-a-glance whether forwardToIncremental is even running, vs
552
842
  /// being short-circuited at the `if (ingestActive)` guard in
@@ -1,8 +1,10 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  package io.imagestitcher.rn
3
3
 
4
+ import com.facebook.react.bridge.ReadableArray
4
5
  import com.facebook.react.uimanager.SimpleViewManager
5
6
  import com.facebook.react.uimanager.ThemedReactContext
7
+ import com.facebook.react.uimanager.annotations.ReactProp
6
8
 
7
9
  /**
8
10
  * RN ViewManager for `RNSARCameraView`.
@@ -16,11 +18,19 @@ import com.facebook.react.uimanager.ThemedReactContext
16
18
  * in `src/camera/ARCameraView.tsx` which auto-selects the native
17
19
  * component for iOS vs Android.
18
20
  *
19
- * The view itself is config-free for now (no JS-side props beyond
20
- * `style`) since lifecycle is driven by mount/unmount + the
21
- * incremental stitcher's start/finalize methods. Future phases may
22
- * add props like `enabled` to allow JS-controlled pause/resume of
23
- * the GL render loop.
21
+ * Props:
22
+ * - `overlays` (0.20.0) declarative `AROverlay[]` drawn on the AR
23
+ * overlay layer above the camera preview (see [AROverlayStore] /
24
+ * [AROverlayRenderer]). React state-driven: when the array changes,
25
+ * RN re-sends it and we REPLACE the view's JS overlay namespace. The
26
+ * imperative ref API (setOverlays / addOverlay / updateOverlay /
27
+ * removeOverlay / clearOverlays) routes through the `RNSARSession`
28
+ * native module instead (the same idiom takePhoto uses); both write the
29
+ * SAME JS namespace, and the renderer draws the union of JS + native-
30
+ * plugin overlays.
31
+ *
32
+ * Lifecycle remains driven by mount/unmount + the incremental stitcher's
33
+ * start/finalize methods.
24
34
  */
25
35
  class RNSARCameraViewManager : SimpleViewManager<RNSARCameraView>() {
26
36
 
@@ -29,6 +39,21 @@ class RNSARCameraViewManager : SimpleViewManager<RNSARCameraView>() {
29
39
  override fun createViewInstance(reactContext: ThemedReactContext): RNSARCameraView =
30
40
  RNSARCameraView(reactContext)
31
41
 
42
+ /**
43
+ * 0.20.0 — declarative `overlays` prop. Parses the `AROverlay[]` array
44
+ * (the shared TS contract shape) and REPLACES the view's JS overlay
45
+ * namespace. A null / empty array clears the JS overlays (native-plugin
46
+ * overlays are untouched). Malformed entries are dropped (see
47
+ * [AROverlayData.fromReadableArray]).
48
+ *
49
+ * React diffs the prop by value before re-sending, so we don't diff
50
+ * here — a fresh array means "this is the new full JS overlay set".
51
+ */
52
+ @ReactProp(name = "overlays")
53
+ fun setOverlays(view: RNSARCameraView, overlays: ReadableArray?) {
54
+ view.setOverlaysFromJs(AROverlayData.fromReadableArray(overlays))
55
+ }
56
+
32
57
  companion object {
33
58
  const val REACT_CLASS = "RNSARCameraView"
34
59
  }
@@ -5,6 +5,7 @@ import android.util.Log
5
5
  import com.facebook.react.bridge.Arguments
6
6
  import com.facebook.react.bridge.WritableMap
7
7
  import java.util.concurrent.CopyOnWriteArrayList
8
+ import java.util.concurrent.atomic.AtomicReference
8
9
 
9
10
  /**
10
11
  * 0.19.0 — process-wide registry of [ARFramePlugin]s (iOS twin:
@@ -40,6 +41,73 @@ object RNSARPluginRegistry {
40
41
 
41
42
  private val registered = CopyOnWriteArrayList<ARFramePlugin>()
42
43
 
44
+ // ── 0.20.0 — native-plugin overlay path ──────────────────────────────
45
+ //
46
+ // A native plugin can place AR overlays DIRECTLY (native→native, zero JS
47
+ // latency) via [setOverlays] / [addOverlay] / [removeOverlay] /
48
+ // [clearOverlays]. These write the **plugin namespace** of the bound
49
+ // view's [AROverlayStore]; the renderer draws the UNION of plugin + JS
50
+ // overlays, so a JS `setOverlays` never clobbers plugin overlays (and
51
+ // vice-versa).
52
+ //
53
+ // We CACHE the latest plugin overlay set here so a view that binds AFTER
54
+ // a plugin placed overlays (e.g. plugin registered + overlays set in
55
+ // MainApplication.onCreate, before any ARCameraView mounts) still picks
56
+ // them up — [RNSARCameraView] replays the cache into its store when it
57
+ // binds (see [currentPluginOverlays]).
58
+ private val pluginOverlays = AtomicReference<List<AROverlayData>>(emptyList())
59
+
60
+ /**
61
+ * Replace the ENTIRE native-plugin overlay set. Merges with (does NOT
62
+ * clobber) JS-set overlays — the renderer draws the union. Safe from
63
+ * any thread (a plugin's own work queue, typically).
64
+ *
65
+ * @param overlays the plugin overlays to render (an empty list clears
66
+ * the plugin namespace; JS overlays untouched).
67
+ */
68
+ @JvmStatic
69
+ fun setOverlays(overlays: List<AROverlayData>) {
70
+ pluginOverlays.set(overlays.toList())
71
+ boundStore()?.setPluginOverlays(overlays)
72
+ }
73
+
74
+ /** Add or replace one plugin overlay by id. */
75
+ @JvmStatic
76
+ fun addOverlay(overlay: AROverlayData) {
77
+ pluginOverlays.updateAndGet { cur ->
78
+ val idx = cur.indexOfFirst { it.id == overlay.id }
79
+ if (idx < 0) cur + overlay else cur.toMutableList().also { it[idx] = overlay }
80
+ }
81
+ boundStore()?.addPluginOverlay(overlay)
82
+ }
83
+
84
+ /** Remove one plugin overlay by id (no-op if unknown). */
85
+ @JvmStatic
86
+ fun removeOverlay(id: String) {
87
+ pluginOverlays.updateAndGet { cur -> cur.filterNot { it.id == id } }
88
+ boundStore()?.removePluginOverlay(id)
89
+ }
90
+
91
+ /** Clear ALL plugin overlays (JS overlays untouched). */
92
+ @JvmStatic
93
+ fun clearOverlays() {
94
+ pluginOverlays.set(emptyList())
95
+ boundStore()?.clearPluginOverlays()
96
+ }
97
+
98
+ /**
99
+ * The cached plugin overlay set — replayed into a freshly-bound view's
100
+ * store so plugins that placed overlays before any view mounted still
101
+ * render. Called by [RNSARCameraView.onAttachedToWindow].
102
+ */
103
+ @JvmStatic
104
+ internal fun currentPluginOverlays(): List<AROverlayData> = pluginOverlays.get()
105
+
106
+ /// The bound AR camera view's overlay store, or null when no view is
107
+ /// mounted yet (overlays land in the cache until one binds).
108
+ private fun boundStore(): AROverlayStore? =
109
+ RNSARSession.instance?.boundOverlayStore()
110
+
43
111
  /**
44
112
  * Register a plugin. Idempotent by [ARFramePlugin.name]: registering a
45
113
  * plugin whose name matches an existing one REPLACES the old instance
@@ -186,6 +186,91 @@ class RNSARSession(reactContext: ReactApplicationContext)
186
186
  arFrameMetaLastEmitNs = 0L
187
187
  }
188
188
 
189
+ // ── 0.20.0 — AR overlay/annotation imperative API ────────────────────
190
+ //
191
+ // JS-facing methods backing the `<Camera>` / `<ARCameraView>` ref's
192
+ // overlay API (setOverlays / addOverlay / updateOverlay / removeOverlay /
193
+ // clearOverlays) AND the declarative `overlays` prop (which TS funnels
194
+ // through `setOverlays`). Each forwards to the bound AR camera view's
195
+ // [AROverlayStore] JS namespace; the renderer draws the UNION of these
196
+ // and any native-plugin overlays. Fire-and-forget (no Promise) — mirrors
197
+ // the other config-prop setters (setPlaneDetection, lockPortrait).
198
+ //
199
+ // No-op (logged) when no AR camera view is bound — the host called an
200
+ // overlay method before mounting <ARCameraView>; the next mount will NOT
201
+ // auto-replay JS overlays (unlike plugin overlays), so the host should
202
+ // set overlays after mount (the declarative `overlays` prop does this
203
+ // naturally via its mount effect).
204
+
205
+ @ReactMethod
206
+ fun setOverlays(overlays: com.facebook.react.bridge.ReadableArray?) {
207
+ val view = attachedView ?: run {
208
+ Log.d(TAG, "setOverlays: no AR camera view bound — ignoring")
209
+ return
210
+ }
211
+ view.setOverlaysFromJs(AROverlayData.fromReadableArray(overlays))
212
+ }
213
+
214
+ @ReactMethod
215
+ fun addOverlay(overlay: com.facebook.react.bridge.ReadableMap?) {
216
+ val view = attachedView ?: run {
217
+ Log.d(TAG, "addOverlay: no AR camera view bound — ignoring")
218
+ return
219
+ }
220
+ val parsed = AROverlayData.fromReadableMap(overlay) ?: run {
221
+ Log.w(TAG, "addOverlay: malformed overlay (no id / no anchor) — ignoring")
222
+ return
223
+ }
224
+ view.addOverlayFromJs(parsed)
225
+ }
226
+
227
+ @ReactMethod
228
+ fun updateOverlay(id: String?, patch: com.facebook.react.bridge.ReadableMap?) {
229
+ if (id.isNullOrEmpty() || patch == null) {
230
+ Log.w(TAG, "updateOverlay: missing id or patch — ignoring")
231
+ return
232
+ }
233
+ val view = attachedView ?: run {
234
+ Log.d(TAG, "updateOverlay: no AR camera view bound — ignoring")
235
+ return
236
+ }
237
+ view.updateOverlayFromJs(id, patch)
238
+ }
239
+
240
+ @ReactMethod
241
+ fun removeOverlay(id: String?) {
242
+ if (id.isNullOrEmpty()) return
243
+ val view = attachedView ?: run {
244
+ Log.d(TAG, "removeOverlay: no AR camera view bound — ignoring")
245
+ return
246
+ }
247
+ view.removeOverlayFromJs(id)
248
+ }
249
+
250
+ @ReactMethod
251
+ fun clearOverlays() {
252
+ val view = attachedView ?: run {
253
+ Log.d(TAG, "clearOverlays: no AR camera view bound — ignoring")
254
+ return
255
+ }
256
+ view.clearOverlaysFromJs()
257
+ }
258
+
259
+ // v0.20.0 — raycast from the screen-centre crosshair to the nearest real
260
+ // surface; resolves `{ worldPosition: [x,y,z] }` or null. The hitTest
261
+ // needs the live ARCore frame on the GL thread, so the view fulfils it on
262
+ // the next render tick. Resolves null (not reject) when no view is bound
263
+ // so the JS controller falls back to its fixed 1 m-ahead placement.
264
+ @ReactMethod
265
+ fun raycast(promise: Promise) {
266
+ val view = attachedView ?: run {
267
+ Log.d(TAG, "raycast: no AR camera view bound — resolving null")
268
+ promise.resolve(null)
269
+ return
270
+ }
271
+ view.requestRaycast(promise)
272
+ }
273
+
189
274
  @ReactMethod
190
275
  fun isSupported(promise: Promise) {
191
276
  // `checkAvailability` can return UNKNOWN_CHECKING if the
@@ -892,12 +977,26 @@ class RNSARSession(reactContext: ReactApplicationContext)
892
977
 
893
978
  internal fun bindCameraView(view: RNSARCameraView) {
894
979
  attachedView = view
980
+ // 0.20.0 — replay any plugin overlays placed BEFORE this view
981
+ // mounted (e.g. from MainApplication.onCreate) so they render
982
+ // immediately. Plugin overlays live in the plugin namespace; JS
983
+ // overlays (set via the imperative API) survive across remounts on
984
+ // the view's own store, so we don't touch them here.
985
+ val cached = RNSARPluginRegistry.currentPluginOverlays()
986
+ if (cached.isNotEmpty()) {
987
+ view.overlayStore.setPluginOverlays(cached)
988
+ }
895
989
  }
896
990
 
897
991
  internal fun unbindCameraView(view: RNSARCameraView) {
898
992
  if (attachedView === view) attachedView = null
899
993
  }
900
994
 
995
+ /// 0.20.0 — the bound AR camera view's overlay store, or null when no
996
+ /// view is mounted. Used by [RNSARPluginRegistry] to push native-plugin
997
+ /// overlays into the live renderer.
998
+ internal fun boundOverlayStore(): AROverlayStore? = attachedView?.overlayStore
999
+
901
1000
  /**
902
1001
  * Emit a pre-built [ARFrameMeta] WritableMap to JS over the shared
903
1002
  * `RNImageStitcherARFrame` device event. Called from
@@ -32,6 +32,8 @@ import React from 'react';
32
32
  import { type ViewStyle } from 'react-native';
33
33
  import type { CameraFrameProcessor } from '../stitching/CameraFrame';
34
34
  import type { ARFrameMeta, ARPluginResult } from '../stitching/ARFrameMeta';
35
+ import type { AROverlay } from '../stitching/AROverlay';
36
+ import { type AROverlayMethods } from './arOverlayController';
35
37
  export interface ARCameraViewProps {
36
38
  /** Layout style, typically `StyleSheet.absoluteFill` or `flex: 1`. */
37
39
  style?: ViewStyle;
@@ -137,6 +139,31 @@ export interface ARCameraViewProps {
137
139
  * present; cleanup on unmount / when the handler is removed).
138
140
  */
139
141
  onArPluginResult?: (e: ARPluginResult) => void;
142
+ /**
143
+ * v0.20.0 — AR OVERLAY / ANNOTATION renderer. A declarative array of 2D
144
+ * shapes the native overlay layer draws ON TOP of the AR camera preview,
145
+ * each anchored to WORLD positions and REPROJECTED to screen on every AR
146
+ * frame from the current camera pose + intrinsics (smooth, display-rate
147
+ * tracking; no 3D engine).
148
+ *
149
+ * State-driven: pass a React-state array and update it as your world points
150
+ * change. The set is diffed against the current overlays BY `id` (add /
151
+ * update / remove), so re-passing the same ids is cheap. Each render pushes
152
+ * the resolved array to native via `RNSARSession.setOverlays`.
153
+ *
154
+ * For zero-render-latency / fire-and-forget mutations use the imperative ref
155
+ * methods instead ({@link ARCameraViewHandle.setOverlays} etc.) — both paths
156
+ * funnel through the same native channel and stay consistent. JS-set
157
+ * overlays are merged on the native side with any overlays a registered AR
158
+ * plugin placed directly (`RNISARPluginRegistry.setOverlays` /
159
+ * `RNSARPluginRegistry.setOverlays`); the two sets are namespaced so neither
160
+ * clobbers the other.
161
+ *
162
+ * See {@link AROverlay} for the shape (single world point + size, or explicit
163
+ * world quad; `outline` / `box`; optional label + colour; `mode:'3d'` is a
164
+ * documented scaffold this release and renders as `'2d'`).
165
+ */
166
+ overlays?: AROverlay[];
140
167
  }
141
168
  /**
142
169
  * Imperative handle exposed via the ref — shape mirrors the subset
@@ -148,8 +175,13 @@ export interface ARCameraViewProps {
148
175
  * Note we do NOT exhaustively mirror vision-camera's API surface —
149
176
  * only the methods the panorama capture flow uses today. As the
150
177
  * SDK grows AR-aware features, methods are added here.
178
+ *
179
+ * v0.20.0 — also exposes the imperative AR-overlay methods
180
+ * ({@link AROverlayMethods}: `setOverlays` / `addOverlay` / `updateOverlay` /
181
+ * `removeOverlay` / `clearOverlays`) so a host can drive overlays without a
182
+ * render (the declarative `overlays` prop is the React-state alternative).
151
183
  */
152
- export interface ARCameraViewHandle {
184
+ export interface ARCameraViewHandle extends AROverlayMethods {
153
185
  /**
154
186
  * Capture the latest ARFrame as a JPEG. Resolves with a
155
187
  * vision-camera-compatible PhotoFile (`{ path, width, height,