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.
- package/CHANGELOG.md +32 -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 +290 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +30 -5
- package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +68 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +99 -0
- package/dist/camera/ARCameraView.d.ts +33 -1
- package/dist/camera/ARCameraView.js +33 -2
- package/dist/camera/Camera.d.ts +45 -1
- 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 +4 -1
- package/dist/index.js +5 -2
- 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 +81 -0
- package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +37 -0
- package/ios/Sources/RNImageStitcher/RNISAROverlay.swift +409 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +281 -3
- package/package.json +1 -1
- package/src/camera/ARCameraView.tsx +73 -2
- package/src/camera/Camera.tsx +71 -2
- package/src/camera/arOverlayController.ts +184 -0
- package/src/index.ts +15 -0
- 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
|
-
|
|
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.
|
|
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')) {
|
package/src/camera/Camera.tsx
CHANGED
|
@@ -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
|
|
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 {
|