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.
- package/CHANGELOG.md +62 -0
- package/android/src/main/java/io/imagestitcher/rn/ARFrameContext.kt +89 -0
- package/android/src/main/java/io/imagestitcher/rn/ARFramePlugin.kt +57 -0
- package/android/src/main/java/io/imagestitcher/rn/AROverlayRenderer.kt +406 -0
- package/android/src/main/java/io/imagestitcher/rn/AROverlayStore.kt +441 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +472 -13
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +30 -5
- package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +177 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +127 -0
- package/dist/camera/ARCameraView.d.ts +55 -2
- package/dist/camera/ARCameraView.js +68 -2
- package/dist/camera/Camera.d.ts +65 -2
- package/dist/camera/Camera.js +24 -6
- package/dist/camera/arOverlayController.d.ts +52 -0
- package/dist/camera/arOverlayController.js +132 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -2
- package/dist/stitching/ARFrameMeta.d.ts +49 -0
- package/dist/stitching/AROverlay.d.ts +97 -0
- package/dist/stitching/AROverlay.js +4 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +15 -8
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +22 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +14 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +117 -1
- package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +25 -0
- package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +66 -54
- package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +284 -0
- package/ios/Sources/RNImageStitcher/RNISAROverlay.swift +409 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +281 -3
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +127 -1
- package/package.json +1 -1
- package/src/camera/ARCameraView.tsx +139 -3
- package/src/camera/Camera.tsx +94 -3
- package/src/camera/arOverlayController.ts +184 -0
- package/src/index.ts +21 -1
- package/src/stitching/ARFrameMeta.ts +50 -0
- 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
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
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)
|
|
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
|
-
//
|
|
417
|
-
//
|
|
418
|
-
// preview path stays
|
|
419
|
-
|
|
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
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* the
|
|
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
|
}
|