react-native-image-stitcher 0.19.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 (30) hide show
  1. package/CHANGELOG.md +32 -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 +281 -3
  25. package/package.json +1 -1
  26. package/src/camera/ARCameraView.tsx +73 -2
  27. package/src/camera/Camera.tsx +71 -2
  28. package/src/camera/arOverlayController.ts +184 -0
  29. package/src/index.ts +15 -0
  30. package/src/stitching/AROverlay.ts +105 -0
@@ -31,17 +31,37 @@
31
31
 
32
32
  import Foundation
33
33
  import ARKit
34
+ import SceneKit
34
35
  import UIKit
36
+ import simd
35
37
 
36
38
 
37
39
  @objc(RNSARCameraView)
38
- public final class RNSARCameraView: UIView {
40
+ public final class RNSARCameraView: UIView, ARSCNViewDelegate {
39
41
 
40
42
  /// The ARSCNView that does the actual rendering. Bound to the
41
43
  /// singleton's ARSession so all preview surfaces share the same
42
44
  /// session (and the same pose log that the stitcher consumes).
43
45
  private var arSCNView: ARSCNView!
44
46
 
47
+ /// v0.20.0 — world-anchored overlays backed by REAL `ARAnchor`s. Each
48
+ /// overlay becomes an `ARAnchor` added to the session; ARSCNView creates a
49
+ /// node per anchor (via `renderer(_:nodeFor:)`) and keeps its transform
50
+ /// synced to the anchor every frame — and crucially ARKit *refines* the
51
+ /// anchor as its world understanding improves (drift / re-localization),
52
+ /// so the marker stays glued to the real-world spot across a long session,
53
+ /// not just a short one (which a fixed world coordinate would not survive).
54
+ /// Diffed against the overlay store each render pass (`RNISAROverlay` is
55
+ /// `Equatable`): add new ids, rebuild changed ones, remove gone ones.
56
+ ///
57
+ /// Two maps, guarded by `anchorLock` because `nodeFor` may run on a
58
+ /// different (render) thread than the `updateAtTime` diff:
59
+ /// - `overlayAnchors`: overlay id → (its anchor, the overlay it came from)
60
+ /// - `anchorOverlays`: anchor UUID → overlay (so `nodeFor` can build it)
61
+ private var overlayAnchors: [String: (anchor: ARAnchor, overlay: RNISAROverlay)] = [:]
62
+ private var anchorOverlays: [UUID: RNISAROverlay] = [:]
63
+ private let anchorLock = NSLock()
64
+
45
65
  public override init(frame: CGRect) {
46
66
  super.init(frame: frame)
47
67
  setupView()
@@ -68,6 +88,14 @@ public final class RNSARCameraView: UIView {
68
88
  // a renderer.
69
89
  arSCNView.session = RNSARSession.shared.arSession
70
90
 
91
+ // v0.20.0 — become the ARSCNView's render delegate so
92
+ // `renderer(_:updateAtTime:)` fires once per render pass (display
93
+ // rate) on the MAIN thread. This is our per-frame overlay redraw
94
+ // hook: cheap (a handful of overlays), already on the main thread,
95
+ // and gives us smooth display-rate tracking without touching the
96
+ // ARSession delegate (which `RNSARSession` owns for pose logging).
97
+ arSCNView.delegate = self
98
+
71
99
  // We don't draw any 3D content in Phase 4.4. Disable
72
100
  // SceneKit's automatic statistics overlay and lighting model
73
101
  // — we just want the camera feed.
@@ -78,6 +106,39 @@ public final class RNSARCameraView: UIView {
78
106
  // this view outside ARSCNView's letterboxed sub-rect).
79
107
  backgroundColor = .black
80
108
  addSubview(arSCNView)
109
+
110
+ // v0.20.0 — overlays render as world-anchored SceneKit nodes inside
111
+ // `arSCNView.scene` (see `syncOverlayNodes`), so there is no separate
112
+ // 2D overlay UIView to add here.
113
+ }
114
+
115
+ // MARK: - v0.20.0 — declarative `overlays` prop (KVC from RN)
116
+
117
+ /// Declarative `overlays` prop. RN sets this via KVC
118
+ /// (`RCT_EXPORT_VIEW_PROPERTY(overlays, NSArray)`) whenever the prop
119
+ /// changes; we replace the JS overlay namespace wholesale (declarative
120
+ /// = the full set each render). Forwarded to the global store rather
121
+ /// than held per-view because the overlay world is global to the single
122
+ /// AR session. `@objc` + `NSArray` so KVC can set it.
123
+ @objc public var overlays: NSArray = [] {
124
+ didSet {
125
+ Self.applyJSSetOverlays(overlays)
126
+ }
127
+ }
128
+
129
+ /// Parse a JS overlay-dictionary array and replace the JS overlay
130
+ /// namespace in the shared store. Shared by the declarative prop
131
+ /// setter (above) and the imperative `setOverlays` view command (in
132
+ /// `RNSARCameraViewManager`).
133
+ static func applyJSSetOverlays(_ overlays: NSArray) {
134
+ var parsed: [RNISAROverlay] = []
135
+ parsed.reserveCapacity(overlays.count)
136
+ for item in overlays {
137
+ guard let dict = item as? [String: Any],
138
+ let o = RNISAROverlay.from(dictionary: dict) else { continue }
139
+ parsed.append(o)
140
+ }
141
+ RNISAROverlayStore.shared.setJSOverlays(parsed)
81
142
  }
82
143
 
83
144
  public override func layoutSubviews() {
@@ -96,7 +157,12 @@ public final class RNSARCameraView: UIView {
96
157
  // ARSCNView fills a same-AR sub-rect, there is nothing to crop
97
158
  // and the user sees the full captured scene. The parent view's
98
159
  // black background fills the remainder.
99
- arSCNView.frame = letterboxedFrame()
160
+ let lb = letterboxedFrame()
161
+ arSCNView.frame = lb
162
+ // Overlay nodes live in `arSCNView.scene` and are rendered by the
163
+ // same (letterboxed) ARSCNView against the live AR camera, so they
164
+ // need no separate framing — they align with the camera feed
165
+ // automatically.
100
166
  }
101
167
 
102
168
  /// Returns the largest `CGRect` inside `bounds` that matches the
@@ -184,6 +250,218 @@ public final class RNSARCameraView: UIView {
184
250
  }
185
251
  }
186
252
  }
187
- }
188
253
 
254
+ // MARK: - ARSCNViewDelegate (v0.20.0 overlay redraw hook)
255
+
256
+ /// Called once per ARSCNView render pass. Keeps the set of overlay
257
+ /// ARAnchors in sync with the overlay store (add / rebuild / remove).
258
+ /// ARKit then tracks each anchor and ARSCNView positions its node — we do
259
+ /// NOT project or position anything by hand. Cheap: an `==` diff over a
260
+ /// handful of overlays.
261
+ ///
262
+ /// We deliberately do NOT touch `RNSARSession`'s pose log / plugin /
263
+ /// onArFrame paths — those ride the ARSession delegate which the singleton
264
+ /// owns. This delegate is purely the overlay-sync hook.
265
+ public func renderer(
266
+ _ renderer: SCNSceneRenderer,
267
+ updateAtTime time: TimeInterval
268
+ ) {
269
+ syncOverlayAnchors()
270
+ }
271
+
272
+ /// ARSCNViewDelegate: vend the node for one of our overlay anchors. May
273
+ /// run on the render thread; ARSCNView keeps the returned node's transform
274
+ /// synced to the (ARKit-refined) anchor. We build the visual RELATIVE to
275
+ /// the anchor origin — the anchor carries the world position.
276
+ public func renderer(
277
+ _ renderer: SCNSceneRenderer,
278
+ nodeFor anchor: ARAnchor
279
+ ) -> SCNNode? {
280
+ anchorLock.lock()
281
+ let overlay = anchorOverlays[anchor.identifier]
282
+ anchorLock.unlock()
283
+ guard let overlay = overlay else { return nil }
284
+
285
+ if let quad = overlay.worldQuad, quad.count >= 3 {
286
+ // Anchor sits at the centroid; draw the loop relative to it.
287
+ var c = simd_float3(0, 0, 0)
288
+ for v in quad { c += v }
289
+ c /= Float(quad.count)
290
+ return Self.makeQuadOutlineNode(
291
+ relCorners: quad.map { $0 - c },
292
+ color: overlay.color, label: overlay.label)
293
+ }
294
+ return Self.makeBillboardNode(
295
+ sizeMeters: overlay.sizeMeters, color: overlay.color,
296
+ label: overlay.label)
297
+ }
298
+
299
+ /// Diff the overlay store against the live anchors: add an ARAnchor for
300
+ /// each new/changed overlay, remove anchors whose overlay is gone.
301
+ private func syncOverlayAnchors() {
302
+ let session = arSCNView.session
303
+ let overlays = RNISAROverlayStore.shared.snapshot()
304
+ var liveIDs = Set<String>()
305
+ liveIDs.reserveCapacity(overlays.count)
306
+
307
+ for overlay in overlays {
308
+ liveIDs.insert(overlay.id)
309
+ anchorLock.lock()
310
+ let existing = overlayAnchors[overlay.id]
311
+ anchorLock.unlock()
312
+ if let existing = existing, existing.overlay == overlay { continue }
313
+
314
+ // New or changed → drop the old anchor, add a fresh one.
315
+ if let existing = existing {
316
+ session.remove(anchor: existing.anchor)
317
+ anchorLock.lock()
318
+ anchorOverlays[existing.anchor.identifier] = nil
319
+ overlayAnchors[overlay.id] = nil
320
+ anchorLock.unlock()
321
+ }
322
+ guard let pose = Self.anchorPose(for: overlay) else { continue }
323
+ let anchor = ARAnchor(transform: pose)
324
+ anchorLock.lock()
325
+ anchorOverlays[anchor.identifier] = overlay
326
+ overlayAnchors[overlay.id] = (anchor, overlay)
327
+ anchorLock.unlock()
328
+ session.add(anchor: anchor)
329
+ }
330
+
331
+ // Remove anchors whose overlay id is gone (snapshot under the lock,
332
+ // call session.remove outside it).
333
+ anchorLock.lock()
334
+ let goneIDs = overlayAnchors.keys.filter { !liveIDs.contains($0) }
335
+ let goneAnchors = goneIDs.compactMap { overlayAnchors[$0]?.anchor }
336
+ for id in goneIDs {
337
+ if let a = overlayAnchors[id]?.anchor { anchorOverlays[a.identifier] = nil }
338
+ overlayAnchors[id] = nil
339
+ }
340
+ anchorLock.unlock()
341
+ for a in goneAnchors { session.remove(anchor: a) }
342
+ }
343
+
344
+ // MARK: - v0.20.0 overlay node builders (RELATIVE to the anchor origin)
345
+
346
+ /// World transform for an overlay's anchor: a translation-only pose at the
347
+ /// `worldPosition`, or at the centroid of `worldQuad`. nil if no geometry.
348
+ private static func anchorPose(for overlay: RNISAROverlay) -> simd_float4x4? {
349
+ let p: simd_float3
350
+ if let quad = overlay.worldQuad, quad.count >= 3 {
351
+ var c = simd_float3(0, 0, 0)
352
+ for v in quad { c += v }
353
+ p = c / Float(quad.count)
354
+ } else if let center = overlay.worldPosition {
355
+ p = center
356
+ } else {
357
+ return nil
358
+ }
359
+ var m = matrix_identity_float4x4
360
+ m.columns.3 = simd_float4(p.x, p.y, p.z, 1)
361
+ return m
362
+ }
363
+
364
+ /// A camera-facing billboard plane (at the anchor origin), sized in metres,
365
+ /// textured with a stroked outline + optional centred label. Always drawn
366
+ /// on top (depth read/write off) so an annotation is never hidden.
367
+ private static func makeBillboardNode(
368
+ sizeMeters: CGSize?,
369
+ color: UIColor,
370
+ label: String?
371
+ ) -> SCNNode {
372
+ let w = sizeMeters?.width ?? RNISAROverlay.defaultMarkerExtent
373
+ let h = sizeMeters?.height ?? RNISAROverlay.defaultMarkerExtent
374
+ let plane = SCNPlane(width: w, height: h)
375
+ let mat = SCNMaterial()
376
+ mat.diffuse.contents = overlayImage(color: color, label: label)
377
+ mat.isDoubleSided = true
378
+ mat.lightingModel = .constant // unlit — show the texture as-is
379
+ mat.writesToDepthBuffer = false
380
+ mat.readsFromDepthBuffer = false // never occluded by the scene
381
+ plane.firstMaterial = mat
382
+
383
+ let node = SCNNode(geometry: plane)
384
+ node.renderingOrder = 1000 // draw after the camera background
385
+ let billboard = SCNBillboardConstraint()
386
+ billboard.freeAxes = .all // always face the camera, flat
387
+ node.constraints = [billboard]
388
+ return node
389
+ }
390
+
391
+ /// A 3D line-loop through corners expressed RELATIVE to the anchor origin
392
+ /// (the anchor is at the quad centroid), with an optional camera-facing
393
+ /// label at the centre.
394
+ private static func makeQuadOutlineNode(
395
+ relCorners: [simd_float3],
396
+ color: UIColor,
397
+ label: String?
398
+ ) -> SCNNode {
399
+ let vertices = relCorners.map { SCNVector3($0.x, $0.y, $0.z) }
400
+ var indices: [Int32] = []
401
+ let n = vertices.count
402
+ indices.reserveCapacity(n * 2)
403
+ for i in 0..<n {
404
+ indices.append(Int32(i))
405
+ indices.append(Int32((i + 1) % n)) // close the loop
406
+ }
407
+ let src = SCNGeometrySource(vertices: vertices)
408
+ let elem = SCNGeometryElement(indices: indices, primitiveType: .line)
409
+ let geo = SCNGeometry(sources: [src], elements: [elem])
410
+ let mat = SCNMaterial()
411
+ mat.diffuse.contents = color
412
+ mat.lightingModel = .constant
413
+ mat.writesToDepthBuffer = false
414
+ mat.readsFromDepthBuffer = false
415
+ geo.firstMaterial = mat
416
+
417
+ let node = SCNNode(geometry: geo)
418
+ node.renderingOrder = 1000
419
+
420
+ if let label = label, !label.isEmpty {
421
+ // Label at the centroid (≈ local origin in relative space).
422
+ let labelNode = makeBillboardNode(
423
+ sizeMeters: CGSize(width: 0.12, height: 0.12),
424
+ color: color, label: label)
425
+ node.addChildNode(labelNode)
426
+ }
427
+ return node
428
+ }
429
+
430
+ /// Render a stroked rounded-rect outline + optional centred label chip to
431
+ /// a square image, used as the billboard plane's texture. Transparent
432
+ /// background so only the outline + chip show over the camera feed.
433
+ private static func overlayImage(color: UIColor, label: String?) -> UIImage {
434
+ let px: CGFloat = 512
435
+ let renderer = UIGraphicsImageRenderer(size: CGSize(width: px, height: px))
436
+ return renderer.image { ctx in
437
+ let cg = ctx.cgContext
438
+ let inset = px * 0.07
439
+ let rect = CGRect(x: inset, y: inset,
440
+ width: px - 2 * inset, height: px - 2 * inset)
441
+ let path = UIBezierPath(roundedRect: rect, cornerRadius: px * 0.05)
442
+ cg.setStrokeColor(color.cgColor)
443
+ cg.setLineWidth(px * 0.03)
444
+ path.stroke()
445
+
446
+ guard let label = label, !label.isEmpty else { return }
447
+ let fontSize = px * 0.13
448
+ let font = UIFont.systemFont(ofSize: fontSize, weight: .bold)
449
+ let attrs: [NSAttributedString.Key: Any] = [
450
+ .font: font, .foregroundColor: UIColor.white,
451
+ ]
452
+ let ts = (label as NSString).size(withAttributes: attrs)
453
+ let pad = fontSize * 0.35
454
+ let chip = CGRect(x: (px - ts.width) / 2 - pad,
455
+ y: (px - ts.height) / 2 - pad,
456
+ width: ts.width + 2 * pad,
457
+ height: ts.height + 2 * pad)
458
+ color.withAlphaComponent(0.9).setFill()
459
+ UIBezierPath(roundedRect: chip, cornerRadius: pad).fill()
460
+ (label as NSString).draw(
461
+ at: CGPoint(x: (px - ts.width) / 2, y: (px - ts.height) / 2),
462
+ withAttributes: attrs)
463
+ }
464
+ }
465
+
466
+ }
189
467
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-image-stitcher",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Pose-aware panorama capture + stitching for React Native. One <Camera> component, both tap-to-photo and hold-to-pan modes, both AR-backed and IMU-fallback capture paths.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -45,6 +45,11 @@ import {
45
45
  import { ensureStitcherProxyInstalled } from '../stitching/ensureStitcherProxyInstalled';
46
46
  import type { CameraFrameProcessor } from '../stitching/CameraFrame';
47
47
  import type { ARFrameMeta, ARPluginResult } from '../stitching/ARFrameMeta';
48
+ import type { AROverlay } from '../stitching/AROverlay';
49
+ import {
50
+ createAROverlayController,
51
+ type AROverlayMethods,
52
+ } from './arOverlayController';
48
53
 
49
54
 
50
55
  // React Native looks up the component by its NATIVE name.
@@ -167,6 +172,32 @@ export interface ARCameraViewProps {
167
172
  * present; cleanup on unmount / when the handler is removed).
168
173
  */
169
174
  onArPluginResult?: (e: ARPluginResult) => void;
175
+
176
+ /**
177
+ * v0.20.0 — AR OVERLAY / ANNOTATION renderer. A declarative array of 2D
178
+ * shapes the native overlay layer draws ON TOP of the AR camera preview,
179
+ * each anchored to WORLD positions and REPROJECTED to screen on every AR
180
+ * frame from the current camera pose + intrinsics (smooth, display-rate
181
+ * tracking; no 3D engine).
182
+ *
183
+ * State-driven: pass a React-state array and update it as your world points
184
+ * change. The set is diffed against the current overlays BY `id` (add /
185
+ * update / remove), so re-passing the same ids is cheap. Each render pushes
186
+ * the resolved array to native via `RNSARSession.setOverlays`.
187
+ *
188
+ * For zero-render-latency / fire-and-forget mutations use the imperative ref
189
+ * methods instead ({@link ARCameraViewHandle.setOverlays} etc.) — both paths
190
+ * funnel through the same native channel and stay consistent. JS-set
191
+ * overlays are merged on the native side with any overlays a registered AR
192
+ * plugin placed directly (`RNISARPluginRegistry.setOverlays` /
193
+ * `RNSARPluginRegistry.setOverlays`); the two sets are namespaced so neither
194
+ * clobbers the other.
195
+ *
196
+ * See {@link AROverlay} for the shape (single world point + size, or explicit
197
+ * world quad; `outline` / `box`; optional label + colour; `mode:'3d'` is a
198
+ * documented scaffold this release and renders as `'2d'`).
199
+ */
200
+ overlays?: AROverlay[];
170
201
  }
