react-native-image-stitcher 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/android/src/main/java/io/imagestitcher/rn/ARFrameContext.kt +89 -0
  3. package/android/src/main/java/io/imagestitcher/rn/ARFramePlugin.kt +57 -0
  4. package/android/src/main/java/io/imagestitcher/rn/AROverlayRenderer.kt +406 -0
  5. package/android/src/main/java/io/imagestitcher/rn/AROverlayStore.kt +441 -0
  6. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +472 -13
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +30 -5
  8. package/android/src/main/java/io/imagestitcher/rn/RNSARPluginRegistry.kt +177 -0
  9. package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +127 -0
  10. package/dist/camera/ARCameraView.d.ts +55 -2
  11. package/dist/camera/ARCameraView.js +68 -2
  12. package/dist/camera/Camera.d.ts +65 -2
  13. package/dist/camera/Camera.js +24 -6
  14. package/dist/camera/arOverlayController.d.ts +52 -0
  15. package/dist/camera/arOverlayController.js +132 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.js +5 -2
  18. package/dist/stitching/ARFrameMeta.d.ts +49 -0
  19. package/dist/stitching/AROverlay.d.ts +97 -0
  20. package/dist/stitching/AROverlay.js +4 -0
  21. package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +15 -8
  22. package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +22 -0
  23. package/ios/Sources/RNImageStitcher/ARSessionBridge.m +14 -0
  24. package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +117 -1
  25. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.h +25 -0
  26. package/ios/Sources/RNImageStitcher/CameraFrameHostObject.mm +66 -54
  27. package/ios/Sources/RNImageStitcher/RNISARFramePlugin.swift +284 -0
  28. package/ios/Sources/RNImageStitcher/RNISAROverlay.swift +409 -0
  29. package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +281 -3
  30. package/ios/Sources/RNImageStitcher/RNSARSession.swift +127 -1
  31. package/package.json +1 -1
  32. package/src/camera/ARCameraView.tsx +139 -3
  33. package/src/camera/Camera.tsx +94 -3
  34. package/src/camera/arOverlayController.ts +184 -0
  35. package/src/index.ts +21 -1
  36. package/src/stitching/ARFrameMeta.ts +50 -0
  37. package/src/stitching/AROverlay.ts +105 -0
@@ -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. Mirrors
657
- // ExtractARAnchors above but into an NSDictionary array (no byte
658
- // marshaling that path is for the full host object). Empty array
659
- // when the prop is off (cheap + JSON-stable, matching the TS contract's
660
- // `Array<...>` rather than null). Mesh anchors are summarised under
661
- // `mesh` (counts) below, NOT listed individually here unless enableMesh
662
- // is off to match Android's collectTrackingAnchors which surfaces
663
- // plane/image anchors and emits mesh as a separate summary.
664
- NSMutableArray *anchorsOut = [NSMutableArray array];
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
+ }