react-native-image-stitcher 0.18.0 → 0.20.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 (37) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/android/src/main/java/io/imagestitcher/rn/ARFrameContext.kt +89 -0
  3. package/android/src/main/java/io/imagestitcher/rn/ARFramePlugin.kt +57 -0
  4. package/android/src/main/java/io/imagestitcher/rn/AROverlayRenderer.kt +406 -0
  5. package/android/src/main/java/io/imagestitcher/rn/AROverlayStore.kt +441 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +472 -13
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +30 -5
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +177 -0
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +127 -0
  10. package/dist/camera/ARCameraView.d.ts +55 -2
  11. package/dist/camera/ARCameraView.js +68 -2
  12. package/dist/camera/Camera.d.ts +65 -2
  13. package/dist/camera/Camera.js +24 -6
  14. package/dist/camera/arOverlayController.d.ts +52 -0
  15. package/dist/camera/arOverlayController.js +132 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.js +5 -2
  18. package/dist/stitching/ARFrameMeta.d.ts +49 -0
  19. package/dist/stitching/AROverlay.d.ts +97 -0
  20. package/dist/stitching/AROverlay.js +4 -0
  21. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +15 -8
  22. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +22 -0
  23. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +14 -0
  24. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +117 -1
  25. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +25 -0
  26. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +66 -54
  27. package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +284 -0
  28. package/ios/Sources/RNImageStitcher/RNISAROverlay.swift +409 -0
  29. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +281 -3
  30. package/ios/Sources/RNImageStitcher/RNSARSession.swift +127 -1
  31. package/package.json +1 -1
  32. package/src/camera/ARCameraView.tsx +139 -3
  33. package/src/camera/Camera.tsx +94 -3
  34. package/src/camera/arOverlayController.ts +184 -0
  35. package/src/index.ts +21 -1
  36. package/src/stitching/ARFrameMeta.ts +50 -0
  37. 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,28 +460,58 @@ class RNSARCameraView @JvmOverloads constructor(
398
460
  // contract was already in place for Phase 4.
399
461
  appendPose(camera, frame.timestamp)
400
462
 
401
- // onArFrame (v0.18.0)LIGHT AR-metadata event channel. Built
402
- // + emitted INDEPENDENTLY of the stitcher ingest / host-worklet
403
- // fan-out below: a host that only wants per-frame AR metadata
404
- // (no capture, no worklet) still gets it. Gated + throttled
405
- // internally; near-free (one volatile read + one nanoTime
406
- // compare) when disabled or inside the throttle window.
407
- maybeEmitArFrameMeta(frame, camera)
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)
408
474
 
409
475
  // Forward to the incremental stitcher when capture is engaged,
410
476
  // OR when an AR frame-processor host worklet is registered (the
411
477
  // v0.8.0 Phase 4b.iii fan-out forwards preview frames whenever
412
478
  // host worklets exist, even with capture off — the host worklet
413
- // observes the live AR stream). `forwardToIncremental` does the
479
+ // observes the live AR stream), OR when a native AR plugin is
480
+ // registered (0.19.0 — `forwardToIncremental` builds the
481
+ // ARFrameContext + runs the plugins; their SYNC results are stashed
482
+ // for the onArFrame meta below). `forwardToIncremental` does the
414
483
  // NV21 pack once and gates the first-party ingest internally on