171
202
 
172
203
 
@@ -180,8 +211,13 @@ export interface ARCameraViewProps {
180
211
  * Note we do NOT exhaustively mirror vision-camera's API surface —
181
212
  * only the methods the panorama capture flow uses today. As the
182
213
  * SDK grows AR-aware features, methods are added here.
214
+ *
215
+ * v0.20.0 — also exposes the imperative AR-overlay methods
216
+ * ({@link AROverlayMethods}: `setOverlays` / `addOverlay` / `updateOverlay` /
217
+ * `removeOverlay` / `clearOverlays`) so a host can drive overlays without a
218
+ * render (the declarative `overlays` prop is the React-state alternative).
183
219
  */
184
- export interface ARCameraViewHandle {
220
+ export interface ARCameraViewHandle extends AROverlayMethods {
185
221
  /**
186
222
  * Capture the latest ARFrame as a JPEG. Resolves with a
187
223
  * vision-camera-compatible PhotoFile (`{ path, width, height,
@@ -260,6 +296,7 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
260
296
  onArFrame,
261
297
  arFrameMetaInterval,
262
298
  onArPluginResult,
299
+ overlays,
263
300
  },
264
301
  ref,
265
302
  ): React.JSX.Element {
@@ -268,6 +305,19 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
268
305
  // pair vision-camera uses.
269
306
  const recordingCallbacksRef = useRef<RecordingCallbacks | null>(null);
270
307
 
308
+ // v0.20.0 — AR overlay controller (shared logic with <Camera>). One
309
+ // instance per mount holds the JS-set overlay collection (keyed by id) and
310
+ // pushes the full array to native on every mutation. Both the declarative
311
+ // `overlays` prop (effect below) and the imperative ref methods drive it,
312
+ // so the two APIs can never diverge.
313
+ const overlayControllerRef = useRef<
314
+ ReturnType<typeof createAROverlayController> | null
315
+ >(null);
316
+ if (overlayControllerRef.current == null) {
317
+ overlayControllerRef.current = createAROverlayController();
318
+ }
319
+ const overlayController = overlayControllerRef.current;
320
+
271
321
  // AR frame-processor registration. Installs the native
272
322
  // `__stitcherProxy` (idempotent) and registers the host worklet so
273
323
  // the AR session's per-frame fan-out invokes it; unregisters on
@@ -427,7 +477,28 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
427
477
  };
428
478
  }, [arPluginResultEnabled]);
429
479
 
480
+ // v0.20.0 — declarative `overlays` prop → native. Each render pushes the
481
+ // resolved array through the controller (which replaces the JS-set
482
+ // collection wholesale and dispatches to `RNSARSession.setOverlays`). The
483
+ // controller dedups identical native dispatches at the wire level is NOT
484
+ // attempted here — React only re-runs this when `overlays` identity
485
+ // changes, and native overlay set is cheap (a handful of shapes). When the
486
+ // prop is omitted we DON'T touch the controller, so a host driving overlays
487
+ // purely imperatively (via the ref) isn't clobbered by an undefined prop.
488
+ useEffect(() => {
489
+ if (overlays == null) {
490
+ return;
491
+ }
492
+ overlayController.setOverlays(overlays);
493
+ }, [overlays, overlayController]);
494
+
430
495
  useImperativeHandle(ref, () => ({
496
+ setOverlays: overlayController.setOverlays,
497
+ addOverlay: overlayController.addOverlay,
498
+ updateOverlay: overlayController.updateOverlay,
499
+ removeOverlay: overlayController.removeOverlay,
500
+ clearOverlays: overlayController.clearOverlays,
501
+ raycast: overlayController.raycast,
431
502
  takePhoto: async (options = {}) => {
432
503
  const native: any =
433
504
  (NativeModules as Record<string, unknown>).RNSARSession;
@@ -479,7 +550,7 @@ export const ARCameraView = forwardRef<ARCameraViewHandle, ARCameraViewProps>(
479
550
  callbacks.onRecordingError?.(err as Error);
480
551
  }
481
552
  },
482
- }), []);
553
+ }), [overlayController]);
483
554
 
484
555
  if (!NativeARCameraView
485
556
  || (Platform.OS !== 'ios' && Platform.OS !== 'android')) {
@@ -41,8 +41,10 @@
41
41
  */
42
42
 
43
43
  import React, {
44
+ forwardRef,
44
45
  useCallback,
45
46
  useEffect,
47
+ useImperativeHandle,
46
48
  useMemo,
47
49
  useRef,
48
50
  useState,
@@ -68,6 +70,8 @@ import type {
68
70
  import { useARSession } from '../ar/useARSession';
69
71
  import type { CameraFrameProcessor } from '../stitching/CameraFrame';
70
72
  import type { ARFrameMeta, ARPluginResult } from '../stitching/ARFrameMeta';
73
+ import type { AROverlay } from '../stitching/AROverlay';
74
+ import type { AROverlayMethods } from './arOverlayController';
71
75
  import { ARCameraView, type ARCameraViewHandle } from './ARCameraView';
72
76
  import { CameraShutter } from './CameraShutter';
73
77
  import { CameraView } from './CameraView';
@@ -834,6 +838,26 @@ export interface CameraProps {
834
838
  */
835
839
  onArPluginResult?: (e: ARPluginResult) => void;
836
840
 
841
+ /**
842
+ * v0.20.0 — AR OVERLAY / ANNOTATION renderer. A declarative array of 2D
843
+ * shapes drawn ON TOP of the AR camera preview, each anchored to WORLD
844
+ * positions and REPROJECTED to screen on every AR frame from the current
845
+ * camera pose + intrinsics (smooth display-rate tracking, no 3D engine).
846
+ * Only meaningful in AR capture (`captureSource === 'ar'`); `<Camera>`
847
+ * threads this straight through to the underlying `<ARCameraView>`.
848
+ *
849
+ * State-driven: pass a React-state array and update it as your world points
850
+ * change (e.g. from {@link CameraProps.onArFrame} plane anchors). The set is
851
+ * diffed against the current overlays BY `id`. For zero-render-latency
852
+ * mutations use the imperative ref methods on the `<Camera>` handle instead
853
+ * ({@link CameraHandle}: `setOverlays` / `addOverlay` / `updateOverlay` /
854
+ * `removeOverlay` / `clearOverlays`) — both paths funnel through the same
855
+ * native channel. JS-set overlays merge on the native side with overlays a
856
+ * registered AR plugin placed directly (namespaced so neither clobbers the
857
+ * other). See {@link AROverlay} for the shape.
858
+ */
859
+ overlays?: AROverlay[];
860
+
837
861
  // ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────────
838
862
  /**
839
863
  * Which device holds the non-AR panorama capture accepts.
@@ -922,6 +946,26 @@ export interface CameraProps {
922
946
  }
923
947
 
924
948
 
949
+ /**
950
+ * v0.20.0 — imperative handle exposed via the `<Camera>` ref.
951
+ *
952
+ * Currently scoped to the AR-overlay methods ({@link AROverlayMethods}:
953
+ * `setOverlays` / `addOverlay` / `updateOverlay` / `removeOverlay` /
954
+ * `clearOverlays`), which forward to the underlying `<ARCameraView>`'s overlay
955
+ * channel when AR mode is mounted. They are no-ops while the camera is in
956
+ * non-AR mode (no `<ARCameraView>` is mounted, and overlays only render over
957
+ * the AR preview) — use the declarative {@link CameraProps.overlays} prop for
958
+ * a set that survives AR↔non-AR transitions, since it re-applies automatically
959
+ * whenever `<ARCameraView>` (re)mounts.
960
+ *
961
+ * The shape is identical to {@link ARCameraViewHandle}'s overlay subset so a
962
+ * host can use either component with the same overlay code. Photo / panorama
963
+ * capture remain driven by the built-in shutter (no imperative capture methods
964
+ * on this handle — see the component docstring's scope note).
965
+ */
966
+ export interface CameraHandle extends AROverlayMethods {}
967
+
968
+
925
969
  // ─── Sub-components ─────────────────────────────────────────────────
926
970
 
927
971
  /**
@@ -1207,8 +1251,15 @@ function extractPanoramaOverrides(props: CameraProps): PanoramaPropOverrides {
1207
1251
 
1208
1252
  /**
1209
1253
  * The public `<Camera>` component.
1254
+ *
1255
+ * v0.20.0 — now a `forwardRef`. The ref exposes {@link CameraHandle} (the AR
1256
+ * overlay methods); existing callers that don't pass a ref are unaffected
1257
+ * (`forwardRef` makes the ref optional).
1210
1258
  */
1211
- export function Camera(props: CameraProps): React.JSX.Element {
1259
+ export const Camera = forwardRef<CameraHandle, CameraProps>(function Camera(
1260
+ props: CameraProps,
1261
+ ref,
1262
+ ): React.JSX.Element {
1212
1263
  const {
1213
1264
  defaultCaptureSource = 'non-ar',
1214
1265
  defaultLens = '1x',
@@ -1248,6 +1299,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
1248
1299
  onArFrame,
1249
1300
  arFrameMetaInterval,
1250
1301
  onArPluginResult,
1302
+ overlays,
1251
1303
  engine = 'batch-keyframe',
1252
1304
  // ── Panorama GUIDANCE (feature/pano-ux-guidance) ──────────────
1253
1305
  panMode = 'vertical',
@@ -1511,6 +1563,22 @@ export function Camera(props: CameraProps): React.JSX.Element {
1511
1563
  const visionCameraRef = useRef<VisionCamera | null>(null);
1512
1564
  const arViewRef = useRef<ARCameraViewHandle | null>(null);
1513
1565
 
1566
+ // v0.20.0 — AR overlay imperative handle. `<Camera>` itself renders no
1567
+ // overlay layer; the overlay methods forward to the mounted
1568
+ // `<ARCameraView>`'s handle (which owns the controller + native dispatch).
1569
+ // No-op when AR mode isn't mounted (`arViewRef.current === null`), matching
1570
+ // the CameraHandle docstring — the declarative `overlays` prop is the path
1571
+ // that survives AR↔non-AR transitions. The `overlays` prop is also threaded
1572
+ // straight to `<ARCameraView>` below, so a host can use either API.
1573
+ useImperativeHandle(ref, (): CameraHandle => ({
1574
+ setOverlays: (o) => arViewRef.current?.setOverlays(o),
1575
+ addOverlay: (o) => arViewRef.current?.addOverlay(o),
1576
+ updateOverlay: (id, patch) => arViewRef.current?.updateOverlay(id, patch),
1577
+ removeOverlay: (id) => arViewRef.current?.removeOverlay(id),
1578
+ clearOverlays: () => arViewRef.current?.clearOverlays(),
1579
+ raycast: () => arViewRef.current?.raycast() ?? Promise.resolve(null),
1580
+ }), []);
1581
+
1514
1582
  // Effect that does the async transition work whenever the settled
1515
1583
  // refs disagree with the current isAR/lens. Order matters:
1516
1584
  // 1. Set the cameraTransitioning state so the gate stays closed
@@ -2519,6 +2587,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
2519
2587
  onArFrame={onArFrame}
2520
2588
  arFrameMetaInterval={arFrameMetaInterval}
2521
2589
  onArPluginResult={onArPluginResult}
2590
+ overlays={overlays}
2522
2591
  />
2523
2592
  ) : (
2524
2593
  <CameraView
@@ -3015,7 +3084,7 @@ export function Camera(props: CameraProps): React.JSX.Element {
3015
3084
  />
3016
3085
  </View>
3017
3086
  );
3018
- }
3087
+ });
3019
3088
 
3020
3089
 
3021
3090
  function noop(): void {