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
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
import Foundation
|
|
18
18
|
import React
|
|
19
|
+
import ARKit
|
|
20
|
+
import simd
|
|
19
21
|
|
|
20
22
|
// v0.18.0 — `RNSARSessionBridge` is now an `RCTEventEmitter` (was a
|
|
21
23
|
// plain `NSObject`) so it can deliver the `onArFrame` LIGHT-metadata
|
|
@@ -38,6 +40,8 @@ public final class RNSARSessionBridge: RCTEventEmitter {
|
|
|
38
40
|
private var hasListeners: Bool = false
|
|
39
41
|
|
|
40
42
|
private static let arFrameEvent = "RNImageStitcherARFrame"
|
|
43
|
+
// v0.19.0 — async AR-plugin result channel (RNISARPluginRegistry.emit).
|
|
44
|
+
private static let arPluginResultEvent = "RNImageStitcherARPluginResult"
|
|
41
45
|
|
|
42
46
|
public override init() {
|
|
43
47
|
super.init()
|
|
@@ -56,6 +60,20 @@ public final class RNSARSessionBridge: RCTEventEmitter {
|
|
|
56
60
|
name: .retailensARFrameMeta,
|
|
57
61
|
object: nil
|
|
58
62
|
)
|
|
63
|
+
// v0.19.0 — observe the async AR-plugin result channel (posted by
|
|
64
|
+
// `RNISARPluginRegistry.emit`) and re-emit as a JS device event.
|
|
65
|
+
// Same de-dupe rationale as the onArFrame observer above.
|
|
66
|
+
NotificationCenter.default.removeObserver(
|
|
67
|
+
self,
|
|
68
|
+
name: .retailensARPluginResult,
|
|
69
|
+
object: nil
|
|
70
|
+
)
|
|
71
|
+
NotificationCenter.default.addObserver(
|
|
72
|
+
self,
|
|
73
|
+
selector: #selector(handleArPluginResult(_:)),
|
|
74
|
+
name: .retailensARPluginResult,
|
|
75
|
+
object: nil
|
|
76
|
+
)
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
deinit {
|
|
@@ -71,7 +89,7 @@ public final class RNSARSessionBridge: RCTEventEmitter {
|
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
public override func supportedEvents() -> [String]! {
|
|
74
|
-
return [Self.arFrameEvent]
|
|
92
|
+
return [Self.arFrameEvent, Self.arPluginResultEvent]
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
public override func startObserving() {
|
|
@@ -100,6 +118,25 @@ public final class RNSARSessionBridge: RCTEventEmitter {
|
|
|
100
118
|
}
|
|
101
119
|
}
|
|
102
120
|
|
|
121
|
+
/// v0.19.0 — forward a posted async AR-plugin result
|
|
122
|
+
/// (`{ plugin, result }`, from `RNISARPluginRegistry.emit`) to JS as
|
|
123
|
+
/// the `RNImageStitcherARPluginResult` device event. Dropped when no
|
|
124
|
+
/// JS listener is attached. Emits via `enqueueJSCall` on the main
|
|
125
|
+
/// queue (same `sendEvent`-avoidance rationale as `handleArFrameMeta`).
|
|
126
|
+
@objc private func handleArPluginResult(_ notification: Notification) {
|
|
127
|
+
guard hasListeners else { return }
|
|
128
|
+
guard let userInfo = notification.userInfo else { return }
|
|
129
|
+
DispatchQueue.main.async { [weak self] in
|
|
130
|
+
guard let self = self, let bridge = self.bridge else { return }
|
|
131
|
+
bridge.enqueueJSCall(
|
|
132
|
+
"RCTDeviceEventEmitter",
|
|
133
|
+
method: "emit",
|
|
134
|
+
args: [Self.arPluginResultEvent, userInfo],
|
|
135
|
+
completion: nil
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
// MARK: - Module methods
|
|
104
141
|
|
|
105
142
|
/// v0.18.0 — toggle the `onArFrame` LIGHT-metadata channel. Called
|
|
@@ -203,6 +240,85 @@ public final class RNSARSessionBridge: RCTEventEmitter {
|
|
|
203
240
|
}
|
|
204
241
|
}
|
|
205
242
|
|
|
243
|
+
// MARK: - v0.20.0 — AR overlay renderer
|
|
244
|
+
|
|
245
|
+
/// Replace the ENTIRE JS-set overlay collection. The JS layer (the
|
|
246
|
+
/// shared `arOverlayController`) does the per-id diff and always sends
|
|
247
|
+
/// the FULL current array here on every mutation (declarative prop +
|
|
248
|
+
/// imperative ref methods both funnel through this one method).
|
|
249
|
+
///
|
|
250
|
+
/// Native replaces its JS-overlay namespace in `RNISAROverlayStore`
|
|
251
|
+
/// wholesale; the per-frame draw view in the mounted `RNSARCameraView`
|
|
252
|
+
/// reprojects + strokes them every ARFrame. Plugin-placed overlays
|
|
253
|
+
/// (a SEPARATE namespace, via `RNISARPluginRegistry.setOverlays`) are
|
|
254
|
+
/// untouched — the draw view renders the UNION.
|
|
255
|
+
///
|
|
256
|
+
/// `overlays` is an array of dictionaries matching the JS `AROverlay`
|
|
257
|
+
/// shape (`id`, `worldPosition?`, `sizeMeters?`, `worldQuad?`, `shape?`,
|
|
258
|
+
/// `label?`, `color?`, `mode?`). Entries missing an `id` or any
|
|
259
|
+
/// geometry are dropped. Synchronous (no main-queue hop needed — it
|
|
260
|
+
/// only mutates the thread-safe store; the draw view reads it on the
|
|
261
|
+
/// next render pass).
|
|
262
|
+
@objc(setOverlays:resolver:rejecter:)
|
|
263
|
+
public func setOverlays(
|
|
264
|
+
overlays: NSArray,
|
|
265
|
+
resolver: @escaping RCTPromiseResolveBlock,
|
|
266
|
+
rejecter: @escaping RCTPromiseRejectBlock
|
|
267
|
+
) {
|
|
268
|
+
var parsed: [RNISAROverlay] = []
|
|
269
|
+
parsed.reserveCapacity(overlays.count)
|
|
270
|
+
for item in overlays {
|
|
271
|
+
guard let dict = item as? [String: Any],
|
|
272
|
+
let o = RNISAROverlay.from(dictionary: dict) else { continue }
|
|
273
|
+
parsed.append(o)
|
|
274
|
+
}
|
|
275
|
+
RNISAROverlayStore.shared.setJSOverlays(parsed)
|
|
276
|
+
resolver(nil)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// MARK: - v0.20.0 — raycast (crosshair → real-world surface)
|
|
280
|
+
|
|
281
|
+
/// Raycast from the screen CENTER (the crosshair) along the camera's view
|
|
282
|
+
/// ray and resolve the first real-world surface hit as
|
|
283
|
+
/// `{ worldPosition: [x, y, z] }` (metres, ARKit world frame), or `null`
|
|
284
|
+
/// when nothing is hit (e.g. a featureless wall before any plane is
|
|
285
|
+
/// detected — the caller then falls back to a fixed distance ahead).
|
|
286
|
+
///
|
|
287
|
+
/// Uses an `.estimatedPlane` target so it works before a plane is fully
|
|
288
|
+
/// detected. No screen point arg is needed: the crosshair is the centre,
|
|
289
|
+
/// so the ray is exactly the camera's forward (−Z) axis from its position.
|
|
290
|
+
/// Pin the marker on THIS hit (then anchor it) and it sits on the real
|
|
291
|
+
/// surface at the real distance, instead of floating a guessed metre ahead.
|
|
292
|
+
@objc(raycast:rejecter:)
|
|
293
|
+
public func raycast(
|
|
294
|
+
resolver: @escaping RCTPromiseResolveBlock,
|
|
295
|
+
rejecter: @escaping RCTPromiseRejectBlock
|
|
296
|
+
) {
|
|
297
|
+
DispatchQueue.main.async {
|
|
298
|
+
let session = RNSARSession.shared.arSession
|
|
299
|
+
guard let frame = session.currentFrame else {
|
|
300
|
+
resolver(nil)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
let t = frame.camera.transform
|
|
304
|
+
let origin = simd_float3(t.columns.3.x, t.columns.3.y, t.columns.3.z)
|
|
305
|
+
// ARKit camera looks down its local −Z.
|
|
306
|
+
let forward = -simd_float3(t.columns.2.x, t.columns.2.y, t.columns.2.z)
|
|
307
|
+
let query = ARRaycastQuery(
|
|
308
|
+
origin: origin,
|
|
309
|
+
direction: simd_normalize(forward),
|
|
310
|
+
allowing: .estimatedPlane,
|
|
311
|
+
alignment: .any
|
|
312
|
+
)
|
|
313
|
+
guard let hit = session.raycast(query).first else {
|
|
314
|
+
resolver(nil)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
let p = hit.worldTransform.columns.3
|
|
318
|
+
resolver(["worldPosition": [p.x, p.y, p.z]])
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
206
322
|
@objc(snapshotPoseLog:rejecter:)
|
|
207
323
|
public func snapshotPoseLog(
|
|
208
324
|
resolver: @escaping RCTPromiseResolveBlock,
|
|
@@ -78,6 +78,31 @@ NS_SWIFT_NAME(CameraFrameHostObject)
|
|
|
78
78
|
pose:(RNSARFramePose *)pose
|
|
79
79
|
NS_SWIFT_NAME(lightArFrameMeta(from:pose:));
|
|
80
80
|
|
|
81
|
+
/// Build the gated anchor-dictionary array for an ARFrame — the SAME
|
|
82
|
+
/// `[{ id, type, alignment?, extent?, classification?, transform }]`
|
|
83
|
+
/// shape the `onArFrame` LIGHT meta surfaces (mesh anchors excluded;
|
|
84
|
+
/// they're summarised under `mesh`). Returns an EMPTY array when the
|
|
85
|
+
/// JS `enableAnchors` flag is off (read from the shared C++ extraction
|
|
86
|
+
/// config) — matching the TS contract's `Array<...>` (never null).
|
|
87
|
+
///
|
|
88
|
+
/// Extracted so BOTH the `onArFrame` light-meta path AND the v0.19.0 AR
|
|
89
|
+
/// plugin context (`RNISARFrameContext.anchors`) build anchors from one
|
|
90
|
+
/// source of truth (DRY).
|
|
91
|
+
///
|
|
92
|
+
/// Thread: safe to call from the ARSession delegate queue (reads the
|
|
93
|
+
/// frame synchronously; copies nothing that outlives the call).
|
|
94
|
+
+ (NSArray<NSDictionary *> *)arAnchorDictsFromFrame:(ARFrame *)arFrame
|
|
95
|
+
NS_SWIFT_NAME(arAnchorDicts(from:));
|
|
96
|
+
|
|
97
|
+
/// Whether the shared C++ extraction config currently has `enableDepth`
|
|
98
|
+
/// on (the `<Camera enableDepth>` prop, set via JS
|
|
99
|
+
/// `__stitcherProxy.setExtractionConfig`). The v0.19.0 AR plugin
|
|
100
|
+
/// context uses this to decide whether to expose the raw
|
|
101
|
+
/// `ARFrame.sceneDepth` depthBuffer to plugins — gating the config read
|
|
102
|
+
/// (C++) in the .mm so Swift doesn't need the C++ header.
|
|
103
|
+
+ (BOOL)arExtractionDepthEnabled
|
|
104
|
+
NS_SWIFT_NAME(arExtractionDepthEnabled());
|
|
105
|
+
|
|
81
106
|
@end
|
|
82
107
|
|
|
83
108
|
NS_ASSUME_NONNULL_END
|
|
@@ -653,60 +653,15 @@ void ExtractARMesh(ARFrame* arFrame, retailens::CameraFrameData& data) {
|
|
|
653
653
|
meta[@"depth"] = depthValue;
|
|
654
654
|
|
|
655
655
|
// anchors: id / coarse type / row-major 4x4 transform, plus plane
|
|
656
|
-
// alignment + extent + (capable-device) classification.
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
if (cfg.anchors) {
|
|
666
|
-
for (ARAnchor *a in arFrame.anchors) {
|
|
667
|
-
// ARMeshAnchors are summarised under `mesh`; skip here.
|
|
668
|
-
if ([a isKindOfClass:[ARMeshAnchor class]]) continue;
|
|
669
|
-
|
|
670
|
-
NSMutableDictionary *anchor = [NSMutableDictionary dictionary];
|
|
671
|
-
anchor[@"id"] = a.identifier.UUIDString;
|
|
672
|
-
|
|
673
|
-
if ([a isKindOfClass:[ARPlaneAnchor class]]) {
|
|
674
|
-
anchor[@"type"] = @"plane";
|
|
675
|
-
ARPlaneAnchor *plane = (ARPlaneAnchor *)a;
|
|
676
|
-
anchor[@"alignment"] =
|
|
677
|
-
(plane.alignment == ARPlaneAnchorAlignmentVertical) ? @"vertical"
|
|
678
|
-
: @"horizontal";
|
|
679
|
-
// [extentX, extentZ] in plane-local metres (deprecated `extent`
|
|
680
|
-
// for iOS-15 parity, same as ExtractARAnchors).
|
|
681
|
-
anchor[@"extent"] = @[ @(plane.extent.x), @(plane.extent.z) ];
|
|
682
|
-
if (ARPlaneAnchor.isClassificationSupported &&
|
|
683
|
-
plane.classificationStatus == ARPlaneClassificationStatusKnown) {
|
|
684
|
-
std::string cls = PlaneClassificationString(plane.classification);
|
|
685
|
-
if (!cls.empty()) {
|
|
686
|
-
anchor[@"classification"] =
|
|
687
|
-
[NSString stringWithUTF8String:cls.c_str()];
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
} else if ([a isKindOfClass:[ARImageAnchor class]]) {
|
|
691
|
-
anchor[@"type"] = @"image";
|
|
692
|
-
} else {
|
|
693
|
-
anchor[@"type"] = @"point";
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// Row-major anchor->world (transpose ARKit's column-major matrix),
|
|
697
|
-
// 16 NSNumbers — same transpose as ExtractARAnchors.
|
|
698
|
-
const simd_float4x4 m = a.transform;
|
|
699
|
-
NSMutableArray *transform = [NSMutableArray arrayWithCapacity:16];
|
|
700
|
-
for (int r = 0; r < 4; ++r) {
|
|
701
|
-
for (int c = 0; c < 4; ++c) {
|
|
702
|
-
[transform addObject:@((double)m.columns[c][r])];
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
anchor[@"transform"] = transform;
|
|
706
|
-
[anchorsOut addObject:anchor];
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
meta[@"anchors"] = anchorsOut;
|
|
656
|
+
// alignment + extent + (capable-device) classification. Built by the
|
|
657
|
+
// shared `arAnchorDictsFromFrame:` helper (DRY — same source the
|
|
658
|
+
// v0.19.0 AR plugin context reuses). Empty array when the prop is off
|
|
659
|
+
// (cheap + JSON-stable, matching the TS contract's `Array<...>` rather
|
|
660
|
+
// than null). Mesh anchors are summarised under `mesh` (counts) below,
|
|
661
|
+
// NOT listed individually — to match Android's collectTrackingAnchors
|
|
662
|
+
// which surfaces plane/image anchors and emits mesh as a separate
|
|
663
|
+
// summary.
|
|
664
|
+
meta[@"anchors"] = [self arAnchorDictsFromFrame:arFrame];
|
|
710
665
|
|
|
711
666
|
// mesh: anchor / vertex / face COUNTS only (no vertex/face byte
|
|
712
667
|
// marshaling). null when the prop is off. Counts are read from each
|
|
@@ -741,6 +696,63 @@ void ExtractARMesh(ARFrame* arFrame, retailens::CameraFrameData& data) {
|
|
|
741
696
|
return meta;
|
|
742
697
|
}
|
|
743
698
|
|
|
699
|
+
+ (NSArray<NSDictionary *> *)arAnchorDictsFromFrame:(ARFrame *)arFrame {
|
|
700
|
+
// Gate on the shared C++ extraction config's `anchors` flag — when the
|
|
701
|
+
// <Camera enableAnchors> prop is off, return an empty array (JSON-
|
|
702
|
+
// stable, matches the TS `Array<...>` contract; never null).
|
|
703
|
+
NSMutableArray<NSDictionary *> *anchorsOut = [NSMutableArray array];
|
|
704
|
+
const retailens::ExtractionConfig cfg = retailens::getExtractionConfig();
|
|
705
|
+
if (!cfg.anchors || arFrame == nil) return anchorsOut;
|
|
706
|
+
|
|
707
|
+
for (ARAnchor *a in arFrame.anchors) {
|
|
708
|
+
// ARMeshAnchors are summarised under `mesh` (counts), not listed here.
|
|
709
|
+
if ([a isKindOfClass:[ARMeshAnchor class]]) continue;
|
|
710
|
+
|
|
711
|
+
NSMutableDictionary *anchor = [NSMutableDictionary dictionary];
|
|
712
|
+
anchor[@"id"] = a.identifier.UUIDString;
|
|
713
|
+
|
|
714
|
+
if ([a isKindOfClass:[ARPlaneAnchor class]]) {
|
|
715
|
+
anchor[@"type"] = @"plane";
|
|
716
|
+
ARPlaneAnchor *plane = (ARPlaneAnchor *)a;
|
|
717
|
+
anchor[@"alignment"] =
|
|
718
|
+
(plane.alignment == ARPlaneAnchorAlignmentVertical) ? @"vertical"
|
|
719
|
+
: @"horizontal";
|
|
720
|
+
// [extentX, extentZ] in plane-local metres (deprecated `extent` for
|
|
721
|
+
// iOS-15 parity, same as ExtractARAnchors).
|
|
722
|
+
anchor[@"extent"] = @[ @(plane.extent.x), @(plane.extent.z) ];
|
|
723
|
+
if (ARPlaneAnchor.isClassificationSupported &&
|
|
724
|
+
plane.classificationStatus == ARPlaneClassificationStatusKnown) {
|
|
725
|
+
std::string cls = PlaneClassificationString(plane.classification);
|
|
726
|
+
if (!cls.empty()) {
|
|
727
|
+
anchor[@"classification"] =
|
|
728
|
+
[NSString stringWithUTF8String:cls.c_str()];
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
} else if ([a isKindOfClass:[ARImageAnchor class]]) {
|
|
732
|
+
anchor[@"type"] = @"image";
|
|
733
|
+
} else {
|
|
734
|
+
anchor[@"type"] = @"point";
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Row-major anchor->world (transpose ARKit's column-major matrix),
|
|
738
|
+
// 16 NSNumbers — same transpose as ExtractARAnchors.
|
|
739
|
+
const simd_float4x4 m = a.transform;
|
|
740
|
+
NSMutableArray *transform = [NSMutableArray arrayWithCapacity:16];
|
|
741
|
+
for (int r = 0; r < 4; ++r) {
|
|
742
|
+
for (int c = 0; c < 4; ++c) {
|
|
743
|
+
[transform addObject:@((double)m.columns[c][r])];
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
anchor[@"transform"] = transform;
|
|
747
|
+
[anchorsOut addObject:anchor];
|
|
748
|
+
}
|
|
749
|
+
return anchorsOut;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
+ (BOOL)arExtractionDepthEnabled {
|
|
753
|
+
return retailens::getExtractionConfig().depth ? YES : NO;
|
|
754
|
+
}
|
|
755
|
+
|
|
744
756
|
- (void)invalidate {
|
|
745
757
|
if (_hostObject) {
|
|
746
758
|
_hostObject->invalidate();
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// RNISARFramePlugin — v0.19.0 native AR plugin framework (iOS).
|
|
4
|
+
//
|
|
5
|
+
// The SDK owns the ARSession and drives one per-frame callback path
|
|
6
|
+
// (`RNSARSession.session(_:didUpdate:)`). Host apps that need to run
|
|
7
|
+
// heavier native per-frame analysis (OCR, barcode reading, ML
|
|
8
|
+
// inference, …) register a *native* plugin against this framework — the
|
|
9
|
+
// SDK ships ONLY the generic plumbing; no OCR or other concrete plugin.
|
|
10
|
+
//
|
|
11
|
+
// The ergonomics mirror vision-camera's FrameProcessorPlugin
|
|
12
|
+
// registration: the host conforms a class to `RNISARFramePlugin`,
|
|
13
|
+
// registers it once at startup via `RNISARPluginRegistry.shared`, and
|
|
14
|
+
// the SDK calls `process(_:)` on the AR thread for every ARFrame while
|
|
15
|
+
// the registry is non-empty.
|
|
16
|
+
//
|
|
17
|
+
// Two result channels:
|
|
18
|
+
// 1. SYNC — `process(_:)` returns a light `[String: Any]?`. Non-nil
|
|
19
|
+
// results are folded into the throttled `onArFrame` `ARFrameMeta`
|
|
20
|
+
// under `plugins: { [name]: result }`, riding the existing
|
|
21
|
+
// `RNImageStitcherARFrame` device event. Use for cheap, per-frame
|
|
22
|
+
// scalars (brightness, a quick blur score, …).
|
|
23
|
+
// 2. ASYNC — the plugin offloads heavy work to its own queue and later
|
|
24
|
+
// calls `RNISARPluginRegistry.shared.emit(name, result)`, which the
|
|
25
|
+
// SDK re-emits as the `RNImageStitcherARPluginResult` device event
|
|
26
|
+
// `{ plugin, result }`. Use for OCR / ML whose latency exceeds a
|
|
27
|
+
// frame interval.
|
|
28
|
+
//
|
|
29
|
+
// PERFORMANCE CONTRACT: the SDK only builds the `RNISARFrameContext` and
|
|
30
|
+
// calls plugins when the registry is NON-EMPTY, so a zero-plugin app
|
|
31
|
+
// pays nothing on the AR hot path. Plugins MUST self-throttle (the SDK
|
|
32
|
+
// calls `process(_:)` on every ARFrame) and MUST offload anything
|
|
33
|
+
// heavier than a few hundred microseconds.
|
|
34
|
+
|
|
35
|
+
import Foundation
|
|
36
|
+
import ARKit
|
|
37
|
+
import CoreVideo
|
|
38
|
+
|
|
39
|
+
// MARK: - Async result event channel
|
|
40
|
+
|
|
41
|
+
/// v0.19.0 — async AR-plugin result channel. `RNISARPluginRegistry.emit`
|
|
42
|
+
/// posts this notification (carrying `{ plugin, result }`); the
|
|
43
|
+
/// `RNSARSessionBridge` (an RCTEventEmitter) observes it and re-emits as
|
|
44
|
+
/// the JS `RNImageStitcherARPluginResult` device event. We route via
|
|
45
|
+
/// NotificationCenter — rather than the registry holding a bridge
|
|
46
|
+
/// reference — mirroring the `.retailensARFrameMeta` (`onArFrame`)
|
|
47
|
+
/// channel, so the framework-free engine pattern is preserved.
|
|
48
|
+
public extension Notification.Name {
|
|
49
|
+
static let retailensARPluginResult =
|
|
50
|
+
Notification.Name("RNImageStitcherARPluginResult")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
// MARK: - Plugin protocol
|
|
55
|
+
|
|
56
|
+
/// A native AR frame plugin. Host apps conform a class to this and
|
|
57
|
+
/// register it once via `RNISARPluginRegistry.shared.register(_:)`.
|
|
58
|
+
///
|
|
59
|
+
/// The SDK calls `process(_:)` once per ARFrame on the AR (ARSession
|
|
60
|
+
/// delegate) thread while the registry is non-empty. Return a light
|
|
61
|
+
/// `[String: Any]?` for the SYNC channel, or `nil`; for heavy work,
|
|
62
|
+
/// offload to your own queue and later call
|
|
63
|
+
/// `RNISARPluginRegistry.shared.emit(name(), result)` for the ASYNC
|
|
64
|
+
/// channel.
|
|
65
|
+
@objc public protocol RNISARFramePlugin: AnyObject {
|
|
66
|
+
/// Stable identifier for this plugin. Used as the key in the
|
|
67
|
+
/// `onArFrame` meta's `plugins` map AND as the `plugin` field of the
|
|
68
|
+
/// async `RNImageStitcherARPluginResult` event. Keep it constant for
|
|
69
|
+
/// the plugin's lifetime; the registry stores plugins keyed by name
|
|
70
|
+
/// (a second `register` with the same name replaces the first).
|
|
71
|
+
func name() -> String
|
|
72
|
+
|
|
73
|
+
/// Called on the AR thread once per ARFrame while the registry is
|
|
74
|
+
/// non-empty. Return a light JSON-safe result for the SYNC channel
|
|
75
|
+
/// (NSNumber / NSString / NSArray / NSDictionary leaves) or `nil`.
|
|
76
|
+
///
|
|
77
|
+
/// LIFETIME: `context.pixelBuffer` (and `context.depthBuffer`) are the
|
|
78
|
+
/// live ARFrame buffers — VALID ONLY for the duration of this call.
|
|
79
|
+
/// ARKit recycles them once `process(_:)` returns. If you offload
|
|
80
|
+
/// work to another thread/queue, you MUST copy the bytes you need
|
|
81
|
+
/// BEFORE returning. Do NOT retain the CVPixelBuffer expecting the
|
|
82
|
+
/// pixels to survive — a CF retain does not protect against ARKit's
|
|
83
|
+
/// pool reuse.
|
|
84
|
+
func process(_ context: RNISARFrameContext) -> [String: Any]?
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
// MARK: - Per-frame context
|
|
89
|
+
|
|
90
|
+
/// Zero-copy native view of one ARFrame, handed to each plugin's
|
|
91
|
+
/// `process(_:)`. Exposes the live capture buffer + pose + intrinsics +
|
|
92
|
+
/// (opt-in) depth + (opt-in) anchors. Nothing here is copied for the
|
|
93
|
+
/// plugin's benefit; the buffers belong to ARKit and are valid only
|
|
94
|
+
/// during the synchronous `process(_:)` call (see the lifetime note on
|
|
95
|
+
/// `RNISARFramePlugin.process(_:)`).
|
|
96
|
+
@objc(RNISARFrameContext)
|
|
97
|
+
public final class RNISARFrameContext: NSObject {
|
|
98
|
+
|
|
99
|
+
/// The ARFrame's `capturedImage` (BGRA/YUV `CVPixelBuffer`). VALID
|
|
100
|
+
/// ONLY during `process(_:)` — copy before offloading (see the
|
|
101
|
+
/// lifetime note on `RNISARFramePlugin.process(_:)`).
|
|
102
|
+
@objc public let pixelBuffer: CVPixelBuffer
|
|
103
|
+
|
|
104
|
+
/// Frame timestamp in NANOSECONDS (AR-framework monotonic clock) —
|
|
105
|
+
/// matches `CameraFrame.timestampNs` and the `ARFrameMeta.timestamp`
|
|
106
|
+
/// contract.
|
|
107
|
+
@objc public let timestampNs: Double
|
|
108
|
+
|
|
109
|
+
/// Camera intrinsics (pixels). `imageWidth`/`imageHeight` are the
|
|
110
|
+
/// capture resolution the intrinsics are expressed against.
|
|
111
|
+
@objc public let fx: Double
|
|
112
|
+
@objc public let fy: Double
|
|
113
|
+
@objc public let cx: Double
|
|
114
|
+
@objc public let cy: Double
|
|
115
|
+
@objc public let imageWidth: Int
|
|
116
|
+
@objc public let imageHeight: Int
|
|
117
|
+
|
|
118
|
+
/// World-space camera pose: rotation as a unit quaternion
|
|
119
|
+
/// `[x, y, z, w]` and translation `[x, y, z]` in metres (ARKit's
|
|
120
|
+
/// right-handed, Y-up, -Z-forward world frame).
|
|
121
|
+
@objc public let poseRotation: [Double]
|
|
122
|
+
@objc public let poseTranslation: [Double]
|
|
123
|
+
|
|
124
|
+
/// Tracking quality: `"notAvailable"` / `"limited"` / `"normal"`.
|
|
125
|
+
@objc public let trackingState: String
|
|
126
|
+
|
|
127
|
+
/// The ARFrame's `sceneDepth` (or `smoothedSceneDepth`) depth map
|
|
128
|
+
/// `CVPixelBuffer` — `nil` unless the `<Camera enableDepth>` prop is
|
|
129
|
+
/// on AND the device produced depth this frame. VALID ONLY during
|
|
130
|
+
/// `process(_:)`; copy before offloading.
|
|
131
|
+
@objc public let depthBuffer: CVPixelBuffer?
|
|
132
|
+
|
|
133
|
+
/// Tracking anchors as the same light dicts the `onArFrame` meta
|
|
134
|
+
/// surfaces (`{ id, type, alignment?, extent?, classification?,
|
|
135
|
+
/// transform }`; mesh anchors excluded). EMPTY unless the
|
|
136
|
+
/// `<Camera enableAnchors>` prop is on.
|
|
137
|
+
@objc public let anchors: [[String: Any]]
|
|
138
|
+
|
|
139
|
+
@objc public init(
|
|
140
|
+
pixelBuffer: CVPixelBuffer,
|
|
141
|
+
timestampNs: Double,
|
|
142
|
+
fx: Double, fy: Double, cx: Double, cy: Double,
|
|
143
|
+
imageWidth: Int, imageHeight: Int,
|
|
144
|
+
poseRotation: [Double],
|
|
145
|
+
poseTranslation: [Double],
|
|
146
|
+
trackingState: String,
|
|
147
|
+
depthBuffer: CVPixelBuffer?,
|
|
148
|
+
anchors: [[String: Any]]
|
|
149
|
+
) {
|
|
150
|
+
self.pixelBuffer = pixelBuffer
|
|
151
|
+
self.timestampNs = timestampNs
|
|
152
|
+
self.fx = fx; self.fy = fy; self.cx = cx; self.cy = cy
|
|
153
|
+
self.imageWidth = imageWidth
|
|
154
|
+
self.imageHeight = imageHeight
|
|
155
|
+
self.poseRotation = poseRotation
|
|
156
|
+
self.poseTranslation = poseTranslation
|
|
157
|
+
self.trackingState = trackingState
|
|
158
|
+
self.depthBuffer = depthBuffer
|
|
159
|
+
self.anchors = anchors
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
// MARK: - Registry
|
|
165
|
+
|
|
166
|
+
/// Process-wide registry of native AR plugins + the async result router.
|
|
167
|
+
///
|
|
168
|
+
/// The host registers plugins at startup (e.g. in the AppDelegate); the
|
|
169
|
+
/// SDK reads `plugins()` on the AR thread each frame. Also the entry
|
|
170
|
+
/// point for the ASYNC channel: a plugin calls `emit(name, result)` from
|
|
171
|
+
/// its own queue and the SDK re-emits a `RNImageStitcherARPluginResult`
|
|
172
|
+
/// JS event.
|
|
173
|
+
///
|
|
174
|
+
/// THREAD SAFETY: registration (host/startup thread) and reads (AR
|
|
175
|
+
/// thread) are serialised by an internal lock. `plugins()` returns a
|
|
176
|
+
/// snapshot array so the AR thread can iterate without holding the lock.
|
|
177
|
+
@objc(RNISARPluginRegistry)
|
|
178
|
+
public final class RNISARPluginRegistry: NSObject {
|
|
179
|
+
|
|
180
|
+
/// Shared instance — the only way hosts register plugins.
|
|
181
|
+
@objc public static let shared = RNISARPluginRegistry()
|
|
182
|
+
|
|
183
|
+
/// Registered plugins, keyed by `name()` for O(1) replace/unregister.
|
|
184
|
+
/// Insertion order is preserved for deterministic `process(_:)`
|
|
185
|
+
/// ordering via a parallel ordered key list.
|
|
186
|
+
private var pluginsByName: [String: RNISARFramePlugin] = [:]
|
|
187
|
+
private var order: [String] = []
|
|
188
|
+
private let lock = NSLock()
|
|
189
|
+
|
|
190
|
+
private override init() { super.init() }
|
|
191
|
+
|
|
192
|
+
/// Register (or replace) a plugin. Keyed by `plugin.name()`: a
|
|
193
|
+
/// second register with the same name replaces the first (and keeps
|
|
194
|
+
/// its position in the ordering). Idempotent for the same instance.
|
|
195
|
+
@objc public func register(_ plugin: RNISARFramePlugin) {
|
|
196
|
+
let key = plugin.name()
|
|
197
|
+
lock.lock()
|
|
198
|
+
defer { lock.unlock() }
|
|
199
|
+
if pluginsByName[key] == nil {
|
|
200
|
+
order.append(key)
|
|
201
|
+
}
|
|
202
|
+
pluginsByName[key] = plugin
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// Remove the plugin registered under `name`. No-op if absent.
|
|
206
|
+
@objc public func unregister(_ name: String) {
|
|
207
|
+
lock.lock()
|
|
208
|
+
defer { lock.unlock() }
|
|
209
|
+
pluginsByName.removeValue(forKey: name)
|
|
210
|
+
order.removeAll { $0 == name }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// Snapshot of registered plugins in registration order. Returns a
|
|
214
|
+
/// copy so the AR thread can iterate without holding the lock.
|
|
215
|
+
@objc public func plugins() -> [RNISARFramePlugin] {
|
|
216
|
+
lock.lock()
|
|
217
|
+
defer { lock.unlock() }
|
|
218
|
+
return order.compactMap { pluginsByName[$0] }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// Whether any plugin is registered. Cheap gate the SDK checks per
|
|
222
|
+
/// ARFrame before building the (per-frame) `RNISARFrameContext`.
|
|
223
|
+
@objc public var isEmpty: Bool {
|
|
224
|
+
lock.lock()
|
|
225
|
+
defer { lock.unlock() }
|
|
226
|
+
return order.isEmpty
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// ASYNC channel — route a plugin's later-computed result to JS as the
|
|
230
|
+
/// `RNImageStitcherARPluginResult` device event `{ plugin, result }`.
|
|
231
|
+
/// Safe to call from any thread (the plugin's own queue); posts on
|
|
232
|
+
/// NotificationCenter, which the bridge observes + re-emits on the
|
|
233
|
+
/// main queue.
|
|
234
|
+
///
|
|
235
|
+
/// `pluginName` should match the plugin's `name()` so JS can correlate
|
|
236
|
+
/// the result with its source.
|
|
237
|
+
@objc public func emit(_ pluginName: String, _ result: [String: Any]) {
|
|
238
|
+
NotificationCenter.default.post(
|
|
239
|
+
name: .retailensARPluginResult,
|
|
240
|
+
object: nil,
|
|
241
|
+
userInfo: [
|
|
242
|
+
"plugin": pluginName,
|
|
243
|
+
"result": result,
|
|
244
|
+
]
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// MARK: - v0.20.0 — native-plugin overlay placement
|
|
249
|
+
|
|
250
|
+
// A native plugin can place AR overlays DIRECTLY (native→native, zero
|
|
251
|
+
// JS latency) via the methods below. Plugin overlays live in their
|
|
252
|
+
// OWN namespace in `RNISAROverlayStore`, separate from JS-set overlays
|
|
253
|
+
// — the draw view renders the UNION, so a plugin placing overlays
|
|
254
|
+
// never clobbers `<Camera overlays={...}>` / the imperative ref, and
|
|
255
|
+
// vice-versa. Safe to call from any thread (the store is internally
|
|
256
|
+
// locked); the per-frame draw view picks the change up on its next
|
|
257
|
+
// redraw.
|
|
258
|
+
|
|
259
|
+
/// Replace the ENTIRE plugin overlay set. Pass the same dictionary
|
|
260
|
+
/// shape the JS `AROverlay` interface uses (`id`, `worldPosition`,
|
|
261
|
+
/// `sizeMeters`, `worldQuad`, `shape`, `label`, `color`, `mode`).
|
|
262
|
+
/// Entries missing an `id` or any geometry are dropped.
|
|
263
|
+
@objc public func setOverlays(_ overlays: [[String: Any]]) {
|
|
264
|
+
let parsed = overlays.compactMap { RNISAROverlay.from(dictionary: $0) }
|
|
265
|
+
RNISAROverlayStore.shared.setPluginOverlays(parsed)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Add or replace a single plugin overlay (same dictionary shape as
|
|
269
|
+
/// `setOverlays`). No-op if the dict has no `id` / no geometry.
|
|
270
|
+
@objc public func addOverlay(_ overlay: [String: Any]) {
|
|
271
|
+
guard let parsed = RNISAROverlay.from(dictionary: overlay) else { return }
|
|
272
|
+
RNISAROverlayStore.shared.addPluginOverlay(parsed)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/// Remove a single plugin overlay by id. No-op if absent.
|
|
276
|
+
@objc public func removeOverlay(_ id: String) {
|
|
277
|
+
RNISAROverlayStore.shared.removePluginOverlay(id)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// Clear ALL plugin overlays. JS-set overlays are untouched.
|
|
281
|
+
@objc public func clearOverlays() {
|
|
282
|
+
RNISAROverlayStore.shared.clearPluginOverlays()
|
|
283
|
+
}
|
|
284
|
+
}
|