415
- // `ingestActive`; the host-worklet dispatch is gated on the
416
- // native registry count. `hasHostWorklets()` is a cheap atomic
417
- // read (microseconds) so the common capture-off / no-worklet
418
- // preview path stays near-free.
419
- if (ingestActive || StitcherWorkletRuntime.hasHostWorklets()) {
484
+ // `ingestActive`; the host-worklet dispatch is gated on the native
485
+ // worklet registry count; the plugin invocation on the plugin
486
+ // registry. All three checks are cheap atomic reads so the common
487
+ // idle preview path (no capture, no worklet, no plugin) stays
488
+ // near-free.
489
+ //
490
+ // ORDER (0.19.0): run forwardToIncremental BEFORE maybeEmitArFrameMeta
491
+ // so the native-plugin SYNC results computed in the former are
492
+ // available to fold into the onArFrame `plugins` field built in the
493
+ // latter (same render frame).
494
+ if (ingestActive ||
495
+ StitcherWorkletRuntime.hasHostWorklets() ||
496
+ !RNSARPluginRegistry.isEmpty()
497
+ ) {
420
498
  forwardToIncremental(frame, camera)
499
+ } else {
500
+ // No consumer this frame — make sure last frame's stashed plugin
501
+ // sync results don't leak into a later onArFrame meta.
502
+ lastPluginSyncResults = null
421
503
  }
422
504
 
505
+ // onArFrame (v0.18.0) — LIGHT AR-metadata event channel. Built
506
+ // + emitted INDEPENDENTLY of the stitcher ingest / host-worklet
507
+ // fan-out above: a host that only wants per-frame AR metadata
508
+ // (no capture, no worklet) still gets it. Gated + throttled
509
+ // internally; near-free (one volatile read + one nanoTime
510
+ // compare) when disabled or inside the throttle window. Native-
511
+ // plugin SYNC results (0.19.0) stashed by forwardToIncremental
512
+ // above ride along under the meta's `plugins` field.
513
+ maybeEmitArFrameMeta(frame, camera)
514
+
423
515
  // takePhoto consumer — runs on EVERY render tick (not just
424
516
  // when ingest is active), since the host calls takePhoto in
425
517
  // photo mode where ingest is off. No-op when no request is
@@ -427,6 +519,65 @@ class RNSARCameraView @JvmOverloads constructor(
427
519
  pendingTakePhoto.getAndSet(null)?.let { req ->
428
520
  fulfilTakePhoto(frame, req)
429
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
+ }
430
581
  }
431
582
 
432
583
  /// Capture the current ARCore frame to JPEG and resolve / reject
@@ -529,6 +680,163 @@ class RNSARCameraView @JvmOverloads constructor(
529
680
  RNSARSession.instance?.updateTrackingState(camera.trackingState)
530
681
  }
531
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
+
532
840
  /// P3-G diagnostic — rate-limit the per-frame log so we can see
533
841
  /// at-a-glance whether forwardToIncremental is even running, vs
534
842
  /// being short-circuited at the `if (ingestActive)` guard in
@@ -813,6 +1121,132 @@ class RNSARCameraView @JvmOverloads constructor(
813
1121
  anchorAlignments = anchorAlignments,
814
1122
  anchorExtents = anchorExtents,
815
1123
  )
1124
+
1125
+ // ── 0.19.0 — native AR-plugin per-frame invocation ───────────────
1126
+ //
1127
+ // Mirror of iOS' RNSARSession.session(_:didUpdate:) plugin loop.
1128
+ // Only build the ARFrameContext + call plugins when the registry is
1129
+ // NON-EMPTY (the onDrawFrame gate already let us in via that check,
1130
+ // but a worklet-only frame can reach here with an empty plugin
1131
+ // registry — re-check so those frames pay nothing). Runs on the AR
1132
+ // (GL render) thread, synchronously. Reuses the already-packed
1133
+ // `packed.nv21`, the depth/anchors collected above, and the pose +
1134
+ // intrinsics already read — no extra Image acquire, no second pack.
1135
+ // Depth is passed ONLY when the host opted into enableDepth (a
1136
+ // mesh-only host acquired depth for its mesh, but the contract says
1137
+ // the context's depth is null unless enableDepth).
1138
+ runArPlugins(
1139
+ packed, qarr, tArr, arTracking, frame, intrinsics, anchors,
1140
+ depth = if (flags.depth) depth else null,
1141
+ )
1142
+ }
1143
+
1144
+ /// 0.19.0 — last frame's native-plugin SYNC results, keyed by plugin
1145
+ /// name. Written by [runArPlugins] on the GL render thread, read by
1146
+ /// [maybeEmitArFrameMeta] on the same thread one step later in the same
1147
+ /// onDrawFrame tick. Null = no plugins ran / no sync results this
1148
+ /// frame. Single-threaded handoff (both on the GL thread) so no
1149
+ /// synchronisation is needed, but @Volatile is cheap insurance against
1150
+ /// any future cross-thread read.
1151
+ @Volatile
1152
+ private var lastPluginSyncResults: Map<String, Any?>? = null
1153
+
1154
+ /**
1155
+ * 0.19.0 — build one [ARFrameContext] from the current frame and invoke
1156
+ * every registered [ARFramePlugin].
1157
+ *
1158
+ * - Non-null SYNC results are collected into a `{ name -> result }` map
1159
+ * and stashed in [lastPluginSyncResults] for [maybeEmitArFrameMeta]
1160
+ * to fold into the onArFrame `plugins` field this same tick.
1161
+ * - A plugin returning `null` defers to the ASYNC channel
1162
+ * ([RNSARPluginRegistry.emit] → `RNImageStitcherARPluginResult`).
1163
+ *
1164
+ * A throwing plugin is isolated (logged, skipped) so one bad plugin
1165
+ * can't take down the AR render loop.
1166
+ *
1167
+ * Reuses caller-collected data (no extra Image work):
1168
+ * @param packed the already-packed NV21 camera image.
1169
+ * @param qarr pose rotation quaternion [x,y,z,w].
1170
+ * @param tArr pose translation [x,y,z] (world metres).
1171
+ * @param tracking contract tracking string ("normal"|"limited"|"notAvailable").
1172
+ * @param frame the ARCore frame (for the timestamp).
1173
+ * @param intrinsics camera intrinsics (fx,fy,cx,cy + image dims).
1174
+ * @param anchors anchor descriptors already collected for onArFrame
1175
+ * (enableAnchors-gated; empty otherwise).
1176
+ * @param depth row-packed DEPTH16 or null (enableDepth-gated).
1177
+ */
1178
+ private fun runArPlugins(
1179
+ packed: YuvImageConverter.PackedYuv,
1180
+ qarr: FloatArray,
1181
+ tArr: FloatArray,
1182
+ tracking: String,
1183
+ frame: com.google.ar.core.Frame,
1184
+ intrinsics: com.google.ar.core.CameraIntrinsics,
1185
+ anchors: List<ArAnchorData>,
1186
+ depth: ArDepthData?,
1187
+ ) {
1188
+ val plugins = RNSARPluginRegistry.plugins()
1189
+ if (plugins.isEmpty()) {
1190
+ lastPluginSyncResults = null
1191
+ return
1192
+ }
1193
+
1194
+ // Flatten the already-collected anchor descriptors into plain maps
1195
+ // (id/type/transform + optional alignment/extent) so plugins get the
1196
+ // same shape as the JS `ARAnchor` contract without a JSI dependency.
1197
+ val anchorMaps: List<Map<String, Any?>> =
1198
+ if (anchors.isEmpty()) emptyList()
1199
+ else anchors.map { a ->
1200
+ val m = HashMap<String, Any?>(5)
1201
+ m["id"] = a.id
1202
+ m["type"] = a.type
1203
+ m["transform"] = a.transform
1204
+ if (a.alignment.isNotEmpty()) m["alignment"] = a.alignment
1205
+ a.extent?.let { m["extent"] = it }
1206
+ m
1207
+ }
1208
+
1209
+ val ctx = ARFrameContext(
1210
+ nv21 = packed.nv21,
1211
+ width = packed.width,
1212
+ height = packed.height,
1213
+ timestampNs = frame.timestamp.toDouble(),
1214
+ fx = intrinsics.focalLength[0].toDouble(),
1215
+ fy = intrinsics.focalLength[1].toDouble(),
1216
+ cx = intrinsics.principalPoint[0].toDouble(),
1217
+ cy = intrinsics.principalPoint[1].toDouble(),
1218
+ imageWidth = intrinsics.imageDimensions[0],
1219
+ imageHeight = intrinsics.imageDimensions[1],
1220
+ poseRotation = doubleArrayOf(
1221
+ qarr[0].toDouble(), qarr[1].toDouble(),
1222
+ qarr[2].toDouble(), qarr[3].toDouble(),
1223
+ ),
1224
+ poseTranslation = doubleArrayOf(
1225
+ tArr[0].toDouble(), tArr[1].toDouble(), tArr[2].toDouble(),
1226
+ ),
1227
+ trackingState = tracking,
1228
+ depthBytes = depth?.bytes,
1229
+ depthWidth = depth?.width ?: 0,
1230
+ depthHeight = depth?.height ?: 0,
1231
+ anchors = anchorMaps,
1232
+ )
1233
+
1234
+ var sync: HashMap<String, Any?>? = null
1235
+ for (plugin in plugins) {
1236
+ val result = try {
1237
+ plugin.process(ctx)
1238
+ } catch (t: Throwable) {
1239
+ if (forwardLogTick % 30 == 1) {
1240
+ Log.w(TAG, "AR plugin '${plugin.name()}' threw in process(): ${t.message}")
1241
+ }
1242
+ null
1243
+ }
1244
+ if (result != null) {
1245
+ if (sync == null) sync = HashMap()
1246
+ sync[plugin.name()] = result
1247
+ }
1248
+ }
1249
+ lastPluginSyncResults = sync
816
1250
  }
817
1251
 
818
1252
  /// Packed DEPTH16 result: dense (no row padding) uint16-per-pixel
@@ -1186,6 +1620,31 @@ class RNSARCameraView @JvmOverloads constructor(
1186
1620
  meta.putNull("mesh")
1187
1621
  }
1188
1622
 
1623
+ // ── plugins (0.19.0) — native-plugin SYNC results, if any ────────
1624
+ // `lastPluginSyncResults` was stashed by `runArPlugins` earlier
1625
+ // in THIS same onDrawFrame tick (forwardToIncremental runs before
1626
+ // maybeEmitArFrameMeta). Each value is the WritableMap a plugin
1627
+ // returned from `process()`; we re-key it under `plugins[name]`.
1628
+ // Omitted entirely when no plugin produced a sync result (JS sees
1629
+ // `meta.plugins === undefined`), matching the optional `plugins?`
1630
+ // field in the ARFrameMeta contract.
1631
+ val pluginResults = lastPluginSyncResults
1632
+ if (!pluginResults.isNullOrEmpty()) {
1633
+ val pluginsMap = com.facebook.react.bridge.Arguments.createMap()
1634
+ for ((name, value) in pluginResults) {
1635
+ when (value) {
1636
+ is com.facebook.react.bridge.WritableMap ->
1637
+ pluginsMap.putMap(name, value)
1638
+ null -> pluginsMap.putNull(name)
1639
+ // Defensive: a plugin should only ever return a
1640
+ // WritableMap, but never let an unexpected type crash the
1641
+ // emit — drop it.
1642
+ else -> { /* skip unsupported result type */ }
1643
+ }
1644
+ }
1645
+ meta.putMap("plugins", pluginsMap)
1646
+ }
1647
+
1189
1648
  session.emitArFrameMeta(meta)
1190
1649
  }
1191
1650
 
@@ -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
  }